@krizaka/auth 0.3.0 → 0.5.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/README.md +4 -2
- package/dist/index.d.ts +51 -23
- package/dist/index.js +128 -56
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
# @krizaka/auth
|
|
2
2
|
|
|
3
|
-
The account of a Krizaka product, behind a port: a display name, language, theme and avatar seed; the e-mail change by a link mailed to the **new** address (the verified flag is set by that very act); the password (checked before anything sensitive, other sessions signed out on change); the live sessions with sign-out of the others; the data export; the deletion. Each product keeps its **own** accounts — the package brings an adapter over the product's PostgreSQL and the migration it needs, nothing central.
|
|
3
|
+
The account of a Krizaka product, behind a port: a display name, language, theme and avatar seed; the e-mail change by a link mailed to the **new** address (the verified flag is set by that very act); the password (checked before anything sensitive, other sessions signed out on change; reset by a link mailed to the account's address — the same answer whether or not the address has an account, the link used once within 30 minutes by default — `passwordResetTtlMinutes`, 5 to 1440, read by the product from its settings and said in the mail — every session signed out); the live sessions with sign-out of the others; the data export; the deletion. Each product keeps its **own** accounts — the package brings an adapter over the product's PostgreSQL and the migration it needs, nothing central.
|
|
4
4
|
|
|
5
5
|
```ts
|
|
6
6
|
import { accountsMigration, postgresAccounts } from "@krizaka/auth";
|
|
7
7
|
|
|
8
8
|
await db.query(accountsMigration({ backfillVerified: true })); // once, in your migrations (idempotent)
|
|
9
|
-
const accounts = postgresAccounts({ query, mail, baseUrl: "https://www.akinaza.com", product: "Akinaza", tokenPrefix: "ae", passwords: { hash, verify }, tokenHash });
|
|
9
|
+
const accounts = postgresAccounts({ 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
10
|
await accounts.updateProfile(id, { name: "Éric", theme: "dark" });
|
|
11
11
|
await accounts.sessions(id, new Date(), currentSessionId);
|
|
12
|
+
await accounts.requestPasswordReset(email, new Date()); // mails `<baseUrl>/<locale>/reset-password?token=…`
|
|
13
|
+
await accounts.resetPassword(token, newPassword, new Date()); // "CHANGED" | "INVALID"
|
|
12
14
|
```
|
|
13
15
|
|
|
14
16
|
The product keeps what is its own: its session cookie and token hashing (`tokenHash`), its password hashing, its mailer, its rate limits and its HTTP answers (`AccountError` carries a status and a stable code). Time is passed in (`now`); nothing here reads the clock.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,25 +1,6 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* What the account adapter needs from a product's database, safe to run on a database that already has `accounts` and `sessions`:
|
|
3
|
-
* 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
|
|
4
|
-
* address). `accounts` needs `id`, `email`, `locale`, `password_hash`; `sessions` needs `account_id`, `created_at`, `expires_at`, `ip`, `user_agent`.
|
|
5
|
-
* `backfillVerified`: for accounts that exist already, treat the address as verified (invitation-only products).
|
|
6
|
-
*/
|
|
7
|
-
declare function accountsMigration(options?: {
|
|
8
|
-
backfillVerified?: boolean;
|
|
9
|
-
}): string;
|
|
10
|
-
/**
|
|
11
|
-
* Memberships (who has which role in which scope) and invitations (an e-mail address invited with a role, by a link used once), from the
|
|
12
|
-
* product's role model. `scopeTable` is the table of the scope (shops, workspaces): when given, a membership and an invitation point at it.
|
|
13
|
-
* Idempotent.
|
|
14
|
-
*/
|
|
15
|
-
declare function rolesMigration(roles: readonly string[], options?: {
|
|
16
|
-
ownerTable?: string;
|
|
17
|
-
scopeTable?: string;
|
|
18
|
-
}): string;
|
|
19
|
-
|
|
20
1
|
/**
|
|
21
2
|
* The account PORT (ADR-0008 of Yomizaka, now shared). A product's account — name, language, theme, avatar, e-mail change with
|
|
22
|
-
* verification, password, sessions, export, deletion — behind one interface. Every product keeps its OWN accounts: no central
|
|
3
|
+
* 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
|
|
23
4
|
* service, no runtime dependency between products.
|
|
24
5
|
*/
|
|
25
6
|
type Theme = "light" | "dark" | "system";
|
|
@@ -66,6 +47,10 @@ interface AccountService {
|
|
|
66
47
|
current: string;
|
|
67
48
|
next: string;
|
|
68
49
|
}, keepSessionId?: string): Promise<void>;
|
|
50
|
+
/** 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). */
|
|
51
|
+
requestPasswordReset(email: string, now: Date): Promise<void>;
|
|
52
|
+
/** 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. */
|
|
53
|
+
resetPassword(token: string, next: string, now: Date): Promise<"CHANGED" | "INVALID">;
|
|
69
54
|
/** Checks the password. Throws `AccountError(403, "WRONG_PASSWORD")`. */
|
|
70
55
|
confirmPassword(accountId: string, password: string): Promise<void>;
|
|
71
56
|
/** The person's live sign-ins, newest first; `currentSessionId` is flagged. */
|
|
@@ -94,8 +79,15 @@ interface Mail {
|
|
|
94
79
|
}
|
|
95
80
|
interface AccountDeps {
|
|
96
81
|
query: Query;
|
|
82
|
+
/**
|
|
83
|
+
* Runs `work` in one transaction on one connection (BEGIN … COMMIT, rolled back when `work` throws) — the product's pool
|
|
84
|
+
* knows how. The password reset runs under it: the account row is locked, the links retired and issued, the token claimed,
|
|
85
|
+
* the password set and the sessions ended as one. Without it, the statements run one by one on `query` (one process, no
|
|
86
|
+
* concurrent requests): give it in production.
|
|
87
|
+
*/
|
|
88
|
+
transaction?: <T>(work: (query: Query) => Promise<T>) => Promise<T>;
|
|
97
89
|
mail: (mail: Mail) => Promise<void>;
|
|
98
|
-
/** `https://www.akinaza.com`: the e-mail change link is `<baseUrl>/<locale>/confirm-email?token=…`. */
|
|
90
|
+
/** `https://www.akinaza.com`: the e-mail change link is `<baseUrl>/<locale>/confirm-email?token=…`, the reset link `<baseUrl>/<locale>/reset-password?token=…`. */
|
|
99
91
|
baseUrl: string;
|
|
100
92
|
/** The product's name, in the words of the mail (« Akinaza »). */
|
|
101
93
|
product: string;
|
|
@@ -107,19 +99,55 @@ interface AccountDeps {
|
|
|
107
99
|
};
|
|
108
100
|
/** How a token is kept: the product's own keyed hash, the one its sessions use. */
|
|
109
101
|
tokenHash: (token: string) => string;
|
|
102
|
+
/**
|
|
103
|
+
* How long a password reset link lives, in minutes: a whole number from 5 to 1440 (`PASSWORD_RESET_MIN_MINUTES`–`PASSWORD_RESET_MAX_MINUTES`),
|
|
104
|
+
* default `PASSWORD_RESET_TTL_MINUTES` (30). The product reads it from its settings; the expiry and the mailed text both use it.
|
|
105
|
+
* Out of range, `postgresAccounts` throws.
|
|
106
|
+
*/
|
|
107
|
+
passwordResetTtlMinutes?: number;
|
|
110
108
|
/** Test seam: the token to issue. */
|
|
111
109
|
token?: () => string;
|
|
112
110
|
}
|
|
113
111
|
declare const EMAIL_CHANGE_TTL_HOURS = 24;
|
|
112
|
+
/** The default lifetime of a password reset link, in minutes (`AccountDeps.passwordResetTtlMinutes` changes it). */
|
|
113
|
+
declare const PASSWORD_RESET_TTL_MINUTES = 30;
|
|
114
|
+
declare const PASSWORD_RESET_MIN_MINUTES = 5;
|
|
115
|
+
declare const PASSWORD_RESET_MAX_MINUTES = 1440;
|
|
116
|
+
/** One format for every mailed link (an e-mail change, a password reset): the product's prefix, a version, 256 random bits. */
|
|
117
|
+
declare const linkToken: (prefix: string) => string;
|
|
118
|
+
declare const isLinkToken: (prefix: string, value: string | undefined | null) => value is string;
|
|
114
119
|
declare const emailChangeToken: (prefix: string) => string;
|
|
115
120
|
declare const isEmailChangeToken: (prefix: string, value: string | undefined | null) => value is string;
|
|
121
|
+
declare function passwordResetMail(product: string, locale: "en" | "fr", url: string, ttlMinutes?: number): {
|
|
122
|
+
subject: string;
|
|
123
|
+
text: string;
|
|
124
|
+
};
|
|
116
125
|
declare function emailChangeMail(product: string, locale: "en" | "fr", url: string): {
|
|
117
126
|
subject: string;
|
|
118
127
|
text: string;
|
|
119
128
|
};
|
|
120
|
-
/** The adapter over the product's own PostgreSQL (`accounts`, `sessions`, `email_changes`; see `accountsMigration`). */
|
|
129
|
+
/** The adapter over the product's own PostgreSQL (`accounts`, `sessions`, `email_changes`, `password_resets`; see `accountsMigration`). */
|
|
121
130
|
declare function postgresAccounts(deps: AccountDeps): AccountService;
|
|
122
131
|
|
|
132
|
+
/**
|
|
133
|
+
* What the account adapter needs from a product's database, safe to run on a database that already has `accounts` and `sessions`:
|
|
134
|
+
* 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
|
|
135
|
+
* address) and the table of password resets (a link mailed to the account's address). `accounts` needs `id`, `email`, `locale`, `password_hash`; `sessions` needs `account_id`, `created_at`, `expires_at`, `ip`, `user_agent`.
|
|
136
|
+
* `backfillVerified`: for accounts that exist already, treat the address as verified (invitation-only products).
|
|
137
|
+
*/
|
|
138
|
+
declare function accountsMigration(options?: {
|
|
139
|
+
backfillVerified?: boolean;
|
|
140
|
+
}): string;
|
|
141
|
+
/**
|
|
142
|
+
* Memberships (who has which role in which scope) and invitations (an e-mail address invited with a role, by a link used once), from the
|
|
143
|
+
* product's role model. `scopeTable` is the table of the scope (shops, workspaces): when given, a membership and an invitation point at it.
|
|
144
|
+
* Idempotent.
|
|
145
|
+
*/
|
|
146
|
+
declare function rolesMigration(roles: readonly string[], options?: {
|
|
147
|
+
ownerTable?: string;
|
|
148
|
+
scopeTable?: string;
|
|
149
|
+
}): string;
|
|
150
|
+
|
|
123
151
|
/**
|
|
124
152
|
* Roles per scope (a shop, a workspace), as DATA: a model says which roles exist, which permissions each holds and who may hand which
|
|
125
153
|
* role to whom. The code asks `can(role, "invoices:write")`, never `if (role === "owner")`. One person may belong to several scopes, with
|
|
@@ -222,4 +250,4 @@ declare function createRoles<Role extends string, Permission extends string>(dep
|
|
|
222
250
|
};
|
|
223
251
|
type Roles<Role extends string = string, Permission extends string = string> = ReturnType<typeof createRoles<Role, Permission>>;
|
|
224
252
|
|
|
225
|
-
export { type AccountDeps, AccountError, type AccountProfile, type AccountService, EMAIL_CHANGE_TTL_HOURS, type Invitation, type Mail, type Member, type ProfilePatch, type Query, ROLES_CODE, type RoleModel, type Roles, type RolesCode, type RolesDeps, RolesError, type RolesQuery, type SessionSummary, type Theme, accountsMigration, can, createRoles, defineRoles, emailChangeMail, emailChangeToken, isEmailChangeToken, postgresAccounts, rolesMigration };
|
|
253
|
+
export { type AccountDeps, AccountError, type AccountProfile, type AccountService, EMAIL_CHANGE_TTL_HOURS, type Invitation, type Mail, type Member, PASSWORD_RESET_MAX_MINUTES, PASSWORD_RESET_MIN_MINUTES, PASSWORD_RESET_TTL_MINUTES, type ProfilePatch, type Query, ROLES_CODE, type RoleModel, type Roles, type RolesCode, type RolesDeps, RolesError, type RolesQuery, type SessionSummary, type Theme, accountsMigration, can, createRoles, defineRoles, emailChangeMail, emailChangeToken, isEmailChangeToken, isLinkToken, linkToken, passwordResetMail, postgresAccounts, rolesMigration };
|
package/dist/index.js
CHANGED
|
@@ -1,54 +1,6 @@
|
|
|
1
|
-
// src/
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS name text CHECK (name IS NULL OR char_length(name) <= 80);
|
|
5
|
-
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS avatar_seed text CHECK (avatar_seed IS NULL OR avatar_seed ~ '^[a-z0-9-]{1,40}$');
|
|
6
|
-
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS theme text CHECK (theme IS NULL OR theme IN ('light', 'dark', 'system'));
|
|
7
|
-
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS email_verified_at timestamptz;
|
|
8
|
-
${options.backfillVerified ? "UPDATE accounts SET email_verified_at = created_at WHERE email_verified_at IS NULL;\n" : ""}CREATE TABLE IF NOT EXISTS email_changes (
|
|
9
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
10
|
-
account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
|
11
|
-
new_email text NOT NULL CHECK (new_email = lower(new_email)),
|
|
12
|
-
token_hash text NOT NULL UNIQUE,
|
|
13
|
-
created_at timestamptz NOT NULL DEFAULT now(),
|
|
14
|
-
expires_at timestamptz NOT NULL,
|
|
15
|
-
used_at timestamptz
|
|
16
|
-
);
|
|
17
|
-
`;
|
|
18
|
-
}
|
|
19
|
-
var IDENT = /^[a-z_][a-z0-9_]{0,62}$/;
|
|
20
|
-
var ident = (name) => {
|
|
21
|
-
if (!IDENT.test(name)) throw new Error(`not a safe SQL identifier: ${name}`);
|
|
22
|
-
return name;
|
|
23
|
-
};
|
|
24
|
-
function rolesMigration(roles, options = {}) {
|
|
25
|
-
const owner = ident(options.ownerTable ?? "accounts");
|
|
26
|
-
const scope = options.scopeTable ? ` REFERENCES ${ident(options.scopeTable)}(id) ON DELETE CASCADE` : "";
|
|
27
|
-
const list = roles.map((r) => `'${ident(r)}'`).join(", ");
|
|
28
|
-
return `
|
|
29
|
-
CREATE TABLE IF NOT EXISTS memberships (
|
|
30
|
-
scope_id uuid NOT NULL${scope},
|
|
31
|
-
account_id uuid NOT NULL REFERENCES ${owner}(id) ON DELETE CASCADE,
|
|
32
|
-
role text NOT NULL CHECK (role IN (${list})),
|
|
33
|
-
invited_by uuid REFERENCES ${owner}(id) ON DELETE SET NULL,
|
|
34
|
-
created_at timestamptz NOT NULL,
|
|
35
|
-
PRIMARY KEY (scope_id, account_id)
|
|
36
|
-
);
|
|
37
|
-
CREATE INDEX IF NOT EXISTS memberships_account ON memberships(account_id);
|
|
38
|
-
CREATE TABLE IF NOT EXISTS invitations (
|
|
39
|
-
id uuid PRIMARY KEY,
|
|
40
|
-
scope_id uuid NOT NULL${scope},
|
|
41
|
-
email text NOT NULL CHECK (email = lower(email)),
|
|
42
|
-
role text NOT NULL CHECK (role IN (${list})),
|
|
43
|
-
token_hash text NOT NULL UNIQUE,
|
|
44
|
-
invited_by uuid REFERENCES ${owner}(id) ON DELETE SET NULL,
|
|
45
|
-
created_at timestamptz NOT NULL,
|
|
46
|
-
expires_at timestamptz NOT NULL,
|
|
47
|
-
used_at timestamptz
|
|
48
|
-
);
|
|
49
|
-
CREATE INDEX IF NOT EXISTS invitations_scope ON invitations(scope_id) WHERE used_at IS NULL;
|
|
50
|
-
`;
|
|
51
|
-
}
|
|
1
|
+
// src/accounts.ts
|
|
2
|
+
import crypto from "crypto";
|
|
3
|
+
import { addHours, addMinutes } from "date-fns";
|
|
52
4
|
|
|
53
5
|
// src/port.ts
|
|
54
6
|
var AccountError = class extends Error {
|
|
@@ -62,16 +14,41 @@ var AccountError = class extends Error {
|
|
|
62
14
|
code;
|
|
63
15
|
};
|
|
64
16
|
|
|
65
|
-
// src/
|
|
66
|
-
import crypto from "crypto";
|
|
67
|
-
import { addHours } from "date-fns";
|
|
17
|
+
// src/accounts.ts
|
|
68
18
|
var EMAIL_CHANGE_TTL_HOURS = 24;
|
|
19
|
+
var PASSWORD_RESET_TTL_MINUTES = 30;
|
|
20
|
+
var PASSWORD_RESET_MIN_MINUTES = 5;
|
|
21
|
+
var PASSWORD_RESET_MAX_MINUTES = 1440;
|
|
22
|
+
var checkResetTtl = (minutes) => {
|
|
23
|
+
if (!Number.isInteger(minutes) || minutes < PASSWORD_RESET_MIN_MINUTES || minutes > PASSWORD_RESET_MAX_MINUTES) {
|
|
24
|
+
throw new RangeError(`passwordResetTtlMinutes must be a whole number from ${PASSWORD_RESET_MIN_MINUTES} to ${PASSWORD_RESET_MAX_MINUTES}, got ${minutes}`);
|
|
25
|
+
}
|
|
26
|
+
return minutes;
|
|
27
|
+
};
|
|
69
28
|
var NAME_MAX = 80;
|
|
70
29
|
var PASSWORD_MIN = 12;
|
|
71
30
|
var PASSWORD_MAX = 256;
|
|
72
31
|
var THEMES = ["light", "dark", "system"];
|
|
73
|
-
var
|
|
74
|
-
var
|
|
32
|
+
var linkToken = (prefix) => `${prefix}1.${crypto.randomBytes(32).toString("base64url")}`;
|
|
33
|
+
var isLinkToken = (prefix, value) => !!value && new RegExp(`^${prefix}1\\.[A-Za-z0-9_-]{43}$`).test(value);
|
|
34
|
+
var emailChangeToken = linkToken;
|
|
35
|
+
var isEmailChangeToken = isLinkToken;
|
|
36
|
+
function passwordResetMail(product, locale, url, ttlMinutes = PASSWORD_RESET_TTL_MINUTES) {
|
|
37
|
+
checkResetTtl(ttlMinutes);
|
|
38
|
+
return locale === "fr" ? { subject: `R\xE9initialisez votre mot de passe \u2014 ${product}`, text: `Bonjour,
|
|
39
|
+
|
|
40
|
+
Pour choisir un nouveau mot de passe pour votre compte ${product}, ouvrez ce lien (valide ${ttlMinutes} min, une seule fois) :
|
|
41
|
+
${url}
|
|
42
|
+
|
|
43
|
+
Si vous n'avez rien demand\xE9, ignorez ce message : votre mot de passe ne change pas.
|
|
44
|
+
` } : { subject: `Reset your password \u2014 ${product}`, text: `Hello,
|
|
45
|
+
|
|
46
|
+
To choose a new password for your ${product} account, open this link (valid ${ttlMinutes} min, once):
|
|
47
|
+
${url}
|
|
48
|
+
|
|
49
|
+
If you did not ask for this, ignore this message: your password does not change.
|
|
50
|
+
` };
|
|
51
|
+
}
|
|
75
52
|
function emailChangeMail(product, locale, url) {
|
|
76
53
|
return locale === "fr" ? { subject: `Confirmez votre nouvelle adresse \u2014 ${product}`, text: `Bonjour,
|
|
77
54
|
|
|
@@ -102,6 +79,8 @@ var cleanName = (raw) => {
|
|
|
102
79
|
};
|
|
103
80
|
function postgresAccounts(deps) {
|
|
104
81
|
const { query } = deps;
|
|
82
|
+
const resetTtlMinutes = checkResetTtl(deps.passwordResetTtlMinutes ?? PASSWORD_RESET_TTL_MINUTES);
|
|
83
|
+
const transaction = deps.transaction ?? ((work) => work(query));
|
|
105
84
|
const wrong = () => new AccountError(403, "WRONG_PASSWORD", "The password is not right");
|
|
106
85
|
async function profileOf(id) {
|
|
107
86
|
const [row] = await query(
|
|
@@ -162,6 +141,33 @@ function postgresAccounts(deps) {
|
|
|
162
141
|
await query("DELETE FROM sessions WHERE account_id = $1 AND ($2::uuid IS NULL OR id <> $2::uuid)", [id, keepSessionId ?? null]);
|
|
163
142
|
},
|
|
164
143
|
confirmPassword,
|
|
144
|
+
async requestPasswordReset(email, now) {
|
|
145
|
+
const address = email.trim().toLowerCase();
|
|
146
|
+
if (!looksLikeEmail(address)) return;
|
|
147
|
+
const issued = await transaction(async (q) => {
|
|
148
|
+
const [row] = await q("SELECT id, locale FROM accounts WHERE email = $1 AND password_hash IS NOT NULL FOR UPDATE", [address]);
|
|
149
|
+
if (!row) return null;
|
|
150
|
+
await q("UPDATE password_resets SET used_at = $2 WHERE account_id = $1 AND used_at IS NULL", [row.id, now]);
|
|
151
|
+
const token = (deps.token ?? (() => linkToken(deps.tokenPrefix)))();
|
|
152
|
+
await q("INSERT INTO password_resets (account_id, token_hash, expires_at) VALUES ($1, $2, $3)", [row.id, deps.tokenHash(token), addMinutes(now, resetTtlMinutes)]);
|
|
153
|
+
return { token, locale: row.locale };
|
|
154
|
+
});
|
|
155
|
+
if (!issued) return;
|
|
156
|
+
const url = `${deps.baseUrl.replace(/\/$/, "")}/${issued.locale}/reset-password?token=${encodeURIComponent(issued.token)}`;
|
|
157
|
+
await deps.mail({ to: address, locale: issued.locale, ...passwordResetMail(deps.product, issued.locale, url, resetTtlMinutes) });
|
|
158
|
+
},
|
|
159
|
+
async resetPassword(token, next, now) {
|
|
160
|
+
if (next.length < PASSWORD_MIN) throw new AccountError(400, "PASSWORD_TOO_SHORT", `At least ${PASSWORD_MIN} characters`);
|
|
161
|
+
if (next.length > PASSWORD_MAX) throw new AccountError(400, "PASSWORD_TOO_LONG", `At most ${PASSWORD_MAX} characters`);
|
|
162
|
+
if (!isLinkToken(deps.tokenPrefix, token)) return "INVALID";
|
|
163
|
+
return transaction(async (q) => {
|
|
164
|
+
const [row] = await q("UPDATE password_resets SET used_at = $2 WHERE token_hash = $1 AND used_at IS NULL AND expires_at > $2 RETURNING account_id", [deps.tokenHash(token), now]);
|
|
165
|
+
if (!row) return "INVALID";
|
|
166
|
+
await q("UPDATE accounts SET password_hash = $2 WHERE id = $1", [row.account_id, await deps.passwords.hash(next)]);
|
|
167
|
+
await q("DELETE FROM sessions WHERE account_id = $1", [row.account_id]);
|
|
168
|
+
return "CHANGED";
|
|
169
|
+
});
|
|
170
|
+
},
|
|
165
171
|
async sessions(id, now, currentSessionId) {
|
|
166
172
|
const rows = await query("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]);
|
|
167
173
|
return rows.map((r) => ({ id: r.id, createdAt: r.created_at, expiresAt: r.expires_at, ip: r.ip, userAgent: r.user_agent, current: r.id === currentSessionId }));
|
|
@@ -181,6 +187,66 @@ function postgresAccounts(deps) {
|
|
|
181
187
|
};
|
|
182
188
|
}
|
|
183
189
|
|
|
190
|
+
// src/migration.ts
|
|
191
|
+
function accountsMigration(options = {}) {
|
|
192
|
+
return `
|
|
193
|
+
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS name text CHECK (name IS NULL OR char_length(name) <= 80);
|
|
194
|
+
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS avatar_seed text CHECK (avatar_seed IS NULL OR avatar_seed ~ '^[a-z0-9-]{1,40}$');
|
|
195
|
+
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS theme text CHECK (theme IS NULL OR theme IN ('light', 'dark', 'system'));
|
|
196
|
+
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS email_verified_at timestamptz;
|
|
197
|
+
${options.backfillVerified ? "UPDATE accounts SET email_verified_at = created_at WHERE email_verified_at IS NULL;\n" : ""}CREATE TABLE IF NOT EXISTS email_changes (
|
|
198
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
199
|
+
account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
|
200
|
+
new_email text NOT NULL CHECK (new_email = lower(new_email)),
|
|
201
|
+
token_hash text NOT NULL UNIQUE,
|
|
202
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
203
|
+
expires_at timestamptz NOT NULL,
|
|
204
|
+
used_at timestamptz
|
|
205
|
+
);
|
|
206
|
+
CREATE TABLE IF NOT EXISTS password_resets (
|
|
207
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
208
|
+
account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
|
209
|
+
token_hash text NOT NULL UNIQUE,
|
|
210
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
211
|
+
expires_at timestamptz NOT NULL,
|
|
212
|
+
used_at timestamptz
|
|
213
|
+
);
|
|
214
|
+
`;
|
|
215
|
+
}
|
|
216
|
+
var IDENT = /^[a-z_][a-z0-9_]{0,62}$/;
|
|
217
|
+
var ident = (name) => {
|
|
218
|
+
if (!IDENT.test(name)) throw new Error(`not a safe SQL identifier: ${name}`);
|
|
219
|
+
return name;
|
|
220
|
+
};
|
|
221
|
+
function rolesMigration(roles, options = {}) {
|
|
222
|
+
const owner = ident(options.ownerTable ?? "accounts");
|
|
223
|
+
const scope = options.scopeTable ? ` REFERENCES ${ident(options.scopeTable)}(id) ON DELETE CASCADE` : "";
|
|
224
|
+
const list = roles.map((r) => `'${ident(r)}'`).join(", ");
|
|
225
|
+
return `
|
|
226
|
+
CREATE TABLE IF NOT EXISTS memberships (
|
|
227
|
+
scope_id uuid NOT NULL${scope},
|
|
228
|
+
account_id uuid NOT NULL REFERENCES ${owner}(id) ON DELETE CASCADE,
|
|
229
|
+
role text NOT NULL CHECK (role IN (${list})),
|
|
230
|
+
invited_by uuid REFERENCES ${owner}(id) ON DELETE SET NULL,
|
|
231
|
+
created_at timestamptz NOT NULL,
|
|
232
|
+
PRIMARY KEY (scope_id, account_id)
|
|
233
|
+
);
|
|
234
|
+
CREATE INDEX IF NOT EXISTS memberships_account ON memberships(account_id);
|
|
235
|
+
CREATE TABLE IF NOT EXISTS invitations (
|
|
236
|
+
id uuid PRIMARY KEY,
|
|
237
|
+
scope_id uuid NOT NULL${scope},
|
|
238
|
+
email text NOT NULL CHECK (email = lower(email)),
|
|
239
|
+
role text NOT NULL CHECK (role IN (${list})),
|
|
240
|
+
token_hash text NOT NULL UNIQUE,
|
|
241
|
+
invited_by uuid REFERENCES ${owner}(id) ON DELETE SET NULL,
|
|
242
|
+
created_at timestamptz NOT NULL,
|
|
243
|
+
expires_at timestamptz NOT NULL,
|
|
244
|
+
used_at timestamptz
|
|
245
|
+
);
|
|
246
|
+
CREATE INDEX IF NOT EXISTS invitations_scope ON invitations(scope_id) WHERE used_at IS NULL;
|
|
247
|
+
`;
|
|
248
|
+
}
|
|
249
|
+
|
|
184
250
|
// src/roles.ts
|
|
185
251
|
import crypto2 from "crypto";
|
|
186
252
|
import { addHours as addHours2 } from "date-fns";
|
|
@@ -313,6 +379,9 @@ function createRoles(deps) {
|
|
|
313
379
|
export {
|
|
314
380
|
AccountError,
|
|
315
381
|
EMAIL_CHANGE_TTL_HOURS,
|
|
382
|
+
PASSWORD_RESET_MAX_MINUTES,
|
|
383
|
+
PASSWORD_RESET_MIN_MINUTES,
|
|
384
|
+
PASSWORD_RESET_TTL_MINUTES,
|
|
316
385
|
ROLES_CODE,
|
|
317
386
|
RolesError,
|
|
318
387
|
accountsMigration,
|
|
@@ -322,6 +391,9 @@ export {
|
|
|
322
391
|
emailChangeMail,
|
|
323
392
|
emailChangeToken,
|
|
324
393
|
isEmailChangeToken,
|
|
394
|
+
isLinkToken,
|
|
395
|
+
linkToken,
|
|
396
|
+
passwordResetMail,
|
|
325
397
|
postgresAccounts,
|
|
326
398
|
rolesMigration
|
|
327
399
|
};
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/migration.ts","../src/port.ts","../src/postgres.ts","../src/roles.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\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 * Memberships (who has which role in which scope) and invitations (an e-mail address invited with a role, by a link used once), from the\n * product's role model. `scopeTable` is the table of the scope (shops, workspaces): when given, a membership and an invitation point at it.\n * Idempotent.\n */\nexport function rolesMigration(roles: readonly string[], options: { ownerTable?: string; scopeTable?: string } = {}): string {\n const owner = ident(options.ownerTable ?? \"accounts\");\n const scope = options.scopeTable ? ` REFERENCES ${ident(options.scopeTable)}(id) ON DELETE CASCADE` : \"\";\n const list = roles.map((r) => `'${ident(r)}'`).join(\", \");\n return `\nCREATE TABLE IF NOT EXISTS memberships (\n scope_id uuid NOT NULL${scope},\n account_id uuid NOT NULL REFERENCES ${owner}(id) ON DELETE CASCADE,\n role text NOT NULL CHECK (role IN (${list})),\n invited_by uuid REFERENCES ${owner}(id) ON DELETE SET NULL,\n created_at timestamptz NOT NULL,\n PRIMARY KEY (scope_id, account_id)\n);\nCREATE INDEX IF NOT EXISTS memberships_account ON memberships(account_id);\nCREATE TABLE IF NOT EXISTS invitations (\n id uuid PRIMARY KEY,\n scope_id uuid NOT NULL${scope},\n email text NOT NULL CHECK (email = lower(email)),\n role text NOT NULL CHECK (role IN (${list})),\n token_hash text NOT NULL UNIQUE,\n invited_by uuid REFERENCES ${owner}(id) ON DELETE SET NULL,\n created_at timestamptz NOT NULL,\n expires_at timestamptz NOT NULL,\n used_at timestamptz\n);\nCREATE INDEX IF NOT EXISTS invitations_scope ON invitations(scope_id) WHERE used_at IS NULL;\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","import crypto from \"node:crypto\";\n\nimport { addHours } from \"date-fns\";\n\n/**\n * Roles per scope (a shop, a workspace), as DATA: a model says which roles exist, which permissions each holds and who may hand which\n * role to whom. The code asks `can(role, \"invoices:write\")`, never `if (role === \"owner\")`. One person may belong to several scopes, with\n * a role in each. Membership and invitations live in the product's PostgreSQL (see `rolesMigration`).\n */\nexport interface RoleModel<Role extends string = string, Permission extends string = string> {\n roles: readonly Role[];\n permissions: Record<Role, readonly Permission[]>;\n /** For each role, the roles it may invite, change and remove (the owner's, typically, is every role). */\n manages: Record<Role, readonly Role[]>;\n}\n\nexport const defineRoles = <const Role extends string, const Permission extends string>(model: RoleModel<Role, Permission>): RoleModel<Role, Permission> => model;\n\nexport const can = <Role extends string, Permission extends string>(model: RoleModel<Role, Permission>, role: Role | null | undefined, permission: Permission): boolean => !!role && model.permissions[role].includes(permission);\n\nexport const ROLES_CODE = {\n forbidden: \"FORBIDDEN\",\n notMember: \"NOT_A_MEMBER\",\n unknownRole: \"UNKNOWN_ROLE\",\n lastOwner: \"LAST_OWNER\",\n notAllowed: \"ROLE_NOT_ALLOWED\",\n inviteInvalid: \"INVITE_INVALID\",\n inviteWrongAccount: \"INVITE_WRONG_ACCOUNT\",\n} as const;\nexport type RolesCode = (typeof ROLES_CODE)[keyof typeof ROLES_CODE];\nconst STATUS: Record<RolesCode, number> = { FORBIDDEN: 403, NOT_A_MEMBER: 404, UNKNOWN_ROLE: 400, LAST_OWNER: 409, ROLE_NOT_ALLOWED: 403, INVITE_INVALID: 400, INVITE_WRONG_ACCOUNT: 403 };\n\nexport class RolesError extends Error {\n readonly status: number;\n constructor(readonly code: RolesCode) {\n super(code);\n this.name = \"RolesError\";\n this.status = STATUS[code];\n }\n}\n\nexport interface RolesQuery {\n <T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;\n}\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) => query<{ account_id: string }>(\"SELECT account_id FROM memberships WHERE scope_id = $1 AND role = $2\", [scopeId, ownerRole()]);\n /** The role that cannot disappear: the first of the model that manages every role. */\n const ownerRole = (): Role => 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\", [scopeId, accountId]);\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 (await query<{ scope_id: string; role: Role }>(\"SELECT scope_id, role FROM memberships WHERE account_id = $1 ORDER BY created_at\", [accountId])).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<{ account_id: string; email: string; name: string | null; role: Role; created_at: Date }>(\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) => ({ accountId: r.account_id, email: r.email, name: r.name, role: r.role, since: r.created_at }));\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)) throw new RolesError(ROLES_CODE.notAllowed);\n if (current === ownerRole() && role !== current && (await owners(scopeId)).length <= 1) 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(scopeId: string, actor: { id: string }, input: { email: string; role: string }, now: Date): 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\", [scopeId, email, now]);\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 [crypto.randomUUID(), scopeId, email, role, deps.tokenHash(token), actor.id, now, addHours(now, deps.inviteTtlHours)],\n );\n return { token, invitation: { id: row!.id, email, role, createdAt: row!.created_at, expiresAt: row!.expires_at } };\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 }>(\"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\", [scopeId, now]);\n return rows.map((r) => ({ id: r.id, email: r.email, role: r.role, createdAt: r.created_at, expiresAt: r.expires_at }));\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 }>(\"SELECT role FROM invitations WHERE id = $1 AND scope_id = $2 AND used_at IS NULL\", [invitationId, scopeId]);\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(token: string, account: { id: string; email: string }, now: Date): 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)) await query(\"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\", [row.scope_id, account.id, row.role, row.invited_by, now]);\n return { scopeId: row.scope_id, role: (!current || model.manages[current].length < model.manages[row.role].length ? row.role : current) as Role };\n },\n };\n}\nexport type Roles<Role extends string = string, Permission extends string = string> = ReturnType<typeof createRoles<Role, Permission>>;\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;AAEA,IAAM,QAAQ;AACd,IAAM,QAAQ,CAAC,SAAiB;AAC9B,MAAI,CAAC,MAAM,KAAK,IAAI,EAAG,OAAM,IAAI,MAAM,8BAA8B,IAAI,EAAE;AAC3E,SAAO;AACT;AAOO,SAAS,eAAe,OAA0B,UAAwD,CAAC,GAAW;AAC3H,QAAM,QAAQ,MAAM,QAAQ,cAAc,UAAU;AACpD,QAAM,QAAQ,QAAQ,aAAa,eAAe,MAAM,QAAQ,UAAU,CAAC,2BAA2B;AACtG,QAAM,OAAO,MAAM,IAAI,CAAC,MAAM,IAAI,MAAM,CAAC,CAAC,GAAG,EAAE,KAAK,IAAI;AACxD,SAAO;AAAA;AAAA,4BAEmB,KAAK;AAAA,wCACO,KAAK;AAAA,6CACA,IAAI;AAAA,+BAClB,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,4BAOR,KAAK;AAAA;AAAA,6CAEY,IAAI;AAAA;AAAA,+BAElB,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAOpC;;;ACHO,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;AAgClB,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,QAAQ,QAAQ,QAAQ,GAAG,gBAAgB,KAAK,SAAS,QAAQ,QAAQ,GAAG,EAAE,CAAC;AAAA,IAChH;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;;;ACxKA,OAAOA,aAAY;AAEnB,SAAS,YAAAC,iBAAgB;AAclB,IAAM,cAAc,CAA6D,UAAoE;AAErJ,IAAM,MAAM,CAAiD,OAAoC,MAA+B,eAAoC,CAAC,CAAC,QAAQ,MAAM,YAAY,IAAI,EAAE,SAAS,UAAU;AAEzN,IAAM,aAAa;AAAA,EACxB,WAAW;AAAA,EACX,WAAW;AAAA,EACX,aAAa;AAAA,EACb,WAAW;AAAA,EACX,YAAY;AAAA,EACZ,eAAe;AAAA,EACf,oBAAoB;AACtB;AAEA,IAAM,SAAoC,EAAE,WAAW,KAAK,cAAc,KAAK,cAAc,KAAK,YAAY,KAAK,kBAAkB,KAAK,gBAAgB,KAAK,sBAAsB,IAAI;AAElL,IAAM,aAAN,cAAyB,MAAM;AAAA,EAEpC,YAAqB,MAAiB;AACpC,UAAM,IAAI;AADS;AAEnB,SAAK,OAAO;AACZ,SAAK,SAAS,OAAO,IAAI;AAAA,EAC3B;AAAA,EAJqB;AAAA,EADZ;AAMX;AAiCO,SAAS,YAA4D,MAAmC;AAC7G,QAAM,EAAE,OAAO,MAAM,IAAI;AACzB,QAAM,QAAQ,CAAC,SAAgC,MAAM,MAA4B,SAAS,IAAI;AAC9F,QAAM,WAAW,CAAC,SAAuB;AACvC,QAAI,CAAC,MAAM,IAAI,EAAG,OAAM,IAAI,WAAW,WAAW,WAAW;AAC7D,WAAO;AAAA,EACT;AACA,QAAM,WAAW,KAAK,UAAU,MAAM,GAAG,KAAK,WAAW,KAAKD,QAAO,YAAY,EAAE,EAAE,SAAS,WAAW,CAAC;AAC1G,QAAM,SAAS,CAAC,YAAoB,MAA8B,wEAAwE,CAAC,SAAS,UAAU,CAAC,CAAC;AAEhK,QAAM,YAAY,MAAY,MAAM,MAAM,KAAK,CAAC,MAAM,MAAM,MAAM,MAAM,CAAC,MAAM,MAAM,QAAQ,CAAC,EAAE,SAAS,CAAC,KAAK,MAAM,CAAC,CAAC,KAAK,MAAM,MAAM,CAAC;AAEzI,iBAAe,OAAO,WAAmB,SAAuC;AAC9E,UAAM,CAAC,GAAG,IAAI,MAAM,MAAsB,wEAAwE,CAAC,SAAS,SAAS,CAAC;AACtI,WAAO,KAAK,QAAQ;AAAA,EACtB;AAEA,SAAO;AAAA,IACL;AAAA,IACA;AAAA;AAAA,IAGA,MAAM,SAAS,WAA+D;AAC5E,cAAQ,MAAM,MAAwC,oFAAoF,CAAC,SAAS,CAAC,GAAG,IAAI,CAAC,OAAO,EAAE,SAAS,EAAE,UAAU,MAAM,EAAE,KAAK,EAAE;AAAA,IAC5M;AAAA;AAAA,IAGA,MAAM,UAAU,WAAmB,SAAiB,YAAuC;AACzF,YAAM,OAAO,MAAM,OAAO,WAAW,OAAO;AAC5C,UAAI,CAAC,KAAM,OAAM,IAAI,WAAW,WAAW,SAAS;AACpD,UAAI,CAAC,IAAI,OAAO,MAAM,UAAU,EAAG,OAAM,IAAI,WAAW,WAAW,SAAS;AAC5E,aAAO;AAAA,IACT;AAAA;AAAA,IAGA,MAAM,MAAM,SAAiB,WAAmB,MAAc,WAA0B,KAA0B;AAChH,eAAS,IAAI;AACb,YAAM;AAAA,QACJ;AAAA,QACA,CAAC,SAAS,WAAW,MAAM,WAAW,GAAG;AAAA,MAC3C;AAAA,IACF;AAAA,IAEA,MAAM,QAAQ,SAA0C;AACtD,YAAM,OAAO,MAAM;AAAA,QACjB;AAAA,QACA,CAAC,OAAO;AAAA,MACV;AACA,aAAO,KAAK,IAAI,CAAC,OAAO,EAAE,WAAW,EAAE,YAAY,OAAO,EAAE,OAAO,MAAM,EAAE,MAAM,MAAM,EAAE,MAAM,OAAO,EAAE,WAAW,EAAE;AAAA,IACvH;AAAA;AAAA,IAGA,MAAM,QAAQ,SAAiB,OAAuB,QAAgB,MAA6B;AACjG,eAAS,IAAI;AACb,YAAM,OAAO,MAAM,OAAO,MAAM,IAAI,OAAO;AAC3C,YAAM,UAAU,MAAM,OAAO,QAAQ,OAAO;AAC5C,UAAI,CAAC,QAAQ,CAAC,QAAS,OAAM,IAAI,WAAW,WAAW,SAAS;AAChE,UAAI,CAAC,MAAM,QAAQ,IAAI,EAAE,SAAS,OAAO,KAAK,CAAC,MAAM,QAAQ,IAAI,EAAE,SAAS,IAAY,EAAG,OAAM,IAAI,WAAW,WAAW,UAAU;AACrI,UAAI,YAAY,UAAU,KAAK,SAAS,YAAY,MAAM,OAAO,OAAO,GAAG,UAAU,EAAG,OAAM,IAAI,WAAW,WAAW,SAAS;AACjI,YAAM,MAAM,4EAA4E,CAAC,SAAS,QAAQ,IAAI,CAAC;AAAA,IACjH;AAAA;AAAA,IAGA,MAAM,OAAO,SAAiB,OAAuB,QAA+B;AAClF,YAAM,OAAO,MAAM,OAAO,MAAM,IAAI,OAAO;AAC3C,YAAM,UAAU,MAAM,OAAO,QAAQ,OAAO;AAC5C,UAAI,CAAC,QAAQ,CAAC,QAAS,OAAM,IAAI,WAAW,WAAW,SAAS;AAChE,UAAI,MAAM,OAAO,UAAU,CAAC,MAAM,QAAQ,IAAI,EAAE,SAAS,OAAO,EAAG,OAAM,IAAI,WAAW,WAAW,UAAU;AAC7G,UAAI,YAAY,UAAU,MAAM,MAAM,OAAO,OAAO,GAAG,UAAU,EAAG,OAAM,IAAI,WAAW,WAAW,SAAS;AAC7G,YAAM,MAAM,mEAAmE,CAAC,SAAS,MAAM,CAAC;AAAA,IAClG;AAAA;AAAA,IAGA,MAAM,OAAO,SAAiB,OAAuB,OAAwC,KAAqE;AAChK,YAAM,OAAO,SAAS,MAAM,IAAI;AAChC,YAAM,OAAO,MAAM,OAAO,MAAM,IAAI,OAAO;AAC3C,UAAI,CAAC,KAAM,OAAM,IAAI,WAAW,WAAW,SAAS;AACpD,UAAI,CAAC,MAAM,QAAQ,IAAI,EAAE,SAAS,IAAI,EAAG,OAAM,IAAI,WAAW,WAAW,UAAU;AACnF,YAAM,QAAQ,MAAM,MAAM,KAAK,EAAE,YAAY;AAC7C,YAAM,MAAM,8FAA8F,CAAC,SAAS,OAAO,GAAG,CAAC;AAC/H,YAAM,QAAQ,SAAS;AACvB,YAAM,CAAC,GAAG,IAAI,MAAM;AAAA,QAClB;AAAA,QACA,CAACA,QAAO,WAAW,GAAG,SAAS,OAAO,MAAM,KAAK,UAAU,KAAK,GAAG,MAAM,IAAI,KAAKC,UAAS,KAAK,KAAK,cAAc,CAAC;AAAA,MACtH;AACA,aAAO,EAAE,OAAO,YAAY,EAAE,IAAI,IAAK,IAAI,OAAO,MAAM,WAAW,IAAK,YAAY,WAAW,IAAK,WAAW,EAAE;AAAA,IACnH;AAAA,IAEA,MAAM,mBAAmB,SAAiB,KAAwC;AAChF,YAAM,OAAO,MAAM,MAAqF,mJAAmJ,CAAC,SAAS,GAAG,CAAC;AACzQ,aAAO,KAAK,IAAI,CAAC,OAAO,EAAE,IAAI,EAAE,IAAI,OAAO,EAAE,OAAO,MAAM,EAAE,MAAM,WAAW,EAAE,YAAY,WAAW,EAAE,WAAW,EAAE;AAAA,IACvH;AAAA,IAEA,MAAM,iBAAiB,SAAiB,OAAuB,cAAsB,KAA0B;AAC7G,YAAM,OAAO,MAAM,OAAO,MAAM,IAAI,OAAO;AAC3C,UAAI,CAAC,KAAM,OAAM,IAAI,WAAW,WAAW,SAAS;AACpD,YAAM,CAAC,GAAG,IAAI,MAAM,MAAsB,oFAAoF,CAAC,cAAc,OAAO,CAAC;AACrJ,UAAI,CAAC,IAAK,OAAM,IAAI,WAAW,WAAW,aAAa;AACvD,UAAI,CAAC,MAAM,QAAQ,IAAI,EAAE,SAAS,IAAI,IAAI,EAAG,OAAM,IAAI,WAAW,WAAW,UAAU;AACvF,YAAM,MAAM,qDAAqD,CAAC,cAAc,GAAG,CAAC;AAAA,IACtF;AAAA;AAAA,IAGA,MAAM,iBAAiB,OAAe,SAAwC,KAAqD;AACjI,YAAM,CAAC,GAAG,IAAI,MAAM;AAAA,QAClB;AAAA,QACA,CAAC,KAAK,UAAU,KAAK,GAAG,GAAG;AAAA,MAC7B;AACA,UAAI,CAAC,IAAK,OAAM,IAAI,WAAW,WAAW,aAAa;AACvD,UAAI,IAAI,UAAU,QAAQ,MAAM,KAAK,EAAE,YAAY,EAAG,OAAM,IAAI,WAAW,WAAW,kBAAkB;AACxG,YAAM,MAAM,qDAAqD,CAAC,IAAI,IAAI,GAAG,CAAC;AAC9E,YAAM,UAAU,MAAM,OAAO,QAAQ,IAAI,IAAI,QAAQ;AAErD,UAAI,CAAC,WAAY,MAAM,QAAQ,OAAO,EAAE,SAAS,MAAM,QAAQ,IAAI,IAAI,EAAE,OAAS,OAAM,MAAM,kLAAkL,CAAC,IAAI,UAAU,QAAQ,IAAI,IAAI,MAAM,IAAI,YAAY,GAAG,CAAC;AACzU,aAAO,EAAE,SAAS,IAAI,UAAU,MAAO,CAAC,WAAW,MAAM,QAAQ,OAAO,EAAE,SAAS,MAAM,QAAQ,IAAI,IAAI,EAAE,SAAS,IAAI,OAAO,QAAiB;AAAA,IAClJ;AAAA,EACF;AACF;","names":["crypto","addHours"]}
|
|
1
|
+
{"version":3,"sources":["../src/accounts.ts","../src/port.ts","../src/migration.ts","../src/roles.ts"],"sourcesContent":["import crypto from \"node:crypto\";\n\nimport { addHours, addMinutes } 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 /**\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: { 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 /**\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, `postgresAccounts` 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(`passwordResetTtlMinutes must be a whole number from ${PASSWORD_RESET_MIN_MINUTES} to ${PASSWORD_RESET_MAX_MINUTES}, got ${minutes}`);\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 => !!value && new RegExp(`^${prefix}1\\\\.[A-Za-z0-9_-]{43}$`).test(value);\nexport const emailChangeToken = linkToken;\nexport const isEmailChangeToken = isLinkToken;\n\nexport function passwordResetMail(product: string, locale: \"en\" | \"fr\", url: string, ttlMinutes: number = PASSWORD_RESET_TTL_MINUTES) {\n checkResetTtl(ttlMinutes);\n return locale === \"fr\"\n ? { subject: `Réinitialisez votre mot de passe — ${product}`, 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 : { subject: `Reset your password — ${product}`, 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\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`, `password_resets`; see `accountsMigration`). */\nexport function postgresAccounts(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 { 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 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\" }>(\"SELECT id, locale FROM accounts WHERE email = $1 AND password_hash IS NOT NULL FOR UPDATE\", [address]);\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)\", [row.id, deps.tokenHash(token), addMinutes(now, resetTtlMinutes)]);\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({ to: address, locale: issued.locale, ...passwordResetMail(deps.product, issued.locale, url, resetTtlMinutes) });\n },\n\n async resetPassword(token, next, now) {\n if (next.length < PASSWORD_MIN) throw new AccountError(400, \"PASSWORD_TOO_SHORT\", `At least ${PASSWORD_MIN} characters`);\n if (next.length > PASSWORD_MAX) 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 }>(\"UPDATE password_resets SET used_at = $2 WHERE token_hash = $1 AND used_at IS NULL AND expires_at > $2 RETURNING account_id\", [deps.tokenHash(token), now]);\n if (!row) return \"INVALID\" as const;\n await q(\"UPDATE accounts SET password_hash = $2 WHERE id = $1\", [row.account_id, await deps.passwords.hash(next)]);\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<{ 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","/**\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(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","/**\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) and the table of password resets (a link mailed to the account's 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);\nCREATE TABLE IF NOT EXISTS password_resets (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,\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\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 * Memberships (who has which role in which scope) and invitations (an e-mail address invited with a role, by a link used once), from the\n * product's role model. `scopeTable` is the table of the scope (shops, workspaces): when given, a membership and an invitation point at it.\n * Idempotent.\n */\nexport function rolesMigration(roles: readonly string[], options: { ownerTable?: string; scopeTable?: string } = {}): string {\n const owner = ident(options.ownerTable ?? \"accounts\");\n const scope = options.scopeTable ? ` REFERENCES ${ident(options.scopeTable)}(id) ON DELETE CASCADE` : \"\";\n const list = roles.map((r) => `'${ident(r)}'`).join(\", \");\n return `\nCREATE TABLE IF NOT EXISTS memberships (\n scope_id uuid NOT NULL${scope},\n account_id uuid NOT NULL REFERENCES ${owner}(id) ON DELETE CASCADE,\n role text NOT NULL CHECK (role IN (${list})),\n invited_by uuid REFERENCES ${owner}(id) ON DELETE SET NULL,\n created_at timestamptz NOT NULL,\n PRIMARY KEY (scope_id, account_id)\n);\nCREATE INDEX IF NOT EXISTS memberships_account ON memberships(account_id);\nCREATE TABLE IF NOT EXISTS invitations (\n id uuid PRIMARY KEY,\n scope_id uuid NOT NULL${scope},\n email text NOT NULL CHECK (email = lower(email)),\n role text NOT NULL CHECK (role IN (${list})),\n token_hash text NOT NULL UNIQUE,\n invited_by uuid REFERENCES ${owner}(id) ON DELETE SET NULL,\n created_at timestamptz NOT NULL,\n expires_at timestamptz NOT NULL,\n used_at timestamptz\n);\nCREATE INDEX IF NOT EXISTS invitations_scope ON invitations(scope_id) WHERE used_at IS NULL;\n`;\n}\n","import crypto from \"node:crypto\";\n\nimport { addHours } from \"date-fns\";\n\n/**\n * Roles per scope (a shop, a workspace), as DATA: a model says which roles exist, which permissions each holds and who may hand which\n * role to whom. The code asks `can(role, \"invoices:write\")`, never `if (role === \"owner\")`. One person may belong to several scopes, with\n * a role in each. Membership and invitations live in the product's PostgreSQL (see `rolesMigration`).\n */\nexport interface RoleModel<Role extends string = string, Permission extends string = string> {\n roles: readonly Role[];\n permissions: Record<Role, readonly Permission[]>;\n /** For each role, the roles it may invite, change and remove (the owner's, typically, is every role). */\n manages: Record<Role, readonly Role[]>;\n}\n\nexport const defineRoles = <const Role extends string, const Permission extends string>(model: RoleModel<Role, Permission>): RoleModel<Role, Permission> => model;\n\nexport const can = <Role extends string, Permission extends string>(model: RoleModel<Role, Permission>, role: Role | null | undefined, permission: Permission): boolean => !!role && model.permissions[role].includes(permission);\n\nexport const ROLES_CODE = {\n forbidden: \"FORBIDDEN\",\n notMember: \"NOT_A_MEMBER\",\n unknownRole: \"UNKNOWN_ROLE\",\n lastOwner: \"LAST_OWNER\",\n notAllowed: \"ROLE_NOT_ALLOWED\",\n inviteInvalid: \"INVITE_INVALID\",\n inviteWrongAccount: \"INVITE_WRONG_ACCOUNT\",\n} as const;\nexport type RolesCode = (typeof ROLES_CODE)[keyof typeof ROLES_CODE];\nconst STATUS: Record<RolesCode, number> = { FORBIDDEN: 403, NOT_A_MEMBER: 404, UNKNOWN_ROLE: 400, LAST_OWNER: 409, ROLE_NOT_ALLOWED: 403, INVITE_INVALID: 400, INVITE_WRONG_ACCOUNT: 403 };\n\nexport class RolesError extends Error {\n readonly status: number;\n constructor(readonly code: RolesCode) {\n super(code);\n this.name = \"RolesError\";\n this.status = STATUS[code];\n }\n}\n\nexport interface RolesQuery {\n <T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;\n}\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) => query<{ account_id: string }>(\"SELECT account_id FROM memberships WHERE scope_id = $1 AND role = $2\", [scopeId, ownerRole()]);\n /** The role that cannot disappear: the first of the model that manages every role. */\n const ownerRole = (): Role => 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\", [scopeId, accountId]);\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 (await query<{ scope_id: string; role: Role }>(\"SELECT scope_id, role FROM memberships WHERE account_id = $1 ORDER BY created_at\", [accountId])).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<{ account_id: string; email: string; name: string | null; role: Role; created_at: Date }>(\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) => ({ accountId: r.account_id, email: r.email, name: r.name, role: r.role, since: r.created_at }));\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)) throw new RolesError(ROLES_CODE.notAllowed);\n if (current === ownerRole() && role !== current && (await owners(scopeId)).length <= 1) 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(scopeId: string, actor: { id: string }, input: { email: string; role: string }, now: Date): 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\", [scopeId, email, now]);\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 [crypto.randomUUID(), scopeId, email, role, deps.tokenHash(token), actor.id, now, addHours(now, deps.inviteTtlHours)],\n );\n return { token, invitation: { id: row!.id, email, role, createdAt: row!.created_at, expiresAt: row!.expires_at } };\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 }>(\"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\", [scopeId, now]);\n return rows.map((r) => ({ id: r.id, email: r.email, role: r.role, createdAt: r.created_at, expiresAt: r.expires_at }));\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 }>(\"SELECT role FROM invitations WHERE id = $1 AND scope_id = $2 AND used_at IS NULL\", [invitationId, scopeId]);\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(token: string, account: { id: string; email: string }, now: Date): 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)) await query(\"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\", [row.scope_id, account.id, row.role, row.invited_by, now]);\n return { scopeId: row.scope_id, role: (!current || model.manages[current].length < model.manages[row.role].length ? row.role : current) as Role };\n },\n };\n}\nexport type Roles<Role extends string = string, Permission extends string = string> = ReturnType<typeof createRoles<Role, Permission>>;\n"],"mappings":"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","names":["crypto","addHours"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@krizaka/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|
|
@@ -42,14 +42,14 @@
|
|
|
42
42
|
"date-fns": "^4.4.0"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
|
-
"@krizaka/config": "^0.7.0",
|
|
46
45
|
"@types/node": "^22.10.0",
|
|
47
46
|
"@types/pg": "^8.23.1",
|
|
48
47
|
"eslint": "^9.39.0",
|
|
49
48
|
"pg": "^8.23.1",
|
|
50
49
|
"tsup": "^8.5.0",
|
|
51
50
|
"typescript": "^5.9.3",
|
|
52
|
-
"vitest": "^4.1.11"
|
|
51
|
+
"vitest": "^4.1.11",
|
|
52
|
+
"@krizaka/config": "^0.8.0"
|
|
53
53
|
},
|
|
54
54
|
"scripts": {
|
|
55
55
|
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsup",
|