@haruhimemoe/next-kit 0.1.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.
Files changed (78) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +210 -0
  4. package/dist/auth/create.d.ts +204 -0
  5. package/dist/auth/create.js +102 -0
  6. package/dist/auth/index.d.ts +13 -0
  7. package/dist/auth/index.js +13 -0
  8. package/dist/auth/indexes.d.ts +23 -0
  9. package/dist/auth/indexes.js +44 -0
  10. package/dist/auth/osu-id.d.ts +10 -0
  11. package/dist/auth/osu-id.js +10 -0
  12. package/dist/auth/osu.d.ts +80 -0
  13. package/dist/auth/osu.js +67 -0
  14. package/dist/auth/session.d.ts +48 -0
  15. package/dist/auth/session.js +32 -0
  16. package/dist/auth-react/RestoreSignedIn.d.ts +33 -0
  17. package/dist/auth-react/RestoreSignedIn.js +38 -0
  18. package/dist/auth-react/account-store.d.ts +55 -0
  19. package/dist/auth-react/account-store.js +78 -0
  20. package/dist/auth-react/index.d.ts +17 -0
  21. package/dist/auth-react/index.js +17 -0
  22. package/dist/auth-react/marker.d.ts +35 -0
  23. package/dist/auth-react/marker.js +30 -0
  24. package/dist/auth-react/sign-in.d.ts +24 -0
  25. package/dist/auth-react/sign-in.js +24 -0
  26. package/dist/auth-react/use-account.d.ts +52 -0
  27. package/dist/auth-react/use-account.js +52 -0
  28. package/dist/env/errors.d.ts +18 -0
  29. package/dist/env/errors.js +21 -0
  30. package/dist/env/index.d.ts +13 -0
  31. package/dist/env/index.js +13 -0
  32. package/dist/env/optional.d.ts +54 -0
  33. package/dist/env/optional.js +84 -0
  34. package/dist/env/osu-app.d.ts +24 -0
  35. package/dist/env/osu-app.js +32 -0
  36. package/dist/env/server-env.d.ts +63 -0
  37. package/dist/env/server-env.js +88 -0
  38. package/dist/mongo/client.d.ts +56 -0
  39. package/dist/mongo/client.js +72 -0
  40. package/dist/mongo/collections.d.ts +16 -0
  41. package/dist/mongo/collections.js +29 -0
  42. package/dist/mongo/duplicate.d.ts +16 -0
  43. package/dist/mongo/duplicate.js +16 -0
  44. package/dist/mongo/index.d.ts +13 -0
  45. package/dist/mongo/index.js +13 -0
  46. package/dist/mongo/indexes.d.ts +55 -0
  47. package/dist/mongo/indexes.js +81 -0
  48. package/dist/server/body.d.ts +57 -0
  49. package/dist/server/body.js +74 -0
  50. package/dist/server/budget.d.ts +39 -0
  51. package/dist/server/budget.js +46 -0
  52. package/dist/server/client-ip.d.ts +30 -0
  53. package/dist/server/client-ip.js +92 -0
  54. package/dist/server/counter.d.ts +80 -0
  55. package/dist/server/counter.js +76 -0
  56. package/dist/server/cross-site.d.ts +32 -0
  57. package/dist/server/cross-site.js +36 -0
  58. package/dist/server/errors.d.ts +57 -0
  59. package/dist/server/errors.js +55 -0
  60. package/dist/server/index.d.ts +20 -0
  61. package/dist/server/index.js +20 -0
  62. package/dist/server/machine-auth.d.ts +55 -0
  63. package/dist/server/machine-auth.js +74 -0
  64. package/dist/server/rate-limit.d.ts +75 -0
  65. package/dist/server/rate-limit.js +101 -0
  66. package/dist/server/safe-next.d.ts +36 -0
  67. package/dist/server/safe-next.js +42 -0
  68. package/dist/server/security-txt.d.ts +32 -0
  69. package/dist/server/security-txt.js +31 -0
  70. package/dist/testing/env.d.ts +29 -0
  71. package/dist/testing/env.js +33 -0
  72. package/dist/testing/index.d.ts +13 -0
  73. package/dist/testing/index.js +13 -0
  74. package/dist/testing/mongo.d.ts +44 -0
  75. package/dist/testing/mongo.js +46 -0
  76. package/dist/testing/msw.d.ts +18 -0
  77. package/dist/testing/msw.js +24 -0
  78. package/package.json +140 -0
@@ -0,0 +1,76 @@
1
+ /**
2
+ * @file src/server/counter.ts
3
+ * @desc Fixed windows and the counter document rate limits and budgets share: one document per
4
+ * scope, subject and window (`{scope}:{subject}:{windowStartSeconds}`), bumped with one
5
+ * findOneAndUpdate upsert $inc, removed by the TTL index on expiresAt a minute after its
6
+ * window ends. Before, each app computed the window three times (rate limits and two osu!
7
+ * budget windows). counterTtlIndex is the index that removes them.
8
+ * @author David @dvhsh (https://dvh.sh)
9
+ * @created Mon Sep 28, 2026
10
+ * @modified Mon Sep 28, 2026
11
+ */
12
+ import { isDuplicateKeyError } from "../mongo/duplicate.js";
13
+ import { ttlIndex } from "../mongo/indexes.js";
14
+ /** The collection both apps keep counters in. */
15
+ export const RATE_LIMITS_COLLECTION = "rate_limits";
16
+ /** MongoDB's TTL monitor runs about once a minute; the grace keeps a live window's counter. */
17
+ export const COUNTER_GRACE_MS = 60_000;
18
+ /**
19
+ * @function counterTtlIndex
20
+ * @param collection {string} the counters' collection (default RATE_LIMITS_COLLECTION)
21
+ * @returns {IndexSpec} the TTL index on expiresAt that removes spent counters (pass it to
22
+ * ensureIndexes from @haruhimemoe/next-kit/mongo)
23
+ */
24
+ export const counterTtlIndex = (collection = RATE_LIMITS_COLLECTION) => ttlIndex(collection, "expiresAt");
25
+ /**
26
+ * @function windowFor
27
+ * @param rule {RateLimitRule} the limit
28
+ * @param nowMs {number} current time (ms)
29
+ * @returns {RateLimitWindow} the window holding nowMs; resetSeconds is 1 to windowSeconds
30
+ */
31
+ export const windowFor = (rule, nowMs) => {
32
+ const size = rule.windowSeconds * 1000;
33
+ const start = Math.floor(nowMs / size) * size;
34
+ const end = start + size;
35
+ return {
36
+ start,
37
+ end,
38
+ resetSeconds: Math.ceil((end - nowMs) / 1000),
39
+ expiresAt: new Date(end + COUNTER_GRACE_MS),
40
+ };
41
+ };
42
+ /**
43
+ * @function rateLimitId
44
+ * @param rule {RateLimitRule} the limit
45
+ * @param subject {string} an IP subject, a user subject or a fixed one like "global"
46
+ * @param nowMs {number} current time (ms)
47
+ * @returns {string} "{scope}:{subject}:{windowStartSeconds}"
48
+ */
49
+ export const rateLimitId = (rule, subject, nowMs) => `${rule.scope}:${subject}:${windowFor(rule, nowMs).start / 1000}`;
50
+ /**
51
+ * @function counters
52
+ * @param store {CounterStore} database and collection
53
+ * @returns {Promise<Collection<CounterDoc>>} the counter collection
54
+ */
55
+ export const counters = async ({ db, collection = RATE_LIMITS_COLLECTION, }) => (await db()).collection(collection);
56
+ /**
57
+ * @function bumpCounter
58
+ * @param store {CounterStore} database and collection
59
+ * @param rule {RateLimitRule} the limit
60
+ * @param subject {string} who is counted
61
+ * @param nowMs {number} current time (ms)
62
+ * @param cost {number} how much to add
63
+ * @returns {Promise<number>} the count after this bump
64
+ * @throws when the counter can't be written
65
+ */
66
+ export const bumpCounter = async (store, rule, subject, nowMs, cost) => {
67
+ const collection = await counters(store);
68
+ const bump = () => collection.findOneAndUpdate({ _id: rateLimitId(rule, subject, nowMs) }, { $inc: { count: cost }, $setOnInsert: { expiresAt: windowFor(rule, nowMs).expiresAt } }, { upsert: true, returnDocument: "after" });
69
+ // Two first hits in a window can race to insert; the loser's retry finds the document.
70
+ const doc = await bump().catch((error) => {
71
+ if (!isDuplicateKeyError(error))
72
+ throw error;
73
+ return bump();
74
+ });
75
+ return doc?.count ?? cost;
76
+ };
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @file src/server/cross-site.ts
3
+ * @desc The same-origin guard every cookie-authenticated write runs: a request whose Origin is
4
+ * neither its own nor the site's, or whose Sec-Fetch-Site says cross-site or same-site (a
5
+ * sibling *.haruhime.moe host is another site here), gets a 403. Moved from pools
6
+ * (src/lib/api.ts); the site's URL and name come from the caller.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Mon Sep 28, 2026
9
+ * @modified Mon Sep 28, 2026
10
+ */
11
+ /** refuseCrossSite's options. */
12
+ export type CrossSiteOptions = {
13
+ /** The site's canonical URL; its origin is allowed besides the request's own. */
14
+ siteUrl: string;
15
+ /** The site's name, for the 403 message. */
16
+ siteTitle: string;
17
+ };
18
+ /**
19
+ * @function crossSiteMessage
20
+ * @param siteTitle {string} the site's name
21
+ * @returns {string} the 403 message refuseCrossSite sends
22
+ */
23
+ export declare const crossSiteMessage: (siteTitle: string) => string;
24
+ /**
25
+ * @function refuseCrossSite
26
+ * @param request {Request} a cookie-authenticated mutation
27
+ * @param options {CrossSiteOptions} the site's URL and name
28
+ * @returns {Response | null} 403 forbidden when Origin is present and isn't the request's own
29
+ * origin or the site's, or when Sec-Fetch-Site says cross-site or same-site; otherwise
30
+ * null
31
+ */
32
+ export declare const refuseCrossSite: (request: Request, { siteUrl, siteTitle }: CrossSiteOptions) => Response | null;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @file src/server/cross-site.ts
3
+ * @desc The same-origin guard every cookie-authenticated write runs: a request whose Origin is
4
+ * neither its own nor the site's, or whose Sec-Fetch-Site says cross-site or same-site (a
5
+ * sibling *.haruhime.moe host is another site here), gets a 403. Moved from pools
6
+ * (src/lib/api.ts); the site's URL and name come from the caller.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Mon Sep 28, 2026
9
+ * @modified Mon Sep 28, 2026
10
+ */
11
+ import { jsonError } from "./errors.js";
12
+ /** Sec-Fetch-Site values that mean another site (or a sibling host) sent the request. */
13
+ const FOREIGN_FETCH_SITES = new Set(["cross-site", "same-site"]);
14
+ /**
15
+ * @function crossSiteMessage
16
+ * @param siteTitle {string} the site's name
17
+ * @returns {string} the 403 message refuseCrossSite sends
18
+ */
19
+ export const crossSiteMessage = (siteTitle) => `This request has to come from ${siteTitle} itself.`;
20
+ /**
21
+ * @function refuseCrossSite
22
+ * @param request {Request} a cookie-authenticated mutation
23
+ * @param options {CrossSiteOptions} the site's URL and name
24
+ * @returns {Response | null} 403 forbidden when Origin is present and isn't the request's own
25
+ * origin or the site's, or when Sec-Fetch-Site says cross-site or same-site; otherwise
26
+ * null
27
+ */
28
+ export const refuseCrossSite = (request, { siteUrl, siteTitle }) => {
29
+ const origin = request.headers.get("origin");
30
+ const ownOrigin = new URL(request.url).origin;
31
+ const siteOrigin = new URL(siteUrl).origin;
32
+ const foreignOrigin = origin !== null && origin !== ownOrigin && origin !== siteOrigin;
33
+ const fetchSite = request.headers.get("sec-fetch-site");
34
+ const foreignFetch = fetchSite !== null && FOREIGN_FETCH_SITES.has(fetchSite);
35
+ return foreignOrigin || foreignFetch ? jsonError(403, crossSiteMessage(siteTitle)) : null;
36
+ };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * @file src/server/errors.ts
3
+ * @desc The JSON error answer every route sends ({ error: { code, message } }), the stable code
4
+ * per status, and two small header helpers (no-store, set several headers at once).
5
+ * Moved from packs and pools (src/lib/api.ts, src/lib/rate-limit.ts).
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Mon Sep 28, 2026
8
+ * @modified Mon Sep 28, 2026
9
+ */
10
+ /** Stable machine codes per status. Messages are for people and may change; codes don't. */
11
+ export declare const ERROR_CODES: Readonly<{
12
+ readonly 400: "bad_request";
13
+ readonly 401: "unauthorized";
14
+ readonly 403: "forbidden";
15
+ readonly 404: "not_found";
16
+ readonly 409: "conflict";
17
+ readonly 413: "too_large";
18
+ readonly 415: "unsupported_media_type";
19
+ readonly 429: "rate_limited";
20
+ readonly 500: "internal_error";
21
+ readonly 502: "upstream_error";
22
+ readonly 503: "unavailable";
23
+ }>;
24
+ /** The body of every error answer. */
25
+ export type ApiErrorBody = {
26
+ error: {
27
+ code: string;
28
+ message: string;
29
+ };
30
+ };
31
+ /**
32
+ * @function errorCodeFor
33
+ * @param status {number} HTTP status
34
+ * @returns {string} its code from ERROR_CODES; otherwise "internal_error" for 5xx, "bad_request"
35
+ */
36
+ export declare const errorCodeFor: (status: number) => string;
37
+ /**
38
+ * @function jsonError
39
+ * @param status {number} HTTP status
40
+ * @param message {string} shown to the person
41
+ * @param code {string} machine code (default: from the status)
42
+ * @returns {Response} `{ error: { code, message } }` JSON
43
+ */
44
+ export declare const jsonError: (status: number, message: string, code?: string) => Response;
45
+ /**
46
+ * @function withHeaders
47
+ * @param response {Response} a response with mutable headers
48
+ * @param headers {Record<string, string>} headers to set
49
+ * @returns {Response} the same response
50
+ */
51
+ export declare const withHeaders: (response: Response, headers: Record<string, string>) => Response;
52
+ /**
53
+ * @function noStore
54
+ * @param response {Response} a response with mutable headers
55
+ * @returns {Response} the same response, never cached
56
+ */
57
+ export declare const noStore: (response: Response) => Response;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * @file src/server/errors.ts
3
+ * @desc The JSON error answer every route sends ({ error: { code, message } }), the stable code
4
+ * per status, and two small header helpers (no-store, set several headers at once).
5
+ * Moved from packs and pools (src/lib/api.ts, src/lib/rate-limit.ts).
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Mon Sep 28, 2026
8
+ * @modified Mon Sep 28, 2026
9
+ */
10
+ /** Stable machine codes per status. Messages are for people and may change; codes don't. */
11
+ export const ERROR_CODES = Object.freeze({
12
+ 400: "bad_request",
13
+ 401: "unauthorized",
14
+ 403: "forbidden",
15
+ 404: "not_found",
16
+ 409: "conflict",
17
+ 413: "too_large",
18
+ 415: "unsupported_media_type",
19
+ 429: "rate_limited",
20
+ 500: "internal_error",
21
+ 502: "upstream_error",
22
+ 503: "unavailable",
23
+ });
24
+ /**
25
+ * @function errorCodeFor
26
+ * @param status {number} HTTP status
27
+ * @returns {string} its code from ERROR_CODES; otherwise "internal_error" for 5xx, "bad_request"
28
+ */
29
+ export const errorCodeFor = (status) => ERROR_CODES[status] ??
30
+ (status >= 500 ? "internal_error" : "bad_request");
31
+ /**
32
+ * @function jsonError
33
+ * @param status {number} HTTP status
34
+ * @param message {string} shown to the person
35
+ * @param code {string} machine code (default: from the status)
36
+ * @returns {Response} `{ error: { code, message } }` JSON
37
+ */
38
+ export const jsonError = (status, message, code = errorCodeFor(status)) => Response.json({ error: { code, message } }, { status });
39
+ /**
40
+ * @function withHeaders
41
+ * @param response {Response} a response with mutable headers
42
+ * @param headers {Record<string, string>} headers to set
43
+ * @returns {Response} the same response
44
+ */
45
+ export const withHeaders = (response, headers) => {
46
+ for (const [name, value] of Object.entries(headers))
47
+ response.headers.set(name, value);
48
+ return response;
49
+ };
50
+ /**
51
+ * @function noStore
52
+ * @param response {Response} a response with mutable headers
53
+ * @returns {Response} the same response, never cached
54
+ */
55
+ export const noStore = (response) => withHeaders(response, { "Cache-Control": "no-store" });
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @file src/server/index.ts
3
+ * @desc @haruhimemoe/next-kit/server: route handler helpers. JSON errors and headers, the body
4
+ * parser and id lists, the cross-site guard, the client IP and its rate-limit subject,
5
+ * fixed-window rate limits and call budgets in MongoDB, bearer machine auth, where to go
6
+ * after sign-in, and security.txt. Server only: machine-auth loads node:crypto.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Mon Sep 28, 2026
9
+ * @modified Mon Sep 28, 2026
10
+ */
11
+ export { MAX_BODY_BYTES, MAX_ID, type ParsedBody, type ParseIdListOptions, type ParseJsonBodyOptions, parseIdList, parseJsonBody, } from "./body.js";
12
+ export { type Budget, type BudgetOptions, createBudget } from "./budget.js";
13
+ export { clientIp, MAX_IP_LENGTH, rateLimitSubject } from "./client-ip.js";
14
+ export { COUNTER_GRACE_MS, type CounterDoc, type CounterStore, counterTtlIndex, RATE_LIMITS_COLLECTION, type RateLimitRule, type RateLimitWindow, rateLimitId, windowFor, } from "./counter.js";
15
+ export { type CrossSiteOptions, crossSiteMessage, refuseCrossSite } from "./cross-site.js";
16
+ export { type ApiErrorBody, ERROR_CODES, errorCodeFor, jsonError, noStore, withHeaders, } from "./errors.js";
17
+ export { type BearerFailureLimit, type BearerGuardOptions, bearerToken, refuseWithoutBearer, sameSecret, } from "./machine-auth.js";
18
+ export { createRateLimiter, type RateLimiter, type RateLimiterOptions, type RateLimitResult, rateLimitHeaders, retryText, tooManyRequests, unlimited, userSubject, } from "./rate-limit.js";
19
+ export { DEFAULT_SIGN_IN_PATH, MAX_NEXT_LENGTH, type SafeNextOptions, safeNextPath, signInHref, } from "./safe-next.js";
20
+ export { buildSecurityTxt, SECURITY_TXT_LIFETIME_DAYS, SECURITY_TXT_PATH, type SecurityTxtOptions, } from "./security-txt.js";
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @file src/server/index.ts
3
+ * @desc @haruhimemoe/next-kit/server: route handler helpers. JSON errors and headers, the body
4
+ * parser and id lists, the cross-site guard, the client IP and its rate-limit subject,
5
+ * fixed-window rate limits and call budgets in MongoDB, bearer machine auth, where to go
6
+ * after sign-in, and security.txt. Server only: machine-auth loads node:crypto.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Mon Sep 28, 2026
9
+ * @modified Mon Sep 28, 2026
10
+ */
11
+ export { MAX_BODY_BYTES, MAX_ID, parseIdList, parseJsonBody, } from "./body.js";
12
+ export { createBudget } from "./budget.js";
13
+ export { clientIp, MAX_IP_LENGTH, rateLimitSubject } from "./client-ip.js";
14
+ export { COUNTER_GRACE_MS, counterTtlIndex, RATE_LIMITS_COLLECTION, rateLimitId, windowFor, } from "./counter.js";
15
+ export { crossSiteMessage, refuseCrossSite } from "./cross-site.js";
16
+ export { ERROR_CODES, errorCodeFor, jsonError, noStore, withHeaders, } from "./errors.js";
17
+ export { bearerToken, refuseWithoutBearer, sameSecret, } from "./machine-auth.js";
18
+ export { createRateLimiter, rateLimitHeaders, retryText, tooManyRequests, unlimited, userSubject, } from "./rate-limit.js";
19
+ export { DEFAULT_SIGN_IN_PATH, MAX_NEXT_LENGTH, safeNextPath, signInHref, } from "./safe-next.js";
20
+ export { buildSecurityTxt, SECURITY_TXT_LIFETIME_DAYS, SECURITY_TXT_PATH, } from "./security-txt.js";
@@ -0,0 +1,55 @@
1
+ /**
2
+ * @file src/server/machine-auth.ts
3
+ * @desc Bearer secrets for machine-only routes (a cron job, another app's service calls), which
4
+ * read no session or cookies. They fail closed: 503 not_configured while the secret isn't
5
+ * set or is invalid (logged by name, never by value), 401 for a missing or wrong one. The
6
+ * comparison hashes both sides with SHA-256 and compares the digests with timingSafeEqual,
7
+ * so the time taken says nothing about the secret's length or content. The secret is
8
+ * compared before any failure is counted, so the right one always gets through, even from
9
+ * an IP past its failure limit. Moved from packs (src/lib/machine-auth.ts).
10
+ * @author David @dvhsh (https://dvh.sh)
11
+ * @created Mon Sep 28, 2026
12
+ * @modified Mon Sep 28, 2026
13
+ */
14
+ import type { RateLimitRule } from "./counter.js";
15
+ import { type RateLimiter } from "./rate-limit.js";
16
+ /**
17
+ * @function sameSecret
18
+ * @param given {string} what the request sent
19
+ * @param secret {string} the configured secret
20
+ * @returns {boolean} whether they're equal, compared as SHA-256 digests with timingSafeEqual
21
+ */
22
+ export declare const sameSecret: (given: string, secret: string) => boolean;
23
+ /**
24
+ * @function bearerToken
25
+ * @param headers {Headers} request headers
26
+ * @returns {string | null} what follows `Authorization: Bearer `, or null when there is none
27
+ */
28
+ export declare const bearerToken: (headers: Headers) => string | null;
29
+ /** Counting a caller's failures: the limiter and the rule (per IP subject). */
30
+ export type BearerFailureLimit = {
31
+ limiter: Pick<RateLimiter, "hit">;
32
+ rule: RateLimitRule;
33
+ };
34
+ /** refuseWithoutBearer's options. */
35
+ export type BearerGuardOptions = {
36
+ /** Reads the secret now; undefined while unset. An EnvError means set but invalid. */
37
+ secret: () => string | undefined;
38
+ /** Names the secret in the log line, like "cron" or "pools". */
39
+ label: string;
40
+ /** The 503 message while the secret isn't set up. */
41
+ notConfigured: string;
42
+ /** Counts a missing or wrong secret against the caller's IP: past the limit, 429 not 401. */
43
+ failures?: BearerFailureLimit;
44
+ /** Every refusal is no-store (default false). */
45
+ noStore?: boolean;
46
+ };
47
+ /**
48
+ * @function refuseWithoutBearer
49
+ * @param request {Request} the incoming request
50
+ * @param options {BearerGuardOptions} the secret, its label, the 503 message, failure counting
51
+ * @returns {Promise<Response | null>} null when the request carries `Authorization: Bearer
52
+ * <secret>`, however often its IP failed; otherwise a 503 not_configured, a 401, or a
53
+ * 429 when failures are counted and that IP is past the limit
54
+ */
55
+ export declare const refuseWithoutBearer: (request: Request, options: BearerGuardOptions) => Promise<Response | null>;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @file src/server/machine-auth.ts
3
+ * @desc Bearer secrets for machine-only routes (a cron job, another app's service calls), which
4
+ * read no session or cookies. They fail closed: 503 not_configured while the secret isn't
5
+ * set or is invalid (logged by name, never by value), 401 for a missing or wrong one. The
6
+ * comparison hashes both sides with SHA-256 and compares the digests with timingSafeEqual,
7
+ * so the time taken says nothing about the secret's length or content. The secret is
8
+ * compared before any failure is counted, so the right one always gets through, even from
9
+ * an IP past its failure limit. Moved from packs (src/lib/machine-auth.ts).
10
+ * @author David @dvhsh (https://dvh.sh)
11
+ * @created Mon Sep 28, 2026
12
+ * @modified Mon Sep 28, 2026
13
+ */
14
+ import { createHash, timingSafeEqual } from "node:crypto";
15
+ import { EnvError } from "../env/errors.js";
16
+ import { clientIp, rateLimitSubject } from "./client-ip.js";
17
+ import { jsonError, noStore } from "./errors.js";
18
+ import { tooManyRequests } from "./rate-limit.js";
19
+ const BEARER = "Bearer ";
20
+ const digest = (value) => createHash("sha256").update(value, "utf8").digest();
21
+ /**
22
+ * @function sameSecret
23
+ * @param given {string} what the request sent
24
+ * @param secret {string} the configured secret
25
+ * @returns {boolean} whether they're equal, compared as SHA-256 digests with timingSafeEqual
26
+ */
27
+ export const sameSecret = (given, secret) => timingSafeEqual(digest(given), digest(secret));
28
+ /**
29
+ * @function bearerToken
30
+ * @param headers {Headers} request headers
31
+ * @returns {string | null} what follows `Authorization: Bearer `, or null when there is none
32
+ */
33
+ export const bearerToken = (headers) => {
34
+ const header = headers.get("authorization") ?? "";
35
+ return header.startsWith(BEARER) && header.length > BEARER.length
36
+ ? header.slice(BEARER.length)
37
+ : null;
38
+ };
39
+ /** The secret, or undefined when it's unset or invalid (logged by name, never by value). */
40
+ const configured = (read, label) => {
41
+ try {
42
+ return read();
43
+ }
44
+ catch (error) {
45
+ if (!(error instanceof EnvError))
46
+ throw error;
47
+ console.error(`[${label}] ${error.message}`);
48
+ return undefined;
49
+ }
50
+ };
51
+ /**
52
+ * @function refuseWithoutBearer
53
+ * @param request {Request} the incoming request
54
+ * @param options {BearerGuardOptions} the secret, its label, the 503 message, failure counting
55
+ * @returns {Promise<Response | null>} null when the request carries `Authorization: Bearer
56
+ * <secret>`, however often its IP failed; otherwise a 503 not_configured, a 401, or a
57
+ * 429 when failures are counted and that IP is past the limit
58
+ */
59
+ export const refuseWithoutBearer = async (request, options) => {
60
+ const finish = (response) => (options.noStore ? noStore(response) : response);
61
+ const secret = configured(options.secret, options.label);
62
+ if (!secret)
63
+ return finish(jsonError(503, options.notConfigured, "not_configured"));
64
+ const given = bearerToken(request.headers);
65
+ if (given !== null && sameSecret(given, secret))
66
+ return null;
67
+ if (options.failures) {
68
+ const { limiter, rule } = options.failures;
69
+ const result = await limiter.hit(rule, rateLimitSubject(clientIp(request.headers)));
70
+ if (!result.allowed)
71
+ return finish(tooManyRequests(result));
72
+ }
73
+ return finish(jsonError(401, "Not authorized."));
74
+ };
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @file src/server/rate-limit.ts
3
+ * @desc Fixed-window rate limits in MongoDB (src/server/counter.ts has the document). A hit can
4
+ * cost more than one (a call carrying several ops counts each). Subjects are IP subjects
5
+ * (client-ip.ts) or, for signed-in users, userSubject(). Counting fails open: if the write
6
+ * fails, the request is allowed and the error logged. Moved from pools
7
+ * (src/lib/rate-limit.ts, with db injection and cost) plus packs' deleteRateLimitsFor as
8
+ * deleteSubject.
9
+ * @author David @dvhsh (https://dvh.sh)
10
+ * @created Mon Sep 28, 2026
11
+ * @modified Mon Sep 28, 2026
12
+ */
13
+ import { type CounterStore, type RateLimitRule } from "./counter.js";
14
+ /** One counted hit. */
15
+ export type RateLimitResult = {
16
+ allowed: boolean;
17
+ limit: number;
18
+ remaining: number;
19
+ resetSeconds: number;
20
+ };
21
+ /**
22
+ * @function rateLimitHeaders
23
+ * @param result {RateLimitResult} a counted hit
24
+ * @returns {Record<string, string>} RateLimit-Limit/Remaining/Reset, plus Retry-After when refused
25
+ */
26
+ export declare const rateLimitHeaders: (result: RateLimitResult) => Record<string, string>;
27
+ /**
28
+ * @function retryText
29
+ * @param seconds {number} wait time
30
+ * @returns {string} "45 seconds", "1 minute", "30 minutes"
31
+ */
32
+ export declare const retryText: (seconds: number) => string;
33
+ /**
34
+ * @function tooManyRequests
35
+ * @param result {RateLimitResult} a refused hit
36
+ * @returns {Response} 429 rate_limited with every rate-limit header
37
+ */
38
+ export declare const tooManyRequests: (result: RateLimitResult) => Response;
39
+ /**
40
+ * @function userSubject
41
+ * @param user {{ osuId: number }} a signed-in user
42
+ * @returns {string} "osu:<osuId>", their subject for per-user limits and the osu! budget: it
43
+ * outlives their user id, which a deleted and re-made account doesn't keep
44
+ */
45
+ export declare const userSubject: (user: {
46
+ osuId: number;
47
+ }) => string;
48
+ /**
49
+ * @function unlimited
50
+ * @param rule {RateLimitRule} the limit
51
+ * @param nowMs {number} current time (ms)
52
+ * @returns {RateLimitResult} nothing counted: the full limit left (a hit that couldn't be counted,
53
+ * or a response that must carry headers before anything was counted)
54
+ */
55
+ export declare const unlimited: (rule: RateLimitRule, nowMs: number) => RateLimitResult;
56
+ /** createRateLimiter's options: where counters live, and a clock for tests. */
57
+ export type RateLimiterOptions = CounterStore & {
58
+ now?: () => number;
59
+ };
60
+ /** What createRateLimiter returns. */
61
+ export type RateLimiter = {
62
+ /** Counts a hit (cost 1 unless given); allowed while count <= limit, and when counting fails. */
63
+ hit: (rule: RateLimitRule, subject: string, cost?: number) => Promise<RateLimitResult>;
64
+ /** Counts a hit; a no-store 429 when it's over the limit, otherwise null. */
65
+ refuseOverLimit: (rule: RateLimitRule, subject: string, cost?: number) => Promise<Response | null>;
66
+ /** Removes a subject's counters under these rules (account deletion); resolves to how many. */
67
+ deleteSubject: (rules: readonly RateLimitRule[], subject: string) => Promise<number>;
68
+ };
69
+ /**
70
+ * @function createRateLimiter
71
+ * @param options {RateLimiterOptions} the database (resolved per call), the collection (default
72
+ * rate_limits) and a clock (default Date.now)
73
+ * @returns {RateLimiter} hit, refuseOverLimit and deleteSubject over those counters
74
+ */
75
+ export declare const createRateLimiter: ({ now, ...store }: RateLimiterOptions) => RateLimiter;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * @file src/server/rate-limit.ts
3
+ * @desc Fixed-window rate limits in MongoDB (src/server/counter.ts has the document). A hit can
4
+ * cost more than one (a call carrying several ops counts each). Subjects are IP subjects
5
+ * (client-ip.ts) or, for signed-in users, userSubject(). Counting fails open: if the write
6
+ * fails, the request is allowed and the error logged. Moved from pools
7
+ * (src/lib/rate-limit.ts, with db injection and cost) plus packs' deleteRateLimitsFor as
8
+ * deleteSubject.
9
+ * @author David @dvhsh (https://dvh.sh)
10
+ * @created Mon Sep 28, 2026
11
+ * @modified Mon Sep 28, 2026
12
+ */
13
+ import { bumpCounter, counters, windowFor, } from "./counter.js";
14
+ import { jsonError, noStore, withHeaders } from "./errors.js";
15
+ /**
16
+ * @function rateLimitHeaders
17
+ * @param result {RateLimitResult} a counted hit
18
+ * @returns {Record<string, string>} RateLimit-Limit/Remaining/Reset, plus Retry-After when refused
19
+ */
20
+ export const rateLimitHeaders = (result) => ({
21
+ "RateLimit-Limit": String(result.limit),
22
+ "RateLimit-Remaining": String(result.remaining),
23
+ "RateLimit-Reset": String(result.resetSeconds),
24
+ ...(result.allowed ? {} : { "Retry-After": String(result.resetSeconds) }),
25
+ });
26
+ /**
27
+ * @function retryText
28
+ * @param seconds {number} wait time
29
+ * @returns {string} "45 seconds", "1 minute", "30 minutes"
30
+ */
31
+ export const retryText = (seconds) => {
32
+ if (seconds < 60)
33
+ return `${seconds} second${seconds === 1 ? "" : "s"}`;
34
+ const minutes = Math.ceil(seconds / 60);
35
+ return `${minutes} minute${minutes === 1 ? "" : "s"}`;
36
+ };
37
+ /**
38
+ * @function tooManyRequests
39
+ * @param result {RateLimitResult} a refused hit
40
+ * @returns {Response} 429 rate_limited with every rate-limit header
41
+ */
42
+ export const tooManyRequests = (result) => withHeaders(jsonError(429, `Too many requests. Try again in ${retryText(result.resetSeconds)}.`), rateLimitHeaders(result));
43
+ /**
44
+ * @function userSubject
45
+ * @param user {{ osuId: number }} a signed-in user
46
+ * @returns {string} "osu:<osuId>", their subject for per-user limits and the osu! budget: it
47
+ * outlives their user id, which a deleted and re-made account doesn't keep
48
+ */
49
+ export const userSubject = (user) => `osu:${user.osuId}`;
50
+ /**
51
+ * @function unlimited
52
+ * @param rule {RateLimitRule} the limit
53
+ * @param nowMs {number} current time (ms)
54
+ * @returns {RateLimitResult} nothing counted: the full limit left (a hit that couldn't be counted,
55
+ * or a response that must carry headers before anything was counted)
56
+ */
57
+ export const unlimited = (rule, nowMs) => ({
58
+ allowed: true,
59
+ limit: rule.limit,
60
+ remaining: rule.limit,
61
+ resetSeconds: windowFor(rule, nowMs).resetSeconds,
62
+ });
63
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
64
+ /**
65
+ * @function createRateLimiter
66
+ * @param options {RateLimiterOptions} the database (resolved per call), the collection (default
67
+ * rate_limits) and a clock (default Date.now)
68
+ * @returns {RateLimiter} hit, refuseOverLimit and deleteSubject over those counters
69
+ */
70
+ export const createRateLimiter = ({ now = Date.now, ...store }) => {
71
+ const hit = async (rule, subject, cost = 1) => {
72
+ const nowMs = now();
73
+ const base = unlimited(rule, nowMs);
74
+ try {
75
+ const count = await bumpCounter(store, rule, subject, nowMs, cost);
76
+ return { ...base, allowed: count <= rule.limit, remaining: Math.max(0, rule.limit - count) };
77
+ }
78
+ catch (error) {
79
+ console.error(`rate limit: couldn't count ${rule.scope}`, error);
80
+ return base;
81
+ }
82
+ };
83
+ return {
84
+ hit,
85
+ refuseOverLimit: async (rule, subject, cost = 1) => {
86
+ const result = await hit(rule, subject, cost);
87
+ return result.allowed ? null : noStore(tooManyRequests(result));
88
+ },
89
+ deleteSubject: async (rules, subject) => {
90
+ if (rules.length === 0)
91
+ return 0;
92
+ const scopes = rules.map((rule) => escapeRegExp(rule.scope)).join("|");
93
+ // Anchored on both ends: a subject can't widen the pattern to another scope or subject.
94
+ const pattern = `^(?:${scopes}):${escapeRegExp(subject)}:\\d+$`;
95
+ const { deletedCount } = await (await counters(store)).deleteMany({
96
+ _id: { $regex: pattern },
97
+ });
98
+ return deletedCount;
99
+ },
100
+ };
101
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @file src/server/safe-next.ts
3
+ * @desc Where to go after sign-in. Only same-site absolute paths pass; anything else (other
4
+ * hosts, protocol-relative, backslashes, control characters, the sign-in page itself)
5
+ * becomes the caller's fallback, a page every signed-in user can open. Browser-safe, so
6
+ * @haruhimemoe/next-kit/auth-react exports it too. Moved from pools (src/utils/safe-next.ts);
7
+ * packs' copy let /signin through.
8
+ * @author David @dvhsh (https://dvh.sh)
9
+ * @created Mon Sep 28, 2026
10
+ * @modified Mon Sep 28, 2026
11
+ */
12
+ /** The sign-in page both sites use. */
13
+ export declare const DEFAULT_SIGN_IN_PATH = "/signin";
14
+ /** A `next` longer than this is refused unread. */
15
+ export declare const MAX_NEXT_LENGTH = 512;
16
+ /** safeNextPath's options. */
17
+ export type SafeNextOptions = {
18
+ /** Where to go when `next` isn't safe (pools: /account, packs: /me). */
19
+ fallback: string;
20
+ /** The sign-in page, never a `next` (default DEFAULT_SIGN_IN_PATH). */
21
+ signInPath?: string;
22
+ };
23
+ /**
24
+ * @function safeNextPath
25
+ * @param raw {string | null | undefined} untrusted ?next= value
26
+ * @param options {SafeNextOptions} the fallback and the sign-in page
27
+ * @returns {string} a same-site path, or the fallback
28
+ */
29
+ export declare const safeNextPath: (raw: string | null | undefined, { fallback, signInPath }: SafeNextOptions) => string;
30
+ /**
31
+ * @function signInHref
32
+ * @param next {string} where to land after signing in
33
+ * @param signInPath {string} the sign-in page (default DEFAULT_SIGN_IN_PATH)
34
+ * @returns {string} the sign-in link carrying it, like /signin?next=%2Fpacks
35
+ */
36
+ export declare const signInHref: (next: string, signInPath?: string) => string;