@mycadet/gateway 0.12.9 → 0.12.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mycadet/gateway",
3
- "version": "0.12.9",
3
+ "version": "0.12.10",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -53,7 +53,7 @@ function getMachineEntropy(): string {
53
53
  } catch {
54
54
  parts.push("unknown-user");
55
55
  }
56
- // Must match assistant/src/util/platform.ts#getPlatformName.
56
+ // Must match assistant/src/util/os.ts#getPlatformName.
57
57
  parts.push(process.platform);
58
58
  parts.push(process.arch);
59
59
  try {
@@ -56,7 +56,7 @@ function getMachineEntropy(): string {
56
56
  } catch {
57
57
  parts.push("unknown-user");
58
58
  }
59
- // Must mirror assistant/src/util/platform.ts#getPlatformName (raw platform).
59
+ // Must mirror assistant/src/util/os.ts#getPlatformName (raw platform).
60
60
  parts.push(process.platform);
61
61
  parts.push(process.arch);
62
62
  try {
@@ -1,3 +1,5 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
1
3
  import { beforeEach, describe, expect, test } from "bun:test";
2
4
 
3
5
  const { handleCreateRemoteWebPairingChallenge } =
@@ -15,6 +17,7 @@ const {
15
17
  } = await import("../remote-web/pairing-challenge-store.js");
16
18
  const { resetRemoteWebPairingChallengeRateLimiterForTests } =
17
19
  await import("../remote-web/pairing-challenge-rate-limit-store.js");
20
+ const { testSecurityDir } = await import("./test-preload.js");
18
21
 
19
22
  import {
20
23
  LOOPBACK_IP,
@@ -501,3 +504,37 @@ describe("remote web pairing request list/approve/deny", () => {
501
504
  });
502
505
  });
503
506
  });
507
+
508
+ describe("file-backed persistence", () => {
509
+ test("persists challenges to the security dir for other processes", () => {
510
+ setRemoteWebPairingChallengeNowForTests(() => 1_000);
511
+ createRemoteWebPairingChallenge(PUBLIC_BASE_URL, TEST_REQUESTER);
512
+
513
+ const storePath = join(
514
+ testSecurityDir,
515
+ "remote-web-pairing-challenges.json",
516
+ );
517
+ expect(existsSync(storePath)).toBe(true);
518
+ const persisted: unknown[] = JSON.parse(readFileSync(storePath, "utf-8"));
519
+ expect(persisted).toHaveLength(1);
520
+
521
+ // The reset helper removes the file so other tests start empty.
522
+ resetRemoteWebPairingChallengesForTests();
523
+ expect(existsSync(storePath)).toBe(false);
524
+ });
525
+
526
+ test("starts empty and warns when the store file is corrupt", () => {
527
+ const storePath = join(
528
+ testSecurityDir,
529
+ "remote-web-pairing-challenges.json",
530
+ );
531
+ writeFileSync(storePath, "{not json", "utf-8");
532
+
533
+ const challenge = createRemoteWebPairingChallenge(
534
+ PUBLIC_BASE_URL,
535
+ TEST_REQUESTER,
536
+ );
537
+ const stored = getRemoteWebPairingChallengeForTests(challenge.userCode);
538
+ expect(stored).toBeDefined();
539
+ });
540
+ });
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Tests for the `gateway pairing mint <url>` CLI command. The mint executes
3
+ * the pairing logic DIRECTLY against the file-backed challenge store — no
4
+ * IPC (the assistant daemon shares the gateway's socket, so an IPC-exposed
5
+ * mint would let the assistant approve pairings for itself). These tests run
6
+ * the real command and pin both the printed challenge and its landing in
7
+ * the on-disk store the running gateway reads.
8
+ */
9
+
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
13
+ import { z } from "zod";
14
+
15
+ const { parsePairingArgs, runPairingCommand } = await import("../pairing.js");
16
+ const {
17
+ getRemoteWebPairingChallengeForTests,
18
+ resetRemoteWebPairingChallengesForTests,
19
+ } = await import("../../remote-web/pairing-challenge-store.js");
20
+ const { resetRemoteWebPairingChallengeRateLimiterForTests } =
21
+ await import("../../remote-web/pairing-challenge-rate-limit-store.js");
22
+ const { testSecurityDir } = await import("../../__tests__/test-preload.js");
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Test infrastructure
26
+ // ---------------------------------------------------------------------------
27
+
28
+ const PrintedChallengeSchema = z.object({
29
+ deviceCode: z.string().min(1),
30
+ userCode: z.string().regex(/^[A-Z2-9]{4}-[A-Z2-9]{4}$/),
31
+ verificationUri: z.string(),
32
+ expiresAt: z.string(),
33
+ expiresInSeconds: z.number(),
34
+ intervalSeconds: z.number(),
35
+ });
36
+
37
+ let logs: string[];
38
+ let errors: string[];
39
+ let logSpy: ReturnType<typeof spyOn>;
40
+ let errorSpy: ReturnType<typeof spyOn>;
41
+
42
+ beforeEach(() => {
43
+ resetRemoteWebPairingChallengesForTests();
44
+ resetRemoteWebPairingChallengeRateLimiterForTests();
45
+
46
+ logs = [];
47
+ errors = [];
48
+ logSpy = spyOn(console, "log").mockImplementation((...args: unknown[]) => {
49
+ logs.push(args.map(String).join(" "));
50
+ });
51
+ errorSpy = spyOn(console, "error").mockImplementation(
52
+ (...args: unknown[]) => {
53
+ errors.push(args.map(String).join(" "));
54
+ },
55
+ );
56
+ });
57
+
58
+ afterEach(() => {
59
+ logSpy.mockRestore();
60
+ errorSpy.mockRestore();
61
+ });
62
+
63
+ function lastLoggedChallenge() {
64
+ const line = logs[logs.length - 1];
65
+ expect(line).toBeTruthy();
66
+ return PrintedChallengeSchema.parse(JSON.parse(line!));
67
+ }
68
+
69
+ describe("gateway pairing mint", () => {
70
+ test("mints an approved challenge directly against the file-backed store", async () => {
71
+ const code = await runPairingCommand([
72
+ "mint",
73
+ "https://my-assistant.ts.net",
74
+ ]);
75
+
76
+ expect(code).toBe(0);
77
+ const challenge = lastLoggedChallenge();
78
+ expect(challenge.verificationUri).toBe(
79
+ "https://my-assistant.ts.net/assistant/pair",
80
+ );
81
+ expect(challenge.intervalSeconds).toBe(5);
82
+
83
+ // Approved on the spot: the CLI invocation IS the approval.
84
+ const stored = getRemoteWebPairingChallengeForTests(challenge.userCode);
85
+ expect(stored?.status).toBe("approved");
86
+ });
87
+
88
+ test("persists the challenge to the store the running gateway reads", async () => {
89
+ const code = await runPairingCommand([
90
+ "mint",
91
+ "https://my-assistant.ts.net",
92
+ ]);
93
+ expect(code).toBe(0);
94
+
95
+ const storePath = join(
96
+ testSecurityDir,
97
+ "remote-web-pairing-challenges.json",
98
+ );
99
+ expect(existsSync(storePath)).toBe(true);
100
+ const raw = readFileSync(storePath, "utf-8");
101
+ expect(raw).toContain('"status":"approved"');
102
+ expect(raw).toContain('"requesterIp":"cli"');
103
+ });
104
+
105
+ test("rejects an unparseable public base URL", async () => {
106
+ const code = await runPairingCommand(["mint", "not-a-url"]);
107
+
108
+ expect(code).toBe(1);
109
+ expect(errors.join("\n")).toContain("publicBaseUrl is required");
110
+ });
111
+
112
+ test("requires the public base URL argument", async () => {
113
+ const code = await runPairingCommand(["mint"]);
114
+
115
+ expect(code).toBe(1);
116
+ expect(errors.join("\n")).toContain("mint requires the public base URL");
117
+ });
118
+ });
119
+
120
+ describe("parsePairingArgs", () => {
121
+ test("splits the subcommand and positional", () => {
122
+ expect(parsePairingArgs(["mint", "https://x.example"])).toEqual({
123
+ subcommand: "mint",
124
+ positional: "https://x.example",
125
+ });
126
+ expect(parsePairingArgs([])).toEqual({
127
+ subcommand: null,
128
+ positional: null,
129
+ });
130
+ });
131
+ });
package/src/cli/index.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { runContactsCommand } from "./contacts.js";
11
+ import { runPairingCommand } from "./pairing.js";
11
12
 
12
13
  function printUsage(): void {
13
14
  console.log("Usage: gateway <command>");
@@ -15,7 +16,12 @@ function printUsage(): void {
15
16
  console.log("Operator commands for gateway-owned state.");
16
17
  console.log("");
17
18
  console.log("Commands:");
18
- console.log(" contacts Manage the contact ACL with list, get, set-risk-threshold");
19
+ console.log(
20
+ " contacts Manage the contact ACL with list, get, set-risk-threshold",
21
+ );
22
+ console.log(
23
+ " pairing Mint/approve a remote-web pairing challenge (mint <url>)",
24
+ );
19
25
  }
20
26
 
21
27
  const args = process.argv.slice(2);
@@ -31,6 +37,11 @@ if (command === "contacts") {
31
37
  process.exit(code);
32
38
  }
33
39
 
40
+ if (command === "pairing") {
41
+ const code = await runPairingCommand(args.slice(1));
42
+ process.exit(code);
43
+ }
44
+
34
45
  console.error(`Unknown command: ${command}`);
35
46
  printUsage();
36
47
  process.exit(1);
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `gateway pairing` — operator subcommands for the remote-web pairing flow.
3
+ *
4
+ * gateway pairing mint <url>
5
+ *
6
+ * Mints (and approves) a remote-web pairing challenge by executing the
7
+ * pairing mint logic DIRECTLY in this process — deliberately NOT over the
8
+ * gateway's IPC socket, which the assistant daemon shares (an IPC-exposed
9
+ * mint would let the assistant mint + approve pairings for itself). The
10
+ * challenge persists to the file-backed pairing store under
11
+ * GATEWAY_SECURITY_DIR, so the running gateway's verification and
12
+ * token-exchange routes see it. This is the surface `cadet pair` invokes
13
+ * (directly on the host, or via `docker exec` inside the gateway container):
14
+ * process execution is the local-presence proof, so no loopback-peer check
15
+ * is needed and the topology (native or containerized) stops mattering.
16
+ * Prints the challenge response as single-line JSON on stdout; the caller
17
+ * parses it and renders the pairing link/QR.
18
+ */
19
+
20
+ import { mintAndApproveRemoteWebPairingChallenge } from "../remote-web/pairing-mint.js";
21
+
22
+ function printUsage(): void {
23
+ console.log("Usage: gateway pairing mint <url>");
24
+ console.log("");
25
+ console.log(
26
+ "Mint and approve a remote-web pairing challenge on this machine and",
27
+ );
28
+ console.log("print the challenge as JSON.");
29
+ console.log("");
30
+ console.log("Arguments:");
31
+ console.log(" <url> Public base URL to advertise in the pairing link,");
32
+ console.log(" e.g. https://your-assistant.ts.net");
33
+ }
34
+
35
+ /** Parse `pairing` argv (after the command word) into a subcommand + arg. */
36
+ export function parsePairingArgs(args: string[]): {
37
+ subcommand: string | null;
38
+ positional: string | null;
39
+ } {
40
+ const [subcommand = null, positional = null] = args;
41
+ return { subcommand, positional };
42
+ }
43
+
44
+ export async function runPairingCommand(args: string[]): Promise<number> {
45
+ const { subcommand, positional } = parsePairingArgs(args);
46
+
47
+ if (!subcommand || subcommand === "--help" || subcommand === "-h") {
48
+ printUsage();
49
+ return subcommand ? 0 : 1;
50
+ }
51
+ if (subcommand !== "mint") {
52
+ console.error(`Error: unknown pairing subcommand '${subcommand}'.`);
53
+ printUsage();
54
+ return 1;
55
+ }
56
+ if (!positional) {
57
+ console.error("Error: mint requires the public base URL to advertise.");
58
+ printUsage();
59
+ return 1;
60
+ }
61
+
62
+ try {
63
+ const challenge = mintAndApproveRemoteWebPairingChallenge(positional);
64
+ console.log(JSON.stringify(challenge));
65
+ return 0;
66
+ } catch (err) {
67
+ const message = err instanceof Error ? err.message : String(err);
68
+ console.error(`Error: pairing mint failed: ${message}`);
69
+ return 1;
70
+ }
71
+ }
@@ -46,7 +46,7 @@ type StoreFile = StoreFileV1 | StoreFileV2;
46
46
  const STORE_KEY_FILENAME = "store.key";
47
47
 
48
48
  function getPlatformName(): string {
49
- // Must match assistant/src/util/platform.ts#getPlatformName exactly.
49
+ // Must match assistant/src/util/os.ts#getPlatformName exactly.
50
50
  // Using user-friendly labels like "macOS" here changes PBKDF2 entropy and
51
51
  // makes gateway unable to decrypt credentials written by the daemon.
52
52
  return process.platform;
@@ -40,6 +40,7 @@ import { findCadetGuardian } from "../../auth/guardian-bootstrap.js";
40
40
  import type { GatewayConfig } from "../../config.js";
41
41
  import { requestHasTunnelBridgeAuth } from "../../tunnel/bridge-auth.js";
42
42
  import { readStoredCloudUserId } from "../../cloud-user-id.js";
43
+ import { isWebsocketUpgradeRequest } from "../utils.js";
43
44
 
44
45
  const TUNNEL_USER_ID_HEADER = "x-tunnel-user-id";
45
46
  const TUNNEL_ORG_ID_HEADER = "x-tunnel-org-id";
@@ -175,7 +176,7 @@ export async function authorizeGuardianStream(
175
176
  // Checked here as well as in the shared gate, because the cloud path
176
177
  // below skips that gate entirely: without this, a cloud caller sending a
177
178
  // plain request would fall through to `server.upgrade` and get a 500.
178
- if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") {
179
+ if (!isWebsocketUpgradeRequest(req)) {
179
180
  return new Response("Upgrade Required", { status: 426 });
180
181
  }
181
182
 
@@ -97,14 +97,17 @@ describe("POST /v1/ingress/domains", () => {
97
97
  expect(afterSecond).toBe(afterFirst);
98
98
  });
99
99
 
100
- test("rejects non-loopback callers", async () => {
100
+ test("does not gate on the peer IP — auth lives in the edge-guardian strategy", async () => {
101
+ // The docker hatch's gateway sees the host CLI as a non-loopback peer
102
+ // (the docker bridge IP); peer-IP gating here 403'd valid
103
+ // guardian-token requests, so it moved to the auth strategy.
101
104
  const res = await handleSaveIngressDomain(
102
105
  request({ publicBaseUrl: "https://foo.ngrok.app" }),
103
106
  "10.0.0.1",
104
107
  new ConfigFileCache(),
105
108
  );
106
109
 
107
- expect(res.status).toBe(403);
110
+ expect(res.status).toBe(200);
108
111
  });
109
112
 
110
113
  test("rejects a body without a usable public URL", async () => {
@@ -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 }),
@@ -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
 
@@ -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
  }
@@ -1,4 +1,14 @@
1
1
  import { createHash, randomBytes, randomInt } from "node:crypto";
2
+ import {
3
+ mkdirSync,
4
+ readFileSync,
5
+ renameSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+
11
+ import { getGatewaySecurityDir } from "../paths.js";
2
12
 
3
13
  import {
4
14
  REMOTE_WEB_PAIRING_CODE_TTL_MS,
@@ -70,6 +80,94 @@ function nowMs(): number {
70
80
  return nowForTests?.() ?? Date.now();
71
81
  }
72
82
 
83
+ /**
84
+ * The challenge store is FILE-BACKED under GATEWAY_SECURITY_DIR so a mint can
85
+ * run in ANY process: `gateway pairing mint` executes the mint logic directly
86
+ * (deliberately never over the gateway's IPC socket — the assistant daemon
87
+ * shares that socket, and an IPC-exposed mint would let the assistant
88
+ * approve pairings for itself) and persists the challenge here. The running
89
+ * gateway's verification and token-exchange routes re-read the file on every
90
+ * store call, so cross-process writes land. Writes are atomic (tmp + rename)
91
+ * and the single-user local flow makes last-writer-wins races a non-issue.
92
+ */
93
+ const STORE_FILENAME = "remote-web-pairing-challenges.json";
94
+
95
+ let lastResolvedStorePath: string | null = null;
96
+
97
+ function challengeStoreFilePath(): string {
98
+ const resolved = join(getGatewaySecurityDir(), STORE_FILENAME);
99
+ lastResolvedStorePath = resolved;
100
+ return resolved;
101
+ }
102
+
103
+ function isNotExistError(err: unknown): boolean {
104
+ return err instanceof Error && "code" in err && err.code === "ENOENT";
105
+ }
106
+
107
+ function isChallengeLike(
108
+ value: unknown,
109
+ ): value is PendingRemoteWebPairingChallenge {
110
+ return (
111
+ typeof value === "object" &&
112
+ value !== null &&
113
+ "userCodeHash" in value &&
114
+ typeof value.userCodeHash === "string" &&
115
+ "deviceCodeHash" in value &&
116
+ typeof value.deviceCodeHash === "string"
117
+ );
118
+ }
119
+
120
+ /**
121
+ * Re-sync the in-memory maps from disk at the top of every exported call.
122
+ * A missing file is an empty store; a corrupt one warns and starts empty.
123
+ * Expired challenges are NOT dropped here — the same cleanup/expiry checks
124
+ * that ran before file-backing still classify them ("expired", not
125
+ * "invalid") and delete them on their own paths.
126
+ */
127
+ function loadFromDisk(): void {
128
+ challengesByUserCodeHash.clear();
129
+ challengesByDeviceCodeHash.clear();
130
+
131
+ let raw: string;
132
+ try {
133
+ raw = readFileSync(challengeStoreFilePath(), "utf-8");
134
+ } catch (err) {
135
+ if (isNotExistError(err)) return;
136
+ throw err;
137
+ }
138
+
139
+ let parsed: unknown;
140
+ try {
141
+ parsed = JSON.parse(raw);
142
+ } catch {
143
+ parsed = null;
144
+ }
145
+ if (!Array.isArray(parsed)) {
146
+ console.warn(
147
+ `[pairing-challenge-store] could not read ${challengeStoreFilePath()} — starting empty.`,
148
+ );
149
+ return;
150
+ }
151
+ for (const entry of parsed) {
152
+ if (!isChallengeLike(entry)) continue;
153
+ challengesByUserCodeHash.set(entry.userCodeHash, entry);
154
+ challengesByDeviceCodeHash.set(entry.deviceCodeHash, entry);
155
+ }
156
+ }
157
+
158
+ /** Persist the current challenges so other processes can see them. */
159
+ function saveToDisk(): void {
160
+ const filePath = challengeStoreFilePath();
161
+ mkdirSync(dirname(filePath), { recursive: true });
162
+ const tmpPath = `${filePath}.${process.pid}.tmp`;
163
+ writeFileSync(
164
+ tmpPath,
165
+ JSON.stringify([...challengesByUserCodeHash.values()]),
166
+ { mode: 0o600 },
167
+ );
168
+ renameSync(tmpPath, filePath);
169
+ }
170
+
73
171
  function hashSecret(value: string): string {
74
172
  return createHash("sha256").update(value).digest("hex");
75
173
  }
@@ -100,6 +198,7 @@ function cleanupExpiredChallenges(now = nowMs()): void {
100
198
  }
101
199
 
102
200
  export function checkRemoteWebPairingChallengeCapacity(): RemoteWebPairingChallengeCapacityLimit | null {
201
+ loadFromDisk();
103
202
  const now = nowMs();
104
203
  cleanupExpiredChallenges(now);
105
204
 
@@ -122,6 +221,7 @@ export function createRemoteWebPairingChallenge(
122
221
  publicBaseUrl: string,
123
222
  requester: { ip: string; userAgent: string | null; viaEdgeProxy: boolean },
124
223
  ): RemoteWebPairingChallengeResponse {
224
+ loadFromDisk();
125
225
  cleanupExpiredChallenges();
126
226
 
127
227
  const deviceCode = randomBytes(DEVICE_CODE_BYTES).toString("base64url");
@@ -152,6 +252,7 @@ export function createRemoteWebPairingChallenge(
152
252
  };
153
253
  challengesByUserCodeHash.set(userCodeHash, challenge);
154
254
  challengesByDeviceCodeHash.set(deviceCodeHash, challenge);
255
+ saveToDisk();
155
256
 
156
257
  return {
157
258
  deviceCode,
@@ -188,12 +289,15 @@ function approveChallenge(
188
289
  export function approveRemoteWebPairingChallenge(
189
290
  userCode: string,
190
291
  ): ApproveRemoteWebPairingChallengeResult {
292
+ loadFromDisk();
191
293
  const userCodeHash = hashSecret(normalizeUserCode(userCode));
192
294
  const challenge = challengesByUserCodeHash.get(userCodeHash);
193
295
  if (!challenge) {
194
296
  return { status: "invalid" };
195
297
  }
196
- return approveChallenge(challenge);
298
+ const result = approveChallenge(challenge);
299
+ saveToDisk();
300
+ return result;
197
301
  }
198
302
 
199
303
  function findChallengeById(
@@ -208,6 +312,7 @@ function findChallengeById(
208
312
  }
209
313
 
210
314
  export function listPendingRemoteWebPairingChallenges(): RemoteWebPairingRequestSummary[] {
315
+ loadFromDisk();
211
316
  cleanupExpiredChallenges();
212
317
 
213
318
  const pending = [...challengesByUserCodeHash.values()]
@@ -229,11 +334,14 @@ export function listPendingRemoteWebPairingChallenges(): RemoteWebPairingRequest
229
334
  export function approveRemoteWebPairingChallengeById(
230
335
  id: string,
231
336
  ): ApproveRemoteWebPairingChallengeResult {
337
+ loadFromDisk();
232
338
  const challenge = findChallengeById(id);
233
339
  if (!challenge) {
234
340
  return { status: "invalid" };
235
341
  }
236
- return approveChallenge(challenge);
342
+ const result = approveChallenge(challenge);
343
+ saveToDisk();
344
+ return result;
237
345
  }
238
346
 
239
347
  export type DenyRemoteWebPairingChallengeResult =
@@ -244,6 +352,7 @@ export type DenyRemoteWebPairingChallengeResult =
244
352
  export function denyRemoteWebPairingChallengeById(
245
353
  id: string,
246
354
  ): DenyRemoteWebPairingChallengeResult {
355
+ loadFromDisk();
247
356
  const challenge = findChallengeById(id);
248
357
  if (!challenge) {
249
358
  return { status: "invalid" };
@@ -256,12 +365,14 @@ export function denyRemoteWebPairingChallengeById(
256
365
  }
257
366
 
258
367
  deleteChallenge(challenge);
368
+ saveToDisk();
259
369
  return { status: "denied" };
260
370
  }
261
371
 
262
372
  export function claimRemoteWebPairingChallengeExchange(
263
373
  deviceCode: string,
264
374
  ): ClaimRemoteWebPairingChallengeExchangeResult {
375
+ loadFromDisk();
265
376
  const deviceCodeHash = hashSecret(deviceCode);
266
377
  const challenge = challengesByDeviceCodeHash.get(deviceCodeHash);
267
378
  if (!challenge) return { status: "invalid" };
@@ -269,6 +380,7 @@ export function claimRemoteWebPairingChallengeExchange(
269
380
  const now = nowMs();
270
381
  if (challenge.expiresAtMs <= now) {
271
382
  deleteChallenge(challenge);
383
+ saveToDisk();
272
384
  return { status: "expired" };
273
385
  }
274
386
 
@@ -290,6 +402,7 @@ export function claimRemoteWebPairingChallengeExchange(
290
402
 
291
403
  challenge.status = "exchanging";
292
404
  challenge.exchangeStartedAtMs = now;
405
+ saveToDisk();
293
406
  return {
294
407
  status: "approved",
295
408
  publicBaseUrl: challenge.publicBaseUrl,
@@ -301,27 +414,41 @@ export function claimRemoteWebPairingChallengeExchange(
301
414
  export function completeRemoteWebPairingChallengeExchange(
302
415
  deviceCode: string,
303
416
  ): void {
417
+ loadFromDisk();
304
418
  const challenge = challengesByDeviceCodeHash.get(hashSecret(deviceCode));
305
419
  if (!challenge || challenge.status !== "exchanging") return;
306
420
 
307
421
  challenge.status = "consumed";
308
422
  challenge.consumedAtMs = nowMs();
423
+ saveToDisk();
309
424
  }
310
425
 
311
426
  export function releaseRemoteWebPairingChallengeExchange(
312
427
  deviceCode: string,
313
428
  ): void {
429
+ loadFromDisk();
314
430
  const challenge = challengesByDeviceCodeHash.get(hashSecret(deviceCode));
315
431
  if (!challenge || challenge.status !== "exchanging") return;
316
432
 
317
433
  challenge.status = "approved";
318
434
  challenge.exchangeStartedAtMs = undefined;
435
+ saveToDisk();
319
436
  }
320
437
 
321
438
  export function resetRemoteWebPairingChallengesForTests(): void {
322
439
  challengesByUserCodeHash.clear();
323
440
  challengesByDeviceCodeHash.clear();
324
441
  nowForTests = null;
442
+ // Best effort on the LAST-RESOLVED path: test files may delete
443
+ // GATEWAY_SECURITY_DIR before this runs, and re-resolving then would
444
+ // either throw the ephemeral-path guard or point at the wrong dir.
445
+ if (lastResolvedStorePath) {
446
+ try {
447
+ rmSync(lastResolvedStorePath, { force: true });
448
+ } catch {
449
+ /* the maps are cleared either way */
450
+ }
451
+ }
325
452
  }
326
453
 
327
454
  export function setRemoteWebPairingChallengeNowForTests(
@@ -333,5 +460,6 @@ export function setRemoteWebPairingChallengeNowForTests(
333
460
  export function getRemoteWebPairingChallengeForTests(
334
461
  userCode: string,
335
462
  ): PendingRemoteWebPairingChallenge | undefined {
463
+ loadFromDisk();
336
464
  return challengesByUserCodeHash.get(hashSecret(normalizeUserCode(userCode)));
337
465
  }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Mint-and-approve for the remote-web pairing flow — executed DIRECTLY in
3
+ * the calling process (the `gateway pairing mint` CLI), deliberately NOT
4
+ * exposed over the gateway's IPC socket: the assistant daemon shares that
5
+ * socket, and an IPC-exposed mint would let the assistant mint + approve
6
+ * pairings for itself, defeating the local-presence proof. Challenges
7
+ * persist to the file-backed pairing store under GATEWAY_SECURITY_DIR, so
8
+ * the RUNNING gateway's verification and token-exchange routes see what
9
+ * this mints.
10
+ *
11
+ * This is the exact sequence the loopback HTTP mint used to run (parse →
12
+ * rate-limit → capacity → create → approve), with the requester metadata
13
+ * honestly naming the CLI invocation instead of an HTTP peer.
14
+ */
15
+
16
+ import type { RemoteWebPairingChallengeResponse } from "@mycadet/service-contracts/remote-web-pairing";
17
+
18
+ import { parsePublicBaseUrl } from "../http/routes/remote-web-pairing-challenge.js";
19
+ import { recordRemoteWebPairingChallengeCreation } from "./pairing-challenge-rate-limit-store.js";
20
+ import {
21
+ approveRemoteWebPairingChallenge,
22
+ checkRemoteWebPairingChallengeCapacity,
23
+ createRemoteWebPairingChallenge,
24
+ type RemoteWebPairingChallengeCapacityLimit,
25
+ } from "./pairing-challenge-store.js";
26
+
27
+ /**
28
+ * Client-facing failure with a stable code (BAD_REQUEST, RATE_LIMITED,
29
+ * PAIRING_CHALLENGE_CAPACITY_EXCEEDED, MINT_APPROVAL_FAILED).
30
+ */
31
+ export class PairingMintError extends Error {
32
+ readonly code: string;
33
+
34
+ constructor(message: string, code: string) {
35
+ super(message);
36
+ this.name = "PairingMintError";
37
+ this.code = code;
38
+ }
39
+ }
40
+
41
+ function rateLimitError(retryAfterSeconds: number): PairingMintError {
42
+ return new PairingMintError(
43
+ `too many remote web pairing challenges; retry in ${retryAfterSeconds}s`,
44
+ "RATE_LIMITED",
45
+ );
46
+ }
47
+
48
+ function capacityLimitError(
49
+ limit: RemoteWebPairingChallengeCapacityLimit,
50
+ ): PairingMintError {
51
+ return new PairingMintError(
52
+ `too many pending remote web pairing challenges; retry in ${limit.retryAfterSeconds}s`,
53
+ "PAIRING_CHALLENGE_CAPACITY_EXCEEDED",
54
+ );
55
+ }
56
+
57
+ export function mintAndApproveRemoteWebPairingChallenge(
58
+ publicBaseUrlRaw: string,
59
+ ): RemoteWebPairingChallengeResponse {
60
+ const publicBaseUrl = parsePublicBaseUrl(publicBaseUrlRaw);
61
+ if (!publicBaseUrl) {
62
+ throw new PairingMintError(
63
+ "publicBaseUrl is required (an http(s) URL with a host)",
64
+ "BAD_REQUEST",
65
+ );
66
+ }
67
+
68
+ const rateLimited = recordRemoteWebPairingChallengeCreation();
69
+ if (rateLimited) throw rateLimitError(rateLimited.retryAfterSeconds);
70
+
71
+ const capacityLimited = checkRemoteWebPairingChallengeCapacity();
72
+ if (capacityLimited) throw capacityLimitError(capacityLimited);
73
+
74
+ // The requester fields back the host approval UI; a direct mint has no
75
+ // HTTP peer to report, so the honest provenance is the CLI invocation
76
+ // itself.
77
+ const challenge = createRemoteWebPairingChallenge(publicBaseUrl, {
78
+ ip: "cli",
79
+ userAgent: null,
80
+ viaEdgeProxy: false,
81
+ });
82
+
83
+ const approved = approveRemoteWebPairingChallenge(challenge.userCode);
84
+ if (approved.status !== "approved") {
85
+ // Unreachable for a challenge minted this tick, but fail loudly rather
86
+ // than hand out an unapproved link.
87
+ throw new PairingMintError(
88
+ `minted challenge could not be approved (${approved.status})`,
89
+ "MINT_APPROVAL_FAILED",
90
+ );
91
+ }
92
+
93
+ return challenge;
94
+ }