@jwilleke/ngdpbase-types 5.27.0 → 5.28.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.
@@ -36,9 +36,12 @@ export type { PageAction } from '../../src/utils/pageUrl.js';
36
36
  export type { default as ConsentManager, ConsentScopeStatus } from '../../src/managers/ConsentManager.js';
37
37
  export type { AddonConsentRegistration, ConsentDocument, ConsentRecord, ConsentRequiredError, ConsentScope, ConsentSeen, ConsentStatus } from '../../src/types/Consent.js';
38
38
  export type { default as ImportManager } from '../../src/managers/ImportManager.js';
39
- export type { IceShim, IceReceiver, IceItem, IceKind, IceWarning, ShimDirection, ShimAccepts, ShimTools, ShimReadResult, ReceiveInfo, ReceiveOutcome, IceCitation, KeptOriginal, OriginalChoice, IceUpload, IceSource, IceRunRequest, IceRunReport, IceFileReport, IceFileStatus } from '../../src/ice/types.js';
39
+ export type { IceShim, IceReceiver, IceItem, IceKind, IceWarning, ShimDirection, ShimAccepts, ShimTools, ShimReadResult, ReceiveInfo, ReceiveOutcome, IceCitation, KeptOriginal, OriginalChoice, IceOriginalChoices, IceUpload, IceSource, IceRunRequest, IceRunReport, IceFileReport, IceFileStatus } from '../../src/ice/types.js';
40
+ export type { default as UserManager, UserUpdateInput, UserUpdateFn } from '../../src/managers/UserManager.js';
41
+ export type { User, UserPreferences } from '../../src/types/User.js';
40
42
  export type { default as PersonManager, LinkOfferOutcome } from '../../src/managers/PersonManager.js';
41
43
  export type { default as OrganizationManager } from '../../src/managers/OrganizationManager.js';
42
44
  export type { Person, PersonAffiliation, PersonInput, PersonUpdate } from '../../src/types/Person.js';
43
45
  export type { Organization, OrganizationInput, OrganizationUpdate } from '../../src/types/Organization.js';
44
46
  export type { ContactRefusedError } from '../../src/utils/contactAccess.js';
47
+ export type { default as RoleManager } from '../../src/managers/RoleManager.js';
@@ -13,8 +13,8 @@
13
13
  * the call arrived under, and the address it came from. What does not: roles.
14
14
  * Authority is resolved at the moment of each decision, never carried.
15
15
  */
16
- import type { PermissionSubject } from '../managers/UserManager.js';
17
- import type { JobContext } from './JobContext.js';
16
+ import type { PermissionSubject, JobSubject } from '../managers/UserManager.js';
17
+ import { type JobContext } from './JobContext.js';
18
18
  export type ActorContext = PermissionSubject | JobContext;
19
19
  /**
20
20
  * The fields an audit record takes from a context.
@@ -35,6 +35,19 @@ export interface ActorAttribution {
35
35
  metadata: Record<string, unknown>;
36
36
  }
37
37
  export declare function isJobContext(ctx: ActorContext): ctx is JobContext;
38
+ /**
39
+ * The one place a context becomes the subject a decision reads (#2022).
40
+ *
41
+ * Called by the deciders — `PolicyDecisionPoint` (`decide`, `permits`,
42
+ * `holds`) and `PolicyInformationPoint`'s page questions — and by nobody
43
+ * else: a caller hands them its context as given. A `JobContext` carries
44
+ * identity, never authority, so it becomes a `JobSubject` whose roles policy
45
+ * resolves now, keeping its token, share and proxy claims
46
+ * (`toPermissionSubject`). Anything else is already a subject and passes
47
+ * through. The object guard is #1173's: `'x' in 'jim'` throws. Not part of
48
+ * the add-on API; `npm run lint:context` holds callers to it.
49
+ */
50
+ export declare function decisionSubject<S extends PermissionSubject | JobSubject>(input: ActorContext | S | null | undefined): PermissionSubject | JobSubject | S | null | undefined;
38
51
  /**
39
52
  * Who acted, for the record — read from the context, never guessed.
40
53
  *
@@ -1,8 +1,28 @@
1
+ import { toPermissionSubject } from './JobContext.js';
1
2
  import { attributedTo } from './bootActions.js';
2
3
  import { proxyAttribution } from '../utils/proxyClaim.js';
3
4
  export function isJobContext(ctx) {
4
5
  return 'origin' in ctx && 'requestedAt' in ctx;
5
6
  }
7
+ /**
8
+ * The one place a context becomes the subject a decision reads (#2022).
9
+ *
10
+ * Called by the deciders — `PolicyDecisionPoint` (`decide`, `permits`,
11
+ * `holds`) and `PolicyInformationPoint`'s page questions — and by nobody
12
+ * else: a caller hands them its context as given. A `JobContext` carries
13
+ * identity, never authority, so it becomes a `JobSubject` whose roles policy
14
+ * resolves now, keeping its token, share and proxy claims
15
+ * (`toPermissionSubject`). Anything else is already a subject and passes
16
+ * through. The object guard is #1173's: `'x' in 'jim'` throws. Not part of
17
+ * the add-on API; `npm run lint:context` holds callers to it.
18
+ */
19
+ export function decisionSubject(input) {
20
+ if (typeof input !== 'object' || input === null)
21
+ return input;
22
+ // A JobSubject is not an ActorContext, and is not a job context either.
23
+ const ctx = input;
24
+ return isJobContext(ctx) ? toPermissionSubject(ctx) : input;
25
+ }
6
26
  /**
7
27
  * Who acted, for the record — read from the context, never guessed.
8
28
  *
@@ -121,6 +121,16 @@ export interface RegisteredShim extends IceShim {
121
121
  }
122
122
  /** D11: what the person importing chose to do with the original file. */
123
123
  export type OriginalChoice = 'cite' | 'keep' | 'none';
124
+ /**
125
+ * D11: the original-file choices a receiver offers, and which one is chosen
126
+ * when the user picks none (#2021). The import screen offers only `allowed`,
127
+ * and the engine refuses a run asking for any other.
128
+ */
129
+ export interface IceOriginalChoices {
130
+ allowed: OriginalChoice[];
131
+ /** One of `allowed`. Default: `ngdpbase.import.engine.default-original` when allowed, else the first allowed. */
132
+ default?: OriginalChoice;
133
+ }
124
134
  /** A citation to the origin (D11): the receiver renders it, as a footnote on a page. */
125
135
  export interface IceCitation {
126
136
  name: string;
@@ -172,6 +182,8 @@ export interface IceReceiver {
172
182
  * store). Without this, `keep` hands the original to the files receiver.
173
183
  */
174
184
  keepOriginal?(original: IceItem, ctx: ActorContext): Promise<ReceiveOutcome>;
185
+ /** The original-file choices this receiver offers (D11). Without it: all three. */
186
+ originals?: IceOriginalChoices;
175
187
  }
176
188
  export interface RegisteredReceiver extends IceReceiver {
177
189
  id: string;
@@ -238,6 +250,14 @@ export interface IceRunReport {
238
250
  error?: string;
239
251
  files: IceFileReport[];
240
252
  }
253
+ /**
254
+ * A kept original as the ledger remembers it, with the door that kept it
255
+ * (a receiver's own `keepOriginal`, or `core.files`). A later run of the same
256
+ * fingerprint through the same door reuses it instead of keeping another copy.
257
+ */
258
+ export interface LedgerOriginal extends KeptOriginal {
259
+ door: string;
260
+ }
241
261
  /** What the engine remembers about one file in a scope. */
242
262
  export interface LedgerFileEntry {
243
263
  name: string;
@@ -247,6 +267,8 @@ export interface LedgerFileEntry {
247
267
  message?: string;
248
268
  at: string;
249
269
  refs: string[];
270
+ /** The original kept for this fingerprint, whatever became of the conversion (D11). */
271
+ original?: LedgerOriginal;
250
272
  }
251
273
  /**
252
274
  * Where the engine keeps fingerprints, per-file status and item keys, per
@@ -121,8 +121,12 @@ export interface AddonModule {
121
121
  * "section was not rendered at all" (addon disabled mid-session, cached
122
122
  * form). Without it, absent checkbox fields look identical to "user
123
123
  * submitted no such field" and you may silently clobber stored values.
124
+ *
125
+ * `ctx` is the signed-in user's own context, forwarded from the request:
126
+ * pass it to your manager's door (and to the PDP) instead of rebuilding a
127
+ * subject from `user` (#2018).
124
128
  */
125
- profileSection?(user: AddonProfileUser): Promise<AddonProfileSection | null> | AddonProfileSection | null;
129
+ profileSection?(user: AddonProfileUser, ctx: ActorContext): Promise<AddonProfileSection | null> | AddonProfileSection | null;
126
130
  /**
127
131
  * Optional save handler paired with `profileSection()` (#534).
128
132
  *
@@ -673,7 +677,7 @@ declare class AddonsManager extends BaseManager {
673
677
  * Result order follows registration order of the addons map so the
674
678
  * displayed section order is stable across renders.
675
679
  */
676
- getProfileSections(user: AddonProfileUser): Promise<AddonProfileSectionEntry[]>;
680
+ getProfileSections(user: AddonProfileUser, ctx: ActorContext): Promise<AddonProfileSectionEntry[]>;
677
681
  /**
678
682
  * #1234: say at load when an addon still implements the pre-#1234 hook.
679
683
  *
@@ -689,7 +693,7 @@ declare class AddonsManager extends BaseManager {
689
693
  * `saveProfileSection()` handler (#534).
690
694
  *
691
695
  * Runs SEQUENTIALLY — each addon's read-modify-write of user preferences
692
- * (typical implementation: `getUser → merge → updateUser`) must see the
696
+ * (`updateUser`'s function form, #2025) must see the
693
697
  * previous addon's writes. Parallel execution would create a same-snapshot
694
698
  * race where two addons read identical preferences, each merge their own
695
699
  * keys, and the second writer clobbers the first writer's changes.
@@ -67,10 +67,11 @@ declare class AssetManager extends BaseManager {
67
67
  /**
68
68
  * Return all AssetRecords referenced by the given page slug.
69
69
  *
70
- * O(1) index lookup + one getById() call per referenced asset.
70
+ * O(1) index lookup + one provider lookup per referenced asset.
71
71
  * Stale entries (asset deleted or provider removed) are silently filtered out.
72
+ * Nothing for a caller who may not read assets (`asset-read`, #2018).
72
73
  */
73
- getAssetsForPage(slug: string): Promise<AssetRecord[]>;
74
+ getAssetsForPage(ctx: ActorContext, slug: string): Promise<AssetRecord[]>;
74
75
  /**
75
76
  * Remove a page's entry from the reverse index.
76
77
  * Call this when a page is deleted or renamed.
@@ -103,8 +104,11 @@ declare class AssetManager extends BaseManager {
103
104
  /**
104
105
  * Retrieve a single asset by id. If providerId is given only that provider
105
106
  * is checked; otherwise all providers are tried in registration order.
107
+ * Null for a caller who may not read assets (`asset-read`, asked of the PDP
108
+ * with `ctx`, as FileManager asks for the public place; #2018).
106
109
  */
107
- getById(id: string, providerId?: string): Promise<AssetRecord | null>;
110
+ getById(ctx: ActorContext, id: string, providerId?: string): Promise<AssetRecord | null>;
111
+ private lookup;
108
112
  /**
109
113
  * Generate or retrieve a cached thumbnail.
110
114
  * Requires the provider to declare the 'thumbnail' capability.
@@ -539,6 +539,11 @@ class ExportManager extends BaseManager {
539
539
  * @returns {Promise<void>}
540
540
  */
541
541
  async deleteExport(filename) {
542
+ // Only a plain file name inside the export folder: never a path that
543
+ // reaches another folder.
544
+ if (!filename || filename !== path.basename(filename) || filename === '.' || filename === '..') {
545
+ throw new Error(`Not an export file name: ${filename}`);
546
+ }
542
547
  const filePath = path.join(this.exportDirectory, filename);
543
548
  await fs.unlink(filePath);
544
549
  logger.info(`Deleted export: ${filename}`);
@@ -31,8 +31,8 @@ import BaseManager, { BackupData } from './BaseManager.js';
31
31
  import type { WikiEngine } from '../types/WikiEngine.js';
32
32
  import { IContentConverter } from '../converters/IContentConverter.js';
33
33
  import { type ZipReadLimits } from '../utils/zipArchive.js';
34
- import { type JobContext } from '../context/JobContext.js';
35
- import type { IceLedger, IceReceiver, IceRunReport, IceRunRequest, IceShim, RegisteredShim } from '../ice/types.js';
34
+ import type { JobContext } from '../context/JobContext.js';
35
+ import type { IceLedger, IceOriginalChoices, IceReceiver, IceRunReport, IceRunRequest, IceShim, RegisteredShim } from '../ice/types.js';
36
36
  /**
37
37
  * Options for import operations
38
38
  */
@@ -253,6 +253,12 @@ export type TakeoutImportReport = {
253
253
  */
254
254
  reportPage?: string;
255
255
  };
256
+ /** A receiver the import screen may offer this user (#2021): its id, label and original-file choices (D11). */
257
+ export interface UsableReceiver {
258
+ id: string;
259
+ label?: string;
260
+ originals: Required<IceOriginalChoices>;
261
+ }
256
262
  /**
257
263
  * Import Manager class
258
264
  *
@@ -310,11 +316,24 @@ declare class ImportManager extends BaseManager {
310
316
  listShims(): Array<Pick<RegisteredShim, 'id' | 'owner' | 'label' | 'direction' | 'produces' | 'receivers'> & {
311
317
  enabled: boolean;
312
318
  }>;
313
- /** The receivers this context may use (D5): each answers with its own existing permission. */
314
- receiversFor(ctx: ActorContext): Promise<Array<{
315
- id: string;
316
- label?: string;
317
- }>>;
319
+ /**
320
+ * The receivers this context may use (D5): each answers with its own
321
+ * existing permission. Each comes with the original-file choices it offers
322
+ * (D11), for the import screen (#2021).
323
+ */
324
+ receiversFor(ctx: ActorContext): Promise<UsableReceiver[]>;
325
+ /**
326
+ * The original-file choices a receiver offers (D11, #2021): what it
327
+ * declares, else all three; its default when it names one it allows, else
328
+ * `ngdpbase.import.engine.default-original` when allowed, else the first.
329
+ */
330
+ private originalChoices;
331
+ /**
332
+ * The request as the engine runs it: the receiver's default original when
333
+ * none was chosen, and a refusal for a choice the receiver does not offer
334
+ * (D11, #2021). An unknown receiver is left for the run to report.
335
+ */
336
+ private withOriginal;
318
337
  /**
319
338
  * Run one import now and wait for it. For code already off the request
320
339
  * path: an add-on's own background job, a test. A route uses
@@ -332,6 +351,8 @@ declare class ImportManager extends BaseManager {
332
351
  enqueueImport(request: IceRunRequest, ctx: JobContext): Promise<string>;
333
352
  /** A run's report, to the person it runs as; null to anyone else. */
334
353
  getImportRun(runId: string, ctx: ActorContext): IceRunReport | null;
354
+ /** The runs this process holds for the user `ctx` names, newest first (#2021): their own, never anyone else's. */
355
+ importRunsFor(ctx: ActorContext): IceRunReport[];
335
356
  /** Use this ledger instead of the file one: a test's, or a database table's. */
336
357
  setImportLedger(ledger: IceLedger): void;
337
358
  private newRunReport;
@@ -88,13 +88,15 @@ declare class OrganizationManager extends BaseManager {
88
88
  * problem actually bites.
89
89
  */
90
90
  getInstallOrg(): Promise<Organization | null>;
91
- getById(id: string): Promise<Organization | null>;
92
- getByFile(filename: string): Promise<Organization | null>;
91
+ /** One organization by `@id`, for a caller who may read contacts (`contact-read`), or core's own work (#2018). */
92
+ getById(ctx: ActorContext, id: string): Promise<Organization | null>;
93
+ /** One organization by its file name, decided as {@link getById} (#2018). */
94
+ getByFile(ctx: ActorContext, filename: string): Promise<Organization | null>;
93
95
  /** #1006: how many organizations this instance holds. Count only. */
94
96
  getManagerStats(): Promise<ManagerStats>;
95
97
  /** Every organization, for a caller who may read contacts (`contact-read`). */
96
98
  list(ctx: ActorContext): Promise<Organization[]>;
97
- /** One organization by `@id`, for a caller who may read contacts (`contact-read`). */
99
+ /** One organization by `@id`: {@link getById}. */
98
100
  get(ctx: ActorContext, id: string): Promise<Organization | null>;
99
101
  /**
100
102
  * Organizations whose `name`, `legalName`, `email` or a contact point's
@@ -1128,6 +1128,38 @@ declare class PageManager extends BaseManager implements CatalogSource {
1128
1128
  * const deleted = await pageManager.deletePage('Old Page', req.userContext);
1129
1129
  */
1130
1130
  deletePage(identifier: string, ctx: ActorContext): Promise<boolean>;
1131
+ /**
1132
+ * Delete the pages a test made (#2008): every shared page carrying the
1133
+ * `test-artifact` system keyword (#1355), and never a page without it.
1134
+ * `only` narrows the set further — a deleted test account's profile is
1135
+ * `{ author, systemCategory: 'user-profile' }`.
1136
+ *
1137
+ * Each page is recorded as `page-delete` before it is deleted (on-failure
1138
+ * refuse: a page whose record cannot be written is not deleted), then goes
1139
+ * through {@link deletePage}, so the delete is soft and the shared indexes
1140
+ * follow. One page's failure never stops the rest; it is reported.
1141
+ *
1142
+ * The caller decides who may ask: the sweep route asks `admin-system`, and
1143
+ * an account delete has already been decided as `user-delete`. Private
1144
+ * vault pages are not in the shared index, so they are not reached here.
1145
+ *
1146
+ * @param ctx - Who is acting; named in every record
1147
+ * @param only - Narrow to one author and/or system-category
1148
+ */
1149
+ deleteTestArtifacts(ctx: ActorContext, only?: {
1150
+ author?: string;
1151
+ systemCategory?: string;
1152
+ }): Promise<{
1153
+ deleted: Array<{
1154
+ title: string;
1155
+ uuid: string;
1156
+ }>;
1157
+ failed: Array<{
1158
+ title: string;
1159
+ uuid: string;
1160
+ error: string;
1161
+ }>;
1162
+ }>;
1131
1163
  /**
1132
1164
  * The name a page was reached by, as the shared indexes key it: a private
1133
1165
  * page's path (#1456), else its title — the caller may have used a uuid or
@@ -23,8 +23,13 @@ export type LinkOfferOutcome = 'sent' | 'no-match' | 'several' | 'unavailable' |
23
23
  * `linkAccount` take the caller's context first and ask the PDP: `contact-read`
24
24
  * to list, search or get; `contact-manage` to change (`linkAccount` also `user-edit`). Each change is recorded as
25
25
  * the `contact-manage` audit event. A refusal throws `ContactRefusedError`.
26
- * `getById` / `getByIdentifier` stay as context-free lookups for core's own
27
- * hot paths (RoleManager resolves roles through them on every request).
26
+ *
27
+ * Every lookup takes the caller's context too (#2018): `getById`,
28
+ * `getByIdentifier` and `getByUserIdentifier`. A user reads their own identity
29
+ * record, and `update` changes it, under `profile-manage`; anyone else's needs
30
+ * `contact-read` / `contact-manage`. Core's own reads (RoleManager resolving
31
+ * roles on every request, UserManager's account lifecycle) pass the system
32
+ * context, allowed without a policy evaluation (`requireRecordAccess`).
28
33
  *
29
34
  * A later sign-up joins a person with no account in one of two ways: the new
30
35
  * account confirms an emailed link (`offerSignUpLink` → `confirmSignUpLink`),
@@ -32,8 +37,8 @@ export type LinkOfferOutcome = 'sent' | 'no-match' | 'several' | 'unavailable' |
32
37
  * person keeps its `@id` and takes the account; the person made for the new
33
38
  * account is folded into it and removed, so there is never a second person.
34
39
  *
35
- * `getByUserIdentifier(username)` remains as a lazy-migration entry point
36
- * for legacy users that pre-date the sync wiring.
40
+ * `getByUserIdentifier(ctx, username)` remains as a lazy-migration entry
41
+ * point for legacy users that pre-date the sync wiring.
37
42
  */
38
43
  declare class PersonManager extends BaseManager {
39
44
  readonly description = "Canonical Person records (#617)";
@@ -56,15 +61,26 @@ declare class PersonManager extends BaseManager {
56
61
  */
57
62
  backupFolders(): BackupFolder[];
58
63
  getProvider(): PersonProvider | null;
59
- getById(id: string): Promise<Person | null>;
60
- getByIdentifier(identifier: string): Promise<Person | null>;
64
+ /**
65
+ * One identity record by `@id` (#2018): the caller's own under
66
+ * `profile-manage`, anyone else's under `contact-read`, core's own work
67
+ * under the system context. A refusal throws `ContactRefusedError`.
68
+ */
69
+ getById(ctx: ActorContext, id: string): Promise<Person | null>;
70
+ /**
71
+ * The identity record whose `identifier` is `identifier` (a username, or a
72
+ * contact's `person:<uuid>`), decided as {@link getById} before it is read.
73
+ */
74
+ getByIdentifier(ctx: ActorContext, identifier: string): Promise<Person | null>;
75
+ private cachedById;
76
+ private cachedByIdentifier;
61
77
  /** #1006: how many people this instance holds. Count only, never records. */
62
78
  getManagerStats(): Promise<ManagerStats>;
63
79
  /** True when the person has no account: its identifier is `person:<uuid>` (#1930). */
64
80
  static isAccountless(person: Pick<Person, 'identifier'>): boolean;
65
81
  /** Every person, for a caller who may read contacts (`contact-read`). */
66
82
  list(ctx: ActorContext): Promise<Person[]>;
67
- /** One person by `@id`, for a caller who may read contacts (`contact-read`). */
83
+ /** One person by `@id`: {@link getById}, the caller's own under `profile-manage`, anyone else's under `contact-read`. */
68
84
  get(ctx: ActorContext, id: string): Promise<Person | null>;
69
85
  /**
70
86
  * Persons whose name (`name`, `givenName`, `familyName`) or `email` contains
@@ -82,7 +98,11 @@ declare class PersonManager extends BaseManager {
82
98
  * person yet: that is how UserManager makes the person for a new account.
83
99
  */
84
100
  create(ctx: ActorContext, input: PersonInput): Promise<Person>;
85
- /** Change a person's fields, affiliations included. `@id` and `identifier` never change here. */
101
+ /**
102
+ * Change a person's fields, affiliations included. `@id` and `identifier`
103
+ * never change here. The caller's own identity record under
104
+ * `profile-manage`, anyone else's under `contact-manage` (#2018).
105
+ */
86
106
  update(ctx: ActorContext, id: string, patch: PersonUpdate): Promise<Person | null>;
87
107
  delete(ctx: ActorContext, id: string): Promise<boolean>;
88
108
  /**
@@ -123,7 +143,10 @@ declare class PersonManager extends BaseManager {
123
143
  * to it, and that person is removed. Never a second person.
124
144
  */
125
145
  private join;
126
- /** Each affiliation names an organization that exists; `role` is trimmed text. Throws on any other shape. */
146
+ /**
147
+ * Each affiliation names an organization that exists, looked up with the
148
+ * caller's context; `role` is trimmed text. Throws on any other shape.
149
+ */
127
150
  private checkedAffiliations;
128
151
  private configNumber;
129
152
  /**
@@ -136,9 +159,11 @@ declare class PersonManager extends BaseManager {
136
159
  *
137
160
  * Returns the Person paired to `username`. If none exists and a matching
138
161
  * User does, a Person is created from the User's email/displayName and
139
- * persisted. Subsequent calls return the persisted record.
162
+ * persisted. Subsequent calls return the persisted record. Decided as
163
+ * {@link getByIdentifier} (#2018): a signed-in user's own under
164
+ * `profile-manage` ("your receipts"), anyone else's under `contact-read`.
140
165
  */
141
- getByUserIdentifier(username: string): Promise<Person | null>;
166
+ getByUserIdentifier(ctx: ActorContext, username: string): Promise<Person | null>;
142
167
  private requireProvider;
143
168
  private getMetrics;
144
169
  private normalizeProviderName;
@@ -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[]>;
@@ -65,8 +69,13 @@ declare class RoleManager extends BaseManager {
65
69
  *
66
70
  * Returns `[]` when PersonManager is unavailable, the user has no Person
67
71
  * record, no record lists them, or the lookup throws.
72
+ *
73
+ * `ctx` is who is asking (#2018), decided by PersonManager's door: a user
74
+ * about themselves under `profile-manage`, about anyone else under
75
+ * `contact-read`; core's own work passes the system context. A refusal is
76
+ * thrown as `ContactRefusedError`, never answered as "no roles".
68
77
  */
69
- resolveUserRoles(username: string): Promise<string[]>;
78
+ resolveUserRoles(ctx: ActorContext, username: string): Promise<string[]>;
70
79
  /**
71
80
  * The `required-aal` levels the OPERATOR set in their own config (#448,
72
81
  * #1523), as opposed to the shipped defaults. An operator's level that no
@@ -86,15 +95,26 @@ declare class RoleManager extends BaseManager {
86
95
  * `anonymous`) requires nothing beyond being signed in.
87
96
  */
88
97
  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). */
98
+ /** Whether `username` holds `roleName` now, asked by `ctx`; decided as {@link resolveUserRoles} (#2018). */
99
+ hasRole(ctx: ActorContext, username: string, roleName: string): Promise<boolean>;
100
+ /**
101
+ * Assign one role to an existing account, and record it (#1204). A role
102
+ * that cannot be given is refused (#2015): the error is thrown, admins are
103
+ * notified, and the attempt is recorded as a failed user-edit.
104
+ */
91
105
  assignRole(username: string, roleName: string, ctx: ActorContext): Promise<boolean>;
92
- /** Remove one role from an existing account, and record it (#1204). */
106
+ /** Remove one role from an existing account, and record it (#1204); refused like `assignRole` when it cannot be done. */
93
107
  removeRole(username: string, roleName: string, ctx: ActorContext): Promise<boolean>;
94
108
  /**
95
109
  * Make the user's memberships go from `oldRoles` to `newRoles`. Used by
96
110
  * UserManager when an account is created or edited; the account write
97
111
  * records its own audit event, so this records none.
112
+ *
113
+ * All or nothing (#2015): when any change cannot be made, the ones already
114
+ * made are undone and a `RoleChangeError` is thrown, after it is logged as
115
+ * an error and admins are notified. That includes a change that cannot even
116
+ * be attempted: no PersonManager, no Person record for the username, or no
117
+ * anchor Organization.
98
118
  */
99
119
  applyRoleDiff(username: string, oldRoles: string[], newRoles: string[]): Promise<void>;
100
120
  /**
@@ -108,28 +128,67 @@ declare class RoleManager extends BaseManager {
108
128
  * a role change and is recorded as one, under the system context `ctx`,
109
129
  * deferred until the audit sink is up when this runs at boot.
110
130
  *
131
+ * #2015: each account gets the role or not. One that does not is recorded as
132
+ * a failed user-edit, and every miss is an error in the log and one error
133
+ * notification for admins naming the accounts to fix by hand. The boot goes
134
+ * on: nobody asked, so there is nobody to refuse.
135
+ *
111
136
  * @returns how many accounts were given the role (0 once it has been given)
112
137
  */
113
138
  grantToEveryAccountOnce(roleName: string, usernames: readonly string[], ctx: JobContext): Promise<number>;
114
139
  /**
115
140
  * Move every membership held by one Person `@id` to another (#1930): when
116
141
  * 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.
142
+ * account's person held go with the account. Returns how many records
143
+ * changed. PersonManager's join records the change. All or nothing (#2015).
119
144
  */
120
145
  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
146
  /**
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.
147
+ * Remove a deleted account's Person from every role it held. All or nothing
148
+ * (#2015): when any removal fails the others are put back and it throws, so
149
+ * the account delete is refused. Returns the undo, for a delete that fails
150
+ * after this: it gives every role back. An account with no Person holds no
151
+ * role, so there is nothing to remove.
152
+ */
153
+ removeAllMemberships(username: string): Promise<() => Promise<void>>;
154
+ /** The roles `username` gains and loses, all or nothing; `notify` false lets a batch send one notice. */
155
+ private changeRoles;
156
+ /**
157
+ * The Person `@id` and anchor Organization a username's role changes need
158
+ * (#1027). Either missing refuses the change (#2015): an account must not
159
+ * be left without the role it was given, nor keep one it was meant to lose.
160
+ */
161
+ private membershipTarget;
162
+ /** One member change and its inverse. */
163
+ private memberChange;
164
+ /**
165
+ * Make each change in turn (#2015). When one fails, undo the ones made, in
166
+ * reverse, and throw. Returns the changes made, for a later undo.
167
+ */
168
+ private allOrNothing;
169
+ /** Undo `done`, newest first; returns the labels that could not be undone. */
170
+ private undoAll;
171
+ /**
172
+ * A role change that did not happen is always an error (operator,
173
+ * 2026-10-23): logged at error level, and an error notification for admins
174
+ * unless the caller sends one for a whole batch. Returns the error to throw.
175
+ */
176
+ private roleFailure;
177
+ /** An error notification for admins; a notification that cannot be sent is logged, never thrown. */
178
+ private reportToAdmins;
179
+ /** #2015: the membership queue's key for one role record. */
180
+ private membershipKey;
181
+ /**
182
+ * Make `personId` a member of the role record, or not, one change at a time
183
+ * per record (#2015). The record is read inside the queue, so a change never
184
+ * works from a member list another change has since replaced. A missing
185
+ * record is created in `createIn` for an add; with no `createIn` an add to a
186
+ * missing record throws, and a removal from one changes nothing. False,
187
+ * writing nothing, when the person already is (or is not) a member.
130
188
  */
131
- private addMember;
132
- private removeMember;
189
+ private setMember;
190
+ /** assignRole / removeRole: the change, recorded as a user-edit whether it happened or not (#1204, #2015). */
191
+ private recordedRoleChange;
133
192
  /**
134
193
  * The role record for (installOrg, namedPosition), created when missing.
135
194
  *
@@ -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
  *
@@ -358,6 +370,15 @@ declare class UserManager extends BaseManager {
358
370
  upgradePasswordHash(username: string, newHash: string, legacy: boolean): Promise<void>;
359
371
  /** 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
372
  recordSignIn(username: string): Promise<void>;
373
+ /** Run `task` inside the account's key: one read-merge-write at a time per account (#2025). Not re-entrant. */
374
+ private inAccountKey;
375
+ /**
376
+ * Read an account, change it and write it back, inside its key (#2025): for
377
+ * core's own bookkeeping (sign-in counters, the hash upgrade, the profile
378
+ * page), which is not an edit and is not recorded. False when there is no
379
+ * such account.
380
+ */
381
+ private patchAccount;
361
382
  /**
362
383
  * Check if a display name conflicts with existing page names or other users
363
384
  * @param {string} displayName - Display name to check
@@ -400,6 +421,12 @@ declare class UserManager extends BaseManager {
400
421
  * @returns {Promise<Omit<User, 'password'>>} Created user (without password)
401
422
  */
402
423
  createUser(userData: UserCreateInput, ctx: ActorContext): Promise<Omit<User, 'password'>>;
424
+ /**
425
+ * Take back an account whose roles could not be given (#2015): its Person,
426
+ * then the account. A failure here is an error in the log; the role failure
427
+ * that caused it is what the caller is told.
428
+ */
429
+ private undoAccountCreate;
403
430
  /**
404
431
  * Update user
405
432
  */
@@ -419,7 +446,31 @@ declare class UserManager extends BaseManager {
419
446
  * which accounts exist.
420
447
  */
421
448
  resetPasswordWithRecoveryWords(username: string, words: string, newPassword: string, ctx: ActorContext): Promise<boolean>;
422
- updateUser(username: string, updates: UserUpdateInput, ctx: ActorContext): Promise<User>;
449
+ /**
450
+ * Change an account (#2025: one change at a time per account).
451
+ *
452
+ * `updates` is the fields to merge, or a function that works them out from
453
+ * the account as it is now: `(current) => updates`. The function runs inside
454
+ * the account's `KeyedSerial` key, between the read and the write, so two
455
+ * changes to one account never read the same old record. It gets a copy of
456
+ * the account without the password hash, and returns the fields to merge, or
457
+ * `undefined` to change nothing (nothing is written or recorded). It must not
458
+ * call `updateUser` for the same account: the key is not re-entrant.
459
+ * Either form is checked, applied and recorded the same way.
460
+ *
461
+ * Returns the account as written (as read, when nothing changed), without
462
+ * the password hash: the hash never leaves UserManager except to the
463
+ * password check (`storedPasswordHash`).
464
+ */
465
+ updateUser(username: string, updates: UserUpdateInput | UserUpdateFn, ctx: ActorContext): Promise<Omit<User, 'password'>>;
466
+ /**
467
+ * The steps of an account change that come after the write, outside the
468
+ * account's key: they reach other managers, which may change the account
469
+ * themselves.
470
+ */
471
+ private finishEdit;
472
+ /** Read, check and write one account change. Runs inside the account's key (#2025). */
473
+ private editAccount;
423
474
  /**
424
475
  * Delete user
425
476
  */
@@ -469,6 +520,8 @@ declare class UserManager extends BaseManager {
469
520
  * who changed it, so the record says why.
470
521
  */
471
522
  private personSyncContext;
523
+ /** Core's bookkeeping for an account write, the read of its roles included (#2018): the system context, saying what and who changed the account. */
524
+ private lifecycleContext;
472
525
  /**
473
526
  * Persist a Person record paired with a newly-created User. The install's
474
527
  * 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.
@@ -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';
@@ -0,0 +1,31 @@
1
+ /**
2
+ * KeyedSerial — one task at a time per key, within this process (#2015).
3
+ *
4
+ * A read-modify-write of one record (read the file, change it, write it back)
5
+ * is only safe while nothing else writes that record in between. Two role
6
+ * changes to one role used to interleave: each read the member list before the
7
+ * other wrote it, and the second write dropped the first one's member. Running
8
+ * every read-modify-write of a key through `run` makes them happen one after
9
+ * another, each reading what the last one wrote. Different keys still run side
10
+ * by side.
11
+ *
12
+ * What it does not do:
13
+ *
14
+ * - **Not across processes.** Two server processes sharing one data folder can
15
+ * still race; no supported deployment runs that way.
16
+ * - **Not re-entrant.** A task must not `run` the same key again and wait for
17
+ * it: the inner task queues behind the outer one, which never finishes.
18
+ *
19
+ * A failed task fails its own caller only; the next task on the key runs as
20
+ * usual. A key's entry is dropped once its queue is empty, so the map holds
21
+ * only keys with work in flight.
22
+ */
23
+ export declare class KeyedSerial {
24
+ /** The settled tail of each key's queue: never rejects. */
25
+ private readonly tails;
26
+ /** Run `task` after every earlier task on `key` has settled; resolves or rejects as `task` does. */
27
+ run<T>(key: string, task: () => Promise<T>): Promise<T>;
28
+ /** Resolves once every task queued so far, on every key, has settled (shutdown). */
29
+ settled(): Promise<void>;
30
+ }
31
+ export default KeyedSerial;
@@ -25,7 +25,6 @@
25
25
  * slice 4).
26
26
  */
27
27
  import { isJobContext } from '../context/ActorContext.js';
28
- import { toPermissionSubject } from '../context/JobContext.js';
29
28
  import { shareNamesVault } from '../types/Share.js';
30
29
  import { mixesProxyWithDelegation, proxyCarries } from './proxyClaim.js';
31
30
  export function mayActInPrivateContainer(ctx, owner, opts = {}) {
@@ -60,6 +59,5 @@ export function mayActInPrivateContainer(ctx, owner, opts = {}) {
60
59
  export async function permitsInVault(pdp, ctx, action, where) {
61
60
  if (!pdp || !mayActInPrivateContainer(ctx, where.owner, { vault: where.vault }))
62
61
  return false;
63
- const subject = isJobContext(ctx) ? toPermissionSubject(ctx) : ctx;
64
- return (await pdp.decide(subject, { action, resource: { type: 'page', id: where.resource }, attributes: { vault: where.vault } })).permit;
62
+ return (await pdp.decide(ctx, { action, resource: { type: 'page', id: where.resource }, attributes: { vault: where.vault } })).permit;
65
63
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * A role change that did not happen (#2015).
3
+ *
4
+ * Operator, 2026-10-23: adding or removing a role completes or it does not
5
+ * happen, everywhere, and one that cannot be made is always an error. So a
6
+ * request that asks for several role changes either gets all of them or none:
7
+ * RoleManager undoes the ones that landed before the failure, then throws
8
+ * this, and the request that asked fails with it.
9
+ *
10
+ * The message is shown to whoever asked (an administrator's form). It names
11
+ * the account, the change that failed and, when it is something an
12
+ * administrator can fix, the reason (no Person record, no anchor
13
+ * Organization). Storage detail (paths, errno) stays in `cause`, for the log.
14
+ */
15
+ export declare class RoleChangeError extends Error {
16
+ /** The change that could not be made, as `add <role>` / `remove <role>`. */
17
+ readonly failed: readonly string[];
18
+ /** Changes that landed, could not be undone, and so are still in effect. Empty when nothing was left changed. */
19
+ readonly notUndone: readonly string[];
20
+ constructor(subject: string, failed: readonly string[], options?: {
21
+ reason?: string;
22
+ cause?: unknown;
23
+ notUndone?: readonly string[];
24
+ });
25
+ }
@@ -12,10 +12,10 @@
12
12
  * had somewhere to go that is not a second evaluator: a role name skips
13
13
  * PolicyEvaluator, deny policies and the token ceiling; this does not.
14
14
  */
15
- import type { PermissionSubject } from '../managers/UserManager.js';
15
+ import type { ActorContext } from '../context/ActorContext.js';
16
16
  import type { CorePermission } from '../security/permissions.generated.js';
17
17
  type EngineLike = {
18
18
  getManager: (name: string) => unknown;
19
19
  } | null | undefined;
20
- export declare function subjectMayDo(engine: EngineLike, subject: PermissionSubject | null | undefined, action: CorePermission): Promise<boolean>;
20
+ export declare function subjectMayDo(engine: EngineLike, subject: ActorContext | null | undefined, action: CorePermission): Promise<boolean>;
21
21
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilleke/ngdpbase-types",
3
- "version": "5.27.0",
3
+ "version": "5.28.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",