diff --git a/packages/appstash/README.md b/packages/appstash/README.md index 4a7593f..b44077a 100644 --- a/packages/appstash/README.md +++ b/packages/appstash/README.md @@ -251,6 +251,50 @@ const dirs = appstash('myapp', { console.log(dirs.config); // /opt/myapp/.myapp/config ``` +## Config store + +`createConfigStore(tool, options?)` layers a context + credential store on top of the +directories above: named contexts (each with an endpoint and optional per-target +endpoints), credentials per context, per-context vars, and `getClientConfig(target)` +resolution (store → env vars → actionable error). + +Every file it writes is atomic (temp file + `rename`) and mode `0600`. A stored file +that exists but does not parse throws with its path — it is never silently replaced +with defaults. + +### One signed-in state across several tools + +Pass `stashName` when multiple binaries are really one product. They then share +contexts and credentials, while `tool` still drives env-var prefixes (`CSDK_TOKEN`) +and the command names in error messages: + +```typescript +// A generated SDK CLI, an agent CLI and a desktop app, one login: +createConfigStore('csdk', { stashName: 'constructive' }); +createConfigStore('agent', { stashName: 'constructive' }); +createConfigStore('desktop', { stashName: 'constructive' }); +``` + +### Encrypting secrets at rest + +Supply a `SecretCodec` to transform the secret-bearing fields (`token`, +`refreshToken`, `apiKey`) on the way to disk; everything else stays readable. The +codec name is recorded in `credentials.json`, so a file written by a different codec +is reported instead of decoded into garbage: + +```typescript +import { safeStorage } from 'electron'; + +createConfigStore('desktop', { + stashName: 'constructive', + codec: { + name: 'electron-safeStorage', + encode: (s) => safeStorage.encryptString(s).toString('base64'), + decode: (s) => safeStorage.decryptString(Buffer.from(s, 'base64')) + } +}); +``` + ## Design Philosophy - **Simple**: One function, clear structure diff --git a/packages/appstash/__tests__/config-store-shared.test.ts b/packages/appstash/__tests__/config-store-shared.test.ts new file mode 100644 index 0000000..1080a80 --- /dev/null +++ b/packages/appstash/__tests__/config-store-shared.test.ts @@ -0,0 +1,187 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { createConfigStore, SecretCodec } from '../src'; + +describe('createConfigStore — shared identity, secrets and durability', () => { + let tempBase: string; + + beforeEach(() => { + tempBase = fs.mkdtempSync(path.join(os.tmpdir(), 'appstash-shared-test-')); + }); + + afterEach(() => { + fs.rmSync(tempBase, { recursive: true, force: true }); + }); + + const rot13: SecretCodec = { + name: 'rot13', + encode: (s: string) => s.replace(/[a-z]/gi, (c: string) => + String.fromCharCode(((c.charCodeAt(0) - (c < 'a' ? 65 : 97) + 13) % 26) + (c < 'a' ? 65 : 97)) + ), + decode: (s: string) => rot13.encode(s) + }; + + const credentialsFile = (stash: string) => + path.join(tempBase, `.${stash}`, 'config', 'credentials.json'); + + describe('stashName', () => { + it('lets two differently-named tools share one signed-in state', () => { + const csdk = createConfigStore('csdk', { baseDir: tempBase, stashName: 'constructive' }); + csdk.createContext('localnet', { endpoint: 'http://api.localhost:3000/graphql' }); + csdk.setCurrentContext('localnet'); + csdk.setCredentials('localnet', { token: 'tok', email: 'dan@example.com' }); + + const agent = createConfigStore('agent', { baseDir: tempBase, stashName: 'constructive' }); + + expect(agent.getCurrentContext()?.name).toBe('localnet'); + expect(agent.getCredentials('localnet')).toMatchObject({ token: 'tok', email: 'dan@example.com' }); + expect(fs.existsSync(credentialsFile('constructive'))).toBe(true); + }); + + it('keeps tools isolated when no stashName is given', () => { + const a = createConfigStore('toola', { baseDir: tempBase }); + a.createContext('dev', { endpoint: 'http://a' }); + a.setCurrentContext('dev'); + + const b = createConfigStore('toolb', { baseDir: tempBase }); + expect(b.getCurrentContext()).toBeNull(); + }); + + it('still uses toolName for env-var prefixes and error text', () => { + const store = createConfigStore('csdk', { baseDir: tempBase, stashName: 'constructive' }); + process.env.CSDK_API_ENDPOINT = 'http://from-env/graphql'; + try { + expect(store.getClientConfig('api').endpoint).toBe('http://from-env/graphql'); + } finally { + delete process.env.CSDK_API_ENDPOINT; + } + + const bare = createConfigStore('csdk', { baseDir: tempBase, stashName: 'constructive' }); + expect(() => bare.getClientConfig('api')).toThrow(/csdk context create/); + }); + }); + + describe('session identity fields', () => { + it('round-trips the identity carried alongside the token', () => { + const store = createConfigStore('testapp', { baseDir: tempBase }); + const signedInAt = Date.now(); + store.setCredentials('prod', { + token: 'access', + refreshToken: 'refresh', + userId: 'user-1', + email: 'dan@example.com', + apiKey: 'cnc_live_sk_abc', + keyId: 'key-1', + apiKeyExpiresAt: '2027-01-01T00:00:00.000Z', + signedInAt + }); + + expect(store.getCredentials('prod')).toEqual({ + token: 'access', + refreshToken: 'refresh', + userId: 'user-1', + email: 'dan@example.com', + apiKey: 'cnc_live_sk_abc', + keyId: 'key-1', + apiKeyExpiresAt: '2027-01-01T00:00:00.000Z', + signedInAt + }); + }); + }); + + describe('secret codec', () => { + it('encodes secret fields at rest and decodes them on read', () => { + const store = createConfigStore('testapp', { baseDir: tempBase, codec: rot13 }); + store.setCredentials('prod', { + token: 'secret', + refreshToken: 'refresh', + apiKey: 'apikey', + email: 'dan@example.com' + }); + + const onDisk = JSON.parse(fs.readFileSync(credentialsFile('testapp'), 'utf8')); + expect(onDisk.codec).toBe('rot13'); + expect(onDisk.tokens.prod.token).toBe(rot13.encode('secret')); + expect(onDisk.tokens.prod.apiKey).toBe(rot13.encode('apikey')); + // Non-secret fields stay readable. + expect(onDisk.tokens.prod.email).toBe('dan@example.com'); + + expect(store.getCredentials('prod')).toMatchObject({ + token: 'secret', + refreshToken: 'refresh', + apiKey: 'apikey' + }); + }); + + it('records plaintext when no codec is configured', () => { + const store = createConfigStore('testapp', { baseDir: tempBase }); + store.setCredentials('prod', { token: 'secret' }); + + const onDisk = JSON.parse(fs.readFileSync(credentialsFile('testapp'), 'utf8')); + expect(onDisk.codec).toBe('plaintext'); + expect(onDisk.tokens.prod.token).toBe('secret'); + }); + + it('refuses to read credentials written by a different codec', () => { + const plain = createConfigStore('testapp', { baseDir: tempBase }); + plain.setCredentials('prod', { token: 'secret' }); + + const encrypted = createConfigStore('testapp', { baseDir: tempBase, codec: rot13 }); + expect(() => encrypted.getCredentials('prod')).toThrow(/"plaintext" codec.*uses "rot13"/s); + }); + + it('accepts an empty store regardless of codec', () => { + const encrypted = createConfigStore('testapp', { baseDir: tempBase, codec: rot13 }); + expect(encrypted.getCredentials('prod')).toBeNull(); + expect(encrypted.hasValidCredentials('prod')).toBe(false); + }); + }); + + describe('durability and permissions', () => { + it('writes credentials 0600 and leaves no temp files behind', () => { + const store = createConfigStore('testapp', { baseDir: tempBase }); + store.setCredentials('prod', { token: 'secret' }); + + const file = credentialsFile('testapp'); + expect(fs.statSync(file).mode & 0o777).toBe(0o600); + const leftovers = fs.readdirSync(path.dirname(file)).filter((f) => f.endsWith('.tmp')); + expect(leftovers).toEqual([]); + }); + + it('writes context and settings files 0600 too', () => { + const store = createConfigStore('testapp', { baseDir: tempBase }); + store.createContext('prod', { endpoint: 'http://api' }); + store.setCurrentContext('prod'); + store.setVar('DATABASE_ID', 'db-1', 'prod'); + + const configDir = path.join(tempBase, '.testapp', 'config'); + for (const file of [ + path.join(configDir, 'settings.json'), + path.join(configDir, 'contexts', 'prod.json'), + path.join(configDir, 'vars', 'prod.json') + ]) { + expect(fs.statSync(file).mode & 0o777).toBe(0o600); + } + }); + + it('throws with the path when a stored file is malformed', () => { + const store = createConfigStore('testapp', { baseDir: tempBase }); + const file = credentialsFile('testapp'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, '{ this is not json'); + + expect(() => store.getCredentials('prod')).toThrow(/Malformed JSON in .*credentials\.json/); + }); + + it('does not silently drop a malformed context when listing', () => { + const store = createConfigStore('testapp', { baseDir: tempBase }); + store.createContext('good', { endpoint: 'http://api' }); + const contextsDir = path.join(tempBase, '.testapp', 'config', 'contexts'); + fs.writeFileSync(path.join(contextsDir, 'broken.json'), 'nope'); + + expect(() => store.listContexts()).toThrow(/Malformed JSON in .*broken\.json/); + }); + }); +}); diff --git a/packages/appstash/src/config-store.ts b/packages/appstash/src/config-store.ts index e2b4dab..e0ceaad 100644 --- a/packages/appstash/src/config-store.ts +++ b/packages/appstash/src/config-store.ts @@ -19,10 +19,37 @@ export interface ContextCredentials { token: string; expiresAt?: string; refreshToken?: string; + /** Identity the token belongs to. */ + userId?: string; + email?: string; + /** Long-lived API key minted for this context, and its server-side id. */ + apiKey?: string; + keyId?: string; + apiKeyExpiresAt?: string; + /** Epoch millis of the sign-in that produced these credentials. */ + signedInAt?: number; } +/** + * At-rest transform for the secret-bearing fields of stored credentials + * (`token`, `refreshToken`, `apiKey`). Hosts with a keychain supply one — e.g. + * Electron `safeStorage` — so the same on-disk layout can be encrypted without + * the store knowing how. `decode` receives exactly what `encode` produced. + */ +export interface SecretCodec { + /** Recorded on disk, so a file written by a different codec is detected. */ + name: string; + encode(plaintext: string): string; + decode(encoded: string): string; +} + +const SECRET_FIELDS = ['token', 'refreshToken', 'apiKey'] as const; +const PLAINTEXT_CODEC = 'plaintext'; + export interface Credentials { tokens: Record; + /** Name of the codec that encoded the secret fields. */ + codec?: string; } export interface GlobalSettings { @@ -31,6 +58,16 @@ export interface GlobalSettings { export interface ConfigStoreOptions { baseDir?: string; + /** + * Directory identity to store under, when it differs from the tool name. + * Several CLIs that are one product — a generated SDK CLI, an agent CLI, a + * desktop app — pass the same `stashName`, so signing in through one signs + * you in everywhere, while `toolName` still drives env-var prefixes and the + * commands named in error messages. Defaults to `toolName`. + */ + stashName?: string; + /** At-rest transform for secret fields. Defaults to plaintext. */ + codec?: SecretCodec; } export interface ClientConfig { @@ -63,28 +100,57 @@ export interface ConfigStore { getClientConfig(targetName: string, contextName?: string): ClientConfig; } +/** + * Read stored JSON, or `fallback` when the file does not exist. A file that + * exists but does not parse is a real problem — a half-written credential file, + * a hand-edit gone wrong — so it throws with the path instead of quietly + * resetting the caller's configuration. + */ function readJson(filePath: string, fallback: T): T { - if (fs.existsSync(filePath)) { - try { - return JSON.parse(fs.readFileSync(filePath, 'utf8')); - } catch { + let raw: string; + try { + raw = fs.readFileSync(filePath, 'utf8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { return JSON.parse(JSON.stringify(fallback)); } + throw err; + } + try { + return JSON.parse(raw); + } catch (err) { + throw new Error(`Malformed JSON in ${filePath}: ${(err as Error).message}`); } - return JSON.parse(JSON.stringify(fallback)); } -function writeJson(filePath: string, data: unknown, mode?: number): void { +/** + * Write JSON atomically: a temp file in the destination directory, then + * `rename`. A crash or a concurrent reader mid-write can never observe a + * truncated file. `mode` is applied to the temp file before the rename so the + * contents are never briefly world-readable. + */ +function writeJson(filePath: string, data: unknown, mode = 0o600): void { const dir = path.dirname(filePath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); + fs.mkdirSync(dir, { recursive: true }); + const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.tmp`); + try { + fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode }); + fs.renameSync(tmp, filePath); + } catch (err) { + try { + fs.unlinkSync(tmp); + } catch { + // the temp file may not exist; the original error is what matters + } + throw err; } - const options: fs.WriteFileOptions = mode ? { mode } : {}; - fs.writeFileSync(filePath, JSON.stringify(data, null, 2), options); } export function createConfigStore(toolName: string, options?: ConfigStoreOptions): ConfigStore { - const dirs = appstash(toolName, { ensure: true, baseDir: options?.baseDir }); + const stashName = options?.stashName ?? toolName; + const codec = options?.codec; + const codecName = codec?.name ?? PLAINTEXT_CODEC; + const dirs = appstash(stashName, { ensure: true, baseDir: options?.baseDir }); function settingsPath(): string { return resolve(dirs, 'config', 'settings.json'); @@ -134,17 +200,11 @@ export function createConfigStore(toolName: string, options?: ConfigStoreOptions if (!fs.existsSync(contextsDir)) { return []; } - const files = fs.readdirSync(contextsDir).filter(f => f.endsWith('.json')); - const contexts: ContextConfig[] = []; - for (const file of files) { - try { - const content = fs.readFileSync(path.join(contextsDir, file), 'utf8'); - contexts.push(JSON.parse(content)); - } catch { - // skip invalid files - } - } - return contexts; + return fs + .readdirSync(contextsDir) + .filter(f => f.endsWith('.json')) + .map(f => readJson(path.join(contextsDir, f), null)) + .filter((ctx): ctx is ContextConfig => ctx !== null); } function deleteContext(name: string): boolean { @@ -180,23 +240,47 @@ export function createConfigStore(toolName: string, options?: ConfigStoreOptions return true; } + /** Map the secret fields of one entry through `transform`. */ + function mapSecrets( + creds: ContextCredentials, + transform: (value: string) => string + ): ContextCredentials { + const mapped: ContextCredentials = { ...creds }; + for (const field of SECRET_FIELDS) { + const value = mapped[field]; + if (typeof value === 'string') mapped[field] = transform(value); + } + return mapped; + } + + /** Stored credentials, still encoded. */ function loadCredentials(): Credentials { - return readJson(credentialsPath(), { tokens: {} }); + const credentials = readJson(credentialsPath(), { tokens: {} }); + const fileCodec = credentials.codec ?? PLAINTEXT_CODEC; + if (Object.keys(credentials.tokens).length > 0 && fileCodec !== codecName) { + throw new Error( + `Credentials in ${credentialsPath()} were written with the "${fileCodec}" codec, ` + + `but this store uses "${codecName}". Sign in again to rewrite them.` + ); + } + return credentials; } function saveCredentials(credentials: Credentials): void { - writeJson(credentialsPath(), credentials, 0o600); + writeJson(credentialsPath(), { ...credentials, codec: codecName }, 0o600); } function setCredentials(contextName: string, creds: ContextCredentials): void { const credentials = loadCredentials(); - credentials.tokens[contextName] = creds; + credentials.tokens[contextName] = codec ? mapSecrets(creds, codec.encode) : creds; saveCredentials(credentials); } function getCredentials(contextName: string): ContextCredentials | null { const credentials = loadCredentials(); - return credentials.tokens[contextName] || null; + const stored = credentials.tokens[contextName]; + if (!stored) return null; + return codec ? mapSecrets(stored, codec.decode) : stored; } function removeCredentials(contextName: string): boolean { diff --git a/packages/appstash/src/index.ts b/packages/appstash/src/index.ts index 4036c24..d3821ee 100644 --- a/packages/appstash/src/index.ts +++ b/packages/appstash/src/index.ts @@ -285,5 +285,6 @@ export type { ContextTargetEndpoint, Credentials, GlobalSettings, + SecretCodec, } from './config-store'; export { createConfigStore } from './config-store';