Skip to content

feat(backend)!: require THINKER_ENCRYPTION_KEY, separate from BETTER_AUTH_SECRET - #150

Merged
patrickbrandao merged 1 commit into
mainfrom
feat/27-encryption-key-required
Oct 14, 2026
Merged

patrickbrandao merged 1 commit into
mainfrom
feat/27-encryption-key-required

Conversation

@patrickbrandao

Copy link
Copy Markdown
Owner

Closes #27

What

  • crypto.util.ts: getEncryptionSecret() reads only THINKER_ENCRYPTION_KEY. getDecryptionSecrets() returns [key, PREVIOUS]. BETTER_AUTH_SECRET is no longer read in this file.

  • New assertEncryptionKeyConfig() / encryptionKeyConfigProblems() throw a StartupConfigError when:

    • the key is missing;
    • the key is under 32 chars;
    • the key equals BETTER_AUTH_SECRET;
    • THINKER_ENCRYPTION_KEY_PREVIOUS is set but under 32 chars.

    Error messages name the variables, never their values, and include the openssl rand -hex 32 hint.

  • main.ts runs the check in bootstrap() before the app and migrations start. A config error exits 1 with Configuration error: ... instead of the migration hint. The legacy-key check now throws the same error type.

  • EncryptApiKeys migration and secret-reencryption.service.ts no longer read or mention the fallback.

  • docker/docker-compose.yml now requires the key with ${THINKER_ENCRYPTION_KEY:?...}.

  • Docs: docker/.env.example, packages/backend/.env.example, DOCKER_README.md (docker run examples now pass the key) and docs/dev/manifest-guide.md.

  • CI: the e2e job env and docker-smoke both set a key. Docker-smoke also checks that install.sh writes the key.

  • The changeset ships inside 0.1.0 and bumps nothing, like feat(backend)!: rename client-visible identifiers to THINKER_*, thk_ and x-thinker-* #138.

Not done here (out of my scope): docker/install.sh is unchanged, so docker/.env.example keeps the commented # THINKER_ENCRYPTION_KEY= line it rewrites. The issue's switch to an uncommented line and the "--upgrade without the key" abort still belong to devops. The release notes in docs/releases/0.1.0.md (docs-ptbr) also need an entry for this change.

Tests

  • Unit tests:
    • the gate in each case (missing, short, equal, short PREVIOUS, valid PREVIOUS, no secret value in any message);
    • no fallback in either function;
    • a known-ciphertext fixture with a rotation through PREVIOUS;
    • the fixture does not decrypt via BETTER_AUTH_SECRET.
  • Specs that relied on the fallback now set the key. test/support/unit-env.ts and test/support/e2e-env.ts supply a test key.
  • E2E test/encryption-key-required.e2e-spec.ts boots the real src/main.ts with the key missing, short, equal to BETTER_AUTH_SECRET, and with a short PREVIOUS. Each run must exit non-zero with the expected message. It also saves a provider key through the API and checks that it decrypts with the key and not with BETTER_AUTH_SECRET.
  • Local results:
    • npm run lint (0 errors), npm run typecheck and npm run build pass for packages/backend;
    • npx jest: 475 suites, 9096 tests passed;
    • TZ=UTC npm run test:e2e:docker: 72 suites, 604 tests passed.

qa-docker is needed (runtime boot behavior changed).

🤖 Generated with Claude Code

…AUTH_SECRET

At-rest encryption no longer falls back to the session secret.

- crypto.util: getEncryptionSecret() reads only THINKER_ENCRYPTION_KEY;
  getDecryptionSecrets() is [key, PREVIOUS]. New assertEncryptionKeyConfig()
  throws a StartupConfigError when the key is missing, under 32 chars or
  equal to BETTER_AUTH_SECRET, or PREVIOUS is set but under 32 chars.
- main.ts runs the check before the app and its migrations start; config
  errors exit 1 with their own message instead of the migration hint. The
  legacy-key check uses the same error type.
- EncryptApiKeys migration and the re-encryption service no longer mention
  or read BETTER_AUTH_SECRET.
- Compose requires the key (${VAR:?}); env examples, Docker README and the
  developer guide document it as required. Docker smoke and CI e2e set one.
- Tests: unit coverage for the gate and a known-ciphertext fixture with a
  PREVIOUS rotation; e2e boots the real server with a missing, short,
  equal and short-PREVIOUS key and checks the exit and message, and checks
  a provider key saved through the API does not decrypt with
  BETTER_AUTH_SECRET. Unit and e2e setup files supply a test key.

BREAKING CHANGE: THINKER_ENCRYPTION_KEY must be set (32+ chars, different
from BETTER_AUTH_SECRET) or the server does not start.

Refs #27

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@patrickbrandao

Copy link
Copy Markdown
Owner Author

Review of #150: require THINKER_ENCRYPTION_KEY with no BETTER_AUTH_SECRET fallback (#27)

Diff reviewed: git diff origin/main...f32a40f4b8f5e454de307f6ec270beb47b18b767 (29 files).

Summary

  • getEncryptionSecret() reads only THINKER_ENCRYPTION_KEY. getDecryptionSecrets() returns the key plus an optional THINKER_ENCRYPTION_KEY_PREVIOUS. BETTER_AUTH_SECRET is never a candidate.
  • assertEncryptionKeyConfig runs at boot before the app or its migrations start. It rejects a key that is missing, shorter than 32 characters or equal to BETTER_AUTH_SECRET, and a PREVIOUS value that is set but too short. The error messages never include secret values, and a test checks this.
  • StartupConfigError gives operators a clean fatal message in main.ts, and legacy-env.ts now reuses it.
  • In migration EncryptApiKeys, only the fallback was removed. The migration destroys no data, and the boot gate guarantees the key is set before it runs.
  • Compose uses ${THINKER_ENCRYPTION_KEY:?}. install.sh already generated the key. The smoke and install e2e jobs now check that the key was generated, and the CI e2e env sets it.
  • No new outbound destination. No new manifest/MANIFEST_* identifiers. Credentials are not logged.

Tests

Unit coverage in crypto.util.spec.ts: the config check, no fallback, a fixed ciphertext that stays readable, and rotation through PREVIOUS. A new test/encryption-key-required.e2e-spec.ts boots the real process for each refusal case and checks that a provider key is stored under the dedicated key and not under the session secret. Coverage is at the right level.

Findings

should-fix 1: there is no upgrade path for installs that relied on the fallback. In docker/DOCKER_README.md:131-137 and docs/dev/manifest-guide.md, nothing tells an existing operator, including one migrating from Manifest, who never set THINKER_ENCRYPTION_KEY what to do. Failure scenario: on upgrade, Compose refuses to start. The operator generates a fresh key, and every stored provider key and OAuth token silently stops decrypting, because read paths treat a failed decrypt as "not connected". The safe recipe exists: set a new THINKER_ENCRYPTION_KEY, set THINKER_ENCRYPTION_KEY_PREVIOUS=<old BETTER_AUTH_SECRET>, boot, wait for the re-encryption log, then remove PREVIOUS. Fix: document that recipe in the DOCKER_README upgrade section and in the 0.1.0 release notes, and point to it from the boot error message when the key is missing. Recordings written under the old secret stay unreadable, as the README already says.

nit 2: stale comment at docker/install.sh:291-296. It still says "Left unset the backend falls back to BETTER_AUTH_SECRET ... and warns about it on every boot". Update it: the key is now required.

nit 3: the doc comment at packages/backend/src/common/utils/crypto.util.ts:107 and onward could mention that PREVIOUS may legitimately hold the old BETTER_AUTH_SECRET during the upgrade above. This would help the upgrade recipe. Optional.

High-risk criteria that apply

  • It changes crypto and key handling: THINKER_ENCRYPTION_KEY and how provider credentials and OAuth tokens are encrypted.
  • It changes .github/workflows/ci.yml and .github/workflows/docker-smoke.yml. This is outside the Escopo of CI mínima provisória #38. The edits are small and only add a generated test key, with no change to permissions or secrets usage, but the criterion still applies.

Nothing blocking. qa-docker is required for this runtime change: it should cover boot refusal and a normal boot with both keys set. Should-fix 1 must be fixed or tracked in an issue linked in the PR before merge.

CI on the reviewed SHA: all 19 check runs completed with success (CodeQL, Docker build amd64/arm64, Compose smoke, install.sh e2e, Lint, Typecheck, Build, Unit backend/cli/shared/frontend, Backend e2e UTC and America/Sao_Paulo, Changeset check, Scripts, dependency-review).

Head revisado: f32a40f
Veredito: risco alto (aguardar mantenedor)

@patrickbrandao

Copy link
Copy Markdown
Owner Author

Review should-fix 1 and the nits are tracked in #152.

@patrickbrandao

Copy link
Copy Markdown
Owner Author

qa-docker report for PR #150

Image thinker:qa-q15017227 (arm64, build 1m08s, 429MB). Base images are those named in docker/Dockerfile. Postgres is postgres:16-alpine@sha256:7218...80ea. THINKER_CATALOG_SYNC=false, mock provider only, volumes isolated by override (thinker-qa-q15017227_*), and ports 5432/5433 were not touched.

Step Result
Build pass
Boot and health pass
Check 1a: THINKER_ENCRYPTION_KEY missing pass: exit 1, "is not set ... required"
Check 1b: key shorter than 32 chars pass: exit 1, "too short (14 chars)"
Check 1c: key equal to BETTER_AUTH_SECRET pass: exit 1, "must differ from BETTER_AUTH_SECRET"
Check 1d: no secret value in the messages pass: grep of the output found 0 matches
Check 2a: provider key stored encrypted pass: tenant_providers.api_key_encrypted is salt:iv:tag:ct form, no plaintext
Check 2b: proxied request with that key pass
Check 2c: decrypt with BETTER_AUTH_SECRET pass: fails with GCM auth error, while THINKER_ENCRYPTION_KEY decrypts it
Check 3: rotation via THINKER_ENCRYPTION_KEY_PREVIOUS pass: proxy works after rotating, and SecretReencryptionService re-encrypted 1 of 1 under the new key. Without PREVIOUS the old key is unusable ("Failed to decrypt API key" warning), as expected.
Smoke: dashboard HTML, admin setup, agent key, /v1/models pass
Smoke: /v1/chat/completions, /v1/messages, /v1/responses pass
Gateway contracts (9 tests) pass
Backend e2e (test:e2e:docker) pass on retry: 71/72 suites on the first run, 15 tests failed in proxy.e2e-spec and no-phonehome-egress.e2e-spec with hook timeouts (30s)
e2e rerun of those two suites pass (37/37)
Teardown pass: only labeled volumes were removed, none remain

Flaky: the first e2e run took 30 minutes with a host load average above 120, because other QA runs were going at the same time. Both failures were hook timeouts and passed on rerun.

Finding (non-blocking): docker/docker-compose.yml and .env.example do not pass THINKER_ENCRYPTION_KEY_PREVIOUS into the container. Rotation therefore needs a compose override, and I used one. Suggest adding - THINKER_ENCRYPTION_KEY_PREVIOUS=${THINKER_ENCRYPTION_KEY_PREVIOUS:-} to the compose environment. The dashboard <title> still says "Manifest", which is legacy and out of scope.

Tested SHA: f32a40f

@patrickbrandao
patrickbrandao marked this pull request as ready for review October 14, 2026 13:21
@patrickbrandao
patrickbrandao merged commit 46bcbda into main Oct 14, 2026
19 checks passed
@patrickbrandao
patrickbrandao deleted the feat/27-encryption-key-required branch October 14, 2026 13:21
patrickbrandao added a commit that referenced this pull request Oct 14, 2026
* feat(cli)!: rename the CLI to thinker with THINKER_* variables

- Package thinker-cli (private, no publishConfig), bin thinker -> bin/thinker.js.
- Env vars THINKER_URL, THINKER_API_KEY, THINKER_AGENT_KEY, THINKER_AGENT_URL;
  the old names are not read (no aliases).
- Config and agent-key store under ~/.config/thinker/; default origin stays
  http://localhost:2099. Fix the stale "then Cloud" resolution comment.
- User-Agent thinker-cli/<version>; usage, hints and messages say thinker.
- Embedded skill renamed thinker-cli and rewritten for a self-hosted Thinker
  instance; regenerated skill-content.gen.ts.
- routing test detects the gateway's peacock error tag for any product word,
  so it keeps working across the server-side rename of that tag.
- README: local install (npm install -g ./packages/cli or npm pack) and a note
  that the upstream CLI's config directory (including leftover telemetry
  files) is neither read nor migrated.
- Root: changesets ignore list and check-changesets message use thinker-cli;
  lockfile updated; dev guide CLI section refreshed.

BREAKING CHANGE: the CLI command, environment variables and config directory
are renamed; run thinker login again.

Refs #23

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(docker)!: rebrand image, compose and installer to Thinker

- docker-compose.yml: project/service `thinker`, Postgres user/db
  `thinker`, volumes thinker_pgdata and thinker_request_recordings (no
  legacy pins), `# thinker-compose-version: 1`, no legacy 3001 section,
  TRUST_PROXY exposed with a `loopback` default so clients on a private
  network cannot spoof X-Forwarded-For to dodge the per-IP rate limit.
- install.sh: THINKER_INSTALLER_SOURCE / THINKER_INSTALLER_SKIP_PULL (no
  aliases), raw source patrickbrandao/thinker, default dir $HOME/thinker,
  thinker compose marker, `docker compose logs -f thinker`, LAN note via
  HOST_BIND_ADDRESS.
- Dockerfile: Thinker OCI labels, drop the stale base.digest label,
  ENV PORT=2099 and healthcheck fallback 2099, ship /app/LICENSE and a
  THIRD_PARTY_NOTICES generated offline before node_modules is pruned.
- Backend: single DEFAULT_PORT = 2099 constant replaces every 3001
  fallback; spec fixtures follow.
- DOCKER_README.md: minimal rebrand (no upstream stars badge, API Bot
  banner or manifest.build links).
- docker-smoke.yml: drop the Compose override and the patched compose
  copy; tag the local build tmsoftbrasil/thinker:ci and select it with
  THINKER_VERSION=ci (restores the image-tag check), new service, volume
  and container names, and check the installer writes two distinct
  secrets. docker.yml checks LICENSE, THIRD_PARTY_NOTICES and PORT=2099.

BREAKING CHANGE: MANIFEST_INSTALLER_* are no longer read; the Compose
project, service and volume names changed.

Refs #141

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(n8n)!: rebrand the n8n node as n8n-nodes-thinker

- git mv integrations/n8n-nodes-manifest -> integrations/n8n-nodes-thinker;
  package n8n-nodes-thinker 0.1.0, repository/homepage/author point at this
  repo. Lockfile: only the root name/version lines change.
- Nodes Thinker and LmChatThinker, credential ThinkerApi (thinkerApi), base
  URL default http://localhost:2099.
- Both nodes refuse an API key that does not start with thk_ before sending
  any request.
- THINKER_ATTRIBUTION_HEADERS with User-Agent n8n-nodes-thinker; the backend
  caller classifier recognises the new UA.
- Provisional "T" logo until #10.
- dist/ removed from git and ignored; root nodes/ and credentials/ mirrors
  deleted.
- CHANGELOG restarted at 0.1.0; README documents npm pack and a manual
  community-node install. LICENSE.md keeps the MNFST, Inc. notice (MIT).

BREAKING CHANGE: node, credential and package names change; existing n8n
workflows must be rewired to the Thinker nodes and credential.

Closes #24

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(dev): describe the unpublished thinker-cli package

Refs #23

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(docker): keep the upgraded compose file readable (0644)

mktemp creates the replacement with mode 0600; the compose file carries
no secrets, so restore the usual 0644 after the atomic move. Found by
qa-docker.

Refs #26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(deploy)!: rebrand community deploy templates to Thinker

- render.yaml, app.json, .do/deploy.template.yaml, Dockerfile.heroku and
  deploy/* (fly, coolify, gcp, aws, easypanel, apple-containers, koyeb,
  digitalocean, heroku) use the image tmsoftbrasil/thinker:0.1 (Apple
  Containers follows THINKER_VERSION like Compose) and generate or ask
  for THINKER_ENCRYPTION_KEY; since #138 the backend refuses to boot
  with only MANIFEST_ENCRYPTION_KEY.
- Drop the dead MANIFEST_MODE everywhere, manifest.build links, the
  upstream logo and the AWS quick-create link to Manifest's S3 bucket.
- deploy/aws/manifest.yaml -> deploy/aws/thinker.yaml; Apple Containers:
  MANIFEST_ENV_FILE / MANIFEST_PG_VOLUME / MANIFEST_RECORDINGS_VOLUME ->
  THINKER_* (no aliases), container names thinker-postgres/thinker-app,
  Postgres digest read from docker/docker-compose.yml (Dependabot keeps
  that one current) with a synced fallback; Coolify Postgres pinned by
  digest.
- Every TUTORIAL.md starts with the PT-BR "community-maintained, not
  tested in CI" notice; root templates carry it as a comment.
- CONTRIBUTING.md: Thinker naming, thk_ keys, Docker Hub image and the
  tag-driven release flow; the stale Codecov badge goes.

BREAKING CHANGE: MANIFEST_ENV_FILE, MANIFEST_PG_VOLUME and
MANIFEST_RECORDINGS_VOLUME are no longer read by
deploy/apple-containers/start.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(frontend): rebrand the dashboard to Thinker (#25)

- Thinker name, logo (brand assets, cache-busted with ?v=1) and favicon on
  every page; index.html title and OG/Twitter tags with no absolute URLs.
- Setup guides: OpenCode, OpenClaw disable, MCP clients use the `thinker`
  provider/server name; n8n uses n8n-nodes-thinker; Craft points at a custom
  OpenAI-compatible endpoint; HeaderTierModal placeholder x-thinker-tier.
- Links: Docs and Help point to the Thinker repository (docs, issues);
  Manifest terms, privacy, support email, blog, discussion and the
  modelparams.dev report action are removed.
- Web Storage keys move to thinker*; pre-rename keys are deleted on load;
  lazy-reload survives blocked sessionStorage.
- Gateway marker `thinker` gets the Thinker icon and label.
- Routing pricing toast explains when catalog sync is off (#75 item 5).
- Playground headers: shared validators, warnings for duplicate and
  over-cap rows, announcement reset on close, adapter note (#105 items 4-5).
- Tests use thk_ keys and x-thinker-tier as data (#141 frontend part).
- Docker smoke checks <title>Thinker</title>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore!: rename every remaining Manifest reference to Thinker

Sweep the identifiers, types, files, MCP tool names, routing reasons,
analytics fields, error messages, docs, URLs and test fixtures that still
carried the upstream brand, on top of the folded rebrand branches.

- MCP tools manifest_* -> thinker_*; MCP server name thinker.
- ManifestError/ManifestErrorCode/formatManifestError/ManifestRequest and
  friends -> Thinker*; files manifest-error, manifest-runtime and
  manifest-error-docs renamed.
- routing_reason manifest_* -> thinker_* with an append-only migration
  (RenameThinkerRoutingReasons1803600000000, up/down).
- origin=manifest filter -> origin=thinker; manifest_errors,
  manifest_lift_pct, manifest_error_code/message -> thinker_*.
- Error docs links point to docs/erros.md in the repository (new PT-BR
  page) instead of manifest.build; OpenRouter HTTP-Referer and email
  footers point to the repository; default dashboard URL is
  http://localhost:2099.
- Dev seed login admin@thinker.example.com / thinker.
- Templates use THINKER_RECORDING_* (backend side in #148) and the
  Compose file, Apple Containers script and Docker Smoke require
  THINKER_ENCRYPTION_KEY (#150).
- README links point to patrickbrandao/thinker; upstream-only badges,
  banners and the hosted cloud section removed.

Kept on purpose: upstream attribution, legacy guards (legacy-env,
legacy-storage, absence assertions), frozen storage-format salts,
applied migrations, and generic "manifest" meanings (OCI manifests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(backend): no hosted gateway host left in backend production files

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(backend): escape error text in SSE and friendly bodies, add nosniff

CodeQL flagged reflected XSS / exception text as HTML on the playground
SSE writer, the friendly SSE response and the proxy stream-error frames.
Those bodies now go through safeJsonStringify, which writes <, >, &,
U+2028 and U+2029 as \uXXXX escapes: the JSON every client parses is
unchanged. SSE responses and the friendly JSON response also send
X-Content-Type-Options: nosniff, and the stream-error path declares
text/event-stream when no header was sent yet.

Also drop the duplicated THINKER_BASE_URL and stale TODO(#31) in Docker
Smoke, and document that 0.1 is fresh-install only for Manifest users.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

THINKER_ENCRYPTION_KEY obrigatória e separada do BETTER_AUTH_SECRET

1 participant