@better-auth/api-key 1.7.0-beta.4 → 1.7.0-beta.6

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/client.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { n as ApiKeyConfigurationOptions, r as ApiKeyOptions, t as ApiKey } from "./types-BR70O3Q3.mjs";
2
- import { i as API_KEY_ERROR_CODES, n as apiKey } from "./index-CI6mGUwK.mjs";
1
+ import { n as ApiKeyConfigurationOptions, r as ApiKeyOptions, t as ApiKey } from "./types-CbtANSbR.mjs";
2
+ import { i as API_KEY_ERROR_CODES, n as apiKey } from "./index-BU-xUFsI.mjs";
3
3
  import * as better_auth0 from "better-auth";
4
4
 
5
5
  //#region src/client.d.ts
package/dist/client.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { n as API_KEY_ERROR_CODES, t as PACKAGE_VERSION } from "./version-L3icplj1.mjs";
1
+ import { n as API_KEY_ERROR_CODES, t as PACKAGE_VERSION } from "./version-rSzahEXu.mjs";
2
2
  //#region src/client.ts
3
3
  const apiKeyClient = () => {
4
4
  return {
@@ -1,4 +1,4 @@
1
- import { n as ApiKeyConfigurationOptions, r as ApiKeyOptions, t as ApiKey } from "./types-BR70O3Q3.mjs";
1
+ import { n as ApiKeyConfigurationOptions, r as ApiKeyOptions, t as ApiKey } from "./types-CbtANSbR.mjs";
2
2
  import * as better_auth0 from "better-auth";
3
3
  import * as zod from "zod";
4
4
  import * as better_call0 from "better-call";
@@ -187,7 +187,7 @@ declare function apiKey(_configurations?: (ApiKeyConfigurationOptions & ApiKeyOp
187
187
  image?: string | null | undefined;
188
188
  } & Record<string, any>;
189
189
  } | null) => void;
190
- socialProviders: better_auth0.OAuthProvider[];
190
+ socialProviders: better_auth0.UpstreamProvider[];
191
191
  authCookies: better_auth0.BetterAuthCookies;
192
192
  logger: ReturnType<typeof better_auth0.createLogger>;
193
193
  rateLimit: {
package/dist/index.d.mts CHANGED
@@ -1,3 +1,3 @@
1
- import { n as ApiKeyConfigurationOptions, r as ApiKeyOptions, t as ApiKey } from "./types-BR70O3Q3.mjs";
2
- import { i as API_KEY_ERROR_CODES, n as apiKey, r as defaultKeyHasher, t as API_KEY_TABLE_NAME } from "./index-CI6mGUwK.mjs";
1
+ import { n as ApiKeyConfigurationOptions, r as ApiKeyOptions, t as ApiKey } from "./types-CbtANSbR.mjs";
2
+ import { i as API_KEY_ERROR_CODES, n as apiKey, r as defaultKeyHasher, t as API_KEY_TABLE_NAME } from "./index-BU-xUFsI.mjs";
3
3
  export { API_KEY_ERROR_CODES, API_KEY_TABLE_NAME, ApiKey, ApiKeyConfigurationOptions, ApiKeyOptions, apiKey, defaultKeyHasher };
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { n as API_KEY_ERROR_CODES, t as PACKAGE_VERSION } from "./version-L3icplj1.mjs";
1
+ import { n as API_KEY_ERROR_CODES, t as PACKAGE_VERSION } from "./version-rSzahEXu.mjs";
2
2
  import { createAuthEndpoint, createAuthMiddleware } from "@better-auth/core/api";
3
3
  import { base64Url } from "@better-auth/utils/base64";
4
4
  import { createHash } from "@better-auth/utils/hash";
@@ -167,21 +167,46 @@ async function getApiKeyByIdFromStorage(ctx, id, storage) {
167
167
  return deserializeApiKey(await storage.get(key));
168
168
  }
169
169
  /**
170
+ * Serializes reference-list mutations per `refKey` within a single process.
171
+ * Each new mutation chains onto the previous one for the same key, so the
172
+ * read/modify/write below never interleaves with another mutation of the same
173
+ * list. This closes the lost-update race in secondary-storage-only mode, where
174
+ * the serialized list is the source of truth for listing.
175
+ *
176
+ * Limitation: the lock is in-process only. Across multiple server instances
177
+ * sharing one secondary storage, concurrent writers can still lose updates.
178
+ * Secondary storage with `fallbackToDatabase` avoids this by treating the list
179
+ * as an invalidate-only cache with the database as the source of truth.
180
+ * FIXME(api-key-reflist-durable): on `next`, drop the source-of-truth reference
181
+ * list entirely and make the database authoritative for listing, removing this
182
+ * in-process lock.
183
+ */
184
+ const refListLocks = /* @__PURE__ */ new Map();
185
+ function withRefListLock(refKey, task) {
186
+ const tracked = (refListLocks.get(refKey) ?? Promise.resolve()).then(task, task).finally(() => {
187
+ if (refListLocks.get(refKey) === tracked) refListLocks.delete(refKey);
188
+ });
189
+ refListLocks.set(refKey, tracked);
190
+ return tracked;
191
+ }
192
+ /**
170
193
  * Read-modify-write the ref list:
171
194
  * used only when the list is the source of truth.
172
195
  */
173
196
  async function modifyRefList(storage, refKey, modify) {
174
- const refListData = await storage.get(refKey);
175
- let keyIds = [];
176
- if (refListData && typeof refListData === "string") try {
177
- keyIds = JSON.parse(refListData);
178
- } catch {
179
- keyIds = [];
180
- }
181
- else if (Array.isArray(refListData)) keyIds = refListData;
182
- const next = modify(keyIds);
183
- if (next.length === 0) await storage.delete(refKey);
184
- else await storage.set(refKey, JSON.stringify(next));
197
+ await withRefListLock(refKey, async () => {
198
+ const refListData = await storage.get(refKey);
199
+ let keyIds = [];
200
+ if (refListData && typeof refListData === "string") try {
201
+ keyIds = JSON.parse(refListData);
202
+ } catch {
203
+ keyIds = [];
204
+ }
205
+ else if (Array.isArray(refListData)) keyIds = refListData;
206
+ const next = modify(keyIds);
207
+ if (next.length === 0) await storage.delete(refKey);
208
+ else await storage.set(refKey, JSON.stringify(next));
209
+ });
185
210
  }
186
211
  async function setApiKeyInStorage(_ctx, apiKey, storage, ttl, opts) {
187
212
  const serialized = serializeApiKey(apiKey);
@@ -720,7 +745,7 @@ function createApiKey({ defaultKeyGenerator, configurations, schema, deleteAllEx
720
745
  const { configId, name, expiresIn, prefix, remaining, metadata, refillAmount, refillInterval, permissions, rateLimitMax, rateLimitTimeWindow, rateLimitEnabled } = ctx.body;
721
746
  const opts = resolveConfiguration(ctx.context, configurations, configId);
722
747
  const keyGenerator = opts.customKeyGenerator || defaultKeyGenerator;
723
- const session = await getSessionFromCtx(ctx);
748
+ const session = await getSessionFromCtx(ctx, { disableCookieCache: true });
724
749
  const isClientRequest = ctx.request || ctx.headers;
725
750
  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);
726
751
  if (ctx.request && ctx.body.userId !== void 0) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.UNAUTHORIZED_SESSION);
@@ -852,7 +877,7 @@ function createApiKey({ defaultKeyGenerator, configurations, schema, deleteAllEx
852
877
  //#endregion
853
878
  //#region src/routes/delete-all-expired-api-keys.ts
854
879
  function deleteAllExpiredApiKeysEndpoint({ deleteAllExpiredApiKeys }) {
855
- return createAuthEndpoint({ method: "POST" }, async (ctx) => {
880
+ return createAuthEndpoint.serverOnly({ method: "POST" }, async (ctx) => {
856
881
  try {
857
882
  await deleteAllExpiredApiKeys(ctx.context, true);
858
883
  } catch (error) {
@@ -1467,7 +1492,7 @@ function updateApiKey({ configurations, schema, deleteAllExpiredApiKeys }) {
1467
1492
  } }
1468
1493
  }, async (ctx) => {
1469
1494
  const { configId, keyId, expiresIn, enabled, metadata, refillAmount, refillInterval, remaining, name, permissions, rateLimitEnabled, rateLimitTimeWindow, rateLimitMax } = ctx.body;
1470
- const session = await getSessionFromCtx(ctx);
1495
+ const session = await getSessionFromCtx(ctx, { disableCookieCache: true });
1471
1496
  const authRequired = ctx.request || ctx.headers;
1472
1497
  const user = authRequired && !session ? null : session?.user || { id: ctx.body.userId };
1473
1498
  if (!user?.id) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.UNAUTHORIZED_SESSION);
@@ -1565,75 +1590,54 @@ function updateApiKey({ configurations, schema, deleteAllExpiredApiKeys }) {
1565
1590
  //#endregion
1566
1591
  //#region src/rate-limit.ts
1567
1592
  /**
1568
- * Determines if a request is allowed based on rate limiting parameters.
1569
- *
1570
- * @returns An object indicating whether the request is allowed and, if not,
1571
- * a message and updated ApiKey data.
1593
+ * Decides how the current request affects the per-key rate-limit counter, based
1594
+ * on the read-in-memory ApiKey. The verify route applies the result atomically;
1595
+ * this function performs no writes.
1572
1596
  */
1573
- function isRateLimited(apiKey, opts) {
1597
+ function evaluateRateLimit(apiKey, opts) {
1574
1598
  const now = /* @__PURE__ */ new Date();
1575
1599
  const lastRequest = apiKey.lastRequest;
1576
1600
  const rateLimitTimeWindow = apiKey.rateLimitTimeWindow;
1577
1601
  const rateLimitMax = apiKey.rateLimitMax;
1578
- let requestCount = apiKey.requestCount;
1579
1602
  if (opts.rateLimit.enabled === false) return {
1580
- success: true,
1581
- message: null,
1582
- update: { lastRequest: now },
1583
- tryAgainIn: null
1603
+ type: "skip",
1604
+ lastRequest: now
1584
1605
  };
1585
1606
  if (apiKey.rateLimitEnabled === false) return {
1586
- success: true,
1587
- message: null,
1588
- update: { lastRequest: now },
1589
- tryAgainIn: null
1607
+ type: "skip",
1608
+ lastRequest: now
1590
1609
  };
1591
1610
  if (rateLimitTimeWindow === null || rateLimitMax === null) return {
1592
- success: true,
1593
- message: null,
1594
- update: null,
1595
- tryAgainIn: null
1611
+ type: "skip",
1612
+ lastRequest: null
1596
1613
  };
1597
1614
  if (lastRequest === null) return {
1598
- success: true,
1599
- message: null,
1600
- update: {
1601
- lastRequest: now,
1602
- requestCount: 1
1603
- },
1604
- tryAgainIn: null
1615
+ type: "start",
1616
+ now
1605
1617
  };
1606
1618
  const timeSinceLastRequest = now.getTime() - new Date(lastRequest).getTime();
1607
1619
  if (timeSinceLastRequest > rateLimitTimeWindow) return {
1608
- success: true,
1609
- message: null,
1610
- update: {
1611
- lastRequest: now,
1612
- requestCount: 1
1613
- },
1614
- tryAgainIn: null
1620
+ type: "reset",
1621
+ now,
1622
+ windowStart: new Date(now.getTime() - rateLimitTimeWindow)
1615
1623
  };
1616
- if (requestCount >= rateLimitMax) return {
1617
- success: false,
1624
+ if (apiKey.requestCount >= rateLimitMax) return {
1625
+ type: "deny",
1618
1626
  message: API_KEY_ERROR_CODES.RATE_LIMIT_EXCEEDED.message,
1619
- update: null,
1620
1627
  tryAgainIn: Math.ceil(rateLimitTimeWindow - timeSinceLastRequest)
1621
1628
  };
1622
- requestCount++;
1623
1629
  return {
1624
- success: true,
1625
- message: null,
1626
- tryAgainIn: null,
1627
- update: {
1628
- lastRequest: now,
1629
- requestCount
1630
- }
1630
+ type: "increment",
1631
+ now,
1632
+ max: rateLimitMax,
1633
+ windowStart: new Date(now.getTime() - rateLimitTimeWindow)
1631
1634
  };
1632
1635
  }
1633
1636
  //#endregion
1634
1637
  //#region src/routes/verify-api-key.ts
1635
1638
  async function validateApiKey({ key, ctx, lookupOpts, configurations, schema, permissions, expectedConfigId, runCustomValidator }) {
1636
- const apiKey = await getApiKey$1(ctx, lookupOpts.disableKeyHashing ? key : await defaultKeyHasher(key), lookupOpts);
1639
+ const hashedKey = lookupOpts.disableKeyHashing ? key : await defaultKeyHasher(key);
1640
+ const apiKey = await getApiKey$1(ctx, hashedKey, lookupOpts);
1637
1641
  if (!apiKey) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.INVALID_API_KEY);
1638
1642
  if (expectedConfigId !== void 0 && !configIdMatches(apiKey.configId, expectedConfigId)) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.INVALID_API_KEY);
1639
1643
  const opts = resolveConfiguration(ctx.context, configurations, apiKey.configId);
@@ -1677,8 +1681,6 @@ async function validateApiKey({ key, ctx, lookupOpts, configurations, schema, pe
1677
1681
  if (!apiKeyPermissions) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.KEY_NOT_FOUND);
1678
1682
  if (!role(apiKeyPermissions).authorize(permissions).success) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.KEY_NOT_FOUND);
1679
1683
  }
1680
- let remaining = apiKey.remaining;
1681
- let lastRefillAt = apiKey.lastRefillAt;
1682
1684
  if (apiKey.remaining === 0 && apiKey.refillAmount === null) {
1683
1685
  const deleteExhaustedKey = async () => {
1684
1686
  if (opts.storage === "secondary-storage" && opts.fallbackToDatabase) {
@@ -1704,78 +1706,257 @@ async function validateApiKey({ key, ctx, lookupOpts, configurations, schema, pe
1704
1706
  }));
1705
1707
  else await deleteExhaustedKey();
1706
1708
  throw APIError$1.from("TOO_MANY_REQUESTS", API_KEY_ERROR_CODES.USAGE_EXCEEDED);
1707
- } else if (remaining !== null) {
1708
- const now = Date.now();
1709
- const refillInterval = apiKey.refillInterval;
1710
- const refillAmount = apiKey.refillAmount;
1711
- const lastTime = new Date(lastRefillAt ?? apiKey.createdAt).getTime();
1712
- if (refillInterval && refillAmount) {
1713
- if (now - lastTime > refillInterval) {
1714
- remaining = refillAmount;
1715
- lastRefillAt = /* @__PURE__ */ new Date();
1716
- }
1709
+ }
1710
+ return {
1711
+ apiKey: opts.storage === "database" || opts.storage === "secondary-storage" && opts.fallbackToDatabase ? await claimUsageInDatabase({
1712
+ ctx,
1713
+ apiKey,
1714
+ opts,
1715
+ hashedKey
1716
+ }) : await claimUsageInSecondaryStorage({
1717
+ ctx,
1718
+ apiKey,
1719
+ opts,
1720
+ hashedKey
1721
+ }),
1722
+ opts
1723
+ };
1724
+ }
1725
+ /**
1726
+ * Atomically consume quota and a rate-limit slot against the database row, the
1727
+ * source of truth for `database` and `secondary-storage` + `fallbackToDatabase`
1728
+ * modes. Each guarded `incrementOne` only mutates the row while the guard still
1729
+ * holds, so concurrent verifications cannot drive `remaining` below zero or push
1730
+ * `requestCount` past the configured max. The cache (when present) is refreshed
1731
+ * from the resulting row.
1732
+ */
1733
+ async function claimUsageInDatabase({ ctx, apiKey, opts, hashedKey }) {
1734
+ let row = apiKey;
1735
+ if (apiKey.remaining !== null) row = await consumeRemaining(ctx, apiKey);
1736
+ row = await consumeRateLimit(ctx, row, opts);
1737
+ const finalRow = await ctx.context.adapter.update({
1738
+ model: API_KEY_TABLE_NAME,
1739
+ where: [{
1740
+ field: "id",
1741
+ value: row.id
1742
+ }],
1743
+ update: { updatedAt: /* @__PURE__ */ new Date() }
1744
+ });
1745
+ if (!finalRow) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.INVALID_API_KEY);
1746
+ if (opts.storage === "secondary-storage" && opts.fallbackToDatabase) await setApiKey(ctx, finalRow, opts);
1747
+ return finalRow;
1748
+ }
1749
+ /**
1750
+ * Guarded quota consumption. When a refill is due, exactly one verification wins
1751
+ * the refill (compare-and-swap on the observed `lastRefillAt`); any concurrent
1752
+ * verification falls through to the plain guarded decrement against the refilled
1753
+ * value. The decrement only applies while `remaining > 0`, so it can never go
1754
+ * negative. Returns the updated row; throws when the quota is exhausted.
1755
+ */
1756
+ async function consumeRemaining(ctx, apiKey) {
1757
+ const now = /* @__PURE__ */ new Date();
1758
+ const { refillInterval, refillAmount } = apiKey;
1759
+ if (refillInterval && refillAmount) {
1760
+ const lastTime = new Date(apiKey.lastRefillAt ?? apiKey.createdAt).getTime();
1761
+ if (now.getTime() - lastTime > refillInterval) {
1762
+ const refilled = await ctx.context.adapter.incrementOne({
1763
+ model: API_KEY_TABLE_NAME,
1764
+ where: [{
1765
+ field: "id",
1766
+ value: apiKey.id
1767
+ }, {
1768
+ field: "lastRefillAt",
1769
+ value: apiKey.lastRefillAt
1770
+ }],
1771
+ increment: {},
1772
+ set: {
1773
+ remaining: refillAmount - 1,
1774
+ lastRefillAt: now
1775
+ }
1776
+ });
1777
+ if (refilled) return refilled;
1717
1778
  }
1718
- if (remaining === 0) throw APIError$1.from("TOO_MANY_REQUESTS", API_KEY_ERROR_CODES.USAGE_EXCEEDED);
1719
- else remaining--;
1720
1779
  }
1721
- const { message, success, update, tryAgainIn } = isRateLimited(apiKey, opts);
1722
- if (success === false) throw new APIError$1("TOO_MANY_REQUESTS", {
1723
- message: message ?? void 0,
1780
+ const decremented = await ctx.context.adapter.incrementOne({
1781
+ model: API_KEY_TABLE_NAME,
1782
+ where: [{
1783
+ field: "id",
1784
+ value: apiKey.id
1785
+ }, {
1786
+ field: "remaining",
1787
+ operator: "gt",
1788
+ value: 0
1789
+ }],
1790
+ increment: { remaining: -1 }
1791
+ });
1792
+ if (!decremented) throw APIError$1.from("TOO_MANY_REQUESTS", API_KEY_ERROR_CODES.USAGE_EXCEEDED);
1793
+ return decremented;
1794
+ }
1795
+ /**
1796
+ * Guarded rate-limit consumption. The common in-window path increments
1797
+ * `requestCount` only while it is below the max (compare-and-swap), so a burst
1798
+ * of concurrent verifications can never exceed the limit. Window resets and the
1799
+ * first request in a window are guarded conditional sets; a request that loses
1800
+ * every guard within an active window is rejected. Returns the updated row, or
1801
+ * the unchanged row when rate limiting does not apply.
1802
+ */
1803
+ async function consumeRateLimit(ctx, apiKey, opts) {
1804
+ const decision = evaluateRateLimit(apiKey, opts);
1805
+ if (decision.type === "deny") throw new APIError$1("TOO_MANY_REQUESTS", {
1806
+ message: decision.message,
1724
1807
  code: "RATE_LIMITED",
1725
- details: { tryAgainIn }
1808
+ details: { tryAgainIn: decision.tryAgainIn }
1726
1809
  });
1727
- const updated = {
1728
- ...apiKey,
1729
- ...update,
1730
- remaining,
1731
- lastRefillAt,
1732
- updatedAt: /* @__PURE__ */ new Date()
1733
- };
1734
- const performUpdate = async () => {
1735
- if (opts.storage === "database") return ctx.context.adapter.update({
1736
- model: API_KEY_TABLE_NAME,
1810
+ if (decision.type === "skip") {
1811
+ if (decision.lastRequest === null) return apiKey;
1812
+ return await ctx.context.adapter.update({
1813
+ model: "apikey",
1737
1814
  where: [{
1738
1815
  field: "id",
1739
1816
  value: apiKey.id
1740
1817
  }],
1741
- update: {
1742
- ...updated,
1743
- id: void 0
1744
- }
1745
- });
1746
- else if (opts.storage === "secondary-storage" && opts.fallbackToDatabase) {
1747
- const dbUpdated = await ctx.context.adapter.update({
1748
- model: API_KEY_TABLE_NAME,
1749
- where: [{
1818
+ update: { lastRequest: decision.lastRequest }
1819
+ }) ?? apiKey;
1820
+ }
1821
+ if (decision.type === "increment") {
1822
+ const incremented = await ctx.context.adapter.incrementOne({
1823
+ model: API_KEY_TABLE_NAME,
1824
+ where: [
1825
+ {
1750
1826
  field: "id",
1751
1827
  value: apiKey.id
1752
- }],
1753
- update: {
1754
- ...updated,
1755
- id: void 0
1828
+ },
1829
+ {
1830
+ field: "lastRequest",
1831
+ operator: "gt",
1832
+ value: decision.windowStart
1833
+ },
1834
+ {
1835
+ field: "requestCount",
1836
+ operator: "lt",
1837
+ value: decision.max
1756
1838
  }
1757
- });
1758
- if (dbUpdated) await setApiKey(ctx, dbUpdated, opts);
1759
- return dbUpdated;
1760
- } else {
1761
- await setApiKey(ctx, updated, opts);
1762
- return updated;
1839
+ ],
1840
+ increment: { requestCount: 1 },
1841
+ set: { lastRequest: decision.now }
1842
+ });
1843
+ if (incremented) return incremented;
1844
+ const fresh = await ctx.context.adapter.findOne({
1845
+ model: API_KEY_TABLE_NAME,
1846
+ where: [{
1847
+ field: "id",
1848
+ value: apiKey.id
1849
+ }]
1850
+ });
1851
+ if (!fresh) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.INVALID_API_KEY);
1852
+ return consumeRateLimit(ctx, fresh, opts);
1853
+ }
1854
+ const windowGuard = decision.type === "reset" ? {
1855
+ field: "lastRequest",
1856
+ operator: "lte",
1857
+ value: decision.windowStart
1858
+ } : {
1859
+ field: "lastRequest",
1860
+ operator: "eq",
1861
+ value: null
1862
+ };
1863
+ const started = await ctx.context.adapter.incrementOne({
1864
+ model: API_KEY_TABLE_NAME,
1865
+ where: [{
1866
+ field: "id",
1867
+ value: apiKey.id
1868
+ }, windowGuard],
1869
+ increment: {},
1870
+ set: {
1871
+ requestCount: 1,
1872
+ lastRequest: decision.now
1873
+ }
1874
+ });
1875
+ if (started) return started;
1876
+ const fresh = await ctx.context.adapter.findOne({
1877
+ model: API_KEY_TABLE_NAME,
1878
+ where: [{
1879
+ field: "id",
1880
+ value: apiKey.id
1881
+ }]
1882
+ });
1883
+ if (!fresh) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.INVALID_API_KEY);
1884
+ return consumeRateLimit(ctx, fresh, opts);
1885
+ }
1886
+ /**
1887
+ * Secondary-storage-only mode has no database row to guard, so quota and
1888
+ * rate-limit consumption stays a read-modify-write merge over the serialized
1889
+ * key. This is the residual non-atomic path; strict enforcement requires the
1890
+ * database (use `fallbackToDatabase`) or an atomic secondary-storage primitive.
1891
+ * FIXME(api-key-secondary-atomic): back this with SecondaryStorage.increment on
1892
+ * `next` so secondary-storage-only mode enforces quota and rate limits atomically.
1893
+ */
1894
+ async function claimUsageInSecondaryStorage({ ctx, apiKey, opts, hashedKey }) {
1895
+ let remaining = apiKey.remaining;
1896
+ let lastRefillAt = apiKey.lastRefillAt;
1897
+ if (remaining !== null) {
1898
+ const now = Date.now();
1899
+ const { refillInterval, refillAmount } = apiKey;
1900
+ const lastTime = new Date(lastRefillAt ?? apiKey.createdAt).getTime();
1901
+ if (refillInterval && refillAmount && now - lastTime > refillInterval) {
1902
+ remaining = refillAmount;
1903
+ lastRefillAt = /* @__PURE__ */ new Date();
1763
1904
  }
1905
+ if (remaining === 0) throw APIError$1.from("TOO_MANY_REQUESTS", API_KEY_ERROR_CODES.USAGE_EXCEEDED);
1906
+ remaining--;
1907
+ }
1908
+ const mutations = {
1909
+ ...applyRateLimitToSnapshot(apiKey, opts),
1910
+ remaining,
1911
+ lastRefillAt,
1912
+ updatedAt: /* @__PURE__ */ new Date()
1913
+ };
1914
+ const performUpdate = async () => {
1915
+ const fresh = await getApiKey$1(ctx, hashedKey, opts);
1916
+ if (!fresh) return null;
1917
+ const merged = {
1918
+ ...fresh,
1919
+ ...mutations
1920
+ };
1921
+ await setApiKey(ctx, merged, opts);
1922
+ return merged;
1764
1923
  };
1765
- let newApiKey = null;
1766
1924
  if (opts.deferUpdates) {
1767
1925
  ctx.context.runInBackground(performUpdate().catch((error) => {
1768
1926
  ctx.context.logger.error("Failed to update API key:", error);
1769
1927
  }));
1770
- newApiKey = updated;
1771
- } else {
1772
- newApiKey = await performUpdate();
1773
- if (!newApiKey) throw APIError$1.from("INTERNAL_SERVER_ERROR", API_KEY_ERROR_CODES.FAILED_TO_UPDATE_API_KEY);
1928
+ return {
1929
+ ...apiKey,
1930
+ ...mutations
1931
+ };
1932
+ }
1933
+ const updated = await performUpdate();
1934
+ if (!updated) throw APIError$1.from("INTERNAL_SERVER_ERROR", API_KEY_ERROR_CODES.FAILED_TO_UPDATE_API_KEY);
1935
+ return updated;
1936
+ }
1937
+ /**
1938
+ * Translate a rate-limit decision into a counter snapshot for the
1939
+ * secondary-storage merge write. Denials throw before any write.
1940
+ */
1941
+ function applyRateLimitToSnapshot(apiKey, opts) {
1942
+ const decision = evaluateRateLimit(apiKey, opts);
1943
+ switch (decision.type) {
1944
+ case "deny": throw new APIError$1("TOO_MANY_REQUESTS", {
1945
+ message: decision.message,
1946
+ code: "RATE_LIMITED",
1947
+ details: { tryAgainIn: decision.tryAgainIn }
1948
+ });
1949
+ case "skip": return decision.lastRequest === null ? {} : { lastRequest: decision.lastRequest };
1950
+ case "start":
1951
+ case "reset": return {
1952
+ lastRequest: decision.now,
1953
+ requestCount: 1
1954
+ };
1955
+ case "increment": return {
1956
+ lastRequest: decision.now,
1957
+ requestCount: apiKey.requestCount + 1
1958
+ };
1774
1959
  }
1775
- return {
1776
- apiKey: newApiKey,
1777
- opts
1778
- };
1779
1960
  }
1780
1961
  const verifyApiKeyBodySchema = z.object({
1781
1962
  configId: z.string().meta({ description: "Configuration ID to scope verification to. When omitted, the key is validated against its own configuration." }).optional(),
@@ -1783,7 +1964,7 @@ const verifyApiKeyBodySchema = z.object({
1783
1964
  permissions: z.record(z.string(), z.array(z.string())).meta({ description: "The permissions to verify." }).optional()
1784
1965
  });
1785
1966
  function verifyApiKey({ configurations, schema, deleteAllExpiredApiKeys }) {
1786
- return createAuthEndpoint({
1967
+ return createAuthEndpoint.serverOnly({
1787
1968
  method: "POST",
1788
1969
  body: verifyApiKeyBodySchema
1789
1970
  }, async (ctx) => {
@@ -1,6 +1,7 @@
1
1
  import * as better_auth0 from "better-auth";
2
2
  import { Statements } from "better-auth/plugins/access";
3
3
  import { Awaitable, GenericEndpointContext, HookEndpointContext, LiteralString } from "@better-auth/core";
4
+ import { SecondaryStorage } from "@better-auth/core/db";
4
5
  import { InferOptionSchema } from "better-auth/types";
5
6
 
6
7
  //#region src/schema.d.ts
@@ -417,20 +418,7 @@ interface ApiKeyConfigurationOptions {
417
418
  * Useful when you want to use a different storage backend specifically for API keys,
418
419
  * or when you need custom logic for storage operations.
419
420
  */
420
- customStorage?: {
421
- /**
422
- * Get a value from storage
423
- */
424
- get: (key: string) => Awaitable<unknown>;
425
- /**
426
- * Set a value in storage
427
- */
428
- set: (key: string, value: string, ttl?: number | undefined) => Awaitable<void | null | unknown>;
429
- /**
430
- * Delete a value from storage
431
- */
432
- delete: (key: string) => Awaitable<void | null | string>;
433
- } | undefined;
421
+ customStorage?: SecondaryStorage | undefined;
434
422
  /**
435
423
  * Defer non-critical updates (rate limiting counters, timestamps, remaining count)
436
424
  * to run after the response is sent using the global `advanced.backgroundTasks` handler.
package/dist/types.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { n as ApiKeyConfigurationOptions, r as ApiKeyOptions, t as ApiKey } from "./types-BR70O3Q3.mjs";
1
+ import { n as ApiKeyConfigurationOptions, r as ApiKeyOptions, t as ApiKey } from "./types-CbtANSbR.mjs";
2
2
  export { ApiKey, ApiKeyConfigurationOptions, ApiKeyOptions };
@@ -35,6 +35,6 @@ const API_KEY_ERROR_CODES = defineErrorCodes({
35
35
  });
36
36
  //#endregion
37
37
  //#region src/version.ts
38
- const PACKAGE_VERSION = "1.7.0-beta.4";
38
+ const PACKAGE_VERSION = "1.7.0-beta.6";
39
39
  //#endregion
40
40
  export { API_KEY_ERROR_CODES as n, PACKAGE_VERSION as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-auth/api-key",
3
- "version": "1.7.0-beta.4",
3
+ "version": "1.7.0-beta.6",
4
4
  "description": "API Key plugin for Better Auth.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -61,14 +61,14 @@
61
61
  },
62
62
  "devDependencies": {
63
63
  "tsdown": "0.21.1",
64
- "@better-auth/core": "1.7.0-beta.4",
65
- "better-auth": "1.7.0-beta.4"
64
+ "@better-auth/core": "1.7.0-beta.6",
65
+ "better-auth": "1.7.0-beta.6"
66
66
  },
67
67
  "peerDependencies": {
68
- "@better-auth/utils": "0.4.1",
69
- "better-call": "1.3.5",
70
- "@better-auth/core": "^1.7.0-beta.4",
71
- "better-auth": "^1.7.0-beta.4"
68
+ "@better-auth/utils": "0.4.2",
69
+ "better-call": "1.3.6",
70
+ "@better-auth/core": "^1.7.0-beta.6",
71
+ "better-auth": "^1.7.0-beta.6"
72
72
  },
73
73
  "scripts": {
74
74
  "build": "tsdown",