fix(credentials): one vault per user, in the config dir (#119)

The credential store resolved its base directory against process.cwd().
Running the CLI from inside a git checkout wrote `.logicsrc/credentials`
into that repo's working tree — a directory containing `vault/`, the one
place raw credential values touch disk — untracked, unignored, and one
`git add -A` from being committed. Two such directories were sitting in
unrelated repos on the machine this was found on.

A per-directory store is also the wrong shape for what the store is for.
It is the record of what was rotated and what the prior values were, and
a record that forks per project folder is several records that disagree.
There is one user, one identity, one vault.

Everything now hangs off a single logicsrcHome(): $LOGICSRC_HOME, else
$XDG_CONFIG_HOME/logicsrc, else ~/.config/logicsrc. The credential store,
the identity and the CLI config all read it rather than each deriving
their own answer — three separate derivations is how the vault ended up
somewhere the config never was.

~/.logicsrc is migrated rather than abandoned. It holds the X25519 secret
key, and losing that loses access to every team vault the member was ever
given, so it is moved on first use; a move that fails says so on stderr
instead of leaving someone silently logged out with a key still on disk
somewhere they were not told about. If the new directory already exists
it wins and the old one is left untouched, because two directories both
claiming to be the identity is how a login writes one and a read finds
the other.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-07-31 22:53:17 -07:00 • committed by GitHub
parent 87266bb815
commit 36236eb1a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 223 additions and 27 deletions

View file

@ -1,21 +1,23 @@
import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { logicsrcHome } from "./identity.js";
import type { CredentialSyncPlan, CredentialSyncRun, CredentialAuditEvent, CredentialValueBag } from "./types.js";
/**
* File-backed store so the CLI can reference plans/runs by id across invocations.
*
* Layout under the base dir (default `$LOGICSRC_CREDENTIAL_HOME` or
* `<cwd>/.logicsrc/credentials`):
* `~/.config/logicsrc/credentials`):
* plans/<id>.json redacted sync plans (fingerprints only)
* runs/<id>.json run records (fingerprints only)
* audit/<runId>.json audit events (fingerprints only)
* vault/<runId>.json rollback pre-image — RAW prior target values, mode 0600
*
* The vault is the only place raw values touch disk, and only to make rollback
* possible. It is written 0600 and lives under a `.logicsrc` dir that callers
* should gitignore. Audit and plan records never contain raw values.
* possible. It is written 0600 and lives in the user's config dir, outside any
* project — so there is nothing for a caller to gitignore, and nothing that
* lands in a repo because the CLI was run from inside one. Audit and plan
* records never contain raw values.
*/
export interface CredentialStore {
baseDir: string;
@ -29,14 +31,26 @@ export interface CredentialStore {
getVault(runId: string): CredentialValueBag | undefined;
}
/**
* The one credential store for this user, on this machine.
*
* This used to fall back to `<cwd>/.logicsrc/credentials`, which meant the
* vault was wherever you happened to be standing: run the CLI in a git
* checkout and it wrote a directory named `credentials/vault` into that
* repo's working tree — untracked, unignored, one `git add -A` away from
* being published. Worse, the store is meant to be the record of what was
* rotated, and a per-directory store is a record with as many disagreeing
* copies as you have project folders.
*
* There is one vault per user. `$LOGICSRC_CREDENTIAL_HOME` still points it
* somewhere explicit, for tests and for anyone keeping it on a mounted
* volume; nothing derives it from the working directory any more.
*/
export function defaultCredentialHome(): string {
if (process.env.LOGICSRC_CREDENTIAL_HOME) {
return resolve(process.env.LOGICSRC_CREDENTIAL_HOME);
}
if (process.env.LOGICSRC_HOME) {
return resolve(process.env.LOGICSRC_HOME, "credentials");
}
return resolve(process.cwd(), ".logicsrc", "credentials");
return join(logicsrcHome(), "credentials");
}
function readJson<T>(file: string): T | undefined {