@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.
Files changed (85) hide show
  1. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  2. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  3. package/node_modules/@mycadet/ces-client/src/http-credentials.ts +18 -1
  4. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  5. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  6. package/node_modules/@mycadet/gateway-client/src/gateway-ipc-contracts.ts +0 -20
  7. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  8. package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  9. package/openapi.json +1 -1
  10. package/package.json +1 -1
  11. package/src/__tests__/actor-token-revocation.test.ts +41 -34
  12. package/src/__tests__/contact-store-mirror-op-missing.test.ts +33 -70
  13. package/src/__tests__/contacts-mirror-op-reporter.test.ts +33 -89
  14. package/src/__tests__/credential-requests.test.ts +4 -4
  15. package/src/__tests__/devices-mine.test.ts +187 -0
  16. package/src/__tests__/edge-auth.test.ts +1 -20
  17. package/src/__tests__/edge-guardian-auth.test.ts +1 -17
  18. package/src/__tests__/feature-flag-resolver.test.ts +2 -30
  19. package/src/__tests__/feature-flag-watcher-callback.test.ts +0 -22
  20. package/src/__tests__/feature-flags-route.test.ts +2 -191
  21. package/src/__tests__/guardian-bootstrap-lookups.test.ts +22 -29
  22. package/src/__tests__/guardian-init-lockfile.test.ts +2 -2
  23. package/src/__tests__/guardian-integrity-reporter.test.ts +51 -82
  24. package/src/__tests__/guardian-integrity.test.ts +16 -20
  25. package/src/__tests__/handle-inbound-trust-verdict.test.ts +1 -14
  26. package/src/__tests__/ipc-feature-flag-routes.test.ts +0 -56
  27. package/src/__tests__/lum-2783-recovery-e2e.test.ts +0 -12
  28. package/src/__tests__/post-assistant-ready-deferred.test.ts +0 -18
  29. package/src/__tests__/refresh-device-binding.test.ts +0 -12
  30. package/src/__tests__/remote-web-pairing-token.test.ts +5 -15
  31. package/src/__tests__/runtime-client.test.ts +7 -7
  32. package/src/__tests__/speech-relay-websocket.test.ts +4 -4
  33. package/src/__tests__/trust-verdict-resolver-guardian-integrity.test.ts +30 -36
  34. package/src/__tests__/twilio-webhooks.test.ts +2 -2
  35. package/src/auth/actor-token-revocation.ts +34 -9
  36. package/src/auth/guardian-bootstrap.ts +1 -1
  37. package/src/cli/__tests__/pairing.test.ts +95 -0
  38. package/src/cli/index.ts +1 -1
  39. package/src/cli/pairing.ts +81 -6
  40. package/src/contacts-mirror-op-reporter.ts +19 -36
  41. package/src/db/connection.ts +0 -10
  42. package/src/feature-flag-resolver.ts +2 -7
  43. package/src/feature-flag-watcher.ts +1 -14
  44. package/src/guardian-integrity-reporter.ts +19 -47
  45. package/src/http/middleware/auth.ts +33 -27
  46. package/src/http/routes/auth-token.ts +1 -1
  47. package/src/http/routes/devices-mine.ts +103 -0
  48. package/src/http/routes/feature-flags.ts +1 -8
  49. package/src/http/routes/guardian-pin.ts +2 -11
  50. package/src/http/routes/ipc-runtime-proxy.ts +1 -1
  51. package/src/http/routes/live-voice-websocket.ts +1 -1
  52. package/src/http/routes/runtime-audio-stream.ts +1 -1
  53. package/src/http/routes/runtime-proxy.ts +2 -2
  54. package/src/http/routes/speech-relay-websocket.ts +2 -2
  55. package/src/http/routes/twilio-voice-webhook.test.ts +13 -13
  56. package/src/index.ts +11 -198
  57. package/src/ipc/feature-flag-handlers.ts +2 -9
  58. package/src/remote-web/pairing-challenge-store.ts +15 -0
  59. package/src/risk/command-registry/commands/assistant.ts +0 -24
  60. package/src/risk/command-registry.test.ts +2 -4
  61. package/src/schema.ts +44 -372
  62. package/src/__tests__/auth-fallback-count-tracker.test.ts +0 -92
  63. package/src/__tests__/auth-fallback-reporter.test.ts +0 -101
  64. package/src/__tests__/backup-worker-cloud-mode.test.ts +0 -44
  65. package/src/__tests__/backup-worker-export-timeout.test.ts +0 -164
  66. package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -216
  67. package/src/__tests__/remote-feature-flag-sync.test.ts +0 -961
  68. package/src/auth-fallback-count-tracker.ts +0 -113
  69. package/src/auth-fallback-reporter.ts +0 -127
  70. package/src/backup/backup-key.ts +0 -138
  71. package/src/backup/backup-routes.ts +0 -161
  72. package/src/backup/backup-worker.ts +0 -425
  73. package/src/backup/list-snapshots.ts +0 -97
  74. package/src/backup/local-writer.ts +0 -87
  75. package/src/backup/offsite-writer.ts +0 -182
  76. package/src/backup/paths.ts +0 -113
  77. package/src/backup/stream-crypt.ts +0 -258
  78. package/src/device-id.ts +0 -18
  79. package/src/feature-flag-remote-store.ts +0 -166
  80. package/src/http/routes/migration-proxy.ts +0 -636
  81. package/src/internal-telemetry-client.ts +0 -39
  82. package/src/ipc/debug-export-handlers.test.ts +0 -102
  83. package/src/ipc/debug-export-handlers.ts +0 -81
  84. package/src/remote-feature-flag-sync.ts +0 -440
  85. 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 Mint/approve a remote-web pairing challenge (mint <url>)",
23
+ " pairing Remote-web pairing challenges (mint <url> | list [--json])",
24
24
  );
25
25
  }
26
26
 
@@ -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 mint <url>");
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
- "Mint and approve a remote-web pairing challenge on this machine and",
44
+ " list List every persisted pairing challenge — active and",
27
45
  );
28
- console.log("print the challenge as JSON.");
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("Arguments:");
31
- console.log(" <url> Public base URL to advertise in the pairing link,");
32
- console.log(" e.g. https://your-assistant.ts.net");
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 and relays a
6
- // `contacts_mirror_op_missing` watchdog telemetry event to the daemon's
7
- // internal telemetry route (the route predates the mirror ops, so an old
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
- * Watchdog `check_name` for a daemon missing a contacts mirror op. Platform
17
- * dashboards filter on this exact string — it is the cross-repo contract.
18
- */
19
- export const MIRROR_OP_MISSING_CHECK_NAME = "contacts_mirror_op_missing";
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 + telemetry
39
- * relay. At most one report per op per hour per process. Fire-and-forget —
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
- reporter.report({
47
- key: op,
48
- checkName: MIRROR_OP_MISSING_CHECK_NAME,
49
- message:
50
- "daemon does not implement this contacts mirror op — gateway write applied " +
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
- detail: { op, ...detail },
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;
@@ -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) > remote (platform-pushed) > registry default.
10
- * Undeclared keys return `false` (fail closed), even if stale local/remote
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 and relays a watchdog
6
- // telemetry event to the daemon's internal telemetry route, which POSTs it
7
- // directly to platform ingest — bypassing any state owned by the broken trust
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
- * Watchdog `check_name` for a refused cadet-guardian mint (evidence of a
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 reporter = createWatchdogReporter({
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 + telemetry relay. Fires on
38
- * the first detection per process and at most hourly thereafter (the state is
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
- GUARDIAN_MISSING_CHECK_NAME,
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 + telemetry relay. Same
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 check_name distinguishes the
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
- GUARDIAN_MINT_REFUSED_CHECK_NAME,
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
- checkName: string,
52
+ check: string,
71
53
  message: string,
72
54
  detail: Record<string, unknown>,
73
55
  ): void {
74
- reporter.report({
75
- key: checkName,
76
- checkName,
77
- message,
78
- detail,
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 { admitActorToken } from "../../auth/actor-token-revocation.js";
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() && isPlatformManaged();
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 (isPlatformManaged()) {
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
- if (!admitActorToken(token, claims)) {
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(`${guard} ${path} ${failureKind}`)
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
  }