@krizaka/auth 0.13.2 → 0.13.3

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/README.md CHANGED
@@ -7,7 +7,17 @@ import { emitMigration } from "@krizaka/db";
7
7
  import { accountsSchema, dbAccounts } from "@krizaka/auth";
8
8
 
9
9
  emitMigration(accountsSchema({ backfillVerified: true }), 1, { tables: { accounts: "accounts" } }); // commit the SQL once, in your migrations (idempotent)
10
- const accounts = dbAccounts({ query, transaction, mail, baseUrl: "https://www.akinaza.com", product: "Akinaza", tokenPrefix: "ae", passwords: { hash, verify }, tokenHash, passwordResetTtlMinutes: 30 /* optional, 5–1440 */ }); // `transaction`: the work on one connection (BEGIN … COMMIT); the password reset needs it under concurrent requests
10
+ const accounts = dbAccounts({
11
+ query,
12
+ transaction,
13
+ mail,
14
+ baseUrl: "https://www.folioviv.com",
15
+ product: "Folioviv",
16
+ tokenPrefix: "ae",
17
+ passwords: { hash, verify },
18
+ tokenHash,
19
+ passwordResetTtlMinutes: 30 /* optional, 5–1440 */,
20
+ }); // `transaction`: the work on one connection (BEGIN … COMMIT); the password reset needs it under concurrent requests
11
21
  await accounts.updateProfile(id, { name: "Éric", theme: "dark" });
12
22
  await accounts.sessions(id, new Date(), currentSessionId);
13
23
  await accounts.requestPasswordReset(email, new Date()); // mails `<baseUrl>/<locale>/reset-password?token=…`
@@ -31,7 +41,11 @@ Each product keeps its own accounts; **krizaka-users** is the federation provide
31
41
  import { createFederatedSignIn, dbIdentityLinks, identityLinksSchema, usersIdentityProvider } from "@krizaka/auth";
32
42
 
33
43
  emitMigration(identityLinksSchema, 1, { tables: { accounts: "accounts" } }); // once, in your migrations (idempotent): (issuer, subject) → account
34
- const federation = usersIdentityProvider({ baseUrl: "https://auth.dev.akinaza.com", appUrl: "https://dev.akinaza.com", fetch });
44
+ const federation = usersIdentityProvider({
45
+ baseUrl: "https://auth.dev.folioviv.com",
46
+ appUrl: "https://dev.folioviv.com",
47
+ fetch,
48
+ });
35
49
  const signIn = createFederatedSignIn({ links: dbIdentityLinks(query), accounts: { findByEmail, create }, clock });
36
50
 
37
51
  const { url } = await federation.start("google", "/fr/account"); // redirect there
@@ -48,25 +62,52 @@ In the product's `proxy.ts` (Web `Request`/`Response`, no framework): `landingRe
48
62
 
49
63
  ## Sessions, passwords, addresses and the administration's locks
50
64
 
51
- What Yomizaka and Akinaza each copied (`password.ts`, `client-ip.ts`, `session.ts`, the admin gate of `access.ts`) is here once. Each piece is a function of its inputs: the product reads its environment and its framework, and passes values.
65
+ What Verikora and Folioviv each copied (`password.ts`, `client-ip.ts`, `session.ts`, the admin gate of `access.ts`) is here once. Each piece is a function of its inputs: the product reads its environment and its framework, and passes values.
52
66
 
53
67
  ```ts
54
- import { allowedIps, clientIp, createSessions, dbSessionStore, hashPassword, requireAdmin, sessionCookie, sessionTokenOf, verifyPassword } from "@krizaka/auth";
68
+ import {
69
+ allowedIps,
70
+ clientIp,
71
+ createSessions,
72
+ dbSessionStore,
73
+ hashPassword,
74
+ requireAdmin,
75
+ sessionCookie,
76
+ sessionTokenOf,
77
+ verifyPassword,
78
+ } from "@krizaka/auth";
55
79
 
56
80
  const sessions = createSessions({
57
- store: dbSessionStore(query, { columns: ["id", "email", "role", "locale"], toAccount: (row) => ({ id: String(row.id), email: String(row.email), role: row.role as "USER" | "ADMIN", locale: row.locale as "en" | "fr" }), active: { column: "status", equals: "ACTIVE" } }),
81
+ store: dbSessionStore(query, {
82
+ columns: ["id", "email", "role", "locale"],
83
+ toAccount: (row) => ({
84
+ id: String(row.id),
85
+ email: String(row.email),
86
+ role: row.role as "USER" | "ADMIN",
87
+ locale: row.locale as "en" | "fr",
88
+ }),
89
+ active: { column: "status", equals: "ACTIVE" },
90
+ }),
58
91
  clock: { now: () => new Date() },
59
- tokenPrefix: "ys", // tokens are `ys1.<43 characters>`
60
- secret: () => env.AUTH_SESSION_SECRET, // keys the stored hash (HMAC-SHA256)
92
+ tokenPrefix: "ys", // tokens are `ys1.<43 characters>`
93
+ secret: () => env.AUTH_SESSION_SECRET, // keys the stored hash (HMAC-SHA256)
61
94
  sessionDays: () => settings.get("auth.sessionDays"), // the setting, read for each new session
62
95
  });
63
96
 
64
- const ip = clientIp(request.headers, { header: env.ABUSE_CLIENT_IP_HEADER, trustedProxyHops: env.ABUSE_TRUSTED_PROXY_HOPS });
97
+ const ip = clientIp(request.headers, {
98
+ header: env.ABUSE_CLIENT_IP_HEADER,
99
+ trustedProxyHops: env.ABUSE_TRUSTED_PROXY_HOPS,
100
+ });
65
101
  const { token, expires } = await sessions.create(account.id, { ip, userAgent: request.headers.get("user-agent") });
66
- cookies().set(sessionCookie(token, expires, { name: "yomizaka_session", secure: production }));
67
-
68
- const session = await sessions.resolve(sessionTokenOf(request.headers, { cookie: "yomizaka_session" })); // bearer of a native app, else the cookie
69
- const verdict = requireAdmin({ session, ip, allowedIps: allowedIps(env.AUTH_ALLOWED_IPS, { production }), surface: "api" }); // { ok: true } | { ok: false, status, code }
102
+ cookies().set(sessionCookie(token, expires, { name: "verikora_session", secure: production }));
103
+
104
+ const session = await sessions.resolve(sessionTokenOf(request.headers, { cookie: "verikora_session" })); // bearer of a native app, else the cookie
105
+ const verdict = requireAdmin({
106
+ session,
107
+ ip,
108
+ allowedIps: allowedIps(env.AUTH_ALLOWED_IPS, { production }),
109
+ surface: "api",
110
+ }); // { ok: true } | { ok: false, status, code }
70
111
  ```
71
112
 
72
113
  - **Passwords:** `hashPassword` and `verifyPassword` are scrypt (N=2^15, r=8, p=1), stored `scrypt$N$r$p$salt$hash`, the format of the copies: a hash a product stored verifies here (a test pins one). `passwordProblem` (12 to 256 characters) and `decoyHash` (verify against it when the address has no account: the same work, the same time). Hand them to `dbAccounts({ passwords: { hash: hashPassword, verify: verifyPassword } })`.
@@ -294,4 +294,4 @@ export {
294
294
  dbAccounts,
295
295
  postgresAccounts
296
296
  };
297
- //# sourceMappingURL=chunk-34CFR7EE.js.map
297
+ //# sourceMappingURL=chunk-73MO6V5M.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/port.ts","../src/accounts.ts"],"sourcesContent":["/**\n * The account PORT (ADR-0008 of Verikora, now shared). A product's account — name, language, theme, avatar, e-mail change with\n * verification, password (changed when signed in, or reset by a mailed link), sessions, export, deletion — behind one interface. Every product keeps its OWN accounts: no central\n * service, no runtime dependency between products.\n */\nexport type Theme = \"light\" | \"dark\" | \"system\";\n\nexport interface AccountProfile {\n id: string;\n email: string;\n name: string | null;\n locale: \"en\" | \"fr\";\n /** The theme the person chose; null = follow the device. */\n theme: Theme | null;\n /** A seed of the default avatars (the UI draws it); null = derived from the e-mail address. */\n avatarSeed: string | null;\n emailVerified: boolean;\n /** An address awaiting its verification link, if the person asked to change it. */\n pendingEmail: string | null;\n}\n\nexport interface ProfilePatch {\n name?: string | null;\n locale?: \"en\" | \"fr\";\n theme?: Theme | null;\n avatarSeed?: string | null;\n}\n\n/** A sign-in of the person: where from and when. Never the token. */\nexport interface SessionSummary {\n id: string;\n createdAt: Date;\n expiresAt: Date;\n ip: string | null;\n userAgent: string | null;\n current: boolean;\n}\n\nexport interface AccountService {\n profile(accountId: string): Promise<AccountProfile>;\n updateProfile(accountId: string, patch: ProfilePatch): Promise<AccountProfile>;\n /** Asks to change the address: the password is checked, a link is mailed to the NEW address. Answers the same whether or not it is already taken (no enumeration). */\n requestEmailChange(accountId: string, input: { newEmail: string; password: string }, now: Date): Promise<void>;\n /** Applies the change from the mailed link (single use, expires); the new address is verified by that very act. */\n confirmEmailChange(token: string, now: Date): Promise<\"CHANGED\" | \"INVALID\">;\n /** Changes the password (the current one is checked) and signs every other session out; `keepSessionId` stays. */\n changePassword(accountId: string, input: { current: string; next: string }, keepSessionId?: string): Promise<void>;\n /** Asks for a reset link, mailed (in the person's language) when an account with a password has this address; answers the same either way (no enumeration). */\n requestPasswordReset(email: string, now: Date): Promise<void>;\n /** Sets a new password from the mailed link — single use, expires — and signs every session out. The new password is checked before the link is used up. */\n resetPassword(token: string, next: string, now: Date): Promise<\"CHANGED\" | \"INVALID\">;\n /** Checks the password. Throws `AccountError(403, \"WRONG_PASSWORD\")`. */\n confirmPassword(accountId: string, password: string): Promise<void>;\n /** The person's live sign-ins, newest first; `currentSessionId` is flagged. */\n sessions(accountId: string, now: Date, currentSessionId?: string): Promise<SessionSummary[]>;\n /** Signs every other session out (and returns how many); `keepSessionId` stays. */\n signOutOthers(accountId: string, keepSessionId: string): Promise<number>;\n /** What the product keeps about the person, as one JSON-able document: the profile, the live sign-ins, and what `extra` adds. */\n exportAccount(\n accountId: string,\n now: Date,\n extra?: () => Promise<Record<string, unknown>>,\n ): Promise<Record<string, unknown>>;\n /** Deletes the identity: the account, its sessions and everything that hangs on it (the product erases its own data first). */\n deleteAccount(accountId: string): Promise<void>;\n}\n\nexport class AccountError extends Error {\n constructor(\n readonly status: number,\n readonly code: string,\n message: string,\n ) {\n super(message);\n this.name = \"AccountError\";\n }\n}\n","import crypto from \"node:crypto\";\n\nimport type { Query } from \"@krizaka/db\";\nimport { addHours, addMinutes } from \"date-fns\";\n\nimport {\n AccountError,\n type AccountProfile,\n type AccountService,\n type ProfilePatch,\n type SessionSummary,\n type Theme,\n} from \"./port\";\n\n/** A SQL executor: `@krizaka/db`'s `Query`, which the product passes (its pool, or its transaction's client). */\nexport type { Query };\n\nexport interface Mail {\n to: string;\n /** The language of the mail (the person's). */\n locale: \"en\" | \"fr\";\n subject: string;\n text: string;\n /**\n * The link this mail carries, as data: a product that sends branded HTML (`@krizaka/email`: `passwordReset`, `emailChange`)\n * renders it from this instead of from `text`. Absent on a mail that is not a link.\n */\n link?: MailLink;\n}\n\n/** A mailed link: what it is for, where it goes, and how long it lives. */\nexport interface MailLink {\n kind: \"passwordReset\" | \"emailChange\";\n url: string;\n /** `passwordReset`: the lifetime in minutes. */\n minutes?: number;\n /** `emailChange`: the lifetime in hours. */\n hours?: number;\n}\n\nexport interface AccountDeps {\n query: Query;\n /**\n * Runs `work` in one transaction on one connection (BEGIN … COMMIT, rolled back when `work` throws) — the product's pool\n * knows how. The password reset runs under it: the account row is locked, the links retired and issued, the token claimed,\n * the password set and the sessions ended as one. Without it, the statements run one by one on `query` (one process, no\n * concurrent requests): give it in production.\n */\n transaction?: <T>(work: (query: Query) => Promise<T>) => Promise<T>;\n mail: (mail: Mail) => Promise<void>;\n /** `https://www.folioviv.com`: the e-mail change link is `<baseUrl>/<locale>/confirm-email?token=…`, the reset link `<baseUrl>/<locale>/reset-password?token=…`. */\n baseUrl: string;\n /** The product's name, in the words of the mail (« Folioviv »). */\n product: string;\n /** The product's token prefix, two or three letters (`ye1.` for Verikora: `ye`). The tokens are `<prefix>1.<43 base64url>`. */\n tokenPrefix: string;\n passwords: {\n hash: (password: string) => Promise<string>;\n verify: (password: string, stored: string) => Promise<boolean>;\n };\n /** How a token is kept: the product's own keyed hash, the one its sessions use. */\n tokenHash: (token: string) => string;\n /**\n * How long a password reset link lives, in minutes: a whole number from 5 to 1440 (`PASSWORD_RESET_MIN_MINUTES`–`PASSWORD_RESET_MAX_MINUTES`),\n * default `PASSWORD_RESET_TTL_MINUTES` (30). The product reads it from its settings; the expiry and the mailed text both use it.\n * Out of range, `dbAccounts` throws.\n */\n passwordResetTtlMinutes?: number;\n /** Test seam: the token to issue. */\n token?: () => string;\n}\n\n// @fixed: the lifetime of an e-mail change link, a security parameter\nexport const EMAIL_CHANGE_TTL_HOURS = 24;\n/** The default lifetime of a password reset link, in minutes (`AccountDeps.passwordResetTtlMinutes` changes it). */\n// @fixed: the default lifetime of a password reset link, a security parameter\nexport const PASSWORD_RESET_TTL_MINUTES = 30;\n// @fixed: the shortest lifetime a product may set: a person must have time to open the mail\nexport const PASSWORD_RESET_MIN_MINUTES = 5;\n// @fixed: the longest lifetime a product may set: a day\nexport const PASSWORD_RESET_MAX_MINUTES = 1440;\n\nconst checkResetTtl = (minutes: number): number => {\n if (!Number.isInteger(minutes) || minutes < PASSWORD_RESET_MIN_MINUTES || minutes > PASSWORD_RESET_MAX_MINUTES) {\n throw new RangeError(\n `passwordResetTtlMinutes must be a whole number from ${PASSWORD_RESET_MIN_MINUTES} to ${PASSWORD_RESET_MAX_MINUTES}, got ${minutes}`,\n );\n }\n return minutes;\n};\nconst NAME_MAX = 80;\nconst PASSWORD_MIN = 12;\nconst PASSWORD_MAX = 256;\nconst THEMES: readonly Theme[] = [\"light\", \"dark\", \"system\"];\n\n/** One format for every mailed link (an e-mail change, a password reset): the product's prefix, a version, 256 random bits. */\nexport const linkToken = (prefix: string) => `${prefix}1.${crypto.randomBytes(32).toString(\"base64url\")}`;\nexport const isLinkToken = (prefix: string, value: string | undefined | null): value is string =>\n !!value && new RegExp(`^${prefix}1\\\\.[A-Za-z0-9_-]{43}$`).test(value);\nexport const emailChangeToken = linkToken;\nexport const isEmailChangeToken = isLinkToken;\n\nexport function passwordResetMail(\n product: string,\n locale: \"en\" | \"fr\",\n url: string,\n ttlMinutes: number = PASSWORD_RESET_TTL_MINUTES,\n) {\n checkResetTtl(ttlMinutes);\n return locale === \"fr\"\n ? {\n subject: `Réinitialisez votre mot de passe — ${product}`,\n text: `Bonjour,\\n\\nPour choisir un nouveau mot de passe pour votre compte ${product}, ouvrez ce lien (valide ${ttlMinutes} min, une seule fois) :\\n${url}\\n\\nSi vous n'avez rien demandé, ignorez ce message : votre mot de passe ne change pas.\\n`,\n }\n : {\n subject: `Reset your password — ${product}`,\n text: `Hello,\\n\\nTo choose a new password for your ${product} account, open this link (valid ${ttlMinutes} min, once):\\n${url}\\n\\nIf you did not ask for this, ignore this message: your password does not change.\\n`,\n };\n}\n\nexport function emailChangeMail(product: string, locale: \"en\" | \"fr\", url: string) {\n return locale === \"fr\"\n ? {\n subject: `Confirmez votre nouvelle adresse — ${product}`,\n text: `Bonjour,\\n\\nPour utiliser cette adresse avec votre compte ${product}, confirmez-la en ouvrant ce lien (valide ${EMAIL_CHANGE_TTL_HOURS} h) :\\n${url}\\n\\nSi vous n'avez rien demandé, ignorez ce message : rien ne change.\\n`,\n }\n : {\n subject: `Confirm your new address — ${product}`,\n text: `Hello,\\n\\nTo use this address with your ${product} account, confirm it by opening this link (valid ${EMAIL_CHANGE_TTL_HOURS} h):\\n${url}\\n\\nIf you did not ask for this, ignore this message: nothing changes.\\n`,\n };\n}\n\n/** One « @ », something on each side, a dot inside the domain, no space — checked without a backtracking pattern. */\nconst looksLikeEmail = (address: string): boolean => {\n if (address.length > 320 || /\\s/.test(address)) return false;\n const at = address.indexOf(\"@\");\n if (at < 1 || at !== address.lastIndexOf(\"@\")) return false;\n const domain = address.slice(at + 1);\n const dot = domain.lastIndexOf(\".\");\n return dot > 0 && dot < domain.length - 1;\n};\n\nconst cleanName = (raw: string): string => {\n const name = raw.replace(/\\s+/g, \" \").trim();\n if (name.length > NAME_MAX) throw new AccountError(400, \"BAD_REQUEST\", `A name is at most ${NAME_MAX} characters`);\n return name;\n};\n\ninterface ProfileRow {\n id: string;\n email: string;\n name: string | null;\n locale: \"en\" | \"fr\";\n theme: Theme | null;\n avatar_seed: string | null;\n email_verified_at: Date | null;\n pending: string | null;\n}\n\n/** The adapter over the product's own PostgreSQL (`accounts`, `sessions`, `email_changes`, `password_resets`; see `accountsMigration`). */\nexport function dbAccounts(deps: AccountDeps): AccountService {\n const { query } = deps;\n const resetTtlMinutes = checkResetTtl(deps.passwordResetTtlMinutes ?? PASSWORD_RESET_TTL_MINUTES);\n const transaction = deps.transaction ?? (<T>(work: (q: Query) => Promise<T>) => work(query));\n const wrong = () => new AccountError(403, \"WRONG_PASSWORD\", \"The password is not right\");\n\n async function profileOf(id: string): Promise<AccountProfile> {\n const [row] = await query<ProfileRow>(\n `SELECT a.id, a.email, a.name, a.locale, a.theme, a.avatar_seed, a.email_verified_at,\n (SELECT c.new_email FROM email_changes c WHERE c.account_id = a.id AND c.used_at IS NULL AND c.expires_at > now() ORDER BY c.created_at DESC LIMIT 1) AS pending\n FROM accounts a WHERE a.id = $1`,\n [id],\n );\n if (!row) throw new AccountError(404, \"NOT_FOUND\", \"No such account\");\n return {\n id: row.id,\n email: row.email,\n name: row.name,\n locale: row.locale,\n theme: row.theme,\n avatarSeed: row.avatar_seed,\n emailVerified: !!row.email_verified_at,\n pendingEmail: row.pending,\n };\n }\n\n async function confirmPassword(id: string, password: string): Promise<void> {\n const [row] = await query<{ password_hash: string | null }>(\"SELECT password_hash FROM accounts WHERE id = $1\", [\n id,\n ]);\n if (\n !row?.password_hash ||\n password.length > PASSWORD_MAX ||\n !(await deps.passwords.verify(password, row.password_hash))\n )\n throw wrong();\n }\n\n return {\n profile: profileOf,\n\n async updateProfile(id, patch: ProfilePatch) {\n const name = patch.name === undefined ? undefined : patch.name === null ? null : cleanName(patch.name);\n if (patch.avatarSeed && !/^[a-z0-9-]{1,40}$/.test(patch.avatarSeed))\n throw new AccountError(400, \"BAD_REQUEST\", \"Not an avatar\");\n if (patch.theme && !THEMES.includes(patch.theme)) throw new AccountError(400, \"BAD_REQUEST\", \"Not a theme\");\n await query(\n `UPDATE accounts SET name = CASE WHEN $2::boolean THEN $3 ELSE name END, locale = COALESCE($4, locale),\n avatar_seed = CASE WHEN $5::boolean THEN $6 ELSE avatar_seed END, theme = CASE WHEN $7::boolean THEN $8 ELSE theme END WHERE id = $1`,\n [\n id,\n name !== undefined,\n name === \"\" ? null : (name ?? null),\n patch.locale ?? null,\n patch.avatarSeed !== undefined,\n patch.avatarSeed ?? null,\n patch.theme !== undefined,\n patch.theme ?? null,\n ],\n );\n return profileOf(id);\n },\n\n async requestEmailChange(id, input, now) {\n await confirmPassword(id, input.password);\n const address = input.newEmail.trim().toLowerCase();\n if (!looksLikeEmail(address)) throw new AccountError(400, \"BAD_REQUEST\", \"Not an e-mail address\");\n const [taken] = await query(\"SELECT 1 FROM accounts WHERE email = $1\", [address]);\n if (taken) return; // the same answer, no mail: the address is not disclosed\n const profile = await profileOf(id);\n await query(\"UPDATE email_changes SET used_at = $2 WHERE account_id = $1 AND used_at IS NULL\", [id, now]);\n const token = (deps.token ?? (() => emailChangeToken(deps.tokenPrefix)))();\n await query(\"INSERT INTO email_changes (account_id, new_email, token_hash, expires_at) VALUES ($1, $2, $3, $4)\", [\n id,\n address,\n deps.tokenHash(token),\n addHours(now, EMAIL_CHANGE_TTL_HOURS),\n ]);\n const url = `${deps.baseUrl.replace(/\\/$/, \"\")}/${profile.locale}/confirm-email?token=${encodeURIComponent(token)}`;\n await deps.mail({\n to: address,\n locale: profile.locale,\n ...emailChangeMail(deps.product, profile.locale, url),\n link: { kind: \"emailChange\", url, hours: EMAIL_CHANGE_TTL_HOURS },\n });\n },\n\n async confirmEmailChange(token, now) {\n if (!isEmailChangeToken(deps.tokenPrefix, token)) return \"INVALID\";\n const [row] = await query<{ account_id: string; new_email: string }>(\n \"UPDATE email_changes SET used_at = $2 WHERE token_hash = $1 AND used_at IS NULL AND expires_at > $2 RETURNING account_id, new_email\",\n [deps.tokenHash(token), now],\n );\n if (!row) return \"INVALID\";\n try {\n await query(\"UPDATE accounts SET email = $2, email_verified_at = $3 WHERE id = $1\", [\n row.account_id,\n row.new_email,\n now,\n ]);\n } catch {\n return \"INVALID\"; // taken in the meantime\n }\n return \"CHANGED\";\n },\n\n async changePassword(id, input, keepSessionId) {\n await confirmPassword(id, input.current);\n if (input.next.length < PASSWORD_MIN)\n throw new AccountError(400, \"PASSWORD_TOO_SHORT\", `At least ${PASSWORD_MIN} characters`);\n if (input.next.length > PASSWORD_MAX)\n throw new AccountError(400, \"PASSWORD_TOO_LONG\", `At most ${PASSWORD_MAX} characters`);\n await query(\"UPDATE accounts SET password_hash = $2 WHERE id = $1\", [id, await deps.passwords.hash(input.next)]);\n await query(\"DELETE FROM sessions WHERE account_id = $1 AND ($2::uuid IS NULL OR id <> $2::uuid)\", [\n id,\n keepSessionId ?? null,\n ]);\n },\n\n confirmPassword,\n\n async requestPasswordReset(email, now) {\n const address = email.trim().toLowerCase();\n if (!looksLikeEmail(address)) return; // the same answer: nothing is disclosed\n // One transaction, the account row locked: two requests at once leave one valid link — the later one. The mail goes after the commit.\n const issued = await transaction(async (q) => {\n const [row] = await q<{ id: string; locale: \"en\" | \"fr\" }>(\n \"SELECT id, locale FROM accounts WHERE email = $1 AND password_hash IS NOT NULL FOR UPDATE\",\n [address],\n );\n if (!row) return null;\n await q(\"UPDATE password_resets SET used_at = $2 WHERE account_id = $1 AND used_at IS NULL\", [row.id, now]);\n const token = (deps.token ?? (() => linkToken(deps.tokenPrefix)))();\n await q(\"INSERT INTO password_resets (account_id, token_hash, expires_at) VALUES ($1, $2, $3)\", [\n row.id,\n deps.tokenHash(token),\n addMinutes(now, resetTtlMinutes),\n ]);\n return { token, locale: row.locale };\n });\n if (!issued) return;\n const url = `${deps.baseUrl.replace(/\\/$/, \"\")}/${issued.locale}/reset-password?token=${encodeURIComponent(issued.token)}`;\n await deps.mail({\n to: address,\n locale: issued.locale,\n ...passwordResetMail(deps.product, issued.locale, url, resetTtlMinutes),\n link: { kind: \"passwordReset\", url, minutes: resetTtlMinutes },\n });\n },\n\n async resetPassword(token, next, now) {\n if (next.length < PASSWORD_MIN)\n throw new AccountError(400, \"PASSWORD_TOO_SHORT\", `At least ${PASSWORD_MIN} characters`);\n if (next.length > PASSWORD_MAX)\n throw new AccountError(400, \"PASSWORD_TOO_LONG\", `At most ${PASSWORD_MAX} characters`);\n if (!isLinkToken(deps.tokenPrefix, token)) return \"INVALID\";\n // One transaction: the link is claimed, the password set and every session ended as one — a failure leaves the link usable.\n return transaction(async (q) => {\n const [row] = await q<{ account_id: string }>(\n \"UPDATE password_resets SET used_at = $2 WHERE token_hash = $1 AND used_at IS NULL AND expires_at > $2 RETURNING account_id\",\n [deps.tokenHash(token), now],\n );\n if (!row) return \"INVALID\" as const;\n await q(\"UPDATE accounts SET password_hash = $2 WHERE id = $1\", [\n row.account_id,\n await deps.passwords.hash(next),\n ]);\n await q(\"DELETE FROM sessions WHERE account_id = $1\", [row.account_id]);\n return \"CHANGED\" as const;\n });\n },\n\n async sessions(id, now, currentSessionId) {\n const rows = await query<{\n id: string;\n created_at: Date;\n expires_at: Date;\n ip: string | null;\n user_agent: string | null;\n }>(\n \"SELECT id, created_at, expires_at, ip, user_agent FROM sessions WHERE account_id = $1 AND expires_at > $2 ORDER BY created_at DESC\",\n [id, now],\n );\n return rows.map((r): SessionSummary => ({\n id: r.id,\n createdAt: r.created_at,\n expiresAt: r.expires_at,\n ip: r.ip,\n userAgent: r.user_agent,\n current: r.id === currentSessionId,\n }));\n },\n\n async signOutOthers(id, keepSessionId) {\n return (\n await query(\"DELETE FROM sessions WHERE account_id = $1 AND id <> $2::uuid RETURNING id\", [id, keepSessionId])\n ).length;\n },\n\n async exportAccount(id, now, extra) {\n const profile = await profileOf(id);\n const live = await this.sessions(id, now);\n return {\n exportedAt: now.toISOString(),\n account: profile,\n sessions: live.map(({ current: _current, ...s }) => s),\n ...(extra ? await extra() : {}),\n };\n },\n\n async deleteAccount(id) {\n const rows = await query(\"DELETE FROM accounts WHERE id = $1 RETURNING id\", [id]);\n if (!rows.length) throw new AccountError(404, \"NOT_FOUND\", \"No such account\");\n },\n };\n}\n\n/** @deprecated The old name of `dbAccounts` (a name says what a thing does, not the technology). Removed in the next major. */\nexport const postgresAccounts = dbAccounts;\n"],"mappings":"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","names":[]}
package/dist/index.d.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  import { Query, ModuleSchema } from '@krizaka/db';
2
2
  export { Query } from '@krizaka/db';
3
- import { E as ExternalIdentity, a as IdentityLinkStore, S as SessionStore, N as NewSession, I as IdentityProvider } from './sessions-DZXjHjzd.js';
4
- export { C as CompleteOptions, F as FEDERATED_PROVIDERS, b as FEDERATION_CODE, c as FederatedProvider, d as FederationCode, e as FederationError, f as IdentityLinkError, g as Session, h as SessionCookie, i as Sessions, j as SessionsDeps, k as StartOptions, l as StartedSignIn, m as clearedSessionCookie, n as createSessions, o as dbIdentityLinks, p as identityLinksMigration, q as isFederatedProvider, r as isLocalReturn, s as memoryIdentityLinks, t as memoryIdentityProvider, u as pkceChallenge, v as sessionCookie, w as sessionTokenOf } from './sessions-DZXjHjzd.js';
3
+ import { E as ExternalIdentity, a as IdentityLinkStore, S as SessionStore, N as NewSession, I as IdentityProvider } from './sessions-DscdTKEJ.js';
4
+ export { C as CompleteOptions, F as FEDERATED_PROVIDERS, b as FEDERATION_CODE, c as FederatedProvider, d as FederationCode, e as FederationError, f as IdentityLinkError, g as Session, h as SessionCookie, i as Sessions, j as SessionsDeps, k as StartOptions, l as StartedSignIn, m as clearedSessionCookie, n as createSessions, o as dbIdentityLinks, p as identityLinksMigration, q as isFederatedProvider, r as isLocalReturn, s as memoryIdentityLinks, t as memoryIdentityProvider, u as pkceChallenge, v as sessionCookie, w as sessionTokenOf } from './sessions-DscdTKEJ.js';
5
5
  import { a as RoleModel } from './roles-model-CSCe8O57.js';
6
6
  export { R as ROLES_CODE, b as RolesCode, c as RolesError, d as can, e as defineRoles } from './roles-model-CSCe8O57.js';
7
7
 
8
8
  /**
9
- * The account PORT (ADR-0008 of Yomizaka, now shared). A product's account — name, language, theme, avatar, e-mail change with
9
+ * The account PORT (ADR-0008 of Verikora, now shared). A product's account — name, language, theme, avatar, e-mail change with
10
10
  * verification, password (changed when signed in, or reset by a mailed link), sessions, export, deletion — behind one interface. Every product keeps its OWN accounts: no central
11
11
  * service, no runtime dependency between products.
12
12
  */
@@ -106,11 +106,11 @@ interface AccountDeps {
106
106
  */
107
107
  transaction?: <T>(work: (query: Query) => Promise<T>) => Promise<T>;
108
108
  mail: (mail: Mail) => Promise<void>;
109
- /** `https://www.akinaza.com`: the e-mail change link is `<baseUrl>/<locale>/confirm-email?token=…`, the reset link `<baseUrl>/<locale>/reset-password?token=…`. */
109
+ /** `https://www.folioviv.com`: the e-mail change link is `<baseUrl>/<locale>/confirm-email?token=…`, the reset link `<baseUrl>/<locale>/reset-password?token=…`. */
110
110
  baseUrl: string;
111
- /** The product's name, in the words of the mail (« Akinaza »). */
111
+ /** The product's name, in the words of the mail (« Folioviv »). */
112
112
  product: string;
113
- /** The product's token prefix, two or three letters (`ye1.` for Yomizaka: `ye`). The tokens are `<prefix>1.<43 base64url>`. */
113
+ /** The product's token prefix, two or three letters (`ye1.` for Verikora: `ye`). The tokens are `<prefix>1.<43 base64url>`. */
114
114
  tokenPrefix: string;
115
115
  passwords: {
116
116
  hash: (password: string) => Promise<string>;
@@ -279,14 +279,14 @@ type FederatedSignIn = ReturnType<typeof createFederatedSignIn>;
279
279
  declare const SESSION_HINT_COOKIE = "kz-session";
280
280
  /** Where a signed-in visitor of the landing route goes. */
281
281
  interface LandingRule {
282
- /** The product's session cookie name (`yomizaka_session`). */
282
+ /** The product's session cookie name (`verikora_session`). */
283
283
  cookie: string;
284
284
  /** The landing routes (`["/", "/fr", "/en"]`): exact paths, a trailing slash tolerated. */
285
285
  landing: readonly string[];
286
286
  /** The signed-in start page, or a function of the request (the language of the address). */
287
287
  home: string | ((request: Request) => string);
288
288
  /**
289
- * The product's own public origin (`https://dev.yomizaka.com`), from its declared environment (APP_URL), NEVER from the
289
+ * The product's own public origin (`https://dev.verikora.com`), from its declared environment (APP_URL), NEVER from the
290
290
  * request: `Host` and `x-forwarded-*` are client-controlled. With it the Location is absolute on that origin (what a
291
291
  * Next.js proxy accepts). Only the origin of the value is used. Optional outside production (the Location is then
292
292
  * relative); REQUIRED in production, where a missing origin throws instead of falling back to the request's headers.
@@ -494,7 +494,7 @@ type Fetcher = (url: string, init: {
494
494
  json(): Promise<unknown>;
495
495
  }>;
496
496
  interface UsersFederationOptions {
497
- /** The tenant host of the product: `https://auth.dev.akinaza.com`. */
497
+ /** The tenant host of the product: `https://auth.dev.folioviv.com`. */
498
498
  baseUrl: string;
499
499
  /** The product's public address: the start URL sends the person back to it, so `returnTo` is absolute from it. */
500
500
  appUrl: string;
@@ -503,7 +503,8 @@ interface UsersFederationOptions {
503
503
  /** Reads krizaka-users' answer into an identity; anything that is not a complete identity is refused. */
504
504
  declare function parseExternalIdentity(body: unknown): ExternalIdentity;
505
505
  /**
506
- * The adapter for krizaka-users (`auth.<product>` tenant host, krizaka/krizaka-users#31): `start` builds the tenant's start address,
506
+ * The adapter for the users service (`auth.<product>` tenant host), which lives in krizaka/krizaka-java `krizaka-users/` since
507
+ * ADR-021 (the federation landed as krizaka/krizaka-users#31, the old split): `start` builds the tenant's start address,
507
508
  * `complete` exchanges the one-time code (and the PKCE verifier of a native app) for the verified identity.
508
509
  */
509
510
  declare function usersIdentityProvider({ baseUrl, appUrl, fetch }: UsersFederationOptions): IdentityProvider;
package/dist/index.js CHANGED
@@ -29,7 +29,7 @@ import {
29
29
  linkToken,
30
30
  passwordResetMail,
31
31
  postgresAccounts
32
- } from "./chunk-34CFR7EE.js";
32
+ } from "./chunk-73MO6V5M.js";
33
33
  import {
34
34
  ROLES_CODE,
35
35
  RolesError,
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/admin-gate.ts","../src/client-ip.ts","../src/federated-sign-in.ts","../src/landing.ts","../src/migration.ts","../src/password.ts","../src/roles.ts","../src/sessions.ts","../src/sessions-memory.ts","../src/sessions-persistence.ts","../src/users-federation.ts"],"sourcesContent":["/**\n * The administration's locks, as one pure decision every product shares (it was copied in each product's `lib/access.ts`): a session,\n * the ADMIN role, and an address on the allowed list. A pure function of its inputs: the product reads the session and the address\n * and maps the verdict to its own answer.\n */\n\n/** Stable codes of a refusal. */\nexport const ADMIN_REFUSAL = {\n unauthenticated: \"UNAUTHENTICATED\",\n forbidden: \"FORBIDDEN\",\n ipNotAllowed: \"IP_NOT_ALLOWED\",\n notFound: \"NOT_FOUND\",\n} as const;\nexport type AdminRefusal = (typeof ADMIN_REFUSAL)[keyof typeof ADMIN_REFUSAL];\n\n/** The verdict: let in, or the status and code to answer. */\nexport type AdminDecision = { ok: true } | { ok: false; status: 401 | 403 | 404; code: AdminRefusal };\n\n/**\n * The list the administration's address lock reads, from the environment's value (`AUTH_ALLOWED_IPS`): an empty list is « nobody » in\n * production and « anybody » in development, so a forgotten setting never opens the console.\n * @param list the configured addresses\n * @returns the list to hand to `requireAdmin`, or null (anybody)\n */\nexport function allowedIps(list: readonly string[], options: { production: boolean }): readonly string[] | null {\n if (list.length > 0) return list;\n return options.production ? [] : null;\n}\n\n/** Whether an address may use the administration: `null` allowed means anybody, an empty list nobody, and a missing address is never allowed by a list. */\nexport function ipAllowed(ip: string | null, allowed: readonly string[] | null): boolean {\n if (allowed === null) return true;\n return !!ip && allowed.includes(ip);\n}\n\nexport interface AdminGateInput {\n /** The caller's session, or null when there is none. Only its account's role is read. */\n session: { account: { role: string } } | null;\n /** The caller's address (see `clientIp`). */\n ip: string | null;\n /** The addresses allowed (see `allowedIps`); null = anybody. */\n allowedIps: readonly string[] | null;\n /** `api` (default): 401 without a session, 403 with the reason. `page`: the administration does not announce itself, so every failed lock is a 404. */\n surface?: \"api\" | \"page\";\n /** The role that administers. Default `ADMIN`. */\n role?: string;\n}\n\n/**\n * The administration's three locks. The first that fails decides.\n * @returns `{ ok: true }`, or the status (401, 403 or 404 on a page) and the code to answer\n */\nexport function requireAdmin(input: AdminGateInput): AdminDecision {\n const page = input.surface === \"page\";\n const refuse = (status: 401 | 403, code: AdminRefusal): AdminDecision =>\n page ? { ok: false, status: 404, code: ADMIN_REFUSAL.notFound } : { ok: false, status, code };\n if (!input.session) return refuse(401, ADMIN_REFUSAL.unauthenticated);\n if (input.session.account.role !== (input.role ?? \"ADMIN\")) return refuse(403, ADMIN_REFUSAL.forbidden);\n if (!ipAllowed(input.ip, input.allowedIps)) return refuse(403, ADMIN_REFUSAL.ipNotAllowed);\n return { ok: true };\n}\n","/** How a deployment tells the client's address: the header its edge sets, and how many proxies append to `X-Forwarded-For`. */\nexport interface ClientIpRules {\n /** A header the edge sets and overwrites with the client's address (`do-connecting-ip` on DigitalOcean App Platform, `cf-connecting-ip` behind Cloudflare). Read first when present. */\n header?: string | undefined;\n /** How many trusted proxies append to `X-Forwarded-For`: 1 (the default) to 10. */\n trustedProxyHops?: number | undefined;\n}\n\nconst IPV4 = /^(?:\\d{1,3}\\.){3}\\d{1,3}$/;\nconst IPV6 = /^[0-9a-f:]+(?:%\\w+)?$/i;\nfunction isIp(value: string): boolean {\n return (\n (IPV4.test(value) && value.split(\".\").every((n) => Number(n) <= 255)) || (value.includes(\":\") && IPV6.test(value))\n );\n}\n\n/**\n * The client's IP address, as far as this deployment can trust it. `X-Forwarded-For` is a list each proxy appends to: its LEFT end is\n * whatever the client sent (anyone can write `X-Forwarded-For: 1.2.3.4`), so a limit keyed on it is a limit the attacker chooses. The\n * address the last trusted proxy saw is read from the RIGHT:\n *\n * - the `header` the edge sets and overwrites, when present;\n * - otherwise the entry `trustedProxyHops` (default 1: one proxy) from the right of `X-Forwarded-For`, then `X-Real-IP`;\n * - otherwise null (local development without a proxy).\n *\n * The product reads its environment (`ABUSE_CLIENT_IP_HEADER`, `ABUSE_TRUSTED_PROXY_HOPS`) and passes the values: this function reads none.\n * @param headers the request's headers\n * @returns a valid IPv4 or IPv6 address, or null\n */\nexport function clientIp(headers: Headers, rules: ClientIpRules = {}): string | null {\n const custom = rules.header?.trim().toLowerCase();\n if (custom) {\n const value = headers.get(custom)?.split(\",\")[0]?.trim();\n if (value && isIp(value)) return value;\n }\n const hops = Math.max(1, Math.min(10, Number(rules.trustedProxyHops) || 1));\n const chain = (headers.get(\"x-forwarded-for\") ?? \"\")\n .split(\",\")\n .map((part) => part.trim())\n .filter(Boolean);\n const fromRight = chain[chain.length - hops];\n if (fromRight && isIp(fromRight)) return fromRight;\n const real = headers.get(\"x-real-ip\")?.trim();\n if (real && isIp(real)) return real;\n return null;\n}\n","import type { ExternalIdentity } from \"./federation\";\nimport type { IdentityLinkStore } from \"./identity-links\";\n\n/** What federated sign-in needs from the product's accounts: no second user table, just a look-up and, when registration is open, a creation. */\nexport interface FederatedAccounts {\n /** The account that holds this address (lower case), and whether the product has verified it; null when none. */\n findByEmail(email: string): Promise<{ id: string; emailVerified: boolean } | null>;\n /** Creates an account for a person the provider vouches for (address verified, no password). Absent when registration is closed. */\n create?(identity: ExternalIdentity & { email: string }): Promise<string>;\n}\n\n/** Why a sign-in was refused: a stable code the product maps to its words. */\nexport const FEDERATED_REFUSAL = {\n /** The provider did not verify the address (or gave none): nothing is linked, nothing is created. */\n emailNotVerified: \"EMAIL_NOT_VERIFIED\",\n /** An account holds the address but the product never verified it: linking would let a stranger take it over. */\n accountEmailUnverified: \"ACCOUNT_EMAIL_UNVERIFIED\",\n /** No account, and registration is closed. */\n noAccount: \"NO_ACCOUNT\",\n} as const;\nexport type FederatedRefusal = (typeof FEDERATED_REFUSAL)[keyof typeof FEDERATED_REFUSAL];\n\nexport type FederatedSignInResult =\n /** `via`: `link` already linked, `linked` attached to the account of that address now, `created` a new account. */\n | { status: \"signed-in\"; accountId: string; via: \"link\" | \"linked\" | \"created\" }\n | { status: \"refused\"; code: FederatedRefusal };\n\nexport interface FederatedSignInDeps {\n links: IdentityLinkStore;\n accounts: FederatedAccounts;\n clock: { now(): Date };\n}\n\n/**\n * From a verified external identity to an account of the product. The rule that matters: an identity attaches to an EXISTING account\n * only when the address is verified on both sides — by the provider and by the product. Otherwise a stranger who registers at\n * Facebook with your address would sign in as you.\n */\nexport function createFederatedSignIn({ links, accounts, clock }: FederatedSignInDeps) {\n return {\n async signIn(identity: ExternalIdentity): Promise<FederatedSignInResult> {\n const known = await links.find(identity.issuer, identity.subject);\n if (known) return { status: \"signed-in\", accountId: known, via: \"link\" };\n\n const email = identity.email?.trim().toLowerCase();\n if (!email || !identity.emailVerified) return { status: \"refused\", code: FEDERATED_REFUSAL.emailNotVerified };\n\n const existing = await accounts.findByEmail(email);\n if (existing) {\n if (!existing.emailVerified) return { status: \"refused\", code: FEDERATED_REFUSAL.accountEmailUnverified };\n await links.link(existing.id, identity.issuer, identity.subject, clock.now());\n return { status: \"signed-in\", accountId: existing.id, via: \"linked\" };\n }\n\n if (!accounts.create) return { status: \"refused\", code: FEDERATED_REFUSAL.noAccount };\n const accountId = await accounts.create({ ...identity, email });\n await links.link(accountId, identity.issuer, identity.subject, clock.now());\n return { status: \"signed-in\", accountId, via: \"created\" };\n },\n };\n}\n\nexport type FederatedSignIn = ReturnType<typeof createFederatedSignIn>;\n","// The two proxy-side rules of « a signed-in person never sees the public side » (krizaka-ui#827), the same for every\n// product. Web-standard Request/Response only (a Next.js `proxy.ts` calls them). The cookie is a HINT: its presence\n// decides a redirect and a placeholder, never an authorization — the page and the API still resolve the session.\n\n/** The readable flag the proxy keeps next to the HttpOnly session cookie: « a session cookie exists », value `1`, never the token. */\nexport const SESSION_HINT_COOKIE = \"kz-session\";\n\nconst cookieValue = (headers: Headers, name: string): string | undefined => {\n const prefix = `${name}=`;\n return headers\n .get(\"cookie\")\n ?.split(\";\")\n .map((part) => part.trim())\n .find((part) => part.startsWith(prefix))\n ?.slice(prefix.length);\n};\n\n/** Where a signed-in visitor of the landing route goes. */\nexport interface LandingRule {\n /** The product's session cookie name (`yomizaka_session`). */\n cookie: string;\n /** The landing routes (`[\"/\", \"/fr\", \"/en\"]`): exact paths, a trailing slash tolerated. */\n landing: readonly string[];\n /** The signed-in start page, or a function of the request (the language of the address). */\n home: string | ((request: Request) => string);\n /**\n * The product's own public origin (`https://dev.yomizaka.com`), from its declared environment (APP_URL), NEVER from the\n * request: `Host` and `x-forwarded-*` are client-controlled. With it the Location is absolute on that origin (what a\n * Next.js proxy accepts). Only the origin of the value is used. Optional outside production (the Location is then\n * relative); REQUIRED in production, where a missing origin throws instead of falling back to the request's headers.\n */\n origin?: string;\n}\n\n// Linear on any input (a loop, not a `/\\/+$/` regular expression: CodeQL js/polynomial-redos on the request path).\nconst trimmed = (path: string) => {\n let end = path.length;\n while (end > 1 && path.charCodeAt(end - 1) === 47) end--;\n return path.slice(0, end);\n};\n\n/**\n * The landing route with a session cookie answers 307 to the home before any HTML: private, never stored, varying with the\n * cookie. `null` for everything else (a signed-out visitor, another page, a write).\n */\nexport function landingRedirect(request: Request, rule: LandingRule): Response | null {\n // Fail loudly at the first request of a misconfigured deployment, signed in or not: never a silent fallback.\n if (!rule.origin && globalThis.process?.env?.NODE_ENV === \"production\") {\n throw new Error(\"landingRedirect: `origin` (the app's declared public URL) is required in production\");\n }\n if (request.method !== \"GET\" && request.method !== \"HEAD\") return null;\n if (!cookieValue(request.headers, rule.cookie)) return null;\n const url = new URL(request.url);\n if (!rule.landing.some((path) => trimmed(path) === trimmed(url.pathname))) return null;\n const home = typeof rule.home === \"function\" ? rule.home(request) : rule.home;\n // Never built from the request's Host or scheme (client-controlled: host-header injection, open redirect). Relative by\n // default; absolute only on the origin the app configured, and a `home` that leaves that origin falls back to relative.\n const target = new URL(home, url);\n const path = `${target.pathname}${target.search}${target.hash}`;\n const base = rule.origin ? new URL(rule.origin).origin : null;\n const location = base && new URL(home, base).origin === base ? new URL(home, base).href : path;\n return new Response(null, {\n status: 307,\n headers: { Location: location, \"Cache-Control\": \"private, no-store\", Vary: \"Cookie\" },\n });\n}\n\n/**\n * Keeps the hint flag in step with the session cookie on any response (pages stay static: the proxy runs on every request,\n * the HTML is the same for everyone). Sets it when the session cookie exists, clears it when it is gone, else touches nothing.\n */\nexport function sessionHint<T extends Response>(\n response: T,\n request: Request,\n options: { cookie: string; secure: boolean },\n): T {\n const session = Boolean(cookieValue(request.headers, options.cookie));\n const flagged = cookieValue(request.headers, SESSION_HINT_COOKIE) === \"1\";\n const base = `${SESSION_HINT_COOKIE}=${session ? \"1\" : \"\"}; Path=/; SameSite=Lax${options.secure ? \"; Secure\" : \"\"}`;\n if (session && !flagged) response.headers.append(\"Set-Cookie\", base);\n else if (!session && cookieValue(request.headers, SESSION_HINT_COOKIE) !== undefined)\n response.headers.append(\"Set-Cookie\", `${base}; Max-Age=0`);\n return response;\n}\n","import { accountsSchema, rolesSchema } from \"./schema\";\n\nconst IDENT = /^[a-z_][a-z0-9_]{0,62}$/;\nconst ident = (name: string) => {\n if (!IDENT.test(name)) throw new Error(`not a safe SQL identifier: ${name}`);\n return name;\n};\n\n/**\n * The SQL of version 1 of the accounts tables, for a product that runs it by hand (idempotent).\n * @deprecated Use `accountsSchema` with `emitMigration` or `adoptMigration` from `@krizaka/db`: the SQL gets a version, a checksum and a marker. Removed in the next major.\n */\nexport function accountsMigration(options: { backfillVerified?: boolean } = {}): string {\n return accountsSchema(options).migrations[0]!.sql({ schema: \"public\", tables: { accounts: \"accounts\" } });\n}\n\n/**\n * The SQL of version 1 of the memberships and invitations tables, for a product that runs it by hand (idempotent).\n * @deprecated Use `rolesSchema` with `emitMigration` or `adoptMigration` from `@krizaka/db`. Removed in the next major.\n */\nexport function rolesMigration(\n roles: readonly string[],\n options: { ownerTable?: string; scopeTable?: string } = {},\n): string {\n const tables = {\n owner: ident(options.ownerTable ?? \"accounts\"),\n ...(options.scopeTable ? { scope: ident(options.scopeTable) } : {}),\n };\n return rolesSchema(roles, { scoped: options.scopeTable !== undefined }).migrations[0]!.sql({\n schema: \"public\",\n tables,\n });\n}\n","import crypto from \"node:crypto\";\n\n/** Called only when a password is hashed or checked: the module also loads in a browser bundle (the role model), where `crypto.scrypt` does not exist. */\nconst scrypt = (password: string, salt: Buffer, keylen: number, options: crypto.ScryptOptions): Promise<Buffer> =>\n new Promise((resolve, reject) => {\n crypto.scrypt(password, salt, keylen, options, (error, key) => (error ? reject(error) : resolve(key)));\n });\n\n/**\n * scrypt (N=2^15, r=8, p=1), a 16-byte salt, stored as `scrypt$N$r$p$salt$hash` (base64url). These are the parameters of the copies\n * the products carried (Yomizaka, Akinaza): a hash they stored verifies here, and one made here verifies there.\n */\n// @fixed: the cost of the algorithm is part of the stored format; changing it is a new scheme, not a setting\nconst N = 32768;\nconst R = 8;\nconst P = 1;\nconst KEYLEN = 32;\nconst MAXMEM = 64 * 1024 * 1024;\nconst OPTIONS = { N, r: R, p: P, maxmem: MAXMEM };\n\n/** The shortest password the products accept. */\n// @fixed: a security floor shared by every product, not a business parameter\nexport const PASSWORD_MIN_LENGTH = 12;\n/** The longest: a longer one is refused before any work is done (a very long password is a way to make the server spend time). */\n// @fixed: bounds the work a request can make the server do\nexport const PASSWORD_MAX_LENGTH = 256;\n\n/** Why a new password is refused, or null when its length is acceptable. */\nexport function passwordProblem(password: string): \"TOO_SHORT\" | \"TOO_LONG\" | null {\n if (password.length < PASSWORD_MIN_LENGTH) return \"TOO_SHORT\";\n if (password.length > PASSWORD_MAX_LENGTH) return \"TOO_LONG\";\n return null;\n}\n\n/**\n * Hashes a password with a fresh salt.\n * @returns `scrypt$N$r$p$salt$hash`\n */\nexport async function hashPassword(password: string): Promise<string> {\n const salt = crypto.randomBytes(16);\n const hash = await scrypt(password, salt, KEYLEN, OPTIONS);\n return `scrypt$${N}$${R}$${P}$${salt.toString(\"base64url\")}$${hash.toString(\"base64url\")}`;\n}\n\n/**\n * Checks a password against a stored hash, in constant time. A hash of another scheme, a malformed one or an over-long password is `false`.\n * The cost parameters are read from the stored value, so an older hash still verifies after the default changes.\n */\nexport async function verifyPassword(password: string, stored: string): Promise<boolean> {\n const [scheme, n, r, p, salt, hash] = stored.split(\"$\");\n if (scheme !== \"scrypt\" || !salt || !hash || password.length > PASSWORD_MAX_LENGTH) return false;\n const expected = Buffer.from(hash, \"base64url\");\n const given = await scrypt(password, Buffer.from(salt, \"base64url\"), expected.length, {\n N: Number(n),\n r: Number(r),\n p: Number(p),\n maxmem: MAXMEM,\n });\n return given.length === expected.length && crypto.timingSafeEqual(given, expected);\n}\n\nlet decoy: Promise<string> | undefined;\n/** A hash to verify against when the address has no account: the same work, the same time, the same answer. */\nexport const decoyHash = (): Promise<string> => (decoy ??= hashPassword(crypto.randomBytes(24).toString(\"base64url\")));\n","import crypto from \"node:crypto\";\n\nimport type { Query } from \"@krizaka/db\";\nimport { addHours } from \"date-fns\";\n\nimport { can, type RoleModel, ROLES_CODE, RolesError } from \"./roles-model\";\n\nexport { can, defineRoles, type RoleModel, ROLES_CODE, type RolesCode, RolesError } from \"./roles-model\";\n\n/** The SQL executor the roles take: `@krizaka/db`'s `Query`. */\nexport type RolesQuery = Query;\n\nexport interface RolesDeps<Role extends string, Permission extends string> {\n query: RolesQuery;\n model: RoleModel<Role, Permission>;\n /** The invitation link's token prefix (`ai` → `ai1.<43>`) and how the product keeps a token (its keyed hash). */\n tokenPrefix: string;\n tokenHash: (token: string) => string;\n /** Hours an invitation stays valid. */\n inviteTtlHours: number;\n token?: () => string;\n}\n\nexport interface Member<Role extends string> {\n accountId: string;\n email: string;\n name: string | null;\n role: Role;\n since: Date;\n}\nexport interface Invitation<Role extends string> {\n id: string;\n email: string;\n role: Role;\n expiresAt: Date;\n createdAt: Date;\n}\n\n/** Who may do what in a scope, over the product's PostgreSQL. */\nexport function createRoles<Role extends string, Permission extends string>(deps: RolesDeps<Role, Permission>) {\n const { query, model } = deps;\n const known = (role: string): role is Role => (model.roles as readonly string[]).includes(role);\n const mustKnow = (role: string): Role => {\n if (!known(role)) throw new RolesError(ROLES_CODE.unknownRole);\n return role;\n };\n const newToken = deps.token ?? (() => `${deps.tokenPrefix}1.${crypto.randomBytes(32).toString(\"base64url\")}`);\n const owners = (scopeId: string) =>\n query<{ account_id: string }>(\"SELECT account_id FROM memberships WHERE scope_id = $1 AND role = $2\", [\n scopeId,\n ownerRole(),\n ]);\n /** The role that cannot disappear: the first of the model that manages every role. */\n const ownerRole = (): Role =>\n model.roles.find((r) => model.roles.every((x) => model.manages[r].includes(x) || x === r)) ?? model.roles[0]!;\n\n async function roleOf(accountId: string, scopeId: string): Promise<Role | null> {\n const [row] = await query<{ role: Role }>(\"SELECT role FROM memberships WHERE scope_id = $1 AND account_id = $2\", [\n scopeId,\n accountId,\n ]);\n return row?.role ?? null;\n }\n\n return {\n ownerRole,\n roleOf,\n\n /** Every scope the person belongs to, with their role in it. */\n async scopesOf(accountId: string): Promise<{ scopeId: string; role: Role }[]> {\n return (\n await query<{ scope_id: string; role: Role }>(\n \"SELECT scope_id, role FROM memberships WHERE account_id = $1 ORDER BY created_at\",\n [accountId],\n )\n ).map((r) => ({ scopeId: r.scope_id, role: r.role }));\n },\n\n /** The person's role in the scope if it holds the permission; otherwise `NOT_A_MEMBER` (404: nothing tells the scope exists) or `FORBIDDEN`. */\n async authorize(accountId: string, scopeId: string, permission: Permission): Promise<Role> {\n const role = await roleOf(accountId, scopeId);\n if (!role) throw new RolesError(ROLES_CODE.notMember);\n if (!can(model, role, permission)) throw new RolesError(ROLES_CODE.forbidden);\n return role;\n },\n\n /** Puts a person in a scope with a role (changing it if they are there): for a first owner, a seed, an accepted invitation. */\n async grant(scopeId: string, accountId: string, role: string, invitedBy: string | null, now: Date): Promise<void> {\n mustKnow(role);\n await query(\n \"INSERT INTO memberships (scope_id, account_id, role, invited_by, created_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (scope_id, account_id) DO UPDATE SET role = excluded.role\",\n [scopeId, accountId, role, invitedBy, now],\n );\n },\n\n async members(scopeId: string): Promise<Member<Role>[]> {\n const rows = await query<{\n account_id: string;\n email: string;\n name: string | null;\n role: Role;\n created_at: Date;\n }>(\n \"SELECT m.account_id, a.email, a.name, m.role, m.created_at FROM memberships m JOIN accounts a ON a.id = m.account_id WHERE m.scope_id = $1 ORDER BY m.created_at\",\n [scopeId],\n );\n return rows.map((r) => ({\n accountId: r.account_id,\n email: r.email,\n name: r.name,\n role: r.role,\n since: r.created_at,\n }));\n },\n\n /** Changes someone's role. The actor must manage both the current and the new role; the last owner can never be demoted. */\n async setRole(scopeId: string, actor: { id: string }, target: string, role: string): Promise<void> {\n mustKnow(role);\n const mine = await roleOf(actor.id, scopeId);\n const current = await roleOf(target, scopeId);\n if (!mine || !current) throw new RolesError(ROLES_CODE.notMember);\n if (!model.manages[mine].includes(current) || !model.manages[mine].includes(role as Role))\n throw new RolesError(ROLES_CODE.notAllowed);\n if (current === ownerRole() && role !== current && (await owners(scopeId)).length <= 1)\n throw new RolesError(ROLES_CODE.lastOwner);\n await query(\"UPDATE memberships SET role = $3 WHERE scope_id = $1 AND account_id = $2\", [scopeId, target, role]);\n },\n\n /** Removes someone from the scope (or leaves it, when the target is the actor). The last owner cannot go. */\n async remove(scopeId: string, actor: { id: string }, target: string): Promise<void> {\n const mine = await roleOf(actor.id, scopeId);\n const current = await roleOf(target, scopeId);\n if (!mine || !current) throw new RolesError(ROLES_CODE.notMember);\n if (actor.id !== target && !model.manages[mine].includes(current)) throw new RolesError(ROLES_CODE.notAllowed);\n if (current === ownerRole() && (await owners(scopeId)).length <= 1) throw new RolesError(ROLES_CODE.lastOwner);\n await query(\"DELETE FROM memberships WHERE scope_id = $1 AND account_id = $2\", [scopeId, target]);\n },\n\n /** Invites an e-mail address with a role the actor manages. The token is in the answer once (the product mails it); only its hash is kept. A new invitation for the same address replaces the pending one. */\n async invite(\n scopeId: string,\n actor: { id: string },\n input: { email: string; role: string },\n now: Date,\n ): Promise<{ token: string; invitation: Invitation<Role> }> {\n const role = mustKnow(input.role);\n const mine = await roleOf(actor.id, scopeId);\n if (!mine) throw new RolesError(ROLES_CODE.notMember);\n if (!model.manages[mine].includes(role)) throw new RolesError(ROLES_CODE.notAllowed);\n const email = input.email.trim().toLowerCase();\n await query(\"UPDATE invitations SET used_at = $3 WHERE scope_id = $1 AND email = $2 AND used_at IS NULL\", [\n scopeId,\n email,\n now,\n ]);\n const token = newToken();\n const [row] = await query<{ id: string; created_at: Date; expires_at: Date }>(\n \"INSERT INTO invitations (id, scope_id, email, role, token_hash, invited_by, created_at, expires_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, created_at, expires_at\",\n [\n crypto.randomUUID(),\n scopeId,\n email,\n role,\n deps.tokenHash(token),\n actor.id,\n now,\n addHours(now, deps.inviteTtlHours),\n ],\n );\n return {\n token,\n invitation: { id: row!.id, email, role, createdAt: row!.created_at, expiresAt: row!.expires_at },\n };\n },\n\n async pendingInvitations(scopeId: string, now: Date): Promise<Invitation<Role>[]> {\n const rows = await query<{ id: string; email: string; role: Role; created_at: Date; expires_at: Date }>(\n \"SELECT id, email, role, created_at, expires_at FROM invitations WHERE scope_id = $1 AND used_at IS NULL AND expires_at > $2 ORDER BY created_at\",\n [scopeId, now],\n );\n return rows.map((r) => ({\n id: r.id,\n email: r.email,\n role: r.role,\n createdAt: r.created_at,\n expiresAt: r.expires_at,\n }));\n },\n\n async revokeInvitation(scopeId: string, actor: { id: string }, invitationId: string, now: Date): Promise<void> {\n const mine = await roleOf(actor.id, scopeId);\n if (!mine) throw new RolesError(ROLES_CODE.notMember);\n const [row] = await query<{ role: Role }>(\n \"SELECT role FROM invitations WHERE id = $1 AND scope_id = $2 AND used_at IS NULL\",\n [invitationId, scopeId],\n );\n if (!row) throw new RolesError(ROLES_CODE.inviteInvalid);\n if (!model.manages[mine].includes(row.role)) throw new RolesError(ROLES_CODE.notAllowed);\n await query(\"UPDATE invitations SET used_at = $2 WHERE id = $1\", [invitationId, now]);\n },\n\n /** The signed-in account takes the invitation: it must have been sent to this account's address; the link works once and expires. */\n async acceptInvitation(\n token: string,\n account: { id: string; email: string },\n now: Date,\n ): Promise<{ scopeId: string; role: Role }> {\n const [row] = await query<{ id: string; scope_id: string; email: string; role: Role; invited_by: string | null }>(\n \"SELECT id, scope_id, email, role, invited_by FROM invitations WHERE token_hash = $1 AND used_at IS NULL AND expires_at > $2\",\n [deps.tokenHash(token), now],\n );\n if (!row) throw new RolesError(ROLES_CODE.inviteInvalid);\n if (row.email !== account.email.trim().toLowerCase()) throw new RolesError(ROLES_CODE.inviteWrongAccount);\n await query(\"UPDATE invitations SET used_at = $2 WHERE id = $1\", [row.id, now]);\n const current = await roleOf(account.id, row.scope_id);\n // already in: keep the higher role (an invitation never demotes)\n if (!current || model.manages[current].length < model.manages[row.role].length)\n await query(\n \"INSERT INTO memberships (scope_id, account_id, role, invited_by, created_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (scope_id, account_id) DO UPDATE SET role = excluded.role\",\n [row.scope_id, account.id, row.role, row.invited_by, now],\n );\n return {\n scopeId: row.scope_id,\n role: (!current || model.manages[current].length < model.manages[row.role].length ? row.role : current) as Role,\n };\n },\n };\n}\nexport type Roles<Role extends string = string, Permission extends string = string> = ReturnType<\n typeof createRoles<Role, Permission>\n>;\n","import crypto from \"node:crypto\";\n\nimport type { Clock } from \"@krizaka/db\";\n\n/**\n * Web sessions (the copies each product carried: Yomizaka `ys1.`, Akinaza `as1.`). The cookie holds a random token\n * (`<prefix>1.` and 32 bytes); the store keeps only its keyed hash, so a leaked table opens nothing. The account is re-read on every\n * request (a suspension applies at once). The lifetime is the setting `auth.sessionDays`.\n */\n\n/** A live session and the account it signs in. `Account` is the product's own shape (id, e-mail, role, locale, name…). */\nexport interface Session<Account> {\n id: string;\n account: Account;\n}\n\n/** What a store keeps for a new session. */\nexport interface NewSession {\n accountId: string;\n tokenHash: string;\n createdAt: Date;\n expiresAt: Date;\n ip: string | null;\n userAgent: string | null;\n}\n\n/** Where sessions live. Adapters: the PostgreSQL store over the product's `sessions` table, and a memory one. */\nexport interface SessionStore<Account> {\n insert(session: NewSession): Promise<void>;\n /** The session of this hash, when it has not expired at `now` and its account is active; null otherwise. */\n find(tokenHash: string, now: Date): Promise<Session<Account> | null>;\n delete(tokenHash: string): Promise<void>;\n}\n\nexport interface SessionsDeps<Account> {\n store: SessionStore<Account>;\n clock: Clock;\n /** The product's token letters: `ys` makes `ys1.<43 characters>`. Lower-case letters, 1 to 4. */\n tokenPrefix: string;\n /** What keys the stored hash (`AUTH_SESSION_SECRET`). A function lets the product read its environment when it is needed. */\n secret: string | (() => string);\n /** Days a new session lives: the setting `auth.sessionDays`, read for each new session. */\n sessionDays: number | (() => number | Promise<number>);\n}\n\nexport interface Sessions<Account> {\n /** A fresh token: shaped `<prefix>1.<43>`, random, never stored (only its hash is). */\n newToken(): string;\n isToken(value: string | undefined): value is string;\n /** The keyed hash the store keeps for a token. */\n tokenHash(token: string): string;\n /** Opens a session: the token for the cookie (shown once) and when it expires. */\n create(\n accountId: string,\n meta: { ip: string | null; userAgent: string | null },\n ): Promise<{ token: string; expires: Date }>;\n /** The session a token opens, or null (unknown, malformed, expired, account not active). */\n resolve(token: string | undefined): Promise<Session<Account> | null>;\n /** Ends the session of this token. A malformed token is ignored. */\n revoke(token: string | undefined): Promise<void>;\n}\n\nconst BODY = /^[A-Za-z0-9_-]{43}$/;\nconst PREFIX = /^[a-z]{1,4}$/;\nconst USER_AGENT_MAX = 300;\n\n/**\n * The sessions service of a product.\n * @param deps the store, the clock, the token letters, the secret and the lifetime\n * @throws RangeError for a token prefix that is not 1 to 4 lower-case letters, or a lifetime that is not a whole number of days from 1\n */\nexport function createSessions<Account>(deps: SessionsDeps<Account>): Sessions<Account> {\n if (!PREFIX.test(deps.tokenPrefix))\n throw new RangeError(`tokenPrefix must be 1 to 4 lower-case letters, got ${deps.tokenPrefix}`);\n const head = `${deps.tokenPrefix}1.`;\n const secret = () => (typeof deps.secret === \"function\" ? deps.secret() : deps.secret);\n const isToken = (value: string | undefined): value is string =>\n !!value && value.startsWith(head) && BODY.test(value.slice(head.length));\n const tokenHash = (token: string) => crypto.createHmac(\"sha256\", `session:${secret()}`).update(token).digest(\"hex\");\n const newToken = () => `${head}${crypto.randomBytes(32).toString(\"base64url\")}`;\n return {\n newToken,\n isToken,\n tokenHash,\n async create(accountId, meta) {\n const days = typeof deps.sessionDays === \"function\" ? await deps.sessionDays() : deps.sessionDays;\n if (!Number.isInteger(days) || days < 1)\n throw new RangeError(`sessionDays must be a whole number of days from 1, got ${days}`);\n const token = newToken();\n const createdAt = deps.clock.now();\n // days of 24 hours, not calendar days: the same instant whatever the server's time zone (the copies added 86 400 000 ms a day)\n const expires = new Date(createdAt);\n expires.setUTCHours(expires.getUTCHours() + days * 24);\n await deps.store.insert({\n accountId,\n tokenHash: tokenHash(token),\n createdAt,\n expiresAt: expires,\n ip: meta.ip,\n userAgent: meta.userAgent?.slice(0, USER_AGENT_MAX) ?? null,\n });\n return { token, expires };\n },\n async resolve(token) {\n if (!isToken(token)) return null;\n return deps.store.find(tokenHash(token), deps.clock.now());\n },\n async revoke(token) {\n if (!isToken(token)) return;\n await deps.store.delete(tokenHash(token));\n },\n };\n}\n\n/** The attributes of the session cookie, for the product's framework (`cookies().set(cookie)`, `NextResponse.cookies.set`…). */\nexport interface SessionCookie {\n name: string;\n value: string;\n httpOnly: true;\n secure: boolean;\n sameSite: \"lax\";\n path: \"/\";\n expires: Date;\n}\n\n/**\n * The session cookie: HttpOnly, SameSite=Lax, on the whole site, `Secure` where the product says (production).\n * @param options the cookie's name (`yomizaka_session`) and whether it is `Secure`\n */\nexport function sessionCookie(token: string, expires: Date, options: { name: string; secure: boolean }): SessionCookie {\n return {\n name: options.name,\n value: token,\n httpOnly: true,\n secure: options.secure,\n sameSite: \"lax\",\n path: \"/\",\n expires,\n };\n}\n\n/** The same cookie, expired now: what signing out sets. */\nexport function clearedSessionCookie(options: { name: string; secure: boolean }, now: Date): SessionCookie {\n return sessionCookie(\"\", now, options);\n}\n\n/**\n * The session token of a request: the native apps' `Authorization: Bearer` (which wins over a stale cookie), else the web's cookie.\n * @param headers the request's headers\n * @param options the cookie's name; `bearer: false` for a product with no native app\n * @returns the raw token (not yet checked), or undefined\n */\nexport function sessionTokenOf(headers: Headers, options: { cookie: string; bearer?: boolean }): string | undefined {\n if (options.bearer !== false) {\n const bearer = /^bearer (\\S+)$/i.exec(headers.get(\"authorization\") ?? \"\")?.[1];\n if (bearer) return bearer;\n }\n const prefix = `${options.cookie}=`;\n return headers\n .get(\"cookie\")\n ?.split(\";\")\n .map((part) => part.trim())\n .find((part) => part.startsWith(prefix))\n ?.slice(prefix.length);\n}\n","import type { NewSession, Session, SessionStore } from \"./sessions\";\n\n/**\n * An in-memory session store, for unit tests and demos. `accountOf` plays the accounts table: it answers the account of an id, or null\n * when it does not exist or is not active.\n */\nexport function memorySessionStore<Account>(\n accountOf: (accountId: string) => Account | null,\n): SessionStore<Account> & { rows(): readonly NewSession[] } {\n const rows = new Map<string, NewSession & { id: string }>();\n let next = 0;\n return {\n async insert(session) {\n if (rows.has(session.tokenHash)) throw new Error(\"duplicate session token hash\");\n next += 1;\n rows.set(session.tokenHash, { ...session, id: `session-${next}` });\n },\n async find(tokenHash, now): Promise<Session<Account> | null> {\n const row = rows.get(tokenHash);\n if (!row || row.expiresAt.getTime() <= now.getTime()) return null;\n const account = accountOf(row.accountId);\n return account === null ? null : { id: row.id, account };\n },\n async delete(tokenHash) {\n rows.delete(tokenHash);\n },\n rows: () => [...rows.values()],\n };\n}\n","import { ident, type Query } from \"@krizaka/db\";\n\nimport type { Session, SessionStore } from \"./sessions\";\n\nexport interface DbSessionStoreOptions<Account> {\n /** The product's session table. Default `sessions` (`id`, `account_id`, `token_hash` unique, `created_at`, `expires_at`, `ip`, `user_agent`). */\n sessions?: string;\n /** The product's account table. Default `accounts`. */\n accounts?: string;\n /** The account columns the session carries (`id`, `email`, `role`, `locale`, `name`…): all checked, all read in one query with the session. */\n columns: readonly string[];\n /** Builds the product's account from a row holding exactly `columns`. */\n toAccount: (row: Record<string, unknown>) => Account;\n /** An account that is not active signs nobody in (a suspension applies at once): the column and the value it must have (`status` = `ACTIVE`). */\n active?: { column: string; equals: string };\n}\n\n/**\n * The session store over the product's own tables (`sessions` joined to `accounts`): the declared tables and columns are checked by\n * `ident()` and nothing else reaches SQL. The expiry is compared with the clock's `now`, not the database's.\n * @param query the product's query\n * @param options the table names, the account columns and how the account is built\n * @throws DbError `DB_UNSAFE_IDENTIFIER` for a table or column name that is not a safe identifier\n */\nexport function dbSessionStore<Account>(query: Query, options: DbSessionStoreOptions<Account>): SessionStore<Account> {\n const sessions = ident(options.sessions ?? \"sessions\");\n const accounts = ident(options.accounts ?? \"accounts\");\n const columns = options.columns.map((column) => `a.${ident(column)}`).join(\", \");\n const active = options.active ? ` AND a.${ident(options.active.column)} = $3` : \"\";\n return {\n async insert(session) {\n await query(\n `INSERT INTO ${sessions} (account_id, token_hash, created_at, expires_at, ip, user_agent) VALUES ($1, $2, $3, $4, $5, $6)`,\n [session.accountId, session.tokenHash, session.createdAt, session.expiresAt, session.ip, session.userAgent],\n );\n },\n async find(tokenHash, now): Promise<Session<Account> | null> {\n const params: unknown[] = [tokenHash, now];\n if (options.active) params.push(options.active.equals);\n const [row] = await query<Record<string, unknown> & { session_id: string }>(\n `SELECT s.id AS session_id, ${columns} FROM ${sessions} s JOIN ${accounts} a ON a.id = s.account_id WHERE s.token_hash = $1 AND s.expires_at > $2${active}`,\n params,\n );\n if (!row) return null;\n const { session_id: id, ...account } = row;\n return { id, account: options.toAccount(account) };\n },\n async delete(tokenHash) {\n await query(`DELETE FROM ${sessions} WHERE token_hash = $1`, [tokenHash]);\n },\n };\n}\n","import {\n type CompleteOptions,\n type ExternalIdentity,\n type FederatedProvider,\n FEDERATION_CODE,\n FederationError,\n type IdentityProvider,\n isFederatedProvider,\n isLocalReturn,\n type StartedSignIn,\n type StartOptions,\n} from \"./federation\";\n\n/** The `fetch` the adapter calls krizaka-users with (injected: no global, no network in tests). */\nexport type Fetcher = (\n url: string,\n init: { method: \"POST\"; headers: Record<string, string>; body: string },\n) => Promise<{ ok: boolean; status: number; json(): Promise<unknown> }>;\n\nexport interface UsersFederationOptions {\n /** The tenant host of the product: `https://auth.dev.akinaza.com`. */\n baseUrl: string;\n /** The product's public address: the start URL sends the person back to it, so `returnTo` is absolute from it. */\n appUrl: string;\n fetch: Fetcher;\n}\n\nconst START = (base: string, provider: FederatedProvider) => `${base}/api/v1/auth/web/${provider}/start`;\nconst EXCHANGE = (base: string) => `${base}/api/v1/auth/web/exchange`;\n\nconst text = (value: unknown): string | null => (typeof value === \"string\" && value.length > 0 ? value : null);\n\n/** Reads krizaka-users' answer into an identity; anything that is not a complete identity is refused. */\nexport function parseExternalIdentity(body: unknown): ExternalIdentity {\n const o = (body ?? {}) as Record<string, unknown>;\n const issuer = text(o.issuer);\n const subject = text(o.subject);\n if (!issuer || !subject)\n throw new FederationError(FEDERATION_CODE.unavailable, \"krizaka-users answered without an issuer and a subject\");\n return { issuer, subject, email: text(o.email), emailVerified: o.email_verified === true, name: text(o.name) };\n}\n\n/**\n * The adapter for krizaka-users (`auth.<product>` tenant host, krizaka/krizaka-users#31): `start` builds the tenant's start address,\n * `complete` exchanges the one-time code (and the PKCE verifier of a native app) for the verified identity.\n */\nexport function usersIdentityProvider({ baseUrl, appUrl, fetch }: UsersFederationOptions): IdentityProvider {\n const base = baseUrl.replace(/\\/+$/, \"\");\n const app = appUrl.replace(/\\/+$/, \"\");\n return {\n async start(provider, returnTo, options: StartOptions = {}): Promise<StartedSignIn> {\n if (!isFederatedProvider(provider))\n throw new FederationError(FEDERATION_CODE.unknownProvider, `Unknown provider ${String(provider)}`);\n if (!isLocalReturn(returnTo))\n throw new FederationError(FEDERATION_CODE.badReturn, \"That return address is not allowed\");\n const state = crypto.randomUUID();\n const query = new URLSearchParams({ return_to: `${app}${returnTo}`, state });\n if (options.codeChallenge) {\n query.set(\"code_challenge\", options.codeChallenge);\n query.set(\"code_challenge_method\", \"S256\");\n }\n return { url: `${START(base, provider)}?${query}`, state };\n },\n async complete(code, options: CompleteOptions = {}) {\n const response = await fetch(EXCHANGE(base), {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ code, ...(options.codeVerifier ? { code_verifier: options.codeVerifier } : {}) }),\n });\n if (response.status === 400 || response.status === 401 || response.status === 404 || response.status === 410)\n throw new FederationError(FEDERATION_CODE.invalidCode, \"That code is not valid\");\n if (!response.ok)\n throw new FederationError(FEDERATION_CODE.unavailable, `krizaka-users answered ${response.status}`);\n return parseExternalIdentity(await response.json());\n },\n };\n}\n"],"mappings":"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","names":["path","crypto","crypto","crypto","crypto","ident"]}
1
+ {"version":3,"sources":["../src/admin-gate.ts","../src/client-ip.ts","../src/federated-sign-in.ts","../src/landing.ts","../src/migration.ts","../src/password.ts","../src/roles.ts","../src/sessions.ts","../src/sessions-memory.ts","../src/sessions-persistence.ts","../src/users-federation.ts"],"sourcesContent":["/**\n * The administration's locks, as one pure decision every product shares (it was copied in each product's `lib/access.ts`): a session,\n * the ADMIN role, and an address on the allowed list. A pure function of its inputs: the product reads the session and the address\n * and maps the verdict to its own answer.\n */\n\n/** Stable codes of a refusal. */\nexport const ADMIN_REFUSAL = {\n unauthenticated: \"UNAUTHENTICATED\",\n forbidden: \"FORBIDDEN\",\n ipNotAllowed: \"IP_NOT_ALLOWED\",\n notFound: \"NOT_FOUND\",\n} as const;\nexport type AdminRefusal = (typeof ADMIN_REFUSAL)[keyof typeof ADMIN_REFUSAL];\n\n/** The verdict: let in, or the status and code to answer. */\nexport type AdminDecision = { ok: true } | { ok: false; status: 401 | 403 | 404; code: AdminRefusal };\n\n/**\n * The list the administration's address lock reads, from the environment's value (`AUTH_ALLOWED_IPS`): an empty list is « nobody » in\n * production and « anybody » in development, so a forgotten setting never opens the console.\n * @param list the configured addresses\n * @returns the list to hand to `requireAdmin`, or null (anybody)\n */\nexport function allowedIps(list: readonly string[], options: { production: boolean }): readonly string[] | null {\n if (list.length > 0) return list;\n return options.production ? [] : null;\n}\n\n/** Whether an address may use the administration: `null` allowed means anybody, an empty list nobody, and a missing address is never allowed by a list. */\nexport function ipAllowed(ip: string | null, allowed: readonly string[] | null): boolean {\n if (allowed === null) return true;\n return !!ip && allowed.includes(ip);\n}\n\nexport interface AdminGateInput {\n /** The caller's session, or null when there is none. Only its account's role is read. */\n session: { account: { role: string } } | null;\n /** The caller's address (see `clientIp`). */\n ip: string | null;\n /** The addresses allowed (see `allowedIps`); null = anybody. */\n allowedIps: readonly string[] | null;\n /** `api` (default): 401 without a session, 403 with the reason. `page`: the administration does not announce itself, so every failed lock is a 404. */\n surface?: \"api\" | \"page\";\n /** The role that administers. Default `ADMIN`. */\n role?: string;\n}\n\n/**\n * The administration's three locks. The first that fails decides.\n * @returns `{ ok: true }`, or the status (401, 403 or 404 on a page) and the code to answer\n */\nexport function requireAdmin(input: AdminGateInput): AdminDecision {\n const page = input.surface === \"page\";\n const refuse = (status: 401 | 403, code: AdminRefusal): AdminDecision =>\n page ? { ok: false, status: 404, code: ADMIN_REFUSAL.notFound } : { ok: false, status, code };\n if (!input.session) return refuse(401, ADMIN_REFUSAL.unauthenticated);\n if (input.session.account.role !== (input.role ?? \"ADMIN\")) return refuse(403, ADMIN_REFUSAL.forbidden);\n if (!ipAllowed(input.ip, input.allowedIps)) return refuse(403, ADMIN_REFUSAL.ipNotAllowed);\n return { ok: true };\n}\n","/** How a deployment tells the client's address: the header its edge sets, and how many proxies append to `X-Forwarded-For`. */\nexport interface ClientIpRules {\n /** A header the edge sets and overwrites with the client's address (`do-connecting-ip` on DigitalOcean App Platform, `cf-connecting-ip` behind Cloudflare). Read first when present. */\n header?: string | undefined;\n /** How many trusted proxies append to `X-Forwarded-For`: 1 (the default) to 10. */\n trustedProxyHops?: number | undefined;\n}\n\nconst IPV4 = /^(?:\\d{1,3}\\.){3}\\d{1,3}$/;\nconst IPV6 = /^[0-9a-f:]+(?:%\\w+)?$/i;\nfunction isIp(value: string): boolean {\n return (\n (IPV4.test(value) && value.split(\".\").every((n) => Number(n) <= 255)) || (value.includes(\":\") && IPV6.test(value))\n );\n}\n\n/**\n * The client's IP address, as far as this deployment can trust it. `X-Forwarded-For` is a list each proxy appends to: its LEFT end is\n * whatever the client sent (anyone can write `X-Forwarded-For: 1.2.3.4`), so a limit keyed on it is a limit the attacker chooses. The\n * address the last trusted proxy saw is read from the RIGHT:\n *\n * - the `header` the edge sets and overwrites, when present;\n * - otherwise the entry `trustedProxyHops` (default 1: one proxy) from the right of `X-Forwarded-For`, then `X-Real-IP`;\n * - otherwise null (local development without a proxy).\n *\n * The product reads its environment (`ABUSE_CLIENT_IP_HEADER`, `ABUSE_TRUSTED_PROXY_HOPS`) and passes the values: this function reads none.\n * @param headers the request's headers\n * @returns a valid IPv4 or IPv6 address, or null\n */\nexport function clientIp(headers: Headers, rules: ClientIpRules = {}): string | null {\n const custom = rules.header?.trim().toLowerCase();\n if (custom) {\n const value = headers.get(custom)?.split(\",\")[0]?.trim();\n if (value && isIp(value)) return value;\n }\n const hops = Math.max(1, Math.min(10, Number(rules.trustedProxyHops) || 1));\n const chain = (headers.get(\"x-forwarded-for\") ?? \"\")\n .split(\",\")\n .map((part) => part.trim())\n .filter(Boolean);\n const fromRight = chain[chain.length - hops];\n if (fromRight && isIp(fromRight)) return fromRight;\n const real = headers.get(\"x-real-ip\")?.trim();\n if (real && isIp(real)) return real;\n return null;\n}\n","import type { ExternalIdentity } from \"./federation\";\nimport type { IdentityLinkStore } from \"./identity-links\";\n\n/** What federated sign-in needs from the product's accounts: no second user table, just a look-up and, when registration is open, a creation. */\nexport interface FederatedAccounts {\n /** The account that holds this address (lower case), and whether the product has verified it; null when none. */\n findByEmail(email: string): Promise<{ id: string; emailVerified: boolean } | null>;\n /** Creates an account for a person the provider vouches for (address verified, no password). Absent when registration is closed. */\n create?(identity: ExternalIdentity & { email: string }): Promise<string>;\n}\n\n/** Why a sign-in was refused: a stable code the product maps to its words. */\nexport const FEDERATED_REFUSAL = {\n /** The provider did not verify the address (or gave none): nothing is linked, nothing is created. */\n emailNotVerified: \"EMAIL_NOT_VERIFIED\",\n /** An account holds the address but the product never verified it: linking would let a stranger take it over. */\n accountEmailUnverified: \"ACCOUNT_EMAIL_UNVERIFIED\",\n /** No account, and registration is closed. */\n noAccount: \"NO_ACCOUNT\",\n} as const;\nexport type FederatedRefusal = (typeof FEDERATED_REFUSAL)[keyof typeof FEDERATED_REFUSAL];\n\nexport type FederatedSignInResult =\n /** `via`: `link` already linked, `linked` attached to the account of that address now, `created` a new account. */\n | { status: \"signed-in\"; accountId: string; via: \"link\" | \"linked\" | \"created\" }\n | { status: \"refused\"; code: FederatedRefusal };\n\nexport interface FederatedSignInDeps {\n links: IdentityLinkStore;\n accounts: FederatedAccounts;\n clock: { now(): Date };\n}\n\n/**\n * From a verified external identity to an account of the product. The rule that matters: an identity attaches to an EXISTING account\n * only when the address is verified on both sides — by the provider and by the product. Otherwise a stranger who registers at\n * Facebook with your address would sign in as you.\n */\nexport function createFederatedSignIn({ links, accounts, clock }: FederatedSignInDeps) {\n return {\n async signIn(identity: ExternalIdentity): Promise<FederatedSignInResult> {\n const known = await links.find(identity.issuer, identity.subject);\n if (known) return { status: \"signed-in\", accountId: known, via: \"link\" };\n\n const email = identity.email?.trim().toLowerCase();\n if (!email || !identity.emailVerified) return { status: \"refused\", code: FEDERATED_REFUSAL.emailNotVerified };\n\n const existing = await accounts.findByEmail(email);\n if (existing) {\n if (!existing.emailVerified) return { status: \"refused\", code: FEDERATED_REFUSAL.accountEmailUnverified };\n await links.link(existing.id, identity.issuer, identity.subject, clock.now());\n return { status: \"signed-in\", accountId: existing.id, via: \"linked\" };\n }\n\n if (!accounts.create) return { status: \"refused\", code: FEDERATED_REFUSAL.noAccount };\n const accountId = await accounts.create({ ...identity, email });\n await links.link(accountId, identity.issuer, identity.subject, clock.now());\n return { status: \"signed-in\", accountId, via: \"created\" };\n },\n };\n}\n\nexport type FederatedSignIn = ReturnType<typeof createFederatedSignIn>;\n","// The two proxy-side rules of « a signed-in person never sees the public side » (krizaka-ui#827), the same for every\n// product. Web-standard Request/Response only (a Next.js `proxy.ts` calls them). The cookie is a HINT: its presence\n// decides a redirect and a placeholder, never an authorization — the page and the API still resolve the session.\n\n/** The readable flag the proxy keeps next to the HttpOnly session cookie: « a session cookie exists », value `1`, never the token. */\nexport const SESSION_HINT_COOKIE = \"kz-session\";\n\nconst cookieValue = (headers: Headers, name: string): string | undefined => {\n const prefix = `${name}=`;\n return headers\n .get(\"cookie\")\n ?.split(\";\")\n .map((part) => part.trim())\n .find((part) => part.startsWith(prefix))\n ?.slice(prefix.length);\n};\n\n/** Where a signed-in visitor of the landing route goes. */\nexport interface LandingRule {\n /** The product's session cookie name (`verikora_session`). */\n cookie: string;\n /** The landing routes (`[\"/\", \"/fr\", \"/en\"]`): exact paths, a trailing slash tolerated. */\n landing: readonly string[];\n /** The signed-in start page, or a function of the request (the language of the address). */\n home: string | ((request: Request) => string);\n /**\n * The product's own public origin (`https://dev.verikora.com`), from its declared environment (APP_URL), NEVER from the\n * request: `Host` and `x-forwarded-*` are client-controlled. With it the Location is absolute on that origin (what a\n * Next.js proxy accepts). Only the origin of the value is used. Optional outside production (the Location is then\n * relative); REQUIRED in production, where a missing origin throws instead of falling back to the request's headers.\n */\n origin?: string;\n}\n\n// Linear on any input (a loop, not a `/\\/+$/` regular expression: CodeQL js/polynomial-redos on the request path).\nconst trimmed = (path: string) => {\n let end = path.length;\n while (end > 1 && path.charCodeAt(end - 1) === 47) end--;\n return path.slice(0, end);\n};\n\n/**\n * The landing route with a session cookie answers 307 to the home before any HTML: private, never stored, varying with the\n * cookie. `null` for everything else (a signed-out visitor, another page, a write).\n */\nexport function landingRedirect(request: Request, rule: LandingRule): Response | null {\n // Fail loudly at the first request of a misconfigured deployment, signed in or not: never a silent fallback.\n if (!rule.origin && globalThis.process?.env?.NODE_ENV === \"production\") {\n throw new Error(\"landingRedirect: `origin` (the app's declared public URL) is required in production\");\n }\n if (request.method !== \"GET\" && request.method !== \"HEAD\") return null;\n if (!cookieValue(request.headers, rule.cookie)) return null;\n const url = new URL(request.url);\n if (!rule.landing.some((path) => trimmed(path) === trimmed(url.pathname))) return null;\n const home = typeof rule.home === \"function\" ? rule.home(request) : rule.home;\n // Never built from the request's Host or scheme (client-controlled: host-header injection, open redirect). Relative by\n // default; absolute only on the origin the app configured, and a `home` that leaves that origin falls back to relative.\n const target = new URL(home, url);\n const path = `${target.pathname}${target.search}${target.hash}`;\n const base = rule.origin ? new URL(rule.origin).origin : null;\n const location = base && new URL(home, base).origin === base ? new URL(home, base).href : path;\n return new Response(null, {\n status: 307,\n headers: { Location: location, \"Cache-Control\": \"private, no-store\", Vary: \"Cookie\" },\n });\n}\n\n/**\n * Keeps the hint flag in step with the session cookie on any response (pages stay static: the proxy runs on every request,\n * the HTML is the same for everyone). Sets it when the session cookie exists, clears it when it is gone, else touches nothing.\n */\nexport function sessionHint<T extends Response>(\n response: T,\n request: Request,\n options: { cookie: string; secure: boolean },\n): T {\n const session = Boolean(cookieValue(request.headers, options.cookie));\n const flagged = cookieValue(request.headers, SESSION_HINT_COOKIE) === \"1\";\n const base = `${SESSION_HINT_COOKIE}=${session ? \"1\" : \"\"}; Path=/; SameSite=Lax${options.secure ? \"; Secure\" : \"\"}`;\n if (session && !flagged) response.headers.append(\"Set-Cookie\", base);\n else if (!session && cookieValue(request.headers, SESSION_HINT_COOKIE) !== undefined)\n response.headers.append(\"Set-Cookie\", `${base}; Max-Age=0`);\n return response;\n}\n","import { accountsSchema, rolesSchema } from \"./schema\";\n\nconst IDENT = /^[a-z_][a-z0-9_]{0,62}$/;\nconst ident = (name: string) => {\n if (!IDENT.test(name)) throw new Error(`not a safe SQL identifier: ${name}`);\n return name;\n};\n\n/**\n * The SQL of version 1 of the accounts tables, for a product that runs it by hand (idempotent).\n * @deprecated Use `accountsSchema` with `emitMigration` or `adoptMigration` from `@krizaka/db`: the SQL gets a version, a checksum and a marker. Removed in the next major.\n */\nexport function accountsMigration(options: { backfillVerified?: boolean } = {}): string {\n return accountsSchema(options).migrations[0]!.sql({ schema: \"public\", tables: { accounts: \"accounts\" } });\n}\n\n/**\n * The SQL of version 1 of the memberships and invitations tables, for a product that runs it by hand (idempotent).\n * @deprecated Use `rolesSchema` with `emitMigration` or `adoptMigration` from `@krizaka/db`. Removed in the next major.\n */\nexport function rolesMigration(\n roles: readonly string[],\n options: { ownerTable?: string; scopeTable?: string } = {},\n): string {\n const tables = {\n owner: ident(options.ownerTable ?? \"accounts\"),\n ...(options.scopeTable ? { scope: ident(options.scopeTable) } : {}),\n };\n return rolesSchema(roles, { scoped: options.scopeTable !== undefined }).migrations[0]!.sql({\n schema: \"public\",\n tables,\n });\n}\n","import crypto from \"node:crypto\";\n\n/** Called only when a password is hashed or checked: the module also loads in a browser bundle (the role model), where `crypto.scrypt` does not exist. */\nconst scrypt = (password: string, salt: Buffer, keylen: number, options: crypto.ScryptOptions): Promise<Buffer> =>\n new Promise((resolve, reject) => {\n crypto.scrypt(password, salt, keylen, options, (error, key) => (error ? reject(error) : resolve(key)));\n });\n\n/**\n * scrypt (N=2^15, r=8, p=1), a 16-byte salt, stored as `scrypt$N$r$p$salt$hash` (base64url). These are the parameters of the copies\n * the products carried (Verikora, Folioviv): a hash they stored verifies here, and one made here verifies there.\n */\n// @fixed: the cost of the algorithm is part of the stored format; changing it is a new scheme, not a setting\nconst N = 32768;\nconst R = 8;\nconst P = 1;\nconst KEYLEN = 32;\nconst MAXMEM = 64 * 1024 * 1024;\nconst OPTIONS = { N, r: R, p: P, maxmem: MAXMEM };\n\n/** The shortest password the products accept. */\n// @fixed: a security floor shared by every product, not a business parameter\nexport const PASSWORD_MIN_LENGTH = 12;\n/** The longest: a longer one is refused before any work is done (a very long password is a way to make the server spend time). */\n// @fixed: bounds the work a request can make the server do\nexport const PASSWORD_MAX_LENGTH = 256;\n\n/** Why a new password is refused, or null when its length is acceptable. */\nexport function passwordProblem(password: string): \"TOO_SHORT\" | \"TOO_LONG\" | null {\n if (password.length < PASSWORD_MIN_LENGTH) return \"TOO_SHORT\";\n if (password.length > PASSWORD_MAX_LENGTH) return \"TOO_LONG\";\n return null;\n}\n\n/**\n * Hashes a password with a fresh salt.\n * @returns `scrypt$N$r$p$salt$hash`\n */\nexport async function hashPassword(password: string): Promise<string> {\n const salt = crypto.randomBytes(16);\n const hash = await scrypt(password, salt, KEYLEN, OPTIONS);\n return `scrypt$${N}$${R}$${P}$${salt.toString(\"base64url\")}$${hash.toString(\"base64url\")}`;\n}\n\n/**\n * Checks a password against a stored hash, in constant time. A hash of another scheme, a malformed one or an over-long password is `false`.\n * The cost parameters are read from the stored value, so an older hash still verifies after the default changes.\n */\nexport async function verifyPassword(password: string, stored: string): Promise<boolean> {\n const [scheme, n, r, p, salt, hash] = stored.split(\"$\");\n if (scheme !== \"scrypt\" || !salt || !hash || password.length > PASSWORD_MAX_LENGTH) return false;\n const expected = Buffer.from(hash, \"base64url\");\n const given = await scrypt(password, Buffer.from(salt, \"base64url\"), expected.length, {\n N: Number(n),\n r: Number(r),\n p: Number(p),\n maxmem: MAXMEM,\n });\n return given.length === expected.length && crypto.timingSafeEqual(given, expected);\n}\n\nlet decoy: Promise<string> | undefined;\n/** A hash to verify against when the address has no account: the same work, the same time, the same answer. */\nexport const decoyHash = (): Promise<string> => (decoy ??= hashPassword(crypto.randomBytes(24).toString(\"base64url\")));\n","import crypto from \"node:crypto\";\n\nimport type { Query } from \"@krizaka/db\";\nimport { addHours } from \"date-fns\";\n\nimport { can, type RoleModel, ROLES_CODE, RolesError } from \"./roles-model\";\n\nexport { can, defineRoles, type RoleModel, ROLES_CODE, type RolesCode, RolesError } from \"./roles-model\";\n\n/** The SQL executor the roles take: `@krizaka/db`'s `Query`. */\nexport type RolesQuery = Query;\n\nexport interface RolesDeps<Role extends string, Permission extends string> {\n query: RolesQuery;\n model: RoleModel<Role, Permission>;\n /** The invitation link's token prefix (`ai` → `ai1.<43>`) and how the product keeps a token (its keyed hash). */\n tokenPrefix: string;\n tokenHash: (token: string) => string;\n /** Hours an invitation stays valid. */\n inviteTtlHours: number;\n token?: () => string;\n}\n\nexport interface Member<Role extends string> {\n accountId: string;\n email: string;\n name: string | null;\n role: Role;\n since: Date;\n}\nexport interface Invitation<Role extends string> {\n id: string;\n email: string;\n role: Role;\n expiresAt: Date;\n createdAt: Date;\n}\n\n/** Who may do what in a scope, over the product's PostgreSQL. */\nexport function createRoles<Role extends string, Permission extends string>(deps: RolesDeps<Role, Permission>) {\n const { query, model } = deps;\n const known = (role: string): role is Role => (model.roles as readonly string[]).includes(role);\n const mustKnow = (role: string): Role => {\n if (!known(role)) throw new RolesError(ROLES_CODE.unknownRole);\n return role;\n };\n const newToken = deps.token ?? (() => `${deps.tokenPrefix}1.${crypto.randomBytes(32).toString(\"base64url\")}`);\n const owners = (scopeId: string) =>\n query<{ account_id: string }>(\"SELECT account_id FROM memberships WHERE scope_id = $1 AND role = $2\", [\n scopeId,\n ownerRole(),\n ]);\n /** The role that cannot disappear: the first of the model that manages every role. */\n const ownerRole = (): Role =>\n model.roles.find((r) => model.roles.every((x) => model.manages[r].includes(x) || x === r)) ?? model.roles[0]!;\n\n async function roleOf(accountId: string, scopeId: string): Promise<Role | null> {\n const [row] = await query<{ role: Role }>(\"SELECT role FROM memberships WHERE scope_id = $1 AND account_id = $2\", [\n scopeId,\n accountId,\n ]);\n return row?.role ?? null;\n }\n\n return {\n ownerRole,\n roleOf,\n\n /** Every scope the person belongs to, with their role in it. */\n async scopesOf(accountId: string): Promise<{ scopeId: string; role: Role }[]> {\n return (\n await query<{ scope_id: string; role: Role }>(\n \"SELECT scope_id, role FROM memberships WHERE account_id = $1 ORDER BY created_at\",\n [accountId],\n )\n ).map((r) => ({ scopeId: r.scope_id, role: r.role }));\n },\n\n /** The person's role in the scope if it holds the permission; otherwise `NOT_A_MEMBER` (404: nothing tells the scope exists) or `FORBIDDEN`. */\n async authorize(accountId: string, scopeId: string, permission: Permission): Promise<Role> {\n const role = await roleOf(accountId, scopeId);\n if (!role) throw new RolesError(ROLES_CODE.notMember);\n if (!can(model, role, permission)) throw new RolesError(ROLES_CODE.forbidden);\n return role;\n },\n\n /** Puts a person in a scope with a role (changing it if they are there): for a first owner, a seed, an accepted invitation. */\n async grant(scopeId: string, accountId: string, role: string, invitedBy: string | null, now: Date): Promise<void> {\n mustKnow(role);\n await query(\n \"INSERT INTO memberships (scope_id, account_id, role, invited_by, created_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (scope_id, account_id) DO UPDATE SET role = excluded.role\",\n [scopeId, accountId, role, invitedBy, now],\n );\n },\n\n async members(scopeId: string): Promise<Member<Role>[]> {\n const rows = await query<{\n account_id: string;\n email: string;\n name: string | null;\n role: Role;\n created_at: Date;\n }>(\n \"SELECT m.account_id, a.email, a.name, m.role, m.created_at FROM memberships m JOIN accounts a ON a.id = m.account_id WHERE m.scope_id = $1 ORDER BY m.created_at\",\n [scopeId],\n );\n return rows.map((r) => ({\n accountId: r.account_id,\n email: r.email,\n name: r.name,\n role: r.role,\n since: r.created_at,\n }));\n },\n\n /** Changes someone's role. The actor must manage both the current and the new role; the last owner can never be demoted. */\n async setRole(scopeId: string, actor: { id: string }, target: string, role: string): Promise<void> {\n mustKnow(role);\n const mine = await roleOf(actor.id, scopeId);\n const current = await roleOf(target, scopeId);\n if (!mine || !current) throw new RolesError(ROLES_CODE.notMember);\n if (!model.manages[mine].includes(current) || !model.manages[mine].includes(role as Role))\n throw new RolesError(ROLES_CODE.notAllowed);\n if (current === ownerRole() && role !== current && (await owners(scopeId)).length <= 1)\n throw new RolesError(ROLES_CODE.lastOwner);\n await query(\"UPDATE memberships SET role = $3 WHERE scope_id = $1 AND account_id = $2\", [scopeId, target, role]);\n },\n\n /** Removes someone from the scope (or leaves it, when the target is the actor). The last owner cannot go. */\n async remove(scopeId: string, actor: { id: string }, target: string): Promise<void> {\n const mine = await roleOf(actor.id, scopeId);\n const current = await roleOf(target, scopeId);\n if (!mine || !current) throw new RolesError(ROLES_CODE.notMember);\n if (actor.id !== target && !model.manages[mine].includes(current)) throw new RolesError(ROLES_CODE.notAllowed);\n if (current === ownerRole() && (await owners(scopeId)).length <= 1) throw new RolesError(ROLES_CODE.lastOwner);\n await query(\"DELETE FROM memberships WHERE scope_id = $1 AND account_id = $2\", [scopeId, target]);\n },\n\n /** Invites an e-mail address with a role the actor manages. The token is in the answer once (the product mails it); only its hash is kept. A new invitation for the same address replaces the pending one. */\n async invite(\n scopeId: string,\n actor: { id: string },\n input: { email: string; role: string },\n now: Date,\n ): Promise<{ token: string; invitation: Invitation<Role> }> {\n const role = mustKnow(input.role);\n const mine = await roleOf(actor.id, scopeId);\n if (!mine) throw new RolesError(ROLES_CODE.notMember);\n if (!model.manages[mine].includes(role)) throw new RolesError(ROLES_CODE.notAllowed);\n const email = input.email.trim().toLowerCase();\n await query(\"UPDATE invitations SET used_at = $3 WHERE scope_id = $1 AND email = $2 AND used_at IS NULL\", [\n scopeId,\n email,\n now,\n ]);\n const token = newToken();\n const [row] = await query<{ id: string; created_at: Date; expires_at: Date }>(\n \"INSERT INTO invitations (id, scope_id, email, role, token_hash, invited_by, created_at, expires_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, created_at, expires_at\",\n [\n crypto.randomUUID(),\n scopeId,\n email,\n role,\n deps.tokenHash(token),\n actor.id,\n now,\n addHours(now, deps.inviteTtlHours),\n ],\n );\n return {\n token,\n invitation: { id: row!.id, email, role, createdAt: row!.created_at, expiresAt: row!.expires_at },\n };\n },\n\n async pendingInvitations(scopeId: string, now: Date): Promise<Invitation<Role>[]> {\n const rows = await query<{ id: string; email: string; role: Role; created_at: Date; expires_at: Date }>(\n \"SELECT id, email, role, created_at, expires_at FROM invitations WHERE scope_id = $1 AND used_at IS NULL AND expires_at > $2 ORDER BY created_at\",\n [scopeId, now],\n );\n return rows.map((r) => ({\n id: r.id,\n email: r.email,\n role: r.role,\n createdAt: r.created_at,\n expiresAt: r.expires_at,\n }));\n },\n\n async revokeInvitation(scopeId: string, actor: { id: string }, invitationId: string, now: Date): Promise<void> {\n const mine = await roleOf(actor.id, scopeId);\n if (!mine) throw new RolesError(ROLES_CODE.notMember);\n const [row] = await query<{ role: Role }>(\n \"SELECT role FROM invitations WHERE id = $1 AND scope_id = $2 AND used_at IS NULL\",\n [invitationId, scopeId],\n );\n if (!row) throw new RolesError(ROLES_CODE.inviteInvalid);\n if (!model.manages[mine].includes(row.role)) throw new RolesError(ROLES_CODE.notAllowed);\n await query(\"UPDATE invitations SET used_at = $2 WHERE id = $1\", [invitationId, now]);\n },\n\n /** The signed-in account takes the invitation: it must have been sent to this account's address; the link works once and expires. */\n async acceptInvitation(\n token: string,\n account: { id: string; email: string },\n now: Date,\n ): Promise<{ scopeId: string; role: Role }> {\n const [row] = await query<{ id: string; scope_id: string; email: string; role: Role; invited_by: string | null }>(\n \"SELECT id, scope_id, email, role, invited_by FROM invitations WHERE token_hash = $1 AND used_at IS NULL AND expires_at > $2\",\n [deps.tokenHash(token), now],\n );\n if (!row) throw new RolesError(ROLES_CODE.inviteInvalid);\n if (row.email !== account.email.trim().toLowerCase()) throw new RolesError(ROLES_CODE.inviteWrongAccount);\n await query(\"UPDATE invitations SET used_at = $2 WHERE id = $1\", [row.id, now]);\n const current = await roleOf(account.id, row.scope_id);\n // already in: keep the higher role (an invitation never demotes)\n if (!current || model.manages[current].length < model.manages[row.role].length)\n await query(\n \"INSERT INTO memberships (scope_id, account_id, role, invited_by, created_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (scope_id, account_id) DO UPDATE SET role = excluded.role\",\n [row.scope_id, account.id, row.role, row.invited_by, now],\n );\n return {\n scopeId: row.scope_id,\n role: (!current || model.manages[current].length < model.manages[row.role].length ? row.role : current) as Role,\n };\n },\n };\n}\nexport type Roles<Role extends string = string, Permission extends string = string> = ReturnType<\n typeof createRoles<Role, Permission>\n>;\n","import crypto from \"node:crypto\";\n\nimport type { Clock } from \"@krizaka/db\";\n\n/**\n * Web sessions (the copies each product carried: Verikora `ys1.`, Folioviv `as1.`). The cookie holds a random token\n * (`<prefix>1.` and 32 bytes); the store keeps only its keyed hash, so a leaked table opens nothing. The account is re-read on every\n * request (a suspension applies at once). The lifetime is the setting `auth.sessionDays`.\n */\n\n/** A live session and the account it signs in. `Account` is the product's own shape (id, e-mail, role, locale, name…). */\nexport interface Session<Account> {\n id: string;\n account: Account;\n}\n\n/** What a store keeps for a new session. */\nexport interface NewSession {\n accountId: string;\n tokenHash: string;\n createdAt: Date;\n expiresAt: Date;\n ip: string | null;\n userAgent: string | null;\n}\n\n/** Where sessions live. Adapters: the PostgreSQL store over the product's `sessions` table, and a memory one. */\nexport interface SessionStore<Account> {\n insert(session: NewSession): Promise<void>;\n /** The session of this hash, when it has not expired at `now` and its account is active; null otherwise. */\n find(tokenHash: string, now: Date): Promise<Session<Account> | null>;\n delete(tokenHash: string): Promise<void>;\n}\n\nexport interface SessionsDeps<Account> {\n store: SessionStore<Account>;\n clock: Clock;\n /** The product's token letters: `ys` makes `ys1.<43 characters>`. Lower-case letters, 1 to 4. */\n tokenPrefix: string;\n /** What keys the stored hash (`AUTH_SESSION_SECRET`). A function lets the product read its environment when it is needed. */\n secret: string | (() => string);\n /** Days a new session lives: the setting `auth.sessionDays`, read for each new session. */\n sessionDays: number | (() => number | Promise<number>);\n}\n\nexport interface Sessions<Account> {\n /** A fresh token: shaped `<prefix>1.<43>`, random, never stored (only its hash is). */\n newToken(): string;\n isToken(value: string | undefined): value is string;\n /** The keyed hash the store keeps for a token. */\n tokenHash(token: string): string;\n /** Opens a session: the token for the cookie (shown once) and when it expires. */\n create(\n accountId: string,\n meta: { ip: string | null; userAgent: string | null },\n ): Promise<{ token: string; expires: Date }>;\n /** The session a token opens, or null (unknown, malformed, expired, account not active). */\n resolve(token: string | undefined): Promise<Session<Account> | null>;\n /** Ends the session of this token. A malformed token is ignored. */\n revoke(token: string | undefined): Promise<void>;\n}\n\nconst BODY = /^[A-Za-z0-9_-]{43}$/;\nconst PREFIX = /^[a-z]{1,4}$/;\nconst USER_AGENT_MAX = 300;\n\n/**\n * The sessions service of a product.\n * @param deps the store, the clock, the token letters, the secret and the lifetime\n * @throws RangeError for a token prefix that is not 1 to 4 lower-case letters, or a lifetime that is not a whole number of days from 1\n */\nexport function createSessions<Account>(deps: SessionsDeps<Account>): Sessions<Account> {\n if (!PREFIX.test(deps.tokenPrefix))\n throw new RangeError(`tokenPrefix must be 1 to 4 lower-case letters, got ${deps.tokenPrefix}`);\n const head = `${deps.tokenPrefix}1.`;\n const secret = () => (typeof deps.secret === \"function\" ? deps.secret() : deps.secret);\n const isToken = (value: string | undefined): value is string =>\n !!value && value.startsWith(head) && BODY.test(value.slice(head.length));\n const tokenHash = (token: string) => crypto.createHmac(\"sha256\", `session:${secret()}`).update(token).digest(\"hex\");\n const newToken = () => `${head}${crypto.randomBytes(32).toString(\"base64url\")}`;\n return {\n newToken,\n isToken,\n tokenHash,\n async create(accountId, meta) {\n const days = typeof deps.sessionDays === \"function\" ? await deps.sessionDays() : deps.sessionDays;\n if (!Number.isInteger(days) || days < 1)\n throw new RangeError(`sessionDays must be a whole number of days from 1, got ${days}`);\n const token = newToken();\n const createdAt = deps.clock.now();\n // days of 24 hours, not calendar days: the same instant whatever the server's time zone (the copies added 86 400 000 ms a day)\n const expires = new Date(createdAt);\n expires.setUTCHours(expires.getUTCHours() + days * 24);\n await deps.store.insert({\n accountId,\n tokenHash: tokenHash(token),\n createdAt,\n expiresAt: expires,\n ip: meta.ip,\n userAgent: meta.userAgent?.slice(0, USER_AGENT_MAX) ?? null,\n });\n return { token, expires };\n },\n async resolve(token) {\n if (!isToken(token)) return null;\n return deps.store.find(tokenHash(token), deps.clock.now());\n },\n async revoke(token) {\n if (!isToken(token)) return;\n await deps.store.delete(tokenHash(token));\n },\n };\n}\n\n/** The attributes of the session cookie, for the product's framework (`cookies().set(cookie)`, `NextResponse.cookies.set`…). */\nexport interface SessionCookie {\n name: string;\n value: string;\n httpOnly: true;\n secure: boolean;\n sameSite: \"lax\";\n path: \"/\";\n expires: Date;\n}\n\n/**\n * The session cookie: HttpOnly, SameSite=Lax, on the whole site, `Secure` where the product says (production).\n * @param options the cookie's name (`verikora_session`) and whether it is `Secure`\n */\nexport function sessionCookie(token: string, expires: Date, options: { name: string; secure: boolean }): SessionCookie {\n return {\n name: options.name,\n value: token,\n httpOnly: true,\n secure: options.secure,\n sameSite: \"lax\",\n path: \"/\",\n expires,\n };\n}\n\n/** The same cookie, expired now: what signing out sets. */\nexport function clearedSessionCookie(options: { name: string; secure: boolean }, now: Date): SessionCookie {\n return sessionCookie(\"\", now, options);\n}\n\n/**\n * The session token of a request: the native apps' `Authorization: Bearer` (which wins over a stale cookie), else the web's cookie.\n * @param headers the request's headers\n * @param options the cookie's name; `bearer: false` for a product with no native app\n * @returns the raw token (not yet checked), or undefined\n */\nexport function sessionTokenOf(headers: Headers, options: { cookie: string; bearer?: boolean }): string | undefined {\n if (options.bearer !== false) {\n const bearer = /^bearer (\\S+)$/i.exec(headers.get(\"authorization\") ?? \"\")?.[1];\n if (bearer) return bearer;\n }\n const prefix = `${options.cookie}=`;\n return headers\n .get(\"cookie\")\n ?.split(\";\")\n .map((part) => part.trim())\n .find((part) => part.startsWith(prefix))\n ?.slice(prefix.length);\n}\n","import type { NewSession, Session, SessionStore } from \"./sessions\";\n\n/**\n * An in-memory session store, for unit tests and demos. `accountOf` plays the accounts table: it answers the account of an id, or null\n * when it does not exist or is not active.\n */\nexport function memorySessionStore<Account>(\n accountOf: (accountId: string) => Account | null,\n): SessionStore<Account> & { rows(): readonly NewSession[] } {\n const rows = new Map<string, NewSession & { id: string }>();\n let next = 0;\n return {\n async insert(session) {\n if (rows.has(session.tokenHash)) throw new Error(\"duplicate session token hash\");\n next += 1;\n rows.set(session.tokenHash, { ...session, id: `session-${next}` });\n },\n async find(tokenHash, now): Promise<Session<Account> | null> {\n const row = rows.get(tokenHash);\n if (!row || row.expiresAt.getTime() <= now.getTime()) return null;\n const account = accountOf(row.accountId);\n return account === null ? null : { id: row.id, account };\n },\n async delete(tokenHash) {\n rows.delete(tokenHash);\n },\n rows: () => [...rows.values()],\n };\n}\n","import { ident, type Query } from \"@krizaka/db\";\n\nimport type { Session, SessionStore } from \"./sessions\";\n\nexport interface DbSessionStoreOptions<Account> {\n /** The product's session table. Default `sessions` (`id`, `account_id`, `token_hash` unique, `created_at`, `expires_at`, `ip`, `user_agent`). */\n sessions?: string;\n /** The product's account table. Default `accounts`. */\n accounts?: string;\n /** The account columns the session carries (`id`, `email`, `role`, `locale`, `name`…): all checked, all read in one query with the session. */\n columns: readonly string[];\n /** Builds the product's account from a row holding exactly `columns`. */\n toAccount: (row: Record<string, unknown>) => Account;\n /** An account that is not active signs nobody in (a suspension applies at once): the column and the value it must have (`status` = `ACTIVE`). */\n active?: { column: string; equals: string };\n}\n\n/**\n * The session store over the product's own tables (`sessions` joined to `accounts`): the declared tables and columns are checked by\n * `ident()` and nothing else reaches SQL. The expiry is compared with the clock's `now`, not the database's.\n * @param query the product's query\n * @param options the table names, the account columns and how the account is built\n * @throws DbError `DB_UNSAFE_IDENTIFIER` for a table or column name that is not a safe identifier\n */\nexport function dbSessionStore<Account>(query: Query, options: DbSessionStoreOptions<Account>): SessionStore<Account> {\n const sessions = ident(options.sessions ?? \"sessions\");\n const accounts = ident(options.accounts ?? \"accounts\");\n const columns = options.columns.map((column) => `a.${ident(column)}`).join(\", \");\n const active = options.active ? ` AND a.${ident(options.active.column)} = $3` : \"\";\n return {\n async insert(session) {\n await query(\n `INSERT INTO ${sessions} (account_id, token_hash, created_at, expires_at, ip, user_agent) VALUES ($1, $2, $3, $4, $5, $6)`,\n [session.accountId, session.tokenHash, session.createdAt, session.expiresAt, session.ip, session.userAgent],\n );\n },\n async find(tokenHash, now): Promise<Session<Account> | null> {\n const params: unknown[] = [tokenHash, now];\n if (options.active) params.push(options.active.equals);\n const [row] = await query<Record<string, unknown> & { session_id: string }>(\n `SELECT s.id AS session_id, ${columns} FROM ${sessions} s JOIN ${accounts} a ON a.id = s.account_id WHERE s.token_hash = $1 AND s.expires_at > $2${active}`,\n params,\n );\n if (!row) return null;\n const { session_id: id, ...account } = row;\n return { id, account: options.toAccount(account) };\n },\n async delete(tokenHash) {\n await query(`DELETE FROM ${sessions} WHERE token_hash = $1`, [tokenHash]);\n },\n };\n}\n","import {\n type CompleteOptions,\n type ExternalIdentity,\n type FederatedProvider,\n FEDERATION_CODE,\n FederationError,\n type IdentityProvider,\n isFederatedProvider,\n isLocalReturn,\n type StartedSignIn,\n type StartOptions,\n} from \"./federation\";\n\n/** The `fetch` the adapter calls krizaka-users with (injected: no global, no network in tests). */\nexport type Fetcher = (\n url: string,\n init: { method: \"POST\"; headers: Record<string, string>; body: string },\n) => Promise<{ ok: boolean; status: number; json(): Promise<unknown> }>;\n\nexport interface UsersFederationOptions {\n /** The tenant host of the product: `https://auth.dev.folioviv.com`. */\n baseUrl: string;\n /** The product's public address: the start URL sends the person back to it, so `returnTo` is absolute from it. */\n appUrl: string;\n fetch: Fetcher;\n}\n\nconst START = (base: string, provider: FederatedProvider) => `${base}/api/v1/auth/web/${provider}/start`;\nconst EXCHANGE = (base: string) => `${base}/api/v1/auth/web/exchange`;\n\nconst text = (value: unknown): string | null => (typeof value === \"string\" && value.length > 0 ? value : null);\n\n/** Reads krizaka-users' answer into an identity; anything that is not a complete identity is refused. */\nexport function parseExternalIdentity(body: unknown): ExternalIdentity {\n const o = (body ?? {}) as Record<string, unknown>;\n const issuer = text(o.issuer);\n const subject = text(o.subject);\n if (!issuer || !subject)\n throw new FederationError(FEDERATION_CODE.unavailable, \"krizaka-users answered without an issuer and a subject\");\n return { issuer, subject, email: text(o.email), emailVerified: o.email_verified === true, name: text(o.name) };\n}\n\n/**\n * The adapter for the users service (`auth.<product>` tenant host), which lives in krizaka/krizaka-java `krizaka-users/` since\n * ADR-021 (the federation landed as krizaka/krizaka-users#31, the old split): `start` builds the tenant's start address,\n * `complete` exchanges the one-time code (and the PKCE verifier of a native app) for the verified identity.\n */\nexport function usersIdentityProvider({ baseUrl, appUrl, fetch }: UsersFederationOptions): IdentityProvider {\n const base = baseUrl.replace(/\\/+$/, \"\");\n const app = appUrl.replace(/\\/+$/, \"\");\n return {\n async start(provider, returnTo, options: StartOptions = {}): Promise<StartedSignIn> {\n if (!isFederatedProvider(provider))\n throw new FederationError(FEDERATION_CODE.unknownProvider, `Unknown provider ${String(provider)}`);\n if (!isLocalReturn(returnTo))\n throw new FederationError(FEDERATION_CODE.badReturn, \"That return address is not allowed\");\n const state = crypto.randomUUID();\n const query = new URLSearchParams({ return_to: `${app}${returnTo}`, state });\n if (options.codeChallenge) {\n query.set(\"code_challenge\", options.codeChallenge);\n query.set(\"code_challenge_method\", \"S256\");\n }\n return { url: `${START(base, provider)}?${query}`, state };\n },\n async complete(code, options: CompleteOptions = {}) {\n const response = await fetch(EXCHANGE(base), {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ code, ...(options.codeVerifier ? { code_verifier: options.codeVerifier } : {}) }),\n });\n if (response.status === 400 || response.status === 401 || response.status === 404 || response.status === 410)\n throw new FederationError(FEDERATION_CODE.invalidCode, \"That code is not valid\");\n if (!response.ok)\n throw new FederationError(FEDERATION_CODE.unavailable, `krizaka-users answered ${response.status}`);\n return parseExternalIdentity(await response.json());\n },\n };\n}\n"],"mappings":"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","names":["path","crypto","crypto","crypto","crypto","ident"]}
@@ -95,7 +95,7 @@ declare function identityLinksMigration(): string;
95
95
  declare function dbIdentityLinks(query: Query): IdentityLinkStore;
96
96
 
97
97
  /**
98
- * Web sessions (the copies each product carried: Yomizaka `ys1.`, Akinaza `as1.`). The cookie holds a random token
98
+ * Web sessions (the copies each product carried: Verikora `ys1.`, Folioviv `as1.`). The cookie holds a random token
99
99
  * (`<prefix>1.` and 32 bytes); the store keeps only its keyed hash, so a leaked table opens nothing. The account is re-read on every
100
100
  * request (a suspension applies at once). The lifetime is the setting `auth.sessionDays`.
101
101
  */
@@ -167,7 +167,7 @@ interface SessionCookie {
167
167
  }
168
168
  /**
169
169
  * The session cookie: HttpOnly, SameSite=Lax, on the whole site, `Secure` where the product says (production).
170
- * @param options the cookie's name (`yomizaka_session`) and whether it is `Secure`
170
+ * @param options the cookie's name (`verikora_session`) and whether it is `Secure`
171
171
  */
172
172
  declare function sessionCookie(token: string, expires: Date, options: {
173
173
  name: string;
package/dist/settings.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  PASSWORD_RESET_MAX_MINUTES,
3
3
  PASSWORD_RESET_MIN_MINUTES,
4
4
  PASSWORD_RESET_TTL_MINUTES
5
- } from "./chunk-34CFR7EE.js";
5
+ } from "./chunk-73MO6V5M.js";
6
6
 
7
7
  // src/settings.ts
8
8
  import { integer } from "@krizaka/settings";
package/dist/testkit.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { E as ExternalIdentity, I as IdentityProvider, S as SessionStore, a as IdentityLinkStore } from './sessions-DZXjHjzd.js';
1
+ import { E as ExternalIdentity, I as IdentityProvider, S as SessionStore, a as IdentityLinkStore } from './sessions-DscdTKEJ.js';
2
2
  import '@krizaka/db';
3
3
 
4
4
  /** What an `IdentityProvider` adapter is run against: the provider, and a way to make the far side hand out a one-time code. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krizaka/auth",
3
- "version": "0.13.2",
3
+ "version": "0.13.3",
4
4
  "description": "The account of a Krizaka product — name, language, theme, avatar, e-mail change by link with the verified flag, password, sessions with sign-out of the others, data export, deletion — behind a port, with a PostgreSQL adapter and its migration. Each product keeps its own accounts; no framework, no wall clock, no mail vendor.",
5
5
  "keywords": [
6
6
  "krizaka",
@@ -63,7 +63,7 @@
63
63
  "dependencies": {
64
64
  "date-fns": "^4.4.0",
65
65
  "@krizaka/db": "^0.3.1",
66
- "@krizaka/settings": "^0.6.0"
66
+ "@krizaka/settings": "^0.7.0"
67
67
  },
68
68
  "peerDependencies": {
69
69
  "vitest": ">=4",
@@ -86,7 +86,7 @@
86
86
  "typescript": "^5.9.3",
87
87
  "vitest": "^4.1.11",
88
88
  "zod": "^4.6.7",
89
- "@krizaka/config": "^0.20.0"
89
+ "@krizaka/config": "^0.21.0"
90
90
  },
91
91
  "scripts": {
92
92
  "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsup",
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/port.ts","../src/accounts.ts"],"sourcesContent":["/**\n * The account PORT (ADR-0008 of Yomizaka, now shared). A product's account — name, language, theme, avatar, e-mail change with\n * verification, password (changed when signed in, or reset by a mailed link), sessions, export, deletion — behind one interface. Every product keeps its OWN accounts: no central\n * service, no runtime dependency between products.\n */\nexport type Theme = \"light\" | \"dark\" | \"system\";\n\nexport interface AccountProfile {\n id: string;\n email: string;\n name: string | null;\n locale: \"en\" | \"fr\";\n /** The theme the person chose; null = follow the device. */\n theme: Theme | null;\n /** A seed of the default avatars (the UI draws it); null = derived from the e-mail address. */\n avatarSeed: string | null;\n emailVerified: boolean;\n /** An address awaiting its verification link, if the person asked to change it. */\n pendingEmail: string | null;\n}\n\nexport interface ProfilePatch {\n name?: string | null;\n locale?: \"en\" | \"fr\";\n theme?: Theme | null;\n avatarSeed?: string | null;\n}\n\n/** A sign-in of the person: where from and when. Never the token. */\nexport interface SessionSummary {\n id: string;\n createdAt: Date;\n expiresAt: Date;\n ip: string | null;\n userAgent: string | null;\n current: boolean;\n}\n\nexport interface AccountService {\n profile(accountId: string): Promise<AccountProfile>;\n updateProfile(accountId: string, patch: ProfilePatch): Promise<AccountProfile>;\n /** Asks to change the address: the password is checked, a link is mailed to the NEW address. Answers the same whether or not it is already taken (no enumeration). */\n requestEmailChange(accountId: string, input: { newEmail: string; password: string }, now: Date): Promise<void>;\n /** Applies the change from the mailed link (single use, expires); the new address is verified by that very act. */\n confirmEmailChange(token: string, now: Date): Promise<\"CHANGED\" | \"INVALID\">;\n /** Changes the password (the current one is checked) and signs every other session out; `keepSessionId` stays. */\n changePassword(accountId: string, input: { current: string; next: string }, keepSessionId?: string): Promise<void>;\n /** Asks for a reset link, mailed (in the person's language) when an account with a password has this address; answers the same either way (no enumeration). */\n requestPasswordReset(email: string, now: Date): Promise<void>;\n /** Sets a new password from the mailed link — single use, expires — and signs every session out. The new password is checked before the link is used up. */\n resetPassword(token: string, next: string, now: Date): Promise<\"CHANGED\" | \"INVALID\">;\n /** Checks the password. Throws `AccountError(403, \"WRONG_PASSWORD\")`. */\n confirmPassword(accountId: string, password: string): Promise<void>;\n /** The person's live sign-ins, newest first; `currentSessionId` is flagged. */\n sessions(accountId: string, now: Date, currentSessionId?: string): Promise<SessionSummary[]>;\n /** Signs every other session out (and returns how many); `keepSessionId` stays. */\n signOutOthers(accountId: string, keepSessionId: string): Promise<number>;\n /** What the product keeps about the person, as one JSON-able document: the profile, the live sign-ins, and what `extra` adds. */\n exportAccount(\n accountId: string,\n now: Date,\n extra?: () => Promise<Record<string, unknown>>,\n ): Promise<Record<string, unknown>>;\n /** Deletes the identity: the account, its sessions and everything that hangs on it (the product erases its own data first). */\n deleteAccount(accountId: string): Promise<void>;\n}\n\nexport class AccountError extends Error {\n constructor(\n readonly status: number,\n readonly code: string,\n message: string,\n ) {\n super(message);\n this.name = \"AccountError\";\n }\n}\n","import crypto from \"node:crypto\";\n\nimport type { Query } from \"@krizaka/db\";\nimport { addHours, addMinutes } from \"date-fns\";\n\nimport {\n AccountError,\n type AccountProfile,\n type AccountService,\n type ProfilePatch,\n type SessionSummary,\n type Theme,\n} from \"./port\";\n\n/** A SQL executor: `@krizaka/db`'s `Query`, which the product passes (its pool, or its transaction's client). */\nexport type { Query };\n\nexport interface Mail {\n to: string;\n /** The language of the mail (the person's). */\n locale: \"en\" | \"fr\";\n subject: string;\n text: string;\n /**\n * The link this mail carries, as data: a product that sends branded HTML (`@krizaka/email`: `passwordReset`, `emailChange`)\n * renders it from this instead of from `text`. Absent on a mail that is not a link.\n */\n link?: MailLink;\n}\n\n/** A mailed link: what it is for, where it goes, and how long it lives. */\nexport interface MailLink {\n kind: \"passwordReset\" | \"emailChange\";\n url: string;\n /** `passwordReset`: the lifetime in minutes. */\n minutes?: number;\n /** `emailChange`: the lifetime in hours. */\n hours?: number;\n}\n\nexport interface AccountDeps {\n query: Query;\n /**\n * Runs `work` in one transaction on one connection (BEGIN … COMMIT, rolled back when `work` throws) — the product's pool\n * knows how. The password reset runs under it: the account row is locked, the links retired and issued, the token claimed,\n * the password set and the sessions ended as one. Without it, the statements run one by one on `query` (one process, no\n * concurrent requests): give it in production.\n */\n transaction?: <T>(work: (query: Query) => Promise<T>) => Promise<T>;\n mail: (mail: Mail) => Promise<void>;\n /** `https://www.akinaza.com`: the e-mail change link is `<baseUrl>/<locale>/confirm-email?token=…`, the reset link `<baseUrl>/<locale>/reset-password?token=…`. */\n baseUrl: string;\n /** The product's name, in the words of the mail (« Akinaza »). */\n product: string;\n /** The product's token prefix, two or three letters (`ye1.` for Yomizaka: `ye`). The tokens are `<prefix>1.<43 base64url>`. */\n tokenPrefix: string;\n passwords: {\n hash: (password: string) => Promise<string>;\n verify: (password: string, stored: string) => Promise<boolean>;\n };\n /** How a token is kept: the product's own keyed hash, the one its sessions use. */\n tokenHash: (token: string) => string;\n /**\n * How long a password reset link lives, in minutes: a whole number from 5 to 1440 (`PASSWORD_RESET_MIN_MINUTES`–`PASSWORD_RESET_MAX_MINUTES`),\n * default `PASSWORD_RESET_TTL_MINUTES` (30). The product reads it from its settings; the expiry and the mailed text both use it.\n * Out of range, `dbAccounts` throws.\n */\n passwordResetTtlMinutes?: number;\n /** Test seam: the token to issue. */\n token?: () => string;\n}\n\n// @fixed: the lifetime of an e-mail change link, a security parameter\nexport const EMAIL_CHANGE_TTL_HOURS = 24;\n/** The default lifetime of a password reset link, in minutes (`AccountDeps.passwordResetTtlMinutes` changes it). */\n// @fixed: the default lifetime of a password reset link, a security parameter\nexport const PASSWORD_RESET_TTL_MINUTES = 30;\n// @fixed: the shortest lifetime a product may set: a person must have time to open the mail\nexport const PASSWORD_RESET_MIN_MINUTES = 5;\n// @fixed: the longest lifetime a product may set: a day\nexport const PASSWORD_RESET_MAX_MINUTES = 1440;\n\nconst checkResetTtl = (minutes: number): number => {\n if (!Number.isInteger(minutes) || minutes < PASSWORD_RESET_MIN_MINUTES || minutes > PASSWORD_RESET_MAX_MINUTES) {\n throw new RangeError(\n `passwordResetTtlMinutes must be a whole number from ${PASSWORD_RESET_MIN_MINUTES} to ${PASSWORD_RESET_MAX_MINUTES}, got ${minutes}`,\n );\n }\n return minutes;\n};\nconst NAME_MAX = 80;\nconst PASSWORD_MIN = 12;\nconst PASSWORD_MAX = 256;\nconst THEMES: readonly Theme[] = [\"light\", \"dark\", \"system\"];\n\n/** One format for every mailed link (an e-mail change, a password reset): the product's prefix, a version, 256 random bits. */\nexport const linkToken = (prefix: string) => `${prefix}1.${crypto.randomBytes(32).toString(\"base64url\")}`;\nexport const isLinkToken = (prefix: string, value: string | undefined | null): value is string =>\n !!value && new RegExp(`^${prefix}1\\\\.[A-Za-z0-9_-]{43}$`).test(value);\nexport const emailChangeToken = linkToken;\nexport const isEmailChangeToken = isLinkToken;\n\nexport function passwordResetMail(\n product: string,\n locale: \"en\" | \"fr\",\n url: string,\n ttlMinutes: number = PASSWORD_RESET_TTL_MINUTES,\n) {\n checkResetTtl(ttlMinutes);\n return locale === \"fr\"\n ? {\n subject: `Réinitialisez votre mot de passe — ${product}`,\n text: `Bonjour,\\n\\nPour choisir un nouveau mot de passe pour votre compte ${product}, ouvrez ce lien (valide ${ttlMinutes} min, une seule fois) :\\n${url}\\n\\nSi vous n'avez rien demandé, ignorez ce message : votre mot de passe ne change pas.\\n`,\n }\n : {\n subject: `Reset your password — ${product}`,\n text: `Hello,\\n\\nTo choose a new password for your ${product} account, open this link (valid ${ttlMinutes} min, once):\\n${url}\\n\\nIf you did not ask for this, ignore this message: your password does not change.\\n`,\n };\n}\n\nexport function emailChangeMail(product: string, locale: \"en\" | \"fr\", url: string) {\n return locale === \"fr\"\n ? {\n subject: `Confirmez votre nouvelle adresse — ${product}`,\n text: `Bonjour,\\n\\nPour utiliser cette adresse avec votre compte ${product}, confirmez-la en ouvrant ce lien (valide ${EMAIL_CHANGE_TTL_HOURS} h) :\\n${url}\\n\\nSi vous n'avez rien demandé, ignorez ce message : rien ne change.\\n`,\n }\n : {\n subject: `Confirm your new address — ${product}`,\n text: `Hello,\\n\\nTo use this address with your ${product} account, confirm it by opening this link (valid ${EMAIL_CHANGE_TTL_HOURS} h):\\n${url}\\n\\nIf you did not ask for this, ignore this message: nothing changes.\\n`,\n };\n}\n\n/** One « @ », something on each side, a dot inside the domain, no space — checked without a backtracking pattern. */\nconst looksLikeEmail = (address: string): boolean => {\n if (address.length > 320 || /\\s/.test(address)) return false;\n const at = address.indexOf(\"@\");\n if (at < 1 || at !== address.lastIndexOf(\"@\")) return false;\n const domain = address.slice(at + 1);\n const dot = domain.lastIndexOf(\".\");\n return dot > 0 && dot < domain.length - 1;\n};\n\nconst cleanName = (raw: string): string => {\n const name = raw.replace(/\\s+/g, \" \").trim();\n if (name.length > NAME_MAX) throw new AccountError(400, \"BAD_REQUEST\", `A name is at most ${NAME_MAX} characters`);\n return name;\n};\n\ninterface ProfileRow {\n id: string;\n email: string;\n name: string | null;\n locale: \"en\" | \"fr\";\n theme: Theme | null;\n avatar_seed: string | null;\n email_verified_at: Date | null;\n pending: string | null;\n}\n\n/** The adapter over the product's own PostgreSQL (`accounts`, `sessions`, `email_changes`, `password_resets`; see `accountsMigration`). */\nexport function dbAccounts(deps: AccountDeps): AccountService {\n const { query } = deps;\n const resetTtlMinutes = checkResetTtl(deps.passwordResetTtlMinutes ?? PASSWORD_RESET_TTL_MINUTES);\n const transaction = deps.transaction ?? (<T>(work: (q: Query) => Promise<T>) => work(query));\n const wrong = () => new AccountError(403, \"WRONG_PASSWORD\", \"The password is not right\");\n\n async function profileOf(id: string): Promise<AccountProfile> {\n const [row] = await query<ProfileRow>(\n `SELECT a.id, a.email, a.name, a.locale, a.theme, a.avatar_seed, a.email_verified_at,\n (SELECT c.new_email FROM email_changes c WHERE c.account_id = a.id AND c.used_at IS NULL AND c.expires_at > now() ORDER BY c.created_at DESC LIMIT 1) AS pending\n FROM accounts a WHERE a.id = $1`,\n [id],\n );\n if (!row) throw new AccountError(404, \"NOT_FOUND\", \"No such account\");\n return {\n id: row.id,\n email: row.email,\n name: row.name,\n locale: row.locale,\n theme: row.theme,\n avatarSeed: row.avatar_seed,\n emailVerified: !!row.email_verified_at,\n pendingEmail: row.pending,\n };\n }\n\n async function confirmPassword(id: string, password: string): Promise<void> {\n const [row] = await query<{ password_hash: string | null }>(\"SELECT password_hash FROM accounts WHERE id = $1\", [\n id,\n ]);\n if (\n !row?.password_hash ||\n password.length > PASSWORD_MAX ||\n !(await deps.passwords.verify(password, row.password_hash))\n )\n throw wrong();\n }\n\n return {\n profile: profileOf,\n\n async updateProfile(id, patch: ProfilePatch) {\n const name = patch.name === undefined ? undefined : patch.name === null ? null : cleanName(patch.name);\n if (patch.avatarSeed && !/^[a-z0-9-]{1,40}$/.test(patch.avatarSeed))\n throw new AccountError(400, \"BAD_REQUEST\", \"Not an avatar\");\n if (patch.theme && !THEMES.includes(patch.theme)) throw new AccountError(400, \"BAD_REQUEST\", \"Not a theme\");\n await query(\n `UPDATE accounts SET name = CASE WHEN $2::boolean THEN $3 ELSE name END, locale = COALESCE($4, locale),\n avatar_seed = CASE WHEN $5::boolean THEN $6 ELSE avatar_seed END, theme = CASE WHEN $7::boolean THEN $8 ELSE theme END WHERE id = $1`,\n [\n id,\n name !== undefined,\n name === \"\" ? null : (name ?? null),\n patch.locale ?? null,\n patch.avatarSeed !== undefined,\n patch.avatarSeed ?? null,\n patch.theme !== undefined,\n patch.theme ?? null,\n ],\n );\n return profileOf(id);\n },\n\n async requestEmailChange(id, input, now) {\n await confirmPassword(id, input.password);\n const address = input.newEmail.trim().toLowerCase();\n if (!looksLikeEmail(address)) throw new AccountError(400, \"BAD_REQUEST\", \"Not an e-mail address\");\n const [taken] = await query(\"SELECT 1 FROM accounts WHERE email = $1\", [address]);\n if (taken) return; // the same answer, no mail: the address is not disclosed\n const profile = await profileOf(id);\n await query(\"UPDATE email_changes SET used_at = $2 WHERE account_id = $1 AND used_at IS NULL\", [id, now]);\n const token = (deps.token ?? (() => emailChangeToken(deps.tokenPrefix)))();\n await query(\"INSERT INTO email_changes (account_id, new_email, token_hash, expires_at) VALUES ($1, $2, $3, $4)\", [\n id,\n address,\n deps.tokenHash(token),\n addHours(now, EMAIL_CHANGE_TTL_HOURS),\n ]);\n const url = `${deps.baseUrl.replace(/\\/$/, \"\")}/${profile.locale}/confirm-email?token=${encodeURIComponent(token)}`;\n await deps.mail({\n to: address,\n locale: profile.locale,\n ...emailChangeMail(deps.product, profile.locale, url),\n link: { kind: \"emailChange\", url, hours: EMAIL_CHANGE_TTL_HOURS },\n });\n },\n\n async confirmEmailChange(token, now) {\n if (!isEmailChangeToken(deps.tokenPrefix, token)) return \"INVALID\";\n const [row] = await query<{ account_id: string; new_email: string }>(\n \"UPDATE email_changes SET used_at = $2 WHERE token_hash = $1 AND used_at IS NULL AND expires_at > $2 RETURNING account_id, new_email\",\n [deps.tokenHash(token), now],\n );\n if (!row) return \"INVALID\";\n try {\n await query(\"UPDATE accounts SET email = $2, email_verified_at = $3 WHERE id = $1\", [\n row.account_id,\n row.new_email,\n now,\n ]);\n } catch {\n return \"INVALID\"; // taken in the meantime\n }\n return \"CHANGED\";\n },\n\n async changePassword(id, input, keepSessionId) {\n await confirmPassword(id, input.current);\n if (input.next.length < PASSWORD_MIN)\n throw new AccountError(400, \"PASSWORD_TOO_SHORT\", `At least ${PASSWORD_MIN} characters`);\n if (input.next.length > PASSWORD_MAX)\n throw new AccountError(400, \"PASSWORD_TOO_LONG\", `At most ${PASSWORD_MAX} characters`);\n await query(\"UPDATE accounts SET password_hash = $2 WHERE id = $1\", [id, await deps.passwords.hash(input.next)]);\n await query(\"DELETE FROM sessions WHERE account_id = $1 AND ($2::uuid IS NULL OR id <> $2::uuid)\", [\n id,\n keepSessionId ?? null,\n ]);\n },\n\n confirmPassword,\n\n async requestPasswordReset(email, now) {\n const address = email.trim().toLowerCase();\n if (!looksLikeEmail(address)) return; // the same answer: nothing is disclosed\n // One transaction, the account row locked: two requests at once leave one valid link — the later one. The mail goes after the commit.\n const issued = await transaction(async (q) => {\n const [row] = await q<{ id: string; locale: \"en\" | \"fr\" }>(\n \"SELECT id, locale FROM accounts WHERE email = $1 AND password_hash IS NOT NULL FOR UPDATE\",\n [address],\n );\n if (!row) return null;\n await q(\"UPDATE password_resets SET used_at = $2 WHERE account_id = $1 AND used_at IS NULL\", [row.id, now]);\n const token = (deps.token ?? (() => linkToken(deps.tokenPrefix)))();\n await q(\"INSERT INTO password_resets (account_id, token_hash, expires_at) VALUES ($1, $2, $3)\", [\n row.id,\n deps.tokenHash(token),\n addMinutes(now, resetTtlMinutes),\n ]);\n return { token, locale: row.locale };\n });\n if (!issued) return;\n const url = `${deps.baseUrl.replace(/\\/$/, \"\")}/${issued.locale}/reset-password?token=${encodeURIComponent(issued.token)}`;\n await deps.mail({\n to: address,\n locale: issued.locale,\n ...passwordResetMail(deps.product, issued.locale, url, resetTtlMinutes),\n link: { kind: \"passwordReset\", url, minutes: resetTtlMinutes },\n });\n },\n\n async resetPassword(token, next, now) {\n if (next.length < PASSWORD_MIN)\n throw new AccountError(400, \"PASSWORD_TOO_SHORT\", `At least ${PASSWORD_MIN} characters`);\n if (next.length > PASSWORD_MAX)\n throw new AccountError(400, \"PASSWORD_TOO_LONG\", `At most ${PASSWORD_MAX} characters`);\n if (!isLinkToken(deps.tokenPrefix, token)) return \"INVALID\";\n // One transaction: the link is claimed, the password set and every session ended as one — a failure leaves the link usable.\n return transaction(async (q) => {\n const [row] = await q<{ account_id: string }>(\n \"UPDATE password_resets SET used_at = $2 WHERE token_hash = $1 AND used_at IS NULL AND expires_at > $2 RETURNING account_id\",\n [deps.tokenHash(token), now],\n );\n if (!row) return \"INVALID\" as const;\n await q(\"UPDATE accounts SET password_hash = $2 WHERE id = $1\", [\n row.account_id,\n await deps.passwords.hash(next),\n ]);\n await q(\"DELETE FROM sessions WHERE account_id = $1\", [row.account_id]);\n return \"CHANGED\" as const;\n });\n },\n\n async sessions(id, now, currentSessionId) {\n const rows = await query<{\n id: string;\n created_at: Date;\n expires_at: Date;\n ip: string | null;\n user_agent: string | null;\n }>(\n \"SELECT id, created_at, expires_at, ip, user_agent FROM sessions WHERE account_id = $1 AND expires_at > $2 ORDER BY created_at DESC\",\n [id, now],\n );\n return rows.map((r): SessionSummary => ({\n id: r.id,\n createdAt: r.created_at,\n expiresAt: r.expires_at,\n ip: r.ip,\n userAgent: r.user_agent,\n current: r.id === currentSessionId,\n }));\n },\n\n async signOutOthers(id, keepSessionId) {\n return (\n await query(\"DELETE FROM sessions WHERE account_id = $1 AND id <> $2::uuid RETURNING id\", [id, keepSessionId])\n ).length;\n },\n\n async exportAccount(id, now, extra) {\n const profile = await profileOf(id);\n const live = await this.sessions(id, now);\n return {\n exportedAt: now.toISOString(),\n account: profile,\n sessions: live.map(({ current: _current, ...s }) => s),\n ...(extra ? await extra() : {}),\n };\n },\n\n async deleteAccount(id) {\n const rows = await query(\"DELETE FROM accounts WHERE id = $1 RETURNING id\", [id]);\n if (!rows.length) throw new AccountError(404, \"NOT_FOUND\", \"No such account\");\n },\n };\n}\n\n/** @deprecated The old name of `dbAccounts` (a name says what a thing does, not the technology). Removed in the next major. */\nexport const postgresAccounts = dbAccounts;\n"],"mappings":"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","names":[]}