@mycadet/gateway 0.12.11 → 0.13.0-dev.202610171617.9ea363e

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 (101) 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/channels.ts +0 -1
  3. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  4. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  5. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
  6. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  7. package/node_modules/@mycadet/gateway-client/src/admission-policy-contract.ts +1 -2
  8. package/node_modules/@mycadet/gateway-client/src/gateway-ipc-contracts.ts +0 -20
  9. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  10. package/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
  11. package/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  12. package/openapi.json +1 -1
  13. package/package.json +5 -1
  14. package/src/__tests__/actor-token-revocation.test.ts +41 -34
  15. package/src/__tests__/channel-admission-policy-routes.test.ts +4 -17
  16. package/src/__tests__/contact-store-mirror-op-missing.test.ts +33 -70
  17. package/src/__tests__/contacts-control-plane-proxy.test.ts +0 -18
  18. package/src/__tests__/contacts-mirror-op-reporter.test.ts +33 -89
  19. package/src/__tests__/credential-requests.test.ts +4 -4
  20. package/src/__tests__/devices-mine.test.ts +187 -0
  21. package/src/__tests__/edge-auth.test.ts +1 -20
  22. package/src/__tests__/edge-guardian-auth.test.ts +1 -17
  23. package/src/__tests__/feature-flag-resolver.test.ts +2 -30
  24. package/src/__tests__/feature-flag-watcher-callback.test.ts +0 -22
  25. package/src/__tests__/feature-flags-route.test.ts +10 -193
  26. package/src/__tests__/guardian-bootstrap-lookups.test.ts +18 -25
  27. package/src/__tests__/guardian-integrity-reporter.test.ts +51 -82
  28. package/src/__tests__/guardian-integrity.test.ts +16 -20
  29. package/src/__tests__/handle-inbound-trust-verdict.test.ts +1 -14
  30. package/src/__tests__/ipc-feature-flag-routes.test.ts +0 -56
  31. package/src/__tests__/lum-2783-recovery-e2e.test.ts +0 -12
  32. package/src/__tests__/post-assistant-ready-deferred.test.ts +0 -18
  33. package/src/__tests__/refresh-device-binding.test.ts +0 -12
  34. package/src/__tests__/remote-web-ingress-denylist.test.ts +22 -13
  35. package/src/__tests__/remote-web-pairing-token.test.ts +4 -14
  36. package/src/__tests__/route-schema-guard.test.ts +0 -19
  37. package/src/__tests__/runtime-client.test.ts +7 -7
  38. package/src/__tests__/seed-admission-policy.test.ts +0 -6
  39. package/src/__tests__/speech-relay-websocket.test.ts +4 -4
  40. package/src/__tests__/trust-verdict-resolver-guardian-integrity.test.ts +30 -36
  41. package/src/__tests__/twilio-webhooks.test.ts +2 -2
  42. package/src/auth/actor-token-revocation.ts +34 -9
  43. package/src/channels/inbound-event.ts +1 -3
  44. package/src/channels/types.ts +0 -2
  45. package/src/cli/__tests__/pairing.test.ts +95 -0
  46. package/src/cli/index.ts +1 -1
  47. package/src/cli/pairing.ts +81 -6
  48. package/src/contacts-mirror-op-reporter.ts +19 -36
  49. package/src/db/admission-policy-store.ts +1 -1
  50. package/src/db/connection.ts +0 -10
  51. package/src/db/seed-admission-policy.ts +1 -1
  52. package/src/feature-flag-registry.json +0 -130
  53. package/src/feature-flag-resolver.ts +2 -7
  54. package/src/feature-flag-watcher.ts +1 -14
  55. package/src/guardian-integrity-reporter.ts +19 -47
  56. package/src/handlers/handle-inbound.ts +1 -1
  57. package/src/http/middleware/auth.ts +33 -27
  58. package/src/http/routes/auth-token.ts +1 -1
  59. package/src/http/routes/channel-admission-policy.ts +2 -2
  60. package/src/http/routes/contacts-control-plane-proxy.ts +0 -9
  61. package/src/http/routes/devices-mine.ts +103 -0
  62. package/src/http/routes/feature-flags.ts +1 -8
  63. package/src/http/routes/guardian-pin.ts +2 -11
  64. package/src/http/routes/ipc-runtime-proxy.ts +1 -1
  65. package/src/http/routes/live-voice-websocket.ts +1 -1
  66. package/src/http/routes/runtime-audio-stream.ts +1 -1
  67. package/src/http/routes/runtime-proxy.ts +2 -2
  68. package/src/http/routes/twilio-voice-webhook.test.ts +13 -13
  69. package/src/index.ts +11 -210
  70. package/src/ipc/feature-flag-handlers.ts +2 -9
  71. package/src/remote-web/pairing-challenge-store.ts +15 -0
  72. package/src/risk/command-registry/commands/assistant.ts +0 -24
  73. package/src/risk/command-registry.test.ts +2 -4
  74. package/src/schema.ts +45 -373
  75. package/src/types.ts +0 -1
  76. package/src/__tests__/auth-fallback-count-tracker.test.ts +0 -92
  77. package/src/__tests__/auth-fallback-reporter.test.ts +0 -101
  78. package/src/__tests__/backup-worker-cloud-mode.test.ts +0 -44
  79. package/src/__tests__/backup-worker-export-timeout.test.ts +0 -164
  80. package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -216
  81. package/src/__tests__/remote-feature-flag-sync.test.ts +0 -961
  82. package/src/auth-fallback-count-tracker.ts +0 -113
  83. package/src/auth-fallback-reporter.ts +0 -127
  84. package/src/backup/backup-key.ts +0 -138
  85. package/src/backup/backup-routes.ts +0 -161
  86. package/src/backup/backup-worker.ts +0 -425
  87. package/src/backup/list-snapshots.ts +0 -97
  88. package/src/backup/local-writer.ts +0 -87
  89. package/src/backup/offsite-writer.ts +0 -182
  90. package/src/backup/paths.ts +0 -113
  91. package/src/backup/stream-crypt.ts +0 -258
  92. package/src/device-id.ts +0 -18
  93. package/src/feature-flag-remote-store.ts +0 -166
  94. package/src/http/routes/a2a-routes.test.ts +0 -146
  95. package/src/http/routes/a2a-routes.ts +0 -126
  96. package/src/http/routes/migration-proxy.ts +0 -636
  97. package/src/internal-telemetry-client.ts +0 -39
  98. package/src/ipc/debug-export-handlers.test.ts +0 -102
  99. package/src/ipc/debug-export-handlers.ts +0 -81
  100. package/src/remote-feature-flag-sync.ts +0 -440
  101. package/src/watchdog-reporter.ts +0 -110
@@ -28,10 +28,10 @@ describe("tunnelHostForCloudHost", () => {
28
28
 
29
29
  test("maps env-prefixed platform hosts to their env tunnel", () => {
30
30
  expect(tunnelHostForCloudHost("staging-cloud.mycadet.ai")).toBe(
31
- "tunnel-staging.mycadet.ai",
31
+ "staging-tunnel.mycadet.ai",
32
32
  );
33
33
  expect(tunnelHostForCloudHost("dev-cloud.mycadet.ai")).toBe(
34
- "tunnel-dev.mycadet.ai",
34
+ "dev-tunnel.mycadet.ai",
35
35
  );
36
36
  });
37
37
 
@@ -39,7 +39,6 @@ export const CHANNEL_IDS = [
39
39
  "slack",
40
40
  "email",
41
41
  "platform",
42
- "a2a",
43
42
  "discord",
44
43
  "plugin",
45
44
  // Internal id for conversations a guardian shares with a contact. It has no
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Map a Cadet Cloud host onto its environment's tunnel host, following the
3
3
  * deployment naming convention (`cloud.mycadet.ai` → `tunnel.mycadet.ai`,
4
- * `{env}-cloud.mycadet.ai` → `tunnel-{env}.mycadet.ai`). Returns null for
4
+ * `{env}-cloud.mycadet.ai` → `{env}-tunnel.mycadet.ai`). Returns null for
5
5
  * hosts outside that convention (localhost, custom domains). Shared by the
6
6
  * gateway speech relay and the web live-voice client so the two ends of the
7
7
  * cloud-speech transport can't drift.
@@ -11,7 +11,7 @@ export function tunnelHostForCloudHost(host: string): string | null {
11
11
  return "tunnel.mycadet.ai";
12
12
  }
13
13
  const match = /^([a-z0-9-]+)-cloud\.mycadet\.ai$/.exec(host);
14
- return match ? `tunnel-${match[1]}.mycadet.ai` : null;
14
+ return match ? `${match[1]}-tunnel.mycadet.ai` : null;
15
15
  }
16
16
 
17
17
  export function normalizePublicBaseUrl(value: unknown): string | undefined {
@@ -28,10 +28,10 @@ describe("tunnelHostForCloudHost", () => {
28
28
 
29
29
  test("maps env-prefixed platform hosts to their env tunnel", () => {
30
30
  expect(tunnelHostForCloudHost("staging-cloud.mycadet.ai")).toBe(
31
- "tunnel-staging.mycadet.ai",
31
+ "staging-tunnel.mycadet.ai",
32
32
  );
33
33
  expect(tunnelHostForCloudHost("dev-cloud.mycadet.ai")).toBe(
34
- "tunnel-dev.mycadet.ai",
34
+ "dev-tunnel.mycadet.ai",
35
35
  );
36
36
  });
37
37
 
@@ -39,7 +39,6 @@ export const CHANNEL_IDS = [
39
39
  "slack",
40
40
  "email",
41
41
  "platform",
42
- "a2a",
43
42
  "discord",
44
43
  "plugin",
45
44
  // Internal id for conversations a guardian shares with a contact. It has no
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Map a Cadet Cloud host onto its environment's tunnel host, following the
3
3
  * deployment naming convention (`cloud.mycadet.ai` → `tunnel.mycadet.ai`,
4
- * `{env}-cloud.mycadet.ai` → `tunnel-{env}.mycadet.ai`). Returns null for
4
+ * `{env}-cloud.mycadet.ai` → `{env}-tunnel.mycadet.ai`). Returns null for
5
5
  * hosts outside that convention (localhost, custom domains). Shared by the
6
6
  * gateway speech relay and the web live-voice client so the two ends of the
7
7
  * cloud-speech transport can't drift.
@@ -11,7 +11,7 @@ export function tunnelHostForCloudHost(host: string): string | null {
11
11
  return "tunnel.mycadet.ai";
12
12
  }
13
13
  const match = /^([a-z0-9-]+)-cloud\.mycadet\.ai$/.exec(host);
14
- return match ? `tunnel-${match[1]}.mycadet.ai` : null;
14
+ return match ? `${match[1]}-tunnel.mycadet.ai` : null;
15
15
  }
16
16
 
17
17
  export function normalizePublicBaseUrl(value: unknown): string | undefined {
@@ -52,7 +52,7 @@ export const ADMISSION_FLOOR: Record<AdmissionPolicy, number> = {
52
52
  * Hard-exempt internal channels — never subject to PUT policy, omitted from
53
53
  * GET list, runtime admission stage short-circuits without floor check.
54
54
  *
55
- * `platform` / `a2a` are peer/internal channels with no human-trust model.
55
+ * `platform` is the peer/internal channel with no human-trust model.
56
56
  *
57
57
  * `phone` is NOT exempt — voice ingress enforces the admission floor.
58
58
  *
@@ -62,7 +62,6 @@ export const ADMISSION_FLOOR: Record<AdmissionPolicy, number> = {
62
62
  */
63
63
  export const ADMISSION_POLICY_EXEMPT_CHANNELS: ReadonlySet<string> = new Set([
64
64
  "platform",
65
- "a2a",
66
65
  ]);
67
66
 
68
67
  export function isAdmissionPolicyExemptChannel(channelType: string): boolean {
@@ -68,26 +68,6 @@ export type GatewayLogsTailRouteParams = z.infer<
68
68
  typeof GatewayLogsTailRouteParamsSchema
69
69
  >;
70
70
 
71
- /**
72
- * `gateway_debug_export`: a consistent copy of the gateway database plus its
73
- * recent log files, as one gzipped tar, for a debug bundle. No params.
74
- */
75
- export const GatewayDebugExportIpcParamsSchema = z
76
- .object({})
77
- .strict()
78
- .default({});
79
-
80
- export const GatewayDebugExportIpcResponseSchema = z.object({
81
- ok: z.literal(true),
82
- /** `tar.gz` bytes, base64. Contains gateway.sqlite and gateway-logs/. */
83
- archive_base64: z.string(),
84
- size_bytes: z.number().int().nonnegative(),
85
- });
86
-
87
- export type GatewayDebugExportIpcResponse = z.infer<
88
- typeof GatewayDebugExportIpcResponseSchema
89
- >;
90
-
91
71
  export const GatewayLogsTailIpcResponseSchema = z.object({
92
72
  lines: z.array(z.record(z.string(), z.unknown())),
93
73
  truncated: z.boolean(),
@@ -28,10 +28,10 @@ describe("tunnelHostForCloudHost", () => {
28
28
 
29
29
  test("maps env-prefixed platform hosts to their env tunnel", () => {
30
30
  expect(tunnelHostForCloudHost("staging-cloud.mycadet.ai")).toBe(
31
- "tunnel-staging.mycadet.ai",
31
+ "staging-tunnel.mycadet.ai",
32
32
  );
33
33
  expect(tunnelHostForCloudHost("dev-cloud.mycadet.ai")).toBe(
34
- "tunnel-dev.mycadet.ai",
34
+ "dev-tunnel.mycadet.ai",
35
35
  );
36
36
  });
37
37
 
@@ -39,7 +39,6 @@ export const CHANNEL_IDS = [
39
39
  "slack",
40
40
  "email",
41
41
  "platform",
42
- "a2a",
43
42
  "discord",
44
43
  "plugin",
45
44
  // Internal id for conversations a guardian shares with a contact. It has no
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Map a Cadet Cloud host onto its environment's tunnel host, following the
3
3
  * deployment naming convention (`cloud.mycadet.ai` → `tunnel.mycadet.ai`,
4
- * `{env}-cloud.mycadet.ai` → `tunnel-{env}.mycadet.ai`). Returns null for
4
+ * `{env}-cloud.mycadet.ai` → `{env}-tunnel.mycadet.ai`). Returns null for
5
5
  * hosts outside that convention (localhost, custom domains). Shared by the
6
6
  * gateway speech relay and the web live-voice client so the two ends of the
7
7
  * cloud-speech transport can't drift.
@@ -11,7 +11,7 @@ export function tunnelHostForCloudHost(host: string): string | null {
11
11
  return "tunnel.mycadet.ai";
12
12
  }
13
13
  const match = /^([a-z0-9-]+)-cloud\.mycadet\.ai$/.exec(host);
14
- return match ? `tunnel-${match[1]}.mycadet.ai` : null;
14
+ return match ? `${match[1]}-tunnel.mycadet.ai` : null;
15
15
  }
16
16
 
17
17
  export function normalizePublicBaseUrl(value: unknown): string | undefined {
package/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Cadet Gateway API",
5
- "version": "0.12.8",
5
+ "version": "0.12.9",
6
6
  "description": "Auto-generated OpenAPI specification for the Cadet Gateway HTTP endpoints."
7
7
  },
8
8
  "servers": [{ "url": "", "description": "Same-origin — the gateway" }],
package/package.json CHANGED
@@ -1,6 +1,10 @@
1
1
  {
2
2
  "name": "@mycadet/gateway",
3
- "version": "0.12.11",
3
+ "version": "0.13.0-dev.202610171617.9ea363e",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/vargasjr-dev/mycadet-temp.git"
7
+ },
4
8
  "license": "MIT",
5
9
  "type": "module",
6
10
  "exports": {
@@ -30,10 +30,6 @@ const { createRuntimeProxyHandler } =
30
30
  await import("../http/routes/runtime-proxy.js");
31
31
  const { bustGuardianIntegrityCache } =
32
32
  await import("../auth/guardian-integrity.js");
33
- const {
34
- resetGuardianIntegrityReporterForTesting,
35
- setGuardianIntegrityReporterOverridesForTesting,
36
- } = await import("../guardian-integrity-reporter.js");
37
33
 
38
34
  const ACTOR_SUB = "actor:self:guardian-001";
39
35
  const actorClaims = { sub: ACTOR_SUB } as TokenClaims;
@@ -128,20 +124,12 @@ beforeEach(async () => {
128
124
  await initGatewayDb();
129
125
  __resetLastUsedDebounceForTests();
130
126
  // The integrity state is module-cached across tests; token rows seeded here
131
- // are integrity evidence, so keep the reporter silenced and the cache cold.
127
+ // are integrity evidence, so keep the cache cold.
132
128
  bustGuardianIntegrityCache();
133
- resetGuardianIntegrityReporterForTesting();
134
- setGuardianIntegrityReporterOverridesForTesting({
135
- fetchImpl: async () => new Response("{}"),
136
- mintToken: () => "svc-token",
137
- baseUrl: "http://127.0.0.1:7821",
138
- log: { error: () => {}, warn: () => {} },
139
- });
140
129
  });
141
130
 
142
131
  afterEach(() => {
143
132
  resetGatewayDb();
144
- resetGuardianIntegrityReporterForTesting();
145
133
  bustGuardianIntegrityCache();
146
134
  if (savedSecurityDir === undefined) {
147
135
  delete process.env.GATEWAY_SECURITY_DIR;
@@ -158,28 +146,41 @@ afterEach(() => {
158
146
  describe("admitActorToken: revocation verdict", () => {
159
147
  test("rejects an actor token whose record is revoked", () => {
160
148
  insertTokenRecord("token-revoked", "revoked");
161
- expect(admitActorToken("token-revoked", actorClaims)).toBe(false);
149
+ expect(admitActorToken("token-revoked", actorClaims).admitted).toBe(false);
162
150
  });
163
151
 
164
152
  test("admits an actor token whose record is active", () => {
165
153
  insertTokenRecord("token-active", "active");
166
- expect(admitActorToken("token-active", actorClaims)).toBe(true);
154
+ expect(admitActorToken("token-active", actorClaims).admitted).toBe(true);
155
+ });
156
+
157
+ test("hands the loaded record back for route reuse", () => {
158
+ insertTokenRecord("token-recorded", "active");
159
+ const { record } = admitActorToken("token-recorded", actorClaims);
160
+ expect(record).toMatchObject({
161
+ status: "active",
162
+ guardianPrincipalId: "guardian-001",
163
+ hashedDeviceId: hashToken("device-A"),
164
+ platform: "web",
165
+ });
166
+ expect(record?.issuedAt).toBeNumber();
167
+ expect(record?.expiresAt).toBeNumber();
167
168
  });
168
169
 
169
170
  test("admits (fail-open) an actor token with no record", () => {
170
- expect(admitActorToken("token-unknown", actorClaims)).toBe(true);
171
+ expect(admitActorToken("token-unknown", actorClaims).admitted).toBe(true);
171
172
  });
172
173
 
173
174
  test("never checks non-actor tokens (svc)", () => {
174
175
  // Even if a row with this hash were revoked, a svc sub must be ignored.
175
176
  insertTokenRecord("svc-token", "revoked");
176
177
  const svcClaims = { sub: "svc:gateway:self" } as TokenClaims;
177
- expect(admitActorToken("svc-token", svcClaims)).toBe(true);
178
+ expect(admitActorToken("svc-token", svcClaims).admitted).toBe(true);
178
179
  });
179
180
 
180
181
  test("admits (fail-open) when the gateway DB is unavailable", () => {
181
182
  resetGatewayDb();
182
- expect(admitActorToken("token-anything", actorClaims)).toBe(true);
183
+ expect(admitActorToken("token-anything", actorClaims).admitted).toBe(true);
183
184
  });
184
185
 
185
186
  test("still detects revocation when the token has surrounding whitespace", () => {
@@ -187,8 +188,10 @@ describe("admitActorToken: revocation verdict", () => {
187
188
  // supplied with trailing whitespace (e.g. a `?token=<jwt>%20` WS param)
188
189
  // must still resolve to the revoked record.
189
190
  insertTokenRecord("token-revoked", "revoked");
190
- expect(admitActorToken("token-revoked ", actorClaims)).toBe(false);
191
- expect(admitActorToken(" token-revoked\n", actorClaims)).toBe(false);
191
+ expect(admitActorToken("token-revoked ", actorClaims).admitted).toBe(false);
192
+ expect(admitActorToken(" token-revoked\n", actorClaims).admitted).toBe(
193
+ false,
194
+ );
192
195
  });
193
196
 
194
197
  test("a revoked token is rejected without stamping any row", () => {
@@ -197,7 +200,7 @@ describe("admitActorToken: revocation verdict", () => {
197
200
  insertTokenRecord("token-stale", "revoked");
198
201
  insertTokenRecord("token-fresh", "active");
199
202
 
200
- expect(admitActorToken("token-stale", actorClaims)).toBe(false);
203
+ expect(admitActorToken("token-stale", actorClaims).admitted).toBe(false);
201
204
 
202
205
  expect(readRow("token-stale")?.lastUsedAt).toBeNull();
203
206
  expect(readRow("token-fresh")?.lastUsedAt).toBeNull();
@@ -228,19 +231,19 @@ describe("signature-encoding canonicalization (revocation bypass)", () => {
228
231
  insertTokenRecord(jwt, "revoked"); // stored under the canonical token hash
229
232
 
230
233
  // Baseline: the canonical token is detected as revoked.
231
- expect(admitActorToken(jwt, actorClaims)).toBe(false);
234
+ expect(admitActorToken(jwt, actorClaims).admitted).toBe(false);
232
235
 
233
236
  // Bypass attempt: same token, signature re-encoded (different string, same
234
237
  // bytes). Must still resolve to the revoked record.
235
238
  const padded = padSignature(jwt);
236
239
  expect(padded).not.toBe(jwt);
237
- expect(admitActorToken(padded, actorClaims)).toBe(false);
240
+ expect(admitActorToken(padded, actorClaims).admitted).toBe(false);
238
241
  });
239
242
 
240
243
  test("does not falsely revoke an active token re-encoded with padding", () => {
241
244
  const jwt = mintActorJwt();
242
245
  insertTokenRecord(jwt, "active");
243
- expect(admitActorToken(padSignature(jwt), actorClaims)).toBe(true);
246
+ expect(admitActorToken(padSignature(jwt), actorClaims).admitted).toBe(true);
244
247
  });
245
248
  });
246
249
 
@@ -365,12 +368,12 @@ describe("/auth/token revocation", () => {
365
368
  .get();
366
369
 
367
370
  expect(derivedRecord?.status).toBe("derived");
368
- expect(admitActorToken(derivedJwt, actorClaims)).toBe(true);
371
+ expect(admitActorToken(derivedJwt, actorClaims).admitted).toBe(true);
369
372
 
370
373
  revokeActorTokensByDevice("guardian-001", hashToken("device-A"));
371
374
 
372
- expect(admitActorToken(sourceJwt, actorClaims)).toBe(false);
373
- expect(admitActorToken(derivedJwt, actorClaims)).toBe(false);
375
+ expect(admitActorToken(sourceJwt, actorClaims).admitted).toBe(false);
376
+ expect(admitActorToken(derivedJwt, actorClaims).admitted).toBe(false);
374
377
  });
375
378
 
376
379
  test("fails closed with a repairable 401 when guardian rows are lost over evidence (no divergent mint)", async () => {
@@ -528,7 +531,7 @@ describe("admitActorToken: device last-used stamping", () => {
528
531
  const before = readRow("token-used");
529
532
  expect(before?.lastUsedAt).toBeNull();
530
533
 
531
- expect(admitActorToken("token-used", actorClaims)).toBe(true);
534
+ expect(admitActorToken("token-used", actorClaims).admitted).toBe(true);
532
535
 
533
536
  const after = readRow("token-used");
534
537
  expect(after?.lastUsedAt).toBeGreaterThan(0);
@@ -540,7 +543,7 @@ describe("admitActorToken: device last-used stamping", () => {
540
543
  insertTokenRecord("token-counted", "active");
541
544
 
542
545
  const sql = captureSql(() => {
543
- expect(admitActorToken("token-counted", actorClaims)).toBe(true);
546
+ expect(admitActorToken("token-counted", actorClaims).admitted).toBe(true);
544
547
  });
545
548
 
546
549
  // The verdict and the stamp share one SELECT; the stamp adds the UPDATE.
@@ -560,7 +563,9 @@ describe("admitActorToken: device last-used stamping", () => {
560
563
  .run();
561
564
 
562
565
  const sql = captureSql(() => {
563
- expect(admitActorToken("token-debounced", actorClaims)).toBe(true);
566
+ expect(admitActorToken("token-debounced", actorClaims).admitted).toBe(
567
+ true,
568
+ );
564
569
  });
565
570
 
566
571
  expect(readRow("token-debounced")?.lastUsedAt).toBe(sentinel);
@@ -593,7 +598,7 @@ describe("admitActorToken: device last-used stamping", () => {
593
598
  const svcClaims = { sub: "svc:gateway:self" } as TokenClaims;
594
599
 
595
600
  const sql = captureSql(() => {
596
- expect(admitActorToken("svc-token", svcClaims)).toBe(true);
601
+ expect(admitActorToken("svc-token", svcClaims).admitted).toBe(true);
597
602
  });
598
603
 
599
604
  expect(readRow("svc-token")?.lastUsedAt).toBeNull();
@@ -604,7 +609,9 @@ describe("admitActorToken: device last-used stamping", () => {
604
609
  test("is a no-op for an unrecorded token hash", () => {
605
610
  insertTokenRecord("token-recorded", "active");
606
611
 
607
- expect(admitActorToken("token-unrecorded", actorClaims)).toBe(true);
612
+ expect(admitActorToken("token-unrecorded", actorClaims).admitted).toBe(
613
+ true,
614
+ );
608
615
  expect(readRow("token-recorded")?.lastUsedAt).toBeNull();
609
616
  });
610
617
 
@@ -623,7 +630,7 @@ describe("admitActorToken: device last-used stamping", () => {
623
630
  "CREATE TRIGGER fail_last_used BEFORE UPDATE ON actor_token_records BEGIN SELECT RAISE(ABORT, 'stamp failed'); END",
624
631
  );
625
632
 
626
- expect(admitActorToken("token-retry", actorClaims)).toBe(true);
633
+ expect(admitActorToken("token-retry", actorClaims).admitted).toBe(true);
627
634
  expect(readRow("token-retry")?.lastUsedAt).toBeNull();
628
635
 
629
636
  rawDb.exec("DROP TRIGGER fail_last_used");
@@ -657,7 +664,7 @@ describe("admitActorToken: device last-used stamping", () => {
657
664
  ).$client;
658
665
  rawDb.exec("ALTER TABLE actor_token_records RENAME TO actor_token_stash");
659
666
 
660
- expect(admitActorToken("token-transient", actorClaims)).toBe(true);
667
+ expect(admitActorToken("token-transient", actorClaims).admitted).toBe(true);
661
668
 
662
669
  rawDb.exec("ALTER TABLE actor_token_stash RENAME TO actor_token_records");
663
670
  insertTokenRecord("token-transient", "active");
@@ -125,12 +125,10 @@ describe("GET /v1/channel-admission-policy", () => {
125
125
  expect(phone?.policy).toBe(ADMISSION_POLICY_DEFAULT);
126
126
  });
127
127
 
128
- test("omits exempt `a2a` and hidden `cadet`/`whatsapp` but includes enforced `phone`", async () => {
129
- // a2a stays exempt (no runtime admission model). cadet/whatsapp are still
130
- // enforced at runtime but hidden from the Channel Trust Floors UI, so they
131
- // must not surface even when a row is persisted. phone is now enforced and
132
- // visible, so it must surface.
133
- store.set("a2a", "guardian_only");
128
+ test("omits hidden `cadet`/`whatsapp` but includes enforced `phone`", async () => {
129
+ // cadet/whatsapp are still enforced at runtime but hidden from the
130
+ // Channel Trust Floors UI, so they must not surface even when a row is
131
+ // persisted. phone is now enforced and visible, so it must surface.
134
132
  store.set("cadet", "guardian_only");
135
133
  store.set("whatsapp", "guardian_only");
136
134
  store.set("cadet-shared", "guardian_only");
@@ -263,17 +261,6 @@ describe("PUT /v1/channel-admission-policy/:channelType", () => {
263
261
  }
264
262
  });
265
263
 
266
- test("§8.1: rejects PUT for exempt channel `a2a` with 403", async () => {
267
- const handler = createChannelAdmissionPolicySetHandler();
268
- const res = await handler(
269
- jsonRequest("http://localhost/v1/channel-admission-policy/a2a", "PUT", {
270
- policy: "guardian_only",
271
- }),
272
- "a2a",
273
- );
274
- expect(res.status).toBe(403);
275
- expect(store.get("a2a")).toBe(ADMISSION_POLICY_DEFAULT);
276
- });
277
264
 
278
265
  test("upserts `phone` like any enforced channel (voice ingress wired)", async () => {
279
266
  const handler = createChannelAdmissionPolicySetHandler();
@@ -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
  });
@@ -2507,24 +2507,6 @@ describe("handleDeleteContact (gateway-native)", () => {
2507
2507
  });
2508
2508
 
2509
2509
  describe("handleCreateInvite (gateway-native mint)", () => {
2510
- test("rejects sourceChannel 'a2a' (A2A invites are daemon-managed)", async () => {
2511
- contactStoreCreateInviteMock = makeEchoCreateInviteMock();
2512
-
2513
- const handler = createContactsControlPlaneProxyHandler(makeConfig());
2514
- const res = await handler.handleCreateInvite(
2515
- new Request("http://localhost:7830/v1/contacts/invites", {
2516
- method: "POST",
2517
- headers: { "content-type": "application/json" },
2518
- body: JSON.stringify({ contactId: "ct_1", sourceChannel: "a2a" }),
2519
- }),
2520
- );
2521
-
2522
- expect(res.status).toBe(400);
2523
- const body = await res.json();
2524
- expect(body.error.code).toBe("BAD_REQUEST");
2525
- expect(contactStoreCreateInviteMock).toHaveBeenCalledTimes(0);
2526
- });
2527
-
2528
2510
  test("mints token + 6-digit code natively for non-voice invites; only hashes reach the store", async () => {
2529
2511
  contactStoreCreateInviteMock = makeEchoCreateInviteMock();
2530
2512