@plosson/agentio 0.7.2 → 0.7.4

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.
@@ -0,0 +1,423 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { createOAuthStore, type PersistableOAuthState } from './oauth-store';
4
+
5
+ /**
6
+ * Pure unit tests for the OAuth store. No subprocesses, no real config
7
+ * file. The store takes an injectable `save` callback so we can capture
8
+ * persistence without touching disk, and an injectable `now` so we can
9
+ * test expiry without sleeping.
10
+ */
11
+
12
+ interface Harness {
13
+ store: ReturnType<typeof createOAuthStore>;
14
+ saved: PersistableOAuthState[];
15
+ setNow: (ms: number) => void;
16
+ }
17
+
18
+ function makeStore(
19
+ initial?: Partial<PersistableOAuthState>,
20
+ startTime = 1_700_000_000_000
21
+ ): Harness {
22
+ const saved: PersistableOAuthState[] = [];
23
+ let nowMs = startTime;
24
+ const store = createOAuthStore({
25
+ initial,
26
+ save: async (state) => {
27
+ // Deep-clone to make sure callers don't accidentally mutate the
28
+ // captured snapshot under our feet.
29
+ saved.push(JSON.parse(JSON.stringify(state)));
30
+ },
31
+ now: () => nowMs,
32
+ });
33
+ return {
34
+ store,
35
+ saved,
36
+ setNow: (ms) => {
37
+ nowMs = ms;
38
+ },
39
+ };
40
+ }
41
+
42
+ /* ------------------------------------------------------------------ */
43
+ /* clients */
44
+ /* ------------------------------------------------------------------ */
45
+
46
+ describe('OAuthStore — clients (DCR)', () => {
47
+ test('registerClient persists and returns a client_id with cli_ prefix', async () => {
48
+ const { store, saved } = makeStore();
49
+ const c = await store.registerClient({
50
+ clientName: 'Claude Code',
51
+ redirectUris: ['http://localhost:53682/callback'],
52
+ });
53
+ expect(c.clientId).toMatch(/^cli_[A-Za-z0-9_-]+$/);
54
+ // 16 random bytes base64url = 22 chars + "cli_" prefix = 26 chars
55
+ expect(c.clientId.length).toBe(26);
56
+ expect(c.clientName).toBe('Claude Code');
57
+ expect(c.redirectUris).toEqual(['http://localhost:53682/callback']);
58
+ expect(c.createdAt).toBeGreaterThan(0);
59
+ expect(saved).toHaveLength(1);
60
+ expect(saved[0].clients).toHaveLength(1);
61
+ expect(saved[0].clients[0].clientId).toBe(c.clientId);
62
+ });
63
+
64
+ test('multiple registerClient calls produce distinct client_ids', async () => {
65
+ const { store } = makeStore();
66
+ const a = await store.registerClient({ redirectUris: ['x'] });
67
+ const b = await store.registerClient({ redirectUris: ['y'] });
68
+ expect(a.clientId).not.toBe(b.clientId);
69
+ });
70
+
71
+ test('redirectUris are deep-copied (caller mutation does not leak in)', async () => {
72
+ const { store } = makeStore();
73
+ const uris = ['http://a/cb', 'http://b/cb'];
74
+ const c = await store.registerClient({ redirectUris: uris });
75
+ uris.push('http://evil/cb');
76
+ expect(c.redirectUris).toEqual(['http://a/cb', 'http://b/cb']);
77
+ });
78
+
79
+ test('findClient returns undefined for unknown id', () => {
80
+ const { store } = makeStore();
81
+ expect(store.findClient('cli_nope')).toBeUndefined();
82
+ });
83
+
84
+ test('findClient returns the registered client', async () => {
85
+ const { store } = makeStore();
86
+ const c = await store.registerClient({ redirectUris: ['x'] });
87
+ expect(store.findClient(c.clientId)).toEqual(c);
88
+ });
89
+
90
+ test('listClients returns a copy (caller cannot mutate internal state)', async () => {
91
+ const { store } = makeStore();
92
+ await store.registerClient({ redirectUris: ['x'] });
93
+ const list1 = store.listClients();
94
+ list1.push({
95
+ clientId: 'cli_injected',
96
+ redirectUris: [],
97
+ createdAt: 0,
98
+ });
99
+ const list2 = store.listClients();
100
+ expect(list2).toHaveLength(1);
101
+ });
102
+
103
+ test('initial clients from constructor are loaded', () => {
104
+ const { store } = makeStore({
105
+ clients: [
106
+ {
107
+ clientId: 'cli_preexisting',
108
+ redirectUris: ['http://x/cb'],
109
+ createdAt: 1,
110
+ },
111
+ ],
112
+ });
113
+ expect(store.findClient('cli_preexisting')).toBeDefined();
114
+ expect(store.listClients()).toHaveLength(1);
115
+ });
116
+ });
117
+
118
+ /* ------------------------------------------------------------------ */
119
+ /* tokens */
120
+ /* ------------------------------------------------------------------ */
121
+
122
+ describe('OAuthStore — tokens', () => {
123
+ test('issueToken returns a 43-char base64url token (32 random bytes)', async () => {
124
+ const { store } = makeStore();
125
+ const t = await store.issueToken({
126
+ clientId: 'cli_x',
127
+ scope: 'gchat:default',
128
+ });
129
+ expect(t.token).toMatch(/^[A-Za-z0-9_-]+$/);
130
+ // 32 bytes base64url with no padding = 43 chars
131
+ expect(t.token.length).toBe(43);
132
+ expect(t.clientId).toBe('cli_x');
133
+ expect(t.scope).toBe('gchat:default');
134
+ expect(t.expiresAt - t.issuedAt).toBe(30 * 24 * 60 * 60 * 1000);
135
+ });
136
+
137
+ test('issued tokens persist on every call', async () => {
138
+ const { store, saved } = makeStore();
139
+ await store.issueToken({ clientId: 'a', scope: 's' });
140
+ await store.issueToken({ clientId: 'b', scope: 's' });
141
+ expect(saved).toHaveLength(2);
142
+ expect(saved[1].tokens).toHaveLength(2);
143
+ });
144
+
145
+ test('findToken returns the token within its lifetime', async () => {
146
+ const { store } = makeStore();
147
+ const t = await store.issueToken({ clientId: 'x', scope: 's' });
148
+ expect(store.findToken(t.token)).toEqual(t);
149
+ });
150
+
151
+ test('findToken returns undefined for unknown values', () => {
152
+ const { store } = makeStore();
153
+ expect(store.findToken('nope')).toBeUndefined();
154
+ });
155
+
156
+ test('findToken returns undefined after expiry (now > expiresAt)', async () => {
157
+ const { store, setNow } = makeStore(undefined, 1_000_000);
158
+ const t = await store.issueToken({ clientId: 'x', scope: 's' });
159
+ expect(store.findToken(t.token)).toBeDefined();
160
+
161
+ setNow(t.expiresAt + 1);
162
+ expect(store.findToken(t.token)).toBeUndefined();
163
+ });
164
+
165
+ test('findToken does NOT auto-prune (expired token still in listTokens)', async () => {
166
+ const { store, setNow } = makeStore(undefined, 1_000_000);
167
+ const t = await store.issueToken({ clientId: 'x', scope: 's' });
168
+ setNow(t.expiresAt + 1);
169
+ expect(store.findToken(t.token)).toBeUndefined();
170
+ // Still in the underlying array — pruneExpiredTokens is the explicit GC.
171
+ expect(store.listTokens().some((x) => x.token === t.token)).toBe(true);
172
+ });
173
+
174
+ test('revokeToken returns true and persists when token existed', async () => {
175
+ const { store, saved } = makeStore();
176
+ const t = await store.issueToken({ clientId: 'x', scope: 's' });
177
+ saved.length = 0;
178
+
179
+ const removed = await store.revokeToken(t.token);
180
+ expect(removed).toBe(true);
181
+ expect(store.findToken(t.token)).toBeUndefined();
182
+ expect(saved).toHaveLength(1);
183
+ expect(saved[0].tokens).toEqual([]);
184
+ });
185
+
186
+ test('revokeToken returns false and does NOT persist when token unknown', async () => {
187
+ const { store, saved } = makeStore();
188
+ await store.issueToken({ clientId: 'x', scope: 's' });
189
+ saved.length = 0;
190
+
191
+ const removed = await store.revokeToken('does-not-exist');
192
+ expect(removed).toBe(false);
193
+ expect(saved).toHaveLength(0);
194
+ });
195
+
196
+ test('revokeAllTokens removes all tokens and returns the count', async () => {
197
+ const { store, saved } = makeStore();
198
+ await store.issueToken({ clientId: 'a', scope: 's' });
199
+ await store.issueToken({ clientId: 'b', scope: 's' });
200
+ await store.issueToken({ clientId: 'c', scope: 's' });
201
+ saved.length = 0;
202
+
203
+ const removed = await store.revokeAllTokens();
204
+ expect(removed).toBe(3);
205
+ expect(store.listTokens()).toEqual([]);
206
+ expect(saved).toHaveLength(1);
207
+ expect(saved[0].tokens).toEqual([]);
208
+ });
209
+
210
+ test('pruneExpiredTokens removes only the expired ones', async () => {
211
+ const { store, setNow } = makeStore(undefined, 1_000_000);
212
+ const fresh = await store.issueToken({ clientId: 'fresh', scope: 's' });
213
+ const stale = await store.issueToken({ clientId: 'stale', scope: 's' });
214
+
215
+ setNow(stale.expiresAt + 1);
216
+ // Both technically expired now (issued at the same time).
217
+ const removed = await store.pruneExpiredTokens();
218
+ expect(removed).toBe(2);
219
+
220
+ // Issue a new one after fast-forward — should survive.
221
+ const survivor = await store.issueToken({
222
+ clientId: 'survivor',
223
+ scope: 's',
224
+ });
225
+ expect(store.listTokens()).toEqual([survivor]);
226
+
227
+ // Confirm the original two are gone.
228
+ expect(store.findToken(fresh.token)).toBeUndefined();
229
+ expect(store.findToken(stale.token)).toBeUndefined();
230
+ });
231
+
232
+ test('pruneExpiredTokens with nothing to prune does not call save', async () => {
233
+ const { store, saved } = makeStore();
234
+ await store.issueToken({ clientId: 'x', scope: 's' });
235
+ saved.length = 0;
236
+ const removed = await store.pruneExpiredTokens();
237
+ expect(removed).toBe(0);
238
+ expect(saved).toHaveLength(0);
239
+ });
240
+
241
+ test('listTokens returns a copy', async () => {
242
+ const { store } = makeStore();
243
+ await store.issueToken({ clientId: 'x', scope: 's' });
244
+ const list = store.listTokens();
245
+ list.length = 0;
246
+ expect(store.listTokens()).toHaveLength(1);
247
+ });
248
+
249
+ test('initial tokens from constructor are loaded', () => {
250
+ const initialToken = {
251
+ token: 'preexisting-token',
252
+ clientId: 'cli_x',
253
+ scope: 's',
254
+ issuedAt: 1,
255
+ expiresAt: Number.MAX_SAFE_INTEGER,
256
+ };
257
+ const { store } = makeStore({ tokens: [initialToken] });
258
+ expect(store.findToken('preexisting-token')).toEqual(initialToken);
259
+ });
260
+ });
261
+
262
+ /* ------------------------------------------------------------------ */
263
+ /* codes */
264
+ /* ------------------------------------------------------------------ */
265
+
266
+ describe('OAuthStore — auth codes (in-memory)', () => {
267
+ test('createCode returns a 32-char base64url code', () => {
268
+ const { store } = makeStore();
269
+ const c = store.createCode({
270
+ clientId: 'cli_x',
271
+ redirectUri: 'http://localhost/cb',
272
+ codeChallenge: 'challenge',
273
+ scope: 'gchat:default',
274
+ });
275
+ expect(c.code).toMatch(/^[A-Za-z0-9_-]+$/);
276
+ // 24 random bytes base64url = 32 chars
277
+ expect(c.code.length).toBe(32);
278
+ expect(c.clientId).toBe('cli_x');
279
+ expect(c.redirectUri).toBe('http://localhost/cb');
280
+ expect(c.codeChallenge).toBe('challenge');
281
+ expect(c.scope).toBe('gchat:default');
282
+ });
283
+
284
+ test('consumeCode returns the code and removes it', () => {
285
+ const { store } = makeStore();
286
+ const c = store.createCode({
287
+ clientId: 'cli_x',
288
+ redirectUri: 'http://localhost/cb',
289
+ codeChallenge: 'ch',
290
+ scope: 's',
291
+ });
292
+
293
+ const first = store.consumeCode(c.code);
294
+ expect(first).toEqual(c);
295
+
296
+ // Second consume returns undefined — codes are one-shot.
297
+ const second = store.consumeCode(c.code);
298
+ expect(second).toBeUndefined();
299
+ });
300
+
301
+ test('consumeCode returns undefined for unknown code', () => {
302
+ const { store } = makeStore();
303
+ expect(store.consumeCode('nope')).toBeUndefined();
304
+ });
305
+
306
+ test('consumeCode returns undefined and removes the code if expired', () => {
307
+ const { store, setNow } = makeStore(undefined, 1_000_000);
308
+ const c = store.createCode({
309
+ clientId: 'cli_x',
310
+ redirectUri: 'http://localhost/cb',
311
+ codeChallenge: 'ch',
312
+ scope: 's',
313
+ });
314
+
315
+ setNow(c.expiresAt + 1);
316
+ expect(store.consumeCode(c.code)).toBeUndefined();
317
+ // Even though expired, it was deleted on the consume call.
318
+ expect(store.consumeCode(c.code)).toBeUndefined();
319
+ });
320
+
321
+ test('codes are NOT persisted (save callback never sees them)', () => {
322
+ const { store, saved } = makeStore();
323
+ store.createCode({
324
+ clientId: 'cli_x',
325
+ redirectUri: 'http://localhost/cb',
326
+ codeChallenge: 'ch',
327
+ scope: 's',
328
+ });
329
+ expect(saved).toHaveLength(0);
330
+ });
331
+
332
+ test('multiple codes can coexist independently', () => {
333
+ const { store } = makeStore();
334
+ const a = store.createCode({
335
+ clientId: 'cli_a',
336
+ redirectUri: 'http://a/cb',
337
+ codeChallenge: 'cha',
338
+ scope: 'sa',
339
+ });
340
+ const b = store.createCode({
341
+ clientId: 'cli_b',
342
+ redirectUri: 'http://b/cb',
343
+ codeChallenge: 'chb',
344
+ scope: 'sb',
345
+ });
346
+ expect(a.code).not.toBe(b.code);
347
+
348
+ // Consuming one does not affect the other.
349
+ expect(store.consumeCode(a.code)?.clientId).toBe('cli_a');
350
+ expect(store.consumeCode(b.code)?.clientId).toBe('cli_b');
351
+ });
352
+ });
353
+
354
+ /* ------------------------------------------------------------------ */
355
+ /* concurrency / mutex */
356
+ /* ------------------------------------------------------------------ */
357
+
358
+ describe('OAuthStore — concurrent mutations', () => {
359
+ test('100 parallel issueToken calls all succeed and persist exactly 100 tokens', async () => {
360
+ const { store, saved } = makeStore();
361
+ const results = await Promise.all(
362
+ Array.from({ length: 100 }, (_, i) =>
363
+ store.issueToken({ clientId: `cli_${i}`, scope: 's' })
364
+ )
365
+ );
366
+ expect(results).toHaveLength(100);
367
+ expect(new Set(results.map((t) => t.token)).size).toBe(100);
368
+ expect(store.listTokens()).toHaveLength(100);
369
+
370
+ // The final saved snapshot has all 100 tokens.
371
+ expect(saved.at(-1)?.tokens.length).toBe(100);
372
+ });
373
+
374
+ test('parallel issue + revoke do not leave the store inconsistent', async () => {
375
+ const { store } = makeStore();
376
+ const issued = await store.issueToken({ clientId: 'x', scope: 's' });
377
+
378
+ await Promise.all([
379
+ store.issueToken({ clientId: 'y', scope: 's' }),
380
+ store.revokeToken(issued.token),
381
+ store.issueToken({ clientId: 'z', scope: 's' }),
382
+ ]);
383
+
384
+ const tokens = store.listTokens();
385
+ expect(tokens).toHaveLength(2);
386
+ expect(tokens.find((t) => t.token === issued.token)).toBeUndefined();
387
+ });
388
+
389
+ test('save callback is invoked once per mutation, in order', async () => {
390
+ const { store, saved } = makeStore();
391
+ await store.registerClient({ redirectUris: ['x'] });
392
+ await store.issueToken({ clientId: 'a', scope: 's' });
393
+ await store.issueToken({ clientId: 'b', scope: 's' });
394
+ expect(saved).toHaveLength(3);
395
+ expect(saved[0].clients).toHaveLength(1);
396
+ expect(saved[0].tokens).toHaveLength(0);
397
+ expect(saved[1].tokens).toHaveLength(1);
398
+ expect(saved[2].tokens).toHaveLength(2);
399
+ });
400
+ });
401
+
402
+ /* ------------------------------------------------------------------ */
403
+ /* snapshot */
404
+ /* ------------------------------------------------------------------ */
405
+
406
+ describe('OAuthStore — snapshot', () => {
407
+ test('snapshot returns the current persistable state', async () => {
408
+ const { store } = makeStore();
409
+ await store.registerClient({ redirectUris: ['x'] });
410
+ await store.issueToken({ clientId: 'cli_x', scope: 's' });
411
+ const snap = store.snapshot();
412
+ expect(snap.clients).toHaveLength(1);
413
+ expect(snap.tokens).toHaveLength(1);
414
+ });
415
+
416
+ test('snapshot returns copies, not references', async () => {
417
+ const { store } = makeStore();
418
+ await store.issueToken({ clientId: 'cli_x', scope: 's' });
419
+ const snap = store.snapshot();
420
+ snap.tokens.length = 0;
421
+ expect(store.listTokens()).toHaveLength(1);
422
+ });
423
+ });
@@ -0,0 +1,216 @@
1
+ import { randomBytes } from 'crypto';
2
+
3
+ import type { AuthCode, OAuthClient, ServerToken } from '../types/server';
4
+
5
+ /**
6
+ * Persistent store for OAuth state. Used by the agentio HTTP MCP server to
7
+ * track DCR-registered clients, issued bearer tokens, and short-lived auth
8
+ * codes.
9
+ *
10
+ * State held by the store:
11
+ * - **clients**: persistent (passed in `initial`, written via `save`).
12
+ * - **tokens**: persistent (same).
13
+ * - **codes**: in-memory only — they live ~60 seconds during an active
14
+ * OAuth flow, so a process restart between /authorize and /token is
15
+ * acceptable cause for the user to re-run the flow.
16
+ *
17
+ * The store does not own its persistence: callers inject a `save` callback
18
+ * that knows how to write `{ clients, tokens }` somewhere durable. The
19
+ * production wiring in daemon.ts passes a callback that round-trips through
20
+ * `loadConfig` / `saveConfig` to preserve unrelated `config.server.*`
21
+ * fields. Tests can pass a no-op `save` and inspect the in-memory state
22
+ * directly.
23
+ *
24
+ * Concurrency: this is a single-process server, but two simultaneous
25
+ * in-flight OAuth flows could still race two `save()` calls. A tiny
26
+ * promise-chain mutex (`withWriteLock`) serializes all mutations.
27
+ */
28
+
29
+ const TOKEN_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
30
+ const CODE_LIFETIME_MS = 60 * 1000; // 60 seconds
31
+
32
+ export interface PersistableOAuthState {
33
+ clients: OAuthClient[];
34
+ tokens: ServerToken[];
35
+ }
36
+
37
+ export interface OAuthStoreOptions {
38
+ initial?: Partial<PersistableOAuthState>;
39
+ /**
40
+ * Persist the current `{ clients, tokens }` snapshot. Called after every
41
+ * mutation, under the write mutex. Receiving a fresh snapshot (not a
42
+ * mutation delta) keeps the contract simple.
43
+ */
44
+ save: (state: PersistableOAuthState) => Promise<void>;
45
+ /**
46
+ * Override `Date.now()` for tests that need to fast-forward time
47
+ * (e.g. expiring tokens or codes).
48
+ */
49
+ now?: () => number;
50
+ }
51
+
52
+ export interface OAuthStore {
53
+ // clients
54
+ registerClient(args: {
55
+ clientName?: string;
56
+ redirectUris: string[];
57
+ }): Promise<OAuthClient>;
58
+ findClient(clientId: string): OAuthClient | undefined;
59
+ listClients(): OAuthClient[];
60
+
61
+ // tokens
62
+ issueToken(args: { clientId: string; scope: string }): Promise<ServerToken>;
63
+ findToken(token: string): ServerToken | undefined;
64
+ revokeToken(token: string): Promise<boolean>;
65
+ revokeAllTokens(): Promise<number>;
66
+ listTokens(): ServerToken[];
67
+ pruneExpiredTokens(): Promise<number>;
68
+
69
+ // codes (in-memory)
70
+ createCode(args: {
71
+ clientId: string;
72
+ redirectUri: string;
73
+ codeChallenge: string;
74
+ scope: string;
75
+ }): AuthCode;
76
+ consumeCode(code: string): AuthCode | undefined;
77
+
78
+ // diagnostics / introspection (not for hot path)
79
+ snapshot(): PersistableOAuthState;
80
+ }
81
+
82
+ export function createOAuthStore(opts: OAuthStoreOptions): OAuthStore {
83
+ const now = opts.now ?? (() => Date.now());
84
+ let clients: OAuthClient[] = [...(opts.initial?.clients ?? [])];
85
+ let tokens: ServerToken[] = [...(opts.initial?.tokens ?? [])];
86
+ const codes = new Map<string, AuthCode>();
87
+
88
+ let writeMutex: Promise<void> = Promise.resolve();
89
+
90
+ function withWriteLock<T>(fn: () => Promise<T>): Promise<T> {
91
+ const next = writeMutex.then(fn);
92
+ writeMutex = next.then(
93
+ () => undefined,
94
+ () => undefined
95
+ );
96
+ return next;
97
+ }
98
+
99
+ function snapshot(): PersistableOAuthState {
100
+ return { clients: [...clients], tokens: [...tokens] };
101
+ }
102
+
103
+ async function persist(): Promise<void> {
104
+ await opts.save(snapshot());
105
+ }
106
+
107
+ return {
108
+ /* ---- clients ---- */
109
+
110
+ async registerClient(args) {
111
+ return withWriteLock(async () => {
112
+ const client: OAuthClient = {
113
+ clientId: `cli_${randomBytes(16).toString('base64url')}`,
114
+ clientName: args.clientName,
115
+ redirectUris: [...args.redirectUris],
116
+ createdAt: now(),
117
+ };
118
+ clients.push(client);
119
+ await persist();
120
+ return client;
121
+ });
122
+ },
123
+
124
+ findClient(clientId) {
125
+ return clients.find((c) => c.clientId === clientId);
126
+ },
127
+
128
+ listClients() {
129
+ return [...clients];
130
+ },
131
+
132
+ /* ---- tokens ---- */
133
+
134
+ async issueToken(args) {
135
+ return withWriteLock(async () => {
136
+ const issuedAt = now();
137
+ const token: ServerToken = {
138
+ token: randomBytes(32).toString('base64url'),
139
+ clientId: args.clientId,
140
+ scope: args.scope,
141
+ issuedAt,
142
+ expiresAt: issuedAt + TOKEN_LIFETIME_MS,
143
+ };
144
+ tokens.push(token);
145
+ await persist();
146
+ return token;
147
+ });
148
+ },
149
+
150
+ findToken(tokenValue) {
151
+ const t = tokens.find((x) => x.token === tokenValue);
152
+ if (!t) return undefined;
153
+ if (t.expiresAt < now()) return undefined;
154
+ return t;
155
+ },
156
+
157
+ async revokeToken(tokenValue) {
158
+ return withWriteLock(async () => {
159
+ const before = tokens.length;
160
+ tokens = tokens.filter((t) => t.token !== tokenValue);
161
+ if (tokens.length === before) return false;
162
+ await persist();
163
+ return true;
164
+ });
165
+ },
166
+
167
+ async revokeAllTokens() {
168
+ return withWriteLock(async () => {
169
+ const count = tokens.length;
170
+ tokens = [];
171
+ await persist();
172
+ return count;
173
+ });
174
+ },
175
+
176
+ listTokens() {
177
+ return [...tokens];
178
+ },
179
+
180
+ async pruneExpiredTokens() {
181
+ return withWriteLock(async () => {
182
+ const cutoff = now();
183
+ const before = tokens.length;
184
+ tokens = tokens.filter((t) => t.expiresAt >= cutoff);
185
+ const removed = before - tokens.length;
186
+ if (removed > 0) await persist();
187
+ return removed;
188
+ });
189
+ },
190
+
191
+ /* ---- codes (in-memory only) ---- */
192
+
193
+ createCode(args) {
194
+ const code: AuthCode = {
195
+ code: randomBytes(24).toString('base64url'),
196
+ clientId: args.clientId,
197
+ redirectUri: args.redirectUri,
198
+ codeChallenge: args.codeChallenge,
199
+ scope: args.scope,
200
+ expiresAt: now() + CODE_LIFETIME_MS,
201
+ };
202
+ codes.set(code.code, code);
203
+ return code;
204
+ },
205
+
206
+ consumeCode(codeValue) {
207
+ const c = codes.get(codeValue);
208
+ if (!c) return undefined;
209
+ codes.delete(codeValue);
210
+ if (c.expiresAt < now()) return undefined;
211
+ return c;
212
+ },
213
+
214
+ snapshot,
215
+ };
216
+ }