synomem 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +47 -68
  3. package/dist/backend.d.ts +18 -6
  4. package/dist/backend.d.ts.map +1 -1
  5. package/dist/backend.js +55 -41
  6. package/dist/backend.js.map +1 -1
  7. package/dist/cli.d.ts +20 -33
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +1391 -1321
  10. package/dist/cli.js.map +1 -1
  11. package/dist/configure.d.ts +12 -46
  12. package/dist/configure.d.ts.map +1 -1
  13. package/dist/configure.js +51 -192
  14. package/dist/configure.js.map +1 -1
  15. package/dist/credentials.d.ts +82 -33
  16. package/dist/credentials.d.ts.map +1 -1
  17. package/dist/credentials.js +270 -44
  18. package/dist/credentials.js.map +1 -1
  19. package/dist/discover.d.ts +8 -35
  20. package/dist/discover.d.ts.map +1 -1
  21. package/dist/discover.js +42 -38
  22. package/dist/discover.js.map +1 -1
  23. package/dist/errors.d.ts +1 -1
  24. package/dist/errors.d.ts.map +1 -1
  25. package/dist/errors.js +4 -0
  26. package/dist/errors.js.map +1 -1
  27. package/dist/import.d.ts +3 -0
  28. package/dist/import.d.ts.map +1 -1
  29. package/dist/import.js +3 -0
  30. package/dist/import.js.map +1 -1
  31. package/dist/index.d.ts +10 -8
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +6 -5
  34. package/dist/index.js.map +1 -1
  35. package/dist/mcp/index.d.ts +18 -18
  36. package/dist/mcp/index.d.ts.map +1 -1
  37. package/dist/mcp/index.js +402 -231
  38. package/dist/mcp/index.js.map +1 -1
  39. package/dist/mcp-server.d.ts +5 -1
  40. package/dist/mcp-server.d.ts.map +1 -1
  41. package/dist/mcp-server.js +27 -105
  42. package/dist/mcp-server.js.map +1 -1
  43. package/dist/oauth.d.ts +32 -33
  44. package/dist/oauth.d.ts.map +1 -1
  45. package/dist/oauth.js +196 -126
  46. package/dist/oauth.js.map +1 -1
  47. package/dist/profiles.d.ts +243 -0
  48. package/dist/profiles.d.ts.map +1 -0
  49. package/dist/profiles.js +479 -0
  50. package/dist/profiles.js.map +1 -0
  51. package/dist/project.d.ts +8 -39
  52. package/dist/project.d.ts.map +1 -1
  53. package/dist/project.js +36 -94
  54. package/dist/project.js.map +1 -1
  55. package/dist/remote.d.ts +24 -17
  56. package/dist/remote.d.ts.map +1 -1
  57. package/dist/remote.js +54 -52
  58. package/dist/remote.js.map +1 -1
  59. package/dist/resolvers.d.ts +54 -0
  60. package/dist/resolvers.d.ts.map +1 -0
  61. package/dist/resolvers.js +336 -0
  62. package/dist/resolvers.js.map +1 -0
  63. package/dist/service.d.ts +3 -2
  64. package/dist/service.d.ts.map +1 -1
  65. package/dist/skill-install.d.ts +4 -6
  66. package/dist/skill-install.d.ts.map +1 -1
  67. package/dist/skill-install.js +13 -12
  68. package/dist/skill-install.js.map +1 -1
  69. package/dist/types.d.ts +45 -15
  70. package/dist/types.d.ts.map +1 -1
  71. package/docs/cli.md +173 -196
  72. package/docs/mcp.md +69 -65
  73. package/package.json +1 -1
  74. package/skills/synomem/SKILL.md +29 -26
  75. package/skills/synomem/references/examples.md +13 -11
  76. package/src/backend.ts +66 -64
  77. package/src/cli.ts +2123 -2219
  78. package/src/configure.ts +62 -241
  79. package/src/credentials.ts +316 -85
  80. package/src/discover.ts +53 -59
  81. package/src/errors.ts +4 -0
  82. package/src/import.ts +5 -0
  83. package/src/index.ts +15 -19
  84. package/src/mcp/index.ts +473 -277
  85. package/src/mcp-server.ts +32 -114
  86. package/src/oauth.ts +249 -131
  87. package/src/profiles.ts +661 -0
  88. package/src/project.ts +42 -108
  89. package/src/remote.ts +69 -63
  90. package/src/resolvers.ts +385 -0
  91. package/src/service.ts +2 -7
  92. package/src/skill-install.ts +17 -18
  93. package/src/types.ts +40 -15
@@ -1,42 +1,80 @@
1
- import { createHash } from 'node:crypto';
1
+ /**
2
+ * Credential storage, indexed by connection — never by actor or workspace.
3
+ *
4
+ * One stored secret serves every profile that routes through its connection:
5
+ * adding a profile for another agent or workspace never copies a refresh
6
+ * token, and rotating a credential updates exactly one entry. Which actor and
7
+ * workspace an operation uses is the profile's business (see `profiles.ts`),
8
+ * and the server authorizes it on every request regardless of what is stored
9
+ * here.
10
+ *
11
+ * Two credential kinds, deliberately distinct so nothing tries to refresh a
12
+ * key or treat an OAuth family as a static secret:
13
+ *
14
+ * - `oauth`: an authorization-code family for one connection, refreshed
15
+ * under a cross-process lock with a generation counter;
16
+ * - `access-key`: a member-owned `syn_…` key, used exactly as stored.
17
+ *
18
+ * Backends: the macOS Keychain, Linux Secret Service (`secret-tool`), an
19
+ * explicit restricted file, or the process environment. There is no silent
20
+ * fallback from one to another: a keychain that refuses a write is an error
21
+ * that names the explicit alternative.
22
+ */
2
23
  import { spawn } from 'node:child_process';
24
+ import { randomBytes, randomUUID } from 'node:crypto';
25
+ import {
26
+ chmodSync,
27
+ closeSync,
28
+ existsSync,
29
+ linkSync,
30
+ mkdirSync,
31
+ openSync,
32
+ readFileSync,
33
+ renameSync,
34
+ rmSync,
35
+ statSync,
36
+ writeSync,
37
+ } from 'node:fs';
38
+ import { hostname } from 'node:os';
39
+ import { join } from 'node:path';
40
+ import { z } from 'zod';
3
41
  import { SynomemError } from './errors.js';
4
- import type { ActorIdentity } from './types.js';
42
+ import { atomicWriteFile } from './fs-utils.js';
5
43
 
6
44
  const serviceName = 'ai.synomem.credentials';
7
45
  const maximumOutputBytes = 128 * 1024;
8
46
 
9
- /**
10
- * A member-owned access key.
11
- *
12
- * Not an OAuth credential: it has no refresh, no token endpoint and no
13
- * client, and it authorizes the MEMBER who created it, reaching every
14
- * workspace their organization membership allows -- never a single machine
15
- * or a single workspace. Keeping it a distinct shape stops code treating it
16
- * as refreshable, which would mean silently failing to renew something that
17
- * never expires that way.
18
- *
19
- * The `kind` value and this type's own name predate member-owned access
20
- * keys; both still say "installation" because renaming either changes the
21
- * shape already written to disk and the OS keychain on every machine that
22
- * has run `synomem config`, for no behavioral gain.
23
- */
24
- export interface StoredInstallationKey {
25
- kind: 'installation-key';
26
- accessToken: string;
27
- }
47
+ export const storedOAuthCredentialSchema = z.object({
48
+ kind: z.literal('oauth'),
49
+ issuer: z.string().min(1),
50
+ resource: z.string().min(1),
51
+ clientId: z.string().min(1),
52
+ tokenEndpoint: z.string().min(1),
53
+ scope: z.string(),
54
+ accessToken: z.string().min(1),
55
+ refreshToken: z.string().min(1).optional(),
56
+ /** Epoch milliseconds, already reduced by a safety margin. */
57
+ expiresAt: z.number().int().nonnegative(),
58
+ /** Incremented on every successful refresh; the compare-and-swap token. */
59
+ generation: z.number().int().nonnegative(),
60
+ });
28
61
 
29
- export type StoredCredential = StoredOAuthCredential | StoredInstallationKey;
62
+ export const storedAccessKeySchema = z.object({
63
+ kind: z.literal('access-key'),
64
+ secret: z.string().min(1),
65
+ });
30
66
 
31
- export interface StoredOAuthCredential {
32
- accessToken: string;
33
- refreshToken?: string;
34
- expiresAt?: number;
35
- tokenEndpoint: string;
36
- clientId: string;
37
- resource: string;
38
- scope: string;
39
- }
67
+ export const storedCredentialSchema = z.discriminatedUnion('kind', [
68
+ storedOAuthCredentialSchema,
69
+ storedAccessKeySchema,
70
+ ]);
71
+
72
+ export type StoredOAuthCredential = z.infer<typeof storedOAuthCredentialSchema>;
73
+ export type StoredAccessKey = z.infer<typeof storedAccessKeySchema>;
74
+ export type StoredCredential = z.infer<typeof storedCredentialSchema>;
75
+
76
+ /** Where a connection's secret lives. `environment` stores nothing. */
77
+ export type CredentialBackendKind = 'keychain' | 'file' | 'environment';
40
78
 
41
79
  export interface CredentialStore {
42
80
  get(reference: string): Promise<StoredCredential | undefined>;
@@ -44,20 +82,26 @@ export interface CredentialStore {
44
82
  delete(reference: string): Promise<boolean>;
45
83
  }
46
84
 
47
- export function credentialReference(
48
- baseUrl: string,
49
- workspaceId: string,
50
- actor: ActorIdentity,
51
- ): string {
52
- return createHash('sha256')
53
- .update(
54
- JSON.stringify({
55
- baseUrl: new URL(baseUrl).origin,
56
- workspaceId,
57
- actor: { kind: actor.kind, id: actor.id },
58
- }),
59
- )
60
- .digest('base64url');
85
+ /** An opaque, non-secret handle for a stored secret. Never derived from names. */
86
+ export function newSecretReference(): string {
87
+ return `synomem-${randomUUID()}`;
88
+ }
89
+
90
+ export function parseStoredCredential(value: string): StoredCredential {
91
+ let raw: unknown;
92
+ try {
93
+ raw = JSON.parse(value);
94
+ } catch {
95
+ throw new SynomemError('AUTH_REQUIRED', 'Stored Synomem credential is malformed.');
96
+ }
97
+ const parsed = storedCredentialSchema.safeParse(raw);
98
+ if (!parsed.success) {
99
+ throw new SynomemError(
100
+ 'AUTH_REQUIRED',
101
+ 'Stored Synomem credential is in an unsupported format. Run `synomem connection login` (or `connection add-key`) again.',
102
+ );
103
+ }
104
+ return parsed.data;
61
105
  }
62
106
 
63
107
  interface CommandResult {
@@ -105,34 +149,28 @@ async function command(
105
149
  });
106
150
  }
107
151
 
108
- function parseCredential(value: string): StoredOAuthCredential {
109
- try {
110
- const parsed = JSON.parse(value) as Partial<StoredOAuthCredential>;
111
- if (
112
- typeof parsed.accessToken !== 'string' ||
113
- typeof parsed.tokenEndpoint !== 'string' ||
114
- typeof parsed.clientId !== 'string' ||
115
- typeof parsed.resource !== 'string' ||
116
- typeof parsed.scope !== 'string'
117
- ) {
118
- throw new Error();
119
- }
120
- return parsed as StoredOAuthCredential;
121
- } catch {
122
- throw new SynomemError('AUTH_REQUIRED', 'Stored Synomem credentials are malformed.');
123
- }
124
- }
125
-
152
+ /** macOS Keychain or Linux Secret Service. */
126
153
  export class OsCredentialStore implements CredentialStore {
127
154
  private readonly platform: NodeJS.Platform;
128
155
  private readonly run: CredentialCommandRunner;
129
156
 
130
157
  constructor(options: { platform?: NodeJS.Platform; run?: CredentialCommandRunner } = {}) {
131
158
  this.platform = options.platform ?? process.platform;
132
- this.run = options.run ?? command;
159
+ const run = options.run ?? command;
160
+ // A missing helper (a headless Linux box with no Secret Service) is a
161
+ // configuration answer, not a crash: name the explicit alternatives. There
162
+ // is still no fallback — the caller chooses --store file deliberately.
163
+ this.run = async (executable, arguments_, input) => {
164
+ try {
165
+ return await run(executable, arguments_, input);
166
+ } catch (error) {
167
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') this.unsupported();
168
+ throw error;
169
+ }
170
+ };
133
171
  }
134
172
 
135
- async get(reference: string): Promise<StoredOAuthCredential | undefined> {
173
+ async get(reference: string): Promise<StoredCredential | undefined> {
136
174
  const result =
137
175
  this.platform === 'darwin'
138
176
  ? await this.run('/usr/bin/security', [
@@ -147,11 +185,11 @@ export class OsCredentialStore implements CredentialStore {
147
185
  ? await this.run('secret-tool', ['lookup', 'service', serviceName, 'account', reference])
148
186
  : this.unsupported();
149
187
  if (result.code !== 0) return undefined;
150
- return parseCredential(result.stdout.trim());
188
+ return parseStoredCredential(result.stdout.trim());
151
189
  }
152
190
 
153
- async set(reference: string, credential: StoredOAuthCredential): Promise<void> {
154
- const serialized = JSON.stringify(credential);
191
+ async set(reference: string, credential: StoredCredential): Promise<void> {
192
+ const serialized = JSON.stringify(storedCredentialSchema.parse(credential));
155
193
  const result =
156
194
  this.platform === 'darwin'
157
195
  ? await this.run(
@@ -163,7 +201,7 @@ export class OsCredentialStore implements CredentialStore {
163
201
  '-s',
164
202
  serviceName,
165
203
  '-l',
166
- 'Synomem OAuth credential',
204
+ 'Synomem credential',
167
205
  '-U',
168
206
  '-w',
169
207
  ],
@@ -172,21 +210,14 @@ export class OsCredentialStore implements CredentialStore {
172
210
  : this.platform === 'linux'
173
211
  ? await this.run(
174
212
  'secret-tool',
175
- [
176
- 'store',
177
- '--label=Synomem OAuth credential',
178
- 'service',
179
- serviceName,
180
- 'account',
181
- reference,
182
- ],
213
+ ['store', '--label=Synomem credential', 'service', serviceName, 'account', reference],
183
214
  serialized,
184
215
  )
185
216
  : this.unsupported();
186
217
  if (result.code !== 0) {
187
218
  throw new SynomemError(
188
- 'AUTH_REQUIRED',
189
- 'The operating-system credential store rejected the credential.',
219
+ 'CONFIG_INVALID',
220
+ 'The operating-system credential store rejected the credential. Re-run with --store file to use a restricted file instead.',
190
221
  );
191
222
  }
192
223
  }
@@ -208,17 +239,217 @@ export class OsCredentialStore implements CredentialStore {
208
239
  }
209
240
 
210
241
  /*
211
- * Windows Credential Manager is not implemented yet. `cmdkey` can write a
212
- * generic credential but deliberately will not read the secret back, so a
213
- * store built on it would accept a credential and then never return it --
214
- * worse than saying so plainly.
242
+ * Windows Credential Manager is not implemented: `cmdkey` can write a generic
243
+ * credential but will not read the secret back, so a store built on it would
244
+ * accept a credential and never return it.
215
245
  */
216
246
  private unsupported(): never {
217
247
  throw new SynomemError(
218
248
  'CONFIG_INVALID',
219
249
  this.platform === 'win32'
220
- ? 'Windows Credential Manager storage is not supported yet. Run `synomem config init` and choose the restricted-file store, or set SYNOMEM_ACCESS_TOKEN.'
221
- : 'Operating-system credential storage needs the macOS Keychain, or secret-tool on Linux. Run `synomem config init` and choose the restricted-file store, or set SYNOMEM_ACCESS_TOKEN.',
250
+ ? 'Windows Credential Manager storage is not supported yet. Use --store file, or --store environment with SYNOMEM_ACCESS_TOKEN.'
251
+ : 'No operating-system credential store is available here (it needs the macOS Keychain, or secret-tool with a Secret Service on Linux). Choose one explicitly: --store file keeps it in a 0600 file under your Synomem home; --store environment reads SYNOMEM_ACCESS_TOKEN.',
252
+ );
253
+ }
254
+ }
255
+
256
+ /**
257
+ * One restricted file per secret under `<home>/credentials/`, mode 0600 in a
258
+ * 0700 directory. Chosen explicitly (`--store file`), never as a fallback.
259
+ */
260
+ export class FileCredentialStore implements CredentialStore {
261
+ private readonly directory: string;
262
+
263
+ constructor(home: string) {
264
+ this.directory = join(home, 'credentials');
265
+ }
266
+
267
+ private path(reference: string): string {
268
+ if (!/^[A-Za-z0-9_-]{1,100}$/.test(reference)) {
269
+ throw new SynomemError('CONFIG_INVALID', 'Credential reference is malformed.');
270
+ }
271
+ return join(this.directory, `${reference}.json`);
272
+ }
273
+
274
+ async get(reference: string): Promise<StoredCredential | undefined> {
275
+ const path = this.path(reference);
276
+ if (!existsSync(path)) return undefined;
277
+ assertPrivate(this.directory, 0o700);
278
+ assertPrivate(path, 0o600);
279
+ return parseStoredCredential(readFileSync(path, 'utf8'));
280
+ }
281
+
282
+ async set(reference: string, credential: StoredCredential): Promise<void> {
283
+ if (!existsSync(this.directory)) mkdirSync(this.directory, { recursive: true, mode: 0o700 });
284
+ chmodSync(this.directory, 0o700);
285
+ atomicWriteFile(
286
+ this.path(reference),
287
+ `${JSON.stringify(storedCredentialSchema.parse(credential))}\n`,
288
+ 0o600,
289
+ );
290
+ }
291
+
292
+ async delete(reference: string): Promise<boolean> {
293
+ const path = this.path(reference);
294
+ if (!existsSync(path)) return false;
295
+ rmSync(path, { force: true });
296
+ return true;
297
+ }
298
+ }
299
+
300
+ /*
301
+ * Like ssh with a private key: a credential file (or its directory) that other
302
+ * users can read, or that another user owns, is refused rather than used. The
303
+ * fix is the owner's to make; silently tightening it would hide that the
304
+ * secret may already have been exposed.
305
+ */
306
+ function assertPrivate(path: string, expected: number): void {
307
+ if (process.platform === 'win32') return;
308
+ const stat = statSync(path);
309
+ if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
310
+ throw new SynomemError(
311
+ 'CONFIG_INVALID',
312
+ `${path} is owned by another user; refusing to use it.`,
313
+ );
314
+ }
315
+ if ((stat.mode & 0o077) !== 0) {
316
+ throw new SynomemError(
317
+ 'CONFIG_INVALID',
318
+ `${path} is accessible to other users (mode ${(stat.mode & 0o777).toString(8)}). ` +
319
+ `Run \`chmod ${expected.toString(8)} ${path}\` if you are sure it was not exposed, ` +
320
+ 'otherwise remove it and sign in again.',
222
321
  );
223
322
  }
224
323
  }
324
+
325
+ export interface CredentialStores {
326
+ keychain: CredentialStore;
327
+ file: CredentialStore;
328
+ }
329
+
330
+ export function defaultCredentialStores(home: string): CredentialStores {
331
+ return { keychain: new OsCredentialStore(), file: new FileCredentialStore(home) };
332
+ }
333
+
334
+ /**
335
+ * A cross-process lock around one credential's refresh.
336
+ *
337
+ * `O_EXCL` creation is the mutual exclusion. The lock file records its owner
338
+ * (pid, host) and a random token. A lock is broken only when its owner is
339
+ * provably gone — a dead pid on this host — or, for an owner on another host
340
+ * or an unreadable file, once it is older than `staleMs`. A live owner is never
341
+ * preempted however long its refresh takes (the refresh request itself times
342
+ * out well inside `staleMs`).
343
+ *
344
+ * Breaking renames the file aside first and checks the token: if another
345
+ * process already broke and re-acquired it in between, the fresh lock is put
346
+ * back rather than deleted. Correctness against a concurrent writer is still
347
+ * the generation compare-and-swap in the refresh path; the lock exists so that
348
+ * only one process ever spends a given refresh token. Locks assume one host's
349
+ * filesystem; a home shared over a network filesystem is not supported.
350
+ */
351
+ export async function withCredentialLock<T>(
352
+ home: string,
353
+ name: string,
354
+ operation: () => Promise<T>,
355
+ options: { staleMs?: number; timeoutMs?: number; pollMs?: number } = {},
356
+ ): Promise<T> {
357
+ if (!/^[A-Za-z0-9_.-]{1,100}$/.test(name)) {
358
+ throw new SynomemError('CONFIG_INVALID', 'Credential name is malformed.');
359
+ }
360
+ const directory = join(home, 'locks');
361
+ if (!existsSync(directory)) mkdirSync(directory, { recursive: true, mode: 0o700 });
362
+ const path = join(directory, `${name}.lock`);
363
+ const staleMs = options.staleMs ?? 60_000;
364
+ const deadline = Date.now() + (options.timeoutMs ?? 30_000);
365
+ const pollMs = options.pollMs ?? 50;
366
+ const token = randomBytes(16).toString('hex');
367
+ const host = hostname();
368
+ let descriptor: number | undefined;
369
+ while (descriptor === undefined) {
370
+ try {
371
+ descriptor = openSync(path, 'wx', 0o600);
372
+ writeSync(descriptor, `${process.pid}\n${host}\n${token}\n`);
373
+ } catch (error) {
374
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
375
+ if (breakIfAbandoned(path, host, staleMs)) continue;
376
+ if (Date.now() > deadline) {
377
+ throw new SynomemError(
378
+ 'DATABASE_BUSY',
379
+ `Another Synomem process is refreshing the ${name} credential. Try again.`,
380
+ );
381
+ }
382
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
383
+ }
384
+ }
385
+ try {
386
+ return await operation();
387
+ } finally {
388
+ closeSync(descriptor);
389
+ // Release only our own lock.
390
+ if (readLock(path)?.token === token) rmSync(path, { force: true });
391
+ }
392
+ }
393
+
394
+ interface LockOwner {
395
+ pid: number;
396
+ host: string;
397
+ token: string;
398
+ }
399
+
400
+ function readLock(path: string): LockOwner | undefined {
401
+ try {
402
+ const [pid, host, token] = readFileSync(path, 'utf8').split('\n');
403
+ const parsed = Number(pid);
404
+ if (!Number.isSafeInteger(parsed) || !host || !token) return undefined;
405
+ return { pid: parsed, host, token };
406
+ } catch {
407
+ return undefined;
408
+ }
409
+ }
410
+
411
+ function processAlive(pid: number): boolean {
412
+ try {
413
+ process.kill(pid, 0);
414
+ return true;
415
+ } catch (error) {
416
+ // EPERM: it exists but belongs to someone else — alive.
417
+ return (error as NodeJS.ErrnoException).code === 'EPERM';
418
+ }
419
+ }
420
+
421
+ /** Returns true when the caller should immediately retry acquiring. */
422
+ function breakIfAbandoned(path: string, host: string, staleMs: number): boolean {
423
+ let age: number;
424
+ try {
425
+ age = Date.now() - statSync(path).mtimeMs;
426
+ } catch {
427
+ return true; // Released between the failed open and now.
428
+ }
429
+ const owner = readLock(path);
430
+ // A lock whose contents are still being written is fresh, not abandoned.
431
+ if (!owner && age < 1_000) return false;
432
+ const abandoned = owner
433
+ ? owner.host === host
434
+ ? !processAlive(owner.pid)
435
+ : age > staleMs
436
+ : age > staleMs;
437
+ if (!abandoned) return false;
438
+ const aside = `${path}.broken-${randomBytes(6).toString('hex')}`;
439
+ try {
440
+ renameSync(path, aside);
441
+ } catch {
442
+ return true; // Someone else broke or released it first.
443
+ }
444
+ const taken = readLock(aside);
445
+ if (owner && taken?.token !== owner.token) {
446
+ // We grabbed a lock re-acquired after our inspection: restore it.
447
+ try {
448
+ linkSync(aside, path);
449
+ } catch {
450
+ /* a third process holds the path now; ours is the one to discard */
451
+ }
452
+ }
453
+ rmSync(aside, { force: true });
454
+ return true;
455
+ }
package/src/discover.ts CHANGED
@@ -1,24 +1,18 @@
1
1
  /*
2
- * Finding out which workspaces a credential can reach, so nobody has to type
3
- * one from memory.
2
+ * Finding out what a credential can reach, so nobody has to type an id from memory.
4
3
  *
5
- * A hosted workspace ID looks like `ws-04psqx2rkt8ttft7a1t2z69r97`. Asking a
6
- * person to enter that during setup is asking them to leave and go find it, and
7
- * the credential they just authorized already knows the answer -- or knows
8
- * enough to offer a short list.
4
+ * Two questions, two routes, because they carry different authority:
9
5
  *
10
- * Two credentials, two routes, because they carry different authority:
6
+ * `discoverContexts` / `describeIdentity` ask the DATA plane, with any credential
7
+ * (OAuth access token or `syn_` access key), which workspace/actor targets that
8
+ * credential's grant may use right now (identity contract §4). No context has to be
9
+ * chosen first — that is the point.
11
10
  *
12
- * A member-owned access key authenticates the ACCOUNT that created it, and
13
- * reaches every workspace in that account's organization -- never just one
14
- * -- so `discoverAccessKeyWorkspaces` lists them off the data plane, with no
15
- * workspace chosen yet, which is exactly what setup cannot supply up front.
16
- *
17
- * A browser sign-in also authorizes an ACCOUNT, which may reach several
18
- * organizations, each with several workspaces. `discoverOrganizations` lists
19
- * them for selection.
11
+ * `discoverOrganizations` asks the control plane which organizations an account
12
+ * belongs to, for first-party setup flows that hold an account session.
20
13
  */
21
14
  import { SynomemError } from './errors.js';
15
+ import type { ContextListing, IdentityDescription } from './types.js';
22
16
 
23
17
  export interface DiscoveredWorkspace {
24
18
  id: string;
@@ -33,19 +27,6 @@ export interface DiscoveredOrganization {
33
27
  workspaces: DiscoveredWorkspace[];
34
28
  }
35
29
 
36
- export interface WorkspaceMembership {
37
- id: string;
38
- displayName: string;
39
- roles: string[];
40
- /**
41
- * Reachable right now, with this same credential, by naming it as
42
- * Synomem-Workspace-Id — no new token or re-authentication. A human
43
- * credential's own organization is the boundary; an agent credential has
44
- * only ever one such entry, its own.
45
- */
46
- addressableWithThisToken: boolean;
47
- }
48
-
49
30
  export interface DiscoveryOptions {
50
31
  baseUrl: string;
51
32
  accessToken: string;
@@ -60,7 +41,11 @@ interface Envelope<T> {
60
41
  error?: { code?: string; message?: string };
61
42
  }
62
43
 
63
- async function readJson<T>(options: DiscoveryOptions, path: string): Promise<T> {
44
+ async function readJson<T>(
45
+ options: DiscoveryOptions,
46
+ path: string,
47
+ extraHeaders?: Record<string, string>,
48
+ ): Promise<T> {
64
49
  const request = options.fetch ?? globalThis.fetch;
65
50
  const url = new URL(
66
51
  path,
@@ -69,7 +54,11 @@ async function readJson<T>(options: DiscoveryOptions, path: string): Promise<T>
69
54
  let response: Response;
70
55
  try {
71
56
  response = await request(url, {
72
- headers: { authorization: `Bearer ${options.accessToken}`, accept: 'application/json' },
57
+ headers: {
58
+ authorization: `Bearer ${options.accessToken}`,
59
+ accept: 'application/json',
60
+ ...extraHeaders,
61
+ },
73
62
  ...(options.signal ? { signal: options.signal } : {}),
74
63
  });
75
64
  } catch (error) {
@@ -87,32 +76,24 @@ async function readJson<T>(options: DiscoveryOptions, path: string): Promise<T>
87
76
  if (!response.ok || body.ok !== true || body.data === undefined) {
88
77
  // 401 and 403 are the ones a person can act on, so they keep their own
89
78
  // codes rather than being flattened into a generic protocol error.
79
+ const reported = body.error?.code;
90
80
  const code =
91
- response.status === 401 ? 'AUTH_REQUIRED' : response.status === 403 ? 'AUTH_FORBIDDEN' : null;
81
+ reported === 'CONTEXT_REQUIRED' ||
82
+ reported === 'CONTEXT_FORBIDDEN' ||
83
+ reported === 'CONTEXT_AMBIGUOUS' ||
84
+ reported === 'REAUTHORIZATION_REQUIRED'
85
+ ? reported
86
+ : response.status === 401
87
+ ? 'AUTH_REQUIRED'
88
+ : response.status === 403
89
+ ? 'AUTH_FORBIDDEN'
90
+ : null;
92
91
  const message = body.error?.message ?? `${url.pathname} returned ${response.status}.`;
93
92
  throw new SynomemError(code ?? 'REMOTE_PROTOCOL', message);
94
93
  }
95
94
  return body.data;
96
95
  }
97
96
 
98
- /**
99
- * Every workspace a member-owned access key can reach, asked before any one
100
- * of them has been chosen.
101
- *
102
- * Answered by the data plane rather than the control plane: an access key is
103
- * not an account principal the control plane recognizes, but the data plane
104
- * already knows which organization owns it and can list that organization's
105
- * workspaces without requiring one to be named first.
106
- */
107
- export async function discoverAccessKeyWorkspaces(
108
- options: DiscoveryOptions,
109
- ): Promise<{ organizationId: string; workspaces: DiscoveredWorkspace[] }> {
110
- return await readJson<{ organizationId: string; workspaces: DiscoveredWorkspace[] }>(
111
- options,
112
- 'v1/access-keys/workspaces',
113
- );
114
- }
115
-
116
97
  /**
117
98
  * Every organization this account belongs to, each with its workspaces.
118
99
  *
@@ -147,19 +128,32 @@ export async function discoverOrganizations(
147
128
  }
148
129
 
149
130
  /**
150
- * Every workspace this credential's account belongs to, and which are
151
- * reachable right now without a new token — the data-plane counterpart to
152
- * `discoverOrganizations`, and the one that actually works with an ordinary
153
- * bearer token (OAuth or access key alike). `/v1/me` requires a first-party
154
- * control-plane credential a CLI never holds; `/v1/identity` requires only
155
- * the normal `synomem:read` scope every credential already has.
131
+ * Every context this credential's grant may use right now, with canonical ids
132
+ * (`GET /v1/contexts`). Follows `nextCursor` until the listing is complete, bounded.
156
133
  */
157
- export async function discoverIdentity(
158
- options: DiscoveryOptions,
159
- ): Promise<{ workspaceId: string; workspaces: WorkspaceMembership[] }> {
160
- return await readJson<{ workspaceId: string; workspaces: WorkspaceMembership[] }>(
134
+ export async function discoverContexts(options: DiscoveryOptions): Promise<ContextListing> {
135
+ let listing: ContextListing | undefined;
136
+ let cursor: string | null | undefined;
137
+ for (let page = 0; page < 20; page += 1) {
138
+ const next: ContextListing = await readJson<ContextListing>(
139
+ options,
140
+ `v1/contexts?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`,
141
+ );
142
+ listing = listing ? { ...next, contexts: [...listing.contexts, ...next.contexts] } : next;
143
+ cursor = next.nextCursor;
144
+ if (!cursor) break;
145
+ }
146
+ return { ...listing!, nextCursor: null };
147
+ }
148
+
149
+ /** Who this credential is: account, connection, grant, and effective context if selected. */
150
+ export async function describeIdentity(
151
+ options: DiscoveryOptions & { contextId?: string },
152
+ ): Promise<IdentityDescription> {
153
+ return await readJson<IdentityDescription>(
161
154
  options,
162
155
  'v1/identity',
156
+ options.contextId ? { 'synomem-context-id': options.contextId } : undefined,
163
157
  );
164
158
  }
165
159
 
package/src/errors.ts CHANGED
@@ -30,6 +30,10 @@ export const errorCodes = [
30
30
  'CONFIG_INVALID',
31
31
  'AUTH_REQUIRED',
32
32
  'AUTH_FORBIDDEN',
33
+ 'CONTEXT_REQUIRED',
34
+ 'CONTEXT_FORBIDDEN',
35
+ 'CONTEXT_AMBIGUOUS',
36
+ 'REAUTHORIZATION_REQUIRED',
33
37
  'UNSUPPORTED_BACKEND',
34
38
  'REMOTE_UNAVAILABLE',
35
39
  'REMOTE_PROTOCOL',
package/src/import.ts CHANGED
@@ -169,6 +169,8 @@ export async function createLocalImportBundle(fromHome: string): Promise<ImportB
169
169
  export interface RemoteImportClientOptions {
170
170
  baseUrl: string;
171
171
  workspaceId: string;
172
+ /** The selected context; sent as Synomem-Context-Id (identity contract §3). */
173
+ contextId?: string;
172
174
  credentialProvider: SynomemCredentialProvider;
173
175
  fetch?: typeof fetch;
174
176
  }
@@ -176,6 +178,7 @@ export interface RemoteImportClientOptions {
176
178
  export class RemoteImportClient {
177
179
  private readonly baseUrl: URL;
178
180
  private readonly workspaceId: string;
181
+ private readonly contextId: string | undefined;
179
182
  private readonly credentialProvider: SynomemCredentialProvider;
180
183
  private readonly fetchImplementation: typeof fetch;
181
184
 
@@ -195,6 +198,7 @@ export class RemoteImportClient {
195
198
  throw new SynomemError('CONFIG_INVALID', 'Remote import baseUrl must be an origin.');
196
199
  }
197
200
  this.workspaceId = options.workspaceId;
201
+ this.contextId = options.contextId;
198
202
  this.credentialProvider = options.credentialProvider;
199
203
  this.fetchImplementation = options.fetch ?? fetch;
200
204
  }
@@ -224,6 +228,7 @@ export class RemoteImportClient {
224
228
  accept: 'application/json',
225
229
  authorization: `Bearer ${token}`,
226
230
  'content-type': 'application/json',
231
+ ...(this.contextId ? { 'synomem-context-id': this.contextId } : {}),
227
232
  },
228
233
  body: JSON.stringify(payload),
229
234
  });