@exortek/apikey 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -32
- package/dist/index.cjs +22 -12
- package/dist/index.d.ts +2 -2
- package/dist/index.mjs +22 -12
- package/dist/middleware/core.d.ts +2 -2
- package/dist/middleware/express.cjs +22 -12
- package/dist/middleware/express.mjs +22 -12
- package/dist/middleware/fastify.cjs +109 -48
- package/dist/middleware/fastify.d.ts +1 -1
- package/dist/middleware/fastify.mjs +109 -48
- package/dist/stores.cjs +539 -325
- package/dist/stores.mjs +539 -325
- package/dist/token.d.ts +3 -3
- package/package.json +3 -12
package/dist/stores.cjs
CHANGED
|
@@ -25,27 +25,6 @@
|
|
|
25
25
|
* duplicates work the assert family already handles — don't.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
/**
|
|
29
|
-
* `true` when `value` is a plain object *or* a class instance — anything
|
|
30
|
-
* whose property access is safe. Rejects `null`, arrays, and every
|
|
31
|
-
* primitive.
|
|
32
|
-
*
|
|
33
|
-
* The Node convention `typeof x === 'object' && x !== null` accepts
|
|
34
|
-
* arrays, which almost never matches caller intent (an array is not a
|
|
35
|
-
* "config object"). This predicate additionally rules arrays out, so
|
|
36
|
-
* `isObject(x)` really means "I can read `x.foo` without exploding on
|
|
37
|
-
* a numeric index".
|
|
38
|
-
*
|
|
39
|
-
* Class instances (Node `KeyObject`, `URL`, user-defined classes) pass
|
|
40
|
-
* because they carry `typeof === 'object'` — the check is duck-typed,
|
|
41
|
-
* not prototype-restricted.
|
|
42
|
-
*
|
|
43
|
-
* @param {unknown} value
|
|
44
|
-
* @returns {boolean}
|
|
45
|
-
*/
|
|
46
|
-
function isObject(value) {
|
|
47
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
48
|
-
}
|
|
49
28
|
|
|
50
29
|
/**
|
|
51
30
|
* `true` when `value` is a string primitive. `String` boxed objects
|
|
@@ -82,6 +61,507 @@ function isUndefined(value) {
|
|
|
82
61
|
return typeof value === 'undefined';
|
|
83
62
|
}
|
|
84
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Duck-type guard for a Redis-compatible client. Every `@exortek/*`
|
|
66
|
+
* package that ships a Redis store (jwt blacklist, session store,
|
|
67
|
+
* security rate-limit) does the same "not null + required methods
|
|
68
|
+
* are functions" check up front so a missing dependency surfaces as
|
|
69
|
+
* a clean typed error instead of `TypeError: client.get is not a
|
|
70
|
+
* function` deep in a store operation.
|
|
71
|
+
*
|
|
72
|
+
* Callers pass their own `wrap` callback that throws their typed
|
|
73
|
+
* error class with the message — this file has no opinion on the
|
|
74
|
+
* error surface.
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {unknown} client
|
|
79
|
+
* @param {readonly string[]} methods Method names that must be functions on the client.
|
|
80
|
+
* @param {(message: string) => never} wrap
|
|
81
|
+
* Called with a diagnostic message when the check fails; must throw.
|
|
82
|
+
* The caller's typed error class is emitted from inside this callback.
|
|
83
|
+
* @returns {void}
|
|
84
|
+
*/
|
|
85
|
+
function assertRedisClient(client, methods, wrap) {
|
|
86
|
+
if (!client || typeof client !== 'object') {
|
|
87
|
+
wrap('client is required — pass a Redis-compatible instance (ioredis / node-redis / @upstash/redis)');
|
|
88
|
+
}
|
|
89
|
+
const c = /** @type {Record<string, unknown>} */ (client);
|
|
90
|
+
for (const method of methods) {
|
|
91
|
+
if (typeof c[method] !== 'function') {
|
|
92
|
+
wrap(`client is missing '${method}()' — is this a Redis-compatible client?`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Redis client-shape compatibility helpers.
|
|
99
|
+
*
|
|
100
|
+
* ioredis, node-redis v4+, and @upstash/redis all expose the same
|
|
101
|
+
* operations under slightly different names and calling conventions.
|
|
102
|
+
* This module creates a normalised adapter so every store in the repo
|
|
103
|
+
* can share a single detection path instead of copy-pasting the same
|
|
104
|
+
* three-way dispatch.
|
|
105
|
+
*
|
|
106
|
+
* Internal to `@exortek/shared` — consumed by `incr-store.js` and
|
|
107
|
+
* `record-store.js`, not re-exported to consuming packages.
|
|
108
|
+
*/
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @param {object} client A Redis-compatible client instance.
|
|
113
|
+
* @returns {{
|
|
114
|
+
* mget: (keys: string[]) => Promise<(string|null)[]>,
|
|
115
|
+
* sadd: (key: string, member: string) => Promise<any>,
|
|
116
|
+
* srem: (key: string, member: string) => Promise<any>,
|
|
117
|
+
* smembers: (key: string) => Promise<string[]>,
|
|
118
|
+
* setWithTTL: (key: string, value: string, ttlMs: number) => Promise<void>,
|
|
119
|
+
* setPlain: (key: string, value: string) => Promise<void>,
|
|
120
|
+
* parseRecord: (raw: string|object|null) => object|null,
|
|
121
|
+
* }}
|
|
122
|
+
*/
|
|
123
|
+
function createRedisHelpers(client) {
|
|
124
|
+
return {
|
|
125
|
+
async mget(keys) {
|
|
126
|
+
if (keys.length === 0) {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
if (isFunction(client.mget)) {
|
|
130
|
+
return client.mget(...keys);
|
|
131
|
+
}
|
|
132
|
+
if (isFunction(client.mGet)) {
|
|
133
|
+
return client.mGet(keys);
|
|
134
|
+
}
|
|
135
|
+
return Promise.all(keys.map(k => client.get(k)));
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
async sadd(key, member) {
|
|
139
|
+
if (isFunction(client.sadd)) {
|
|
140
|
+
return client.sadd(key, member);
|
|
141
|
+
}
|
|
142
|
+
if (isFunction(client.sAdd)) {
|
|
143
|
+
return client.sAdd(key, member);
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
async srem(key, member) {
|
|
149
|
+
if (isFunction(client.srem)) {
|
|
150
|
+
return client.srem(key, member);
|
|
151
|
+
}
|
|
152
|
+
if (isFunction(client.sRem)) {
|
|
153
|
+
return client.sRem(key, member);
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
async smembers(key) {
|
|
159
|
+
if (isFunction(client.smembers)) {
|
|
160
|
+
return client.smembers(key);
|
|
161
|
+
}
|
|
162
|
+
if (isFunction(client.sMembers)) {
|
|
163
|
+
return client.sMembers(key);
|
|
164
|
+
}
|
|
165
|
+
return [];
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
async setWithTTL(key, value, ttlMs) {
|
|
169
|
+
const px = Math.max(1, Math.ceil(ttlMs));
|
|
170
|
+
try {
|
|
171
|
+
await client.set(key, value, 'PX', px);
|
|
172
|
+
} catch {
|
|
173
|
+
await client.set(key, value, { PX: px });
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
async setPlain(key, value) {
|
|
178
|
+
await client.set(key, value);
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
parseRecord(raw) {
|
|
182
|
+
if (!raw) {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
return isString(raw) ? JSON.parse(raw) : raw;
|
|
187
|
+
} catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Shared indexed record store — primary key + secondary owner-index.
|
|
196
|
+
*
|
|
197
|
+
* Memory and Redis factories for the CRUD + index pattern used by
|
|
198
|
+
* `@exortek/apikey`, `@exortek/magic-link`, and `@exortek/session`.
|
|
199
|
+
*
|
|
200
|
+
* Each consuming package wraps these factories and adds its own
|
|
201
|
+
* domain-specific methods (revoke, consume, etc.). The factories
|
|
202
|
+
* provide the common put/get/update/list + index management.
|
|
203
|
+
*/
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
// Memory
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* @typedef {object} MemoryRecordStoreConfig
|
|
210
|
+
* @property {string} idField Primary key field name ('id' | 'sid').
|
|
211
|
+
* @property {string} indexField Secondary index field name ('userId' | 'email' | 'uid').
|
|
212
|
+
* @property {'shallow' | 'deep' | 'none'} [copyStrategy='shallow']
|
|
213
|
+
*/
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* @param {MemoryRecordStoreConfig} config
|
|
217
|
+
* @returns {{
|
|
218
|
+
* put: (record: object) => Promise<void>,
|
|
219
|
+
* getById: (id: string) => Promise<object|null>,
|
|
220
|
+
* update: (id: string, patch: object) => Promise<object|null>,
|
|
221
|
+
* listByIndex: (indexValue: string) => Promise<object[]>,
|
|
222
|
+
* byId: Map<string, object>,
|
|
223
|
+
* byIndex: Map<string, Set<string>>,
|
|
224
|
+
* _size: () => number,
|
|
225
|
+
* }}
|
|
226
|
+
*/
|
|
227
|
+
function createMemoryRecordStore(config) {
|
|
228
|
+
const { idField, indexField, copyStrategy = 'shallow' } = config;
|
|
229
|
+
|
|
230
|
+
/** @type {Map<string, object>} */
|
|
231
|
+
const byId = new Map();
|
|
232
|
+
/** @type {Map<string, Set<string>>} */
|
|
233
|
+
const byIndex = new Map();
|
|
234
|
+
|
|
235
|
+
function copy(record) {
|
|
236
|
+
if (copyStrategy === 'deep') {
|
|
237
|
+
return structuredClone(record);
|
|
238
|
+
}
|
|
239
|
+
if (copyStrategy === 'shallow') {
|
|
240
|
+
return { ...record };
|
|
241
|
+
}
|
|
242
|
+
return record;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function addIndex(indexValue, id) {
|
|
246
|
+
if (!isString(indexValue)) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
let set = byIndex.get(indexValue);
|
|
250
|
+
if (!set) {
|
|
251
|
+
set = new Set();
|
|
252
|
+
byIndex.set(indexValue, set);
|
|
253
|
+
}
|
|
254
|
+
set.add(id);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function removeIndex(indexValue, id) {
|
|
258
|
+
if (!isString(indexValue)) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const set = byIndex.get(indexValue);
|
|
262
|
+
if (!set) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
set.delete(id);
|
|
266
|
+
if (set.size === 0) {
|
|
267
|
+
byIndex.delete(indexValue);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
byId,
|
|
273
|
+
byIndex,
|
|
274
|
+
|
|
275
|
+
async put(record) {
|
|
276
|
+
byId.set(record[idField], copy(record));
|
|
277
|
+
addIndex(record[indexField], record[idField]);
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
async getById(id) {
|
|
281
|
+
const record = byId.get(id);
|
|
282
|
+
return record ? copy(record) : null;
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
async update(id, patch) {
|
|
286
|
+
const existing = byId.get(id);
|
|
287
|
+
if (!existing) {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
const oldIndex = existing[indexField];
|
|
291
|
+
const next = { ...existing, ...patch };
|
|
292
|
+
byId.set(id, next);
|
|
293
|
+
if (!isUndefined(patch[indexField]) && patch[indexField] !== oldIndex) {
|
|
294
|
+
removeIndex(oldIndex, id);
|
|
295
|
+
addIndex(next[indexField], id);
|
|
296
|
+
}
|
|
297
|
+
return copy(next);
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
async listByIndex(indexValue) {
|
|
301
|
+
const set = byIndex.get(indexValue);
|
|
302
|
+
if (!set) {
|
|
303
|
+
return [];
|
|
304
|
+
}
|
|
305
|
+
const out = [];
|
|
306
|
+
for (const id of set) {
|
|
307
|
+
const record = byId.get(id);
|
|
308
|
+
if (record) {
|
|
309
|
+
out.push(copy(record));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return out;
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
_size: () => byId.size,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Redis
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* @typedef {object} RedisRecordStoreConfig
|
|
323
|
+
* @property {string} idField Primary key field name.
|
|
324
|
+
* @property {string} indexField Secondary index field name.
|
|
325
|
+
* @property {string} [keyPrefix='']
|
|
326
|
+
* @property {boolean} [tombstones=false]
|
|
327
|
+
* @property {string} [tombstonePrefix='rev:']
|
|
328
|
+
* @property {string} [indexPrefix='u:']
|
|
329
|
+
* @property {boolean} [ttl=false] Whether records have TTL.
|
|
330
|
+
* @property {(record: object, parsedTomb: object) => object} [applyTombstone]
|
|
331
|
+
* Required when `tombstones: true`. Receives the parsed record and
|
|
332
|
+
* the parsed tombstone — must mutate and return the record.
|
|
333
|
+
* @property {(msg: string) => never} [wrap]
|
|
334
|
+
*/
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* @param {object} client
|
|
338
|
+
* @param {RedisRecordStoreConfig} config
|
|
339
|
+
* @returns {{
|
|
340
|
+
* put: (record: object, ttlMs?: number) => Promise<void>,
|
|
341
|
+
* getById: (id: string) => Promise<object|null>,
|
|
342
|
+
* update: (id: string, patch: object, ttlMs?: number) => Promise<object|null>,
|
|
343
|
+
* listByIndex: (indexValue: string) => Promise<object[]>,
|
|
344
|
+
* readRecord: (id: string) => Promise<object|null>,
|
|
345
|
+
* writeRecord: (record: object, ttlMs?: number) => Promise<void>,
|
|
346
|
+
* writeTombstone: (id: string, data: object, ttlMs?: number) => Promise<void>,
|
|
347
|
+
* fetchIndexRecords: (indexValue: string) => Promise<[string, object|null][]>,
|
|
348
|
+
* indexAdd: (indexValue: string, id: string) => Promise<void>,
|
|
349
|
+
* indexRemove: (indexValue: string, id: string) => Promise<void>,
|
|
350
|
+
* helpers: ReturnType<typeof createRedisHelpers>,
|
|
351
|
+
* }}
|
|
352
|
+
*/
|
|
353
|
+
function createRedisRecordStore(client, config) {
|
|
354
|
+
const wrap =
|
|
355
|
+
config.wrap ??
|
|
356
|
+
(msg => {
|
|
357
|
+
throw new TypeError(msg);
|
|
358
|
+
});
|
|
359
|
+
assertRedisClient(client, ['get', 'set', 'del'], wrap);
|
|
360
|
+
|
|
361
|
+
const {
|
|
362
|
+
idField,
|
|
363
|
+
indexField,
|
|
364
|
+
keyPrefix = '',
|
|
365
|
+
tombstones = false,
|
|
366
|
+
tombstonePrefix = 'rev:',
|
|
367
|
+
indexPrefix = 'u:',
|
|
368
|
+
ttl = false,
|
|
369
|
+
applyTombstone,
|
|
370
|
+
} = config;
|
|
371
|
+
|
|
372
|
+
const helpers = createRedisHelpers(client);
|
|
373
|
+
|
|
374
|
+
const rk = id => `${keyPrefix}${id}`;
|
|
375
|
+
const revk = id => `${keyPrefix}${tombstonePrefix}${id}`;
|
|
376
|
+
const ik = indexValue => `${keyPrefix}${indexPrefix}${indexValue}`;
|
|
377
|
+
|
|
378
|
+
function overlayTombstone(record, rawTomb) {
|
|
379
|
+
if (!record || !rawTomb || !applyTombstone) {
|
|
380
|
+
return record;
|
|
381
|
+
}
|
|
382
|
+
const tomb = helpers.parseRecord(rawTomb);
|
|
383
|
+
if (!tomb) {
|
|
384
|
+
return record;
|
|
385
|
+
}
|
|
386
|
+
return applyTombstone(record, tomb);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function readRecord(id) {
|
|
390
|
+
if (tombstones) {
|
|
391
|
+
const [rawRecord, rawTomb] = await helpers.mget([rk(id), revk(id)]);
|
|
392
|
+
const record = helpers.parseRecord(rawRecord);
|
|
393
|
+
return record ? overlayTombstone(record, rawTomb) : null;
|
|
394
|
+
}
|
|
395
|
+
return helpers.parseRecord(await client.get(rk(id)));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function writeRecord(record, ttlMs) {
|
|
399
|
+
const value = JSON.stringify(record);
|
|
400
|
+
if (ttl && ttlMs !== undefined) {
|
|
401
|
+
await helpers.setWithTTL(rk(record[idField]), value, ttlMs);
|
|
402
|
+
} else {
|
|
403
|
+
await helpers.setPlain(rk(record[idField]), value);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function writeTombstone(id, data, ttlMs) {
|
|
408
|
+
const value = JSON.stringify(data);
|
|
409
|
+
if (ttl && ttlMs !== undefined) {
|
|
410
|
+
await helpers.setWithTTL(revk(id), value, ttlMs);
|
|
411
|
+
} else {
|
|
412
|
+
await helpers.setPlain(revk(id), value);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function indexAdd(indexValue, id) {
|
|
417
|
+
await helpers.sadd(ik(indexValue), id);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function indexRemove(indexValue, id) {
|
|
421
|
+
await helpers.srem(ik(indexValue), id);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function fetchIndexRecords(indexValue) {
|
|
425
|
+
const ids = await helpers.smembers(ik(indexValue));
|
|
426
|
+
if (!ids || ids.length === 0) {
|
|
427
|
+
return [];
|
|
428
|
+
}
|
|
429
|
+
const keys = [];
|
|
430
|
+
for (const id of ids) {
|
|
431
|
+
keys.push(rk(id));
|
|
432
|
+
if (tombstones) {
|
|
433
|
+
keys.push(revk(id));
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
const rows = await helpers.mget(keys);
|
|
437
|
+
const stride = tombstones ? 2 : 1;
|
|
438
|
+
const pairs = [];
|
|
439
|
+
for (let i = 0; i < ids.length; i++) {
|
|
440
|
+
const raw = rows[i * stride];
|
|
441
|
+
const rawTomb = tombstones ? rows[i * stride + 1] : null;
|
|
442
|
+
const record = helpers.parseRecord(raw);
|
|
443
|
+
if (!record) {
|
|
444
|
+
await indexRemove(indexValue, ids[i]);
|
|
445
|
+
pairs.push([ids[i], null]);
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
pairs.push([ids[i], overlayTombstone(record, rawTomb)]);
|
|
449
|
+
}
|
|
450
|
+
return pairs;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return {
|
|
454
|
+
async put(record, ttlMs) {
|
|
455
|
+
await writeRecord(record, ttlMs);
|
|
456
|
+
if (isString(record[indexField])) {
|
|
457
|
+
await indexAdd(record[indexField], record[idField]);
|
|
458
|
+
}
|
|
459
|
+
},
|
|
460
|
+
|
|
461
|
+
async getById(id) {
|
|
462
|
+
return readRecord(id);
|
|
463
|
+
},
|
|
464
|
+
|
|
465
|
+
async update(id, patch, ttlMs) {
|
|
466
|
+
const existing = await readRecord(id);
|
|
467
|
+
if (!existing) {
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
const oldIndex = existing[indexField];
|
|
471
|
+
const next = { ...existing, ...patch };
|
|
472
|
+
await writeRecord(next, ttlMs);
|
|
473
|
+
if (!isUndefined(patch[indexField]) && patch[indexField] !== oldIndex) {
|
|
474
|
+
if (isString(oldIndex)) {
|
|
475
|
+
await indexRemove(oldIndex, id);
|
|
476
|
+
}
|
|
477
|
+
if (isString(next[indexField])) {
|
|
478
|
+
await indexAdd(next[indexField], id);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return next;
|
|
482
|
+
},
|
|
483
|
+
|
|
484
|
+
async listByIndex(indexValue) {
|
|
485
|
+
const pairs = await fetchIndexRecords(indexValue);
|
|
486
|
+
return pairs.filter(([, r]) => r !== null).map(([, r]) => r);
|
|
487
|
+
},
|
|
488
|
+
|
|
489
|
+
readRecord,
|
|
490
|
+
writeRecord,
|
|
491
|
+
writeTombstone,
|
|
492
|
+
fetchIndexRecords,
|
|
493
|
+
indexAdd,
|
|
494
|
+
indexRemove,
|
|
495
|
+
helpers,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* In-process memory store for API keys.
|
|
501
|
+
*
|
|
502
|
+
* Not cluster-safe — every worker has its own state. Use for dev,
|
|
503
|
+
* single-node deployments, and tests. For multi-worker production use
|
|
504
|
+
* {@link redisStore} or bring your own DB-backed implementation of
|
|
505
|
+
* `ApiKeyStore`.
|
|
506
|
+
*
|
|
507
|
+
* Backed by two Maps: one keyed by `id` (the primary lookup path),
|
|
508
|
+
* one keyed by `userId` holding a `Set<id>` reverse index for
|
|
509
|
+
* `listByUser` / `revokeAllForUser`. Both are pruned on write.
|
|
510
|
+
*/
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* @returns {import('../index.js').ApiKeyStore & { _size: () => number }}
|
|
515
|
+
*/
|
|
516
|
+
function memoryStore() {
|
|
517
|
+
const store = createMemoryRecordStore({
|
|
518
|
+
idField: 'id',
|
|
519
|
+
indexField: 'userId',
|
|
520
|
+
copyStrategy: 'shallow',
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
return {
|
|
524
|
+
put: record => store.put(record),
|
|
525
|
+
getById: id => store.getById(id),
|
|
526
|
+
update: (id, patch) => store.update(id, patch),
|
|
527
|
+
listByUser: userId => store.listByIndex(userId),
|
|
528
|
+
|
|
529
|
+
async revoke(id, reason) {
|
|
530
|
+
const existing = store.byId.get(id);
|
|
531
|
+
if (!existing || existing.revokedAt) {
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
existing.revokedAt = Date.now();
|
|
535
|
+
if (isString(reason)) {
|
|
536
|
+
existing.revokedReason = reason;
|
|
537
|
+
}
|
|
538
|
+
return true;
|
|
539
|
+
},
|
|
540
|
+
|
|
541
|
+
async revokeAllForUser(userId, reason) {
|
|
542
|
+
const set = store.byIndex.get(userId);
|
|
543
|
+
if (!set) {
|
|
544
|
+
return 0;
|
|
545
|
+
}
|
|
546
|
+
const now = Date.now();
|
|
547
|
+
let count = 0;
|
|
548
|
+
for (const id of set) {
|
|
549
|
+
const record = store.byId.get(id);
|
|
550
|
+
if (record && !record.revokedAt) {
|
|
551
|
+
record.revokedAt = now;
|
|
552
|
+
if (isString(reason)) {
|
|
553
|
+
record.revokedReason = reason;
|
|
554
|
+
}
|
|
555
|
+
count += 1;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return count;
|
|
559
|
+
},
|
|
560
|
+
|
|
561
|
+
_size: () => store._size(),
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
85
565
|
/**
|
|
86
566
|
* Imperative single-argument guard helpers — the everyday
|
|
87
567
|
* `assertPositiveInt(x, 'name')` shape used at API boundaries.
|
|
@@ -509,177 +989,13 @@ const { invalidArgument} = defineGuards(
|
|
|
509
989
|
ErrorCode.INVALID_ARGUMENT,
|
|
510
990
|
);
|
|
511
991
|
|
|
512
|
-
/**
|
|
513
|
-
* In-process memory store for API keys.
|
|
514
|
-
*
|
|
515
|
-
* Not cluster-safe — every worker has its own state. Use for dev,
|
|
516
|
-
* single-node deployments, and tests. For multi-worker production use
|
|
517
|
-
* {@link redisStore} or bring your own DB-backed implementation of
|
|
518
|
-
* `ApiKeyStore`.
|
|
519
|
-
*
|
|
520
|
-
* Backed by two Maps: one keyed by `id` (the primary lookup path),
|
|
521
|
-
* one keyed by `userId` holding a `Set<id>` reverse index for
|
|
522
|
-
* `listByUser` / `revokeAllForUser`. Both are pruned on write.
|
|
523
|
-
*/
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
/**
|
|
527
|
-
* @returns {import('../index.js').ApiKeyStore & { _size: () => number }}
|
|
528
|
-
*/
|
|
529
|
-
function memoryStore() {
|
|
530
|
-
/** @type {Map<string, import('../index.js').ApiKeyRecord>} */
|
|
531
|
-
const byId = new Map();
|
|
532
|
-
/** @type {Map<string, Set<string>>} */
|
|
533
|
-
const byUser = new Map();
|
|
534
|
-
|
|
535
|
-
function indexUser(uid, id) {
|
|
536
|
-
if (!isString(uid)) {
|
|
537
|
-
return;
|
|
538
|
-
}
|
|
539
|
-
let set = byUser.get(uid);
|
|
540
|
-
if (!set) {
|
|
541
|
-
set = new Set();
|
|
542
|
-
byUser.set(uid, set);
|
|
543
|
-
}
|
|
544
|
-
set.add(id);
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
function unindexUser(uid, id) {
|
|
548
|
-
if (!isString(uid)) {
|
|
549
|
-
return;
|
|
550
|
-
}
|
|
551
|
-
const set = byUser.get(uid);
|
|
552
|
-
if (!set) {
|
|
553
|
-
return;
|
|
554
|
-
}
|
|
555
|
-
set.delete(id);
|
|
556
|
-
if (set.size === 0) {
|
|
557
|
-
byUser.delete(uid);
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
return {
|
|
562
|
-
async put(record) {
|
|
563
|
-
if (!isObject(record) || !isString(record.id)) {
|
|
564
|
-
throw invalidArgument('memoryStore.put.record.id must be a string');
|
|
565
|
-
}
|
|
566
|
-
// Store a shallow copy so a caller mutating the record they
|
|
567
|
-
// passed in doesn't retroactively mutate what we've stored —
|
|
568
|
-
// matches the copy-on-read contract of getById / update.
|
|
569
|
-
byId.set(record.id, { ...record });
|
|
570
|
-
indexUser(record.userId, record.id);
|
|
571
|
-
},
|
|
572
|
-
|
|
573
|
-
async getById(id) {
|
|
574
|
-
const record = byId.get(id);
|
|
575
|
-
return record ? { ...record } : null;
|
|
576
|
-
},
|
|
577
|
-
|
|
578
|
-
async update(id, patch) {
|
|
579
|
-
const existing = byId.get(id);
|
|
580
|
-
if (!existing) {
|
|
581
|
-
return null;
|
|
582
|
-
}
|
|
583
|
-
const oldUserId = existing.userId;
|
|
584
|
-
const next = { ...existing, ...patch };
|
|
585
|
-
byId.set(id, next);
|
|
586
|
-
if (!isUndefined(patch.userId) && patch.userId !== oldUserId) {
|
|
587
|
-
unindexUser(oldUserId, id);
|
|
588
|
-
indexUser(next.userId, id);
|
|
589
|
-
}
|
|
590
|
-
return { ...next };
|
|
591
|
-
},
|
|
592
|
-
|
|
593
|
-
async revoke(id, reason) {
|
|
594
|
-
const existing = byId.get(id);
|
|
595
|
-
if (!existing || existing.revokedAt) {
|
|
596
|
-
return false;
|
|
597
|
-
}
|
|
598
|
-
existing.revokedAt = Date.now();
|
|
599
|
-
if (isString(reason)) {
|
|
600
|
-
existing.revokedReason = reason;
|
|
601
|
-
}
|
|
602
|
-
return true;
|
|
603
|
-
},
|
|
604
|
-
|
|
605
|
-
async revokeAllForUser(userId, reason) {
|
|
606
|
-
const set = byUser.get(userId);
|
|
607
|
-
if (!set) {
|
|
608
|
-
return 0;
|
|
609
|
-
}
|
|
610
|
-
const now = Date.now();
|
|
611
|
-
let count = 0;
|
|
612
|
-
for (const id of set) {
|
|
613
|
-
const record = byId.get(id);
|
|
614
|
-
if (record && !record.revokedAt) {
|
|
615
|
-
record.revokedAt = now;
|
|
616
|
-
if (isString(reason)) {
|
|
617
|
-
record.revokedReason = reason;
|
|
618
|
-
}
|
|
619
|
-
count += 1;
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
return count;
|
|
623
|
-
},
|
|
624
|
-
|
|
625
|
-
async listByUser(userId) {
|
|
626
|
-
const set = byUser.get(userId);
|
|
627
|
-
if (!set) {
|
|
628
|
-
return [];
|
|
629
|
-
}
|
|
630
|
-
const out = [];
|
|
631
|
-
for (const id of set) {
|
|
632
|
-
const record = byId.get(id);
|
|
633
|
-
if (record) {
|
|
634
|
-
out.push({ ...record });
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
return out;
|
|
638
|
-
},
|
|
639
|
-
|
|
640
|
-
_size: () => byId.size,
|
|
641
|
-
};
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
/**
|
|
645
|
-
* Duck-type guard for a Redis-compatible client. Every `@exortek/*`
|
|
646
|
-
* package that ships a Redis store (jwt blacklist, session store,
|
|
647
|
-
* security rate-limit) does the same "not null + required methods
|
|
648
|
-
* are functions" check up front so a missing dependency surfaces as
|
|
649
|
-
* a clean typed error instead of `TypeError: client.get is not a
|
|
650
|
-
* function` deep in a store operation.
|
|
651
|
-
*
|
|
652
|
-
* Callers pass their own `wrap` callback that throws their typed
|
|
653
|
-
* error class with the message — this file has no opinion on the
|
|
654
|
-
* error surface.
|
|
655
|
-
*/
|
|
656
|
-
|
|
657
|
-
/**
|
|
658
|
-
* @param {unknown} client
|
|
659
|
-
* @param {readonly string[]} methods Method names that must be functions on the client.
|
|
660
|
-
* @param {(message: string) => never} wrap
|
|
661
|
-
* Called with a diagnostic message when the check fails; must throw.
|
|
662
|
-
* The caller's typed error class is emitted from inside this callback.
|
|
663
|
-
* @returns {void}
|
|
664
|
-
*/
|
|
665
|
-
function assertRedisClient(client, methods, wrap) {
|
|
666
|
-
if (!client || typeof client !== 'object') {
|
|
667
|
-
wrap('client is required — pass a Redis-compatible instance (ioredis / node-redis / @upstash/redis)');
|
|
668
|
-
}
|
|
669
|
-
const c = /** @type {Record<string, unknown>} */ (client);
|
|
670
|
-
for (const method of methods) {
|
|
671
|
-
if (typeof c[method] !== 'function') {
|
|
672
|
-
wrap(`client is missing '${method}()' — is this a Redis-compatible client?`);
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
|
|
677
992
|
/**
|
|
678
993
|
* Redis-backed store for API keys.
|
|
679
994
|
*
|
|
680
995
|
* Layout:
|
|
681
996
|
*
|
|
682
997
|
* <keyPrefix><id> — JSON blob of the record (primary lookup)
|
|
998
|
+
* <keyPrefix>rev:<id> — revocation tombstone (see below)
|
|
683
999
|
* <keyPrefix>u:<userId> — SADD-set of the user's key ids
|
|
684
1000
|
*
|
|
685
1001
|
* Cluster-safe: state lives in Redis, so a key put on one worker is
|
|
@@ -688,9 +1004,15 @@ function assertRedisClient(client, methods, wrap) {
|
|
|
688
1004
|
* `ioredis`, `node-redis@4+`, and `@upstash/redis`.
|
|
689
1005
|
*
|
|
690
1006
|
* There is no TTL on the record — API keys are meant to be long-lived
|
|
691
|
-
* and their `expiresAt` field is authoritative.
|
|
692
|
-
*
|
|
693
|
-
* `
|
|
1007
|
+
* and their `expiresAt` field is authoritative.
|
|
1008
|
+
*
|
|
1009
|
+
* **Why tombstones:** `update()` is a read-modify-write (GET → SET), so
|
|
1010
|
+
* a concurrent `revoke()` on another worker could be overwritten by an
|
|
1011
|
+
* in-flight update carrying the pre-revoke copy — silently un-revoking
|
|
1012
|
+
* the key. Revocations are therefore written to a separate
|
|
1013
|
+
* `<keyPrefix>rev:<id>` key that no update path ever touches; `getById()`
|
|
1014
|
+
* and `listByUser()` overlay it onto the record. A revocation can never
|
|
1015
|
+
* be lost to a lost-update race.
|
|
694
1016
|
*/
|
|
695
1017
|
|
|
696
1018
|
|
|
@@ -705,174 +1027,66 @@ function assertRedisClient(client, methods, wrap) {
|
|
|
705
1027
|
* @returns {import('../index.js').ApiKeyStore}
|
|
706
1028
|
*/
|
|
707
1029
|
function redisStore(client, options = {}) {
|
|
708
|
-
assertRedisClient(client, ['get', 'set', 'del'], msg => {
|
|
709
|
-
throw invalidArgument(`redisStore.client: ${msg}`);
|
|
710
|
-
});
|
|
711
1030
|
const keyPrefix = options.keyPrefix ?? 'apikey:';
|
|
712
|
-
const rk = id => `${keyPrefix}${id}`;
|
|
713
|
-
const uk = uid => `${keyPrefix}u:${uid}`;
|
|
714
|
-
|
|
715
|
-
async function readRecord(id) {
|
|
716
|
-
const raw = await client.get(rk(id));
|
|
717
|
-
if (!raw) {
|
|
718
|
-
return null;
|
|
719
|
-
}
|
|
720
|
-
try {
|
|
721
|
-
return isString(raw) ? JSON.parse(raw) : raw;
|
|
722
|
-
} catch {
|
|
723
|
-
return null;
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
async function writeRecord(record) {
|
|
728
|
-
await client.set(rk(record.id), JSON.stringify(record));
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
async function sadd(uid, id) {
|
|
732
|
-
if (isFunction(client.sadd)) {
|
|
733
|
-
return client.sadd(uk(uid), id);
|
|
734
|
-
}
|
|
735
|
-
if (isFunction(client.sAdd)) {
|
|
736
|
-
return client.sAdd(uk(uid), id);
|
|
737
|
-
}
|
|
738
|
-
return null;
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
async function srem(uid, id) {
|
|
742
|
-
if (isFunction(client.srem)) {
|
|
743
|
-
return client.srem(uk(uid), id);
|
|
744
|
-
}
|
|
745
|
-
if (isFunction(client.sRem)) {
|
|
746
|
-
return client.sRem(uk(uid), id);
|
|
747
|
-
}
|
|
748
|
-
return null;
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
async function smembers(uid) {
|
|
752
|
-
if (isFunction(client.smembers)) {
|
|
753
|
-
return client.smembers(uk(uid));
|
|
754
|
-
}
|
|
755
|
-
if (isFunction(client.sMembers)) {
|
|
756
|
-
return client.sMembers(uk(uid));
|
|
757
|
-
}
|
|
758
|
-
return [];
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
async function mget(keys) {
|
|
762
|
-
if (keys.length === 0) {
|
|
763
|
-
return [];
|
|
764
|
-
}
|
|
765
|
-
if (isFunction(client.mget)) {
|
|
766
|
-
return client.mget(...keys);
|
|
767
|
-
}
|
|
768
|
-
if (isFunction(client.mGet)) {
|
|
769
|
-
return client.mGet(keys);
|
|
770
|
-
}
|
|
771
|
-
return Promise.all(keys.map(k => client.get(k)));
|
|
772
|
-
}
|
|
773
1031
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
1032
|
+
const store = createRedisRecordStore(client, {
|
|
1033
|
+
idField: 'id',
|
|
1034
|
+
indexField: 'userId',
|
|
1035
|
+
keyPrefix,
|
|
1036
|
+
tombstones: true,
|
|
1037
|
+
tombstonePrefix: 'rev:',
|
|
1038
|
+
indexPrefix: 'u:',
|
|
1039
|
+
applyTombstone(record, tomb) {
|
|
1040
|
+
record.revokedAt = record.revokedAt ?? Date.now();
|
|
1041
|
+
if (isString(tomb.reason) && record.revokedReason === undefined) {
|
|
1042
|
+
record.revokedReason = tomb.reason;
|
|
782
1043
|
}
|
|
1044
|
+
return record;
|
|
783
1045
|
},
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
return readRecord(id);
|
|
1046
|
+
wrap: msg => {
|
|
1047
|
+
throw invalidArgument(msg);
|
|
787
1048
|
},
|
|
1049
|
+
});
|
|
788
1050
|
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
const oldUserId = existing.userId;
|
|
795
|
-
const next = { ...existing, ...patch };
|
|
796
|
-
await writeRecord(next);
|
|
797
|
-
if (patch.userId !== undefined && patch.userId !== oldUserId) {
|
|
798
|
-
if (oldUserId) {
|
|
799
|
-
await srem(oldUserId, id);
|
|
800
|
-
}
|
|
801
|
-
if (next.userId) {
|
|
802
|
-
await sadd(next.userId, id);
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
return next;
|
|
806
|
-
},
|
|
1051
|
+
return {
|
|
1052
|
+
put: record => store.put(record),
|
|
1053
|
+
getById: id => store.getById(id),
|
|
1054
|
+
update: (id, patch) => store.update(id, patch),
|
|
1055
|
+
listByUser: userId => store.listByIndex(userId),
|
|
807
1056
|
|
|
808
1057
|
async revoke(id, reason) {
|
|
809
|
-
const existing = await readRecord(id);
|
|
1058
|
+
const existing = await store.readRecord(id);
|
|
810
1059
|
if (!existing || existing.revokedAt) {
|
|
811
1060
|
return false;
|
|
812
1061
|
}
|
|
1062
|
+
await store.writeTombstone(id, { reason: reason ?? null, at: Date.now() });
|
|
813
1063
|
existing.revokedAt = Date.now();
|
|
814
1064
|
if (isString(reason)) {
|
|
815
1065
|
existing.revokedReason = reason;
|
|
816
1066
|
}
|
|
817
|
-
await writeRecord(existing);
|
|
1067
|
+
await store.writeRecord(existing);
|
|
818
1068
|
return true;
|
|
819
1069
|
},
|
|
820
1070
|
|
|
821
1071
|
async revokeAllForUser(userId, reason) {
|
|
822
|
-
const
|
|
823
|
-
if (!ids || ids.length === 0) {
|
|
824
|
-
return 0;
|
|
825
|
-
}
|
|
826
|
-
const rows = await mget(ids.map(rk));
|
|
1072
|
+
const pairs = await store.fetchIndexRecords(userId);
|
|
827
1073
|
let count = 0;
|
|
828
1074
|
const writes = [];
|
|
829
|
-
for (
|
|
830
|
-
|
|
831
|
-
if (!raw) {
|
|
832
|
-
continue;
|
|
833
|
-
}
|
|
834
|
-
let record;
|
|
835
|
-
try {
|
|
836
|
-
record = isString(raw) ? JSON.parse(raw) : raw;
|
|
837
|
-
} catch {
|
|
838
|
-
continue;
|
|
839
|
-
}
|
|
840
|
-
if (record.revokedAt) {
|
|
1075
|
+
for (const [id, record] of pairs) {
|
|
1076
|
+
if (!record || record.revokedAt) {
|
|
841
1077
|
continue;
|
|
842
1078
|
}
|
|
843
1079
|
record.revokedAt = Date.now();
|
|
844
1080
|
if (isString(reason)) {
|
|
845
1081
|
record.revokedReason = reason;
|
|
846
1082
|
}
|
|
847
|
-
writes.push(
|
|
1083
|
+
writes.push(store.writeTombstone(id, { reason: reason ?? null, at: Date.now() }));
|
|
1084
|
+
writes.push(store.writeRecord(record));
|
|
848
1085
|
count += 1;
|
|
849
1086
|
}
|
|
850
1087
|
await Promise.all(writes);
|
|
851
1088
|
return count;
|
|
852
1089
|
},
|
|
853
|
-
|
|
854
|
-
async listByUser(userId) {
|
|
855
|
-
const ids = await smembers(userId);
|
|
856
|
-
if (!ids || ids.length === 0) {
|
|
857
|
-
return [];
|
|
858
|
-
}
|
|
859
|
-
const rows = await mget(ids.map(rk));
|
|
860
|
-
const out = [];
|
|
861
|
-
for (let i = 0; i < ids.length; i++) {
|
|
862
|
-
const raw = rows[i];
|
|
863
|
-
if (!raw) {
|
|
864
|
-
// Dead index reference — prune.
|
|
865
|
-
await srem(userId, ids[i]);
|
|
866
|
-
continue;
|
|
867
|
-
}
|
|
868
|
-
try {
|
|
869
|
-
out.push(isString(raw) ? JSON.parse(raw) : raw);
|
|
870
|
-
} catch {
|
|
871
|
-
// Skip corrupt row.
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
return out;
|
|
875
|
-
},
|
|
876
1090
|
};
|
|
877
1091
|
}
|
|
878
1092
|
|