@mycadet/gateway 0.12.10 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/node_modules/@mycadet/ces-client/src/http-credentials.ts +18 -1
- package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/node_modules/@mycadet/gateway-client/src/gateway-ipc-contracts.ts +0 -20
- package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- 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 +22 -29
- package/src/__tests__/guardian-init-lockfile.test.ts +2 -2
- 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 +5 -15
- 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/auth/guardian-bootstrap.ts +1 -1
- 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/speech-relay-websocket.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
|
@@ -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
|
|
package/src/cli/pairing.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* `gateway pairing` — operator subcommands for the remote-web pairing flow.
|
|
3
3
|
*
|
|
4
4
|
* gateway pairing mint <url>
|
|
5
|
+
* gateway pairing list [--json]
|
|
5
6
|
*
|
|
6
7
|
* Mints (and approves) a remote-web pairing challenge by executing the
|
|
7
8
|
* pairing mint logic DIRECTLY in this process — deliberately NOT over the
|
|
@@ -15,21 +16,46 @@
|
|
|
15
16
|
* is needed and the topology (native or containerized) stops mattering.
|
|
16
17
|
* Prints the challenge response as single-line JSON on stdout; the caller
|
|
17
18
|
* parses it and renders the pairing link/QR.
|
|
19
|
+
*
|
|
20
|
+
* `list` reads the same file-backed store and reports EVERY persisted
|
|
21
|
+
* challenge — active and expired — with each one's mint and expiration
|
|
22
|
+
* timestamp, so the host can see in-flight and stale pairing sessions.
|
|
18
23
|
*/
|
|
19
24
|
|
|
25
|
+
import {
|
|
26
|
+
listPersistedRemoteWebPairingChallenges,
|
|
27
|
+
type PendingRemoteWebPairingChallenge,
|
|
28
|
+
} from "../remote-web/pairing-challenge-store.js";
|
|
20
29
|
import { mintAndApproveRemoteWebPairingChallenge } from "../remote-web/pairing-mint.js";
|
|
21
30
|
|
|
22
31
|
function printUsage(): void {
|
|
23
|
-
console.log("Usage: gateway pairing
|
|
32
|
+
console.log("Usage: gateway pairing <subcommand>");
|
|
33
|
+
console.log("");
|
|
34
|
+
console.log("Operator commands for the remote-web pairing flow.");
|
|
24
35
|
console.log("");
|
|
36
|
+
console.log("Subcommands:");
|
|
37
|
+
console.log(
|
|
38
|
+
" mint <url> Mint and approve a pairing challenge; prints the",
|
|
39
|
+
);
|
|
40
|
+
console.log(
|
|
41
|
+
" challenge (device code, pairing link, expiry) as JSON.",
|
|
42
|
+
);
|
|
25
43
|
console.log(
|
|
26
|
-
"
|
|
44
|
+
" list List every persisted pairing challenge — active and",
|
|
27
45
|
);
|
|
28
|
-
console.log(
|
|
46
|
+
console.log(
|
|
47
|
+
" expired — with its mint and expiration timestamp.",
|
|
48
|
+
);
|
|
49
|
+
console.log("");
|
|
50
|
+
console.log("Options:");
|
|
51
|
+
console.log(" --json Machine-readable JSON (list)");
|
|
29
52
|
console.log("");
|
|
30
|
-
console.log("
|
|
31
|
-
console.log("
|
|
32
|
-
console.log("
|
|
53
|
+
console.log("Examples:");
|
|
54
|
+
console.log(" $ gateway pairing mint https://your-assistant.ts.net");
|
|
55
|
+
console.log(" $ gateway pairing list");
|
|
56
|
+
console.log(" $ gateway pairing list --json");
|
|
57
|
+
console.log("");
|
|
58
|
+
console.log("From the host: cadet exec --service gateway -- gateway pairing list");
|
|
33
59
|
}
|
|
34
60
|
|
|
35
61
|
/** Parse `pairing` argv (after the command word) into a subcommand + arg. */
|
|
@@ -41,6 +67,52 @@ export function parsePairingArgs(args: string[]): {
|
|
|
41
67
|
return { subcommand, positional };
|
|
42
68
|
}
|
|
43
69
|
|
|
70
|
+
function listLiveness(
|
|
71
|
+
challenge: PendingRemoteWebPairingChallenge,
|
|
72
|
+
now: number,
|
|
73
|
+
): "active" | "expired" {
|
|
74
|
+
return challenge.expiresAtMs <= now ? "expired" : "active";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function runPairingList(json: boolean): number {
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
const challenges = listPersistedRemoteWebPairingChallenges();
|
|
80
|
+
|
|
81
|
+
if (json) {
|
|
82
|
+
console.log(
|
|
83
|
+
JSON.stringify({
|
|
84
|
+
ok: true,
|
|
85
|
+
challenges: challenges.map((challenge) => ({
|
|
86
|
+
...challenge,
|
|
87
|
+
expired: challenge.expiresAtMs <= now,
|
|
88
|
+
})),
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (challenges.length === 0) {
|
|
95
|
+
console.log("No pairing challenges found.");
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for (const challenge of challenges) {
|
|
100
|
+
const liveness = listLiveness(challenge, now);
|
|
101
|
+
console.log(
|
|
102
|
+
`${challenge.userCode} ${challenge.status} ${liveness} ` +
|
|
103
|
+
`minted ${new Date(challenge.createdAtMs).toISOString()} ` +
|
|
104
|
+
`expires ${new Date(challenge.expiresAtMs).toISOString()}`,
|
|
105
|
+
);
|
|
106
|
+
const userAgent = challenge.requesterUserAgent
|
|
107
|
+
? ` ${challenge.requesterUserAgent}`
|
|
108
|
+
: "";
|
|
109
|
+
console.log(
|
|
110
|
+
` ${challenge.verificationUri} requester ${challenge.requesterIp}${userAgent}`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
44
116
|
export async function runPairingCommand(args: string[]): Promise<number> {
|
|
45
117
|
const { subcommand, positional } = parsePairingArgs(args);
|
|
46
118
|
|
|
@@ -48,6 +120,9 @@ export async function runPairingCommand(args: string[]): Promise<number> {
|
|
|
48
120
|
printUsage();
|
|
49
121
|
return subcommand ? 0 : 1;
|
|
50
122
|
}
|
|
123
|
+
if (subcommand === "list") {
|
|
124
|
+
return runPairingList(args.includes("--json"));
|
|
125
|
+
}
|
|
51
126
|
if (subcommand !== "mint") {
|
|
52
127
|
console.error(`Error: unknown pairing subcommand '${subcommand}'.`);
|
|
53
128
|
printUsage();
|
|
@@ -2,21 +2,18 @@
|
|
|
2
2
|
// with "Unknown method" (old daemon behind a newer gateway). The mirror ops
|
|
3
3
|
// deliberately have no raw-SQL fallback, so the gateway write has already
|
|
4
4
|
// applied while the assistant mirror silently missed it — divergence persists
|
|
5
|
-
// until reconciliation. Emits an error-level log
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// daemon still accepts it). Rate-limited per op per process; a lost event is
|
|
9
|
-
// acceptable for a rare, high-signal check (no retry). Scaffolding:
|
|
10
|
-
// watchdog-reporter.ts.
|
|
5
|
+
// until reconciliation. Emits an error-level log, rate-limited per op per
|
|
6
|
+
// process (the state is sticky until the daemon updates, so every op would
|
|
7
|
+
// otherwise re-fire it).
|
|
11
8
|
|
|
12
9
|
import { getLogger } from "./logger.js";
|
|
13
|
-
import { createWatchdogReporter } from "./watchdog-reporter.js";
|
|
14
10
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
11
|
+
const log = getLogger("contacts-mirror-op-reporter");
|
|
12
|
+
|
|
13
|
+
// At most one report per op per hour per process.
|
|
14
|
+
const REPORT_INTERVAL_MS = 60 * 60 * 1000;
|
|
15
|
+
|
|
16
|
+
const lastReportAtByKey = new Map<string, number>();
|
|
20
17
|
|
|
21
18
|
/**
|
|
22
19
|
* True when a daemon IPC failure is the IPC server's unknown-method rejection
|
|
@@ -27,35 +24,21 @@ export function isUnknownIpcMethodError(err: unknown): boolean {
|
|
|
27
24
|
return err instanceof Error && err.message.startsWith("Unknown method:");
|
|
28
25
|
}
|
|
29
26
|
|
|
30
|
-
const reporter = createWatchdogReporter({
|
|
31
|
-
log: getLogger("contacts-mirror-op-reporter"),
|
|
32
|
-
relayFailedMessage: "mirror-op-missing telemetry relay failed (non-fatal)",
|
|
33
|
-
relayRejectedMessage:
|
|
34
|
-
"mirror-op-missing telemetry relay rejected (non-fatal)",
|
|
35
|
-
});
|
|
36
|
-
|
|
37
27
|
/**
|
|
38
|
-
* Report a mirror op the daemon doesn't implement: error log
|
|
39
|
-
*
|
|
40
|
-
* never throws, never blocks the caller.
|
|
28
|
+
* Report a mirror op the daemon doesn't implement: a rate-limited error log.
|
|
29
|
+
* Fire-and-forget — never throws, never blocks the caller.
|
|
41
30
|
*/
|
|
42
31
|
export function reportMirrorOpMissing(
|
|
43
32
|
op: string,
|
|
44
33
|
detail: Record<string, unknown>,
|
|
45
34
|
): void {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
const last = lastReportAtByKey.get(op) ?? Number.NEGATIVE_INFINITY;
|
|
37
|
+
if (now - last < REPORT_INTERVAL_MS) return;
|
|
38
|
+
lastReportAtByKey.set(op, now);
|
|
39
|
+
log.error(
|
|
40
|
+
{ op, ...detail },
|
|
41
|
+
"daemon does not implement this contacts mirror op — gateway write applied " +
|
|
51
42
|
"without the assistant mirror (divergence until the daemon updates)",
|
|
52
|
-
|
|
53
|
-
warnContext: { op },
|
|
54
|
-
});
|
|
43
|
+
);
|
|
55
44
|
}
|
|
56
|
-
|
|
57
|
-
/** Test-only seams; see {@link createWatchdogReporter}. */
|
|
58
|
-
export const setMirrorOpReporterOverridesForTesting =
|
|
59
|
-
reporter.setOverridesForTesting;
|
|
60
|
-
export const resetMirrorOpReporterForTesting = reporter.resetForTesting;
|
|
61
|
-
export const flushMirrorOpReporterForTesting = reporter.flushForTesting;
|
package/src/db/connection.ts
CHANGED
|
@@ -208,16 +208,6 @@ export function getGatewayDb(): GatewayDb {
|
|
|
208
208
|
return db;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
/**
|
|
212
|
-
* Write a consistent copy of the gateway database to `destPath`.
|
|
213
|
-
*
|
|
214
|
-
* `VACUUM INTO` reads through the live connection, so committed rows still
|
|
215
|
-
* sitting in the WAL are included and the copy needs no -wal/-shm files.
|
|
216
|
-
*/
|
|
217
|
-
export function snapshotGatewayDb(destPath: string): void {
|
|
218
|
-
getRawDb(getGatewayDb()).run("VACUUM INTO ?", [destPath]);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
211
|
/**
|
|
222
212
|
* Extract the underlying bun:sqlite Database from a Drizzle instance.
|
|
223
213
|
* Internal helper — not exported. Production code should use getGatewayDb()
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { loadFeatureFlagDefaults } from "./feature-flag-defaults.js";
|
|
2
2
|
import { readEnvFeatureFlagOverrides } from "./feature-flag-env-overrides.js";
|
|
3
|
-
import { readRemoteFeatureFlags } from "./feature-flag-remote-store.js";
|
|
4
3
|
import { readPersistedFeatureFlags } from "./feature-flag-store.js";
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
6
|
* Resolve the raw value for a feature flag.
|
|
8
7
|
*
|
|
9
|
-
* Priority: env override > persisted (user-toggled) >
|
|
10
|
-
* Undeclared keys return `false` (fail closed), even if stale local
|
|
8
|
+
* Priority: env override > persisted (user-toggled) > registry default.
|
|
9
|
+
* Undeclared keys return `false` (fail closed), even if stale local
|
|
11
10
|
* state contains a value for them.
|
|
12
11
|
*/
|
|
13
12
|
export function getFeatureFlagValue(key: string): boolean | string {
|
|
@@ -22,10 +21,6 @@ export function getFeatureFlagValue(key: string): boolean | string {
|
|
|
22
21
|
const persistedValue = persisted[key];
|
|
23
22
|
if (persistedValue !== undefined) return persistedValue;
|
|
24
23
|
|
|
25
|
-
const remote = readRemoteFeatureFlags();
|
|
26
|
-
const remoteValue = remote[key];
|
|
27
|
-
if (remoteValue !== undefined) return remoteValue;
|
|
28
|
-
|
|
29
24
|
return defaultDef.defaultEnabled;
|
|
30
25
|
}
|
|
31
26
|
|
|
@@ -15,10 +15,6 @@ import {
|
|
|
15
15
|
clearFeatureFlagStoreCache,
|
|
16
16
|
getFeatureFlagStorePath,
|
|
17
17
|
} from "./feature-flag-store.js";
|
|
18
|
-
import {
|
|
19
|
-
refreshRemoteFeatureFlagStoreCache,
|
|
20
|
-
getRemoteFeatureFlagStorePath,
|
|
21
|
-
} from "./feature-flag-remote-store.js";
|
|
22
18
|
import { getLogger } from "./logger.js";
|
|
23
19
|
|
|
24
20
|
const log = getLogger("feature-flag-watcher");
|
|
@@ -33,14 +29,12 @@ export class FeatureFlagWatcher {
|
|
|
33
29
|
private watcher: FSWatcher | null = null;
|
|
34
30
|
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
35
31
|
private localFlagFilename: string;
|
|
36
|
-
private remoteFlagFilename: string;
|
|
37
32
|
/** Accumulates which files changed during the debounce window. */
|
|
38
33
|
private pendingFilenames = new Set<string>();
|
|
39
34
|
private onChanged?: () => void;
|
|
40
35
|
|
|
41
36
|
constructor(options?: FeatureFlagWatcherOptions) {
|
|
42
37
|
this.localFlagFilename = basename(getFeatureFlagStorePath());
|
|
43
|
-
this.remoteFlagFilename = basename(getRemoteFeatureFlagStorePath());
|
|
44
38
|
this.onChanged = options?.onChanged;
|
|
45
39
|
}
|
|
46
40
|
|
|
@@ -55,11 +49,7 @@ export class FeatureFlagWatcher {
|
|
|
55
49
|
|
|
56
50
|
try {
|
|
57
51
|
this.watcher = watch(dir, { persistent: false }, (_event, filename) => {
|
|
58
|
-
if (
|
|
59
|
-
filename &&
|
|
60
|
-
filename !== this.localFlagFilename &&
|
|
61
|
-
filename !== this.remoteFlagFilename
|
|
62
|
-
) {
|
|
52
|
+
if (filename && filename !== this.localFlagFilename) {
|
|
63
53
|
return;
|
|
64
54
|
}
|
|
65
55
|
this.scheduleInvalidation(filename ?? undefined);
|
|
@@ -105,9 +95,6 @@ export class FeatureFlagWatcher {
|
|
|
105
95
|
if (filenames.has(this.localFlagFilename)) {
|
|
106
96
|
clearFeatureFlagStoreCache();
|
|
107
97
|
}
|
|
108
|
-
if (filenames.has(this.remoteFlagFilename)) {
|
|
109
|
-
refreshRemoteFeatureFlagStoreCache();
|
|
110
|
-
}
|
|
111
98
|
log.info(
|
|
112
99
|
{ filenames: [...filenames] },
|
|
113
100
|
"Feature flag cache invalidated due to file change",
|
|
@@ -2,46 +2,28 @@
|
|
|
2
2
|
// lost its guardian rows while carrying evidence of prior onboarding
|
|
3
3
|
// (`gateway_guardian_missing`, see auth/guardian-integrity.ts) and a refused
|
|
4
4
|
// cadet-guardian mint (`gateway_guardian_mint_refused`, see
|
|
5
|
-
// auth/guardian-bootstrap.ts). Emits an error-level log
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// path so the alarm cannot suppress itself. Rate-limited per check_name per
|
|
9
|
-
// process; a lost event is acceptable for a rare, high-signal check (no
|
|
10
|
-
// retry). Scaffolding: watchdog-reporter.ts.
|
|
5
|
+
// auth/guardian-bootstrap.ts). Emits an error-level log, rate-limited per
|
|
6
|
+
// check per process (both states are sticky until repair, so every verdict
|
|
7
|
+
// or mint attempt would otherwise re-fire them).
|
|
11
8
|
|
|
12
9
|
import { getLogger } from "./logger.js";
|
|
13
|
-
import { createWatchdogReporter } from "./watchdog-reporter.js";
|
|
14
10
|
|
|
15
|
-
|
|
16
|
-
* Watchdog `check_name` for the missing-guardian integrity failure. Platform
|
|
17
|
-
* dashboards filter on this exact string — it is the cross-repo contract.
|
|
18
|
-
*/
|
|
19
|
-
export const GUARDIAN_MISSING_CHECK_NAME = "gateway_guardian_missing";
|
|
11
|
+
const log = getLogger("guardian-integrity-reporter");
|
|
20
12
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
* prior guardian, no active cadet binding). Fires even when the integrity
|
|
24
|
-
* state is `ok` — a guardian contact row can be intact while the cadet
|
|
25
|
-
* binding is lost/inactive, and clients see `guardian_repair_required` 401s.
|
|
26
|
-
*/
|
|
27
|
-
export const GUARDIAN_MINT_REFUSED_CHECK_NAME = "gateway_guardian_mint_refused";
|
|
13
|
+
// At most one report per check per hour per process.
|
|
14
|
+
const REPORT_INTERVAL_MS = 60 * 60 * 1000;
|
|
28
15
|
|
|
29
|
-
const
|
|
30
|
-
log: getLogger("guardian-integrity-reporter"),
|
|
31
|
-
relayFailedMessage: "guardian-integrity telemetry relay failed (non-fatal)",
|
|
32
|
-
relayRejectedMessage:
|
|
33
|
-
"guardian-integrity telemetry relay rejected (non-fatal)",
|
|
34
|
-
});
|
|
16
|
+
const lastReportAtByKey = new Map<string, number>();
|
|
35
17
|
|
|
36
18
|
/**
|
|
37
|
-
* Report the missing-guardian state: error log
|
|
38
|
-
*
|
|
19
|
+
* Report the missing-guardian state: a rate-limited error log. Fires on the
|
|
20
|
+
* first detection per process and at most hourly thereafter (the state is
|
|
39
21
|
* sticky until the guardian is re-seeded, so every verdict would otherwise
|
|
40
22
|
* re-fire it). Fire-and-forget — never throws, never blocks the caller.
|
|
41
23
|
*/
|
|
42
24
|
export function reportMissingGuardian(detail: Record<string, unknown>): void {
|
|
43
25
|
report(
|
|
44
|
-
|
|
26
|
+
"gateway_guardian_missing",
|
|
45
27
|
"gateway DB has no guardian rows but evidence of prior onboarding — " +
|
|
46
28
|
"trust verdicts will fail closed until the guardian is re-seeded",
|
|
47
29
|
detail,
|
|
@@ -49,9 +31,9 @@ export function reportMissingGuardian(detail: Record<string, unknown>): void {
|
|
|
49
31
|
}
|
|
50
32
|
|
|
51
33
|
/**
|
|
52
|
-
* Report a refused cadet-guardian mint: error log
|
|
34
|
+
* Report a refused cadet-guardian mint: a rate-limited error log. Same
|
|
53
35
|
* per-check rate limiting and fire-and-forget semantics as
|
|
54
|
-
* {@link reportMissingGuardian}; the sibling
|
|
36
|
+
* {@link reportMissingGuardian}; the sibling check distinguishes the
|
|
55
37
|
* cadet-binding-lost-but-contact-intact refusal, which the missing-guardian
|
|
56
38
|
* state check cannot see.
|
|
57
39
|
*/
|
|
@@ -59,7 +41,7 @@ export function reportGuardianMintRefused(
|
|
|
59
41
|
detail: Record<string, unknown>,
|
|
60
42
|
): void {
|
|
61
43
|
report(
|
|
62
|
-
|
|
44
|
+
"gateway_guardian_mint_refused",
|
|
63
45
|
"refused to mint a cadet guardian principal over evidence of a prior " +
|
|
64
46
|
"guardian — clients get guardian_repair_required until re-pair",
|
|
65
47
|
detail,
|
|
@@ -67,23 +49,13 @@ export function reportGuardianMintRefused(
|
|
|
67
49
|
}
|
|
68
50
|
|
|
69
51
|
function report(
|
|
70
|
-
|
|
52
|
+
check: string,
|
|
71
53
|
message: string,
|
|
72
54
|
detail: Record<string, unknown>,
|
|
73
55
|
): void {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
warnContext: { checkName },
|
|
80
|
-
});
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const last = lastReportAtByKey.get(check) ?? Number.NEGATIVE_INFINITY;
|
|
58
|
+
if (now - last < REPORT_INTERVAL_MS) return;
|
|
59
|
+
lastReportAtByKey.set(check, now);
|
|
60
|
+
log.error({ check, ...detail }, message);
|
|
81
61
|
}
|
|
82
|
-
|
|
83
|
-
/** Test-only seams; see {@link createWatchdogReporter}. */
|
|
84
|
-
export const setGuardianIntegrityReporterOverridesForTesting =
|
|
85
|
-
reporter.setOverridesForTesting;
|
|
86
|
-
export const resetGuardianIntegrityReporterForTesting =
|
|
87
|
-
reporter.resetForTesting;
|
|
88
|
-
export const flushGuardianIntegrityReporterForTesting =
|
|
89
|
-
reporter.flushForTesting;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { Server } from "bun";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
admitActorToken,
|
|
5
|
+
type AdmittedActorTokenRecord,
|
|
6
|
+
} from "../../auth/actor-token-revocation.js";
|
|
4
7
|
import { findCadetGuardian } from "../../auth/guardian-bootstrap.js";
|
|
5
8
|
import {
|
|
6
9
|
isNarrowScopeProfile,
|
|
@@ -9,9 +12,9 @@ import {
|
|
|
9
12
|
import { parseSub } from "../../auth/subject.js";
|
|
10
13
|
import { validateEdgeToken } from "../../auth/token-exchange.js";
|
|
11
14
|
import type { Scope, TokenClaims } from "../../auth/types.js";
|
|
12
|
-
import { AuthFallbackCountTracker } from "../../auth-fallback-count-tracker.js";
|
|
13
15
|
import { AuthFallbackLogThrottle } from "../../auth-fallback-log-throttle.js";
|
|
14
16
|
import type { AuthRateLimiter } from "../../auth-rate-limiter.js";
|
|
17
|
+
import { getIsCloud } from "../../cloud-url.js";
|
|
15
18
|
import { getLogger } from "../../logger.js";
|
|
16
19
|
import { readStoredCloudUserId } from "../../cloud-user-id.js";
|
|
17
20
|
import { isLoopbackPeer } from "../../util/is-loopback-address.js";
|
|
@@ -25,12 +28,6 @@ const log = getLogger("auth");
|
|
|
25
28
|
// per request, so this state can't live in its closure.
|
|
26
29
|
const loopbackFallbackLogThrottle = new AuthFallbackLogThrottle();
|
|
27
30
|
|
|
28
|
-
// Exact, unthrottled count of every loopback fallback, keyed by
|
|
29
|
-
// (guard, path, failureKind). Drained and shipped to the daemon telemetry route
|
|
30
|
-
// by AuthFallbackReporter. Process-lifetime singleton for the same reason as the
|
|
31
|
-
// throttle above; exported so the reporter and tests share the one instance.
|
|
32
|
-
export const loopbackFallbackCountTracker = new AuthFallbackCountTracker();
|
|
33
|
-
|
|
34
31
|
type GetClientIp = () => string;
|
|
35
32
|
|
|
36
33
|
// ---------------------------------------------------------------------------
|
|
@@ -51,18 +48,12 @@ export function isHttpAuthDisabled(): boolean {
|
|
|
51
48
|
return process.env.DISABLE_HTTP_AUTH?.trim().toLowerCase() === "true";
|
|
52
49
|
}
|
|
53
50
|
|
|
54
|
-
/** True when IS_CLOUD is set (vembda-managed deployment). */
|
|
55
|
-
function isPlatformManaged(): boolean {
|
|
56
|
-
const v = process.env.IS_CLOUD?.trim();
|
|
57
|
-
return v === "1" || v?.toLowerCase() === "true";
|
|
58
|
-
}
|
|
59
|
-
|
|
60
51
|
/**
|
|
61
52
|
* True when the cloud-managed auth bypass is in effect — both flags set.
|
|
62
53
|
* Either alone leaves JWT validation in place.
|
|
63
54
|
*/
|
|
64
|
-
function isCloudAuthBypassActive(): boolean {
|
|
65
|
-
return isHttpAuthDisabled() &&
|
|
55
|
+
export function isCloudAuthBypassActive(): boolean {
|
|
56
|
+
return isHttpAuthDisabled() && getIsCloud();
|
|
66
57
|
}
|
|
67
58
|
|
|
68
59
|
/**
|
|
@@ -73,7 +64,7 @@ export function logAuthBypassState(): void {
|
|
|
73
64
|
if (!isHttpAuthDisabled()) {
|
|
74
65
|
return;
|
|
75
66
|
}
|
|
76
|
-
if (
|
|
67
|
+
if (getIsCloud()) {
|
|
77
68
|
log.info(
|
|
78
69
|
"DISABLE_HTTP_AUTH + IS_CLOUD both set: JWT validation bypassed; " +
|
|
79
70
|
"X-Cadet-User-Id is cross-checked against the bound platform user id",
|
|
@@ -87,6 +78,23 @@ export function logAuthBypassState(): void {
|
|
|
87
78
|
}
|
|
88
79
|
}
|
|
89
80
|
|
|
81
|
+
/**
|
|
82
|
+
* The actor-token row the auth layer already loaded by the request bearer's
|
|
83
|
+
* hash while admitting it (see admitActorToken), keyed by request so routes
|
|
84
|
+
* can reuse the row instead of re-querying the same token hash.
|
|
85
|
+
*/
|
|
86
|
+
const admittedActorTokenRecords = new WeakMap<
|
|
87
|
+
Request,
|
|
88
|
+
AdmittedActorTokenRecord
|
|
89
|
+
>();
|
|
90
|
+
|
|
91
|
+
/** The admitted actor-token row for this request, when edge auth loaded one. */
|
|
92
|
+
export function admittedActorTokenRecord(
|
|
93
|
+
req: Request,
|
|
94
|
+
): AdmittedActorTokenRecord | undefined {
|
|
95
|
+
return admittedActorTokenRecords.get(req);
|
|
96
|
+
}
|
|
97
|
+
|
|
90
98
|
/**
|
|
91
99
|
* Build edge-auth guard functions that share a rate limiter and IP resolver.
|
|
92
100
|
*
|
|
@@ -415,7 +423,11 @@ export function createAuthMiddleware(
|
|
|
415
423
|
token: string,
|
|
416
424
|
claims: TokenClaims,
|
|
417
425
|
): Response | null {
|
|
418
|
-
|
|
426
|
+
const admission = admitActorToken(token, claims);
|
|
427
|
+
if (admission.record) {
|
|
428
|
+
admittedActorTokenRecords.set(req, admission.record);
|
|
429
|
+
}
|
|
430
|
+
if (!admission.admitted) {
|
|
419
431
|
authRateLimiter.recordFailure(getClientIp());
|
|
420
432
|
log.warn(
|
|
421
433
|
{ path: new URL(req.url).pathname },
|
|
@@ -443,17 +455,11 @@ export function createAuthMiddleware(
|
|
|
443
455
|
if (!server || !isLoopbackPeer(server, req, { trustProxy })) return false;
|
|
444
456
|
|
|
445
457
|
const path = new URL(req.url).pathname;
|
|
446
|
-
const failureKind =
|
|
447
|
-
typeof extra?.authFailure === "string"
|
|
448
|
-
? extra.authFailure
|
|
449
|
-
: "unspecified";
|
|
450
|
-
|
|
451
|
-
// Count every fallback (unthrottled) for telemetry. The throttle below only
|
|
452
|
-
// governs log volume — it must not gate the count, or the data undercounts.
|
|
453
|
-
loopbackFallbackCountTracker.increment(guard, path, failureKind);
|
|
454
458
|
|
|
455
459
|
if (
|
|
456
|
-
loopbackFallbackLogThrottle.shouldLog(
|
|
460
|
+
loopbackFallbackLogThrottle.shouldLog(
|
|
461
|
+
`${guard} ${path} ${typeof extra?.authFailure === "string" ? extra.authFailure : "unspecified"}`,
|
|
462
|
+
)
|
|
457
463
|
) {
|
|
458
464
|
const peer = server.requestIP(req);
|
|
459
465
|
log.warn(
|
|
@@ -156,7 +156,7 @@ export async function handleCreateToken(
|
|
|
156
156
|
// Don't let a revoked token re-mint a fresh one — that would be an escape
|
|
157
157
|
// hatch around device revocation, since the source token still verifies by
|
|
158
158
|
// signature until it expires.
|
|
159
|
-
if (!admitActorToken(bearerToken, verifyResult.claims)) {
|
|
159
|
+
if (!admitActorToken(bearerToken, verifyResult.claims).admitted) {
|
|
160
160
|
log.warn("Token create rejected: source token revoked");
|
|
161
161
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
162
162
|
}
|