@better-auth/api-key 1.7.0-beta.1 → 1.7.0-beta.10

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/dist/index.mjs CHANGED
@@ -1,5 +1,6 @@
1
- import { n as API_KEY_ERROR_CODES, t as PACKAGE_VERSION } from "./version-Cgx_4P2P.mjs";
1
+ import { n as API_KEY_ERROR_CODES, t as PACKAGE_VERSION } from "./version--lF5Z0S8.mjs";
2
2
  import { createAuthEndpoint, createAuthMiddleware } from "@better-auth/core/api";
3
+ import { getIP } from "@better-auth/core/utils/ip";
3
4
  import { base64Url } from "@better-auth/utils/base64";
4
5
  import { createHash } from "@better-auth/utils/hash";
5
6
  import { BetterAuthError } from "better-auth";
@@ -10,11 +11,11 @@ import { APIError as APIError$1 } from "@better-auth/core/error";
10
11
  import { generateId } from "@better-auth/core/utils/id";
11
12
  import { safeJSONParse } from "@better-auth/core/utils/json";
12
13
  import * as z from "zod";
13
- import { isDevelopment, isTest } from "@better-auth/core/env";
14
- import { isValidIP, normalizeIP } from "@better-auth/core/utils/ip";
14
+ import { mapConcurrent } from "@better-auth/core/utils/async";
15
15
  import { role } from "better-auth/plugins/access";
16
16
  import { parseJSON } from "better-auth/client";
17
17
  //#region src/adapter.ts
18
+ const STORAGE_CONCURRENCY = 10;
18
19
  /**
19
20
  * Parses double-stringified metadata synchronously without updating the database.
20
21
  * Use this for reading metadata, then call migrateLegacyMetadataInBackground for DB updates.
@@ -165,49 +166,79 @@ async function getApiKeyByIdFromStorage(ctx, id, storage) {
165
166
  return deserializeApiKey(await storage.get(key));
166
167
  }
167
168
  /**
168
- * Store API key in secondary storage
169
+ * Serializes reference-list mutations per `refKey` within a single process.
170
+ * Each new mutation chains onto the previous one for the same key, so the
171
+ * read/modify/write below never interleaves with another mutation of the same
172
+ * list. This closes the lost-update race in secondary-storage-only mode, where
173
+ * the serialized list is the source of truth for listing.
174
+ *
175
+ * Limitation: the lock is in-process only. Across multiple server instances
176
+ * sharing one secondary storage, concurrent writers can still lose updates.
177
+ * Secondary storage with `fallbackToDatabase` avoids this by treating the list
178
+ * as an invalidate-only cache with the database as the source of truth.
179
+ * FIXME(api-key-reflist-durable): on `next`, drop the source-of-truth reference
180
+ * list entirely and make the database authoritative for listing, removing this
181
+ * in-process lock.
182
+ */
183
+ const refListLocks = /* @__PURE__ */ new Map();
184
+ function withRefListLock(refKey, task) {
185
+ const tracked = (refListLocks.get(refKey) ?? Promise.resolve()).then(task, task).finally(() => {
186
+ if (refListLocks.get(refKey) === tracked) refListLocks.delete(refKey);
187
+ });
188
+ refListLocks.set(refKey, tracked);
189
+ return tracked;
190
+ }
191
+ /**
192
+ * Read-modify-write the ref list:
193
+ * used only when the list is the source of truth.
169
194
  */
170
- async function setApiKeyInStorage(ctx, apiKey, storage, ttl) {
195
+ async function modifyRefList(storage, refKey, modify) {
196
+ await withRefListLock(refKey, async () => {
197
+ const refListData = await storage.get(refKey);
198
+ let keyIds = [];
199
+ if (refListData && typeof refListData === "string") try {
200
+ keyIds = JSON.parse(refListData);
201
+ } catch {
202
+ keyIds = [];
203
+ }
204
+ else if (Array.isArray(refListData)) keyIds = refListData;
205
+ const next = modify(keyIds);
206
+ if (next.length === 0) await storage.delete(refKey);
207
+ else await storage.set(refKey, JSON.stringify(next));
208
+ });
209
+ }
210
+ async function setApiKeyInStorage(_ctx, apiKey, storage, ttl, opts) {
171
211
  const serialized = serializeApiKey(apiKey);
172
- const hashedKey = apiKey.key;
173
- const id = apiKey.id;
174
- await storage.set(getStorageKeyByHashedKey(hashedKey), serialized, ttl);
175
- await storage.set(getStorageKeyById(id), serialized, ttl);
176
212
  const refKey = getStorageKeyByReferenceId(apiKey.referenceId);
177
- const refListData = await storage.get(refKey);
178
- let keyIds = [];
179
- if (refListData && typeof refListData === "string") try {
180
- keyIds = JSON.parse(refListData);
181
- } catch {
182
- keyIds = [];
183
- }
184
- else if (Array.isArray(refListData)) keyIds = refListData;
185
- if (!keyIds.includes(id)) {
186
- keyIds.push(id);
187
- await storage.set(refKey, JSON.stringify(keyIds));
213
+ if (opts.fallbackToDatabase) {
214
+ await Promise.all([
215
+ storage.set(getStorageKeyByHashedKey(apiKey.key), serialized, ttl),
216
+ storage.set(getStorageKeyById(apiKey.id), serialized, ttl),
217
+ storage.delete(refKey)
218
+ ]);
219
+ return;
188
220
  }
221
+ await Promise.all([storage.set(getStorageKeyByHashedKey(apiKey.key), serialized, ttl), storage.set(getStorageKeyById(apiKey.id), serialized, ttl)]);
222
+ await modifyRefList(storage, refKey, (ids) => ids.includes(apiKey.id) ? ids : [...ids, apiKey.id]);
189
223
  }
190
224
  /**
191
225
  * Delete API key from secondary storage
192
226
  */
193
- async function deleteApiKeyFromStorage(ctx, apiKey, storage) {
194
- const hashedKey = apiKey.key;
195
- const id = apiKey.id;
196
- const referenceId = apiKey.referenceId;
197
- await storage.delete(getStorageKeyByHashedKey(hashedKey));
198
- await storage.delete(getStorageKeyById(id));
199
- const refKey = getStorageKeyByReferenceId(referenceId);
200
- const refListData = await storage.get(refKey);
201
- let keyIds = [];
202
- if (refListData && typeof refListData === "string") try {
203
- keyIds = JSON.parse(refListData);
204
- } catch {
205
- keyIds = [];
227
+ async function deleteApiKeyFromStorage(ctx, apiKey, storage, opts) {
228
+ const refKey = getStorageKeyByReferenceId(apiKey.referenceId);
229
+ if (opts.fallbackToDatabase) {
230
+ await Promise.all([
231
+ storage.delete(getStorageKeyByHashedKey(apiKey.key)),
232
+ storage.delete(getStorageKeyById(apiKey.id)),
233
+ storage.delete(refKey)
234
+ ]);
235
+ return;
206
236
  }
207
- else if (Array.isArray(refListData)) keyIds = refListData;
208
- const filteredIds = keyIds.filter((keyId) => keyId !== id);
209
- if (filteredIds.length === 0) await storage.delete(refKey);
210
- else await storage.set(refKey, JSON.stringify(filteredIds));
237
+ await Promise.all([
238
+ storage.delete(getStorageKeyByHashedKey(apiKey.key)),
239
+ storage.delete(getStorageKeyById(apiKey.id)),
240
+ modifyRefList(storage, refKey, (ids) => ids.filter((keyId) => keyId !== apiKey.id))
241
+ ]);
211
242
  }
212
243
  /**
213
244
  * Unified getter for API keys with support for all storage modes
@@ -233,7 +264,7 @@ async function getApiKey$1(ctx, hashedKey, opts) {
233
264
  value: hashedKey
234
265
  }]
235
266
  });
236
- if (dbKey && storage) await setApiKeyInStorage(ctx, dbKey, storage, calculateTTL(dbKey));
267
+ if (dbKey && storage) await setApiKeyInStorage(ctx, dbKey, storage, calculateTTL(dbKey), opts);
237
268
  return dbKey;
238
269
  }
239
270
  if (opts.storage === "secondary-storage") {
@@ -272,7 +303,7 @@ async function getApiKeyById(ctx, id, opts) {
272
303
  value: id
273
304
  }]
274
305
  });
275
- if (dbKey && storage) await setApiKeyInStorage(ctx, dbKey, storage, calculateTTL(dbKey));
306
+ if (dbKey && storage) await setApiKeyInStorage(ctx, dbKey, storage, calculateTTL(dbKey), opts);
276
307
  return dbKey;
277
308
  }
278
309
  if (opts.storage === "secondary-storage") {
@@ -296,7 +327,7 @@ async function setApiKey(ctx, apiKey, opts) {
296
327
  if (opts.storage === "database") return;
297
328
  if (opts.storage === "secondary-storage") {
298
329
  if (!storage) throw new Error("Secondary storage is required when storage mode is 'secondary-storage'");
299
- await setApiKeyInStorage(ctx, apiKey, storage, ttl);
330
+ await setApiKeyInStorage(ctx, apiKey, storage, ttl, opts);
300
331
  return;
301
332
  }
302
333
  }
@@ -308,7 +339,7 @@ async function deleteApiKey$1(ctx, apiKey, opts) {
308
339
  if (opts.storage === "database") return;
309
340
  if (opts.storage === "secondary-storage") {
310
341
  if (!storage) throw new Error("Secondary storage is required when storage mode is 'secondary-storage'");
311
- await deleteApiKeyFromStorage(ctx, apiKey, storage);
342
+ await deleteApiKeyFromStorage(ctx, apiKey, storage, opts);
312
343
  return;
313
344
  }
314
345
  }
@@ -378,11 +409,7 @@ async function listApiKeys$1(ctx, referenceId, opts, paginationOpts) {
378
409
  }
379
410
  else if (Array.isArray(refListData)) keyIds = refListData;
380
411
  if (keyIds.length > 0) {
381
- const apiKeys = [];
382
- for (const id of keyIds) {
383
- const apiKey = await getApiKeyByIdFromStorage(ctx, id, storage);
384
- if (apiKey) apiKeys.push(apiKey);
385
- }
412
+ const apiKeys = (await mapConcurrent(keyIds, (id) => getApiKeyByIdFromStorage(ctx, id, storage), { concurrency: STORAGE_CONCURRENCY })).filter((key) => key !== null && key !== void 0);
386
413
  return {
387
414
  apiKeys: applySortingAndPagination(apiKeys, sortBy, sortDirection, limit, offset),
388
415
  total: apiKeys.length
@@ -409,11 +436,8 @@ async function listApiKeys$1(ctx, referenceId, opts, paginationOpts) {
409
436
  }]
410
437
  })]);
411
438
  if (storage && dbKeys.length > 0) {
412
- const keyIds = [];
413
- for (const apiKey of dbKeys) {
414
- await setApiKeyInStorage(ctx, apiKey, storage, calculateTTL(apiKey));
415
- keyIds.push(apiKey.id);
416
- }
439
+ await mapConcurrent(dbKeys, (apiKey) => setApiKeyInStorage(ctx, apiKey, storage, calculateTTL(apiKey), opts), { concurrency: STORAGE_CONCURRENCY });
440
+ const keyIds = dbKeys.map((apiKey) => apiKey.id);
417
441
  await storage.set(refKey, JSON.stringify(keyIds));
418
442
  }
419
443
  return {
@@ -442,11 +466,7 @@ async function listApiKeys$1(ctx, referenceId, opts, paginationOpts) {
442
466
  apiKeys: [],
443
467
  total: 0
444
468
  };
445
- const apiKeys = [];
446
- for (const id of keyIds) {
447
- const apiKey = await getApiKeyByIdFromStorage(ctx, id, storage);
448
- if (apiKey) apiKeys.push(apiKey);
449
- }
469
+ const apiKeys = (await mapConcurrent(keyIds, (id) => getApiKeyByIdFromStorage(ctx, id, storage), { concurrency: STORAGE_CONCURRENCY })).filter((key) => key !== null && key !== void 0);
450
470
  return {
451
471
  apiKeys: applySortingAndPagination(apiKeys, sortBy, sortDirection, limit, offset),
452
472
  total: apiKeys.length
@@ -555,21 +575,6 @@ const getDate = (span, unit = "ms") => {
555
575
  function isAPIError(error) {
556
576
  return error instanceof APIError || error instanceof APIError$1 || error?.name === "APIError";
557
577
  }
558
- const LOCALHOST_IP = "127.0.0.1";
559
- function getIp(req, options) {
560
- if (options.advanced?.ipAddress?.disableIpTracking) return null;
561
- const headers = "headers" in req ? req.headers : req;
562
- const ipHeaders = options.advanced?.ipAddress?.ipAddressHeaders || ["x-forwarded-for"];
563
- for (const key of ipHeaders) {
564
- const value = "get" in headers ? headers.get(key) : headers[key];
565
- if (typeof value === "string") {
566
- const ip = value.split(",")[0].trim();
567
- if (isValidIP(ip)) return normalizeIP(ip, { ipv6Subnet: options.advanced?.ipAddress?.ipv6Subnet });
568
- }
569
- }
570
- if (isTest() || isDevelopment()) return LOCALHOST_IP;
571
- return null;
572
- }
573
578
  //#endregion
574
579
  //#region src/routes/create-api-key.ts
575
580
  const createApiKeyBodySchema = z.object({
@@ -724,7 +729,7 @@ function createApiKey({ defaultKeyGenerator, configurations, schema, deleteAllEx
724
729
  const { configId, name, expiresIn, prefix, remaining, metadata, refillAmount, refillInterval, permissions, rateLimitMax, rateLimitTimeWindow, rateLimitEnabled } = ctx.body;
725
730
  const opts = resolveConfiguration(ctx.context, configurations, configId);
726
731
  const keyGenerator = opts.customKeyGenerator || defaultKeyGenerator;
727
- const session = await getSessionFromCtx(ctx);
732
+ const session = await getSessionFromCtx(ctx, { disableCookieCache: true });
728
733
  const isClientRequest = ctx.request || ctx.headers;
729
734
  if (isClientRequest && (refillAmount !== void 0 || refillInterval !== void 0 || rateLimitMax !== void 0 || rateLimitTimeWindow !== void 0 || rateLimitEnabled !== void 0 || permissions !== void 0 || remaining !== null)) throw APIError$1.from("BAD_REQUEST", API_KEY_ERROR_CODES.SERVER_ONLY_PROPERTY);
730
735
  if (ctx.request && ctx.body.userId !== void 0) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.UNAUTHORIZED_SESSION);
@@ -856,7 +861,7 @@ function createApiKey({ defaultKeyGenerator, configurations, schema, deleteAllEx
856
861
  //#endregion
857
862
  //#region src/routes/delete-all-expired-api-keys.ts
858
863
  function deleteAllExpiredApiKeysEndpoint({ deleteAllExpiredApiKeys }) {
859
- return createAuthEndpoint({ method: "POST" }, async (ctx) => {
864
+ return createAuthEndpoint.serverOnly({ method: "POST" }, async (ctx) => {
860
865
  try {
861
866
  await deleteAllExpiredApiKeys(ctx.context, true);
862
867
  } catch (error) {
@@ -1285,18 +1290,16 @@ function listApiKeys({ configurations, schema, deleteAllExpiredApiKeys }) {
1285
1290
  const storageKey = getStorageIdentifier(config);
1286
1291
  if (!storageGroups.has(storageKey)) storageGroups.set(storageKey, config);
1287
1292
  }
1293
+ const groupResults = await Promise.all([...storageGroups.values()].map((opts) => listApiKeys$1(ctx, referenceId, opts, {
1294
+ limit: void 0,
1295
+ offset: void 0,
1296
+ sortBy: ctx.query?.sortBy,
1297
+ sortDirection: ctx.query?.sortDirection
1298
+ })));
1288
1299
  const seenIds = /* @__PURE__ */ new Set();
1289
- for (const opts of storageGroups.values()) {
1290
- const { apiKeys } = await listApiKeys$1(ctx, referenceId, opts, {
1291
- limit: void 0,
1292
- offset: void 0,
1293
- sortBy: ctx.query?.sortBy,
1294
- sortDirection: ctx.query?.sortDirection
1295
- });
1296
- for (const key of apiKeys) if (!seenIds.has(key.id)) {
1297
- seenIds.add(key.id);
1298
- allApiKeys.push(key);
1299
- }
1300
+ for (const { apiKeys } of groupResults) for (const key of apiKeys) if (!seenIds.has(key.id)) {
1301
+ seenIds.add(key.id);
1302
+ allApiKeys.push(key);
1300
1303
  }
1301
1304
  }
1302
1305
  let filteredApiKeys = allApiKeys.filter((key) => {
@@ -1473,7 +1476,7 @@ function updateApiKey({ configurations, schema, deleteAllExpiredApiKeys }) {
1473
1476
  } }
1474
1477
  }, async (ctx) => {
1475
1478
  const { configId, keyId, expiresIn, enabled, metadata, refillAmount, refillInterval, remaining, name, permissions, rateLimitEnabled, rateLimitTimeWindow, rateLimitMax } = ctx.body;
1476
- const session = await getSessionFromCtx(ctx);
1479
+ const session = await getSessionFromCtx(ctx, { disableCookieCache: true });
1477
1480
  const authRequired = ctx.request || ctx.headers;
1478
1481
  const user = authRequired && !session ? null : session?.user || { id: ctx.body.userId };
1479
1482
  if (!user?.id) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.UNAUTHORIZED_SESSION);
@@ -1571,76 +1574,63 @@ function updateApiKey({ configurations, schema, deleteAllExpiredApiKeys }) {
1571
1574
  //#endregion
1572
1575
  //#region src/rate-limit.ts
1573
1576
  /**
1574
- * Determines if a request is allowed based on rate limiting parameters.
1575
- *
1576
- * @returns An object indicating whether the request is allowed and, if not,
1577
- * a message and updated ApiKey data.
1577
+ * Decides how the current request affects the per-key rate-limit counter, based
1578
+ * on the read-in-memory ApiKey. The verify route applies the result atomically;
1579
+ * this function performs no writes.
1578
1580
  */
1579
- function isRateLimited(apiKey, opts) {
1581
+ function evaluateRateLimit(apiKey, opts) {
1580
1582
  const now = /* @__PURE__ */ new Date();
1581
1583
  const lastRequest = apiKey.lastRequest;
1582
1584
  const rateLimitTimeWindow = apiKey.rateLimitTimeWindow;
1583
1585
  const rateLimitMax = apiKey.rateLimitMax;
1584
- let requestCount = apiKey.requestCount;
1585
1586
  if (opts.rateLimit.enabled === false) return {
1586
- success: true,
1587
- message: null,
1588
- update: { lastRequest: now },
1589
- tryAgainIn: null
1587
+ type: "skip",
1588
+ lastRequest: now
1590
1589
  };
1591
1590
  if (apiKey.rateLimitEnabled === false) return {
1592
- success: true,
1593
- message: null,
1594
- update: { lastRequest: now },
1595
- tryAgainIn: null
1591
+ type: "skip",
1592
+ lastRequest: now
1596
1593
  };
1597
1594
  if (rateLimitTimeWindow === null || rateLimitMax === null) return {
1598
- success: true,
1599
- message: null,
1600
- update: null,
1601
- tryAgainIn: null
1595
+ type: "skip",
1596
+ lastRequest: null
1602
1597
  };
1603
1598
  if (lastRequest === null) return {
1604
- success: true,
1605
- message: null,
1606
- update: {
1607
- lastRequest: now,
1608
- requestCount: 1
1609
- },
1610
- tryAgainIn: null
1599
+ type: "start",
1600
+ now
1611
1601
  };
1612
1602
  const timeSinceLastRequest = now.getTime() - new Date(lastRequest).getTime();
1613
1603
  if (timeSinceLastRequest > rateLimitTimeWindow) return {
1614
- success: true,
1615
- message: null,
1616
- update: {
1617
- lastRequest: now,
1618
- requestCount: 1
1619
- },
1620
- tryAgainIn: null
1604
+ type: "reset",
1605
+ now,
1606
+ windowStart: new Date(now.getTime() - rateLimitTimeWindow)
1621
1607
  };
1622
- if (requestCount >= rateLimitMax) return {
1623
- success: false,
1608
+ if (apiKey.requestCount >= rateLimitMax) return {
1609
+ type: "deny",
1624
1610
  message: API_KEY_ERROR_CODES.RATE_LIMIT_EXCEEDED.message,
1625
- update: null,
1626
1611
  tryAgainIn: Math.ceil(rateLimitTimeWindow - timeSinceLastRequest)
1627
1612
  };
1628
- requestCount++;
1629
1613
  return {
1630
- success: true,
1631
- message: null,
1632
- tryAgainIn: null,
1633
- update: {
1634
- lastRequest: now,
1635
- requestCount
1636
- }
1614
+ type: "increment",
1615
+ now,
1616
+ max: rateLimitMax,
1617
+ windowStart: new Date(now.getTime() - rateLimitTimeWindow)
1637
1618
  };
1638
1619
  }
1639
1620
  //#endregion
1640
1621
  //#region src/routes/verify-api-key.ts
1641
- async function validateApiKey({ hashedKey, ctx, opts, schema, permissions }) {
1642
- const apiKey = await getApiKey$1(ctx, hashedKey, opts);
1622
+ async function validateApiKey({ key, ctx, lookupOpts, configurations, schema, permissions, expectedConfigId, runCustomValidator }) {
1623
+ const hashedKey = lookupOpts.disableKeyHashing ? key : await defaultKeyHasher(key);
1624
+ const apiKey = await getApiKey$1(ctx, hashedKey, lookupOpts);
1643
1625
  if (!apiKey) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.INVALID_API_KEY);
1626
+ if (expectedConfigId !== void 0 && !configIdMatches(apiKey.configId, expectedConfigId)) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.INVALID_API_KEY);
1627
+ const opts = resolveConfiguration(ctx.context, configurations, apiKey.configId);
1628
+ if (runCustomValidator && opts.customAPIKeyValidator) {
1629
+ if (!await opts.customAPIKeyValidator({
1630
+ ctx,
1631
+ key
1632
+ })) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.KEY_NOT_FOUND);
1633
+ }
1644
1634
  if (apiKey.enabled === false) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.KEY_DISABLED);
1645
1635
  if (apiKey.expiresAt) {
1646
1636
  if (Date.now() > new Date(apiKey.expiresAt).getTime()) {
@@ -1675,8 +1665,6 @@ async function validateApiKey({ hashedKey, ctx, opts, schema, permissions }) {
1675
1665
  if (!apiKeyPermissions) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.KEY_NOT_FOUND);
1676
1666
  if (!role(apiKeyPermissions).authorize(permissions).success) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.KEY_NOT_FOUND);
1677
1667
  }
1678
- let remaining = apiKey.remaining;
1679
- let lastRefillAt = apiKey.lastRefillAt;
1680
1668
  if (apiKey.remaining === 0 && apiKey.refillAmount === null) {
1681
1669
  const deleteExhaustedKey = async () => {
1682
1670
  if (opts.storage === "secondary-storage" && opts.fallbackToDatabase) {
@@ -1702,89 +1690,271 @@ async function validateApiKey({ hashedKey, ctx, opts, schema, permissions }) {
1702
1690
  }));
1703
1691
  else await deleteExhaustedKey();
1704
1692
  throw APIError$1.from("TOO_MANY_REQUESTS", API_KEY_ERROR_CODES.USAGE_EXCEEDED);
1705
- } else if (remaining !== null) {
1706
- const now = Date.now();
1707
- const refillInterval = apiKey.refillInterval;
1708
- const refillAmount = apiKey.refillAmount;
1709
- const lastTime = new Date(lastRefillAt ?? apiKey.createdAt).getTime();
1710
- if (refillInterval && refillAmount) {
1711
- if (now - lastTime > refillInterval) {
1712
- remaining = refillAmount;
1713
- lastRefillAt = /* @__PURE__ */ new Date();
1714
- }
1693
+ }
1694
+ return {
1695
+ apiKey: opts.storage === "database" || opts.storage === "secondary-storage" && opts.fallbackToDatabase ? await claimUsageInDatabase({
1696
+ ctx,
1697
+ apiKey,
1698
+ opts,
1699
+ hashedKey
1700
+ }) : await claimUsageInSecondaryStorage({
1701
+ ctx,
1702
+ apiKey,
1703
+ opts,
1704
+ hashedKey
1705
+ }),
1706
+ opts
1707
+ };
1708
+ }
1709
+ /**
1710
+ * Atomically consume quota and a rate-limit slot against the database row, the
1711
+ * source of truth for `database` and `secondary-storage` + `fallbackToDatabase`
1712
+ * modes. Each guarded `incrementOne` only mutates the row while the guard still
1713
+ * holds, so concurrent verifications cannot drive `remaining` below zero or push
1714
+ * `requestCount` past the configured max. The cache (when present) is refreshed
1715
+ * from the resulting row.
1716
+ */
1717
+ async function claimUsageInDatabase({ ctx, apiKey, opts, hashedKey }) {
1718
+ let row = apiKey;
1719
+ if (apiKey.remaining !== null) row = await consumeRemaining(ctx, apiKey);
1720
+ row = await consumeRateLimit(ctx, row, opts);
1721
+ const finalRow = await ctx.context.adapter.update({
1722
+ model: API_KEY_TABLE_NAME,
1723
+ where: [{
1724
+ field: "id",
1725
+ value: row.id
1726
+ }],
1727
+ update: { updatedAt: /* @__PURE__ */ new Date() }
1728
+ });
1729
+ if (!finalRow) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.INVALID_API_KEY);
1730
+ if (opts.storage === "secondary-storage" && opts.fallbackToDatabase) await setApiKey(ctx, finalRow, opts);
1731
+ return finalRow;
1732
+ }
1733
+ /**
1734
+ * Guarded quota consumption. When a refill is due, exactly one verification wins
1735
+ * the refill (compare-and-swap on the observed `lastRefillAt`); any concurrent
1736
+ * verification falls through to the plain guarded decrement against the refilled
1737
+ * value. The decrement only applies while `remaining > 0`, so it can never go
1738
+ * negative. Returns the updated row; throws when the quota is exhausted.
1739
+ */
1740
+ async function consumeRemaining(ctx, apiKey) {
1741
+ const now = /* @__PURE__ */ new Date();
1742
+ const { refillInterval, refillAmount } = apiKey;
1743
+ if (refillInterval && refillAmount) {
1744
+ const lastTime = new Date(apiKey.lastRefillAt ?? apiKey.createdAt).getTime();
1745
+ if (now.getTime() - lastTime > refillInterval) {
1746
+ const refilled = await ctx.context.adapter.incrementOne({
1747
+ model: API_KEY_TABLE_NAME,
1748
+ where: [{
1749
+ field: "id",
1750
+ value: apiKey.id
1751
+ }, {
1752
+ field: "lastRefillAt",
1753
+ value: apiKey.lastRefillAt
1754
+ }],
1755
+ increment: {},
1756
+ set: {
1757
+ remaining: refillAmount - 1,
1758
+ lastRefillAt: now
1759
+ }
1760
+ });
1761
+ if (refilled) return refilled;
1715
1762
  }
1716
- if (remaining === 0) throw APIError$1.from("TOO_MANY_REQUESTS", API_KEY_ERROR_CODES.USAGE_EXCEEDED);
1717
- else remaining--;
1718
1763
  }
1719
- const { message, success, update, tryAgainIn } = isRateLimited(apiKey, opts);
1720
- if (success === false) throw new APIError$1("UNAUTHORIZED", {
1721
- message: message ?? void 0,
1764
+ const decremented = await ctx.context.adapter.incrementOne({
1765
+ model: API_KEY_TABLE_NAME,
1766
+ where: [{
1767
+ field: "id",
1768
+ value: apiKey.id
1769
+ }, {
1770
+ field: "remaining",
1771
+ operator: "gt",
1772
+ value: 0
1773
+ }],
1774
+ increment: { remaining: -1 }
1775
+ });
1776
+ if (!decremented) throw APIError$1.from("TOO_MANY_REQUESTS", API_KEY_ERROR_CODES.USAGE_EXCEEDED);
1777
+ return decremented;
1778
+ }
1779
+ /**
1780
+ * Guarded rate-limit consumption. The common in-window path increments
1781
+ * `requestCount` only while it is below the max (compare-and-swap), so a burst
1782
+ * of concurrent verifications can never exceed the limit. Window resets and the
1783
+ * first request in a window are guarded conditional sets; a request that loses
1784
+ * every guard within an active window is rejected. Returns the updated row, or
1785
+ * the unchanged row when rate limiting does not apply.
1786
+ */
1787
+ async function consumeRateLimit(ctx, apiKey, opts) {
1788
+ const decision = evaluateRateLimit(apiKey, opts);
1789
+ if (decision.type === "deny") throw new APIError$1("TOO_MANY_REQUESTS", {
1790
+ message: decision.message,
1722
1791
  code: "RATE_LIMITED",
1723
- details: { tryAgainIn }
1792
+ details: { tryAgainIn: decision.tryAgainIn }
1724
1793
  });
1725
- const updated = {
1726
- ...apiKey,
1727
- ...update,
1728
- remaining,
1729
- lastRefillAt,
1730
- updatedAt: /* @__PURE__ */ new Date()
1731
- };
1732
- const performUpdate = async () => {
1733
- if (opts.storage === "database") return ctx.context.adapter.update({
1734
- model: API_KEY_TABLE_NAME,
1794
+ if (decision.type === "skip") {
1795
+ if (decision.lastRequest === null) return apiKey;
1796
+ return await ctx.context.adapter.update({
1797
+ model: "apikey",
1735
1798
  where: [{
1736
1799
  field: "id",
1737
1800
  value: apiKey.id
1738
1801
  }],
1739
- update: {
1740
- ...updated,
1741
- id: void 0
1742
- }
1743
- });
1744
- else if (opts.storage === "secondary-storage" && opts.fallbackToDatabase) {
1745
- const dbUpdated = await ctx.context.adapter.update({
1746
- model: API_KEY_TABLE_NAME,
1747
- where: [{
1802
+ update: { lastRequest: decision.lastRequest }
1803
+ }) ?? apiKey;
1804
+ }
1805
+ if (decision.type === "increment") {
1806
+ const incremented = await ctx.context.adapter.incrementOne({
1807
+ model: API_KEY_TABLE_NAME,
1808
+ where: [
1809
+ {
1748
1810
  field: "id",
1749
1811
  value: apiKey.id
1750
- }],
1751
- update: {
1752
- ...updated,
1753
- id: void 0
1812
+ },
1813
+ {
1814
+ field: "lastRequest",
1815
+ operator: "gt",
1816
+ value: decision.windowStart
1817
+ },
1818
+ {
1819
+ field: "requestCount",
1820
+ operator: "lt",
1821
+ value: decision.max
1754
1822
  }
1755
- });
1756
- if (dbUpdated) await setApiKey(ctx, dbUpdated, opts);
1757
- return dbUpdated;
1758
- } else {
1759
- await setApiKey(ctx, updated, opts);
1760
- return updated;
1823
+ ],
1824
+ increment: { requestCount: 1 },
1825
+ set: { lastRequest: decision.now }
1826
+ });
1827
+ if (incremented) return incremented;
1828
+ const fresh = await ctx.context.adapter.findOne({
1829
+ model: API_KEY_TABLE_NAME,
1830
+ where: [{
1831
+ field: "id",
1832
+ value: apiKey.id
1833
+ }]
1834
+ });
1835
+ if (!fresh) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.INVALID_API_KEY);
1836
+ return consumeRateLimit(ctx, fresh, opts);
1837
+ }
1838
+ const windowGuard = decision.type === "reset" ? {
1839
+ field: "lastRequest",
1840
+ operator: "lte",
1841
+ value: decision.windowStart
1842
+ } : {
1843
+ field: "lastRequest",
1844
+ operator: "eq",
1845
+ value: null
1846
+ };
1847
+ const started = await ctx.context.adapter.incrementOne({
1848
+ model: API_KEY_TABLE_NAME,
1849
+ where: [{
1850
+ field: "id",
1851
+ value: apiKey.id
1852
+ }, windowGuard],
1853
+ increment: {},
1854
+ set: {
1855
+ requestCount: 1,
1856
+ lastRequest: decision.now
1857
+ }
1858
+ });
1859
+ if (started) return started;
1860
+ const fresh = await ctx.context.adapter.findOne({
1861
+ model: API_KEY_TABLE_NAME,
1862
+ where: [{
1863
+ field: "id",
1864
+ value: apiKey.id
1865
+ }]
1866
+ });
1867
+ if (!fresh) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.INVALID_API_KEY);
1868
+ return consumeRateLimit(ctx, fresh, opts);
1869
+ }
1870
+ /**
1871
+ * Secondary-storage-only mode has no database row to guard, so quota and
1872
+ * rate-limit consumption stays a read-modify-write merge over the serialized
1873
+ * key. This is the residual non-atomic path; strict enforcement requires the
1874
+ * database (use `fallbackToDatabase`) or an atomic secondary-storage primitive.
1875
+ * FIXME(api-key-secondary-atomic): back this with SecondaryStorage.increment on
1876
+ * `next` so secondary-storage-only mode enforces quota and rate limits atomically.
1877
+ */
1878
+ async function claimUsageInSecondaryStorage({ ctx, apiKey, opts, hashedKey }) {
1879
+ let remaining = apiKey.remaining;
1880
+ let lastRefillAt = apiKey.lastRefillAt;
1881
+ if (remaining !== null) {
1882
+ const now = Date.now();
1883
+ const { refillInterval, refillAmount } = apiKey;
1884
+ const lastTime = new Date(lastRefillAt ?? apiKey.createdAt).getTime();
1885
+ if (refillInterval && refillAmount && now - lastTime > refillInterval) {
1886
+ remaining = refillAmount;
1887
+ lastRefillAt = /* @__PURE__ */ new Date();
1761
1888
  }
1889
+ if (remaining === 0) throw APIError$1.from("TOO_MANY_REQUESTS", API_KEY_ERROR_CODES.USAGE_EXCEEDED);
1890
+ remaining--;
1891
+ }
1892
+ const mutations = {
1893
+ ...applyRateLimitToSnapshot(apiKey, opts),
1894
+ remaining,
1895
+ lastRefillAt,
1896
+ updatedAt: /* @__PURE__ */ new Date()
1897
+ };
1898
+ const performUpdate = async () => {
1899
+ const fresh = await getApiKey$1(ctx, hashedKey, opts);
1900
+ if (!fresh) return null;
1901
+ const merged = {
1902
+ ...fresh,
1903
+ ...mutations
1904
+ };
1905
+ await setApiKey(ctx, merged, opts);
1906
+ return merged;
1762
1907
  };
1763
- let newApiKey = null;
1764
1908
  if (opts.deferUpdates) {
1765
1909
  ctx.context.runInBackground(performUpdate().catch((error) => {
1766
1910
  ctx.context.logger.error("Failed to update API key:", error);
1767
1911
  }));
1768
- newApiKey = updated;
1769
- } else {
1770
- newApiKey = await performUpdate();
1771
- if (!newApiKey) throw APIError$1.from("INTERNAL_SERVER_ERROR", API_KEY_ERROR_CODES.FAILED_TO_UPDATE_API_KEY);
1912
+ return {
1913
+ ...apiKey,
1914
+ ...mutations
1915
+ };
1916
+ }
1917
+ const updated = await performUpdate();
1918
+ if (!updated) throw APIError$1.from("INTERNAL_SERVER_ERROR", API_KEY_ERROR_CODES.FAILED_TO_UPDATE_API_KEY);
1919
+ return updated;
1920
+ }
1921
+ /**
1922
+ * Translate a rate-limit decision into a counter snapshot for the
1923
+ * secondary-storage merge write. Denials throw before any write.
1924
+ */
1925
+ function applyRateLimitToSnapshot(apiKey, opts) {
1926
+ const decision = evaluateRateLimit(apiKey, opts);
1927
+ switch (decision.type) {
1928
+ case "deny": throw new APIError$1("TOO_MANY_REQUESTS", {
1929
+ message: decision.message,
1930
+ code: "RATE_LIMITED",
1931
+ details: { tryAgainIn: decision.tryAgainIn }
1932
+ });
1933
+ case "skip": return decision.lastRequest === null ? {} : { lastRequest: decision.lastRequest };
1934
+ case "start":
1935
+ case "reset": return {
1936
+ lastRequest: decision.now,
1937
+ requestCount: 1
1938
+ };
1939
+ case "increment": return {
1940
+ lastRequest: decision.now,
1941
+ requestCount: apiKey.requestCount + 1
1942
+ };
1772
1943
  }
1773
- return newApiKey;
1774
1944
  }
1775
1945
  const verifyApiKeyBodySchema = z.object({
1776
- configId: z.string().meta({ description: "The configuration ID to use for verification. If not provided, the default configuration will be used." }).optional(),
1946
+ configId: z.string().meta({ description: "Configuration ID to scope verification to. When omitted, the key is validated against its own configuration." }).optional(),
1777
1947
  key: z.string().meta({ description: "The key to verify" }),
1778
1948
  permissions: z.record(z.string(), z.array(z.string())).meta({ description: "The permissions to verify." }).optional()
1779
1949
  });
1780
1950
  function verifyApiKey({ configurations, schema, deleteAllExpiredApiKeys }) {
1781
- return createAuthEndpoint({
1951
+ return createAuthEndpoint.serverOnly({
1782
1952
  method: "POST",
1783
1953
  body: verifyApiKeyBodySchema
1784
1954
  }, async (ctx) => {
1785
1955
  const { configId, key } = ctx.body;
1786
1956
  const lookupOpts = resolveConfiguration(ctx.context, configurations, configId);
1787
- if (lookupOpts.customAPIKeyValidator) {
1957
+ if (configId !== void 0 && lookupOpts.customAPIKeyValidator) {
1788
1958
  if (!await lookupOpts.customAPIKeyValidator({
1789
1959
  ctx,
1790
1960
  key
@@ -1797,17 +1967,22 @@ function verifyApiKey({ configurations, schema, deleteAllExpiredApiKeys }) {
1797
1967
  key: null
1798
1968
  });
1799
1969
  }
1800
- const hashed = lookupOpts.disableKeyHashing ? key : await defaultKeyHasher(key);
1801
1970
  let apiKey = null;
1971
+ let opts;
1802
1972
  try {
1803
- apiKey = await validateApiKey({
1804
- hashedKey: hashed,
1973
+ const result = await validateApiKey({
1974
+ key,
1805
1975
  permissions: ctx.body.permissions,
1806
1976
  ctx,
1807
- opts: lookupOpts,
1808
- schema
1977
+ lookupOpts,
1978
+ configurations,
1979
+ schema,
1980
+ expectedConfigId: configId,
1981
+ runCustomValidator: configId === void 0
1809
1982
  });
1810
- if ((apiKey ? resolveConfiguration(ctx.context, configurations, apiKey.configId) : lookupOpts).deferUpdates) ctx.context.runInBackground(deleteAllExpiredApiKeys(ctx.context).catch((err) => {
1983
+ apiKey = result.apiKey;
1984
+ opts = result.opts;
1985
+ if (opts.deferUpdates) ctx.context.runInBackground(deleteAllExpiredApiKeys(ctx.context).catch((err) => {
1811
1986
  ctx.context.logger.error("Failed to delete expired API keys:", err);
1812
1987
  }));
1813
1988
  } catch (error) {
@@ -1834,7 +2009,6 @@ function verifyApiKey({ configurations, schema, deleteAllExpiredApiKeys }) {
1834
2009
  key: 1,
1835
2010
  permissions: void 0
1836
2011
  };
1837
- const opts = apiKey ? resolveConfiguration(ctx.context, configurations, apiKey.configId) : lookupOpts;
1838
2012
  let migratedMetadata = null;
1839
2013
  if (apiKey) migratedMetadata = await migrateDoubleStringifiedMetadata(ctx, apiKey, opts);
1840
2014
  returningApiKey.permissions = returningApiKey.permissions ? safeJSONParse(returningApiKey.permissions) : null;
@@ -2163,11 +2337,13 @@ function apiKey(_configurations, _options) {
2163
2337
  key
2164
2338
  })) throw APIError.from("FORBIDDEN", API_KEY_ERROR_CODES.INVALID_API_KEY);
2165
2339
  }
2166
- const apiKey = await validateApiKey({
2167
- hashedKey: config.disableKeyHashing ? key : await defaultKeyHasher(key),
2340
+ const { apiKey } = await validateApiKey({
2341
+ key,
2168
2342
  ctx,
2169
- opts: config,
2170
- schema
2343
+ lookupOpts: config,
2344
+ configurations,
2345
+ schema,
2346
+ expectedConfigId: config.configId
2171
2347
  });
2172
2348
  const cleanupTask = deleteAllExpiredApiKeys(ctx.context).catch((err) => {
2173
2349
  ctx.context.logger.error("Failed to delete expired API keys:", err);
@@ -2189,7 +2365,7 @@ function apiKey(_configurations, _options) {
2189
2365
  token: key,
2190
2366
  userId: apiKey.referenceId,
2191
2367
  userAgent: ctx.request?.headers.get("user-agent") ?? null,
2192
- ipAddress: ctx.request ? getIp(ctx.request, ctx.context.options) : null,
2368
+ ipAddress: ctx.request ? getIP(ctx.request, ctx.context.options) : null,
2193
2369
  createdAt: /* @__PURE__ */ new Date(),
2194
2370
  updatedAt: /* @__PURE__ */ new Date(),
2195
2371
  expiresAt: apiKey.expiresAt || getDate(ctx.context.options.session?.expiresIn || 3600 * 24 * 7, "ms")