@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,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
+ }