@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.
- package/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +210 -0
- package/dist/auth/create.d.ts +204 -0
- package/dist/auth/create.js +102 -0
- package/dist/auth/index.d.ts +13 -0
- package/dist/auth/index.js +13 -0
- package/dist/auth/indexes.d.ts +23 -0
- package/dist/auth/indexes.js +44 -0
- package/dist/auth/osu-id.d.ts +10 -0
- package/dist/auth/osu-id.js +10 -0
- package/dist/auth/osu.d.ts +80 -0
- package/dist/auth/osu.js +67 -0
- package/dist/auth/session.d.ts +48 -0
- package/dist/auth/session.js +32 -0
- package/dist/auth-react/RestoreSignedIn.d.ts +33 -0
- package/dist/auth-react/RestoreSignedIn.js +38 -0
- package/dist/auth-react/account-store.d.ts +55 -0
- package/dist/auth-react/account-store.js +78 -0
- package/dist/auth-react/index.d.ts +17 -0
- package/dist/auth-react/index.js +17 -0
- package/dist/auth-react/marker.d.ts +35 -0
- package/dist/auth-react/marker.js +30 -0
- package/dist/auth-react/sign-in.d.ts +24 -0
- package/dist/auth-react/sign-in.js +24 -0
- package/dist/auth-react/use-account.d.ts +52 -0
- package/dist/auth-react/use-account.js +52 -0
- package/dist/env/errors.d.ts +18 -0
- package/dist/env/errors.js +21 -0
- package/dist/env/index.d.ts +13 -0
- package/dist/env/index.js +13 -0
- package/dist/env/optional.d.ts +54 -0
- package/dist/env/optional.js +84 -0
- package/dist/env/osu-app.d.ts +24 -0
- package/dist/env/osu-app.js +32 -0
- package/dist/env/server-env.d.ts +63 -0
- package/dist/env/server-env.js +88 -0
- package/dist/mongo/client.d.ts +56 -0
- package/dist/mongo/client.js +72 -0
- package/dist/mongo/collections.d.ts +16 -0
- package/dist/mongo/collections.js +29 -0
- package/dist/mongo/duplicate.d.ts +16 -0
- package/dist/mongo/duplicate.js +16 -0
- package/dist/mongo/index.d.ts +13 -0
- package/dist/mongo/index.js +13 -0
- package/dist/mongo/indexes.d.ts +55 -0
- package/dist/mongo/indexes.js +81 -0
- package/dist/server/body.d.ts +57 -0
- package/dist/server/body.js +74 -0
- package/dist/server/budget.d.ts +39 -0
- package/dist/server/budget.js +46 -0
- package/dist/server/client-ip.d.ts +30 -0
- package/dist/server/client-ip.js +92 -0
- package/dist/server/counter.d.ts +80 -0
- package/dist/server/counter.js +76 -0
- package/dist/server/cross-site.d.ts +32 -0
- package/dist/server/cross-site.js +36 -0
- package/dist/server/errors.d.ts +57 -0
- package/dist/server/errors.js +55 -0
- package/dist/server/index.d.ts +20 -0
- package/dist/server/index.js +20 -0
- package/dist/server/machine-auth.d.ts +55 -0
- package/dist/server/machine-auth.js +74 -0
- package/dist/server/rate-limit.d.ts +75 -0
- package/dist/server/rate-limit.js +101 -0
- package/dist/server/safe-next.d.ts +36 -0
- package/dist/server/safe-next.js +42 -0
- package/dist/server/security-txt.d.ts +32 -0
- package/dist/server/security-txt.js +31 -0
- package/dist/testing/env.d.ts +29 -0
- package/dist/testing/env.js +33 -0
- package/dist/testing/index.d.ts +13 -0
- package/dist/testing/index.js +13 -0
- package/dist/testing/mongo.d.ts +44 -0
- package/dist/testing/mongo.js +46 -0
- package/dist/testing/msw.d.ts +18 -0
- package/dist/testing/msw.js +24 -0
- 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;
|