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.
- package/CHANGELOG.md +38 -0
- package/README.md +47 -68
- package/dist/backend.d.ts +18 -6
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js +55 -41
- package/dist/backend.js.map +1 -1
- package/dist/cli.d.ts +20 -25
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1394 -1281
- package/dist/cli.js.map +1 -1
- package/dist/configure.d.ts +12 -46
- package/dist/configure.d.ts.map +1 -1
- package/dist/configure.js +51 -192
- package/dist/configure.js.map +1 -1
- package/dist/credentials.d.ts +73 -33
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +167 -43
- package/dist/credentials.js.map +1 -1
- package/dist/discover.d.ts +10 -13
- package/dist/discover.d.ts.map +1 -1
- package/dist/discover.js +45 -30
- package/dist/discover.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +5 -0
- package/dist/errors.js.map +1 -1
- package/dist/import.d.ts +3 -0
- package/dist/import.d.ts.map +1 -1
- package/dist/import.js +3 -0
- package/dist/import.js.map +1 -1
- package/dist/index.d.ts +9 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -5
- package/dist/index.js.map +1 -1
- package/dist/mcp/index.d.ts +18 -7
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +402 -183
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp-server.d.ts +5 -1
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +27 -105
- package/dist/mcp-server.js.map +1 -1
- package/dist/oauth.d.ts +31 -33
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +178 -125
- package/dist/oauth.js.map +1 -1
- package/dist/profiles.d.ts +243 -0
- package/dist/profiles.d.ts.map +1 -0
- package/dist/profiles.js +465 -0
- package/dist/profiles.js.map +1 -0
- package/dist/project.d.ts +8 -39
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +36 -94
- package/dist/project.js.map +1 -1
- package/dist/remote.d.ts +23 -15
- package/dist/remote.d.ts.map +1 -1
- package/dist/remote.js +54 -49
- package/dist/remote.js.map +1 -1
- package/dist/resolvers.d.ts +47 -0
- package/dist/resolvers.d.ts.map +1 -0
- package/dist/resolvers.js +255 -0
- package/dist/resolvers.js.map +1 -0
- package/dist/service.d.ts +2 -0
- package/dist/service.d.ts.map +1 -1
- package/dist/skill-install.d.ts +4 -6
- package/dist/skill-install.d.ts.map +1 -1
- package/dist/skill-install.js +13 -12
- package/dist/skill-install.js.map +1 -1
- package/dist/types.d.ts +51 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/cli.md +173 -196
- package/docs/mcp.md +69 -65
- package/package.json +1 -1
- package/skills/synomem/SKILL.md +30 -4
- package/skills/synomem/references/examples.md +14 -0
- package/src/backend.ts +66 -64
- package/src/cli.ts +2137 -2163
- package/src/configure.ts +62 -241
- package/src/credentials.ts +208 -84
- package/src/discover.ts +60 -36
- package/src/errors.ts +5 -0
- package/src/import.ts +5 -0
- package/src/index.ts +14 -12
- package/src/mcp/index.ts +473 -194
- package/src/mcp-server.ts +32 -114
- package/src/oauth.ts +229 -130
- package/src/profiles.ts +644 -0
- package/src/project.ts +42 -108
- package/src/remote.ts +69 -58
- package/src/resolvers.ts +299 -0
- package/src/service.ts +2 -0
- package/src/skill-install.ts +17 -18
- package/src/types.ts +46 -0
package/src/credentials.ts
CHANGED
|
@@ -1,42 +1,78 @@
|
|
|
1
|
-
|
|
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
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
60
|
+
export const storedAccessKeySchema = z.object({
|
|
61
|
+
kind: z.literal('access-key'),
|
|
62
|
+
secret: z.string().min(1),
|
|
63
|
+
});
|
|
30
64
|
|
|
31
|
-
export
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
|
175
|
+
return parseStoredCredential(result.stdout.trim());
|
|
151
176
|
}
|
|
152
177
|
|
|
153
|
-
async set(reference: string, credential:
|
|
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
|
|
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
|
-
'
|
|
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
|
|
212
|
-
*
|
|
213
|
-
*
|
|
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.
|
|
221
|
-
: 'Operating-system credential storage needs the macOS Keychain, or secret-tool on Linux.
|
|
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
|
|
3
|
-
* one from memory.
|
|
2
|
+
* Finding out what a credential can reach, so nobody has to type an id from memory.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
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>(
|
|
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: {
|
|
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
|
-
|
|
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 {
|
|
13
|
-
|
|
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
|
|
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
|
-
|
|
63
|
+
describeIdentity,
|
|
64
|
+
discoverContexts,
|
|
63
65
|
discoverOrganizations,
|
|
64
66
|
workspaceChoices,
|
|
65
67
|
} from './discover.js';
|