Skip to content

0042 — Where platform secrets live (user scope, and the project-store blind spot)

Status: DRAFT — open questions resolved with Matt 2026-07-26 (Q1 deferred, Q2 → a dedicated ~/.keryx/accounts.yaml); Q3/Q4 still open. Unblocks spec 0039 D7: the owning project's .keryx.yaml cannot be committed while it carries live platform OAuth tokens, so a fresh CI clone has no storage: block and the unattended pipeline cannot resolve the object store. Date: 2026-07-26 Relates: 0001 §3.2 (stateless tool — the owning project holds the state) · 0001 §4.2 (token storage, the unattended-refresh wrinkle) · 0014 §3.1 (per-project config layer + inheritance allowlist) · 0039 D7 (committed .keryx.yaml for CI self-sufficiency) · 0043 (a config-keychain adapter — the tidier long-term resolution model, deliberately NOT a prerequisite here).


1. Problem

The blog's committed-by-intent .keryx.yaml currently holds live platform credentials — an Instagram access_token and TikTok/YouTube refresh_tokens — next to ordinary non-secret config including the storage: block. It is gitignored as a stopgap (blog MR !120), which means 0039 D7 is unmet: CI clones the repo and finds no storage:.

The tokens are also duplicated. The same values (verified byte-identical) live in the user-scoped global config ~/.keryx/config.yaml. That is not sloppiness — it is the symptom:

Fact Where
keryx auth / auth refresh write tokens to props.Config — the global store pkg/cmd/auth/refresh/main.go:27
Secret resolution is env → keychain → the reader it is handed; no global fallback pkg/oauth/store.go:25
The studio's post path hands the poster a.configStore() — the project store pkg/studio/handlers_social.go:294
Project stores deliberately exclude platforms.* / auth.* from inheritance pkg/studio/projectconfig.go:36

So the CLI posts fine (it reads global), while the studio cannot see the tokens keryx auth just wrote. The project copy exists solely to make studio posting work. That is the actual defect: a layering gap against an intent projectconfig.go already states in a comment — those subtrees "carry secret fallbacks … and must never leak into a project Store."

There is also a standing contradiction. On writing a token to config, keryx prints "the config file (plaintext — keep it out of version control)" (pkg/oauth/store.go:64), while 0039 D7 asks for that same file to be committed. Both cannot hold while one file carries secrets and project config.

2. Direction

User scope is the principled home for a credential, not a compromise. §3.2 forbids keryx owning project state; a token is neither project state nor tool state but account state — it belongs to the human, not the repo. Two projects posting to the same account should share one token; duplicating it into each repo is the anomaly. 0039 D7 already adjudicated this in as many words:

This does NOT strain statelessness (§3.2 forbids keryx owning project state; user-scoped tool config is the existing GTB global layer …).

~/.keryx/ already holds user-scoped tool state on the same reasoning (the studio.yaml project registry, spec 0011 §2.1). Nothing new is being invented.

3. Design

3.1 A secret layer, contributed through the config Backend seam

The project config.Store gains a read-only, lowest-precedence layer sourced from the user-scoped accounts file (§3.4), contributed through go/config's first-class Backend seam (config.WithBackend) rather than as a bespoke fallback bolted onto projectconfig.go:

config.WithBackend(secretlayer.New(
    accountsPath,                       // ~/.keryx/accounts.yaml
    secretlayer.Subtrees("platforms", "auth"),
))

The backend's Load() reads that file and returns one layer carrying only those subtrees, with Source.Writable: false. Nothing else is inherited, so 0014 §3.1's selectivity is preserved — this is not "inherit the whole user config".

Three properties fall out of using the seam rather than special-casing:

  • Reads resolve by ordinary layer precedence, with Origin/Explain provenance, so keryx config can say a token came from the user layer.
  • Writes cannot route there — a non-writable layer is not a write target, so a project file cannot acquire secrets by editing. That is exactly how today's duplicates appeared, and it becomes structurally impossible.
  • It is the same socket 0043 plugs into. When a keychain backend exists it is added at higher precedence and this file layer becomes the fallback beneath it — no call-site changes, no second resolution model. This is explicitly a stopgap in that shape, not a parallel mechanism to unpick later.

This is deliberately not adding the subtrees to inheritedSubtrees. That list is a provenanced inheritance layer whose keys become project-editable on write (0035 §3.1: "an edit to an inherited key routes to .keryx.yaml, forking it to project-owned"). Applying that to secrets would re-create the bug.

Resolution then reads, unchanged in shape:

env var  →  keychain  →  project config  →  user config layer   (new final step)
                ↑                                    ↑
          today: pkg/oauth ladder            0042: a config Backend
          0043: a config Backend             0043: unchanged, now the fallback
  • Desktop with a working keychain — resolves at step 2; no config file ever holds a secret.
  • Headless dev box / CI — no keychain (see dev-server-no-keychain), so it falls through to global, never the project file.
  • CI — masked variables set the env var, which already wins at step 1.

3.2 What the owning project's .keryx.yaml then holds

Only non-secret config: storage:, themes:, providers:, content:, platforms.<p>.enabled / account identifiers (app_id, user_id, open_id, channel_id, client_id, author_urn) — the wiring, not the credential. That file becomes committable and D7 is met: a fresh CI clone is self-sufficient for storage, and supplies its secrets from masked variables.

Account identifiers are deliberately project-level: they say which account this project posts to, which is genuinely project state.

3.3 Writes go to the accounts file

keryx auth <platform> and auth refresh write credentials to ~/.keryx/accounts.yaml (§3.4) — user-scoped as today, but no longer mixed into general config. The studio's config surface must refuse the secret keys; pkg/studio/handlers_config.go's allowlist already excludes them (it lists auth.writeback.backend / auth.alerts.backend only), so this is mostly a case of keeping it that way, with a test that pins it.

3.4 A dedicated accounts file (Matt, 2026-07-26)

Credentials move out of ~/.keryx/config.yaml into a sibling ~/.keryx/accounts.yaml. Same user scope, but a file that holds only account credentials:

~/.keryx/accounts.yaml     credentials ONLY — never committed, mode 0600
~/.keryx/config.yaml       general user config — no secrets
<project>/.keryx.yaml      project config — committable, no secrets

Why a separate file rather than a subtree of the existing one:

  • The blast radius of a mistake shrinks. Any "dump/copy my config" action — by a human, a support request, or a future config export — cannot leak a token, because the token is not in that file. Today's incident began exactly that way, with secrets sitting next to shareable config.
  • It can be protected as a unit0600 on a file that is only secrets is meaningful; on a mixed file it is a blunt instrument.
  • It names what it holds. accounts.yaml matches the framing in §2: this is account state, not tool config and not project config.
  • It is the shape the keychain replaces. When 0043 lands, the keychain backend supersedes this one file cleanly, rather than having to pick secret keys back out of general config.

Migration (one-off, scripted or by hand): move the platforms.* credential keys from ~/.keryx/config.yaml into ~/.keryx/accounts.yaml, chmod 0600, and strip the duplicates from the blog's .keryx.yaml. Only then un-gitignore that file (blog MR !120 added the ignore), after re-verifying a live post — the ignore is cheap insurance until the new path is proven end to end.

4. Decisions

  • D1 — User scope for credentials. Confirmed against §3.2: account state, not project state. No change to the stateless posture.
  • D2 — Read-only fallback, not inheritance. Inherited keys fork on write (0035 §3.1); secrets must not. The fallback is unforkable by construction.
  • D3 — storage: stays project-level. 0039 D7's deferred option (inherit storage from global) is NOT adopted: storage.prefix is the project's identity in the shared bucket, and a shared default would collide keyspaces.
  • D4 — The keychain is not a prerequisite. It is already step 2 of resolution (pkg/oauth/store.go:32, :49) and simply unavailable on this dev box. See 0043 for the tidier model; this spec must work without it.
  • D5 — Use the Backend seam, and call it a stopgap. The user-file secret layer is contributed the same way a keychain will be (§3.1), so 0043 is an additional backend at higher precedence rather than a rewrite. Deliberately chosen over a bespoke fallback so there is one resolution model, one place provenance comes from, and nothing to unpick when the keychain lands.
  • D6 — Credentials live in their own ~/.keryx/accounts.yaml (§3.4), not as a subtree of general user config. Shrinks the blast radius of any config-sharing mistake, can be mode-0600 as a unit, and is the shape 0043's keychain cleanly supersedes.

5. Open questions

Resolved with Matt, 2026-07-26:

  • Q1 — Per-project accounts → DEFERRED. User scope assumes one account per platform across all projects. Two projects posting to different accounts would need credentials keyed by account, with the project naming which. The blog is the only consumer today; revisit when a second appears. Recorded so the limit is known rather than discovered.
  • Q2 — Migration → to ~/.keryx/accounts.yaml (§3.4, D6). Credentials leave general user config for a dedicated accounts file; the blog's duplicates are then stripped, and the .keryx.yaml ignore lifted only after a live post re-verifies the new path.

Still open: - Q3 — Should keryx auth warn on plaintext? Today it reports where the secret landed. Worth a louder note when it lands in a config file rather than a keychain, given that file is now adjacent to committable config? - Q4 — Does doctor check this? A check for "secrets present in a project config file" would have caught the original hazard, and would stop it recurring. In scope here, or a separate doctor slice?

6. Test plan

  • Unit — a project store resolves platforms.* from global when its own file lacks the key; a project file value still wins over global; a write to a secret key does not land in the project file; non-secret keys keep their existing inheritance/fork behaviour.
  • Unit (oauth) — resolution order holds end to end: env beats keychain beats project beats global.
  • Studio — the post path resolves a token that exists only in global (the regression this spec exists for); the config allowlist still refuses secret keys.
  • e2e (godog) — a project whose .keryx.yaml carries no secrets can still post (faked publisher), proving a committed project file is sufficient.
  • Accounts fileauth writes land in ~/.keryx/accounts.yaml, not config.yaml; the file is created mode 0600; a missing accounts file is a layer that supplies nothing, never an error.
  • Manual — migrate credentials to accounts.yaml, strip the blog's duplicates, confirm keryx post and a studio post both still resolve, then un-gitignore .keryx.yaml and confirm a fresh clone has the storage: block.