@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,16 @@
1
+ /**
2
+ * @file src/mongo/duplicate.ts
3
+ * @desc Whether a MongoDB error is a duplicate key (E11000): two upserts racing to insert the
4
+ * same _id, or a unique index that existing rows break. Pure, no driver import.
5
+ * @author David @dvhsh (https://dvh.sh)
6
+ * @created Mon Sep 28, 2026
7
+ * @modified Mon Sep 28, 2026
8
+ */
9
+ /** MongoDB's duplicate key error code. */
10
+ export declare const DUPLICATE_KEY = 11000;
11
+ /**
12
+ * @function isDuplicateKeyError
13
+ * @param error {unknown} anything thrown
14
+ * @returns {boolean} true when it carries code 11000
15
+ */
16
+ export declare const isDuplicateKeyError: (error: unknown) => boolean;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @file src/mongo/duplicate.ts
3
+ * @desc Whether a MongoDB error is a duplicate key (E11000): two upserts racing to insert the
4
+ * same _id, or a unique index that existing rows break. Pure, no driver import.
5
+ * @author David @dvhsh (https://dvh.sh)
6
+ * @created Mon Sep 28, 2026
7
+ * @modified Mon Sep 28, 2026
8
+ */
9
+ /** MongoDB's duplicate key error code. */
10
+ export const DUPLICATE_KEY = 11000;
11
+ /**
12
+ * @function isDuplicateKeyError
13
+ * @param error {unknown} anything thrown
14
+ * @returns {boolean} true when it carries code 11000
15
+ */
16
+ export const isDuplicateKeyError = (error) => typeof error === "object" && error !== null && "code" in error && error.code === DUPLICATE_KEY;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @file src/mongo/index.ts
3
+ * @desc @haruhimemoe/next-kit/mongo: the connect-once MongoDB client with Mongoose on the same
4
+ * client, index declaration and duplicate-safe creation, the collection-name constants
5
+ * pattern, and the duplicate key check. Server only.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Mon Sep 28, 2026
8
+ * @modified Mon Sep 28, 2026
9
+ */
10
+ export { createMongo, DEFAULT_MAX_POOL_SIZE, DEFAULT_SERVER_SELECTION_TIMEOUT_MS, type Mongo, type MongoOptions, } from "./client.js";
11
+ export { defineCollections } from "./collections.js";
12
+ export { DUPLICATE_KEY, isDuplicateKeyError } from "./duplicate.js";
13
+ export { ensureIndexes, type IndexReport, type IndexSpec, indexName, ttlIndex, } from "./indexes.js";
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @file src/mongo/index.ts
3
+ * @desc @haruhimemoe/next-kit/mongo: the connect-once MongoDB client with Mongoose on the same
4
+ * client, index declaration and duplicate-safe creation, the collection-name constants
5
+ * pattern, and the duplicate key check. Server only.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Mon Sep 28, 2026
8
+ * @modified Mon Sep 28, 2026
9
+ */
10
+ export { createMongo, DEFAULT_MAX_POOL_SIZE, DEFAULT_SERVER_SELECTION_TIMEOUT_MS, } from "./client.js";
11
+ export { defineCollections } from "./collections.js";
12
+ export { DUPLICATE_KEY, isDuplicateKeyError } from "./duplicate.js";
13
+ export { ensureIndexes, indexName, ttlIndex, } from "./indexes.js";
@@ -0,0 +1,55 @@
1
+ /**
2
+ * @file src/mongo/indexes.ts
3
+ * @desc Index declaration and creation for collections Mongoose doesn't manage. Each index builds
4
+ * on its own over whatever data is there: a unique one that existing duplicates break is
5
+ * skipped and logged with up to 10 of the duplicate keys (never for a secret key, like a
6
+ * session token), any other failure is logged, and the rest still build. Nothing throws:
7
+ * a missing index must not take sign-in or the site down. createIndex is a no-op when the
8
+ * index already exists. Moved from pools (src/lib/db-indexes.ts), whose auth indexes got
9
+ * this treatment; the TTL indexes both apps declared use it too.
10
+ * @author David @dvhsh (https://dvh.sh)
11
+ * @created Mon Sep 28, 2026
12
+ * @modified Mon Sep 28, 2026
13
+ */
14
+ import type { Db, Document } from "mongodb";
15
+ /** One index to build. */
16
+ export type IndexSpec = {
17
+ collection: string;
18
+ /** Field order matters; 1 ascending, -1 descending. */
19
+ key: Record<string, 1 | -1>;
20
+ /** Defaults to MongoDB's own (`field_1_other_-1`). */
21
+ name?: string;
22
+ unique?: boolean;
23
+ /** A TTL index: documents go this long after the key's date (0: at it). */
24
+ expireAfterSeconds?: number;
25
+ partialFilterExpression?: Document;
26
+ /** The key is a credential: never log its values. */
27
+ secret?: boolean;
28
+ };
29
+ /** What ensureIndexes did, by index name. */
30
+ export type IndexReport = {
31
+ built: string[];
32
+ skipped: string[];
33
+ };
34
+ /**
35
+ * @function indexName
36
+ * @param spec {IndexSpec} an index
37
+ * @returns {string} its name, or the one MongoDB gives it (`osuId_1`, `a_1_b_-1`)
38
+ */
39
+ export declare const indexName: (spec: IndexSpec) => string;
40
+ /**
41
+ * @function ttlIndex
42
+ * @param collection {string} the collection
43
+ * @param field {string} the date field
44
+ * @param expireAfterSeconds {number} how long after that date a document goes (default 0)
45
+ * @param name {string | undefined} the index name (default MongoDB's)
46
+ * @returns {IndexSpec} a TTL index on that field
47
+ */
48
+ export declare const ttlIndex: (collection: string, field: string, expireAfterSeconds?: number, name?: string) => IndexSpec;
49
+ /**
50
+ * @function ensureIndexes
51
+ * @param db {Db} the database
52
+ * @param specs {readonly IndexSpec[]} the indexes to build
53
+ * @returns {Promise<IndexReport>} each index built, or skipped and logged (never thrown)
54
+ */
55
+ export declare const ensureIndexes: (db: Db, specs: readonly IndexSpec[]) => Promise<IndexReport>;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * @file src/mongo/indexes.ts
3
+ * @desc Index declaration and creation for collections Mongoose doesn't manage. Each index builds
4
+ * on its own over whatever data is there: a unique one that existing duplicates break is
5
+ * skipped and logged with up to 10 of the duplicate keys (never for a secret key, like a
6
+ * session token), any other failure is logged, and the rest still build. Nothing throws:
7
+ * a missing index must not take sign-in or the site down. createIndex is a no-op when the
8
+ * index already exists. Moved from pools (src/lib/db-indexes.ts), whose auth indexes got
9
+ * this treatment; the TTL indexes both apps declared use it too.
10
+ * @author David @dvhsh (https://dvh.sh)
11
+ * @created Mon Sep 28, 2026
12
+ * @modified Mon Sep 28, 2026
13
+ */
14
+ import { isDuplicateKeyError } from "./duplicate.js";
15
+ /**
16
+ * @function indexName
17
+ * @param spec {IndexSpec} an index
18
+ * @returns {string} its name, or the one MongoDB gives it (`osuId_1`, `a_1_b_-1`)
19
+ */
20
+ export const indexName = (spec) => spec.name ??
21
+ Object.entries(spec.key)
22
+ .map(([field, order]) => `${field}_${order}`)
23
+ .join("_");
24
+ /**
25
+ * @function ttlIndex
26
+ * @param collection {string} the collection
27
+ * @param field {string} the date field
28
+ * @param expireAfterSeconds {number} how long after that date a document goes (default 0)
29
+ * @param name {string | undefined} the index name (default MongoDB's)
30
+ * @returns {IndexSpec} a TTL index on that field
31
+ */
32
+ export const ttlIndex = (collection, field, expireAfterSeconds = 0, name) => ({
33
+ collection,
34
+ key: { [field]: 1 },
35
+ expireAfterSeconds,
36
+ ...(name === undefined ? {} : { name }),
37
+ });
38
+ /** Up to 10 key values held by more than one row, with how many rows hold each. */
39
+ const duplicatesOf = async (db, spec) => {
40
+ const group = Object.fromEntries(Object.keys(spec.key).map((field) => [field, `$${field}`]));
41
+ return db
42
+ .collection(spec.collection)
43
+ .aggregate([
44
+ { $group: { _id: group, rows: { $sum: 1 } } },
45
+ { $match: { rows: { $gt: 1 } } },
46
+ { $limit: 10 },
47
+ ])
48
+ .toArray();
49
+ };
50
+ const buildIndex = async (db, spec) => {
51
+ const name = indexName(spec);
52
+ const { collection, key, secret, ...options } = spec;
53
+ try {
54
+ await db.collection(collection).createIndex(key, { ...options, name });
55
+ return true;
56
+ }
57
+ catch (error) {
58
+ if (!isDuplicateKeyError(error)) {
59
+ console.error(`db: couldn't create ${name}`, secret ? "" : error);
60
+ return false;
61
+ }
62
+ const found = secret ? [] : await duplicatesOf(db, spec).catch(() => []);
63
+ console.error(`db: ${collection} has rows sharing ${Object.keys(key).join(", ")}, so ${name} wasn't built. ` +
64
+ "Merge or remove the extra rows; it builds on the next start.", secret ? "" : JSON.stringify(found));
65
+ return false;
66
+ }
67
+ };
68
+ /**
69
+ * @function ensureIndexes
70
+ * @param db {Db} the database
71
+ * @param specs {readonly IndexSpec[]} the indexes to build
72
+ * @returns {Promise<IndexReport>} each index built, or skipped and logged (never thrown)
73
+ */
74
+ export const ensureIndexes = async (db, specs) => {
75
+ const results = await Promise.all(specs.map((spec) => buildIndex(db, spec)));
76
+ const report = { built: [], skipped: [] };
77
+ specs.forEach((spec, index) => {
78
+ report[results[index] ? "built" : "skipped"].push(indexName(spec));
79
+ });
80
+ return report;
81
+ };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * @file src/server/body.ts
3
+ * @desc Reading what a route is sent. parseJsonBody takes application/json only (a cross-site
4
+ * form can't send it without a CORS preflight), at most 16 KB unless the route gives its
5
+ * own cap, and a zod schema (pass z.strictObject so unknown keys are refused); a schema
6
+ * refusal's code is the one its refinement names in `params.code`, like content_filter.
7
+ * parseIdList reads `?ids=1,2,3`. Moved from pools (src/lib/api.ts), which had packs'
8
+ * version plus the cap option, the named codes and a strict id parser.
9
+ * @author David @dvhsh (https://dvh.sh)
10
+ * @created Mon Sep 28, 2026
11
+ * @modified Mon Sep 28, 2026
12
+ */
13
+ import type { z } from "zod";
14
+ /** The default body cap: a 64-slot pack or an admin edit is under 3 KB of JSON. */
15
+ export declare const MAX_BODY_BYTES = 16384;
16
+ /** What parseJsonBody gives back: the parsed data, or an error answer ready to send. */
17
+ export type ParsedBody<T> = {
18
+ ok: true;
19
+ data: T;
20
+ } | {
21
+ ok: false;
22
+ response: Response;
23
+ };
24
+ /** parseJsonBody's options. */
25
+ export type ParseJsonBodyOptions = {
26
+ /** The 413 message (default "That request is too large."). */
27
+ tooLarge?: string;
28
+ /** The cap in bytes (default MAX_BODY_BYTES). */
29
+ maxBytes?: number;
30
+ };
31
+ /**
32
+ * @function parseJsonBody
33
+ * @param request {Request} incoming request
34
+ * @param schema {z.ZodType} what the body must be
35
+ * @param options {ParseJsonBodyOptions} the 413 message and the cap
36
+ * @returns {Promise<ParsedBody<z.output<T>>>} parsed data, or a 415, 413 or 400 answer: the
37
+ * 400's message is the first issue's, its code the refinement's `params.code` if it's a
38
+ * string, else bad_request
39
+ */
40
+ export declare const parseJsonBody: <T extends z.ZodType>(request: Request, schema: T, { tooLarge, maxBytes }?: ParseJsonBodyOptions) => Promise<ParsedBody<z.output<T>>>;
41
+ /** The largest id an osu! beatmap (or any 32-bit signed id) can have. */
42
+ export declare const MAX_ID = 2147483647;
43
+ /** parseIdList's options. */
44
+ export type ParseIdListOptions = {
45
+ /** At most this many ids. */
46
+ max: number;
47
+ /** Whether one id is acceptable (default: 1 to MAX_ID). */
48
+ isValid?: (id: number) => boolean;
49
+ };
50
+ /**
51
+ * @function parseIdList
52
+ * @param raw {string | null} a comma-separated query value, like `?ids=`
53
+ * @param options {ParseIdListOptions} the most ids and the per-id check
54
+ * @returns {number[] | null} 1 to `max` ids in the order sent, each 1 to 10 plain digits and
55
+ * valid; null for anything else (an empty part, spaces, signs, hex, exponents, too many)
56
+ */
57
+ export declare const parseIdList: (raw: string | null, { max, isValid }: ParseIdListOptions) => number[] | null;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @file src/server/body.ts
3
+ * @desc Reading what a route is sent. parseJsonBody takes application/json only (a cross-site
4
+ * form can't send it without a CORS preflight), at most 16 KB unless the route gives its
5
+ * own cap, and a zod schema (pass z.strictObject so unknown keys are refused); a schema
6
+ * refusal's code is the one its refinement names in `params.code`, like content_filter.
7
+ * parseIdList reads `?ids=1,2,3`. Moved from pools (src/lib/api.ts), which had packs'
8
+ * version plus the cap option, the named codes and a strict id parser.
9
+ * @author David @dvhsh (https://dvh.sh)
10
+ * @created Mon Sep 28, 2026
11
+ * @modified Mon Sep 28, 2026
12
+ */
13
+ import { jsonError } from "./errors.js";
14
+ /** The default body cap: a 64-slot pack or an admin edit is under 3 KB of JSON. */
15
+ export const MAX_BODY_BYTES = 16_384;
16
+ /**
17
+ * @function parseJsonBody
18
+ * @param request {Request} incoming request
19
+ * @param schema {z.ZodType} what the body must be
20
+ * @param options {ParseJsonBodyOptions} the 413 message and the cap
21
+ * @returns {Promise<ParsedBody<z.output<T>>>} parsed data, or a 415, 413 or 400 answer: the
22
+ * 400's message is the first issue's, its code the refinement's `params.code` if it's a
23
+ * string, else bad_request
24
+ */
25
+ export const parseJsonBody = async (request, schema, { tooLarge = "That request is too large.", maxBytes = MAX_BODY_BYTES } = {}) => {
26
+ const type = request.headers.get("content-type")?.toLowerCase() ?? "";
27
+ if (!type.startsWith("application/json")) {
28
+ return { ok: false, response: jsonError(415, "Send the request as JSON.") };
29
+ }
30
+ // Refuse an honest oversized body before reading it; still measure what actually arrived.
31
+ if (Number(request.headers.get("content-length") ?? 0) > maxBytes) {
32
+ return { ok: false, response: jsonError(413, tooLarge) };
33
+ }
34
+ const text = await request.text();
35
+ if (new TextEncoder().encode(text).length > maxBytes) {
36
+ return { ok: false, response: jsonError(413, tooLarge) };
37
+ }
38
+ let body;
39
+ try {
40
+ body = JSON.parse(text);
41
+ }
42
+ catch {
43
+ return { ok: false, response: jsonError(400, "That request wasn't valid JSON.") };
44
+ }
45
+ const parsed = schema.safeParse(body);
46
+ if (parsed.success)
47
+ return { ok: true, data: parsed.data };
48
+ const issue = parsed.error.issues[0];
49
+ const named = issue?.code === "custom" ? issue.params?.code : undefined;
50
+ return {
51
+ ok: false,
52
+ response: jsonError(400, issue?.message ?? "That request isn't valid.", typeof named === "string" ? named : undefined),
53
+ };
54
+ };
55
+ /** The largest id an osu! beatmap (or any 32-bit signed id) can have. */
56
+ export const MAX_ID = 2_147_483_647;
57
+ const isPositiveId = (id) => id >= 1 && id <= MAX_ID;
58
+ /**
59
+ * @function parseIdList
60
+ * @param raw {string | null} a comma-separated query value, like `?ids=`
61
+ * @param options {ParseIdListOptions} the most ids and the per-id check
62
+ * @returns {number[] | null} 1 to `max` ids in the order sent, each 1 to 10 plain digits and
63
+ * valid; null for anything else (an empty part, spaces, signs, hex, exponents, too many)
64
+ */
65
+ export const parseIdList = (raw, { max, isValid = isPositiveId }) => {
66
+ // The longest valid id (10 digits) plus a comma; a longer value is refused unread.
67
+ if (raw === null || raw.length > max * 11)
68
+ return null;
69
+ const parts = raw.split(",");
70
+ if (parts.some((part) => !/^\d{1,10}$/.test(part)))
71
+ return null;
72
+ const ids = parts.map(Number);
73
+ return ids.length <= max && ids.every(isValid) ? ids : null;
74
+ };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @file src/server/budget.ts
3
+ * @desc A shared call budget, like the osu! API's: a global fixed window across every instance
4
+ * and, optionally, each subject's share, counted in the same documents as rate limits. The
5
+ * share is counted first, and a subject past its share never touches the global counter,
6
+ * so one caller can't spend the budget for everyone. gate() is the beforeCall a request
7
+ * hands @haruhimemoe/osu: once it says no, it says no for the rest of that request, and a
8
+ * counter it can't write counts as no. Moved from pools (src/lib/osu-budget.ts) and packs
9
+ * (src/lib/osu/attributes.ts), which held the same functions around their own constants.
10
+ * @author David @dvhsh (https://dvh.sh)
11
+ * @created Mon Sep 28, 2026
12
+ * @modified Mon Sep 28, 2026
13
+ */
14
+ import { type CounterStore, type RateLimitRule } from "./counter.js";
15
+ /** createBudget's options. */
16
+ export type BudgetOptions = CounterStore & {
17
+ /** The budget every caller shares (osu!: 50 calls a minute). */
18
+ global: RateLimitRule;
19
+ /** The subject the global counter is kept under (default "global"). */
20
+ globalSubject?: string;
21
+ /** Each subject's share, when one is passed (osu!: 20 calls a minute per IP). */
22
+ perSubject?: RateLimitRule;
23
+ };
24
+ /** What createBudget returns. */
25
+ export type Budget = {
26
+ /**
27
+ * Takes one call from the budget: true when it fits the subject's share (when a subject is
28
+ * given) and the global window. Throws when a counter can't be written.
29
+ */
30
+ take: (subject?: string, nowMs?: number) => Promise<boolean>;
31
+ /** A beforeCall: true while the budget allows, false from its first no (or failure) on. */
32
+ gate: (subject?: string, now?: () => number) => () => Promise<boolean>;
33
+ };
34
+ /**
35
+ * @function createBudget
36
+ * @param options {BudgetOptions} the counters' store, the global rule and the per-subject share
37
+ * @returns {Budget} take and gate over those counters
38
+ */
39
+ export declare const createBudget: ({ global, globalSubject, perSubject, ...store }: BudgetOptions) => Budget;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @file src/server/budget.ts
3
+ * @desc A shared call budget, like the osu! API's: a global fixed window across every instance
4
+ * and, optionally, each subject's share, counted in the same documents as rate limits. The
5
+ * share is counted first, and a subject past its share never touches the global counter,
6
+ * so one caller can't spend the budget for everyone. gate() is the beforeCall a request
7
+ * hands @haruhimemoe/osu: once it says no, it says no for the rest of that request, and a
8
+ * counter it can't write counts as no. Moved from pools (src/lib/osu-budget.ts) and packs
9
+ * (src/lib/osu/attributes.ts), which held the same functions around their own constants.
10
+ * @author David @dvhsh (https://dvh.sh)
11
+ * @created Mon Sep 28, 2026
12
+ * @modified Mon Sep 28, 2026
13
+ */
14
+ import { bumpCounter } from "./counter.js";
15
+ /**
16
+ * @function createBudget
17
+ * @param options {BudgetOptions} the counters' store, the global rule and the per-subject share
18
+ * @returns {Budget} take and gate over those counters
19
+ */
20
+ export const createBudget = ({ global, globalSubject = "global", perSubject, ...store }) => {
21
+ const take = async (subject, nowMs = Date.now()) => {
22
+ if (subject !== undefined && perSubject) {
23
+ const used = await bumpCounter(store, perSubject, subject, nowMs, 1);
24
+ if (used > perSubject.limit)
25
+ return false;
26
+ }
27
+ return (await bumpCounter(store, global, globalSubject, nowMs, 1)) <= global.limit;
28
+ };
29
+ const gate = (subject, now = Date.now) => {
30
+ let refused = false;
31
+ return async () => {
32
+ if (refused)
33
+ return false;
34
+ try {
35
+ if (await take(subject, now()))
36
+ return true;
37
+ }
38
+ catch (error) {
39
+ console.error(`[${global.scope}] budget: couldn't count`, error);
40
+ }
41
+ refused = true;
42
+ return false;
43
+ };
44
+ };
45
+ return { take, gate };
46
+ };
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @file src/server/client-ip.ts
3
+ * @desc The caller's IP as Vercel reports it (x-real-ip, then the first x-forwarded-for entry),
4
+ * and the subject every per-IP counter keys on: an IPv4 address whole, an IPv6 address by
5
+ * its /64, since one host usually gets a whole /64. Vercel's edge overwrites both headers
6
+ * with the connecting client's IP (it never appends a client-sent value), so neither can be
7
+ * spoofed in production. That holds only while Vercel is the first hop: behind another
8
+ * proxy or CDN, both headers carry whatever that hop sends. Byte-identical in packs and
9
+ * pools (src/utils/client-ip.ts) before it moved here.
10
+ * @author David @dvhsh (https://dvh.sh)
11
+ * @created Mon Sep 28, 2026
12
+ * @modified Mon Sep 28, 2026
13
+ */
14
+ /** Longest IPv6 text is 45 characters; anything longer is junk, and it becomes part of an _id. */
15
+ export declare const MAX_IP_LENGTH = 64;
16
+ /**
17
+ * @function clientIp
18
+ * @param headers {Headers} request headers
19
+ * @returns {string} the IP, or "unknown" when neither header has one
20
+ */
21
+ export declare const clientIp: (headers: Headers) => string;
22
+ /**
23
+ * @function rateLimitSubject
24
+ * @param ip {string} what clientIp returned
25
+ * @returns {string} the counter subject: an IPv4 address unchanged; the IPv4 part of an
26
+ * IPv4-mapped IPv6 address; any other IPv6 address as "g1:g2:g3:g4::/64" (lowercase, no
27
+ * leading zeros), so every address in one /64 shares a counter; "unknown" and anything
28
+ * that isn't an address unchanged
29
+ */
30
+ export declare const rateLimitSubject: (ip: string) => string;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * @file src/server/client-ip.ts
3
+ * @desc The caller's IP as Vercel reports it (x-real-ip, then the first x-forwarded-for entry),
4
+ * and the subject every per-IP counter keys on: an IPv4 address whole, an IPv6 address by
5
+ * its /64, since one host usually gets a whole /64. Vercel's edge overwrites both headers
6
+ * with the connecting client's IP (it never appends a client-sent value), so neither can be
7
+ * spoofed in production. That holds only while Vercel is the first hop: behind another
8
+ * proxy or CDN, both headers carry whatever that hop sends. Byte-identical in packs and
9
+ * pools (src/utils/client-ip.ts) before it moved here.
10
+ * @author David @dvhsh (https://dvh.sh)
11
+ * @created Mon Sep 28, 2026
12
+ * @modified Mon Sep 28, 2026
13
+ */
14
+ /** Longest IPv6 text is 45 characters; anything longer is junk, and it becomes part of an _id. */
15
+ export const MAX_IP_LENGTH = 64;
16
+ /**
17
+ * @function clientIp
18
+ * @param headers {Headers} request headers
19
+ * @returns {string} the IP, or "unknown" when neither header has one
20
+ */
21
+ export const clientIp = (headers) => {
22
+ const real = headers.get("x-real-ip")?.trim();
23
+ const forwarded = headers.get("x-forwarded-for")?.split(",")[0]?.trim();
24
+ return (real || forwarded || "unknown").slice(0, MAX_IP_LENGTH);
25
+ };
26
+ const HEX_GROUP = /^[0-9a-f]{1,4}$/;
27
+ const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
28
+ /**
29
+ * @function ipv4Groups
30
+ * @param text {string} a dotted IPv4 address
31
+ * @returns {number[] | null} its two 16-bit groups, or null when it isn't one
32
+ */
33
+ const ipv4Groups = (text) => {
34
+ const octets = IPV4.exec(text)?.slice(1).map(Number);
35
+ if (!octets || octets.some((octet) => octet > 255))
36
+ return null;
37
+ const [a = 0, b = 0, c = 0, d = 0] = octets;
38
+ return [(a << 8) | b, (c << 8) | d];
39
+ };
40
+ /**
41
+ * @function ipv6Groups
42
+ * @param text {string} a lowercase IPv6 address, possibly compressed or ending in dotted IPv4
43
+ * @returns {number[] | null} its eight 16-bit groups, or null when it isn't one
44
+ */
45
+ const ipv6Groups = (text) => {
46
+ const halves = text.split("::");
47
+ if (halves.length > 2)
48
+ return null;
49
+ const parse = (half) => {
50
+ if (half === "")
51
+ return [];
52
+ const parts = half.split(":");
53
+ const groups = [];
54
+ for (const [index, part] of parts.entries()) {
55
+ if (HEX_GROUP.test(part))
56
+ groups.push(Number.parseInt(part, 16));
57
+ else if (index === parts.length - 1 && ipv4Groups(part))
58
+ groups.push(...(ipv4Groups(part) ?? []));
59
+ else
60
+ return null;
61
+ }
62
+ return groups;
63
+ };
64
+ const head = parse(halves[0] ?? "");
65
+ const tail = halves.length === 2 ? parse(halves[1] ?? "") : [];
66
+ if (!head || !tail)
67
+ return null;
68
+ if (halves.length === 1)
69
+ return head.length === 8 ? head : null;
70
+ const missing = 8 - head.length - tail.length;
71
+ return missing >= 1 ? [...head, ...Array(missing).fill(0), ...tail] : null;
72
+ };
73
+ /**
74
+ * @function rateLimitSubject
75
+ * @param ip {string} what clientIp returned
76
+ * @returns {string} the counter subject: an IPv4 address unchanged; the IPv4 part of an
77
+ * IPv4-mapped IPv6 address; any other IPv6 address as "g1:g2:g3:g4::/64" (lowercase, no
78
+ * leading zeros), so every address in one /64 shares a counter; "unknown" and anything
79
+ * that isn't an address unchanged
80
+ */
81
+ export const rateLimitSubject = (ip) => {
82
+ if (!ip.includes(":"))
83
+ return ip;
84
+ const groups = ipv6Groups(ip.toLowerCase());
85
+ if (!groups)
86
+ return ip;
87
+ const [g1 = 0, g2 = 0, g3 = 0, g4 = 0, g5 = 0, g6 = 0, g7 = 0, g8 = 0] = groups;
88
+ if (g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0 && g6 === 0xffff) {
89
+ return [g7 >> 8, g7 & 0xff, g8 >> 8, g8 & 0xff].join(".");
90
+ }
91
+ return `${[g1, g2, g3, g4].map((group) => group.toString(16)).join(":")}::/64`;
92
+ };
@@ -0,0 +1,80 @@
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 type { Collection, Db } from "mongodb";
13
+ import { type IndexSpec } from "../mongo/indexes.js";
14
+ /** A fixed-window limit: at most `limit` hits per `windowSeconds`, counted under `scope`. */
15
+ export type RateLimitRule = {
16
+ scope: string;
17
+ limit: number;
18
+ windowSeconds: number;
19
+ };
20
+ /** The collection both apps keep counters in. */
21
+ export declare const RATE_LIMITS_COLLECTION = "rate_limits";
22
+ /** MongoDB's TTL monitor runs about once a minute; the grace keeps a live window's counter. */
23
+ export declare const COUNTER_GRACE_MS = 60000;
24
+ /**
25
+ * @function counterTtlIndex
26
+ * @param collection {string} the counters' collection (default RATE_LIMITS_COLLECTION)
27
+ * @returns {IndexSpec} the TTL index on expiresAt that removes spent counters (pass it to
28
+ * ensureIndexes from @haruhimemoe/next-kit/mongo)
29
+ */
30
+ export declare const counterTtlIndex: (collection?: string) => IndexSpec;
31
+ /** One counter document. */
32
+ export type CounterDoc = {
33
+ _id: string;
34
+ count: number;
35
+ expiresAt: Date;
36
+ };
37
+ /** Where a counter lives: the database (resolved per call) and the collection name. */
38
+ export type CounterStore = {
39
+ db: () => Promise<Db>;
40
+ collection?: string;
41
+ };
42
+ /** The window holding a moment, and when its counter may be removed. */
43
+ export type RateLimitWindow = {
44
+ start: number;
45
+ end: number;
46
+ resetSeconds: number;
47
+ expiresAt: Date;
48
+ };
49
+ /**
50
+ * @function windowFor
51
+ * @param rule {RateLimitRule} the limit
52
+ * @param nowMs {number} current time (ms)
53
+ * @returns {RateLimitWindow} the window holding nowMs; resetSeconds is 1 to windowSeconds
54
+ */
55
+ export declare const windowFor: (rule: RateLimitRule, nowMs: number) => RateLimitWindow;
56
+ /**
57
+ * @function rateLimitId
58
+ * @param rule {RateLimitRule} the limit
59
+ * @param subject {string} an IP subject, a user subject or a fixed one like "global"
60
+ * @param nowMs {number} current time (ms)
61
+ * @returns {string} "{scope}:{subject}:{windowStartSeconds}"
62
+ */
63
+ export declare const rateLimitId: (rule: RateLimitRule, subject: string, nowMs: number) => string;
64
+ /**
65
+ * @function counters
66
+ * @param store {CounterStore} database and collection
67
+ * @returns {Promise<Collection<CounterDoc>>} the counter collection
68
+ */
69
+ export declare const counters: ({ db, collection, }: CounterStore) => Promise<Collection<CounterDoc>>;
70
+ /**
71
+ * @function bumpCounter
72
+ * @param store {CounterStore} database and collection
73
+ * @param rule {RateLimitRule} the limit
74
+ * @param subject {string} who is counted
75
+ * @param nowMs {number} current time (ms)
76
+ * @param cost {number} how much to add
77
+ * @returns {Promise<number>} the count after this bump
78
+ * @throws when the counter can't be written
79
+ */
80
+ export declare const bumpCounter: (store: CounterStore, rule: RateLimitRule, subject: string, nowMs: number, cost: number) => Promise<number>;