@jwilleke/ngdpbase-types 5.30.1 → 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';
@@ -41,12 +43,12 @@ export type { IceShim, IceReceiver, IceItem, IceKind, IceWarning, ShimDirection,
41
43
  export type { default as UserManager, UserUpdateInput, UserUpdateFn } from '../../src/managers/UserManager.js';
42
44
  export type { User, UserPreferences } from '../../src/types/User.js';
43
45
  export type { default as AuthManager, InviteOutcome, InvitationView } from '../../src/managers/AuthManager.js';
44
- export type { default as IdentityManager, LinkOfferOutcome } from '../../src/managers/IdentityManager.js';
46
+ export type { default as IdentityManager, LinkOfferOutcome, AffiliationQuery, IdentitySearch, IdentityMatch } from '../../src/managers/IdentityManager.js';
45
47
  export type {
46
48
  /** @deprecated Renamed `IdentityManager` (#2038); this alias goes after one release. */
47
49
  default as PersonManager } from '../../src/managers/IdentityManager.js';
48
50
  export type { default as OrganizationManager } from '../../src/managers/OrganizationManager.js';
49
- export type { Person, PersonAffiliation, PersonInput, PersonUpdate } from '../../src/types/Person.js';
51
+ export type { Person, PersonAffiliation, LegacyPersonAffiliation, PersonChange, PersonInput, PersonUpdate } from '../../src/types/Person.js';
50
52
  export type { Organization, OrganizationInput, OrganizationUpdate, ContactPoint, ContactPointEntry, PostalAddress } from '../../src/types/Organization.js';
51
53
  export type { ContactRefusedError } from '../../src/utils/contactAccess.js';
52
54
  export type { default as RoleManager } from '../../src/managers/RoleManager.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
  *
@@ -327,6 +327,15 @@ declare class BackgroundJobManager extends BaseManager {
327
327
  private startDrain;
328
328
  /** Every scheduled job, its state and its recent history. */
329
329
  listScheduledJobs(): Promise<ScheduledJobView[]>;
330
+ /**
331
+ * Every scheduled job's id and display name, as `listScheduledJobs` names
332
+ * them, without reading their state — for the admin roles page's Jobs part
333
+ * (#2067).
334
+ */
335
+ scheduledJobNames(): Array<{
336
+ id: string;
337
+ displayName: string;
338
+ }>;
330
339
  /**
331
340
  * One scheduled job as `listScheduledJobs` shows it, without its run
332
341
  * history, or null when no job of that id is scheduled. For a manager that
@@ -1,8 +1,31 @@
1
1
  import BaseManager, { type BackupFolder, type ManagerStats } from './BaseManager.js';
2
2
  import type { WikiEngine } from '../types/WikiEngine.js';
3
- import type { Person, PersonInput, PersonUpdate } 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
+ /** What `search(ctx, { affiliation })` looks for (#2039). */
7
+ export interface AffiliationQuery {
8
+ /** The organization, by its `@id` (or `{ '@id' }`). */
9
+ memberOf: string | {
10
+ '@id': string;
11
+ };
12
+ /** The title, compared without case or surrounding spaces. Left out: any title, or none. */
13
+ title?: string;
14
+ /** True (the default): only affiliations held today, the first and last day included. False: any, past or future too. */
15
+ current?: boolean;
16
+ }
17
+ /** A search by affiliation (#2039). `limit` as for a text search; 0 is no limit. */
18
+ export interface IdentitySearch {
19
+ affiliation: AffiliationQuery;
20
+ limit?: number;
21
+ }
22
+ /** One identity a search by affiliation found: a user or a contact, and the affiliations that matched. */
23
+ export interface IdentityMatch {
24
+ identity: Person;
25
+ /** True for a user (an identity with credentials), false for a contact. */
26
+ hasCredentials: boolean;
27
+ affiliations: PersonAffiliation[];
28
+ }
6
29
  /** What the confirmation page shows: the person the link would join. */
7
30
  export interface LinkOfferDetails {
8
31
  personName: string | null;
@@ -52,6 +75,7 @@ declare class IdentityManager extends BaseManager {
52
75
  private storageDir;
53
76
  private byIdentifierCache;
54
77
  private byIdCache;
78
+ private allCache;
55
79
  private metricsRef;
56
80
  /** Sign-up link offers by token (#1930). In memory: a restart drops them, and an administrator links by hand. */
57
81
  private linkOffers;
@@ -59,6 +83,18 @@ declare class IdentityManager extends BaseManager {
59
83
  private readonly accountRecords;
60
84
  constructor(engine: WikiEngine);
61
85
  initialize(config?: Record<string, unknown>): Promise<void>;
86
+ /**
87
+ * Once, at start-up (#2039): every identity record still holding an
88
+ * affiliation in the old `{ organization, role? }` shape is rewritten as
89
+ * schema.org `OrganizationRole` (organization → `memberOf`, role →
90
+ * `roleName`), each rewrite recorded as `identity-manage` (`person-update`,
91
+ * field `affiliation`) under the system context. Nothing is dropped: any
92
+ * other field an old entry carries is kept on it. A record with no old entry
93
+ * is not touched, so a second start finds nothing to do. A record that
94
+ * cannot be rewritten is logged and left as it is; reads convert it anyway.
95
+ * Runs before any request, so nothing else writes these records meanwhile.
96
+ */
97
+ private convertLegacyAffiliations;
62
98
  /** `ngdpbase.application.persons.storagedir`, resolved: where the records are. */
63
99
  private configuredStorageDir;
64
100
  /**
@@ -79,6 +115,8 @@ declare class IdentityManager extends BaseManager {
79
115
  */
80
116
  getByIdentifier(ctx: ActorContext, identifier: string): Promise<Person | null>;
81
117
  private cachedById;
118
+ /** Every record, affiliations in the current shape, read from the provider once per write (#2039). */
119
+ private allRecords;
82
120
  private cachedByIdentifier;
83
121
  /** #1006: how many people this instance holds. Count only, never records. */
84
122
  getManagerStats(): Promise<ManagerStats>;
@@ -93,11 +131,20 @@ declare class IdentityManager extends BaseManager {
93
131
  * `query`, ignoring case; an empty query matches everyone. `accountless`
94
132
  * narrows to persons with no account. At most `limit` results
95
133
  * (`ngdpbase.application.persons.search.limit` by default).
134
+ *
135
+ * Or by affiliation (#2039): `search(ctx, { affiliation: { memberOf, title?,
136
+ * current? } })` finds every identity, users and contacts alike, with an
137
+ * affiliation to that organization: with that title when one is given, and
138
+ * held today (the first and last day included) unless `current` is false.
139
+ * Each match says whether it has credentials and which affiliations matched.
140
+ * Checked like any read of others' records: `identity-read`.
96
141
  */
97
142
  search(ctx: ActorContext, query: string, options?: {
98
143
  limit?: number;
99
144
  accountless?: boolean;
100
145
  }): Promise<Person[]>;
146
+ search(ctx: ActorContext, query: IdentitySearch): Promise<IdentityMatch[]>;
147
+ private searchByAffiliation;
101
148
  /**
102
149
  * Create a person. With no `identifier` the person has no account and gets
103
150
  * `person:<uuid>`. An `identifier` must be an existing account that has no
@@ -108,8 +155,48 @@ declare class IdentityManager extends BaseManager {
108
155
  * Change a person's fields, affiliations included. `@id` and `identifier`
109
156
  * never change here. The caller's own identity record under
110
157
  * `profile-manage`, anyone else's under `identity-manage` (#2018).
158
+ *
159
+ * Affiliations (#2039): one with the site's own organization is a fact, so
160
+ * adding, changing or removing one also asks `identity-manage`, even on the
161
+ * caller's own record; one with any other organization is a claim, which a
162
+ * user may set on their own record under `profile-manage`.
163
+ */
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.
111
196
  */
112
- update(ctx: ActorContext, id: string, patch: PersonUpdate): Promise<Person | null>;
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;
113
200
  delete(ctx: ActorContext, id: string): Promise<boolean>;
114
201
  /**
115
202
  * An administrator joins a person who has no account to an account (#1930),
@@ -150,10 +237,18 @@ declare class IdentityManager extends BaseManager {
150
237
  */
151
238
  private join;
152
239
  /**
153
- * Each affiliation names an organization that exists, looked up with the
154
- * caller's context; `role` is trimmed text. Throws on any other shape.
240
+ * Each affiliation is a schema.org `OrganizationRole` (#2039): `memberOf` an
241
+ * organization that exists, an optional `roleName` (the title, trimmed free
242
+ * text) and optional `startDate` / `endDate` (`YYYY-MM-DD`, the start not
243
+ * after the end; a null end is an ongoing one, stored without it). An entry
244
+ * in the old `{ organization, role? }` shape is converted (one release, for
245
+ * add-ons not yet switched). Throws on any other shape. The organization is
246
+ * looked up as core's own check, under the system context, so a user may
247
+ * name one on their own record without `identity-read`.
155
248
  */
156
249
  private checkedAffiliations;
250
+ /** The site's own organization's `@id` (`OrganizationManager.getInstallOrg`): an affiliation with it is a fact (#2039). */
251
+ private siteOrganizationId;
157
252
  private configNumber;
158
253
  /**
159
254
  * Clear all caches. Called on every write — the simplest correct
@@ -181,5 +276,15 @@ declare class IdentityManager extends BaseManager {
181
276
  private getMetrics;
182
277
  private normalizeProviderName;
183
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
+ }
184
289
  export { ContactRefusedError } from '../utils/contactAccess.js';
185
290
  export default IdentityManager;
@@ -2,7 +2,7 @@ import BaseManager, { type BackupFolder, type ManagerStats } from './BaseManager
2
2
  import type { WikiEngine } from '../types/WikiEngine.js';
3
3
  import type { Organization, OrganizationInput, OrganizationUpdate } from '../types/Organization.js';
4
4
  import type { OrganizationProvider } from '../types/OrganizationProvider.js';
5
- import type { Person } from '../types/Person.js';
5
+ import type { Person, PersonAffiliation } from '../types/Person.js';
6
6
  import type { ActorContext } from '../context/ActorContext.js';
7
7
  /**
8
8
  * Subset of install-form data this manager needs to seed the anchor org.
@@ -107,13 +107,17 @@ declare class OrganizationManager extends BaseManager {
107
107
  limit?: number;
108
108
  }): Promise<Organization[]>;
109
109
  /**
110
- * The people affiliated with an organization, read from each person's
111
- * `affiliation` (#1930): the link is stored once, on the person. Each comes
112
- * with the role they hold there, when one is given.
110
+ * The identities affiliated with an organization, users and contacts alike,
111
+ * read from each identity record's `affiliation` (#1930): the link is stored
112
+ * once, on the identity record. One entry per affiliation, each with its
113
+ * schema.org `OrganizationRole` (title as `roleName`, dates) (#2039), past
114
+ * and future ones included; `IdentityManager.search(ctx, { affiliation })`
115
+ * answers who holds one today. Searching identities is IdentityManager's,
116
+ * so this asks it.
113
117
  */
114
118
  people(ctx: ActorContext, organizationId: string): Promise<Array<{
115
119
  person: Person;
116
- role?: string;
120
+ affiliation: PersonAffiliation;
117
121
  }>>;
118
122
  /**
119
123
  * Create an organization. `name` is required; `@id` defaults to the `url`,
@@ -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;
@@ -222,6 +222,8 @@ declare class UserManager extends BaseManager {
222
222
  private passwordSalt?;
223
223
  /** One read-merge-write at a time per account, keyed by the normalized username (#2025). */
224
224
  private readonly accountWrites;
225
+ /** Whether this process has already reported the withheld session cookie (#2071). */
226
+ private sessionCookieWithheldReported;
225
227
  /**
226
228
  * Creates a new UserManager instance
227
229
  *
@@ -247,22 +249,44 @@ declare class UserManager extends BaseManager {
247
249
  initialize(config?: Record<string, unknown>): Promise<void>;
248
250
  /** An email as accounts are compared on it (#1748): trimmed, any case. No provider rules (Gmail dots, plus addressing, aliases). */
249
251
  private static emailKey;
250
- /** The accounts holding this email, by username. Empty for no email. */
251
- private accountsWithEmail;
252
252
  /**
253
- * One email, one account (#1748): refuse to save an email another account
254
- * already has. Not a setting. Thrown as `email-taken`, so registration shows
255
- * its safe message and other routes their own.
256
- */
257
- private assertEmailFree;
258
- /**
259
- * Accounts that share an email, saved before #1748 refused it. They are not
260
- * changed — an admin decides — but they are never silent: the manager is
261
- * degraded (dashboard, `manager-state-change` audit), the log says which,
262
- * and admins get a notification. Until resolved, a lookup by that email
263
- * refuses (see `getUserByEmail`).
264
- */
265
- private reportDuplicateEmails;
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.
257
+ */
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>;
281
+ /**
282
+ * The session cookie is `secure` and a request came over plain http, so
283
+ * express-session sent no cookie and nobody can sign in (#2071). Sessions
284
+ * are this manager's, so the report is too: an error in the log, the
285
+ * dashboard's "needs your attention", and a notification for admins, once
286
+ * per process. Every later call is one boolean check, because app.ts calls
287
+ * this on each such request.
288
+ */
289
+ reportSessionCookieWithheld(): void;
266
290
  /**
267
291
  * Normalize provider name from configuration (lowercase) to class name (PascalCase)
268
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
  *