@haruhimemoe/next-kit 0.4.0 → 0.6.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,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.6.0] - 2026-10-04
10
+
11
+ ### Added
12
+ - `docs` entry point: a content registry (`defineContent`, `CONTENT_SECTIONS`, `SECTION_LABELS`) and the path helpers (`contentPath`, `markdownPath`, `findEntry`, `contentParams`) for an app's docs, guides and legal pages, plus app-made extra entries like bb's tag pages. No runtime imports.
13
+ - `mdxToMarkdown` in `docs`: converts bb-flavored MDX to plain Markdown (callouts to blockquotes, import/export lines dropped, capitalized JSX removed, root-relative links and images absolutized, a title heading added when missing). Content inside fenced code blocks is left untouched. Still no runtime imports.
14
+ - `docs/files` entry point: `readContentMarkdown` reads and converts a registered entry's markdown file, and `contentFileDrift` compares a registry against the files on disk. Loads `node:fs`.
15
+ - `contentLlmsTxt`, `contentLlmsFull`, `contentSitemap` and `contentRewrites` in `docs`: llms.txt (sections in order Docs, Guides, API, Legal, empty ones left out), llms-full.txt (each entry's own leading H1 stripped, since `llmsFull` writes the part title), sitemap records per section (an index path, each entry, each extra) and the one rewrite rule for a content page's ".md" mirror. Built on `llmsTxt`/`llmsFull`/`SitemapRecord` from `seo`. Still no runtime imports.
16
+
17
+ ### Changed
18
+ - **Breaking (check only).** `next-kit check` now also checks files under `content/` and adds the `brand` and `legal` standards: `brand/page.tsx` and `legal/page.tsx` plus `legal/[x]/page.tsx`, `legal/[x]/md/route.ts`, `content/legal/terms.mdx` and `content/legal/privacy.mdx` are required on every app. A `docs` standard (`docs/page.tsx`, `docs/[x]/page.tsx`, `docs/[x]/md/route.ts`) joins in once `src/app/api/v1` exists or any `content/docs` file does; a `guides` standard joins in only once a `content/guides` file does. The `api` standard drops its `docs/api/page.tsx` requirement in favor of `content/docs/api.mdx`. `checkStandards` takes a second `contentFiles` argument; `StandardResult.missing` entries now carry their own `src/app/` or `content/` prefix. This is a 0.x minor: an app with no `/brand`, `/legal` route or legal content fails `next-kit check` in CI until it adds them.
19
+ - `@haruhimemoe/ui` peer range also covers 0.10.0 (not yet published).
20
+
21
+ ## [0.5.0] - 2026-10-04
22
+
23
+ ### Added
24
+ - `buildSecurityTxt` takes `contactUrl`, listed before the email.
25
+ - `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.
26
+ - `next-kit check` bin: fails when an app is missing a standard route.
27
+
28
+ ### Changed
29
+ - `@haruhimemoe/ui` peer range now covers 0.5 through 0.9.
30
+
9
31
  ## [0.4.0] - 2026-09-28
10
32
 
11
33
  ### Added
@@ -52,7 +74,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
52
74
  - `@haruhimemoe/next-kit/auth-react`: `createSignedInMarker`, `createAccountStore`, `useAccount`, `createAccount`, `RestoreSignedIn` and `osuSignIn`.
53
75
  - `@haruhimemoe/next-kit/testing`: `startMemoryMongo`, `setupTestDb`, `setupMsw` and the fake osu! app env.
54
76
 
55
- [unreleased]: https://github.com/haruhimemoe/next-kit/compare/v0.4.0...HEAD
77
+ [unreleased]: https://github.com/haruhimemoe/next-kit/compare/v0.6.0...HEAD
78
+ [0.6.0]: https://github.com/haruhimemoe/next-kit/compare/v0.5.0...v0.6.0
79
+ [0.5.0]: https://github.com/haruhimemoe/next-kit/compare/v0.4.0...v0.5.0
56
80
  [0.4.0]: https://github.com/haruhimemoe/next-kit/compare/v0.3.0...v0.4.0
57
81
  [0.3.0]: https://github.com/haruhimemoe/next-kit/compare/v0.2.1...v0.3.0
58
82
  [0.2.1]: https://github.com/haruhimemoe/next-kit/compare/v0.2.0...v0.2.1
package/README.md CHANGED
@@ -11,6 +11,8 @@ 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.
15
+ - **`/docs`:** a content registry for an app's docs, guides and legal pages: sections, entries, app-made extra entries (like bb's tag pages), the path helpers a dynamic route needs, and `mdxToMarkdown` to turn bb-flavored MDX into plain Markdown. No runtime imports. **`/docs/files`:** reads the markdown files a registry's entries point at and reports drift between the registry and disk (node:fs).
14
16
 
15
17
  Every name, path, limit and message comes from the caller. There is no root entry point; import a subpath.
16
18
 
@@ -29,9 +31,12 @@ bun add @haruhimemoe/next-kit zod
29
31
  | `env` | nothing else |
30
32
  | `mongo` | `mongodb` ^7.6.0, `mongoose` ^9.10.2 |
31
33
  | `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) |
34
+ | `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 \|\| ^0.10.0 (with its theme set up) |
33
35
  | `seo` | `next` ^16.3.6 types only (nothing loads at runtime) |
34
36
  | `testing` | `vitest` ^5.0.1, `msw` ^2.15.0, `mongodb-memory-server` ^11.3.0 |
37
+ | `api-keys` | `mongodb` ^7.6.0 |
38
+ | `docs` | nothing else |
39
+ | `docs/files` | nothing else (`node:fs` is built in) |
35
40
 
36
41
  ## Use
37
42
 
@@ -147,6 +152,91 @@ export default async () => sitemapEntries(SEO_SITE, [["/", "/search"], pools.map
147
152
  export const GET = () => textResponse(llmsTxt({ title: "pools.haruhime.moe", summary: SEO_SITE.description, sections }));
148
153
  ```
149
154
 
155
+ ### Docs (./docs)
156
+
157
+ One registry per app, built from its docs, guides and legal entries; the crawl helpers and a dynamic route are built on top of it.
158
+
159
+ ```ts
160
+ // src/constants/content.ts
161
+ import { defineContent } from "@haruhimemoe/next-kit/docs";
162
+
163
+ export const CONTENT = defineContent({
164
+ docs: [{ slug: "api", title: "API", description: "The /api/v1 reference.", lastUpdated: "2026-10-04" }],
165
+ guides: [{ slug: "make-a-pack", title: "Make a pack", description: "Build your first mappool.", lastUpdated: "2026-10-04" }],
166
+ legal: [
167
+ { slug: "terms", title: "Terms of service", description: "The rules for using pools.", lastUpdated: "2026-10-04" },
168
+ { slug: "privacy", title: "Privacy policy", description: "What pools stores and why.", lastUpdated: "2026-10-04" },
169
+ ],
170
+ });
171
+
172
+ // src/app/docs/[slug]/page.tsx (and guides/, legal/, the same shape)
173
+ import { findEntry } from "@haruhimemoe/next-kit/docs";
174
+ import { readContentMarkdown } from "@haruhimemoe/next-kit/docs/files";
175
+ import { CONTENT } from "../../../constants/content";
176
+
177
+ export default async function DocPage({ params }: { params: Promise<{ slug: string }> }) {
178
+ const { slug } = await params;
179
+ const entry = findEntry(CONTENT, "docs", slug);
180
+ if (!entry) return notFound();
181
+ const markdown = await readContentMarkdown(CONTENT, "docs", slug, { siteUrl: SEO_SITE.url });
182
+ return <Markdown>{markdown}</Markdown>;
183
+ }
184
+
185
+ // src/app/llms.txt/route.ts
186
+ import { contentLlmsTxt } from "@haruhimemoe/next-kit/docs";
187
+ import { textResponse } from "@haruhimemoe/next-kit/seo";
188
+
189
+ export const GET = () =>
190
+ textResponse(contentLlmsTxt({ site: SEO_SITE, title: "pools.haruhime.moe", summary: SEO_SITE.description, content: CONTENT }));
191
+
192
+ // src/app/llms-full.txt/route.ts
193
+ import { contentLlmsFull } from "@haruhimemoe/next-kit/docs";
194
+ import { readContentMarkdown } from "@haruhimemoe/next-kit/docs/files";
195
+
196
+ export const GET = async () =>
197
+ textResponse(
198
+ await contentLlmsFull({
199
+ site: SEO_SITE, title: "pools.haruhime.moe", content: CONTENT,
200
+ read: (section, slug) => readContentMarkdown(CONTENT, section, slug, { siteUrl: SEO_SITE.url }),
201
+ }),
202
+ );
203
+
204
+ // src/app/sitemap.ts
205
+ import { contentSitemap } from "@haruhimemoe/next-kit/docs";
206
+
207
+ export default async () => sitemapEntries(SEO_SITE, [["/", "/search"], contentSitemap(CONTENT)]);
208
+
209
+ // next.config.ts
210
+ import { contentRewrites } from "@haruhimemoe/next-kit/docs";
211
+
212
+ export default { async rewrites() { return contentRewrites(); } };
213
+ ```
214
+
215
+ ## Standards check
216
+
217
+ `next-kit check [dir]` walks `src/app` and, when it exists, `content/` (both under `dir`, default: the current directory) and confirms every standard file exists, so CI catches a missing one before a page does. It checks files only; the content registry itself is never parsed.
218
+
219
+ ```sh
220
+ bunx next-kit check
221
+ ```
222
+
223
+ Every app is checked against:
224
+
225
+ - **crawl** (always): `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`).
226
+ - **brand** (always): `brand/page.tsx`.
227
+ - **legal** (always): `legal/page.tsx`, `legal/[x]/page.tsx`, `legal/[x]/md/route.ts` (any dynamic segment name), and `content/legal/terms.mdx` plus `content/legal/privacy.mdx`.
228
+ - **docs**, once `src/app/api/v1` exists or any `content/docs` file does: `docs/page.tsx`, `docs/[x]/page.tsx`, `docs/[x]/md/route.ts`.
229
+ - **guides**, only once a `content/guides` file exists: the same three files under `guides/`. An app with no guides is never asked for them.
230
+ - **api**, once `src/app/api/v1` exists: `api/v1/me/route.ts`, `api/v1/openapi.json/route.ts`, `api/me/api-key/route.ts`, and `content/docs/api.mdx`.
231
+
232
+ Route groups like `(public)/` are ignored, since they don't change the URL. A missing route file prints as `missing src/app/<path>`; a missing content file prints as `missing content/<path>`.
233
+
234
+ 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:
235
+
236
+ ```yaml
237
+ - run: bunx next-kit check
238
+ ```
239
+
150
240
  ## API
151
241
 
152
242
  ### server
@@ -167,7 +257,7 @@ export const GET = () => textResponse(llmsTxt({ title: "pools.haruhime.moe", sum
167
257
  | `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
258
  | `sameSecret(given, secret)`, `bearerToken(headers)` | SHA-256 digests compared with `timingSafeEqual`, and the token after `Bearer `. |
169
259
  | `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`. |
260
+ | `buildSecurityTxt({ contactEmail, siteUrl, policyUrl, now, contactUrl? })` | The RFC 9116 body, expiring a year after `now`, with optional `contactUrl` listed before the email. |
171
261
 
172
262
  ### env
173
263
 
@@ -251,6 +341,56 @@ Since 0.3.0. Every helper takes the app's `Site`: `name`, `url` (the canonical o
251
341
  | `setupMsw(...handlers)` | An msw server for the file; an unhandled request is an error. |
252
342
  | `TEST_OSU_APP_ENV`, `stubOsuAppEnv(overrides?)`, `stubEnv(values)` | A valid fake env and `vi.stubEnv` helpers. |
253
343
 
344
+ ### api-keys
345
+
346
+ | Export | What it does |
347
+ | --- | --- |
348
+ | `API_KEY_BYTES` | Random bytes in a key: 32, shown as 43 base64url characters. |
349
+ | `API_KEY_DISPLAY_LENGTH` | Characters of a key shown on account pages and in data exports (prefix + 8): 12. |
350
+ | `API_KEY_PREFIX_PATTERN` | Every app prefix: `h`, two lowercase letters, `_`. |
351
+ | `generateApiKey(prefix)` | A new key for that prefix; show it once and store only `hashApiKey(key)`. Throws `TypeError` on a bad prefix. |
352
+ | `hashApiKey(key)` | A key's SHA-256 hex digest, the only form ever stored. |
353
+ | `apiKeyDisplay(key)` | A key's first `API_KEY_DISPLAY_LENGTH` characters. |
354
+ | `isApiKeyFormat(prefix, value)` | True only for that prefix plus 43 base64url characters; checked before hashing an untrusted token. |
355
+ | `apiKeyToken(headers)` | The single token after `Bearer ` (any case, extra spaces ignored) in a request's `authorization` header, or null. |
356
+ | `assertApiKeyPrefix(prefix)` | Throws `TypeError` unless the prefix matches `API_KEY_PREFIX_PATTERN`. |
357
+ | `API_KEYS_COLLECTION` | The collection every app keeps its keys in: `api_keys`. |
358
+ | `LAST_USED_INTERVAL_MS` | `lastUsedAt` is written at most this often (an hour), to save writes. |
359
+ | `apiKeyIndexSpecs(collection?)` | Unique `userId` and unique `hash` (`secret: true`, never logged), for the app's own index list. |
360
+ | `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. |
361
+ | `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). |
362
+ | `API_SERVER_ERROR` | The 500 message when a key lookup or handler throws. |
363
+ | `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. |
364
+
365
+ ### docs
366
+
367
+ No runtime imports.
368
+
369
+ | Export | What it does |
370
+ | --- | --- |
371
+ | `CONTENT_SECTIONS`, `ContentSection` | The sections a site can have, in display order: `"docs"`, `"guides"`, `"legal"`. |
372
+ | `SECTION_LABELS` | The nav label for each section, like "Guides". |
373
+ | `defineContent(input)` | Validates and fills in a `Content`: `sections` lists only the non-empty ones, in `CONTENT_SECTIONS` order; `entries` and `extra` hold every section (empty arrays for the ones left out). Throws naming the section and slug (or extra href) for a bad slug (lowercase words, single hyphens), a duplicate slug or extra href, a `lastUpdated` that isn't a real `YYYY-MM-DD` date, or a blank title. |
374
+ | `ContentEntry`, `HowToStep` | A markdown-backed page: `slug`, `title`, `navTitle?`, `description`, `lastUpdated` (`YYYY-MM-DD`), `howTo?` (numbered steps). |
375
+ | `ExtraEntry` | An app-made page shown in a section's nav and search, like bb's tag pages: `href`, `title`, `navTitle?`, `description`, `group`, `badge?`, `lastUpdated?`, `markdownHref?`. |
376
+ | `contentPath(section, slug)`, `markdownPath(section, slug)` | `/section/slug` and `/section/slug.md`. |
377
+ | `findEntry(content, section, slug)` | The matching `ContentEntry`, or undefined. |
378
+ | `contentParams(content, section)` | `{ slug }[]` for a dynamic route's `generateStaticParams`. |
379
+ | `mdxToMarkdown(source, { title, siteUrl, transforms? })` | Converts bb-flavored MDX to plain Markdown, outside fenced code blocks only: CRLF/CR become LF (`transforms` run first, on the whole source); top-level `import`/`export` lines and an `export const x = {` block are dropped; `<Callout type="..." title="...">body</Callout>` becomes a blockquote (`> **Type:** body`, type missing means "Note"); other capitalized JSX tags are removed (text between them stays, lowercase HTML tags stay); a link or image target starting with a single `/` becomes absolute with `siteUrl`; `title` is prepended as a `# ` heading when the first non-blank line isn't one; runs of 3+ blank lines collapse to 2, and the result ends with exactly one newline. |
380
+ | `contentLlmsTxt({ site, title, summary, notes?, content, api? })` | The llms.txt body: sections in order Docs, Guides, API, Legal. Each entry links to its absolute `.md` URL with its description as the note; an extra links to `markdownHref` when set, else `href`. An empty section (no entries, no extras, no `api` links) is left out. |
381
+ | `contentLlmsFull({ site, title, summary?, content, read, before?, after? })` | The llms-full.txt body: `before`, then every registry entry in section order (title, its absolute page URL, and `read(section, slug)`'s Markdown with its own leading `# ` heading stripped, since `llmsFull` writes the part title as the H1), then `after`. `read` is usually `readContentMarkdown` from `docs/files`. |
382
+ | `contentSitemap(content)` | A `SitemapRecord[]` for `seo`'s `sitemapEntries`: one non-empty section's index path (like `/docs`, `lastModified` set to the newest `lastUpdated` among its entries and extras, omitted when none have one), then each entry with its own `lastUpdated`, then each extra with its own `lastUpdated` when set. |
383
+ | `contentRewrites()` | The one Next.js rewrite rule that mirrors a content page's `.md` URL (`/docs/x.md`, `/guides/x.md`, `/legal/x.md`) to its route handler (`/docs/x/md`, ...). Pure, takes no registry. |
384
+
385
+ ### docs/files
386
+
387
+ `node:fs`. Markdown source for an entry lives at `<root>/content/<section>/<slug>.mdx`.
388
+
389
+ | Export | What it does |
390
+ | --- | --- |
391
+ | `readContentMarkdown(content, section, slug, { root?, siteUrl, transforms? })` | Reads a registered entry's markdown file and converts it with `mdxToMarkdown` (using the entry's `title`). `root` defaults to `process.cwd()`. Returns null for an unregistered slug; rejects (ENOENT) when the slug is registered but its file is missing. |
392
+ | `contentFileDrift(content, { root? })` | `{ missingFiles, unregistered }`: `missingFiles` lists registered entries with no file on disk (like `"guides/x.mdx"`); `unregistered` lists `.mdx` files on disk with no registry entry. `root` defaults to `process.cwd()`. |
393
+
254
394
  ## Migration
255
395
 
256
396
  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:
@@ -269,7 +409,7 @@ For pools.haruhime.moe, `createMongo` runs `onConnect` (the privilege check, ind
269
409
 
270
410
  ## Compatibility
271
411
 
272
- ES modules for Node 22.12+ on the server. `auth-react` also runs in browsers; its hook and component files keep `"use client"`, and it loads only `react`, `next/navigation.js` and `@haruhimemoe/ui`. `server` loads `node:crypto` for machine auth. `seo` loads nothing at runtime (Next's types only), so it runs anywhere.
412
+ ES modules for Node 22.12+ on the server. `auth-react` also runs in browsers; its hook and component files keep `"use client"`, and it loads only `react`, `next/navigation.js` and `@haruhimemoe/ui`. `server` loads `node:crypto` for machine auth. `seo` and `docs` load nothing at runtime (Next's types only, or nothing), so they run anywhere; `docs/files` loads `node:fs` and stays server only.
273
413
 
274
414
  ## License
275
415
 
@@ -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;