Skip to content

Commit 44e4a29

Browse files
authored
Merge pull request #105 from constructive-io/feat/shared-config-store
feat(appstash): shared stash identity, session fields, secret codec, atomic 0600 writes
2 parents 4a180cf + 38e1acf commit 44e4a29

4 files changed

Lines changed: 342 additions & 26 deletions

File tree

‎packages/appstash/README.md‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,50 @@ const dirs = appstash('myapp', {
251251
console.log(dirs.config); // /opt/myapp/.myapp/config
252252
```
253253

254+
## Config store
255+
256+
`createConfigStore(tool, options?)` layers a context + credential store on top of the
257+
directories above: named contexts (each with an endpoint and optional per-target
258+
endpoints), credentials per context, per-context vars, and `getClientConfig(target)`
259+
resolution (store → env vars → actionable error).
260+
261+
Every file it writes is atomic (temp file + `rename`) and mode `0600`. A stored file
262+
that exists but does not parse throws with its path — it is never silently replaced
263+
with defaults.
264+
265+
### One signed-in state across several tools
266+
267+
Pass `stashName` when multiple binaries are really one product. They then share
268+
contexts and credentials, while `tool` still drives env-var prefixes (`CSDK_TOKEN`)
269+
and the command names in error messages:
270+
271+
```typescript
272+
// A generated SDK CLI, an agent CLI and a desktop app, one login:
273+
createConfigStore('csdk', { stashName: 'constructive' });
274+
createConfigStore('agent', { stashName: 'constructive' });
275+
createConfigStore('desktop', { stashName: 'constructive' });
276+
```
277+
278+
### Encrypting secrets at rest
279+
280+
Supply a `SecretCodec` to transform the secret-bearing fields (`token`,
281+
`refreshToken`, `apiKey`) on the way to disk; everything else stays readable. The
282+
codec name is recorded in `credentials.json`, so a file written by a different codec
283+
is reported instead of decoded into garbage:
284+
285+
```typescript
286+
import { safeStorage } from 'electron';
287+
288+
createConfigStore('desktop', {
289+
stashName: 'constructive',
290+
codec: {
291+
name: 'electron-safeStorage',
292+
encode: (s) => safeStorage.encryptString(s).toString('base64'),
293+
decode: (s) => safeStorage.decryptString(Buffer.from(s, 'base64'))
294+
}
295+
});
296+
```
297+
254298
## Design Philosophy
255299

256300
- **Simple**: One function, clear structure
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import * as fs from 'fs';
2+
import * as os from 'os';
3+
import * as path from 'path';
4+
5+
import { createConfigStore, SecretCodec } from '../src';
6+
7+
describe('createConfigStore — shared identity, secrets and durability', () => {
8+
let tempBase: string;
9+
10+
beforeEach(() => {
11+
tempBase = fs.mkdtempSync(path.join(os.tmpdir(), 'appstash-shared-test-'));
12+
});
13+
14+
afterEach(() => {
15+
fs.rmSync(tempBase, { recursive: true, force: true });
16+
});
17+
18+
const rot13: SecretCodec = {
19+
name: 'rot13',
20+
encode: (s: string) => s.replace(/[a-z]/gi, (c: string) =>
21+
String.fromCharCode(((c.charCodeAt(0) - (c < 'a' ? 65 : 97) + 13) % 26) + (c < 'a' ? 65 : 97))
22+
),
23+
decode: (s: string) => rot13.encode(s)
24+
};
25+
26+
const credentialsFile = (stash: string) =>
27+
path.join(tempBase, `.${stash}`, 'config', 'credentials.json');
28+
29+
describe('stashName', () => {
30+
it('lets two differently-named tools share one signed-in state', () => {
31+
const csdk = createConfigStore('csdk', { baseDir: tempBase, stashName: 'constructive' });
32+
csdk.createContext('localnet', { endpoint: 'http://api.localhost:3000/graphql' });
33+
csdk.setCurrentContext('localnet');
34+
csdk.setCredentials('localnet', { token: 'tok', email: 'dan@example.com' });
35+
36+
const agent = createConfigStore('agent', { baseDir: tempBase, stashName: 'constructive' });
37+
38+
expect(agent.getCurrentContext()?.name).toBe('localnet');
39+
expect(agent.getCredentials('localnet')).toMatchObject({ token: 'tok', email: 'dan@example.com' });
40+
expect(fs.existsSync(credentialsFile('constructive'))).toBe(true);
41+
});
42+
43+
it('keeps tools isolated when no stashName is given', () => {
44+
const a = createConfigStore('toola', { baseDir: tempBase });
45+
a.createContext('dev', { endpoint: 'http://a' });
46+
a.setCurrentContext('dev');
47+
48+
const b = createConfigStore('toolb', { baseDir: tempBase });
49+
expect(b.getCurrentContext()).toBeNull();
50+
});
51+
52+
it('still uses toolName for env-var prefixes and error text', () => {
53+
const store = createConfigStore('csdk', { baseDir: tempBase, stashName: 'constructive' });
54+
process.env.CSDK_API_ENDPOINT = 'http://from-env/graphql';
55+
try {
56+
expect(store.getClientConfig('api').endpoint).toBe('http://from-env/graphql');
57+
} finally {
58+
delete process.env.CSDK_API_ENDPOINT;
59+
}
60+
61+
const bare = createConfigStore('csdk', { baseDir: tempBase, stashName: 'constructive' });
62+
expect(() => bare.getClientConfig('api')).toThrow(/csdk context create/);
63+
});
64+
});
65+
66+
describe('session identity fields', () => {
67+
it('round-trips the identity carried alongside the token', () => {
68+
const store = createConfigStore('testapp', { baseDir: tempBase });
69+
const signedInAt = Date.now();
70+
store.setCredentials('prod', {
71+
token: 'access',
72+
refreshToken: 'refresh',
73+
userId: 'user-1',
74+
email: 'dan@example.com',
75+
apiKey: 'cnc_live_sk_abc',
76+
keyId: 'key-1',
77+
apiKeyExpiresAt: '2027-01-01T00:00:00.000Z',
78+
signedInAt
79+
});
80+
81+
expect(store.getCredentials('prod')).toEqual({
82+
token: 'access',
83+
refreshToken: 'refresh',
84+
userId: 'user-1',
85+
email: 'dan@example.com',
86+
apiKey: 'cnc_live_sk_abc',
87+
keyId: 'key-1',
88+
apiKeyExpiresAt: '2027-01-01T00:00:00.000Z',
89+
signedInAt
90+
});
91+
});
92+
});
93+
94+
describe('secret codec', () => {
95+
it('encodes secret fields at rest and decodes them on read', () => {
96+
const store = createConfigStore('testapp', { baseDir: tempBase, codec: rot13 });
97+
store.setCredentials('prod', {
98+
token: 'secret',
99+
refreshToken: 'refresh',
100+
apiKey: 'apikey',
101+
email: 'dan@example.com'
102+
});
103+
104+
const onDisk = JSON.parse(fs.readFileSync(credentialsFile('testapp'), 'utf8'));
105+
expect(onDisk.codec).toBe('rot13');
106+
expect(onDisk.tokens.prod.token).toBe(rot13.encode('secret'));
107+
expect(onDisk.tokens.prod.apiKey).toBe(rot13.encode('apikey'));
108+
// Non-secret fields stay readable.
109+
expect(onDisk.tokens.prod.email).toBe('dan@example.com');
110+
111+
expect(store.getCredentials('prod')).toMatchObject({
112+
token: 'secret',
113+
refreshToken: 'refresh',
114+
apiKey: 'apikey'
115+
});
116+
});
117+
118+
it('records plaintext when no codec is configured', () => {
119+
const store = createConfigStore('testapp', { baseDir: tempBase });
120+
store.setCredentials('prod', { token: 'secret' });
121+
122+
const onDisk = JSON.parse(fs.readFileSync(credentialsFile('testapp'), 'utf8'));
123+
expect(onDisk.codec).toBe('plaintext');
124+
expect(onDisk.tokens.prod.token).toBe('secret');
125+
});
126+
127+
it('refuses to read credentials written by a different codec', () => {
128+
const plain = createConfigStore('testapp', { baseDir: tempBase });
129+
plain.setCredentials('prod', { token: 'secret' });
130+
131+
const encrypted = createConfigStore('testapp', { baseDir: tempBase, codec: rot13 });
132+
expect(() => encrypted.getCredentials('prod')).toThrow(/"plaintext" codec.*uses "rot13"/s);
133+
});
134+
135+
it('accepts an empty store regardless of codec', () => {
136+
const encrypted = createConfigStore('testapp', { baseDir: tempBase, codec: rot13 });
137+
expect(encrypted.getCredentials('prod')).toBeNull();
138+
expect(encrypted.hasValidCredentials('prod')).toBe(false);
139+
});
140+
});
141+
142+
describe('durability and permissions', () => {
143+
it('writes credentials 0600 and leaves no temp files behind', () => {
144+
const store = createConfigStore('testapp', { baseDir: tempBase });
145+
store.setCredentials('prod', { token: 'secret' });
146+
147+
const file = credentialsFile('testapp');
148+
expect(fs.statSync(file).mode & 0o777).toBe(0o600);
149+
const leftovers = fs.readdirSync(path.dirname(file)).filter((f) => f.endsWith('.tmp'));
150+
expect(leftovers).toEqual([]);
151+
});
152+
153+
it('writes context and settings files 0600 too', () => {
154+
const store = createConfigStore('testapp', { baseDir: tempBase });
155+
store.createContext('prod', { endpoint: 'http://api' });
156+
store.setCurrentContext('prod');
157+
store.setVar('DATABASE_ID', 'db-1', 'prod');
158+
159+
const configDir = path.join(tempBase, '.testapp', 'config');
160+
for (const file of [
161+
path.join(configDir, 'settings.json'),
162+
path.join(configDir, 'contexts', 'prod.json'),
163+
path.join(configDir, 'vars', 'prod.json')
164+
]) {
165+
expect(fs.statSync(file).mode & 0o777).toBe(0o600);
166+
}
167+
});
168+
169+
it('throws with the path when a stored file is malformed', () => {
170+
const store = createConfigStore('testapp', { baseDir: tempBase });
171+
const file = credentialsFile('testapp');
172+
fs.mkdirSync(path.dirname(file), { recursive: true });
173+
fs.writeFileSync(file, '{ this is not json');
174+
175+
expect(() => store.getCredentials('prod')).toThrow(/Malformed JSON in .*credentials\.json/);
176+
});
177+
178+
it('does not silently drop a malformed context when listing', () => {
179+
const store = createConfigStore('testapp', { baseDir: tempBase });
180+
store.createContext('good', { endpoint: 'http://api' });
181+
const contextsDir = path.join(tempBase, '.testapp', 'config', 'contexts');
182+
fs.writeFileSync(path.join(contextsDir, 'broken.json'), 'nope');
183+
184+
expect(() => store.listContexts()).toThrow(/Malformed JSON in .*broken\.json/);
185+
});
186+
});
187+
});

0 commit comments

Comments
 (0)