Skip to content

Release v2026.10.01 - #384

Merged
admonstrator merged 45 commits into
mainfrom
claude/release-review-2026-10-01-iax78p
Oct 13, 2026
Merged

admonstrator merged 45 commits into
mainfrom
claude/release-review-2026-10-01-iax78p

Conversation

@admonstrator

Copy link
Copy Markdown
Owner

Before: main is on v2026.09.03. It has no Claude support, no single sign-on, and the settings page is one long page.

After: main gets everything that is on nightly as release v2026.10.01:

This branch is nightly with main merged in. On top, it carries two Anthropic fixes from the previous release review: a linear-time trailing-slash strip for the Claude API URL, and the stored Claude key is sent only to a configured host. They are not on nightly yet; merging main back into nightly after this release brings them over.

How: merge of origin/nightly into the release branch (no conflicts), plus a changelog commit for the SSO and settings entries. The version stays v2026.10.01 in config/config.js and config/changelog.js. Locally the offline suite shows 122 passed, 6 skipped and 0 failed, and OpenAPI shows no drift.

Supersedes #381.

🤖 Generated with Claude Code

https://claude.ai/code/session_012u2T47w6ynKbZ2rxoc4Gf4


Generated by Claude Code

krauseerl and others added 30 commits October 5, 2026 22:48
* feat(auth): single sign-on with any OpenID Connect provider

Background:
Zettelrobbe has a single administrator account with a local password.
Operators running an identity provider (Authentik, Authelia, Keycloak, ...)
want to sign in through it instead of keeping yet another password.
Requested in #320 and #259.

Changes:
- New services/oidcService.js and routes/oidc.js: standard authorization
  code flow with PKCE (S256), state and nonce via openid-client. Endpoints,
  keys and token endpoint auth methods come from discovery; nothing is
  provider-specific. Tolerates a trailing-slash issuer mismatch and a full
  discovery URL as OIDC_ISSUER_URL.
- One account, no user logic: the identity whose e-mail claim equals
  OIDC_ADMIN_EMAIL (case-insensitive) signs in as the administrator. No
  provisioning, no group handling; access is restricted at the provider.
- OIDC_ADMIN_EMAIL is required. With OIDC_ENABLED=yes but an incomplete
  configuration, SSO stays off and startup logs what is missing.
- Login form: an "or" divider and an SSO button, customizable with
  OIDC_BUTTON_TEXT, OIDC_BUTTON_ICON, OIDC_BUTTON_COLOR and
  OIDC_BUTTON_TEXT_COLOR (colours and icon URLs are validated).
- OIDC_CLIENT_SECRET is masked in settings and logs; OIDC_* keys are part
  of the env export.
- docs/sso-oidc.md: configuration, provider examples, button customization,
  security notes and troubleshooting. README and docker-compose.yml point
  to it. OpenAPI regenerated for the two new routes.

Testing:
- tests/test-oidc-sso.js (29 checks) against a fake Authentik-like
  provider: happy path, refusals (state, missing transaction, provider
  error, other e-mail), token separation, auth methods, issuer spelling,
  button customization and helper validation.
- node scripts/run-tests.js --all: 111 passed, 6 skipped (need a running
  server), 1 failed (changelog-releases, fails on main too).
- Login page rendered in Chromium with default and customized button.
- eslint, prettier and stylelint clean on changed files.

Impact:
Off by default. Password login is unchanged and keeps working with SSO
enabled. New dependency: openid-client (loaded only when SSO is used).

Upstream Status:
Not submitted upstream yet.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LYo842vZsjeyzKRLQuLqwX

* fix(auth): reject an OIDC_REDIRECT_URI without scheme

Background:
A redirect URI configured without its scheme
(paperless-ai.example.com/auth/oidc/callback) was sent to the provider
verbatim. Authentik rejected it with a generic "Redirect URI Error",
which gave no hint at the cause. Seen on a real Authentik setup.

Changes:
- getConfigErrors() requires OIDC_REDIRECT_URI, when set, to be an
  absolute http(s) URL. Otherwise SSO stays off and the startup log
  names the setting.
- docs/sso-oidc.md troubleshooting mentions the scheme.

Testing:
- tests/test-oidc-sso.js: new check, 30 passed.
- eslint and prettier clean on changed files.

Impact:
Only affects misconfigured installs, which now fail visibly at startup
instead of at the provider.
…tion (#6)

Background:
GET /health is a public route (Docker healthcheck, uptime monitors). It
returned the full scanner and Paperless-ngx snapshot to anyone, including
raw error strings. Against a local instance with Paperless-ngx stopped, an
unauthenticated request returned:
  "lastError": "connect ECONNREFUSED 127.0.0.1:8000"
  "paperless": { ..., "error": "connect ECONNREFUSED 127.0.0.1:8000" }
plus the scan schedule and run timestamps. The HealthResponse schema's own
example ("connect ECONNREFUSED 172.18.0.2:8000") shows the same leak of
internal host names and ports (CWE-200). The 500 branch also returned the
raw exception message.

Changes:
- routes/setup.js: isAuthenticatedCaller() accepts a session token or the
  API key on a public route. /health answers an anonymous caller with only
  { status, database } and the same HTTP status codes as before (503 when
  degraded in strict mode). Authenticated callers get the full payload,
  unchanged. The 500 branch returns a generic message to anonymous callers.
- @Swagger description of /health and the HealthResponse schema state which
  fields are authenticated-only; OPENAPI/openapi.json regenerated.
- tests/test-health-disclosure.js (security area).
- tests/test-scanner-startup-resilience.js: its source check now matches the
  status expression without the object-literal key.

Testing:
- Local instance, Paperless-ngx stopped: anonymous and wrong-key requests
  return {"status":"degraded","database":"ok"} with HTTP 503; the API key
  returns the full payload with HTTP 503.
- New test: 3 of 6 fail on main, 6/6 pass with the fix.
- node scripts/run-tests.js --all: all pass except changelog-releases,
  which fails identically on main.
- eslint and prettier clean on the changed files; regen-openapi produces no
  further diff.

Impact:
- Monitors that read scanner or paperless fields from /health without
  credentials must send the x-api-key header. The HTTP status code and the
  top-level status field, which the Docker healthcheck and the settings
  page restart check use, are unchanged.

Upstream Status:
- Not reported upstream.


Claude-Session: https://claude.ai/code/session_011apVSoVYdcjtnV6Ly3bepu

Co-authored-by: Claude <noreply@anthropic.com>
Background:
The MFA step of POST /login accepted the same six-digit code any number of
times while it stayed inside the ±1 step window (up to 90 seconds). Against
a local instance, two independent sign-ins (separate cookie jars) with the
same password and the same code both returned 302 /dashboard with a session
cookie. A code captured over the shoulder, from a screen share or by a
phishing proxy could therefore open a second session after the user's own
sign-in. RFC 6238 section 5.2 requires the verifier not to accept a code
again after a successful validation (CWE-294).

Changes:
- routes/setup.js: findTotpStep() returns the time step a code matches;
  verifyTotpToken() keeps its boolean contract on top of it.
  consumeTotpToken() remembers the last accepted step per username (in
  memory) and refuses that step and older ones.
- The login MFA step, POST /api/settings/mfa/enable and
  POST /api/settings/mfa/disable now consume the code. The setup-wizard
  challenge and POST /api/settings/mfa/verify (a code check without an
  action) keep the non-consuming check.
- tests/test-totp-replay.js (security area).

Testing:
- Same two-session reproduction after the fix: first sign-in 302
  /dashboard, second sign-in 200 (MFA form again) without a session cookie.
- New test: the replay case fails on main and passes with the fix.
- node scripts/run-tests.js --all: 116 passed, 2 skipped, 1 failed
  (changelog-releases, fails identically on main).
- eslint and prettier clean on the changed files.

Impact:
- A user who just enabled MFA, or just signed in, has to wait for the next
  code (at most 30 seconds) before the same authenticator code works again.
- The memory of used steps is lost on restart, which only re-opens the
  90-second window for codes used right before the restart.
- No API or response shape change.

Upstream Status:
- Not reported upstream.


Claude-Session: https://claude.ai/code/session_011apVSoVYdcjtnV6Ly3bepu

Co-authored-by: Claude <noreply@anthropic.com>
Background:
`npm audit --omit=dev` reported three vulnerable runtime dependencies:
- proxy-addr 2.0.7 (critical, GHSA-jqcg-44mw-7w3h): IP spoofing via an
  IPv4-mapped IPv6 trust subnet. Express uses it to compute req.ip when
  TRUST_PROXY is set, and req.ip keys the login and API rate limiters.
- brace-expansion 5.0.9 (high, GHSA-q2hr-2g5m-vwhr, GHSA-qhr7-859c-m2p7,
  GHSA-6j4f-fj2g-mc7p): CPU / stack exhaustion denial of service.
- ip-address 10.4.0 (moderate, GHSA-rpw4-54j3-4h4q, GHSA-2vr4-cq9g-pvrc,
  GHSA-j6r3-76f7-8jcv, GHSA-h3mg-xc3c-68pw), pulled in by
  express-rate-limit.

Changes:
- `npm audit fix` (no --force): proxy-addr 2.0.8, brace-expansion 5.0.12,
  ip-address 10.7.3, plus the dev-only patch bumps npm resolved alongside
  (postcss-selector-parser 7.1.6, source-map-js 1.2.2). Lockfile only;
  package.json ranges are unchanged.

Testing:
- `npm audit --omit=dev`: 0 vulnerabilities (was 1 critical, 1 high,
  1 moderate).
- `node scripts/run-tests.js --all`: 115 passed, 2 skipped, 1 failed. The
  failure (changelog-releases: newest changelog block does not match
  PAPERLESS_AI_VERSION) fails identically on main without this change.

Impact:
- No API or behaviour change. The remaining dev-only advisories (braces via
  nodemon/stylelint) need a breaking major bump and are not part of this
  change.

Upstream Status:
- Not reported upstream.


Claude-Session: https://claude.ai/code/session_011apVSoVYdcjtnV6Ly3bepu

Co-authored-by: Claude <noreply@anthropic.com>
)

Background:
POST /login returned immediately when the username was unknown, but ran a
bcrypt comparison when it existed. Against a local instance with a cost-10
hash, eight attempts each measured:
  existing username:  mean 91.9 ms
  unknown username:   mean  4.5 ms
An account created by the setup wizard is hashed with cost 15, which widens
the gap to seconds. One timed request is enough to confirm the
administrator's username, so the login rate limit does not help
(CWE-208 / CWE-204).

Changes:
- routes/setup.js: on an unknown username (or an account without a
  password hash), spendPasswordCheckTime() runs bcrypt.compare() against a
  dummy hash before answering. The dummy uses the cost of the stored
  account (bcrypt.getRounds), default 15, and is generated once per cost.
- tests/test-login-username-timing.js (security area): counts bcrypt
  comparisons per login path and checks the dummy cost, so CI does not
  depend on wall-clock timing.

Testing:
- Same measurement after the fix: existing 91.4 ms, unknown 96.0 ms.
- New test fails on main (3 of 5) and passes with the fix.
- node scripts/run-tests.js --area security: 21/21 passed.
- eslint and prettier clean on the changed files.

Impact:
- A failed login with an unknown username now takes as long as one with a
  wrong password. No API or response change.

Upstream Status:
- Not reported upstream.


Claude-Session: https://claude.ai/code/session_011apVSoVYdcjtnV6Ly3bepu

Co-authored-by: Claude <noreply@anthropic.com>
Background:
The Claude API is not OpenAI-compatible: the system prompt is a field of
its own, max_tokens is required, answers are typed content blocks, usage
is reported as input/output tokens, models can decline with
stop_reason "refusal", and the current models reject sampling parameters
and differ in whether thinking can be switched off. Pointing the custom
provider at it is therefore not enough; it needs a provider of its own.

Changes:
- Add @anthropic-ai/sdk.
- config.anthropic block: ANTHROPIC_API_KEY, ANTHROPIC_API_URL (proxy
  only), ANTHROPIC_MODEL (default claude-haiku-5-5), ANTHROPIC_EFFORT,
  ANTHROPIC_MAX_TOKENS (default 16000), ANTHROPIC_STRUCTURED_OUTPUT and
  ANTHROPIC_PROMPT_CACHING (both default yes).
- services/anthropicSupport.js: per-model request rules
  (anthropicModelTraits), thinking/effort fields (anthropicThinkingParams),
  completion cap, client factory, answer text, usage mapping and the
  truncation/refusal check shared by the AI and OCR paths.
- services/anthropicService.js: provider skeleton wired into
  AIServiceFactory as 'anthropic'.
- ANTHROPIC_API_KEY joins the bootstrap, settings and log redaction
  secret lists.
- Register test-anthropic-support plus three placeholder tests in a new
  'anthropic' test area.

Testing:
- node scripts/run-tests.js --all: 115 passed, 6 skipped (server-bound).
- test-anthropic-support covers the per-model thinking fields, effort
  filtering, completion cap, text/usage mapping and stop reasons.

Impact:
No behaviour change for the existing providers. AI_PROVIDER=anthropic
selects the skeleton, which does not analyse yet.

Upstream Status:
Not applicable (fork-only feature).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dZrUuuXyUkHuSP5sYdnML
Background:
The setup wizard validates an OCR provider by running a real OCR request
before the settings are saved, so the validation and the OCR service need
one function that takes the connection details as arguments.

Changes:
- mistralOcrService.runAnthropicOcr({ apiKey, apiUrl, model, base64,
  mimeType, timeoutMs }) placeholder; implemented in a follow-up commit.

Testing:
- eslint, prettier, test-mistral-ocr-image-payload.

Impact:
None; the method is not called yet.

Upstream Status:
Not applicable (fork-only feature).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dZrUuuXyUkHuSP5sYdnML
Background:
OCR_PROVIDER=anthropic was reserved by the contract commit, but
runAnthropicOcr() only threw. The OCR settings are shared with the other
providers in ways that would send the wrong things to the Claude API:
config.mistralOcr.apiKey falls back to MISTRAL_API_KEY, MISTRAL_OCR_MODEL
defaults to mistral-ocr-latest, and the Messages API has limits (32 MB per
request, 10 MB per image, PDF and four image types only) that otherwise
surface as a bare 413 or 400.

Changes:
- provider getter returns 'anthropic' for OCR_PROVIDER=anthropic;
  ollama/custom and everything else map as before. New providerLabel
  getter ("Local OCR" / "Claude OCR" / "Mistral OCR") replaces the inline
  ternary in processQueueItem().
- Claude OCR key: OCR_API_KEY, else ANTHROPIC_API_KEY, never the
  MISTRAL_API_KEY fallback. Model: MISTRAL_OCR_MODEL only when it names a
  Claude model (also with an anthropic./anthropic/ proxy prefix), else
  ANTHROPIC_MODEL. URL: OCR_API_URL when set, else ANTHROPIC_API_URL.
- runAnthropicOcr({ apiKey, apiUrl, model, base64, mimeType, timeoutMs,
  maxTokens }) sends one streamed Messages request: a document block for a
  PDF or an image block for PNG/JPEG/GIF/WebP, then the transcription
  instruction (reading order, Markdown tables, no translation, summary or
  commentary, [illegible] for unreadable spots, "[no text]" for an empty
  document, which is returned as ''). Thinking as low as the model allows
  via anthropicThinkingParams(model, { reasoning: false }); no sampling
  parameters; max_tokens 64000 (fits every current model and Claude Haiku
  4.5), raised by ANTHROPIC_MAX_TOKENS up to 128000. Answer read with
  anthropicMessageText(), stop reason checked with
  assertAnthropicCompleted(), usage logged via mapAnthropicUsage(). It
  reads no config, so the setup validation can test unsaved details; a
  data URL is accepted as input.
- Limits are checked before sending, with the limit in the message and an
  error code: claude_ocr_unsupported_type, claude_ocr_too_large,
  claude_ocr_empty_input.
- A PDF over 32 MB falls back to the poppler page rendering local OCR
  uses (same OCR_PDF_RENDER_* settings), one image request per page;
  without rendering or pdftoppm it fails naming the limit and the remedy.
  _performOcrOnRenderedPages() takes an optional page engine and label for
  that; local OCR keeps its defaults.
- A watchdog aborts a request after timeoutMs without progress (before the
  answer starts or between two streamed events) and reports the usual OCR
  timeout message; a long transcript that keeps arriving is not cut short.
  API errors become one line: "Claude API answered HTTP <status> (<type>):
  <message>".

Testing:
- npx eslint / npx prettier --check on the changed file.
- node scripts/run-tests.js --all: 115 passed, 6 skipped (server-bound),
  same as before; existing OCR tests unchanged and green.
- node scripts/regen-openapi.js: no spec change.

Impact:
A truncated or refused transcript throws, so processQueueItem() fails the
item exactly like a truncated local OCR result (queue 'failed', failure
recorded as ocr_failed, nothing written to Paperless-ngx). Mistral and local
OCR behave as before.

Upstream Status:
Not applicable (fork-only feature).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dZrUuuXyUkHuSP5sYdnML
Background:
tests/test-ocr-provider-anthropic.js was a placeholder registered by the
contract commit (area 'anthropic').

Changes:
- A real HTTP server on an ephemeral port answers like the Messages API,
  including streamed answers (thinking and text blocks, stop reasons,
  stop_details, error bodies, delayed headers, stalled and slow streams).
  The real SDK talks to it; the configuration is the real config module
  loaded from environment variables in an empty working directory.
- 19 cases: provider selection and labels; key precedence (OCR_API_KEY,
  then ANTHROPIC_API_KEY, never MISTRAL_API_KEY, no key means no request);
  model selection (Mistral default replaced, Claude model kept) and URL
  precedence; PDF and PNG request shape (block type and order, media type,
  stream, max_tokens 64000, no temperature/top_p/top_k, thinking off per
  model for Haiku 5.5, Sonnet 5.5 and Opus 5.5); text read past a leading
  thinking block and the "[no text]" marker; max_tokens and refusal fail
  the queue item without write-back; a completed transcript is written
  back; TIFF, office types, an image over 10 MB and a PDF over 32 MB
  without poppler fail unsent with the limit named; a PDF over 32 MB with
  poppler goes out as page images; runAnthropicOcr uses its arguments, not
  config; API errors as one line; stall, silent server and slow-but-alive
  stream; ANTHROPIC_MAX_TOKENS raising the OCR cap up to 128000.

Testing:
- node tests/test-ocr-provider-anthropic.js: 19 passed (about 4 s).
- Mutation check: replacing the key, model, thinking, completion check,
  no-text marker, watchdog refresh or provider routing each fails at least
  one case.
- node scripts/run-tests.js --all: 115 passed, 6 skipped (server-bound).

Impact:
Test-only.

Upstream Status:
Not applicable (fork-only feature).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dZrUuuXyUkHuSP5sYdnML
Background:
Claude reads PDFs and images natively, so it can serve as an OCR
provider next to Mistral and local vision models.

Changes:
- OCR_PROVIDER=anthropic routes performOcr to runAnthropicOcr: one
  streamed Messages request with the PDF as a document block or the
  image as an image block, thinking off, no temperature.
- Key: OCR_API_KEY, else ANTHROPIC_API_KEY, never MISTRAL_API_KEY.
  Model: MISTRAL_OCR_MODEL only when it names a Claude model, else
  ANTHROPIC_MODEL. URL: OCR_API_URL, else ANTHROPIC_API_URL.
- Limits checked before sending (32 MB request, 10 MB image, PDF and
  png/jpeg/gif/webp only); a PDF over the request limit falls back to
  poppler page rendering. Truncation and refusal fail the queue item
  without writing partial text back.

Testing:
- test-ocr-provider-anthropic: 19 cases against a fake Messages API.
- node scripts/run-tests.js --all: 115 passed, 6 skipped.

Impact:
New OCR provider; existing providers unchanged.

Upstream Status:
Not applicable (fork-only feature).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dZrUuuXyUkHuSP5sYdnML
Background:
calculateTokens() and truncateToTokenLimit() run tiktoken for OpenAI
model names and a four-characters-a-token estimate for everything else.
The current Claude tokenizers count noticeably more tokens per character
than that (the newest generation about 30% more than the one before), so
the estimate would come out low and let through documents the token
budget meant to cut. The Duplicates review also names the model it
measures its prompts against per provider and would have reported the
OpenAI model for the Anthropic provider.

Changes:
- serviceUtils: models named claude-* get their own estimate of three
  characters a token in calculateTokens() and truncateToTokenLimit(),
  never tiktoken; the truncation breaks at a word boundary like the
  other estimated models. Every other model is unchanged.
- entityMatchAiService.modelName(): case 'anthropic' returns
  ANTHROPIC_MODEL.

Testing:
- Covered by tests/test-anthropic-provider.js in the provider commit
  (Claude estimate, unchanged estimate for other models, modelName).
- eslint and prettier on both files.

Impact:
Only requests for claude-* models estimate differently. No behaviour
change for the existing providers.

Upstream Status:
Not applicable (fork-only feature).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dZrUuuXyUkHuSP5sYdnML
Background:
Every provider service assembles the document-analysis prompt itself;
the four copies agree only because they were copied from each other. The
Anthropic provider needs the same prompt and, for structured output, a
JSON schema of the object that prompt asks for. A fifth copy would drift.

Changes:
- services/analysisPrompt.js, free of I/O:
  - buildAnalysisPrompt(): customService's assembly byte for byte
    (custom-fields template, USE_EXISTING_DATA block, restriction
    placeholders, external API data, USE_PROMPT_TAGS, webhook prompt),
    returning the system prompt, the prompt tags it counts, the template,
    the parsed custom fields, a parse error for the caller to log and
    which response shape the prompt asks for. A CUSTOM_FIELDS value
    without a custom_fields list falls back to no fields instead of the
    TypeError customService runs into.
  - analysisResponseSchema(): the JSON schema of that object, every
    object closed with additionalProperties: false, every key required,
    correspondent/document_type/document_date and custom field values
    nullable, custom field names fixed to the configured names; the
    predefined-tags shape without document type and custom fields.
  - parseAnalysisAnswer(): customService's tolerant JSON parser.
- Only the Anthropic provider uses it in this change; the other four
  providers are untouched and can switch later.

Testing:
- Covered by tests/test-anthropic-provider.js in the provider commit,
  including a byte-for-byte comparison with the system prompt
  customService sends in nine configurations.
- eslint and prettier.

Impact:
None on its own; the module is not used by an existing provider.

Upstream Status:
Not applicable (fork-only feature).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dZrUuuXyUkHuSP5sYdnML
Background:
AI_PROVIDER=anthropic selected a skeleton that could not analyse. The
Claude Messages API differs from the OpenAI dialect in request shape,
streaming events, usage, stop reasons and per-model thinking rules, so it
needs its own provider service with the same contract as the other four.

Changes:
- anthropicService:
  - analyzeDocument(): thumbnail caching, external API data, the prompt
    from buildAnalysisPrompt(), token budget and truncation, response log
    and validation as in customService, sent as a Messages request with a
    top-level system field, one user message, max_tokens from
    ANTHROPIC_MAX_TOKENS, thinking/effort from anthropicThinkingParams()
    and no sampling parameters. Answers are read with
    anthropicMessageText(), checked with assertAnthropicCompleted()
    (ai_response_truncated / ai_refused, neither routed to OCR) and
    metrics come from mapAnthropicUsage(). A missing thumbnail no longer
    aborts the analysis.
  - Structured output (ANTHROPIC_STRUCTURED_OUTPUT): output_config.format
    with analysisResponseSchema() where the model supports it, merged
    with the effort. A schema the API refuses with 400 is retried once
    without it and then skipped for the rest of the process.
  - Prompt caching (ANTHROPIC_PROMPT_CACHING): the system prompt goes out
    as one text block with cache_control, for the analysis and for
    generateText with a system prompt; one debug line per request with
    the cache reads and writes.
  - generateText(): systemPrompt, model, maxTokens, signal, reasoning and
    onProgress as documented in aiGenerateOptions; temperature accepted
    and ignored; lastGenerateTextUsage set from the final message.
  - checkStatus(): models.retrieve(model), no tokens spent; key and model
    errors told apart by SDK error class.
  - Every request is streamed: the SDK refuses non-streamed requests whose
    max_tokens could take longer than ten minutes.
- aiGenerateOptions: runMessagesStream()/consumeMessagesStream() feed the
  existing progress collector from Messages events (text deltas as
  answer, thinking deltas as reasoning), take stop reason and usage from
  the final message and report done exactly once on success, truncation
  and abort.
- tests/helpers/fake-anthropic.js: real HTTP server for POST /v1/messages
  (JSON and SSE) and GET /v1/models/:id.

Testing:
- tests/test-anthropic-provider.js, 25 cases against the fake through the
  real SDK: request shape, thinking/effort for Haiku 5.5, Sonnet 5.5 and
  Opus 5.5, schema on/off/unsupported, cache_control on/off, identical
  system prompt across documents, thinking-first answers, usage and
  cache log, truncation, refusal (not queued for OCR), schema refusal
  fallback, predefined-tags schema, custom-fields schema, byte-for-byte
  prompt parity with customService, generateText plain/streamed/usage/
  options/truncation/refusal, abort mid-stream and before the request,
  checkStatus, token estimate, Duplicates model name, timeout, no key.
- node scripts/run-tests.js --all: 115 passed, 6 skipped (server-bound).
- eslint and prettier on every changed file; OpenAPI regeneration
  produces no diff.

Impact:
AI_PROVIDER=anthropic now analyses documents, generates text for the
Duplicates review and tag simplification, and reports its status. No
behaviour change for the existing providers.

Upstream Status:
Not applicable (fork-only feature).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dZrUuuXyUkHuSP5sYdnML
Background:
The Claude API needs a provider of its own; the contract added the
configuration and shared request rules, this fills in the service.

Changes:
- anthropicService: analyzeDocument, generateText (streamed progress,
  abort, truncation and refusal like the other providers) and a
  token-free checkStatus via models.retrieve. Every request is
  streamed because the SDK refuses non-streamed requests with large
  max_tokens.
- Stage 2: structured output. The analysis request carries a JSON
  schema derived from the prompt and the configured custom fields; a
  schema the API rejects is retried once without and remembered per
  model.
- Stage 3: prompt caching. The system prompt goes out as a cacheable
  block for analysis and generateText.
- analysisPrompt.js: pure prompt builder taken from customService, the
  response schema and the tolerant answer parser; only the Anthropic
  provider uses it so far.
- aiGenerateOptions: runMessagesStream feeding the shared progress
  collector. serviceUtils: about 3 characters per token for claude-*.
  entityMatchAiService: model name for anthropic.

Testing:
- test-anthropic-provider: 25 cases against a fake Messages API,
  including a byte-for-byte system prompt comparison with
  customService in nine configurations.
- node scripts/run-tests.js --all: 115 passed, 6 skipped.

Impact:
AI_PROVIDER=anthropic analyses documents. Other providers unchanged.

Upstream Status:
Not applicable (fork-only feature).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dZrUuuXyUkHuSP5sYdnML
Background:
CLAUDE.md listed four AI providers and three OCR modes.

Changes:
- Name anthropic in the provider list and OCR modes, point at
  anthropicSupport.js and analysisPrompt.js, and note the Claude token
  estimate.

Testing:
- prettier --check CLAUDE.md.

Impact:
Documentation only.

Upstream Status:
Not applicable (fork-only feature).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dZrUuuXyUkHuSP5sYdnML
Background:
Anthropic becomes a fifth AI provider and a fourth OCR provider. The setup
wizard and the settings page prove a configuration before it is saved, list
the models a provider offers, and decide at startup whether the stored
configuration is complete. None of that knew the provider.

Changes:
- validateAnthropicConfig(apiKey, model, apiUrl) proves key and model with
  client.models.retrieve(model) (GET /v1/models/{id}), so a test spends no
  tokens; a proxy URL goes through validateApiUrl with the setup options the
  custom provider uses. Wired into validateConfig and
  hasRequiredConfiguration (key + model).
- discoverAnthropicModels({ apiKey, apiUrl }) returns { models, live, error }:
  the live list from client.models.list(), or ANTHROPIC_SUGGESTED_MODELS when
  there is no key, the URL is refused or the call fails.
- Claude OCR: resolveAnthropicOcrConnection() (OCR key, then the Anthropic AI
  key; OCR URL, then the AI URL), resolveAnthropicOcrModel() (a claude-* OCR
  model, else the AI model, else the default) and validateAnthropicOcrConfig(),
  a real round trip through mistralOcrService.runAnthropicOcr() on the
  existing validation image. validateOcrConfig, detectOcrApiUrlForSetup and
  discoverOcrModels accept 'anthropic'; detectAiApiUrlForSetup and
  discoverAiModels accept it for the AI side.
- The runtime configuration snapshot lists ANTHROPIC_API_KEY, ANTHROPIC_MODEL
  (defaulting to ANTHROPIC_DEFAULT_MODEL, so an injected key alone is a
  complete configuration) and ANTHROPIC_API_URL.

Testing:
- tests/test-anthropic-settings.js (filled in a later commit of this series)
  runs the validation and discovery against a fake /v1/models server.
- npx eslint, npx prettier --check.

Impact:
Additive; existing providers take the same paths as before.

Upstream Status:
Not applicable (fork-only feature).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dZrUuuXyUkHuSP5sYdnML
Background:
The routes behind the setup wizard and the settings page hard-code the
provider lists, the keys each provider stores and the OCR key handling. For
Claude OCR the shared OCR fields are a trap: OCR_API_KEY and OCR_API_URL are
read before the Anthropic AI settings, so a Mistral key or an auto-detected
Ollama URL left there would end in a 401 or a request to the wrong host.

Changes:
- "Anthropic (Claude)" preset (model ANTHROPIC_DEFAULT_MODEL, no URL, token
  placeholder sk-ant-...) in DEFAULT_AI_PROVIDER_PRESETS and in
  config/ai-provider-presets.json, which replaces the defaults when present.
- getAnthropicFormOptions() renders as `anthropicOptions` on /setup and
  /settings: default model, suggested models, effort levels, default max
  tokens.
- Every provider list (setup complete, AI test, presets filter, settings save)
  accepts 'anthropic'; every OCR provider list accepts 'anthropic'.
- /api/setup/complete writes ANTHROPIC_API_KEY, ANTHROPIC_MODEL and, only when
  set, ANTHROPIC_API_URL; for Claude OCR it never writes the key into
  MISTRAL_API_KEY and refuses when no Anthropic key can be found.
- POST /settings: anthropicApiKey/Model/Effort/MaxTokens/StructuredOutput/
  PromptCaching/ApiUrl map to the ANTHROPIC_* keys (saved while anthropic is
  selected; an empty key keeps the stored one). Effort must be empty or one
  of ANTHROPIC_EFFORT_LEVELS, max tokens a positive whole number, the URL
  http(s); anything else is a 400.
- Claude OCR on save: when the OCR provider crosses to or from anthropic, a
  stored OCR_API_KEY is cleared unless a new key is typed, and a typed key is
  not mirrored into MISTRAL_API_KEY. A leftover OCR_API_URL is cleared unless
  the operator typed a different (proxy) URL. The OCR model is stored as the
  Claude model it will run with.
- resolveOcrApiKeyForProvider(): the OCR test/model routes never hand
  MISTRAL_API_KEY, or an OCR_API_KEY saved for another provider, to Claude.
  tests/test-ocr-model-fetch-key.js counts the three call sites of the new
  resolver instead of the old one.
- Model discovery for anthropic (AI and OCR) returns the live list or the
  suggested models with `live: false` and a message that says which.
- /manual/analyze routes anthropic through AIServiceFactory and records its
  token metrics; /about support info gains anthropicModel, anthropicApiUrl
  and anthropicKeySet.
- ENV export, env preview and the settings config maps list the ANTHROPIC_*
  keys; @Swagger docs for /api/settings/ai/test, /api/settings/ocr/test (new
  block), /api/settings/ocr/models and POST /settings list anthropic and the
  new fields; OPENAPI/openapi.json regenerated.

Testing:
- node scripts/run-tests.js --all; test-anthropic-settings covers the save,
  the refusals, the stale-key and stale-URL rules and setup completion.
- node scripts/regen-openapi.js; npx eslint; npx prettier --check.

Impact:
Additive for existing providers. Switching the OCR provider to or from Claude
without typing a key now clears the stored OCR key, so it cannot reach the
wrong vendor.

Upstream Status:
Not applicable (fork-only feature).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dZrUuuXyUkHuSP5sYdnML
Background:
The bug report prefill and the About diagnostics list every provider's
non-secret settings and whether its key is set; Anthropic was missing. The
Claude provider can also fail a document with the new reason code
ai_refused, which the failed-documents page showed as a raw code.

Changes:
- server.js res.locals appAnthropicModel, appAnthropicApiUrl ('default' when
  empty) and appAnthropicKeySet; the sidebar bug-report payload reads them
  behind typeof guards, so views rendered without them keep working.
- public/js/bug-report-link.js adds ANTHROPIC_MODEL, ANTHROPIC_API_URL and
  ANTHROPIC_API_KEY=<set|missing> to config_notes; the About diagnostics add
  the same three lines.
- public/js/failed.js labels ai_refused "Declined by the AI model".

Testing:
- node scripts/run-tests.js --all (the shared-partial UI tests render the
  sidebar without the new locals).
- npx eslint, npx prettier --check.

Impact:
Additive; no secret value is exposed, only whether the key is set.

Upstream Status:
Not applicable (fork-only feature).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dZrUuuXyUkHuSP5sYdnML
…tup pages

Background:
With the routes in place, the settings page and the setup wizard still could
not select Anthropic or Claude OCR, and their page scripts assumed every
provider other than OpenAI and Azure needs an API URL.

Changes:
- Settings page: AI provider option "Anthropic (Claude)" and an
  #anthropicSettings block from existing zr components: API key (password,
  "Configured" placeholder, empty keeps the stored value), Claude model (text
  input with the #anthropicModelOptions datalist and #fetchAnthropicModelsBtn
  for the live list), effort select ("Model default" plus every level),
  max tokens, structured output and prompt caching switches, optional API URL
  ("Only for a proxy; leave empty for api.anthropic.com"). Both temperature
  fields get a [data-anthropic-hint] note that Claude models ignore them.
- OCR provider option "Claude (Anthropic)" on both pages: the key is
  optional ("Empty uses the Anthropic API key from the AI provider"), the URL
  field is an optional proxy URL and is emptied when switching to Claude, the
  model select offers the suggested Claude models with the AI model
  preselected, and a Claude model is reset when switching back to Mistral.
  PDF rendering options stay hidden, as for Mistral (Claude reads PDFs).
- Setup wizard: the Anthropic preset fills the model select with the
  suggested models; the API URL is optional for anthropic (#aiApiUrlHint) and
  the token required; the env preview shows AI_PROVIDER=anthropic,
  ANTHROPIC_API_KEY, ANTHROPIC_MODEL (and ANTHROPIC_API_URL when set) and
  OCR_PROVIDER=anthropic.
- OCR test, model discovery and save payloads send the Anthropic AI key, URL
  and model while Anthropic is the AI provider, and no Mistral default model
  for Claude OCR. The "reset local overrides" mapping covers the new fields.
- tests/test-anthropic-settings.js: hasRequiredConfiguration, validation and
  validateConfig routing against a fake /v1/models server, the discovery
  fallback, validateOcrConfig for anthropic with runAnthropicOcr stubbed (key
  fallback, empty transcript, SSRF), the bootstrap sanitizer, the settings and
  setup renders, the settings save and its refusals, the stale OCR key and URL
  rules, and setup completion.

Testing:
- node tests/test-anthropic-settings.js (24 cases);
  node scripts/run-tests.js --all.
- Rendered /settings and /setup through the real router on a throwaway server
  and looked at screenshots, desktop and 390 px, light and dark, with
  Anthropic selected for AI and OCR; switching OCR from custom to Claude
  emptied the URL and preselected the AI model.
- npx eslint, npx prettier --check.

Impact:
Additive; the pages behave as before for every other provider.

Upstream Status:
Not applicable (fork-only feature).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dZrUuuXyUkHuSP5sYdnML
Background:
The provider and Claude OCR need to be selectable and configurable
without editing environment variables by hand.

Changes:
- Setup wizard: "Anthropic (Claude)" preset (claude-haiku-5-5, token,
  no URL), env preview, and Claude as OCR provider.
- Settings: Anthropic block (API key, model with live list and
  suggestions, effort, max tokens, structured output, prompt caching,
  optional proxy URL), temperature hints, and Claude OCR with an
  optional key that falls back to the Anthropic key.
- setupService: token-free key/model validation via models.retrieve,
  model discovery with a fallback list, and a real Claude OCR round
  trip on the validation image.
- Routes accept anthropic everywhere providers are listed; manual
  analysis goes through the factory; OCR keys are never mirrored into
  MISTRAL_API_KEY for Claude and a stale OCR_API_URL is cleared.
- Support info, failed reasons ("Declined by the AI model") and the
  OpenAPI spec cover the new provider.

Testing:
- test-anthropic-settings: 24 cases.
- Settings and wizard reviewed in a mock server, desktop and 390 px,
  light and dark.
- node scripts/run-tests.js --all: 115 passed, 6 skipped.

Impact:
Anthropic can be configured from the UI.

Upstream Status:
Not applicable (fork-only feature).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dZrUuuXyUkHuSP5sYdnML
feat: Anthropic (Claude) as AI and OCR provider
Background:
JWT_SECRET is minted once at first start and appears in no form, while
setupService.saveConfig() rewrites runtime-overrides.json (and data/.env
in legacy mode) whole. The settings page saves without the secret, so
the restart that follows every save found none, minted a new one and
invalidated every session, including the one that had just saved.

The page did not notice. Signed-in routes answer an invalid session with
a redirect to /login, fetch follows it, and callers received the login
page as a 200 they could not parse. Fetching OCR models after a save,
for example, reported "Model discovery failed" with nothing in the log.

Changes:
- setupService.saveConfig() adds the running JWT_SECRET when the
  configuration it is given does not carry one. A secret injected
  through the environment stays protected and is never written.
- public/js/session-guard.js, loaded after csrf.js on every page, sends
  the browser to /login when a request ends there, once, and never on
  the login page itself.

Testing:
- tests/test-settings-save-session-secret.js: the secret survives a
  save and a fresh process start in runtime-first and legacy mode; a
  carried secret wins; an injected one is not persisted. Fails without
  the fix.
- tests/test-session-guard.js runs csrf.js and session-guard.js together,
  including the stale-token retry that ends at /login. Fails without
  the guard.
- node scripts/run-tests.js --all: 117 passed, 6 skipped. ESLint,
  Prettier, stylelint clean; OpenAPI unchanged.

Impact:
Saving settings no longer signs anybody out. A session that does end
(expiry, a reset of local overrides) now leads to the login page instead
of generic errors.

Upstream Status:
Not reported upstream; the settings save path is fork-specific.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dZrUuuXyUkHuSP5sYdnML
Background:
The manual tagging page still carried pieces from before the move off
Tailwind. Its tag chips were built with Tailwind utility classes that
no longer exist, so tags rendered as bare text next to unstyled icon
buttons. The modules had no head/body structure, so the analyze button
and the preview boxes ran edge to edge, and the preview lost the
document's line breaks and stretched the page to the document's length.

Changes:
- views/manual.ejs: every card uses .zr-module__head and __body; the
  correspondent and title are .zr-kv rows, the preview a .zr-panel; Save
  Tags sits in the Tags card head; section labels are .zr-label.
- manual-scripts.ejs: tags are .zr-chip elements with typed, labelled
  .zr-link buttons, the markup settings.ejs and the styleguide use; AI
  suggestions carry an outlined modifier.
- pages/shared.css: the unused .stat-box rules are gone; the preview
  keeps line breaks and scrolls within 600px (320px on phones); empty
  tag lists show a short hint.
- badges.css: chip icons are sized to their 15px buttons instead of
  overflowing past the chip's rounded end, on every page with chips.

Testing:
- Rendered with mock data in a throwaway server, desktop and 390px,
  light and dark, empty and with a document and AI suggestions loaded.
- Adding a suggestion, removing a tag and adding one from the select
  still update the hidden tags field (checked in the browser).
- node scripts/run-tests.js --all: 117 passed, 6 skipped. Prettier and
  stylelint clean.

Impact:
The manual page matches the rest of the interface. Element IDs and the
page's requests are unchanged.

Upstream Status:
Not applicable; the zr interface is fork-specific.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dZrUuuXyUkHuSP5sYdnML
Background
The next release carries the Anthropic provider (merged into nightly
earlier) and the two fixes in this branch. The What's New modal and the
Settings changelog only show what config/changelog.js lists, and its
newest block has to match PAPERLESS_AI_VERSION.

Changes
- config/changelog.js: new release block v2026.10.01 with four entries:
  Claude as AI provider, Claude as OCR provider, settings save no longer
  ending sessions, manual tagging page back on the current design.
- config/config.js: PAPERLESS_AI_VERSION bumped to v2026.10.01. The
  version follows the release workflow's scheme (current month, first
  build); the latest tag is v2026.09.03 and October has none yet.

Testing
- node scripts/run-tests.js --all: 117 passed, 6 skipped (need a running
  server), 0 failed; changelog-releases checks the version sync.
- Prettier and ESLint clean on both files; OpenAPI regeneration leaves
  the spec unchanged.

Impact
After the update the What's New modal shows the four entries. No
behaviour change.

Upstream Status
Fork-only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dZrUuuXyUkHuSP5sYdnML
fix: keep sessions across a settings save; repair the manual tagging page
Background
npm audit on nightly reported 14 advisories (1 critical, 11 high, 2
moderate). Two of them ship in the production image: proxy-addr under
express (IP spoofing via IPv4-mapped IPv6 trust subnets, critical) and
ip-address under express-rate-limit (SSRF and subnet-check bypasses).

Changes
- package-lock.json only, via npm audit fix without --force:
  proxy-addr 2.0.7 -> 2.0.8, ip-address 10.4.0 -> 10.7.4,
  brace-expansion 5.0.9 -> 5.0.12, source-map-js 1.2.1 -> 1.2.2,
  postcss-selector-parser 7.1.5 -> 7.1.6, stylelint 17.15.0 -> 17.16.0.
- No range in package.json changes.

Testing
- node scripts/run-tests.js --all: 117 passed, 6 skipped (need a running
  server), 0 failed.
- npm run lint:css clean with the newer stylelint.
- npm audit: 9 high left, all from braces 3.0.3 (GHSA-vfj7-8cjw-p6xm),
  for which no fixed version is published. It is reached only through
  nodemon and stylelint, both devDependencies that the image does not
  install (npm ci --omit=dev).

Impact
The production image no longer carries the critical proxy-addr advisory
or the ip-address advisories. No behaviour change intended.

Upstream Status
Fork-only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dZrUuuXyUkHuSP5sYdnML
fix(deps): close the fixable npm advisories on nightly
… model

Background
Every model field in Settings is a select filled by its Fetch button:
OpenAI, Ollama, custom and the OCR model. The Claude model was a text
input with a datalist instead. It behaved differently from the rest:
the suggestions only appeared while the field was empty or matched, so
a configured model hid all others, and a fetched list was easy to miss.

Changes
- views/settings.ejs: #anthropicModel is a select. It lists the
  suggested Claude models plus the configured one when that is not among
  them, with the configured (or default) model selected. The datalist is
  gone.
- public/js/settings.js: Fetch Models fills the select through
  populateModelSelect, the helper the other model fields use, keeping the
  current choice selected and listed even when the answer omits it. A
  preset selection goes through the same helper, so a preset model that
  is not listed yet is added instead of leaving the select empty.
- tests/test-anthropic-settings.js: the render assertions check the
  select and its selected option instead of the datalist.

Testing
- node scripts/run-tests.js --all: 117 passed, 6 skipped (need a running
  server), 0 failed.
- Settings rendered through the router test harness in Chromium against
  a fake Claude API, light and dark: the select shows the configured
  model, Fetch Models lists the five live models and keeps the selection,
  no page errors.
- Prettier and ESLint clean on the changed files.

Impact
The Claude model is chosen the same way as every other model. Typing a
model id that is neither suggested nor returned by the API is no longer
possible in the form; ANTHROPIC_MODEL still accepts any id.

Upstream Status
Fork-only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dZrUuuXyUkHuSP5sYdnML
fix(settings): pick the Claude model from a dropdown like every other model
…-2026-10-01-iax78p

# Conflicts:
#	config/config.js
claude and others added 14 commits October 12, 2026 19:03
…ear time

Background:
CodeQL flagged /\/+$/ on the Claude API URL as a polynomial regular
expression: on a value made of many slashes it backtracks quadratically.
The same pattern was added for the Claude OCR base URL.

Changes:
- createAnthropicClient() and MistralOcrService.apiBase (anthropic branch)
  use the existing stripTrailingSlashes() helper from serviceUtils, which
  scans once from the end.

Testing:
- node scripts/run-tests.js --all: 117 passed, 0 failed
- ESLint and Prettier clean on both files

Impact:
Same result for every URL; no quadratic cost on crafted input.

Upstream Status:
Not applicable (fork-only provider).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012u2T47w6ynKbZ2rxoc4Gf4
Background:
The settings AI and OCR test/model endpoints treat an empty key field as
"use the stored key". For Claude they did so whatever URL the request
named, so a signed-in caller could send apiUrl=https://some.host with an
empty key and receive ANTHROPIC_API_KEY (or the Claude OCR_API_KEY) in the
x-api-key header. The same file already refuses this for Quickstart
(resolveSettingsQuickstartApiKey); Claude did not follow that rule.

Changes:
- anthropicSupport: isConfiguredAnthropicHost() accepts an empty URL,
  ANTHROPIC_API_URL (empty = api.anthropic.com) and, while Claude is the
  OCR provider, OCR_API_URL.
- routes/setup.js: resolveSettingsAiToken() and
  resolveOcrApiKeyForProvider() withhold the stored Claude key from any
  other host. Typed keys are unchanged.
- setupService.resolveAnthropicOcrConnection(): the ANTHROPIC_API_KEY
  fallback follows the same rule, which also covers the setup routes.
- Tests: the stored-key case now configures its host; a new case checks
  that AI models, OCR models and the OCR connection get no stored key for
  an unconfigured host (fails without the fix).

Testing:
- node scripts/run-tests.js --all: 117 passed, 0 failed
- ESLint and Prettier clean on changed files, no OpenAPI drift

Impact:
No change for the settings page, which sends the configured URL. Testing
a new proxy URL now needs the key typed in, as with Quickstart.

Upstream Status:
Not applicable (fork-only provider).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012u2T47w6ynKbZ2rxoc4Gf4
Background:
#372 targets main and brings OpenID Connect single sign-on plus four
auth hardening fixes (login timing, TOTP replay, health details behind
auth, dependency updates). It is merged into nightly first so it can be
tested there before a release.

Changes:
- config/config.js: keep nightly's PAPERLESS_AI_VERSION (v2026.10.01).
- scripts/run-tests.js: auth area keeps nightly's settings-save-session-
  secret and session-guard tests and adds oidc-sso.
- package-lock.json: nightly's lockfile, regenerated with
  npm install --package-lock-only to add openid-client.

Testing:
- node scripts/run-tests.js --all: 121 passed, 0 failed, 6 skipped
- ESLint, Prettier, stylelint clean on changed files; no OpenAPI drift
- npm audit --omit=dev: 0 vulnerabilities

Impact:
SSO stays off unless OIDC_ENABLED is set; password login is unchanged.

Upstream Status:
Not applicable (fork feature).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012u2T47w6ynKbZ2rxoc4Gf4
… out of /setup

Background:
Review of #372 on nightly found two gaps where single sign-on meets
existing configuration code:
- setupService.saveConfig() rewrites runtime-overrides.json (and data/.env
  in legacy mode) from the settings form, which has no OIDC_* fields. OIDC
  lines kept in data/.env or the overrides were dropped by the first save
  and SSO turned off after the restart. Same cause as the JWT_SECRET loss
  fixed earlier on nightly. Compose-injected values were not affected.
- OIDC_CLIENT_SECRET was missing from the bootstrap sanitizer, so the open
  /setup page could serialise it into window.__SETUP_BOOTSTRAP__ when it
  sat in data/.env or the overrides before an account existed.

Changes:
- withSessionSecret() also carries every running OIDC_* value the saved
  configuration does not set itself.
- OIDC_CLIENT_SECRET added to BOOTSTRAP_SECRET_FIELDS.
- test-settings-save-session-secret: OIDC_* in the runtime overrides
  survive a settings save (fails without the fix).

Testing:
- node scripts/run-tests.js --all: 121 passed, 0 failed
- ESLint and Prettier clean on changed files

Impact:
SSO configured in data/.env or the overrides keeps working after a
settings save; the client secret never reaches the setup page.

Upstream Status:
Not applicable (fork feature).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012u2T47w6ynKbZ2rxoc4Gf4
Background:
SSO signs in whoever presents OIDC_ADMIN_EMAIL. A provider that allows
self-registration or freely edited profile addresses could let anyone
claim the admin's address; email_verified was never consulted. #372's
own test provider sent email_verified: false, which suggests some
providers do so by default, so a hard refusal needs an escape hatch.

Changes:
- oidcService.authorize(): refuse when email_verified is false (boolean
  or the string "false"). A missing claim is not treated as unverified.
- config.getOidcConfig(): OIDC_ALLOW_UNVERIFIED_EMAIL (default no) turns
  the refusal off for providers that never confirm addresses.
- docs/sso-oidc.md: option table and security notes.
- test-oidc-sso: fake provider now reports a verified address; new cases
  for an unverified address, a missing claim and the switch.

Testing:
- node tests/test-oidc-sso.js: 32 passed, 0 failed
- node scripts/run-tests.js --all: 121 passed, 0 failed
- ESLint and Prettier clean on changed files, no OpenAPI drift

Impact:
Providers that send email_verified: false for the admin need the address
verified there or OIDC_ALLOW_UNVERIFIED_EMAIL=yes.

Upstream Status:
Not applicable (fork feature).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012u2T47w6ynKbZ2rxoc4Gf4
Single sign-on with OpenID Connect (#372) on nightly
… settings)

Background:
Single sign-on is configured only through environment variables so far.
The settings page will get a Single sign-on section and is regrouped
into one view per topic. Both halves of that work need the OIDC values
in the page's config locals first.

Changes:
- GET /settings: config carries every OIDC_* key with the defaults of
  config.getOidcConfig(), so values injected by the container are listed
  as locked like every other key and OIDC_CLIENT_SECRET is blanked as a
  secret.
- Environment file group 'Single sign-on (OIDC)' lists
  OIDC_ALLOW_UNVERIFIED_EMAIL.

Testing:
- node scripts/run-tests.js --all: 121 passed, 0 failed
- ESLint and Prettier clean, no OpenAPI drift

Impact:
None visible yet; the page does not render the new keys.

Upstream Status:
Not applicable (fork feature).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012u2T47w6ynKbZ2rxoc4Gf4
…s page

Background:
Single sign-on (OIDC_*) could only be configured through environment
variables. The settings page gets a Single sign-on section; this is the
server half: the save route has to store and check the OIDC values, the
page needs the derived callback URL and the running configuration's
problems, and a test button needs an endpoint that checks a provider
before anything is saved.

Changes:
- POST /settings: the 14 form keys oidcEnabled ... oidcButtonTextColor
  are written to the 14 OIDC_* keys. Switches become yes/no, scopes are
  whitespace-normalised, the auth method lower-cased, the button text cut
  to 60 characters. A key the body does not carry keeps the stored value
  (setupService.withSessionSecret() carries it), and an empty client
  secret keeps the stored secret.
- When SSO ends up switched on, the save is refused with 400
  ({ success: false, error }) if the issuer URL is not http(s), the client
  ID is empty, the admin e-mail is invalid, the redirect URI is not empty
  or absolute http(s), the auth method is unknown, or the button icon or
  colours are unusable. The check sees the values that will be in force:
  a container-injected key wins over a typed one. Switched off, whatever
  was typed is saved.
- GET /settings passes oidcDetectedRedirectUri (callback URL derived from
  the request) and oidcStatus ({ enabled, errors } from
  oidcService.getConfigErrors() for the running configuration).
- New POST /api/settings/oidc/test { issuerUrl, clientId, clientSecret,
  tokenAuthMethod }: runs discovery, picks the auth method a login would
  use, and checks the client credentials by redeeming a made-up
  authorization code (invalid_client = wrong credentials, invalid_grant =
  client accepted). Answers { success, data: { issuer, tokenAuthMethods,
  chosenAuthMethod, pkce }, message }, success false with HTTP 200 when
  the provider fails, 400 for invalid input. An empty secret uses the
  stored one only for an issuer with the configured issuer's origin.
  Private addresses are allowed for the issuer.
- oidcService: discovery and client construction split into
  _discoverProvider() and _createClientConfig() so login and the test
  share them; new testConnection() and deriveRedirectUri();
  getConfigErrors() takes optional settings; TOKEN_AUTH_METHODS,
  MAX_BUTTON_TEXT_LENGTH and isAbsoluteHttpUrl exported.
- docs/sso-oidc.md: SSO can be set on the settings page, injected values
  stay locked there, the test button.
- OPENAPI/openapi.json regenerated.

Testing:
- New tests/test-settings-oidc.js (auth area, 24 checks): save writes the
  keys, switches and text limits, empty secret and absent keys keep stored
  values, eight refusals when switched on, switched-off save, injected
  value counts, GET /settings locals, and the test endpoint against two
  fake providers on 127.0.0.1 (success, chosen method, invalid_client,
  unreachable provider, 400 input checks, unauthenticated redirect, stored
  secret used for the configured origin and never sent to another one).
- node scripts/run-tests.js --all: 122 passed, 6 skipped, 0 failed.
- eslint, prettier --check on changed files: clean.

Impact:
SSO is configurable from the settings page once the UI section lands.
Existing saves without oidc* keys behave as before. Unauthenticated calls
to the test endpoint get the router guard's redirect to /login, like the
other settings API endpoints.

Upstream Status:
Not applicable; SSO exists only in this fork.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012u2T47w6ynKbZ2rxoc4Gf4
Background:
The settings page stacked every section in one long scroll, and single
sign-on could only be configured through OIDC_* environment variables.
The page is regrouped into one topic per view, and the OIDC settings get a
section of their own.

Changes:
- views/settings.ejs: six groups (Basics, Document analysis, Processing,
  Tag tools, Sign-in and security, System) with 19 entries, listed in a
  sidebar on desktop and a "Group › Entry" select on phones. Only the
  selected entry is shown, with its group label and title above the
  existing modules. All entries stay in the one #setupForm and are toggled
  with .hidden, so saving still sends every field. A search field heads the
  page.
- New Single sign-on entry (badge "New"): status pill from oidcStatus
  (On / Off / "On, but incomplete: ..."), cards "Enable single sign-on"
  (switch, issuer, client ID, client secret, redirect URI with the derived
  callback as placeholder and a Copy button, Test connection), "Who may
  sign in" (admin e-mail, e-mail claim, unverified-address switch) and
  "Login button" (text, icon, colours, live preview of the login button),
  plus a collapsible Advanced block (scopes, token authentication). The
  inputs use the 14 contract ids/body keys; switches send yes/no. Missing
  oidcStatus / oidcDetectedRedirectUri locals fall back safely.
- public/js/modules/settings-nav.js (new): hash navigation (#sso,
  #ai-provider, ...; Back and reload work), legacy hashes (#system-tab,
  #mfa-tab, #ai-tab, #duplicates-tab, #simplify-tab, #ocr-tab,
  #troubleshooting-tab, #env-tab, #changelog-tab and any #sec-* id) land
  on the right entry, search by label, hint and env key with keyboard
  support, unsaved-change dots per topic, and switching to the entry of a
  field named in a save error or refused by browser validation.
- public/js/modules/settings-sso.js (new): POST /api/settings/oidc/test
  with the current field values and the result inline (a redirect to the
  login page reads as an ended session), copying the redirect URI, and the
  login button preview with the server's colour and icon rules.
- public/js/modules/settings-form.js: the leave warning for unsaved
  changes, the baseline resets only after a successful save
  (zr:settings-saved) instead of on submit, switch rows count for the
  restart note, and the dirty fields are announced for the topic list.
- public/js/settings.js: the 14 OIDC fields join the env-key registry
  (Managed by ENV / Overwritten pills). The registry now also names each
  field's variable in its hint and sets data-env-key for the search; a
  locked switch disables its visible half too. The save handler announces
  success and failure for the two modules.
- public/css/pages/settings.css: layout of the topic list, search, entry
  head, env-key code, SSO preview and swatches, inside @layer pages.
- Links to the old anchors in views/history.ejs, views/styleguide.ejs and
  public/js/duplicates.js point at the new hashes.
- tests: test-settings-duplicates-section.js and test-duplicates-ui.js
  follow the new entry ids, topic list and link hash.

Testing:
- node scripts/run-tests.js --all: 121 passed, 6 skipped, 0 failed.
- eslint, prettier and stylelint clean on the changed files.
- Throwaway Express server rendering the view with mock locals and mock
  endpoints, driven with Playwright: legacy hashes, Back, search and pick,
  cross-entry counter and dots, leave warning, save error switching to the
  SSO entry and focusing the field, all 14 contract keys in the POST body,
  pills on locked/overwritten fields, phone layout without horizontal
  overflow.

Impact:
- The settings page shows one topic at a time; old bookmarks and links
  still open the right topic.
- Single sign-on can be configured in the UI once the backend half of
  this phase is in.

Upstream Status:
- Not applicable; Zettelrobbe-specific UI.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012u2T47w6ynKbZ2rxoc4Gf4
Settings: one topic per view, and single sign-on on the settings page
Background:
Release v2026.10.01 now ships everything on nightly, including single
sign-on (contributed in #372), the regrouped settings page and the auth
fixes that came with #372. The changelog block only listed the Anthropic
work.

Changes:
- Add entries for OpenID Connect single sign-on (credited to @krauseerl,
  #372), SSO on the settings page, the regrouped settings page, the Claude
  model dropdown and the login, TOTP and health hardening.

Testing:
- node scripts/run-tests.js --all: 122 passed, 6 skipped, 0 failed
- eslint and prettier on config/changelog.js clean; OpenAPI unchanged

Impact:
Changelog text only.

Upstream Status:
Not applicable (fork-specific release notes).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012u2T47w6ynKbZ2rxoc4Gf4
Comment thread services/oidcService.js Fixed
Background:
CodeQL flagged the /\/+$/ regex in oidcService.stripTrailingSlash() as a
polynomial regular expression on user-provided data (the issuer URL from
the settings page and the SSO test endpoint).

Changes:
- stripTrailingSlash() delegates to stripTrailingSlashes() from
  services/serviceUtils.js, which walks back over '/' characters.

Testing:
- node scripts/run-tests.js --all: 122 passed, 6 skipped, 0 failed
- eslint and prettier on services/oidcService.js clean

Impact:
Same result for every input, no backtracking.

Upstream Status:
Not applicable (OIDC exists only in this fork).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012u2T47w6ynKbZ2rxoc4Gf4

Copy link
Copy Markdown
Owner Author

CodeQL status: the polynomial-regex alert in services/oidcService.js is fixed in 281f1dd.

The second alert, "Server-side request forgery" in services/setupService.js (validatePaperlessConfig, lines 304-308), is not introduced by this PR. The same function and both callers in routes/setup.js are unchanged on main; CodeQL notes that it may report older alerts when a diff is this large. The URL is the Paperless-ngx address an authenticated admin enters, checked by validateApiUrl() with private addresses allowed on purpose, because Paperless-ngx usually runs on the local network. Closing it fully would mean an allowlist of Paperless hosts, which is outside a release PR. Leaving it for a maintainer to dismiss or track separately.


Generated by Claude Code

@admonstrator admonstrator self-assigned this Oct 13, 2026
@admonstrator
admonstrator merged commit c6adeb5 into main Oct 13, 2026
6 checks passed
@admonstrator
admonstrator deleted the claude/release-review-2026-10-01-iax78p branch October 13, 2026 11:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants