@jwilleke/ngdpbase-types 5.31.0 → 5.32.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.
@@ -24,6 +24,8 @@ export type { Decision } from '../../src/types/Policy.js';
24
24
  export type { PluginContext, PluginParams, PluginFormatters } from '../../src/managers/PluginManager.js';
25
25
  export type { SimplePlugin } from '../../src/plugins/types.js';
26
26
  export type { KeyedSerial } from '../../src/utils/keyedSerial.js';
27
+ export type { AddonReviews, ReviewDecision, ReviewHandler, ReviewHandlerResult, ReviewRequest, ReviewRequestInput, ReviewStatus } from '../../src/types/Review.js';
28
+ export type { default as ReviewManager, ReviewRefusedError, ReviewsUnavailableError, ReviewNotOpenError, ReviewInputError, ReviewHandlerFailedError } from '../../src/managers/ReviewManager.js';
27
29
  export type { AuditEvent } from '../../src/utils/auditEvents.js';
28
30
  export type { default as BackgroundJobManager, JobDefinition, JobResult, JobRunContext } from '../../src/managers/BackgroundJobManager.js';
29
31
  export type { default as DatabaseManager, AddonDatabaseOptions } from '../../src/managers/DatabaseManager.js';
@@ -34,7 +34,7 @@ import type { BackupData, BackupFolder } from './BaseManager.js';
34
34
  import type { WikiEngine } from '../types/WikiEngine.js';
35
35
  import type { PermissionSubject } from './UserManager.js';
36
36
  import type { JobContext } from '../context/JobContext.js';
37
- import type { Aal, AuthProvider, AuthInitiateContext, AuthVerifyCredentials, PhishingResistance, ViaToken } from '../providers/BaseAuthProvider.js';
37
+ import type { Aal, AuthProvider, AuthInitiateContext, AuthVerifyCredentials, PhishingResistance, ReportedAssurance, ViaToken } from '../providers/BaseAuthProvider.js';
38
38
  import { type OutsideProviderIcon } from '../providers/OidcAuthProvider.js';
39
39
  import { type PasskeyRelyingParty } from '../providers/PasskeyAuthProvider.js';
40
40
  import { type CredentialKind, type CredentialRecord } from '../providers/BaseCredentialsProvider.js';
@@ -229,6 +229,26 @@ export interface PendingConnection {
229
229
  /** Where the sign-in that follows was going; a declined connection goes there. */
230
230
  returnTo?: string;
231
231
  }
232
+ /**
233
+ * An outside sign-in whose verified email several accounts here share
234
+ * (#2073): held in the session while the person chooses which account, asked
235
+ * every time and never remembered (no identity is connected). Single use.
236
+ */
237
+ export interface OutsideChoice {
238
+ providerId: string;
239
+ /** The provider's verified email. */
240
+ email: string;
241
+ /** The accounts on that email, by username, when the callback came back. */
242
+ choices: string[];
243
+ /** The provider's name, for the page. */
244
+ displayName: string;
245
+ /** What the provider reported about its own sign-in (#1633), kept for the level it counts for. */
246
+ reported: ReportedAssurance;
247
+ /** Where the sign-in was going. */
248
+ returnTo: string;
249
+ /** Epoch ms. */
250
+ expiresAt: number;
251
+ }
232
252
  /** How an outside sign-in's callback ended (#1743), for the route to answer. */
233
253
  export type OutsideSignIn =
234
254
  /** Signed in (`created`: an account was made for it first); `next` is where to go. */
@@ -241,6 +261,10 @@ export type OutsideSignIn =
241
261
  outcome: 'connect-first';
242
262
  error?: string;
243
263
  }
264
+ /** #2073: several accounts share the verified email; held for the person to choose one (`outsideChoice`). */
265
+ | {
266
+ outcome: 'choose-account';
267
+ }
244
268
  /** Refused; nothing says whether an account exists. */
245
269
  | {
246
270
  outcome: 'refused';
@@ -601,6 +625,22 @@ declare class AuthManager extends BaseManager {
601
625
  private createAccountFor;
602
626
  /** Delete an account created for an outside sign-in that could not be finished (#1743), through UserManager, which closes its access. */
603
627
  private removeCreatedAccount;
628
+ /**
629
+ * The accounts an outside sign-in to a shared email offers (#2073), for the
630
+ * page asking which; null when none is waiting, and an expired one is dropped.
631
+ */
632
+ outsideChoice(req: Request): {
633
+ displayName: string;
634
+ choices: string[];
635
+ } | null;
636
+ /**
637
+ * Finish an outside sign-in to a shared email (#2073): `account` must be one
638
+ * the sign-in offered, still hold that email, be active, and be allowed to
639
+ * use the provider. The choice is used once, connects nothing and is not
640
+ * remembered; the next sign-in asks again. A sign-in at AAL1 takes the
641
+ * second step, as any outside sign-in does (#1532).
642
+ */
643
+ finishOutsideChoice(req: Request, account: string): Promise<OutsideSignIn>;
604
644
  /**
605
645
  * The person's pending connection, for the sign-in page's notice and the
606
646
  * confirmation page; an expired one is dropped (#1743).
@@ -1273,6 +1313,10 @@ declare class AuthManager extends BaseManager {
1273
1313
  private pendingSignIns;
1274
1314
  private approvalTokens;
1275
1315
  private enrolTokens;
1316
+ /** #2073: links confirming a user's new email, by token; one waiting per account. In memory: a restart drops them. */
1317
+ private emailChangeTokens;
1318
+ /** #2073: when each account's email-change links were sent, for the cooldown and the cap. In memory. */
1319
+ private emailChangeSends;
1276
1320
  /** Mail can carry an email-link factor: a mail transport and an explicit base-url for the link. */
1277
1321
  private mailForLinks;
1278
1322
  private linkBase;
@@ -1390,6 +1434,52 @@ declare class AuthManager extends BaseManager {
1390
1434
  startEmailFactorEnrolment(ctx: PermissionSubject, username: string): Promise<'sent' | 'no-email' | 'unavailable' | 'already'>;
1391
1435
  /** Finish enrolling from the emailed link, by the same signed-in person. Single use. */
1392
1436
  confirmEmailFactorEnrolment(ctx: PermissionSubject, token: string): Promise<boolean>;
1437
+ /**
1438
+ * A user asks to change their own email (#2073, operator 2026-10-25): it
1439
+ * changes only when they click a link sent to the new address. Until then
1440
+ * the account keeps its old email. The link is single use, lasts
1441
+ * `ngdpbase.auth.email-change.ttl-minutes` (60), is bound to the account and
1442
+ * the new address, and replaces any link still waiting for that account. The
1443
+ * old address, when there is one, is told a change was asked, with no link.
1444
+ * Recorded as `emailchange-request`, naming the account, never an address.
1445
+ *
1446
+ * Only the account itself asks (its own context, signed in or just
1447
+ * registered). `unavailable`: this site cannot send email now, so nothing
1448
+ * is sent and an administrator can change it instead. `failed`: the send
1449
+ * failed and no link is waiting. `unchanged`: the address is the one the
1450
+ * account already has. `throttled`: the account asked too soon after its
1451
+ * last link (`ngdpbase.auth.email-change.cooldown-seconds`, 60) or too often
1452
+ * in the window (`ngdpbase.auth.email-change.max-per-window` links per
1453
+ * `ngdpbase.auth.email-change.window-minutes`, 5 per 60); no mail is sent.
1454
+ */
1455
+ requestEmailChange(ctx: ActorContext, username: string, newEmail: string, via: 'profile' | 'registration'): Promise<'sent' | 'unchanged' | 'unavailable' | 'failed' | 'throttled'>;
1456
+ /** The new address waiting to be confirmed for `username` (#2073), for the profile to show; null when none. */
1457
+ pendingEmailChange(username: string): string | null;
1458
+ /** What a confirmation link would change (#2073), for its page; null for an unknown, used or expired link. */
1459
+ emailChangeOffer(token: string): {
1460
+ username: string;
1461
+ address: string;
1462
+ } | null;
1463
+ /**
1464
+ * Confirm a new email from its emailed link (#2073). Single use: the link is
1465
+ * spent first. The rules are checked again now: the account still exists,
1466
+ * is on, and is not a shared account whose email only an administrator
1467
+ * changes; and UserManager's own (an administrator's email is never
1468
+ * shared). Saved through UserManager under the system context naming the
1469
+ * account (the link is the authority), which keeps the identity record in
1470
+ * step. Nobody is signed in. Recorded as `emailchange-confirm`. A user who
1471
+ * had no email yet (just registered) is then offered their contact record,
1472
+ * as at sign-up (#1930).
1473
+ */
1474
+ confirmEmailChange(token: string): Promise<{
1475
+ outcome: 'changed';
1476
+ username: string;
1477
+ } | {
1478
+ outcome: 'expired';
1479
+ } | {
1480
+ outcome: 'refused';
1481
+ message: string;
1482
+ }>;
1393
1483
  /**
1394
1484
  * Initiate a challenge-based auth flow (magic link email, OAuth redirect).
1395
1485
  */
@@ -1473,6 +1563,11 @@ declare class AuthManager extends BaseManager {
1473
1563
  * here would make an unbindable flow look like an attack.
1474
1564
  */
1475
1565
  getDeviceState(providerId: string, handle: string): string | null;
1566
+ /**
1567
+ * The accounts an emailed link to a shared address offers (#2073), by
1568
+ * username, or null when the link names one account. Read without consuming.
1569
+ */
1570
+ getAccountChoices(providerId: string, handle: string): string[] | null;
1476
1571
  /**
1477
1572
  * Create the account behind a first-time credential (#1026, #1049).
1478
1573
  *
@@ -1,6 +1,6 @@
1
1
  import BaseManager, { type BackupFolder, type ManagerStats } from './BaseManager.js';
2
2
  import type { WikiEngine } from '../types/WikiEngine.js';
3
- import type { Person, PersonAffiliation, PersonChange, PersonInput } from '../types/Person.js';
3
+ import type { ContactDetailsInput, Person, PersonAffiliation, PersonChange, PersonInput } from '../types/Person.js';
4
4
  import type { PersonProvider } from '../types/PersonProvider.js';
5
5
  import { type ActorContext } from '../context/ActorContext.js';
6
6
  /** What `search(ctx, { affiliation })` looks for (#2039). */
@@ -162,6 +162,41 @@ declare class IdentityManager extends BaseManager {
162
162
  * user may set on their own record under `profile-manage`.
163
163
  */
164
164
  update(ctx: ActorContext, id: string, patch: PersonChange): Promise<Person | null>;
165
+ /**
166
+ * The parts of an identity record a user keeps on `/profile` (#2073):
167
+ * given and family name, phones, the emergency contact, the home and
168
+ * mailing addresses, languages, a photo and links. A part left out of
169
+ * `input` is not touched; a text given empty is removed.
170
+ *
171
+ * - Phones are stored in E.164 with any extension apart (`extension`),
172
+ * read with the country chosen beside each one (the configured default,
173
+ * `ngdpbase.application.persons.default-country`, when none is given); a
174
+ * number typed with a leading `+` keeps its own country code.
175
+ * - A phone, the emergency contact and the mailing address each take the
176
+ * place of the record's first entry of their kind in `contactPoint`
177
+ * (a ContactPoint by `contactType`, the mailing address as the
178
+ * `mailing` PostalAddress), keeping that entry's other fields; every
179
+ * other entry, an add-on's included, is kept as it is.
180
+ * - Languages are stored as tags ("es"), given by name or tag.
181
+ *
182
+ * Everything is checked before anything is written: a bad part throws
183
+ * {@link ContactDetailsInvalidError}, naming the part and the rule, never
184
+ * the value. The access and the record are {@link update}'s: the caller's
185
+ * own record under `profile-manage`, anyone else's under `identity-manage`,
186
+ * recorded as an `identity-manage` update naming the fields that changed.
187
+ * Nothing changed, nothing is written or recorded.
188
+ */
189
+ updateContactDetails(ctx: ActorContext, id: string, input: ContactDetailsInput): Promise<Person | null>;
190
+ /**
191
+ * Check `input` as {@link updateContactDetails} would, writing nothing
192
+ * (#2073): throws {@link ContactDetailsInvalidError} for a bad part. For a
193
+ * caller that writes something else first (the /profile card writes the
194
+ * account's name and email), so a bad detail refuses the whole save before
195
+ * anything is written.
196
+ */
197
+ checkContactDetails(input: ContactDetailsInput): void;
198
+ /** The country a phone or address is read with when none is chosen (#2073): `ngdpbase.application.persons.default-country`, `US` when unset or unknown. */
199
+ defaultCountry(): string;
165
200
  delete(ctx: ActorContext, id: string): Promise<boolean>;
166
201
  /**
167
202
  * An administrator joins a person who has no account to an account (#1930),
@@ -241,5 +276,15 @@ declare class IdentityManager extends BaseManager {
241
276
  private getMetrics;
242
277
  private normalizeProviderName;
243
278
  }
279
+ /**
280
+ * A contact detail given to {@link IdentityManager.updateContactDetails} is not valid (#2073); nothing was written.
281
+ * `part` is which detail (a phone's `contactType`, `emergency`, `address`, `mailing`, `knowsLanguage`, `image`,
282
+ * `sameAs`, `name`), `field` which box of it. The message names the rule, never the value given: it reaches URLs and logs.
283
+ */
284
+ export declare class ContactDetailsInvalidError extends Error {
285
+ readonly part: string;
286
+ readonly field: string;
287
+ constructor(part: string, field: string, message: string);
288
+ }
244
289
  export { ContactRefusedError } from '../utils/contactAccess.js';
245
290
  export default IdentityManager;
@@ -0,0 +1,108 @@
1
+ import BaseManager from './BaseManager.js';
2
+ import type { WikiEngine } from '../types/WikiEngine.js';
3
+ import type { ActorContext } from '../context/ActorContext.js';
4
+ import type { Decision } from '../types/Policy.js';
5
+ import type { ReviewDecision, ReviewHandler, ReviewHandlerResult, ReviewRequest, ReviewRequestInput, ReviewStatus } from '../types/Review.js';
6
+ export type { ReviewDecision, ReviewHandler, ReviewHandlerResult, ReviewRequest, ReviewRequestInput, ReviewStatus };
7
+ /** The door said no: the caller may not decide (or see) this request. `decision` is the PDP's answer. */
8
+ export declare class ReviewRefusedError extends Error {
9
+ readonly permission: string;
10
+ readonly decision: Decision;
11
+ readonly name = "ReviewRefusedError";
12
+ constructor(permission: string, decision: Decision);
13
+ }
14
+ /** Requests cannot be kept on this site right now; the message says why and how to fix it. */
15
+ export declare class ReviewsUnavailableError extends Error {
16
+ readonly name = "ReviewsUnavailableError";
17
+ }
18
+ /** A request that is not open any more (decided already, or by someone else just now). */
19
+ export declare class ReviewNotOpenError extends Error {
20
+ readonly name = "ReviewNotOpenError";
21
+ }
22
+ /** What was filed is not a valid request; the message says what is wrong. */
23
+ export declare class ReviewInputError extends Error {
24
+ readonly name = "ReviewInputError";
25
+ }
26
+ /** The handler did not carry out an approved request; it stays open with `reason`. */
27
+ export declare class ReviewHandlerFailedError extends Error {
28
+ readonly request: ReviewRequest;
29
+ readonly reason: string;
30
+ readonly name = "ReviewHandlerFailedError";
31
+ constructor(request: ReviewRequest, reason: string);
32
+ }
33
+ declare class ReviewManager extends BaseManager {
34
+ readonly description = "Requests for review: filed by add-ons, decided by the holders of a permission, carried out by the add-on (#2072)";
35
+ private db;
36
+ /** Why the door is closed, in plain words for an administrator; null while it is open. */
37
+ private unavailable;
38
+ /** Handler id → its add-on and the function. */
39
+ private readonly handlers;
40
+ /** One decision at a time per request, so two reviewers cannot both run its handler (#2015). */
41
+ private readonly serial;
42
+ constructor(engine: WikiEngine);
43
+ initialize(config?: Record<string, unknown>): Promise<void>;
44
+ /** Why requests cannot be filed or decided on this site, or null when they can. */
45
+ getUnavailableReason(): string | null;
46
+ /**
47
+ * Register the handler that carries out an approved request (#2072). Core
48
+ * calls this for an add-on through `host.registerReviewHandler`, which binds
49
+ * `owner` to the add-on's slug: the id must be `<owner>.<name>`, so one
50
+ * add-on can never register, or replace, another's. Registering the same id
51
+ * again (a restart of the add-on) replaces it.
52
+ */
53
+ registerHandler(owner: string, handlerId: string, run: ReviewHandler): void;
54
+ /** Forget an add-on's handlers (it was turned off): its open requests then fail to approve, saying so. */
55
+ unregisterHandlers(owner: string): void;
56
+ /**
57
+ * File a request. `ctx` is whoever the request is filed for (a public
58
+ * form's anonymous visitor included); filing asks no permission, because
59
+ * only add-on code reaches this, and an add-on files only for a handler it
60
+ * registered. Holders of `decidePermission` are told, without waiting.
61
+ *
62
+ * @throws ReviewInputError when the request is malformed: a kind or handler
63
+ * outside the handler's add-on, an unknown or ungrantable permission, no
64
+ * subject or summary, or details too large
65
+ * @throws ReviewsUnavailableError when requests cannot be kept here
66
+ */
67
+ fileRequest(ctx: ActorContext, input: ReviewRequestInput): Promise<ReviewRequest>;
68
+ /**
69
+ * The open requests `ctx` may decide, oldest first: each asked of the PDP
70
+ * by its permission, with the caller's context, never a role. At most
71
+ * `ngdpbase.reviews.list-limit` of them.
72
+ */
73
+ listOpen(ctx: ActorContext): Promise<ReviewRequest[]>;
74
+ /**
75
+ * One request, when `ctx` may decide it (open or decided); null when there
76
+ * is no such request.
77
+ *
78
+ * @throws ReviewRefusedError when the caller does not hold its permission
79
+ */
80
+ get(ctx: ActorContext, id: string): Promise<ReviewRequest | null>;
81
+ /**
82
+ * Approve or reject an open request, as `ctx`.
83
+ *
84
+ * - Reject: the request is marked rejected with the note; no handler runs.
85
+ * - Approve: the add-on's handler runs with `ctx`. When it returns, the
86
+ * request is approved with the handler's summary. When it throws (or is
87
+ * not registered), the request stays open with the reason and the time,
88
+ * and {@link ReviewHandlerFailedError} is thrown so the reviewer sees it.
89
+ *
90
+ * @throws ReviewRefusedError when `ctx` may not decide it (hand to `enforceDeny`)
91
+ * @throws ReviewNotOpenError when it is decided already, or does not exist
92
+ */
93
+ decide(ctx: ActorContext, id: string, decision: ReviewDecision, note?: string): Promise<ReviewRequest>;
94
+ private open;
95
+ private settings;
96
+ private decider;
97
+ /** A whole number of 0 or more from the configuration, or the default. */
98
+ private number;
99
+ /** Throws {@link ReviewRefusedError} unless `ctx` may decide a request with this permission. No context or no PDP refuses. */
100
+ private requireDecider;
101
+ /** Remove decided requests older than `ngdpbase.reviews.retention-days` (0 keeps them). One indexed delete. */
102
+ private removeExpired;
103
+ /** Tell the holders of the request's permission. Never throws: a notice that cannot be sent is logged. */
104
+ private notifyDeciders;
105
+ /** The audit record: who, which request, its kind, handler and permission — names, never the summary, details or note. */
106
+ private record;
107
+ }
108
+ export default ReviewManager;
@@ -249,14 +249,35 @@ declare class UserManager extends BaseManager {
249
249
  initialize(config?: Record<string, unknown>): Promise<void>;
250
250
  /** An email as accounts are compared on it (#1748): trimmed, any case. No provider rules (Gmail dots, plus addressing, aliases). */
251
251
  private static emailKey;
252
- /** The accounts holding this email, by username. Empty for no email. */
253
- private accountsWithEmail;
254
252
  /**
255
- * One email, one account (#1748): refuse to save an email another account
256
- * already has. Not a setting. Thrown as `email-taken`, so registration shows
257
- * its safe message and other routes their own.
253
+ * Every account holding this email (#2073): two or more accounts may share
254
+ * one (a couple, an older adult and a carer). Compared trimmed and in any
255
+ * case, with no provider rules (plus addressing is a different address).
256
+ * Empty for no email. Without the password hash.
258
257
  */
259
- private assertEmailFree;
258
+ getUsersByEmail(email: unknown): Promise<Array<Omit<User, 'password'>>>;
259
+ /**
260
+ * An account holding the admin role never shares its email (#2073, operator
261
+ * 2026-10-25), either way round: refused when `username` is or will be an
262
+ * administrator and another account holds `email`, and when another account
263
+ * holding `email` is an administrator. `roles` are the account's roles
264
+ * after the change, when the change sets them; otherwise its current ones
265
+ * are read. Throws `UserCreateError('admin-email-shared')`; the message names
266
+ * the accounts, for the log and an administrator, never for a visitor.
267
+ */
268
+ assertAdminEmailUnshared(username: string, email: unknown, ctx: ActorContext, roles?: readonly string[]): Promise<void>;
269
+ /**
270
+ * An account never sets its own email without the emailed link (#2073): a
271
+ * change of email made as the account itself is refused unless the caller
272
+ * holds `user-edit` (an administrator in Admin → Users is trusted). The
273
+ * link is AuthManager's (`requestEmailChange`, `confirmEmailChange`), which
274
+ * saves under the system context. Throws `UserCreateError('email-unconfirmed')`.
275
+ */
276
+ private assertEmailChangeAllowed;
277
+ /** The usernames of every account whose email another account holds too (#2073): the admins' marker on the user list. */
278
+ usernamesSharingAnEmail(): Promise<Set<string>>;
279
+ /** Whether an account other than `ownUsername` holds this email too (#2073): for the note at save and the admins' marker. */
280
+ isEmailShared(email: unknown, ownUsername: string | null): Promise<boolean>;
260
281
  /**
261
282
  * The session cookie is `secure` and a request came over plain http, so
262
283
  * express-session sent no cookie and nobody can sign in (#2071). Sessions
@@ -266,14 +287,6 @@ declare class UserManager extends BaseManager {
266
287
  * this on each such request.
267
288
  */
268
289
  reportSessionCookieWithheld(): void;
269
- /**
270
- * Accounts that share an email, saved before #1748 refused it. They are not
271
- * changed — an admin decides — but they are never silent: the manager is
272
- * degraded (dashboard, `manager-state-change` audit), the log says which,
273
- * and admins get a notification. Until resolved, a lookup by that email
274
- * refuses (see `getUserByEmail`).
275
- */
276
- private reportDuplicateEmails;
277
290
  /**
278
291
  * Normalize provider name from configuration (lowercase) to class name (PascalCase)
279
292
  * @param {string} providerName - Provider name from configuration (e.g., 'fileuserprovider')
@@ -70,6 +70,5 @@ export declare class AuthentikBearerAuthProvider implements AuthProvider {
70
70
  * config.defaultRole — even with no group hits, user gets the default.
71
71
  */
72
72
  private mapGroupsToRoles;
73
- private findUserByEmail;
74
73
  private deriveUniqueUsername;
75
74
  }
@@ -46,6 +46,8 @@ export interface AuthVerifyCredentials {
46
46
  password?: string;
47
47
  /** One-time token — used by MagicLinkAuthProvider */
48
48
  token?: string;
49
+ /** The account chosen for a link to a shared address (#2073) — used by MagicLinkAuthProvider. */
50
+ account?: string;
49
51
  /** OAuth `state` from the callback — used by OidcAuthProvider */
50
52
  state?: string;
51
53
  /** The browser-binding value from the caller's cookie (#1022 magic link, #1630 OAuth) */
@@ -207,6 +209,11 @@ export interface AuthProvider {
207
209
  * Must be read BEFORE `consumeToken()`, which deletes the entry.
208
210
  */
209
211
  getDeviceState?(handle: string): string | null;
212
+ /**
213
+ * The accounts a flow to a shared address offers (#2073), by username, or
214
+ * null when it names one account. Read without consuming.
215
+ */
216
+ accountChoices?(handle: string): string[] | null;
210
217
  /**
211
218
  * Create the account behind a first-time credential (#1049).
212
219
  *
@@ -67,6 +67,5 @@ export declare class CloudflareAccessAuthProvider implements AuthProvider {
67
67
  * config.defaultRole — even with no group hits, user gets the default.
68
68
  */
69
69
  private mapGroupsToRoles;
70
- private findUserByEmail;
71
70
  private deriveUniqueUsername;
72
71
  }
@@ -16,6 +16,11 @@
16
16
  *
17
17
  * Tokens are in-memory only — lost on restart, acceptable for the default 15-min TTL.
18
18
  *
19
+ * A shared address (#2073): two or more accounts may hold one email. The link
20
+ * then names no account; the person opening it chooses one of the accounts on
21
+ * the address, and the link alone is enough for any of them (the mailbox is
22
+ * the shared trust, operator 2026-10-25).
23
+ *
19
24
  * @see AuthManager
20
25
  * @see BaseAuthProvider
21
26
  */
@@ -57,6 +62,13 @@ export declare class MagicLinkAuthProvider implements AuthProvider {
57
62
  * Does NOT consume the token — call consumeToken() after the session is created.
58
63
  */
59
64
  verify(credentials: AuthVerifyCredentials): Promise<AuthResult | null>;
65
+ /**
66
+ * The accounts a link to a shared address offers (#2073), by username, or
67
+ * null for a link to one account (or an unknown or expired one). Read
68
+ * without consuming, so the page asking which account can be shown.
69
+ */
70
+ accountChoices(token: string): string[] | null;
71
+ private liveEntry;
60
72
  /**
61
73
  * Delete the token and report whether THIS caller was the one that did.
62
74
  *
@@ -87,6 +87,36 @@ const DATABASE_MIGRATIONS = [
87
87
  created_at TEXT NOT NULL
88
88
  )`);
89
89
  }
90
+ },
91
+ {
92
+ // #2072: requests for review. `details` is the request's JSON, which can
93
+ // be personal: ReviewManager writes rows only while this database is
94
+ // encrypted. A failed handler leaves the row `open`, with `failure` and
95
+ // `failed_at` saying why and when.
96
+ id: '20261025120000',
97
+ description: 'reviews: review_requests',
98
+ up: (db) => {
99
+ db.exec(`CREATE TABLE review_requests (
100
+ id TEXT PRIMARY KEY,
101
+ kind TEXT NOT NULL,
102
+ handler_id TEXT NOT NULL,
103
+ decide_permission TEXT NOT NULL,
104
+ subject TEXT NOT NULL,
105
+ summary TEXT NOT NULL,
106
+ details TEXT NOT NULL,
107
+ status TEXT NOT NULL,
108
+ filed_at TEXT NOT NULL,
109
+ filed_by TEXT NOT NULL,
110
+ decided_at TEXT,
111
+ decided_by TEXT,
112
+ note TEXT,
113
+ outcome TEXT,
114
+ failed_at TEXT,
115
+ failure TEXT
116
+ )`);
117
+ db.exec('CREATE INDEX review_requests_by_status ON review_requests (status, filed_at)');
118
+ db.exec('CREATE INDEX review_requests_by_decided ON review_requests (decided_at)');
119
+ }
90
120
  }
91
121
  ];
92
122
  export default DATABASE_MIGRATIONS;
@@ -316,6 +316,16 @@ export interface ApplicationOrganizationConfig {
316
316
  'ngdpbase.application.persons.search.limit': number;
317
317
  /** #1930: minutes the emailed "Is this you?" sign-up link stays usable */
318
318
  'ngdpbase.application.persons.link-offer.ttl-minutes': number;
319
+ /** #2073: the country (ISO 3166-1 alpha-2) a phone or address on /profile is read with until the user picks another */
320
+ 'ngdpbase.application.persons.default-country': string;
321
+ /** #2073: minutes the link confirming a user's new email stays usable */
322
+ 'ngdpbase.auth.email-change.ttl-minutes': number;
323
+ /** #2073: the least time between two email-change links for one account, in seconds */
324
+ 'ngdpbase.auth.email-change.cooldown-seconds': number;
325
+ /** #2073: the most email-change links one account is sent per window */
326
+ 'ngdpbase.auth.email-change.max-per-window': number;
327
+ /** #2073: that window, in minutes */
328
+ 'ngdpbase.auth.email-change.window-minutes': number;
319
329
  /** #1930: the most results `OrganizationManager.search` returns when the caller gives no limit */
320
330
  'ngdpbase.application.organization.search.limit': number;
321
331
  /** Storage directory for OrganizationRole JSON files (one per (org, namedPosition) pair) */
@@ -39,7 +39,10 @@ export interface ContactPoint {
39
39
  /** Who or what this contact point reaches, e.g. an agent's or an emergency contact's name (schema.org Thing.name). */
40
40
  name?: string;
41
41
  email?: string;
42
+ /** E.164 (`+14155552671`) when written from /profile (#2073); add-ons may store other forms. */
42
43
  telephone?: string;
44
+ /** A phone's extension, digits, kept apart from `telephone` (#2073). */
45
+ extension?: string;
43
46
  url?: string;
44
47
  availableLanguage?: string[];
45
48
  }
@@ -111,3 +111,48 @@ export type PersonChange = Omit<PersonUpdate, 'affiliation'> & {
111
111
  export type PersonInput = PersonChange & {
112
112
  identifier?: string;
113
113
  };
114
+ /** A phone as a user typed it (#2073): the number, the country to read it with, and an extension. */
115
+ export interface PhoneInput {
116
+ number?: string;
117
+ /** ISO 3166-1 alpha-2; the configured default country when left out. Ignored for a number typed with a leading `+`. */
118
+ country?: string;
119
+ extension?: string;
120
+ }
121
+ /** An address as a user typed it (#2073). Every field empty removes the address. */
122
+ export interface AddressInput {
123
+ streetAddress?: string;
124
+ addressLocality?: string;
125
+ addressRegion?: string;
126
+ postalCode?: string;
127
+ /** ISO 3166-1 alpha-2; the configured default country when left out. */
128
+ addressCountry?: string;
129
+ }
130
+ /**
131
+ * What `IdentityManager.updateContactDetails` takes (#2073): the parts of an
132
+ * identity record a user keeps on /profile. A part left out is not touched;
133
+ * a text given empty is removed.
134
+ */
135
+ export interface ContactDetailsInput {
136
+ givenName?: string;
137
+ familyName?: string;
138
+ /** Phones by `contactType` (`home`, `mobile`, `business`, `other`, or any other kind). */
139
+ phones?: Array<PhoneInput & {
140
+ contactType: string;
141
+ }>;
142
+ /** The `emergency` ContactPoint: who to call, their phone and email. */
143
+ emergency?: {
144
+ name?: string;
145
+ email?: string;
146
+ phone?: PhoneInput;
147
+ };
148
+ /** The home address (`address`). */
149
+ address?: AddressInput;
150
+ /** The `mailing` PostalAddress in `contactPoint`. */
151
+ mailing?: AddressInput;
152
+ /** Languages, each a name ("Spanish") or a tag ("es"); stored as tags. */
153
+ knowsLanguage?: string[];
154
+ /** A photo: a web address. */
155
+ image?: string;
156
+ /** Links: web addresses. */
157
+ sameAs?: string[];
158
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Requests for review (#2072): what an add-on files, what a reviewer sees,
3
+ * and the handler that carries out an approved request. ReviewManager is the
4
+ * door; these are its shapes, declared once.
5
+ */
6
+ import type { ActorContext } from '../context/ActorContext.js';
7
+ /** Where a request is: waiting for a decision, or decided. A handler failure leaves it `open`. */
8
+ export type ReviewStatus = 'open' | 'approved' | 'rejected';
9
+ /** The two answers a reviewer gives. */
10
+ export type ReviewDecision = 'approve' | 'reject';
11
+ /** What an add-on files: `fileRequest(ctx, input)`. */
12
+ export interface ReviewRequestInput {
13
+ /** What kind of request this is, `<slug>.<kind>` (`accommodation.new-owner`). Starts with the filing add-on's slug. */
14
+ kind: string;
15
+ /** What the request is about, in a few words a reviewer recognises (`Unit 12B`). */
16
+ subject: string;
17
+ /** One line saying what approving it does (`Add Pat Lee as an owner of unit 12B`). */
18
+ summary: string;
19
+ /** What the reviewer needs to decide, shown as label and value pairs; may be personal, so it is kept only in the encrypted database. */
20
+ details?: Record<string, string | number | boolean | null>;
21
+ /** The permission whose holders may decide it. Declared in `ngdpbase.permissions.definitions` and grantable; never a role name. */
22
+ decidePermission: string;
23
+ /** The handler that carries it out once approved, as registered with `host.registerReviewHandler` (`<slug>.<name>`). */
24
+ handlerId: string;
25
+ }
26
+ /** A request as the door hands it out. */
27
+ export interface ReviewRequest {
28
+ id: string;
29
+ kind: string;
30
+ subject: string;
31
+ summary: string;
32
+ details: Record<string, string | number | boolean | null>;
33
+ decidePermission: string;
34
+ handlerId: string;
35
+ status: ReviewStatus;
36
+ /** ISO 8601, UTC. A screen shows it in the viewer's local time. */
37
+ filedAt: string;
38
+ /** The username of whoever filed it (`anonymous` for a public form). */
39
+ filedBy: string;
40
+ decidedAt: string | null;
41
+ decidedBy: string | null;
42
+ /** The reviewer's note, if they wrote one. */
43
+ note: string | null;
44
+ /** On approval: what the handler said it did. */
45
+ outcome: string | null;
46
+ /** The last time the handler failed, and why, while the request stays open. */
47
+ failedAt: string | null;
48
+ failure: string | null;
49
+ }
50
+ /** What a handler answers when it has carried out the request. */
51
+ export interface ReviewHandlerResult {
52
+ /** What was done, in plain words, shown to reviewers (`Account created for Pat Lee and invitation sent`). */
53
+ summary: string;
54
+ }
55
+ /**
56
+ * Carries out an approved request, with the decider's context. Throw to say it
57
+ * was not done: the request stays open and the error's message is shown to
58
+ * reviewers as the reason, so write it for them.
59
+ */
60
+ export type ReviewHandler = (request: ReviewRequest, ctx: ActorContext) => Promise<ReviewHandlerResult>;
61
+ /** The door as `host.reviews` hands it to an add-on: the same calls, with `fileRequest` held to the add-on's own prefix. */
62
+ export interface AddonReviews {
63
+ fileRequest(ctx: ActorContext, input: ReviewRequestInput): Promise<ReviewRequest>;
64
+ listOpen(ctx: ActorContext): Promise<ReviewRequest[]>;
65
+ get(ctx: ActorContext, id: string): Promise<ReviewRequest | null>;
66
+ decide(ctx: ActorContext, id: string, decision: ReviewDecision, note?: string): Promise<ReviewRequest>;
67
+ }
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import type { ShareGrant } from './Share.js';
19
19
  import type { ProxyClaim } from '../managers/UserManager.js';
20
- import type { SignInRecord, PendingConnection } from '../managers/AuthManager.js';
20
+ import type { SignInRecord, PendingConnection, OutsideChoice } from '../managers/AuthManager.js';
21
21
  declare module 'express-session' {
22
22
  interface SessionData {
23
23
  csrfToken?: string;
@@ -45,6 +45,8 @@ declare module 'express-session' {
45
45
  pendingRedirect?: string;
46
46
  /** A verified outside identity no account has connected, waiting for the person to sign in another way (#1743). */
47
47
  pendingConnection?: PendingConnection;
48
+ /** #2073: an outside sign-in whose email several accounts share, waiting for the person to choose one. */
49
+ outsideChoice?: OutsideChoice;
48
50
  /**
49
51
  * Set at sign-in when the person has core terms to accept at their current
50
52
  * version (#1603); the consent gate holds the session to the prompt until
@@ -23,6 +23,10 @@
23
23
  * unchanged, so an add-on never imports them from `dist/` (#1813);
24
24
  * - `keyedSerial()` returns a new `KeyedSerial`, core's own per-key queue, so
25
25
  * an add-on's manager changes each of its records one at a time (#2065);
26
+ * - `reviews` is ReviewManager's door (#2072), with `fileRequest` held to this
27
+ * add-on's own `<slug>.` kinds and handlers, and `registerReviewHandler`
28
+ * registers only `<slug>.<name>` handlers, so one add-on can never file for,
29
+ * or carry out, another's requests;
26
30
  * - the object is `Object.freeze`d, so no add-on can replace a value.
27
31
  *
28
32
  * Additive only: nothing here is removed or renamed without a major release.
@@ -43,6 +47,7 @@ import { KeyedSerial } from './keyedSerial.js';
43
47
  import { recordAuditEvent } from './auditEvents.js';
44
48
  import { addonAuditEventName } from './auditEventNames.js';
45
49
  import type { IceReceiver, IceShim } from '../ice/types.js';
50
+ import type { AddonReviews, ReviewHandler } from '../types/Review.js';
46
51
  import type { AddonManifest } from '../managers/AddonsManager.js';
47
52
  /** The values core offers an add-on, under the names they were imported as. */
48
53
  export interface AddonHost {
@@ -98,6 +103,20 @@ export interface AddonHost {
98
103
  * the read included. Not re-entrant: `fn` must not `run` the same key again.
99
104
  */
100
105
  readonly keyedSerial: () => KeyedSerial;
106
+ /**
107
+ * #2072: core's one way to review requests. `fileRequest(ctx, { kind,
108
+ * subject, summary, details, decidePermission, handlerId })` files one for
109
+ * the holders of `decidePermission` to decide; `kind` and `handlerId` must
110
+ * start with this add-on's slug. `listOpen`, `get` and `decide` are the
111
+ * reviewer's calls, each asked of the PDP with the caller's context.
112
+ */
113
+ readonly reviews: AddonReviews;
114
+ /**
115
+ * #2072: register the handler that carries out an approved request, as
116
+ * `<slug>.<name>`; any other prefix throws. It is called with the decider's
117
+ * context; throw to leave the request open with your message as the reason.
118
+ */
119
+ readonly registerReviewHandler: (handlerId: string, handler: ReviewHandler) => void;
101
120
  /**
102
121
  * #1979 (ICE, D6): register an import shim, as `<slug>.<shim.name>`. Call it
103
122
  * in `register`. Refused when the manifest's `shims` does not name it.
@@ -23,6 +23,10 @@
23
23
  * unchanged, so an add-on never imports them from `dist/` (#1813);
24
24
  * - `keyedSerial()` returns a new `KeyedSerial`, core's own per-key queue, so
25
25
  * an add-on's manager changes each of its records one at a time (#2065);
26
+ * - `reviews` is ReviewManager's door (#2072), with `fileRequest` held to this
27
+ * add-on's own `<slug>.` kinds and handlers, and `registerReviewHandler`
28
+ * registers only `<slug>.<name>` handlers, so one add-on can never file for,
29
+ * or carry out, another's requests;
26
30
  * - the object is `Object.freeze`d, so no add-on can replace a value.
27
31
  *
28
32
  * Additive only: nothing here is removed or renamed without a major release.
@@ -93,6 +97,30 @@ export function buildAddonHost(slug, addonDefaults, setEnabledForPerson = noPers
93
97
  }
94
98
  return registry;
95
99
  };
100
+ const reviewDoor = () => {
101
+ const door = engine?.getManager('ReviewManager');
102
+ if (!door)
103
+ throw new Error('reviews are not available: this host was built without a site\'s ReviewManager');
104
+ return door;
105
+ };
106
+ const ownPrefix = (what, value) => {
107
+ if (typeof value !== 'string' || !value.startsWith(`${slug}.`)) {
108
+ const message = `Add-on '${slug}' may file only its own review requests: a ${what} starts '${slug}.'; '${String(value)}' does not`;
109
+ logger.error(`[addons] ${message}`);
110
+ throw new Error(message);
111
+ }
112
+ };
113
+ const reviews = {
114
+ fileRequest: async (ctx, input) => {
115
+ ownPrefix('kind', input?.kind);
116
+ ownPrefix('handler', input?.handlerId);
117
+ return reviewDoor().fileRequest(ctx, input);
118
+ },
119
+ listOpen: async (ctx) => reviewDoor().listOpen(ctx),
120
+ get: async (ctx, id) => reviewDoor().get(ctx, id),
121
+ decide: async (ctx, id, decision, note) => reviewDoor().decide(ctx, id, decision, note)
122
+ };
123
+ Object.freeze(reviews);
96
124
  const hostAddonAuditEventName = (name) => {
97
125
  const checked = addonAuditEventName(name);
98
126
  refuseUndeclared(slug, name, declared);
@@ -123,6 +151,8 @@ export function buildAddonHost(slug, addonDefaults, setEnabledForPerson = noPers
123
151
  refusedNodeMessage,
124
152
  isAtLeast,
125
153
  zonedParts,
126
- keyedSerial: () => new KeyedSerial()
154
+ keyedSerial: () => new KeyedSerial(),
155
+ reviews,
156
+ registerReviewHandler: (handlerId, handler) => reviewDoor().registerHandler(slug, handlerId, handler)
127
157
  });
128
158
  }
@@ -54,6 +54,10 @@ export declare const AUDIT_EVENT: {
54
54
  readonly CONSENT_GRANT: "consent-grant";
55
55
  /** A user's acceptances ended (#1603): today only when their account is deleted, which removes their consent records; names the documents and versions they had accepted. Recorded before the records are removed */
56
56
  readonly CONSENT_WITHDRAW: "consent-withdraw";
57
+ /** A user's new email was confirmed by its emailed link and saved (#2073). Names only, never an address */
58
+ readonly EMAILCHANGE_CONFIRM: "emailchange-confirm";
59
+ /** A user asked to change their own email: a confirmation link was sent to the new address (#2073). Names only, never an address */
60
+ readonly EMAILCHANGE_REQUEST: "emailchange-request";
57
61
  /** A page's footnote list changed — action add, import, transfer, update or delete; page content written on someone's behalf (#1233) */
58
62
  readonly FOOTNOTE_EDIT: "footnote-edit";
59
63
  /** Identity record or organization created, changed, deleted or linked to an account (recorded as contact-manage before #2038); metadata names the kind (person or organization), the operation and the fields changed, never their values */
@@ -132,6 +136,14 @@ export declare const AUDIT_EVENT: {
132
136
  readonly REAUTH_PROMPT: "reauth-prompt";
133
137
  /** Step-up: the user re-authenticated, with the factor used */
134
138
  readonly REAUTH_SUCCESS: "reauth-success";
139
+ /** A request was approved and the add-on's handler carried it out (#2072): who decided, its id, kind and handler; never the note, summary or details */
140
+ readonly REVIEW_APPROVED: "review-approved";
141
+ /** A request was approved but the add-on's handler did not carry it out, so it stays open (#2072): who approved it, its id, kind and handler */
142
+ readonly REVIEW_FAILED: "review-failed";
143
+ /** A request was filed for review (#2072): who filed it, its id, kind, the add-on handler and the permission whose holders decide; never its summary or details */
144
+ readonly REVIEW_FILED: "review-filed";
145
+ /** A request was rejected (#2072): who decided, its id, kind and handler; never the note, summary or details */
146
+ readonly REVIEW_REJECTED: "review-rejected";
135
147
  /** Page search; not recorded, read volume */
136
148
  readonly SEARCH_PAGE: "search-page";
137
149
  /** Users searched for; enumerating users is disclosive in a way searching pages is not. Off by default as read volume */
@@ -61,6 +61,10 @@ export const AUDIT_EVENT = {
61
61
  CONSENT_GRANT: 'consent-grant',
62
62
  /** A user's acceptances ended (#1603): today only when their account is deleted, which removes their consent records; names the documents and versions they had accepted. Recorded before the records are removed */
63
63
  CONSENT_WITHDRAW: 'consent-withdraw',
64
+ /** A user's new email was confirmed by its emailed link and saved (#2073). Names only, never an address */
65
+ EMAILCHANGE_CONFIRM: 'emailchange-confirm',
66
+ /** A user asked to change their own email: a confirmation link was sent to the new address (#2073). Names only, never an address */
67
+ EMAILCHANGE_REQUEST: 'emailchange-request',
64
68
  /** A page's footnote list changed — action add, import, transfer, update or delete; page content written on someone's behalf (#1233) */
65
69
  FOOTNOTE_EDIT: 'footnote-edit',
66
70
  /** Identity record or organization created, changed, deleted or linked to an account (recorded as contact-manage before #2038); metadata names the kind (person or organization), the operation and the fields changed, never their values */
@@ -139,6 +143,14 @@ export const AUDIT_EVENT = {
139
143
  REAUTH_PROMPT: 'reauth-prompt',
140
144
  /** Step-up: the user re-authenticated, with the factor used */
141
145
  REAUTH_SUCCESS: 'reauth-success',
146
+ /** A request was approved and the add-on's handler carried it out (#2072): who decided, its id, kind and handler; never the note, summary or details */
147
+ REVIEW_APPROVED: 'review-approved',
148
+ /** A request was approved but the add-on's handler did not carry it out, so it stays open (#2072): who approved it, its id, kind and handler */
149
+ REVIEW_FAILED: 'review-failed',
150
+ /** A request was filed for review (#2072): who filed it, its id, kind, the add-on handler and the permission whose holders decide; never its summary or details */
151
+ REVIEW_FILED: 'review-filed',
152
+ /** A request was rejected (#2072): who decided, its id, kind and handler; never the note, summary or details */
153
+ REVIEW_REJECTED: 'review-rejected',
142
154
  /** Page search; not recorded, read volume */
143
155
  SEARCH_PAGE: 'search-page',
144
156
  /** Users searched for; enumerating users is disclosive in a way searching pages is not. Off by default as read volume */
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Language names and tags for an identity record's `knowsLanguage` (#2073).
3
+ * The names are the runtime's own (`Intl.DisplayNames`), so no list is kept
4
+ * here: every two-letter ISO 639-1 code the runtime can name.
5
+ */
6
+ /** The tag for a language given by name ("Spanish") or tag ("es"), ignoring case; null when unknown. */
7
+ export declare function languageTag(given: string): string | null;
8
+ /** A stored tag as a name ("es" → "Spanish"); the tag itself when the runtime has no name for it. */
9
+ export declare function languageName(tag: string): string;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Phone numbers and countries for identity records (#2073).
3
+ *
4
+ * Phones are stored in E.164 (`+14155552671`), the extension apart. What a
5
+ * user types is read with the country chosen beside it; a number typed with
6
+ * a leading `+` keeps its own country code. Parsing and the per-country
7
+ * rules are libphonenumber-js's (Google's libphonenumber metadata, kept
8
+ * current by its maintainers); country names are the runtime's own
9
+ * (`Intl.DisplayNames`), so no list is kept here.
10
+ */
11
+ /** A country the dropdowns offer: its ISO 3166-1 alpha-2 code, its name, and its calling code. */
12
+ export interface CountryOption {
13
+ code: string;
14
+ name: string;
15
+ callingCode: string;
16
+ }
17
+ /** Every country libphonenumber knows, by English name. Built once. */
18
+ export declare function countryOptions(): CountryOption[];
19
+ /** `code` (any case) when it is a country the dropdowns offer, else null. */
20
+ export declare function knownCountry(code: unknown): string | null;
21
+ /**
22
+ * What the user typed, as E.164 with any extension typed after it
23
+ * (`x12`, `ext. 12`), read with `country` unless it starts with `+`.
24
+ * Null when it is not a valid number.
25
+ */
26
+ export declare function toE164(typed: string, country: string): {
27
+ telephone: string;
28
+ extension: string | null;
29
+ } | null;
30
+ /**
31
+ * A stored number shown back: its national form and its country, for the
32
+ * dropdown. A value that is not E.164 (written before #2073, or by an
33
+ * add-on) is shown as stored, with `fallback` as its country.
34
+ */
35
+ export declare function localForm(stored: string, fallback: string): {
36
+ number: string;
37
+ country: string;
38
+ };
@@ -32,8 +32,10 @@ export type UserCreateFailureReason =
32
32
  'username-taken'
33
33
  /** The display name collides with something that already exists. */
34
34
  | 'display-name-conflict'
35
- /** The email address already belongs to another account (#1748). */
36
- | 'email-taken';
35
+ /** An account holding the admin role would share its email (#2073). */
36
+ | 'admin-email-shared'
37
+ /** The account would set its own email without the emailed link (#2073). */
38
+ | 'email-unconfirmed';
37
39
  /**
38
40
  * A user-creation failure with a reason a caller can branch on.
39
41
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilleke/ngdpbase-types",
3
- "version": "5.31.0",
3
+ "version": "5.32.0",
4
4
  "description": "TypeScript types for ngdpbase add-ons, and a test kit of core's own values. A dev dependency, versioned with ngdpbase.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Jim Willeke",