@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
|
@@ -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
|
|
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
|
-
*
|
|
13
|
-
*
|
|
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,
|
|
14
|
+
import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
|
|
16
15
|
|
|
17
|
-
const errorLogs: unknown[] = [];
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
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",
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
@@ -50,7 +50,6 @@ beforeEach(() => {
|
|
|
50
50
|
_setRegistryCandidateOverrides([defaultsPath]);
|
|
51
51
|
resetFeatureFlagDefaultsCache();
|
|
52
52
|
clearFeatureFlagStoreCache();
|
|
53
|
-
clearRemoteFeatureFlagStoreCache();
|
|
54
53
|
});
|
|
55
54
|
|
|
56
55
|
afterEach(() => {
|
|
@@ -63,7 +62,6 @@ afterEach(() => {
|
|
|
63
62
|
_setRegistryCandidateOverrides(null);
|
|
64
63
|
resetFeatureFlagDefaultsCache();
|
|
65
64
|
clearFeatureFlagStoreCache();
|
|
66
|
-
clearRemoteFeatureFlagStoreCache();
|
|
67
65
|
resetEnvOverridesCache();
|
|
68
66
|
delete process.env.CADET_FLAG_BROWSER;
|
|
69
67
|
delete process.env.CADET_FLAG_A2A_CHANNEL;
|
|
@@ -76,8 +74,6 @@ const { resetFeatureFlagDefaultsCache, _setRegistryCandidateOverrides } =
|
|
|
76
74
|
await import("../feature-flag-defaults.js");
|
|
77
75
|
const { clearFeatureFlagStoreCache, writeFeatureFlag } =
|
|
78
76
|
await import("../feature-flag-store.js");
|
|
79
|
-
const { clearRemoteFeatureFlagStoreCache, writeRemoteFeatureFlags } =
|
|
80
|
-
await import("../feature-flag-remote-store.js");
|
|
81
77
|
const { resetEnvOverridesCache } =
|
|
82
78
|
await import("../feature-flag-env-overrides.js");
|
|
83
79
|
|
|
@@ -95,19 +91,8 @@ describe("isFeatureFlagEnabled", () => {
|
|
|
95
91
|
expect(isFeatureFlagEnabled("a2a-channel")).toBe(true);
|
|
96
92
|
});
|
|
97
93
|
|
|
98
|
-
test("
|
|
99
|
-
writeRemoteFeatureFlags({
|
|
100
|
-
browser: false,
|
|
101
|
-
"a2a-channel": true,
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
expect(isFeatureFlagEnabled("browser")).toBe(false);
|
|
105
|
-
expect(isFeatureFlagEnabled("a2a-channel")).toBe(true);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
test("ignores persisted and remote values for undeclared flags", () => {
|
|
94
|
+
test("ignores persisted values for undeclared flags", () => {
|
|
109
95
|
writeFeatureFlag("unknown", true);
|
|
110
|
-
writeRemoteFeatureFlags({ unknown: true });
|
|
111
96
|
|
|
112
97
|
expect(isFeatureFlagEnabled("unknown")).toBe(false);
|
|
113
98
|
});
|
|
@@ -146,19 +131,7 @@ describe("getFeatureFlagValue", () => {
|
|
|
146
131
|
expect(getFeatureFlagValue("default-model")).toBe("gpt-4");
|
|
147
132
|
});
|
|
148
133
|
|
|
149
|
-
test("
|
|
150
|
-
writeRemoteFeatureFlags({ "default-model": "gpt-4" });
|
|
151
|
-
expect(getFeatureFlagValue("default-model")).toBe("gpt-4");
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
test("persisted takes precedence over remote for string flags", () => {
|
|
155
|
-
writeRemoteFeatureFlags({ "default-model": "remote-model" });
|
|
156
|
-
writeFeatureFlag("default-model", "persisted-model");
|
|
157
|
-
expect(getFeatureFlagValue("default-model")).toBe("persisted-model");
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
test("env override wins over persisted, remote, and default values", () => {
|
|
161
|
-
writeRemoteFeatureFlags({ browser: false });
|
|
134
|
+
test("env override wins over persisted and default values", () => {
|
|
162
135
|
writeFeatureFlag("browser", false);
|
|
163
136
|
|
|
164
137
|
process.env.CADET_FLAG_BROWSER = "true";
|
|
@@ -168,7 +141,6 @@ describe("getFeatureFlagValue", () => {
|
|
|
168
141
|
});
|
|
169
142
|
|
|
170
143
|
test("env override wins for string flags", () => {
|
|
171
|
-
writeRemoteFeatureFlags({ "default-model": "remote-model" });
|
|
172
144
|
writeFeatureFlag("default-model", "persisted-model");
|
|
173
145
|
|
|
174
146
|
process.env.CADET_FLAG_DEFAULT_MODEL = "env-model";
|
|
@@ -13,12 +13,6 @@ mock.module("../feature-flag-store.js", () => ({
|
|
|
13
13
|
getFeatureFlagStorePath: () => join(flagDir, "feature-flags.json"),
|
|
14
14
|
}));
|
|
15
15
|
|
|
16
|
-
mock.module("../feature-flag-remote-store.js", () => ({
|
|
17
|
-
refreshRemoteFeatureFlagStoreCache: mock(() => {}),
|
|
18
|
-
getRemoteFeatureFlagStorePath: () =>
|
|
19
|
-
join(flagDir, "remote-feature-flags.json"),
|
|
20
|
-
}));
|
|
21
|
-
|
|
22
16
|
const { FeatureFlagWatcher } = await import("../feature-flag-watcher.js");
|
|
23
17
|
|
|
24
18
|
describe("FeatureFlagWatcher onChanged callback", () => {
|
|
@@ -52,22 +46,6 @@ describe("FeatureFlagWatcher onChanged callback", () => {
|
|
|
52
46
|
watcher.stop();
|
|
53
47
|
});
|
|
54
48
|
|
|
55
|
-
test("calls onChanged after debounce fires on remote flag file change", async () => {
|
|
56
|
-
const onChanged = mock(() => {});
|
|
57
|
-
const watcher = new FeatureFlagWatcher({ onChanged });
|
|
58
|
-
watcher.start();
|
|
59
|
-
|
|
60
|
-
writeFileSync(
|
|
61
|
-
join(flagDir, "remote-feature-flags.json"),
|
|
62
|
-
JSON.stringify({ remote: true }),
|
|
63
|
-
);
|
|
64
|
-
|
|
65
|
-
await new Promise((r) => setTimeout(r, 700));
|
|
66
|
-
|
|
67
|
-
expect(onChanged).toHaveBeenCalledTimes(1);
|
|
68
|
-
watcher.stop();
|
|
69
|
-
});
|
|
70
|
-
|
|
71
49
|
test("does not call onChanged when no callback is provided", async () => {
|
|
72
50
|
const watcher = new FeatureFlagWatcher();
|
|
73
51
|
watcher.start();
|