@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 +25 -1
- package/README.md +143 -3
- package/dist/api-keys/format.d.ts +56 -0
- package/dist/api-keys/format.js +67 -0
- package/dist/api-keys/guard.d.ts +74 -0
- package/dist/api-keys/guard.js +90 -0
- package/dist/api-keys/index.d.ts +12 -0
- package/dist/api-keys/index.js +12 -0
- package/dist/api-keys/store.d.ts +64 -0
- package/dist/api-keys/store.js +95 -0
- package/dist/check/cli.d.ts +17 -0
- package/dist/check/cli.js +57 -0
- package/dist/check/standards.d.ts +29 -0
- package/dist/check/standards.js +85 -0
- package/dist/docs/crawl.d.ts +81 -0
- package/dist/docs/crawl.js +115 -0
- package/dist/docs/files/index.d.ts +43 -0
- package/dist/docs/files/index.js +74 -0
- package/dist/docs/index.d.ts +15 -0
- package/dist/docs/index.js +15 -0
- package/dist/docs/markdown-segments.d.ts +37 -0
- package/dist/docs/markdown-segments.js +147 -0
- package/dist/docs/markdown.d.ts +31 -0
- package/dist/docs/markdown.js +117 -0
- package/dist/docs/registry.d.ts +94 -0
- package/dist/docs/registry.js +102 -0
- package/dist/server/security-txt.d.ts +7 -5
- package/dist/server/security-txt.js +6 -5
- package/package.json +20 -4
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.
|
|
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`
|
|
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;
|