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/Explainprovenance, sokeryx configcan 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 unit —
0600on a file that is only secrets is meaningful; on a mixed file it is a blunt instrument. - It names what it holds.
accounts.yamlmatches 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 (inheritstoragefrom global) is NOT adopted:storage.prefixis 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
Backendseam, 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.yamlignore 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.yamlcarries no secrets can still post (faked publisher), proving a committed project file is sufficient. - Accounts file —
authwrites land in~/.keryx/accounts.yaml, notconfig.yaml; the file is created mode0600; a missing accounts file is a layer that supplies nothing, never an error. - Manual — migrate credentials to
accounts.yaml, strip the blog's duplicates, confirmkeryx postand a studio post both still resolve, then un-gitignore.keryx.yamland confirm a fresh clone has thestorage:block.