@krizaka/auth 0.7.0 → 0.8.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 +6 -4
- package/dist/{chunk-H7AKE2OH.js → chunk-U5SDV7YV.js} +111 -11
- package/dist/chunk-U5SDV7YV.js.map +1 -0
- package/dist/identity-links-BI3DYE2I.d.ts +97 -0
- package/dist/index.d.ts +186 -13
- package/dist/index.js +13 -53
- package/dist/index.js.map +1 -1
- package/dist/testkit.d.ts +2 -1
- package/dist/testkit.js +1 -1
- package/package.json +3 -2
- package/dist/chunk-H7AKE2OH.js.map +0 -1
- package/dist/identity-links-C5AG7Fbp.d.ts +0 -239
package/README.md
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
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
|
-
import {
|
|
6
|
+
import { emitMigration } from "@krizaka/db";
|
|
7
|
+
import { accountsSchema, dbAccounts } from "@krizaka/auth";
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
emitMigration(accountsSchema({ backfillVerified: true }), 1, { tables: { accounts: "accounts" } }); // commit the SQL once, in your migrations (idempotent)
|
|
9
10
|
const accounts = dbAccounts({ query, transaction, mail, baseUrl: "https://www.akinaza.com", product: "Akinaza", tokenPrefix: "ae", passwords: { hash, verify }, tokenHash, passwordResetTtlMinutes: 30 /* optional, 5–1440 */ }); // `transaction`: the work on one connection (BEGIN … COMMIT); the password reset needs it under concurrent requests
|
|
10
11
|
await accounts.updateProfile(id, { name: "Éric", theme: "dark" });
|
|
11
12
|
await accounts.sessions(id, new Date(), currentSessionId);
|
|
@@ -13,6 +14,7 @@ await accounts.requestPasswordReset(email, new Date()); // mails `<baseUrl>/<loc
|
|
|
13
14
|
await accounts.resetPassword(token, newPassword, new Date()); // "CHANGED" | "INVALID"
|
|
14
15
|
```
|
|
15
16
|
|
|
17
|
+
**Schema.** The tables are a `@krizaka/db` module (`auth`), version 1 byte-identical to the SQL this package shipped before, so a product that has them adopts with no DDL change: `adoptMigration(accountsSchema(), 1, context)` writes the marker only, `emitMigration(…)` writes the SQL for a database that lacks them. The old migration function stays, `@deprecated`, for one minor. The roles are the module `auth_roles` (`rolesSchema(roles, { scoped })`, tables `owner` and `scope`) and the federated links `auth_identity_links` (`identityLinksSchema`).
|
|
16
18
|
A link mail carries `mail.link` (`kind`, `url`, `minutes` or `hours`) beside `subject` and `text`: a product that sends branded HTML (`@krizaka/email`: `passwordReset`, `emailChange`) renders it from the data.
|
|
17
19
|
|
|
18
20
|
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.
|
|
@@ -26,9 +28,9 @@ A role model is data: `defineRoles({ roles, permissions, manages })` says which
|
|
|
26
28
|
Each product keeps its own accounts; **krizaka-users** is the federation provider behind a port, so a product holds no OAuth app of its own. `IdentityProvider` has two calls: `start(provider, returnTo, { codeChallenge? })` gives the address to send the person to, `complete(code, { codeVerifier? })` exchanges the one-time code (single use) for a verified `ExternalIdentity` (`issuer`, `subject`, `email`, `emailVerified`). A return address stays on the product (a path); an unknown provider is refused.
|
|
27
29
|
|
|
28
30
|
```ts
|
|
29
|
-
import { createFederatedSignIn, dbIdentityLinks,
|
|
31
|
+
import { createFederatedSignIn, dbIdentityLinks, identityLinksSchema, usersIdentityProvider } from "@krizaka/auth";
|
|
30
32
|
|
|
31
|
-
|
|
33
|
+
emitMigration(identityLinksSchema, 1, { tables: { accounts: "accounts" } }); // once, in your migrations (idempotent): (issuer, subject) → account
|
|
32
34
|
const federation = usersIdentityProvider({ baseUrl: "https://auth.dev.akinaza.com", appUrl: "https://dev.akinaza.com", fetch });
|
|
33
35
|
const signIn = createFederatedSignIn({ links: dbIdentityLinks(query), accounts: { findByEmail, create }, clock });
|
|
34
36
|
|
|
@@ -44,6 +44,112 @@ function memoryIdentityProvider() {
|
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// src/schema.ts
|
|
48
|
+
import { defineModule } from "@krizaka/db";
|
|
49
|
+
function accountsSchema(options = {}) {
|
|
50
|
+
return defineModule({
|
|
51
|
+
module: "auth",
|
|
52
|
+
schema: "public",
|
|
53
|
+
tables: ["accounts"],
|
|
54
|
+
migrations: [
|
|
55
|
+
{
|
|
56
|
+
version: 1,
|
|
57
|
+
name: "accounts profile, e-mail changes, password resets",
|
|
58
|
+
sql: (c) => `
|
|
59
|
+
ALTER TABLE ${c.tables.accounts} ADD COLUMN IF NOT EXISTS name text CHECK (name IS NULL OR char_length(name) <= 80);
|
|
60
|
+
ALTER TABLE ${c.tables.accounts} ADD COLUMN IF NOT EXISTS avatar_seed text CHECK (avatar_seed IS NULL OR avatar_seed ~ '^[a-z0-9-]{1,40}$');
|
|
61
|
+
ALTER TABLE ${c.tables.accounts} ADD COLUMN IF NOT EXISTS theme text CHECK (theme IS NULL OR theme IN ('light', 'dark', 'system'));
|
|
62
|
+
ALTER TABLE ${c.tables.accounts} ADD COLUMN IF NOT EXISTS email_verified_at timestamptz;
|
|
63
|
+
${options.backfillVerified ? `UPDATE ${c.tables.accounts} SET email_verified_at = created_at WHERE email_verified_at IS NULL;
|
|
64
|
+
` : ""}CREATE TABLE IF NOT EXISTS email_changes (
|
|
65
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
66
|
+
account_id uuid NOT NULL REFERENCES ${c.tables.accounts}(id) ON DELETE CASCADE,
|
|
67
|
+
new_email text NOT NULL CHECK (new_email = lower(new_email)),
|
|
68
|
+
token_hash text NOT NULL UNIQUE,
|
|
69
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
70
|
+
expires_at timestamptz NOT NULL,
|
|
71
|
+
used_at timestamptz
|
|
72
|
+
);
|
|
73
|
+
CREATE TABLE IF NOT EXISTS password_resets (
|
|
74
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
75
|
+
account_id uuid NOT NULL REFERENCES ${c.tables.accounts}(id) ON DELETE CASCADE,
|
|
76
|
+
token_hash text NOT NULL UNIQUE,
|
|
77
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
78
|
+
expires_at timestamptz NOT NULL,
|
|
79
|
+
used_at timestamptz
|
|
80
|
+
);
|
|
81
|
+
`
|
|
82
|
+
}
|
|
83
|
+
]
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
var IDENT = /^[a-z_][a-z0-9_]{0,62}$/;
|
|
87
|
+
var ident = (name) => {
|
|
88
|
+
if (!IDENT.test(name)) throw new Error(`not a safe SQL identifier: ${name}`);
|
|
89
|
+
return name;
|
|
90
|
+
};
|
|
91
|
+
function rolesSchema(roles, options = {}) {
|
|
92
|
+
const list = roles.map((r) => `'${ident(r)}'`).join(", ");
|
|
93
|
+
return defineModule({
|
|
94
|
+
module: "auth_roles",
|
|
95
|
+
schema: "public",
|
|
96
|
+
tables: options.scoped ? ["owner", "scope"] : ["owner"],
|
|
97
|
+
migrations: [
|
|
98
|
+
{
|
|
99
|
+
version: 1,
|
|
100
|
+
name: "memberships and invitations",
|
|
101
|
+
sql: (c) => {
|
|
102
|
+
const scope = options.scoped ? ` REFERENCES ${c.tables.scope}(id) ON DELETE CASCADE` : "";
|
|
103
|
+
return `
|
|
104
|
+
CREATE TABLE IF NOT EXISTS memberships (
|
|
105
|
+
scope_id uuid NOT NULL${scope},
|
|
106
|
+
account_id uuid NOT NULL REFERENCES ${c.tables.owner}(id) ON DELETE CASCADE,
|
|
107
|
+
role text NOT NULL CHECK (role IN (${list})),
|
|
108
|
+
invited_by uuid REFERENCES ${c.tables.owner}(id) ON DELETE SET NULL,
|
|
109
|
+
created_at timestamptz NOT NULL,
|
|
110
|
+
PRIMARY KEY (scope_id, account_id)
|
|
111
|
+
);
|
|
112
|
+
CREATE INDEX IF NOT EXISTS memberships_account ON memberships(account_id);
|
|
113
|
+
CREATE TABLE IF NOT EXISTS invitations (
|
|
114
|
+
id uuid PRIMARY KEY,
|
|
115
|
+
scope_id uuid NOT NULL${scope},
|
|
116
|
+
email text NOT NULL CHECK (email = lower(email)),
|
|
117
|
+
role text NOT NULL CHECK (role IN (${list})),
|
|
118
|
+
token_hash text NOT NULL UNIQUE,
|
|
119
|
+
invited_by uuid REFERENCES ${c.tables.owner}(id) ON DELETE SET NULL,
|
|
120
|
+
created_at timestamptz NOT NULL,
|
|
121
|
+
expires_at timestamptz NOT NULL,
|
|
122
|
+
used_at timestamptz
|
|
123
|
+
);
|
|
124
|
+
CREATE INDEX IF NOT EXISTS invitations_scope ON invitations(scope_id) WHERE used_at IS NULL;
|
|
125
|
+
`;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
var identityLinksSchema = defineModule({
|
|
132
|
+
module: "auth_identity_links",
|
|
133
|
+
schema: "public",
|
|
134
|
+
tables: ["accounts"],
|
|
135
|
+
migrations: [
|
|
136
|
+
{
|
|
137
|
+
version: 1,
|
|
138
|
+
name: "external identities",
|
|
139
|
+
sql: (c) => `
|
|
140
|
+
CREATE TABLE IF NOT EXISTS external_identities (
|
|
141
|
+
issuer text NOT NULL CHECK (char_length(issuer) BETWEEN 1 AND 200),
|
|
142
|
+
subject text NOT NULL CHECK (char_length(subject) BETWEEN 1 AND 200),
|
|
143
|
+
account_id uuid NOT NULL REFERENCES ${c.tables.accounts}(id) ON DELETE CASCADE,
|
|
144
|
+
linked_at timestamptz NOT NULL,
|
|
145
|
+
PRIMARY KEY (issuer, subject)
|
|
146
|
+
);
|
|
147
|
+
CREATE INDEX IF NOT EXISTS external_identities_account ON external_identities(account_id, linked_at);
|
|
148
|
+
`
|
|
149
|
+
}
|
|
150
|
+
]
|
|
151
|
+
});
|
|
152
|
+
|
|
47
153
|
// src/identity-links.ts
|
|
48
154
|
import { compareAsc } from "date-fns";
|
|
49
155
|
var IdentityLinkError = class extends Error {
|
|
@@ -78,16 +184,7 @@ ${subject}`;
|
|
|
78
184
|
};
|
|
79
185
|
}
|
|
80
186
|
function identityLinksMigration() {
|
|
81
|
-
return
|
|
82
|
-
CREATE TABLE IF NOT EXISTS external_identities (
|
|
83
|
-
issuer text NOT NULL CHECK (char_length(issuer) BETWEEN 1 AND 200),
|
|
84
|
-
subject text NOT NULL CHECK (char_length(subject) BETWEEN 1 AND 200),
|
|
85
|
-
account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
|
86
|
-
linked_at timestamptz NOT NULL,
|
|
87
|
-
PRIMARY KEY (issuer, subject)
|
|
88
|
-
);
|
|
89
|
-
CREATE INDEX IF NOT EXISTS external_identities_account ON external_identities(account_id, linked_at);
|
|
90
|
-
`;
|
|
187
|
+
return identityLinksSchema.migrations[0].sql({ schema: "public", tables: { accounts: "accounts" } });
|
|
91
188
|
}
|
|
92
189
|
function dbIdentityLinks(query) {
|
|
93
190
|
return {
|
|
@@ -123,9 +220,12 @@ export {
|
|
|
123
220
|
pkceChallenge,
|
|
124
221
|
isLocalReturn,
|
|
125
222
|
memoryIdentityProvider,
|
|
223
|
+
accountsSchema,
|
|
224
|
+
rolesSchema,
|
|
225
|
+
identityLinksSchema,
|
|
126
226
|
IdentityLinkError,
|
|
127
227
|
memoryIdentityLinks,
|
|
128
228
|
identityLinksMigration,
|
|
129
229
|
dbIdentityLinks
|
|
130
230
|
};
|
|
131
|
-
//# sourceMappingURL=chunk-
|
|
231
|
+
//# sourceMappingURL=chunk-U5SDV7YV.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/federation.ts","../src/schema.ts","../src/identity-links.ts"],"sourcesContent":["import crypto from \"node:crypto\";\n\n/**\n * Federated sign-in (Google, Apple, Facebook, a native app's PKCE) delegated to krizaka-users, behind a PORT. The product keeps its\n * own accounts (`postgresAccounts`); this port only answers « who is this person, according to the provider ». An external\n * identity is never a second user table: it is a link to an account the product already has.\n */\nexport type FederatedProvider = \"google\" | \"apple\" | \"facebook\";\n\n/** The providers the port knows; anything else is refused before it reaches the adapter. */\nexport const FEDERATED_PROVIDERS: readonly FederatedProvider[] = [\"google\", \"apple\", \"facebook\"];\n\nexport const isFederatedProvider = (value: unknown): value is FederatedProvider => FEDERATED_PROVIDERS.includes(value as FederatedProvider);\n\n/** Who the provider says the person is. `issuer` + `subject` identify them for good; the address can change. */\nexport interface ExternalIdentity {\n /** The authority that vouches (`https://accounts.google.com`, `https://appleid.apple.com`…). */\n issuer: string;\n /** The person's stable id at that authority. */\n subject: string;\n email: string | null;\n /** The provider checked the address (a Facebook address, for one, may not be). */\n emailVerified: boolean;\n name: string | null;\n}\n\n/** What a sign-in is started with: a native app sends the PKCE challenge, a browser flow does not. */\nexport interface StartOptions {\n /** The S256 challenge of a PKCE flow (a native app); the matching verifier goes to `complete`. */\n codeChallenge?: string;\n}\n\nexport interface StartedSignIn {\n /** Where to send the person: the provider's consent page, through krizaka-users. */\n url: string;\n /** Echoed back with the code: the product checks it against the one it kept for this browser. */\n state: string;\n}\n\nexport interface CompleteOptions {\n /** The PKCE verifier of the challenge given to `start`. */\n codeVerifier?: string;\n}\n\nexport interface IdentityProvider {\n /** Starts a sign-in with `provider`; the person comes back to `returnTo` (a path or an address the adapter allows) with a one-time code. */\n start(provider: FederatedProvider, returnTo: string, options?: StartOptions): Promise<StartedSignIn>;\n /** Exchanges the one-time code (single use) for the verified identity. Throws `FederationError(\"INVALID_CODE\")` for a code that is unknown, used or expired. */\n complete(code: string, options?: CompleteOptions): Promise<ExternalIdentity>;\n}\n\nexport const FEDERATION_CODE = {\n invalidCode: \"INVALID_CODE\",\n unknownProvider: \"UNKNOWN_PROVIDER\",\n badReturn: \"BAD_RETURN_TO\",\n unavailable: \"UNAVAILABLE\",\n} as const;\nexport type FederationCode = (typeof FEDERATION_CODE)[keyof typeof FEDERATION_CODE];\n\nexport class FederationError extends Error {\n constructor(\n readonly code: FederationCode,\n message: string,\n ) {\n super(message);\n this.name = \"FederationError\";\n }\n}\n\n/** The S256 challenge of a PKCE verifier: base64url(SHA-256(verifier)). A native app computes it; the adapter and the in-memory provider check it. */\nexport const pkceChallenge = (verifier: string): string => crypto.createHash(\"sha256\").update(verifier).digest(\"base64url\");\n\n/** A return address stays on the product: a path (`/fr/account`), never `//host` or a scheme. */\nexport const isLocalReturn = (returnTo: string): boolean => returnTo.startsWith(\"/\") && !returnTo.startsWith(\"//\") && !returnTo.includes(\"\\\\\") && [...returnTo].every((c) => c.charCodeAt(0) > 31);\n\n/** An in-memory provider: the tests' adapter, and the reference the contract is written against. */\nexport function memoryIdentityProvider(): IdentityProvider & {\n /** Test seam: the one-time code the provider would hand back for this identity (with the PKCE challenge it was started with, if any). */\n issue(identity: ExternalIdentity, challenge?: string): string;\n} {\n const pending = new Map<string, { identity: ExternalIdentity; challenge: string | null }>();\n let counter = 0;\n return {\n async start(provider, returnTo) {\n if (!isFederatedProvider(provider)) throw new FederationError(FEDERATION_CODE.unknownProvider, `Unknown provider ${String(provider)}`);\n if (!isLocalReturn(returnTo)) throw new FederationError(FEDERATION_CODE.badReturn, \"That return address is not allowed\");\n counter += 1;\n const state = `state-${counter}`;\n return { url: `https://auth.test/${provider}/start?state=${state}`, state };\n },\n async complete(code, options = {}) {\n const entry = pending.get(code);\n pending.delete(code); // single use, whatever happens next\n if (!entry) throw new FederationError(FEDERATION_CODE.invalidCode, \"That code is not valid\");\n if (entry.challenge !== null && (!options.codeVerifier || pkceChallenge(options.codeVerifier) !== entry.challenge)) throw new FederationError(FEDERATION_CODE.invalidCode, \"That code is not valid\");\n return entry.identity;\n },\n issue(identity, challenge) {\n counter += 1;\n pending.set(`code-${counter}`, { identity, challenge: challenge ?? null });\n return `code-${counter}`;\n },\n };\n}\n","import { defineModule, type ModuleSchema } from \"@krizaka/db\";\n\n/**\n * The tables of « auth », declared as modules (ADR-001 §3). They are added to a product's own `accounts` and `sessions` tables, which\n * existed before the module schemas: they stay in `public`, un-qualified, and a product that has them adopts version 1\n * (`adoptMigration`, markers only) while one that does not emits it (`emitMigration`). Version 1 of each is frozen once released:\n * a change is version 2. The one product table every one of them references is declared by the product (`accounts`, `owner`).\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`;\n * `sessions` needs `account_id`, `created_at`, `expires_at`, `ip`, `user_agent`.\n * @param options `backfillVerified`: for accounts that exist already, treat the address as verified (invitation-only products)\n */\nexport function accountsSchema(options: { backfillVerified?: boolean } = {}): ModuleSchema {\n return defineModule({\n module: \"auth\",\n schema: \"public\",\n tables: [\"accounts\"],\n migrations: [\n {\n version: 1,\n name: \"accounts profile, e-mail changes, password resets\",\n sql: (c) => `\nALTER TABLE ${c.tables.accounts} ADD COLUMN IF NOT EXISTS name text CHECK (name IS NULL OR char_length(name) <= 80);\nALTER TABLE ${c.tables.accounts} ADD COLUMN IF NOT EXISTS avatar_seed text CHECK (avatar_seed IS NULL OR avatar_seed ~ '^[a-z0-9-]{1,40}$');\nALTER TABLE ${c.tables.accounts} ADD COLUMN IF NOT EXISTS theme text CHECK (theme IS NULL OR theme IN ('light', 'dark', 'system'));\nALTER TABLE ${c.tables.accounts} ADD COLUMN IF NOT EXISTS email_verified_at timestamptz;\n${options.backfillVerified ? `UPDATE ${c.tables.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 ${c.tables.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 ${c.tables.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 ],\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. Declares the account table (`owner`) and, when the roles are scoped, the scope table (`scope`: shops, workspaces):\n * a membership and an invitation then point at it. Idempotent.\n * @param roles the role names of the model: each becomes a value of the `role` check, so each must be a safe identifier\n * @param options `scoped`: true when the product declares a `scope` table\n * @throws Error for a role that is not a safe SQL identifier\n */\nexport function rolesSchema(roles: readonly string[], options: { scoped?: boolean } = {}): ModuleSchema {\n const list = roles.map((r) => `'${ident(r)}'`).join(\", \");\n return defineModule({\n module: \"auth_roles\",\n schema: \"public\",\n tables: options.scoped ? [\"owner\", \"scope\"] : [\"owner\"],\n migrations: [\n {\n version: 1,\n name: \"memberships and invitations\",\n sql: (c) => {\n const scope = options.scoped ? ` REFERENCES ${c.tables.scope}(id) ON DELETE CASCADE` : \"\";\n return `\nCREATE TABLE IF NOT EXISTS memberships (\n scope_id uuid NOT NULL${scope},\n account_id uuid NOT NULL REFERENCES ${c.tables.owner}(id) ON DELETE CASCADE,\n role text NOT NULL CHECK (role IN (${list})),\n invited_by uuid REFERENCES ${c.tables.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 ${c.tables.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 ],\n });\n}\n\n/** Which external identity belongs to which account (federated sign-in): `(issuer, subject) → account`, never a second user table. Idempotent. */\nexport const identityLinksSchema = defineModule({\n module: \"auth_identity_links\",\n schema: \"public\",\n tables: [\"accounts\"],\n migrations: [\n {\n version: 1,\n name: \"external identities\",\n sql: (c) => `\nCREATE TABLE IF NOT EXISTS external_identities (\n issuer text NOT NULL CHECK (char_length(issuer) BETWEEN 1 AND 200),\n subject text NOT NULL CHECK (char_length(subject) BETWEEN 1 AND 200),\n account_id uuid NOT NULL REFERENCES ${c.tables.accounts}(id) ON DELETE CASCADE,\n linked_at timestamptz NOT NULL,\n PRIMARY KEY (issuer, subject)\n);\nCREATE INDEX IF NOT EXISTS external_identities_account ON external_identities(account_id, linked_at);\n`,\n },\n ],\n});\n","import { compareAsc } from \"date-fns\";\n\nimport type { Query } from \"./accounts\";\nimport { identityLinksSchema } from \"./schema\";\n\n/**\n * Which external identity belongs to which account. Never a second user table: a row is only `(issuer, subject) → account`.\n * The product's `accounts` stays the one place a person lives.\n */\nexport interface IdentityLinkStore {\n /** The account this external identity is linked to, or null. */\n find(issuer: string, subject: string): Promise<string | null>;\n /** Links it. Idempotent for the same account; throws when the identity is already linked to another one. */\n link(accountId: string, issuer: string, subject: string, now: Date): Promise<void>;\n /** Removes the link (the person unlinks Google); true when there was one. */\n unlink(accountId: string, issuer: string, subject: string): Promise<boolean>;\n /** The identities linked to an account, oldest first. */\n list(accountId: string): Promise<{ issuer: string; subject: string; linkedAt: Date }[]>;\n}\n\nexport class IdentityLinkError extends Error {\n readonly code = \"ALREADY_LINKED\";\n constructor() {\n super(\"That external identity is already linked to another account\");\n this.name = \"IdentityLinkError\";\n }\n}\n\n/** An in-memory store: the tests' adapter, and the reference the contract is written against. */\nexport function memoryIdentityLinks(): IdentityLinkStore {\n const rows = new Map<string, { accountId: string; issuer: string; subject: string; linkedAt: Date }>();\n const key = (issuer: string, subject: string) => `${issuer}\\n${subject}`;\n return {\n async find(issuer, subject) {\n return rows.get(key(issuer, subject))?.accountId ?? null;\n },\n async link(accountId, issuer, subject, now) {\n const existing = rows.get(key(issuer, subject));\n if (existing && existing.accountId !== accountId) throw new IdentityLinkError();\n if (!existing) rows.set(key(issuer, subject), { accountId, issuer, subject, linkedAt: now });\n },\n async unlink(accountId, issuer, subject) {\n const existing = rows.get(key(issuer, subject));\n if (existing?.accountId !== accountId) return false;\n rows.delete(key(issuer, subject));\n return true;\n },\n async list(accountId) {\n return [...rows.values()].filter((r) => r.accountId === accountId).sort((a, b) => compareAsc(a.linkedAt, b.linkedAt)).map(({ issuer, subject, linkedAt }) => ({ issuer, subject, linkedAt }));\n },\n };\n}\n\n/**\n * The table of links (idempotent). `accounts` is the product's account table.\n * @deprecated Use `identityLinksSchema` with `emitMigration` or `adoptMigration` from `@krizaka/db`. Removed in the next major.\n */\nexport function identityLinksMigration(): string {\n return identityLinksSchema.migrations[0]!.sql({ schema: \"public\", tables: { accounts: \"accounts\" } });\n}\n\n/** The store over the product's own database (`external_identities`; see `identityLinksMigration`). */\nexport function dbIdentityLinks(query: Query): IdentityLinkStore {\n return {\n async find(issuer, subject) {\n const [row] = await query<{ account_id: string }>(\"SELECT account_id FROM external_identities WHERE issuer = $1 AND subject = $2\", [issuer, subject]);\n return row?.account_id ?? null;\n },\n async link(accountId, issuer, subject, now) {\n // One statement: the primary key decides a race; the same account is a no-op, another account is refused.\n const rows = await query<{ account_id: string }>(\n `INSERT INTO external_identities (issuer, subject, account_id, linked_at) VALUES ($1, $2, $3, $4)\n ON CONFLICT (issuer, subject) DO UPDATE SET issuer = excluded.issuer WHERE external_identities.account_id = excluded.account_id\n RETURNING account_id`,\n [issuer, subject, accountId, now],\n );\n if (rows.length === 0) throw new IdentityLinkError();\n },\n async unlink(accountId, issuer, subject) {\n const rows = await query(\"DELETE FROM external_identities WHERE issuer = $1 AND subject = $2 AND account_id = $3 RETURNING 1\", [issuer, subject, accountId]);\n return rows.length > 0;\n },\n async list(accountId) {\n const rows = await query<{ issuer: string; subject: string; linked_at: Date }>(\"SELECT issuer, subject, linked_at FROM external_identities WHERE account_id = $1 ORDER BY linked_at, issuer, subject\", [accountId]);\n return rows.map((r) => ({ issuer: r.issuer, subject: r.subject, linkedAt: r.linked_at }));\n },\n };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Query } from '@krizaka/db';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Federated sign-in (Google, Apple, Facebook, a native app's PKCE) delegated to krizaka-users, behind a PORT. The product keeps its
|
|
5
|
+
* own accounts (`postgresAccounts`); this port only answers « who is this person, according to the provider ». An external
|
|
6
|
+
* identity is never a second user table: it is a link to an account the product already has.
|
|
7
|
+
*/
|
|
8
|
+
type FederatedProvider = "google" | "apple" | "facebook";
|
|
9
|
+
/** The providers the port knows; anything else is refused before it reaches the adapter. */
|
|
10
|
+
declare const FEDERATED_PROVIDERS: readonly FederatedProvider[];
|
|
11
|
+
declare const isFederatedProvider: (value: unknown) => value is FederatedProvider;
|
|
12
|
+
/** Who the provider says the person is. `issuer` + `subject` identify them for good; the address can change. */
|
|
13
|
+
interface ExternalIdentity {
|
|
14
|
+
/** The authority that vouches (`https://accounts.google.com`, `https://appleid.apple.com`…). */
|
|
15
|
+
issuer: string;
|
|
16
|
+
/** The person's stable id at that authority. */
|
|
17
|
+
subject: string;
|
|
18
|
+
email: string | null;
|
|
19
|
+
/** The provider checked the address (a Facebook address, for one, may not be). */
|
|
20
|
+
emailVerified: boolean;
|
|
21
|
+
name: string | null;
|
|
22
|
+
}
|
|
23
|
+
/** What a sign-in is started with: a native app sends the PKCE challenge, a browser flow does not. */
|
|
24
|
+
interface StartOptions {
|
|
25
|
+
/** The S256 challenge of a PKCE flow (a native app); the matching verifier goes to `complete`. */
|
|
26
|
+
codeChallenge?: string;
|
|
27
|
+
}
|
|
28
|
+
interface StartedSignIn {
|
|
29
|
+
/** Where to send the person: the provider's consent page, through krizaka-users. */
|
|
30
|
+
url: string;
|
|
31
|
+
/** Echoed back with the code: the product checks it against the one it kept for this browser. */
|
|
32
|
+
state: string;
|
|
33
|
+
}
|
|
34
|
+
interface CompleteOptions {
|
|
35
|
+
/** The PKCE verifier of the challenge given to `start`. */
|
|
36
|
+
codeVerifier?: string;
|
|
37
|
+
}
|
|
38
|
+
interface IdentityProvider {
|
|
39
|
+
/** Starts a sign-in with `provider`; the person comes back to `returnTo` (a path or an address the adapter allows) with a one-time code. */
|
|
40
|
+
start(provider: FederatedProvider, returnTo: string, options?: StartOptions): Promise<StartedSignIn>;
|
|
41
|
+
/** Exchanges the one-time code (single use) for the verified identity. Throws `FederationError("INVALID_CODE")` for a code that is unknown, used or expired. */
|
|
42
|
+
complete(code: string, options?: CompleteOptions): Promise<ExternalIdentity>;
|
|
43
|
+
}
|
|
44
|
+
declare const FEDERATION_CODE: {
|
|
45
|
+
readonly invalidCode: "INVALID_CODE";
|
|
46
|
+
readonly unknownProvider: "UNKNOWN_PROVIDER";
|
|
47
|
+
readonly badReturn: "BAD_RETURN_TO";
|
|
48
|
+
readonly unavailable: "UNAVAILABLE";
|
|
49
|
+
};
|
|
50
|
+
type FederationCode = (typeof FEDERATION_CODE)[keyof typeof FEDERATION_CODE];
|
|
51
|
+
declare class FederationError extends Error {
|
|
52
|
+
readonly code: FederationCode;
|
|
53
|
+
constructor(code: FederationCode, message: string);
|
|
54
|
+
}
|
|
55
|
+
/** The S256 challenge of a PKCE verifier: base64url(SHA-256(verifier)). A native app computes it; the adapter and the in-memory provider check it. */
|
|
56
|
+
declare const pkceChallenge: (verifier: string) => string;
|
|
57
|
+
/** A return address stays on the product: a path (`/fr/account`), never `//host` or a scheme. */
|
|
58
|
+
declare const isLocalReturn: (returnTo: string) => boolean;
|
|
59
|
+
/** An in-memory provider: the tests' adapter, and the reference the contract is written against. */
|
|
60
|
+
declare function memoryIdentityProvider(): IdentityProvider & {
|
|
61
|
+
/** Test seam: the one-time code the provider would hand back for this identity (with the PKCE challenge it was started with, if any). */
|
|
62
|
+
issue(identity: ExternalIdentity, challenge?: string): string;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Which external identity belongs to which account. Never a second user table: a row is only `(issuer, subject) → account`.
|
|
67
|
+
* The product's `accounts` stays the one place a person lives.
|
|
68
|
+
*/
|
|
69
|
+
interface IdentityLinkStore {
|
|
70
|
+
/** The account this external identity is linked to, or null. */
|
|
71
|
+
find(issuer: string, subject: string): Promise<string | null>;
|
|
72
|
+
/** Links it. Idempotent for the same account; throws when the identity is already linked to another one. */
|
|
73
|
+
link(accountId: string, issuer: string, subject: string, now: Date): Promise<void>;
|
|
74
|
+
/** Removes the link (the person unlinks Google); true when there was one. */
|
|
75
|
+
unlink(accountId: string, issuer: string, subject: string): Promise<boolean>;
|
|
76
|
+
/** The identities linked to an account, oldest first. */
|
|
77
|
+
list(accountId: string): Promise<{
|
|
78
|
+
issuer: string;
|
|
79
|
+
subject: string;
|
|
80
|
+
linkedAt: Date;
|
|
81
|
+
}[]>;
|
|
82
|
+
}
|
|
83
|
+
declare class IdentityLinkError extends Error {
|
|
84
|
+
readonly code = "ALREADY_LINKED";
|
|
85
|
+
constructor();
|
|
86
|
+
}
|
|
87
|
+
/** An in-memory store: the tests' adapter, and the reference the contract is written against. */
|
|
88
|
+
declare function memoryIdentityLinks(): IdentityLinkStore;
|
|
89
|
+
/**
|
|
90
|
+
* The table of links (idempotent). `accounts` is the product's account table.
|
|
91
|
+
* @deprecated Use `identityLinksSchema` with `emitMigration` or `adoptMigration` from `@krizaka/db`. Removed in the next major.
|
|
92
|
+
*/
|
|
93
|
+
declare function identityLinksMigration(): string;
|
|
94
|
+
/** The store over the product's own database (`external_identities`; see `identityLinksMigration`). */
|
|
95
|
+
declare function dbIdentityLinks(query: Query): IdentityLinkStore;
|
|
96
|
+
|
|
97
|
+
export { type CompleteOptions as C, type ExternalIdentity as E, FEDERATED_PROVIDERS as F, type IdentityLinkStore as I, type StartOptions as S, type IdentityProvider as a, FEDERATION_CODE as b, type FederatedProvider as c, type FederationCode as d, FederationError as e, IdentityLinkError as f, type StartedSignIn as g, dbIdentityLinks as h, identityLinksMigration as i, isFederatedProvider as j, isLocalReturn as k, memoryIdentityProvider as l, memoryIdentityLinks as m, pkceChallenge as p };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,152 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export {
|
|
1
|
+
import { Query, ModuleSchema } from '@krizaka/db';
|
|
2
|
+
export { Query } from '@krizaka/db';
|
|
3
|
+
import { E as ExternalIdentity, I as IdentityLinkStore, a as IdentityProvider } from './identity-links-BI3DYE2I.js';
|
|
4
|
+
export { C as CompleteOptions, F as FEDERATED_PROVIDERS, b as FEDERATION_CODE, c as FederatedProvider, d as FederationCode, e as FederationError, f as IdentityLinkError, S as StartOptions, g as StartedSignIn, h as dbIdentityLinks, i as identityLinksMigration, j as isFederatedProvider, k as isLocalReturn, m as memoryIdentityLinks, l as memoryIdentityProvider, p as pkceChallenge } from './identity-links-BI3DYE2I.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The account PORT (ADR-0008 of Yomizaka, now shared). A product's account — name, language, theme, avatar, e-mail change with
|
|
8
|
+
* 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
|
|
9
|
+
* service, no runtime dependency between products.
|
|
10
|
+
*/
|
|
11
|
+
type Theme = "light" | "dark" | "system";
|
|
12
|
+
interface AccountProfile {
|
|
13
|
+
id: string;
|
|
14
|
+
email: string;
|
|
15
|
+
name: string | null;
|
|
16
|
+
locale: "en" | "fr";
|
|
17
|
+
/** The theme the person chose; null = follow the device. */
|
|
18
|
+
theme: Theme | null;
|
|
19
|
+
/** A seed of the default avatars (the UI draws it); null = derived from the e-mail address. */
|
|
20
|
+
avatarSeed: string | null;
|
|
21
|
+
emailVerified: boolean;
|
|
22
|
+
/** An address awaiting its verification link, if the person asked to change it. */
|
|
23
|
+
pendingEmail: string | null;
|
|
24
|
+
}
|
|
25
|
+
interface ProfilePatch {
|
|
26
|
+
name?: string | null;
|
|
27
|
+
locale?: "en" | "fr";
|
|
28
|
+
theme?: Theme | null;
|
|
29
|
+
avatarSeed?: string | null;
|
|
30
|
+
}
|
|
31
|
+
/** A sign-in of the person: where from and when. Never the token. */
|
|
32
|
+
interface SessionSummary {
|
|
33
|
+
id: string;
|
|
34
|
+
createdAt: Date;
|
|
35
|
+
expiresAt: Date;
|
|
36
|
+
ip: string | null;
|
|
37
|
+
userAgent: string | null;
|
|
38
|
+
current: boolean;
|
|
39
|
+
}
|
|
40
|
+
interface AccountService {
|
|
41
|
+
profile(accountId: string): Promise<AccountProfile>;
|
|
42
|
+
updateProfile(accountId: string, patch: ProfilePatch): Promise<AccountProfile>;
|
|
43
|
+
/** 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). */
|
|
44
|
+
requestEmailChange(accountId: string, input: {
|
|
45
|
+
newEmail: string;
|
|
46
|
+
password: string;
|
|
47
|
+
}, now: Date): Promise<void>;
|
|
48
|
+
/** Applies the change from the mailed link (single use, expires); the new address is verified by that very act. */
|
|
49
|
+
confirmEmailChange(token: string, now: Date): Promise<"CHANGED" | "INVALID">;
|
|
50
|
+
/** Changes the password (the current one is checked) and signs every other session out; `keepSessionId` stays. */
|
|
51
|
+
changePassword(accountId: string, input: {
|
|
52
|
+
current: string;
|
|
53
|
+
next: string;
|
|
54
|
+
}, keepSessionId?: string): Promise<void>;
|
|
55
|
+
/** 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). */
|
|
56
|
+
requestPasswordReset(email: string, now: Date): Promise<void>;
|
|
57
|
+
/** 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. */
|
|
58
|
+
resetPassword(token: string, next: string, now: Date): Promise<"CHANGED" | "INVALID">;
|
|
59
|
+
/** Checks the password. Throws `AccountError(403, "WRONG_PASSWORD")`. */
|
|
60
|
+
confirmPassword(accountId: string, password: string): Promise<void>;
|
|
61
|
+
/** The person's live sign-ins, newest first; `currentSessionId` is flagged. */
|
|
62
|
+
sessions(accountId: string, now: Date, currentSessionId?: string): Promise<SessionSummary[]>;
|
|
63
|
+
/** Signs every other session out (and returns how many); `keepSessionId` stays. */
|
|
64
|
+
signOutOthers(accountId: string, keepSessionId: string): Promise<number>;
|
|
65
|
+
/** What the product keeps about the person, as one JSON-able document: the profile, the live sign-ins, and what `extra` adds. */
|
|
66
|
+
exportAccount(accountId: string, now: Date, extra?: () => Promise<Record<string, unknown>>): Promise<Record<string, unknown>>;
|
|
67
|
+
/** Deletes the identity: the account, its sessions and everything that hangs on it (the product erases its own data first). */
|
|
68
|
+
deleteAccount(accountId: string): Promise<void>;
|
|
69
|
+
}
|
|
70
|
+
declare class AccountError extends Error {
|
|
71
|
+
readonly status: number;
|
|
72
|
+
readonly code: string;
|
|
73
|
+
constructor(status: number, code: string, message: string);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface Mail {
|
|
77
|
+
to: string;
|
|
78
|
+
/** The language of the mail (the person's). */
|
|
79
|
+
locale: "en" | "fr";
|
|
80
|
+
subject: string;
|
|
81
|
+
text: string;
|
|
82
|
+
/**
|
|
83
|
+
* The link this mail carries, as data: a product that sends branded HTML (`@krizaka/email`: `passwordReset`, `emailChange`)
|
|
84
|
+
* renders it from this instead of from `text`. Absent on a mail that is not a link.
|
|
85
|
+
*/
|
|
86
|
+
link?: MailLink;
|
|
87
|
+
}
|
|
88
|
+
/** A mailed link: what it is for, where it goes, and how long it lives. */
|
|
89
|
+
interface MailLink {
|
|
90
|
+
kind: "passwordReset" | "emailChange";
|
|
91
|
+
url: string;
|
|
92
|
+
/** `passwordReset`: the lifetime in minutes. */
|
|
93
|
+
minutes?: number;
|
|
94
|
+
/** `emailChange`: the lifetime in hours. */
|
|
95
|
+
hours?: number;
|
|
96
|
+
}
|
|
97
|
+
interface AccountDeps {
|
|
98
|
+
query: Query;
|
|
99
|
+
/**
|
|
100
|
+
* Runs `work` in one transaction on one connection (BEGIN … COMMIT, rolled back when `work` throws) — the product's pool
|
|
101
|
+
* knows how. The password reset runs under it: the account row is locked, the links retired and issued, the token claimed,
|
|
102
|
+
* the password set and the sessions ended as one. Without it, the statements run one by one on `query` (one process, no
|
|
103
|
+
* concurrent requests): give it in production.
|
|
104
|
+
*/
|
|
105
|
+
transaction?: <T>(work: (query: Query) => Promise<T>) => Promise<T>;
|
|
106
|
+
mail: (mail: Mail) => Promise<void>;
|
|
107
|
+
/** `https://www.akinaza.com`: the e-mail change link is `<baseUrl>/<locale>/confirm-email?token=…`, the reset link `<baseUrl>/<locale>/reset-password?token=…`. */
|
|
108
|
+
baseUrl: string;
|
|
109
|
+
/** The product's name, in the words of the mail (« Akinaza »). */
|
|
110
|
+
product: string;
|
|
111
|
+
/** The product's token prefix, two or three letters (`ye1.` for Yomizaka: `ye`). The tokens are `<prefix>1.<43 base64url>`. */
|
|
112
|
+
tokenPrefix: string;
|
|
113
|
+
passwords: {
|
|
114
|
+
hash: (password: string) => Promise<string>;
|
|
115
|
+
verify: (password: string, stored: string) => Promise<boolean>;
|
|
116
|
+
};
|
|
117
|
+
/** How a token is kept: the product's own keyed hash, the one its sessions use. */
|
|
118
|
+
tokenHash: (token: string) => string;
|
|
119
|
+
/**
|
|
120
|
+
* How long a password reset link lives, in minutes: a whole number from 5 to 1440 (`PASSWORD_RESET_MIN_MINUTES`–`PASSWORD_RESET_MAX_MINUTES`),
|
|
121
|
+
* default `PASSWORD_RESET_TTL_MINUTES` (30). The product reads it from its settings; the expiry and the mailed text both use it.
|
|
122
|
+
* Out of range, `dbAccounts` throws.
|
|
123
|
+
*/
|
|
124
|
+
passwordResetTtlMinutes?: number;
|
|
125
|
+
/** Test seam: the token to issue. */
|
|
126
|
+
token?: () => string;
|
|
127
|
+
}
|
|
128
|
+
declare const EMAIL_CHANGE_TTL_HOURS = 24;
|
|
129
|
+
/** The default lifetime of a password reset link, in minutes (`AccountDeps.passwordResetTtlMinutes` changes it). */
|
|
130
|
+
declare const PASSWORD_RESET_TTL_MINUTES = 30;
|
|
131
|
+
declare const PASSWORD_RESET_MIN_MINUTES = 5;
|
|
132
|
+
declare const PASSWORD_RESET_MAX_MINUTES = 1440;
|
|
133
|
+
/** One format for every mailed link (an e-mail change, a password reset): the product's prefix, a version, 256 random bits. */
|
|
134
|
+
declare const linkToken: (prefix: string) => string;
|
|
135
|
+
declare const isLinkToken: (prefix: string, value: string | undefined | null) => value is string;
|
|
136
|
+
declare const emailChangeToken: (prefix: string) => string;
|
|
137
|
+
declare const isEmailChangeToken: (prefix: string, value: string | undefined | null) => value is string;
|
|
138
|
+
declare function passwordResetMail(product: string, locale: "en" | "fr", url: string, ttlMinutes?: number): {
|
|
139
|
+
subject: string;
|
|
140
|
+
text: string;
|
|
141
|
+
};
|
|
142
|
+
declare function emailChangeMail(product: string, locale: "en" | "fr", url: string): {
|
|
143
|
+
subject: string;
|
|
144
|
+
text: string;
|
|
145
|
+
};
|
|
146
|
+
/** The adapter over the product's own PostgreSQL (`accounts`, `sessions`, `email_changes`, `password_resets`; see `accountsMigration`). */
|
|
147
|
+
declare function dbAccounts(deps: AccountDeps): AccountService;
|
|
148
|
+
/** @deprecated The old name of `dbAccounts` (a name says what a thing does, not the technology). Removed in the next major. */
|
|
149
|
+
declare const postgresAccounts: typeof dbAccounts;
|
|
3
150
|
|
|
4
151
|
/** What federated sign-in needs from the product's accounts: no second user table, just a look-up and, when registration is open, a creation. */
|
|
5
152
|
interface FederatedAccounts {
|
|
@@ -51,18 +198,15 @@ declare function createFederatedSignIn({ links, accounts, clock }: FederatedSign
|
|
|
51
198
|
type FederatedSignIn = ReturnType<typeof createFederatedSignIn>;
|
|
52
199
|
|
|
53
200
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* 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`.
|
|
57
|
-
* `backfillVerified`: for accounts that exist already, treat the address as verified (invitation-only products).
|
|
201
|
+
* The SQL of version 1 of the accounts tables, for a product that runs it by hand (idempotent).
|
|
202
|
+
* @deprecated Use `accountsSchema` with `emitMigration` or `adoptMigration` from `@krizaka/db`: the SQL gets a version, a checksum and a marker. Removed in the next major.
|
|
58
203
|
*/
|
|
59
204
|
declare function accountsMigration(options?: {
|
|
60
205
|
backfillVerified?: boolean;
|
|
61
206
|
}): string;
|
|
62
207
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* Idempotent.
|
|
208
|
+
* The SQL of version 1 of the memberships and invitations tables, for a product that runs it by hand (idempotent).
|
|
209
|
+
* @deprecated Use `rolesSchema` with `emitMigration` or `adoptMigration` from `@krizaka/db`. Removed in the next major.
|
|
66
210
|
*/
|
|
67
211
|
declare function rolesMigration(roles: readonly string[], options?: {
|
|
68
212
|
ownerTable?: string;
|
|
@@ -97,9 +241,8 @@ declare class RolesError extends Error {
|
|
|
97
241
|
readonly status: number;
|
|
98
242
|
constructor(code: RolesCode);
|
|
99
243
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
244
|
+
/** The SQL executor the roles take: `@krizaka/db`'s `Query`. */
|
|
245
|
+
type RolesQuery = Query;
|
|
103
246
|
interface RolesDeps<Role extends string, Permission extends string> {
|
|
104
247
|
query: RolesQuery;
|
|
105
248
|
model: RoleModel<Role, Permission>;
|
|
@@ -171,6 +314,36 @@ declare function createRoles<Role extends string, Permission extends string>(dep
|
|
|
171
314
|
};
|
|
172
315
|
type Roles<Role extends string = string, Permission extends string = string> = ReturnType<typeof createRoles<Role, Permission>>;
|
|
173
316
|
|
|
317
|
+
/**
|
|
318
|
+
* The tables of « auth », declared as modules (ADR-001 §3). They are added to a product's own `accounts` and `sessions` tables, which
|
|
319
|
+
* existed before the module schemas: they stay in `public`, un-qualified, and a product that has them adopts version 1
|
|
320
|
+
* (`adoptMigration`, markers only) while one that does not emits it (`emitMigration`). Version 1 of each is frozen once released:
|
|
321
|
+
* a change is version 2. The one product table every one of them references is declared by the product (`accounts`, `owner`).
|
|
322
|
+
*/
|
|
323
|
+
/**
|
|
324
|
+
* What the account adapter needs from a product's database, safe to run on a database that already has `accounts` and `sessions`:
|
|
325
|
+
* 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
|
|
326
|
+
* address) and the table of password resets (a link mailed to the account's address). `accounts` needs `id`, `email`, `locale`, `password_hash`;
|
|
327
|
+
* `sessions` needs `account_id`, `created_at`, `expires_at`, `ip`, `user_agent`.
|
|
328
|
+
* @param options `backfillVerified`: for accounts that exist already, treat the address as verified (invitation-only products)
|
|
329
|
+
*/
|
|
330
|
+
declare function accountsSchema(options?: {
|
|
331
|
+
backfillVerified?: boolean;
|
|
332
|
+
}): ModuleSchema;
|
|
333
|
+
/**
|
|
334
|
+
* 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
|
|
335
|
+
* product's role model. Declares the account table (`owner`) and, when the roles are scoped, the scope table (`scope`: shops, workspaces):
|
|
336
|
+
* a membership and an invitation then point at it. Idempotent.
|
|
337
|
+
* @param roles the role names of the model: each becomes a value of the `role` check, so each must be a safe identifier
|
|
338
|
+
* @param options `scoped`: true when the product declares a `scope` table
|
|
339
|
+
* @throws Error for a role that is not a safe SQL identifier
|
|
340
|
+
*/
|
|
341
|
+
declare function rolesSchema(roles: readonly string[], options?: {
|
|
342
|
+
scoped?: boolean;
|
|
343
|
+
}): ModuleSchema;
|
|
344
|
+
/** Which external identity belongs to which account (federated sign-in): `(issuer, subject) → account`, never a second user table. Idempotent. */
|
|
345
|
+
declare const identityLinksSchema: ModuleSchema;
|
|
346
|
+
|
|
174
347
|
/** The `fetch` the adapter calls krizaka-users with (injected: no global, no network in tests). */
|
|
175
348
|
type Fetcher = (url: string, init: {
|
|
176
349
|
method: "POST";
|
|
@@ -196,4 +369,4 @@ declare function parseExternalIdentity(body: unknown): ExternalIdentity;
|
|
|
196
369
|
*/
|
|
197
370
|
declare function usersIdentityProvider({ baseUrl, appUrl, fetch }: UsersFederationOptions): IdentityProvider;
|
|
198
371
|
|
|
199
|
-
export { ExternalIdentity, FEDERATED_REFUSAL, type FederatedAccounts, type FederatedRefusal, type FederatedSignIn, type FederatedSignInDeps, type FederatedSignInResult, type Fetcher, IdentityLinkStore, IdentityProvider, type Invitation, type Member, ROLES_CODE, type RoleModel, type Roles, type RolesCode, type RolesDeps, RolesError, type RolesQuery, type UsersFederationOptions, accountsMigration, can, createFederatedSignIn, createRoles, defineRoles, parseExternalIdentity, rolesMigration, usersIdentityProvider };
|
|
372
|
+
export { type AccountDeps, AccountError, type AccountProfile, type AccountService, EMAIL_CHANGE_TTL_HOURS, ExternalIdentity, FEDERATED_REFUSAL, type FederatedAccounts, type FederatedRefusal, type FederatedSignIn, type FederatedSignInDeps, type FederatedSignInResult, type Fetcher, IdentityLinkStore, IdentityProvider, type Invitation, type Mail, type MailLink, type Member, PASSWORD_RESET_MAX_MINUTES, PASSWORD_RESET_MIN_MINUTES, PASSWORD_RESET_TTL_MINUTES, type ProfilePatch, ROLES_CODE, type RoleModel, type Roles, type RolesCode, type RolesDeps, RolesError, type RolesQuery, type SessionSummary, type Theme, type UsersFederationOptions, accountsMigration, accountsSchema, can, createFederatedSignIn, createRoles, dbAccounts, defineRoles, emailChangeMail, emailChangeToken, identityLinksSchema, isEmailChangeToken, isLinkToken, linkToken, parseExternalIdentity, passwordResetMail, postgresAccounts, rolesMigration, rolesSchema, usersIdentityProvider };
|