@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
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
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { and, eq, max } from "drizzle-orm";
|
|
2
|
+
|
|
3
|
+
import { actorTokenRecordHash } from "../../auth/actor-token-revocation.js";
|
|
4
|
+
import { getGatewayDb } from "../../db/connection.js";
|
|
5
|
+
import { actorTokenRecords } from "../../db/schema.js";
|
|
6
|
+
import { errorResponse } from "../loopback-guard.js";
|
|
7
|
+
import {
|
|
8
|
+
admittedActorTokenRecord,
|
|
9
|
+
isCloudAuthBypassActive,
|
|
10
|
+
} from "../middleware/auth.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `GET /v1/devices/mine` — the caller's own paired-device row.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately browser-reachable (no loopback guard, no origin rejection):
|
|
16
|
+
* the bearer IS the credential, and the route answers only for the token
|
|
17
|
+
* presenting it. This is how the paired web UI surfaces "this device" so
|
|
18
|
+
* its row can be matched against the host-side `cadet devices` list — the
|
|
19
|
+
* gateway stores nothing but the hashed device id, so the hash is the only
|
|
20
|
+
* matchable identity either side can name. The projection mirrors
|
|
21
|
+
* `/v1/devices` exactly (including the cross-status `max(lastUsedAt)`, since
|
|
22
|
+
* a rotation leaves the newest activity stamp on the revoked row), so a
|
|
23
|
+
* `devices/mine` response matches the same device's row in the list output
|
|
24
|
+
* field for field.
|
|
25
|
+
*/
|
|
26
|
+
export async function handleGetMyDevice(req: Request): Promise<Response> {
|
|
27
|
+
if (req.method !== "GET") {
|
|
28
|
+
return new Response("method not allowed", {
|
|
29
|
+
status: 405,
|
|
30
|
+
headers: { Allow: "GET" },
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Cloud-hosted assistants have no paired-device rows to name — the route
|
|
35
|
+
// only answers for self-hosted gateways.
|
|
36
|
+
if (isCloudAuthBypassActive()) {
|
|
37
|
+
return errorResponse(
|
|
38
|
+
"NOT_AVAILABLE_ON_CLOUD",
|
|
39
|
+
"this assistant is cloud-hosted — it has no paired-device rows",
|
|
40
|
+
404,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const authorization = req.headers.get("authorization") ?? "";
|
|
45
|
+
const bearer = authorization.startsWith("Bearer ")
|
|
46
|
+
? authorization.slice("Bearer ".length).trim()
|
|
47
|
+
: "";
|
|
48
|
+
|
|
49
|
+
const db = getGatewayDb();
|
|
50
|
+
// Edge auth already loaded this row by the bearer's hash while admitting
|
|
51
|
+
// the token (admitActorToken) — reuse it when present. The fallback query
|
|
52
|
+
// covers paths where admission produced no row (fail-open on a DB error,
|
|
53
|
+
// or the handler driven directly in tests).
|
|
54
|
+
const row =
|
|
55
|
+
admittedActorTokenRecord(req) ??
|
|
56
|
+
db
|
|
57
|
+
.select({
|
|
58
|
+
guardianPrincipalId: actorTokenRecords.guardianPrincipalId,
|
|
59
|
+
hashedDeviceId: actorTokenRecords.hashedDeviceId,
|
|
60
|
+
platform: actorTokenRecords.platform,
|
|
61
|
+
pairingUserAgent: actorTokenRecords.pairingUserAgent,
|
|
62
|
+
clientReportedName: actorTokenRecords.clientReportedName,
|
|
63
|
+
issuedAt: actorTokenRecords.issuedAt,
|
|
64
|
+
expiresAt: actorTokenRecords.expiresAt,
|
|
65
|
+
})
|
|
66
|
+
.from(actorTokenRecords)
|
|
67
|
+
.where(eq(actorTokenRecords.tokenHash, actorTokenRecordHash(bearer)))
|
|
68
|
+
.get();
|
|
69
|
+
|
|
70
|
+
if (!row) {
|
|
71
|
+
return errorResponse(
|
|
72
|
+
"DEVICE_NOT_FOUND",
|
|
73
|
+
"no paired device row for this token",
|
|
74
|
+
404,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const lastUsed = db
|
|
79
|
+
.select({ lastUsedAt: max(actorTokenRecords.lastUsedAt) })
|
|
80
|
+
.from(actorTokenRecords)
|
|
81
|
+
.where(
|
|
82
|
+
and(
|
|
83
|
+
eq(actorTokenRecords.hashedDeviceId, row.hashedDeviceId),
|
|
84
|
+
eq(actorTokenRecords.guardianPrincipalId, row.guardianPrincipalId),
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
.get();
|
|
88
|
+
|
|
89
|
+
return Response.json(
|
|
90
|
+
{
|
|
91
|
+
device: {
|
|
92
|
+
hashedDeviceId: row.hashedDeviceId,
|
|
93
|
+
platform: row.platform,
|
|
94
|
+
pairingUserAgent: row.pairingUserAgent ?? null,
|
|
95
|
+
clientReportedName: row.clientReportedName ?? null,
|
|
96
|
+
issuedAt: row.issuedAt,
|
|
97
|
+
expiresAt: row.expiresAt ?? null,
|
|
98
|
+
lastUsedAt: lastUsed?.lastUsedAt ?? null,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{ headers: { "Cache-Control": "no-store" } },
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
isFlagDeclared,
|
|
6
6
|
} from "../../feature-flag-defaults.js";
|
|
7
7
|
import { readEnvFeatureFlagOverrides } from "../../feature-flag-env-overrides.js";
|
|
8
|
-
import { readRemoteFeatureFlags } from "../../feature-flag-remote-store.js";
|
|
9
8
|
import {
|
|
10
9
|
readPersistedFeatureFlags,
|
|
11
10
|
writeFeatureFlag,
|
|
@@ -81,19 +80,13 @@ export function createFeatureFlagsGetHandler() {
|
|
|
81
80
|
try {
|
|
82
81
|
const defaults = loadFeatureFlagDefaults();
|
|
83
82
|
const persisted = readPersistedFeatureFlags();
|
|
84
|
-
const remote = readRemoteFeatureFlags();
|
|
85
83
|
const envOverrides = readEnvFeatureFlagOverrides();
|
|
86
84
|
|
|
87
85
|
const entries: FeatureFlagEntry[] = [];
|
|
88
86
|
for (const [key, def] of Object.entries(defaults)) {
|
|
89
87
|
const persistedValue = persisted[key];
|
|
90
|
-
const remoteValue = remote[key];
|
|
91
88
|
const base =
|
|
92
|
-
persistedValue !== undefined
|
|
93
|
-
? persistedValue
|
|
94
|
-
: remoteValue !== undefined
|
|
95
|
-
? remoteValue
|
|
96
|
-
: def.defaultEnabled;
|
|
89
|
+
persistedValue !== undefined ? persistedValue : def.defaultEnabled;
|
|
97
90
|
const envValue = envOverrides[key];
|
|
98
91
|
entries.push({
|
|
99
92
|
key,
|