@krizaka/auth 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -78,6 +78,8 @@ declare class AccountError extends Error {
78
78
  type Query = <T = Record<string, unknown>>(sql: string, params?: unknown[]) => Promise<T[]>;
79
79
  interface Mail {
80
80
  to: string;
81
+ /** The language of the mail (the person's). */
82
+ locale: "en" | "fr";
81
83
  subject: string;
82
84
  text: string;
83
85
  }
package/dist/index.js CHANGED
@@ -108,7 +108,7 @@ function postgresAccounts(deps) {
108
108
  const token = (deps.token ?? (() => emailChangeToken(deps.tokenPrefix)))();
109
109
  await query("INSERT INTO email_changes (account_id, new_email, token_hash, expires_at) VALUES ($1, $2, $3, $4)", [id, address, deps.tokenHash(token), addHours(now, EMAIL_CHANGE_TTL_HOURS)]);
110
110
  const url = `${deps.baseUrl.replace(/\/$/, "")}/${profile.locale}/confirm-email?token=${encodeURIComponent(token)}`;
111
- await deps.mail({ to: address, ...emailChangeMail(deps.product, profile.locale, url) });
111
+ await deps.mail({ to: address, locale: profile.locale, ...emailChangeMail(deps.product, profile.locale, url) });
112
112
  },
113
113
  async confirmEmailChange(token, now) {
114
114
  if (!isEmailChangeToken(deps.tokenPrefix, token)) return "INVALID";
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/migration.ts","../src/port.ts","../src/postgres.ts"],"sourcesContent":["/**\n * What the account adapter needs from a product's database, safe to run on a database that already has `accounts` and `sessions`:\n * a display name, a theme, an avatar seed, the verified-address flag, and the table of e-mail changes (a link mailed to the NEW\n * address). `accounts` needs `id`, `email`, `locale`, `password_hash`; `sessions` needs `account_id`, `created_at`, `expires_at`, `ip`, `user_agent`.\n * `backfillVerified`: for accounts that exist already, treat the address as verified (invitation-only products).\n */\nexport function accountsMigration(options: { backfillVerified?: boolean } = {}): string {\n return `\nALTER TABLE accounts ADD COLUMN IF NOT EXISTS name text CHECK (name IS NULL OR char_length(name) <= 80);\nALTER TABLE accounts ADD COLUMN IF NOT EXISTS avatar_seed text CHECK (avatar_seed IS NULL OR avatar_seed ~ '^[a-z0-9-]{1,40}$');\nALTER TABLE accounts ADD COLUMN IF NOT EXISTS theme text CHECK (theme IS NULL OR theme IN ('light', 'dark', 'system'));\nALTER TABLE accounts ADD COLUMN IF NOT EXISTS email_verified_at timestamptz;\n${options.backfillVerified ? \"UPDATE accounts SET email_verified_at = created_at WHERE email_verified_at IS NULL;\\n\" : \"\"}CREATE TABLE IF NOT EXISTS email_changes (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,\n new_email text NOT NULL CHECK (new_email = lower(new_email)),\n token_hash text NOT NULL UNIQUE,\n created_at timestamptz NOT NULL DEFAULT now(),\n expires_at timestamptz NOT NULL,\n used_at timestamptz\n);\n`;\n}\n","/**\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, 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 /** 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(accountId: string, now: Date, extra?: () => Promise<Record<string, unknown>>): 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 { addHours } from \"date-fns\";\n\nimport { AccountError, type AccountProfile, type AccountService, type ProfilePatch, type SessionSummary, type Theme } from \"./port\";\n\n/** A SQL executor: the rows of a statement and its parameters (the product's pool). */\nexport type Query = <T = Record<string, unknown>>(sql: string, params?: unknown[]) => Promise<T[]>;\n\nexport interface Mail {\n to: string;\n subject: string;\n text: string;\n}\n\nexport interface AccountDeps {\n query: Query;\n mail: (mail: Mail) => Promise<void>;\n /** `https://www.akinaza.com`: the e-mail change link is `<baseUrl>/<locale>/confirm-email?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: { hash: (password: string) => Promise<string>; verify: (password: string, stored: string) => Promise<boolean> };\n /** How a token is kept: the product's own keyed hash, the one its sessions use. */\n tokenHash: (token: string) => string;\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;\nconst NAME_MAX = 80;\nconst PASSWORD_MIN = 12;\nconst PASSWORD_MAX = 256;\nconst THEMES: readonly Theme[] = [\"light\", \"dark\", \"system\"];\n\nexport const emailChangeToken = (prefix: string) => `${prefix}1.${crypto.randomBytes(32).toString(\"base64url\")}`;\nexport const isEmailChangeToken = (prefix: string, value: string | undefined | null): value is string => !!value && new RegExp(`^${prefix}1\\\\.[A-Za-z0-9_-]{43}$`).test(value);\n\nexport function emailChangeMail(product: string, locale: \"en\" | \"fr\", url: string) {\n return locale === \"fr\"\n ? { subject: `Confirmez votre nouvelle adresse — ${product}`, 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 : { subject: `Confirm your new address — ${product}`, 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/** 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`; see `accountsMigration`). */\nexport function postgresAccounts(deps: AccountDeps): AccountService {\n const { query } = deps;\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 { id: row.id, email: row.email, name: row.name, locale: row.locale, theme: row.theme, avatarSeed: row.avatar_seed, emailVerified: !!row.email_verified_at, pendingEmail: row.pending };\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\", [id]);\n if (!row?.password_hash || password.length > PASSWORD_MAX || !(await deps.passwords.verify(password, row.password_hash))) 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)) 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 [id, name !== undefined, name === \"\" ? null : (name ?? null), patch.locale ?? null, patch.avatarSeed !== undefined, patch.avatarSeed ?? null, patch.theme !== undefined, patch.theme ?? null],\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)\", [id, address, deps.tokenHash(token), addHours(now, EMAIL_CHANGE_TTL_HOURS)]);\n const url = `${deps.baseUrl.replace(/\\/$/, \"\")}/${profile.locale}/confirm-email?token=${encodeURIComponent(token)}`;\n await deps.mail({ to: address, ...emailChangeMail(deps.product, profile.locale, url) });\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 }>(\"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\", [deps.tokenHash(token), now]);\n if (!row) return \"INVALID\";\n try {\n await query(\"UPDATE accounts SET email = $2, email_verified_at = $3 WHERE id = $1\", [row.account_id, row.new_email, now]);\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) throw new AccountError(400, \"PASSWORD_TOO_SHORT\", `At least ${PASSWORD_MIN} characters`);\n if (input.next.length > PASSWORD_MAX) 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)\", [id, keepSessionId ?? null]);\n },\n\n confirmPassword,\n\n async sessions(id, now, currentSessionId) {\n const rows = await query<{ id: string; created_at: Date; expires_at: Date; ip: string | null; user_agent: string | null }>(\"SELECT id, created_at, expires_at, ip, user_agent FROM sessions WHERE account_id = $1 AND expires_at > $2 ORDER BY created_at DESC\", [id, now]);\n return rows.map((r): SessionSummary => ({ id: r.id, createdAt: r.created_at, expiresAt: r.expires_at, ip: r.ip, userAgent: r.user_agent, current: r.id === currentSessionId }));\n },\n\n async signOutOthers(id, keepSessionId) {\n return (await query(\"DELETE FROM sessions WHERE account_id = $1 AND id <> $2::uuid RETURNING id\", [id, keepSessionId])).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 { exportedAt: now.toISOString(), account: profile, sessions: live.map(({ current: _current, ...s }) => s), ...(extra ? await extra() : {}) };\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"],"mappings":";AAMO,SAAS,kBAAkB,UAA0C,CAAC,GAAW;AACtF,SAAO;AAAA;AAAA;AAAA;AAAA;AAAA,EAKP,QAAQ,mBAAmB,0FAA0F,EAAE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAUzH;;;ACqCO,IAAM,eAAN,cAA2B,MAAM;AAAA,EACtC,YACW,QACA,MACT,SACA;AACA,UAAM,OAAO;AAJJ;AACA;AAIT,SAAK,OAAO;AAAA,EACd;AAAA,EANW;AAAA,EACA;AAMb;;;ACpEA,OAAO,YAAY;AAEnB,SAAS,gBAAgB;AA8BlB,IAAM,yBAAyB;AACtC,IAAM,WAAW;AACjB,IAAM,eAAe;AACrB,IAAM,eAAe;AACrB,IAAM,SAA2B,CAAC,SAAS,QAAQ,QAAQ;AAEpD,IAAM,mBAAmB,CAAC,WAAmB,GAAG,MAAM,KAAK,OAAO,YAAY,EAAE,EAAE,SAAS,WAAW,CAAC;AACvG,IAAM,qBAAqB,CAAC,QAAgB,UAAsD,CAAC,CAAC,SAAS,IAAI,OAAO,IAAI,MAAM,wBAAwB,EAAE,KAAK,KAAK;AAEtK,SAAS,gBAAgB,SAAiB,QAAqB,KAAa;AACjF,SAAO,WAAW,OACd,EAAE,SAAS,2CAAsC,OAAO,IAAI,MAAM;AAAA;AAAA,gDAA6D,OAAO,6CAA6C,sBAAsB;AAAA,EAAU,GAAG;AAAA;AAAA;AAAA,EAA0E,IAChS,EAAE,SAAS,mCAA8B,OAAO,IAAI,MAAM;AAAA;AAAA,gCAA2C,OAAO,oDAAoD,sBAAsB;AAAA,EAAS,GAAG;AAAA;AAAA;AAAA,EAA2E;AACnR;AAGA,IAAM,iBAAiB,CAAC,YAA6B;AACnD,MAAI,QAAQ,SAAS,OAAO,KAAK,KAAK,OAAO,EAAG,QAAO;AACvD,QAAM,KAAK,QAAQ,QAAQ,GAAG;AAC9B,MAAI,KAAK,KAAK,OAAO,QAAQ,YAAY,GAAG,EAAG,QAAO;AACtD,QAAM,SAAS,QAAQ,MAAM,KAAK,CAAC;AACnC,QAAM,MAAM,OAAO,YAAY,GAAG;AAClC,SAAO,MAAM,KAAK,MAAM,OAAO,SAAS;AAC1C;AAEA,IAAM,YAAY,CAAC,QAAwB;AACzC,QAAM,OAAO,IAAI,QAAQ,QAAQ,GAAG,EAAE,KAAK;AAC3C,MAAI,KAAK,SAAS,SAAU,OAAM,IAAI,aAAa,KAAK,eAAe,qBAAqB,QAAQ,aAAa;AACjH,SAAO;AACT;AAcO,SAAS,iBAAiB,MAAmC;AAClE,QAAM,EAAE,MAAM,IAAI;AAClB,QAAM,QAAQ,MAAM,IAAI,aAAa,KAAK,kBAAkB,2BAA2B;AAEvF,iBAAe,UAAU,IAAqC;AAC5D,UAAM,CAAC,GAAG,IAAI,MAAM;AAAA,MAClB;AAAA;AAAA;AAAA,MAGA,CAAC,EAAE;AAAA,IACL;AACA,QAAI,CAAC,IAAK,OAAM,IAAI,aAAa,KAAK,aAAa,iBAAiB;AACpE,WAAO,EAAE,IAAI,IAAI,IAAI,OAAO,IAAI,OAAO,MAAM,IAAI,MAAM,QAAQ,IAAI,QAAQ,OAAO,IAAI,OAAO,YAAY,IAAI,aAAa,eAAe,CAAC,CAAC,IAAI,mBAAmB,cAAc,IAAI,QAAQ;AAAA,EAC9L;AAEA,iBAAe,gBAAgB,IAAY,UAAiC;AAC1E,UAAM,CAAC,GAAG,IAAI,MAAM,MAAwC,oDAAoD,CAAC,EAAE,CAAC;AACpH,QAAI,CAAC,KAAK,iBAAiB,SAAS,SAAS,gBAAgB,CAAE,MAAM,KAAK,UAAU,OAAO,UAAU,IAAI,aAAa,EAAI,OAAM,MAAM;AAAA,EACxI;AAEA,SAAO;AAAA,IACL,SAAS;AAAA,IAET,MAAM,cAAc,IAAI,OAAqB;AAC3C,YAAM,OAAO,MAAM,SAAS,SAAY,SAAY,MAAM,SAAS,OAAO,OAAO,UAAU,MAAM,IAAI;AACrG,UAAI,MAAM,cAAc,CAAC,oBAAoB,KAAK,MAAM,UAAU,EAAG,OAAM,IAAI,aAAa,KAAK,eAAe,eAAe;AAC/H,UAAI,MAAM,SAAS,CAAC,OAAO,SAAS,MAAM,KAAK,EAAG,OAAM,IAAI,aAAa,KAAK,eAAe,aAAa;AAC1G,YAAM;AAAA,QACJ;AAAA;AAAA,QAEA,CAAC,IAAI,SAAS,QAAW,SAAS,KAAK,OAAQ,QAAQ,MAAO,MAAM,UAAU,MAAM,MAAM,eAAe,QAAW,MAAM,cAAc,MAAM,MAAM,UAAU,QAAW,MAAM,SAAS,IAAI;AAAA,MAC9L;AACA,aAAO,UAAU,EAAE;AAAA,IACrB;AAAA,IAEA,MAAM,mBAAmB,IAAI,OAAO,KAAK;AACvC,YAAM,gBAAgB,IAAI,MAAM,QAAQ;AACxC,YAAM,UAAU,MAAM,SAAS,KAAK,EAAE,YAAY;AAClD,UAAI,CAAC,eAAe,OAAO,EAAG,OAAM,IAAI,aAAa,KAAK,eAAe,uBAAuB;AAChG,YAAM,CAAC,KAAK,IAAI,MAAM,MAAM,2CAA2C,CAAC,OAAO,CAAC;AAChF,UAAI,MAAO;AACX,YAAM,UAAU,MAAM,UAAU,EAAE;AAClC,YAAM,MAAM,mFAAmF,CAAC,IAAI,GAAG,CAAC;AACxG,YAAM,SAAS,KAAK,UAAU,MAAM,iBAAiB,KAAK,WAAW,IAAI;AACzE,YAAM,MAAM,qGAAqG,CAAC,IAAI,SAAS,KAAK,UAAU,KAAK,GAAG,SAAS,KAAK,sBAAsB,CAAC,CAAC;AAC5L,YAAM,MAAM,GAAG,KAAK,QAAQ,QAAQ,OAAO,EAAE,CAAC,IAAI,QAAQ,MAAM,wBAAwB,mBAAmB,KAAK,CAAC;AACjH,YAAM,KAAK,KAAK,EAAE,IAAI,SAAS,GAAG,gBAAgB,KAAK,SAAS,QAAQ,QAAQ,GAAG,EAAE,CAAC;AAAA,IACxF;AAAA,IAEA,MAAM,mBAAmB,OAAO,KAAK;AACnC,UAAI,CAAC,mBAAmB,KAAK,aAAa,KAAK,EAAG,QAAO;AACzD,YAAM,CAAC,GAAG,IAAI,MAAM,MAAiD,uIAAuI,CAAC,KAAK,UAAU,KAAK,GAAG,GAAG,CAAC;AACxO,UAAI,CAAC,IAAK,QAAO;AACjB,UAAI;AACF,cAAM,MAAM,wEAAwE,CAAC,IAAI,YAAY,IAAI,WAAW,GAAG,CAAC;AAAA,MAC1H,QAAQ;AACN,eAAO;AAAA,MACT;AACA,aAAO;AAAA,IACT;AAAA,IAEA,MAAM,eAAe,IAAI,OAAO,eAAe;AAC7C,YAAM,gBAAgB,IAAI,MAAM,OAAO;AACvC,UAAI,MAAM,KAAK,SAAS,aAAc,OAAM,IAAI,aAAa,KAAK,sBAAsB,YAAY,YAAY,aAAa;AAC7H,UAAI,MAAM,KAAK,SAAS,aAAc,OAAM,IAAI,aAAa,KAAK,qBAAqB,WAAW,YAAY,aAAa;AAC3H,YAAM,MAAM,wDAAwD,CAAC,IAAI,MAAM,KAAK,UAAU,KAAK,MAAM,IAAI,CAAC,CAAC;AAC/G,YAAM,MAAM,uFAAuF,CAAC,IAAI,iBAAiB,IAAI,CAAC;AAAA,IAChI;AAAA,IAEA;AAAA,IAEA,MAAM,SAAS,IAAI,KAAK,kBAAkB;AACxC,YAAM,OAAO,MAAM,MAAwG,sIAAsI,CAAC,IAAI,GAAG,CAAC;AAC1Q,aAAO,KAAK,IAAI,CAAC,OAAuB,EAAE,IAAI,EAAE,IAAI,WAAW,EAAE,YAAY,WAAW,EAAE,YAAY,IAAI,EAAE,IAAI,WAAW,EAAE,YAAY,SAAS,EAAE,OAAO,iBAAiB,EAAE;AAAA,IAChL;AAAA,IAEA,MAAM,cAAc,IAAI,eAAe;AACrC,cAAQ,MAAM,MAAM,8EAA8E,CAAC,IAAI,aAAa,CAAC,GAAG;AAAA,IAC1H;AAAA,IAEA,MAAM,cAAc,IAAI,KAAK,OAAO;AAClC,YAAM,UAAU,MAAM,UAAU,EAAE;AAClC,YAAM,OAAO,MAAM,KAAK,SAAS,IAAI,GAAG;AACxC,aAAO,EAAE,YAAY,IAAI,YAAY,GAAG,SAAS,SAAS,UAAU,KAAK,IAAI,CAAC,EAAE,SAAS,UAAU,GAAG,EAAE,MAAM,CAAC,GAAG,GAAI,QAAQ,MAAM,MAAM,IAAI,CAAC,EAAG;AAAA,IACpJ;AAAA,IAEA,MAAM,cAAc,IAAI;AACtB,YAAM,OAAO,MAAM,MAAM,mDAAmD,CAAC,EAAE,CAAC;AAChF,UAAI,CAAC,KAAK,OAAQ,OAAM,IAAI,aAAa,KAAK,aAAa,iBAAiB;AAAA,IAC9E;AAAA,EACF;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/migration.ts","../src/port.ts","../src/postgres.ts"],"sourcesContent":["/**\n * What the account adapter needs from a product's database, safe to run on a database that already has `accounts` and `sessions`:\n * a display name, a theme, an avatar seed, the verified-address flag, and the table of e-mail changes (a link mailed to the NEW\n * address). `accounts` needs `id`, `email`, `locale`, `password_hash`; `sessions` needs `account_id`, `created_at`, `expires_at`, `ip`, `user_agent`.\n * `backfillVerified`: for accounts that exist already, treat the address as verified (invitation-only products).\n */\nexport function accountsMigration(options: { backfillVerified?: boolean } = {}): string {\n return `\nALTER TABLE accounts ADD COLUMN IF NOT EXISTS name text CHECK (name IS NULL OR char_length(name) <= 80);\nALTER TABLE accounts ADD COLUMN IF NOT EXISTS avatar_seed text CHECK (avatar_seed IS NULL OR avatar_seed ~ '^[a-z0-9-]{1,40}$');\nALTER TABLE accounts ADD COLUMN IF NOT EXISTS theme text CHECK (theme IS NULL OR theme IN ('light', 'dark', 'system'));\nALTER TABLE accounts ADD COLUMN IF NOT EXISTS email_verified_at timestamptz;\n${options.backfillVerified ? \"UPDATE accounts SET email_verified_at = created_at WHERE email_verified_at IS NULL;\\n\" : \"\"}CREATE TABLE IF NOT EXISTS email_changes (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,\n new_email text NOT NULL CHECK (new_email = lower(new_email)),\n token_hash text NOT NULL UNIQUE,\n created_at timestamptz NOT NULL DEFAULT now(),\n expires_at timestamptz NOT NULL,\n used_at timestamptz\n);\n`;\n}\n","/**\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, 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 /** 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(accountId: string, now: Date, extra?: () => Promise<Record<string, unknown>>): 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 { addHours } from \"date-fns\";\n\nimport { AccountError, type AccountProfile, type AccountService, type ProfilePatch, type SessionSummary, type Theme } from \"./port\";\n\n/** A SQL executor: the rows of a statement and its parameters (the product's pool). */\nexport type Query = <T = Record<string, unknown>>(sql: string, params?: unknown[]) => Promise<T[]>;\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\nexport interface AccountDeps {\n query: Query;\n mail: (mail: Mail) => Promise<void>;\n /** `https://www.akinaza.com`: the e-mail change link is `<baseUrl>/<locale>/confirm-email?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: { hash: (password: string) => Promise<string>; verify: (password: string, stored: string) => Promise<boolean> };\n /** How a token is kept: the product's own keyed hash, the one its sessions use. */\n tokenHash: (token: string) => string;\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;\nconst NAME_MAX = 80;\nconst PASSWORD_MIN = 12;\nconst PASSWORD_MAX = 256;\nconst THEMES: readonly Theme[] = [\"light\", \"dark\", \"system\"];\n\nexport const emailChangeToken = (prefix: string) => `${prefix}1.${crypto.randomBytes(32).toString(\"base64url\")}`;\nexport const isEmailChangeToken = (prefix: string, value: string | undefined | null): value is string => !!value && new RegExp(`^${prefix}1\\\\.[A-Za-z0-9_-]{43}$`).test(value);\n\nexport function emailChangeMail(product: string, locale: \"en\" | \"fr\", url: string) {\n return locale === \"fr\"\n ? { subject: `Confirmez votre nouvelle adresse — ${product}`, 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 : { subject: `Confirm your new address — ${product}`, 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/** 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`; see `accountsMigration`). */\nexport function postgresAccounts(deps: AccountDeps): AccountService {\n const { query } = deps;\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 { id: row.id, email: row.email, name: row.name, locale: row.locale, theme: row.theme, avatarSeed: row.avatar_seed, emailVerified: !!row.email_verified_at, pendingEmail: row.pending };\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\", [id]);\n if (!row?.password_hash || password.length > PASSWORD_MAX || !(await deps.passwords.verify(password, row.password_hash))) 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)) 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 [id, name !== undefined, name === \"\" ? null : (name ?? null), patch.locale ?? null, patch.avatarSeed !== undefined, patch.avatarSeed ?? null, patch.theme !== undefined, patch.theme ?? null],\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)\", [id, address, deps.tokenHash(token), addHours(now, EMAIL_CHANGE_TTL_HOURS)]);\n const url = `${deps.baseUrl.replace(/\\/$/, \"\")}/${profile.locale}/confirm-email?token=${encodeURIComponent(token)}`;\n await deps.mail({ to: address, locale: profile.locale, ...emailChangeMail(deps.product, profile.locale, url) });\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 }>(\"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\", [deps.tokenHash(token), now]);\n if (!row) return \"INVALID\";\n try {\n await query(\"UPDATE accounts SET email = $2, email_verified_at = $3 WHERE id = $1\", [row.account_id, row.new_email, now]);\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) throw new AccountError(400, \"PASSWORD_TOO_SHORT\", `At least ${PASSWORD_MIN} characters`);\n if (input.next.length > PASSWORD_MAX) 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)\", [id, keepSessionId ?? null]);\n },\n\n confirmPassword,\n\n async sessions(id, now, currentSessionId) {\n const rows = await query<{ id: string; created_at: Date; expires_at: Date; ip: string | null; user_agent: string | null }>(\"SELECT id, created_at, expires_at, ip, user_agent FROM sessions WHERE account_id = $1 AND expires_at > $2 ORDER BY created_at DESC\", [id, now]);\n return rows.map((r): SessionSummary => ({ id: r.id, createdAt: r.created_at, expiresAt: r.expires_at, ip: r.ip, userAgent: r.user_agent, current: r.id === currentSessionId }));\n },\n\n async signOutOthers(id, keepSessionId) {\n return (await query(\"DELETE FROM sessions WHERE account_id = $1 AND id <> $2::uuid RETURNING id\", [id, keepSessionId])).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 { exportedAt: now.toISOString(), account: profile, sessions: live.map(({ current: _current, ...s }) => s), ...(extra ? await extra() : {}) };\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"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krizaka/auth",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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",