@jwilleke/ngdpbase-types 5.27.0 → 5.29.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.
@@ -12,6 +12,9 @@ declare class RoleManager extends BaseManager {
12
12
  private storageDir;
13
13
  private memberCache;
14
14
  private byOrgPositionCache;
15
+ private cacheGeneration;
16
+ /** #2015: one membership change at a time per role record, read included. */
17
+ private readonly membership;
15
18
  private metricsRef;
16
19
  constructor(engine: WikiEngine);
17
20
  initialize(config?: Record<string, unknown>): Promise<void>;
@@ -42,7 +45,8 @@ declare class RoleManager extends BaseManager {
42
45
  backupFolders(): BackupFolder[];
43
46
  /** Provider accessor, mainly for tests. */
44
47
  getProvider(): RoleProvider | null;
45
- getById(id: string): Promise<Role | null>;
48
+ /** One role record by `@id`: its members are identity records, so `contact-read`, or core's own work (#2018). */
49
+ getById(ctx: ActorContext, id: string): Promise<Role | null>;
46
50
  getByOrgAndPosition(organizationId: string, namedPosition: string): Promise<Role | null>;
47
51
  listByMember(personId: string): Promise<Role[]>;
48
52
  list(): Promise<Role[]>;
@@ -63,10 +67,16 @@ declare class RoleManager extends BaseManager {
63
67
  * The pseudo-roles `'Authenticated'` and `'All'` are NOT added here — the
64
68
  * caller adds those when constructing `userContext.roles`.
65
69
  *
66
- * Returns `[]` when PersonManager is unavailable, the user has no Person
67
- * record, no record lists them, or the lookup throws.
70
+ * Returns `[]` when PersonManager is unavailable, there is no account by
71
+ * that username, no record lists them, or the lookup throws. An account
72
+ * missing its identity record gets it here (#2037).
73
+ *
74
+ * `ctx` is who is asking (#2018), decided by PersonManager's door: a user
75
+ * about themselves under `profile-manage`, about anyone else under
76
+ * `contact-read`; core's own work passes the system context. A refusal is
77
+ * thrown as `ContactRefusedError`, never answered as "no roles".
68
78
  */
69
- resolveUserRoles(username: string): Promise<string[]>;
79
+ resolveUserRoles(ctx: ActorContext, username: string): Promise<string[]>;
70
80
  /**
71
81
  * The `required-aal` levels the OPERATOR set in their own config (#448,
72
82
  * #1523), as opposed to the shipped defaults. An operator's level that no
@@ -86,15 +96,26 @@ declare class RoleManager extends BaseManager {
86
96
  * `anonymous`) requires nothing beyond being signed in.
87
97
  */
88
98
  roleRequiredAal(): Record<string, 1 | 2 | 3>;
89
- hasRole(username: string, roleName: string): Promise<boolean>;
90
- /** Assign one role to an existing account, and record it (#1204). */
99
+ /** Whether `username` holds `roleName` now, asked by `ctx`; decided as {@link resolveUserRoles} (#2018). */
100
+ hasRole(ctx: ActorContext, username: string, roleName: string): Promise<boolean>;
101
+ /**
102
+ * Assign one role to an existing account, and record it (#1204). A role
103
+ * that cannot be given is refused (#2015): the error is thrown, admins are
104
+ * notified, and the attempt is recorded as a failed user-edit.
105
+ */
91
106
  assignRole(username: string, roleName: string, ctx: ActorContext): Promise<boolean>;
92
- /** Remove one role from an existing account, and record it (#1204). */
107
+ /** Remove one role from an existing account, and record it (#1204); refused like `assignRole` when it cannot be done. */
93
108
  removeRole(username: string, roleName: string, ctx: ActorContext): Promise<boolean>;
94
109
  /**
95
110
  * Make the user's memberships go from `oldRoles` to `newRoles`. Used by
96
111
  * UserManager when an account is created or edited; the account write
97
112
  * records its own audit event, so this records none.
113
+ *
114
+ * All or nothing (#2015): when any change cannot be made, the ones already
115
+ * made are undone and a `RoleChangeError` is thrown, after it is logged as
116
+ * an error and admins are notified. That includes a change that cannot even
117
+ * be attempted: no PersonManager, no Person record for the username, or no
118
+ * anchor Organization.
98
119
  */
99
120
  applyRoleDiff(username: string, oldRoles: string[], newRoles: string[]): Promise<void>;
100
121
  /**
@@ -108,28 +129,67 @@ declare class RoleManager extends BaseManager {
108
129
  * a role change and is recorded as one, under the system context `ctx`,
109
130
  * deferred until the audit sink is up when this runs at boot.
110
131
  *
132
+ * #2015: each account gets the role or not. One that does not is recorded as
133
+ * a failed user-edit, and every miss is an error in the log and one error
134
+ * notification for admins naming the accounts to fix by hand. The boot goes
135
+ * on: nobody asked, so there is nobody to refuse.
136
+ *
111
137
  * @returns how many accounts were given the role (0 once it has been given)
112
138
  */
113
139
  grantToEveryAccountOnce(roleName: string, usernames: readonly string[], ctx: JobContext): Promise<number>;
114
140
  /**
115
141
  * Move every membership held by one Person `@id` to another (#1930): when
116
142
  * PersonManager joins an account to an existing person, the roles the
117
- * account's person held go with the account. One write per role record;
118
- * returns how many records changed. PersonManager's join records the change.
143
+ * account's person held go with the account. Returns how many records
144
+ * changed. PersonManager's join records the change. All or nothing (#2015).
119
145
  */
120
146
  moveMemberships(fromPersonId: string, toPersonId: string): Promise<number>;
121
- /** Remove a deleted account's Person from every role it held. */
122
- removeAllMemberships(username: string): Promise<void>;
123
147
  /**
124
- * Add the user's Person `@id` to the role record for (installOrg, roleName).
125
- * Idempotent: a no-op when the Person is already a member.
126
- *
127
- * Failures are logged, not thrown — account writes must succeed even when
128
- * role storage is degraded. #1027: every abandon path says why, because a
129
- * silent one is indistinguishable from success.
148
+ * Remove a deleted account's Person from every role it held. All or nothing
149
+ * (#2015): when any removal fails the others are put back and it throws, so
150
+ * the account delete is refused. Returns the undo, for a delete that fails
151
+ * after this: it gives every role back. An account with no Person holds no
152
+ * role, so there is nothing to remove.
153
+ */
154
+ removeAllMemberships(username: string): Promise<() => Promise<void>>;
155
+ /** The roles `username` gains and loses, all or nothing; `notify` false lets a batch send one notice. */
156
+ private changeRoles;
157
+ /**
158
+ * The Person `@id` and anchor Organization a username's role changes need
159
+ * (#1027). Either missing refuses the change (#2015): an account must not
160
+ * be left without the role it was given, nor keep one it was meant to lose.
161
+ */
162
+ private membershipTarget;
163
+ /** One member change and its inverse. */
164
+ private memberChange;
165
+ /**
166
+ * Make each change in turn (#2015). When one fails, undo the ones made, in
167
+ * reverse, and throw. Returns the changes made, for a later undo.
168
+ */
169
+ private allOrNothing;
170
+ /** Undo `done`, newest first; returns the labels that could not be undone. */
171
+ private undoAll;
172
+ /**
173
+ * A role change that did not happen is always an error (operator,
174
+ * 2026-10-23): logged at error level, and an error notification for admins
175
+ * unless the caller sends one for a whole batch. Returns the error to throw.
176
+ */
177
+ private roleFailure;
178
+ /** An error notification for admins; a notification that cannot be sent is logged, never thrown. */
179
+ private reportToAdmins;
180
+ /** #2015: the membership queue's key for one role record. */
181
+ private membershipKey;
182
+ /**
183
+ * Make `personId` a member of the role record, or not, one change at a time
184
+ * per record (#2015). The record is read inside the queue, so a change never
185
+ * works from a member list another change has since replaced. A missing
186
+ * record is created in `createIn` for an add; with no `createIn` an add to a
187
+ * missing record throws, and a removal from one changes nothing. False,
188
+ * writing nothing, when the person already is (or is not) a member.
130
189
  */
131
- private addMember;
132
- private removeMember;
190
+ private setMember;
191
+ /** assignRole / removeRole: the change, recorded as a user-edit whether it happened or not (#1204, #2015). */
192
+ private recordedRoleChange;
133
193
  /**
134
194
  * The role record for (installOrg, namedPosition), created when missing.
135
195
  *
@@ -153,7 +153,7 @@ interface UserCreateInput {
153
153
  /**
154
154
  * User update input data
155
155
  */
156
- interface UserUpdateInput {
156
+ export interface UserUpdateInput {
157
157
  email?: string;
158
158
  displayName?: string;
159
159
  password?: string;
@@ -166,6 +166,16 @@ interface UserUpdateInput {
166
166
  profilePage?: string;
167
167
  [key: string]: unknown;
168
168
  }
169
+ /**
170
+ * A change worked out from the account as it is now (#2025), the function form
171
+ * of `updateUser`. It runs inside the account's key, is handed a copy of the
172
+ * account without the password hash (an add-on's own fields included, as
173
+ * `UserUpdateInput` allows them), and returns the fields to merge, or
174
+ * `undefined` to change nothing.
175
+ */
176
+ export type UserUpdateFn = (current: Omit<User, 'password'> & {
177
+ [key: string]: unknown;
178
+ }) => UserUpdateInput | undefined | Promise<UserUpdateInput | undefined>;
169
179
  /**
170
180
  * UserManager - Handles user authentication, authorization, and roles
171
181
  *
@@ -208,6 +218,8 @@ declare class UserManager extends BaseManager {
208
218
  private provider;
209
219
  private providerClass?;
210
220
  private passwordSalt?;
221
+ /** One read-merge-write at a time per account, keyed by the normalized username (#2025). */
222
+ private readonly accountWrites;
211
223
  /**
212
224
  * Creates a new UserManager instance
213
225
  *
@@ -328,6 +340,17 @@ declare class UserManager extends BaseManager {
328
340
  private withAccountRoles;
329
341
  /** `ngdpbase.user.account-roles`: the roles every account holds (#1539). */
330
342
  private accountRoles;
343
+ /**
344
+ * Every account has exactly one identity record, made by `createUser`
345
+ * (#2037). One that has none (written straight into the user store, or
346
+ * whose record was lost) gets it at boot, once, through PersonManager's
347
+ * door under the system context, each creation recorded as
348
+ * `contact-manage`. The identity records are listed once and only the
349
+ * missing ones are made, so a large user store costs one read. A failure
350
+ * is logged and the boot goes on: the role paths make the record when the
351
+ * account is next used (`getByUserIdentifier`).
352
+ */
353
+ private giveIdentityRecordsToAccountsWithout;
331
354
  /**
332
355
  * Accounts made before a role joined `ngdpbase.user.account-roles` get it
333
356
  * once, at boot (#1539) — RoleManager decides "once" and records each grant.
@@ -358,6 +381,15 @@ declare class UserManager extends BaseManager {
358
381
  upgradePasswordHash(username: string, newHash: string, legacy: boolean): Promise<void>;
359
382
  /** Count a completed sign-in on the account: last sign-in and sign-in count (#1758; AuthManager calls it for a sign-in, never a re-check). */
360
383
  recordSignIn(username: string): Promise<void>;
384
+ /** Run `task` inside the account's key: one read-merge-write at a time per account (#2025). Not re-entrant. */
385
+ private inAccountKey;
386
+ /**
387
+ * Read an account, change it and write it back, inside its key (#2025): for
388
+ * core's own bookkeeping (sign-in counters, the hash upgrade, the profile
389
+ * page), which is not an edit and is not recorded. False when there is no
390
+ * such account.
391
+ */
392
+ private patchAccount;
361
393
  /**
362
394
  * Check if a display name conflicts with existing page names or other users
363
395
  * @param {string} displayName - Display name to check
@@ -400,6 +432,12 @@ declare class UserManager extends BaseManager {
400
432
  * @returns {Promise<Omit<User, 'password'>>} Created user (without password)
401
433
  */
402
434
  createUser(userData: UserCreateInput, ctx: ActorContext): Promise<Omit<User, 'password'>>;
435
+ /**
436
+ * Take back an account whose roles could not be given (#2015): its Person,
437
+ * then the account. A failure here is an error in the log; the role failure
438
+ * that caused it is what the caller is told.
439
+ */
440
+ private undoAccountCreate;
403
441
  /**
404
442
  * Update user
405
443
  */
@@ -419,7 +457,31 @@ declare class UserManager extends BaseManager {
419
457
  * which accounts exist.
420
458
  */
421
459
  resetPasswordWithRecoveryWords(username: string, words: string, newPassword: string, ctx: ActorContext): Promise<boolean>;
422
- updateUser(username: string, updates: UserUpdateInput, ctx: ActorContext): Promise<User>;
460
+ /**
461
+ * Change an account (#2025: one change at a time per account).
462
+ *
463
+ * `updates` is the fields to merge, or a function that works them out from
464
+ * the account as it is now: `(current) => updates`. The function runs inside
465
+ * the account's `KeyedSerial` key, between the read and the write, so two
466
+ * changes to one account never read the same old record. It gets a copy of
467
+ * the account without the password hash, and returns the fields to merge, or
468
+ * `undefined` to change nothing (nothing is written or recorded). It must not
469
+ * call `updateUser` for the same account: the key is not re-entrant.
470
+ * Either form is checked, applied and recorded the same way.
471
+ *
472
+ * Returns the account as written (as read, when nothing changed), without
473
+ * the password hash: the hash never leaves UserManager except to the
474
+ * password check (`storedPasswordHash`).
475
+ */
476
+ updateUser(username: string, updates: UserUpdateInput | UserUpdateFn, ctx: ActorContext): Promise<Omit<User, 'password'>>;
477
+ /**
478
+ * The steps of an account change that come after the write, outside the
479
+ * account's key: they reach other managers, which may change the account
480
+ * themselves.
481
+ */
482
+ private finishEdit;
483
+ /** Read, check and write one account change. Runs inside the account's key (#2025). */
484
+ private editAccount;
423
485
  /**
424
486
  * Delete user
425
487
  */
@@ -469,6 +531,8 @@ declare class UserManager extends BaseManager {
469
531
  * who changed it, so the record says why.
470
532
  */
471
533
  private personSyncContext;
534
+ /** Core's bookkeeping for an account write, the read of its roles included (#2018): the system context, saying what and who changed the account. */
535
+ private lifecycleContext;
472
536
  /**
473
537
  * Persist a Person record paired with a newly-created User. The install's
474
538
  * anchor org (when configured) is referenced via `memberOf`; without it
@@ -27,8 +27,15 @@ import type { PermissionSubject, JobSubject } from '../managers/UserManager.js';
27
27
  import type { WikiEngine } from '../types/WikiEngine.js';
28
28
  import type { Decision, DecisionRequest, Policy } from '../types/Policy.js';
29
29
  import { type RoleGrant } from '../utils/roleGrants.js';
30
+ import { type ActorContext } from '../context/ActorContext.js';
30
31
  export declare const POLICIES_KEY = "ngdpbase.access.policies";
31
32
  export declare const POLICIES_ENABLED_KEY = "ngdpbase.access.policies.enabled";
33
+ /**
34
+ * What `decide`, `permits` and `holds` take (#2022): the caller's own context
35
+ * — a request's `PermissionSubject` or a job's `JobContext` — or a subject
36
+ * already in decision form (`JobSubject`). A caller passes what it was given.
37
+ */
38
+ type DecisionInput = ActorContext | JobSubject | null | undefined;
32
39
  export declare class PolicyDecisionPoint extends BaseManager {
33
40
  /**
34
41
  * A manager so every PEP can reach it through `engine.getManager`, the same
@@ -84,10 +91,11 @@ export declare class PolicyDecisionPoint extends BaseManager {
84
91
  * of {@link decide} for a capability. It was `UserManager.hasPermission`
85
92
  * until #1431 step 14; the decision was already here, only the door moved.
86
93
  *
87
- * Takes a `PermissionSubject` — the request's own identity, forwarded from
88
- * `req.userContext` (WikiContext, ApiContext, ParseContext) or a
89
- * `JobContext` for work with no request (#631). Roles arrive already
90
- * resolved; the session middleware did that once per request.
94
+ * Takes the caller's context as it was given (#2022): a `PermissionSubject`
95
+ * — the request's own identity, forwarded from `req.userContext`
96
+ * (WikiContext, ApiContext, ParseContext), roles already resolved by the
97
+ * session middleware — or a `JobContext` for work with no request (#631),
98
+ * which the PDP turns into a subject itself. Never convert one first.
91
99
  *
92
100
  * #1173 Part B: the username-string overload this method once accepted is
93
101
  * gone. A string cannot carry `viaToken`, so the agent-token ceiling below
@@ -98,12 +106,13 @@ export declare class PolicyDecisionPoint extends BaseManager {
98
106
  * separate question {@link userHoldsPermission} — "does the named user hold
99
107
  * this?" — which is a lookup about somebody else, not an authorisation.
100
108
  *
101
- * @param subject - The identity being authorised, with `roles` resolved and
102
- * `viaToken` present when a bearer token authenticated it.
109
+ * @param subject - The caller's context: the request's subject, with
110
+ * `viaToken` present when a bearer token authenticated it,
111
+ * or a job's context.
103
112
  * @param action - Action/permission to check (e.g., 'page-create', 'user-read')
104
113
  * @returns True if the subject may perform the action under current policy
105
114
  */
106
- permits(subject: PermissionSubject | JobSubject | null | undefined, action: string): Promise<boolean>;
115
+ permits(subject: DecisionInput, action: string): Promise<boolean>;
107
116
  /**
108
117
  * Does the subject HOLD this permission (#1635)? Policy alone — the
109
118
  * step-up freshness rule is not applied. A different question from
@@ -112,7 +121,7 @@ export declare class PolicyDecisionPoint extends BaseManager {
112
121
  * person whose sign-in is not fresh still sees the way to the prompt that
113
122
  * makes it fresh. A door never asks this.
114
123
  */
115
- holds(subject: PermissionSubject | JobSubject | null | undefined, action: string): Promise<boolean>;
124
+ holds(subject: DecisionInput, action: string): Promise<boolean>;
116
125
  /**
117
126
  * Step-up (#1525, #1635), decided here — in the one place every door asks —
118
127
  * rather than by routes in front of the doors. A permission marked
@@ -252,7 +261,7 @@ export declare class PolicyDecisionPoint extends BaseManager {
252
261
  * time, not at enqueue time (#631, #1212).
253
262
  * 4. __The policies__, through `PolicyEvaluator`.
254
263
  */
255
- decide(subject: PermissionSubject | JobSubject | null | undefined, request: DecisionRequest): Promise<Decision>;
264
+ decide(subject: DecisionInput, request: DecisionRequest): Promise<Decision>;
256
265
  /** `decide`, with the step-up rule applied or not — {@link holds} is the only caller that leaves it out. */
257
266
  private evaluate;
258
267
  }
@@ -1,5 +1,5 @@
1
1
  import BaseManager from '../managers/BaseManager.js';
2
- import type { ActorContext } from '../context/ActorContext.js';
2
+ import { type ActorContext } from '../context/ActorContext.js';
3
3
  import { WikiEngine } from '../types/WikiEngine.js';
4
4
  import type { AgentTokenGrant, ProxyClaim } from '../managers/UserManager.js';
5
5
  import type { Request } from 'express';
@@ -46,6 +46,12 @@ interface UserContext {
46
46
  viaProxy?: ProxyClaim;
47
47
  [key: string]: unknown;
48
48
  }
49
+ /**
50
+ * What the page questions take (#2022): the caller's context as given — a
51
+ * request's subject or a job's `JobContext` — or a subject already in decision
52
+ * form. They convert it themselves, through {@link asSubject}.
53
+ */
54
+ type PageQuestionCaller = ActorContext | UserContext | null | undefined;
49
55
  /**
50
56
  * Access decision log entry
51
57
  */
@@ -173,6 +179,15 @@ declare class PolicyInformationPoint extends BaseManager {
173
179
  * visitor holds, which is the safe answer for someone who no longer exists.
174
180
  */
175
181
  resolveSubjectNow(username: string | undefined): Promise<UserContext>;
182
+ /**
183
+ * The subject as a page's own rules match it (#2022): for a job's subject
184
+ * (`resolveRolesNow`, #1212), its username with the roles its account
185
+ * holds NOW, read through {@link resolveSubjectNow} — the path the PDP
186
+ * takes for the same subject; anyone else as carried. A job carries no
187
+ * roles, so matching it without this matched none, and an audience naming
188
+ * a role its user holds left the job out.
189
+ */
190
+ private matchableNow;
176
191
  /**
177
192
  * Who is making this request: the subject the session middleware resolved,
178
193
  * else the session's account resolved now, else the anonymous visitor.
@@ -348,14 +363,14 @@ declare class PolicyInformationPoint extends BaseManager {
348
363
  * private page gets at Tier 0: the owner, or a delegate of the owner; never
349
364
  * a role. A refusal is recorded like any page refusal (`authorization-deny`).
350
365
  *
351
- * @param userContext - the request's subject, as given (null = anonymous)
366
+ * @param caller - the caller's context, as given: a request's subject or a job's (#2022); null is anonymous
352
367
  * @param owner - the container's owner (a private file's `creator`)
353
368
  * @param resource - what is being reached, for the record (e.g. `attachment:<id>`)
354
369
  * @param action - the action asked for (e.g. `view`)
355
370
  * @param vault - the vault inside the container, when the question is about one:
356
371
  * a share link the owner issued gets in only for a vault it names (#1388)
357
372
  */
358
- canAccessPrivateContainer(userContext: UserContext | null | undefined, owner: string, resource: string, action: string, vault?: string): boolean;
373
+ canAccessPrivateContainer(caller: PageQuestionCaller, owner: string, resource: string, action: string, vault?: string): boolean;
359
374
  /**
360
375
  * Check whether a user can access a given page — for cross-page checks where
361
376
  * the current WikiContext describes a DIFFERENT page than the one being
@@ -381,14 +396,14 @@ declare class PolicyInformationPoint extends BaseManager {
381
396
  * case.
382
397
  * - any tier returns deny.
383
398
  *
384
- * @param userContext - The user requesting access (may be null / anonymous).
399
+ * @param caller - The caller's context, as given: a request's subject or a job's (#2022); null / undefined is anonymous.
385
400
  * @param pageName - Target page to check.
386
401
  * @param action - Action verb (e.g., `'view'`, `'edit'`, `'delete'`).
387
402
  * @param knownMetadata - The page's metadata when the caller has just
388
403
  * resolved it: the page door (#1622), which reads the page by the same
389
404
  * resolution it decides on. Omitted, it is loaded here.
390
405
  */
391
- canUserAccessPage(userContext: UserContext | null | undefined, pageName: string, action: string, knownMetadata?: PageFrontmatter): Promise<boolean>;
406
+ canUserAccessPage(caller: PageQuestionCaller, pageName: string, action: string, knownMetadata?: PageFrontmatter): Promise<boolean>;
392
407
  /**
393
408
  * {@link canUserAccessPage} as an affordance (#1668): would the door allow
394
409
  * this action on that page — asked only to decide whether to show a control
@@ -399,7 +414,7 @@ declare class PolicyInformationPoint extends BaseManager {
399
414
  * @param knownMetadata - As for {@link canUserAccessPage}: the page's
400
415
  * metadata when the caller has just resolved it (`PageManager.mayShowPage`).
401
416
  */
402
- pageAffordanceFor(userContext: UserContext | null | undefined, pageName: string, action: string, knownMetadata?: PageFrontmatter): Promise<boolean>;
417
+ pageAffordanceFor(caller: PageQuestionCaller, pageName: string, action: string, knownMetadata?: PageFrontmatter): Promise<boolean>;
403
418
  /** The cross-page check, for the door (`record`) or an affordance (#1668). */
404
419
  private crossPageDecision;
405
420
  /**
@@ -425,7 +440,7 @@ declare class PolicyInformationPoint extends BaseManager {
425
440
  * A candidate without metadata is not listed (#714's convention). Order is
426
441
  * preserved. Returns titles.
427
442
  */
428
- filterAccessiblePages(userContext: UserContext | null | undefined, action: string, candidates: ReadonlyArray<{
443
+ filterAccessiblePages(caller: PageQuestionCaller, action: string, candidates: ReadonlyArray<{
429
444
  title: string;
430
445
  metadata: PageFrontmatter | null | undefined;
431
446
  }>): Promise<string[]>;
@@ -445,7 +460,7 @@ declare class PolicyInformationPoint extends BaseManager {
445
460
  * A refusal is audited as `authorization-deny` on the media resource, with
446
461
  * the share attribution when there is one.
447
462
  */
448
- canUserAccessMediaItem(userContext: UserContext | null | undefined, item: MediaItem): Promise<boolean>;
463
+ canUserAccessMediaItem(caller: PageQuestionCaller, item: MediaItem): Promise<boolean>;
449
464
  /**
450
465
  * Check front matter audience / access fields (Tier 1).
451
466
  * Returns a decision object; decided=false means no front matter restriction — fall through.
@@ -462,10 +477,10 @@ declare class PolicyInformationPoint extends BaseManager {
462
477
  * write door, so a write cannot reach a page its writer is outside the
463
478
  * audience of — whatever route it came by. Records nothing; the door does.
464
479
  *
465
- * @param userContext - the subject, as given
480
+ * @param caller - the caller's context, as given (a job's included, #2022)
466
481
  * @param metadata - the page's current frontmatter
467
482
  */
468
- audienceAdmits(userContext: UserContext | null | undefined, metadata: PageFrontmatter | null | undefined): Promise<boolean>;
483
+ audienceAdmits(caller: PageQuestionCaller, metadata: PageFrontmatter | null | undefined): Promise<boolean>;
469
484
  /**
470
485
  * Record/audit an access decision.
471
486
  * Accepts either a single object or positional args for backward compatibility.
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Consent to a site's documents (#1603): the shapes ConsentManager and its
3
+ * callers share, and what an add-on registers.
4
+ */
5
+ /** Where a person is asked to accept (#1603): `?scope=` (core when absent) and `?returnTo=`. */
6
+ export const CONSENT_PATH = '/consent';
7
+ /** The scope of core's own documents, `ngdpbase.consent.documents`. */
8
+ export const CORE_CONSENT_SCOPE = 'core';
9
+ /**
10
+ * Thrown when a per-person add-on is turned on before its documents are
11
+ * accepted (#1603). `documents` names what is still to accept.
12
+ */
13
+ export class ConsentRequiredError extends Error {
14
+ scope;
15
+ documents;
16
+ name = 'ConsentRequiredError';
17
+ constructor(scope, documents) {
18
+ super(`Turning ${scope} on needs its ${documents.join(' and ')} accepted first`);
19
+ this.scope = scope;
20
+ this.documents = documents;
21
+ }
22
+ }
@@ -73,7 +73,7 @@ export interface User {
73
73
  /**
74
74
  * @deprecated since #617 iteration 3b. Role membership is now stored
75
75
  * canonically as OrganizationRole records owned by `RoleManager`; resolve
76
- * a user's roles via `RoleManager.resolveUserRoles(username)`. The field
76
+ * a user's roles via `RoleManager.resolveUserRoles(ctx, username)`. The field
77
77
  * is retained as optional for backwards compatibility while in-flight
78
78
  * `users.json` files still carry it; the `strip-user-roles.ts` migration
79
79
  * removes it. New code MUST NOT read or write this field directly.
@@ -50,9 +50,9 @@ export declare const AUDIT_EVENT: {
50
50
  readonly CONFIG_CHANGE: "config-change";
51
51
  /** Every custom configuration value discarded; recorded before the reset, which cannot proceed without it */
52
52
  readonly CONFIG_RESET: "config-reset";
53
- /** A person accepted a document they are asked to accept (#1603): which document (core's terms-of-service or privacy-policy, or an add-on's), its version and effective date, and how (sign-up, sign-in, turning an add-on on). Recorded before the acceptance is stored, which cannot proceed without it: consent must be provable */
53
+ /** A user accepted a document they are asked to accept (#1603): which document (core's terms-of-service or privacy-policy, or an add-on's), its version and effective date, and how (sign-up, sign-in, turning an add-on on). Recorded before the acceptance is stored, which cannot proceed without it: consent must be provable */
54
54
  readonly CONSENT_GRANT: "consent-grant";
55
- /** A person'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 */
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
57
  /** Contact created, changed, deleted or linked to an account; metadata names the kind (person or organization), the operation and the fields changed, never their values */
58
58
  readonly CONTACT_MANAGE: "contact-manage";
@@ -112,21 +112,21 @@ export declare const AUDIT_EVENT: {
112
112
  readonly POLICY_EVALUATE: "policy-evaluate";
113
113
  /** Security posture at startup, compared against the previous start */
114
114
  readonly POSTURE_RECORDED: "posture-recorded";
115
- /** Proxy grant made: someone may act for the person named in user; actor is who made it (#1762) */
115
+ /** Proxy grant made: someone may act for the user named in user; actor is who made it (#1762) */
116
116
  readonly PROXY_GRANT: "proxy-grant";
117
- /** Proxy grant ended: revoked, or the account closed; user is the person represented, actor who ended it (#1762) */
117
+ /** Proxy grant ended: revoked, or the account closed; user is the user represented, actor who ended it (#1762) */
118
118
  readonly PROXY_REVOKE: "proxy-revoke";
119
- /** A signed-in session started or stopped acting for someone under a proxy grant, or opens their private store: chosen, switched-back, ended because the grant no longer allows it (grant-ended, proxy-access-off), or vault-opened; user is the person represented, actor the person signed in (#1762) */
119
+ /** A signed-in session started or stopped acting for someone under a proxy grant, or opens their private store: chosen, switched-back, ended because the grant no longer allows it (grant-ended, proxy-access-off), or vault-opened; user is the user represented, actor the user signed in (#1762) */
120
120
  readonly PROXY_SWITCH: "proxy-switch";
121
- /** Step-up: a re-authentication failed (wrong password, another person's passkey, throttled) */
121
+ /** Step-up: a re-authentication failed (wrong password, another user's passkey, throttled) */
122
122
  readonly REAUTH_FAILURE: "reauth-failure";
123
123
  /** Step-up: an action asked for a fresh sign-in (#1525) — or refused a delegated credential that cannot give one */
124
124
  readonly REAUTH_PROMPT: "reauth-prompt";
125
- /** Step-up: the person re-authenticated, with the factor used */
125
+ /** Step-up: the user re-authenticated, with the factor used */
126
126
  readonly REAUTH_SUCCESS: "reauth-success";
127
127
  /** Page search; not recorded, read volume */
128
128
  readonly SEARCH_PAGE: "search-page";
129
- /** People searched for; enumerating people is disclosive in a way searching pages is not. Off by default as read volume */
129
+ /** Users searched for; enumerating users is disclosive in a way searching pages is not. Off by default as read volume */
130
130
  readonly SEARCH_USER: "search-user";
131
131
  /** A masked configuration value shown to an administrator; the key is recorded, never the value */
132
132
  readonly SECRET_REVEAL: "secret-reveal";
@@ -146,9 +146,9 @@ export declare const AUDIT_EVENT: {
146
146
  readonly SHARE_REVOKE: "share-revoke";
147
147
  /** Sign-in approval: the browser that was waiting gave up the sign-in (#1532) */
148
148
  readonly SIGNINAPPROVAL_ABANDON: "signinapproval-abandon";
149
- /** Sign-in approval: the person pressed Approve (#1532) */
149
+ /** Sign-in approval: the user pressed Approve (#1532) */
150
150
  readonly SIGNINAPPROVAL_APPROVE: "signinapproval-approve";
151
- /** Sign-in approval: the person pressed Deny; that sign-in will not complete (#1532) */
151
+ /** Sign-in approval: the user pressed Deny; that sign-in will not complete (#1532) */
152
152
  readonly SIGNINAPPROVAL_DENY: "signinapproval-deny";
153
153
  /** Sign-in approval: a waiting sign-in expired before it was completed (#1532) */
154
154
  readonly SIGNINAPPROVAL_EXPIRE: "signinapproval-expire";
@@ -57,9 +57,9 @@ export const AUDIT_EVENT = {
57
57
  CONFIG_CHANGE: 'config-change',
58
58
  /** Every custom configuration value discarded; recorded before the reset, which cannot proceed without it */
59
59
  CONFIG_RESET: 'config-reset',
60
- /** A person accepted a document they are asked to accept (#1603): which document (core's terms-of-service or privacy-policy, or an add-on's), its version and effective date, and how (sign-up, sign-in, turning an add-on on). Recorded before the acceptance is stored, which cannot proceed without it: consent must be provable */
60
+ /** A user accepted a document they are asked to accept (#1603): which document (core's terms-of-service or privacy-policy, or an add-on's), its version and effective date, and how (sign-up, sign-in, turning an add-on on). Recorded before the acceptance is stored, which cannot proceed without it: consent must be provable */
61
61
  CONSENT_GRANT: 'consent-grant',
62
- /** A person'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 */
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
64
  /** Contact created, changed, deleted or linked to an account; metadata names the kind (person or organization), the operation and the fields changed, never their values */
65
65
  CONTACT_MANAGE: 'contact-manage',
@@ -119,21 +119,21 @@ export const AUDIT_EVENT = {
119
119
  POLICY_EVALUATE: 'policy-evaluate',
120
120
  /** Security posture at startup, compared against the previous start */
121
121
  POSTURE_RECORDED: 'posture-recorded',
122
- /** Proxy grant made: someone may act for the person named in user; actor is who made it (#1762) */
122
+ /** Proxy grant made: someone may act for the user named in user; actor is who made it (#1762) */
123
123
  PROXY_GRANT: 'proxy-grant',
124
- /** Proxy grant ended: revoked, or the account closed; user is the person represented, actor who ended it (#1762) */
124
+ /** Proxy grant ended: revoked, or the account closed; user is the user represented, actor who ended it (#1762) */
125
125
  PROXY_REVOKE: 'proxy-revoke',
126
- /** A signed-in session started or stopped acting for someone under a proxy grant, or opens their private store: chosen, switched-back, ended because the grant no longer allows it (grant-ended, proxy-access-off), or vault-opened; user is the person represented, actor the person signed in (#1762) */
126
+ /** A signed-in session started or stopped acting for someone under a proxy grant, or opens their private store: chosen, switched-back, ended because the grant no longer allows it (grant-ended, proxy-access-off), or vault-opened; user is the user represented, actor the user signed in (#1762) */
127
127
  PROXY_SWITCH: 'proxy-switch',
128
- /** Step-up: a re-authentication failed (wrong password, another person's passkey, throttled) */
128
+ /** Step-up: a re-authentication failed (wrong password, another user's passkey, throttled) */
129
129
  REAUTH_FAILURE: 'reauth-failure',
130
130
  /** Step-up: an action asked for a fresh sign-in (#1525) — or refused a delegated credential that cannot give one */
131
131
  REAUTH_PROMPT: 'reauth-prompt',
132
- /** Step-up: the person re-authenticated, with the factor used */
132
+ /** Step-up: the user re-authenticated, with the factor used */
133
133
  REAUTH_SUCCESS: 'reauth-success',
134
134
  /** Page search; not recorded, read volume */
135
135
  SEARCH_PAGE: 'search-page',
136
- /** People searched for; enumerating people is disclosive in a way searching pages is not. Off by default as read volume */
136
+ /** Users searched for; enumerating users is disclosive in a way searching pages is not. Off by default as read volume */
137
137
  SEARCH_USER: 'search-user',
138
138
  /** A masked configuration value shown to an administrator; the key is recorded, never the value */
139
139
  SECRET_REVEAL: 'secret-reveal',
@@ -153,9 +153,9 @@ export const AUDIT_EVENT = {
153
153
  SHARE_REVOKE: 'share-revoke',
154
154
  /** Sign-in approval: the browser that was waiting gave up the sign-in (#1532) */
155
155
  SIGNINAPPROVAL_ABANDON: 'signinapproval-abandon',
156
- /** Sign-in approval: the person pressed Approve (#1532) */
156
+ /** Sign-in approval: the user pressed Approve (#1532) */
157
157
  SIGNINAPPROVAL_APPROVE: 'signinapproval-approve',
158
- /** Sign-in approval: the person pressed Deny; that sign-in will not complete (#1532) */
158
+ /** Sign-in approval: the user pressed Deny; that sign-in will not complete (#1532) */
159
159
  SIGNINAPPROVAL_DENY: 'signinapproval-deny',
160
160
  /** Sign-in approval: a waiting sign-in expired before it was completed (#1532) */
161
161
  SIGNINAPPROVAL_EXPIRE: 'signinapproval-expire',
@@ -12,6 +12,12 @@
12
12
  * A route turns it into the answer with `ApiContext.enforceDeny(err.permission,
13
13
  * err.decision)`, the one place a refusal is shaped (#1431); the door never
14
14
  * builds an HTTP answer itself.
15
+ *
16
+ * Every lookup takes a context too (#2018, operator 2026-10-23), through
17
+ * {@link requireRecordAccess}: a user's own identity record under
18
+ * `profile-manage`, anyone else's under `contact-read` / `contact-manage`, and
19
+ * core's own reads under the system context without a policy evaluation, so
20
+ * resolving roles on every request stays cheap.
15
21
  */
16
22
  import { type ActorContext } from '../context/ActorContext.js';
17
23
  import type { CorePermission } from '../security/permissions.generated.js';
@@ -37,6 +43,30 @@ export declare class ContactRefusedError extends Error {
37
43
  * refuses: closed, never open.
38
44
  */
39
45
  export declare function requireContactPermission(engine: EngineLike, ctx: ActorContext, kind: keyof typeof CONTACT_PERMISSION, also?: readonly CorePermission[]): Promise<void>;
46
+ /**
47
+ * Core's own work (#2018): a boot or schedule context naming the system
48
+ * principal, with no token, share or proxy claim. Recognised by its fields
49
+ * alone, with no policy evaluation, because RoleManager resolves roles
50
+ * through these lookups on every request. A request never carries this
51
+ * context: the system principal's name is reserved, so no account holds it.
52
+ */
53
+ export declare function isSystemWork(engine: EngineLike, ctx: ActorContext | null | undefined): boolean;
54
+ /**
55
+ * A lookup or change of one record at a contacts door (#2018). Throws
56
+ * {@link ContactRefusedError} unless `ctx` may:
57
+ *
58
+ * - read it as core's own work ({@link isSystemWork}): allowed, not evaluated;
59
+ * - read or change its own identity record (the context's username is the
60
+ * record's `identifier`): `profile-manage`, which every user holds;
61
+ * - read or change any other record: `contact-read` / `contact-manage`.
62
+ *
63
+ * `record` is the identity record (or what names it, `{ identifier }`); left
64
+ * out for a record that is never anyone's own (an organization, a role).
65
+ * "Own" is decided by comparing usernames, never by a role.
66
+ */
67
+ export declare function requireRecordAccess(engine: EngineLike, ctx: ActorContext, kind: keyof typeof CONTACT_PERMISSION, record?: {
68
+ identifier?: unknown;
69
+ } | null): Promise<void>;
40
70
  /** What a contacts record says was done. Names fields, never their values. */
41
71
  export interface ContactChange {
42
72
  kind: 'person' | 'organization';