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
package/src/mcp-server.ts CHANGED
@@ -1,123 +1,41 @@
1
1
  #!/usr/bin/env node
2
- import { parseArgs } from 'node:util';
3
- import { createConfiguredService } from './backend.js';
4
- import { resolveWorkspaceSelection } from './project.js';
5
- import { SynomemError } from './errors.js';
6
- import { actorSchema } from './schemas.js';
7
- import { startMcpServer } from './mcp/index.js';
8
- import type { ActorIdentity } from './types.js';
9
- import { packageVersion } from './version.js';
10
-
11
- const version = packageVersion();
12
-
13
- const { values } = parseArgs({
14
- options: {
15
- home: { type: 'string' },
16
- workspace: { type: 'string' },
17
- 'agent-id': { type: 'string' },
18
- 'actor-id': { type: 'string' },
19
- 'actor-kind': { type: 'string' },
20
- 'actor-name': { type: 'string' },
21
- help: { type: 'boolean', short: 'h' },
22
- version: { type: 'boolean', short: 'v' },
23
- },
24
- });
25
-
26
2
  /**
27
- * Loads an agent's identity from canonical state rather than from arguments.
3
+ * The stdio MCP entry point.
28
4
  *
29
- * A display name passed on the command line is a claim the runtime makes about
30
- * itself, and it is written into every event the session appends. Reading the
31
- * profile instead means a misconfigured runtime cannot sign another agent's
32
- * name to work, and renaming an agent takes effect without re-registering it
33
- * with every harness.
5
+ * `startMcpServer` runs the shared tool catalog for one context resolver: a fixed
6
+ * profile (one pinned workspace/actor) or an explicit preset (several, selected per
7
+ * call). Which resolver is decided by the CLI's profile resolution (identity contract
8
+ * §6.1), so `synomem-mcp <args>` is exactly `synomem mcp <args>` — one resolution
9
+ * implementation for every entry point, never a second set of identity flags here.
34
10
  */
35
- async function resolveAgentActor(agentId: string, home?: string): Promise<ActorIdentity> {
36
- const lookup = createConfiguredService({
37
- actor: { kind: 'system', id: 'synomem-mcp' },
38
- ...(home ? { home } : {}),
11
+ import { realpathSync } from 'node:fs';
12
+ import { pathToFileURL } from 'node:url';
13
+ import { serveStdio } from './mcp/index.js';
14
+ import type { ContextResolver } from './resolvers.js';
15
+
16
+ export async function startMcpServer(options: {
17
+ resolver: ContextResolver;
18
+ instructions?: string;
19
+ }): Promise<void> {
20
+ const runtime = await serveStdio(options.resolver, {
21
+ ...(options.instructions ? { instructions: options.instructions } : {}),
39
22
  });
40
- await lookup.init();
41
- try {
42
- const resolution = await lookup.agents.resolve(agentId);
43
- if (!resolution.match) {
44
- throw new SynomemError(
45
- 'AGENT_NOT_FOUND',
46
- resolution.candidates.length
47
- ? `"${agentId}" matches ${resolution.candidates.length} agents: ${resolution.candidates
48
- .map((candidate) => candidate.id)
49
- .join(', ')}. Register the MCP server with a canonical agent ID.`
50
- : `Unknown agent: ${agentId}. Create it with \`synomem agent create\` first.`,
51
- );
52
- }
53
- return {
54
- kind: 'agent',
55
- id: resolution.match.id,
56
- ...(resolution.match.displayName ? { displayName: resolution.match.displayName } : {}),
23
+ await new Promise<void>((resolve) => {
24
+ const previous = runtime.server.server.onclose;
25
+ runtime.server.server.onclose = () => {
26
+ previous?.();
27
+ resolve();
57
28
  };
58
- } finally {
59
- await lookup.close();
60
- }
29
+ });
30
+ await options.resolver.close?.();
61
31
  }
62
32
 
63
- if (values.help) {
64
- process.stdout.write(
65
- `synomem-mcp ${version}
66
-
67
- Actor-bound Synomem MCP server (stdio transport)
68
-
69
- Options:
70
- --home <path> Storage root
71
- --workspace <name> Local workspace to act in. Defaults to the workspace
72
- named by .synomem/config.json in the working directory
73
- or any directory above it, then to SYNOMEM_WORKSPACE,
74
- then to the default workspace.
75
- --agent-id <id> Bound agent, whose identity is read from Synomem
76
- (or SYNOMEM_AGENT_ID)
77
- --actor-id <id> Bound non-agent actor ID (or SYNOMEM_ACTOR_ID)
78
- --actor-kind <kind> human or system (or SYNOMEM_ACTOR_KIND)
79
- --actor-name <name> Display name for a non-agent actor
80
- (or SYNOMEM_ACTOR_NAME)
81
- -h, --help Show help
82
- -v, --version Show version
83
-
84
- Prefer --agent-id for an agent runtime: the display name and kind then come
85
- from the agent's profile instead of from whatever the harness was told to pass.
86
- `,
87
- );
88
- } else if (values.version) {
89
- process.stdout.write(`${version}\n`);
90
- } else {
91
- /*
92
- * The workspace is resolved from where the server was STARTED, which is what
93
- * makes a project binding work at all.
94
- *
95
- * A harness launches this process in the repository it opened, so a
96
- * `.synomem/config.json` there selects the workspace for the whole session
97
- * without the harness knowing anything about workspaces, and without anybody
98
- * repeating a flag. `--home` still wins, because it names a home outright
99
- * rather than a workspace inside one.
100
- */
101
- const selection = values.home
102
- ? undefined
103
- : resolveWorkspaceSelection({
104
- ...(values.workspace ? { flag: values.workspace } : {}),
105
- env: process.env,
106
- });
107
- const home = values.home ?? selection?.home;
108
-
109
- const agentId =
110
- values['agent-id'] ?? process.env.SYNOMEM_AGENT_ID ?? selection?.actor ?? undefined;
111
- const actor = agentId
112
- ? await resolveAgentActor(agentId, home)
113
- : actorSchema.parse({
114
- id: values['actor-id'] ?? process.env.SYNOMEM_ACTOR_ID,
115
- kind: values['actor-kind'] ?? process.env.SYNOMEM_ACTOR_KIND,
116
- displayName: values['actor-name'] ?? process.env.SYNOMEM_ACTOR_NAME,
117
- });
118
-
119
- await startMcpServer({
120
- actor,
121
- ...(home ? { home } : {}),
122
- });
33
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
34
+ const { runCli } = await import('./cli.js');
35
+ process.exitCode = await runCli([
36
+ process.argv[0] ?? 'node',
37
+ process.argv[1],
38
+ 'mcp',
39
+ ...process.argv.slice(2),
40
+ ]);
123
41
  }
package/src/oauth.ts CHANGED
@@ -1,16 +1,27 @@
1
+ /**
2
+ * The CLI's own OAuth 2.1 client (RFC 8252 native app).
3
+ *
4
+ * Authorization code + PKCE S256 through the system browser and a loopback
5
+ * redirect, against the pre-registered public client `synomem-cli`. The token
6
+ * is audienced to the Synomem API itself: discovery starts from the API's own
7
+ * protected-resource metadata (RFC 9728), never from the MCP gateway, and the
8
+ * discovered issuer and resource are validated before any browser opens.
9
+ *
10
+ * Which agent and workspace the resulting connection may act as is chosen on
11
+ * the consent screen and enforced by the API; nothing here names an actor.
12
+ */
1
13
  import { createServer } from 'node:http';
2
14
  import { spawn } from 'node:child_process';
3
- import { randomBytes } from 'node:crypto';
4
- import {
5
- discoverOAuthServerInfo,
6
- startAuthorization,
7
- } from '@modelcontextprotocol/sdk/client/auth.js';
15
+ import { createHash, randomBytes } from 'node:crypto';
8
16
  import { SynomemError } from './errors.js';
9
- import type { CredentialStore, StoredOAuthCredential } from './credentials.js';
10
- import type { SynomemCredentialProvider } from './remote.js';
11
- import { readCredentialFile } from './configure.js';
17
+ import type { StoredOAuthCredential } from './credentials.js';
12
18
 
13
- const defaultScope = 'synomem:read synomem:write offline_access';
19
+ export const DEFAULT_CLI_CLIENT_ID = 'synomem-cli';
20
+ export const DEFAULT_CALLBACK_PORT = 43_817;
21
+ export const DEFAULT_CLI_SCOPE = 'openid offline_access synomem:read synomem:write';
22
+ const maximumDocumentBytes = 64 * 1024;
23
+ /** Access tokens are treated as expired this long before they actually are. */
24
+ const expirySafetyMs = 30_000;
14
25
 
15
26
  interface TokenResponse {
16
27
  access_token: string;
@@ -19,35 +30,170 @@ interface TokenResponse {
19
30
  scope?: string;
20
31
  }
21
32
 
22
- function secureEndpoint(value: string, label: string): URL {
23
- const url = new URL(value);
24
- if (url.protocol !== 'https:') {
33
+ export interface ApiAuthorizationMetadata {
34
+ resource: string;
35
+ issuer: string;
36
+ authorizationEndpoint: string;
37
+ tokenEndpoint: string;
38
+ }
39
+
40
+ function trimSlash(value: string): string {
41
+ return value.replace(/\/+$/, '');
42
+ }
43
+
44
+ /** HTTPS, or loopback HTTP (development against a local stack). */
45
+ export function secureUrl(value: string, label: string): URL {
46
+ let url: URL;
47
+ try {
48
+ url = new URL(value);
49
+ } catch {
50
+ throw new SynomemError('AUTH_REQUIRED', `${label} is not a valid URL.`);
51
+ }
52
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
53
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
25
54
  throw new SynomemError('AUTH_REQUIRED', `${label} must use HTTPS.`);
26
55
  }
56
+ if (url.username || url.password) {
57
+ throw new SynomemError('AUTH_REQUIRED', `${label} must not carry credentials.`);
58
+ }
27
59
  return url;
28
60
  }
29
61
 
62
+ async function readJson(
63
+ url: URL,
64
+ fetchImplementation: typeof fetch,
65
+ label: string,
66
+ ): Promise<Record<string, unknown>> {
67
+ let response: Response;
68
+ try {
69
+ response = await fetchImplementation(url, {
70
+ redirect: 'error',
71
+ headers: { accept: 'application/json' },
72
+ });
73
+ } catch {
74
+ throw new SynomemError('REMOTE_UNAVAILABLE', `${label} is unavailable.`);
75
+ }
76
+ const text = await response.text();
77
+ if (Buffer.byteLength(text) > maximumDocumentBytes) {
78
+ throw new SynomemError('REMOTE_PROTOCOL', `${label} exceeded the safe size limit.`);
79
+ }
80
+ if (!response.ok) {
81
+ throw new SynomemError('REMOTE_PROTOCOL', `${label} returned HTTP ${response.status}.`);
82
+ }
83
+ try {
84
+ const parsed = JSON.parse(text) as unknown;
85
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error();
86
+ return parsed as Record<string, unknown>;
87
+ } catch {
88
+ throw new SynomemError('REMOTE_PROTOCOL', `${label} is not valid JSON.`);
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Where to sign in for this API, validated.
94
+ *
95
+ * The protected-resource metadata must name THIS API as its resource and at
96
+ * least one authorization server; the authorization server's metadata must
97
+ * name itself as issuer, publish HTTPS endpoints and support S256. A mismatch
98
+ * anywhere stops the login before a browser opens.
99
+ */
100
+ export async function discoverApiAuthorization(
101
+ apiUrl: string,
102
+ fetchImplementation: typeof fetch = fetch,
103
+ ): Promise<ApiAuthorizationMetadata> {
104
+ const api = secureUrl(apiUrl, 'Synomem API URL');
105
+ const resourceMetadata = await readJson(
106
+ new URL('/.well-known/oauth-protected-resource', api.origin),
107
+ fetchImplementation,
108
+ 'API protected-resource metadata',
109
+ );
110
+ const resource = resourceMetadata.resource;
111
+ if (typeof resource !== 'string' || trimSlash(resource) !== trimSlash(api.origin)) {
112
+ throw new SynomemError(
113
+ 'AUTH_REQUIRED',
114
+ 'The API protected-resource metadata does not describe this API.',
115
+ );
116
+ }
117
+ const servers = resourceMetadata.authorization_servers;
118
+ const issuerValue: unknown = Array.isArray(servers) ? (servers as unknown[])[0] : undefined;
119
+ if (typeof issuerValue !== 'string') {
120
+ throw new SynomemError('AUTH_REQUIRED', 'The API names no authorization server.');
121
+ }
122
+ const issuer = secureUrl(issuerValue, 'Authorization server');
123
+ const asMetadata = await readJson(
124
+ new URL(
125
+ `/.well-known/oauth-authorization-server${issuer.pathname === '/' ? '' : trimSlash(issuer.pathname)}`,
126
+ issuer.origin,
127
+ ),
128
+ fetchImplementation,
129
+ 'Authorization server metadata',
130
+ );
131
+ if (
132
+ typeof asMetadata.issuer !== 'string' ||
133
+ trimSlash(asMetadata.issuer) !== trimSlash(issuer.href)
134
+ ) {
135
+ throw new SynomemError(
136
+ 'AUTH_REQUIRED',
137
+ 'The authorization server metadata issuer does not match.',
138
+ );
139
+ }
140
+ const authorizationEndpoint = asMetadata.authorization_endpoint;
141
+ const tokenEndpoint = asMetadata.token_endpoint;
142
+ if (typeof authorizationEndpoint !== 'string' || typeof tokenEndpoint !== 'string') {
143
+ throw new SynomemError('AUTH_REQUIRED', 'Authorization server discovery is incomplete.');
144
+ }
145
+ secureUrl(authorizationEndpoint, 'OAuth authorization endpoint');
146
+ secureUrl(tokenEndpoint, 'OAuth token endpoint');
147
+ const methods = asMetadata.code_challenge_methods_supported;
148
+ if (!Array.isArray(methods) || !methods.includes('S256')) {
149
+ throw new SynomemError('AUTH_REQUIRED', 'The authorization server must support PKCE S256.');
150
+ }
151
+ return {
152
+ resource: trimSlash(resource),
153
+ issuer: trimSlash(asMetadata.issuer),
154
+ authorizationEndpoint,
155
+ tokenEndpoint,
156
+ };
157
+ }
158
+
159
+ export const TOKEN_REQUEST_TIMEOUT_MS = 20_000;
160
+
161
+ /*
162
+ * Failures that prove the request never reached the server. Anything else — a
163
+ * timeout, a reset mid-response — may have followed a committed rotation, so
164
+ * the refresh token it carried must be treated as spent (plan §6: never retry
165
+ * a possibly consumed token; reauthorize instead).
166
+ */
167
+ const NOT_SENT = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'ENETUNREACH', 'EHOSTUNREACH']);
168
+ function neverDelivered(error: unknown): boolean {
169
+ const cause = (error as { cause?: { code?: unknown } } | undefined)?.cause;
170
+ return typeof cause?.code === 'string' && NOT_SENT.has(cause.code);
171
+ }
172
+
30
173
  async function tokenRequest(
31
174
  endpoint: string,
32
175
  parameters: URLSearchParams,
33
176
  fetchImplementation: typeof fetch,
34
- signal?: AbortSignal,
35
177
  ): Promise<TokenResponse> {
36
178
  let response: Response;
37
179
  try {
38
- response = await fetchImplementation(secureEndpoint(endpoint, 'OAuth token endpoint'), {
180
+ response = await fetchImplementation(secureUrl(endpoint, 'OAuth token endpoint'), {
39
181
  method: 'POST',
40
182
  redirect: 'error',
41
183
  headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
42
184
  body: parameters,
43
- ...(signal ? { signal } : {}),
185
+ // Well inside the credential lock's stale window, so a refresh can never
186
+ // outlive the lock that serializes it.
187
+ signal: AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS),
44
188
  });
45
189
  } catch (error) {
46
190
  if (error instanceof SynomemError) throw error;
47
- throw new SynomemError('REMOTE_UNAVAILABLE', 'The OAuth token endpoint is unavailable.');
191
+ throw new SynomemError('REMOTE_UNAVAILABLE', 'The OAuth token endpoint is unavailable.', {
192
+ delivery: neverDelivered(error) ? 'not_sent' : 'indeterminate',
193
+ });
48
194
  }
49
195
  const text = await response.text();
50
- if (Buffer.byteLength(text) > 64 * 1024) {
196
+ if (Buffer.byteLength(text) > maximumDocumentBytes) {
51
197
  throw new SynomemError('REMOTE_PROTOCOL', 'OAuth token response exceeded the safe limit.');
52
198
  }
53
199
  let parsed: Partial<TokenResponse>;
@@ -62,11 +208,17 @@ async function tokenRequest(
62
208
  return parsed as TokenResponse;
63
209
  }
64
210
 
211
+ function expiresAt(expiresIn: number | undefined): number {
212
+ // A token without a lifetime is still refreshed on the API's schedule: 10
213
+ // minutes is the contract's maximum access-token lifetime.
214
+ return Date.now() + Math.max(0, (expiresIn ?? 600) * 1_000 - expirySafetyMs);
215
+ }
216
+
65
217
  function launchBrowser(url: URL): void {
66
- const command = process.platform === 'darwin' ? 'open' : 'xdg-open';
67
218
  if (process.platform !== 'darwin' && process.platform !== 'linux') {
68
219
  throw new SynomemError('CONFIG_INVALID', `Open this URL in a browser: ${url.href}`);
69
220
  }
221
+ const command = process.platform === 'darwin' ? 'open' : 'xdg-open';
70
222
  const child = spawn(command, [url.href], { detached: true, stdio: 'ignore' });
71
223
  child.once('error', () => undefined);
72
224
  child.unref();
@@ -92,7 +244,14 @@ async function authorizationCode(
92
244
  if (oauthError || !code || returnedState !== state) {
93
245
  response.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
94
246
  response.end('Synomem authorization failed. Return to your terminal.');
95
- finish(new SynomemError('AUTH_REQUIRED', 'OAuth callback validation failed.'));
247
+ finish(
248
+ new SynomemError(
249
+ 'AUTH_REQUIRED',
250
+ oauthError === 'access_denied'
251
+ ? 'Authorization was declined.'
252
+ : 'OAuth callback validation failed.',
253
+ ),
254
+ );
96
255
  return;
97
256
  }
98
257
  response.writeHead(200, {
@@ -124,10 +283,8 @@ async function authorizationCode(
124
283
  }
125
284
 
126
285
  export interface OAuthLoginOptions {
127
- baseUrl: string;
128
- clientId: string;
129
- credentialReference: string;
130
- credentialStore: CredentialStore;
286
+ apiUrl: string;
287
+ clientId?: string;
131
288
  scope?: string;
132
289
  callbackPort?: number;
133
290
  fetch?: typeof fetch;
@@ -135,140 +292,101 @@ export interface OAuthLoginOptions {
135
292
  timeoutMs?: number;
136
293
  }
137
294
 
138
- export async function loginWithOAuth(options: OAuthLoginOptions): Promise<void> {
295
+ /** Runs the browser flow and returns the credential for the caller to store. */
296
+ export async function loginWithOAuth(options: OAuthLoginOptions): Promise<StoredOAuthCredential> {
139
297
  const fetchImplementation = options.fetch ?? fetch;
140
- const mcpUrl = new URL('/mcp', options.baseUrl);
141
- const discovered = await discoverOAuthServerInfo(mcpUrl, { fetchFn: fetchImplementation });
142
- const metadata = discovered.authorizationServerMetadata;
143
- if (!metadata?.authorization_endpoint || !metadata.token_endpoint) {
144
- throw new SynomemError('AUTH_REQUIRED', 'OAuth server discovery is incomplete.');
145
- }
146
- secureEndpoint(metadata.authorization_endpoint, 'OAuth authorization endpoint');
147
- secureEndpoint(metadata.token_endpoint, 'OAuth token endpoint');
148
- if (!metadata.code_challenge_methods_supported?.includes('S256')) {
149
- throw new SynomemError('AUTH_REQUIRED', 'The OAuth server must support PKCE S256.');
150
- }
151
- const callbackPort = options.callbackPort ?? 43_817;
152
- const redirectUrl = new URL(`http://127.0.0.1:${callbackPort}/callback`);
298
+ const metadata = await discoverApiAuthorization(options.apiUrl, fetchImplementation);
299
+ const clientId = options.clientId ?? DEFAULT_CLI_CLIENT_ID;
300
+ const scope = options.scope ?? DEFAULT_CLI_SCOPE;
301
+ const callbackPort = options.callbackPort ?? DEFAULT_CALLBACK_PORT;
302
+ const redirectUri = `http://127.0.0.1:${callbackPort}/callback`;
153
303
  const state = randomBytes(32).toString('base64url');
154
- const resource = new URL(discovered.resourceMetadata?.resource ?? mcpUrl.href);
155
- const scope = options.scope ?? defaultScope;
156
- const started = await startAuthorization(discovered.authorizationServerUrl, {
157
- metadata,
158
- clientInformation: { client_id: options.clientId },
159
- redirectUrl,
160
- scope,
161
- state,
162
- resource,
163
- });
304
+ const verifier = randomBytes(32).toString('base64url');
305
+ const challenge = createHash('sha256').update(verifier).digest('base64url');
306
+
307
+ const authorizationUrl = new URL(metadata.authorizationEndpoint);
308
+ authorizationUrl.searchParams.set('response_type', 'code');
309
+ authorizationUrl.searchParams.set('client_id', clientId);
310
+ authorizationUrl.searchParams.set('redirect_uri', redirectUri);
311
+ authorizationUrl.searchParams.set('scope', scope);
312
+ authorizationUrl.searchParams.set('state', state);
313
+ authorizationUrl.searchParams.set('code_challenge', challenge);
314
+ authorizationUrl.searchParams.set('code_challenge_method', 'S256');
315
+ authorizationUrl.searchParams.set('resource', metadata.resource);
316
+
164
317
  const code = await authorizationCode(
165
- started.authorizationUrl,
318
+ authorizationUrl,
166
319
  state,
167
320
  callbackPort,
168
321
  options.openBrowser ?? launchBrowser,
169
322
  options.timeoutMs ?? 5 * 60_000,
170
323
  );
171
324
  const tokens = await tokenRequest(
172
- metadata.token_endpoint,
325
+ metadata.tokenEndpoint,
173
326
  new URLSearchParams({
174
327
  grant_type: 'authorization_code',
175
- client_id: options.clientId,
328
+ client_id: clientId,
176
329
  code,
177
- code_verifier: started.codeVerifier,
178
- redirect_uri: redirectUrl.href,
179
- resource: resource.href,
330
+ code_verifier: verifier,
331
+ redirect_uri: redirectUri,
332
+ resource: metadata.resource,
180
333
  }),
181
334
  fetchImplementation,
182
335
  );
183
- await options.credentialStore.set(options.credentialReference, {
336
+ return {
337
+ kind: 'oauth',
338
+ issuer: metadata.issuer,
339
+ resource: metadata.resource,
340
+ clientId,
341
+ tokenEndpoint: metadata.tokenEndpoint,
342
+ scope: tokens.scope ?? scope,
184
343
  accessToken: tokens.access_token,
185
344
  ...(tokens.refresh_token ? { refreshToken: tokens.refresh_token } : {}),
186
- ...(tokens.expires_in
187
- ? { expiresAt: Date.now() + Math.max(0, tokens.expires_in - 30) * 1_000 }
188
- : {}),
189
- tokenEndpoint: metadata.token_endpoint,
190
- clientId: options.clientId,
191
- resource: resource.href,
192
- scope: tokens.scope ?? scope,
193
- });
345
+ expiresAt: expiresAt(tokens.expires_in),
346
+ generation: 0,
347
+ };
194
348
  }
195
349
 
196
- export class StoredCredentialProvider implements SynomemCredentialProvider {
197
- private refresh?: Promise<string | undefined>;
198
- private credential?: StoredOAuthCredential;
199
- /** Set when the stored credential is an access key rather than an OAuth grant. */
200
- private staticAccessToken?: string;
201
- private loaded = false;
202
-
203
- constructor(
204
- private readonly reference: string,
205
- private readonly store: CredentialStore,
206
- private readonly env: NodeJS.ProcessEnv = process.env,
207
- private readonly fetchImplementation: typeof fetch = fetch,
208
- /**
209
- * Where `synomem config`'s restricted-file credential store, if chosen,
210
- * would have written an access key. Optional because local-only and
211
- * environment-only callers have no such file to fall back to.
212
- */
213
- private readonly home?: string,
214
- ) {}
215
-
216
- async getAccessToken(signal?: AbortSignal): Promise<string | undefined> {
217
- if (this.env.SYNOMEM_ACCESS_TOKEN) return this.env.SYNOMEM_ACCESS_TOKEN;
218
- if (!this.loaded) {
219
- const stored = await this.store.get(this.reference);
220
- /*
221
- * An access key is not an OAuth credential: it cannot be refreshed and
222
- * has no client or token endpoint. It is used exactly as stored,
223
- * whichever of the two places it was found.
224
- */
225
- if (stored && 'kind' in stored) {
226
- this.staticAccessToken = stored.accessToken;
227
- } else if (stored) {
228
- this.credential = stored;
229
- } else if (this.home) {
230
- this.staticAccessToken = readCredentialFile(this.home);
231
- }
232
- this.loaded = true;
233
- }
234
- if (this.staticAccessToken) return this.staticAccessToken;
235
- const credential = this.credential;
236
- if (!credential) return undefined;
237
- if (!credential.expiresAt || credential.expiresAt > Date.now()) return credential.accessToken;
238
- if (!credential.refreshToken) return undefined;
239
- this.refresh ??= this.refreshCredential(credential, signal).finally(() => {
240
- this.refresh = undefined;
241
- });
242
- return await this.refresh;
350
+ /**
351
+ * Spends the refresh token once. Callers hold the credential lock and write the
352
+ * result before releasing it; a failure here is never retried with the same
353
+ * (possibly consumed) refresh token.
354
+ */
355
+ export async function refreshOAuthCredential(
356
+ credential: StoredOAuthCredential,
357
+ fetchImplementation: typeof fetch = fetch,
358
+ ): Promise<StoredOAuthCredential> {
359
+ if (!credential.refreshToken) {
360
+ throw new SynomemError(
361
+ 'REAUTHORIZATION_REQUIRED',
362
+ 'The stored credential cannot be refreshed.',
363
+ );
243
364
  }
244
-
245
- private async refreshCredential(
246
- credential: StoredOAuthCredential,
247
- signal?: AbortSignal,
248
- ): Promise<string> {
249
- const tokens = await tokenRequest(
365
+ let tokens: TokenResponse;
366
+ try {
367
+ tokens = await tokenRequest(
250
368
  credential.tokenEndpoint,
251
369
  new URLSearchParams({
252
370
  grant_type: 'refresh_token',
253
371
  client_id: credential.clientId,
254
- refresh_token: credential.refreshToken!,
372
+ refresh_token: credential.refreshToken,
255
373
  resource: credential.resource,
256
- scope: credential.scope,
257
374
  }),
258
- this.fetchImplementation,
259
- signal,
375
+ fetchImplementation,
376
+ );
377
+ } catch (error) {
378
+ if (error instanceof SynomemError && error.code === 'REMOTE_UNAVAILABLE') throw error;
379
+ throw new SynomemError(
380
+ 'REAUTHORIZATION_REQUIRED',
381
+ 'The stored credential was refused when refreshing.',
260
382
  );
261
- const updated: StoredOAuthCredential = {
262
- ...credential,
263
- accessToken: tokens.access_token,
264
- refreshToken: tokens.refresh_token ?? credential.refreshToken,
265
- ...(tokens.expires_in
266
- ? { expiresAt: Date.now() + Math.max(0, tokens.expires_in - 30) * 1_000 }
267
- : { expiresAt: undefined }),
268
- scope: tokens.scope ?? credential.scope,
269
- };
270
- await this.store.set(this.reference, updated);
271
- this.credential = updated;
272
- return updated.accessToken;
273
383
  }
384
+ return {
385
+ ...credential,
386
+ accessToken: tokens.access_token,
387
+ ...(tokens.refresh_token ? { refreshToken: tokens.refresh_token } : {}),
388
+ scope: tokens.scope ?? credential.scope,
389
+ expiresAt: expiresAt(tokens.expires_in),
390
+ generation: credential.generation + 1,
391
+ };
274
392
  }