@mycadet/gateway 0.12.10 → 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 (85) hide show
  1. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  2. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  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 +10 -10
  5. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  6. package/node_modules/@mycadet/gateway-client/src/gateway-ipc-contracts.ts +0 -20
  7. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  8. package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  9. package/openapi.json +1 -1
  10. package/package.json +1 -1
  11. package/src/__tests__/actor-token-revocation.test.ts +41 -34
  12. package/src/__tests__/contact-store-mirror-op-missing.test.ts +33 -70
  13. package/src/__tests__/contacts-mirror-op-reporter.test.ts +33 -89
  14. package/src/__tests__/credential-requests.test.ts +4 -4
  15. package/src/__tests__/devices-mine.test.ts +187 -0
  16. package/src/__tests__/edge-auth.test.ts +1 -20
  17. package/src/__tests__/edge-guardian-auth.test.ts +1 -17
  18. package/src/__tests__/feature-flag-resolver.test.ts +2 -30
  19. package/src/__tests__/feature-flag-watcher-callback.test.ts +0 -22
  20. package/src/__tests__/feature-flags-route.test.ts +2 -191
  21. package/src/__tests__/guardian-bootstrap-lookups.test.ts +22 -29
  22. package/src/__tests__/guardian-init-lockfile.test.ts +2 -2
  23. package/src/__tests__/guardian-integrity-reporter.test.ts +51 -82
  24. package/src/__tests__/guardian-integrity.test.ts +16 -20
  25. package/src/__tests__/handle-inbound-trust-verdict.test.ts +1 -14
  26. package/src/__tests__/ipc-feature-flag-routes.test.ts +0 -56
  27. package/src/__tests__/lum-2783-recovery-e2e.test.ts +0 -12
  28. package/src/__tests__/post-assistant-ready-deferred.test.ts +0 -18
  29. package/src/__tests__/refresh-device-binding.test.ts +0 -12
  30. package/src/__tests__/remote-web-pairing-token.test.ts +5 -15
  31. package/src/__tests__/runtime-client.test.ts +7 -7
  32. package/src/__tests__/speech-relay-websocket.test.ts +4 -4
  33. package/src/__tests__/trust-verdict-resolver-guardian-integrity.test.ts +30 -36
  34. package/src/__tests__/twilio-webhooks.test.ts +2 -2
  35. package/src/auth/actor-token-revocation.ts +34 -9
  36. package/src/auth/guardian-bootstrap.ts +1 -1
  37. package/src/cli/__tests__/pairing.test.ts +95 -0
  38. package/src/cli/index.ts +1 -1
  39. package/src/cli/pairing.ts +81 -6
  40. package/src/contacts-mirror-op-reporter.ts +19 -36
  41. package/src/db/connection.ts +0 -10
  42. package/src/feature-flag-resolver.ts +2 -7
  43. package/src/feature-flag-watcher.ts +1 -14
  44. package/src/guardian-integrity-reporter.ts +19 -47
  45. package/src/http/middleware/auth.ts +33 -27
  46. package/src/http/routes/auth-token.ts +1 -1
  47. package/src/http/routes/devices-mine.ts +103 -0
  48. package/src/http/routes/feature-flags.ts +1 -8
  49. package/src/http/routes/guardian-pin.ts +2 -11
  50. package/src/http/routes/ipc-runtime-proxy.ts +1 -1
  51. package/src/http/routes/live-voice-websocket.ts +1 -1
  52. package/src/http/routes/runtime-audio-stream.ts +1 -1
  53. package/src/http/routes/runtime-proxy.ts +2 -2
  54. package/src/http/routes/speech-relay-websocket.ts +2 -2
  55. package/src/http/routes/twilio-voice-webhook.test.ts +13 -13
  56. package/src/index.ts +11 -198
  57. package/src/ipc/feature-flag-handlers.ts +2 -9
  58. package/src/remote-web/pairing-challenge-store.ts +15 -0
  59. package/src/risk/command-registry/commands/assistant.ts +0 -24
  60. package/src/risk/command-registry.test.ts +2 -4
  61. package/src/schema.ts +44 -372
  62. package/src/__tests__/auth-fallback-count-tracker.test.ts +0 -92
  63. package/src/__tests__/auth-fallback-reporter.test.ts +0 -101
  64. package/src/__tests__/backup-worker-cloud-mode.test.ts +0 -44
  65. package/src/__tests__/backup-worker-export-timeout.test.ts +0 -164
  66. package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -216
  67. package/src/__tests__/remote-feature-flag-sync.test.ts +0 -961
  68. package/src/auth-fallback-count-tracker.ts +0 -113
  69. package/src/auth-fallback-reporter.ts +0 -127
  70. package/src/backup/backup-key.ts +0 -138
  71. package/src/backup/backup-routes.ts +0 -161
  72. package/src/backup/backup-worker.ts +0 -425
  73. package/src/backup/list-snapshots.ts +0 -97
  74. package/src/backup/local-writer.ts +0 -87
  75. package/src/backup/offsite-writer.ts +0 -182
  76. package/src/backup/paths.ts +0 -113
  77. package/src/backup/stream-crypt.ts +0 -258
  78. package/src/device-id.ts +0 -18
  79. package/src/feature-flag-remote-store.ts +0 -166
  80. package/src/http/routes/migration-proxy.ts +0 -636
  81. package/src/internal-telemetry-client.ts +0 -39
  82. package/src/ipc/debug-export-handlers.test.ts +0 -102
  83. package/src/ipc/debug-export-handlers.ts +0 -81
  84. package/src/remote-feature-flag-sync.ts +0 -440
  85. package/src/watchdog-reporter.ts +0 -110
@@ -17,7 +17,6 @@ import {
17
17
  beforeAll,
18
18
  beforeEach,
19
19
  afterAll,
20
- afterEach,
21
20
  mock,
22
21
  } from "bun:test";
23
22
 
@@ -48,25 +47,33 @@ mock.module("../ipc/assistant-client.js", () => ({
48
47
 
49
48
  // ── Imports ──────────────────────────────────────────────────────────────────
50
49
 
51
- import { ContactStore } from "../db/contact-store.js";
52
50
  import {
53
51
  initGatewayDb,
54
52
  getGatewayDb,
55
53
  resetGatewayDb,
56
54
  } from "../db/connection.js";
57
55
  import { contacts, contactChannels } from "../db/schema.js";
58
- import {
59
- MIRROR_OP_MISSING_CHECK_NAME,
60
- flushMirrorOpReporterForTesting,
61
- resetMirrorOpReporterForTesting,
62
- setMirrorOpReporterOverridesForTesting,
63
- } from "../contacts-mirror-op-reporter.js";
56
+ import * as mirrorOpReporter from "../contacts-mirror-op-reporter.js";
64
57
 
65
58
  // ── Setup ────────────────────────────────────────────────────────────────────
66
59
 
67
- const errorLogs: [Record<string, unknown>, string][] = [];
68
- const warnLogs: unknown[] = [];
69
- let telemetryBodies: unknown[] = [];
60
+ // The reporter's report fns carry the detail payload; capture them by
61
+ // re-registering the reporter module with a recorder (its real sibling
62
+ // exports — the unknown-method classifier — stay live for the store).
63
+ const reportCalls: { op: string; detail: Record<string, unknown> }[] = [];
64
+
65
+ mock.module("../contacts-mirror-op-reporter.js", () => ({
66
+ ...mirrorOpReporter,
67
+ reportMirrorOpMissing: (op: string, detail: Record<string, unknown>) => {
68
+ reportCalls.push({ op, detail });
69
+ },
70
+ }));
71
+
72
+ // The SUT must load AFTER the mock registrations above — a static import
73
+ // would bind contact-store to the REAL ipc client + reporter before either
74
+ // mock.module runs (bun mock.module is not retroactive for modules already
75
+ // imported), and the escalations would never reach the recorder.
76
+ const { ContactStore } = await import("../db/contact-store.js");
70
77
 
71
78
  beforeAll(async () => {
72
79
  await initGatewayDb();
@@ -88,30 +95,7 @@ beforeEach(() => {
88
95
  return {};
89
96
  });
90
97
 
91
- errorLogs.length = 0;
92
- warnLogs.length = 0;
93
- telemetryBodies = [];
94
- resetMirrorOpReporterForTesting();
95
- setMirrorOpReporterOverridesForTesting({
96
- fetchImpl: async (_url, init) => {
97
- telemetryBodies.push(JSON.parse(String(init?.body)));
98
- return new Response("{}");
99
- },
100
- mintToken: () => "svc-token",
101
- baseUrl: "http://127.0.0.1:7821",
102
- log: {
103
- error: (detail, msg) => {
104
- errorLogs.push([detail, msg]);
105
- },
106
- warn: (detail, msg) => {
107
- warnLogs.push([detail, msg]);
108
- },
109
- },
110
- });
111
- });
112
-
113
- afterEach(() => {
114
- resetMirrorOpReporterForTesting();
98
+ reportCalls.length = 0;
115
99
  });
116
100
 
117
101
  afterAll(() => {
@@ -161,7 +145,7 @@ function unknownMethodError(method: string): Error {
161
145
  // ── Tests ────────────────────────────────────────────────────────────────────
162
146
 
163
147
  describe("mergeContacts — unknown mirror op escalation", () => {
164
- test("unknown method → error log + telemetry; gateway merge still applied", async () => {
148
+ test("unknown method → fail-loud report; gateway merge still applied", async () => {
165
149
  seedContact("ct_keep");
166
150
  seedContact("ct_merge");
167
151
  seedChannel("ch_1", "ct_merge");
@@ -175,7 +159,6 @@ describe("mergeContacts — unknown mirror op escalation", () => {
175
159
  "ct_keep",
176
160
  "ct_merge",
177
161
  );
178
- await flushMirrorOpReporterForTesting();
179
162
 
180
163
  // Gateway (source of truth) merge stands: donor gone, channel moved.
181
164
  expect(contact!.id).toBe("ct_keep");
@@ -196,26 +179,16 @@ describe("mergeContacts — unknown mirror op escalation", () => {
196
179
  .find((c) => c.id === "ch_1")?.contactId,
197
180
  ).toBe("ct_keep");
198
181
 
199
- // Escalated: one error log + one watchdog telemetry event.
200
- expect(errorLogs.length).toBe(1);
201
- expect(errorLogs[0][0]).toMatchObject({
202
- op: "contacts_mirror_merge_contact",
203
- keepId: "ct_keep",
204
- mergeId: "ct_merge",
205
- });
206
- expect(telemetryBodies).toEqual([
182
+ // Escalated: exactly one fail-loud report with the op + detail.
183
+ expect(reportCalls).toEqual([
207
184
  {
208
- check_name: MIRROR_OP_MISSING_CHECK_NAME,
209
- detail: {
210
- op: "contacts_mirror_merge_contact",
211
- keepId: "ct_keep",
212
- mergeId: "ct_merge",
213
- },
185
+ op: "contacts_mirror_merge_contact",
186
+ detail: { keepId: "ct_keep", mergeId: "ct_merge" },
214
187
  },
215
188
  ]);
216
189
  });
217
190
 
218
- test("other IPC failure → best-effort warn only, same gateway state", async () => {
191
+ test("other IPC failure → no report, same gateway state", async () => {
219
192
  seedContact("ct_keep");
220
193
  seedContact("ct_merge");
221
194
  seedChannel("ch_1", "ct_merge");
@@ -229,7 +202,6 @@ describe("mergeContacts — unknown mirror op escalation", () => {
229
202
  "ct_keep",
230
203
  "ct_merge",
231
204
  );
232
- await flushMirrorOpReporterForTesting();
233
205
 
234
206
  expect(contact!.id).toBe("ct_keep");
235
207
  expect(mirrored).toBe(false);
@@ -241,14 +213,13 @@ describe("mergeContacts — unknown mirror op escalation", () => {
241
213
  .find((c) => c.id === "ct_merge"),
242
214
  ).toBeUndefined();
243
215
 
244
- // No escalation: no reporter error log, no telemetry.
245
- expect(errorLogs.length).toBe(0);
246
- expect(telemetryBodies).toEqual([]);
216
+ // No escalation: the report never fires.
217
+ expect(reportCalls).toEqual([]);
247
218
  });
248
219
  });
249
220
 
250
221
  describe("upsertContact — unknown mirror op escalation", () => {
251
- test("unknown method → error log + telemetry; gateway upsert still applied", async () => {
222
+ test("unknown method → fail-loud report; gateway upsert still applied", async () => {
252
223
  ipcThrowOn.set(
253
224
  "contacts_mirror_upsert_full",
254
225
  unknownMethodError("contacts_mirror_upsert_full"),
@@ -259,7 +230,6 @@ describe("upsertContact — unknown mirror op escalation", () => {
259
230
  displayName: "Alice",
260
231
  channels: [{ type: "slack", address: "U12345" }],
261
232
  });
262
- await flushMirrorOpReporterForTesting();
263
233
 
264
234
  // Gateway write stands.
265
235
  expect(created).toBe(true);
@@ -272,20 +242,15 @@ describe("upsertContact — unknown mirror op escalation", () => {
272
242
  ).toBeDefined();
273
243
  expect(store.getChannelsForContact(contact.id)).toHaveLength(1);
274
244
 
275
- expect(errorLogs.length).toBe(1);
276
- expect(errorLogs[0][0]).toMatchObject({
277
- op: "contacts_mirror_upsert_full",
278
- contactId: contact.id,
279
- });
280
- expect(telemetryBodies).toEqual([
245
+ expect(reportCalls).toEqual([
281
246
  {
282
- check_name: MIRROR_OP_MISSING_CHECK_NAME,
283
- detail: { op: "contacts_mirror_upsert_full", contactId: contact.id },
247
+ op: "contacts_mirror_upsert_full",
248
+ detail: { contactId: contact.id },
284
249
  },
285
250
  ]);
286
251
  });
287
252
 
288
- test("other IPC failure → best-effort warn only, same gateway state", async () => {
253
+ test("other IPC failure → no report, same gateway state", async () => {
289
254
  ipcThrowOn.set("contacts_mirror_upsert_full", new Error("socket closed"));
290
255
 
291
256
  const store = new ContactStore();
@@ -293,11 +258,9 @@ describe("upsertContact — unknown mirror op escalation", () => {
293
258
  displayName: "Bob",
294
259
  channels: [{ type: "slack", address: "U67890" }],
295
260
  });
296
- await flushMirrorOpReporterForTesting();
297
261
 
298
262
  expect(created).toBe(true);
299
263
  expect(store.getChannelsForContact(contact.id)).toHaveLength(1);
300
- expect(errorLogs.length).toBe(0);
301
- expect(telemetryBodies).toEqual([]);
264
+ expect(reportCalls).toEqual([]);
302
265
  });
303
266
  });
@@ -1,121 +1,65 @@
1
1
  /**
2
2
  * Fail-loud mirror-op-missing reporter (contacts-mirror-op-reporter.ts):
3
3
  *
4
- * - First report per op emits one error log and one telemetry relay POST with
5
- * the `contacts_mirror_op_missing` check name and `{ op, ...detail }`.
4
+ * - First report per op emits one error log carrying `{ op, ...detail }`.
6
5
  * - Repeat reports for the same op inside the hourly window are dropped; a
7
6
  * DIFFERENT op still reports (per-op rate limit).
8
- * - Relay failures never throw out of the caller.
9
7
  * - `isUnknownIpcMethodError` matches only the IPC server's unknown-method
10
8
  * rejection shape.
11
9
  *
12
- * Logging is observed through the reporter's test-only `log` override —
13
- * bun's mock.module is process-global and would leak into other test files.
10
+ * Logs are observed by mocking the logger module (a side-effect boundary —
11
+ * the same convention as sleep-wake-detector.test.ts). Per-file CI truth:
12
+ * the mock is process-global, so this file pins nothing else.
14
13
  */
15
- import { beforeEach, afterEach, describe, expect, test } from "bun:test";
14
+ import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
16
15
 
17
- const errorLogs: unknown[] = [];
18
- const warnLogs: unknown[] = [];
16
+ const errorLogs: [Record<string, unknown>, string][] = [];
17
+
18
+ mock.module("../logger.js", () => ({
19
+ getLogger: () => ({
20
+ error: (detail: Record<string, unknown>, msg: string) => {
21
+ errorLogs.push([detail, msg]);
22
+ },
23
+ warn: () => {},
24
+ }),
25
+ }));
19
26
 
20
27
  await import("./test-preload.js");
21
- const {
22
- MIRROR_OP_MISSING_CHECK_NAME,
23
- flushMirrorOpReporterForTesting,
24
- isUnknownIpcMethodError,
25
- reportMirrorOpMissing,
26
- resetMirrorOpReporterForTesting,
27
- setMirrorOpReporterOverridesForTesting,
28
- } = await import("../contacts-mirror-op-reporter.js");
28
+ const { isUnknownIpcMethodError, reportMirrorOpMissing } =
29
+ await import("../contacts-mirror-op-reporter.js");
29
30
 
30
- type FetchCall = { url: string; init: RequestInit };
31
- let fetchCalls: FetchCall[] = [];
32
- let fetchResult: () => Promise<Response> = async () => new Response("{}");
31
+ let fakeNow = 1_000_000_000_000;
33
32
 
34
33
  beforeEach(() => {
35
- fetchCalls = [];
36
- fetchResult = async () => new Response("{}");
37
34
  errorLogs.length = 0;
38
- warnLogs.length = 0;
39
- resetMirrorOpReporterForTesting();
40
- setMirrorOpReporterOverridesForTesting({
41
- fetchImpl: async (url, init) => {
42
- fetchCalls.push({ url: String(url), init: init ?? {} });
43
- return fetchResult();
44
- },
45
- mintToken: () => "svc-token",
46
- baseUrl: "http://127.0.0.1:7821",
47
- log: {
48
- error: (detail, msg) => {
49
- errorLogs.push([detail, msg]);
50
- },
51
- warn: (detail, msg) => {
52
- warnLogs.push([detail, msg]);
53
- },
54
- },
55
- });
56
- });
57
-
58
- afterEach(() => {
59
- resetMirrorOpReporterForTesting();
35
+ fakeNow += 24 * 60 * 60 * 1000; // start each test outside prior windows
36
+ spyOn(Date, "now").mockImplementation(() => fakeNow);
60
37
  });
61
38
 
62
39
  describe("reportMirrorOpMissing", () => {
63
- test("first report logs an error and relays one watchdog event", async () => {
64
- reportMirrorOpMissing("contacts_mirror_merge_contact", {
65
- keepId: "ct_keep",
66
- mergeId: "ct_merge",
67
- });
68
- await flushMirrorOpReporterForTesting();
69
-
70
- expect(errorLogs.length).toBe(1);
71
- expect(fetchCalls.length).toBe(1);
72
- expect(fetchCalls[0].url).toBe(
73
- "http://127.0.0.1:7821/v1/internal/telemetry/watchdog",
74
- );
75
- const headers = fetchCalls[0].init.headers as Record<string, string>;
76
- expect(headers.Authorization).toBe("Bearer svc-token");
77
- expect(JSON.parse(String(fetchCalls[0].init.body))).toEqual({
78
- check_name: MIRROR_OP_MISSING_CHECK_NAME,
79
- detail: {
80
- op: "contacts_mirror_merge_contact",
40
+ test("first report logs one error with the op + detail", () => {
41
+ expect(() =>
42
+ reportMirrorOpMissing("contacts_mirror_merge_contact", {
81
43
  keepId: "ct_keep",
82
44
  mergeId: "ct_merge",
83
- },
45
+ }),
46
+ ).not.toThrow();
47
+
48
+ expect(errorLogs.length).toBe(1);
49
+ expect(errorLogs[0]![0]).toMatchObject({
50
+ op: "contacts_mirror_merge_contact",
51
+ keepId: "ct_keep",
52
+ mergeId: "ct_merge",
84
53
  });
54
+ expect(errorLogs[0]![1]).toContain("divergence until the daemon updates");
85
55
  });
86
56
 
87
- test("repeat reports for the same op are dropped; a different op still fires", async () => {
57
+ test("repeat reports for the same op are dropped; a different op still fires", () => {
88
58
  reportMirrorOpMissing("contacts_mirror_merge_contact", { keepId: "a" });
89
59
  reportMirrorOpMissing("contacts_mirror_merge_contact", { keepId: "b" });
90
60
  reportMirrorOpMissing("contacts_mirror_upsert_full", { contactId: "c" });
91
- await flushMirrorOpReporterForTesting();
92
61
 
93
62
  expect(errorLogs.length).toBe(2);
94
- expect(fetchCalls.length).toBe(2);
95
- });
96
-
97
- test("a rejected relay never throws out of the caller", async () => {
98
- fetchResult = async () => {
99
- throw new Error("daemon unreachable");
100
- };
101
-
102
- expect(() =>
103
- reportMirrorOpMissing("contacts_mirror_upsert_full", { contactId: "x" }),
104
- ).not.toThrow();
105
- await flushMirrorOpReporterForTesting();
106
-
107
- expect(errorLogs.length).toBe(1);
108
- expect(warnLogs.length).toBe(1);
109
- });
110
-
111
- test("a non-ok relay response is swallowed with a warning", async () => {
112
- fetchResult = async () => new Response("nope", { status: 500 });
113
-
114
- reportMirrorOpMissing("contacts_mirror_upsert_full", { contactId: "x" });
115
- await flushMirrorOpReporterForTesting();
116
-
117
- expect(fetchCalls.length).toBe(1);
118
- expect(warnLogs.length).toBe(1);
119
63
  });
120
64
  });
121
65
 
@@ -121,7 +121,7 @@ describe("credential-request mint (IPC)", () => {
121
121
  test("makes public ingress live on the successful mint path", async () => {
122
122
  const ensurePublicIngressLive = mock(async () => {});
123
123
  const result = await createCredentialRequest(
124
- fakeGatewayConfig("https://tunnel-dev.mycadet.ai"),
124
+ fakeGatewayConfig("https://dev-tunnel.mycadet.ai"),
125
125
  fakeConfigFile(undefined),
126
126
  { service: "github", field: "api_token" },
127
127
  {
@@ -152,7 +152,7 @@ describe("credential-request mint (IPC)", () => {
152
152
  test("does not read the cloud assistant id when the config URL resolves", async () => {
153
153
  const getCloudAssistantId = mock(async () => "assistant-123");
154
154
  const result = await createCredentialRequest(
155
- fakeGatewayConfig("https://tunnel-dev.mycadet.ai"),
155
+ fakeGatewayConfig("https://dev-tunnel.mycadet.ai"),
156
156
  fakeConfigFile("https://assistant.example.com"),
157
157
  { service: "github", field: "api_token" },
158
158
  { getCloudAssistantId },
@@ -165,7 +165,7 @@ describe("credential-request mint (IPC)", () => {
165
165
  // ingress.publicBaseUrl is Tunnel-managed and cleared on tunnel flaps, so
166
166
  // the mint must still resolve a URL from tunnelBaseUrl + the assistant id.
167
167
  const result = await createCredentialRequest(
168
- fakeGatewayConfig("https://tunnel-dev.mycadet.ai"),
168
+ fakeGatewayConfig("https://dev-tunnel.mycadet.ai"),
169
169
  fakeConfigFile(undefined),
170
170
  { service: "github", field: "api_token" },
171
171
  { getCloudAssistantId: async () => "assistant-123" },
@@ -173,7 +173,7 @@ describe("credential-request mint (IPC)", () => {
173
173
 
174
174
  if (!result.ok) throw new Error(`mint failed: ${result.error}`);
175
175
  expect(result.url).toBe(
176
- `https://tunnel-dev.mycadet.ai/assistant-123/assistant/credentials/enter#token=${encodeURIComponent(result.token)}`,
176
+ `https://dev-tunnel.mycadet.ai/assistant-123/assistant/credentials/enter#token=${encodeURIComponent(result.token)}`,
177
177
  );
178
178
  });
179
179
 
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Tests for GET /v1/devices/mine — the caller's own paired-device row. The
3
+ * bearer IS the credential (browser-reachable by design), and the response
4
+ * mirrors the /v1/devices projection for the caller's device so a
5
+ * `devices/mine` answer matches that device's row in the host-side
6
+ * `cadet devices` list field for field — including the cross-status
7
+ * max(lastUsedAt) that keeps activity history across token rotation.
8
+ */
9
+ import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
10
+ import { randomUUID } from "node:crypto";
11
+ import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { eq } from "drizzle-orm";
15
+
16
+ import { initSigningKey } from "../auth/token-service.js";
17
+
18
+ initSigningKey(Buffer.from("test-signing-key-at-least-32-bytes-long-xx"));
19
+
20
+ // The pair guardian-lookup reads the gateway DB; the assistant DB proxy is
21
+ // mocked so any incidental assistant access stays inert in tests.
22
+ mock.module("../db/assistant-db-proxy.js", () => ({
23
+ assistantDbQuery: mock(),
24
+ assistantDbRun: mock(),
25
+ assistantDbExec: mock(),
26
+ }));
27
+
28
+ const { initGatewayDb, resetGatewayDb, getGatewayDb } = await import(
29
+ "../db/connection.js"
30
+ );
31
+ const { actorTokenRecords, contacts, contactChannels } = await import(
32
+ "../db/schema.js"
33
+ );
34
+ const { hashToken, mintAndRecordBrowserTokenPair } = await import(
35
+ "../auth/guardian-bootstrap.js"
36
+ );
37
+ const { handleGetMyDevice } = await import("../http/routes/devices-mine.js");
38
+
39
+ const GUARDIAN_ID = "guardian-001";
40
+
41
+ let testRoot: string;
42
+
43
+ function mineRequest(bearer: string | null): Request {
44
+ const headers = new Headers({ host: "localhost:7830" });
45
+ if (bearer) {
46
+ headers.set("authorization", `Bearer ${bearer}`);
47
+ }
48
+ return new Request("http://localhost:7830/v1/devices/mine", {
49
+ method: "GET",
50
+ headers,
51
+ });
52
+ }
53
+
54
+ function rowForToken(token: string) {
55
+ return getGatewayDb()
56
+ .select()
57
+ .from(actorTokenRecords)
58
+ .where(eq(actorTokenRecords.tokenHash, hashToken(token)))
59
+ .get();
60
+ }
61
+
62
+ beforeEach(async () => {
63
+ testRoot = mkdtempSync(join(tmpdir(), "devices-mine-test-"));
64
+ const securityDir = join(testRoot, "protected");
65
+ mkdirSync(securityDir, { recursive: true });
66
+ process.env.GATEWAY_SECURITY_DIR = securityDir;
67
+ await initGatewayDb();
68
+
69
+ // The route answers for any actor-token row; the guardian contact seed
70
+ // mirrors the devices.test.ts fixture shape.
71
+ const now = Date.now();
72
+ getGatewayDb()
73
+ .insert(contacts)
74
+ .values({
75
+ id: GUARDIAN_ID,
76
+ displayName: "Guardian",
77
+ role: "guardian",
78
+ principalId: GUARDIAN_ID,
79
+ createdAt: now,
80
+ updatedAt: now,
81
+ })
82
+ .run();
83
+ getGatewayDb()
84
+ .insert(contactChannels)
85
+ .values({
86
+ id: `ch-${GUARDIAN_ID}`,
87
+ contactId: GUARDIAN_ID,
88
+ type: "cadet",
89
+ address: "guardian-cadet",
90
+ isPrimary: false,
91
+ createdAt: now,
92
+ updatedAt: now,
93
+ })
94
+ .run();
95
+ });
96
+
97
+ afterEach(() => {
98
+ resetGatewayDb();
99
+ delete process.env.GATEWAY_SECURITY_DIR;
100
+ rmSync(testRoot, { recursive: true, force: true });
101
+ });
102
+
103
+ describe("GET /v1/devices/mine", () => {
104
+ test("returns the caller's own device row", async () => {
105
+ const pair = mintAndRecordBrowserTokenPair({
106
+ guardianPrincipalId: GUARDIAN_ID,
107
+ platform: "web",
108
+ browserRefreshCookiePath: "/",
109
+ identity: { pairingUserAgent: "Mozilla/5.0 TestBrowser" },
110
+ });
111
+
112
+ const response = await handleGetMyDevice(mineRequest(pair.accessToken));
113
+ expect(response.status).toBe(200);
114
+
115
+ const body = (await response.json()) as { device: Record<string, unknown> };
116
+ const stored = rowForToken(pair.accessToken);
117
+ expect(stored).toBeTruthy();
118
+ expect(body.device.hashedDeviceId).toBe(stored!.hashedDeviceId);
119
+ expect(body.device.platform).toBe("web");
120
+ expect(body.device.pairingUserAgent).toBe("Mozilla/5.0 TestBrowser");
121
+ expect(body.device.issuedAt).toBe(stored!.issuedAt);
122
+ });
123
+
124
+ test("lastUsedAt is the cross-status max for the device", async () => {
125
+ const pair = mintAndRecordBrowserTokenPair({
126
+ guardianPrincipalId: GUARDIAN_ID,
127
+ platform: "web",
128
+ browserRefreshCookiePath: "/",
129
+ });
130
+ const stored = rowForToken(pair.accessToken);
131
+ expect(stored).toBeTruthy();
132
+
133
+ // A rotation revokes the old row and mints an unstamped replacement,
134
+ // so the newest activity stamp can live on the revoked row — the same
135
+ // rule the /v1/devices list applies.
136
+ const now = Date.now();
137
+ getGatewayDb()
138
+ .insert(actorTokenRecords)
139
+ .values({
140
+ id: randomUUID(),
141
+ tokenHash: hashToken(`rotated-${stored!.hashedDeviceId}`),
142
+ guardianPrincipalId: GUARDIAN_ID,
143
+ hashedDeviceId: stored!.hashedDeviceId,
144
+ platform: "web",
145
+ status: "revoked",
146
+ issuedAt: now - 86_400_000,
147
+ expiresAt: now + 86_400_000,
148
+ lastUsedAt: now - 60_000,
149
+ createdAt: now,
150
+ updatedAt: now,
151
+ })
152
+ .run();
153
+
154
+ const response = await handleGetMyDevice(mineRequest(pair.accessToken));
155
+ expect(response.status).toBe(200);
156
+ const body = (await response.json()) as { device: { lastUsedAt: number | null } };
157
+ expect(body.device.lastUsedAt).toBe(now - 60_000);
158
+ });
159
+
160
+ test("an unknown bearer answers 404, not an error", async () => {
161
+ const response = await handleGetMyDevice(
162
+ mineRequest("not-a-token-anyone-minted"),
163
+ );
164
+ expect(response.status).toBe(404);
165
+ });
166
+
167
+ test("a cloud-hosted assistant answers 404 before any row lookup", async () => {
168
+ process.env.DISABLE_HTTP_AUTH = "true";
169
+ process.env.IS_CLOUD = "true";
170
+ try {
171
+ const response = await handleGetMyDevice(mineRequest(null));
172
+ expect(response.status).toBe(404);
173
+ const body = (await response.json()) as { error: { code: string } };
174
+ expect(body.error.code).toBe("NOT_AVAILABLE_ON_CLOUD");
175
+ } finally {
176
+ delete process.env.DISABLE_HTTP_AUTH;
177
+ delete process.env.IS_CLOUD;
178
+ }
179
+ });
180
+
181
+ test("non-GET answers method not allowed", async () => {
182
+ const response = await handleGetMyDevice(
183
+ new Request("http://localhost:7830/v1/devices/mine", { method: "POST" }),
184
+ );
185
+ expect(response.status).toBe(405);
186
+ });
187
+ });
@@ -64,8 +64,7 @@ mock.module("../auth/token-exchange.js", () => ({
64
64
  }));
65
65
 
66
66
  const { AuthRateLimiter } = await import("../auth-rate-limiter.js");
67
- const { createAuthMiddleware, loopbackFallbackCountTracker } =
68
- await import("../http/middleware/auth.js");
67
+ const { createAuthMiddleware } = await import("../http/middleware/auth.js");
69
68
  const { initGatewayDb, resetGatewayDb, getGatewayDb } =
70
69
  await import("../db/connection.js");
71
70
  const { actorTokenRecords } = await import("../db/schema.js");
@@ -96,7 +95,6 @@ beforeEach(() => {
96
95
  mockReadCredential = mock(async () => undefined);
97
96
  mockPlatformUserIdUnreachable = false;
98
97
  mockValidateEdgeToken = mock(() => ({ ok: false, reason: "noop" }));
99
- loopbackFallbackCountTracker.reset();
100
98
  });
101
99
 
102
100
  afterEach(() => {
@@ -238,21 +236,6 @@ describe("requireEdgeAuth — JWT mode", () => {
238
236
  );
239
237
  expect(res?.status).toBe(401);
240
238
  expect(mockValidateEdgeToken).not.toHaveBeenCalled();
241
- expect(loopbackFallbackCountTracker.snapshot()).toEqual([]);
242
- });
243
-
244
- test("a loopback fallback is counted by (guard, path, failureKind)", async () => {
245
- const { requireEdgeAuth } = makeMiddleware();
246
- await requireEdgeAuth(makeReq(), makeLoopbackServer());
247
- await requireEdgeAuth(makeReq(), makeLoopbackServer());
248
- expect(loopbackFallbackCountTracker.snapshot()).toEqual([
249
- {
250
- guard: "edge",
251
- path: "/v1/something",
252
- failureKind: "missing_authorization",
253
- count: 2,
254
- },
255
- ]);
256
239
  });
257
240
 
258
241
  test("null on valid bearer token", async () => {
@@ -389,7 +372,6 @@ describe("requireEdgeAuthWithScope — JWT mode", () => {
389
372
  );
390
373
  expect(res?.status).toBe(401);
391
374
  expect(mockValidateEdgeToken).not.toHaveBeenCalled();
392
- expect(loopbackFallbackCountTracker.snapshot()).toEqual([]);
393
375
  });
394
376
 
395
377
  test("403 when token's scope_profile lacks the required scope", async () => {
@@ -512,7 +494,6 @@ describe("edge auth refuses a single-route grant", () => {
512
494
  makeLoopbackServer(),
513
495
  );
514
496
  expect(res?.status).toBe(401);
515
- expect(loopbackFallbackCountTracker.snapshot()).toEqual([]);
516
497
  });
517
498
 
518
499
  test("requireEdgeAuthWithScope 401s the grant before the scope check", async () => {
@@ -53,8 +53,7 @@ mock.module("../auth/token-exchange.js", () => ({
53
53
  }));
54
54
 
55
55
  const { AuthRateLimiter } = await import("../auth-rate-limiter.js");
56
- const { createAuthMiddleware, loopbackFallbackCountTracker } =
57
- await import("../http/middleware/auth.js");
56
+ const { createAuthMiddleware } = await import("../http/middleware/auth.js");
58
57
 
59
58
  const PLATFORM_USER_ID = "user-abc-123";
60
59
  const GUARDIAN_PRINCIPAL = "actor-guardian-xyz";
@@ -82,7 +81,6 @@ beforeEach(() => {
82
81
  mockPlatformUserIdUnreachable = false;
83
82
  mockFindCadetGuardian = mock(async () => null);
84
83
  mockValidateEdgeToken = mock(() => ({ ok: false, reason: "noop" }));
85
- loopbackFallbackCountTracker.reset();
86
84
  });
87
85
 
88
86
  afterEach(() => {
@@ -210,7 +208,6 @@ describe("requireEdgeGuardianAuth — actor principal mode", () => {
210
208
  expect(res?.status).toBe(401);
211
209
  expect(mockValidateEdgeToken).not.toHaveBeenCalled();
212
210
  expect(mockFindCadetGuardian).not.toHaveBeenCalled();
213
- expect(loopbackFallbackCountTracker.snapshot()).toEqual([]);
214
211
  });
215
212
 
216
213
  test("invalid bearer token falls back to loopback", async () => {
@@ -224,19 +221,6 @@ describe("requireEdgeGuardianAuth — actor principal mode", () => {
224
221
  expect(mockFindCadetGuardian).not.toHaveBeenCalled();
225
222
  });
226
223
 
227
- test("a guardian loopback fallback is counted under the edge-guardian guard", async () => {
228
- const { requireEdgeGuardianAuth } = makeMiddleware();
229
- await requireEdgeGuardianAuth(makeReq(), makeLoopbackServer());
230
- expect(loopbackFallbackCountTracker.snapshot()).toEqual([
231
- {
232
- guard: "edge-guardian",
233
- path: "/v1/contact-channels/abc/verify",
234
- failureKind: "missing_authorization",
235
- count: 1,
236
- },
237
- ]);
238
- });
239
-
240
224
  test("returns 503 when findCadetGuardian throws (transient assistant DB outage)", async () => {
241
225
  mockValidateEdgeToken = mock(() => ({
242
226
  ok: true,