From c9d37e4ec515da332722b9a3b87b686c7f43f31d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 22:19:30 +0000 Subject: [PATCH 1/3] fix(auth): implement atomic increment for better-auth rate-limit storage better-auth logs "Rate limiting is best-effort" and falls back to a non-atomic get-then-set check whenever its secondaryStorage lacks an increment(key, ttl) method. Add createAuthSecondaryStorage(), which wraps the Cloudflare KV / fsLite-backed wolfstar:auth-ratelimiter mount with a fixed-window increment guarded by an in-process keyed mutex (same pattern as server/utils/wrappedEventHandler.ts), so better-auth uses its atomic consume path instead. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01L3DbZF2CQPuxRnBFWHjVxS --- server/auth.config.ts | 14 +-- server/utils/auth-rate-limit-storage.ts | 68 ++++++++++++ .../utils/auth-rate-limit-storage.spec.ts | 100 ++++++++++++++++++ 3 files changed, 172 insertions(+), 10 deletions(-) create mode 100644 server/utils/auth-rate-limit-storage.ts create mode 100644 test/unit/server/utils/auth-rate-limit-storage.spec.ts diff --git a/server/auth.config.ts b/server/auth.config.ts index 0496c8a6..3be91912 100644 --- a/server/auth.config.ts +++ b/server/auth.config.ts @@ -1,20 +1,14 @@ -import type { SecondaryStorage } from "better-auth"; import type { DiscordProfile } from "better-auth/social-providers"; +import { createAuthSecondaryStorage } from "#server/utils/auth-rate-limit-storage"; import { invalidateCurrentUserCache } from "#server/utils/discord/cache"; import { runtimeConfig } from "#server/utils/runtimeConfig"; import { defineServerAuth } from "@onmax/nuxt-better-auth/config"; import { createAuthMiddleware } from "better-auth/api"; import { isDevelopment } from "std-env"; -const authRateLimitStorage = useStorage("wolfstar:auth-ratelimiter"); - -// Adapts the Nitro/unstorage mount above to better-auth's SecondaryStorage -// shape so its rate-limit counters survive across serverless invocations. -const authSecondaryStorage: SecondaryStorage = { - get: (key) => authRateLimitStorage.getItem(key), - set: (key, value) => authRateLimitStorage.setItem(key, value), - delete: (key) => authRateLimitStorage.removeItem(key), -}; +// Adapts the Nitro/unstorage mount to better-auth's SecondaryStorage shape so +// its rate-limit counters survive across serverless invocations. +const authSecondaryStorage = createAuthSecondaryStorage(useStorage("wolfstar:auth-ratelimiter")); export default defineServerAuth(() => ({ socialProviders: { diff --git a/server/utils/auth-rate-limit-storage.ts b/server/utils/auth-rate-limit-storage.ts new file mode 100644 index 00000000..3ef8c0df --- /dev/null +++ b/server/utils/auth-rate-limit-storage.ts @@ -0,0 +1,68 @@ +import type { SecondaryStorage } from "better-auth"; +import { Collection } from "@discordjs/collection"; +import { AsyncQueue } from "@sapphire/async-queue"; + +interface AuthRateLimitCounter { + count: number; + expiresAt: number; +} + +/** The subset of unstorage's `Storage` interface this adapter relies on. */ +export interface AuthRateLimitStorageDriver { + getItem: (key: string) => Promise; + setItem: (key: string, value: T, opts?: { ttl?: number }) => Promise; + removeItem: (key: string) => Promise; +} + +/** + * Adapts a Nitro/unstorage mount to better-auth's `SecondaryStorage` shape, + * including an atomic-ish `increment` so better-auth's rate limiter can use + * its single-step `consume` path instead of the non-atomic get-then-set + * fallback. + */ +export function createAuthSecondaryStorage(storage: AuthRateLimitStorageDriver): SecondaryStorage { + // In-process keyed mutex serializing increment() read-modify-write cycles + // per storage key, mirroring server/utils/wrappedEventHandler.ts. The + // production driver (Cloudflare KV over HTTP) exposes no atomic increment + // primitive, so this only bounds races to a single server instance; + // cross-instance races remain possible and are an accepted limitation of + // the storage backend. + const incrementKeyLocks = new Collection(); + + async function withIncrementLock(key: string, fn: () => Promise): Promise { + const queue = incrementKeyLocks.ensure(key, () => new AsyncQueue()); + await queue.wait(); + try { + return await fn(); + } finally { + queue.shift(); + if (queue.remaining === 0) { + incrementKeyLocks.delete(key); + } + } + } + + return { + get: (key) => storage.getItem(key), + set: (key, value) => storage.setItem(key, value), + delete: (key) => storage.removeItem(key), + // Fixed-window counter: `ttl` (seconds) is only applied when the window + // is (re)created, and every write re-derives the remaining time until + // that same expiry so later increments never push the window further out. + increment: (key, ttl) => + withIncrementLock(key, async () => { + const now = Date.now(); + const existing = await storage.getItem(key); + const isFresh = !existing || existing.expiresAt <= now; + const expiresAt = isFresh ? now + ttl * 1000 : existing.expiresAt; + const count = isFresh ? 1 : existing.count + 1; + const remainingTtl = Math.max(1, Math.ceil((expiresAt - now) / 1000)); + await storage.setItem( + key, + { count, expiresAt }, + { ttl: remainingTtl }, + ); + return count; + }), + }; +} diff --git a/test/unit/server/utils/auth-rate-limit-storage.spec.ts b/test/unit/server/utils/auth-rate-limit-storage.spec.ts new file mode 100644 index 00000000..d5dc7100 --- /dev/null +++ b/test/unit/server/utils/auth-rate-limit-storage.spec.ts @@ -0,0 +1,100 @@ +import { createAuthSecondaryStorage } from "#server/utils/auth-rate-limit-storage"; +import { beforeEach, describe, expect, it } from "vitest"; + +/** + * `increment()` backs better-auth's atomic `consume` path (see + * server/auth.config.ts). Without it, better-auth falls back to a + * non-atomic get-then-set check that logs: + * "Rate limiting is best-effort: the configured storage has no atomic + * consume...". + * + * Invariants: + * 1. Counts increment within a fixed window and reset once it expires. + * 2. The window's expiry is fixed at creation; later increments never push + * it further out. + * 3. Concurrent increments for the same key are serialized by an in-process + * keyed mutex, so none are lost to a stale read. + */ + +function createFakeStorage() { + const state = new Map(); + return { + state, + storage: { + getItem: async (key: string) => state.get(key)?.value ?? null, + setItem: async (key: string, value: unknown, opts?: { ttl?: number }) => { + state.set(key, { value, ttl: opts?.ttl }); + }, + removeItem: async (key: string) => { + state.delete(key); + }, + }, + }; +} + +describe("createAuthSecondaryStorage", () => { + let fake: ReturnType; + let secondaryStorage: ReturnType; + + beforeEach(() => { + fake = createFakeStorage(); + secondaryStorage = createAuthSecondaryStorage(fake.storage); + }); + + it("starts a fresh window at count 1", async () => { + const count = await secondaryStorage.increment?.("key", 60); + expect(count).toBe(1); + }); + + it("increments the count within the same window", async () => { + await secondaryStorage.increment?.("key", 60); + await secondaryStorage.increment?.("key", 60); + const count = await secondaryStorage.increment?.("key", 60); + expect(count).toBe(3); + }); + + it("does not extend the window's expiry on later increments", async () => { + await secondaryStorage.increment?.("key", 60); + const firstEntry = fake.state.get("key")?.value as { expiresAt: number } | undefined; + + await secondaryStorage.increment?.("key", 60); + const secondEntry = fake.state.get("key")?.value as { expiresAt: number } | undefined; + + expect(secondEntry?.expiresAt).toBe(firstEntry?.expiresAt); + }); + + it("resets the count once the window has expired", async () => { + await secondaryStorage.increment?.("key", 60); + // Force the stored window into the past so the next increment sees it as expired + const entry = fake.state.get("key"); + const value = entry?.value as { count: number; expiresAt: number }; + fake.state.set("key", { value: { ...value, expiresAt: Date.now() - 1 } }); + + const count = await secondaryStorage.increment?.("key", 60); + expect(count).toBe(1); + }); + + it("serializes concurrent increments for the same key", async () => { + const results = await Promise.all( + Array.from({ length: 10 }, () => secondaryStorage.increment?.("key", 60)), + ); + + expect(new Set(results).size).toBe(10); + expect(Math.max(...results.map((r) => r ?? 0))).toBe(10); + }); + + it("keeps counters for different keys independent", async () => { + const a = await secondaryStorage.increment?.("key-a", 60); + const b = await secondaryStorage.increment?.("key-b", 60); + expect(a).toBe(1); + expect(b).toBe(1); + }); + + it("still supports get/set/delete for non-consume storage reads", async () => { + await secondaryStorage.set("plain-key", "plain-value"); + expect(await secondaryStorage.get("plain-key")).toBe("plain-value"); + + await secondaryStorage.delete("plain-key"); + expect(await secondaryStorage.get("plain-key")).toBeNull(); + }); +}); From 6bd54c52df0320c43443fce66ce4bce85b559458 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 22:20:11 +0000 Subject: [PATCH 2/3] chore: sync skilld shipped skills lockfile Adds dotenv, dotenvx, and vite-plus entries picked up by `skilld prepare` during `pnpm install`. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01L3DbZF2CQPuxRnBFWHjVxS --- .claude/skills/dotenv | 1 + .claude/skills/dotenvx | 1 + .claude/skills/skilld-lock.yaml | 18 ++++++++++++++++++ .claude/skills/vite-plus | 1 + 4 files changed, 21 insertions(+) create mode 120000 .claude/skills/dotenv create mode 120000 .claude/skills/dotenvx create mode 120000 .claude/skills/vite-plus diff --git a/.claude/skills/dotenv b/.claude/skills/dotenv new file mode 120000 index 00000000..5b214870 --- /dev/null +++ b/.claude/skills/dotenv @@ -0,0 +1 @@ +/home/user/wolfstar.rocks/node_modules/dotenv/skills/dotenv \ No newline at end of file diff --git a/.claude/skills/dotenvx b/.claude/skills/dotenvx new file mode 120000 index 00000000..0cb0f270 --- /dev/null +++ b/.claude/skills/dotenvx @@ -0,0 +1 @@ +/home/user/wolfstar.rocks/node_modules/dotenv/skills/dotenvx \ No newline at end of file diff --git a/.claude/skills/skilld-lock.yaml b/.claude/skills/skilld-lock.yaml index 6f7c22a8..61d47c06 100644 --- a/.claude/skills/skilld-lock.yaml +++ b/.claude/skills/skilld-lock.yaml @@ -418,3 +418,21 @@ skills: source: "ungh://sxzz/stale-dep" syncedAt: 2026-07-16 generator: skilld + dotenv: + packageName: dotenv + version: 17.4.2 + source: shipped + syncedAt: 2026-07-19 + generator: skilld + dotenvx: + packageName: dotenv + version: 17.4.2 + source: shipped + syncedAt: 2026-07-19 + generator: skilld + vite-plus: + packageName: vite-plus + version: 0.1.19 + source: shipped + syncedAt: 2026-07-19 + generator: skilld diff --git a/.claude/skills/vite-plus b/.claude/skills/vite-plus new file mode 120000 index 00000000..294ad40c --- /dev/null +++ b/.claude/skills/vite-plus @@ -0,0 +1 @@ +/home/user/wolfstar.rocks/node_modules/vite-plus/skills/vite-plus \ No newline at end of file From d9e8506bf7e19f51450e715a6eda4a312264cb5f Mon Sep 17 00:00:00 2001 From: RedStar071 Date: Mon, 20 Jul 2026 10:10:49 +0000 Subject: [PATCH 3/3] chore: drop machine-local shipped skill symlinks The skilld lockfile sync committed absolute symlinks (dotenv, dotenvx, vite-plus) pointing at /home/user/.../node_modules paths that only exist on the author's machine, so they resolve to broken skills in any fresh checkout or CI workspace. Remove the symlinks and their shipped lock entries. Co-authored-by: Codesmith --- .claude/skills/dotenv | 1 - .claude/skills/dotenvx | 1 - .claude/skills/skilld-lock.yaml | 18 ------------------ .claude/skills/vite-plus | 1 - 4 files changed, 21 deletions(-) delete mode 120000 .claude/skills/dotenv delete mode 120000 .claude/skills/dotenvx delete mode 120000 .claude/skills/vite-plus diff --git a/.claude/skills/dotenv b/.claude/skills/dotenv deleted file mode 120000 index 5b214870..00000000 --- a/.claude/skills/dotenv +++ /dev/null @@ -1 +0,0 @@ -/home/user/wolfstar.rocks/node_modules/dotenv/skills/dotenv \ No newline at end of file diff --git a/.claude/skills/dotenvx b/.claude/skills/dotenvx deleted file mode 120000 index 0cb0f270..00000000 --- a/.claude/skills/dotenvx +++ /dev/null @@ -1 +0,0 @@ -/home/user/wolfstar.rocks/node_modules/dotenv/skills/dotenvx \ No newline at end of file diff --git a/.claude/skills/skilld-lock.yaml b/.claude/skills/skilld-lock.yaml index 61d47c06..6f7c22a8 100644 --- a/.claude/skills/skilld-lock.yaml +++ b/.claude/skills/skilld-lock.yaml @@ -418,21 +418,3 @@ skills: source: "ungh://sxzz/stale-dep" syncedAt: 2026-07-16 generator: skilld - dotenv: - packageName: dotenv - version: 17.4.2 - source: shipped - syncedAt: 2026-07-19 - generator: skilld - dotenvx: - packageName: dotenv - version: 17.4.2 - source: shipped - syncedAt: 2026-07-19 - generator: skilld - vite-plus: - packageName: vite-plus - version: 0.1.19 - source: shipped - syncedAt: 2026-07-19 - generator: skilld diff --git a/.claude/skills/vite-plus b/.claude/skills/vite-plus deleted file mode 120000 index 294ad40c..00000000 --- a/.claude/skills/vite-plus +++ /dev/null @@ -1 +0,0 @@ -/home/user/wolfstar.rocks/node_modules/vite-plus/skills/vite-plus \ No newline at end of file