@vielzeug/archive 26.10.14 → 26.10.16
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/dist/_crypto.cjs.map +1 -1
- package/dist/_crypto.js +5 -1
- package/dist/_crypto.js.map +1 -1
- package/dist/_json.cjs.map +1 -1
- package/dist/_json.js +51 -1
- package/dist/_json.js.map +1 -1
- package/dist/auth/_confirmation.cjs +5 -1
- package/dist/auth/_confirmation.cjs.map +1 -1
- package/dist/auth/_confirmation.d.ts.map +1 -1
- package/dist/auth/_confirmation.js +38 -14
- package/dist/auth/_confirmation.js.map +1 -1
- package/dist/auth/config.cjs.map +1 -1
- package/dist/auth/config.js +156 -1
- package/dist/auth/config.js.map +1 -1
- package/dist/auth/errors.cjs.map +1 -1
- package/dist/auth/errors.js +18 -1
- package/dist/auth/errors.js.map +1 -1
- package/dist/auth/store.cjs.map +1 -1
- package/dist/auth/store.js +102 -18
- package/dist/auth/store.js.map +1 -1
- package/dist/auth/worker.cjs.map +1 -1
- package/dist/auth/worker.js +360 -1
- package/dist/auth/worker.js.map +1 -1
- package/dist/auth.js +1 -1
- package/dist/collection.cjs.map +1 -1
- package/dist/collection.js +108 -1
- package/dist/collection.js.map +1 -1
- package/dist/contract.cjs.map +1 -1
- package/dist/contract.js +144 -1
- package/dist/contract.js.map +1 -1
- package/dist/errors.cjs.map +1 -1
- package/dist/errors.js +14 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.js +4 -1
- package/dist/worker/_pagination.cjs.map +1 -1
- package/dist/worker/_pagination.js +55 -1
- package/dist/worker/_pagination.js.map +1 -1
- package/dist/worker/_service.cjs.map +1 -1
- package/dist/worker/_service.js +16 -1
- package/dist/worker/_service.js.map +1 -1
- package/dist/worker/auth.cjs.map +1 -1
- package/dist/worker/auth.js +68 -1
- package/dist/worker/auth.js.map +1 -1
- package/dist/worker/env.cjs.map +1 -1
- package/dist/worker/env.js +76 -1
- package/dist/worker/env.js.map +1 -1
- package/dist/worker/http.cjs +1 -1
- package/dist/worker/http.cjs.map +1 -1
- package/dist/worker/http.d.ts +3 -1
- package/dist/worker/http.d.ts.map +1 -1
- package/dist/worker/http.js +118 -1
- package/dist/worker/http.js.map +1 -1
- package/dist/worker/publications.cjs +1 -1
- package/dist/worker/publications.cjs.map +1 -1
- package/dist/worker/publications.d.ts.map +1 -1
- package/dist/worker/publications.js +470 -1
- package/dist/worker/publications.js.map +1 -1
- package/dist/worker/sql.cjs +3 -3
- package/dist/worker/sql.cjs.map +1 -1
- package/dist/worker/sql.d.ts +4 -2
- package/dist/worker/sql.d.ts.map +1 -1
- package/dist/worker/sql.js +100 -56
- package/dist/worker/sql.js.map +1 -1
- package/dist/worker/worker.cjs.map +1 -1
- package/dist/worker/worker.js +352 -1
- package/dist/worker/worker.js.map +1 -1
- package/dist/worker.js +1 -1
- package/package.json +6 -6
package/dist/auth/config.js
CHANGED
|
@@ -1,2 +1,157 @@
|
|
|
1
|
-
import{AuthOperationError as e}from"./errors.js";
|
|
1
|
+
import { AuthOperationError as e } from "./errors.js";
|
|
2
|
+
import { SignJWT as t, calculateJwkThumbprint as n, createLocalJWKSet as r, importJWK as i, jwtVerify as a } from "jose";
|
|
3
|
+
//#region src/auth/config.ts
|
|
4
|
+
var configError = () => new e(503, "configuration"), o = 320, s = 64, c = 342, l = /^[A-Za-z0-9_-]+$/, u = /* @__PURE__ */ new Set([
|
|
5
|
+
"127.0.0.1",
|
|
6
|
+
"localhost",
|
|
7
|
+
"[::1]"
|
|
8
|
+
]), d = [
|
|
9
|
+
"d",
|
|
10
|
+
"p",
|
|
11
|
+
"q",
|
|
12
|
+
"dp",
|
|
13
|
+
"dq",
|
|
14
|
+
"qi",
|
|
15
|
+
"oth",
|
|
16
|
+
"k"
|
|
17
|
+
], isWellFormedEmail = (e) => e.length <= o && e === e.toLowerCase() && /^[^\s@<>"',;]+@[^\s@<>"',;]+\.[^\s@<>"',;]+$/.test(e), requireString = (e) => {
|
|
18
|
+
if (typeof e != "string" || e.length === 0) throw configError();
|
|
19
|
+
return e;
|
|
20
|
+
}, requireBinding = (e, t) => {
|
|
21
|
+
if (typeof e != "object" || !e || typeof Reflect.get(e, t) != "function") throw configError();
|
|
22
|
+
}, validateUrl = (e, t) => {
|
|
23
|
+
let n;
|
|
24
|
+
try {
|
|
25
|
+
n = new URL(e);
|
|
26
|
+
} catch {
|
|
27
|
+
throw configError();
|
|
28
|
+
}
|
|
29
|
+
if (n.username !== "" || n.password !== "" || n.hash !== "") throw configError();
|
|
30
|
+
let r = u.has(n.hostname);
|
|
31
|
+
if (t === "local" ? n.protocol !== "http:" || !r : n.protocol !== "https:" || r) throw configError();
|
|
32
|
+
return n;
|
|
33
|
+
}, validateOrigin = (e, t) => {
|
|
34
|
+
let n = validateUrl(e, t);
|
|
35
|
+
if (n.pathname !== "/" || n.search !== "" || n.origin !== e) throw configError();
|
|
36
|
+
return n.origin;
|
|
37
|
+
}, isRecord = (e) => typeof e == "object" && !!e, parseJson = (e) => {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(e);
|
|
40
|
+
} catch {
|
|
41
|
+
throw configError();
|
|
42
|
+
}
|
|
43
|
+
}, b64urlMember = (e, t) => {
|
|
44
|
+
let n = e[t];
|
|
45
|
+
if (typeof n != "string" || !l.test(n)) throw configError();
|
|
46
|
+
return n;
|
|
47
|
+
}, checkRsaMetadata = (e) => {
|
|
48
|
+
if (e.kty !== "RSA" || e.alg !== void 0 && e.alg !== "RS256" || e.use !== void 0 && e.use !== "sig") throw configError();
|
|
49
|
+
}, publicMembers = (e) => {
|
|
50
|
+
let t = b64urlMember(e, "n");
|
|
51
|
+
if (t.length < c) throw configError();
|
|
52
|
+
return {
|
|
53
|
+
e: b64urlMember(e, "e"),
|
|
54
|
+
kty: "RSA",
|
|
55
|
+
n: t
|
|
56
|
+
};
|
|
57
|
+
}, toSigningKey = async (e) => ({
|
|
58
|
+
alg: "RS256",
|
|
59
|
+
e: e.e,
|
|
60
|
+
kid: await n({
|
|
61
|
+
e: e.e,
|
|
62
|
+
kty: "RSA",
|
|
63
|
+
n: e.n
|
|
64
|
+
}),
|
|
65
|
+
kty: "RSA",
|
|
66
|
+
n: e.n,
|
|
67
|
+
use: "sig"
|
|
68
|
+
});
|
|
69
|
+
async function activeKey(e) {
|
|
70
|
+
let t = parseJson(e);
|
|
71
|
+
if (!isRecord(t)) throw configError();
|
|
72
|
+
checkRsaMetadata(t);
|
|
73
|
+
let n = publicMembers(t), r = {
|
|
74
|
+
...n,
|
|
75
|
+
d: b64urlMember(t, "d"),
|
|
76
|
+
dp: b64urlMember(t, "dp"),
|
|
77
|
+
dq: b64urlMember(t, "dq"),
|
|
78
|
+
p: b64urlMember(t, "p"),
|
|
79
|
+
q: b64urlMember(t, "q"),
|
|
80
|
+
qi: b64urlMember(t, "qi")
|
|
81
|
+
}, a;
|
|
82
|
+
try {
|
|
83
|
+
let e = await i(r, "RS256", { extractable: !1 });
|
|
84
|
+
if (e instanceof Uint8Array) throw configError();
|
|
85
|
+
a = e;
|
|
86
|
+
} catch {
|
|
87
|
+
throw configError();
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
key: a,
|
|
91
|
+
public: await toSigningKey(n)
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
async function previousKeys(e) {
|
|
95
|
+
if (e === void 0 || e.trim().length === 0) return [];
|
|
96
|
+
let t = parseJson(e);
|
|
97
|
+
if (!isRecord(t) || !Array.isArray(t.keys)) throw configError();
|
|
98
|
+
let n = [];
|
|
99
|
+
for (let e of t.keys) {
|
|
100
|
+
if (!isRecord(e) || (checkRsaMetadata(e), d.some((t) => t in e))) throw configError();
|
|
101
|
+
n.push(await toSigningKey(publicMembers(e)));
|
|
102
|
+
}
|
|
103
|
+
return n;
|
|
104
|
+
}
|
|
105
|
+
var f = /* @__PURE__ */ new WeakMap();
|
|
106
|
+
async function buildConfig(e) {
|
|
107
|
+
let n = requireString(e.APP_ENV);
|
|
108
|
+
if (n !== "local" && n !== "production") throw configError();
|
|
109
|
+
let i = n, o = new Set(requireString(e.ALLOWED_ORIGINS).split(",").map((e) => e.trim()).filter((e) => e.length > 0).map((e) => validateOrigin(e, i)));
|
|
110
|
+
if (o.size === 0) throw configError();
|
|
111
|
+
let c = validateOrigin(requireString(e.PUBLIC_ORIGIN), i), l = requireString(e.AUTH_ISSUER);
|
|
112
|
+
validateUrl(l, i);
|
|
113
|
+
let u = requireString(e.AUDIENCE), d = requireString(e.APP_NAME);
|
|
114
|
+
if (d.length > s || /[\p{Cc}\p{Cs}]/u.test(d)) throw configError();
|
|
115
|
+
let f = requireString(e.SENDER_ADDRESS);
|
|
116
|
+
if (!isWellFormedEmail(f) || typeof e.OPERATOR_EMAILS != "string") throw configError();
|
|
117
|
+
let p = new Set(e.OPERATOR_EMAILS.split(",").map((e) => e.trim().toLowerCase()).filter((e) => e.length > 0));
|
|
118
|
+
for (let e of p) if (!isWellFormedEmail(e)) throw configError();
|
|
119
|
+
requireBinding(e.AUTH_DB, "prepare"), requireBinding(e.AUTH_DB, "batch"), requireBinding(e.AUTH_EMAIL, "send"), requireBinding(e.AUTH_ARCHIVE, "fetch");
|
|
120
|
+
let m = await activeKey(requireString(e.AUTH_JWT_PRIVATE_JWK));
|
|
121
|
+
try {
|
|
122
|
+
let e = await new t({}).setProtectedHeader({
|
|
123
|
+
alg: "RS256",
|
|
124
|
+
kid: m.public.kid
|
|
125
|
+
}).sign(m.key);
|
|
126
|
+
await a(e, r({ keys: [m.public] }), { algorithms: ["RS256"] });
|
|
127
|
+
} catch {
|
|
128
|
+
throw configError();
|
|
129
|
+
}
|
|
130
|
+
let h = (await previousKeys(e.AUTH_JWT_PREVIOUS_PUBLIC_JWKS)).filter((e) => e.kid !== m.public.kid), g = [m.public, ...h.filter((e, t) => h.findIndex((t) => t.kid === e.kid) === t)];
|
|
131
|
+
return {
|
|
132
|
+
allowedOrigins: o,
|
|
133
|
+
appEnv: i,
|
|
134
|
+
appName: d,
|
|
135
|
+
audience: u,
|
|
136
|
+
issuer: l,
|
|
137
|
+
jwks: g,
|
|
138
|
+
operatorEmails: p,
|
|
139
|
+
publicOrigin: c,
|
|
140
|
+
senderAddress: f,
|
|
141
|
+
signing: {
|
|
142
|
+
key: m.key,
|
|
143
|
+
kid: m.public.kid
|
|
144
|
+
},
|
|
145
|
+
verifier: r({ keys: [...g] })
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function authConfig(e) {
|
|
149
|
+
let t = f.get(e);
|
|
150
|
+
if (t !== void 0) return t;
|
|
151
|
+
let n = buildConfig(e);
|
|
152
|
+
return f.set(e, n), n.catch(() => f.delete(e)), n;
|
|
153
|
+
}
|
|
154
|
+
//#endregion
|
|
155
|
+
export { authConfig, isWellFormedEmail };
|
|
156
|
+
|
|
2
157
|
//# sourceMappingURL=config.js.map
|
package/dist/auth/config.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.js","names":[],"sources":["../../src/auth/config.ts"],"sourcesContent":["import {\n type CryptoKey,\n calculateJwkThumbprint,\n createLocalJWKSet,\n importJWK,\n type JWK_RSA_Private,\n type JWK_RSA_Public,\n jwtVerify,\n SignJWT,\n} from 'jose';\nimport type { D1Like } from '../worker/env';\nimport type { ApiError } from '../worker/http';\nimport { AuthOperationError } from './errors';\n\n/** The send-email binding subset the issuer calls; the runtime's SendEmail satisfies it. */\nexport interface SendEmail {\n send(message: { from: string; html?: string; subject: string; text: string; to: string }): Promise<unknown>;\n}\n\n/**\n * The catalog Worker as the issuer reaches it: a Cloudflare service binding (or any\n * `fetch`-shaped stub). Account erasure calls the catalog's `DELETE /api/v1/me/account`\n * through it before removing the issuer's own row.\n */\nexport interface AuthArchiveBinding {\n fetch(request: Request): Promise<Response>;\n}\n\n/**\n * The bindings the auth issuer reads. Declared structurally, like the catalog\n * Worker's Env: a deployment's generated `ExportedHandler<Env>` accepts the factory's\n * result as-is, with no `@cloudflare/workers-types` dependency. Every value is\n * validated on first use per bindings object; a missing or malformed one answers\n * 503 `unavailable` on every route, never a permissive fallback.\n */\nexport interface AuthWorkerEnv {\n /** Comma-separated exact origins allowed to call unsafe routes and read CORS responses. */\n ALLOWED_ORIGINS: string;\n /** Exactly `local` (loopback HTTP only) or `production` (non-loopback HTTPS, Secure cookies). */\n APP_ENV: string;\n /** The app's display name in magic-link email and escaped confirmation-page branding. */\n APP_NAME: string;\n AUDIENCE: string;\n /** Service binding to the sibling catalog Worker: account erasure removes the catalog profile through it. */\n AUTH_ARCHIVE: AuthArchiveBinding;\n /** The issuer's own D1: accounts, links, per-account send and singleton admission budgets. */\n AUTH_DB: D1Like;\n AUTH_EMAIL: SendEmail;\n /** The `iss` claim every token carries, exactly as the catalog Worker's `JWT_ISSUER` knows it. */\n AUTH_ISSUER: string;\n /**\n * Optional JWKS JSON (`{\"keys\":[...]}`) of retired PUBLIC RS256 keys, still served\n * from `/jwks.json` and accepted by the issuer so sessions minted before a rotation\n * keep verifying until they expire. Private members are rejected.\n */\n AUTH_JWT_PREVIOUS_PUBLIC_JWKS?: string;\n /** The active RS256 private key as a JWK JSON string (a wrangler secret): signs every new session. */\n AUTH_JWT_PRIVATE_JWK: string;\n /** Comma-separated emails whose sessions carry the operator role claim. */\n OPERATOR_EMAILS: string;\n /** The app's public origin: magic links, app recovery, signed-in navigation and catalog erasure point here. */\n PUBLIC_ORIGIN: string;\n /** The address emails come from: a domain onboarded to Cloudflare Email Service. */\n SENDER_ADDRESS: string;\n}\n\nexport type AuthAppEnv = 'local' | 'production';\n\nexport interface PublicSigningKey extends JWK_RSA_Public {\n alg: 'RS256';\n kid: string;\n kty: 'RSA';\n use: 'sig';\n}\n\nexport interface AuthConfig {\n allowedOrigins: ReadonlySet<string>;\n appEnv: AuthAppEnv;\n appName: string;\n audience: string;\n issuer: string;\n /** Every key a session may be verified with: the active key first, then retired ones. */\n jwks: readonly PublicSigningKey[];\n operatorEmails: ReadonlySet<string>;\n publicOrigin: string;\n senderAddress: string;\n signing: { key: CryptoKey; kid: string };\n verifier: ReturnType<typeof createLocalJWKSet>;\n}\n\n// Configuration faults never name the offending field: the response must not leak\n// Env names, only a generic temporary unavailability.\nconst configError = (): ApiError => new AuthOperationError(503, 'configuration');\n\nconst EMAIL_MAX = 320;\nconst APP_NAME_MAX = 64;\n// 2048-bit RSA modulus: 256 bytes, 342 base64url characters unpadded.\nconst MIN_MODULUS_CHARS = 342;\nconst BASE64URL = /^[A-Za-z0-9_-]+$/;\nconst LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);\nconst PRIVATE_RSA_MEMBERS = ['d', 'p', 'q', 'dp', 'dq', 'qi', 'oth', 'k'] as const;\n\nexport const isWellFormedEmail = (value: string): boolean =>\n value.length <= EMAIL_MAX &&\n value === value.toLowerCase() &&\n /^[^\\s@<>\"',;]+@[^\\s@<>\"',;]+\\.[^\\s@<>\"',;]+$/.test(value);\n\nconst requireString = (value: unknown): string => {\n if (typeof value !== 'string' || value.length === 0) throw configError();\n return value;\n};\n\nconst requireBinding = (value: unknown, method: string): void => {\n if (typeof value !== 'object' || value === null) throw configError();\n if (typeof Reflect.get(value, method) !== 'function') throw configError();\n};\n\n/** Transport rules: local is loopback HTTP only, production is non-loopback HTTPS; no credentials or fragments. */\nconst validateUrl = (raw: string, appEnv: AuthAppEnv): URL => {\n let url: URL;\n try {\n url = new URL(raw);\n } catch {\n throw configError();\n }\n if (url.username !== '' || url.password !== '' || url.hash !== '') throw configError();\n const loopback = LOOPBACK_HOSTS.has(url.hostname);\n if (appEnv === 'local' ? url.protocol !== 'http:' || !loopback : url.protocol !== 'https:' || loopback) {\n throw configError();\n }\n return url;\n};\n\n/** An exact canonical origin: scheme + host (+port), no path, query, wildcard or `null`. */\nconst validateOrigin = (raw: string, appEnv: AuthAppEnv): string => {\n const url = validateUrl(raw, appEnv);\n if (url.pathname !== '/' || url.search !== '' || url.origin !== raw) throw configError();\n return url.origin;\n};\n\nconst isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;\n\nconst parseJson = (raw: string): unknown => {\n try {\n return JSON.parse(raw);\n } catch {\n throw configError();\n }\n};\n\nconst b64urlMember = (jwk: Record<string, unknown>, name: string): string => {\n const value = jwk[name];\n if (typeof value !== 'string' || !BASE64URL.test(value)) throw configError();\n return value;\n};\n\n/** RS256 signing-key metadata, when present, must agree with how the issuer uses the key. */\nconst checkRsaMetadata = (jwk: Record<string, unknown>): void => {\n if (jwk.kty !== 'RSA') throw configError();\n if (jwk.alg !== undefined && jwk.alg !== 'RS256') throw configError();\n if (jwk.use !== undefined && jwk.use !== 'sig') throw configError();\n};\n\nconst publicMembers = (jwk: Record<string, unknown>): JWK_RSA_Public => {\n const n = b64urlMember(jwk, 'n');\n if (n.length < MIN_MODULUS_CHARS) throw configError();\n return { e: b64urlMember(jwk, 'e'), kty: 'RSA', n };\n};\n\nconst toSigningKey = async (pub: JWK_RSA_Public): Promise<PublicSigningKey> => ({\n alg: 'RS256',\n e: pub.e,\n kid: await calculateJwkThumbprint({ e: pub.e, kty: 'RSA', n: pub.n }),\n kty: 'RSA',\n n: pub.n,\n use: 'sig',\n});\n\nasync function activeKey(raw: string): Promise<{ key: CryptoKey; public: PublicSigningKey }> {\n const parsed = parseJson(raw);\n if (!isRecord(parsed)) throw configError();\n checkRsaMetadata(parsed);\n const pub = publicMembers(parsed);\n const jwk: JWK_RSA_Private = {\n ...pub,\n d: b64urlMember(parsed, 'd'),\n dp: b64urlMember(parsed, 'dp'),\n dq: b64urlMember(parsed, 'dq'),\n p: b64urlMember(parsed, 'p'),\n q: b64urlMember(parsed, 'q'),\n qi: b64urlMember(parsed, 'qi'),\n };\n let key: CryptoKey;\n try {\n const imported = await importJWK(jwk, 'RS256', { extractable: false });\n if (imported instanceof Uint8Array) throw configError();\n key = imported;\n } catch {\n throw configError();\n }\n return { key, public: await toSigningKey(pub) };\n}\n\n/** Retired verification keys: public RSA members only; any private member is a misconfiguration. */\nasync function previousKeys(raw: string | undefined): Promise<PublicSigningKey[]> {\n if (raw === undefined || raw.trim().length === 0) return [];\n const parsed = parseJson(raw);\n if (!isRecord(parsed) || !Array.isArray(parsed.keys)) throw configError();\n const keys: PublicSigningKey[] = [];\n for (const entry of parsed.keys) {\n if (!isRecord(entry)) throw configError();\n checkRsaMetadata(entry);\n if (PRIVATE_RSA_MEMBERS.some((member) => member in entry)) throw configError();\n keys.push(await toSigningKey(publicMembers(entry)));\n }\n return keys;\n}\n\nconst configs = new WeakMap<AuthWorkerEnv, Promise<AuthConfig>>();\n\nasync function buildConfig(env: AuthWorkerEnv): Promise<AuthConfig> {\n const appEnvRaw = requireString(env.APP_ENV);\n if (appEnvRaw !== 'local' && appEnvRaw !== 'production') throw configError();\n const appEnv: AuthAppEnv = appEnvRaw;\n\n const allowedOrigins = new Set(\n requireString(env.ALLOWED_ORIGINS)\n .split(',')\n .map((origin) => origin.trim())\n .filter((origin) => origin.length > 0)\n .map((origin) => validateOrigin(origin, appEnv)),\n );\n if (allowedOrigins.size === 0) throw configError();\n\n const publicOrigin = validateOrigin(requireString(env.PUBLIC_ORIGIN), appEnv);\n // The issuer is matched as the exact configured string (a trailing slash is meaningful).\n const issuer = requireString(env.AUTH_ISSUER);\n validateUrl(issuer, appEnv);\n const audience = requireString(env.AUDIENCE);\n const appName = requireString(env.APP_NAME);\n if (appName.length > APP_NAME_MAX || /[\\p{Cc}\\p{Cs}]/u.test(appName)) throw configError();\n const senderAddress = requireString(env.SENDER_ADDRESS);\n if (!isWellFormedEmail(senderAddress)) throw configError();\n if (typeof env.OPERATOR_EMAILS !== 'string') throw configError();\n const operatorEmails = new Set(\n env.OPERATOR_EMAILS.split(',')\n .map((entry) => entry.trim().toLowerCase())\n .filter((entry) => entry.length > 0),\n );\n for (const email of operatorEmails) if (!isWellFormedEmail(email)) throw configError();\n\n requireBinding(env.AUTH_DB, 'prepare');\n requireBinding(env.AUTH_DB, 'batch');\n requireBinding(env.AUTH_EMAIL, 'send');\n requireBinding(env.AUTH_ARCHIVE, 'fetch');\n\n const active = await activeKey(requireString(env.AUTH_JWT_PRIVATE_JWK));\n try {\n const proof = await new SignJWT({}).setProtectedHeader({ alg: 'RS256', kid: active.public.kid }).sign(active.key);\n await jwtVerify(proof, createLocalJWKSet({ keys: [active.public] }), { algorithms: ['RS256'] });\n } catch {\n throw configError();\n }\n const retired = (await previousKeys(env.AUTH_JWT_PREVIOUS_PUBLIC_JWKS)).filter(\n (key) => key.kid !== active.public.kid,\n );\n const jwks = [\n active.public,\n ...retired.filter((key, i) => retired.findIndex((other) => other.kid === key.kid) === i),\n ];\n\n return {\n allowedOrigins,\n appEnv,\n appName,\n audience,\n issuer,\n jwks,\n operatorEmails,\n publicOrigin,\n senderAddress,\n signing: { key: active.key, kid: active.public.kid },\n verifier: createLocalJWKSet({ keys: [...jwks] }),\n };\n}\n\n/**\n * Validates the env and imports its keys once per bindings object. A failed\n * validation is not cached, so a corrected binding set recovers without a restart.\n */\nexport function authConfig(env: AuthWorkerEnv): Promise<AuthConfig> {\n const cached = configs.get(env);\n if (cached !== undefined) return cached;\n const pending = buildConfig(env);\n configs.set(env, pending);\n pending.catch(() => configs.delete(env));\n return pending;\n}\n"],"mappings":"iKA4FA,IAAM,gBAA8B,IAAI,EAAmB,IAAK,eAAe,EAEzE,EAAY,IACZ,EAAe,GAEf,EAAoB,IACpB,EAAY,mBACZ,EAAiB,IAAI,IAAI,CAAC,YAAa,YAAa,OAAO,CAAC,EAC5D,EAAsB,CAAC,IAAK,IAAK,IAAK,KAAM,KAAM,KAAM,MAAO,GAAG,EAE3D,kBAAqB,GAChC,EAAM,QAAU,GAChB,IAAU,EAAM,YAAY,GAC5B,+CAA+C,KAAK,CAAK,EAErD,cAAiB,GAA2B,CAChD,GAAI,OAAO,GAAU,UAAY,EAAM,SAAW,EAAG,MAAM,YAAY,EACvE,OAAO,CACT,EAEM,gBAAkB,EAAgB,IAAyB,CAE/D,GADI,OAAO,GAAU,WAAY,GAC7B,OAAO,QAAQ,IAAI,EAAO,CAAM,GAAM,WAAY,MAAM,YAAY,CAC1E,EAGM,aAAe,EAAa,IAA4B,CAC5D,IAAI,EACJ,GAAI,CACF,EAAM,IAAI,IAAI,CAAG,CACnB,MAAQ,CACN,MAAM,YAAY,CACpB,CACA,GAAI,EAAI,WAAa,IAAM,EAAI,WAAa,IAAM,EAAI,OAAS,GAAI,MAAM,YAAY,EACrF,IAAM,EAAW,EAAe,IAAI,EAAI,QAAQ,EAChD,GAAI,IAAW,QAAU,EAAI,WAAa,SAAW,CAAC,EAAW,EAAI,WAAa,UAAY,EAC5F,MAAM,YAAY,EAEpB,OAAO,CACT,EAGM,gBAAkB,EAAa,IAA+B,CAClE,IAAM,EAAM,YAAY,EAAK,CAAM,EACnC,GAAI,EAAI,WAAa,KAAO,EAAI,SAAW,IAAM,EAAI,SAAW,EAAK,MAAM,YAAY,EACvF,OAAO,EAAI,MACb,EAEM,SAAY,GAAqD,OAAO,GAAU,YAAY,EAE9F,UAAa,GAAyB,CAC1C,GAAI,CACF,OAAO,KAAK,MAAM,CAAG,CACvB,MAAQ,CACN,MAAM,YAAY,CACpB,CACF,EAEM,cAAgB,EAA8B,IAAyB,CAC3E,IAAM,EAAQ,EAAI,GAClB,GAAI,OAAO,GAAU,UAAY,CAAC,EAAU,KAAK,CAAK,EAAG,MAAM,YAAY,EAC3E,OAAO,CACT,EAGM,iBAAoB,GAAuC,CAG/D,GAFI,EAAI,MAAQ,OACZ,EAAI,MAAQ,IAAA,IAAa,EAAI,MAAQ,SACrC,EAAI,MAAQ,IAAA,IAAa,EAAI,MAAQ,MAAO,MAAM,YAAY,CACpE,EAEM,cAAiB,GAAiD,CACtE,IAAM,EAAI,aAAa,EAAK,GAAG,EAC/B,GAAI,EAAE,OAAS,EAAmB,MAAM,YAAY,EACpD,MAAO,CAAE,EAAG,aAAa,EAAK,GAAG,EAAG,IAAK,MAAO,GAAE,CACpD,EAEM,aAAe,KAAO,KAAoD,CAC9E,IAAK,QACL,EAAG,EAAI,EACP,IAAK,MAAM,EAAuB,CAAE,EAAG,EAAI,EAAG,IAAK,MAAO,EAAG,EAAI,CAAE,CAAC,EACpE,IAAK,MACL,EAAG,EAAI,EACP,IAAK,KACP,GAEA,eAAe,UAAU,EAAoE,CAC3F,IAAM,EAAS,UAAU,CAAG,EAC5B,GAAI,CAAC,SAAS,CAAM,EAAG,MAAM,YAAY,EACzC,iBAAiB,CAAM,EACvB,IAAM,EAAM,cAAc,CAAM,EAC1B,EAAuB,CAC3B,GAAG,EACH,EAAG,aAAa,EAAQ,GAAG,EAC3B,GAAI,aAAa,EAAQ,IAAI,EAC7B,GAAI,aAAa,EAAQ,IAAI,EAC7B,EAAG,aAAa,EAAQ,GAAG,EAC3B,EAAG,aAAa,EAAQ,GAAG,EAC3B,GAAI,aAAa,EAAQ,IAAI,CAC/B,EACI,EACJ,GAAI,CACF,IAAM,EAAW,MAAM,EAAU,EAAK,QAAS,CAAE,YAAa,EAAM,CAAC,EACrE,GAAI,aAAoB,WAAY,MAAM,YAAY,EACtD,EAAM,CACR,MAAQ,CACN,MAAM,YAAY,CACpB,CACA,MAAO,CAAE,MAAK,OAAQ,MAAM,aAAa,CAAG,CAAE,CAChD,CAGA,eAAe,aAAa,EAAsD,CAChF,GAAI,IAAQ,IAAA,IAAa,EAAI,KAAK,CAAC,CAAC,SAAW,EAAG,MAAO,CAAC,EAC1D,IAAM,EAAS,UAAU,CAAG,EAC5B,GAAI,CAAC,SAAS,CAAM,GAAK,CAAC,MAAM,QAAQ,EAAO,IAAI,EAAG,MAAM,YAAY,EACxE,IAAM,EAA2B,CAAC,EAClC,IAAK,IAAM,KAAS,EAAO,KAAM,CAG/B,GAFI,CAAC,SAAS,CAAK,IACnB,iBAAiB,CAAK,EAClB,EAAoB,KAAM,GAAW,KAAU,CAAK,GAAG,MAAM,YAAY,EAC7E,EAAK,KAAK,MAAM,aAAa,cAAc,CAAK,CAAC,CAAC,CACpD,CACA,OAAO,CACT,CAEA,IAAM,EAAU,IAAI,QAEpB,eAAe,YAAY,EAAyC,CAClE,IAAM,EAAY,cAAc,EAAI,OAAO,EAC3C,GAAI,IAAc,SAAW,IAAc,aAAc,MAAM,YAAY,EAC3E,IAAM,EAAqB,EAErB,EAAiB,IAAI,IACzB,cAAc,EAAI,eAAe,CAAC,CAC/B,MAAM,GAAG,CAAC,CACV,IAAK,GAAW,EAAO,KAAK,CAAC,CAAC,CAC9B,OAAQ,GAAW,EAAO,OAAS,CAAC,CAAC,CACrC,IAAK,GAAW,eAAe,EAAQ,CAAM,CAAC,CACnD,EACA,GAAI,EAAe,OAAS,EAAG,MAAM,YAAY,EAEjD,IAAM,EAAe,eAAe,cAAc,EAAI,aAAa,EAAG,CAAM,EAEtE,EAAS,cAAc,EAAI,WAAW,EAC5C,YAAY,EAAQ,CAAM,EAC1B,IAAM,EAAW,cAAc,EAAI,QAAQ,EACrC,EAAU,cAAc,EAAI,QAAQ,EAC1C,GAAI,EAAQ,OAAS,GAAgB,kBAAkB,KAAK,CAAO,EAAG,MAAM,YAAY,EACxF,IAAM,EAAgB,cAAc,EAAI,cAAc,EAEtD,GADI,CAAC,kBAAkB,CAAa,GAChC,OAAO,EAAI,iBAAoB,SAAU,MAAM,YAAY,EAC/D,IAAM,EAAiB,IAAI,IACzB,EAAI,gBAAgB,MAAM,GAAG,CAAC,CAC3B,IAAK,GAAU,EAAM,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAC1C,OAAQ,GAAU,EAAM,OAAS,CAAC,CACvC,EACA,IAAK,IAAM,KAAS,EAAgB,GAAI,CAAC,kBAAkB,CAAK,EAAG,MAAM,YAAY,EAErF,eAAe,EAAI,QAAS,SAAS,EACrC,eAAe,EAAI,QAAS,OAAO,EACnC,eAAe,EAAI,WAAY,MAAM,EACrC,eAAe,EAAI,aAAc,OAAO,EAExC,IAAM,EAAS,MAAM,UAAU,cAAc,EAAI,oBAAoB,CAAC,EACtE,GAAI,CACF,IAAM,EAAQ,MAAM,IAAI,EAAQ,CAAC,CAAC,CAAC,CAAC,mBAAmB,CAAE,IAAK,QAAS,IAAK,EAAO,OAAO,GAAI,CAAC,CAAC,CAAC,KAAK,EAAO,GAAG,EAChH,MAAM,EAAU,EAAO,EAAkB,CAAE,KAAM,CAAC,EAAO,MAAM,CAAE,CAAC,EAAG,CAAE,WAAY,CAAC,OAAO,CAAE,CAAC,CAChG,MAAQ,CACN,MAAM,YAAY,CACpB,CACA,IAAM,GAAW,MAAM,aAAa,EAAI,6BAA6B,EAAA,CAAG,OACrE,GAAQ,EAAI,MAAQ,EAAO,OAAO,GACrC,EACM,EAAO,CACX,EAAO,OACP,GAAG,EAAQ,QAAQ,EAAK,IAAM,EAAQ,UAAW,GAAU,EAAM,MAAQ,EAAI,GAAG,IAAM,CAAC,CACzF,EAEA,MAAO,CACL,iBACA,SACA,UACA,WACA,SACA,OACA,iBACA,eACA,gBACA,QAAS,CAAE,IAAK,EAAO,IAAK,IAAK,EAAO,OAAO,GAAI,EACnD,SAAU,EAAkB,CAAE,KAAM,CAAC,GAAG,CAAI,CAAE,CAAC,CACjD,CACF,CAMA,SAAgB,WAAW,EAAyC,CAClE,IAAM,EAAS,EAAQ,IAAI,CAAG,EAC9B,GAAI,IAAW,IAAA,GAAW,OAAO,EACjC,IAAM,EAAU,YAAY,CAAG,EAG/B,OAFA,EAAQ,IAAI,EAAK,CAAO,EACxB,EAAQ,UAAY,EAAQ,OAAO,CAAG,CAAC,EAChC,CACT"}
|
|
1
|
+
{"version":3,"file":"config.js","names":["AuthOperationError","EMAIL_MAX","APP_NAME_MAX","MIN_MODULUS_CHARS","BASE64URL","LOOPBACK_HOSTS","PRIVATE_RSA_MEMBERS","value","method","url","raw","loopback","appEnv","jwk","name","n","pub","calculateJwkThumbprint","parsed","key","imported","importJWK","keys","entry","member","configs","appEnvRaw","env","allowedOrigins","origin","publicOrigin","issuer","audience","appName","senderAddress","operatorEmails","email","active","proof","SignJWT","jwtVerify","createLocalJWKSet","retired","jwks","other","i","cached","pending"],"sources":["../../src/auth/config.ts"],"sourcesContent":["import {\n type CryptoKey,\n calculateJwkThumbprint,\n createLocalJWKSet,\n importJWK,\n type JWK_RSA_Private,\n type JWK_RSA_Public,\n jwtVerify,\n SignJWT,\n} from 'jose';\nimport type { D1Like } from '../worker/env';\nimport type { ApiError } from '../worker/http';\nimport { AuthOperationError } from './errors';\n\n/** The send-email binding subset the issuer calls; the runtime's SendEmail satisfies it. */\nexport interface SendEmail {\n send(message: { from: string; html?: string; subject: string; text: string; to: string }): Promise<unknown>;\n}\n\n/**\n * The catalog Worker as the issuer reaches it: a Cloudflare service binding (or any\n * `fetch`-shaped stub). Account erasure calls the catalog's `DELETE /api/v1/me/account`\n * through it before removing the issuer's own row.\n */\nexport interface AuthArchiveBinding {\n fetch(request: Request): Promise<Response>;\n}\n\n/**\n * The bindings the auth issuer reads. Declared structurally, like the catalog\n * Worker's Env: a deployment's generated `ExportedHandler<Env>` accepts the factory's\n * result as-is, with no `@cloudflare/workers-types` dependency. Every value is\n * validated on first use per bindings object; a missing or malformed one answers\n * 503 `unavailable` on every route, never a permissive fallback.\n */\nexport interface AuthWorkerEnv {\n /** Comma-separated exact origins allowed to call unsafe routes and read CORS responses. */\n ALLOWED_ORIGINS: string;\n /** Exactly `local` (loopback HTTP only) or `production` (non-loopback HTTPS, Secure cookies). */\n APP_ENV: string;\n /** The app's display name in magic-link email and escaped confirmation-page branding. */\n APP_NAME: string;\n AUDIENCE: string;\n /** Service binding to the sibling catalog Worker: account erasure removes the catalog profile through it. */\n AUTH_ARCHIVE: AuthArchiveBinding;\n /** The issuer's own D1: accounts, links, per-account send and singleton admission budgets. */\n AUTH_DB: D1Like;\n AUTH_EMAIL: SendEmail;\n /** The `iss` claim every token carries, exactly as the catalog Worker's `JWT_ISSUER` knows it. */\n AUTH_ISSUER: string;\n /**\n * Optional JWKS JSON (`{\"keys\":[...]}`) of retired PUBLIC RS256 keys, still served\n * from `/jwks.json` and accepted by the issuer so sessions minted before a rotation\n * keep verifying until they expire. Private members are rejected.\n */\n AUTH_JWT_PREVIOUS_PUBLIC_JWKS?: string;\n /** The active RS256 private key as a JWK JSON string (a wrangler secret): signs every new session. */\n AUTH_JWT_PRIVATE_JWK: string;\n /** Comma-separated emails whose sessions carry the operator role claim. */\n OPERATOR_EMAILS: string;\n /** The app's public origin: magic links, app recovery, signed-in navigation and catalog erasure point here. */\n PUBLIC_ORIGIN: string;\n /** The address emails come from: a domain onboarded to Cloudflare Email Service. */\n SENDER_ADDRESS: string;\n}\n\nexport type AuthAppEnv = 'local' | 'production';\n\nexport interface PublicSigningKey extends JWK_RSA_Public {\n alg: 'RS256';\n kid: string;\n kty: 'RSA';\n use: 'sig';\n}\n\nexport interface AuthConfig {\n allowedOrigins: ReadonlySet<string>;\n appEnv: AuthAppEnv;\n appName: string;\n audience: string;\n issuer: string;\n /** Every key a session may be verified with: the active key first, then retired ones. */\n jwks: readonly PublicSigningKey[];\n operatorEmails: ReadonlySet<string>;\n publicOrigin: string;\n senderAddress: string;\n signing: { key: CryptoKey; kid: string };\n verifier: ReturnType<typeof createLocalJWKSet>;\n}\n\n// Configuration faults never name the offending field: the response must not leak\n// Env names, only a generic temporary unavailability.\nconst configError = (): ApiError => new AuthOperationError(503, 'configuration');\n\nconst EMAIL_MAX = 320;\nconst APP_NAME_MAX = 64;\n// 2048-bit RSA modulus: 256 bytes, 342 base64url characters unpadded.\nconst MIN_MODULUS_CHARS = 342;\nconst BASE64URL = /^[A-Za-z0-9_-]+$/;\nconst LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);\nconst PRIVATE_RSA_MEMBERS = ['d', 'p', 'q', 'dp', 'dq', 'qi', 'oth', 'k'] as const;\n\nexport const isWellFormedEmail = (value: string): boolean =>\n value.length <= EMAIL_MAX &&\n value === value.toLowerCase() &&\n /^[^\\s@<>\"',;]+@[^\\s@<>\"',;]+\\.[^\\s@<>\"',;]+$/.test(value);\n\nconst requireString = (value: unknown): string => {\n if (typeof value !== 'string' || value.length === 0) throw configError();\n return value;\n};\n\nconst requireBinding = (value: unknown, method: string): void => {\n if (typeof value !== 'object' || value === null) throw configError();\n if (typeof Reflect.get(value, method) !== 'function') throw configError();\n};\n\n/** Transport rules: local is loopback HTTP only, production is non-loopback HTTPS; no credentials or fragments. */\nconst validateUrl = (raw: string, appEnv: AuthAppEnv): URL => {\n let url: URL;\n try {\n url = new URL(raw);\n } catch {\n throw configError();\n }\n if (url.username !== '' || url.password !== '' || url.hash !== '') throw configError();\n const loopback = LOOPBACK_HOSTS.has(url.hostname);\n if (appEnv === 'local' ? url.protocol !== 'http:' || !loopback : url.protocol !== 'https:' || loopback) {\n throw configError();\n }\n return url;\n};\n\n/** An exact canonical origin: scheme + host (+port), no path, query, wildcard or `null`. */\nconst validateOrigin = (raw: string, appEnv: AuthAppEnv): string => {\n const url = validateUrl(raw, appEnv);\n if (url.pathname !== '/' || url.search !== '' || url.origin !== raw) throw configError();\n return url.origin;\n};\n\nconst isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;\n\nconst parseJson = (raw: string): unknown => {\n try {\n return JSON.parse(raw);\n } catch {\n throw configError();\n }\n};\n\nconst b64urlMember = (jwk: Record<string, unknown>, name: string): string => {\n const value = jwk[name];\n if (typeof value !== 'string' || !BASE64URL.test(value)) throw configError();\n return value;\n};\n\n/** RS256 signing-key metadata, when present, must agree with how the issuer uses the key. */\nconst checkRsaMetadata = (jwk: Record<string, unknown>): void => {\n if (jwk.kty !== 'RSA') throw configError();\n if (jwk.alg !== undefined && jwk.alg !== 'RS256') throw configError();\n if (jwk.use !== undefined && jwk.use !== 'sig') throw configError();\n};\n\nconst publicMembers = (jwk: Record<string, unknown>): JWK_RSA_Public => {\n const n = b64urlMember(jwk, 'n');\n if (n.length < MIN_MODULUS_CHARS) throw configError();\n return { e: b64urlMember(jwk, 'e'), kty: 'RSA', n };\n};\n\nconst toSigningKey = async (pub: JWK_RSA_Public): Promise<PublicSigningKey> => ({\n alg: 'RS256',\n e: pub.e,\n kid: await calculateJwkThumbprint({ e: pub.e, kty: 'RSA', n: pub.n }),\n kty: 'RSA',\n n: pub.n,\n use: 'sig',\n});\n\nasync function activeKey(raw: string): Promise<{ key: CryptoKey; public: PublicSigningKey }> {\n const parsed = parseJson(raw);\n if (!isRecord(parsed)) throw configError();\n checkRsaMetadata(parsed);\n const pub = publicMembers(parsed);\n const jwk: JWK_RSA_Private = {\n ...pub,\n d: b64urlMember(parsed, 'd'),\n dp: b64urlMember(parsed, 'dp'),\n dq: b64urlMember(parsed, 'dq'),\n p: b64urlMember(parsed, 'p'),\n q: b64urlMember(parsed, 'q'),\n qi: b64urlMember(parsed, 'qi'),\n };\n let key: CryptoKey;\n try {\n const imported = await importJWK(jwk, 'RS256', { extractable: false });\n if (imported instanceof Uint8Array) throw configError();\n key = imported;\n } catch {\n throw configError();\n }\n return { key, public: await toSigningKey(pub) };\n}\n\n/** Retired verification keys: public RSA members only; any private member is a misconfiguration. */\nasync function previousKeys(raw: string | undefined): Promise<PublicSigningKey[]> {\n if (raw === undefined || raw.trim().length === 0) return [];\n const parsed = parseJson(raw);\n if (!isRecord(parsed) || !Array.isArray(parsed.keys)) throw configError();\n const keys: PublicSigningKey[] = [];\n for (const entry of parsed.keys) {\n if (!isRecord(entry)) throw configError();\n checkRsaMetadata(entry);\n if (PRIVATE_RSA_MEMBERS.some((member) => member in entry)) throw configError();\n keys.push(await toSigningKey(publicMembers(entry)));\n }\n return keys;\n}\n\nconst configs = new WeakMap<AuthWorkerEnv, Promise<AuthConfig>>();\n\nasync function buildConfig(env: AuthWorkerEnv): Promise<AuthConfig> {\n const appEnvRaw = requireString(env.APP_ENV);\n if (appEnvRaw !== 'local' && appEnvRaw !== 'production') throw configError();\n const appEnv: AuthAppEnv = appEnvRaw;\n\n const allowedOrigins = new Set(\n requireString(env.ALLOWED_ORIGINS)\n .split(',')\n .map((origin) => origin.trim())\n .filter((origin) => origin.length > 0)\n .map((origin) => validateOrigin(origin, appEnv)),\n );\n if (allowedOrigins.size === 0) throw configError();\n\n const publicOrigin = validateOrigin(requireString(env.PUBLIC_ORIGIN), appEnv);\n // The issuer is matched as the exact configured string (a trailing slash is meaningful).\n const issuer = requireString(env.AUTH_ISSUER);\n validateUrl(issuer, appEnv);\n const audience = requireString(env.AUDIENCE);\n const appName = requireString(env.APP_NAME);\n if (appName.length > APP_NAME_MAX || /[\\p{Cc}\\p{Cs}]/u.test(appName)) throw configError();\n const senderAddress = requireString(env.SENDER_ADDRESS);\n if (!isWellFormedEmail(senderAddress)) throw configError();\n if (typeof env.OPERATOR_EMAILS !== 'string') throw configError();\n const operatorEmails = new Set(\n env.OPERATOR_EMAILS.split(',')\n .map((entry) => entry.trim().toLowerCase())\n .filter((entry) => entry.length > 0),\n );\n for (const email of operatorEmails) if (!isWellFormedEmail(email)) throw configError();\n\n requireBinding(env.AUTH_DB, 'prepare');\n requireBinding(env.AUTH_DB, 'batch');\n requireBinding(env.AUTH_EMAIL, 'send');\n requireBinding(env.AUTH_ARCHIVE, 'fetch');\n\n const active = await activeKey(requireString(env.AUTH_JWT_PRIVATE_JWK));\n try {\n const proof = await new SignJWT({}).setProtectedHeader({ alg: 'RS256', kid: active.public.kid }).sign(active.key);\n await jwtVerify(proof, createLocalJWKSet({ keys: [active.public] }), { algorithms: ['RS256'] });\n } catch {\n throw configError();\n }\n const retired = (await previousKeys(env.AUTH_JWT_PREVIOUS_PUBLIC_JWKS)).filter(\n (key) => key.kid !== active.public.kid,\n );\n const jwks = [\n active.public,\n ...retired.filter((key, i) => retired.findIndex((other) => other.kid === key.kid) === i),\n ];\n\n return {\n allowedOrigins,\n appEnv,\n appName,\n audience,\n issuer,\n jwks,\n operatorEmails,\n publicOrigin,\n senderAddress,\n signing: { key: active.key, kid: active.public.kid },\n verifier: createLocalJWKSet({ keys: [...jwks] }),\n };\n}\n\n/**\n * Validates the env and imports its keys once per bindings object. A failed\n * validation is not cached, so a corrected binding set recovers without a restart.\n */\nexport function authConfig(env: AuthWorkerEnv): Promise<AuthConfig> {\n const cached = configs.get(env);\n if (cached !== undefined) return cached;\n const pending = buildConfig(env);\n configs.set(env, pending);\n pending.catch(() => configs.delete(env));\n return pending;\n}\n"],"mappings":";;;AA4FA,IAAM,oBAA8B,IAAIA,EAAmB,KAAK,eAAe,GAEzEC,IAAY,KACZC,IAAe,IAEfC,IAAoB,KACpBC,IAAY,oBACZC,oBAAiB,IAAI,IAAI;CAAC;CAAa;CAAa;AAAO,CAAC,GAC5DC,IAAsB;CAAC;CAAK;CAAK;CAAK;CAAM;CAAM;CAAM;CAAO;AAAG,GAE3D,qBAAqB,MAChCC,EAAM,UAAUN,KAChBM,MAAUA,EAAM,YAAY,KAC5B,+CAA+C,KAAKA,CAAK,GAErD,iBAAiB,MAA2B;CAChD,IAAI,OAAOA,KAAU,YAAYA,EAAM,WAAW,GAAG,MAAM,YAAY;CACvE,OAAOA;AACT,GAEM,kBAAkB,GAAgB,MAAyB;CAE/D,IADI,OAAOA,KAAU,aAAYA,KAC7B,OAAO,QAAQ,IAAIA,GAAOC,CAAM,KAAM,YAAY,MAAM,YAAY;AAC1E,GAGM,eAAe,GAAa,MAA4B;CAC5D,IAAIC;CACJ,IAAI;EACF,IAAM,IAAI,IAAIC,CAAG;CACnB,QAAQ;EACN,MAAM,YAAY;CACpB;CACA,IAAID,EAAI,aAAa,MAAMA,EAAI,aAAa,MAAMA,EAAI,SAAS,IAAI,MAAM,YAAY;CACrF,IAAME,IAAWN,EAAe,IAAII,EAAI,QAAQ;CAChD,IAAIG,MAAW,UAAUH,EAAI,aAAa,WAAW,CAACE,IAAWF,EAAI,aAAa,YAAYE,GAC5F,MAAM,YAAY;CAEpB,OAAOF;AACT,GAGM,kBAAkB,GAAa,MAA+B;CAClE,IAAMA,IAAM,YAAYC,GAAKE,CAAM;CACnC,IAAIH,EAAI,aAAa,OAAOA,EAAI,WAAW,MAAMA,EAAI,WAAWC,GAAK,MAAM,YAAY;CACvF,OAAOD,EAAI;AACb,GAEM,YAAY,MAAqD,OAAOF,KAAU,cAAYA,GAE9F,aAAa,MAAyB;CAC1C,IAAI;EACF,OAAO,KAAK,MAAMG,CAAG;CACvB,QAAQ;EACN,MAAM,YAAY;CACpB;AACF,GAEM,gBAAgB,GAA8B,MAAyB;CAC3E,IAAMH,IAAQM,EAAIC;CAClB,IAAI,OAAOP,KAAU,YAAY,CAACH,EAAU,KAAKG,CAAK,GAAG,MAAM,YAAY;CAC3E,OAAOA;AACT,GAGM,oBAAoB,MAAuC;CAG/D,IAFIM,EAAI,QAAQ,SACZA,EAAI,QAAQ,KAAA,KAAaA,EAAI,QAAQ,WACrCA,EAAI,QAAQ,KAAA,KAAaA,EAAI,QAAQ,OAAO,MAAM,YAAY;AACpE,GAEM,iBAAiB,MAAiD;CACtE,IAAME,IAAI,aAAaF,GAAK,GAAG;CAC/B,IAAIE,EAAE,SAASZ,GAAmB,MAAM,YAAY;CACpD,OAAO;EAAE,GAAG,aAAaU,GAAK,GAAG;EAAG,KAAK;EAAO;CAAE;AACpD,GAEM,eAAe,OAAO,OAAoD;CAC9E,KAAK;CACL,GAAGG,EAAI;CACP,KAAK,MAAMC,EAAuB;EAAE,GAAGD,EAAI;EAAG,KAAK;EAAO,GAAGA,EAAI;CAAE,CAAC;CACpE,KAAK;CACL,GAAGA,EAAI;CACP,KAAK;AACP;AAEA,eAAe,UAAU,GAAoE;CAC3F,IAAME,IAAS,UAAUR,CAAG;CAC5B,IAAI,CAAC,SAASQ,CAAM,GAAG,MAAM,YAAY;CACzC,iBAAiBA,CAAM;CACvB,IAAMF,IAAM,cAAcE,CAAM,GAC1BL,IAAuB;EAC3B,GAAGG;EACH,GAAG,aAAaE,GAAQ,GAAG;EAC3B,IAAI,aAAaA,GAAQ,IAAI;EAC7B,IAAI,aAAaA,GAAQ,IAAI;EAC7B,GAAG,aAAaA,GAAQ,GAAG;EAC3B,GAAG,aAAaA,GAAQ,GAAG;EAC3B,IAAI,aAAaA,GAAQ,IAAI;CAC/B,GACIC;CACJ,IAAI;EACF,IAAMC,IAAW,MAAMC,EAAUR,GAAK,SAAS,EAAE,aAAa,GAAM,CAAC;EACrE,IAAIO,aAAoB,YAAY,MAAM,YAAY;EACtD,IAAMA;CACR,QAAQ;EACN,MAAM,YAAY;CACpB;CACA,OAAO;EAAE;EAAK,QAAQ,MAAM,aAAaJ,CAAG;CAAE;AAChD;AAGA,eAAe,aAAa,GAAsD;CAChF,IAAIN,MAAQ,KAAA,KAAaA,EAAI,KAAK,CAAC,CAAC,WAAW,GAAG,OAAO,CAAC;CAC1D,IAAMQ,IAAS,UAAUR,CAAG;CAC5B,IAAI,CAAC,SAASQ,CAAM,KAAK,CAAC,MAAM,QAAQA,EAAO,IAAI,GAAG,MAAM,YAAY;CACxE,IAAMI,IAA2B,CAAC;CAClC,KAAK,IAAMC,KAASL,EAAO,MAAM;EAG/B,IAFI,CAAC,SAASK,CAAK,MACnB,iBAAiBA,CAAK,GAClBjB,EAAoB,MAAM,MAAWkB,KAAUD,CAAK,IAAG,MAAM,YAAY;EAC7E,EAAK,KAAK,MAAM,aAAa,cAAcA,CAAK,CAAC,CAAC;CACpD;CACA,OAAOD;AACT;AAEA,IAAMG,oBAAU,IAAI,QAA4C;AAEhE,eAAe,YAAY,GAAyC;CAClE,IAAMC,IAAY,cAAcC,EAAI,OAAO;CAC3C,IAAID,MAAc,WAAWA,MAAc,cAAc,MAAM,YAAY;CAC3E,IAAMd,IAAqBc,GAErBE,IAAiB,IAAI,IACzB,cAAcD,EAAI,eAAe,CAAC,CAC/B,MAAM,GAAG,CAAC,CACV,KAAK,MAAWE,EAAO,KAAK,CAAC,CAAC,CAC9B,QAAQ,MAAWA,EAAO,SAAS,CAAC,CAAC,CACrC,KAAK,MAAW,eAAeA,GAAQjB,CAAM,CAAC,CACnD;CACA,IAAIgB,EAAe,SAAS,GAAG,MAAM,YAAY;CAEjD,IAAME,IAAe,eAAe,cAAcH,EAAI,aAAa,GAAGf,CAAM,GAEtEmB,IAAS,cAAcJ,EAAI,WAAW;CAC5C,YAAYI,GAAQnB,CAAM;CAC1B,IAAMoB,IAAW,cAAcL,EAAI,QAAQ,GACrCM,IAAU,cAAcN,EAAI,QAAQ;CAC1C,IAAIM,EAAQ,SAAS/B,KAAgB,kBAAkB,KAAK+B,CAAO,GAAG,MAAM,YAAY;CACxF,IAAMC,IAAgB,cAAcP,EAAI,cAAc;CAEtD,IADI,CAAC,kBAAkBO,CAAa,KAChC,OAAOP,EAAI,mBAAoB,UAAU,MAAM,YAAY;CAC/D,IAAMQ,IAAiB,IAAI,IACzBR,EAAI,gBAAgB,MAAM,GAAG,CAAC,CAC3B,KAAK,MAAUJ,EAAM,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAC1C,QAAQ,MAAUA,EAAM,SAAS,CAAC,CACvC;CACA,KAAK,IAAMa,KAASD,GAAgB,IAAI,CAAC,kBAAkBC,CAAK,GAAG,MAAM,YAAY;CAKrF,AAHA,eAAeT,EAAI,SAAS,SAAS,GACrC,eAAeA,EAAI,SAAS,OAAO,GACnC,eAAeA,EAAI,YAAY,MAAM,GACrC,eAAeA,EAAI,cAAc,OAAO;CAExC,IAAMU,IAAS,MAAM,UAAU,cAAcV,EAAI,oBAAoB,CAAC;CACtE,IAAI;EACF,IAAMW,IAAQ,MAAM,IAAIC,EAAQ,CAAC,CAAC,CAAC,CAAC,mBAAmB;GAAE,KAAK;GAAS,KAAKF,EAAO,OAAO;EAAI,CAAC,CAAC,CAAC,KAAKA,EAAO,GAAG;EAChH,MAAMG,EAAUF,GAAOG,EAAkB,EAAE,MAAM,CAACJ,EAAO,MAAM,EAAE,CAAC,GAAG,EAAE,YAAY,CAAC,OAAO,EAAE,CAAC;CAChG,QAAQ;EACN,MAAM,YAAY;CACpB;CACA,IAAMK,KAAW,MAAM,aAAaf,EAAI,6BAA6B,EAAA,CAAG,QACrE,MAAQR,EAAI,QAAQkB,EAAO,OAAO,GACrC,GACMM,IAAO,CACXN,EAAO,QACP,GAAGK,EAAQ,QAAQ,GAAK,MAAMA,EAAQ,WAAW,MAAUE,EAAM,QAAQzB,EAAI,GAAG,MAAM0B,CAAC,CACzF;CAEA,OAAO;EACL;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,SAAS;GAAE,KAAKR,EAAO;GAAK,KAAKA,EAAO,OAAO;EAAI;EACnD,UAAUI,EAAkB,EAAE,MAAM,CAAC,GAAGE,CAAI,EAAE,CAAC;CACjD;AACF;AAMA,SAAgB,WAAW,GAAyC;CAClE,IAAMG,IAASrB,EAAQ,IAAIE,CAAG;CAC9B,IAAImB,MAAW,KAAA,GAAW,OAAOA;CACjC,IAAMC,IAAU,YAAYpB,CAAG;CAG/B,OAFAF,EAAQ,IAAIE,GAAKoB,CAAO,GACxBA,EAAQ,YAAYtB,EAAQ,OAAOE,CAAG,CAAC,GAChCoB;AACT"}
|
package/dist/auth/errors.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.cjs","names":[],"sources":["../../src/auth/errors.ts"],"sourcesContent":["import { ApiError } from '../worker/http';\n\nexport type AuthDiagnostic = {\n readonly kind: 'configuration' | 'database' | 'schema' | 'email' | 'catalog' | 'internal';\n};\n\nexport class AuthOperationError extends ApiError {\n readonly authDiagnostic: AuthDiagnostic;\n\n constructor(status: number, kind: AuthDiagnostic['kind'], opts?: ErrorOptions) {\n super(\n status,\n status === 500 ? 'internal' : 'unavailable',\n status === 500 ? 'Something went wrong.' : 'The service is temporarily unavailable.',\n opts,\n );\n this.authDiagnostic = { kind };\n }\n}\n\nexport async function databaseOperation<T>(operation: () => Promise<T>): Promise<T> {\n try {\n return await operation();\n } catch (cause) {\n throw new AuthOperationError(500, 'database', { cause });\n }\n}\n"],"mappings":"sCAMA,IAAa,mBAAb,
|
|
1
|
+
{"version":3,"file":"errors.cjs","names":["ApiError","require_http","status","opts","operation","cause"],"sources":["../../src/auth/errors.ts"],"sourcesContent":["import { ApiError } from '../worker/http';\n\nexport type AuthDiagnostic = {\n readonly kind: 'configuration' | 'database' | 'schema' | 'email' | 'catalog' | 'internal';\n};\n\nexport class AuthOperationError extends ApiError {\n readonly authDiagnostic: AuthDiagnostic;\n\n constructor(status: number, kind: AuthDiagnostic['kind'], opts?: ErrorOptions) {\n super(\n status,\n status === 500 ? 'internal' : 'unavailable',\n status === 500 ? 'Something went wrong.' : 'The service is temporarily unavailable.',\n opts,\n );\n this.authDiagnostic = { kind };\n }\n}\n\nexport async function databaseOperation<T>(operation: () => Promise<T>): Promise<T> {\n try {\n return await operation();\n } catch (cause) {\n throw new AuthOperationError(500, 'database', { cause });\n }\n}\n"],"mappings":"A,sCAMA,IAAa,mBAAb,cAAwCA,EAAAA,QAAS,CAC/C,eAEA,YAAY,EAAgB,EAA8B,EAAqB,CAC7E,MACEE,EACAA,IAAW,IAAM,WAAa,cAC9BA,IAAW,IAAM,wBAA0B,0CAC3CC,CACF,EACA,KAAK,eAAiB,CAAE,MAAK,CAC/B,CACF,EAEA,eAAsB,kBAAqB,EAAyC,CAClF,GAAI,CACF,OAAO,MAAMC,EAAU,CACzB,OAASC,EAAO,CACd,MAAM,IAAI,mBAAmB,IAAK,WAAY,CAAE,OAAM,CAAC,CACzD,CACF,C"}
|
package/dist/auth/errors.js
CHANGED
|
@@ -1,2 +1,19 @@
|
|
|
1
|
-
import{ApiError as e}from"../worker/http.js";
|
|
1
|
+
import { ApiError as e } from "../worker/http.js";
|
|
2
|
+
//#region src/auth/errors.ts
|
|
3
|
+
var AuthOperationError = class extends e {
|
|
4
|
+
authDiagnostic;
|
|
5
|
+
constructor(e, t, n) {
|
|
6
|
+
super(e, e === 500 ? "internal" : "unavailable", e === 500 ? "Something went wrong." : "The service is temporarily unavailable.", n), this.authDiagnostic = { kind: t };
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
async function databaseOperation(e) {
|
|
10
|
+
try {
|
|
11
|
+
return await e();
|
|
12
|
+
} catch (e) {
|
|
13
|
+
throw new AuthOperationError(500, "database", { cause: e });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
//#endregion
|
|
17
|
+
export { AuthOperationError, databaseOperation };
|
|
18
|
+
|
|
2
19
|
//# sourceMappingURL=errors.js.map
|
package/dist/auth/errors.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.js","names":[],"sources":["../../src/auth/errors.ts"],"sourcesContent":["import { ApiError } from '../worker/http';\n\nexport type AuthDiagnostic = {\n readonly kind: 'configuration' | 'database' | 'schema' | 'email' | 'catalog' | 'internal';\n};\n\nexport class AuthOperationError extends ApiError {\n readonly authDiagnostic: AuthDiagnostic;\n\n constructor(status: number, kind: AuthDiagnostic['kind'], opts?: ErrorOptions) {\n super(\n status,\n status === 500 ? 'internal' : 'unavailable',\n status === 500 ? 'Something went wrong.' : 'The service is temporarily unavailable.',\n opts,\n );\n this.authDiagnostic = { kind };\n }\n}\n\nexport async function databaseOperation<T>(operation: () => Promise<T>): Promise<T> {\n try {\n return await operation();\n } catch (cause) {\n throw new AuthOperationError(500, 'database', { cause });\n }\n}\n"],"mappings":"
|
|
1
|
+
{"version":3,"file":"errors.js","names":["ApiError","status","opts","operation","cause"],"sources":["../../src/auth/errors.ts"],"sourcesContent":["import { ApiError } from '../worker/http';\n\nexport type AuthDiagnostic = {\n readonly kind: 'configuration' | 'database' | 'schema' | 'email' | 'catalog' | 'internal';\n};\n\nexport class AuthOperationError extends ApiError {\n readonly authDiagnostic: AuthDiagnostic;\n\n constructor(status: number, kind: AuthDiagnostic['kind'], opts?: ErrorOptions) {\n super(\n status,\n status === 500 ? 'internal' : 'unavailable',\n status === 500 ? 'Something went wrong.' : 'The service is temporarily unavailable.',\n opts,\n );\n this.authDiagnostic = { kind };\n }\n}\n\nexport async function databaseOperation<T>(operation: () => Promise<T>): Promise<T> {\n try {\n return await operation();\n } catch (cause) {\n throw new AuthOperationError(500, 'database', { cause });\n }\n}\n"],"mappings":";;AAMA,IAAa,qBAAb,cAAwCA,EAAS;CAC/C;CAEA,YAAY,GAAgB,GAA8B,GAAqB;EAO7E,AANA,MACEC,GACAA,MAAW,MAAM,aAAa,eAC9BA,MAAW,MAAM,0BAA0B,2CAC3CC,CACF,GACA,KAAK,iBAAiB,EAAE,QAAK;CAC/B;AACF;AAEA,eAAsB,kBAAqB,GAAyC;CAClF,IAAI;EACF,OAAO,MAAMC,EAAU;CACzB,SAASC,GAAO;EACd,MAAM,IAAI,mBAAmB,KAAK,YAAY,EAAE,SAAM,CAAC;CACzD;AACF"}
|
package/dist/auth/store.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"store.cjs","names":[],"sources":["../../src/auth/store.ts"],"sourcesContent":["import type { D1Like } from '../worker/env';\nimport { databaseOperation } from './errors';\n\n/**\n * The issuer's storage, written against exactly `migrations/auth/0001_auth.sql`.\n * Token rows are single-use credentials only; the send allowance lives in its own\n * durable table, so consuming or expiring a link never refunds the budget.\n */\nexport const LINK_TTL_MS = 15 * 60 * 1000;\nexport const LINK_WINDOW_MS = 60 * 60 * 1000;\nexport const LINKS_PER_WINDOW = 3;\nexport const SIGN_IN_WINDOW_MS = 60_000;\nexport const SIGN_INS_PER_WINDOW = 20;\n\nexport interface AccountRow {\n created_at: string;\n email: string;\n id: string;\n last_sign_in_at: string | null;\n session_version: number;\n}\n\nconst SQL = {\n account: 'SELECT id, email, created_at, last_sign_in_at, session_version FROM auth_users WHERE id = ?',\n budgetWindow: 'SELECT window_start FROM auth_link_budget WHERE user_id = ?',\n consumeToken: `UPDATE auth_tokens SET redeemed_at = ?1 WHERE token_hash = ?2 AND expires_at > ?3\nRETURNING user_id AS id, (SELECT email FROM auth_users WHERE id = user_id) AS email,\n (SELECT session_version FROM auth_users WHERE id = user_id) AS sessionVersion`,\n deleteAccount: 'DELETE FROM auth_users WHERE id = ?',\n deleteToken: 'DELETE FROM auth_tokens WHERE token_hash = ?',\n // The no-op update makes RETURNING answer for an existing row too.\n ensureUser:\n 'INSERT INTO auth_users (id, email, created_at, last_sign_in_at) VALUES (?, ?, ?, NULL) ON CONFLICT (email) DO UPDATE SET email = excluded.email RETURNING id, email',\n insertToken: 'INSERT INTO auth_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)',\n revokeSessions: `UPDATE auth_users SET session_version = session_version + 1\nWHERE id = ? AND session_version = ? RETURNING session_version`,\n signInWindow: 'SELECT window_start FROM auth_sign_in_budget WHERE id = 1',\n // One atomic statement opens a window, counts within it, or refuses: no row back\n // means the allowance is spent. Concurrent requests cannot both pass the bound.\n spendBudget: `INSERT INTO auth_link_budget (user_id, window_start, sent) VALUES (?1, ?2, 1)\nON CONFLICT (user_id) DO UPDATE SET\n window_start = CASE WHEN auth_link_budget.window_start <= ?3 THEN ?2 ELSE auth_link_budget.window_start END,\n sent = CASE WHEN auth_link_budget.window_start <= ?3 THEN 1 ELSE auth_link_budget.sent + 1 END\nWHERE auth_link_budget.window_start <= ?3 OR auth_link_budget.sent < ?4\nRETURNING sent`,\n spendSignInBudget: `INSERT INTO auth_sign_in_budget (id, window_start, admitted) VALUES (1, ?1, 1)\nON CONFLICT (id) DO UPDATE SET\n window_start = CASE WHEN auth_sign_in_budget.window_start <= ?2 THEN ?1 ELSE auth_sign_in_budget.window_start END,\n admitted = CASE WHEN auth_sign_in_budget.window_start <= ?2 THEN 1 ELSE auth_sign_in_budget.admitted + 1 END\nWHERE auth_sign_in_budget.window_start <= ?2 OR auth_sign_in_budget.admitted < ?3\nRETURNING admitted`,\n sweepBudget: 'DELETE FROM auth_link_budget WHERE window_start <= ?',\n sweepTokens: 'DELETE FROM auth_tokens WHERE expires_at <= ?',\n // Rows for emails that never completed a sign-in leave once nothing references them.\n sweepUnverified:\n 'DELETE FROM auth_users WHERE last_sign_in_at IS NULL AND NOT EXISTS (SELECT 1 FROM auth_tokens t WHERE t.user_id = auth_users.id) AND NOT EXISTS (SELECT 1 FROM auth_link_budget b WHERE b.user_id = auth_users.id)',\n} as const;\n\nexport async function ensureUser(db: D1Like, email: string): Promise<{ email: string; id: string }> {\n const row = await databaseOperation(() =>\n db\n .prepare(SQL.ensureUser)\n .bind(crypto.randomUUID(), email, new Date().toISOString())\n .first<{ email: string; id: string }>(),\n );\n if (row === null) throw new Error('The account row could not be ensured.');\n return row;\n}\n\n/** The deployment-wide admission budget is spent before account creation or delivery. */\nexport async function spendSignInBudget(db: D1Like, nowMs: number): Promise<number | null> {\n return databaseOperation(async () => {\n const spent = await db\n .prepare(SQL.spendSignInBudget)\n .bind(nowMs, nowMs - SIGN_IN_WINDOW_MS, SIGN_INS_PER_WINDOW)\n .first<{ admitted: number }>();\n if (spent !== null) return null;\n const open = await db.prepare(SQL.signInWindow).first<{ window_start: number }>();\n return Math.max(1, Math.ceil(((open?.window_start ?? nowMs) + SIGN_IN_WINDOW_MS - nowMs) / 1000));\n });\n}\n\n/** Spends one send from the hourly allowance; returns the seconds until it reopens when spent. */\nexport async function spendLinkBudget(db: D1Like, userId: string, nowMs: number): Promise<number | null> {\n return databaseOperation(async () => {\n const windowFloor = nowMs - LINK_WINDOW_MS;\n const spent = await db\n .prepare(SQL.spendBudget)\n .bind(userId, nowMs, windowFloor, LINKS_PER_WINDOW)\n .first<{ sent: number }>();\n if (spent !== null) return null;\n const open = await db.prepare(SQL.budgetWindow).bind(userId).first<{ window_start: number }>();\n const reopensAt = (open?.window_start ?? nowMs) + LINK_WINDOW_MS;\n return Math.max(1, Math.ceil((reopensAt - nowMs) / 1000));\n });\n}\n\nexport async function insertToken(db: D1Like, tokenHash: string, userId: string, nowMs: number): Promise<void> {\n await databaseOperation(() =>\n db\n .prepare(SQL.insertToken)\n .bind(tokenHash, userId, nowMs + LINK_TTL_MS)\n .run(),\n );\n}\n\nexport async function deleteToken(db: D1Like, tokenHash: string): Promise<void> {\n await databaseOperation(() => db.prepare(SQL.deleteToken).bind(tokenHash).run());\n}\n\n/** Single-use consume: the unexpired row answers once and is gone after; the account is stamped signed in. */\nexport async function consumeToken(\n db: D1Like,\n tokenHash: string,\n nowMs: number,\n): Promise<{ email: string; id: string; sessionVersion: number } | null> {\n return databaseOperation(() =>\n db\n .prepare(SQL.consumeToken)\n .bind(new Date(nowMs).toISOString(), tokenHash, nowMs)\n .first<{ email: string; id: string; sessionVersion: number }>(),\n );\n}\n\n/** The trigger deletes outstanding links within this same atomic version change. */\nexport function revokeSessions(\n db: D1Like,\n subject: string,\n version: number,\n): Promise<{ session_version: number } | null> {\n return databaseOperation(() =>\n db.prepare(SQL.revokeSessions).bind(subject, version).first<{ session_version: number }>(),\n );\n}\n\nexport async function authReadiness(db: D1Like): Promise<number | null> {\n return databaseOperation(async () => {\n const columns = {\n auth_link_budget: ['user_id', 'window_start', 'sent'],\n auth_schema_meta: ['id', 'applied'],\n auth_sign_in_budget: ['id', 'window_start', 'admitted'],\n auth_tokens: ['token_hash', 'user_id', 'expires_at', 'redeemed_at'],\n auth_users: ['id', 'email', 'created_at', 'last_sign_in_at', 'session_version'],\n } as const;\n for (const [table, expected] of Object.entries(columns)) {\n const actual = await db.prepare(`PRAGMA table_info(${table})`).all<{ name: string }>();\n if (!expected.every((name) => actual.results?.some((column) => column.name === name))) return null;\n }\n const triggers = await db\n .prepare(\n \"SELECT name FROM sqlite_master WHERE type = 'trigger' AND name IN ('auth_token_redeemed', 'auth_sessions_revoked')\",\n )\n .all<{ name: string }>();\n if (triggers.results?.length !== 2) return null;\n const stamp = await db.prepare('SELECT applied FROM auth_schema_meta WHERE id = 1').first<{ applied: number }>();\n await db\n .prepare(`SELECT u.id, u.email, u.created_at, u.last_sign_in_at, u.session_version,\n t.token_hash, t.user_id, t.expires_at, t.redeemed_at,\n b.user_id, b.window_start, b.sent, s.id, s.window_start, s.admitted\n FROM auth_users u LEFT JOIN auth_tokens t ON 0 LEFT JOIN auth_link_budget b ON 0\n LEFT JOIN auth_sign_in_budget s ON 0 LIMIT 1`)\n .first();\n return typeof stamp?.applied === 'number' && Number.isSafeInteger(stamp.applied) && stamp.applied >= 0\n ? stamp.applied\n : null;\n });\n}\n\nexport function readAccount(db: D1Like, userId: string): Promise<AccountRow | null> {\n return databaseOperation(() => db.prepare(SQL.account).bind(userId).first<AccountRow>());\n}\n\n/** Removes the account; FK cascades take its tokens and send budget. Idempotent. */\nexport async function deleteAccount(db: D1Like, userId: string): Promise<void> {\n await databaseOperation(() => db.prepare(SQL.deleteAccount).bind(userId).run());\n}\n\n/** The scheduled sweep: expired links, closed send windows, then never-verified accounts left unreferenced. */\nexport async function sweep(db: D1Like, nowMs: number): Promise<number> {\n const results = await databaseOperation(() =>\n db.batch([\n db.prepare(SQL.sweepTokens).bind(nowMs),\n db.prepare(SQL.sweepBudget).bind(nowMs - LINK_WINDOW_MS),\n db.prepare(SQL.sweepUnverified),\n ]),\n );\n return results.reduce((total, result) => total + (result.meta?.changes ?? 0), 0);\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"store.cjs","names":["databaseOperation","require_errors","LINK_TTL_MS","LINK_WINDOW_MS","SIGN_IN_WINDOW_MS","SQL","row","db","email","nowMs","open","windowFloor","userId","reopensAt","tokenHash","subject","version","table","expected","actual","column","name","stamp","total","result"],"sources":["../../src/auth/store.ts"],"sourcesContent":["import type { D1Like } from '../worker/env';\nimport { databaseOperation } from './errors';\n\n/**\n * The issuer's storage, written against exactly `migrations/auth/0001_auth.sql`.\n * Token rows are single-use credentials only; the send allowance lives in its own\n * durable table, so consuming or expiring a link never refunds the budget.\n */\nexport const LINK_TTL_MS = 15 * 60 * 1000;\nexport const LINK_WINDOW_MS = 60 * 60 * 1000;\nexport const LINKS_PER_WINDOW = 3;\nexport const SIGN_IN_WINDOW_MS = 60_000;\nexport const SIGN_INS_PER_WINDOW = 20;\n\nexport interface AccountRow {\n created_at: string;\n email: string;\n id: string;\n last_sign_in_at: string | null;\n session_version: number;\n}\n\nconst SQL = {\n account: 'SELECT id, email, created_at, last_sign_in_at, session_version FROM auth_users WHERE id = ?',\n budgetWindow: 'SELECT window_start FROM auth_link_budget WHERE user_id = ?',\n consumeToken: `UPDATE auth_tokens SET redeemed_at = ?1 WHERE token_hash = ?2 AND expires_at > ?3\nRETURNING user_id AS id, (SELECT email FROM auth_users WHERE id = user_id) AS email,\n (SELECT session_version FROM auth_users WHERE id = user_id) AS sessionVersion`,\n deleteAccount: 'DELETE FROM auth_users WHERE id = ?',\n deleteToken: 'DELETE FROM auth_tokens WHERE token_hash = ?',\n // The no-op update makes RETURNING answer for an existing row too.\n ensureUser:\n 'INSERT INTO auth_users (id, email, created_at, last_sign_in_at) VALUES (?, ?, ?, NULL) ON CONFLICT (email) DO UPDATE SET email = excluded.email RETURNING id, email',\n insertToken: 'INSERT INTO auth_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)',\n revokeSessions: `UPDATE auth_users SET session_version = session_version + 1\nWHERE id = ? AND session_version = ? RETURNING session_version`,\n signInWindow: 'SELECT window_start FROM auth_sign_in_budget WHERE id = 1',\n // One atomic statement opens a window, counts within it, or refuses: no row back\n // means the allowance is spent. Concurrent requests cannot both pass the bound.\n spendBudget: `INSERT INTO auth_link_budget (user_id, window_start, sent) VALUES (?1, ?2, 1)\nON CONFLICT (user_id) DO UPDATE SET\n window_start = CASE WHEN auth_link_budget.window_start <= ?3 THEN ?2 ELSE auth_link_budget.window_start END,\n sent = CASE WHEN auth_link_budget.window_start <= ?3 THEN 1 ELSE auth_link_budget.sent + 1 END\nWHERE auth_link_budget.window_start <= ?3 OR auth_link_budget.sent < ?4\nRETURNING sent`,\n spendSignInBudget: `INSERT INTO auth_sign_in_budget (id, window_start, admitted) VALUES (1, ?1, 1)\nON CONFLICT (id) DO UPDATE SET\n window_start = CASE WHEN auth_sign_in_budget.window_start <= ?2 THEN ?1 ELSE auth_sign_in_budget.window_start END,\n admitted = CASE WHEN auth_sign_in_budget.window_start <= ?2 THEN 1 ELSE auth_sign_in_budget.admitted + 1 END\nWHERE auth_sign_in_budget.window_start <= ?2 OR auth_sign_in_budget.admitted < ?3\nRETURNING admitted`,\n sweepBudget: 'DELETE FROM auth_link_budget WHERE window_start <= ?',\n sweepTokens: 'DELETE FROM auth_tokens WHERE expires_at <= ?',\n // Rows for emails that never completed a sign-in leave once nothing references them.\n sweepUnverified:\n 'DELETE FROM auth_users WHERE last_sign_in_at IS NULL AND NOT EXISTS (SELECT 1 FROM auth_tokens t WHERE t.user_id = auth_users.id) AND NOT EXISTS (SELECT 1 FROM auth_link_budget b WHERE b.user_id = auth_users.id)',\n} as const;\n\nexport async function ensureUser(db: D1Like, email: string): Promise<{ email: string; id: string }> {\n const row = await databaseOperation(() =>\n db\n .prepare(SQL.ensureUser)\n .bind(crypto.randomUUID(), email, new Date().toISOString())\n .first<{ email: string; id: string }>(),\n );\n if (row === null) throw new Error('The account row could not be ensured.');\n return row;\n}\n\n/** The deployment-wide admission budget is spent before account creation or delivery. */\nexport async function spendSignInBudget(db: D1Like, nowMs: number): Promise<number | null> {\n return databaseOperation(async () => {\n const spent = await db\n .prepare(SQL.spendSignInBudget)\n .bind(nowMs, nowMs - SIGN_IN_WINDOW_MS, SIGN_INS_PER_WINDOW)\n .first<{ admitted: number }>();\n if (spent !== null) return null;\n const open = await db.prepare(SQL.signInWindow).first<{ window_start: number }>();\n return Math.max(1, Math.ceil(((open?.window_start ?? nowMs) + SIGN_IN_WINDOW_MS - nowMs) / 1000));\n });\n}\n\n/** Spends one send from the hourly allowance; returns the seconds until it reopens when spent. */\nexport async function spendLinkBudget(db: D1Like, userId: string, nowMs: number): Promise<number | null> {\n return databaseOperation(async () => {\n const windowFloor = nowMs - LINK_WINDOW_MS;\n const spent = await db\n .prepare(SQL.spendBudget)\n .bind(userId, nowMs, windowFloor, LINKS_PER_WINDOW)\n .first<{ sent: number }>();\n if (spent !== null) return null;\n const open = await db.prepare(SQL.budgetWindow).bind(userId).first<{ window_start: number }>();\n const reopensAt = (open?.window_start ?? nowMs) + LINK_WINDOW_MS;\n return Math.max(1, Math.ceil((reopensAt - nowMs) / 1000));\n });\n}\n\nexport async function insertToken(db: D1Like, tokenHash: string, userId: string, nowMs: number): Promise<void> {\n await databaseOperation(() =>\n db\n .prepare(SQL.insertToken)\n .bind(tokenHash, userId, nowMs + LINK_TTL_MS)\n .run(),\n );\n}\n\nexport async function deleteToken(db: D1Like, tokenHash: string): Promise<void> {\n await databaseOperation(() => db.prepare(SQL.deleteToken).bind(tokenHash).run());\n}\n\n/** Single-use consume: the unexpired row answers once and is gone after; the account is stamped signed in. */\nexport async function consumeToken(\n db: D1Like,\n tokenHash: string,\n nowMs: number,\n): Promise<{ email: string; id: string; sessionVersion: number } | null> {\n return databaseOperation(() =>\n db\n .prepare(SQL.consumeToken)\n .bind(new Date(nowMs).toISOString(), tokenHash, nowMs)\n .first<{ email: string; id: string; sessionVersion: number }>(),\n );\n}\n\n/** The trigger deletes outstanding links within this same atomic version change. */\nexport function revokeSessions(\n db: D1Like,\n subject: string,\n version: number,\n): Promise<{ session_version: number } | null> {\n return databaseOperation(() =>\n db.prepare(SQL.revokeSessions).bind(subject, version).first<{ session_version: number }>(),\n );\n}\n\nexport async function authReadiness(db: D1Like): Promise<number | null> {\n return databaseOperation(async () => {\n const columns = {\n auth_link_budget: ['user_id', 'window_start', 'sent'],\n auth_schema_meta: ['id', 'applied'],\n auth_sign_in_budget: ['id', 'window_start', 'admitted'],\n auth_tokens: ['token_hash', 'user_id', 'expires_at', 'redeemed_at'],\n auth_users: ['id', 'email', 'created_at', 'last_sign_in_at', 'session_version'],\n } as const;\n for (const [table, expected] of Object.entries(columns)) {\n const actual = await db.prepare(`PRAGMA table_info(${table})`).all<{ name: string }>();\n if (!expected.every((name) => actual.results?.some((column) => column.name === name))) return null;\n }\n const triggers = await db\n .prepare(\n \"SELECT name FROM sqlite_master WHERE type = 'trigger' AND name IN ('auth_token_redeemed', 'auth_sessions_revoked')\",\n )\n .all<{ name: string }>();\n if (triggers.results?.length !== 2) return null;\n const stamp = await db.prepare('SELECT applied FROM auth_schema_meta WHERE id = 1').first<{ applied: number }>();\n await db\n .prepare(`SELECT u.id, u.email, u.created_at, u.last_sign_in_at, u.session_version,\n t.token_hash, t.user_id, t.expires_at, t.redeemed_at,\n b.user_id, b.window_start, b.sent, s.id, s.window_start, s.admitted\n FROM auth_users u LEFT JOIN auth_tokens t ON 0 LEFT JOIN auth_link_budget b ON 0\n LEFT JOIN auth_sign_in_budget s ON 0 LIMIT 1`)\n .first();\n return typeof stamp?.applied === 'number' && Number.isSafeInteger(stamp.applied) && stamp.applied >= 0\n ? stamp.applied\n : null;\n });\n}\n\nexport function readAccount(db: D1Like, userId: string): Promise<AccountRow | null> {\n return databaseOperation(() => db.prepare(SQL.account).bind(userId).first<AccountRow>());\n}\n\n/** Removes the account; FK cascades take its tokens and send budget. Idempotent. */\nexport async function deleteAccount(db: D1Like, userId: string): Promise<void> {\n await databaseOperation(() => db.prepare(SQL.deleteAccount).bind(userId).run());\n}\n\n/** The scheduled sweep: expired links, closed send windows, then never-verified accounts left unreferenced. */\nexport async function sweep(db: D1Like, nowMs: number): Promise<number> {\n const results = await databaseOperation(() =>\n db.batch([\n db.prepare(SQL.sweepTokens).bind(nowMs),\n db.prepare(SQL.sweepBudget).bind(nowMs - LINK_WINDOW_MS),\n db.prepare(SQL.sweepUnverified),\n ]),\n );\n return results.reduce((total, result) => total + (result.meta?.changes ?? 0), 0);\n}\n"],"mappings":"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"}
|
package/dist/auth/store.js
CHANGED
|
@@ -1,19 +1,103 @@
|
|
|
1
|
-
import{databaseOperation as e}from"./errors.js";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
1
|
+
import { databaseOperation as e } from "./errors.js";
|
|
2
|
+
//#region src/auth/store.ts
|
|
3
|
+
var t = 9e5, n = 36e5, r = 6e4, i = {
|
|
4
|
+
account: "SELECT id, email, created_at, last_sign_in_at, session_version FROM auth_users WHERE id = ?",
|
|
5
|
+
budgetWindow: "SELECT window_start FROM auth_link_budget WHERE user_id = ?",
|
|
6
|
+
consumeToken: "UPDATE auth_tokens SET redeemed_at = ?1 WHERE token_hash = ?2 AND expires_at > ?3\nRETURNING user_id AS id, (SELECT email FROM auth_users WHERE id = user_id) AS email,\n (SELECT session_version FROM auth_users WHERE id = user_id) AS sessionVersion",
|
|
7
|
+
deleteAccount: "DELETE FROM auth_users WHERE id = ?",
|
|
8
|
+
deleteToken: "DELETE FROM auth_tokens WHERE token_hash = ?",
|
|
9
|
+
ensureUser: "INSERT INTO auth_users (id, email, created_at, last_sign_in_at) VALUES (?, ?, ?, NULL) ON CONFLICT (email) DO UPDATE SET email = excluded.email RETURNING id, email",
|
|
10
|
+
insertToken: "INSERT INTO auth_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)",
|
|
11
|
+
revokeSessions: "UPDATE auth_users SET session_version = session_version + 1\nWHERE id = ? AND session_version = ? RETURNING session_version",
|
|
12
|
+
signInWindow: "SELECT window_start FROM auth_sign_in_budget WHERE id = 1",
|
|
13
|
+
spendBudget: "INSERT INTO auth_link_budget (user_id, window_start, sent) VALUES (?1, ?2, 1)\nON CONFLICT (user_id) DO UPDATE SET\n window_start = CASE WHEN auth_link_budget.window_start <= ?3 THEN ?2 ELSE auth_link_budget.window_start END,\n sent = CASE WHEN auth_link_budget.window_start <= ?3 THEN 1 ELSE auth_link_budget.sent + 1 END\nWHERE auth_link_budget.window_start <= ?3 OR auth_link_budget.sent < ?4\nRETURNING sent",
|
|
14
|
+
spendSignInBudget: "INSERT INTO auth_sign_in_budget (id, window_start, admitted) VALUES (1, ?1, 1)\nON CONFLICT (id) DO UPDATE SET\n window_start = CASE WHEN auth_sign_in_budget.window_start <= ?2 THEN ?1 ELSE auth_sign_in_budget.window_start END,\n admitted = CASE WHEN auth_sign_in_budget.window_start <= ?2 THEN 1 ELSE auth_sign_in_budget.admitted + 1 END\nWHERE auth_sign_in_budget.window_start <= ?2 OR auth_sign_in_budget.admitted < ?3\nRETURNING admitted",
|
|
15
|
+
sweepBudget: "DELETE FROM auth_link_budget WHERE window_start <= ?",
|
|
16
|
+
sweepTokens: "DELETE FROM auth_tokens WHERE expires_at <= ?",
|
|
17
|
+
sweepUnverified: "DELETE FROM auth_users WHERE last_sign_in_at IS NULL AND NOT EXISTS (SELECT 1 FROM auth_tokens t WHERE t.user_id = auth_users.id) AND NOT EXISTS (SELECT 1 FROM auth_link_budget b WHERE b.user_id = auth_users.id)"
|
|
18
|
+
};
|
|
19
|
+
async function ensureUser(t, n) {
|
|
20
|
+
let r = await e(() => t.prepare(i.ensureUser).bind(crypto.randomUUID(), n, (/* @__PURE__ */ new Date()).toISOString()).first());
|
|
21
|
+
if (r === null) throw Error("The account row could not be ensured.");
|
|
22
|
+
return r;
|
|
23
|
+
}
|
|
24
|
+
async function spendSignInBudget(t, n) {
|
|
25
|
+
return e(async () => {
|
|
26
|
+
if (await t.prepare(i.spendSignInBudget).bind(n, n - 6e4, 20).first() !== null) return null;
|
|
27
|
+
let e = await t.prepare(i.signInWindow).first();
|
|
28
|
+
return Math.max(1, Math.ceil(((e?.window_start ?? n) + r - n) / 1e3));
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
async function spendLinkBudget(t, r, a) {
|
|
32
|
+
return e(async () => {
|
|
33
|
+
let e = a - n;
|
|
34
|
+
if (await t.prepare(i.spendBudget).bind(r, a, e, 3).first() !== null) return null;
|
|
35
|
+
let o = ((await t.prepare(i.budgetWindow).bind(r).first())?.window_start ?? a) + n;
|
|
36
|
+
return Math.max(1, Math.ceil((o - a) / 1e3));
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
async function insertToken(n, r, a, o) {
|
|
40
|
+
await e(() => n.prepare(i.insertToken).bind(r, a, o + t).run());
|
|
41
|
+
}
|
|
42
|
+
async function deleteToken(t, n) {
|
|
43
|
+
await e(() => t.prepare(i.deleteToken).bind(n).run());
|
|
44
|
+
}
|
|
45
|
+
async function consumeToken(t, n, r) {
|
|
46
|
+
return e(() => t.prepare(i.consumeToken).bind(new Date(r).toISOString(), n, r).first());
|
|
47
|
+
}
|
|
48
|
+
function revokeSessions(t, n, r) {
|
|
49
|
+
return e(() => t.prepare(i.revokeSessions).bind(n, r).first());
|
|
50
|
+
}
|
|
51
|
+
async function authReadiness(t) {
|
|
52
|
+
return e(async () => {
|
|
53
|
+
for (let [e, n] of Object.entries({
|
|
54
|
+
auth_link_budget: [
|
|
55
|
+
"user_id",
|
|
56
|
+
"window_start",
|
|
57
|
+
"sent"
|
|
58
|
+
],
|
|
59
|
+
auth_schema_meta: ["id", "applied"],
|
|
60
|
+
auth_sign_in_budget: [
|
|
61
|
+
"id",
|
|
62
|
+
"window_start",
|
|
63
|
+
"admitted"
|
|
64
|
+
],
|
|
65
|
+
auth_tokens: [
|
|
66
|
+
"token_hash",
|
|
67
|
+
"user_id",
|
|
68
|
+
"expires_at",
|
|
69
|
+
"redeemed_at"
|
|
70
|
+
],
|
|
71
|
+
auth_users: [
|
|
72
|
+
"id",
|
|
73
|
+
"email",
|
|
74
|
+
"created_at",
|
|
75
|
+
"last_sign_in_at",
|
|
76
|
+
"session_version"
|
|
77
|
+
]
|
|
78
|
+
})) {
|
|
79
|
+
let r = await t.prepare(`PRAGMA table_info(${e})`).all();
|
|
80
|
+
if (!n.every((e) => r.results?.some((t) => t.name === e))) return null;
|
|
81
|
+
}
|
|
82
|
+
if ((await t.prepare("SELECT name FROM sqlite_master WHERE type = 'trigger' AND name IN ('auth_token_redeemed', 'auth_sessions_revoked')").all()).results?.length !== 2) return null;
|
|
83
|
+
let e = await t.prepare("SELECT applied FROM auth_schema_meta WHERE id = 1").first();
|
|
84
|
+
return await t.prepare("SELECT u.id, u.email, u.created_at, u.last_sign_in_at, u.session_version,\n t.token_hash, t.user_id, t.expires_at, t.redeemed_at,\n b.user_id, b.window_start, b.sent, s.id, s.window_start, s.admitted\n FROM auth_users u LEFT JOIN auth_tokens t ON 0 LEFT JOIN auth_link_budget b ON 0\n LEFT JOIN auth_sign_in_budget s ON 0 LIMIT 1").first(), typeof e?.applied == "number" && Number.isSafeInteger(e.applied) && e.applied >= 0 ? e.applied : null;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function readAccount(t, n) {
|
|
88
|
+
return e(() => t.prepare(i.account).bind(n).first());
|
|
89
|
+
}
|
|
90
|
+
async function deleteAccount(t, n) {
|
|
91
|
+
await e(() => t.prepare(i.deleteAccount).bind(n).run());
|
|
92
|
+
}
|
|
93
|
+
async function sweep(t, r) {
|
|
94
|
+
return (await e(() => t.batch([
|
|
95
|
+
t.prepare(i.sweepTokens).bind(r),
|
|
96
|
+
t.prepare(i.sweepBudget).bind(r - n),
|
|
97
|
+
t.prepare(i.sweepUnverified)
|
|
98
|
+
]))).reduce((e, t) => e + (t.meta?.changes ?? 0), 0);
|
|
99
|
+
}
|
|
100
|
+
//#endregion
|
|
101
|
+
export { t as LINK_TTL_MS, n as LINK_WINDOW_MS, r as SIGN_IN_WINDOW_MS, authReadiness, consumeToken, deleteAccount, deleteToken, ensureUser, insertToken, readAccount, revokeSessions, spendLinkBudget, spendSignInBudget, sweep };
|
|
102
|
+
|
|
19
103
|
//# sourceMappingURL=store.js.map
|
package/dist/auth/store.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"store.js","names":[],"sources":["../../src/auth/store.ts"],"sourcesContent":["import type { D1Like } from '../worker/env';\nimport { databaseOperation } from './errors';\n\n/**\n * The issuer's storage, written against exactly `migrations/auth/0001_auth.sql`.\n * Token rows are single-use credentials only; the send allowance lives in its own\n * durable table, so consuming or expiring a link never refunds the budget.\n */\nexport const LINK_TTL_MS = 15 * 60 * 1000;\nexport const LINK_WINDOW_MS = 60 * 60 * 1000;\nexport const LINKS_PER_WINDOW = 3;\nexport const SIGN_IN_WINDOW_MS = 60_000;\nexport const SIGN_INS_PER_WINDOW = 20;\n\nexport interface AccountRow {\n created_at: string;\n email: string;\n id: string;\n last_sign_in_at: string | null;\n session_version: number;\n}\n\nconst SQL = {\n account: 'SELECT id, email, created_at, last_sign_in_at, session_version FROM auth_users WHERE id = ?',\n budgetWindow: 'SELECT window_start FROM auth_link_budget WHERE user_id = ?',\n consumeToken: `UPDATE auth_tokens SET redeemed_at = ?1 WHERE token_hash = ?2 AND expires_at > ?3\nRETURNING user_id AS id, (SELECT email FROM auth_users WHERE id = user_id) AS email,\n (SELECT session_version FROM auth_users WHERE id = user_id) AS sessionVersion`,\n deleteAccount: 'DELETE FROM auth_users WHERE id = ?',\n deleteToken: 'DELETE FROM auth_tokens WHERE token_hash = ?',\n // The no-op update makes RETURNING answer for an existing row too.\n ensureUser:\n 'INSERT INTO auth_users (id, email, created_at, last_sign_in_at) VALUES (?, ?, ?, NULL) ON CONFLICT (email) DO UPDATE SET email = excluded.email RETURNING id, email',\n insertToken: 'INSERT INTO auth_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)',\n revokeSessions: `UPDATE auth_users SET session_version = session_version + 1\nWHERE id = ? AND session_version = ? RETURNING session_version`,\n signInWindow: 'SELECT window_start FROM auth_sign_in_budget WHERE id = 1',\n // One atomic statement opens a window, counts within it, or refuses: no row back\n // means the allowance is spent. Concurrent requests cannot both pass the bound.\n spendBudget: `INSERT INTO auth_link_budget (user_id, window_start, sent) VALUES (?1, ?2, 1)\nON CONFLICT (user_id) DO UPDATE SET\n window_start = CASE WHEN auth_link_budget.window_start <= ?3 THEN ?2 ELSE auth_link_budget.window_start END,\n sent = CASE WHEN auth_link_budget.window_start <= ?3 THEN 1 ELSE auth_link_budget.sent + 1 END\nWHERE auth_link_budget.window_start <= ?3 OR auth_link_budget.sent < ?4\nRETURNING sent`,\n spendSignInBudget: `INSERT INTO auth_sign_in_budget (id, window_start, admitted) VALUES (1, ?1, 1)\nON CONFLICT (id) DO UPDATE SET\n window_start = CASE WHEN auth_sign_in_budget.window_start <= ?2 THEN ?1 ELSE auth_sign_in_budget.window_start END,\n admitted = CASE WHEN auth_sign_in_budget.window_start <= ?2 THEN 1 ELSE auth_sign_in_budget.admitted + 1 END\nWHERE auth_sign_in_budget.window_start <= ?2 OR auth_sign_in_budget.admitted < ?3\nRETURNING admitted`,\n sweepBudget: 'DELETE FROM auth_link_budget WHERE window_start <= ?',\n sweepTokens: 'DELETE FROM auth_tokens WHERE expires_at <= ?',\n // Rows for emails that never completed a sign-in leave once nothing references them.\n sweepUnverified:\n 'DELETE FROM auth_users WHERE last_sign_in_at IS NULL AND NOT EXISTS (SELECT 1 FROM auth_tokens t WHERE t.user_id = auth_users.id) AND NOT EXISTS (SELECT 1 FROM auth_link_budget b WHERE b.user_id = auth_users.id)',\n} as const;\n\nexport async function ensureUser(db: D1Like, email: string): Promise<{ email: string; id: string }> {\n const row = await databaseOperation(() =>\n db\n .prepare(SQL.ensureUser)\n .bind(crypto.randomUUID(), email, new Date().toISOString())\n .first<{ email: string; id: string }>(),\n );\n if (row === null) throw new Error('The account row could not be ensured.');\n return row;\n}\n\n/** The deployment-wide admission budget is spent before account creation or delivery. */\nexport async function spendSignInBudget(db: D1Like, nowMs: number): Promise<number | null> {\n return databaseOperation(async () => {\n const spent = await db\n .prepare(SQL.spendSignInBudget)\n .bind(nowMs, nowMs - SIGN_IN_WINDOW_MS, SIGN_INS_PER_WINDOW)\n .first<{ admitted: number }>();\n if (spent !== null) return null;\n const open = await db.prepare(SQL.signInWindow).first<{ window_start: number }>();\n return Math.max(1, Math.ceil(((open?.window_start ?? nowMs) + SIGN_IN_WINDOW_MS - nowMs) / 1000));\n });\n}\n\n/** Spends one send from the hourly allowance; returns the seconds until it reopens when spent. */\nexport async function spendLinkBudget(db: D1Like, userId: string, nowMs: number): Promise<number | null> {\n return databaseOperation(async () => {\n const windowFloor = nowMs - LINK_WINDOW_MS;\n const spent = await db\n .prepare(SQL.spendBudget)\n .bind(userId, nowMs, windowFloor, LINKS_PER_WINDOW)\n .first<{ sent: number }>();\n if (spent !== null) return null;\n const open = await db.prepare(SQL.budgetWindow).bind(userId).first<{ window_start: number }>();\n const reopensAt = (open?.window_start ?? nowMs) + LINK_WINDOW_MS;\n return Math.max(1, Math.ceil((reopensAt - nowMs) / 1000));\n });\n}\n\nexport async function insertToken(db: D1Like, tokenHash: string, userId: string, nowMs: number): Promise<void> {\n await databaseOperation(() =>\n db\n .prepare(SQL.insertToken)\n .bind(tokenHash, userId, nowMs + LINK_TTL_MS)\n .run(),\n );\n}\n\nexport async function deleteToken(db: D1Like, tokenHash: string): Promise<void> {\n await databaseOperation(() => db.prepare(SQL.deleteToken).bind(tokenHash).run());\n}\n\n/** Single-use consume: the unexpired row answers once and is gone after; the account is stamped signed in. */\nexport async function consumeToken(\n db: D1Like,\n tokenHash: string,\n nowMs: number,\n): Promise<{ email: string; id: string; sessionVersion: number } | null> {\n return databaseOperation(() =>\n db\n .prepare(SQL.consumeToken)\n .bind(new Date(nowMs).toISOString(), tokenHash, nowMs)\n .first<{ email: string; id: string; sessionVersion: number }>(),\n );\n}\n\n/** The trigger deletes outstanding links within this same atomic version change. */\nexport function revokeSessions(\n db: D1Like,\n subject: string,\n version: number,\n): Promise<{ session_version: number } | null> {\n return databaseOperation(() =>\n db.prepare(SQL.revokeSessions).bind(subject, version).first<{ session_version: number }>(),\n );\n}\n\nexport async function authReadiness(db: D1Like): Promise<number | null> {\n return databaseOperation(async () => {\n const columns = {\n auth_link_budget: ['user_id', 'window_start', 'sent'],\n auth_schema_meta: ['id', 'applied'],\n auth_sign_in_budget: ['id', 'window_start', 'admitted'],\n auth_tokens: ['token_hash', 'user_id', 'expires_at', 'redeemed_at'],\n auth_users: ['id', 'email', 'created_at', 'last_sign_in_at', 'session_version'],\n } as const;\n for (const [table, expected] of Object.entries(columns)) {\n const actual = await db.prepare(`PRAGMA table_info(${table})`).all<{ name: string }>();\n if (!expected.every((name) => actual.results?.some((column) => column.name === name))) return null;\n }\n const triggers = await db\n .prepare(\n \"SELECT name FROM sqlite_master WHERE type = 'trigger' AND name IN ('auth_token_redeemed', 'auth_sessions_revoked')\",\n )\n .all<{ name: string }>();\n if (triggers.results?.length !== 2) return null;\n const stamp = await db.prepare('SELECT applied FROM auth_schema_meta WHERE id = 1').first<{ applied: number }>();\n await db\n .prepare(`SELECT u.id, u.email, u.created_at, u.last_sign_in_at, u.session_version,\n t.token_hash, t.user_id, t.expires_at, t.redeemed_at,\n b.user_id, b.window_start, b.sent, s.id, s.window_start, s.admitted\n FROM auth_users u LEFT JOIN auth_tokens t ON 0 LEFT JOIN auth_link_budget b ON 0\n LEFT JOIN auth_sign_in_budget s ON 0 LIMIT 1`)\n .first();\n return typeof stamp?.applied === 'number' && Number.isSafeInteger(stamp.applied) && stamp.applied >= 0\n ? stamp.applied\n : null;\n });\n}\n\nexport function readAccount(db: D1Like, userId: string): Promise<AccountRow | null> {\n return databaseOperation(() => db.prepare(SQL.account).bind(userId).first<AccountRow>());\n}\n\n/** Removes the account; FK cascades take its tokens and send budget. Idempotent. */\nexport async function deleteAccount(db: D1Like, userId: string): Promise<void> {\n await databaseOperation(() => db.prepare(SQL.deleteAccount).bind(userId).run());\n}\n\n/** The scheduled sweep: expired links, closed send windows, then never-verified accounts left unreferenced. */\nexport async function sweep(db: D1Like, nowMs: number): Promise<number> {\n const results = await databaseOperation(() =>\n db.batch([\n db.prepare(SQL.sweepTokens).bind(nowMs),\n db.prepare(SQL.sweepBudget).bind(nowMs - LINK_WINDOW_MS),\n db.prepare(SQL.sweepUnverified),\n ]),\n );\n return results.reduce((total, result) => total + (result.meta?.changes ?? 0), 0);\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"store.js","names":["LINK_TTL_MS","LINK_WINDOW_MS","SIGN_IN_WINDOW_MS","SQL","row","databaseOperation","db","email","nowMs","open","windowFloor","userId","reopensAt","tokenHash","subject","version","table","expected","actual","column","name","stamp","total","result"],"sources":["../../src/auth/store.ts"],"sourcesContent":["import type { D1Like } from '../worker/env';\nimport { databaseOperation } from './errors';\n\n/**\n * The issuer's storage, written against exactly `migrations/auth/0001_auth.sql`.\n * Token rows are single-use credentials only; the send allowance lives in its own\n * durable table, so consuming or expiring a link never refunds the budget.\n */\nexport const LINK_TTL_MS = 15 * 60 * 1000;\nexport const LINK_WINDOW_MS = 60 * 60 * 1000;\nexport const LINKS_PER_WINDOW = 3;\nexport const SIGN_IN_WINDOW_MS = 60_000;\nexport const SIGN_INS_PER_WINDOW = 20;\n\nexport interface AccountRow {\n created_at: string;\n email: string;\n id: string;\n last_sign_in_at: string | null;\n session_version: number;\n}\n\nconst SQL = {\n account: 'SELECT id, email, created_at, last_sign_in_at, session_version FROM auth_users WHERE id = ?',\n budgetWindow: 'SELECT window_start FROM auth_link_budget WHERE user_id = ?',\n consumeToken: `UPDATE auth_tokens SET redeemed_at = ?1 WHERE token_hash = ?2 AND expires_at > ?3\nRETURNING user_id AS id, (SELECT email FROM auth_users WHERE id = user_id) AS email,\n (SELECT session_version FROM auth_users WHERE id = user_id) AS sessionVersion`,\n deleteAccount: 'DELETE FROM auth_users WHERE id = ?',\n deleteToken: 'DELETE FROM auth_tokens WHERE token_hash = ?',\n // The no-op update makes RETURNING answer for an existing row too.\n ensureUser:\n 'INSERT INTO auth_users (id, email, created_at, last_sign_in_at) VALUES (?, ?, ?, NULL) ON CONFLICT (email) DO UPDATE SET email = excluded.email RETURNING id, email',\n insertToken: 'INSERT INTO auth_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)',\n revokeSessions: `UPDATE auth_users SET session_version = session_version + 1\nWHERE id = ? AND session_version = ? RETURNING session_version`,\n signInWindow: 'SELECT window_start FROM auth_sign_in_budget WHERE id = 1',\n // One atomic statement opens a window, counts within it, or refuses: no row back\n // means the allowance is spent. Concurrent requests cannot both pass the bound.\n spendBudget: `INSERT INTO auth_link_budget (user_id, window_start, sent) VALUES (?1, ?2, 1)\nON CONFLICT (user_id) DO UPDATE SET\n window_start = CASE WHEN auth_link_budget.window_start <= ?3 THEN ?2 ELSE auth_link_budget.window_start END,\n sent = CASE WHEN auth_link_budget.window_start <= ?3 THEN 1 ELSE auth_link_budget.sent + 1 END\nWHERE auth_link_budget.window_start <= ?3 OR auth_link_budget.sent < ?4\nRETURNING sent`,\n spendSignInBudget: `INSERT INTO auth_sign_in_budget (id, window_start, admitted) VALUES (1, ?1, 1)\nON CONFLICT (id) DO UPDATE SET\n window_start = CASE WHEN auth_sign_in_budget.window_start <= ?2 THEN ?1 ELSE auth_sign_in_budget.window_start END,\n admitted = CASE WHEN auth_sign_in_budget.window_start <= ?2 THEN 1 ELSE auth_sign_in_budget.admitted + 1 END\nWHERE auth_sign_in_budget.window_start <= ?2 OR auth_sign_in_budget.admitted < ?3\nRETURNING admitted`,\n sweepBudget: 'DELETE FROM auth_link_budget WHERE window_start <= ?',\n sweepTokens: 'DELETE FROM auth_tokens WHERE expires_at <= ?',\n // Rows for emails that never completed a sign-in leave once nothing references them.\n sweepUnverified:\n 'DELETE FROM auth_users WHERE last_sign_in_at IS NULL AND NOT EXISTS (SELECT 1 FROM auth_tokens t WHERE t.user_id = auth_users.id) AND NOT EXISTS (SELECT 1 FROM auth_link_budget b WHERE b.user_id = auth_users.id)',\n} as const;\n\nexport async function ensureUser(db: D1Like, email: string): Promise<{ email: string; id: string }> {\n const row = await databaseOperation(() =>\n db\n .prepare(SQL.ensureUser)\n .bind(crypto.randomUUID(), email, new Date().toISOString())\n .first<{ email: string; id: string }>(),\n );\n if (row === null) throw new Error('The account row could not be ensured.');\n return row;\n}\n\n/** The deployment-wide admission budget is spent before account creation or delivery. */\nexport async function spendSignInBudget(db: D1Like, nowMs: number): Promise<number | null> {\n return databaseOperation(async () => {\n const spent = await db\n .prepare(SQL.spendSignInBudget)\n .bind(nowMs, nowMs - SIGN_IN_WINDOW_MS, SIGN_INS_PER_WINDOW)\n .first<{ admitted: number }>();\n if (spent !== null) return null;\n const open = await db.prepare(SQL.signInWindow).first<{ window_start: number }>();\n return Math.max(1, Math.ceil(((open?.window_start ?? nowMs) + SIGN_IN_WINDOW_MS - nowMs) / 1000));\n });\n}\n\n/** Spends one send from the hourly allowance; returns the seconds until it reopens when spent. */\nexport async function spendLinkBudget(db: D1Like, userId: string, nowMs: number): Promise<number | null> {\n return databaseOperation(async () => {\n const windowFloor = nowMs - LINK_WINDOW_MS;\n const spent = await db\n .prepare(SQL.spendBudget)\n .bind(userId, nowMs, windowFloor, LINKS_PER_WINDOW)\n .first<{ sent: number }>();\n if (spent !== null) return null;\n const open = await db.prepare(SQL.budgetWindow).bind(userId).first<{ window_start: number }>();\n const reopensAt = (open?.window_start ?? nowMs) + LINK_WINDOW_MS;\n return Math.max(1, Math.ceil((reopensAt - nowMs) / 1000));\n });\n}\n\nexport async function insertToken(db: D1Like, tokenHash: string, userId: string, nowMs: number): Promise<void> {\n await databaseOperation(() =>\n db\n .prepare(SQL.insertToken)\n .bind(tokenHash, userId, nowMs + LINK_TTL_MS)\n .run(),\n );\n}\n\nexport async function deleteToken(db: D1Like, tokenHash: string): Promise<void> {\n await databaseOperation(() => db.prepare(SQL.deleteToken).bind(tokenHash).run());\n}\n\n/** Single-use consume: the unexpired row answers once and is gone after; the account is stamped signed in. */\nexport async function consumeToken(\n db: D1Like,\n tokenHash: string,\n nowMs: number,\n): Promise<{ email: string; id: string; sessionVersion: number } | null> {\n return databaseOperation(() =>\n db\n .prepare(SQL.consumeToken)\n .bind(new Date(nowMs).toISOString(), tokenHash, nowMs)\n .first<{ email: string; id: string; sessionVersion: number }>(),\n );\n}\n\n/** The trigger deletes outstanding links within this same atomic version change. */\nexport function revokeSessions(\n db: D1Like,\n subject: string,\n version: number,\n): Promise<{ session_version: number } | null> {\n return databaseOperation(() =>\n db.prepare(SQL.revokeSessions).bind(subject, version).first<{ session_version: number }>(),\n );\n}\n\nexport async function authReadiness(db: D1Like): Promise<number | null> {\n return databaseOperation(async () => {\n const columns = {\n auth_link_budget: ['user_id', 'window_start', 'sent'],\n auth_schema_meta: ['id', 'applied'],\n auth_sign_in_budget: ['id', 'window_start', 'admitted'],\n auth_tokens: ['token_hash', 'user_id', 'expires_at', 'redeemed_at'],\n auth_users: ['id', 'email', 'created_at', 'last_sign_in_at', 'session_version'],\n } as const;\n for (const [table, expected] of Object.entries(columns)) {\n const actual = await db.prepare(`PRAGMA table_info(${table})`).all<{ name: string }>();\n if (!expected.every((name) => actual.results?.some((column) => column.name === name))) return null;\n }\n const triggers = await db\n .prepare(\n \"SELECT name FROM sqlite_master WHERE type = 'trigger' AND name IN ('auth_token_redeemed', 'auth_sessions_revoked')\",\n )\n .all<{ name: string }>();\n if (triggers.results?.length !== 2) return null;\n const stamp = await db.prepare('SELECT applied FROM auth_schema_meta WHERE id = 1').first<{ applied: number }>();\n await db\n .prepare(`SELECT u.id, u.email, u.created_at, u.last_sign_in_at, u.session_version,\n t.token_hash, t.user_id, t.expires_at, t.redeemed_at,\n b.user_id, b.window_start, b.sent, s.id, s.window_start, s.admitted\n FROM auth_users u LEFT JOIN auth_tokens t ON 0 LEFT JOIN auth_link_budget b ON 0\n LEFT JOIN auth_sign_in_budget s ON 0 LIMIT 1`)\n .first();\n return typeof stamp?.applied === 'number' && Number.isSafeInteger(stamp.applied) && stamp.applied >= 0\n ? stamp.applied\n : null;\n });\n}\n\nexport function readAccount(db: D1Like, userId: string): Promise<AccountRow | null> {\n return databaseOperation(() => db.prepare(SQL.account).bind(userId).first<AccountRow>());\n}\n\n/** Removes the account; FK cascades take its tokens and send budget. Idempotent. */\nexport async function deleteAccount(db: D1Like, userId: string): Promise<void> {\n await databaseOperation(() => db.prepare(SQL.deleteAccount).bind(userId).run());\n}\n\n/** The scheduled sweep: expired links, closed send windows, then never-verified accounts left unreferenced. */\nexport async function sweep(db: D1Like, nowMs: number): Promise<number> {\n const results = await databaseOperation(() =>\n db.batch([\n db.prepare(SQL.sweepTokens).bind(nowMs),\n db.prepare(SQL.sweepBudget).bind(nowMs - LINK_WINDOW_MS),\n db.prepare(SQL.sweepUnverified),\n ]),\n );\n return results.reduce((total, result) => total + (result.meta?.changes ?? 0), 0);\n}\n"],"mappings":"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"}
|