@mycadet/gateway 0.12.11 → 0.13.0

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 (81) hide show
  1. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  2. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  3. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  4. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  5. package/node_modules/@mycadet/gateway-client/src/gateway-ipc-contracts.ts +0 -20
  6. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  7. package/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  8. package/openapi.json +1 -1
  9. package/package.json +1 -1
  10. package/src/__tests__/actor-token-revocation.test.ts +41 -34
  11. package/src/__tests__/contact-store-mirror-op-missing.test.ts +33 -70
  12. package/src/__tests__/contacts-mirror-op-reporter.test.ts +33 -89
  13. package/src/__tests__/credential-requests.test.ts +4 -4
  14. package/src/__tests__/devices-mine.test.ts +187 -0
  15. package/src/__tests__/edge-auth.test.ts +1 -20
  16. package/src/__tests__/edge-guardian-auth.test.ts +1 -17
  17. package/src/__tests__/feature-flag-resolver.test.ts +2 -30
  18. package/src/__tests__/feature-flag-watcher-callback.test.ts +0 -22
  19. package/src/__tests__/feature-flags-route.test.ts +2 -191
  20. package/src/__tests__/guardian-bootstrap-lookups.test.ts +18 -25
  21. package/src/__tests__/guardian-integrity-reporter.test.ts +51 -82
  22. package/src/__tests__/guardian-integrity.test.ts +16 -20
  23. package/src/__tests__/handle-inbound-trust-verdict.test.ts +1 -14
  24. package/src/__tests__/ipc-feature-flag-routes.test.ts +0 -56
  25. package/src/__tests__/lum-2783-recovery-e2e.test.ts +0 -12
  26. package/src/__tests__/post-assistant-ready-deferred.test.ts +0 -18
  27. package/src/__tests__/refresh-device-binding.test.ts +0 -12
  28. package/src/__tests__/remote-web-pairing-token.test.ts +4 -14
  29. package/src/__tests__/runtime-client.test.ts +7 -7
  30. package/src/__tests__/speech-relay-websocket.test.ts +4 -4
  31. package/src/__tests__/trust-verdict-resolver-guardian-integrity.test.ts +30 -36
  32. package/src/__tests__/twilio-webhooks.test.ts +2 -2
  33. package/src/auth/actor-token-revocation.ts +34 -9
  34. package/src/cli/__tests__/pairing.test.ts +95 -0
  35. package/src/cli/index.ts +1 -1
  36. package/src/cli/pairing.ts +81 -6
  37. package/src/contacts-mirror-op-reporter.ts +19 -36
  38. package/src/db/connection.ts +0 -10
  39. package/src/feature-flag-resolver.ts +2 -7
  40. package/src/feature-flag-watcher.ts +1 -14
  41. package/src/guardian-integrity-reporter.ts +19 -47
  42. package/src/http/middleware/auth.ts +33 -27
  43. package/src/http/routes/auth-token.ts +1 -1
  44. package/src/http/routes/devices-mine.ts +103 -0
  45. package/src/http/routes/feature-flags.ts +1 -8
  46. package/src/http/routes/guardian-pin.ts +2 -11
  47. package/src/http/routes/ipc-runtime-proxy.ts +1 -1
  48. package/src/http/routes/live-voice-websocket.ts +1 -1
  49. package/src/http/routes/runtime-audio-stream.ts +1 -1
  50. package/src/http/routes/runtime-proxy.ts +2 -2
  51. package/src/http/routes/twilio-voice-webhook.test.ts +13 -13
  52. package/src/index.ts +11 -198
  53. package/src/ipc/feature-flag-handlers.ts +2 -9
  54. package/src/remote-web/pairing-challenge-store.ts +15 -0
  55. package/src/risk/command-registry/commands/assistant.ts +0 -24
  56. package/src/risk/command-registry.test.ts +2 -4
  57. package/src/schema.ts +44 -372
  58. package/src/__tests__/auth-fallback-count-tracker.test.ts +0 -92
  59. package/src/__tests__/auth-fallback-reporter.test.ts +0 -101
  60. package/src/__tests__/backup-worker-cloud-mode.test.ts +0 -44
  61. package/src/__tests__/backup-worker-export-timeout.test.ts +0 -164
  62. package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -216
  63. package/src/__tests__/remote-feature-flag-sync.test.ts +0 -961
  64. package/src/auth-fallback-count-tracker.ts +0 -113
  65. package/src/auth-fallback-reporter.ts +0 -127
  66. package/src/backup/backup-key.ts +0 -138
  67. package/src/backup/backup-routes.ts +0 -161
  68. package/src/backup/backup-worker.ts +0 -425
  69. package/src/backup/list-snapshots.ts +0 -97
  70. package/src/backup/local-writer.ts +0 -87
  71. package/src/backup/offsite-writer.ts +0 -182
  72. package/src/backup/paths.ts +0 -113
  73. package/src/backup/stream-crypt.ts +0 -258
  74. package/src/device-id.ts +0 -18
  75. package/src/feature-flag-remote-store.ts +0 -166
  76. package/src/http/routes/migration-proxy.ts +0 -636
  77. package/src/internal-telemetry-client.ts +0 -39
  78. package/src/ipc/debug-export-handlers.test.ts +0 -102
  79. package/src/ipc/debug-export-handlers.ts +0 -81
  80. package/src/remote-feature-flag-sync.ts +0 -440
  81. package/src/watchdog-reporter.ts +0 -110
@@ -8,10 +8,6 @@ import { connectClient, sendRequest } from "./helpers/ipc-newline-client.js";
8
8
 
9
9
  const protectedDir = testSecurityDir;
10
10
  const featureFlagStorePath = join(protectedDir, "feature-flags.json");
11
- const remoteFeatureFlagStorePath = join(
12
- protectedDir,
13
- "feature-flags-remote.json",
14
- );
15
11
 
16
12
  // Write the test registry to an isolated temp path
17
13
  const defaultsPath = join(protectedDir, "feature-flag-registry.json");
@@ -63,7 +59,6 @@ beforeEach(() => {
63
59
  _setRegistryCandidateOverrides([defaultsPath]);
64
60
  resetFeatureFlagDefaultsCache();
65
61
  clearFeatureFlagStoreCache();
66
- clearRemoteFeatureFlagStoreCache();
67
62
  });
68
63
 
69
64
  afterEach(() => {
@@ -76,7 +71,6 @@ afterEach(() => {
76
71
  _setRegistryCandidateOverrides(null);
77
72
  resetFeatureFlagDefaultsCache();
78
73
  clearFeatureFlagStoreCache();
79
- clearRemoteFeatureFlagStoreCache();
80
74
  resetEnvOverridesCache();
81
75
  delete process.env.CADET_FLAG_A2A_CHANNEL;
82
76
  });
@@ -84,8 +78,6 @@ afterEach(() => {
84
78
  const { resetFeatureFlagDefaultsCache, _setRegistryCandidateOverrides } =
85
79
  await import("../feature-flag-defaults.js");
86
80
  const { clearFeatureFlagStoreCache } = await import("../feature-flag-store.js");
87
- const { clearRemoteFeatureFlagStoreCache } =
88
- await import("../feature-flag-remote-store.js");
89
81
  const { resetEnvOverridesCache } =
90
82
  await import("../feature-flag-env-overrides.js");
91
83
  const { GatewayIpcServer } = await import("../ipc/server.js");
@@ -161,54 +153,6 @@ describe("IPC feature flag routes", () => {
161
153
  expect(flags["a2a-channel"]).toBe(false); // still default
162
154
  });
163
155
 
164
- test("get_feature_flags merges remote values when no local override", async () => {
165
- writeFileSync(
166
- remoteFeatureFlagStorePath,
167
- JSON.stringify({
168
- version: 1,
169
- values: { "a2a-channel": true },
170
- }),
171
- );
172
- clearRemoteFeatureFlagStoreCache();
173
-
174
- // No local override
175
- if (existsSync(featureFlagStorePath)) {
176
- rmSync(featureFlagStorePath);
177
- }
178
- clearFeatureFlagStoreCache();
179
-
180
- await startServerAndConnect();
181
- const res = await sendRequest(client, "get_feature_flags");
182
-
183
- expect(res.error).toBeUndefined();
184
- const flags = res.result as Record<string, boolean>;
185
- expect(flags["a2a-channel"]).toBe(true); // remote overrides default
186
- });
187
-
188
- test("get_feature_flags falls back to registry defaults for flags missing from a remote snapshot", async () => {
189
- writeFileSync(
190
- remoteFeatureFlagStorePath,
191
- JSON.stringify({
192
- version: 1,
193
- values: { "a2a-channel": true },
194
- }),
195
- );
196
- clearRemoteFeatureFlagStoreCache();
197
-
198
- if (existsSync(featureFlagStorePath)) {
199
- rmSync(featureFlagStorePath);
200
- }
201
- clearFeatureFlagStoreCache();
202
-
203
- await startServerAndConnect();
204
- const res = await sendRequest(client, "get_feature_flags");
205
-
206
- expect(res.error).toBeUndefined();
207
- const flags = res.result as Record<string, boolean>;
208
- expect(flags["a2a-channel"]).toBe(true);
209
- expect(flags["browser"]).toBe(true);
210
- });
211
-
212
156
  test("get_feature_flags includes string flag values", async () => {
213
157
  if (existsSync(featureFlagStorePath)) rmSync(featureFlagStorePath);
214
158
  clearFeatureFlagStoreCache();
@@ -35,10 +35,6 @@ import {
35
35
  resetGatewayDb,
36
36
  } from "../db/connection.js";
37
37
  import { actorTokenRecords, contacts, contactChannels } from "../db/schema.js";
38
- import {
39
- resetGuardianIntegrityReporterForTesting,
40
- setGuardianIntegrityReporterOverridesForTesting,
41
- } from "../guardian-integrity-reporter.js";
42
38
  import { resolveTrustVerdict } from "../risk/trust-verdict-resolver.js";
43
39
 
44
40
  initSigningKey(Buffer.from("test-signing-key-at-least-32-bytes-long"));
@@ -79,17 +75,9 @@ beforeEach(async () => {
79
75
  db.delete(contacts).run();
80
76
  db.delete(actorTokenRecords).run();
81
77
  bustGuardianIntegrityCache();
82
- resetGuardianIntegrityReporterForTesting();
83
- setGuardianIntegrityReporterOverridesForTesting({
84
- fetchImpl: async () => new Response("{}"),
85
- mintToken: () => "svc-token",
86
- baseUrl: "http://127.0.0.1:7821",
87
- log: { error: () => {}, warn: () => {} },
88
- });
89
78
  });
90
79
 
91
80
  afterEach(() => {
92
- resetGuardianIntegrityReporterForTesting();
93
81
  resetGatewayDb();
94
82
  });
95
83
 
@@ -50,10 +50,6 @@ const { contacts, contactChannels, actorTokenRecords } =
50
50
  const { MIGRATIONS } = await import("../db/data-migrations/index.js");
51
51
  const { bustGuardianIntegrityCache } =
52
52
  await import("../auth/guardian-integrity.js");
53
- const {
54
- resetGuardianIntegrityReporterForTesting,
55
- setGuardianIntegrityReporterOverridesForTesting,
56
- } = await import("../guardian-integrity-reporter.js");
57
53
  const { seedContact, seedActorToken } =
58
54
  await import("./helpers/contact-fixtures.js");
59
55
 
@@ -134,12 +130,6 @@ describe("runDeferredTasksWhenAssistantReady", () => {
134
130
  // ensureCadetGuardianBinding throws CadetGuardianMintRefusedError.
135
131
  seedContact({ id: "invited-contact" });
136
132
  bustGuardianIntegrityCache();
137
- setGuardianIntegrityReporterOverridesForTesting({
138
- fetchImpl: async () => new Response("{}"),
139
- mintToken: () => "svc-token",
140
- baseUrl: "http://127.0.0.1:7821",
141
- log: { error: () => {}, warn: () => {} },
142
- });
143
133
  resetPostAssistantReadyForTest(); // restore the REAL executor
144
134
 
145
135
  // The refusal is warn-and-continue inside the executor: the deferred
@@ -152,7 +142,6 @@ describe("runDeferredTasksWhenAssistantReady", () => {
152
142
  );
153
143
  expect(getGatewayDb().select().from(contacts).all()).toHaveLength(1);
154
144
  } finally {
155
- resetGuardianIntegrityReporterForTesting();
156
145
  resetGatewayDb();
157
146
  }
158
147
  });
@@ -176,12 +165,6 @@ describe("runDeferredTasksWhenAssistantReady", () => {
176
165
  }
177
166
  seedActorToken(); // active token, guardianPrincipalId "principal-123"
178
167
  bustGuardianIntegrityCache();
179
- setGuardianIntegrityReporterOverridesForTesting({
180
- fetchImpl: async () => new Response("{}"),
181
- mintToken: () => "svc-token",
182
- baseUrl: "http://127.0.0.1:7821",
183
- log: { error: () => {}, warn: () => {} },
184
- });
185
168
  resetPostAssistantReadyForTest(); // restore the REAL executor
186
169
 
187
170
  await runDeferredTasksWhenAssistantReady(5);
@@ -202,7 +185,6 @@ describe("runDeferredTasksWhenAssistantReady", () => {
202
185
  principalId: "principal-123",
203
186
  });
204
187
  } finally {
205
- resetGuardianIntegrityReporterForTesting();
206
188
  resetGatewayDb();
207
189
  }
208
190
  });
@@ -24,10 +24,6 @@ const { handleGuardianRefresh } =
24
24
  await import("../http/routes/guardian-refresh.js");
25
25
  const { bustGuardianIntegrityCache } =
26
26
  await import("../auth/guardian-integrity.js");
27
- const {
28
- resetGuardianIntegrityReporterForTesting,
29
- setGuardianIntegrityReporterOverridesForTesting,
30
- } = await import("../guardian-integrity-reporter.js");
31
27
 
32
28
  const PRINCIPAL = "guardian-001";
33
29
  const DEVICE_A = "device-A";
@@ -128,18 +124,10 @@ beforeEach(async () => {
128
124
  // silence the fail-loud reporter for the missing-guardian cases.
129
125
  insertGuardianContact();
130
126
  bustGuardianIntegrityCache();
131
- resetGuardianIntegrityReporterForTesting();
132
- setGuardianIntegrityReporterOverridesForTesting({
133
- fetchImpl: async () => new Response("{}"),
134
- mintToken: () => "svc-token",
135
- baseUrl: "http://127.0.0.1:7821",
136
- log: { error: () => {}, warn: () => {} },
137
- });
138
127
  });
139
128
 
140
129
  afterEach(() => {
141
130
  resetGatewayDb();
142
- resetGuardianIntegrityReporterForTesting();
143
131
  bustGuardianIntegrityCache();
144
132
  if (savedSecurityDir === undefined) {
145
133
  delete process.env.GATEWAY_SECURITY_DIR;
@@ -58,10 +58,6 @@ const {
58
58
  } = await import("../remote-web/pairing-challenge-store.js");
59
59
  const { bustGuardianIntegrityCache } =
60
60
  await import("../auth/guardian-integrity.js");
61
- const {
62
- resetGuardianIntegrityReporterForTesting,
63
- setGuardianIntegrityReporterOverridesForTesting,
64
- } = await import("../guardian-integrity-reporter.js");
65
61
  const realBrowserAuthCookies = await import("../http/browser-auth-cookies.js");
66
62
  // Captured before any mock is armed: `mock.module` rewrites the live
67
63
  // namespace, so a restore needs its own reference to the real function.
@@ -622,6 +618,8 @@ describe("remote web pairing token exchange", () => {
622
618
  .where(eq(contacts.role, "guardian"))
623
619
  .all();
624
620
  expect(gwGuardian).toHaveLength(1);
621
+ // The pairing flow mints fresh (brand-neutral principal id, see #294) —
622
+ // recovery from actor-token evidence is boot-time-only.
625
623
  expect(gwGuardian[0].principalId).toMatch(/^principal-/);
626
624
  });
627
625
 
@@ -641,13 +639,13 @@ describe("remote web pairing token exchange", () => {
641
639
  const gwDb = getGatewayDb();
642
640
  const realTransaction = gwDb.transaction.bind(gwDb);
643
641
  let failedOnce = false;
644
-
642
+
645
643
  (gwDb as any).transaction = (...args: unknown[]) => {
646
644
  if (!failedOnce) {
647
645
  failedOnce = true;
648
646
  throw new Error("gateway binding write failed");
649
647
  }
650
-
648
+
651
649
  return (realTransaction as any)(...args);
652
650
  };
653
651
 
@@ -658,7 +656,6 @@ describe("remote web pairing token exchange", () => {
658
656
  ),
659
657
  ).rejects.toThrow("gateway binding write failed");
660
658
  } finally {
661
-
662
659
  (gwDb as any).transaction = realTransaction;
663
660
  }
664
661
 
@@ -703,12 +700,6 @@ describe("remote web pairing token exchange", () => {
703
700
  })
704
701
  .run();
705
702
  bustGuardianIntegrityCache();
706
- setGuardianIntegrityReporterOverridesForTesting({
707
- fetchImpl: async () => new Response("{}"),
708
- mintToken: () => "svc-token",
709
- baseUrl: "http://127.0.0.1:7821",
710
- log: { error: () => {}, warn: () => {} },
711
- });
712
703
 
713
704
  try {
714
705
  const res = await handleRemoteWebPairingToken(
@@ -748,7 +739,6 @@ describe("remote web pairing token exchange", () => {
748
739
  expect(retry.status).toBe(200);
749
740
  expect(activeTokens()).toHaveLength(1);
750
741
  } finally {
751
- resetGuardianIntegrityReporterForTesting();
752
742
  bustGuardianIntegrityCache();
753
743
  }
754
744
  });
@@ -332,7 +332,7 @@ describe("resolvePublicHttpBaseUrl", () => {
332
332
 
333
333
  test("prefers the config publicBaseUrl and strips its trailing slash", async () => {
334
334
  const result = await resolvePublicHttpBaseUrl(
335
- makeConfig({ tunnelBaseUrl: "https://tunnel-dev.mycadet.ai" }),
335
+ makeConfig({ tunnelBaseUrl: "https://dev-tunnel.mycadet.ai" }),
336
336
  fakeConfigFile("https://assistant.example.com/"),
337
337
  getId("assistant-123"),
338
338
  );
@@ -342,7 +342,7 @@ describe("resolvePublicHttpBaseUrl", () => {
342
342
  test("does not read the cloud assistant id when the config URL resolves", async () => {
343
343
  const platformIdGetter = getId("assistant-123");
344
344
  await resolvePublicHttpBaseUrl(
345
- makeConfig({ tunnelBaseUrl: "https://tunnel-dev.mycadet.ai" }),
345
+ makeConfig({ tunnelBaseUrl: "https://dev-tunnel.mycadet.ai" }),
346
346
  fakeConfigFile("https://assistant.example.com"),
347
347
  platformIdGetter,
348
348
  );
@@ -352,20 +352,20 @@ describe("resolvePublicHttpBaseUrl", () => {
352
352
 
353
353
  test("falls back to tunnelBaseUrl + platform assistant id when config is empty", async () => {
354
354
  const result = await resolvePublicHttpBaseUrl(
355
- makeConfig({ tunnelBaseUrl: "https://tunnel-dev.mycadet.ai" }),
355
+ makeConfig({ tunnelBaseUrl: "https://dev-tunnel.mycadet.ai" }),
356
356
  fakeConfigFile(undefined),
357
357
  getId("assistant-123"),
358
358
  );
359
- expect(result).toBe("https://tunnel-dev.mycadet.ai/assistant-123");
359
+ expect(result).toBe("https://dev-tunnel.mycadet.ai/assistant-123");
360
360
  });
361
361
 
362
362
  test("strips a trailing slash on tunnelBaseUrl before joining the assistant id", async () => {
363
363
  const result = await resolvePublicHttpBaseUrl(
364
- makeConfig({ tunnelBaseUrl: "https://tunnel-dev.mycadet.ai/" }),
364
+ makeConfig({ tunnelBaseUrl: "https://dev-tunnel.mycadet.ai/" }),
365
365
  fakeConfigFile(undefined),
366
366
  getId("assistant-123"),
367
367
  );
368
- expect(result).toBe("https://tunnel-dev.mycadet.ai/assistant-123");
368
+ expect(result).toBe("https://dev-tunnel.mycadet.ai/assistant-123");
369
369
  });
370
370
 
371
371
  test("returns undefined when neither the config URL nor the tunnel fallback resolve", async () => {
@@ -375,7 +375,7 @@ describe("resolvePublicHttpBaseUrl", () => {
375
375
  // tunnelBaseUrl present but no platform assistant id → no fallback.
376
376
  expect(
377
377
  await resolvePublicHttpBaseUrl(
378
- makeConfig({ tunnelBaseUrl: "https://tunnel-dev.mycadet.ai" }),
378
+ makeConfig({ tunnelBaseUrl: "https://dev-tunnel.mycadet.ai" }),
379
379
  fakeConfigFile(undefined),
380
380
  getId(undefined),
381
381
  ),
@@ -91,10 +91,10 @@ describe("tunnel origin derivation", () => {
91
91
  "https://tunnel.mycadet.ai",
92
92
  );
93
93
  expect(tunnelOriginForPlatformHost("staging-cloud.mycadet.ai")).toBe(
94
- "https://tunnel-staging.mycadet.ai",
94
+ "https://staging-tunnel.mycadet.ai",
95
95
  );
96
96
  expect(tunnelOriginForPlatformHost("dev-cloud.mycadet.ai")).toBe(
97
- "https://tunnel-dev.mycadet.ai",
97
+ "https://dev-tunnel.mycadet.ai",
98
98
  );
99
99
  expect(tunnelOriginForPlatformHost("mycadet.ai")).toBeNull();
100
100
  expect(tunnelOriginForPlatformHost("localhost")).toBeNull();
@@ -124,10 +124,10 @@ describe("tunnel origin derivation", () => {
124
124
  expect(await handler(req, server)).toBeUndefined();
125
125
  const data = upgradedData<SpeechRelaySocketData>(server);
126
126
  expect(new URL(data.upstreamWsUrl).origin).toBe(
127
- "wss://tunnel-staging.mycadet.ai",
127
+ "wss://staging-tunnel.mycadet.ai",
128
128
  );
129
129
  expect(new URL(data.upstreamHttpUrl).origin).toBe(
130
- "https://tunnel-staging.mycadet.ai",
130
+ "https://staging-tunnel.mycadet.ai",
131
131
  );
132
132
  });
133
133
 
@@ -11,36 +11,46 @@
11
11
  * - A thrown integrity check degrades to the plain unknown verdict — no
12
12
  * crash, no stamp.
13
13
  *
14
- * The fail-loud reporter is silenced/observed through its test-only overrides
15
- * so no relay or log leaves the test process (bun's mock.module is
16
- * process-global and would leak into other test files).
14
+ * The fail-loud reporter's report fns are captured via module
15
+ * re-registration so no log leaves the test process (the reporter's own
16
+ * behavior has its own suite).
17
17
  */
18
- import { beforeEach, afterEach, describe, expect, test } from "bun:test";
18
+ import { beforeEach, afterEach, describe, expect, mock, test } from "bun:test";
19
19
  import { sql } from "drizzle-orm";
20
20
 
21
21
  await import("./test-preload.js");
22
- const { initGatewayDb, resetGatewayDb, getGatewayDb } = await import(
23
- "../db/connection.js"
24
- );
22
+ const { initGatewayDb, resetGatewayDb, getGatewayDb } =
23
+ await import("../db/connection.js");
25
24
  const {
26
25
  contacts,
27
26
  contactChannels,
28
27
  actorTokenRecords,
29
28
  actorRefreshTokenRecords,
30
29
  } = await import("../db/schema.js");
31
- const { seedActorToken, seedContact } = await import(
32
- "./helpers/contact-fixtures.js"
33
- );
34
- const {
35
- resetGuardianIntegrityReporterForTesting,
36
- setGuardianIntegrityReporterOverridesForTesting,
37
- } = await import("../guardian-integrity-reporter.js");
38
- const { bustGuardianIntegrityCache } = await import(
39
- "../auth/guardian-integrity.js"
40
- );
41
- const { resolveTrustVerdict } = await import(
42
- "../risk/trust-verdict-resolver.js"
43
- );
30
+ const { seedActorToken, seedContact } =
31
+ await import("./helpers/contact-fixtures.js");
32
+
33
+ // Captured reporter detail payloads (via module re-registration — the SUTs
34
+ // below must load AFTER this so they bind the recorders, not the real
35
+ // reporter).
36
+ const reportCalls: Record<string, unknown>[] = [];
37
+
38
+ const actualGuardianIntegrityReporter =
39
+ await import("../guardian-integrity-reporter.js");
40
+ mock.module("../guardian-integrity-reporter.js", () => ({
41
+ ...actualGuardianIntegrityReporter,
42
+ reportMissingGuardian: (detail: Record<string, unknown>) => {
43
+ reportCalls.push(detail);
44
+ },
45
+ reportGuardianMintRefused: (detail: Record<string, unknown>) => {
46
+ reportCalls.push(detail);
47
+ },
48
+ }));
49
+
50
+ const { bustGuardianIntegrityCache } =
51
+ await import("../auth/guardian-integrity.js");
52
+ const { resolveTrustVerdict } =
53
+ await import("../risk/trust-verdict-resolver.js");
44
54
 
45
55
  const CHANNEL = "telegram";
46
56
 
@@ -72,9 +82,6 @@ function insertChannel(args: {
72
82
  .run();
73
83
  }
74
84
 
75
- // Captured reporter detail payloads (via the log-override seam).
76
- const reportCalls: Record<string, unknown>[] = [];
77
-
78
85
  beforeEach(async () => {
79
86
  resetGatewayDb();
80
87
  await initGatewayDb();
@@ -84,23 +91,10 @@ beforeEach(async () => {
84
91
  getGatewayDb().delete(actorRefreshTokenRecords).run();
85
92
  bustGuardianIntegrityCache();
86
93
  reportCalls.length = 0;
87
- resetGuardianIntegrityReporterForTesting();
88
- setGuardianIntegrityReporterOverridesForTesting({
89
- fetchImpl: async () => new Response("{}"),
90
- mintToken: () => "svc-token",
91
- baseUrl: "http://127.0.0.1:7821",
92
- log: {
93
- error: (detail) => {
94
- reportCalls.push(detail);
95
- },
96
- warn: () => {},
97
- },
98
- });
99
94
  });
100
95
 
101
96
  afterEach(() => {
102
97
  resetGatewayDb();
103
- resetGuardianIntegrityReporterForTesting();
104
98
  });
105
99
 
106
100
  describe("missing-guardian state (evidence, zero guardian rows)", () => {
@@ -831,7 +831,7 @@ describe("Twilio webhook signature with canonical ingress base URL", () => {
831
831
  );
832
832
 
833
833
  const handler = createTwilioVoiceWebhookHandler(
834
- makeConfig({ tunnelBaseUrl: "https://tunnel-staging.mycadet.ai" }),
834
+ makeConfig({ tunnelBaseUrl: "https://staging-tunnel.mycadet.ai" }),
835
835
  makeCaches(),
836
836
  );
837
837
 
@@ -856,7 +856,7 @@ describe("Twilio webhook signature with canonical ingress base URL", () => {
856
856
  expect(res.status).toBe(200);
857
857
  const body = await res.text();
858
858
  expect(body).toContain(
859
- `url="wss://tunnel-staging.mycadet.ai/${assistantId}/webhooks/twilio/media-stream/sess-1/`,
859
+ `url="wss://staging-tunnel.mycadet.ai/${assistantId}/webhooks/twilio/media-stream/sess-1/`,
860
860
  );
861
861
  expect(body).not.toContain("__CADET_PUBLIC_BASE_URL__");
862
862
  expect(body).not.toContain("staging-cloud.mycadet.ai");
@@ -96,10 +96,30 @@ export function actorTokenRecordHash(rawToken: string): string {
96
96
  return hashToken(canonicalizeTokenForHash(rawToken));
97
97
  }
98
98
 
99
+ /**
100
+ * The projection of the presented token's row that admission loads — routes
101
+ * serving the row (e.g. `GET /v1/devices/mine`) read this instead of
102
+ * re-querying the same token hash.
103
+ */
104
+ export type AdmittedActorTokenRecord = Pick<
105
+ typeof actorTokenRecords.$inferSelect,
106
+ | "status"
107
+ | "guardianPrincipalId"
108
+ | "hashedDeviceId"
109
+ | "platform"
110
+ | "pairingUserAgent"
111
+ | "clientReportedName"
112
+ | "issuedAt"
113
+ | "expiresAt"
114
+ >;
115
+
99
116
  /**
100
117
  * Decide whether a validated token may proceed, and record the activity of the
101
- * device that presented it. Returns false only for an actor token whose record
102
- * is explicitly `status = 'revoked'`; every other case is fail-open.
118
+ * device that presented it. `admitted` is false only for an actor token whose
119
+ * record is explicitly `status = 'revoked'`; every other case is fail-open. The
120
+ * row loaded for the verdict rides back as `record` (null for non-actor
121
+ * principals, unrecorded tokens, and DB-error fail-open) so callers — routes
122
+ * especially — can reuse it instead of re-querying by the same token hash.
103
123
  *
104
124
  * One canonicalize + hash + indexed SELECT serves both jobs, and the two are
105
125
  * inseparable by construction: a caller cannot enforce revocation while
@@ -122,10 +142,10 @@ export function actorTokenRecordHash(rawToken: string): string {
122
142
  export function admitActorToken(
123
143
  rawToken: string,
124
144
  claims: TokenClaims,
125
- ): boolean {
145
+ ): { admitted: boolean; record: AdmittedActorTokenRecord | null } {
126
146
  const parsed = parseSub(claims.sub);
127
147
  if (!parsed.ok || parsed.principalType !== "actor") {
128
- return true;
148
+ return { admitted: true, record: null };
129
149
  }
130
150
 
131
151
  const tokenHash = actorTokenRecordHash(rawToken);
@@ -137,6 +157,11 @@ export function admitActorToken(
137
157
  status: actorTokenRecords.status,
138
158
  guardianPrincipalId: actorTokenRecords.guardianPrincipalId,
139
159
  hashedDeviceId: actorTokenRecords.hashedDeviceId,
160
+ platform: actorTokenRecords.platform,
161
+ pairingUserAgent: actorTokenRecords.pairingUserAgent,
162
+ clientReportedName: actorTokenRecords.clientReportedName,
163
+ issuedAt: actorTokenRecords.issuedAt,
164
+ expiresAt: actorTokenRecords.expiresAt,
140
165
  })
141
166
  .from(actorTokenRecords)
142
167
  .where(eq(actorTokenRecords.tokenHash, tokenHash))
@@ -144,17 +169,17 @@ export function admitActorToken(
144
169
 
145
170
  // Verdict first: a rejected token must never stamp.
146
171
  if (record?.status === "revoked") {
147
- return false;
172
+ return { admitted: false, record };
148
173
  }
149
174
 
150
175
  if (stampDebounce.has(tokenHash)) {
151
- return true;
176
+ return { admitted: true, record: record ?? null };
152
177
  }
153
178
 
154
179
  if (!record) {
155
180
  // A stable answer, not a failure: arm the window like a completed stamp.
156
181
  stampDebounce.mark(tokenHash);
157
- return true;
182
+ return { admitted: true, record: null };
158
183
  }
159
184
 
160
185
  db.update(actorTokenRecords)
@@ -169,13 +194,13 @@ export function admitActorToken(
169
194
  .run();
170
195
 
171
196
  stampDebounce.mark(tokenHash);
172
- return true;
197
+ return { admitted: true, record };
173
198
  } catch (err) {
174
199
  log.warn(
175
200
  { err: err instanceof Error ? err.message : String(err) },
176
201
  "Actor-token admission failed, allowing request (fail-open)",
177
202
  );
178
- return true;
203
+ return { admitted: true, record: null };
179
204
  }
180
205
  }
181
206
 
@@ -16,6 +16,7 @@ const { parsePairingArgs, runPairingCommand } = await import("../pairing.js");
16
16
  const {
17
17
  getRemoteWebPairingChallengeForTests,
18
18
  resetRemoteWebPairingChallengesForTests,
19
+ setRemoteWebPairingChallengeNowForTests,
19
20
  } = await import("../../remote-web/pairing-challenge-store.js");
20
21
  const { resetRemoteWebPairingChallengeRateLimiterForTests } =
21
22
  await import("../../remote-web/pairing-challenge-rate-limit-store.js");
@@ -129,3 +130,97 @@ describe("parsePairingArgs", () => {
129
130
  });
130
131
  });
131
132
  });
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // `gateway pairing list`
136
+ // ---------------------------------------------------------------------------
137
+
138
+ const ListChallengeSchema = z.object({
139
+ id: z.string(),
140
+ userCode: z.string(),
141
+ publicBaseUrl: z.string(),
142
+ verificationUri: z.string(),
143
+ status: z.enum(["pending", "approved", "exchanging", "consumed"]),
144
+ createdAtMs: z.number(),
145
+ expiresAtMs: z.number(),
146
+ requesterIp: z.string(),
147
+ requesterUserAgent: z.string().nullable(),
148
+ viaEdgeProxy: z.boolean(),
149
+ expired: z.boolean(),
150
+ });
151
+
152
+ function lastLoggedChallenges() {
153
+ const line = logs[logs.length - 1];
154
+ expect(line).toBeTruthy();
155
+ const parsed = z
156
+ .object({ ok: z.literal(true), challenges: z.array(ListChallengeSchema) })
157
+ .parse(JSON.parse(line!));
158
+ return parsed.challenges;
159
+ }
160
+
161
+ describe("gateway pairing list", () => {
162
+ test("empty store prints a no-results line and exits 0", async () => {
163
+ const code = await runPairingCommand(["list"]);
164
+
165
+ expect(code).toBe(0);
166
+ expect(logs.join("\n")).toContain("No pairing challenges found.");
167
+ });
168
+
169
+ test("--json lists every persisted challenge newest-first", async () => {
170
+ // Both mints land inside the last minute so their 10-minute TTL keeps
171
+ // them active against the real wall clock the CLI checks with, while
172
+ // the frozen seam still pins deterministic createdAtMs values.
173
+ const older = Date.now() - 60_000;
174
+ const newer = Date.now() - 30_000;
175
+ setRemoteWebPairingChallengeNowForTests(() => older);
176
+ await runPairingCommand(["mint", "https://older.example.com"]);
177
+ setRemoteWebPairingChallengeNowForTests(() => newer);
178
+ await runPairingCommand(["mint", "https://newer.example.com"]);
179
+ setRemoteWebPairingChallengeNowForTests(() => Date.now());
180
+
181
+ const code = await runPairingCommand(["list", "--json"]);
182
+ expect(code).toBe(0);
183
+
184
+ const challenges = lastLoggedChallenges();
185
+ expect(challenges).toHaveLength(2);
186
+ expect(challenges[0]!.publicBaseUrl).toBe("https://newer.example.com");
187
+ expect(challenges[1]!.publicBaseUrl).toBe("https://older.example.com");
188
+ expect(challenges[1]!.createdAtMs).toBe(older);
189
+ expect(challenges[1]!.status).toBe("approved");
190
+ expect(challenges[1]!.expired).toBe(false);
191
+ expect(challenges[1]!.requesterIp).toBe("cli");
192
+ expect(challenges[1]!.userCode).toMatch(/^[A-Z2-9]{4}-[A-Z2-9]{4}$/);
193
+ });
194
+
195
+ test("expired challenges still list, flagged expired", async () => {
196
+ // Mint at a frozen time an hour ago: the challenge's persisted expiry
197
+ // (mint + 10-minute TTL) is then in the past of the real wall clock the
198
+ // CLI checks against, without the store's own expiry paths deleting it.
199
+ setRemoteWebPairingChallengeNowForTests(() => Date.now() - 3_600_000);
200
+ await runPairingCommand(["mint", "https://stale.example.com"]);
201
+ setRemoteWebPairingChallengeNowForTests(() => Date.now());
202
+
203
+ const code = await runPairingCommand(["list", "--json"]);
204
+ expect(code).toBe(0);
205
+
206
+ const challenges = lastLoggedChallenges();
207
+ expect(challenges).toHaveLength(1);
208
+ expect(challenges[0]!.expired).toBe(true);
209
+ expect(challenges[0]!.status).toBe("approved");
210
+ expect(challenges[0]!.publicBaseUrl).toBe("https://stale.example.com");
211
+ });
212
+
213
+ test("human output shows user code, status, liveness, and timestamps", async () => {
214
+ await runPairingCommand(["mint", "https://shown.example.com"]);
215
+
216
+ const code = await runPairingCommand(["list"]);
217
+ expect(code).toBe(0);
218
+
219
+ const output = logs.join("\n");
220
+ expect(output).toContain("approved");
221
+ expect(output).toContain("active");
222
+ expect(output).toContain("minted ");
223
+ expect(output).toContain("expires ");
224
+ expect(output).toContain("https://shown.example.com/assistant/pair");
225
+ });
226
+ });
package/src/cli/index.ts CHANGED
@@ -20,7 +20,7 @@ function printUsage(): void {
20
20
  " contacts Manage the contact ACL with list, get, set-risk-threshold",
21
21
  );
22
22
  console.log(
23
- " pairing Mint/approve a remote-web pairing challenge (mint <url>)",
23
+ " pairing Remote-web pairing challenges (mint <url> | list [--json])",
24
24
  );
25
25
  }
26
26