@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.
Files changed (81) hide show
  1. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  2. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  3. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  4. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  5. package/node_modules/@mycadet/gateway-client/src/gateway-ipc-contracts.ts +0 -20
  6. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  7. package/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  8. package/openapi.json +1 -1
  9. package/package.json +1 -1
  10. package/src/__tests__/actor-token-revocation.test.ts +41 -34
  11. package/src/__tests__/contact-store-mirror-op-missing.test.ts +33 -70
  12. package/src/__tests__/contacts-mirror-op-reporter.test.ts +33 -89
  13. package/src/__tests__/credential-requests.test.ts +4 -4
  14. package/src/__tests__/devices-mine.test.ts +187 -0
  15. package/src/__tests__/edge-auth.test.ts +1 -20
  16. package/src/__tests__/edge-guardian-auth.test.ts +1 -17
  17. package/src/__tests__/feature-flag-resolver.test.ts +2 -30
  18. package/src/__tests__/feature-flag-watcher-callback.test.ts +0 -22
  19. package/src/__tests__/feature-flags-route.test.ts +2 -191
  20. package/src/__tests__/guardian-bootstrap-lookups.test.ts +18 -25
  21. package/src/__tests__/guardian-integrity-reporter.test.ts +51 -82
  22. package/src/__tests__/guardian-integrity.test.ts +16 -20
  23. package/src/__tests__/handle-inbound-trust-verdict.test.ts +1 -14
  24. package/src/__tests__/ipc-feature-flag-routes.test.ts +0 -56
  25. package/src/__tests__/lum-2783-recovery-e2e.test.ts +0 -12
  26. package/src/__tests__/post-assistant-ready-deferred.test.ts +0 -18
  27. package/src/__tests__/refresh-device-binding.test.ts +0 -12
  28. package/src/__tests__/remote-web-pairing-token.test.ts +4 -14
  29. package/src/__tests__/runtime-client.test.ts +7 -7
  30. package/src/__tests__/speech-relay-websocket.test.ts +4 -4
  31. package/src/__tests__/trust-verdict-resolver-guardian-integrity.test.ts +30 -36
  32. package/src/__tests__/twilio-webhooks.test.ts +2 -2
  33. package/src/auth/actor-token-revocation.ts +34 -9
  34. package/src/cli/__tests__/pairing.test.ts +95 -0
  35. package/src/cli/index.ts +1 -1
  36. package/src/cli/pairing.ts +81 -6
  37. package/src/contacts-mirror-op-reporter.ts +19 -36
  38. package/src/db/connection.ts +0 -10
  39. package/src/feature-flag-resolver.ts +2 -7
  40. package/src/feature-flag-watcher.ts +1 -14
  41. package/src/guardian-integrity-reporter.ts +19 -47
  42. package/src/http/middleware/auth.ts +33 -27
  43. package/src/http/routes/auth-token.ts +1 -1
  44. package/src/http/routes/devices-mine.ts +103 -0
  45. package/src/http/routes/feature-flags.ts +1 -8
  46. package/src/http/routes/guardian-pin.ts +2 -11
  47. package/src/http/routes/ipc-runtime-proxy.ts +1 -1
  48. package/src/http/routes/live-voice-websocket.ts +1 -1
  49. package/src/http/routes/runtime-audio-stream.ts +1 -1
  50. package/src/http/routes/runtime-proxy.ts +2 -2
  51. package/src/http/routes/twilio-voice-webhook.test.ts +13 -13
  52. package/src/index.ts +11 -198
  53. package/src/ipc/feature-flag-handlers.ts +2 -9
  54. package/src/remote-web/pairing-challenge-store.ts +15 -0
  55. package/src/risk/command-registry/commands/assistant.ts +0 -24
  56. package/src/risk/command-registry.test.ts +2 -4
  57. package/src/schema.ts +44 -372
  58. package/src/__tests__/auth-fallback-count-tracker.test.ts +0 -92
  59. package/src/__tests__/auth-fallback-reporter.test.ts +0 -101
  60. package/src/__tests__/backup-worker-cloud-mode.test.ts +0 -44
  61. package/src/__tests__/backup-worker-export-timeout.test.ts +0 -164
  62. package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -216
  63. package/src/__tests__/remote-feature-flag-sync.test.ts +0 -961
  64. package/src/auth-fallback-count-tracker.ts +0 -113
  65. package/src/auth-fallback-reporter.ts +0 -127
  66. package/src/backup/backup-key.ts +0 -138
  67. package/src/backup/backup-routes.ts +0 -161
  68. package/src/backup/backup-worker.ts +0 -425
  69. package/src/backup/list-snapshots.ts +0 -97
  70. package/src/backup/local-writer.ts +0 -87
  71. package/src/backup/offsite-writer.ts +0 -182
  72. package/src/backup/paths.ts +0 -113
  73. package/src/backup/stream-crypt.ts +0 -258
  74. package/src/device-id.ts +0 -18
  75. package/src/feature-flag-remote-store.ts +0 -166
  76. package/src/http/routes/migration-proxy.ts +0 -636
  77. package/src/internal-telemetry-client.ts +0 -39
  78. package/src/ipc/debug-export-handlers.test.ts +0 -102
  79. package/src/ipc/debug-export-handlers.ts +0 -81
  80. package/src/remote-feature-flag-sync.ts +0 -440
  81. package/src/watchdog-reporter.ts +0 -110
@@ -1,961 +0,0 @@
1
- import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
2
- import { mkdirSync, rmSync, writeFileSync } from "node:fs";
3
- import { join } from "node:path";
4
-
5
- import type { CredentialCache } from "../credential-cache.js";
6
-
7
- // ---------------------------------------------------------------------------
8
- // Isolated temp directory (mirrors feature-flags-route.test.ts pattern)
9
- // ---------------------------------------------------------------------------
10
- import { testSecurityDir } from "./test-preload.js";
11
-
12
- const protectedDir = testSecurityDir;
13
-
14
- // ---------------------------------------------------------------------------
15
- // Mock fetchImpl
16
- // ---------------------------------------------------------------------------
17
- type FetchFn = (
18
- input: string | URL | Request,
19
- init?: RequestInit,
20
- ) => Promise<Response>;
21
- let fetchMock: ReturnType<typeof mock<FetchFn>> = mock(
22
- async () => new Response(),
23
- );
24
-
25
- mock.module("../fetch.js", () => ({
26
- fetchImpl: (...args: Parameters<FetchFn>) => fetchMock(...args),
27
- }));
28
-
29
- // ---------------------------------------------------------------------------
30
- // Dynamic imports (after mock.module)
31
- // ---------------------------------------------------------------------------
32
- const { RemoteFeatureFlagSync } =
33
- await import("../remote-feature-flag-sync.js");
34
- const { readRemoteFeatureFlags, clearRemoteFeatureFlagStoreCache } =
35
- await import("../feature-flag-remote-store.js");
36
- const { resetFeatureFlagDefaultsCache, _setRegistryCandidateOverrides } =
37
- await import("../feature-flag-defaults.js");
38
- const { resetEnvOverridesCache } =
39
- await import("../feature-flag-env-overrides.js");
40
-
41
- // ---------------------------------------------------------------------------
42
- // Test-local registry with a GA flag (defaultEnabled: true) for the
43
- // "normalizes remote false for GA flags" test. Written to an isolated temp path
44
- // so we never touch the committed registry file.
45
- // ---------------------------------------------------------------------------
46
- const testRegistryPath = join(protectedDir, "feature-flag-registry.json");
47
-
48
- const TEST_REGISTRY = {
49
- version: 1,
50
- flags: [
51
- {
52
- id: "test-ga-flag",
53
- scope: "assistant",
54
- key: "test-ga-flag",
55
- label: "Test GA Flag",
56
- description: "A test flag that is GA (defaultEnabled: true)",
57
- defaultEnabled: true,
58
- },
59
- {
60
- id: "a2a-channel",
61
- scope: "assistant",
62
- key: "a2a-channel",
63
- label: "A2A Channel",
64
- description: "A2A channel integration",
65
- defaultEnabled: false,
66
- },
67
- ],
68
- };
69
-
70
- // ---------------------------------------------------------------------------
71
- // Helpers
72
- // ---------------------------------------------------------------------------
73
-
74
- type FakeCredentialCacheExt = CredentialCache & {
75
- _invalidate(): void;
76
- _setValues(v: Record<string, string | undefined>): void;
77
- };
78
-
79
- /**
80
- * Build a fake CredentialCache that resolves credential keys from an
81
- * in-memory map. Keys follow the `credential/{service}/{field}` format
82
- * produced by `credentialKey()`.
83
- *
84
- * Includes `onInvalidate` support and test helpers:
85
- * - `_invalidate()` — fire all registered invalidation listeners
86
- * - `_setValues(v)` — replace the in-memory credential map
87
- */
88
- function fakeCredentialCache(
89
- initialValues: Record<string, string | undefined> = {},
90
- ): FakeCredentialCacheExt {
91
- let values = { ...initialValues };
92
- const invalidateListeners = new Set<() => void>();
93
- return {
94
- get: async (key: string) => values[key],
95
- onInvalidate: (cb: () => void) => {
96
- invalidateListeners.add(cb);
97
- return () => {
98
- invalidateListeners.delete(cb);
99
- };
100
- },
101
- _invalidate: () => {
102
- for (const cb of invalidateListeners) cb();
103
- },
104
- _setValues: (v: Record<string, string | undefined>) => {
105
- values = { ...v };
106
- },
107
- } as unknown as FakeCredentialCacheExt;
108
- }
109
-
110
- function defaultCredentials(): Record<string, string> {
111
- return {
112
- "credential/cadet/assistant_api_key": "test-api-key",
113
- };
114
- }
115
-
116
- // ---------------------------------------------------------------------------
117
- // Setup / teardown
118
- // ---------------------------------------------------------------------------
119
- const savedCadetCloudUrl = process.env.CADET_CLOUD_URL;
120
- const savedAssistantCredential = process.env.ASSISTANT_API_KEY;
121
- const savedDisablePlatform = process.env.CADET_DISABLE_CLOUD;
122
-
123
- beforeEach(() => {
124
- // Clear env vars that the production code falls back to, so tests remain
125
- // deterministic unless they explicitly set them.
126
- process.env.CADET_CLOUD_URL = "https://test-cloud.mycadet.ai";
127
- delete process.env.ASSISTANT_API_KEY;
128
- delete process.env.CADET_DISABLE_CLOUD;
129
- mkdirSync(protectedDir, { recursive: true });
130
- // Write the test registry and point resolution at it
131
- writeFileSync(testRegistryPath, JSON.stringify(TEST_REGISTRY, null, 2));
132
- _setRegistryCandidateOverrides([testRegistryPath]);
133
- resetFeatureFlagDefaultsCache();
134
- resetEnvOverridesCache();
135
- clearRemoteFeatureFlagStoreCache();
136
- fetchMock = mock(async () => new Response());
137
- });
138
-
139
- afterEach(() => {
140
- // Restore env vars
141
- const restoreEnv = (key: string, saved: string | undefined): void => {
142
- if (saved === undefined) {
143
- delete process.env[key];
144
- } else {
145
- process.env[key] = saved;
146
- }
147
- };
148
- restoreEnv("CADET_CLOUD_URL", savedCadetCloudUrl);
149
- restoreEnv("ASSISTANT_API_KEY", savedAssistantCredential);
150
- restoreEnv("CADET_DISABLE_CLOUD", savedDisablePlatform);
151
- try {
152
- rmSync(protectedDir, { recursive: true, force: true });
153
- mkdirSync(protectedDir, { recursive: true });
154
- } catch {
155
- // best effort cleanup
156
- }
157
- _setRegistryCandidateOverrides(null);
158
- resetFeatureFlagDefaultsCache();
159
- resetEnvOverridesCache();
160
- clearRemoteFeatureFlagStoreCache();
161
- });
162
-
163
- // ---------------------------------------------------------------------------
164
- // Tests
165
- // ---------------------------------------------------------------------------
166
- describe("RemoteFeatureFlagSync", () => {
167
- test("skips sync when platform features are disabled", async () => {
168
- fetchMock = mock(async () => Response.json({ flags: { ff1: true } }));
169
- process.env.CADET_DISABLE_CLOUD = "true";
170
-
171
- const sync = new RemoteFeatureFlagSync({
172
- credentials: fakeCredentialCache(defaultCredentials()),
173
- });
174
- await sync.start();
175
- sync.stop();
176
-
177
- expect(fetchMock).not.toHaveBeenCalled();
178
- });
179
-
180
- test("skips sync when no platform URL is available from env", async () => {
181
- fetchMock = mock(async () => Response.json({ flags: { ff1: true } }));
182
-
183
- const creds = defaultCredentials();
184
- delete process.env.CADET_CLOUD_URL;
185
-
186
- const sync = new RemoteFeatureFlagSync({
187
- credentials: fakeCredentialCache(creds),
188
- });
189
- await sync.start();
190
- sync.stop();
191
-
192
- // No fetch calls — sync is skipped when platform URL is unavailable
193
- expect(fetchMock).not.toHaveBeenCalled();
194
- });
195
-
196
- test("uses the CADET_CLOUD_URL env var", async () => {
197
- fetchMock = mock(async () => Response.json({ flags: {} }));
198
- process.env.CADET_CLOUD_URL = "https://env-platform.example.com";
199
-
200
- const creds = defaultCredentials();
201
-
202
- const sync = new RemoteFeatureFlagSync({
203
- credentials: fakeCredentialCache(creds),
204
- });
205
- await sync.start();
206
- sync.stop();
207
-
208
- expect(fetchMock).toHaveBeenCalledTimes(1);
209
- const [url] = fetchMock.mock.calls[0];
210
- expect(url).toBe(
211
- "https://env-platform.example.com/v1/feature-flags/assistant-flag-values/",
212
- );
213
- });
214
-
215
- test("fetches without auth header when assistant_api_key is missing", async () => {
216
- fetchMock = mock(async () => Response.json({ flags: { ff1: true } }));
217
-
218
- const creds = defaultCredentials();
219
- delete creds["credential/cadet/assistant_api_key"];
220
-
221
- const sync = new RemoteFeatureFlagSync({
222
- credentials: fakeCredentialCache(creds),
223
- });
224
- await sync.start();
225
- sync.stop();
226
-
227
- expect(fetchMock).toHaveBeenCalledTimes(1);
228
- const [, init] = fetchMock.mock.calls[0];
229
- const headers = init?.headers as Record<string, string>;
230
- expect(headers.Authorization).toBeUndefined();
231
- expect(headers.Accept).toBe("application/json");
232
- });
233
-
234
- test("syncs when only cloudUrl and assistantApiKey are present", async () => {
235
- fetchMock = mock(async () => Response.json({ flags: { ff1: true } }));
236
-
237
- process.env.CADET_CLOUD_URL = "https://test-cloud.mycadet.ai";
238
- const creds = {
239
- "credential/cadet/assistant_api_key": "test-api-key",
240
- };
241
-
242
- const sync = new RemoteFeatureFlagSync({
243
- credentials: fakeCredentialCache(creds),
244
- });
245
- await sync.start();
246
- sync.stop();
247
-
248
- expect(fetchMock).toHaveBeenCalledTimes(1);
249
- });
250
-
251
- test("falls back to ASSISTANT_API_KEY env var when credential key is missing", async () => {
252
- fetchMock = mock(async () => Response.json({ flags: { ff1: true } }));
253
- process.env.ASSISTANT_API_KEY = "env-key";
254
-
255
- process.env.CADET_CLOUD_URL = "https://test-cloud.mycadet.ai";
256
- const creds = {};
257
-
258
- const sync = new RemoteFeatureFlagSync({
259
- credentials: fakeCredentialCache(creds),
260
- });
261
- await sync.start();
262
- sync.stop();
263
-
264
- expect(fetchMock).toHaveBeenCalledTimes(1);
265
- const [, init] = fetchMock.mock.calls[0];
266
- const headers = init?.headers as Record<string, string>;
267
- expect(headers.Authorization).toBe("Api-Key env-key");
268
- });
269
-
270
- test("fetches and caches flags on successful response", async () => {
271
- fetchMock = mock(async () =>
272
- Response.json({
273
- flags: { browser: true },
274
- }),
275
- );
276
-
277
- const sync = new RemoteFeatureFlagSync({
278
- credentials: fakeCredentialCache(defaultCredentials()),
279
- });
280
- await sync.start();
281
- sync.stop();
282
-
283
- expect(fetchMock).toHaveBeenCalledTimes(1);
284
-
285
- clearRemoteFeatureFlagStoreCache();
286
- const cached = readRemoteFeatureFlags();
287
- expect(cached).toEqual({ browser: true });
288
- });
289
-
290
- test("syncNow re-fetches with current credentials after an identity change (warm-pool claim)", async () => {
291
- // Regression for JARVIS-1018: a warm-pool pod that already synced flags
292
- // for a previous identity gets reassigned to a new assistant. The
293
- // credential-change wiring in index.ts calls syncNow(); it must re-fetch
294
- // with the NEW api key and overwrite the cached per-assistant flag values
295
- // immediately, rather than serving the previous identity's (or registry
296
- // default) values until the next ~5-min poll.
297
- process.env.CADET_CLOUD_URL = "https://test-cloud.mycadet.ai";
298
- const cache = fakeCredentialCache({
299
- "credential/cadet/assistant_api_key": "old-assistant-key",
300
- });
301
-
302
- // Previous identity: self-intro-greeting OFF.
303
- fetchMock = mock(async () =>
304
- Response.json({ flags: { "self-intro-greeting": false } }),
305
- );
306
-
307
- const sync = new RemoteFeatureFlagSync({ credentials: cache });
308
- await sync.start();
309
- expect(fetchMock).toHaveBeenCalledTimes(1);
310
- clearRemoteFeatureFlagStoreCache();
311
- expect(readRemoteFeatureFlags()).toEqual({ "self-intro-greeting": false });
312
-
313
- // Warm-pool claim: credentials now belong to the newly-assigned assistant,
314
- // whose flag evaluation returns self-intro-greeting ON.
315
- process.env.CADET_CLOUD_URL = "https://test-cloud.mycadet.ai";
316
- cache._setValues({
317
- "credential/cadet/assistant_api_key": "new-assistant-key",
318
- });
319
- fetchMock = mock(async () =>
320
- Response.json({ flags: { "self-intro-greeting": true } }),
321
- );
322
-
323
- await sync.syncNow();
324
- sync.stop();
325
-
326
- // Re-fetched immediately, authenticated as the new identity...
327
- expect(fetchMock).toHaveBeenCalledTimes(1);
328
- const [, init] = fetchMock.mock.calls[0];
329
- const headers = init?.headers as Record<string, string>;
330
- expect(headers.Authorization).toBe("Api-Key new-assistant-key");
331
-
332
- // ...and the cached value now reflects the new assistant.
333
- clearRemoteFeatureFlagStoreCache();
334
- expect(readRemoteFeatureFlags()).toEqual({ "self-intro-greeting": true });
335
- });
336
-
337
- test("syncNow coalesces a follow-up fetch when an identity change lands mid-sync", async () => {
338
- // JARVIS-1018 hardening: during a warm-pool claim several credential files
339
- // are written in quick succession, each firing the change handler's
340
- // syncNow(). The first fetch may be authenticated with a stale key; a
341
- // second syncNow() arriving while it is in flight must NOT be dropped by
342
- // the re-entrancy guard — it must trigger one more fetch so the final
343
- // identity's flags are cached, not the stale ones.
344
- process.env.CADET_CLOUD_URL = "https://test-cloud.mycadet.ai";
345
- const cache = fakeCredentialCache({
346
- "credential/cadet/assistant_api_key": "old-assistant-key",
347
- });
348
-
349
- let fetchCount = 0;
350
- const seenKeys: string[] = [];
351
- fetchMock = mock(async (_input: unknown, init?: RequestInit) => {
352
- fetchCount++;
353
- const headers = (init?.headers ?? {}) as Record<string, string>;
354
- seenKeys.push(headers.Authorization ?? "");
355
- // Slow fetch so the second syncNow lands while the first is in flight.
356
- await new Promise((r) => setTimeout(r, 100));
357
- const isNew = headers.Authorization === "Api-Key new-assistant-key";
358
- return Response.json({ flags: { "self-intro-greeting": isNew } });
359
- });
360
-
361
- const sync = new RemoteFeatureFlagSync({ credentials: cache });
362
- await sync.start(); // initial fetch with the old key
363
- // Reset counters so we only measure the overlapping-syncNow window.
364
- fetchCount = 0;
365
- seenKeys.length = 0;
366
-
367
- // First syncNow starts a slow fetch authenticated with the old key.
368
- const first = sync.syncNow();
369
- await new Promise((r) => setTimeout(r, 20)); // let that fetch begin
370
-
371
- // Warm-pool claim: identity changes, then a second syncNow arrives while
372
- // the first fetch is still in flight.
373
- process.env.CADET_CLOUD_URL = "https://test-cloud.mycadet.ai";
374
- cache._setValues({
375
- "credential/cadet/assistant_api_key": "new-assistant-key",
376
- });
377
- await sync.syncNow(); // returns immediately, records pendingResync
378
- await first; // first loop settles, then runs the coalesced follow-up
379
- sync.stop();
380
-
381
- // Two fetches: the original (old key) plus the coalesced follow-up (new key).
382
- expect(fetchCount).toBe(2);
383
- expect(seenKeys[0]).toBe("Api-Key old-assistant-key");
384
- expect(seenKeys[1]).toBe("Api-Key new-assistant-key");
385
-
386
- // Final cached value reflects the new identity, not the stale fetch.
387
- clearRemoteFeatureFlagStoreCache();
388
- expect(readRemoteFeatureFlags()).toEqual({ "self-intro-greeting": true });
389
- });
390
-
391
- test("preserves cached flags on non-OK response", async () => {
392
- // First, seed cached flags with a successful fetch
393
- fetchMock = mock(async () =>
394
- Response.json({
395
- flags: { browser: true },
396
- }),
397
- );
398
-
399
- const sync1 = new RemoteFeatureFlagSync({
400
- credentials: fakeCredentialCache(defaultCredentials()),
401
- });
402
- await sync1.start();
403
- sync1.stop();
404
-
405
- clearRemoteFeatureFlagStoreCache();
406
- expect(readRemoteFeatureFlags()).toEqual({
407
- browser: true,
408
- });
409
-
410
- // Now simulate a non-OK response — cached flags should be preserved
411
- fetchMock = mock(
412
- async () => new Response("Internal Server Error", { status: 500 }),
413
- );
414
-
415
- const sync2 = new RemoteFeatureFlagSync({
416
- credentials: fakeCredentialCache(defaultCredentials()),
417
- });
418
- await sync2.start();
419
- sync2.stop();
420
-
421
- expect(fetchMock).toHaveBeenCalledTimes(1);
422
-
423
- clearRemoteFeatureFlagStoreCache();
424
- const cached = readRemoteFeatureFlags();
425
- expect(cached).toEqual({ browser: true });
426
- });
427
-
428
- test("preserves cached flags on network error", async () => {
429
- // First, seed cached flags with a successful fetch
430
- fetchMock = mock(async () =>
431
- Response.json({
432
- flags: { browser: true },
433
- }),
434
- );
435
-
436
- const sync1 = new RemoteFeatureFlagSync({
437
- credentials: fakeCredentialCache(defaultCredentials()),
438
- });
439
- await sync1.start();
440
- sync1.stop();
441
-
442
- clearRemoteFeatureFlagStoreCache();
443
- expect(readRemoteFeatureFlags()).toEqual({
444
- browser: true,
445
- });
446
-
447
- // Now simulate a network error — cached flags should be preserved
448
- fetchMock = mock(async () => {
449
- throw new Error("Network failure");
450
- });
451
-
452
- const sync2 = new RemoteFeatureFlagSync({
453
- credentials: fakeCredentialCache(defaultCredentials()),
454
- });
455
- // Should not throw — errors are caught and logged
456
- await sync2.start();
457
- sync2.stop();
458
-
459
- expect(fetchMock).toHaveBeenCalledTimes(1);
460
-
461
- clearRemoteFeatureFlagStoreCache();
462
- const cached = readRemoteFeatureFlags();
463
- expect(cached).toEqual({ browser: true });
464
- });
465
-
466
- test("sends correct auth header", async () => {
467
- fetchMock = mock(async () => Response.json({ flags: {} }));
468
-
469
- const apiKey = "my-secret-key-42";
470
- const creds = {
471
- ...defaultCredentials(),
472
- "credential/cadet/assistant_api_key": apiKey,
473
- };
474
- const sync = new RemoteFeatureFlagSync({
475
- credentials: fakeCredentialCache(creds),
476
- });
477
- await sync.start();
478
- sync.stop();
479
-
480
- expect(fetchMock).toHaveBeenCalledTimes(1);
481
- const [, init] = fetchMock.mock.calls[0];
482
- const headers = init?.headers as Record<string, string>;
483
- expect(headers.Authorization).toBe(`Api-Key ${apiKey}`);
484
- });
485
-
486
- test("constructs correct URL from platform base URL", async () => {
487
- fetchMock = mock(async () => Response.json({ flags: {} }));
488
-
489
- process.env.CADET_CLOUD_URL = "https://test-cloud.mycadet.ai";
490
- const creds = { ...defaultCredentials() };
491
- const sync = new RemoteFeatureFlagSync({
492
- credentials: fakeCredentialCache(creds),
493
- });
494
- await sync.start();
495
- sync.stop();
496
-
497
- expect(fetchMock).toHaveBeenCalledTimes(1);
498
- const [url] = fetchMock.mock.calls[0];
499
- expect(url).toBe(
500
- "https://test-cloud.mycadet.ai/v1/feature-flags/assistant-flag-values/",
501
- );
502
- });
503
-
504
- test("accepts boolean and string values, filters other types from response", async () => {
505
- fetchMock = mock(async () =>
506
- Response.json({
507
- flags: {
508
- browser: true,
509
- "default-model": "claude-sonnet-4-6",
510
- other: 1 as unknown,
511
- valid: false,
512
- nullVal: null as unknown,
513
- },
514
- }),
515
- );
516
-
517
- const sync = new RemoteFeatureFlagSync({
518
- credentials: fakeCredentialCache(defaultCredentials()),
519
- });
520
- await sync.start();
521
- sync.stop();
522
-
523
- clearRemoteFeatureFlagStoreCache();
524
- const cached = readRemoteFeatureFlags();
525
- expect(cached).toEqual({
526
- browser: true,
527
- "default-model": "claude-sonnet-4-6",
528
- valid: false,
529
- });
530
- });
531
-
532
- test("preserves cached flags when response is missing flags field", async () => {
533
- // First, seed cached flags with a successful fetch
534
- fetchMock = mock(async () =>
535
- Response.json({
536
- flags: { browser: true },
537
- }),
538
- );
539
-
540
- const sync1 = new RemoteFeatureFlagSync({
541
- credentials: fakeCredentialCache(defaultCredentials()),
542
- });
543
- await sync1.start();
544
- sync1.stop();
545
-
546
- clearRemoteFeatureFlagStoreCache();
547
- expect(readRemoteFeatureFlags()).toEqual({
548
- browser: true,
549
- });
550
-
551
- // Now simulate a response with missing flags field — cached flags should be preserved
552
- fetchMock = mock(async () => Response.json({ data: "unexpected" }));
553
-
554
- const sync2 = new RemoteFeatureFlagSync({
555
- credentials: fakeCredentialCache(defaultCredentials()),
556
- });
557
- await sync2.start();
558
- sync2.stop();
559
-
560
- clearRemoteFeatureFlagStoreCache();
561
- const cached = readRemoteFeatureFlags();
562
- expect(cached).toEqual({ browser: true });
563
- });
564
-
565
- test("strips trailing slashes from platform URL", async () => {
566
- fetchMock = mock(async () => Response.json({ flags: {} }));
567
-
568
- process.env.CADET_CLOUD_URL = "https://test-cloud.mycadet.ai///";
569
- const creds = { ...defaultCredentials() };
570
- const sync = new RemoteFeatureFlagSync({
571
- credentials: fakeCredentialCache(creds),
572
- });
573
- await sync.start();
574
- sync.stop();
575
-
576
- expect(fetchMock).toHaveBeenCalledTimes(1);
577
- const [url] = fetchMock.mock.calls[0];
578
- expect(url).toBe(
579
- "https://test-cloud.mycadet.ai/v1/feature-flags/assistant-flag-values/",
580
- );
581
- });
582
-
583
- test("normalizes remote false for GA flags (defaultEnabled: true in registry)", async () => {
584
- // The cloud sends false for all flags it knows about (blanket-deny).
585
- // GA flags (defaultEnabled: true in the registry) should not be disabled
586
- // by remote overrides — only local persisted overrides can do that.
587
- // Uses the test-local registry which defines test-ga-flag as GA
588
- // (defaultEnabled: true) and a2a-channel as gated (defaultEnabled: false).
589
- fetchMock = mock(async () =>
590
- Response.json({
591
- flags: {
592
- // GA flag (defaultEnabled: true) — remote false should be normalized
593
- // to true so the missing-key fallback does not disable it.
594
- "test-ga-flag": false,
595
- // Gated flag (defaultEnabled: false) — remote false is kept
596
- "a2a-channel": false,
597
- // GA flag set to true — should be kept (redundant but harmless)
598
- "test-ga-flag-true": true,
599
- // Unknown flag — remote false is kept (not in registry)
600
- "unknown-flag": false,
601
- },
602
- }),
603
- );
604
-
605
- const sync = new RemoteFeatureFlagSync({
606
- credentials: fakeCredentialCache(defaultCredentials()),
607
- });
608
- await sync.start();
609
- sync.stop();
610
-
611
- clearRemoteFeatureFlagStoreCache();
612
- const cached = readRemoteFeatureFlags();
613
- // test-ga-flag (GA, remote false) should be normalized to true
614
- expect(cached["test-ga-flag"]).toBe(true);
615
- // a2a-channel (gated, remote false) should be present
616
- expect(cached["a2a-channel"]).toBe(false);
617
- // test-ga-flag-true (unknown but true) should be present
618
- expect(cached["test-ga-flag-true"]).toBe(true);
619
- // unknown-flag (not in registry, remote false) should be present
620
- expect(cached["unknown-flag"]).toBe(false);
621
- });
622
-
623
- test("GA normalization does not affect string flag values", async () => {
624
- fetchMock = mock(async () =>
625
- Response.json({
626
- flags: {
627
- "test-ga-flag": false,
628
- "a2a-channel": "custom-value",
629
- },
630
- }),
631
- );
632
-
633
- const sync = new RemoteFeatureFlagSync({
634
- credentials: fakeCredentialCache(defaultCredentials()),
635
- });
636
- await sync.start();
637
- sync.stop();
638
-
639
- clearRemoteFeatureFlagStoreCache();
640
- const cached = readRemoteFeatureFlags();
641
- // Boolean false for GA flag is normalized to true
642
- expect(cached["test-ga-flag"]).toBe(true);
643
- // String values are never subject to GA normalization
644
- expect(cached["a2a-channel"]).toBe("custom-value");
645
- });
646
-
647
- test("calls onChanged when remote flags change", async () => {
648
- fetchMock = mock(async () =>
649
- Response.json({
650
- flags: { "new-flag": true },
651
- }),
652
- );
653
-
654
- const onChanged = mock(() => {});
655
- const sync = new RemoteFeatureFlagSync({
656
- credentials: fakeCredentialCache(defaultCredentials()),
657
- onChanged,
658
- });
659
- await sync.start();
660
- sync.stop();
661
-
662
- expect(fetchMock).toHaveBeenCalledTimes(1);
663
- expect(onChanged).toHaveBeenCalledTimes(1);
664
- });
665
-
666
- test("does not call onChanged when remote flags have not changed", async () => {
667
- // First sync to seed the file
668
- fetchMock = mock(async () =>
669
- Response.json({
670
- flags: { "stable-flag": true },
671
- }),
672
- );
673
-
674
- const onChanged1 = mock(() => {});
675
- const sync1 = new RemoteFeatureFlagSync({
676
- credentials: fakeCredentialCache(defaultCredentials()),
677
- onChanged: onChanged1,
678
- });
679
- await sync1.start();
680
- sync1.stop();
681
- expect(onChanged1).toHaveBeenCalledTimes(1);
682
-
683
- // Second sync with same data — onChanged should NOT fire
684
- fetchMock = mock(async () =>
685
- Response.json({
686
- flags: { "stable-flag": true },
687
- }),
688
- );
689
-
690
- const onChanged2 = mock(() => {});
691
- const sync2 = new RemoteFeatureFlagSync({
692
- credentials: fakeCredentialCache(defaultCredentials()),
693
- onChanged: onChanged2,
694
- });
695
- await sync2.start();
696
- sync2.stop();
697
- expect(onChanged2).not.toHaveBeenCalled();
698
- });
699
-
700
- test("does not call onChanged on fetch failure", async () => {
701
- fetchMock = mock(
702
- async () => new Response("Internal Server Error", { status: 500 }),
703
- );
704
-
705
- const onChanged = mock(() => {});
706
- const sync = new RemoteFeatureFlagSync({
707
- credentials: fakeCredentialCache(defaultCredentials()),
708
- onChanged,
709
- });
710
- await sync.start();
711
- sync.stop();
712
-
713
- expect(onChanged).not.toHaveBeenCalled();
714
- });
715
-
716
- test("trims whitespace from credential values", async () => {
717
- fetchMock = mock(async () => Response.json({ flags: {} }));
718
-
719
- process.env.CADET_CLOUD_URL = " https://test-cloud.mycadet.ai ";
720
- const creds = {
721
- "credential/cadet/assistant_api_key": " trimmed-key ",
722
- };
723
- const sync = new RemoteFeatureFlagSync({
724
- credentials: fakeCredentialCache(creds),
725
- });
726
- await sync.start();
727
- sync.stop();
728
-
729
- expect(fetchMock).toHaveBeenCalledTimes(1);
730
- const [url, init] = fetchMock.mock.calls[0];
731
- expect(url).toBe(
732
- "https://test-cloud.mycadet.ai/v1/feature-flags/assistant-flag-values/",
733
- );
734
- const headers = init?.headers as Record<string, string>;
735
- expect(headers.Authorization).toBe("Api-Key trimmed-key");
736
- });
737
-
738
- test("polls with backoff when initial fetch fails, then snaps to steady-state on success", async () => {
739
- // Simulate: first two fetches return 500, third succeeds.
740
- let fetchCallCount = 0;
741
- fetchMock = mock(async () => {
742
- fetchCallCount++;
743
- if (fetchCallCount <= 2) {
744
- return new Response("Internal Server Error", { status: 500 });
745
- }
746
- return Response.json({ flags: { "backoff-flag": true } });
747
- });
748
-
749
- const sync = new RemoteFeatureFlagSync({
750
- credentials: fakeCredentialCache(defaultCredentials()),
751
- initialPollIntervalMs: 50,
752
- });
753
- await sync.start();
754
-
755
- // Initial fetch failed (500) — 1 call so far
756
- expect(fetchCallCount).toBe(1);
757
-
758
- // Wait for first poll (50ms) — still fails (500)
759
- await new Promise((r) => setTimeout(r, 80));
760
- expect(fetchCallCount).toBe(2);
761
-
762
- // Wait for second poll (100ms = 50ms doubled) — succeeds
763
- await new Promise((r) => setTimeout(r, 130));
764
- expect(fetchCallCount).toBe(3);
765
-
766
- clearRemoteFeatureFlagStoreCache();
767
- expect(readRemoteFeatureFlags()).toEqual({ "backoff-flag": true });
768
-
769
- sync.stop();
770
- });
771
-
772
- test("snaps to steady-state interval immediately when initial fetch succeeds", async () => {
773
- fetchMock = mock(async () => Response.json({ flags: { "ok-flag": true } }));
774
-
775
- const sync = new RemoteFeatureFlagSync({
776
- credentials: fakeCredentialCache(defaultCredentials()),
777
- initialPollIntervalMs: 50,
778
- });
779
- await sync.start();
780
-
781
- // Initial fetch succeeded — 1 call
782
- expect(fetchMock).toHaveBeenCalledTimes(1);
783
-
784
- // Wait past what would be the initial poll interval — should NOT poll
785
- // again because the interval snapped to steady-state (5 min)
786
- await new Promise((r) => setTimeout(r, 100));
787
- expect(fetchMock).toHaveBeenCalledTimes(1);
788
-
789
- sync.stop();
790
- });
791
-
792
- test("syncNow during in-flight poll does not create duplicate poll chains", async () => {
793
- // Simulate a slow fetch that takes 200ms to resolve.
794
- let fetchCallCount = 0;
795
- fetchMock = mock(async () => {
796
- fetchCallCount++;
797
- await new Promise((r) => setTimeout(r, 200));
798
- return Response.json({ flags: { ok: true } });
799
- });
800
-
801
- const sync = new RemoteFeatureFlagSync({
802
- credentials: fakeCredentialCache(defaultCredentials()),
803
- initialPollIntervalMs: 50,
804
- });
805
- await sync.start();
806
- // start() awaits its own fetchAndCache, so fetchCallCount is 1 now.
807
- expect(fetchCallCount).toBe(1);
808
-
809
- // Wait for the first poll timer to fire (50ms would be initial, but
810
- // start succeeded so it snapped to steady-state). Instead, we'll
811
- // call syncNow() directly — the interesting case is when poll() is
812
- // already in-flight. To trigger that, we use a short interval.
813
- sync.stop();
814
-
815
- // Reset with short interval to create the race:
816
- fetchCallCount = 0;
817
- fetchMock = mock(async () => {
818
- fetchCallCount++;
819
- // Slow fetch — 150ms
820
- await new Promise((r) => setTimeout(r, 150));
821
- return Response.json({ flags: { ok: true } });
822
- });
823
-
824
- const sync2 = new RemoteFeatureFlagSync({
825
- credentials: fakeCredentialCache(defaultCredentials()),
826
- initialPollIntervalMs: 30,
827
- });
828
- await sync2.start(); // 1 fetch (slow, 150ms)
829
- expect(fetchCallCount).toBe(1);
830
-
831
- // Wait for poll timer to fire and start its fetch (30ms after start)
832
- await new Promise((r) => setTimeout(r, 50));
833
- // poll() has fired and its fetchAndCache() is now in-flight
834
-
835
- // Call syncNow() while poll's fetch is in-flight
836
- const syncNowPromise = sync2.syncNow();
837
-
838
- // Wait for everything to settle
839
- await syncNowPromise;
840
- await new Promise((r) => setTimeout(r, 300));
841
-
842
- // Count how many fetches happened after the race window
843
- const fetchesDuringRace = fetchCallCount;
844
-
845
- // Now wait a bit more — if duplicate poll chains exist, we'd see
846
- // extra fetches firing at the short interval
847
- await new Promise((r) => setTimeout(r, 200));
848
-
849
- // Should NOT have extra fetches from a leaked poll chain
850
- // At most: 1 (start) + 1 (poll) + 1 (syncNow) + 1 (next scheduled poll)
851
- expect(fetchCallCount).toBeLessThanOrEqual(fetchesDuringRace + 1);
852
-
853
- sync2.stop();
854
- });
855
-
856
- test("doubles poll interval on consecutive failures", async () => {
857
- // Always fail with 500
858
- fetchMock = mock(
859
- async () => new Response("Internal Server Error", { status: 500 }),
860
- );
861
-
862
- const sync = new RemoteFeatureFlagSync({
863
- credentials: fakeCredentialCache(defaultCredentials()),
864
- initialPollIntervalMs: 50,
865
- });
866
- await sync.start();
867
-
868
- // Initial fetch failed (500) — 1 call
869
- expect(fetchMock).toHaveBeenCalledTimes(1);
870
-
871
- // After 50ms: first poll fires, still fails → interval doubles to 100ms
872
- await new Promise((r) => setTimeout(r, 80));
873
- expect(fetchMock).toHaveBeenCalledTimes(2);
874
-
875
- // After another 100ms: second poll fires, still fails → interval doubles to 200ms
876
- await new Promise((r) => setTimeout(r, 130));
877
- expect(fetchMock).toHaveBeenCalledTimes(3);
878
-
879
- // After another 200ms: third poll fires
880
- await new Promise((r) => setTimeout(r, 230));
881
- expect(fetchMock).toHaveBeenCalledTimes(4);
882
-
883
- sync.stop();
884
- });
885
-
886
- test("pauses polling when platform URL is missing and resumes on invalidation", async () => {
887
- fetchMock = mock(async () =>
888
- Response.json({ flags: { "resumed-flag": true } }),
889
- );
890
-
891
- const creds = fakeCredentialCache({
892
- "credential/cadet/assistant_api_key": "test-api-key",
893
- });
894
-
895
- const sync = new RemoteFeatureFlagSync({
896
- credentials: creds,
897
- initialPollIntervalMs: 50,
898
- });
899
- // Missing platform URL from the start — polling should pause
900
- delete process.env.CADET_CLOUD_URL;
901
- await sync.start();
902
-
903
- expect(fetchMock).not.toHaveBeenCalled();
904
-
905
- // Wait well past the initial poll interval — should still not poll
906
- await new Promise((r) => setTimeout(r, 200));
907
- expect(fetchMock).not.toHaveBeenCalled();
908
-
909
- // Simulate the cloud URL becoming available
910
- process.env.CADET_CLOUD_URL = "https://test-cloud.mycadet.ai";
911
- creds._invalidate();
912
-
913
- // Wait for syncNow to complete (async, fire-and-forget from callback)
914
- await new Promise((r) => setTimeout(r, 50));
915
-
916
- // Should have fetched once after credential invalidation
917
- expect(fetchMock).toHaveBeenCalledTimes(1);
918
-
919
- clearRemoteFeatureFlagStoreCache();
920
- expect(readRemoteFeatureFlags()).toEqual({ "resumed-flag": true });
921
-
922
- sync.stop();
923
- });
924
-
925
- test("does not downgrade to anonymous fetch when API key is transiently lost", async () => {
926
- const cache = fakeCredentialCache(defaultCredentials());
927
-
928
- // Initial authenticated fetch succeeds
929
- fetchMock = mock(async () =>
930
- Response.json({ flags: { "per-assistant-flag": true } }),
931
- );
932
-
933
- const sync = new RemoteFeatureFlagSync({
934
- credentials: cache,
935
- initialPollIntervalMs: 50,
936
- });
937
- await sync.start();
938
- expect(fetchMock).toHaveBeenCalledTimes(1);
939
-
940
- clearRemoteFeatureFlagStoreCache();
941
- expect(readRemoteFeatureFlags()).toEqual({ "per-assistant-flag": true });
942
-
943
- // Simulate transient API key loss (CES hiccup) — platform URL still present
944
- cache._setValues({});
945
- fetchMock = mock(async () =>
946
- Response.json({ flags: { "per-assistant-flag": false } }),
947
- );
948
-
949
- await sync.syncNow();
950
-
951
- // Should NOT have fetched — transient key loss triggers error/backoff,
952
- // not an anonymous fetch that would overwrite per-assistant values.
953
- expect(fetchMock).not.toHaveBeenCalled();
954
-
955
- // Cached values from the previous authenticated fetch are preserved.
956
- clearRemoteFeatureFlagStoreCache();
957
- expect(readRemoteFeatureFlags()).toEqual({ "per-assistant-flag": true });
958
-
959
- sync.stop();
960
- });
961
- });