Repository navigation
Release v2026.10.01 - #384
Conversation
* 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
…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
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
|
CodeQL status: the polynomial-regex alert in The second alert, "Server-side request forgery" in Generated by Claude Code |
Before:
mainis on v2026.09.03. It has no Claude support, no single sign-on, and the settings page is one long page.After:
maingets everything that is onnightlyas release v2026.10.01:This branch is
nightlywithmainmerged 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 onnightlyyet; mergingmainback intonightlyafter this release brings them over.How: merge of
origin/nightlyinto the release branch (no conflicts), plus a changelog commit for the SSO and settings entries. The version staysv2026.10.01inconfig/config.jsandconfig/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