@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.
- package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
- package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
- package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
- package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
- package/node_modules/@mycadet/gateway-client/src/gateway-ipc-contracts.ts +0 -20
- package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
- package/openapi.json +1 -1
- package/package.json +1 -1
- package/src/__tests__/actor-token-revocation.test.ts +41 -34
- package/src/__tests__/contact-store-mirror-op-missing.test.ts +33 -70
- package/src/__tests__/contacts-mirror-op-reporter.test.ts +33 -89
- package/src/__tests__/credential-requests.test.ts +4 -4
- package/src/__tests__/devices-mine.test.ts +187 -0
- package/src/__tests__/edge-auth.test.ts +1 -20
- package/src/__tests__/edge-guardian-auth.test.ts +1 -17
- package/src/__tests__/feature-flag-resolver.test.ts +2 -30
- package/src/__tests__/feature-flag-watcher-callback.test.ts +0 -22
- package/src/__tests__/feature-flags-route.test.ts +2 -191
- package/src/__tests__/guardian-bootstrap-lookups.test.ts +18 -25
- package/src/__tests__/guardian-integrity-reporter.test.ts +51 -82
- package/src/__tests__/guardian-integrity.test.ts +16 -20
- package/src/__tests__/handle-inbound-trust-verdict.test.ts +1 -14
- package/src/__tests__/ipc-feature-flag-routes.test.ts +0 -56
- package/src/__tests__/lum-2783-recovery-e2e.test.ts +0 -12
- package/src/__tests__/post-assistant-ready-deferred.test.ts +0 -18
- package/src/__tests__/refresh-device-binding.test.ts +0 -12
- package/src/__tests__/remote-web-pairing-token.test.ts +4 -14
- package/src/__tests__/runtime-client.test.ts +7 -7
- package/src/__tests__/speech-relay-websocket.test.ts +4 -4
- package/src/__tests__/trust-verdict-resolver-guardian-integrity.test.ts +30 -36
- package/src/__tests__/twilio-webhooks.test.ts +2 -2
- package/src/auth/actor-token-revocation.ts +34 -9
- package/src/cli/__tests__/pairing.test.ts +95 -0
- package/src/cli/index.ts +1 -1
- package/src/cli/pairing.ts +81 -6
- package/src/contacts-mirror-op-reporter.ts +19 -36
- package/src/db/connection.ts +0 -10
- package/src/feature-flag-resolver.ts +2 -7
- package/src/feature-flag-watcher.ts +1 -14
- package/src/guardian-integrity-reporter.ts +19 -47
- package/src/http/middleware/auth.ts +33 -27
- package/src/http/routes/auth-token.ts +1 -1
- package/src/http/routes/devices-mine.ts +103 -0
- package/src/http/routes/feature-flags.ts +1 -8
- package/src/http/routes/guardian-pin.ts +2 -11
- package/src/http/routes/ipc-runtime-proxy.ts +1 -1
- package/src/http/routes/live-voice-websocket.ts +1 -1
- package/src/http/routes/runtime-audio-stream.ts +1 -1
- package/src/http/routes/runtime-proxy.ts +2 -2
- package/src/http/routes/twilio-voice-webhook.test.ts +13 -13
- package/src/index.ts +11 -198
- package/src/ipc/feature-flag-handlers.ts +2 -9
- package/src/remote-web/pairing-challenge-store.ts +15 -0
- package/src/risk/command-registry/commands/assistant.ts +0 -24
- package/src/risk/command-registry.test.ts +2 -4
- package/src/schema.ts +44 -372
- package/src/__tests__/auth-fallback-count-tracker.test.ts +0 -92
- package/src/__tests__/auth-fallback-reporter.test.ts +0 -101
- package/src/__tests__/backup-worker-cloud-mode.test.ts +0 -44
- package/src/__tests__/backup-worker-export-timeout.test.ts +0 -164
- package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -216
- package/src/__tests__/remote-feature-flag-sync.test.ts +0 -961
- package/src/auth-fallback-count-tracker.ts +0 -113
- package/src/auth-fallback-reporter.ts +0 -127
- package/src/backup/backup-key.ts +0 -138
- package/src/backup/backup-routes.ts +0 -161
- package/src/backup/backup-worker.ts +0 -425
- package/src/backup/list-snapshots.ts +0 -97
- package/src/backup/local-writer.ts +0 -87
- package/src/backup/offsite-writer.ts +0 -182
- package/src/backup/paths.ts +0 -113
- package/src/backup/stream-crypt.ts +0 -258
- package/src/device-id.ts +0 -18
- package/src/feature-flag-remote-store.ts +0 -166
- package/src/http/routes/migration-proxy.ts +0 -636
- package/src/internal-telemetry-client.ts +0 -39
- package/src/ipc/debug-export-handlers.test.ts +0 -102
- package/src/ipc/debug-export-handlers.ts +0 -81
- package/src/remote-feature-flag-sync.ts +0 -440
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
94
|
+
"https://staging-tunnel.mycadet.ai",
|
|
95
95
|
);
|
|
96
96
|
expect(tunnelOriginForPlatformHost("dev-cloud.mycadet.ai")).toBe(
|
|
97
|
-
"https://tunnel
|
|
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
|
|
127
|
+
"wss://staging-tunnel.mycadet.ai",
|
|
128
128
|
);
|
|
129
129
|
expect(new URL(data.upstreamHttpUrl).origin).toBe(
|
|
130
|
-
"https://tunnel
|
|
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
|
|
15
|
-
* so no
|
|
16
|
-
*
|
|
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 } =
|
|
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 } =
|
|
32
|
-
"./helpers/contact-fixtures.js"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
);
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
23
|
+
" pairing Remote-web pairing challenges (mint <url> | list [--json])",
|
|
24
24
|
);
|
|
25
25
|
}
|
|
26
26
|
|