@haruhimemoe/next-kit 0.4.0 → 0.5.0

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 CHANGED
@@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.0] - 2026-10-04
10
+
11
+ ### Added
12
+ - `buildSecurityTxt` takes `contactUrl`, listed before the email.
13
+ - `api-keys` entry point: the shared key format (`h` + two letters + `_`, then 32 random bytes), `createApiKeyStore` over `api_keys`, and `createApiKeyGuard` with `API_LIMITS` for `/api/v1` routes. Moved from packs.
14
+ - `next-kit check` bin: fails when an app is missing a standard route.
15
+
16
+ ### Changed
17
+ - `@haruhimemoe/ui` peer range now covers 0.5 through 0.9.
18
+
9
19
  ## [0.4.0] - 2026-09-28
10
20
 
11
21
  ### Added
@@ -52,7 +62,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
52
62
  - `@haruhimemoe/next-kit/auth-react`: `createSignedInMarker`, `createAccountStore`, `useAccount`, `createAccount`, `RestoreSignedIn` and `osuSignIn`.
53
63
  - `@haruhimemoe/next-kit/testing`: `startMemoryMongo`, `setupTestDb`, `setupMsw` and the fake osu! app env.
54
64
 
55
- [unreleased]: https://github.com/haruhimemoe/next-kit/compare/v0.4.0...HEAD
65
+ [unreleased]: https://github.com/haruhimemoe/next-kit/compare/v0.5.0...HEAD
66
+ [0.5.0]: https://github.com/haruhimemoe/next-kit/compare/v0.4.0...v0.5.0
56
67
  [0.4.0]: https://github.com/haruhimemoe/next-kit/compare/v0.3.0...v0.4.0
57
68
  [0.3.0]: https://github.com/haruhimemoe/next-kit/compare/v0.2.1...v0.3.0
58
69
  [0.2.1]: https://github.com/haruhimemoe/next-kit/compare/v0.2.0...v0.2.1
package/README.md CHANGED
@@ -11,6 +11,7 @@ The Next.js server plumbing the haruhime.moe tools share. [packs.haruhime.moe](h
11
11
  - **`/auth-react`:** the browser half: the signed-in marker cookie, the account store and `useAccount`, `RestoreSignedIn`, and the account components (Sign in with osu!, Sign out, the header's account menu, Delete my account) styled with `@haruhimemoe/ui`.
12
12
  - **`/seo`:** Next.js metadata that keeps each page's canonical, og:url and preview image together, robots.txt with the AI crawler stance written down, sitemap entries with honest lastmod, schema.org JSON-LD builders, and llms.txt. No runtime imports; all four haruhime.moe sites use it.
13
13
  - **`/testing`:** Vitest helpers: one in-memory MongoDB per run, an msw server that refuses unhandled requests, and a fake env.
14
+ - **`/api-keys`:** the shared key format (an app prefix like `hpk_` plus 32 random bytes), a key store over `api_keys`, and the `/api/v1` guard with the standard limits.
14
15
 
15
16
  Every name, path, limit and message comes from the caller. There is no root entry point; import a subpath.
16
17
 
@@ -29,9 +30,10 @@ bun add @haruhimemoe/next-kit zod
29
30
  | `env` | nothing else |
30
31
  | `mongo` | `mongodb` ^7.6.0, `mongoose` ^9.10.2 |
31
32
  | `auth` | `better-auth` ^1.7.5, `mongodb`, `@haruhimemoe/osu` 0.2 or 0.3 |
32
- | `auth-react` | `react` ^19.3.0, `next` ^16.3.6, `@haruhimemoe/ui` ^0.5.0 (with its theme set up) |
33
+ | `auth-react` | `react` ^19.3.0, `next` ^16.3.6, `@haruhimemoe/ui` ^0.5.0 \|\| ^0.6.0 \|\| ^0.7.0 \|\| ^0.8.0 \|\| ^0.9.0 (with its theme set up) |
33
34
  | `seo` | `next` ^16.3.6 types only (nothing loads at runtime) |
34
35
  | `testing` | `vitest` ^5.0.1, `msw` ^2.15.0, `mongodb-memory-server` ^11.3.0 |
36
+ | `api-keys` | `mongodb` ^7.6.0 |
35
37
 
36
38
  ## Use
37
39
 
@@ -147,6 +149,22 @@ export default async () => sitemapEntries(SEO_SITE, [["/", "/search"], pools.map
147
149
  export const GET = () => textResponse(llmsTxt({ title: "pools.haruhime.moe", summary: SEO_SITE.description, sections }));
148
150
  ```
149
151
 
152
+ ## Standards check
153
+
154
+ `next-kit check [dir]` walks `src/app` (default: the current directory) and confirms every standard route exists, so CI catches a missing one before a page does:
155
+
156
+ ```sh
157
+ bunx next-kit check
158
+ ```
159
+
160
+ It always checks the crawl files: `robots.ts`, `sitemap.ts`, `llms.txt/route.ts`, `llms-full.txt/route.ts`, and `.well-known/security.txt/route.ts` (each also accepted as a route handler, like `robots.txt/route.ts`). Once an app has `src/app/api/v1/`, it also checks the public API: `api/v1/me/route.ts`, `api/v1/openapi.json/route.ts`, `api/me/api-key/route.ts`, and a `docs/api` page (a dynamic `docs/[slug]/page.tsx` counts too). Route groups like `(public)/` are ignored, since they don't change the URL.
161
+
162
+ The command prints one `pass` or `FAIL` line per standard, names each missing file, and exits 1 on a failure (or when `src/app` is missing). Add it to CI:
163
+
164
+ ```yaml
165
+ - run: bunx next-kit check
166
+ ```
167
+
150
168
  ## API
151
169
 
152
170
  ### server
@@ -167,7 +185,7 @@ export const GET = () => textResponse(llmsTxt({ title: "pools.haruhime.moe", sum
167
185
  | `refuseWithoutBearer(request, { secret, label, notConfigured, failures?, noStore? })` | Machine auth: null for the right `Bearer` secret, else 503 `not_configured`, 401, or 429 when failures are counted. |
168
186
  | `sameSecret(given, secret)`, `bearerToken(headers)` | SHA-256 digests compared with `timingSafeEqual`, and the token after `Bearer `. |
169
187
  | `safeNextPath(raw, { fallback, signInPath? })`, `signInHref(next, signInPath?)` | A same-site path to go to after sign-in (never the sign-in page), and the link carrying it. |
170
- | `buildSecurityTxt({ contactEmail, siteUrl, policyUrl, now })` | The RFC 9116 body, expiring a year after `now`. |
188
+ | `buildSecurityTxt({ contactEmail, siteUrl, policyUrl, now, contactUrl? })` | The RFC 9116 body, expiring a year after `now`, with optional `contactUrl` listed before the email. |
171
189
 
172
190
  ### env
173
191
 
@@ -251,6 +269,27 @@ Since 0.3.0. Every helper takes the app's `Site`: `name`, `url` (the canonical o
251
269
  | `setupMsw(...handlers)` | An msw server for the file; an unhandled request is an error. |
252
270
  | `TEST_OSU_APP_ENV`, `stubOsuAppEnv(overrides?)`, `stubEnv(values)` | A valid fake env and `vi.stubEnv` helpers. |
253
271
 
272
+ ### api-keys
273
+
274
+ | Export | What it does |
275
+ | --- | --- |
276
+ | `API_KEY_BYTES` | Random bytes in a key: 32, shown as 43 base64url characters. |
277
+ | `API_KEY_DISPLAY_LENGTH` | Characters of a key shown on account pages and in data exports (prefix + 8): 12. |
278
+ | `API_KEY_PREFIX_PATTERN` | Every app prefix: `h`, two lowercase letters, `_`. |
279
+ | `generateApiKey(prefix)` | A new key for that prefix; show it once and store only `hashApiKey(key)`. Throws `TypeError` on a bad prefix. |
280
+ | `hashApiKey(key)` | A key's SHA-256 hex digest, the only form ever stored. |
281
+ | `apiKeyDisplay(key)` | A key's first `API_KEY_DISPLAY_LENGTH` characters. |
282
+ | `isApiKeyFormat(prefix, value)` | True only for that prefix plus 43 base64url characters; checked before hashing an untrusted token. |
283
+ | `apiKeyToken(headers)` | The single token after `Bearer ` (any case, extra spaces ignored) in a request's `authorization` header, or null. |
284
+ | `assertApiKeyPrefix(prefix)` | Throws `TypeError` unless the prefix matches `API_KEY_PREFIX_PATTERN`. |
285
+ | `API_KEYS_COLLECTION` | The collection every app keeps its keys in: `api_keys`. |
286
+ | `LAST_USED_INTERVAL_MS` | `lastUsedAt` is written at most this often (an hour), to save writes. |
287
+ | `apiKeyIndexSpecs(collection?)` | Unique `userId` and unique `hash` (`secret: true`, never logged), for the app's own index list. |
288
+ | `createApiKeyStore({ prefix, db, collection?, now? })` | One key per user over MongoDB: `issue(userId)`, `info(userId)`, `revoke(userId)`, `authenticate(key)` (the owner's user id and a `stamp()` to record the use), `deleteFor(userId)` and `ensureIndexes()`. Throws `TypeError` on a bad prefix. |
289
+ | `API_LIMITS` | The standard fixed-window limits every haruhime API uses: `api` (60/min per user), `apiWrite` (10/min per user, also counted by `api`), `authFail` (20/min per IP), `keyCreate` (10/hour per user). |
290
+ | `API_SERVER_ERROR` | The 500 message when a key lookup or handler throws. |
291
+ | `createApiKeyGuard({ store, limiter, resolveCaller, messages, limits?, now? })` | Returns `withApiKey(handler)`: a `/api/v1` route handler that runs `handler(request, caller, context)` only for a good key under `API_LIMITS`, with `RateLimit-*` headers, `Cache-Control: no-store`, a 401 with `WWW-Authenticate: Bearer` for a missing or bad key (counted per IP), and a JSON 500 for a thrown error. No CORS headers: the API is for servers and bots. |
292
+
254
293
  ## Migration
255
294
 
256
295
  Both apps can drop their copies for the subpaths above. Where the copies differed, this package keeps pools.haruhime.moe's behavior. What changes for packs.haruhime.moe:
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @file src/api-keys/format.ts
3
+ * @desc The haruhime API key format: an app prefix ("h" + two letters + "_", like hpk_ for packs)
4
+ * plus 32 random bytes in base64url. Only the SHA-256 hex digest is stored; the first 12
5
+ * characters are kept for display. Moved from packs (src/lib/api-key.ts) with the prefix
6
+ * passed in.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sat Oct 3, 2026
9
+ * @modified Sat Oct 3, 2026
10
+ */
11
+ /** Random bytes in a key: 43 base64url characters. */
12
+ export declare const API_KEY_BYTES = 32;
13
+ /** Characters of a key shown on account pages and in data exports (prefix + 8). */
14
+ export declare const API_KEY_DISPLAY_LENGTH = 12;
15
+ /** Every app prefix: "h", two lowercase letters, "_". */
16
+ export declare const API_KEY_PREFIX_PATTERN: RegExp;
17
+ /**
18
+ * @function assertApiKeyPrefix
19
+ * @param prefix {string} an app prefix, like "hpl_"
20
+ * @returns {void}
21
+ * @throws {TypeError} when the prefix isn't "h" + two lowercase letters + "_"
22
+ */
23
+ export declare const assertApiKeyPrefix: (prefix: string) => void;
24
+ /**
25
+ * @function generateApiKey
26
+ * @param prefix {string} the app prefix
27
+ * @returns {string} a new key; show it once and store only hashApiKey(key)
28
+ * @throws {TypeError} on a bad prefix
29
+ */
30
+ export declare const generateApiKey: (prefix: string) => string;
31
+ /**
32
+ * @function hashApiKey
33
+ * @param key {string} a full key
34
+ * @returns {string} its SHA-256 hex digest
35
+ */
36
+ export declare const hashApiKey: (key: string) => string;
37
+ /**
38
+ * @function apiKeyDisplay
39
+ * @param key {string} a full key
40
+ * @returns {string} its first API_KEY_DISPLAY_LENGTH characters
41
+ */
42
+ export declare const apiKeyDisplay: (key: string) => string;
43
+ /**
44
+ * @function isApiKeyFormat
45
+ * @param prefix {string} the app prefix
46
+ * @param value {string} an untrusted token
47
+ * @returns {boolean} true only for this prefix plus 43 base64url characters
48
+ */
49
+ export declare const isApiKeyFormat: (prefix: string, value: string) => boolean;
50
+ /**
51
+ * @function apiKeyToken
52
+ * @param headers {Headers} request headers
53
+ * @returns {string | null} the single token after "Bearer " (any case, extra spaces ignored),
54
+ * or null
55
+ */
56
+ export declare const apiKeyToken: (headers: Headers) => string | null;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @file src/api-keys/format.ts
3
+ * @desc The haruhime API key format: an app prefix ("h" + two letters + "_", like hpk_ for packs)
4
+ * plus 32 random bytes in base64url. Only the SHA-256 hex digest is stored; the first 12
5
+ * characters are kept for display. Moved from packs (src/lib/api-key.ts) with the prefix
6
+ * passed in.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sat Oct 3, 2026
9
+ * @modified Sat Oct 3, 2026
10
+ */
11
+ import { createHash, randomBytes } from "node:crypto";
12
+ /** Random bytes in a key: 43 base64url characters. */
13
+ export const API_KEY_BYTES = 32;
14
+ /** Characters of a key shown on account pages and in data exports (prefix + 8). */
15
+ export const API_KEY_DISPLAY_LENGTH = 12;
16
+ /** Every app prefix: "h", two lowercase letters, "_". */
17
+ export const API_KEY_PREFIX_PATTERN = /^h[a-z]{2}_$/;
18
+ const SECRET_LENGTH = Math.ceil((API_KEY_BYTES * 4) / 3);
19
+ const SECRET = new RegExp(`^[A-Za-z0-9_-]{${SECRET_LENGTH}}$`);
20
+ const BEARER = /^Bearer[ \t]+(\S+)$/i;
21
+ /**
22
+ * @function assertApiKeyPrefix
23
+ * @param prefix {string} an app prefix, like "hpl_"
24
+ * @returns {void}
25
+ * @throws {TypeError} when the prefix isn't "h" + two lowercase letters + "_"
26
+ */
27
+ export const assertApiKeyPrefix = (prefix) => {
28
+ if (!API_KEY_PREFIX_PATTERN.test(prefix)) {
29
+ throw new TypeError(`API key prefix must look like "hpk_", got ${JSON.stringify(prefix)}`);
30
+ }
31
+ };
32
+ /**
33
+ * @function generateApiKey
34
+ * @param prefix {string} the app prefix
35
+ * @returns {string} a new key; show it once and store only hashApiKey(key)
36
+ * @throws {TypeError} on a bad prefix
37
+ */
38
+ export const generateApiKey = (prefix) => {
39
+ assertApiKeyPrefix(prefix);
40
+ return `${prefix}${randomBytes(API_KEY_BYTES).toString("base64url")}`;
41
+ };
42
+ /**
43
+ * @function hashApiKey
44
+ * @param key {string} a full key
45
+ * @returns {string} its SHA-256 hex digest
46
+ */
47
+ export const hashApiKey = (key) => createHash("sha256").update(key, "utf8").digest("hex");
48
+ /**
49
+ * @function apiKeyDisplay
50
+ * @param key {string} a full key
51
+ * @returns {string} its first API_KEY_DISPLAY_LENGTH characters
52
+ */
53
+ export const apiKeyDisplay = (key) => key.slice(0, API_KEY_DISPLAY_LENGTH);
54
+ /**
55
+ * @function isApiKeyFormat
56
+ * @param prefix {string} the app prefix
57
+ * @param value {string} an untrusted token
58
+ * @returns {boolean} true only for this prefix plus 43 base64url characters
59
+ */
60
+ export const isApiKeyFormat = (prefix, value) => value.startsWith(prefix) && SECRET.test(value.slice(prefix.length));
61
+ /**
62
+ * @function apiKeyToken
63
+ * @param headers {Headers} request headers
64
+ * @returns {string | null} the single token after "Bearer " (any case, extra spaces ignored),
65
+ * or null
66
+ */
67
+ export const apiKeyToken = (headers) => BEARER.exec(headers.get("authorization")?.trim() ?? "")?.[1] ?? null;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @file src/api-keys/guard.ts
3
+ * @desc The /api/v1 guard every haruhime app uses: looks the Bearer key up; a missing or bad key
4
+ * counts against the caller's IP (auth-fail, 429 past it) and answers 401 with
5
+ * WWW-Authenticate: Bearer; a good key counts per user (api, and api-write for writes),
6
+ * showing whichever runs out first. Every answer gets RateLimit-* headers and no-store, a
7
+ * thrown error included (JSON 500). No CORS headers: the API is for servers and bots.
8
+ * Moved from packs (src/lib/api-auth.ts) with the user lookup and messages passed in.
9
+ * @author David @dvhsh (https://dvh.sh)
10
+ * @created Sat Oct 3, 2026
11
+ * @modified Sat Oct 3, 2026
12
+ */
13
+ import type { RateLimitRule } from "../server/counter.js";
14
+ import { type RateLimiter } from "../server/rate-limit.js";
15
+ import type { ApiKeyStore } from "./store.js";
16
+ /** The standard limits every haruhime API uses (fixed windows). */
17
+ export declare const API_LIMITS: {
18
+ /** Every /api/v1 request, per user. */
19
+ readonly api: {
20
+ readonly scope: "api";
21
+ readonly limit: 60;
22
+ readonly windowSeconds: 60;
23
+ };
24
+ /** POST/PUT/PATCH/DELETE on /api/v1, per user (also counted by api). */
25
+ readonly apiWrite: {
26
+ readonly scope: "api-write";
27
+ readonly limit: 10;
28
+ readonly windowSeconds: 60;
29
+ };
30
+ /** Missing, bad or revoked keys, per IP. */
31
+ readonly authFail: {
32
+ readonly scope: "auth-fail";
33
+ readonly limit: 20;
34
+ readonly windowSeconds: 60;
35
+ };
36
+ /** Creating or regenerating a key on the account page, per user. */
37
+ readonly keyCreate: {
38
+ readonly scope: "key-create";
39
+ readonly limit: 10;
40
+ readonly windowSeconds: 3600;
41
+ };
42
+ };
43
+ /** The rules the guard counts. */
44
+ export type ApiLimits = {
45
+ api: RateLimitRule;
46
+ apiWrite: RateLimitRule;
47
+ authFail: RateLimitRule;
48
+ };
49
+ /** The 500 message when a key lookup or handler throws. */
50
+ export declare const API_SERVER_ERROR = "Something went wrong on our end. Try again in a minute.";
51
+ /** createApiKeyGuard's options. */
52
+ export type ApiKeyGuardOptions<Caller> = {
53
+ store: Pick<ApiKeyStore, "authenticate">;
54
+ limiter: Pick<RateLimiter, "hit">;
55
+ /** Who a key's user is; null for a deleted or system account (answers 401). */
56
+ resolveCaller: (userId: string) => Promise<Caller | null>;
57
+ /** missing: no key sent (name the prefix); invalid: a bad, revoked or replaced key. */
58
+ messages: {
59
+ missing: string;
60
+ invalid: string;
61
+ serverError?: string;
62
+ };
63
+ limits?: ApiLimits;
64
+ now?: () => number;
65
+ };
66
+ /**
67
+ * @function createApiKeyGuard
68
+ * @param options {ApiKeyGuardOptions<Caller>} store, limiter, caller lookup, messages
69
+ * @returns {Function} withApiKey(handler): a route handler that runs handler(request, caller,
70
+ * context) only for a good key under its limits
71
+ */
72
+ export declare const createApiKeyGuard: <Caller extends {
73
+ id: string;
74
+ }>({ store, limiter, resolveCaller, messages, limits, now, }: ApiKeyGuardOptions<Caller>) => <C = unknown>(handler: (request: Request, caller: Caller, context: C) => Promise<Response>) => (request: Request, context: C) => Promise<Response>;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * @file src/api-keys/guard.ts
3
+ * @desc The /api/v1 guard every haruhime app uses: looks the Bearer key up; a missing or bad key
4
+ * counts against the caller's IP (auth-fail, 429 past it) and answers 401 with
5
+ * WWW-Authenticate: Bearer; a good key counts per user (api, and api-write for writes),
6
+ * showing whichever runs out first. Every answer gets RateLimit-* headers and no-store, a
7
+ * thrown error included (JSON 500). No CORS headers: the API is for servers and bots.
8
+ * Moved from packs (src/lib/api-auth.ts) with the user lookup and messages passed in.
9
+ * @author David @dvhsh (https://dvh.sh)
10
+ * @created Sat Oct 3, 2026
11
+ * @modified Sat Oct 3, 2026
12
+ */
13
+ import { clientIp, rateLimitSubject } from "../server/client-ip.js";
14
+ import { jsonError, withHeaders } from "../server/errors.js";
15
+ import { rateLimitHeaders, tooManyRequests, unlimited, } from "../server/rate-limit.js";
16
+ import { apiKeyToken } from "./format.js";
17
+ /** The standard limits every haruhime API uses (fixed windows). */
18
+ export const API_LIMITS = {
19
+ /** Every /api/v1 request, per user. */
20
+ api: { scope: "api", limit: 60, windowSeconds: 60 },
21
+ /** POST/PUT/PATCH/DELETE on /api/v1, per user (also counted by api). */
22
+ apiWrite: { scope: "api-write", limit: 10, windowSeconds: 60 },
23
+ /** Missing, bad or revoked keys, per IP. */
24
+ authFail: { scope: "auth-fail", limit: 20, windowSeconds: 60 },
25
+ /** Creating or regenerating a key on the account page, per user. */
26
+ keyCreate: { scope: "key-create", limit: 10, windowSeconds: 3600 },
27
+ };
28
+ /** The 500 message when a key lookup or handler throws. */
29
+ export const API_SERVER_ERROR = "Something went wrong on our end. Try again in a minute.";
30
+ const WRITE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
31
+ const finish = (response, limit) => withHeaders(response, { ...rateLimitHeaders(limit), "Cache-Control": "no-store" });
32
+ /**
33
+ * @function createApiKeyGuard
34
+ * @param options {ApiKeyGuardOptions<Caller>} store, limiter, caller lookup, messages
35
+ * @returns {Function} withApiKey(handler): a route handler that runs handler(request, caller,
36
+ * context) only for a good key under its limits
37
+ */
38
+ export const createApiKeyGuard = ({ store, limiter, resolveCaller, messages, limits = API_LIMITS, now = () => Date.now(), }) => {
39
+ /** Nothing counted yet (the key lookup threw): the per-user limit, untouched. */
40
+ const uncounted = () => unlimited(limits.api, now());
41
+ const serverError = (error, limit) => {
42
+ console.error("api: request failed", error);
43
+ return finish(jsonError(500, messages.serverError ?? API_SERVER_ERROR), limit);
44
+ };
45
+ const lookUp = async (token) => {
46
+ const match = token ? await store.authenticate(token) : null;
47
+ const caller = match ? await resolveCaller(match.userId) : null;
48
+ // Stamp only a key whose owner checks out (packs' order).
49
+ if (caller)
50
+ await match?.stamp();
51
+ return caller;
52
+ };
53
+ return (handler) => async (request, context) => {
54
+ const token = apiKeyToken(request.headers);
55
+ let caller;
56
+ try {
57
+ caller = await lookUp(token);
58
+ }
59
+ catch (error) {
60
+ return serverError(error, uncounted());
61
+ }
62
+ if (!caller) {
63
+ const failures = await limiter.hit(limits.authFail, rateLimitSubject(clientIp(request.headers)));
64
+ if (!failures.allowed)
65
+ return finish(tooManyRequests(failures), failures);
66
+ const body = token
67
+ ? jsonError(401, messages.invalid, "invalid_api_key")
68
+ : jsonError(401, messages.missing);
69
+ return finish(withHeaders(body, { "WWW-Authenticate": "Bearer" }), failures);
70
+ }
71
+ const requests = await limiter.hit(limits.api, caller.id);
72
+ if (!requests.allowed)
73
+ return finish(tooManyRequests(requests), requests);
74
+ let shown = requests;
75
+ if (WRITE_METHODS.has(request.method)) {
76
+ const writes = await limiter.hit(limits.apiWrite, caller.id);
77
+ if (!writes.allowed)
78
+ return finish(tooManyRequests(writes), writes);
79
+ // Show whichever counter runs out first.
80
+ if (writes.remaining < requests.remaining)
81
+ shown = writes;
82
+ }
83
+ try {
84
+ return finish(await handler(request, caller, context), shown);
85
+ }
86
+ catch (error) {
87
+ return serverError(error, shown);
88
+ }
89
+ };
90
+ };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @file src/api-keys/index.ts
3
+ * @desc @haruhimemoe/next-kit/api-keys: the API key format every haruhime app shares (an app
4
+ * prefix like hpk_ plus 32 random bytes), the key store over api_keys, and the /api/v1
5
+ * guard with the standard limits. Server only: loads node:crypto and mongodb.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Sat Oct 3, 2026
8
+ * @modified Sat Oct 3, 2026
9
+ */
10
+ export { API_KEY_BYTES, API_KEY_DISPLAY_LENGTH, API_KEY_PREFIX_PATTERN, apiKeyDisplay, apiKeyToken, assertApiKeyPrefix, generateApiKey, hashApiKey, isApiKeyFormat, } from "./format.js";
11
+ export { API_LIMITS, API_SERVER_ERROR, type ApiKeyGuardOptions, type ApiLimits, createApiKeyGuard, } from "./guard.js";
12
+ export { API_KEYS_COLLECTION, type ApiKeyCreated, type ApiKeyInfo, type ApiKeyMatch, type ApiKeyStore, type ApiKeyStoreOptions, apiKeyIndexSpecs, createApiKeyStore, LAST_USED_INTERVAL_MS, } from "./store.js";
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @file src/api-keys/index.ts
3
+ * @desc @haruhimemoe/next-kit/api-keys: the API key format every haruhime app shares (an app
4
+ * prefix like hpk_ plus 32 random bytes), the key store over api_keys, and the /api/v1
5
+ * guard with the standard limits. Server only: loads node:crypto and mongodb.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Sat Oct 3, 2026
8
+ * @modified Sat Oct 3, 2026
9
+ */
10
+ export { API_KEY_BYTES, API_KEY_DISPLAY_LENGTH, API_KEY_PREFIX_PATTERN, apiKeyDisplay, apiKeyToken, assertApiKeyPrefix, generateApiKey, hashApiKey, isApiKeyFormat, } from "./format.js";
11
+ export { API_LIMITS, API_SERVER_ERROR, createApiKeyGuard, } from "./guard.js";
12
+ export { API_KEYS_COLLECTION, apiKeyIndexSpecs, createApiKeyStore, LAST_USED_INTERVAL_MS, } from "./store.js";
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @file src/api-keys/store.ts
3
+ * @desc One API key per user in MongoDB (collection api_keys): issue (replaces), info, revoke,
4
+ * authenticate and account deletion. Only the SHA-256 hash and a display prefix are
5
+ * stored. Two issues racing still leave one key (unique userId; the loser updates).
6
+ * authenticate gives the owner's user id; who that user is stays the app's call.
7
+ * lastUsedAt is written at most once an hour. Moved from packs (src/services/api-keys.ts).
8
+ * @author David @dvhsh (https://dvh.sh)
9
+ * @created Sat Oct 3, 2026
10
+ * @modified Sat Oct 3, 2026
11
+ */
12
+ import { type Db } from "mongodb";
13
+ import { type IndexSpec } from "../mongo/indexes.js";
14
+ /** The collection every app keeps its keys in. */
15
+ export declare const API_KEYS_COLLECTION = "api_keys";
16
+ /** lastUsedAt is written at most this often, to save writes. */
17
+ export declare const LAST_USED_INTERVAL_MS: number;
18
+ /** A key as account pages show it. */
19
+ export type ApiKeyInfo = {
20
+ prefix: string;
21
+ createdAt: string;
22
+ lastUsedAt: string | null;
23
+ };
24
+ /** A freshly issued key: the full key, shown once, and its info. */
25
+ export type ApiKeyCreated = {
26
+ key: string;
27
+ apiKey: ApiKeyInfo;
28
+ };
29
+ /** A key that matched: its owner, and stamp() to record the use once the owner checks out. */
30
+ export type ApiKeyMatch = {
31
+ userId: string;
32
+ stamp: () => Promise<void>;
33
+ };
34
+ /** createApiKeyStore's options. */
35
+ export type ApiKeyStoreOptions = {
36
+ prefix: string;
37
+ db: () => Promise<Db>;
38
+ collection?: string;
39
+ now?: () => number;
40
+ };
41
+ /** What createApiKeyStore returns. */
42
+ export type ApiKeyStore = {
43
+ prefix: string;
44
+ issue: (userId: string) => Promise<ApiKeyCreated>;
45
+ info: (userId: string) => Promise<ApiKeyInfo | null>;
46
+ revoke: (userId: string) => Promise<boolean>;
47
+ authenticate: (key: string) => Promise<ApiKeyMatch | null>;
48
+ deleteFor: (userId: string) => Promise<number>;
49
+ ensureIndexes: () => Promise<void>;
50
+ };
51
+ /**
52
+ * @function apiKeyIndexSpecs
53
+ * @param collection {string} the keys collection (default api_keys)
54
+ * @returns {IndexSpec[]} unique userId and unique hash, for the app's own index list
55
+ */
56
+ export declare const apiKeyIndexSpecs: (collection?: string) => IndexSpec[];
57
+ /**
58
+ * @function createApiKeyStore
59
+ * @param options {ApiKeyStoreOptions} the app prefix, the database, the collection (default
60
+ * api_keys) and a clock (default Date.now)
61
+ * @returns {ApiKeyStore} the key operations for that app
62
+ * @throws {TypeError} on a bad prefix
63
+ */
64
+ export declare const createApiKeyStore: ({ prefix, db, collection, now, }: ApiKeyStoreOptions) => ApiKeyStore;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * @file src/api-keys/store.ts
3
+ * @desc One API key per user in MongoDB (collection api_keys): issue (replaces), info, revoke,
4
+ * authenticate and account deletion. Only the SHA-256 hash and a display prefix are
5
+ * stored. Two issues racing still leave one key (unique userId; the loser updates).
6
+ * authenticate gives the owner's user id; who that user is stays the app's call.
7
+ * lastUsedAt is written at most once an hour. Moved from packs (src/services/api-keys.ts).
8
+ * @author David @dvhsh (https://dvh.sh)
9
+ * @created Sat Oct 3, 2026
10
+ * @modified Sat Oct 3, 2026
11
+ */
12
+ import { ObjectId } from "mongodb";
13
+ import { isDuplicateKeyError } from "../mongo/duplicate.js";
14
+ import { ensureIndexes as buildIndexes } from "../mongo/indexes.js";
15
+ import { apiKeyDisplay, assertApiKeyPrefix, generateApiKey, hashApiKey, isApiKeyFormat, } from "./format.js";
16
+ /** The collection every app keeps its keys in. */
17
+ export const API_KEYS_COLLECTION = "api_keys";
18
+ /** lastUsedAt is written at most this often, to save writes. */
19
+ export const LAST_USED_INTERVAL_MS = 60 * 60 * 1000;
20
+ const toInfo = (doc) => ({
21
+ prefix: doc.prefix,
22
+ createdAt: doc.createdAt.toISOString(),
23
+ lastUsedAt: doc.lastUsedAt ? doc.lastUsedAt.toISOString() : null,
24
+ });
25
+ /**
26
+ * @function apiKeyIndexSpecs
27
+ * @param collection {string} the keys collection (default api_keys)
28
+ * @returns {IndexSpec[]} unique userId and unique hash, for the app's own index list
29
+ */
30
+ export const apiKeyIndexSpecs = (collection = API_KEYS_COLLECTION) => [
31
+ { collection, key: { userId: 1 }, unique: true },
32
+ // The hash is a credential digest: never log its values.
33
+ { collection, key: { hash: 1 }, unique: true, secret: true },
34
+ ];
35
+ /**
36
+ * @function createApiKeyStore
37
+ * @param options {ApiKeyStoreOptions} the app prefix, the database, the collection (default
38
+ * api_keys) and a clock (default Date.now)
39
+ * @returns {ApiKeyStore} the key operations for that app
40
+ * @throws {TypeError} on a bad prefix
41
+ */
42
+ export const createApiKeyStore = ({ prefix, db, collection = API_KEYS_COLLECTION,
43
+ // Read per call, so fake timers in app tests move it.
44
+ now = () => Date.now(), }) => {
45
+ assertApiKeyPrefix(prefix);
46
+ const keys = async () => (await db()).collection(collection);
47
+ const issue = async (userId) => {
48
+ const key = generateApiKey(prefix);
49
+ const filter = { userId: new ObjectId(userId) };
50
+ const update = {
51
+ $set: { prefix: apiKeyDisplay(key), hash: hashApiKey(key), createdAt: new Date(now()) },
52
+ $unset: { lastUsedAt: 1 },
53
+ };
54
+ const upsert = async () => (await keys()).findOneAndUpdate(filter, update, { upsert: true, returnDocument: "after" });
55
+ const doc = await upsert().catch((error) => {
56
+ if (!isDuplicateKeyError(error))
57
+ throw error;
58
+ return upsert();
59
+ });
60
+ if (!doc)
61
+ throw new Error("api key upsert returned no document");
62
+ return { key, apiKey: toInfo(doc) };
63
+ };
64
+ const authenticate = async (key) => {
65
+ if (!isApiKeyFormat(prefix, key))
66
+ return null;
67
+ const hash = hashApiKey(key);
68
+ const collectionRef = await keys();
69
+ const doc = await collectionRef.findOne({ hash });
70
+ if (!doc)
71
+ return null;
72
+ const stamp = async () => {
73
+ const at = now();
74
+ if (doc.lastUsedAt && at - doc.lastUsedAt.getTime() < LAST_USED_INTERVAL_MS)
75
+ return;
76
+ // Filter on the hash too, so a regenerate in between isn't stamped with this use.
77
+ await collectionRef.updateOne({ _id: doc._id, hash }, { $set: { lastUsedAt: new Date(at) } });
78
+ };
79
+ return { userId: doc.userId.toString(), stamp };
80
+ };
81
+ return {
82
+ prefix,
83
+ issue,
84
+ authenticate,
85
+ info: async (userId) => {
86
+ const doc = await (await keys()).findOne({ userId: new ObjectId(userId) });
87
+ return doc ? toInfo(doc) : null;
88
+ },
89
+ revoke: async (userId) => (await (await keys()).deleteOne({ userId: new ObjectId(userId) })).deletedCount === 1,
90
+ deleteFor: async (userId) => (await (await keys()).deleteMany({ userId: new ObjectId(userId) })).deletedCount,
91
+ ensureIndexes: async () => {
92
+ await buildIndexes(await db(), apiKeyIndexSpecs(collection));
93
+ },
94
+ };
95
+ };
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file src/check/cli.ts
4
+ * @desc `next-kit check [dir]`: lists src/app under dir (default: the working directory), runs
5
+ * checkStandards and prints one line per standard. Exits 1 when one fails or src/app is
6
+ * missing.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sat Oct 3, 2026
9
+ * @modified Sat Oct 3, 2026
10
+ */
11
+ /**
12
+ * @function runCheck
13
+ * @param root {string} the app's root (holds src/app)
14
+ * @param log {(line: string) => void} where lines go (default console.log)
15
+ * @returns {number} 0 when every standard passes, otherwise 1
16
+ */
17
+ export declare const runCheck: (root: string, log?: (line: string) => void) => number;
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file src/check/cli.ts
4
+ * @desc `next-kit check [dir]`: lists src/app under dir (default: the working directory), runs
5
+ * checkStandards and prints one line per standard. Exits 1 when one fails or src/app is
6
+ * missing.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sat Oct 3, 2026
9
+ * @modified Sat Oct 3, 2026
10
+ */
11
+ import { existsSync, readdirSync, realpathSync } from "node:fs";
12
+ import { join, relative, sep } from "node:path";
13
+ import { argv, cwd, exit } from "node:process";
14
+ import { pathToFileURL } from "node:url";
15
+ import { checkStandards } from "./standards.js";
16
+ const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((entry) => entry.isDirectory() ? walk(join(dir, entry.name)) : [join(dir, entry.name)]);
17
+ /**
18
+ * @function runCheck
19
+ * @param root {string} the app's root (holds src/app)
20
+ * @param log {(line: string) => void} where lines go (default console.log)
21
+ * @returns {number} 0 when every standard passes, otherwise 1
22
+ */
23
+ export const runCheck = (root, log = console.log) => {
24
+ const app = join(root, "src", "app");
25
+ if (!existsSync(app)) {
26
+ log(`next-kit check: no src/app in ${root}`);
27
+ return 1;
28
+ }
29
+ const files = walk(app).map((file) => relative(app, file).split(sep).join("/"));
30
+ const results = checkStandards(files);
31
+ for (const result of results) {
32
+ log(`${result.ok ? "pass" : "FAIL"} ${result.label}`);
33
+ for (const name of result.missing)
34
+ log(` missing src/app/${name}`);
35
+ }
36
+ return results.every((result) => result.ok) ? 0 : 1;
37
+ };
38
+ /* v8 ignore start */
39
+ /** True when this file is the one Node was asked to run, even through a symlinked bin: a
40
+ * symlinked `next-kit` resolves argv[1] to the link, while import.meta.url is the realpath. */
41
+ const isMainEntry = () => {
42
+ const entry = argv[1];
43
+ if (!entry || !existsSync(entry))
44
+ return false;
45
+ return pathToFileURL(realpathSync(entry)).href === import.meta.url;
46
+ };
47
+ if (isMainEntry()) {
48
+ const [command, dir] = argv.slice(2);
49
+ if (command !== "check") {
50
+ console.log("usage: next-kit check [dir]");
51
+ exit(2);
52
+ }
53
+ exit(runCheck(dir ?? cwd()));
54
+ }
55
+ /* v8 ignore stop */
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @file src/check/standards.ts
3
+ * @desc The routes every haruhime app serves, checked against an app's file list (paths under
4
+ * src/app). Crawl files always; the API standard once api/v1 exists. Brand and guides
5
+ * join in phase 2.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Sat Oct 3, 2026
8
+ * @modified Sat Oct 3, 2026
9
+ */
10
+ /** One standard's outcome. */
11
+ export type StandardResult = {
12
+ id: string;
13
+ label: string;
14
+ ok: boolean;
15
+ missing: string[];
16
+ };
17
+ /**
18
+ * @function checkStandards
19
+ * @param files {readonly string[]} every file under src/app, relative, "/"-separated
20
+ * @returns {StandardResult[]} crawl files always, the API standard when api/v1 exists
21
+ */
22
+ export declare const checkStandards: (files: readonly string[]) => StandardResult[];
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @file src/check/standards.ts
3
+ * @desc The routes every haruhime app serves, checked against an app's file list (paths under
4
+ * src/app). Crawl files always; the API standard once api/v1 exists. Brand and guides
5
+ * join in phase 2.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Sat Oct 3, 2026
8
+ * @modified Sat Oct 3, 2026
9
+ */
10
+ const has = (...patterns) => (files) => files.some((file) => patterns.some((pattern) => pattern.test(file)));
11
+ const CRAWL = [
12
+ { name: "robots.ts", matches: has(/^robots\.(ts|js)$/, /^robots\.txt\/route\.(ts|js)$/) },
13
+ { name: "sitemap.ts", matches: has(/^sitemap\.(ts|js)$/, /^sitemap\.xml\/route\.(ts|js)$/) },
14
+ { name: "llms.txt/route.ts", matches: has(/^llms\.txt\/route\.(ts|js)$/) },
15
+ { name: "llms-full.txt/route.ts", matches: has(/^llms-full\.txt\/route\.(ts|js)$/) },
16
+ {
17
+ name: ".well-known/security.txt/route.ts",
18
+ matches: has(/^\.well-known\/security\.txt\/route\.(ts|js)$/),
19
+ },
20
+ ];
21
+ const API = [
22
+ { name: "api/v1/me/route.ts", matches: has(/^api\/v1\/me\/route\.(ts|js)$/) },
23
+ {
24
+ name: "api/v1/openapi.json/route.ts",
25
+ matches: has(/^api\/v1\/openapi\.json\/route\.(ts|js)$/),
26
+ },
27
+ { name: "api/me/api-key/route.ts", matches: has(/^api\/me\/api-key\/route\.(ts|js)$/) },
28
+ {
29
+ name: "docs/api/page.tsx",
30
+ matches: has(/^(\([^)]+\)\/)?docs\/(api|\[[^\]]+\])\/page\.(tsx|jsx|ts|js)$/),
31
+ },
32
+ ];
33
+ const evaluate = (id, label, reqs, files) => {
34
+ const missing = reqs.filter((req) => !req.matches(files)).map((req) => req.name);
35
+ return { id, label, ok: missing.length === 0, missing };
36
+ };
37
+ /** Route groups like (public)/ don't change the URL, so they are dropped before matching. */
38
+ const ungrouped = (file) => file.replace(/(^|\/)\([^)]+\)(?=\/)/g, "").replace(/^\//, "");
39
+ /**
40
+ * @function checkStandards
41
+ * @param files {readonly string[]} every file under src/app, relative, "/"-separated
42
+ * @returns {StandardResult[]} crawl files always, the API standard when api/v1 exists
43
+ */
44
+ export const checkStandards = (files) => {
45
+ const flat = files.map(ungrouped);
46
+ const results = [evaluate("crawl", "Crawl files", CRAWL, flat)];
47
+ if (flat.some((file) => file.startsWith("api/v1/"))) {
48
+ results.push(evaluate("api", "Public API", API, flat));
49
+ }
50
+ return results;
51
+ };
@@ -6,7 +6,7 @@
6
6
  * caller passes them here.
7
7
  * @author David @dvhsh (https://dvh.sh)
8
8
  * @created Mon Sep 28, 2026
9
- * @modified Mon Sep 28, 2026
9
+ * @modified Sat Oct 3, 2026
10
10
  */
11
11
  /** RFC 9116 asks for an Expires less than a year out. */
12
12
  export declare const SECURITY_TXT_LIFETIME_DAYS = 365;
@@ -22,11 +22,13 @@ export type SecurityTxtOptions = {
22
22
  policyUrl: string;
23
23
  /** When the file is built (build time for a static route). */
24
24
  now: Date;
25
+ /** A preferred report URL (like a GitHub advisory form), listed before the email. */
26
+ contactUrl?: string;
25
27
  };
26
28
  /**
27
29
  * @function buildSecurityTxt
28
- * @param options {SecurityTxtOptions} contact, site, policy and build time
29
- * @returns {string} the security.txt body: Contact, Expires, Preferred-Languages, Canonical,
30
- * Policy, one field per line, ending in one newline
30
+ * @param options {SecurityTxtOptions} contact, site, policy, build time, and optional contact URL
31
+ * @returns {string} the security.txt body: Contact (URL first if present), Contact (mailto),
32
+ * Expires, Preferred-Languages, Canonical, Policy, one field per line, ending in one newline
31
33
  */
32
- export declare const buildSecurityTxt: ({ contactEmail, siteUrl, policyUrl, now, }: SecurityTxtOptions) => string;
34
+ export declare const buildSecurityTxt: ({ contactEmail, siteUrl, policyUrl, now, contactUrl, }: SecurityTxtOptions) => string;
@@ -6,7 +6,7 @@
6
6
  * caller passes them here.
7
7
  * @author David @dvhsh (https://dvh.sh)
8
8
  * @created Mon Sep 28, 2026
9
- * @modified Mon Sep 28, 2026
9
+ * @modified Sat Oct 3, 2026
10
10
  */
11
11
  /** RFC 9116 asks for an Expires less than a year out. */
12
12
  export const SECURITY_TXT_LIFETIME_DAYS = 365;
@@ -14,13 +14,14 @@ export const SECURITY_TXT_LIFETIME_DAYS = 365;
14
14
  export const SECURITY_TXT_PATH = "/.well-known/security.txt";
15
15
  /**
16
16
  * @function buildSecurityTxt
17
- * @param options {SecurityTxtOptions} contact, site, policy and build time
18
- * @returns {string} the security.txt body: Contact, Expires, Preferred-Languages, Canonical,
19
- * Policy, one field per line, ending in one newline
17
+ * @param options {SecurityTxtOptions} contact, site, policy, build time, and optional contact URL
18
+ * @returns {string} the security.txt body: Contact (URL first if present), Contact (mailto),
19
+ * Expires, Preferred-Languages, Canonical, Policy, one field per line, ending in one newline
20
20
  */
21
- export const buildSecurityTxt = ({ contactEmail, siteUrl, policyUrl, now, }) => {
21
+ export const buildSecurityTxt = ({ contactEmail, siteUrl, policyUrl, now, contactUrl, }) => {
22
22
  const expires = new Date(now.getTime() + SECURITY_TXT_LIFETIME_DAYS * 86_400_000);
23
23
  const lines = [
24
+ ...(contactUrl ? [`Contact: ${contactUrl}`] : []),
24
25
  `Contact: mailto:${contactEmail}`,
25
26
  `Expires: ${expires.toISOString()}`,
26
27
  "Preferred-Languages: en",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@haruhimemoe/next-kit",
3
- "version": "0.4.0",
4
- "description": "The Next.js plumbing the haruhime.moe tools share: JSON route helpers, rate limits and budgets in MongoDB, bearer machine auth, zod env parsing, a connect-once MongoDB client with safe index builds, better-auth with osu! sign-in, the signed-in marker and account store for the browser, and SEO: metadata, robots.txt, sitemaps, JSON-LD and llms.txt.",
3
+ "version": "0.5.0",
4
+ "description": "The Next.js plumbing the haruhime.moe tools share: JSON route helpers, rate limits and budgets in MongoDB, bearer machine auth, zod env parsing, a connect-once MongoDB client with safe index builds, better-auth with osu! sign-in, the signed-in marker and account store for the browser, SEO: metadata, robots.txt, sitemaps, JSON-LD and llms.txt, and per-app API keys and the /api/v1 guard.",
5
5
  "keywords": [
6
6
  "nextjs",
7
7
  "next.js",
@@ -14,7 +14,8 @@
14
14
  "seo",
15
15
  "json-ld",
16
16
  "sitemap",
17
- "llms.txt"
17
+ "llms.txt",
18
+ "api keys"
18
19
  ],
19
20
  "homepage": "https://github.com/haruhimemoe/next-kit#readme",
20
21
  "bugs": "https://github.com/haruhimemoe/next-kit/issues",
@@ -25,6 +26,9 @@
25
26
  "license": "MIT",
26
27
  "author": "David (https://dvh.sh)",
27
28
  "type": "module",
29
+ "bin": {
30
+ "next-kit": "./dist/check/cli.js"
31
+ },
28
32
  "exports": {
29
33
  "./server": {
30
34
  "types": "./dist/server/index.d.ts",
@@ -54,6 +58,10 @@
54
58
  "types": "./dist/seo/index.d.ts",
55
59
  "default": "./dist/seo/index.js"
56
60
  },
61
+ "./api-keys": {
62
+ "types": "./dist/api-keys/index.d.ts",
63
+ "default": "./dist/api-keys/index.js"
64
+ },
57
65
  "./package.json": "./package.json"
58
66
  },
59
67
  "files": [
@@ -84,7 +92,7 @@
84
92
  },
85
93
  "peerDependencies": {
86
94
  "@haruhimemoe/osu": "^0.2.0 || ^0.3.0 || ^0.4.0",
87
- "@haruhimemoe/ui": "^0.5.0",
95
+ "@haruhimemoe/ui": "^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0 || ^0.9.0",
88
96
  "better-auth": "^1.7.5",
89
97
  "mongodb": "^7.6.0",
90
98
  "mongodb-memory-server": "^11.3.0",