synomem 0.7.2 → 0.9.0

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 +38 -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 -25
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +1394 -1281
  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 +73 -33
  16. package/dist/credentials.d.ts.map +1 -1
  17. package/dist/credentials.js +167 -43
  18. package/dist/credentials.js.map +1 -1
  19. package/dist/discover.d.ts +10 -13
  20. package/dist/discover.d.ts.map +1 -1
  21. package/dist/discover.js +45 -30
  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 +5 -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 +9 -7
  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 -7
  36. package/dist/mcp/index.d.ts.map +1 -1
  37. package/dist/mcp/index.js +402 -183
  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 +31 -33
  44. package/dist/oauth.d.ts.map +1 -1
  45. package/dist/oauth.js +178 -125
  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 +465 -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 +23 -15
  56. package/dist/remote.d.ts.map +1 -1
  57. package/dist/remote.js +54 -49
  58. package/dist/remote.js.map +1 -1
  59. package/dist/resolvers.d.ts +47 -0
  60. package/dist/resolvers.d.ts.map +1 -0
  61. package/dist/resolvers.js +255 -0
  62. package/dist/resolvers.js.map +1 -0
  63. package/dist/service.d.ts +2 -0
  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 +51 -0
  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 +30 -4
  75. package/skills/synomem/references/examples.md +14 -0
  76. package/src/backend.ts +66 -64
  77. package/src/cli.ts +2137 -2163
  78. package/src/configure.ts +62 -241
  79. package/src/credentials.ts +208 -84
  80. package/src/discover.ts +60 -36
  81. package/src/errors.ts +5 -0
  82. package/src/import.ts +5 -0
  83. package/src/index.ts +14 -12
  84. package/src/mcp/index.ts +473 -194
  85. package/src/mcp-server.ts +32 -114
  86. package/src/oauth.ts +229 -130
  87. package/src/profiles.ts +644 -0
  88. package/src/project.ts +42 -108
  89. package/src/remote.ts +69 -58
  90. package/src/resolvers.ts +299 -0
  91. package/src/service.ts +2 -0
  92. package/src/skill-install.ts +17 -18
  93. package/src/types.ts +46 -0
@@ -1,42 +1,78 @@
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 { randomUUID } from 'node:crypto';
25
+ import {
26
+ chmodSync,
27
+ closeSync,
28
+ existsSync,
29
+ mkdirSync,
30
+ openSync,
31
+ readFileSync,
32
+ rmSync,
33
+ statSync,
34
+ unlinkSync,
35
+ writeSync,
36
+ } from 'node:fs';
37
+ import { join } from 'node:path';
38
+ import { z } from 'zod';
3
39
  import { SynomemError } from './errors.js';
4
- import type { ActorIdentity } from './types.js';
40
+ import { atomicWriteFile } from './fs-utils.js';
5
41
 
6
42
  const serviceName = 'ai.synomem.credentials';
7
43
  const maximumOutputBytes = 128 * 1024;
8
44
 
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
- }
45
+ export const storedOAuthCredentialSchema = z.object({
46
+ kind: z.literal('oauth'),
47
+ issuer: z.string().min(1),
48
+ resource: z.string().min(1),
49
+ clientId: z.string().min(1),
50
+ tokenEndpoint: z.string().min(1),
51
+ scope: z.string(),
52
+ accessToken: z.string().min(1),
53
+ refreshToken: z.string().min(1).optional(),
54
+ /** Epoch milliseconds, already reduced by a safety margin. */
55
+ expiresAt: z.number().int().nonnegative(),
56
+ /** Incremented on every successful refresh; the compare-and-swap token. */
57
+ generation: z.number().int().nonnegative(),
58
+ });
28
59
 
29
- export type StoredCredential = StoredOAuthCredential | StoredInstallationKey;
60
+ export const storedAccessKeySchema = z.object({
61
+ kind: z.literal('access-key'),
62
+ secret: z.string().min(1),
63
+ });
30
64
 
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
- }
65
+ export const storedCredentialSchema = z.discriminatedUnion('kind', [
66
+ storedOAuthCredentialSchema,
67
+ storedAccessKeySchema,
68
+ ]);
69
+
70
+ export type StoredOAuthCredential = z.infer<typeof storedOAuthCredentialSchema>;
71
+ export type StoredAccessKey = z.infer<typeof storedAccessKeySchema>;
72
+ export type StoredCredential = z.infer<typeof storedCredentialSchema>;
73
+
74
+ /** Where a connection's secret lives. `environment` stores nothing. */
75
+ export type CredentialBackendKind = 'keychain' | 'file' | 'environment';
40
76
 
41
77
  export interface CredentialStore {
42
78
  get(reference: string): Promise<StoredCredential | undefined>;
@@ -44,20 +80,26 @@ export interface CredentialStore {
44
80
  delete(reference: string): Promise<boolean>;
45
81
  }
46
82
 
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');
83
+ /** An opaque, non-secret handle for a stored secret. Never derived from names. */
84
+ export function newSecretReference(): string {
85
+ return `synomem-${randomUUID()}`;
86
+ }
87
+
88
+ export function parseStoredCredential(value: string): StoredCredential {
89
+ let raw: unknown;
90
+ try {
91
+ raw = JSON.parse(value);
92
+ } catch {
93
+ throw new SynomemError('AUTH_REQUIRED', 'Stored Synomem credential is malformed.');
94
+ }
95
+ const parsed = storedCredentialSchema.safeParse(raw);
96
+ if (!parsed.success) {
97
+ throw new SynomemError(
98
+ 'AUTH_REQUIRED',
99
+ 'Stored Synomem credential is in an unsupported format. Run `synomem connection login` (or `connection add-key`) again.',
100
+ );
101
+ }
102
+ return parsed.data;
61
103
  }
62
104
 
63
105
  interface CommandResult {
@@ -105,24 +147,7 @@ async function command(
105
147
  });
106
148
  }
107
149
 
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
-
150
+ /** macOS Keychain or Linux Secret Service. */
126
151
  export class OsCredentialStore implements CredentialStore {
127
152
  private readonly platform: NodeJS.Platform;
128
153
  private readonly run: CredentialCommandRunner;
@@ -132,7 +157,7 @@ export class OsCredentialStore implements CredentialStore {
132
157
  this.run = options.run ?? command;
133
158
  }
134
159
 
135
- async get(reference: string): Promise<StoredOAuthCredential | undefined> {
160
+ async get(reference: string): Promise<StoredCredential | undefined> {
136
161
  const result =
137
162
  this.platform === 'darwin'
138
163
  ? await this.run('/usr/bin/security', [
@@ -147,11 +172,11 @@ export class OsCredentialStore implements CredentialStore {
147
172
  ? await this.run('secret-tool', ['lookup', 'service', serviceName, 'account', reference])
148
173
  : this.unsupported();
149
174
  if (result.code !== 0) return undefined;
150
- return parseCredential(result.stdout.trim());
175
+ return parseStoredCredential(result.stdout.trim());
151
176
  }
152
177
 
153
- async set(reference: string, credential: StoredOAuthCredential): Promise<void> {
154
- const serialized = JSON.stringify(credential);
178
+ async set(reference: string, credential: StoredCredential): Promise<void> {
179
+ const serialized = JSON.stringify(storedCredentialSchema.parse(credential));
155
180
  const result =
156
181
  this.platform === 'darwin'
157
182
  ? await this.run(
@@ -163,7 +188,7 @@ export class OsCredentialStore implements CredentialStore {
163
188
  '-s',
164
189
  serviceName,
165
190
  '-l',
166
- 'Synomem OAuth credential',
191
+ 'Synomem credential',
167
192
  '-U',
168
193
  '-w',
169
194
  ],
@@ -172,21 +197,14 @@ export class OsCredentialStore implements CredentialStore {
172
197
  : this.platform === 'linux'
173
198
  ? await this.run(
174
199
  'secret-tool',
175
- [
176
- 'store',
177
- '--label=Synomem OAuth credential',
178
- 'service',
179
- serviceName,
180
- 'account',
181
- reference,
182
- ],
200
+ ['store', '--label=Synomem credential', 'service', serviceName, 'account', reference],
183
201
  serialized,
184
202
  )
185
203
  : this.unsupported();
186
204
  if (result.code !== 0) {
187
205
  throw new SynomemError(
188
- 'AUTH_REQUIRED',
189
- 'The operating-system credential store rejected the credential.',
206
+ 'CONFIG_INVALID',
207
+ 'The operating-system credential store rejected the credential. Re-run with --store file to use a restricted file instead.',
190
208
  );
191
209
  }
192
210
  }
@@ -208,17 +226,123 @@ export class OsCredentialStore implements CredentialStore {
208
226
  }
209
227
 
210
228
  /*
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.
229
+ * Windows Credential Manager is not implemented: `cmdkey` can write a generic
230
+ * credential but will not read the secret back, so a store built on it would
231
+ * accept a credential and never return it.
215
232
  */
216
233
  private unsupported(): never {
217
234
  throw new SynomemError(
218
235
  'CONFIG_INVALID',
219
236
  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.',
237
+ ? 'Windows Credential Manager storage is not supported yet. Use --store file, or --store environment with SYNOMEM_ACCESS_TOKEN.'
238
+ : 'Operating-system credential storage needs the macOS Keychain, or secret-tool on Linux. Use --store file, or --store environment with SYNOMEM_ACCESS_TOKEN.',
239
+ );
240
+ }
241
+ }
242
+
243
+ /**
244
+ * One restricted file per secret under `<home>/credentials/`, mode 0600 in a
245
+ * 0700 directory. Chosen explicitly (`--store file`), never as a fallback.
246
+ */
247
+ export class FileCredentialStore implements CredentialStore {
248
+ private readonly directory: string;
249
+
250
+ constructor(home: string) {
251
+ this.directory = join(home, 'credentials');
252
+ }
253
+
254
+ private path(reference: string): string {
255
+ if (!/^[A-Za-z0-9_-]{1,100}$/.test(reference)) {
256
+ throw new SynomemError('CONFIG_INVALID', 'Credential reference is malformed.');
257
+ }
258
+ return join(this.directory, `${reference}.json`);
259
+ }
260
+
261
+ async get(reference: string): Promise<StoredCredential | undefined> {
262
+ const path = this.path(reference);
263
+ if (!existsSync(path)) return undefined;
264
+ return parseStoredCredential(readFileSync(path, 'utf8'));
265
+ }
266
+
267
+ async set(reference: string, credential: StoredCredential): Promise<void> {
268
+ if (!existsSync(this.directory)) mkdirSync(this.directory, { recursive: true, mode: 0o700 });
269
+ chmodSync(this.directory, 0o700);
270
+ atomicWriteFile(
271
+ this.path(reference),
272
+ `${JSON.stringify(storedCredentialSchema.parse(credential))}\n`,
273
+ 0o600,
222
274
  );
223
275
  }
276
+
277
+ async delete(reference: string): Promise<boolean> {
278
+ const path = this.path(reference);
279
+ if (!existsSync(path)) return false;
280
+ rmSync(path, { force: true });
281
+ return true;
282
+ }
283
+ }
284
+
285
+ export interface CredentialStores {
286
+ keychain: CredentialStore;
287
+ file: CredentialStore;
288
+ }
289
+
290
+ export function defaultCredentialStores(home: string): CredentialStores {
291
+ return { keychain: new OsCredentialStore(), file: new FileCredentialStore(home) };
292
+ }
293
+
294
+ /**
295
+ * A cross-process lock around one credential's refresh.
296
+ *
297
+ * `O_EXCL` creation is the mutual exclusion; a lock older than `staleMs` is
298
+ * from a crashed process and is broken. The lock only serializes refreshes —
299
+ * correctness against a concurrent writer is the generation compare-and-swap
300
+ * in `refreshCredential`.
301
+ */
302
+ export async function withCredentialLock<T>(
303
+ home: string,
304
+ name: string,
305
+ operation: () => Promise<T>,
306
+ options: { staleMs?: number; timeoutMs?: number; pollMs?: number } = {},
307
+ ): Promise<T> {
308
+ if (!/^[A-Za-z0-9_.-]{1,100}$/.test(name)) {
309
+ throw new SynomemError('CONFIG_INVALID', 'Credential name is malformed.');
310
+ }
311
+ const directory = join(home, 'locks');
312
+ if (!existsSync(directory)) mkdirSync(directory, { recursive: true, mode: 0o700 });
313
+ const path = join(directory, `${name}.lock`);
314
+ const staleMs = options.staleMs ?? 60_000;
315
+ const deadline = Date.now() + (options.timeoutMs ?? 30_000);
316
+ const pollMs = options.pollMs ?? 50;
317
+ let descriptor: number | undefined;
318
+ while (descriptor === undefined) {
319
+ try {
320
+ descriptor = openSync(path, 'wx', 0o600);
321
+ writeSync(descriptor, `${process.pid}\n`);
322
+ } catch (error) {
323
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
324
+ try {
325
+ if (Date.now() - statSync(path).mtimeMs > staleMs) {
326
+ unlinkSync(path);
327
+ continue;
328
+ }
329
+ } catch {
330
+ // Removed between the failed open and the stat: try again at once.
331
+ continue;
332
+ }
333
+ if (Date.now() > deadline) {
334
+ throw new SynomemError(
335
+ 'DATABASE_BUSY',
336
+ `Another Synomem process is refreshing the ${name} credential. Try again.`,
337
+ );
338
+ }
339
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
340
+ }
341
+ }
342
+ try {
343
+ return await operation();
344
+ } finally {
345
+ closeSync(descriptor);
346
+ rmSync(path, { force: true });
347
+ }
224
348
  }
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;
@@ -47,7 +41,11 @@ interface Envelope<T> {
47
41
  error?: { code?: string; message?: string };
48
42
  }
49
43
 
50
- 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> {
51
49
  const request = options.fetch ?? globalThis.fetch;
52
50
  const url = new URL(
53
51
  path,
@@ -56,7 +54,11 @@ async function readJson<T>(options: DiscoveryOptions, path: string): Promise<T>
56
54
  let response: Response;
57
55
  try {
58
56
  response = await request(url, {
59
- headers: { authorization: `Bearer ${options.accessToken}`, accept: 'application/json' },
57
+ headers: {
58
+ authorization: `Bearer ${options.accessToken}`,
59
+ accept: 'application/json',
60
+ ...extraHeaders,
61
+ },
60
62
  ...(options.signal ? { signal: options.signal } : {}),
61
63
  });
62
64
  } catch (error) {
@@ -74,32 +76,24 @@ async function readJson<T>(options: DiscoveryOptions, path: string): Promise<T>
74
76
  if (!response.ok || body.ok !== true || body.data === undefined) {
75
77
  // 401 and 403 are the ones a person can act on, so they keep their own
76
78
  // codes rather than being flattened into a generic protocol error.
79
+ const reported = body.error?.code;
77
80
  const code =
78
- 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;
79
91
  const message = body.error?.message ?? `${url.pathname} returned ${response.status}.`;
80
92
  throw new SynomemError(code ?? 'REMOTE_PROTOCOL', message);
81
93
  }
82
94
  return body.data;
83
95
  }
84
96
 
85
- /**
86
- * Every workspace a member-owned access key can reach, asked before any one
87
- * of them has been chosen.
88
- *
89
- * Answered by the data plane rather than the control plane: an access key is
90
- * not an account principal the control plane recognizes, but the data plane
91
- * already knows which organization owns it and can list that organization's
92
- * workspaces without requiring one to be named first.
93
- */
94
- export async function discoverAccessKeyWorkspaces(
95
- options: DiscoveryOptions,
96
- ): Promise<{ organizationId: string; workspaces: DiscoveredWorkspace[] }> {
97
- return await readJson<{ organizationId: string; workspaces: DiscoveredWorkspace[] }>(
98
- options,
99
- 'v1/access-keys/workspaces',
100
- );
101
- }
102
-
103
97
  /**
104
98
  * Every organization this account belongs to, each with its workspaces.
105
99
  *
@@ -133,6 +127,36 @@ export async function discoverOrganizations(
133
127
  return organizations;
134
128
  }
135
129
 
130
+ /**
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.
133
+ */
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>(
154
+ options,
155
+ 'v1/identity',
156
+ options.contextId ? { 'synomem-context-id': options.contextId } : undefined,
157
+ );
158
+ }
159
+
136
160
  /** Flattens discovery into the choices a person picks from. */
137
161
  export function workspaceChoices(
138
162
  organizations: DiscoveredOrganization[],
package/src/errors.ts CHANGED
@@ -30,6 +30,11 @@ 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',
37
+ 'UNSUPPORTED_BACKEND',
33
38
  'REMOTE_UNAVAILABLE',
34
39
  'REMOTE_PROTOCOL',
35
40
  'RATE_LIMITED',
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
  });
package/src/index.ts CHANGED
@@ -2,22 +2,24 @@ export { SynomemClient, SynomemCore } from './client.js';
2
2
  export type { SynomemCoreOptions } from './client.js';
3
3
  export type { ProjectionWriter } from './ports/projections.js';
4
4
  export type { SynomemRepository } from './ports/repository.js';
5
- export {
6
- configuredServiceFactory,
7
- createConfiguredService,
8
- readSynomemConfig,
9
- writeSynomemBackend,
10
- } from './backend.js';
5
+ export { readSynomemConfig } from './backend.js';
11
6
  export { RemoteSynomemService, environmentCredentialProvider } from './remote.js';
12
- export type { SynomemCredentialProvider, RemoteSynomemOptions } from './remote.js';
13
- export { credentialReference, OsCredentialStore } from './credentials.js';
7
+ export type {
8
+ RemoteCredential,
9
+ RemoteCredentialSource,
10
+ SynomemCredentialProvider,
11
+ RemoteSynomemOptions,
12
+ } from './remote.js';
13
+ export { createLocalResolver, createRemoteResolver, localContextId } from './resolvers.js';
14
+ export type { ContextResolver, ResolvedContext } from './resolvers.js';
15
+ export { OsCredentialStore } from './credentials.js';
14
16
  export type {
15
17
  CredentialStore,
18
+ StoredAccessKey,
16
19
  StoredCredential,
17
- StoredInstallationKey,
18
20
  StoredOAuthCredential,
19
21
  } from './credentials.js';
20
- export { loginWithOAuth, StoredCredentialProvider } from './oauth.js';
22
+ export { loginWithOAuth } from './oauth.js';
21
23
  export type { OAuthLoginOptions } from './oauth.js';
22
24
  export {
23
25
  createLocalImportBundle,
@@ -54,12 +56,12 @@ export {
54
56
  PROJECT_CONFIG_FILE,
55
57
  PROJECT_DIRECTORY,
56
58
  projectConfigSchema,
57
- resolveWorkspaceSelection,
58
59
  writeProjectSelection,
59
60
  } from './project.js';
60
61
  export type { ProjectConfig, ProjectSelection } from './project.js';
61
62
  export {
62
- discoverAccessKeyWorkspaces,
63
+ describeIdentity,
64
+ discoverContexts,
63
65
  discoverOrganizations,
64
66
  workspaceChoices,
65
67
  } from './discover.js';