@haruhimemoe/next-kit 0.3.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,22 @@ 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
+
19
+ ## [0.4.0] - 2026-09-28
20
+
21
+ ### Added
22
+
23
+ - `seo`: a short title suffix. `Site.shortTitleSuffix` (like "pools") and `pageMetadata`'s `titleSuffix` option: `"auto"` (the default) keeps "keyword · host" and switches to "keyword · pools" only when the full title passes 60 characters (`TITLE_MAX`) and the site sets a short suffix; `"full"`, `"short"` and `"none"` force one. `pageTitle` takes the same mode (default `"full"`) and `notFoundMetadata` shortens like `"auto"`. Sites without `shortTitleSuffix` get the same titles as before.
24
+
9
25
  ## [0.3.0] - 2026-09-28
10
26
 
11
27
  ### Added
@@ -46,7 +62,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
46
62
  - `@haruhimemoe/next-kit/auth-react`: `createSignedInMarker`, `createAccountStore`, `useAccount`, `createAccount`, `RestoreSignedIn` and `osuSignIn`.
47
63
  - `@haruhimemoe/next-kit/testing`: `startMemoryMongo`, `setupTestDb`, `setupMsw` and the fake osu! app env.
48
64
 
49
- [unreleased]: https://github.com/haruhimemoe/next-kit/compare/v0.3.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
67
+ [0.4.0]: https://github.com/haruhimemoe/next-kit/compare/v0.3.0...v0.4.0
50
68
  [0.3.0]: https://github.com/haruhimemoe/next-kit/compare/v0.2.1...v0.3.0
51
69
  [0.2.1]: https://github.com/haruhimemoe/next-kit/compare/v0.2.0...v0.2.1
52
70
  [0.2.0]: https://github.com/haruhimemoe/next-kit/compare/v0.1.0...v0.2.0
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
 
@@ -217,16 +235,16 @@ export const GET = () => textResponse(llmsTxt({ title: "pools.haruhime.moe", sum
217
235
 
218
236
  ### seo
219
237
 
220
- Since 0.3.0. Every helper takes the app's `Site`: `name`, `url` (the canonical origin), `title` (the home page's primary keyword), `titleSuffix?` (defaults to the host), `description`, `locale?` (en_US), `twitter?`, `ogImages`, `organization` and `parent?`.
238
+ Since 0.3.0. Every helper takes the app's `Site`: `name`, `url` (the canonical origin), `title` (the home page's primary keyword), `titleSuffix?` (defaults to the host), `shortTitleSuffix?` (since 0.4.0, like "pools"), `description`, `locale?` (en_US), `twitter?`, `ogImages`, `organization` and `parent?`.
221
239
 
222
240
  | Export | What it does |
223
241
  | --- | --- |
224
242
  | `siteMetadata(site)` | The root layout's `Metadata`: `metadataBase`, title default "keyword · host" and template "%s · host", the clamped description, `applicationName`, openGraph (type, site name, locale, images) and the twitter card. No canonical (a layout canonical leaks into every child) and no icons (the app's icon files). |
225
243
  | `homeMetadata(site, { title?, description? })` | `pageMetadata` for `/` with the site's keyword title. |
226
- | `pageMetadata(site, { path, title, description?, index?, ogType?, images?, modifiedTime?, publishedTime? })` | An absolute "keyword · host" title, the description clamped to 160, `alternates.canonical` and `openGraph.url` set together to the same absolute URL, and a full openGraph and twitter card (images default to `site.ogImages`: Next replaces a layout's openGraph, it doesn't merge it). `index: false` adds noindex, follow. `ogType: "article"` writes the ISO times it has. Throws on a relative path, another origin or a blank title. |
227
- | `notFoundMetadata(site, what?)` | "Pack not found · host" and noindex, for a `generateMetadata` whose record is missing. |
244
+ | `pageMetadata(site, { path, title, titleSuffix?, description?, index?, ogType?, images?, modifiedTime?, publishedTime? })` | An absolute "keyword · host" title (`titleSuffix` defaults to `"auto"`: "keyword · pools" when the full title passes 60 characters and the site sets `shortTitleSuffix`; `"full"`, `"short"` or `"none"` force one), the description clamped to 160, `alternates.canonical` and `openGraph.url` set together to the same absolute URL, and a full openGraph and twitter card (images default to `site.ogImages`: Next replaces a layout's openGraph, it doesn't merge it). `index: false` adds noindex, follow. `ogType: "article"` writes the ISO times it has. Throws on a relative path, another origin or a blank title. |
245
+ | `notFoundMetadata(site, what?)` | "Pack not found · host" (shortened like `"auto"`) and noindex, for a `generateMetadata` whose record is missing. |
228
246
  | `clampDescription(text, max?)`, `DESCRIPTION_MAX` | One line, at most `max` (160) characters: cut at a word, trailing punctuation dropped, "…" added. |
229
- | `pageTitle(site, title)`, `TITLE_SEPARATOR` | "keyword · host", the suffix added once. |
247
+ | `pageTitle(site, title, mode?)`, `TITLE_SEPARATOR`, `TITLE_MAX`, `TitleSuffixMode` | "keyword · host", the suffix added once (either suffix already there counts). `mode`: `"full"` (default), `"short"` (the full one when the site has none), `"none"`, or `"auto"` (short past `TITLE_MAX`, 60). |
230
248
  | `robots(site, { allow?, disallow?, aiBots? })` | `MetadataRoute.Robots`: the `*` group, one group naming the allowed AI bots with the same rules (a bot with its own group ignores `*`), a `Disallow: /` group for blocked ones, the sitemap and the host. `aiBots`: `"allow"` (default), `"block-training"`, or `"block-all"` (Bingbot stays, or the site leaves Bing). |
231
249
  | `AI_BOTS` | The named crawlers, each `{ userAgent, operator, kind: "training" \| "search", searchEngine? }`: GPTBot, OAI-SearchBot, ChatGPT-User, PerplexityBot, Perplexity-User, ClaudeBot, Claude-SearchBot, Claude-User, anthropic-ai, Google-Extended, Applebot-Extended, Bingbot, CCBot, Bytespider, meta-externalagent. |
232
250
  | `sitemapEntries(site, groups)`, `SITEMAP_MAX_URLS` | `MetadataRoute.Sitemap` from groups of paths and `{ path, lastModified?, changeFrequency?, priority? }` records: absolute URLs on the canonical origin, the first entry per URL, `lastModified` only when it's a real date (never made up). Throws past 50,000 URLs or on a priority outside 0 to 1. |
@@ -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
+ };
@@ -15,5 +15,5 @@ export { type LdGraph, type LdLink, type LdNode, SEARCH_TERM, type WebApplicatio
15
15
  export { type LlmsFullPart, type LlmsLink, type LlmsSection, type LlmsTxtOptions, llmsFull, llmsTxt, type TextResponseOptions, textResponse, } from "./llms.js";
16
16
  export { homeMetadata, notFoundMetadata, type PageMetadataOptions, pageMetadata, siteMetadata, } from "./metadata.js";
17
17
  export { AI_BOTS, type AiBot, type AiBotKind, type AiBotsPolicy, type RobotsOptions, robots, } from "./robots.js";
18
- export { HARUHIME_ORG, type OgImage, type Organization, pageTitle, type Site, TITLE_SEPARATOR, } from "./site.js";
18
+ export { HARUHIME_ORG, type OgImage, type Organization, pageTitle, type Site, TITLE_MAX, TITLE_SEPARATOR, type TitleSuffixMode, } from "./site.js";
19
19
  export { type ChangeFrequency, SITEMAP_MAX_URLS, type SitemapGroup, type SitemapRecord, sitemapEntries, } from "./sitemap.js";
package/dist/seo/index.js CHANGED
@@ -14,5 +14,5 @@ export { SEARCH_TERM, } from "./ld-site.js";
14
14
  export { llmsFull, llmsTxt, textResponse, } from "./llms.js";
15
15
  export { homeMetadata, notFoundMetadata, pageMetadata, siteMetadata, } from "./metadata.js";
16
16
  export { AI_BOTS, robots, } from "./robots.js";
17
- export { HARUHIME_ORG, pageTitle, TITLE_SEPARATOR, } from "./site.js";
17
+ export { HARUHIME_ORG, pageTitle, TITLE_MAX, TITLE_SEPARATOR, } from "./site.js";
18
18
  export { SITEMAP_MAX_URLS, sitemapEntries, } from "./sitemap.js";
@@ -9,13 +9,18 @@
9
9
  * @modified Mon Sep 28, 2026
10
10
  */
11
11
  import type { Metadata } from "next";
12
- import { type OgImage, type Site } from "./site.js";
12
+ import { type OgImage, type Site, type TitleSuffixMode } from "./site.js";
13
13
  /** pageMetadata's input. */
14
14
  export type PageMetadataOptions = {
15
15
  /** The page's path, like "/search". Canonical and og:url both come from it. */
16
16
  path: string;
17
17
  /** The primary keyword; " · host" is added (pageTitle). */
18
18
  title: string;
19
+ /**
20
+ * Default "auto": the full suffix, or the site's shortTitleSuffix when the full title would
21
+ * pass TITLE_MAX (60). "full", "short" and "none" force one.
22
+ */
23
+ titleSuffix?: TitleSuffixMode;
19
24
  /** Defaults to the site's description. Clamped to 160 characters. */
20
25
  description?: string;
21
26
  /** false: noindex, follow. Default true. */
@@ -40,7 +45,8 @@ export declare const siteMetadata: (site: Site) => Metadata;
40
45
  * @function pageMetadata
41
46
  * @param site {Site} the site
42
47
  * @param options {PageMetadataOptions} the page
43
- * @returns {Metadata} an absolute "keyword · host" title, the clamped description, the
48
+ * @returns {Metadata} an absolute "keyword · host" title (or "keyword · short" past 60
49
+ * characters, see titleSuffix), the clamped description, the
44
50
  * canonical and og:url (always together, as absolute URLs), a full openGraph and
45
51
  * twitter card, and robots noindex when index is false
46
52
  * @throws {Error} when path doesn't start with "/" or the title is blank
@@ -47,14 +47,15 @@ export const siteMetadata = (site) => {
47
47
  * @function pageMetadata
48
48
  * @param site {Site} the site
49
49
  * @param options {PageMetadataOptions} the page
50
- * @returns {Metadata} an absolute "keyword · host" title, the clamped description, the
50
+ * @returns {Metadata} an absolute "keyword · host" title (or "keyword · short" past 60
51
+ * characters, see titleSuffix), the clamped description, the
51
52
  * canonical and og:url (always together, as absolute URLs), a full openGraph and
52
53
  * twitter card, and robots noindex when index is false
53
54
  * @throws {Error} when path doesn't start with "/" or the title is blank
54
55
  */
55
56
  export const pageMetadata = (site, options) => {
56
57
  const url = absoluteUrl(site, options.path);
57
- const title = pageTitle(site, options.title);
58
+ const title = pageTitle(site, options.title, options.titleSuffix ?? "auto");
58
59
  const description = clampDescription(options.description ?? site.description);
59
60
  const images = [...(options.images ?? site.ogImages)];
60
61
  const base = {
@@ -101,6 +102,6 @@ export const homeMetadata = (site, options = {}) => pageMetadata(site, {
101
102
  * records returned {} and got the site's default title)
102
103
  */
103
104
  export const notFoundMetadata = (site, what = "Page") => ({
104
- title: { absolute: pageTitle(site, `${what} not found`) },
105
+ title: { absolute: pageTitle(site, `${what} not found`, "auto") },
105
106
  robots: { index: false, follow: false },
106
107
  });
@@ -35,6 +35,8 @@ export type Site = {
35
35
  title: string;
36
36
  /** What follows " · " in every title. Defaults to the host, like pools.haruhime.moe. */
37
37
  titleSuffix?: string;
38
+ /** A shorter suffix, like "pools", for titles that would pass TITLE_MAX with the full one. */
39
+ shortTitleSuffix?: string;
38
40
  /** The default description, 140 to 160 characters. */
39
41
  description: string;
40
42
  /** Open Graph locale. Defaults to en_US. */
@@ -79,6 +81,14 @@ export declare const absoluteUrl: (site: Pick<Site, "url">, path: string) => str
79
81
  * @returns {string} a stable JSON-LD @id, like https://www.haruhime.moe/#organization
80
82
  */
81
83
  export declare const nodeId: (url: string, name: string) => string;
84
+ /** The longest title search results show in full; "auto" switches to the short suffix past it. */
85
+ export declare const TITLE_MAX = 60;
86
+ /**
87
+ * Which suffix a title gets: "full" (titleSuffix or the host), "short" (shortTitleSuffix, else
88
+ * full), "none" (the keyword alone), or "auto" (full, unless that passes TITLE_MAX and the site
89
+ * has a shortTitleSuffix).
90
+ */
91
+ export type TitleSuffixMode = "auto" | "full" | "short" | "none";
82
92
  /**
83
93
  * @function titleSuffix
84
94
  * @param site {Site} the site
@@ -89,10 +99,11 @@ export declare const titleSuffix: (site: Site) => string;
89
99
  * @function pageTitle
90
100
  * @param site {Site} the site
91
101
  * @param title {string} the page's primary keyword, like "Search osu! tournament mappools"
92
- * @returns {string} "Primary keyword · host" (the suffix is added once, never twice)
102
+ * @param mode {TitleSuffixMode} which suffix to add (default "full")
103
+ * @returns {string} "Primary keyword · host" (a suffix already there is never added twice)
93
104
  * @throws {Error} when title is blank
94
105
  */
95
- export declare const pageTitle: (site: Site, title: string) => string;
106
+ export declare const pageTitle: (site: Site, title: string, mode?: TitleSuffixMode) => string;
96
107
  /**
97
108
  * @function isoDate
98
109
  * @param value {string | Date | null | undefined} a date, maybe unknown
package/dist/seo/site.js CHANGED
@@ -55,6 +55,8 @@ export const absoluteUrl = (site, path) => {
55
55
  * @returns {string} a stable JSON-LD @id, like https://www.haruhime.moe/#organization
56
56
  */
57
57
  export const nodeId = (url, name) => `${origin(url)}/#${name}`;
58
+ /** The longest title search results show in full; "auto" switches to the short suffix past it. */
59
+ export const TITLE_MAX = 60;
58
60
  /**
59
61
  * @function titleSuffix
60
62
  * @param site {Site} the site
@@ -65,15 +67,29 @@ export const titleSuffix = (site) => site.titleSuffix ?? new URL(site.url).host;
65
67
  * @function pageTitle
66
68
  * @param site {Site} the site
67
69
  * @param title {string} the page's primary keyword, like "Search osu! tournament mappools"
68
- * @returns {string} "Primary keyword · host" (the suffix is added once, never twice)
70
+ * @param mode {TitleSuffixMode} which suffix to add (default "full")
71
+ * @returns {string} "Primary keyword · host" (a suffix already there is never added twice)
69
72
  * @throws {Error} when title is blank
70
73
  */
71
- export const pageTitle = (site, title) => {
74
+ export const pageTitle = (site, title, mode = "full") => {
72
75
  const keyword = title.replace(/\s+/g, " ").trim();
73
76
  if (!keyword)
74
77
  throw new Error("seo: a page title can't be blank");
75
- const suffix = `${TITLE_SEPARATOR}${titleSuffix(site)}`;
76
- return keyword.endsWith(suffix) ? keyword : `${keyword}${suffix}`;
78
+ const full = titleSuffix(site);
79
+ const suffixes = [full, site.shortTitleSuffix]
80
+ .filter(Boolean)
81
+ .map((s) => `${TITLE_SEPARATOR}${s}`);
82
+ if (mode === "none" || suffixes.some((suffix) => keyword.endsWith(suffix)))
83
+ return keyword;
84
+ const long = `${keyword}${TITLE_SEPARATOR}${full}`;
85
+ const short = site.shortTitleSuffix
86
+ ? `${keyword}${TITLE_SEPARATOR}${site.shortTitleSuffix}`
87
+ : long;
88
+ if (mode === "short")
89
+ return short;
90
+ if (mode === "auto" && long.length > TITLE_MAX)
91
+ return short;
92
+ return long;
77
93
  };
78
94
  /**
79
95
  * @function isoDate
@@ -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.3.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",