@remnic/core 9.3.767 → 9.3.769

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.
@@ -11,7 +11,7 @@ import { abortError, isAbortError } from "./abort-error.js";
11
11
  import { EngramAccessInputError, type EngramAccessService, type EngramAccessMemoryResponse, type EngramAccessWriteResponse } from "./access-service.js";
12
12
  import { CorrectionContractError } from "./correction/correction-contract.js";
13
13
  import { WearablesInputError } from "./wearables/errors.js";
14
- import { EngramMcpServer } from "./access-mcp.js";
14
+ import { EngramMcpServer, MCP_SUPPORTED_PROTOCOL_VERSIONS } from "./access-mcp.js";
15
15
  import { validateRequest, type SchemaName, type SchemaTypeFor } from "./access-schema.js";
16
16
  import {
17
17
  OFFLINE_SYNC_APPLY_MAX_BODY_BYTES,
@@ -54,6 +54,21 @@ export interface EngramAccessHttpServerOptions {
54
54
  authTokens?: string[];
55
55
  /** Dynamic token loader — called on each auth check so new/revoked tokens take effect without restart. */
56
56
  authTokensGetter?: () => string[];
57
+ /**
58
+ * Dynamic token-ENTRY loader ({token, connector} pairs from one coherent
59
+ * snapshot). Preferred over `authTokensGetter` when a `tokenPathPolicy`
60
+ * is set: the connector used for the policy decision comes from the SAME
61
+ * entry that validated, so identity can never lag validation.
62
+ */
63
+ authTokenEntriesGetter?: () => ReadonlyArray<{ token: string; connector?: string }>;
64
+ /**
65
+ * Optional per-request scope policy for tokens sourced from
66
+ * `authTokenEntriesGetter`. Return false to deny the (validated) token
67
+ * for this pathname. Static `authToken`/`authTokens` (operator-supplied)
68
+ * bypass the policy. Entries whose connector is missing FAIL CLOSED when
69
+ * a policy is configured.
70
+ */
71
+ tokenPathPolicy?: (connector: string, pathname: string | undefined) => boolean;
57
72
  principal?: string;
58
73
  maxBodyBytes?: number;
59
74
  adminConsoleEnabled?: boolean;
@@ -78,6 +93,28 @@ export interface EngramAccessHttpServerOptions {
78
93
  * existing health behavior.
79
94
  */
80
95
  readiness?: () => AccessHttpReadinessState;
96
+ /**
97
+ * When set, every 401 response includes
98
+ * `WWW-Authenticate: Bearer resource_metadata="<value>"` so MCP clients
99
+ * can discover the OAuth 2.0 protected-resource metadata document
100
+ * (RFC 9728). Must be an absolute http(s) URL; constructor throws on
101
+ * anything else. Unset → bare `Bearer`.
102
+ */
103
+ resourceMetadataUrl?: string;
104
+ /**
105
+ * Optional pre-auth request handler (e.g. OAuth facade mounted by
106
+ * `@remnic/server`). Runs after the admin-console handler and BEFORE
107
+ * bearer authorization. Return true if the request was fully handled
108
+ * (response ended). `ctx.authorized` reports whether the request
109
+ * carries a valid operator bearer token, so the handler can gate
110
+ * operator-only endpoints without owning token validation.
111
+ * Errors thrown by the handler flow into the existing error handling.
112
+ */
113
+ externalRequestHandler?: (
114
+ req: IncomingMessage,
115
+ res: ServerResponse,
116
+ ctx: { authorized: boolean },
117
+ ) => Promise<boolean>;
81
118
  }
82
119
 
83
120
  export interface EngramAccessHttpServerStatus {
@@ -183,6 +220,23 @@ function parseHttpServerPort(port: number | undefined): number {
183
220
  }
184
221
  return port;
185
222
  }
223
+ function assertResourceMetadataUrl(value: string | undefined): string | undefined {
224
+ if (value === undefined) return undefined;
225
+ let parsed: URL;
226
+ try {
227
+ parsed = new URL(value);
228
+ } catch {
229
+ throw new Error(
230
+ `access HTTP resourceMetadataUrl must be an absolute http(s) URL, got: ${value}`,
231
+ );
232
+ }
233
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
234
+ throw new Error(
235
+ `access HTTP resourceMetadataUrl must use http or https, got: ${parsed.protocol}`,
236
+ );
237
+ }
238
+ return value;
239
+ }
186
240
 
187
241
  function parseTrustZoneKindFilter(raw: string | null): TrustZoneRecordKind | undefined {
188
242
  if (raw === null) return undefined;
@@ -283,6 +337,8 @@ export class EngramAccessHttpServer {
283
337
  private readonly authToken?: string;
284
338
  private readonly authTokens: string[];
285
339
  private readonly authTokensGetter?: () => string[];
340
+ private readonly authTokenEntriesGetter?: () => ReadonlyArray<{ token: string; connector?: string }>;
341
+ private readonly tokenPathPolicy?: (connector: string, pathname: string | undefined) => boolean;
286
342
  private readonly authenticatedPrincipal?: string;
287
343
  private readonly maxBodyBytes: number;
288
344
  private readonly adminConsoleEnabled: boolean;
@@ -292,6 +348,12 @@ export class EngramAccessHttpServer {
292
348
  private readonly trustPrincipalHeader: boolean;
293
349
  private readonly adapterRegistry: AdapterRegistry | null;
294
350
  private readonly readiness: () => AccessHttpReadinessState;
351
+ private readonly resourceMetadataUrl?: string;
352
+ private readonly externalRequestHandler?: (
353
+ req: IncomingMessage,
354
+ res: ServerResponse,
355
+ ctx: { authorized: boolean },
356
+ ) => Promise<boolean>;
295
357
  private readonly writeRequestTimestamps: number[] = [];
296
358
  private readonly mcpServer: EngramMcpServer;
297
359
  private server: Server | null = null;
@@ -323,6 +385,8 @@ export class EngramAccessHttpServer {
323
385
  this.authToken = options.authToken?.trim() || undefined;
324
386
  this.authTokens = (options.authTokens ?? []).map((t) => t.trim()).filter(Boolean);
325
387
  this.authTokensGetter = options.authTokensGetter;
388
+ this.authTokenEntriesGetter = options.authTokenEntriesGetter;
389
+ this.tokenPathPolicy = options.tokenPathPolicy;
326
390
  this.authenticatedPrincipal = options.principal?.trim() || undefined;
327
391
  this.maxBodyBytes = Number.isFinite(options.maxBodyBytes)
328
392
  ? Math.max(1, Math.floor(options.maxBodyBytes ?? 131072))
@@ -333,6 +397,8 @@ export class EngramAccessHttpServer {
333
397
  this.adminControls = options.adminControls;
334
398
  this.trustPrincipalHeader = options.trustPrincipalHeader === true;
335
399
  this.readiness = options.readiness ?? (() => ({ ready: true, warmupAttempts: 0 }));
400
+ this.resourceMetadataUrl = assertResourceMetadataUrl(options.resourceMetadataUrl);
401
+ this.externalRequestHandler = options.externalRequestHandler;
336
402
  this.adapterRegistry = options.enableAdapters !== false
337
403
  ? (options.adapterRegistry ?? new AdapterRegistry())
338
404
  : null;
@@ -351,7 +417,7 @@ export class EngramAccessHttpServer {
351
417
  }
352
418
 
353
419
  async start(): Promise<EngramAccessHttpServerStatus> {
354
- if (!this.authToken && this.authTokens.length === 0 && !this.authTokensGetter) {
420
+ if (!this.authToken && this.authTokens.length === 0 && !this.authTokensGetter && !this.authTokenEntriesGetter) {
355
421
  throw new Error("engram access HTTP requires authToken or authTokens");
356
422
  }
357
423
  if (this.server) return this.status();
@@ -642,11 +708,40 @@ export class EngramAccessHttpServer {
642
708
 
643
709
  }
644
710
 
711
+ // Run any host-supplied pre-auth request handler. It runs AFTER the
712
+ // admin-console branch (admin assets are public) and BEFORE the
713
+ // operator bearer gate. The handler decides whether it has fully
714
+ // owned the response (return true) or wants the request to fall
715
+ // through to the normal pipeline. `ctx.authorized` is computed
716
+ // here so the handler can implement operator-only endpoints
717
+ // (e.g. /oauth/pending) without owning token validation.
718
+ if (this.externalRequestHandler) {
719
+ const authorized = this.isAuthorized(req, pathname);
720
+ if (await this.externalRequestHandler(req, res, { authorized })) {
721
+ return;
722
+ }
723
+ }
724
+
645
725
  if (!this.isAuthorized(req, pathname)) {
646
726
  const body = JSON.stringify({ error: "unauthorized", code: "unauthorized" });
647
727
  res.writeHead(401, {
648
728
  "content-type": "application/json; charset=utf-8",
649
- "www-authenticate": "Bearer",
729
+ "www-authenticate": this.bearerChallenge(),
730
+ "x-request-id": correlationId,
731
+ });
732
+ res.end(body);
733
+ return;
734
+ }
735
+
736
+ // Method-conformance for the streamable-HTTP MCP endpoint:
737
+ // GET/DELETE on /mcp must return 405 + Allow: POST instead of
738
+ // silently falling through to the generic 404. POST continues
739
+ // to the normal handler below.
740
+ if (pathname === "/mcp" && (req.method === "GET" || req.method === "DELETE")) {
741
+ const body = JSON.stringify({ error: "method_not_allowed", code: "method_not_allowed" });
742
+ res.writeHead(405, {
743
+ "content-type": "application/json; charset=utf-8",
744
+ allow: "POST",
650
745
  "x-request-id": correlationId,
651
746
  });
652
747
  res.end(body);
@@ -2682,7 +2777,28 @@ export class EngramAccessHttpServer {
2682
2777
  }
2683
2778
 
2684
2779
  private async handleMcpRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
2780
+ // Reject requests that advertise an unknown MCP protocol version in
2781
+ // the streamable-HTTP `MCP-Protocol-Version` header. Absent or
2782
+ // valid → proceed. Unknown → 400 with a JSON-RPC-shaped error so
2783
+ // the client surfaces a clear message. The supported set is
2784
+ // exported by @remnic/core's access-mcp module to keep the
2785
+ // version policy in a single place.
2786
+ const headerVersion = req.headers["mcp-protocol-version"];
2787
+ if (typeof headerVersion === "string" && headerVersion.length > 0) {
2788
+ if (!(MCP_SUPPORTED_PROTOCOL_VERSIONS as readonly string[]).includes(headerVersion)) {
2789
+ this.respondJson(res, 400, {
2790
+ jsonrpc: "2.0",
2791
+ id: null,
2792
+ error: {
2793
+ code: -32000,
2794
+ message: `unsupported MCP-Protocol-Version: ${headerVersion}; supported: ${MCP_SUPPORTED_PROTOCOL_VERSIONS.join(", ")}`,
2795
+ },
2796
+ });
2797
+ return;
2798
+ }
2799
+ }
2685
2800
  const body = await this.readJsonBody(req);
2801
+
2686
2802
  const request = body as {
2687
2803
  jsonrpc?: string;
2688
2804
  id?: string | number | null;
@@ -3118,8 +3234,29 @@ export class EngramAccessHttpServer {
3118
3234
  return result.data as SchemaTypeFor<S>;
3119
3235
  }
3120
3236
 
3121
- private isAuthorized(req: IncomingMessage, pathname?: string): boolean {
3122
- if (!this.authToken && this.authTokens.length === 0 && !this.authTokensGetter) return false;
3237
+ /**
3238
+ * Build the WWW-Authenticate challenge string for 401 responses.
3239
+ * When `resourceMetadataUrl` is configured, includes the RFC 9728
3240
+ * `resource_metadata` parameter so MCP clients (e.g. ChatGPT) can
3241
+ * discover the OAuth 2.0 protected-resource metadata document.
3242
+ * Otherwise the bare `Bearer` challenge is returned (unchanged).
3243
+ */
3244
+ private bearerChallenge(): string {
3245
+ if (this.resourceMetadataUrl) {
3246
+ return `Bearer resource_metadata="${this.resourceMetadataUrl}"`;
3247
+ }
3248
+ return "Bearer";
3249
+ }
3250
+
3251
+ private isAuthorized(req: IncomingMessage, pathname?: string): boolean {
3252
+ if (
3253
+ !this.authToken &&
3254
+ this.authTokens.length === 0 &&
3255
+ !this.authTokensGetter &&
3256
+ !this.authTokenEntriesGetter
3257
+ ) {
3258
+ return false;
3259
+ }
3123
3260
  // Primary path: Authorization: Bearer <token> header.
3124
3261
  const raw = req.headers.authorization;
3125
3262
  let candidate: string | null = null;
@@ -3159,7 +3296,24 @@ export class EngramAccessHttpServer {
3159
3296
  for (const valid of this.authTokens) {
3160
3297
  if (this.timingSafeStringEqual(token, valid)) return true;
3161
3298
  }
3162
- // Check dynamic tokens (reloaded per request for generate/revoke without restart)
3299
+ // Entry-based dynamic tokens are AUTHORITATIVE when configured: the
3300
+ // dynamic-token decision ends here (no fall-through to the string
3301
+ // getter, which carries no identity and would bypass the policy).
3302
+ // Validation and connector identity come from the same snapshot entry,
3303
+ // so a scope policy can never observe a token fresher than the
3304
+ // identity it scopes (mint/revoke coherence).
3305
+ if (this.authTokenEntriesGetter) {
3306
+ for (const entry of this.authTokenEntriesGetter()) {
3307
+ if (!this.timingSafeStringEqual(token, entry.token)) continue;
3308
+ if (!this.tokenPathPolicy) return true;
3309
+ // Fail closed: a policy without a connector identity denies.
3310
+ if (typeof entry.connector !== "string" || entry.connector.length === 0) return false;
3311
+ return this.tokenPathPolicy(entry.connector, pathname);
3312
+ }
3313
+ return false;
3314
+ }
3315
+ // String-token getter (no identity, no policy) — only consulted when
3316
+ // no entry getter is configured.
3163
3317
  if (this.authTokensGetter) {
3164
3318
  for (const valid of this.authTokensGetter()) {
3165
3319
  if (this.timingSafeStringEqual(token, valid)) return true;
package/src/access-mcp.ts CHANGED
@@ -78,7 +78,79 @@ type McpResource = {
78
78
  _meta?: Record<string, unknown>;
79
79
  };
80
80
 
81
- const MCP_PROTOCOL_VERSION = "2024-11-05";
81
+ /**
82
+ * Conservative allowlist of canonical MCP tool suffixes that are
83
+ * unambiguously read-only. Tools in this set are tagged with
84
+ * `annotations: { readOnlyHint: true }` so ChatGPT (and other MCP
85
+ * clients that honor the hint) can skip per-call confirmation.
86
+ *
87
+ * The list is suffix-based so it covers both the `remnic.*` and
88
+ * `engram.*` naming forms. Anything not on it stays unannotated:
89
+ * uncertainty is resolved as "might mutate".
90
+ *
91
+ * Excluded by construction: anything that writes, runs a pipeline,
92
+ * flushes, applies, records, imports, or destructively deletes.
93
+ */
94
+ const MCP_READ_ONLY_TOOL_SUFFIXES: Readonly<Record<string, true>> = {
95
+ recall: true,
96
+ recall_explain: true,
97
+ recall_tier_explain: true,
98
+ recall_xray: true,
99
+ briefing: true,
100
+ wearables_status: true,
101
+ transcript_day: true,
102
+ transcript_search: true,
103
+ transcript_memories: true,
104
+ action_confidence: true,
105
+ capsule_list: true,
106
+ procedural_stats: true,
107
+ memory_get: true,
108
+ memory_timeline: true,
109
+ entity_get: true,
110
+ review_queue_list: true,
111
+ lcm_search: true,
112
+ continuity_incident_list: true,
113
+ identity_anchor_get: true,
114
+ memory_identity: true,
115
+ memory_search: true,
116
+ memory_profile: true,
117
+ memory_entities_list: true,
118
+ memory_questions: true,
119
+ memory_last_recall: true,
120
+ memory_intent_debug: true,
121
+ memory_qmd_debug: true,
122
+ memory_graph_explain: true,
123
+ graph_snapshot: true,
124
+ review_list: true,
125
+ profiling_report: true,
126
+ peer_list: true,
127
+ peer_get: true,
128
+ peer_profile_get: true,
129
+ console_state: true,
130
+ dreams_status: true,
131
+ codegraph_list_projects: true,
132
+ codegraph_index_status: true,
133
+ codegraph_search_graph: true,
134
+ codegraph_trace_path: true,
135
+ codegraph_detect_changes: true,
136
+ codegraph_query_graph: true,
137
+ codegraph_get_schema: true,
138
+ codegraph_get_snippet: true,
139
+ codegraph_get_architecture: true,
140
+ codegraph_search_code: true,
141
+ };
142
+
143
+ /**
144
+ * MCP protocol versions this server understands, ordered newest → oldest.
145
+ * Exported so the HTTP transport can validate the `MCP-Protocol-Version`
146
+ * header and reject requests advertising an unknown version.
147
+ */
148
+ export const MCP_SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = [
149
+ "2025-06-18",
150
+ "2025-03-26",
151
+ "2024-11-05",
152
+ ];
153
+ const MCP_DEFAULT_PROTOCOL_VERSION: string = MCP_SUPPORTED_PROTOCOL_VERSIONS[0] ?? "2025-06-18";
82
154
  const LEGACY_MCP_PREFIX = "engram.";
83
155
  const CANONICAL_MCP_PREFIX = "remnic.";
84
156
 
@@ -87,13 +159,27 @@ function toCanonicalToolName(name: string): string {
87
159
  ? `${CANONICAL_MCP_PREFIX}${name.slice(LEGACY_MCP_PREFIX.length)}`
88
160
  : name;
89
161
  }
90
-
91
162
  function toLegacyToolName(name: string): string {
92
163
  return name.startsWith(CANONICAL_MCP_PREFIX)
93
164
  ? `${LEGACY_MCP_PREFIX}${name.slice(CANONICAL_MCP_PREFIX.length)}`
94
165
  : name;
95
166
  }
96
167
 
168
+ /**
169
+ * Suffix-based allowlist matcher. Returns true for tools whose canonical
170
+ * suffix (after stripping `remnic.` or `engram.`) is in
171
+ * {@link MCP_READ_ONLY_TOOL_SUFFIXES}. Unprefixed names are treated as
172
+ * unannotated.
173
+ */
174
+ function isReadOnlyToolName(name: string): boolean {
175
+ for (const prefix of [CANONICAL_MCP_PREFIX, LEGACY_MCP_PREFIX]) {
176
+ if (name.startsWith(prefix)) {
177
+ return MCP_READ_ONLY_TOOL_SUFFIXES[name.slice(prefix.length)] === true;
178
+ }
179
+ }
180
+ return false;
181
+ }
182
+
97
183
  function withToolAliases(tool: McpTool, emitLegacyTools = true): McpTool[] {
98
184
  const canonicalName = toCanonicalToolName(tool.name);
99
185
  const canonicalTool = canonicalName === tool.name ? tool : { ...tool, name: canonicalName };
@@ -2342,6 +2428,17 @@ export class EngramMcpServer {
2342
2428
  );
2343
2429
  this.tools = [...this.tools, ...chatTools];
2344
2430
  }
2431
+ // Apply `readOnlyHint` annotations to the conservative read-only
2432
+ // allowlist. Done as a final pass so every spread (chat, codegraph,
2433
+ // coding_*, correction, etc.) inherits the annotation without
2434
+ // scattering the same logic across every `withToolAliases` call site.
2435
+ // Suffix-based matching covers both the `remnic.*` and `engram.*`
2436
+ // naming forms emitted by `withToolAliases`.
2437
+ this.tools = this.tools.map((tool) =>
2438
+ isReadOnlyToolName(tool.name) && tool.annotations?.readOnlyHint !== true
2439
+ ? { ...tool, annotations: { ...(tool.annotations ?? {}), readOnlyHint: true } }
2440
+ : tool,
2441
+ );
2345
2442
  }
2346
2443
 
2347
2444
  /** Get clientInfo for a specific MCP session. Returns undefined for non-MCP requests. */
@@ -2369,6 +2466,24 @@ export class EngramMcpServer {
2369
2466
  }
2370
2467
  if (method === "initialize") {
2371
2468
  const params = request.params ?? {};
2469
+ // MCP initialize REQUIRES params.protocolVersion (string). Reject a
2470
+ // missing/mistyped field with JSON-RPC invalid params instead of
2471
+ // silently negotiating (repo rule: never reinterpret invalid input).
2472
+ // An unsupported-but-well-formed version gets the spec-mandated
2473
+ // counter-offer below instead: the server answers with the newest
2474
+ // version it supports and the client decides whether to proceed.
2475
+ if (typeof params.protocolVersion !== "string" || params.protocolVersion.length === 0) {
2476
+ return {
2477
+ jsonrpc: "2.0",
2478
+ id,
2479
+ error: {
2480
+ code: -32602,
2481
+ message:
2482
+ "initialize requires params.protocolVersion (string); " +
2483
+ `supported versions: ${MCP_SUPPORTED_PROTOCOL_VERSIONS.join(", ")}`,
2484
+ },
2485
+ };
2486
+ }
2372
2487
  const rawClientInfo = params.clientInfo as { name?: string; version?: string } | undefined;
2373
2488
  // Generate a server-side session ID for this MCP session.
2374
2489
  // The caller should send this back as Mcp-Session-Id on subsequent requests.
@@ -2391,7 +2506,9 @@ export class EngramMcpServer {
2391
2506
  jsonrpc: "2.0",
2392
2507
  id,
2393
2508
  result: {
2394
- protocolVersion: MCP_PROTOCOL_VERSION,
2509
+ protocolVersion: MCP_SUPPORTED_PROTOCOL_VERSIONS.includes(params.protocolVersion)
2510
+ ? params.protocolVersion
2511
+ : MCP_DEFAULT_PROTOCOL_VERSION,
2395
2512
  capabilities: {
2396
2513
  tools: {},
2397
2514
  resources: {},
package/src/index.ts CHANGED
@@ -977,11 +977,14 @@ export {
977
977
  // ---------------------------------------------------------------------------
978
978
 
979
979
  export {
980
+ buildTokenEntry,
981
+ commitTokenEntry,
980
982
  generateToken,
981
983
  listTokens,
982
984
  revokeToken,
983
985
  getAllValidTokens,
984
986
  getAllValidTokensCached,
987
+ getAllValidTokenEntriesCached,
985
988
  resolveConnectorFromToken,
986
989
  loadTokenStore,
987
990
  saveTokenStore,
@@ -8,6 +8,7 @@ import {
8
8
  buildTokenEntry,
9
9
  commitTokenEntry,
10
10
  generateToken,
11
+ getAllValidTokenEntriesCached,
11
12
  getAllValidTokensCached,
12
13
  loadTokenStore,
13
14
  revokeToken,
@@ -208,3 +209,60 @@ test("generateToken uses a recognizable prefix for the omp connector", async ()
208
209
  await rm(dir, { recursive: true, force: true });
209
210
  }
210
211
  });
212
+
213
+ test("generateToken uses a recognizable prefix for the chatgpt connector", async () => {
214
+ const { dir, tokensPath } = await makeTempTokenPath();
215
+ try {
216
+ const entry = generateToken("chatgpt", tokensPath);
217
+ assert.equal(entry.connector, "chatgpt");
218
+ assert.ok(
219
+ entry.token.startsWith("remnic_cg_"),
220
+ `expected remnic_cg_ prefix, got ${entry.token}`,
221
+ );
222
+ } finally {
223
+ await rm(dir, { recursive: true, force: true });
224
+ }
225
+ });
226
+
227
+ test("commitTokenEntry replaces the prior chatgpt entry so re-linking rotates the token", async () => {
228
+ const { dir, tokensPath } = await makeTempTokenPath();
229
+ try {
230
+ const first = generateToken("chatgpt", tokensPath);
231
+ commitTokenEntry(first, tokensPath);
232
+ const second = generateToken("chatgpt", tokensPath);
233
+ commitTokenEntry(second, tokensPath);
234
+ assert.notEqual(first.token, second.token, "re-linking must mint a new token");
235
+ const store = loadTokenStore(tokensPath);
236
+ const chatgptEntries = store.tokens.filter((t) => t.connector === "chatgpt");
237
+ assert.equal(chatgptEntries.length, 1, "only one chatgpt entry survives the rotate");
238
+ assert.equal(chatgptEntries[0]?.token, second.token, "the new token is the surviving one");
239
+ } finally {
240
+ await rm(dir, { recursive: true, force: true });
241
+ }
242
+ });
243
+
244
+ test("entries snapshot is coherent with validation: mint and revoke take effect immediately", async () => {
245
+ const { dir, tokensPath } = await makeTempTokenPath();
246
+ try {
247
+ // Warm the cache so a stale snapshot WOULD be observable if mutation
248
+ // failed to invalidate it.
249
+ assert.deepEqual([...getAllValidTokenEntriesCached(tokensPath)], []);
250
+
251
+ // Mint-then-use: the fresh token resolves with its connector in the
252
+ // SAME snapshot call that validates it — no window where the token is
253
+ // valid but its identity is unknown.
254
+ const minted = generateToken("chatgpt", tokensPath);
255
+ const afterMint = getAllValidTokenEntriesCached(tokensPath);
256
+ const found = afterMint.find((entry) => entry.token === minted.token);
257
+ assert.ok(found, "freshly minted token must appear immediately");
258
+ assert.equal(found.connector, "chatgpt");
259
+ assert.deepEqual(getAllValidTokensCached(tokensPath), [minted.token]);
260
+
261
+ // Revoke-then-use: the token disappears from the snapshot immediately.
262
+ assert.equal(revokeToken("chatgpt", tokensPath), true);
263
+ assert.deepEqual([...getAllValidTokenEntriesCached(tokensPath)], []);
264
+ assert.deepEqual(getAllValidTokensCached(tokensPath), []);
265
+ } finally {
266
+ await rm(dir, { recursive: true, force: true });
267
+ }
268
+ });
package/src/tokens.ts CHANGED
@@ -36,6 +36,7 @@ const TOKEN_PREFIXES: Record<string, string> = {
36
36
  "windsurf": "remnic_ws_",
37
37
  "amp": "remnic_am_",
38
38
  "generic-mcp": "remnic_gm_",
39
+ "chatgpt": "remnic_cg_",
39
40
  };
40
41
 
41
42
  function defaultTokensPath(): string {
@@ -260,26 +261,36 @@ export function getAllValidTokens(tokensPath?: string): string[] {
260
261
  return loadTokenStore(tokensPath).tokens.map((t) => t.token);
261
262
  }
262
263
 
263
- // Cached token loader to avoid synchronous disk I/O on every HTTP request.
264
- // Re-reads tokens.json at most once per TTL interval (default 5s).
264
+ // Cached token-entry snapshot to avoid synchronous disk I/O on every HTTP
265
+ // request. Re-reads tokens.json at most once per TTL interval (default 5s).
266
+ // There is deliberately exactly ONE cache: validation (token strings) and
267
+ // identity (connector ids) are derived from the SAME snapshot, so a token
268
+ // can never validate against a fresher snapshot than the one that resolves
269
+ // its connector. saveTokenStore() invalidates on every mutation, so a
270
+ // freshly minted or revoked token is coherent immediately.
265
271
  const TOKEN_CACHE_TTL_MS = 5_000;
266
- let _cachedTokens: string[] = [];
272
+ let _cachedEntries: TokenEntry[] = [];
267
273
  let _cachedAt = 0;
268
274
  let _cachedPath: string | undefined;
269
275
 
270
276
  function invalidateTokenCache(): void {
271
- _cachedTokens = [];
277
+ _cachedEntries = [];
272
278
  _cachedAt = 0;
273
279
  _cachedPath = undefined;
274
280
  }
275
281
 
276
- export function getAllValidTokensCached(tokensPath?: string): string[] {
282
+ /** Cached token-entry snapshot ({token, connector} pairs). */
283
+ export function getAllValidTokenEntriesCached(tokensPath?: string): readonly TokenEntry[] {
277
284
  const now = Date.now();
278
- if (now - _cachedAt < TOKEN_CACHE_TTL_MS && tokensPath === _cachedPath) return _cachedTokens;
279
- _cachedTokens = getAllValidTokens(tokensPath);
285
+ if (now - _cachedAt < TOKEN_CACHE_TTL_MS && tokensPath === _cachedPath) return _cachedEntries;
286
+ _cachedEntries = loadTokenStore(tokensPath).tokens;
280
287
  _cachedAt = now;
281
288
  _cachedPath = tokensPath;
282
- return _cachedTokens;
289
+ return _cachedEntries;
290
+ }
291
+
292
+ export function getAllValidTokensCached(tokensPath?: string): string[] {
293
+ return getAllValidTokenEntriesCached(tokensPath).map((entry) => entry.token);
283
294
  }
284
295
 
285
296
  export function resolveConnectorFromToken(token: string, tokensPath?: string): string | undefined {