@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,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/auth-react/use-account.ts
|
|
3
|
+
* @desc The React side of the account store: useAccount (loading during server render), and
|
|
4
|
+
* createAccount, which wires a store to a better-auth client and the app's marker cookie
|
|
5
|
+
* the way packs and pools each did at the bottom of src/hooks/useAccount.ts.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Mon Sep 28, 2026
|
|
8
|
+
* @modified Mon Sep 28, 2026
|
|
9
|
+
*/
|
|
10
|
+
"use client";
|
|
11
|
+
import { createElement, useSyncExternalStore } from "react";
|
|
12
|
+
import { createAccountStore, LOADING, } from "./account-store.js";
|
|
13
|
+
import { RestoreSignedIn } from "./RestoreSignedIn.js";
|
|
14
|
+
/**
|
|
15
|
+
* @function sessionFetcher
|
|
16
|
+
* @param client {SessionClient} the app's better-auth client
|
|
17
|
+
* @returns {() => Promise<SessionData | null>} a getSession that throws better-auth's error
|
|
18
|
+
*/
|
|
19
|
+
export const sessionFetcher = (client) => async () => {
|
|
20
|
+
const { data, error } = await client.getSession();
|
|
21
|
+
if (error)
|
|
22
|
+
throw error;
|
|
23
|
+
return data ?? null;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* @function useAccount
|
|
27
|
+
* @param store {AccountStore} the page-wide store
|
|
28
|
+
* @returns {Account} current account state ("loading" during server render)
|
|
29
|
+
*/
|
|
30
|
+
export const useAccount = (store) => useSyncExternalStore(store.subscribe, store.getSnapshot, () => LOADING);
|
|
31
|
+
/**
|
|
32
|
+
* @function createAccount
|
|
33
|
+
* @param client {SessionClient} the app's better-auth client
|
|
34
|
+
* @param marker {SignedInMarker} the app's signed-in marker
|
|
35
|
+
* @returns {AccountKit} a page-wide store reading document.cookie, with its hook, sign-out and
|
|
36
|
+
* RestoreSignedIn (export them from the app's own "use client" module, so server pages
|
|
37
|
+
* render <RestoreSignedIn next={next} /> with only serializable props)
|
|
38
|
+
*/
|
|
39
|
+
export const createAccount = (client, marker) => {
|
|
40
|
+
const store = createAccountStore({
|
|
41
|
+
getSession: sessionFetcher(client),
|
|
42
|
+
readCookie: () => document.cookie,
|
|
43
|
+
hasMarker: marker.has,
|
|
44
|
+
clearMarker: () => marker.clear(),
|
|
45
|
+
});
|
|
46
|
+
return {
|
|
47
|
+
store,
|
|
48
|
+
useAccount: () => useAccount(store),
|
|
49
|
+
markSignedOut: () => store.markSignedOut(),
|
|
50
|
+
RestoreSignedIn: (props) => createElement(RestoreSignedIn, { ...props, store, hasMarker: marker.has }),
|
|
51
|
+
};
|
|
52
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/env/errors.ts
|
|
3
|
+
* @desc EnvError, thrown for a missing or invalid environment variable. Its message names the
|
|
4
|
+
* variable and never prints its value.
|
|
5
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
6
|
+
* @created Mon Sep 28, 2026
|
|
7
|
+
* @modified Mon Sep 28, 2026
|
|
8
|
+
*/
|
|
9
|
+
/** A missing or invalid environment variable, named but never printed. */
|
|
10
|
+
export declare class EnvError extends Error {
|
|
11
|
+
constructor(message: string);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* @function invalidEnv
|
|
15
|
+
* @param keys {readonly string[]} the variables that are missing or invalid
|
|
16
|
+
* @returns {EnvError} "Missing or invalid environment variables: A, B. See .env.example."
|
|
17
|
+
*/
|
|
18
|
+
export declare const invalidEnv: (keys: readonly string[]) => EnvError;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/env/errors.ts
|
|
3
|
+
* @desc EnvError, thrown for a missing or invalid environment variable. Its message names the
|
|
4
|
+
* variable and never prints its value.
|
|
5
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
6
|
+
* @created Mon Sep 28, 2026
|
|
7
|
+
* @modified Mon Sep 28, 2026
|
|
8
|
+
*/
|
|
9
|
+
/** A missing or invalid environment variable, named but never printed. */
|
|
10
|
+
export class EnvError extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "EnvError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* @function invalidEnv
|
|
18
|
+
* @param keys {readonly string[]} the variables that are missing or invalid
|
|
19
|
+
* @returns {EnvError} "Missing or invalid environment variables: A, B. See .env.example."
|
|
20
|
+
*/
|
|
21
|
+
export const invalidEnv = (keys) => new EnvError(`Missing or invalid environment variables: ${keys.join(", ")}. See .env.example.`);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/env/index.ts
|
|
3
|
+
* @desc @haruhimemoe/next-kit/env: zod env parsing with SKIP_ENV_VALIDATION and the production
|
|
4
|
+
* placeholder guard, the osu! app's five variables, and per-call readers for optional
|
|
5
|
+
* secrets, id lists, flags and origins. Server only.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Mon Sep 28, 2026
|
|
8
|
+
* @modified Mon Sep 28, 2026
|
|
9
|
+
*/
|
|
10
|
+
export { EnvError, invalidEnv } from "./errors.js";
|
|
11
|
+
export { optionalSecret, readFlag, readIdSet, readOptional, readOrigin, } from "./optional.js";
|
|
12
|
+
export { OSU_APP_PLACEHOLDERS, OSU_APP_SECRET_KEYS, type OsuAppEnv, osuAppEnvSchema, } from "./osu-app.js";
|
|
13
|
+
export { BUILD_PHASE, createServerEnv, type EnvSchema, type EnvSource, isEnvValidationSkipped, isProductionServer, type ServerEnv, type ServerEnvOptions, } from "./server-env.js";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/env/index.ts
|
|
3
|
+
* @desc @haruhimemoe/next-kit/env: zod env parsing with SKIP_ENV_VALIDATION and the production
|
|
4
|
+
* placeholder guard, the osu! app's five variables, and per-call readers for optional
|
|
5
|
+
* secrets, id lists, flags and origins. Server only.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Mon Sep 28, 2026
|
|
8
|
+
* @modified Mon Sep 28, 2026
|
|
9
|
+
*/
|
|
10
|
+
export { EnvError, invalidEnv } from "./errors.js";
|
|
11
|
+
export { optionalSecret, readFlag, readIdSet, readOptional, readOrigin, } from "./optional.js";
|
|
12
|
+
export { OSU_APP_PLACEHOLDERS, OSU_APP_SECRET_KEYS, osuAppEnvSchema, } from "./osu-app.js";
|
|
13
|
+
export { BUILD_PHASE, createServerEnv, isEnvValidationSkipped, isProductionServer, } from "./server-env.js";
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/env/optional.ts
|
|
3
|
+
* @desc Variables read on every call instead of memoized with the server env, so a missing or
|
|
4
|
+
* bad value only breaks what uses it and a change applies at the next request (a removed
|
|
5
|
+
* admin id stops working at once): optional secrets, osu! id lists, flags and origins.
|
|
6
|
+
* Moved from packs (optionalSecret: CRON_SECRET, POOLS_SERVICE_TOKEN) and pools
|
|
7
|
+
* (getAdminOsuIds, getAllowSharedDbUser, the PACKS_URL check).
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Mon Sep 28, 2026
|
|
10
|
+
* @modified Mon Sep 28, 2026
|
|
11
|
+
*/
|
|
12
|
+
import type { EnvSource } from "./server-env.js";
|
|
13
|
+
/**
|
|
14
|
+
* @function readOptional
|
|
15
|
+
* @param key {string} the variable
|
|
16
|
+
* @param source {EnvSource} usually process.env (the default)
|
|
17
|
+
* @returns {string | undefined} its value, trimmed; undefined when unset or blank
|
|
18
|
+
*/
|
|
19
|
+
export declare const readOptional: (key: string, source?: EnvSource) => string | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* @function optionalSecret
|
|
22
|
+
* @param key {string} the variable
|
|
23
|
+
* @param minLength {number} the shortest secret accepted
|
|
24
|
+
* @param source {EnvSource} usually process.env (the default)
|
|
25
|
+
* @returns {string | undefined} the secret, trimmed; undefined when unset (whatever needs it then
|
|
26
|
+
* refuses every call)
|
|
27
|
+
* @throws {EnvError} naming (never printing) the variable when it's set but too short
|
|
28
|
+
*/
|
|
29
|
+
export declare const optionalSecret: (key: string, minLength: number, source?: EnvSource) => string | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* @function readIdSet
|
|
32
|
+
* @param key {string} the variable, like ADMIN_OSU_IDS
|
|
33
|
+
* @param source {EnvSource} usually process.env (the default)
|
|
34
|
+
* @returns {ReadonlySet<number>} the comma-separated ids (spaces allowed around commas); empty
|
|
35
|
+
* when unset
|
|
36
|
+
* @throws {EnvError} naming (never printing) the variable when it isn't an id list
|
|
37
|
+
*/
|
|
38
|
+
export declare const readIdSet: (key: string, source?: EnvSource) => ReadonlySet<number>;
|
|
39
|
+
/**
|
|
40
|
+
* @function readFlag
|
|
41
|
+
* @param key {string} the variable
|
|
42
|
+
* @param source {EnvSource} usually process.env (the default)
|
|
43
|
+
* @returns {boolean} true only when it's "true" (trimmed); unset, blank or anything else is false
|
|
44
|
+
*/
|
|
45
|
+
export declare const readFlag: (key: string, source?: EnvSource) => boolean;
|
|
46
|
+
/**
|
|
47
|
+
* @function readOrigin
|
|
48
|
+
* @param key {string} the variable, like PACKS_URL
|
|
49
|
+
* @param fallback {string} the origin when it's unset
|
|
50
|
+
* @param source {EnvSource} usually process.env (the default)
|
|
51
|
+
* @returns {string} an origin (no path, query or hash): https, or http on localhost
|
|
52
|
+
* @throws {EnvError} naming (never printing) the variable when it's anything else
|
|
53
|
+
*/
|
|
54
|
+
export declare const readOrigin: (key: string, fallback: string, source?: EnvSource) => string;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/env/optional.ts
|
|
3
|
+
* @desc Variables read on every call instead of memoized with the server env, so a missing or
|
|
4
|
+
* bad value only breaks what uses it and a change applies at the next request (a removed
|
|
5
|
+
* admin id stops working at once): optional secrets, osu! id lists, flags and origins.
|
|
6
|
+
* Moved from packs (optionalSecret: CRON_SECRET, POOLS_SERVICE_TOKEN) and pools
|
|
7
|
+
* (getAdminOsuIds, getAllowSharedDbUser, the PACKS_URL check).
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Mon Sep 28, 2026
|
|
10
|
+
* @modified Mon Sep 28, 2026
|
|
11
|
+
*/
|
|
12
|
+
import { invalidEnv } from "./errors.js";
|
|
13
|
+
/**
|
|
14
|
+
* @function readOptional
|
|
15
|
+
* @param key {string} the variable
|
|
16
|
+
* @param source {EnvSource} usually process.env (the default)
|
|
17
|
+
* @returns {string | undefined} its value, trimmed; undefined when unset or blank
|
|
18
|
+
*/
|
|
19
|
+
export const readOptional = (key, source = process.env) => source[key]?.trim() || undefined;
|
|
20
|
+
/**
|
|
21
|
+
* @function optionalSecret
|
|
22
|
+
* @param key {string} the variable
|
|
23
|
+
* @param minLength {number} the shortest secret accepted
|
|
24
|
+
* @param source {EnvSource} usually process.env (the default)
|
|
25
|
+
* @returns {string | undefined} the secret, trimmed; undefined when unset (whatever needs it then
|
|
26
|
+
* refuses every call)
|
|
27
|
+
* @throws {EnvError} naming (never printing) the variable when it's set but too short
|
|
28
|
+
*/
|
|
29
|
+
export const optionalSecret = (key, minLength, source = process.env) => {
|
|
30
|
+
const value = readOptional(key, source);
|
|
31
|
+
if (value !== undefined && value.length < minLength)
|
|
32
|
+
throw invalidEnv([key]);
|
|
33
|
+
return value;
|
|
34
|
+
};
|
|
35
|
+
const ID_LIST = /^\d+(\s*,\s*\d+)*$/;
|
|
36
|
+
/**
|
|
37
|
+
* @function readIdSet
|
|
38
|
+
* @param key {string} the variable, like ADMIN_OSU_IDS
|
|
39
|
+
* @param source {EnvSource} usually process.env (the default)
|
|
40
|
+
* @returns {ReadonlySet<number>} the comma-separated ids (spaces allowed around commas); empty
|
|
41
|
+
* when unset
|
|
42
|
+
* @throws {EnvError} naming (never printing) the variable when it isn't an id list
|
|
43
|
+
*/
|
|
44
|
+
export const readIdSet = (key, source = process.env) => {
|
|
45
|
+
const raw = readOptional(key, source);
|
|
46
|
+
if (raw === undefined)
|
|
47
|
+
return new Set();
|
|
48
|
+
if (!ID_LIST.test(raw))
|
|
49
|
+
throw invalidEnv([key]);
|
|
50
|
+
return new Set(raw.split(",").map((part) => Number(part.trim())));
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* @function readFlag
|
|
54
|
+
* @param key {string} the variable
|
|
55
|
+
* @param source {EnvSource} usually process.env (the default)
|
|
56
|
+
* @returns {boolean} true only when it's "true" (trimmed); unset, blank or anything else is false
|
|
57
|
+
*/
|
|
58
|
+
export const readFlag = (key, source = process.env) => readOptional(key, source) === "true";
|
|
59
|
+
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1"]);
|
|
60
|
+
/**
|
|
61
|
+
* @function readOrigin
|
|
62
|
+
* @param key {string} the variable, like PACKS_URL
|
|
63
|
+
* @param fallback {string} the origin when it's unset
|
|
64
|
+
* @param source {EnvSource} usually process.env (the default)
|
|
65
|
+
* @returns {string} an origin (no path, query or hash): https, or http on localhost
|
|
66
|
+
* @throws {EnvError} naming (never printing) the variable when it's anything else
|
|
67
|
+
*/
|
|
68
|
+
export const readOrigin = (key, fallback, source = process.env) => {
|
|
69
|
+
const raw = readOptional(key, source);
|
|
70
|
+
if (raw === undefined)
|
|
71
|
+
return fallback;
|
|
72
|
+
let url;
|
|
73
|
+
try {
|
|
74
|
+
url = new URL(raw);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
throw invalidEnv([key]);
|
|
78
|
+
}
|
|
79
|
+
const secure = url.protocol === "https:" || (url.protocol === "http:" && LOCAL_HOSTS.has(url.hostname));
|
|
80
|
+
if (!secure || url.pathname !== "/" || url.search !== "" || url.hash !== "") {
|
|
81
|
+
throw invalidEnv([key]);
|
|
82
|
+
}
|
|
83
|
+
return url.origin;
|
|
84
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/env/osu-app.ts
|
|
3
|
+
* @desc The five variables a Next app with MongoDB and osu! sign-in needs (the same block in
|
|
4
|
+
* packs' and pools' src/env.ts), their SKIP_ENV_VALIDATION placeholders and which are
|
|
5
|
+
* secrets. Pass them to createServerEnv, or extend the schema first.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Mon Sep 28, 2026
|
|
8
|
+
* @modified Mon Sep 28, 2026
|
|
9
|
+
*/
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
/** MONGODB_URI, BETTER_AUTH_SECRET (32+), BETTER_AUTH_URL, OSU_CLIENT_ID (digits), OSU_CLIENT_SECRET. */
|
|
12
|
+
export declare const osuAppEnvSchema: z.ZodObject<{
|
|
13
|
+
MONGODB_URI: z.ZodString;
|
|
14
|
+
BETTER_AUTH_SECRET: z.ZodString;
|
|
15
|
+
BETTER_AUTH_URL: z.ZodURL;
|
|
16
|
+
OSU_CLIENT_ID: z.ZodString;
|
|
17
|
+
OSU_CLIENT_SECRET: z.ZodString;
|
|
18
|
+
}, z.core.$strip>;
|
|
19
|
+
/** The validated variables. */
|
|
20
|
+
export type OsuAppEnv = z.infer<typeof osuAppEnvSchema>;
|
|
21
|
+
/** Used only under SKIP_ENV_VALIDATION=true. Nothing connects with these. */
|
|
22
|
+
export declare const OSU_APP_PLACEHOLDERS: Readonly<OsuAppEnv>;
|
|
23
|
+
/** The variables whose placeholder would be a known secret on a production server. */
|
|
24
|
+
export declare const OSU_APP_SECRET_KEYS: readonly ["BETTER_AUTH_SECRET", "OSU_CLIENT_SECRET", "MONGODB_URI"];
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/env/osu-app.ts
|
|
3
|
+
* @desc The five variables a Next app with MongoDB and osu! sign-in needs (the same block in
|
|
4
|
+
* packs' and pools' src/env.ts), their SKIP_ENV_VALIDATION placeholders and which are
|
|
5
|
+
* secrets. Pass them to createServerEnv, or extend the schema first.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Mon Sep 28, 2026
|
|
8
|
+
* @modified Mon Sep 28, 2026
|
|
9
|
+
*/
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
/** MONGODB_URI, BETTER_AUTH_SECRET (32+), BETTER_AUTH_URL, OSU_CLIENT_ID (digits), OSU_CLIENT_SECRET. */
|
|
12
|
+
export const osuAppEnvSchema = z.object({
|
|
13
|
+
MONGODB_URI: z.string().regex(/^mongodb(\+srv)?:\/\//),
|
|
14
|
+
BETTER_AUTH_SECRET: z.string().min(32),
|
|
15
|
+
BETTER_AUTH_URL: z.url(),
|
|
16
|
+
OSU_CLIENT_ID: z.string().regex(/^\d+$/),
|
|
17
|
+
OSU_CLIENT_SECRET: z.string().min(1),
|
|
18
|
+
});
|
|
19
|
+
/** Used only under SKIP_ENV_VALIDATION=true. Nothing connects with these. */
|
|
20
|
+
export const OSU_APP_PLACEHOLDERS = Object.freeze({
|
|
21
|
+
MONGODB_URI: "mongodb://127.0.0.1:27017",
|
|
22
|
+
BETTER_AUTH_SECRET: "skip-env-validation-placeholder-secret-000",
|
|
23
|
+
BETTER_AUTH_URL: "http://localhost:3000",
|
|
24
|
+
OSU_CLIENT_ID: "0",
|
|
25
|
+
OSU_CLIENT_SECRET: "placeholder",
|
|
26
|
+
});
|
|
27
|
+
/** The variables whose placeholder would be a known secret on a production server. */
|
|
28
|
+
export const OSU_APP_SECRET_KEYS = Object.freeze([
|
|
29
|
+
"BETTER_AUTH_SECRET",
|
|
30
|
+
"OSU_CLIENT_SECRET",
|
|
31
|
+
"MONGODB_URI",
|
|
32
|
+
]);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/env/server-env.ts
|
|
3
|
+
* @desc Server environment, validated with zod on first use (not at import), so `next build` and
|
|
4
|
+
* public pages build without auth variables. SKIP_ENV_VALIDATION=true (CI) swaps missing
|
|
5
|
+
* values for placeholders nothing connects with; a production server (VERCEL_ENV=production
|
|
6
|
+
* when VERCEL_ENV is set, else NODE_ENV=production; never during `next build`) refuses that
|
|
7
|
+
* when a secret would be one of these public placeholders. Moved from packs and pools
|
|
8
|
+
* (src/env.ts), whose mechanism was the same code around the same five variables.
|
|
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
|
+
/** process.env, or a stand-in in tests. */
|
|
15
|
+
export type EnvSource = Record<string, string | undefined>;
|
|
16
|
+
/** Set in process.env by `next build` for the whole build, prerendering included. */
|
|
17
|
+
export declare const BUILD_PHASE = "phase-production-build";
|
|
18
|
+
/**
|
|
19
|
+
* @function isProductionServer
|
|
20
|
+
* @param source {EnvSource} usually process.env
|
|
21
|
+
* @returns {boolean} false during `next build`; VERCEL_ENV=production when VERCEL_ENV is set;
|
|
22
|
+
* otherwise NODE_ENV=production
|
|
23
|
+
*/
|
|
24
|
+
export declare const isProductionServer: (source: EnvSource) => boolean;
|
|
25
|
+
/**
|
|
26
|
+
* @function isEnvValidationSkipped
|
|
27
|
+
* @param source {EnvSource} usually process.env (the default)
|
|
28
|
+
* @returns {boolean} true in CI builds (SKIP_ENV_VALIDATION=true), where nothing may query the
|
|
29
|
+
* database: public pages then prerender empty
|
|
30
|
+
*/
|
|
31
|
+
export declare const isEnvValidationSkipped: (source?: EnvSource) => boolean;
|
|
32
|
+
/** A zod object of string variables. */
|
|
33
|
+
export type EnvSchema = z.ZodObject<Record<string, z.ZodType<string | undefined>>>;
|
|
34
|
+
/** createServerEnv's options. */
|
|
35
|
+
export type ServerEnvOptions<S extends EnvSchema> = {
|
|
36
|
+
/** The variables every server request needs. */
|
|
37
|
+
schema: S;
|
|
38
|
+
/** Used only under SKIP_ENV_VALIDATION=true. Nothing may connect with these. */
|
|
39
|
+
placeholders: z.output<S>;
|
|
40
|
+
/** The variables whose placeholder, being in public source, would be a known secret. */
|
|
41
|
+
secretKeys: readonly (keyof z.output<S> & string)[];
|
|
42
|
+
};
|
|
43
|
+
/** What createServerEnv returns. */
|
|
44
|
+
export type ServerEnv<T> = {
|
|
45
|
+
/** Every variable name in the schema. */
|
|
46
|
+
keys: readonly (keyof T & string)[];
|
|
47
|
+
/** Validates the variables, trimmed; throws an EnvError naming (never printing) each bad one. */
|
|
48
|
+
parse: (source: EnvSource) => T;
|
|
49
|
+
/** Validates only these variables (a page that needs just the database). */
|
|
50
|
+
pick: <K extends keyof T & string>(source: EnvSource, keys: readonly K[]) => Pick<T, K>;
|
|
51
|
+
/** process.env, parsed once and memoized. */
|
|
52
|
+
get: () => T;
|
|
53
|
+
/** Forgets the memoized value (tests). */
|
|
54
|
+
reset: () => void;
|
|
55
|
+
/** Throws when SKIP_ENV_VALIDATION would put a placeholder secret on a production server. */
|
|
56
|
+
assertNoPlaceholderSecrets: (source: EnvSource, keys?: readonly (keyof T & string)[]) => void;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* @function createServerEnv
|
|
60
|
+
* @param options {ServerEnvOptions<S>} the schema, its placeholders and which keys are secrets
|
|
61
|
+
* @returns {ServerEnv<z.output<S>>} parse, pick, get (memoized), reset and the placeholder guard
|
|
62
|
+
*/
|
|
63
|
+
export declare const createServerEnv: <S extends EnvSchema>({ schema, placeholders, secretKeys, }: ServerEnvOptions<S>) => ServerEnv<z.output<S>>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/env/server-env.ts
|
|
3
|
+
* @desc Server environment, validated with zod on first use (not at import), so `next build` and
|
|
4
|
+
* public pages build without auth variables. SKIP_ENV_VALIDATION=true (CI) swaps missing
|
|
5
|
+
* values for placeholders nothing connects with; a production server (VERCEL_ENV=production
|
|
6
|
+
* when VERCEL_ENV is set, else NODE_ENV=production; never during `next build`) refuses that
|
|
7
|
+
* when a secret would be one of these public placeholders. Moved from packs and pools
|
|
8
|
+
* (src/env.ts), whose mechanism was the same code around the same five variables.
|
|
9
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
10
|
+
* @created Mon Sep 28, 2026
|
|
11
|
+
* @modified Mon Sep 28, 2026
|
|
12
|
+
*/
|
|
13
|
+
import { EnvError, invalidEnv } from "./errors.js";
|
|
14
|
+
/** Set in process.env by `next build` for the whole build, prerendering included. */
|
|
15
|
+
export const BUILD_PHASE = "phase-production-build";
|
|
16
|
+
/**
|
|
17
|
+
* @function isProductionServer
|
|
18
|
+
* @param source {EnvSource} usually process.env
|
|
19
|
+
* @returns {boolean} false during `next build`; VERCEL_ENV=production when VERCEL_ENV is set;
|
|
20
|
+
* otherwise NODE_ENV=production
|
|
21
|
+
*/
|
|
22
|
+
export const isProductionServer = (source) => {
|
|
23
|
+
if (source.NEXT_PHASE === BUILD_PHASE)
|
|
24
|
+
return false;
|
|
25
|
+
if (source.VERCEL_ENV)
|
|
26
|
+
return source.VERCEL_ENV === "production";
|
|
27
|
+
return source.NODE_ENV === "production";
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* @function isEnvValidationSkipped
|
|
31
|
+
* @param source {EnvSource} usually process.env (the default)
|
|
32
|
+
* @returns {boolean} true in CI builds (SKIP_ENV_VALIDATION=true), where nothing may query the
|
|
33
|
+
* database: public pages then prerender empty
|
|
34
|
+
*/
|
|
35
|
+
export const isEnvValidationSkipped = (source = process.env) => source.SKIP_ENV_VALIDATION === "true";
|
|
36
|
+
/**
|
|
37
|
+
* @function createServerEnv
|
|
38
|
+
* @param options {ServerEnvOptions<S>} the schema, its placeholders and which keys are secrets
|
|
39
|
+
* @returns {ServerEnv<z.output<S>>} parse, pick, get (memoized), reset and the placeholder guard
|
|
40
|
+
*/
|
|
41
|
+
export const createServerEnv = ({ schema, placeholders, secretKeys, }) => {
|
|
42
|
+
const keys = Object.freeze(Object.keys(schema.shape));
|
|
43
|
+
const values = placeholders;
|
|
44
|
+
const assertNoPlaceholderSecrets = (source, checked = secretKeys) => {
|
|
45
|
+
if (!isEnvValidationSkipped(source) || !isProductionServer(source))
|
|
46
|
+
return;
|
|
47
|
+
const found = checked.filter((key) => {
|
|
48
|
+
const value = source[key]?.trim();
|
|
49
|
+
return !value || value === values[key];
|
|
50
|
+
});
|
|
51
|
+
if (found.length === 0)
|
|
52
|
+
return;
|
|
53
|
+
throw new EnvError(`SKIP_ENV_VALIDATION is set on a production server, so ${found.join(", ")} would fall back to public placeholders. Set the real values and unset SKIP_ENV_VALIDATION.`);
|
|
54
|
+
};
|
|
55
|
+
const pick = (source, wanted) => {
|
|
56
|
+
const present = {};
|
|
57
|
+
for (const key of wanted) {
|
|
58
|
+
const value = source[key]?.trim();
|
|
59
|
+
if (value)
|
|
60
|
+
present[key] = value;
|
|
61
|
+
}
|
|
62
|
+
if (isEnvValidationSkipped(source)) {
|
|
63
|
+
assertNoPlaceholderSecrets(source, secretKeys.filter((key) => wanted.includes(key)));
|
|
64
|
+
const fallback = Object.fromEntries(wanted.map((key) => [key, values[key]]));
|
|
65
|
+
return { ...fallback, ...present };
|
|
66
|
+
}
|
|
67
|
+
const mask = Object.fromEntries(wanted.map((key) => [key, true]));
|
|
68
|
+
const picked = schema.pick(mask);
|
|
69
|
+
const parsed = picked.safeParse(present);
|
|
70
|
+
if (parsed.success)
|
|
71
|
+
return parsed.data;
|
|
72
|
+
throw invalidEnv([...new Set(parsed.error.issues.map((issue) => String(issue.path[0])))]);
|
|
73
|
+
};
|
|
74
|
+
let cached = null;
|
|
75
|
+
return {
|
|
76
|
+
keys,
|
|
77
|
+
parse: (source) => pick(source, keys),
|
|
78
|
+
pick,
|
|
79
|
+
get: () => {
|
|
80
|
+
cached ??= pick(process.env, keys);
|
|
81
|
+
return cached;
|
|
82
|
+
},
|
|
83
|
+
reset: () => {
|
|
84
|
+
cached = null;
|
|
85
|
+
},
|
|
86
|
+
assertNoPlaceholderSecrets,
|
|
87
|
+
};
|
|
88
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/mongo/client.ts
|
|
3
|
+
* @desc One MongoClient per process, built on first use (never at import, so builds and pages
|
|
4
|
+
* without a database need no env). The database is always the one named here, whatever
|
|
5
|
+
* the URI says. better-auth reads getDb(); Mongoose models live on getModelConnection(),
|
|
6
|
+
* attached to the same client. The first connect runs the app's onConnect (index builds,
|
|
7
|
+
* a privilege check, a backfill). State sits on globalThis so dev reloads don't leak
|
|
8
|
+
* clients; a failed connect is never cached, so one DNS blip can't poison the process.
|
|
9
|
+
* Moved from packs and pools (src/lib/db.ts), identical apart from the name, the global
|
|
10
|
+
* key and pools' post-connect work.
|
|
11
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
12
|
+
* @created Mon Sep 28, 2026
|
|
13
|
+
* @modified Mon Sep 28, 2026
|
|
14
|
+
*/
|
|
15
|
+
import { type Db, MongoClient } from "mongodb";
|
|
16
|
+
import { type Connection } from "mongoose";
|
|
17
|
+
/** Two apps share one M0 cluster (500 connections), each over many Vercel instances. */
|
|
18
|
+
export declare const DEFAULT_MAX_POOL_SIZE = 5;
|
|
19
|
+
/** Fail fast when the cluster is unreachable: a hung function is billed for every second. */
|
|
20
|
+
export declare const DEFAULT_SERVER_SELECTION_TIMEOUT_MS = 5000;
|
|
21
|
+
/** createMongo's options. */
|
|
22
|
+
export type MongoOptions = {
|
|
23
|
+
/** The database every call uses, like "packs" or "pools". */
|
|
24
|
+
dbName: string;
|
|
25
|
+
/** The globalThis key the state lives under, like "__poolsMongo"; one per app. */
|
|
26
|
+
globalKey: string;
|
|
27
|
+
/** Reads MONGODB_URI (on first use, not at import). */
|
|
28
|
+
uri: () => string;
|
|
29
|
+
/** Connections per instance (default DEFAULT_MAX_POOL_SIZE). */
|
|
30
|
+
maxPoolSize?: number;
|
|
31
|
+
/** How long to look for a server (default DEFAULT_SERVER_SELECTION_TIMEOUT_MS). */
|
|
32
|
+
serverSelectionTimeoutMS?: number;
|
|
33
|
+
/** Runs once after the first successful connect; a throw fails that connect. */
|
|
34
|
+
onConnect?: (db: Db, client: MongoClient) => Promise<void>;
|
|
35
|
+
};
|
|
36
|
+
/** What createMongo returns. */
|
|
37
|
+
export type Mongo = {
|
|
38
|
+
/** The shared client (connects lazily on first operation). */
|
|
39
|
+
getMongoClient: () => MongoClient;
|
|
40
|
+
/** The database on the shared client. */
|
|
41
|
+
getDb: () => Db;
|
|
42
|
+
/** The Mongoose connection models register on (usable after connectDb). */
|
|
43
|
+
getModelConnection: () => Connection;
|
|
44
|
+
/** Connects once, attaches Mongoose and runs onConnect; retried after a failure. */
|
|
45
|
+
connectDb: () => Promise<void>;
|
|
46
|
+
/** The database once connectDb has resolved. */
|
|
47
|
+
connectedDb: () => Promise<Db>;
|
|
48
|
+
/** Closes the client and forgets it (tests, CLIs). */
|
|
49
|
+
closeDb: () => Promise<void>;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* @function createMongo
|
|
53
|
+
* @param options {MongoOptions} the database, the global key, the URI and the start-up work
|
|
54
|
+
* @returns {Mongo} getMongoClient, getDb, getModelConnection, connectDb, connectedDb, closeDb
|
|
55
|
+
*/
|
|
56
|
+
export declare const createMongo: ({ dbName, globalKey, uri, maxPoolSize, serverSelectionTimeoutMS, onConnect, }: MongoOptions) => Mongo;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/mongo/client.ts
|
|
3
|
+
* @desc One MongoClient per process, built on first use (never at import, so builds and pages
|
|
4
|
+
* without a database need no env). The database is always the one named here, whatever
|
|
5
|
+
* the URI says. better-auth reads getDb(); Mongoose models live on getModelConnection(),
|
|
6
|
+
* attached to the same client. The first connect runs the app's onConnect (index builds,
|
|
7
|
+
* a privilege check, a backfill). State sits on globalThis so dev reloads don't leak
|
|
8
|
+
* clients; a failed connect is never cached, so one DNS blip can't poison the process.
|
|
9
|
+
* Moved from packs and pools (src/lib/db.ts), identical apart from the name, the global
|
|
10
|
+
* key and pools' post-connect work.
|
|
11
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
12
|
+
* @created Mon Sep 28, 2026
|
|
13
|
+
* @modified Mon Sep 28, 2026
|
|
14
|
+
*/
|
|
15
|
+
import { MongoClient } from "mongodb";
|
|
16
|
+
import mongoose, {} from "mongoose";
|
|
17
|
+
/** Two apps share one M0 cluster (500 connections), each over many Vercel instances. */
|
|
18
|
+
export const DEFAULT_MAX_POOL_SIZE = 5;
|
|
19
|
+
/** Fail fast when the cluster is unreachable: a hung function is billed for every second. */
|
|
20
|
+
export const DEFAULT_SERVER_SELECTION_TIMEOUT_MS = 5000;
|
|
21
|
+
/**
|
|
22
|
+
* @function createMongo
|
|
23
|
+
* @param options {MongoOptions} the database, the global key, the URI and the start-up work
|
|
24
|
+
* @returns {Mongo} getMongoClient, getDb, getModelConnection, connectDb, connectedDb, closeDb
|
|
25
|
+
*/
|
|
26
|
+
export const createMongo = ({ dbName, globalKey, uri, maxPoolSize = DEFAULT_MAX_POOL_SIZE, serverSelectionTimeoutMS = DEFAULT_SERVER_SELECTION_TIMEOUT_MS, onConnect, }) => {
|
|
27
|
+
const store = globalThis;
|
|
28
|
+
const createState = () => {
|
|
29
|
+
const client = new MongoClient(uri(), { maxPoolSize, serverSelectionTimeoutMS });
|
|
30
|
+
const base = mongoose.createConnection();
|
|
31
|
+
// Connection#useDb, not a React hook. The URI has no path, so pick the database here.
|
|
32
|
+
const models = base.useDb(dbName, { useCache: true });
|
|
33
|
+
return { client, base, models, ready: null };
|
|
34
|
+
};
|
|
35
|
+
const state = () => {
|
|
36
|
+
store[globalKey] ??= createState();
|
|
37
|
+
return store[globalKey];
|
|
38
|
+
};
|
|
39
|
+
const getDb = () => state().client.db(dbName);
|
|
40
|
+
const connectDb = async () => {
|
|
41
|
+
const current = state();
|
|
42
|
+
current.ready ??= current.client.connect().then(async () => {
|
|
43
|
+
if (current.base.readyState === 0)
|
|
44
|
+
current.base.setClient(current.client);
|
|
45
|
+
await onConnect?.(current.client.db(dbName), current.client);
|
|
46
|
+
});
|
|
47
|
+
try {
|
|
48
|
+
await current.ready;
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
current.ready = null;
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
getMongoClient: () => state().client,
|
|
57
|
+
getDb,
|
|
58
|
+
getModelConnection: () => state().models,
|
|
59
|
+
connectDb,
|
|
60
|
+
connectedDb: async () => {
|
|
61
|
+
await connectDb();
|
|
62
|
+
return getDb();
|
|
63
|
+
},
|
|
64
|
+
closeDb: async () => {
|
|
65
|
+
const current = store[globalKey];
|
|
66
|
+
if (!current)
|
|
67
|
+
return;
|
|
68
|
+
store[globalKey] = undefined;
|
|
69
|
+
await current.client.close();
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/mongo/collections.ts
|
|
3
|
+
* @desc The collection-name constants pattern both apps use (src/constants/db.ts): one frozen
|
|
4
|
+
* object naming every collection, checked once at load, so a typo or two names for one
|
|
5
|
+
* collection fails at start instead of writing somewhere nobody reads.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Mon Sep 28, 2026
|
|
8
|
+
* @modified Mon Sep 28, 2026
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* @function defineCollections
|
|
12
|
+
* @param names {T} constant key to collection name, like { pools: "pools", rateLimits: "rate_limits" }
|
|
13
|
+
* @returns {Readonly<T>} the same names, frozen
|
|
14
|
+
* @throws {Error} naming the key whose collection name is invalid or used twice
|
|
15
|
+
*/
|
|
16
|
+
export declare const defineCollections: <const T extends Record<string, string>>(names: T) => Readonly<T>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/mongo/collections.ts
|
|
3
|
+
* @desc The collection-name constants pattern both apps use (src/constants/db.ts): one frozen
|
|
4
|
+
* object naming every collection, checked once at load, so a typo or two names for one
|
|
5
|
+
* collection fails at start instead of writing somewhere nobody reads.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Mon Sep 28, 2026
|
|
8
|
+
* @modified Mon Sep 28, 2026
|
|
9
|
+
*/
|
|
10
|
+
/** MongoDB's collection name rules: no $, no NUL, not empty, not a system collection. */
|
|
11
|
+
const COLLECTION_NAME = /^(?!system\.)[^$\0]{1,120}$/;
|
|
12
|
+
/**
|
|
13
|
+
* @function defineCollections
|
|
14
|
+
* @param names {T} constant key to collection name, like { pools: "pools", rateLimits: "rate_limits" }
|
|
15
|
+
* @returns {Readonly<T>} the same names, frozen
|
|
16
|
+
* @throws {Error} naming the key whose collection name is invalid or used twice
|
|
17
|
+
*/
|
|
18
|
+
export const defineCollections = (names) => {
|
|
19
|
+
const seen = new Map();
|
|
20
|
+
for (const [key, name] of Object.entries(names)) {
|
|
21
|
+
if (!COLLECTION_NAME.test(name))
|
|
22
|
+
throw new Error(`defineCollections: ${key} isn't a valid name`);
|
|
23
|
+
const other = seen.get(name);
|
|
24
|
+
if (other)
|
|
25
|
+
throw new Error(`defineCollections: ${key} and ${other} both name "${name}"`);
|
|
26
|
+
seen.set(name, key);
|
|
27
|
+
}
|
|
28
|
+
return Object.freeze({ ...names });
|
|
29
|
+
};
|