@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
@@ -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,
@@ -38,6 +38,7 @@ import type { Logger } from "pino";
38
38
  import { authorizeRuntimeAudioStream } from "./runtime-audio-stream.js";
39
39
  import { findCadetGuardian } from "../../auth/guardian-bootstrap.js";
40
40
  import type { GatewayConfig } from "../../config.js";
41
+ import { getIsCloud } from "../../cloud-url.js";
41
42
  import { requestHasTunnelBridgeAuth } from "../../tunnel/bridge-auth.js";
42
43
  import { readStoredCloudUserId } from "../../cloud-user-id.js";
43
44
  import { isWebsocketUpgradeRequest } from "../utils.js";
@@ -46,16 +47,6 @@ const TUNNEL_USER_ID_HEADER = "x-tunnel-user-id";
46
47
  const TUNNEL_ORG_ID_HEADER = "x-tunnel-org-id";
47
48
  const TUNNEL_ACTOR_HEADER = "x-tunnel-actor";
48
49
 
49
- /**
50
- * True when the gateway runs in managed/cloud mode (vembda + tunnel ingress).
51
- * Mirrors the `IS_CLOUD` check used by the gateway's HTTP edge auth
52
- * (`src/http/middleware/auth.ts`) and feature-flag resolver.
53
- */
54
- export function isPlatformManaged(): boolean {
55
- const v = process.env.IS_CLOUD?.trim().toLowerCase();
56
- return v === "1" || v === "true";
57
- }
58
-
59
50
  /**
60
51
  * True when a tunnel-attested caller can reach this gateway at all: a cloud
61
52
  * pod, or any gateway started with a tunnel (`TUNNEL_BASE_URL`), which is
@@ -68,7 +59,7 @@ export function isPlatformManaged(): boolean {
68
59
  export function acceptsTunnelAttestation(
69
60
  config: Pick<GatewayConfig, "tunnelBaseUrl">,
70
61
  ): boolean {
71
- return isPlatformManaged() || config.tunnelBaseUrl !== undefined;
62
+ return getIsCloud() || config.tunnelBaseUrl !== undefined;
72
63
  }
73
64
 
74
65
  /** Tunnel-attested managed caller context, extracted from injected headers. */
@@ -95,7 +95,7 @@ export async function tryIpcProxy(
95
95
  );
96
96
  return Response.json({ error: "Unauthorized" }, { status: 401 });
97
97
  }
98
- if (!admitActorToken(edgeJwt, result.claims)) {
98
+ if (!admitActorToken(edgeJwt, result.claims).admitted) {
99
99
  log.warn(
100
100
  { method: req.method, path: new URL(req.url).pathname },
101
101
  "IPC proxy auth rejected: actor token revoked",
@@ -168,7 +168,7 @@ async function checkLiveVoiceAuth(
168
168
  return { response: new Response("Unauthorized", { status: 401 }) };
169
169
  }
170
170
 
171
- if (!admitActorToken(rawToken, result.claims)) {
171
+ if (!admitActorToken(rawToken, result.claims).admitted) {
172
172
  log.warn("Live voice WS: rejected, actor token revoked");
173
173
  return { response: new Response("Unauthorized", { status: 401 }) };
174
174
  }
@@ -122,7 +122,7 @@ export function authorizeRuntimeAudioStream(
122
122
  };
123
123
  }
124
124
 
125
- if (!admitActorToken(rawToken, result.claims)) {
125
+ if (!admitActorToken(rawToken, result.claims).admitted) {
126
126
  log.warn("audio stream WS: rejected, actor token revoked");
127
127
  return {
128
128
  ok: false,
@@ -70,7 +70,7 @@ function presentedProxyGrant(authHeader: string | null): TokenClaims | null {
70
70
  if (!result.ok || result.claims.scope_profile !== "oauth_proxy_v1") {
71
71
  return null;
72
72
  }
73
- if (!admitActorToken(token, result.claims)) {
73
+ if (!admitActorToken(token, result.claims).admitted) {
74
74
  return null;
75
75
  }
76
76
  return result.claims;
@@ -149,7 +149,7 @@ export function createRuntimeProxyHandler(config: GatewayConfig) {
149
149
  );
150
150
  return Response.json({ error: "Unauthorized" }, { status: 401 });
151
151
  }
152
- if (!admitActorToken(edgeJwt, result.claims)) {
152
+ if (!admitActorToken(edgeJwt, result.claims).admitted) {
153
153
  log.warn(
154
154
  { method: req.method, path: url.pathname },
155
155
  "Runtime proxy auth rejected: actor token revoked",
@@ -22,7 +22,7 @@
22
22
 
23
23
  import type { OutgoingHttpHeaders } from "node:http";
24
24
 
25
- import { tunnelHostForPlatformHost } from "@mycadet/service-contracts/ingress";
25
+ import { tunnelHostForCloudHost } from "@mycadet/service-contracts/ingress";
26
26
 
27
27
  import { resolveScopeProfile } from "../../auth/scopes.js";
28
28
  import { validateEdgeToken } from "../../auth/token-exchange.js";
@@ -163,7 +163,7 @@ function jsonError(status: number, code: string, detail: string): Response {
163
163
  * shared with the web live-voice client; this wrapper adds the scheme.
164
164
  */
165
165
  export function tunnelOriginForPlatformHost(host: string): string | null {
166
- const tunnelHost = tunnelHostForPlatformHost(host);
166
+ const tunnelHost = tunnelHostForCloudHost(host);
167
167
  return tunnelHost ? `https://${tunnelHost}` : null;
168
168
  }
169
169
 
@@ -394,7 +394,7 @@ describe("resolvePublicBaseWssUrl", () => {
394
394
  test("uses tunnelBaseUrl + cloudAssistantId when both are present", () => {
395
395
  const config = {
396
396
  ...baseConfig,
397
- tunnelBaseUrl: "https://tunnel-dev.mycadet.ai",
397
+ tunnelBaseUrl: "https://dev-tunnel.mycadet.ai",
398
398
  };
399
399
  const result = resolvePublicBaseWssUrl(
400
400
  config,
@@ -402,7 +402,7 @@ describe("resolvePublicBaseWssUrl", () => {
402
402
  "abc12345-0000-0000-0000-000000000000",
403
403
  );
404
404
  expect(result).toBe(
405
- "wss://tunnel-dev.mycadet.ai/abc12345-0000-0000-0000-000000000000",
405
+ "wss://dev-tunnel.mycadet.ai/abc12345-0000-0000-0000-000000000000",
406
406
  );
407
407
  });
408
408
 
@@ -430,24 +430,24 @@ describe("resolvePublicBaseWssUrl", () => {
430
430
  test("falls back to configFile publicBaseUrl when cloudAssistantId is missing", () => {
431
431
  const config = {
432
432
  ...baseConfig,
433
- tunnelBaseUrl: "https://tunnel-dev.mycadet.ai",
433
+ tunnelBaseUrl: "https://dev-tunnel.mycadet.ai",
434
434
  };
435
435
  const mockConfigFile = {
436
436
  getString: (section: string, key: string) =>
437
437
  section === "ingress" && key === "publicBaseUrl"
438
- ? "https://tunnel-dev.mycadet.ai/abc12345-0000-0000-0000-000000000000"
438
+ ? "https://dev-tunnel.mycadet.ai/abc12345-0000-0000-0000-000000000000"
439
439
  : undefined,
440
440
  } as Parameters<typeof resolvePublicBaseWssUrl>[1];
441
441
  const result = resolvePublicBaseWssUrl(config, mockConfigFile, undefined);
442
442
  expect(result).toBe(
443
- "wss://tunnel-dev.mycadet.ai/abc12345-0000-0000-0000-000000000000",
443
+ "wss://dev-tunnel.mycadet.ai/abc12345-0000-0000-0000-000000000000",
444
444
  );
445
445
  });
446
446
 
447
447
  test("uses assistant ID from validated platform callback URL with Tunnel", () => {
448
448
  const config = {
449
449
  ...baseConfig,
450
- tunnelBaseUrl: "https://tunnel-staging.mycadet.ai",
450
+ tunnelBaseUrl: "https://staging-tunnel.mycadet.ai",
451
451
  };
452
452
  const result = resolvePublicBaseWssUrl(
453
453
  config,
@@ -456,14 +456,14 @@ describe("resolvePublicBaseWssUrl", () => {
456
456
  "https://staging-cloud.mycadet.ai/v1/gateway/callbacks/019e2d0d-f355-744c-a12c-d7e7dcefcf1e/webhooks/twilio/voice/?callSessionId=37b47ade-2eaf-469a-bede-6f2454875e6e",
457
457
  );
458
458
  expect(result).toBe(
459
- "wss://tunnel-staging.mycadet.ai/019e2d0d-f355-744c-a12c-d7e7dcefcf1e",
459
+ "wss://staging-tunnel.mycadet.ai/019e2d0d-f355-744c-a12c-d7e7dcefcf1e",
460
460
  );
461
461
  });
462
462
 
463
463
  test("prefers validated platform callback URL over stale configFile publicBaseUrl", () => {
464
464
  const config = {
465
465
  ...baseConfig,
466
- tunnelBaseUrl: "https://tunnel-staging.mycadet.ai",
466
+ tunnelBaseUrl: "https://staging-tunnel.mycadet.ai",
467
467
  };
468
468
  const mockConfigFile = {
469
469
  getString: (section: string, key: string) =>
@@ -478,14 +478,14 @@ describe("resolvePublicBaseWssUrl", () => {
478
478
  "https://staging-cloud.mycadet.ai/v1/gateway/callbacks/019e2d0d-f355-744c-a12c-d7e7dcefcf1e/webhooks/twilio/voice?callSessionId=sess-1",
479
479
  );
480
480
  expect(result).toBe(
481
- "wss://tunnel-staging.mycadet.ai/019e2d0d-f355-744c-a12c-d7e7dcefcf1e",
481
+ "wss://staging-tunnel.mycadet.ai/019e2d0d-f355-744c-a12c-d7e7dcefcf1e",
482
482
  );
483
483
  });
484
484
 
485
485
  test("does not use a cloud callback URL as the websocket base", () => {
486
486
  const config = {
487
487
  ...baseConfig,
488
- tunnelBaseUrl: "https://tunnel-staging.mycadet.ai",
488
+ tunnelBaseUrl: "https://staging-tunnel.mycadet.ai",
489
489
  };
490
490
  const mockConfigFile = {
491
491
  getString: (section: string, key: string) =>
@@ -495,14 +495,14 @@ describe("resolvePublicBaseWssUrl", () => {
495
495
  } as Parameters<typeof resolvePublicBaseWssUrl>[1];
496
496
  const result = resolvePublicBaseWssUrl(config, mockConfigFile, undefined);
497
497
  expect(result).toBe(
498
- "wss://tunnel-staging.mycadet.ai/019e2d0d-f355-744c-a12c-d7e7dcefcf1e",
498
+ "wss://staging-tunnel.mycadet.ai/019e2d0d-f355-744c-a12c-d7e7dcefcf1e",
499
499
  );
500
500
  });
501
501
 
502
502
  test("strips trailing slash from tunnelBaseUrl before joining assistant ID", () => {
503
503
  const config = {
504
504
  ...baseConfig,
505
- tunnelBaseUrl: "https://tunnel-dev.mycadet.ai/",
505
+ tunnelBaseUrl: "https://dev-tunnel.mycadet.ai/",
506
506
  };
507
507
  const result = resolvePublicBaseWssUrl(
508
508
  config,
@@ -510,7 +510,7 @@ describe("resolvePublicBaseWssUrl", () => {
510
510
  "abc12345-0000-0000-0000-000000000000",
511
511
  );
512
512
  expect(result).toBe(
513
- "wss://tunnel-dev.mycadet.ai/abc12345-0000-0000-0000-000000000000",
513
+ "wss://dev-tunnel.mycadet.ai/abc12345-0000-0000-0000-000000000000",
514
514
  );
515
515
  });
516
516
  });
package/src/index.ts CHANGED
@@ -17,12 +17,10 @@ import {
17
17
  } from "./auth/token-service.js";
18
18
  import { validateEdgeToken, mintServiceToken } from "./auth/token-exchange.js";
19
19
  import { findGuardianForChannelActor } from "./auth/guardian-bootstrap.js";
20
- import { AuthFallbackReporter } from "./auth-fallback-reporter.js";
21
- import { loopbackFallbackCountTracker } from "./http/middleware/auth.js";
22
20
  import { ConfigFileCache } from "./config-file-cache.js";
23
21
  import { ConfigFileWatcher } from "./config-file-watcher.js";
24
22
  import { FeatureFlagWatcher } from "./feature-flag-watcher.js";
25
- import { RemoteFeatureFlagSync } from "./remote-feature-flag-sync.js";
23
+
26
24
  import { loadConfig } from "./config.js";
27
25
  import { CredentialCache } from "./credential-cache.js";
28
26
  import { credentialKey } from "./credential-key.js";
@@ -104,6 +102,7 @@ import {
104
102
  handleListDevices,
105
103
  handleRevokeDevice,
106
104
  } from "./http/routes/devices.js";
105
+ import { handleGetMyDevice } from "./http/routes/devices-mine.js";
107
106
  import { handleSaveIngressDomain } from "./http/routes/ingress-domains.js";
108
107
  import { handlePair } from "./http/routes/pair.js";
109
108
  import { handleCredentialEntryPage } from "./http/routes/credential-entry-page.js";
@@ -129,20 +128,7 @@ import { createPsHandler } from "./http/routes/ps.js";
129
128
  import { createTunnelStatusHandler } from "./http/routes/tunnel-status.js";
130
129
  import { createRuntimeHealthProxyHandler } from "./http/routes/runtime-health-proxy.js";
131
130
  import { createUpgradeBroadcastProxyHandler } from "./http/routes/upgrade-broadcast-proxy.js";
132
- import {
133
- createMigrationExportProxyHandler,
134
- createMigrationExportToGcsProxyHandler,
135
- createMigrationImportFromGcsProxyHandler,
136
- createMigrationImportProxyHandler,
137
- createMigrationImportStatusProxyHandler,
138
- createMigrationJobStatusProxyHandler,
139
- } from "./http/routes/migration-proxy.js";
140
131
  import { createMigrationRollbackProxyHandler } from "./http/routes/migration-rollback-proxy.js";
141
- import {
142
- createListBackupsHandler,
143
- createBackupSnapshotHandler,
144
- } from "./backup/backup-routes.js";
145
- import { startBackupWorker } from "./backup/backup-worker.js";
146
132
  import { createWorkspaceCommitProxyHandler } from "./http/routes/workspace-commit-proxy.js";
147
133
  import { createDesktopSetupProxyHandler } from "./http/routes/desktop-setup-proxy.js";
148
134
  import { createBrainGraphProxyHandler } from "./http/routes/brain-graph-proxy.js";
@@ -256,7 +242,6 @@ import { admissionPolicyRoutes } from "./ipc/admission-policy-handlers.js";
256
242
  import { channelPermissionRoutes } from "./ipc/channel-permission-handlers.js";
257
243
  import { trustVerdictRoutes } from "./ipc/trust-verdict-handlers.js";
258
244
  import { guardianDeliveryRoutes } from "./ipc/guardian-delivery-handlers.js";
259
- import { createDebugExportRoutes } from "./ipc/debug-export-handlers.js";
260
245
  import { createLogTailRoutes } from "./ipc/log-tail-handlers.js";
261
246
  import { createChannelSocketHealthRoutes } from "./ipc/channel-socket-health-handlers.js";
262
247
  import { createCredentialRequestIpcRoutes } from "./ipc/credential-request-handlers.js";
@@ -675,15 +660,6 @@ async function main() {
675
660
  const tunnelStatusHandler = createTunnelStatusHandler(tunnelClient);
676
661
  const runtimeHealthProxy = createRuntimeHealthProxyHandler(config);
677
662
  const upgradeBroadcastProxy = createUpgradeBroadcastProxyHandler(config);
678
- const migrationExportProxy = createMigrationExportProxyHandler(config);
679
- const migrationImportProxy = createMigrationImportProxyHandler(config);
680
- const migrationImportStatusProxy =
681
- createMigrationImportStatusProxyHandler(config);
682
- const migrationExportToGcsProxy =
683
- createMigrationExportToGcsProxyHandler(config);
684
- const migrationImportFromGcsProxy =
685
- createMigrationImportFromGcsProxyHandler(config);
686
- const migrationJobStatusProxy = createMigrationJobStatusProxyHandler(config);
687
663
  const migrationRollbackProxy = createMigrationRollbackProxyHandler(config);
688
664
  const workspaceCommitProxy = createWorkspaceCommitProxyHandler(config);
689
665
  const desktopSetupProxy = createDesktopSetupProxyHandler(config);
@@ -743,12 +719,6 @@ async function main() {
743
719
 
744
720
  const audioProxy = createAudioProxyHandler(config);
745
721
 
746
- const backupDeps = {
747
- assistantRuntimeBaseUrl: config.assistantRuntimeBaseUrl,
748
- };
749
- const handleListBackups = createListBackupsHandler(backupDeps);
750
- const handleCreateBackup = createBackupSnapshotHandler(backupDeps);
751
-
752
722
  const handleRuntimeProxy = createRuntimeProxyHandler(config);
753
723
 
754
724
  // Helper to reject when an integration isn't configured
@@ -1100,6 +1070,14 @@ async function main() {
1100
1070
  handleRevokeDevice(req, getClientIp()),
1101
1071
  },
1102
1072
 
1073
+ // ── The caller's own paired-device row (bearer-authed, browser-legit) ──
1074
+ {
1075
+ path: "/v1/devices/mine",
1076
+ method: "GET",
1077
+ auth: "edge",
1078
+ handler: (req) => handleGetMyDevice(req),
1079
+ },
1080
+
1103
1081
  // ── Channel verification sessions ──
1104
1082
  {
1105
1083
  // Bootstrap endpoint — may be replaced with an SSH-based exchange in the
@@ -1324,73 +1302,6 @@ async function main() {
1324
1302
  handler: (req) => upgradeBroadcastProxy(req),
1325
1303
  },
1326
1304
 
1327
- // ── Migration export/import ──
1328
- {
1329
- path: "/v1/migrations/export",
1330
- method: "POST",
1331
- auth: "edge-scoped",
1332
- scope: "settings.write",
1333
- handler: (req) => migrationExportProxy(req),
1334
- },
1335
- {
1336
- path: "/v1/migrations/import",
1337
- method: "POST",
1338
- auth: "edge-scoped",
1339
- scope: "settings.write",
1340
- handler: (req) => migrationImportProxy(req),
1341
- },
1342
- {
1343
- // Async-job status endpoint for URL-based imports. The gateway keeps
1344
- // an in-memory job map keyed by the jobId it handed back in the
1345
- // 202 response; this lets callers poll for progress without holding
1346
- // an HTTP connection open for the full import duration.
1347
- //
1348
- // Trailing slash is optional to preserve compatibility with existing
1349
- // pollers. PlatformMigrationClient.pollImportStatus (macOS) and
1350
- // cli/src/lib/cloud-client.ts both hit `.../status/` against the
1351
- // platform API today; other callers may follow the bare-path
1352
- // convention (`.../status`). Regex routes in the gateway router are
1353
- // NOT trailing-slash-normalized, so the optionality is encoded here.
1354
- path: /^\/v1\/migrations\/import\/([^/]+)\/status\/?$/,
1355
- method: "GET",
1356
- auth: "edge-scoped",
1357
- // Read-only polling endpoint — read scope, not write. Matches the
1358
- // convention used for other GET endpoints in this router (OAuth
1359
- // providers GET, OAuth apps GET) so a token
1360
- // profile with `settings.read` only (e.g. the `ui_page_v1`
1361
- // profile) can still poll import progress.
1362
- scope: "settings.read",
1363
- handler: (req, params) =>
1364
- migrationImportStatusProxy(req, params[0] ?? ""),
1365
- },
1366
-
1367
- // ── Teleport-GCS migration (unified daemon-async flow) ──
1368
- // Registered as explicit routes (not via the runtime-proxy catch-all)
1369
- // for dedicated auth and timeout handling. The daemon returns 202
1370
- // { job_id } on POST and cheap JSON on GET, so the gateway just
1371
- // transparently forwards without wrapping.
1372
- {
1373
- path: "/v1/migrations/export-to-gcs",
1374
- method: "POST",
1375
- auth: "edge-scoped",
1376
- scope: "settings.write",
1377
- handler: (req) => migrationExportToGcsProxy(req),
1378
- },
1379
- {
1380
- path: "/v1/migrations/import-from-gcs",
1381
- method: "POST",
1382
- auth: "edge-scoped",
1383
- scope: "settings.write",
1384
- handler: (req) => migrationImportFromGcsProxy(req),
1385
- },
1386
- {
1387
- path: /^\/v1\/migrations\/jobs\/([^/]+)\/?$/,
1388
- method: "GET",
1389
- auth: "edge-scoped",
1390
- scope: "settings.read",
1391
- handler: (req, params) => migrationJobStatusProxy(req, params[0] ?? ""),
1392
- },
1393
-
1394
1305
  // ── Workspace commit ──
1395
1306
  {
1396
1307
  path: "/v1/admin/workspace-commit",
@@ -1409,45 +1320,6 @@ async function main() {
1409
1320
  handler: (req) => migrationRollbackProxy(req),
1410
1321
  },
1411
1322
 
1412
- // ── Backups ──
1413
- {
1414
- path: "/v1/backups",
1415
- method: "GET",
1416
- auth: "edge-scoped",
1417
- scope: "settings.read",
1418
- handler: (req) => handleListBackups(req),
1419
- },
1420
- {
1421
- path: "/v1/backups/create",
1422
- method: "POST",
1423
- auth: "edge-scoped",
1424
- scope: "settings.write",
1425
- handler: (req) => handleCreateBackup(req),
1426
- },
1427
-
1428
- // ── Backups — assistant-scoped variants ──
1429
- // Mirror the flat /v1/backups routes for clients that use the daemon
1430
- // SDK's assistant-scoped URLs (/v1/assistants/<id>/backups/...).
1431
- // Without these, the request falls through to the runtime-proxy
1432
- // catch-all and the daemon rejects create ("moved to gateway").
1433
- // Backups are gateway-global, so the assistant id is matched and
1434
- // discarded. Same precedent as the trust-rules assistant-scoped
1435
- // variants below.
1436
- {
1437
- path: /^\/v1\/assistants\/[^/]+\/backups\/?$/,
1438
- method: "GET",
1439
- auth: "edge-scoped",
1440
- scope: "settings.read",
1441
- handler: (req) => handleListBackups(req),
1442
- },
1443
- {
1444
- path: /^\/v1\/assistants\/[^/]+\/backups\/create\/?$/,
1445
- method: "POST",
1446
- auth: "edge-scoped",
1447
- scope: "settings.write",
1448
- handler: (req) => handleCreateBackup(req),
1449
- },
1450
-
1451
1323
  // ── Platform push / Live Activity tokens ──
1452
1324
  // Django-owned registration. Remote-gateway clients hit these
1453
1325
  // same-origin; without a dedicated route they fall through to the
@@ -2022,7 +1894,7 @@ async function main() {
2022
1894
  port: config.port,
2023
1895
  idleTimeout: 0,
2024
1896
  // Match the daemon's 512 MB limit (assistant/src/runtime/http-server.ts)
2025
- // so large .vbundle imports proxied through the gateway aren't rejected.
1897
+ // to accommodate large app package uploads.
2026
1898
  maxRequestBodySize: 512 * 1024 * 1024,
2027
1899
  websocket: {
2028
1900
  open(ws) {
@@ -2851,14 +2723,6 @@ async function main() {
2851
2723
  log.info("Discord Gateway client started");
2852
2724
  }
2853
2725
 
2854
- // Lazily bound below, once `remoteFeatureFlagSync` is constructed, so the
2855
- // credential-change callback can trigger an immediate per-assistant flag
2856
- // re-sync. On a warm-pool claim the `cadet` credentials change to the
2857
- // newly-assigned identity; without forcing a re-sync, flag values would not
2858
- // refresh until the next scheduled poll (up to 5 min), so the onboarding
2859
- // first message evaluates flags against stale values (see JARVIS-1018).
2860
- let remoteFeatureFlagSyncRef: RemoteFeatureFlagSync | null = null;
2861
-
2862
2726
  /**
2863
2727
  * Fingerprint of the Telegram bot token the dedup cache's watermark belongs
2864
2728
  * to. Null while no token is stored, which is itself a bot change: the next
@@ -2981,21 +2845,6 @@ async function main() {
2981
2845
  );
2982
2846
  });
2983
2847
  }
2984
-
2985
- // Force an immediate per-assistant feature-flag re-sync. A `cadet`
2986
- // credential change means a warm-pool claim / key rotation / late
2987
- // provisioning — the assistant identity the cloud evaluates flags
2988
- // for just changed. Without this, flags stay at their previous (often
2989
- // registry-default) values until the next ~5-min poll, which the
2990
- // onboarding auto-greet beats, so first-message flags read stale
2991
- // (JARVIS-1018). The ref is null only during the initial credential
2992
- // poll at startup, which `start()` already covers.
2993
- remoteFeatureFlagSyncRef?.syncNow().catch((err) => {
2994
- log.error(
2995
- { err },
2996
- "Failed to sync remote feature flags after cadet credential change",
2997
- );
2998
- });
2999
2848
  }
3000
2849
  });
3001
2850
 
@@ -3102,7 +2951,6 @@ async function main() {
3102
2951
  ...guardianDeliveryRoutes,
3103
2952
  ...riskClassificationRoutes,
3104
2953
  ...createLogTailRoutes(config),
3105
- ...createDebugExportRoutes(config),
3106
2954
  ...createChannelSocketHealthRoutes({
3107
2955
  slack: () => slackSocketClient,
3108
2956
  discord: () => discordGatewayClient,
@@ -3120,11 +2968,6 @@ async function main() {
3120
2968
 
3121
2969
  void refreshRouteSchema();
3122
2970
 
3123
- // ── Backup worker ──
3124
- const backupWorkerHandle = startBackupWorker({
3125
- assistantRuntimeBaseUrl: config.assistantRuntimeBaseUrl,
3126
- });
3127
-
3128
2971
  let tunnelWebhooksEnabled = isFeatureFlagEnabled("tunnel-webhooks");
3129
2972
  emitFlagChanged = () => {
3130
2973
  ipcServer.emit("feature_flags_changed");
@@ -3153,26 +2996,6 @@ async function main() {
3153
2996
  });
3154
2997
  featureFlagWatcher.start();
3155
2998
 
3156
- const remoteFeatureFlagSync = new RemoteFeatureFlagSync({
3157
- credentials: credentialCache,
3158
- onChanged: emitFlagChanged,
3159
- });
3160
- // Bind the ref so the credential-change callback above can force an
3161
- // immediate re-sync when the assistant's `cadet` identity changes
3162
- // (warm-pool claim / key rotation) instead of waiting for the next poll.
3163
- remoteFeatureFlagSyncRef = remoteFeatureFlagSync;
3164
- // Intentionally fire-and-forget: remote flag fetch is best-effort;
3165
- // the gateway continues with registry defaults if it fails.
3166
- void remoteFeatureFlagSync.start();
3167
-
3168
- // Periodically ship legacy-loopback auth-fallback counts to the daemon
3169
- // telemetry route. Best-effort background work; never blocks auth.
3170
- const authFallbackReporter = new AuthFallbackReporter({
3171
- tracker: loopbackFallbackCountTracker,
3172
- baseUrl: config.assistantRuntimeBaseUrl,
3173
- });
3174
- authFallbackReporter.start();
3175
-
3176
2999
  // ── Sleep/wake detection ──
3177
3000
  // Detect system sleep/wake transitions and force-reconnect channels
3178
3001
  // that may have stale connections after the OS suspended the process.
@@ -3186,12 +3009,6 @@ async function main() {
3186
3009
  configFileCache.invalidate();
3187
3010
  credentialCache.invalidate();
3188
3011
 
3189
- // Immediately refresh remote feature flags so the gateway doesn't run
3190
- // with stale values until the next scheduled poll (up to 5 min away).
3191
- remoteFeatureFlagSync.syncNow().catch((err) => {
3192
- log.error({ err }, "Failed to sync remote feature flags after wake");
3193
- });
3194
-
3195
3012
  // Re-register Telegram webhook with current ingress URL
3196
3013
  if (telegramReady) {
3197
3014
  reconcileTelegramWebhook(telegramCaches).catch((err) => {
@@ -3206,13 +3023,9 @@ async function main() {
3206
3023
  draining = true;
3207
3024
  const shutdownTasks: Promise<void>[] = [];
3208
3025
  sleepWakeDetector.stop();
3209
- backupWorkerHandle.stop();
3210
3026
  credentialWatcher.stop();
3211
3027
  configFileWatcher.stop();
3212
3028
  featureFlagWatcher.stop();
3213
- remoteFeatureFlagSync.stop();
3214
- // Stop the timer and flush any buffered auth-fallback counts before exit.
3215
- shutdownTasks.push(authFallbackReporter.stop());
3216
3029
  const tunnelStop = tunnelClient?.stop();
3217
3030
  if (tunnelStop) shutdownTasks.push(tunnelStop);
3218
3031
  ipcServer.stop();