aismemory 0.6.2 → 0.7.1

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 (42) hide show
  1. package/dist/__tests__/agent-load-persistence.test.js +194 -56
  2. package/dist/__tests__/agent-load-persistence.test.js.map +1 -1
  3. package/dist/__tests__/agent-load-session-override-e2e.test.d.ts +1 -0
  4. package/dist/__tests__/agent-load-session-override-e2e.test.js +214 -0
  5. package/dist/__tests__/agent-load-session-override-e2e.test.js.map +1 -0
  6. package/dist/__tests__/agent-preference.test.d.ts +1 -0
  7. package/dist/__tests__/agent-preference.test.js +173 -0
  8. package/dist/__tests__/agent-preference.test.js.map +1 -0
  9. package/dist/__tests__/device-flow-gate-e2e.test.d.ts +1 -0
  10. package/dist/__tests__/device-flow-gate-e2e.test.js +244 -0
  11. package/dist/__tests__/device-flow-gate-e2e.test.js.map +1 -0
  12. package/dist/__tests__/memory-write-failure.test.js +30 -0
  13. package/dist/__tests__/memory-write-failure.test.js.map +1 -1
  14. package/dist/__tests__/reconnect-survival-e2e.test.d.ts +1 -0
  15. package/dist/__tests__/reconnect-survival-e2e.test.js +248 -0
  16. package/dist/__tests__/reconnect-survival-e2e.test.js.map +1 -0
  17. package/dist/__tests__/silent-renewal-e2e.test.d.ts +1 -0
  18. package/dist/__tests__/silent-renewal-e2e.test.js +249 -0
  19. package/dist/__tests__/silent-renewal-e2e.test.js.map +1 -0
  20. package/dist/__tests__/split-brain-upgrade-e2e.test.d.ts +1 -0
  21. package/dist/__tests__/split-brain-upgrade-e2e.test.js +206 -0
  22. package/dist/__tests__/split-brain-upgrade-e2e.test.js.map +1 -0
  23. package/dist/__tests__/token-manager.test.d.ts +1 -0
  24. package/dist/__tests__/token-manager.test.js +403 -0
  25. package/dist/__tests__/token-manager.test.js.map +1 -0
  26. package/dist/__tests__/token-rejection-reauth-e2e.test.d.ts +1 -0
  27. package/dist/__tests__/token-rejection-reauth-e2e.test.js +219 -0
  28. package/dist/__tests__/token-rejection-reauth-e2e.test.js.map +1 -0
  29. package/dist/agent-preference.d.ts +59 -0
  30. package/dist/agent-preference.js +127 -0
  31. package/dist/agent-preference.js.map +1 -0
  32. package/dist/index.js +129 -51
  33. package/dist/index.js.map +1 -1
  34. package/dist/key-auth.d.ts +8 -0
  35. package/dist/key-auth.js +13 -0
  36. package/dist/key-auth.js.map +1 -1
  37. package/dist/telemetry.d.ts +1 -1
  38. package/dist/telemetry.js.map +1 -1
  39. package/dist/token-manager.d.ts +83 -0
  40. package/dist/token-manager.js +237 -0
  41. package/dist/token-manager.js.map +1 -0
  42. package/package.json +1 -1
@@ -0,0 +1,206 @@
1
+ /**
2
+ * CORBOT-48DD2E25 (F4/F5a): upgrade split-brain regression lock.
3
+ *
4
+ * Reproduces the exact upgrade scenario the adjudication traced: a home
5
+ * directory that already has BOTH an enrolled device key (from a prior
6
+ * 0.7.0 session) AND a leftover v1 credentials.json (a stale bearer that
7
+ * predates the key — the shape every home was left in by 0.6.2, or by any
8
+ * 0.7.0 session before enrollKeyIfNeeded ran).
9
+ *
10
+ * Before the fix, `main()` unconditionally pinned `runPostAuth(creds)` to
11
+ * this stale disk token at startup (index.ts, the `if (creds) {...}` block
12
+ * after the transport connects) — so `hydrateIdentity`, the memory-refresh
13
+ * timer, and auto-handoff all closed over the stale bearer, while the very
14
+ * next tool call mints and uses a DIFFERENT token via the TokenManager
15
+ * (key-auth path). Two live tokens, one session: split-brain.
16
+ *
17
+ * This test proves the stale disk bearer is never sent to AIS at all (F4),
18
+ * and that the disk file itself gets migrated to v2 (no token key) by the
19
+ * time startup post-auth would have run (F5a).
20
+ */
21
+ import { createServer } from 'node:http';
22
+ import { spawn } from 'node:child_process';
23
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from 'node:fs';
24
+ import { tmpdir } from 'node:os';
25
+ import { join, resolve } from 'path';
26
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
27
+ import { generateAndSaveKeypair } from '../key-auth.js';
28
+ const STALE_DISK_TOKEN = 'STALE-DISK-TOKEN-V1-MUST-NEVER-BE-SENT';
29
+ function mintJwt(callNumber) {
30
+ const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
31
+ const payload = Buffer.from(JSON.stringify({ sub: 'user-1', user_id: 'user-1', tenant_id: 'tenant-1', call: callNumber })).toString('base64url');
32
+ return `${header}.${payload}.fakesig-${callNumber}`;
33
+ }
34
+ describe('upgrade split-brain: stale v1 disk token never used when a device key is enrolled', () => {
35
+ let fakeAis;
36
+ let port = 0;
37
+ let authHeadersSeen = [];
38
+ let proveCalls = 0;
39
+ beforeAll(async () => {
40
+ fakeAis = createServer((req, res) => {
41
+ req.on('data', () => { });
42
+ req.on('end', () => {
43
+ const url = req.url ?? '';
44
+ const auth = req.headers['authorization'] ?? '';
45
+ if (auth)
46
+ authHeadersSeen.push(auth);
47
+ if (url === '/v1/auth/challenge' && req.method === 'POST') {
48
+ res.writeHead(200, { 'content-type': 'application/json' });
49
+ res.end(JSON.stringify({
50
+ success: true,
51
+ data: { nonce: 'test-nonce', exp: Math.floor(Date.now() / 1000) + 60, hmac: 'test-hmac' },
52
+ }));
53
+ return;
54
+ }
55
+ if (url === '/v1/auth/did-prove' && req.method === 'POST') {
56
+ proveCalls += 1;
57
+ res.writeHead(200, { 'content-type': 'application/json' });
58
+ res.end(JSON.stringify({
59
+ success: true,
60
+ data: {
61
+ bearerToken: mintJwt(proveCalls),
62
+ expiresAt: new Date(Date.now() + 3600 * 1000).toISOString(),
63
+ },
64
+ }));
65
+ return;
66
+ }
67
+ if (url.startsWith('/v1/agents') && !url.includes('/v1/agents/agent-') && req.method === 'GET') {
68
+ res.writeHead(200, { 'content-type': 'application/json' });
69
+ res.end(JSON.stringify({
70
+ success: true,
71
+ data: {
72
+ agents: [{ id: 'agent-1', name: 'Agent1', status: 'active' }],
73
+ pagination: { total: 1, page: 1, limit: 100, hasMore: false },
74
+ },
75
+ }));
76
+ return;
77
+ }
78
+ if (url === '/v1/agents/agent-1' && req.method === 'GET') {
79
+ res.writeHead(200, { 'content-type': 'application/json' });
80
+ res.end(JSON.stringify({ success: true, data: { id: 'agent-1', name: 'Agent1', status: 'active' } }));
81
+ return;
82
+ }
83
+ // hydration (/v1/agents/agent-1/full-context), memory refresh,
84
+ // handoff — generic success.
85
+ res.writeHead(200, { 'content-type': 'application/json' });
86
+ res.end(JSON.stringify({ success: true, data: {} }));
87
+ });
88
+ });
89
+ await new Promise((resolveListen) => {
90
+ fakeAis.listen(0, '127.0.0.1', () => {
91
+ const addr = fakeAis.address();
92
+ if (addr && typeof addr === 'object')
93
+ port = addr.port;
94
+ resolveListen();
95
+ });
96
+ });
97
+ });
98
+ afterAll(async () => {
99
+ await new Promise((r) => fakeAis?.close(() => r()));
100
+ });
101
+ it('startup with an enrolled key + stale v1 disk token: hydrate/refresh/handoff never carry the stale bearer, and the disk file is migrated to v2', async () => {
102
+ const distPath = resolve(__dirname, '..', '..', 'dist', 'index.js');
103
+ if (!existsSync(distPath)) {
104
+ throw new Error(`dist/index.js missing — run \`pnpm build\` first (looked at ${distPath})`);
105
+ }
106
+ authHeadersSeen = [];
107
+ proveCalls = 0;
108
+ const fakeHome = mkdtempSync(join(tmpdir(), 'aismem-split-brain-'));
109
+ const aismemDir = join(fakeHome, '.aismemory');
110
+ mkdirSync(aismemDir, { recursive: true });
111
+ // A device key is already enrolled (prior session).
112
+ await generateAndSaveKeypair({
113
+ userId: 'user-1',
114
+ userDid: 'did:web:test:users:user-1',
115
+ keysDir: join(aismemDir, 'keys'),
116
+ });
117
+ // AND a stale v1 credentials.json is still sitting on disk, valid
118
+ // (not expired) so the pre-fix code's isCredsStale() margin check
119
+ // would happily accept it as the "live" session.
120
+ const credsPath = join(aismemDir, 'credentials.json');
121
+ writeFileSync(credsPath, JSON.stringify({
122
+ token: STALE_DISK_TOKEN,
123
+ agentId: 'agent-1',
124
+ tenantId: 'tenant-1',
125
+ expiresAt: new Date(Date.now() + 3600 * 1000).toISOString(),
126
+ }));
127
+ let proc = null;
128
+ try {
129
+ proc = spawn('node', [distPath], {
130
+ env: { ...process.env, HOME: fakeHome, AIS_URL: `http://127.0.0.1:${port}` },
131
+ stdio: ['pipe', 'pipe', 'pipe'],
132
+ });
133
+ let stdoutBuf = '';
134
+ proc.stdout.on('data', (d) => { stdoutBuf += d.toString(); });
135
+ proc.stderr.on('data', () => { });
136
+ proc.stdin.write(JSON.stringify({
137
+ jsonrpc: '2.0', id: 0, method: 'initialize',
138
+ params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'split-brain-test', version: '0.0.0' } },
139
+ }) + '\n');
140
+ await waitFor(() => stdoutBuf.includes('"id":0'), 5000, 'initialize response');
141
+ // Drive one real tool call through the key-auth (TokenManager) path
142
+ // so post-auth wiring (hydrate/refresh/handoff) actually runs.
143
+ const id1 = 1;
144
+ proc.stdin.write(JSON.stringify({
145
+ jsonrpc: '2.0', id: id1, method: 'tools/call', params: { name: 'whoami', arguments: {} },
146
+ }) + '\n');
147
+ await waitFor(() => stdoutBuf.includes(`"id":${id1}`), 15000, 'first whoami response');
148
+ expect(extractResponseText(stdoutBuf, id1)).toContain('agent-1');
149
+ // Give async post-auth (enrollKeyIfNeeded no-op, hydrateIdentity,
150
+ // applyMemoryRefresh, setupAutoHandoff) a moment to run.
151
+ await new Promise((r) => setTimeout(r, 500));
152
+ // F4: the stale disk bearer must NEVER have been sent to AIS, from
153
+ // startup through post-auth — every Authorization header observed
154
+ // must be a manager-minted token, never the stale one.
155
+ const staleBearerHeader = `Bearer ${STALE_DISK_TOKEN}`;
156
+ expect(authHeadersSeen).not.toContain(staleBearerHeader);
157
+ expect(proveCalls).toBeGreaterThan(0); // the TokenManager did mint its own token
158
+ // F5a: the on-disk file must already be migrated to v2 (ids only) —
159
+ // the stale bearer does not survive past this same process start.
160
+ const rawOnDisk = readFileSync(credsPath, 'utf-8');
161
+ const parsedOnDisk = JSON.parse(rawOnDisk);
162
+ expect(parsedOnDisk.schemaVersion).toBe(2);
163
+ expect(Object.prototype.hasOwnProperty.call(parsedOnDisk, 'token')).toBe(false);
164
+ expect(rawOnDisk).not.toContain(STALE_DISK_TOKEN);
165
+ }
166
+ finally {
167
+ proc?.kill();
168
+ try {
169
+ rmSync(fakeHome, { recursive: true, force: true });
170
+ }
171
+ catch { /* ignore */ }
172
+ }
173
+ }, 30000);
174
+ });
175
+ function waitFor(predicate, ms, label) {
176
+ return new Promise((resolveOuter, reject) => {
177
+ const start = Date.now();
178
+ const tick = () => {
179
+ if (predicate()) {
180
+ resolveOuter();
181
+ return;
182
+ }
183
+ if (Date.now() - start > ms) {
184
+ reject(new Error(`timeout waiting for ${label}`));
185
+ return;
186
+ }
187
+ setTimeout(tick, 50);
188
+ };
189
+ tick();
190
+ });
191
+ }
192
+ function extractResponseText(buf, id) {
193
+ for (const line of buf.split('\n')) {
194
+ if (!line.includes(`"id":${id}`))
195
+ continue;
196
+ try {
197
+ const parsed = JSON.parse(line);
198
+ const text = parsed.result?.content?.[0]?.text;
199
+ if (typeof text === 'string')
200
+ return text;
201
+ }
202
+ catch { /* malformed line; skip */ }
203
+ }
204
+ return '';
205
+ }
206
+ //# sourceMappingURL=split-brain-upgrade-e2e.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"split-brain-upgrade-e2e.test.js","sourceRoot":"","sources":["../../src/__tests__/split-brain-upgrade-e2e.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,403 @@
1
+ import { mkdtempSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { tmpdir } from 'node:os';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { hasEnrolledKey } from '../key-auth.js';
6
+ import { createTokenManager, AisUnreachableError } from '../token-manager.js';
7
+ describe('hasEnrolledKey', () => {
8
+ it('false for empty dir, true for one v1 key, false for two keys', () => {
9
+ const dir = mkdtempSync(join(tmpdir(), 'ais-keys-'));
10
+ expect(hasEnrolledKey({ keysDir: dir })).toBe(false);
11
+ writeFileSync(join(dir, 'a.json'), JSON.stringify({ version: 1, userId: 'u', userDid: 'd', keyType: 'Ed25519', privateKey: 'x', publicKey: 'y', publicKeyJwk: {}, createdAt: 'now' }));
12
+ expect(hasEnrolledKey({ keysDir: dir })).toBe(true);
13
+ writeFileSync(join(dir, 'b.json'), JSON.stringify({ version: 1 }));
14
+ expect(hasEnrolledKey({ keysDir: dir })).toBe(false);
15
+ });
16
+ });
17
+ /**
18
+ * Controllable `authenticate` test double. Starts in 'ok' mode returning
19
+ * sequential tokens (t1, t2, t3, ...), each with a 1h TTL computed from the
20
+ * current (possibly fake) clock at call time. `setMode('fail')` makes every
21
+ * subsequent call reject until switched back.
22
+ */
23
+ function createAuthMock() {
24
+ let mode = 'ok';
25
+ let tokenIndex = 0;
26
+ let callCount = 0;
27
+ const fn = vi.fn(async () => {
28
+ callCount++;
29
+ if (mode === 'fail') {
30
+ throw new Error(`auth-failed-${callCount}`);
31
+ }
32
+ tokenIndex++;
33
+ return { token: `t${tokenIndex}`, expiresAt: new Date(Date.now() + 3_600_000).toISOString() };
34
+ });
35
+ return { fn, calls: () => callCount, setMode: (m) => { mode = m; } };
36
+ }
37
+ function createIdentityMock() {
38
+ let callCount = 0;
39
+ const fn = vi.fn(async () => {
40
+ callCount++;
41
+ return { agentId: 'agent-1', tenantId: 'tenant-1' };
42
+ });
43
+ return { fn, calls: () => callCount };
44
+ }
45
+ /**
46
+ * Advances fake timers in small steps until `predicate()` is true, so
47
+ * jittered backoff delays never need hand-calculated exact totals.
48
+ */
49
+ async function advanceUntil(predicate, stepMs = 50_000, maxSteps = 200) {
50
+ for (let i = 0; i < maxSteps; i++) {
51
+ if (predicate())
52
+ return;
53
+ await vi.advanceTimersByTimeAsync(stepMs);
54
+ }
55
+ if (!predicate()) {
56
+ throw new Error(`advanceUntil: condition not met after ${maxSteps * stepMs}ms`);
57
+ }
58
+ }
59
+ describe('createTokenManager', () => {
60
+ beforeEach(() => {
61
+ vi.useFakeTimers();
62
+ });
63
+ afterEach(() => {
64
+ vi.useRealTimers();
65
+ });
66
+ it('bullet 1: first getCredentials() authenticates + resolves identity; concurrent callers share one in-flight acquire', async () => {
67
+ const auth = createAuthMock();
68
+ const identity = createIdentityMock();
69
+ const mgr = createTokenManager({ authenticate: auth.fn, resolveIdentity: identity.fn });
70
+ const [c1, c2] = await Promise.all([mgr.getCredentials(), mgr.getCredentials()]);
71
+ expect(auth.calls()).toBe(1);
72
+ expect(identity.calls()).toBe(1);
73
+ expect(c1).toEqual(c2);
74
+ expect(c1.token).toBe('t1');
75
+ expect(c1.agentId).toBe('agent-1');
76
+ expect(c1.tenantId).toBe('tenant-1');
77
+ mgr.stop();
78
+ });
79
+ it('bullet 2: renews at 75% of TTL, swaps token/expiresAt in place, does not re-resolve identity', async () => {
80
+ const auth = createAuthMock();
81
+ const identity = createIdentityMock();
82
+ const mgr = createTokenManager({ authenticate: auth.fn, resolveIdentity: identity.fn });
83
+ const first = await mgr.getCredentials();
84
+ expect(first.token).toBe('t1');
85
+ await vi.advanceTimersByTimeAsync(2_700_000); // 75% of the 1h TTL
86
+ expect(auth.calls()).toBe(2);
87
+ const second = await mgr.getCredentials();
88
+ expect(second.token).toBe('t2');
89
+ expect(identity.calls()).toBe(1); // identity resolved only on first acquire
90
+ expect(second.agentId).toBe('agent-1');
91
+ expect(second.tenantId).toBe('tenant-1');
92
+ mgr.stop();
93
+ });
94
+ it('bullet 3: renewal failure retries with backoff, emits renew.retry, and keeps returning the current token', async () => {
95
+ const auth = createAuthMock();
96
+ const identity = createIdentityMock();
97
+ const events = [];
98
+ const mgr = createTokenManager({
99
+ authenticate: auth.fn,
100
+ resolveIdentity: identity.fn,
101
+ onEvent: (event, detail) => events.push({ event, detail }),
102
+ });
103
+ await mgr.getCredentials(); // acquire t1 (call #1)
104
+ auth.setMode('fail');
105
+ await vi.advanceTimersByTimeAsync(2_700_000); // trips the 75%-TTL renewal → call #2 fails
106
+ expect(auth.calls()).toBe(2);
107
+ expect(events.filter((e) => e.event === 'renew.retry')).toHaveLength(1);
108
+ // Current token (t1) is still returned; it has not hit hard expiry yet.
109
+ await expect(mgr.getCredentials()).resolves.toMatchObject({ token: 't1' });
110
+ await vi.advanceTimersByTimeAsync(6_500); // 5s backoff ± 20% jitter → next retry fires
111
+ expect(auth.calls()).toBe(3);
112
+ expect(events.filter((e) => e.event === 'renew.retry')).toHaveLength(2);
113
+ mgr.stop();
114
+ });
115
+ it('bullet 4: after hard expiry getCredentials() rejects with AisUnreachableError (retries continue; first success recovers with no restart)', async () => {
116
+ const auth = createAuthMock();
117
+ const identity = createIdentityMock();
118
+ const events = [];
119
+ const mgr = createTokenManager({
120
+ authenticate: auth.fn,
121
+ resolveIdentity: identity.fn,
122
+ onEvent: (event, detail) => events.push({ event, detail }),
123
+ });
124
+ await mgr.getCredentials(); // acquire t1, expiresAt = start + 1h
125
+ auth.setMode('fail');
126
+ await vi.advanceTimersByTimeAsync(2_700_000); // 75% TTL → first failed renewal (call #2)
127
+ // Advance, in small steps, through repeated backoff retries (capped at
128
+ // 300s ± 20% jitter) until the token hard-expires and the event fires.
129
+ await advanceUntil(() => events.some((e) => e.event === 'renew.hard_expiry'));
130
+ expect(events.filter((e) => e.event === 'renew.hard_expiry')).toHaveLength(1);
131
+ await expect(mgr.getCredentials()).rejects.toBeInstanceOf(AisUnreachableError);
132
+ await expect(mgr.getCredentials()).rejects.toThrow('AIS unreachable');
133
+ await expect(mgr.getCredentials()).rejects.toThrow('no action needed');
134
+ // hard_expiry must fire only once per outage even as failures continue —
135
+ // advance through at least one more capped-backoff retry attempt.
136
+ await vi.advanceTimersByTimeAsync(400_000);
137
+ expect(events.filter((e) => e.event === 'renew.hard_expiry')).toHaveLength(1);
138
+ // First success after the outage recovers getCredentials() with no restart
139
+ // (same manager instance; identity resolved only on the very first acquire).
140
+ const callsBeforeRecovery = auth.calls();
141
+ auth.setMode('ok');
142
+ await advanceUntil(() => auth.calls() > callsBeforeRecovery);
143
+ const recovered = await mgr.getCredentials();
144
+ expect(recovered.token).not.toBe('t1');
145
+ expect(recovered.agentId).toBe('agent-1');
146
+ expect(identity.calls()).toBe(1);
147
+ mgr.stop();
148
+ });
149
+ it('bullet 5: stop() clears all timers so no further renewal attempts occur', async () => {
150
+ const auth = createAuthMock();
151
+ const identity = createIdentityMock();
152
+ const mgr = createTokenManager({ authenticate: auth.fn, resolveIdentity: identity.fn });
153
+ await mgr.getCredentials();
154
+ expect(auth.calls()).toBe(1);
155
+ mgr.stop();
156
+ await vi.advanceTimersByTimeAsync(3_600_000); // well past the 75%-TTL renewal point
157
+ expect(auth.calls()).toBe(1);
158
+ });
159
+ it('reliability fix: sequential getCredentials() calls during an outage share one backoff window instead of each hammering authenticate()', async () => {
160
+ const auth = createAuthMock();
161
+ auth.setMode('fail'); // AIS is down from the very first call — no successful acquire ever happens
162
+ const identity = createIdentityMock();
163
+ const events = [];
164
+ const mgr = createTokenManager({
165
+ authenticate: auth.fn,
166
+ resolveIdentity: identity.fn,
167
+ onEvent: (event, detail) => events.push({ event, detail }),
168
+ });
169
+ // N sequential (not concurrent) calls in quick succession — no time
170
+ // advances between them, mirroring a single MCP chat turn bursting
171
+ // several tool calls while AIS is unreachable.
172
+ const N = 5;
173
+ for (let i = 0; i < N; i++) {
174
+ await expect(mgr.getCredentials()).rejects.toBeInstanceOf(AisUnreachableError);
175
+ }
176
+ // Exactly one authenticate() attempt for the whole burst, not N.
177
+ expect(auth.calls()).toBe(1);
178
+ expect(events.filter((e) => e.event === 'renew.retry')).toHaveLength(1);
179
+ // After the backoff window (5s ± 20% jitter) elapses and the server
180
+ // heals, a getCredentials() call attempts again and succeeds.
181
+ auth.setMode('ok');
182
+ await vi.advanceTimersByTimeAsync(6_500);
183
+ const recovered = await mgr.getCredentials();
184
+ expect(recovered.agentId).toBe('agent-1');
185
+ expect(auth.calls()).toBe(2);
186
+ mgr.stop();
187
+ });
188
+ it('invalidate(): forces a fresh acquire, and the new token reschedules renewal from its own expiry (not the invalidated token\'s old schedule)', async () => {
189
+ const auth = createAuthMock();
190
+ const identity = createIdentityMock();
191
+ const mgr = createTokenManager({ authenticate: auth.fn, resolveIdentity: identity.fn });
192
+ const first = await mgr.getCredentials(); // T=0: acquires t1, expires T=3_600_000
193
+ expect(first.token).toBe('t1');
194
+ expect(auth.calls()).toBe(1);
195
+ // Let time pass before invalidating so t1's original 75%-TTL renewal
196
+ // slot (T=2_700_000) and t2's fresh slot (computed from whenever the
197
+ // re-acquire actually happens) are measurably different.
198
+ await vi.advanceTimersByTimeAsync(100_000); // T=100_000
199
+ mgr.invalidate();
200
+ const second = await mgr.getCredentials(); // re-acquires t2 at T=100_000, expires T=3_700_000
201
+ expect(second.token).toBe('t2');
202
+ expect(auth.calls()).toBe(2);
203
+ expect(identity.calls()).toBe(1); // identity still resolved only once — invalidate() doesn't touch that
204
+ // t1's OLD renewal slot (absolute T=2_700_000) must not fire — it was
205
+ // superseded when the re-acquire rescheduled renewal off t2's expiry.
206
+ await vi.advanceTimersByTimeAsync(2_600_000); // now T=2_700_000
207
+ expect(auth.calls()).toBe(2);
208
+ // t2's OWN 75%-TTL slot (T=100_000 acquire + 2_700_000 = T=2_800_000) fires here.
209
+ await vi.advanceTimersByTimeAsync(100_000); // now T=2_800_000
210
+ expect(auth.calls()).toBe(3);
211
+ mgr.stop();
212
+ });
213
+ it('invalidate() during an active backoff window does not bypass the gate — the next getCredentials() still rejects fast', async () => {
214
+ const auth = createAuthMock();
215
+ const identity = createIdentityMock();
216
+ const mgr = createTokenManager({ authenticate: auth.fn, resolveIdentity: identity.fn });
217
+ await mgr.getCredentials(); // acquire t1 (call #1)
218
+ auth.setMode('fail');
219
+ await vi.advanceTimersByTimeAsync(2_700_000); // 75% TTL -> renewal fails (call #2), backoff window opens
220
+ expect(auth.calls()).toBe(2);
221
+ // t1 is still time-valid (not hard-expired) at this point — without
222
+ // invalidate(), getCredentials() would just return it via the fast path
223
+ // without ever touching the backoff gate. invalidate() drops it so the
224
+ // gate is exercised, but must NOT reset retryDelayMs/lastAttemptAt.
225
+ mgr.invalidate();
226
+ await expect(mgr.getCredentials()).rejects.toBeInstanceOf(AisUnreachableError);
227
+ expect(auth.calls()).toBe(2); // gated — no extra authenticate() attempt inside the window
228
+ mgr.stop();
229
+ });
230
+ // ─────────────────────────────────────────────────────────────────────────
231
+ // CORBOT-4F7CAA8E: TokenManager hardening (F1, F2a, F2b, F3, F7)
232
+ // ─────────────────────────────────────────────────────────────────────────
233
+ it('F2a: recordFailure runs exactly once per failed inFlight regardless of concurrent waiter count', async () => {
234
+ const auth = createAuthMock();
235
+ auth.setMode('fail'); // AIS down from the first call — cold-start failure, shared by 3 concurrent waiters
236
+ const identity = createIdentityMock();
237
+ const events = [];
238
+ const mgr = createTokenManager({
239
+ authenticate: auth.fn,
240
+ resolveIdentity: identity.fn,
241
+ onEvent: (event, detail) => events.push({ event, detail }),
242
+ });
243
+ const results = await Promise.allSettled([
244
+ mgr.getCredentials(),
245
+ mgr.getCredentials(),
246
+ mgr.getCredentials(),
247
+ ]);
248
+ for (const r of results) {
249
+ expect(r.status).toBe('rejected');
250
+ }
251
+ // One shared inFlight authenticate() attempt for all 3 waiters...
252
+ expect(auth.calls()).toBe(1);
253
+ // ...and exactly one recordFailure — NOT one per waiter (which would
254
+ // escalate retryDelayMs 5s -> 10s -> 20s from a single failure).
255
+ expect(events.filter((e) => e.event === 'renew.retry')).toHaveLength(1);
256
+ mgr.stop();
257
+ });
258
+ it('F1: renew() joins the shared inFlight instead of racing a second authenticate() when the gate window and the jittered retry timer disagree', async () => {
259
+ // Force jitter() to always draw its maximum factor (1.2x) so the
260
+ // jittered retry timer fires measurably LATER than the un-jittered
261
+ // backoff value. Before the fix, getCredentials()'s gate compared
262
+ // against the un-jittered retryDelayMs, so a call landing in that gap
263
+ // would slip past the gate and start its OWN authenticate() call while
264
+ // the background renew() timer was still pending — a second, concurrent
265
+ // authenticate() for the same outage window. The race only matters once
266
+ // the current token has hard-expired (before that the fast path shields
267
+ // every call), so this test uses a short (40s) TTL to reach hard expiry
268
+ // quickly instead of the 1h TTL used elsewhere in this file.
269
+ const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(1);
270
+ try {
271
+ const identity = createIdentityMock();
272
+ let mode = 'ok';
273
+ let callCount = 0;
274
+ const auth = vi.fn(async () => {
275
+ callCount++;
276
+ if (mode === 'fail') {
277
+ throw new Error(`auth-failed-${callCount}`);
278
+ }
279
+ return { token: `t${callCount}`, expiresAt: new Date(Date.now() + 40_000).toISOString() };
280
+ });
281
+ const mgr = createTokenManager({ authenticate: auth, resolveIdentity: identity.fn });
282
+ await mgr.getCredentials(); // t1, call #1, expires T=40_000
283
+ expect(callCount).toBe(1);
284
+ mode = 'fail';
285
+ // scheduleRenewal floors at 30s (F7), so the background renewal fires
286
+ // at T=30_000 (before t1's own T=40_000 expiry).
287
+ await vi.advanceTimersByTimeAsync(30_000); // T=30_000 -> call #2 fails; retryDelayMs=5_000, nextAttemptAt=T=36_000
288
+ expect(callCount).toBe(2);
289
+ await vi.advanceTimersByTimeAsync(6_000); // T=36_000 -> call #3 fails; retryDelayMs=10_000, nextAttemptAt=T=48_000
290
+ expect(callCount).toBe(3);
291
+ // T=47_000: t1 (expired T=40_000) is now hard-expired, so
292
+ // getCredentials() actually reaches the gate instead of the fast
293
+ // path. The OLD, buggy gate compared against the un-jittered
294
+ // retryDelayMs (36_000+10_000=46_000) and would have let this call
295
+ // through — racing the real jittered schedule (36_000 +
296
+ // jitter(10_000)=48_000 with the pinned 1.2x factor).
297
+ await vi.advanceTimersByTimeAsync(11_000); // T=47_000
298
+ await expect(mgr.getCredentials()).rejects.toBeInstanceOf(AisUnreachableError);
299
+ expect(callCount).toBe(3); // gated -- no extra authenticate() call from this call
300
+ // Cross the real jittered mark (T=48_000) -- only the background
301
+ // renew() timer makes the next attempt.
302
+ await vi.advanceTimersByTimeAsync(2_000); // T=49_000
303
+ expect(callCount).toBe(4);
304
+ mgr.stop();
305
+ }
306
+ finally {
307
+ randomSpy.mockRestore();
308
+ }
309
+ });
310
+ it('F3: a failed FIRST acquire (no current token) schedules the background retry loop, matching renew()\'s catch', async () => {
311
+ const auth = createAuthMock();
312
+ auth.setMode('fail'); // AIS down before any successful acquire ever happens
313
+ const identity = createIdentityMock();
314
+ const mgr = createTokenManager({ authenticate: auth.fn, resolveIdentity: identity.fn });
315
+ await expect(mgr.getCredentials()).rejects.toBeInstanceOf(AisUnreachableError);
316
+ expect(auth.calls()).toBe(1);
317
+ // Recovery must happen purely from the background retry loop this fix
318
+ // schedules on a cold-start failure — no further getCredentials() call
319
+ // is made here.
320
+ auth.setMode('ok');
321
+ await advanceUntil(() => auth.calls() > 1, 6_500, 5);
322
+ expect(auth.calls()).toBe(2);
323
+ mgr.stop();
324
+ });
325
+ it('F7: acquire() rejects an expiresAt in the past', async () => {
326
+ const identity = createIdentityMock();
327
+ let authCalls = 0;
328
+ const pastAuth = vi.fn(async () => {
329
+ authCalls++;
330
+ return { token: 'past-token', expiresAt: new Date(Date.now() - 1_000).toISOString() };
331
+ });
332
+ const mgr = createTokenManager({ authenticate: pastAuth, resolveIdentity: identity.fn });
333
+ await expect(mgr.getCredentials()).rejects.toBeInstanceOf(AisUnreachableError);
334
+ expect(authCalls).toBe(1);
335
+ mgr.stop();
336
+ });
337
+ it('F7: scheduleRenewal floors the renewal delay at 30s even for a short-TTL token', async () => {
338
+ const identity = createIdentityMock();
339
+ let authCalls = 0;
340
+ const shortTtlAuth = vi.fn(async () => {
341
+ authCalls++;
342
+ return { token: `s${authCalls}`, expiresAt: new Date(Date.now() + 10_000).toISOString() }; // 10s TTL
343
+ });
344
+ const mgr = createTokenManager({ authenticate: shortTtlAuth, resolveIdentity: identity.fn });
345
+ await mgr.getCredentials();
346
+ expect(authCalls).toBe(1);
347
+ // 75% of a 10s TTL is 7.5s -- without the 30s floor, renewal would
348
+ // already have fired well before this point.
349
+ await vi.advanceTimersByTimeAsync(20_000);
350
+ expect(authCalls).toBe(1);
351
+ await vi.advanceTimersByTimeAsync(10_500); // crosses the 30s floor
352
+ expect(authCalls).toBe(2);
353
+ mgr.stop();
354
+ });
355
+ it('F2b regression (escalated-backoff rotation strand): invalidate({resetBackoff:true}) after escalated backoff lets the next getCredentials() re-prove immediately', async () => {
356
+ const auth = createAuthMock();
357
+ const identity = createIdentityMock();
358
+ const mgr = createTokenManager({ authenticate: auth.fn, resolveIdentity: identity.fn });
359
+ await mgr.getCredentials(); // t1, call #1
360
+ auth.setMode('fail');
361
+ // Escalate backoff through several failed renewals: 5s -> 10s -> 20s.
362
+ await vi.advanceTimersByTimeAsync(2_700_000); // 75% TTL -> renewal fails, call #2 (retryDelayMs=5s)
363
+ expect(auth.calls()).toBe(2);
364
+ await advanceUntil(() => auth.calls() >= 3, 6_500, 5); // call #3 (retryDelayMs=10s)
365
+ await advanceUntil(() => auth.calls() >= 4, 12_500, 5); // call #4 (retryDelayMs=20s)
366
+ expect(auth.calls()).toBe(4);
367
+ // t1 is still time-valid — a plain getCredentials() would just return it
368
+ // via the fast path. Simulate the server-rejection / JWT-rotation case
369
+ // invalidate() exists for: the server (now healthy again) rejects the
370
+ // still-time-valid token mid-session.
371
+ auth.setMode('ok');
372
+ mgr.invalidate({ resetBackoff: true });
373
+ // Must re-prove IMMEDIATELY — the escalated 20s backoff window (still
374
+ // very much active by wall-clock terms) must NOT gate this call.
375
+ const recovered = await mgr.getCredentials();
376
+ expect(recovered.token).not.toBe('t1');
377
+ expect(auth.calls()).toBe(5);
378
+ mgr.stop();
379
+ });
380
+ it('acquire(): a non-finite expiresAt from authenticate() rejects instead of scheduling a near-zero renewal loop', async () => {
381
+ const identity = createIdentityMock();
382
+ let authCalls = 0;
383
+ const badAuth = vi.fn(async () => {
384
+ authCalls++;
385
+ return { token: 'bad-token', expiresAt: 'garbage' };
386
+ });
387
+ const mgr = createTokenManager({ authenticate: badAuth, resolveIdentity: identity.fn });
388
+ await expect(mgr.getCredentials()).rejects.toBeInstanceOf(AisUnreachableError);
389
+ expect(authCalls).toBe(1);
390
+ // If the bad expiresAt had scheduled a renewal timer, its NaN-derived
391
+ // delay would tight-loop. Instead: the very next call, made with no
392
+ // time advance, must be gated by the normal backoff window rather than
393
+ // firing a second authenticate() attempt immediately.
394
+ await expect(mgr.getCredentials()).rejects.toBeInstanceOf(AisUnreachableError);
395
+ expect(authCalls).toBe(1);
396
+ // Only after the backoff window elapses does a second attempt happen.
397
+ await vi.advanceTimersByTimeAsync(6_500);
398
+ await expect(mgr.getCredentials()).rejects.toBeInstanceOf(AisUnreachableError);
399
+ expect(authCalls).toBe(2);
400
+ mgr.stop();
401
+ });
402
+ });
403
+ //# sourceMappingURL=token-manager.test.js.map