@mycadet/gateway 0.12.11 → 0.13.0-dev.202610171626.d97b8b8
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/channels.ts +0 -1
- 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/channels.ts +0 -1
- package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
- package/node_modules/@mycadet/gateway-client/src/admission-policy-contract.ts +1 -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/channels.ts +0 -1
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
- package/openapi.json +1 -1
- package/package.json +5 -1
- package/src/__tests__/actor-token-revocation.test.ts +41 -34
- package/src/__tests__/channel-admission-policy-routes.test.ts +4 -17
- package/src/__tests__/contact-store-mirror-op-missing.test.ts +33 -70
- package/src/__tests__/contacts-control-plane-proxy.test.ts +0 -18
- 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 +10 -193
- 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-ingress-denylist.test.ts +22 -13
- package/src/__tests__/remote-web-pairing-token.test.ts +4 -14
- package/src/__tests__/route-schema-guard.test.ts +0 -19
- package/src/__tests__/runtime-client.test.ts +7 -7
- package/src/__tests__/seed-admission-policy.test.ts +0 -6
- 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/channels/inbound-event.ts +1 -3
- package/src/channels/types.ts +0 -2
- 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/admission-policy-store.ts +1 -1
- package/src/db/connection.ts +0 -10
- package/src/db/seed-admission-policy.ts +1 -1
- package/src/feature-flag-registry.json +0 -130
- 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/handlers/handle-inbound.ts +1 -1
- package/src/http/middleware/auth.ts +33 -27
- package/src/http/routes/auth-token.ts +1 -1
- package/src/http/routes/channel-admission-policy.ts +2 -2
- package/src/http/routes/contacts-control-plane-proxy.ts +0 -9
- 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 -210
- 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 +45 -373
- package/src/types.ts +0 -1
- 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/a2a-routes.test.ts +0 -146
- package/src/http/routes/a2a-routes.ts +0 -126
- 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
|
@@ -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
|
|
31
|
+
"staging-tunnel.mycadet.ai",
|
|
32
32
|
);
|
|
33
33
|
expect(tunnelHostForCloudHost("dev-cloud.mycadet.ai")).toBe(
|
|
34
|
-
"tunnel
|
|
34
|
+
"dev-tunnel.mycadet.ai",
|
|
35
35
|
);
|
|
36
36
|
});
|
|
37
37
|
|
package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts
CHANGED
|
@@ -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` → `
|
|
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 ?
|
|
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
|
|
31
|
+
"staging-tunnel.mycadet.ai",
|
|
32
32
|
);
|
|
33
33
|
expect(tunnelHostForCloudHost("dev-cloud.mycadet.ai")).toBe(
|
|
34
|
-
"tunnel
|
|
34
|
+
"dev-tunnel.mycadet.ai",
|
|
35
35
|
);
|
|
36
36
|
});
|
|
37
37
|
|
package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts
CHANGED
|
@@ -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` → `
|
|
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 ?
|
|
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`
|
|
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
|
|
31
|
+
"staging-tunnel.mycadet.ai",
|
|
32
32
|
);
|
|
33
33
|
expect(tunnelHostForCloudHost("dev-cloud.mycadet.ai")).toBe(
|
|
34
|
-
"tunnel
|
|
34
|
+
"dev-tunnel.mycadet.ai",
|
|
35
35
|
);
|
|
36
36
|
});
|
|
37
37
|
|
|
@@ -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` → `
|
|
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 ?
|
|
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.
|
|
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.
|
|
3
|
+
"version": "0.13.0-dev.202610171626.d97b8b8",
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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 →
|
|
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
|
|
200
|
-
expect(
|
|
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
|
-
|
|
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 →
|
|
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:
|
|
245
|
-
expect(
|
|
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 →
|
|
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(
|
|
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
|
-
|
|
283
|
-
detail: {
|
|
247
|
+
op: "contacts_mirror_upsert_full",
|
|
248
|
+
detail: { contactId: contact.id },
|
|
284
249
|
},
|
|
285
250
|
]);
|
|
286
251
|
});
|
|
287
252
|
|
|
288
|
-
test("other IPC failure →
|
|
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(
|
|
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
|
|