@mycadet/gateway 0.12.9 → 0.12.11

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 (33) hide show
  1. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
  2. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
  3. package/node_modules/@mycadet/ces-client/src/http-credentials.ts +18 -1
  4. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
  5. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
  6. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
  7. package/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
  8. package/package.json +1 -1
  9. package/src/__tests__/credential-reader.test.ts +1 -1
  10. package/src/__tests__/credential-watcher.test.ts +1 -1
  11. package/src/__tests__/guardian-bootstrap-lookups.test.ts +4 -4
  12. package/src/__tests__/guardian-init-lockfile.test.ts +2 -2
  13. package/src/__tests__/remote-web-pairing-challenge.test.ts +37 -0
  14. package/src/__tests__/remote-web-pairing-token.test.ts +1 -1
  15. package/src/auth/guardian-bootstrap.ts +1 -1
  16. package/src/cli/__tests__/pairing.test.ts +131 -0
  17. package/src/cli/index.ts +12 -1
  18. package/src/cli/pairing.ts +71 -0
  19. package/src/credential-reader.ts +1 -1
  20. package/src/http/routes/guardian-pin.ts +2 -1
  21. package/src/http/routes/ingress-domains.test.ts +5 -2
  22. package/src/http/routes/ingress-domains.ts +2 -8
  23. package/src/http/routes/live-voice-websocket.ts +2 -1
  24. package/src/http/routes/plugin-webhook-websocket.test.ts +33 -40
  25. package/src/http/routes/plugin-webhook-websocket.ts +2 -1
  26. package/src/http/routes/remote-web-pairing-challenge.ts +8 -1
  27. package/src/http/routes/runtime-audio-stream.ts +2 -1
  28. package/src/http/routes/speech-relay-websocket.ts +4 -3
  29. package/src/http/utils.ts +3 -0
  30. package/src/index.ts +4 -3
  31. package/src/remote-web/pairing-challenge-rate-limit-store.ts +94 -0
  32. package/src/remote-web/pairing-challenge-store.ts +130 -2
  33. package/src/remote-web/pairing-mint.ts +94 -0
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Loopback-guarded recording of a tunnel's public URL.
2
+ * Guardian-authed recording of a tunnel's public URL.
3
3
  *
4
4
  * POST /v1/ingress/domains — the CLI (its ngrok agent, today) tells the
5
5
  * gateway the public URL its tunnel published; the gateway writes
@@ -19,11 +19,7 @@ import { normalizeHttpPublicBaseUrlWithoutTrailingSlash } from "@mycadet/service
19
19
 
20
20
  import type { ConfigFileCache } from "../../config-file-cache.js";
21
21
  import { writeManagedPublicBaseUrl } from "../../tunnel/client.js";
22
- import {
23
- enforceLoopbackOnly,
24
- errorResponse,
25
- rejectBrowserOrigin,
26
- } from "../loopback-guard.js";
22
+ import { errorResponse, rejectBrowserOrigin } from "../loopback-guard.js";
27
23
 
28
24
  const SaveIngressDomainRequestSchema = z.object({
29
25
  publicBaseUrl: z.string(),
@@ -41,8 +37,6 @@ export async function handleSaveIngressDomain(
41
37
  });
42
38
  }
43
39
 
44
- const guardError = enforceLoopbackOnly(req, clientIp, "ingress-domains");
45
- if (guardError) return guardError;
46
40
  const originError = rejectBrowserOrigin(req, clientIp, "ingress-domains");
47
41
  if (originError) return originError;
48
42
 
@@ -16,6 +16,7 @@ import {
16
16
  requireBoundGuardian,
17
17
  requireManagedGuardian,
18
18
  } from "./guardian-pin.js";
19
+ import { isWebsocketUpgradeRequest } from "../utils.js";
19
20
 
20
21
  const log = getLogger("live-voice-ws");
21
22
 
@@ -53,7 +54,7 @@ export function createLiveVoiceWebsocketHandler(config: GatewayConfig) {
53
54
  req: Request,
54
55
  server: import("bun").Server<unknown>,
55
56
  ): Promise<Response | undefined> {
56
- if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") {
57
+ if (!isWebsocketUpgradeRequest(req)) {
57
58
  return new Response("Upgrade Required", { status: 426 });
58
59
  }
59
60
 
@@ -13,6 +13,7 @@ import {
13
13
  isPluginWebhookSocketData,
14
14
  type PluginWebhookSocketData,
15
15
  } from "./plugin-webhook-websocket.js";
16
+ import { IngressRoute } from "../../channels/plugin-ingress.js";
16
17
 
17
18
  initSigningKey(Buffer.from("test-signing-key-at-least-32-bytes-long"));
18
19
 
@@ -24,7 +25,7 @@ const CONFIG = {
24
25
 
25
26
  const PLUGIN_SECRET = "plugin-webhook-secret";
26
27
  const CADET_SECRET = "platform-webhook-secret";
27
- const WS_PATH = "/webhooks/plugins/meeting-bot/realtime";
28
+ const WS_PATH = "/webhooks/plugins/example/realtime";
28
29
 
29
30
  const ROUTE = {
30
31
  path: "realtime",
@@ -43,7 +44,7 @@ function credentialsFor(entries: Record<string, string>) {
43
44
  }
44
45
 
45
46
  const CREDENTIALS = credentialsFor({
46
- "credential/meeting-bot/webhook_secret": PLUGIN_SECRET,
47
+ "credential/example/webhook_secret": PLUGIN_SECRET,
47
48
  "credential/cadet/webhook_secret": CADET_SECRET,
48
49
  });
49
50
 
@@ -53,17 +54,9 @@ function resolution(
53
54
  return { approved: [], pending: [], problems: [], ...overrides };
54
55
  }
55
56
 
56
- function approvedWith(
57
- routes: {
58
- path: string;
59
- kind: "http" | "websocket";
60
- signer: "plugin" | "cadet";
61
- handshake: "signed-headers" | "signed-query";
62
- description: string;
63
- }[],
64
- ): PluginIngressResolution {
57
+ function approvedWith(routes: IngressRoute[]): PluginIngressResolution {
65
58
  return resolution({
66
- approved: [{ plugin: "meeting-bot", routes, digest: "d".repeat(32) }],
59
+ approved: [{ plugin: "example", routes, digest: "d".repeat(32) }],
67
60
  });
68
61
  }
69
62
 
@@ -128,13 +121,13 @@ describe("upgrade gate", () => {
128
121
  const res = await handle(
129
122
  upgradeRequest(),
130
123
  server,
131
- "meeting-bot",
124
+ "example",
132
125
  "realtime",
133
126
  );
134
127
 
135
128
  expect(res).toBeUndefined();
136
129
  expect(upgrades).toHaveLength(1);
137
- expect(upgrades[0]!.plugin).toBe("meeting-bot");
130
+ expect(upgrades[0]!.plugin).toBe("example");
138
131
  expect(isPluginWebhookSocketData(upgrades[0])).toBe(true);
139
132
  });
140
133
 
@@ -145,7 +138,7 @@ describe("upgrade gate", () => {
145
138
  const res = await handle(
146
139
  upgradeRequest({ upgrade: false }),
147
140
  server,
148
- "meeting-bot",
141
+ "example",
149
142
  "realtime",
150
143
  );
151
144
 
@@ -158,7 +151,7 @@ describe("upgrade gate", () => {
158
151
  resolve: () =>
159
152
  resolution({
160
153
  pending: [
161
- { plugin: "meeting-bot", routes: [ROUTE], digest: "d".repeat(32) },
154
+ { plugin: "example", routes: [ROUTE], digest: "d".repeat(32) },
162
155
  ],
163
156
  }),
164
157
  });
@@ -167,7 +160,7 @@ describe("upgrade gate", () => {
167
160
  const res = await handle(
168
161
  upgradeRequest(),
169
162
  server,
170
- "meeting-bot",
163
+ "example",
171
164
  "realtime",
172
165
  );
173
166
 
@@ -183,7 +176,7 @@ describe("upgrade gate", () => {
183
176
  resolution({
184
177
  pending: [
185
178
  {
186
- plugin: "meeting-bot",
179
+ plugin: "example",
187
180
  routes: [{ ...ROUTE, signer: "cadet" as const }],
188
181
  digest: "d".repeat(32),
189
182
  },
@@ -195,7 +188,7 @@ describe("upgrade gate", () => {
195
188
  const res = await handle(
196
189
  upgradeRequest({ secret: CADET_SECRET }),
197
190
  server,
198
- "meeting-bot",
191
+ "example",
199
192
  "realtime",
200
193
  );
201
194
 
@@ -210,7 +203,7 @@ describe("upgrade gate", () => {
210
203
  resolution({
211
204
  pending: [
212
205
  {
213
- plugin: "meeting-bot",
206
+ plugin: "example",
214
207
  routes: [{ ...ROUTE, signer: "cadet" as const }],
215
208
  digest: "d".repeat(32),
216
209
  },
@@ -222,7 +215,7 @@ describe("upgrade gate", () => {
222
215
  const res = await handle(
223
216
  upgradeRequest({ secret: PLUGIN_SECRET }),
224
217
  server,
225
- "meeting-bot",
218
+ "example",
226
219
  "realtime",
227
220
  );
228
221
 
@@ -240,7 +233,7 @@ describe("upgrade gate", () => {
240
233
  const res = await handle(
241
234
  upgradeRequest(),
242
235
  server,
243
- "meeting-bot",
236
+ "example",
244
237
  "realtime",
245
238
  );
246
239
 
@@ -255,7 +248,7 @@ describe("upgrade gate", () => {
255
248
  const res = await handle(
256
249
  upgradeRequest({ path: "/webhooks/plugins/meeting-bot/admin" }),
257
250
  server,
258
- "meeting-bot",
251
+ "example",
259
252
  "admin",
260
253
  );
261
254
 
@@ -274,7 +267,7 @@ describe("upgrade gate", () => {
274
267
  const res = await handle(
275
268
  upgradeRequest(),
276
269
  server,
277
- "meeting-bot",
270
+ "example",
278
271
  "realtime",
279
272
  );
280
273
 
@@ -291,7 +284,7 @@ describe("handshake signature", () => {
291
284
  const res = await handle(
292
285
  upgradeRequest({ secret: "" }),
293
286
  server,
294
- "meeting-bot",
287
+ "example",
295
288
  "realtime",
296
289
  );
297
290
 
@@ -306,7 +299,7 @@ describe("handshake signature", () => {
306
299
  const res = await handle(
307
300
  upgradeRequest({ secret: "nope" }),
308
301
  server,
309
- "meeting-bot",
302
+ "example",
310
303
  "realtime",
311
304
  );
312
305
 
@@ -324,7 +317,7 @@ describe("handshake signature", () => {
324
317
  timestamp: String(Math.floor(Date.now() / 1000) - 3600),
325
318
  }),
326
319
  server,
327
- "meeting-bot",
320
+ "example",
328
321
  "realtime",
329
322
  );
330
323
 
@@ -339,7 +332,7 @@ describe("handshake signature", () => {
339
332
  const res = await handle(
340
333
  upgradeRequest({ timestamp: "" }),
341
334
  server,
342
- "meeting-bot",
335
+ "example",
343
336
  "realtime",
344
337
  );
345
338
 
@@ -355,7 +348,7 @@ describe("handshake signature", () => {
355
348
  const res = await handle(
356
349
  upgradeRequest({ signPath: "/webhooks/plugins/meeting-bot/other" }),
357
350
  server,
358
- "meeting-bot",
351
+ "example",
359
352
  "realtime",
360
353
  );
361
354
 
@@ -370,7 +363,7 @@ describe("handshake signature", () => {
370
363
  const res = await handle(
371
364
  upgradeRequest(),
372
365
  server,
373
- "meeting-bot",
366
+ "example",
374
367
  "realtime",
375
368
  );
376
369
 
@@ -388,7 +381,7 @@ describe("handshake signature", () => {
388
381
  await handle(
389
382
  upgradeRequest({ secret: CADET_SECRET }),
390
383
  server,
391
- "meeting-bot",
384
+ "example",
392
385
  "realtime",
393
386
  ),
394
387
  ).toBeUndefined();
@@ -397,7 +390,7 @@ describe("handshake signature", () => {
397
390
  const rejected = await handle(
398
391
  upgradeRequest({ secret: PLUGIN_SECRET }),
399
392
  server,
400
- "meeting-bot",
393
+ "example",
401
394
  "realtime",
402
395
  );
403
396
  expect(rejected?.status).toBe(403);
@@ -433,7 +426,7 @@ describe("signed-query handshake", () => {
433
426
  const res = await handle(
434
427
  signedQueryRequest(),
435
428
  server,
436
- "meeting-bot",
429
+ "example",
437
430
  "realtime",
438
431
  );
439
432
 
@@ -454,7 +447,7 @@ describe("signed-query handshake", () => {
454
447
  const res = await handle(
455
448
  new Request(url.toString(), { headers: { upgrade: "websocket" } }),
456
449
  server,
457
- "meeting-bot",
450
+ "example",
458
451
  "realtime",
459
452
  );
460
453
 
@@ -469,7 +462,7 @@ describe("signed-query handshake", () => {
469
462
  const res = await handle(
470
463
  signedQueryRequest({ secret: "not-the-plugin-secret" }),
471
464
  server,
472
- "meeting-bot",
465
+ "example",
473
466
  "realtime",
474
467
  );
475
468
 
@@ -487,7 +480,7 @@ describe("signed-query handshake", () => {
487
480
  const res = await handle(
488
481
  signedQueryRequest(),
489
482
  server,
490
- "meeting-bot",
483
+ "example",
491
484
  "realtime",
492
485
  );
493
486
 
@@ -503,7 +496,7 @@ describe("signed-query handshake", () => {
503
496
  const res = await handle(
504
497
  upgradeRequest({ secret: PLUGIN_SECRET }),
505
498
  server,
506
- "meeting-bot",
499
+ "example",
507
500
  "realtime",
508
501
  );
509
502
 
@@ -523,7 +516,7 @@ describe("signed-query handshake", () => {
523
516
  const res = await handle(
524
517
  new Request(url.toString()),
525
518
  server,
526
- "meeting-bot",
519
+ "example",
527
520
  "realtime",
528
521
  );
529
522
 
@@ -551,7 +544,7 @@ function socketData(
551
544
  return {
552
545
  wsType: "plugin-webhook",
553
546
  config: CONFIG,
554
- plugin: "meeting-bot",
547
+ plugin: "example",
555
548
  path: "realtime",
556
549
  queue: [],
557
550
  queuedBytes: 0,
@@ -591,7 +584,7 @@ describe("frame delivery", () => {
591
584
  expect(calls).toHaveLength(1);
592
585
  expect(calls[0]!.method).toBe("user_route_post");
593
586
  expect(calls[0]!.params).toMatchObject({
594
- pathParams: { path: "plugins/meeting-bot/realtime" },
587
+ pathParams: { path: "plugins/example/realtime" },
595
588
  });
596
589
  // The frame travels as the raw body, byte-for-byte.
597
590
  expect(new TextDecoder().decode(calls[0]!.binary)).toBe(evt("joined"));
@@ -41,6 +41,7 @@ import {
41
41
  timestampWithinTolerance,
42
42
  verifyCadetSignature,
43
43
  } from "../cadet-signature.js";
44
+ import { isWebsocketUpgradeRequest } from "../utils.js";
44
45
 
45
46
  const log = getLogger("plugin-webhook-ws");
46
47
 
@@ -131,7 +132,7 @@ export function createPluginWebhookWebsocketHandler(deps: PluginWebhookWsDeps) {
131
132
  plugin: string,
132
133
  path: string,
133
134
  ): Promise<Response | undefined> {
134
- if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") {
135
+ if (!isWebsocketUpgradeRequest(req)) {
135
136
  return new Response("Upgrade Required", { status: 426 });
136
137
  }
137
138
 
@@ -21,7 +21,14 @@ import { methodNotAllowed, readJsonStringField } from "../route-helpers.js";
21
21
 
22
22
  const MAX_CHALLENGE_BODY_BYTES = 512;
23
23
 
24
- function parsePublicBaseUrl(value: string): string | null {
24
+ /**
25
+ * Normalize an advertised base URL to `origin + trailing-slash-stripped path`,
26
+ * or null when the value is empty, unparseable, non-http(s), or hostless.
27
+ * Shared with the direct pairing mint (remote-web/pairing-mint.ts), which
28
+ * validates the CLI-passed URL with the exact same rules the HTTP route
29
+ * applies.
30
+ */
31
+ export function parsePublicBaseUrl(value: string): string | null {
25
32
  if (!value.trim()) {
26
33
  return null;
27
34
  }
@@ -35,6 +35,7 @@ import {
35
35
  import { admitActorToken } from "../../auth/actor-token-revocation.js";
36
36
  import { parseSub } from "../../auth/subject.js";
37
37
  import type { GatewayConfig } from "../../config.js";
38
+ import { isWebsocketUpgradeRequest } from "../utils.js";
38
39
 
39
40
  /**
40
41
  * Cap on frames held while the upstream socket is still connecting, so a
@@ -83,7 +84,7 @@ export function authorizeRuntimeAudioStream(
83
84
  config: GatewayConfig,
84
85
  log: Logger,
85
86
  ): RuntimeAudioStreamAuth {
86
- if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") {
87
+ if (!isWebsocketUpgradeRequest(req)) {
87
88
  return {
88
89
  ok: false,
89
90
  response: new Response("Upgrade Required", { status: 426 }),
@@ -22,7 +22,7 @@
22
22
 
23
23
  import type { OutgoingHttpHeaders } from "node:http";
24
24
 
25
- import { tunnelHostForPlatformHost } from "@mycadet/service-contracts/ingress";
25
+ import { tunnelHostForCloudHost } from "@mycadet/service-contracts/ingress";
26
26
 
27
27
  import { resolveScopeProfile } from "../../auth/scopes.js";
28
28
  import { validateEdgeToken } from "../../auth/token-exchange.js";
@@ -33,6 +33,7 @@ import { getLogger } from "../../logger.js";
33
33
  import { getCloudBaseUrl } from "../../cloud-url.js";
34
34
  import { TUNNEL_FORWARDED_HEADER } from "../../tunnel/bridge-utils.js";
35
35
  import { requestHasTunnelBridgeAuth } from "../../tunnel/bridge-auth.js";
36
+ import { isWebsocketUpgradeRequest } from "../utils.js";
36
37
 
37
38
  const log = getLogger("speech-relay-ws");
38
39
 
@@ -162,7 +163,7 @@ function jsonError(status: number, code: string, detail: string): Response {
162
163
  * shared with the web live-voice client; this wrapper adds the scheme.
163
164
  */
164
165
  export function tunnelOriginForPlatformHost(host: string): string | null {
165
- const tunnelHost = tunnelHostForPlatformHost(host);
166
+ const tunnelHost = tunnelHostForCloudHost(host);
166
167
  return tunnelHost ? `https://${tunnelHost}` : null;
167
168
  }
168
169
 
@@ -325,7 +326,7 @@ export function createSpeechRelayUpgradeHandler(
325
326
 
326
327
  // Non-upgrade request: this is the daemon's dial-failure probe.
327
328
  // Replay the gate and hand back tunnel's own rejection.
328
- if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") {
329
+ if (!isWebsocketUpgradeRequest(req)) {
329
330
  const apiKey = await readAssistantApiKey(deps);
330
331
  if (!apiKey) {
331
332
  return jsonError(
@@ -0,0 +1,3 @@
1
+ export function isWebsocketUpgradeRequest(req: Request) {
2
+ return req.headers.get("upgrade")?.toLowerCase() === "websocket";
3
+ }
package/src/index.ts CHANGED
@@ -278,6 +278,7 @@ import {
278
278
  enablePublicIngress,
279
279
  } from "./tunnel/client.js";
280
280
  import { VERSION_HEADER_NAME, VERSION_HEADER_VALUE } from "./version.js";
281
+ import { isWebsocketUpgradeRequest } from "./http/utils.js";
281
282
 
282
283
  const log = getLogger("main");
283
284
 
@@ -889,11 +890,11 @@ async function main() {
889
890
  handler: () => psHandler.handlePs(),
890
891
  },
891
892
 
892
- // ── Ingress domain recording (localhost CLI only, bearer-authed) ──
893
+ // ── Ingress domain recording (guardian bearer, loopback fallback) ──
893
894
  {
894
895
  path: "/v1/ingress/domains",
895
896
  method: "POST",
896
- auth: "edge",
897
+ auth: "edge-guardian",
897
898
  handler: (req, _params, getClientIp) =>
898
899
  handleSaveIngressDomain(req, getClientIp(), configFileCache),
899
900
  },
@@ -2309,7 +2310,7 @@ async function main() {
2309
2310
  // Plugin ingress declared as `websocket`. Claimed only for a genuine
2310
2311
  // upgrade — a plain request to the same path stays with the route table,
2311
2312
  // where the HTTP half 404s it for not being an approved HTTP route.
2312
- if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
2313
+ if (isWebsocketUpgradeRequest(req)) {
2313
2314
  const match = url.pathname.match(PLUGIN_WEBHOOK_PATH_PATTERN);
2314
2315
  if (match) {
2315
2316
  const upgradeResult = await handlePluginWebhookWs(
@@ -1,5 +1,28 @@
1
+ /**
2
+ * Creation-rate limit for remote-web pairing challenges — 20 per minute,
3
+ * FILE-BACKED under GATEWAY_SECURITY_DIR so every minting process counts
4
+ * against the same window: the `gateway pairing mint` CLI runs in its own
5
+ * process (never over the gateway's IPC socket — the assistant daemon shares
6
+ * that socket), and the gateway's HTTP mint route runs in the server
7
+ * process. A per-process window would be meaningless for the CLI (a fresh
8
+ * process per mint), so the timestamps persist to disk and every call
9
+ * re-reads them.
10
+ */
11
+
12
+ import {
13
+ mkdirSync,
14
+ readFileSync,
15
+ renameSync,
16
+ rmSync,
17
+ writeFileSync,
18
+ } from "node:fs";
19
+ import { dirname, join } from "node:path";
20
+
21
+ import { getGatewaySecurityDir } from "../paths.js";
22
+
1
23
  const RATE_LIMIT_MAX_CHALLENGES = 20;
2
24
  const RATE_LIMIT_WINDOW_MS = 60_000;
25
+ const STORE_FILENAME = "remote-web-pairing-creation-rate.json";
3
26
 
4
27
  export interface RemoteWebPairingChallengeRateLimit {
5
28
  retryAfterSeconds: number;
@@ -7,7 +30,69 @@ export interface RemoteWebPairingChallengeRateLimit {
7
30
 
8
31
  const challengeCreationTimestamps: number[] = [];
9
32
 
33
+ let lastResolvedStorePath: string | null = null;
34
+
35
+ function rateLimitStoreFilePath(): string {
36
+ const resolved = join(getGatewaySecurityDir(), STORE_FILENAME);
37
+ lastResolvedStorePath = resolved;
38
+ return resolved;
39
+ }
40
+
41
+ function isNotExistError(err: unknown): boolean {
42
+ return err instanceof Error && "code" in err && err.code === "ENOENT";
43
+ }
44
+
45
+ /** Re-sync the timestamps from disk; a missing file is an empty window. */
46
+ function loadFromDisk(): void {
47
+ challengeCreationTimestamps.length = 0;
48
+
49
+ let raw: string;
50
+ try {
51
+ raw = readFileSync(rateLimitStoreFilePath(), "utf-8");
52
+ } catch (err) {
53
+ if (isNotExistError(err)) return;
54
+ throw err;
55
+ }
56
+
57
+ let parsed: unknown;
58
+ try {
59
+ parsed = JSON.parse(raw);
60
+ } catch {
61
+ console.warn(
62
+ `[pairing-challenge-rate-limit] could not read ${rateLimitStoreFilePath()} — starting empty.`,
63
+ );
64
+ return;
65
+ }
66
+ if (
67
+ typeof parsed !== "object" ||
68
+ parsed === null ||
69
+ !("timestamps" in parsed) ||
70
+ !Array.isArray(parsed.timestamps)
71
+ ) {
72
+ return;
73
+ }
74
+ for (const timestamp of parsed.timestamps) {
75
+ if (typeof timestamp === "number") {
76
+ challengeCreationTimestamps.push(timestamp);
77
+ }
78
+ }
79
+ }
80
+
81
+ /** Persist the current window atomically so other processes see it. */
82
+ function saveToDisk(): void {
83
+ const filePath = rateLimitStoreFilePath();
84
+ mkdirSync(dirname(filePath), { recursive: true });
85
+ const tmpPath = `${filePath}.${process.pid}.tmp`;
86
+ writeFileSync(
87
+ tmpPath,
88
+ JSON.stringify({ timestamps: [...challengeCreationTimestamps] }),
89
+ { mode: 0o600 },
90
+ );
91
+ renameSync(tmpPath, filePath);
92
+ }
93
+
10
94
  export function recordRemoteWebPairingChallengeCreation(): RemoteWebPairingChallengeRateLimit | null {
95
+ loadFromDisk();
11
96
  const now = Date.now();
12
97
  const windowStart = now - RATE_LIMIT_WINDOW_MS;
13
98
 
@@ -26,9 +111,18 @@ export function recordRemoteWebPairingChallengeCreation(): RemoteWebPairingChall
26
111
  }
27
112
 
28
113
  challengeCreationTimestamps.push(now);
114
+ saveToDisk();
29
115
  return null;
30
116
  }
31
117
 
32
118
  export function resetRemoteWebPairingChallengeRateLimiterForTests(): void {
33
119
  challengeCreationTimestamps.length = 0;
120
+ // Best effort on the LAST-RESOLVED path — see the challenge store's note.
121
+ if (lastResolvedStorePath) {
122
+ try {
123
+ rmSync(lastResolvedStorePath, { force: true });
124
+ } catch {
125
+ /* the timestamps are cleared either way */
126
+ }
127
+ }
34
128
  }