@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
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for the gateway debug-export IPC route.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
6
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
-
import { tmpdir } from "node:os";
|
|
8
|
-
import { join } from "node:path";
|
|
9
|
-
|
|
10
|
-
import type { GatewayConfig } from "../config.js";
|
|
11
|
-
|
|
12
|
-
// The database snapshot is the only piece that needs a live gateway DB;
|
|
13
|
-
// stand it in with a file write so the archive shape can be checked.
|
|
14
|
-
const snapshotGatewayDbMock = mock((destPath: string) => {
|
|
15
|
-
writeFileSync(destPath, "SQLite format 3 (fake)");
|
|
16
|
-
});
|
|
17
|
-
mock.module("../db/connection.js", () => ({
|
|
18
|
-
snapshotGatewayDb: snapshotGatewayDbMock,
|
|
19
|
-
}));
|
|
20
|
-
|
|
21
|
-
const { buildDebugExportArchive, createDebugExportRoutes } =
|
|
22
|
-
await import("./debug-export-handlers.js");
|
|
23
|
-
|
|
24
|
-
function makeConfig(logDir: string | undefined): GatewayConfig {
|
|
25
|
-
return {
|
|
26
|
-
assistantRuntimeBaseUrl: "http://localhost:7821",
|
|
27
|
-
gatewayInternalBaseUrl: "http://127.0.0.1:7830",
|
|
28
|
-
logFile: { dir: logDir, retentionDays: 30 },
|
|
29
|
-
maxAttachmentBytes: {
|
|
30
|
-
telegram: 1,
|
|
31
|
-
slack: 1,
|
|
32
|
-
whatsapp: 1,
|
|
33
|
-
default: 1,
|
|
34
|
-
},
|
|
35
|
-
maxAttachmentConcurrency: 1,
|
|
36
|
-
maxWebhookPayloadBytes: 1,
|
|
37
|
-
port: 7830,
|
|
38
|
-
routingEntries: [],
|
|
39
|
-
runtimeInitialBackoffMs: 1,
|
|
40
|
-
runtimeMaxRetries: 0,
|
|
41
|
-
runtimeProxyRequireAuth: true,
|
|
42
|
-
runtimeTimeoutMs: 1000,
|
|
43
|
-
trustProxy: false,
|
|
44
|
-
} as GatewayConfig;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
async function listArchive(archiveBase64: string): Promise<string[]> {
|
|
48
|
-
const dir = mkdtempSync(join(tmpdir(), "debug-export-test-"));
|
|
49
|
-
try {
|
|
50
|
-
const archivePath = join(dir, "export.tar.gz");
|
|
51
|
-
writeFileSync(archivePath, Buffer.from(archiveBase64, "base64"));
|
|
52
|
-
const tar = Bun.spawn(["/usr/bin/tar", "tzf", archivePath], {
|
|
53
|
-
stdout: "pipe",
|
|
54
|
-
});
|
|
55
|
-
const listing = await new Response(tar.stdout).text();
|
|
56
|
-
await tar.exited;
|
|
57
|
-
return listing
|
|
58
|
-
.split("\n")
|
|
59
|
-
.map((line) => line.replace(/^\.\//, ""))
|
|
60
|
-
.filter((line) => line && line !== ".");
|
|
61
|
-
} finally {
|
|
62
|
-
rmSync(dir, { recursive: true, force: true });
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
beforeEach(() => {
|
|
67
|
-
snapshotGatewayDbMock.mockClear();
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
describe("gateway_debug_export", () => {
|
|
71
|
-
test("registers the method with no params", () => {
|
|
72
|
-
const route = createDebugExportRoutes(makeConfig(undefined))[0];
|
|
73
|
-
expect(route.method).toBe("gateway_debug_export");
|
|
74
|
-
expect(route.schema?.safeParse(undefined).success).toBe(true);
|
|
75
|
-
expect(route.schema?.safeParse({ extra: 1 }).success).toBe(false);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test("packs a consistent database copy and the gateway's log files", async () => {
|
|
79
|
-
const logDir = mkdtempSync(join(tmpdir(), "gateway-logs-"));
|
|
80
|
-
writeFileSync(join(logDir, "gateway-2026-09-18.log"), "line\n");
|
|
81
|
-
try {
|
|
82
|
-
const result = await buildDebugExportArchive(makeConfig(logDir));
|
|
83
|
-
|
|
84
|
-
expect(snapshotGatewayDbMock).toHaveBeenCalledTimes(1);
|
|
85
|
-
expect(result.size_bytes).toBeGreaterThan(0);
|
|
86
|
-
const entries = await listArchive(result.archive_base64);
|
|
87
|
-
expect(entries).toContain("gateway.sqlite");
|
|
88
|
-
expect(entries).toContain("gateway-logs/gateway-2026-09-18.log");
|
|
89
|
-
} finally {
|
|
90
|
-
rmSync(logDir, { recursive: true, force: true });
|
|
91
|
-
}
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
test("still exports the database when there is no log directory", async () => {
|
|
95
|
-
const result = await buildDebugExportArchive(makeConfig(undefined));
|
|
96
|
-
const entries = await listArchive(result.archive_base64);
|
|
97
|
-
expect(entries).toContain("gateway.sqlite");
|
|
98
|
-
expect(entries.some((entry) => entry.startsWith("gateway-logs/"))).toBe(
|
|
99
|
-
false,
|
|
100
|
-
);
|
|
101
|
-
});
|
|
102
|
-
});
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* IPC route for the daemon's debug export.
|
|
3
|
-
*
|
|
4
|
-
* A debug bundle (a `.vbundle` a self-hosted owner sends to Cadet staff)
|
|
5
|
-
* carries the gateway's database and recent logs alongside the daemon's
|
|
6
|
-
* data. The daemon cannot read those itself: in local mode the database
|
|
7
|
-
* lives in the protected directory next to the encryption keys, and in
|
|
8
|
-
* Docker it is on a volume the daemon does not mount. So the gateway
|
|
9
|
-
* produces one archive over the local socket. `VACUUM INTO` gives a
|
|
10
|
-
* consistent copy, and the gateway's token tables hold hashes, not tokens.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
14
|
-
import { tmpdir } from "node:os";
|
|
15
|
-
import { join } from "node:path";
|
|
16
|
-
|
|
17
|
-
import { GatewayDebugExportIpcParamsSchema } from "@mycadet/gateway-client/gateway-ipc-contracts";
|
|
18
|
-
|
|
19
|
-
import type { GatewayConfig } from "../config.js";
|
|
20
|
-
import { snapshotGatewayDb } from "../db/connection.js";
|
|
21
|
-
import { collectGatewayLogs } from "../http/routes/log-export.js";
|
|
22
|
-
import type { IpcRoute } from "./server.js";
|
|
23
|
-
|
|
24
|
-
/** The archive travels base64-encoded over a JSON socket; keep it bounded. */
|
|
25
|
-
export const DEBUG_EXPORT_MAX_BYTES = 64 * 1024 * 1024;
|
|
26
|
-
|
|
27
|
-
export async function buildDebugExportArchive(
|
|
28
|
-
config: GatewayConfig,
|
|
29
|
-
): Promise<{ archive_base64: string; size_bytes: number }> {
|
|
30
|
-
const stagingDir = mkdtempSync(join(tmpdir(), "gateway-debug-export-"));
|
|
31
|
-
try {
|
|
32
|
-
snapshotGatewayDb(join(stagingDir, "gateway.sqlite"));
|
|
33
|
-
await collectGatewayLogs(config, stagingDir);
|
|
34
|
-
|
|
35
|
-
// The absolute path keeps a PATH-injected tar out of the picture on
|
|
36
|
-
// macOS and Linux. Windows 10+ ships bsdtar as tar.exe in System32 with
|
|
37
|
-
// no fixed absolute path, so it resolves through PATH there.
|
|
38
|
-
const tarBinary = process.platform === "win32" ? "tar" : "/usr/bin/tar";
|
|
39
|
-
const archivePath = `${stagingDir}.tar.gz`;
|
|
40
|
-
const tar = Bun.spawn(
|
|
41
|
-
[tarBinary, "czf", archivePath, "-C", stagingDir, "."],
|
|
42
|
-
{
|
|
43
|
-
stdout: "ignore",
|
|
44
|
-
stderr: "pipe",
|
|
45
|
-
windowsHide: true,
|
|
46
|
-
},
|
|
47
|
-
);
|
|
48
|
-
if ((await tar.exited) !== 0) {
|
|
49
|
-
throw new Error(`tar failed: ${await new Response(tar.stderr).text()}`);
|
|
50
|
-
}
|
|
51
|
-
try {
|
|
52
|
-
const bytes = readFileSync(archivePath);
|
|
53
|
-
if (bytes.length > DEBUG_EXPORT_MAX_BYTES) {
|
|
54
|
-
throw new Error(
|
|
55
|
-
`debug export is ${bytes.length} bytes, over the ${DEBUG_EXPORT_MAX_BYTES} byte limit`,
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
return {
|
|
59
|
-
archive_base64: bytes.toString("base64"),
|
|
60
|
-
size_bytes: bytes.length,
|
|
61
|
-
};
|
|
62
|
-
} finally {
|
|
63
|
-
rmSync(archivePath, { force: true });
|
|
64
|
-
}
|
|
65
|
-
} finally {
|
|
66
|
-
rmSync(stagingDir, { recursive: true, force: true });
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function createDebugExportRoutes(config: GatewayConfig): IpcRoute[] {
|
|
71
|
-
return [
|
|
72
|
-
{
|
|
73
|
-
method: "gateway_debug_export",
|
|
74
|
-
schema: GatewayDebugExportIpcParamsSchema,
|
|
75
|
-
handler: async () => ({
|
|
76
|
-
ok: true as const,
|
|
77
|
-
...(await buildDebugExportArchive(config)),
|
|
78
|
-
}),
|
|
79
|
-
},
|
|
80
|
-
];
|
|
81
|
-
}
|
|
@@ -1,440 +0,0 @@
|
|
|
1
|
-
import type { CredentialCache } from "./credential-cache.js";
|
|
2
|
-
import { credentialKey } from "./credential-key.js";
|
|
3
|
-
import { getDeviceId } from "./device-id.js";
|
|
4
|
-
import { fetchImpl } from "./fetch.js";
|
|
5
|
-
import { loadFeatureFlagDefaults } from "./feature-flag-defaults.js";
|
|
6
|
-
import { writeRemoteFeatureFlags } from "./feature-flag-remote-store.js";
|
|
7
|
-
import { areCloudFeaturesEnabled } from "./feature-flag-resolver.js";
|
|
8
|
-
import { getLogger } from "./logger.js";
|
|
9
|
-
|
|
10
|
-
const log = getLogger("remote-feature-flag-sync");
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Steady-state polling interval: 5 minutes.
|
|
14
|
-
*
|
|
15
|
-
* Configurable via `REMOTE_FF_POLL_INTERVAL_MS` env var for testing or
|
|
16
|
-
* deployment tuning.
|
|
17
|
-
*/
|
|
18
|
-
const DEFAULT_POLL_INTERVAL_MS = 5 * 60 * 1000;
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Initial polling interval when the first fetch fails (e.g. CES sidecar
|
|
22
|
-
* not ready yet). Doubles on each consecutive failure until it reaches
|
|
23
|
-
* the steady-state interval.
|
|
24
|
-
*/
|
|
25
|
-
const INITIAL_POLL_INTERVAL_MS = 10_000;
|
|
26
|
-
|
|
27
|
-
function getMaxPollIntervalMs(): number {
|
|
28
|
-
const envVal = process.env.REMOTE_FF_POLL_INTERVAL_MS;
|
|
29
|
-
if (envVal) {
|
|
30
|
-
const parsed = parseInt(envVal, 10);
|
|
31
|
-
if (!isNaN(parsed) && parsed > 0) return parsed;
|
|
32
|
-
}
|
|
33
|
-
return DEFAULT_POLL_INTERVAL_MS;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Discriminated result from a remote feature flag fetch attempt. */
|
|
37
|
-
type RemoteFetchResult =
|
|
38
|
-
| { status: "success"; values: Record<string, boolean | string> }
|
|
39
|
-
| { status: "missing_credentials" }
|
|
40
|
-
| { status: "error" };
|
|
41
|
-
|
|
42
|
-
export type RemoteFeatureFlagSyncConfig = {
|
|
43
|
-
/** Credential cache for resolving platform URL and API key dynamically. */
|
|
44
|
-
credentials: CredentialCache;
|
|
45
|
-
/** Override the initial poll interval (ms) — useful for testing. Defaults to 10 000. */
|
|
46
|
-
initialPollIntervalMs?: number;
|
|
47
|
-
/** Called when remote flags change on disk after a successful fetch. */
|
|
48
|
-
onChanged?: () => void;
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Manages the lifecycle of syncing remote feature flags from the cloud.
|
|
53
|
-
*
|
|
54
|
-
* On start, fetches the current flag state and persists it to disk via the
|
|
55
|
-
* remote feature flag store, then polls with adaptive back-off: starts at
|
|
56
|
-
* {@link INITIAL_POLL_INTERVAL_MS} and doubles on each failure until it
|
|
57
|
-
* reaches the steady-state interval. On the first success the interval
|
|
58
|
-
* snaps to steady-state immediately.
|
|
59
|
-
*
|
|
60
|
-
* When credentials are not configured (user not logged in), polling pauses
|
|
61
|
-
* entirely and resumes automatically when the credential cache is
|
|
62
|
-
* invalidated (e.g. after login).
|
|
63
|
-
*/
|
|
64
|
-
export class RemoteFeatureFlagSync {
|
|
65
|
-
private started = false;
|
|
66
|
-
private pollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
67
|
-
private syncNowActive = false;
|
|
68
|
-
/**
|
|
69
|
-
* Set when `syncNow()` is called while a sync is already in flight. The
|
|
70
|
-
* active loop runs one more fetch after it settles, so a credential/identity
|
|
71
|
-
* change that lands mid-fetch is never dropped (see `syncNow`).
|
|
72
|
-
*/
|
|
73
|
-
private pendingResync = false;
|
|
74
|
-
private hasAuthedSuccessfully = false;
|
|
75
|
-
private waitingForCredentials = false;
|
|
76
|
-
private unsubscribeCredentials: (() => void) | null = null;
|
|
77
|
-
private currentIntervalMs: number;
|
|
78
|
-
private readonly maxIntervalMs: number;
|
|
79
|
-
private readonly credentials: CredentialCache;
|
|
80
|
-
private readonly onChanged?: () => void;
|
|
81
|
-
|
|
82
|
-
constructor(config: RemoteFeatureFlagSyncConfig) {
|
|
83
|
-
this.credentials = config.credentials;
|
|
84
|
-
this.currentIntervalMs =
|
|
85
|
-
config.initialPollIntervalMs ?? INITIAL_POLL_INTERVAL_MS;
|
|
86
|
-
this.maxIntervalMs = getMaxPollIntervalMs();
|
|
87
|
-
this.onChanged = config.onChanged;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
async start(): Promise<void> {
|
|
91
|
-
this.started = true;
|
|
92
|
-
|
|
93
|
-
let result: RemoteFetchResult["status"] = "error";
|
|
94
|
-
try {
|
|
95
|
-
result = await this.fetchAndCache();
|
|
96
|
-
} catch (err) {
|
|
97
|
-
log.warn({ err }, "Failed to sync remote feature flags on startup");
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
if (result === "success") {
|
|
101
|
-
// First fetch succeeded — jump straight to steady-state polling.
|
|
102
|
-
this.currentIntervalMs = this.maxIntervalMs;
|
|
103
|
-
this.scheduleNextPoll();
|
|
104
|
-
} else if (result === "missing_credentials") {
|
|
105
|
-
this.pauseForCredentials();
|
|
106
|
-
} else {
|
|
107
|
-
this.scheduleNextPoll();
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
log.info(
|
|
111
|
-
{
|
|
112
|
-
intervalMs: this.currentIntervalMs,
|
|
113
|
-
waitingForCredentials: this.waitingForCredentials,
|
|
114
|
-
},
|
|
115
|
-
"Remote feature flag polling started",
|
|
116
|
-
);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
stop(): void {
|
|
120
|
-
this.started = false;
|
|
121
|
-
if (this.pollTimer) {
|
|
122
|
-
clearTimeout(this.pollTimer);
|
|
123
|
-
this.pollTimer = null;
|
|
124
|
-
}
|
|
125
|
-
this.clearCredentialWatch();
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Trigger an immediate remote flag sync (e.g. after system wake).
|
|
130
|
-
*
|
|
131
|
-
* Resets the poll timer so the next scheduled poll starts fresh from the
|
|
132
|
-
* steady-state interval after this fetch completes.
|
|
133
|
-
*/
|
|
134
|
-
async syncNow(): Promise<void> {
|
|
135
|
-
// Re-entrancy guard: if a syncNow is already in-flight (e.g. triggered
|
|
136
|
-
// by onInvalidate callback during a wake that also calls syncNow
|
|
137
|
-
// explicitly), don't start a second concurrent fetch. Instead record
|
|
138
|
-
// that a follow-up is needed: the in-flight fetch may have been
|
|
139
|
-
// authenticated with now-stale credentials (a warm-pool claim writes
|
|
140
|
-
// several credential files in quick succession, each firing the change
|
|
141
|
-
// handler), so the active loop runs one more fetch after it settles to
|
|
142
|
-
// guarantee the latest identity's flags are fetched (JARVIS-1018).
|
|
143
|
-
if (this.syncNowActive) {
|
|
144
|
-
this.pendingResync = true;
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
let result: RemoteFetchResult["status"] = "error";
|
|
149
|
-
|
|
150
|
-
// Loop so a credential change that arrives mid-fetch triggers exactly one
|
|
151
|
-
// more fetch after the current one settles, until none is pending. Each
|
|
152
|
-
// iteration re-reads credentials via fetchAndCache, so the final pass
|
|
153
|
-
// always reflects the latest identity.
|
|
154
|
-
do {
|
|
155
|
-
this.pendingResync = false;
|
|
156
|
-
|
|
157
|
-
// Guard: tell poll()'s .finally() not to reschedule — we'll handle it.
|
|
158
|
-
this.syncNowActive = true;
|
|
159
|
-
|
|
160
|
-
// If we were waiting for credentials, clear that state.
|
|
161
|
-
this.clearCredentialWatch();
|
|
162
|
-
|
|
163
|
-
// Cancel the pending poll so we don't double-fetch.
|
|
164
|
-
if (this.pollTimer) {
|
|
165
|
-
clearTimeout(this.pollTimer);
|
|
166
|
-
this.pollTimer = null;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
try {
|
|
170
|
-
result = await this.fetchAndCache();
|
|
171
|
-
if (result === "success") {
|
|
172
|
-
this.currentIntervalMs = this.maxIntervalMs;
|
|
173
|
-
}
|
|
174
|
-
} catch (err) {
|
|
175
|
-
log.warn({ err }, "Failed to sync remote feature flags (syncNow)");
|
|
176
|
-
result = "error";
|
|
177
|
-
} finally {
|
|
178
|
-
this.syncNowActive = false;
|
|
179
|
-
}
|
|
180
|
-
} while (this.started && this.pendingResync);
|
|
181
|
-
|
|
182
|
-
if (this.started) {
|
|
183
|
-
// A concurrent poll() may have called pauseForCredentials() during
|
|
184
|
-
// our await, re-establishing credential-watch state and setting a
|
|
185
|
-
// safety-net timer. Clean that up before deciding what to do next
|
|
186
|
-
// so we don't leak timers or leave waitingForCredentials stale.
|
|
187
|
-
this.clearCredentialWatch();
|
|
188
|
-
if (this.pollTimer) {
|
|
189
|
-
clearTimeout(this.pollTimer);
|
|
190
|
-
this.pollTimer = null;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
if (result === "missing_credentials") {
|
|
194
|
-
this.pauseForCredentials();
|
|
195
|
-
} else {
|
|
196
|
-
this.scheduleNextPoll();
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
private scheduleNextPoll(): void {
|
|
202
|
-
this.pollTimer = setTimeout(() => {
|
|
203
|
-
this.poll();
|
|
204
|
-
}, this.currentIntervalMs);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
private poll(): void {
|
|
208
|
-
if (!this.started) return;
|
|
209
|
-
this.fetchAndCache()
|
|
210
|
-
.then((result) => {
|
|
211
|
-
if (result === "success") {
|
|
212
|
-
// Success — snap to steady-state interval.
|
|
213
|
-
this.currentIntervalMs = this.maxIntervalMs;
|
|
214
|
-
} else if (result === "missing_credentials") {
|
|
215
|
-
this.pauseForCredentials();
|
|
216
|
-
} else {
|
|
217
|
-
// Failure — double the interval, capped at max.
|
|
218
|
-
this.currentIntervalMs = Math.min(
|
|
219
|
-
this.currentIntervalMs * 2,
|
|
220
|
-
this.maxIntervalMs,
|
|
221
|
-
);
|
|
222
|
-
}
|
|
223
|
-
})
|
|
224
|
-
.catch((err) => {
|
|
225
|
-
log.warn({ err }, "Failed to sync remote feature flags during poll");
|
|
226
|
-
this.currentIntervalMs = Math.min(
|
|
227
|
-
this.currentIntervalMs * 2,
|
|
228
|
-
this.maxIntervalMs,
|
|
229
|
-
);
|
|
230
|
-
})
|
|
231
|
-
.finally(() => {
|
|
232
|
-
// If syncNow() is active it owns rescheduling — skip to avoid
|
|
233
|
-
// creating a duplicate poll chain.
|
|
234
|
-
// If waitingForCredentials, credential watch owns resumption.
|
|
235
|
-
if (
|
|
236
|
-
this.started &&
|
|
237
|
-
!this.syncNowActive &&
|
|
238
|
-
!this.waitingForCredentials
|
|
239
|
-
) {
|
|
240
|
-
this.scheduleNextPoll();
|
|
241
|
-
}
|
|
242
|
-
});
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* Stop polling and watch for credential changes instead.
|
|
247
|
-
*
|
|
248
|
-
* Called when credentials are not configured (user not logged in).
|
|
249
|
-
* Resumes sync automatically via two paths:
|
|
250
|
-
* 1. Primary: credential cache invalidation (e.g. after login).
|
|
251
|
-
* 2. Safety net: a delayed retry at the steady-state interval, in case
|
|
252
|
-
* the "missing" result was caused by a transient credential-reader
|
|
253
|
-
* failure (readCesCredential swallows errors as undefined) or an
|
|
254
|
-
* invalidation event was missed between the credential check and
|
|
255
|
-
* the listener registration.
|
|
256
|
-
*/
|
|
257
|
-
private pauseForCredentials(): void {
|
|
258
|
-
if (this.waitingForCredentials) return;
|
|
259
|
-
this.waitingForCredentials = true;
|
|
260
|
-
|
|
261
|
-
// Stop any pending poll.
|
|
262
|
-
if (this.pollTimer) {
|
|
263
|
-
clearTimeout(this.pollTimer);
|
|
264
|
-
this.pollTimer = null;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Primary resume path: credential cache invalidation.
|
|
268
|
-
this.unsubscribeCredentials = this.credentials.onInvalidate(() => {
|
|
269
|
-
if (!this.started || !this.waitingForCredentials) return;
|
|
270
|
-
log.info("Credentials changed — attempting remote feature flag sync");
|
|
271
|
-
this.syncNow().catch((err) => {
|
|
272
|
-
log.warn(
|
|
273
|
-
{ err },
|
|
274
|
-
"Failed to sync remote feature flags after credential change",
|
|
275
|
-
);
|
|
276
|
-
});
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
// Safety net: re-check after the steady-state interval. If credentials
|
|
280
|
-
// are still missing, syncNow → pauseForCredentials re-arms this timer.
|
|
281
|
-
this.pollTimer = setTimeout(() => {
|
|
282
|
-
if (!this.started || !this.waitingForCredentials) return;
|
|
283
|
-
log.debug("Safety re-check: retrying credential read after pause");
|
|
284
|
-
this.syncNow().catch((err) => {
|
|
285
|
-
log.warn(
|
|
286
|
-
{ err },
|
|
287
|
-
"Failed to sync remote feature flags (safety re-check)",
|
|
288
|
-
);
|
|
289
|
-
});
|
|
290
|
-
}, this.maxIntervalMs);
|
|
291
|
-
|
|
292
|
-
log.debug("Paused remote flag polling — waiting for credentials");
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/** Clear the credential invalidation watch if active. */
|
|
296
|
-
private clearCredentialWatch(): void {
|
|
297
|
-
this.waitingForCredentials = false;
|
|
298
|
-
if (this.unsubscribeCredentials) {
|
|
299
|
-
this.unsubscribeCredentials();
|
|
300
|
-
this.unsubscribeCredentials = null;
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/**
|
|
305
|
-
* Fetch remote flags and write them to the store.
|
|
306
|
-
* Returns the status of the fetch attempt.
|
|
307
|
-
*/
|
|
308
|
-
private async fetchAndCache(): Promise<RemoteFetchResult["status"]> {
|
|
309
|
-
const result = await this.fetchRemoteFeatureFlags();
|
|
310
|
-
if (result.status === "missing_credentials") {
|
|
311
|
-
log.debug("Skipping remote flag sync — credentials not configured");
|
|
312
|
-
return "missing_credentials";
|
|
313
|
-
}
|
|
314
|
-
if (result.status === "error") {
|
|
315
|
-
log.warn("Skipping cache write — fetch returned no usable data");
|
|
316
|
-
return "error";
|
|
317
|
-
}
|
|
318
|
-
const changed = writeRemoteFeatureFlags(result.values);
|
|
319
|
-
const msg = "Synced remote feature flags";
|
|
320
|
-
const meta = { count: Object.keys(result.values).length };
|
|
321
|
-
if (changed) {
|
|
322
|
-
log.info(meta, msg);
|
|
323
|
-
this.onChanged?.();
|
|
324
|
-
} else {
|
|
325
|
-
log.debug(meta, msg);
|
|
326
|
-
}
|
|
327
|
-
return "success";
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
private async fetchRemoteFeatureFlags(): Promise<RemoteFetchResult> {
|
|
331
|
-
if (!areCloudFeaturesEnabled()) {
|
|
332
|
-
log.debug("Remote feature flag sync skipped: platform features disabled");
|
|
333
|
-
return { status: "missing_credentials" };
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// Wrap credential reads so transient failures (CES unreachable, keychain
|
|
337
|
-
// errors) are treated as retriable errors with backoff, not as "missing
|
|
338
|
-
// credentials" which would pause polling indefinitely.
|
|
339
|
-
let assistantApiKeyRaw: string | undefined;
|
|
340
|
-
try {
|
|
341
|
-
assistantApiKeyRaw = await this.credentials.get(
|
|
342
|
-
credentialKey("cadet", "assistant_api_key"),
|
|
343
|
-
);
|
|
344
|
-
} catch (err) {
|
|
345
|
-
log.warn({ err }, "Failed to read credentials — will retry on next poll");
|
|
346
|
-
return { status: "error" };
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
// The base URL resolves from the environment only — the credential store
|
|
350
|
-
// never carries it.
|
|
351
|
-
const cloudUrl = (process.env.CADET_CLOUD_URL?.trim() || "").replace(
|
|
352
|
-
/\/+$/,
|
|
353
|
-
"",
|
|
354
|
-
);
|
|
355
|
-
|
|
356
|
-
const assistantCredential =
|
|
357
|
-
assistantApiKeyRaw?.trim() ||
|
|
358
|
-
process.env.ASSISTANT_API_KEY?.trim() ||
|
|
359
|
-
undefined;
|
|
360
|
-
|
|
361
|
-
if (!cloudUrl) {
|
|
362
|
-
log.debug("Remote feature flag sync skipped: no platform URL configured");
|
|
363
|
-
return { status: "missing_credentials" };
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// If we previously fetched with auth and the API key is now missing,
|
|
367
|
-
// treat it as a transient error (backoff + retry) rather than
|
|
368
|
-
// downgrading to an anonymous fetch that would overwrite per-assistant
|
|
369
|
-
// flag values with anonymous defaults.
|
|
370
|
-
if (!assistantCredential && this.hasAuthedSuccessfully) {
|
|
371
|
-
log.warn(
|
|
372
|
-
"API key previously available but now missing — treating as transient error",
|
|
373
|
-
);
|
|
374
|
-
return { status: "error" };
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
const url = `${cloudUrl}/v1/feature-flags/assistant-flag-values/`;
|
|
378
|
-
const headers: Record<string, string> = { Accept: "application/json" };
|
|
379
|
-
if (assistantCredential) {
|
|
380
|
-
headers["Authorization"] = `Api-Key ${assistantCredential}`;
|
|
381
|
-
}
|
|
382
|
-
const deviceId = getDeviceId();
|
|
383
|
-
if (deviceId) {
|
|
384
|
-
headers["Cadet-Device-Id"] = deviceId;
|
|
385
|
-
}
|
|
386
|
-
log.debug(
|
|
387
|
-
{ url, authenticated: !!assistantCredential },
|
|
388
|
-
"Fetching remote feature flags from platform",
|
|
389
|
-
);
|
|
390
|
-
|
|
391
|
-
const response = await fetchImpl(url, {
|
|
392
|
-
method: "GET",
|
|
393
|
-
headers,
|
|
394
|
-
signal: AbortSignal.timeout(10_000),
|
|
395
|
-
});
|
|
396
|
-
|
|
397
|
-
if (!response.ok) {
|
|
398
|
-
log.warn(
|
|
399
|
-
{ status: response.status, url },
|
|
400
|
-
"Platform feature flags request failed",
|
|
401
|
-
);
|
|
402
|
-
return { status: "error" };
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
const body = (await response.json()) as {
|
|
406
|
-
flags?: Record<string, boolean | string>;
|
|
407
|
-
};
|
|
408
|
-
if (!body.flags || typeof body.flags !== "object") {
|
|
409
|
-
log.warn("Platform feature flags response missing 'flags' field");
|
|
410
|
-
return { status: "error" };
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
// Accept boolean and string values from the cloud. Prevent the
|
|
414
|
-
// platform from disabling boolean flags that are already GA
|
|
415
|
-
// (defaultEnabled: true in the registry). The cloud uses a
|
|
416
|
-
// blanket-deny posture, sending false for every flag it knows about.
|
|
417
|
-
// GA normalization only applies to boolean false values; string flag
|
|
418
|
-
// values pass through unchanged.
|
|
419
|
-
const registry = loadFeatureFlagDefaults();
|
|
420
|
-
const values: Record<string, boolean | string> = {};
|
|
421
|
-
for (const [key, value] of Object.entries(body.flags)) {
|
|
422
|
-
if (typeof value !== "boolean" && typeof value !== "string") continue;
|
|
423
|
-
if (value === false && registry[key]?.defaultEnabled === true) {
|
|
424
|
-
log.debug(
|
|
425
|
-
{ key },
|
|
426
|
-
"Normalizing remote false for GA flag to true — defaultEnabled is true",
|
|
427
|
-
);
|
|
428
|
-
values[key] = true;
|
|
429
|
-
continue;
|
|
430
|
-
}
|
|
431
|
-
values[key] = value;
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
if (assistantCredential) {
|
|
435
|
-
this.hasAuthedSuccessfully = true;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
return { status: "success", values };
|
|
439
|
-
}
|
|
440
|
-
}
|