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