@krizaka/auth 0.7.0 → 0.9.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.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/contract.ts"],"sourcesContent":["import { z } from \"zod\";\n\n/**\n * « Who is here »: the one contract of `GET /api/me` for every product (krizaka-ui#551). Browser-safe: zod and the web-standard\n * `Response` only, so the product's route and the bar island of `@krizaka/ui` import the same file.\n *\n * A public page asking who is there is not an error: signed out is 200 with `signedIn: false`, never a 401. The answer is a hint for the\n * bar; it authorises nothing, and every page and route checks the session itself.\n */\n\n/** The path every product serves. */\nexport const ME_PATH = \"/api/me\";\n\n/** What the bar shows of the signed-in person. */\nexport const meAccountSchema = z.object({\n id: z.string().min(1),\n name: z.string().nullable(),\n email: z.string().min(1),\n avatarSeed: z.string().nullable(),\n});\n\nexport const meSchema = z\n .object({\n signedIn: z.boolean(),\n signupOpen: z.boolean(),\n account: meAccountSchema.optional(),\n })\n .refine((me) => me.signedIn === (me.account !== undefined), { message: \"account is present exactly when signedIn\" });\n\nexport type MeAccount = z.infer<typeof meAccountSchema>;\nexport type Me = z.infer<typeof meSchema>;\n\n/** Never cached, never shared: the answer depends on the cookie. */\nexport const ME_HEADERS = { \"Cache-Control\": \"private, no-store\", Vary: \"Cookie\" } as const;\n\n/**\n * The answer of `GET /api/me`: 200, the body checked against the contract, `Cache-Control: private, no-store` and `Vary: Cookie`.\n * @param input whether sign-up is open, and the signed-in account when there is one\n * @throws ZodError when the body breaks the contract (a signed-in answer without an account)\n */\nexport function meResponse(input: { signupOpen: boolean; account?: MeAccount | null | undefined }): Response {\n const body: Me = meSchema.parse({ signedIn: !!input.account, signupOpen: input.signupOpen, ...(input.account ? { account: input.account } : {}) });\n return Response.json(body, { headers: ME_HEADERS });\n}\n"],"mappings":";AAAA,SAAS,SAAS;AAWX,IAAM,UAAU;AAGhB,IAAM,kBAAkB,EAAE,OAAO;AAAA,EACtC,IAAI,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,EACpB,MAAM,EAAE,OAAO,EAAE,SAAS;AAAA,EAC1B,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,EACvB,YAAY,EAAE,OAAO,EAAE,SAAS;AAClC,CAAC;AAEM,IAAM,WAAW,EACrB,OAAO;AAAA,EACN,UAAU,EAAE,QAAQ;AAAA,EACpB,YAAY,EAAE,QAAQ;AAAA,EACtB,SAAS,gBAAgB,SAAS;AACpC,CAAC,EACA,OAAO,CAAC,OAAO,GAAG,cAAc,GAAG,YAAY,SAAY,EAAE,SAAS,2CAA2C,CAAC;AAM9G,IAAM,aAAa,EAAE,iBAAiB,qBAAqB,MAAM,SAAS;AAO1E,SAAS,WAAW,OAAkF;AAC3G,QAAM,OAAW,SAAS,MAAM,EAAE,UAAU,CAAC,CAAC,MAAM,SAAS,YAAY,MAAM,YAAY,GAAI,MAAM,UAAU,EAAE,SAAS,MAAM,QAAQ,IAAI,CAAC,EAAG,CAAC;AACjJ,SAAO,SAAS,KAAK,MAAM,EAAE,SAAS,WAAW,CAAC;AACpD;","names":[]}
package/dist/index.d.ts CHANGED
@@ -1,5 +1,228 @@
1
- import { E as ExternalIdentity, I as IdentityLinkStore, a as IdentityProvider } from './identity-links-C5AG7Fbp.js';
2
- export { A as AccountDeps, b as AccountError, c as AccountProfile, d as AccountService, C as CompleteOptions, e as EMAIL_CHANGE_TTL_HOURS, F as FEDERATED_PROVIDERS, f as FEDERATION_CODE, g as FederatedProvider, h as FederationCode, i as FederationError, j as IdentityLinkError, M as Mail, k as MailLink, P as PASSWORD_RESET_MAX_MINUTES, l as PASSWORD_RESET_MIN_MINUTES, m as PASSWORD_RESET_TTL_MINUTES, n as ProfilePatch, Q as Query, S as SessionSummary, o as StartOptions, p as StartedSignIn, T as Theme, q as dbAccounts, r as dbIdentityLinks, s as emailChangeMail, t as emailChangeToken, u as identityLinksMigration, v as isEmailChangeToken, w as isFederatedProvider, x as isLinkToken, y as isLocalReturn, z as linkToken, B as memoryIdentityLinks, D as memoryIdentityProvider, G as passwordResetMail, H as pkceChallenge, J as postgresAccounts } from './identity-links-C5AG7Fbp.js';
1
+ import { Query, ModuleSchema } from '@krizaka/db';
2
+ export { Query } from '@krizaka/db';
3
+ import { E as ExternalIdentity, I as IdentityLinkStore, S as SessionStore, N as NewSession, a as IdentityProvider } from './sessions-B9YKXs0i.js';
4
+ export { C as CompleteOptions, F as FEDERATED_PROVIDERS, b as FEDERATION_CODE, c as FederatedProvider, d as FederationCode, e as FederationError, f as IdentityLinkError, g as Session, h as SessionCookie, i as Sessions, j as SessionsDeps, k as StartOptions, l as StartedSignIn, m as clearedSessionCookie, n as createSessions, o as dbIdentityLinks, p as identityLinksMigration, q as isFederatedProvider, r as isLocalReturn, s as memoryIdentityLinks, t as memoryIdentityProvider, u as pkceChallenge, v as sessionCookie, w as sessionTokenOf } from './sessions-B9YKXs0i.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;
150
+
151
+ /**
152
+ * The administration's locks, as one pure decision every product shares (it was copied in each product's `lib/access.ts`): a session,
153
+ * the ADMIN role, and an address on the allowed list. A pure function of its inputs: the product reads the session and the address
154
+ * and maps the verdict to its own answer.
155
+ */
156
+ /** Stable codes of a refusal. */
157
+ declare const ADMIN_REFUSAL: {
158
+ readonly unauthenticated: "UNAUTHENTICATED";
159
+ readonly forbidden: "FORBIDDEN";
160
+ readonly ipNotAllowed: "IP_NOT_ALLOWED";
161
+ readonly notFound: "NOT_FOUND";
162
+ };
163
+ type AdminRefusal = (typeof ADMIN_REFUSAL)[keyof typeof ADMIN_REFUSAL];
164
+ /** The verdict: let in, or the status and code to answer. */
165
+ type AdminDecision = {
166
+ ok: true;
167
+ } | {
168
+ ok: false;
169
+ status: 401 | 403 | 404;
170
+ code: AdminRefusal;
171
+ };
172
+ /**
173
+ * The list the administration's address lock reads, from the environment's value (`AUTH_ALLOWED_IPS`): an empty list is « nobody » in
174
+ * production and « anybody » in development, so a forgotten setting never opens the console.
175
+ * @param list the configured addresses
176
+ * @returns the list to hand to `requireAdmin`, or null (anybody)
177
+ */
178
+ declare function allowedIps(list: readonly string[], options: {
179
+ production: boolean;
180
+ }): readonly string[] | null;
181
+ /** Whether an address may use the administration: `null` allowed means anybody, an empty list nobody, and a missing address is never allowed by a list. */
182
+ declare function ipAllowed(ip: string | null, allowed: readonly string[] | null): boolean;
183
+ interface AdminGateInput {
184
+ /** The caller's session, or null when there is none. Only its account's role is read. */
185
+ session: {
186
+ account: {
187
+ role: string;
188
+ };
189
+ } | null;
190
+ /** The caller's address (see `clientIp`). */
191
+ ip: string | null;
192
+ /** The addresses allowed (see `allowedIps`); null = anybody. */
193
+ allowedIps: readonly string[] | null;
194
+ /** `api` (default): 401 without a session, 403 with the reason. `page`: the administration does not announce itself, so every failed lock is a 404. */
195
+ surface?: "api" | "page";
196
+ /** The role that administers. Default `ADMIN`. */
197
+ role?: string;
198
+ }
199
+ /**
200
+ * The administration's three locks. The first that fails decides.
201
+ * @returns `{ ok: true }`, or the status (401, 403 or 404 on a page) and the code to answer
202
+ */
203
+ declare function requireAdmin(input: AdminGateInput): AdminDecision;
204
+
205
+ /** How a deployment tells the client's address: the header its edge sets, and how many proxies append to `X-Forwarded-For`. */
206
+ interface ClientIpRules {
207
+ /** A header the edge sets and overwrites with the client's address (`do-connecting-ip` on DigitalOcean App Platform, `cf-connecting-ip` behind Cloudflare). Read first when present. */
208
+ header?: string | undefined;
209
+ /** How many trusted proxies append to `X-Forwarded-For`: 1 (the default) to 10. */
210
+ trustedProxyHops?: number | undefined;
211
+ }
212
+ /**
213
+ * The client's IP address, as far as this deployment can trust it. `X-Forwarded-For` is a list each proxy appends to: its LEFT end is
214
+ * whatever the client sent (anyone can write `X-Forwarded-For: 1.2.3.4`), so a limit keyed on it is a limit the attacker chooses. The
215
+ * address the last trusted proxy saw is read from the RIGHT:
216
+ *
217
+ * - the `header` the edge sets and overwrites, when present;
218
+ * - otherwise the entry `trustedProxyHops` (default 1: one proxy) from the right of `X-Forwarded-For`, then `X-Real-IP`;
219
+ * - otherwise null (local development without a proxy).
220
+ *
221
+ * The product reads its environment (`ABUSE_CLIENT_IP_HEADER`, `ABUSE_TRUSTED_PROXY_HOPS`) and passes the values: this function reads none.
222
+ * @param headers the request's headers
223
+ * @returns a valid IPv4 or IPv6 address, or null
224
+ */
225
+ declare function clientIp(headers: Headers, rules?: ClientIpRules): string | null;
3
226
 
4
227
  /** 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
228
  interface FederatedAccounts {
@@ -51,24 +274,40 @@ declare function createFederatedSignIn({ links, accounts, clock }: FederatedSign
51
274
  type FederatedSignIn = ReturnType<typeof createFederatedSignIn>;
52
275
 
53
276
  /**
54
- * What the account adapter needs from a product's database, safe to run on a database that already has `accounts` and `sessions`:
55
- * 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
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).
277
+ * The SQL of version 1 of the accounts tables, for a product that runs it by hand (idempotent).
278
+ * @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
279
  */
59
280
  declare function accountsMigration(options?: {
60
281
  backfillVerified?: boolean;
61
282
  }): string;
62
283
  /**
63
- * 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
64
- * product's role model. `scopeTable` is the table of the scope (shops, workspaces): when given, a membership and an invitation point at it.
65
- * Idempotent.
284
+ * The SQL of version 1 of the memberships and invitations tables, for a product that runs it by hand (idempotent).
285
+ * @deprecated Use `rolesSchema` with `emitMigration` or `adoptMigration` from `@krizaka/db`. Removed in the next major.
66
286
  */
67
287
  declare function rolesMigration(roles: readonly string[], options?: {
68
288
  ownerTable?: string;
69
289
  scopeTable?: string;
70
290
  }): string;
71
291
 
292
+ /** The shortest password the products accept. */
293
+ declare const PASSWORD_MIN_LENGTH = 12;
294
+ /** The longest: a longer one is refused before any work is done (a very long password is a way to make the server spend time). */
295
+ declare const PASSWORD_MAX_LENGTH = 256;
296
+ /** Why a new password is refused, or null when its length is acceptable. */
297
+ declare function passwordProblem(password: string): "TOO_SHORT" | "TOO_LONG" | null;
298
+ /**
299
+ * Hashes a password with a fresh salt.
300
+ * @returns `scrypt$N$r$p$salt$hash`
301
+ */
302
+ declare function hashPassword(password: string): Promise<string>;
303
+ /**
304
+ * Checks a password against a stored hash, in constant time. A hash of another scheme, a malformed one or an over-long password is `false`.
305
+ * The cost parameters are read from the stored value, so an older hash still verifies after the default changes.
306
+ */
307
+ declare function verifyPassword(password: string, stored: string): Promise<boolean>;
308
+ /** A hash to verify against when the address has no account: the same work, the same time, the same answer. */
309
+ declare const decoyHash: () => Promise<string>;
310
+
72
311
  /**
73
312
  * Roles per scope (a shop, a workspace), as DATA: a model says which roles exist, which permissions each holds and who may hand which
74
313
  * role to whom. The code asks `can(role, "invoices:write")`, never `if (role === "owner")`. One person may belong to several scopes, with
@@ -97,9 +336,8 @@ declare class RolesError extends Error {
97
336
  readonly status: number;
98
337
  constructor(code: RolesCode);
99
338
  }
100
- interface RolesQuery {
101
- <T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
102
- }
339
+ /** The SQL executor the roles take: `@krizaka/db`'s `Query`. */
340
+ type RolesQuery = Query;
103
341
  interface RolesDeps<Role extends string, Permission extends string> {
104
342
  query: RolesQuery;
105
343
  model: RoleModel<Role, Permission>;
@@ -171,6 +409,74 @@ declare function createRoles<Role extends string, Permission extends string>(dep
171
409
  };
172
410
  type Roles<Role extends string = string, Permission extends string = string> = ReturnType<typeof createRoles<Role, Permission>>;
173
411
 
412
+ /**
413
+ * The tables of « auth », declared as modules (ADR-001 §3). They are added to a product's own `accounts` and `sessions` tables, which
414
+ * existed before the module schemas: they stay in `public`, un-qualified, and a product that has them adopts version 1
415
+ * (`adoptMigration`, markers only) while one that does not emits it (`emitMigration`). Version 1 of each is frozen once released:
416
+ * a change is version 2. The one product table every one of them references is declared by the product (`accounts`, `owner`).
417
+ */
418
+ /**
419
+ * What the account adapter needs from a product's database, safe to run on a database that already has `accounts` and `sessions`:
420
+ * 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
421
+ * address) and the table of password resets (a link mailed to the account's address). `accounts` needs `id`, `email`, `locale`, `password_hash`;
422
+ * `sessions` needs `account_id`, `created_at`, `expires_at`, `ip`, `user_agent`.
423
+ * @param options `backfillVerified`: for accounts that exist already, treat the address as verified (invitation-only products)
424
+ */
425
+ declare function accountsSchema(options?: {
426
+ backfillVerified?: boolean;
427
+ }): ModuleSchema;
428
+ /**
429
+ * 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
430
+ * product's role model. Declares the account table (`owner`) and, when the roles are scoped, the scope table (`scope`: shops, workspaces):
431
+ * a membership and an invitation then point at it. Idempotent.
432
+ * @param roles the role names of the model: each becomes a value of the `role` check, so each must be a safe identifier
433
+ * @param options `scoped`: true when the product declares a `scope` table
434
+ * @throws Error for a role that is not a safe SQL identifier
435
+ */
436
+ declare function rolesSchema(roles: readonly string[], options?: {
437
+ scoped?: boolean;
438
+ }): ModuleSchema;
439
+ /** Which external identity belongs to which account (federated sign-in): `(issuer, subject) → account`, never a second user table. Idempotent. */
440
+ declare const identityLinksSchema: ModuleSchema;
441
+ /**
442
+ * The table of web sessions: the cookie holds a random token, the table its keyed hash (a leaked table opens nothing). The shape the
443
+ * products' own `sessions` table has, so a product that has it adopts version 1 (`adoptMigration`) and one that does not emits it.
444
+ * Idempotent.
445
+ */
446
+ declare const sessionsSchema: ModuleSchema;
447
+
448
+ /**
449
+ * An in-memory session store, for unit tests and demos. `accountOf` plays the accounts table: it answers the account of an id, or null
450
+ * when it does not exist or is not active.
451
+ */
452
+ declare function memorySessionStore<Account>(accountOf: (accountId: string) => Account | null): SessionStore<Account> & {
453
+ rows(): readonly NewSession[];
454
+ };
455
+
456
+ interface DbSessionStoreOptions<Account> {
457
+ /** The product's session table. Default `sessions` (`id`, `account_id`, `token_hash` unique, `created_at`, `expires_at`, `ip`, `user_agent`). */
458
+ sessions?: string;
459
+ /** The product's account table. Default `accounts`. */
460
+ accounts?: string;
461
+ /** The account columns the session carries (`id`, `email`, `role`, `locale`, `name`…): all checked, all read in one query with the session. */
462
+ columns: readonly string[];
463
+ /** Builds the product's account from a row holding exactly `columns`. */
464
+ toAccount: (row: Record<string, unknown>) => Account;
465
+ /** An account that is not active signs nobody in (a suspension applies at once): the column and the value it must have (`status` = `ACTIVE`). */
466
+ active?: {
467
+ column: string;
468
+ equals: string;
469
+ };
470
+ }
471
+ /**
472
+ * The session store over the product's own tables (`sessions` joined to `accounts`): the declared tables and columns are checked by
473
+ * `ident()` and nothing else reaches SQL. The expiry is compared with the clock's `now`, not the database's.
474
+ * @param query the product's query
475
+ * @param options the table names, the account columns and how the account is built
476
+ * @throws DbError `DB_UNSAFE_IDENTIFIER` for a table or column name that is not a safe identifier
477
+ */
478
+ declare function dbSessionStore<Account>(query: Query, options: DbSessionStoreOptions<Account>): SessionStore<Account>;
479
+
174
480
  /** The `fetch` the adapter calls krizaka-users with (injected: no global, no network in tests). */
175
481
  type Fetcher = (url: string, init: {
176
482
  method: "POST";
@@ -196,4 +502,4 @@ declare function parseExternalIdentity(body: unknown): ExternalIdentity;
196
502
  */
197
503
  declare function usersIdentityProvider({ baseUrl, appUrl, fetch }: UsersFederationOptions): IdentityProvider;
198
504
 
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 };
505
+ export { ADMIN_REFUSAL, type AccountDeps, AccountError, type AccountProfile, type AccountService, type AdminDecision, type AdminGateInput, type AdminRefusal, type ClientIpRules, type DbSessionStoreOptions, 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, NewSession, PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, 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, SessionStore, type SessionSummary, type Theme, type UsersFederationOptions, accountsMigration, accountsSchema, allowedIps, can, clientIp, createFederatedSignIn, createRoles, dbAccounts, dbSessionStore, decoyHash, defineRoles, emailChangeMail, emailChangeToken, hashPassword, identityLinksSchema, ipAllowed, isEmailChangeToken, isLinkToken, linkToken, memorySessionStore, parseExternalIdentity, passwordProblem, passwordResetMail, postgresAccounts, requireAdmin, rolesMigration, rolesSchema, sessionsSchema, usersIdentityProvider, verifyPassword };