@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.
@@ -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
  *
@@ -40,21 +40,34 @@ export declare class ApiError extends Error {
40
40
  * offer the way forward, as core routes do (`{ error, reauth }`).
41
41
  */
42
42
  readonly reauth?: string | undefined;
43
+ /**
44
+ * For a refusal because the user has documents to accept (#2027): this
45
+ * site's consent prompt, returning to the page they were on. Sent with
46
+ * the 403 as `consent`; a page is redirected there.
47
+ */
48
+ readonly consent?: string | undefined;
43
49
  constructor(status: number, message: string,
44
50
  /**
45
51
  * For a step-up refusal (#1745): where the person re-authenticates,
46
52
  * returning to the page they were on. Send it with the 403 so the page can
47
53
  * offer the way forward, as core routes do (`{ error, reauth }`).
48
54
  */
49
- reauth?: string | undefined);
55
+ reauth?: string | undefined,
56
+ /**
57
+ * For a refusal because the user has documents to accept (#2027): this
58
+ * site's consent prompt, returning to the page they were on. Sent with
59
+ * the 403 as `consent`; a page is redirected there.
60
+ */
61
+ consent?: string | undefined);
50
62
  }
51
63
  /**
52
64
  * Send a refusal — the one way it reaches the client, from core routes and
53
65
  * every add-on route alike (#1749). JSON is `{ success: false, error, reauth? }`;
54
66
  * text is the message, then where to sign in again for a step-up refusal.
55
- * `page` is for a link the person followed in the browser: a step-up refusal
56
- * redirects to the re-authenticate page, a 401 to sign-in (returning here),
57
- * anything else is the message as text. A route never writes its own refusal:
67
+ * `page` is for a link the user followed or a form they sent in the browser:
68
+ * a step-up refusal redirects to the re-authenticate page, a consent refusal
69
+ * to the consent prompt (#2027), a 401 to sign-in (returning here), anything
70
+ * else is the message as text. JSON carries the consent link as `consent`. A route never writes its own refusal:
58
71
  * the step-up `reauth` is easy to drop.
59
72
  */
60
73
  export declare function sendApiError(res: Response, err: ApiError, mode?: ReauthMode): void;
@@ -4,6 +4,7 @@
4
4
  import '../types/express.js';
5
5
  import { BaseContext } from './BaseContext.js';
6
6
  import { STEP_UP_REASON } from '../types/Policy.js';
7
+ import { CONSENT_PATH } from '../types/Consent.js';
7
8
  // ── ApiError ────────────────────────────────────────────────────────────────
8
9
  /**
9
10
  * Thrown by ApiContext guard methods (requirePermission, actingUsername).
@@ -12,16 +13,24 @@ import { STEP_UP_REASON } from '../types/Policy.js';
12
13
  export class ApiError extends Error {
13
14
  status;
14
15
  reauth;
16
+ consent;
15
17
  constructor(status, message,
16
18
  /**
17
19
  * For a step-up refusal (#1745): where the person re-authenticates,
18
20
  * returning to the page they were on. Send it with the 403 so the page can
19
21
  * offer the way forward, as core routes do (`{ error, reauth }`).
20
22
  */
21
- reauth) {
23
+ reauth,
24
+ /**
25
+ * For a refusal because the user has documents to accept (#2027): this
26
+ * site's consent prompt, returning to the page they were on. Sent with
27
+ * the 403 as `consent`; a page is redirected there.
28
+ */
29
+ consent) {
22
30
  super(message);
23
31
  this.status = status;
24
32
  this.reauth = reauth;
33
+ this.consent = consent;
25
34
  this.name = 'ApiError';
26
35
  }
27
36
  }
@@ -29,19 +38,26 @@ export class ApiError extends Error {
29
38
  * Send a refusal — the one way it reaches the client, from core routes and
30
39
  * every add-on route alike (#1749). JSON is `{ success: false, error, reauth? }`;
31
40
  * text is the message, then where to sign in again for a step-up refusal.
32
- * `page` is for a link the person followed in the browser: a step-up refusal
33
- * redirects to the re-authenticate page, a 401 to sign-in (returning here),
34
- * anything else is the message as text. A route never writes its own refusal:
41
+ * `page` is for a link the user followed or a form they sent in the browser:
42
+ * a step-up refusal redirects to the re-authenticate page, a consent refusal
43
+ * to the consent prompt (#2027), a 401 to sign-in (returning here), anything
44
+ * else is the message as text. JSON carries the consent link as `consent`. A route never writes its own refusal:
35
45
  * the step-up `reauth` is easy to drop.
36
46
  */
37
47
  export function sendApiError(res, err, mode = 'json') {
38
48
  // Only this site's re-authenticate page: an add-on's ApiError cannot send the person elsewhere.
39
49
  const reauth = typeof err.reauth === 'string' && err.reauth.startsWith('/auth/reauth?') ? err.reauth : undefined;
50
+ // Likewise only this site's consent prompt.
51
+ const consent = typeof err.consent === 'string' && (err.consent === CONSENT_PATH || err.consent.startsWith(`${CONSENT_PATH}?`)) ? err.consent : undefined;
40
52
  if (mode === 'page') {
41
53
  if (reauth) {
42
54
  res.redirect(reauth);
43
55
  return;
44
56
  }
57
+ if (consent) {
58
+ res.redirect(consent);
59
+ return;
60
+ }
45
61
  if (err.status === 401) {
46
62
  res.redirect('/login?redirect=' + encodeURIComponent(res.req?.originalUrl || '/'));
47
63
  return;
@@ -50,10 +66,10 @@ export function sendApiError(res, err, mode = 'json') {
50
66
  return;
51
67
  }
52
68
  if (mode === 'text') {
53
- res.status(err.status).send(reauth ? `${err.message}: ${reauth}` : err.message);
69
+ res.status(err.status).send(reauth ? `${err.message}: ${reauth}` : consent ? `${err.message} Read and accept them at ${consent}` : err.message);
54
70
  return;
55
71
  }
56
- res.status(err.status).json({ success: false, error: err.message, ...(reauth ? { reauth } : {}) });
72
+ res.status(err.status).json({ success: false, error: err.message, ...(reauth ? { reauth } : {}), ...(consent ? { consent } : {}) });
57
73
  }
58
74
  /**
59
75
  * The subject a request carries, refused when there is none (#1399).
@@ -43,6 +43,32 @@ export interface ChainSpec {
43
43
  * warnings first. Partial notes are collected into `partial`.
44
44
  */
45
45
  export declare function convertChain(shim: RegisteredShim, input: IceItem, chain: string[], spec: ChainSpec, partial: string[]): Promise<ChainLeaf[]>;
46
+ /** Does this run keep its originals during the upload (#2034)? */
47
+ export declare function keepsAtUpload(receiver: RegisteredReceiver | undefined, original: OriginalChoice): boolean;
48
+ /** What keeping at upload did: how many it kept, or the first file it could not keep. */
49
+ export interface UploadKeepResult {
50
+ kept: number;
51
+ refused?: {
52
+ name: string;
53
+ message: string;
54
+ };
55
+ }
56
+ /**
57
+ * Keep the originals of a run whose receiver keeps into an encrypted store,
58
+ * now, during the upload (#2034). `ctx` is the REQUEST's context: it holds
59
+ * the user's unlocked key, so the receiver's `keepOriginal` seals the bytes
60
+ * and the store's sealed index entry while the key is live. Each kept
61
+ * original is recorded in the ledger by fingerprint and door (#2011), so the
62
+ * queued run reuses it and never keeps again.
63
+ *
64
+ * The files are decided exactly as the run decides them ({@link planFile}):
65
+ * noise, a file too large, one nothing reads and one already imported are
66
+ * not kept. An original already kept through this door is not kept again.
67
+ * Stops at the first refusal (or failure) and says which file: the caller
68
+ * then queues nothing. Originals kept before it stay kept, and recorded, so
69
+ * the next try reuses them.
70
+ */
71
+ export declare function keepOriginalsAtUpload(deps: IceRunDeps, request: IceRunRequest, ctx: ActorContext, scope: string): Promise<UploadKeepResult>;
46
72
  /**
47
73
  * Run one import. Fills and returns `report`, which the caller may be
48
74
  * showing while it runs. Never throws for one file's failure; that file's
@@ -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,15 @@ 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
+ /**
186
+ * `keepOriginal` writes into an encrypted store (phr's vault), which only a
187
+ * request holding the user's unlocked key can write (#2034). The engine then
188
+ * keeps each original during the upload, with the request's own context,
189
+ * before the run is queued; the run reuses what was kept and needs no key.
190
+ */
191
+ keepsEncrypted?: boolean;
192
+ /** The original-file choices this receiver offers (D11). Without it: all three. */
193
+ originals?: IceOriginalChoices;
175
194
  }
176
195
  export interface RegisteredReceiver extends IceReceiver {
177
196
  id: string;
@@ -238,6 +257,14 @@ export interface IceRunReport {
238
257
  error?: string;
239
258
  files: IceFileReport[];
240
259
  }
260
+ /**
261
+ * A kept original as the ledger remembers it, with the door that kept it
262
+ * (a receiver's own `keepOriginal`, or `core.files`). A later run of the same
263
+ * fingerprint through the same door reuses it instead of keeping another copy.
264
+ */
265
+ export interface LedgerOriginal extends KeptOriginal {
266
+ door: string;
267
+ }
241
268
  /** What the engine remembers about one file in a scope. */
242
269
  export interface LedgerFileEntry {
243
270
  name: string;
@@ -247,6 +274,8 @@ export interface LedgerFileEntry {
247
274
  message?: string;
248
275
  at: string;
249
276
  refs: string[];
277
+ /** The original kept for this fingerprint, whatever became of the conversion (D11). */
278
+ original?: LedgerOriginal;
250
279
  }
251
280
  /**
252
281
  * 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}`);
@@ -32,7 +32,7 @@ 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
34
  import { type JobContext } from '../context/JobContext.js';
35
- import type { IceLedger, IceReceiver, IceRunReport, IceRunRequest, IceShim, RegisteredShim } from '../ice/types.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
  */
@@ -207,6 +207,16 @@ export declare class TakeoutImportRefused extends Error {
207
207
  readonly reason: 'not-owner' | 'no-such-store' | 'locked' | 'unreadable';
208
208
  constructor(reason: 'not-owner' | 'no-such-store' | 'locked' | 'unreadable', message: string);
209
209
  }
210
+ /**
211
+ * An import refused at upload, before anything was queued (#2034): keeping
212
+ * the original of `file` in the receiver's encrypted store was refused or
213
+ * failed. `message` is written for the user who uploaded.
214
+ */
215
+ export declare class ImportUploadRefused extends Error {
216
+ readonly file: string;
217
+ readonly reason: string;
218
+ constructor(file: string, reason: string);
219
+ }
210
220
  /** One page's outcome in a takeout import (#1472). */
211
221
  export type TakeoutPageOutcome = {
212
222
  title: string;
@@ -253,6 +263,12 @@ export type TakeoutImportReport = {
253
263
  */
254
264
  reportPage?: string;
255
265
  };
266
+ /** A receiver the import screen may offer this user (#2021): its id, label and original-file choices (D11). */
267
+ export interface UsableReceiver {
268
+ id: string;
269
+ label?: string;
270
+ originals: Required<IceOriginalChoices>;
271
+ }
256
272
  /**
257
273
  * Import Manager class
258
274
  *
@@ -310,11 +326,24 @@ declare class ImportManager extends BaseManager {
310
326
  listShims(): Array<Pick<RegisteredShim, 'id' | 'owner' | 'label' | 'direction' | 'produces' | 'receivers'> & {
311
327
  enabled: boolean;
312
328
  }>;
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
- }>>;
329
+ /**
330
+ * The receivers this context may use (D5): each answers with its own
331
+ * existing permission. Each comes with the original-file choices it offers
332
+ * (D11), for the import screen (#2021).
333
+ */
334
+ receiversFor(ctx: ActorContext): Promise<UsableReceiver[]>;
335
+ /**
336
+ * The original-file choices a receiver offers (D11, #2021): what it
337
+ * declares, else all three; its default when it names one it allows, else
338
+ * `ngdpbase.import.engine.default-original` when allowed, else the first.
339
+ */
340
+ private originalChoices;
341
+ /**
342
+ * The request as the engine runs it: the receiver's default original when
343
+ * none was chosen, and a refusal for a choice the receiver does not offer
344
+ * (D11, #2021). An unknown receiver is left for the run to report.
345
+ */
346
+ private withOriginal;
318
347
  /**
319
348
  * Run one import now and wait for it. For code already off the request
320
349
  * path: an add-on's own background job, a test. A route uses
@@ -325,17 +354,37 @@ declare class ImportManager extends BaseManager {
325
354
  runImport(request: IceRunRequest, ctx: ActorContext): Promise<IceRunReport>;
326
355
  /**
327
356
  * Queue an import as background work and return its run id at once. The
328
- * run carries `ctx` — the person who asked (`jobContextFromRequest`), or a
357
+ * run carries `ctx` — the user who asked (`jobContextFromRequest`), or a
329
358
  * job's context with its reason — and each receiver checks its own
330
- * permission with it when the run reaches it (D5).
359
+ * permission with it when the run reaches it (D5). A user's upload, from
360
+ * a request, goes through {@link enqueueUpload} instead.
331
361
  */
332
362
  enqueueImport(request: IceRunRequest, ctx: JobContext): Promise<string>;
363
+ /**
364
+ * Queue a user's upload, from the request it arrived in (#2034). `ctx` is
365
+ * the REQUEST's context; the run carries the job context derived from it
366
+ * (`jobContextFromRequest`), as {@link enqueueImport} does.
367
+ *
368
+ * When the run keeps originals through a receiver that keeps into an
369
+ * encrypted store (`keepsEncrypted`), they are kept first, here, with
370
+ * `ctx`, while the user's key is in the request: the bytes are sealed and
371
+ * the store's sealed index entry written. Each is recorded in the ledger
372
+ * as the keep-once record (#2011), so the run reuses it and needs no key.
373
+ *
374
+ * @throws ImportUploadRefused when an original could not be kept; nothing is queued
375
+ */
376
+ enqueueUpload(request: IceRunRequest, ctx: ActorContext): Promise<string>;
377
+ private enqueueResolved;
333
378
  /** A run's report, to the person it runs as; null to anyone else. */
334
379
  getImportRun(runId: string, ctx: ActorContext): IceRunReport | null;
380
+ /** The runs this process holds for the user `ctx` names, newest first (#2021): their own, never anyone else's. */
381
+ importRunsFor(ctx: ActorContext): IceRunReport[];
335
382
  /** Use this ledger instead of the file one: a test's, or a database table's. */
336
383
  setImportLedger(ledger: IceLedger): void;
337
384
  private newRunReport;
338
385
  private executeRun;
386
+ /** What a run, or keeping at upload, works with. */
387
+ private iceDeps;
339
388
  private enabledShims;
340
389
  /**
341
390
  * Hand the queue to the background job. One job run drains every queued
@@ -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)` returns a user's identity record and
41
+ * makes it when the user has none (#2037): every user has exactly one.
37
42
  */
38
43
  declare class PersonManager extends BaseManager {
39
44
  readonly description = "Canonical Person records (#617)";
@@ -46,6 +51,8 @@ declare class PersonManager extends BaseManager {
46
51
  private metricsRef;
47
52
  /** Sign-up link offers by token (#1930). In memory: a restart drops them, and an administrator links by hand. */
48
53
  private linkOffers;
54
+ /** #2037: making a user's missing identity record, one at a time per username. */
55
+ private readonly accountRecords;
49
56
  constructor(engine: WikiEngine);
50
57
  initialize(config?: Record<string, unknown>): Promise<void>;
51
58
  /** `ngdpbase.application.persons.storagedir`, resolved: where the records are. */
@@ -56,15 +63,26 @@ declare class PersonManager extends BaseManager {
56
63
  */
57
64
  backupFolders(): BackupFolder[];
58
65
  getProvider(): PersonProvider | null;
59
- getById(id: string): Promise<Person | null>;
60
- getByIdentifier(identifier: string): Promise<Person | null>;
66
+ /**
67
+ * One identity record by `@id` (#2018): the caller's own under
68
+ * `profile-manage`, anyone else's under `contact-read`, core's own work
69
+ * under the system context. A refusal throws `ContactRefusedError`.
70
+ */
71
+ getById(ctx: ActorContext, id: string): Promise<Person | null>;
72
+ /**
73
+ * The identity record whose `identifier` is `identifier` (a username, or a
74
+ * contact's `person:<uuid>`), decided as {@link getById} before it is read.
75
+ */
76
+ getByIdentifier(ctx: ActorContext, identifier: string): Promise<Person | null>;
77
+ private cachedById;
78
+ private cachedByIdentifier;
61
79
  /** #1006: how many people this instance holds. Count only, never records. */
62
80
  getManagerStats(): Promise<ManagerStats>;
63
81
  /** True when the person has no account: its identifier is `person:<uuid>` (#1930). */
64
82
  static isAccountless(person: Pick<Person, 'identifier'>): boolean;
65
83
  /** Every person, for a caller who may read contacts (`contact-read`). */
66
84
  list(ctx: ActorContext): Promise<Person[]>;
67
- /** One person by `@id`, for a caller who may read contacts (`contact-read`). */
85
+ /** One person by `@id`: {@link getById}, the caller's own under `profile-manage`, anyone else's under `contact-read`. */
68
86
  get(ctx: ActorContext, id: string): Promise<Person | null>;
69
87
  /**
70
88
  * Persons whose name (`name`, `givenName`, `familyName`) or `email` contains
@@ -82,7 +100,11 @@ declare class PersonManager extends BaseManager {
82
100
  * person yet: that is how UserManager makes the person for a new account.
83
101
  */
84
102
  create(ctx: ActorContext, input: PersonInput): Promise<Person>;
85
- /** Change a person's fields, affiliations included. `@id` and `identifier` never change here. */
103
+ /**
104
+ * Change a person's fields, affiliations included. `@id` and `identifier`
105
+ * never change here. The caller's own identity record under
106
+ * `profile-manage`, anyone else's under `contact-manage` (#2018).
107
+ */
86
108
  update(ctx: ActorContext, id: string, patch: PersonUpdate): Promise<Person | null>;
87
109
  delete(ctx: ActorContext, id: string): Promise<boolean>;
88
110
  /**
@@ -123,7 +145,10 @@ declare class PersonManager extends BaseManager {
123
145
  * to it, and that person is removed. Never a second person.
124
146
  */
125
147
  private join;
126
- /** Each affiliation names an organization that exists; `role` is trimmed text. Throws on any other shape. */
148
+ /**
149
+ * Each affiliation names an organization that exists, looked up with the
150
+ * caller's context; `role` is trimmed text. Throws on any other shape.
151
+ */
127
152
  private checkedAffiliations;
128
153
  private configNumber;
129
154
  /**
@@ -132,13 +157,22 @@ declare class PersonManager extends BaseManager {
132
157
  */
133
158
  invalidateCache(): void;
134
159
  /**
135
- * Lazy-migration entry point.
160
+ * The identity record of the user `username`, made when the user has none
161
+ * (#2037). Every user has exactly one identity record; one made outside
162
+ * `UserManager.createUser` (an account written straight into the user
163
+ * store) gets it here, from the account's display name and email.
136
164
  *
137
- * Returns the Person paired to `username`. If none exists and a matching
138
- * User does, a Person is created from the User's email/displayName and
139
- * persisted. Subsequent calls return the persisted record.
165
+ * The read is decided as {@link getByIdentifier} (#2018): a signed-in user's
166
+ * own under `profile-manage`, anyone else's under `contact-read`, core's own
167
+ * work under the system context. Making the missing record is core's
168
+ * bookkeeping, not the caller's contact management, so it goes through
169
+ * {@link create} under the system context, naming the caller, and is
170
+ * recorded as `contact-manage`. One at a time per username, so two lookups
171
+ * never make two records. Null when there is no account by that name.
172
+ * Never a second record: a contact with the same email is left alone
173
+ * (an administrator links it, {@link linkAccount}).
140
174
  */
141
- getByUserIdentifier(username: string): Promise<Person | null>;
175
+ getByUserIdentifier(ctx: ActorContext, username: string): Promise<Person | null>;
142
176
  private requireProvider;
143
177
  private getMetrics;
144
178
  private normalizeProviderName;