@jwilleke/ngdpbase-types 5.34.0 → 5.36.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.
@@ -1,5 +1,13 @@
1
1
  import type { IceLedger, LedgerFileEntry } from './types.js';
2
+ /**
3
+ * The ledger file form (#2013). A scope is bound to its receiver and user
4
+ * (`boundScope`); a file without this form was written before that binding,
5
+ * under a scope any caller could name, so it belongs to nobody: it is read
6
+ * as empty, never as anyone's history, and its files are imported once more.
7
+ */
8
+ export declare const LEDGER_FORM = 2;
2
9
  interface ScopeFile {
10
+ form?: number;
3
11
  scope: string;
4
12
  files: LedgerFileEntry[];
5
13
  items: string[];
@@ -21,8 +21,24 @@ export interface IceRunDeps {
21
21
  }
22
22
  /** The id of the receiver that keeps an original when the chosen receiver does not keep its own (D11). */
23
23
  export declare const FILES_RECEIVER = "core.files";
24
- /** The scope a run's fingerprints are checked in, unless the caller names one. */
25
- export declare function defaultScope(receiverId: string, ctx: ActorContext): string;
24
+ /**
25
+ * The engine's key for an item of a file it stored, so a retry of that file
26
+ * skips it (#2010): the file's fingerprint, the chain, and a hash of the
27
+ * item's content (kind, text, data, bytes). Never its position: a shim that
28
+ * changes or orders its output differently cannot make a retry skip the
29
+ * wrong item. `seen` is how many identical items came before it in this file.
30
+ */
31
+ export declare function itemKeyOf(sha256: string, chain: string[], item: IceItem, seen: number): string;
32
+ /** The form of a bound scope (#2013). Scopes written before it never match one. */
33
+ export declare const SCOPE_FORM = "ice2";
34
+ /**
35
+ * The scope a run's fingerprints, per-file status and item keys live in
36
+ * (#2013): bound to the receiver and to the user the run is for, by their
37
+ * identity `@id` (else their username, #2041). The engine works it out from
38
+ * the caller's context; a request cannot name another user's. A caller's own
39
+ * `sub` (phr's source id) narrows it within that user's scope, never outside.
40
+ */
41
+ export declare function boundScope(receiverId: string, owner: string, sub?: string): string;
26
42
  export interface ChainLeaf {
27
43
  item: IceItem;
28
44
  chain: string[];
@@ -201,7 +201,13 @@ export interface IceReceiver {
201
201
  label?: string;
202
202
  /** The kinds it takes, e.g. `['ncm']`, `['file']`, `['phr.health-record']`. */
203
203
  accepts: IceKind[];
204
- /** May this context use this receiver? Its existing permission — the import screen shows only these (D5). */
204
+ /**
205
+ * May this context use this receiver? Its existing permission (D5). The
206
+ * import screen shows only these, and the engine asks it before any work
207
+ * of a run (#2012): a no refuses every file before anything is unpacked,
208
+ * read by a shim, kept or fetched. Answer no whenever `receive` would
209
+ * refuse this context outright; `receive` still checks each item.
210
+ */
205
211
  mayUse(ctx: ActorContext): Promise<boolean>;
206
212
  /** Hand one item to the receiver's own door. */
207
213
  receive(item: IceItem, ctx: ActorContext, info: ReceiveInfo): Promise<ReceiveOutcome>;
@@ -252,10 +258,13 @@ export interface IceRunRequest {
252
258
  /** D11. Default `ngdpbase.import.engine.default-original`. */
253
259
  original?: OriginalChoice;
254
260
  /**
255
- * Whose fingerprints and item keys this run is checked against. Default
256
- * `<receiver>/<username>`: the same file imported again by the same person
257
- * into the same receiver is skipped. A caller with its own notion of a
258
- * source (phr's source id) passes it.
261
+ * A narrower ledger scope within the caller's own (#2013). The engine binds
262
+ * every run's scope to the receiver and to the user it runs as (their
263
+ * identity `@id`, else their username): the same file imported again by
264
+ * the same user into the same receiver is skipped. A caller with its own
265
+ * notion of a source (phr's source id) passes it to keep those apart; it
266
+ * can never reach another user's or another receiver's scope. Never pass a
267
+ * value a client sent.
259
268
  */
260
269
  scope?: string;
261
270
  }
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { type ActorContext } from '../context/ActorContext.js';
14
14
  import type { BackupFolder } from './BaseManager.js';
15
+ import { type UpdateCheck, type UpdateOutcome } from '../utils/addonInstaller.js';
15
16
  import BaseManager from './BaseManager.js';
16
17
  import type { BackupData } from './BaseManager.js';
17
18
  import type { WikiEngine } from '../types/WikiEngine.js';
@@ -302,6 +303,9 @@ export interface AddonDashboardCard {
302
303
  /** URL of the add-on's admin page */
303
304
  adminUrl: string;
304
305
  }
306
+ /** #2100: the PDP refused an administrator's add-on Update or Check. */
307
+ export declare class AddonUpdateForbiddenError extends Error {
308
+ }
305
309
  /**
306
310
  * Status information returned by getStatus()
307
311
  */
@@ -344,6 +348,10 @@ declare class AddonsManager extends BaseManager {
344
348
  private installedDir;
345
349
  /** #1830: the installed add-ons verified this boot — the only ones loaded from `installedDir`. */
346
350
  private verifiedInstalled;
351
+ /** #2100: installed add-ons this start switched to an Update's release; settled after the load pass. */
352
+ private updatedThisStart;
353
+ /** #2100: Updates run one at a time, so two never write the lock file at once. */
354
+ private updateQueue;
347
355
  /** Stylesheets registered by add-ons via registerStylesheet() */
348
356
  private registeredStylesheets;
349
357
  /** Dashboard cards registered by add-ons via registerDashboardCard() */
@@ -374,6 +382,14 @@ declare class AddonsManager extends BaseManager {
374
382
  * this boot, not the next.
375
383
  */
376
384
  private installConfiguredAddons;
385
+ /**
386
+ * The installer's options for this site (#1830, #2100): the registry and the
387
+ * limits from configuration, and every request — registry, attestations, the
388
+ * Sigstore TUF repository — through the guarded client and the egress policy
389
+ * (#1139). `timeoutKey` is the `…install.<key>` that bounds each request: the
390
+ * boot install's, or the shorter one an admin page waits on.
391
+ */
392
+ private installerOptions;
377
393
  /**
378
394
  * `addon-install` (#1830): written after every check passed and before
379
395
  * anything is written to disk. on-failure: refuse — code does not join the
@@ -381,6 +397,47 @@ declare class AddonsManager extends BaseManager {
381
397
  * (recordSystemAction throws, and the installer writes nothing).
382
398
  */
383
399
  private recordAddonInstall;
400
+ /**
401
+ * #2100: after the load pass, settle each add-on this start switched to an
402
+ * Update's release. Loaded (or switched off, so there was nothing to try):
403
+ * the previous release's folder and tarball go. Not loaded: the previous
404
+ * release is restored on disk and in the lock, so the next start runs it.
405
+ * Core cannot unload an add-on that failed half-way (its modules, routes and
406
+ * jobs stay), so the restored release is not loaded in this process.
407
+ */
408
+ private settleUpdatedAddons;
409
+ /**
410
+ * `addon-update` (#2100) for a fall-back: core moved an installed add-on back
411
+ * from `from` to `to` because `from` did not load. on-failure: continue for
412
+ * this record — the fall-back has already happened on disk, and a lost
413
+ * record must not undo it.
414
+ */
415
+ private recordFallback;
416
+ /** #2100: the door for the admin's add-on questions. Throws when the PDP refuses. */
417
+ private requireAddonPermission;
418
+ /**
419
+ * #2100: for each add-on in `ngdpbase.addons.install`, the pinned release,
420
+ * the configured range, and the newest release in it that is newer — asked
421
+ * of the registry now. Called when an administrator opens the add-ons page
422
+ * or presses Check; never at start. Registry unreachable is an answer
423
+ * (`registryUnreachable`), not an error.
424
+ */
425
+ checkInstalledUpdates(ctx: ActorContext): Promise<UpdateCheck[]>;
426
+ /**
427
+ * #2100: an administrator's Update. Downloads `version` of the installed
428
+ * add-on `slug`, checks it exactly as a first install is checked (sha512,
429
+ * provenance, contents, `ngdpbase.minVersion`) and stages it: it runs from
430
+ * the next start. Asks the PDP for `config-manage`. Recorded as
431
+ * `addon-update` (who, the add-on, from and to) before anything is written;
432
+ * a record that cannot be written stops the Update.
433
+ */
434
+ updateInstalledAddon(ctx: ActorContext, slug: string, version: string): Promise<UpdateOutcome>;
435
+ /**
436
+ * `addon-update` (#2100): an administrator's Update, written after every
437
+ * check passed and before anything is written to disk. on-failure: refuse —
438
+ * a record that cannot be written stops the Update.
439
+ */
440
+ private recordAddonUpdate;
384
441
  /**
385
442
  * `addon-verify-failed` (#1830): an integrity, provenance, tarball, manifest,
386
443
  * lock or folder check refused (or, for a repaired folder, found it changed).
@@ -320,6 +320,33 @@ declare class AttachmentManager extends BaseManager implements CatalogSource {
320
320
  * with no person behind it are refused. A refusal reads 'Permission denied'
321
321
  * so the route answers it as one (401 for an anonymous caller).
322
322
  */
323
+ /**
324
+ * Once, at start-up (#2097): the shared pool's uploaders and editors become
325
+ * identity @ids (`IdentityManager.convertRecordedUser`: an orphan for a
326
+ * name with no account, `orphan:unknown` for the pool's `Unknown`), written
327
+ * once through the provider, under the system context. A second start
328
+ * writes nothing. Counts only in the log; orphans on the dashboard. When the
329
+ * identity store cannot answer, nothing is written and the next start tries
330
+ * again. A vault's files convert when the vault is opened
331
+ * ({@link convertVaultUploaders}).
332
+ */
333
+ convertUploadersAtBoot(): Promise<void>;
334
+ /**
335
+ * The uploaders in the vaults of the context's own user that it can open
336
+ * (#2097), when they are opened: at sign-in and unlock. Each vault's files
337
+ * index, through its own I/O. Nothing is written when the identity store
338
+ * cannot answer; the next opening tries again.
339
+ */
340
+ convertVaultUploaders(ctx: ActorContext): Promise<number>;
341
+ /** Counts only, never a name (#2097). */
342
+ private reportUploaderConversion;
343
+ /** The identity store could not answer: nothing is converted this start, and the next start tries again (#2097). */
344
+ private postponeUploaderConversion;
345
+ /**
346
+ * What a file record stores for the user `username` (#2097): their identity
347
+ * @id, or the username when there is none or the identity store cannot answer.
348
+ */
349
+ private userReference;
323
350
  private static uploaderOf;
324
351
  /** A file is added to a private page only by someone who may edit it: the page's own decision (#1398). */
325
352
  private assertMayEditPage;
@@ -309,6 +309,83 @@ declare class IdentityManager extends BaseManager {
309
309
  * postpones the whole pass when it is false.
310
310
  */
311
311
  canAnswer(): boolean;
312
+ /**
313
+ * {@link convertReference} for records that may hold a value meaning "no
314
+ * user was recorded" (#2097): `migrated`, `unknown`, `import`
315
+ * ({@link NO_RECORDED_USER}) become `orphan:<value>` without a lookup,
316
+ * outcome `no-user`, so an account that later takes one of those names
317
+ * gains nothing, and the caller does not count them as needing attention.
318
+ * Everything else is `convertReference`'s, and throws as it does.
319
+ */
320
+ convertRecordedUser(ctx: ActorContext, stored: string): Promise<{
321
+ value: string;
322
+ outcome: 'kept' | 'orphan' | 'converted' | 'orphaned' | 'no-user';
323
+ }>;
324
+ /**
325
+ * What a new record stores for the user `username` (#2097): their identity
326
+ * `@id`; a "no user recorded" value (`migrated`, `unknown`, `import`) as
327
+ * its orphan form, so no account ever gains it; a principal (`anonymous`,
328
+ * the system principal), an `@id` or an orphan as it is; a name with no
329
+ * account, or any name when the store cannot answer ({@link identityIdOf}),
330
+ * as given. The one rule for page authors and editors, tombstones and file
331
+ * uploaders.
332
+ */
333
+ storedReferenceOf(ctx: ActorContext, username: string): Promise<string>;
334
+ /**
335
+ * One start-up conversion pass over stored references (#2097): `convert`
336
+ * is {@link convertRecordedUser}'s value, and `counts` tallies its outcomes
337
+ * (values converted to an @id, orphans found or made, "no user recorded"
338
+ * values) for the caller's counts-only report. When the store cannot
339
+ * answer it throws `IdentityStoreUnavailableError`, so the caller postpones
340
+ * its pass rather than skip a record.
341
+ */
342
+ conversionPass(ctx: ActorContext): {
343
+ convert: (stored: string) => Promise<string>;
344
+ counts: {
345
+ converted: number;
346
+ orphans: number;
347
+ noUser: number;
348
+ };
349
+ };
350
+ /**
351
+ * Whether a stored reference names the user `username` (#2097): it is their
352
+ * identity `@id`, or, on a record not yet converted, their username. An
353
+ * orphan (`orphan:<name>`) and a "no user recorded" value never match
354
+ * anyone, so a later account with the same name inherits nothing. The `@id`
355
+ * is looked up with {@link identityIdOf}: false when the store cannot answer.
356
+ */
357
+ refersTo(ctx: ActorContext, stored: unknown, username: string | null | undefined): Promise<boolean>;
358
+ /**
359
+ * The stored values that name the user `username` (#2097): their identity
360
+ * `@id` and, for records not yet converted, their username — the batch form
361
+ * of {@link refersTo}, for a list filter that compares many records (the
362
+ * page index, the search index). A "no user recorded" name is left out, and
363
+ * no orphan is ever in it. Only the username when the store cannot answer.
364
+ */
365
+ referencesOf(ctx: ActorContext, username: string | null | undefined): Promise<string[]>;
366
+ /**
367
+ * The username a stored reference names (#2097), for code that needs the
368
+ * sign-in name (a vault path, a preference): an `@id` gives its account's
369
+ * username, an orphan gives null, anything else (a username not yet
370
+ * converted, `anonymous`, the system principal) is returned as it is. Null
371
+ * for an `@id` with no account (deleted, or a contact's) and when the store
372
+ * cannot answer, logged without the value; a refusal still throws.
373
+ */
374
+ usernameOf(ctx: ActorContext, stored: unknown): Promise<string | null>;
375
+ /**
376
+ * How a stored reference to a user is shown to people (#2097, operator
377
+ * 2026-10-26): the username followed by the id, `jim (urn:uuid:3f6c…)`. An
378
+ * orphan shows as `jim (no account)`, an `@id` whose account is gone as
379
+ * `urn:uuid:… (no account)`; anything else (a username not yet converted,
380
+ * `anonymous`, the system principal) as it is. Resolved when shown, never
381
+ * stored. Never throws for a store that is down.
382
+ *
383
+ * `ctx` is whoever the name is shown to. Showing who wrote a page needs no
384
+ * `identity-read`: only the username and the id are shown, as a page always
385
+ * showed its author's username, so the record is read as core's own lookup
386
+ * (the system context, naming the caller in its reason).
387
+ */
388
+ describeReference(ctx: ActorContext, stored: unknown): Promise<string>;
312
389
  private requireProvider;
313
390
  private getMetrics;
314
391
  private normalizeProviderName;
@@ -382,6 +382,18 @@ declare class ImportManager extends BaseManager {
382
382
  importRunsFor(ctx: ActorContext): IceRunReport[];
383
383
  /** Use this ledger instead of the file one: a test's, or a database table's. */
384
384
  setImportLedger(ledger: IceLedger): void;
385
+ /**
386
+ * The ledger scope a run uses (#2013): bound to the receiver and to the user
387
+ * `ctx` names, by their identity `@id`; their username only when the
388
+ * identity store answers that there is no record for them (#2041). Never a
389
+ * scope the caller names: a request's `scope` only narrows within this
390
+ * user's own.
391
+ *
392
+ * @throws IdentityStoreUnavailableError when the identity store cannot
393
+ * answer: the run does not start, and nothing is recorded, rather than
394
+ * land in a scope the user's other runs do not use
395
+ */
396
+ private importScope;
385
397
  private newRunReport;
386
398
  private executeRun;
387
399
  /** What a run, or keeping at upload, works with. */
@@ -258,6 +258,31 @@ export declare const CAPTURE_VAULT_MIGRATION = "capture-vault";
258
258
  export declare const CATEGORY_VAULT_MIGRATION = "category-vaults";
259
259
  /** Private slugs from `private--…` to `vaults--…` (#1507). */
260
260
  export declare const VAULT_SLUG_MIGRATION = "vault-slugs";
261
+ /** Page authorship stored as identity @ids (#2097): every page and its history visited once; recorded per site and per vault. */
262
+ export declare const AUTHORSHIP_ID_MIGRATION = "authorship-identity-ids";
263
+ /** What one authorship conversion pass did (#2097), counts only. */
264
+ export interface AuthorshipConversion {
265
+ /** `done`, or `postponed` when the identity store could not answer. */
266
+ status: 'done' | 'postponed';
267
+ /** Every page visited (`full`), or only those whose index entry still names a username. */
268
+ full: boolean;
269
+ pagesVisited: number;
270
+ pagesChanged: number;
271
+ /** Pages whose frontmatter could not be rewritten line by line, left as they are. */
272
+ pagesRefused: number;
273
+ versionsChanged: number;
274
+ tombstonesChanged: number;
275
+ converted: number;
276
+ orphans: number;
277
+ /** Orphans on shipped (documentation, system) and add-on pages: their author is upstream's, not a site user; not needing attention. */
278
+ shippedOrphans: number;
279
+ noUser: number;
280
+ /** Pages and tombstones skipped for their own reason (a file that cannot be read or written, a full disk, a vault that cannot be opened). */
281
+ pagesSkipped: number;
282
+ /** The error class of each skip (`EACCES`, `ENOSPC`, ...), never a message, name or path. */
283
+ skipErrors: string[];
284
+ ms: number;
285
+ }
261
286
  /**
262
287
  * Minimal WikiContext interface for type safety
263
288
  * TODO: Convert WikiContext.js to TypeScript and import proper type
@@ -377,6 +402,15 @@ declare class PageManager extends BaseManager implements CatalogSource {
377
402
  * #754 backfill (v3.33.0).
378
403
  */
379
404
  readonly types: readonly SchemaType[];
405
+ /** One write at a time per page, by uuid (#2097), when the provider keeps no such lock itself. */
406
+ private readonly pageWrites;
407
+ /**
408
+ * Run `task` as the only write on page `key` (#2097): saves, deletes, the
409
+ * authorship conversion, and the provider's own restores and purges. The
410
+ * lock is the provider's (`withPageWrite`), so its trash work takes the same
411
+ * one; a provider without it gets this manager's.
412
+ */
413
+ private pageWrite;
380
414
  /**
381
415
  * On-disk schema version for the page-frontmatter shape (Decision 6).
382
416
  * Bump when the persisted Article-relevant fields on a page change. v1 is
@@ -867,6 +901,12 @@ declare class PageManager extends BaseManager implements CatalogSource {
867
901
  * @throws PageContentValidationError when a filter reports severity 'error'
868
902
  */
869
903
  private assertContentPasses;
904
+ /**
905
+ * What a page stores for the user `name` (#2097): `IdentityManager.storedReferenceOf`.
906
+ */
907
+ private referenceTo;
908
+ /** `audience` and each `access[action]` of a save, user entries as {@link referenceTo} stores them (#2097). */
909
+ private convertAccessListsForSave;
870
910
  /**
871
911
  * The frontmatter a save writes, from the frontmatter it was given (#1462).
872
912
  *
@@ -1415,6 +1455,12 @@ declare class PageManager extends BaseManager implements CatalogSource {
1415
1455
  * feed. New code should prefer this over enumerating getAllPages().
1416
1456
  */
1417
1457
  getRecentChanges(ctx: ActorContext, options?: RecentChangesOptions): Promise<RecentChangeEntry[]>;
1458
+ /**
1459
+ * A principal list (roles and the username) with the user's identity @id
1460
+ * added (#2097), so a listing matches an access-list entry stored as the
1461
+ * @id as the page view does (`PolicyInformationPoint` principals).
1462
+ */
1463
+ private principalsWithIdentityId;
1418
1464
  /**
1419
1465
  * Every indexed page carrying an `addon` provenance stamp.
1420
1466
  * Used by addon orphan detection; reads the in-memory index, no disk I/O.
@@ -1440,15 +1486,25 @@ declare class PageManager extends BaseManager implements CatalogSource {
1440
1486
  * `creator` only.
1441
1487
  */
1442
1488
  getPagesByCreator(username: string, ctx: ActorContext, options?: GetPagesByCreatorOptions): Promise<RecentChangeEntry[]>;
1489
+ /**
1490
+ * The stored values that name `username` (#2097): their identity @id and, for
1491
+ * pages not yet converted, the username (`IdentityManager.referencesOf`), so
1492
+ * "my pages" lists converted and unconverted pages alike and an orphan never.
1493
+ */
1494
+ private referencesOf;
1443
1495
  /**
1444
1496
  * Pages most recently edited by a user (#640 Phase 2).
1445
1497
  */
1446
1498
  getPagesByEditor(username: string, ctx: ActorContext, options?: PagesScanOptions): Promise<RecentChangeEntry[]>;
1447
1499
  /**
1448
1500
  * Pages whose frontmatter audience matches any of the caller's principals,
1449
- * excluding pages the caller already owns (#640 Phase 2).
1501
+ * excluding pages the caller already owns (#640 Phase 2). `options.username`
1502
+ * names the caller, so a page whose author is their identity @id counts as
1503
+ * theirs (#2097).
1450
1504
  */
1451
- getPagesSharedWith(principals: string[], options?: PagesScanOptions): Promise<RecentChangeEntry[]>;
1505
+ getPagesSharedWith(principals: string[], options?: PagesScanOptions & {
1506
+ username?: string;
1507
+ }): Promise<RecentChangeEntry[]>;
1452
1508
  /**
1453
1509
  * The private-container decision for a page (docs/private-stores.md,
1454
1510
  * Access). PolicyInformationPoint's Tier 0 asks this, so every `canAccess` on a page —
@@ -1538,6 +1594,74 @@ declare class PageManager extends BaseManager implements CatalogSource {
1538
1594
  migrateVaultUrlsInPublicPages(ctx: ActorContext): Promise<number>;
1539
1595
  /** Every vault on disk, as `owner/store` — encrypted ones included, since only their folder names are read. */
1540
1596
  private listVaults;
1597
+ /**
1598
+ * The page-authorship conversion at start-up (#2097), on the public pages:
1599
+ * best-effort, never throws, and runs while the site serves (WikiEngine does
1600
+ * not wait for it). See {@link convertAuthorship}.
1601
+ */
1602
+ convertAuthorshipAtBoot(): Promise<AuthorshipConversion | null>;
1603
+ /**
1604
+ * Convert the public pages' authorship to identity @ids (#2097, operator
1605
+ * 2026-10-26), through this door's own write path, one page at a time
1606
+ * with the saves ({@link pageWrite}):
1607
+ *
1608
+ * - each page's frontmatter `author` and `editor`, written without
1609
+ * touching its body, its other fields or its `lastModified`, and without
1610
+ * a new version; its page index entry follows;
1611
+ * - its version history's editors (`manifest.json`), in place;
1612
+ * - the tombstones' `author`, `editor` and `deletedBy`;
1613
+ * - then the search index, rebuilt from the pages once if anything changed.
1614
+ *
1615
+ * Each value gets `IdentityManager.convertRecordedUser`'s answer: a
1616
+ * username with an account becomes its @id; one with none `orphan:<name>`
1617
+ * (counted, and on the dashboard: only an administrator acts on those
1618
+ * pages); `migrated`, `unknown` and `import` `orphan:<value>` without a
1619
+ * lookup (counted apart, not needing attention); @ids, orphans and
1620
+ * principals are kept, so a second run writes nothing.
1621
+ *
1622
+ * The first complete pass visits every page and its history, and is then
1623
+ * recorded in the site's migrations record; later passes visit only pages
1624
+ * whose index entry still holds a value to convert. Pages are taken in
1625
+ * batches of `ngdpbase.page.authorship-conversion.batch-size`, yielding
1626
+ * between batches. When the identity store cannot answer (`canAnswer()`
1627
+ * false, or a lookup fails mid-pass) the pass stops, says so on the
1628
+ * dashboard, and the next start tries again. Logs counts only. Vault pages
1629
+ * convert when their vault is opened ({@link convertVaultAuthorship}).
1630
+ */
1631
+ convertAuthorship(ctx: ActorContext): Promise<AuthorshipConversion | null>;
1632
+ /**
1633
+ * The authorship conversion for the vaults of `owner` this context can open
1634
+ * (#2097), when they are opened: at sign-in and unlock, where the owner's
1635
+ * keys are live. Each vault's pages (frontmatter, its own page index entry,
1636
+ * history, access lists) and the owner's vault trash; each vault is
1637
+ * recorded as done in its own migrations record and skipped from then on.
1638
+ * Postponed, and tried at the next opening, when the identity store cannot
1639
+ * answer; a page or tombstone that fails for its own reason is skipped and
1640
+ * counted, and keeps its vault from being recorded as done.
1641
+ */
1642
+ convertVaultAuthorship(ctx: ActorContext, owner: string): Promise<AuthorshipConversion | null>;
1643
+ /**
1644
+ * How a frontmatter `audience` or `access[action]` entry converts (#2097):
1645
+ * an entry naming a user becomes their identity @id with the pass's own rule
1646
+ * (`orphan:<name>` for a name with no account, counted); every other kind is
1647
+ * kept as written: a role (declared in `ngdpbase.roles.definitions` or held
1648
+ * as a role record), the pseudo-roles `All`, `Authenticated`, `Anonymous`
1649
+ * and `Asserted`, an `addon:`/`job:` or any other `kind:` entry, an @id or
1650
+ * an orphan. A name that is both a role and a username stays the role.
1651
+ */
1652
+ private accessListConverter;
1653
+ /** Every role name a principal list may hold, lower-cased (#2097): declared roles, role records, and the pseudo-roles. */
1654
+ private roleNames;
1655
+ /** Whether an index entry still holds an author or editor the conversion would change (#2097). No lookup is written. */
1656
+ private authorshipNeedsConversion;
1657
+ /** The system-categories whose pages are shipped with the code (source `shipped`), lower-cased (#2097). */
1658
+ private shippedCategories;
1659
+ /** `ngdpbase.page.authorship-conversion.batch-size`: pages converted between yields (#2097). */
1660
+ private authorshipBatchSize;
1661
+ /** Counts only, never a name (#2097): the log, and the dashboard for orphans and refused pages. */
1662
+ private reportAuthorshipConversion;
1663
+ /** The identity store could not answer: nothing more is converted this start, and the next start tries again (#2097). */
1664
+ private postponeAuthorshipConversion;
1541
1665
  /**
1542
1666
  * Rewrite `[Title]` to `[store/Title]` in the private pages that mean it.
1543
1667
  *
@@ -45,6 +45,10 @@ interface AdvancedSearchOptions {
45
45
  };
46
46
  /** #774: which date field `dateRange` filters on. Default `'modified'`. */
47
47
  dateField?: 'modified' | 'created';
48
+ /** A username, or several: pages whose author is that user (#2097: their identity @id or, not yet converted, the username). */
49
+ author?: string | string[];
50
+ /** As `author`, for the last editor. */
51
+ editor?: string | string[];
48
52
  [key: string]: unknown;
49
53
  }
50
54
  /**
@@ -240,6 +244,12 @@ declare class SearchManager extends BaseManager {
240
244
  * });
241
245
  */
242
246
  advancedSearchWithContext(wikiContext: WikiContext, options?: AdvancedSearchOptions): Promise<SearchResult[]>;
247
+ /**
248
+ * The stored values a search for `who` matches (#2097): for each username,
249
+ * that user's identity @id and the username (`IdentityManager.referencesOf`).
250
+ * Without IdentityManager, the values as given.
251
+ */
252
+ private referencesOf;
243
253
  /**
244
254
  * Search for pages matching the query
245
255
  *
@@ -414,6 +414,15 @@ declare class MarkupParser extends BaseManager {
414
414
  * claiming a page is missing.
415
415
  */
416
416
  private resolvePrivateLinkTitles;
417
+ /**
418
+ * The username a PUBLIC page's author names, for a `[store/Title]` link on
419
+ * it (#1457, #2097): the author is stored as an identity @id, and a vault is
420
+ * named by its owner's username, so the @id is resolved once per parse
421
+ * through IdentityManager. Null when a caller already said who the owner
422
+ * is, on a private page (its name carries the owner), on a page with no
423
+ * such link, and for an author that names no account.
424
+ */
425
+ private resolveAuthorOwner;
417
426
  generateCacheKey(content: string, context: ParseContextData): string;
418
427
  /**
419
428
  * Extract style blocks using state-stack parsing for proper nesting support
@@ -186,6 +186,12 @@ declare class ParseContext {
186
186
  * Resolved once per parse by MarkupParser, never per link.
187
187
  */
188
188
  privateLinkTitles: Map<string, Set<string>> | null;
189
+ /**
190
+ * The username of a public page's author, when the author is stored as an
191
+ * identity @id (#2097): what a `[store/Title]` link on it resolves in.
192
+ * Resolved once per parse by MarkupParser; null when not needed.
193
+ */
194
+ pageOwner: string | null;
189
195
  /**
190
196
  * Hand finished HTML from a syntax handler to MarkupParser; write the
191
197
  * returned placeholder into the content instead (#1623). The HTML is merged
@@ -101,10 +101,14 @@ export interface SearchCriteria {
101
101
  statuses?: string[];
102
102
  /** Fields to search in (title, content, category, keywords, or all) */
103
103
  searchIn?: string[];
104
- /** Author to filter by (page creator — matches metadata.author) */
105
- author?: string;
106
- /** Editor to filter by (last modifier — matches metadata.editor) */
107
- editor?: string;
104
+ /**
105
+ * Author to filter by (page creator — matches metadata.author). A list
106
+ * matches any of its values: SearchManager turns a username into the
107
+ * values that name that user, their identity @id and the username (#2097).
108
+ */
109
+ author?: string | string[];
110
+ /** Editor to filter by (last modifier — matches metadata.editor); a list as for `author`. */
111
+ editor?: string | string[];
108
112
  /** Date range */
109
113
  dateRange?: {
110
114
  from?: string;
@@ -203,6 +203,20 @@ declare class BasicAttachmentProvider extends BaseAttachmentProvider implements
203
203
  * @private
204
204
  */
205
205
  private saveMetadata;
206
+ /**
207
+ * The shared pool's uploaders and editors (#2097): each record's
208
+ * `author.name` and `editor.name` become what `convert` answers (the
209
+ * identity @id), and the metadata file is written once when anything
210
+ * changed. `convert` may throw (the identity store cannot answer); then
211
+ * nothing is written. Returns how many values changed.
212
+ */
213
+ convertPoolReferences(convert: (stored: string) => Promise<string>): Promise<number>;
214
+ /**
215
+ * One vault's uploaders (#2097): each entry's `author` in the vault's own
216
+ * files index, through the vault's I/O. Written once when anything
217
+ * changed; nothing when `convert` throws. Returns how many values changed.
218
+ */
219
+ convertStoreReferences(location: StoreFileLocation, convert: (stored: string) => Promise<string>): Promise<number>;
206
220
  /**
207
221
  * Generate unique attachment ID using SHA-256 hash of content
208
222
  * @param buffer - File data
@@ -188,6 +188,13 @@ declare class PolicyInformationPoint extends BaseManager {
188
188
  * a role its user holds left the job out.
189
189
  */
190
190
  private matchableNow;
191
+ /**
192
+ * The subject as matched, carrying its identity @id (#2097): page access
193
+ * lists name users by @id. Matching only: the subject is never changed for
194
+ * any other use. No lookup for nobody or a reserved name; null when the
195
+ * identity store cannot answer, and then only entries not yet converted match.
196
+ */
197
+ private withIdentityId;
191
198
  /**
192
199
  * Who is making this request: the subject the session middleware resolved,
193
200
  * else the session's account resolved now, else the anonymous visitor.
@@ -357,6 +364,14 @@ declare class PolicyInformationPoint extends BaseManager {
357
364
  * audience terms by the NCM funnel (#1446).
358
365
  */
359
366
  private walkPageTiers;
367
+ /**
368
+ * Whether `username` is the page's author (#2097): `IdentityManager.refersTo`,
369
+ * so an author stored as an identity @id matches only its own account, one
370
+ * not yet converted matches its username, and an orphan matches nobody.
371
+ */
372
+ private isPageAuthor;
373
+ /** The username a page's stored author names (#2097), for the private-container fallback. */
374
+ private authorUsername;
360
375
  /**
361
376
  * The private-container decision for something that is not a page — a file
362
377
  * in a store (docs/private-stores.md, Access). The same rule a
@@ -107,6 +107,12 @@ export interface GetPagesByCreatorOptions {
107
107
  * failure mode). Frontmatter is the source of truth and needs no back-fill.
108
108
  */
109
109
  systemKeywords?: string[];
110
+ /**
111
+ * The stored values that name the user (#2097): their identity @id and,
112
+ * for pages not yet converted, their username (`IdentityManager.referencesOf`).
113
+ * Set by PageManager; a provider compares `author` against these. Default `[username]`.
114
+ */
115
+ references?: string[];
110
116
  }
111
117
  /**
112
118
  * Options for {@link PageProvider.getPagesByEditor} / {@link PageProvider.getPagesSharedWith} (#640 Phase 2).
@@ -116,6 +122,12 @@ export interface PagesScanOptions {
116
122
  limit?: number;
117
123
  /** Sort order. Default `lastModified-desc`. */
118
124
  sortBy?: 'lastModified-desc' | 'title-asc';
125
+ /**
126
+ * The stored values that name the user (#2097), as {@link GetPagesByCreatorOptions.references}:
127
+ * `getPagesByEditor` compares `editor` against them, `getPagesSharedWith` leaves out pages
128
+ * whose `author` is one of them. Set by PageManager.
129
+ */
130
+ references?: string[];
119
131
  }
120
132
  /**
121
133
  * Single entry returned by {@link PageProvider.getRecentChanges}.
@@ -362,8 +374,8 @@ export interface PageProvider extends BaseProvider {
362
374
  /**
363
375
  * Return pages owned by the given user (#640).
364
376
  *
365
- * "Owned" matches by `author` or `creator` field — pageIndex keeps these in
366
- * lockstep on save. The visibility filter is intentionally NOT applied here
377
+ * "Owned" matches by `author` (any of `options.references`, #2097) or, on a
378
+ * private page, by `creator`, its container owner's username. The visibility filter is intentionally NOT applied here
367
379
  * because by definition a user can see their own pages: the route handler
368
380
  * verifies that `username === currentUser.username` before calling this.
369
381
  *
@@ -85,6 +85,12 @@ export type InstallStatus =
85
85
  | 'verified'
86
86
  /** Already installed; the folder differed from its verified tarball and was rewritten. */
87
87
  | 'repaired'
88
+ /**
89
+ * #2100: switched to the release an administrator's Update staged; the
90
+ * previous release is kept until AddonsManager confirms this one loaded
91
+ * ({@link confirmUpdate}) or falls back to it ({@link rollBackUpdate}).
92
+ */
93
+ | 'updated'
88
94
  /** Not installed (and not loaded); `reason` says why. */
89
95
  | 'skipped';
90
96
  /** Which verification refused, for `addon-verify-failed`. */
@@ -99,6 +105,20 @@ export interface InstallOutcome {
99
105
  registryUnreachable?: boolean;
100
106
  /** Set when a verification refused, or (for `repaired`) found the folder changed. */
101
107
  failedCheck?: FailedCheck;
108
+ /** For `updated`: the release it replaced. */
109
+ previousVersion?: string;
110
+ /**
111
+ * #2100: a switch an earlier start made and never confirmed (the new release
112
+ * did not load, or the process stopped before it could) was undone at this
113
+ * start, before anything loaded: `from` is the release given up, `to` the
114
+ * one restored.
115
+ */
116
+ rolledBack?: {
117
+ from: string;
118
+ to: string;
119
+ };
120
+ /** #2100: something admins should hear about a staged Update, such as one discarded. */
121
+ notice?: string;
102
122
  }
103
123
  /** `sha512-<base64>` of `data`. */
104
124
  export declare function sha512Integrity(data: Buffer): string;
@@ -107,3 +127,73 @@ export declare function sha512Integrity(data: Buffer): string;
107
127
  * Never throws for one add-on's problem: each outcome says what happened.
108
128
  */
109
129
  export declare function installConfiguredAddons(specs: Record<string, unknown>, opts: InstallerOptions): Promise<InstallOutcome[]>;
130
+ /**
131
+ * The switched-to release loaded (or the add-on is off, so there is nothing to
132
+ * try): the previous release's folder and tarball go. No-op when nothing waits.
133
+ */
134
+ export declare function confirmUpdate(addonsDir: string, slug: string): Promise<void>;
135
+ /**
136
+ * The switched-to release did not load: restore the previous release on disk
137
+ * and in the lock, so the next start runs it. The new folder is set aside
138
+ * (`.rejected-…`) for inspection. Undefined when there is nothing to undo.
139
+ */
140
+ export declare function rollBackUpdate(addonsDir: string, slug: string, now?: Date): Promise<{
141
+ from: string;
142
+ to: string;
143
+ } | undefined>;
144
+ /** Why an Update is refused while the last one is not settled (`previous` still in the lock). */
145
+ export declare const FINISH_LAST_UPDATE = "finish the last update first: restart the site";
146
+ /** What the admin page shows for one add-on in `ngdpbase.addons.install`. */
147
+ export interface UpdateCheck {
148
+ slug: string;
149
+ package?: string;
150
+ /** The configured range (`version` in the entry). */
151
+ range?: string;
152
+ /** The release the lock pins: what runs. Absent when not installed. */
153
+ pinned?: string;
154
+ /** A release an Update downloaded, used from the next start. */
155
+ pending?: string;
156
+ /** The newest release in the range, whether or not this core can run it. */
157
+ newest?: string;
158
+ /** The newest release in the range, newer than what is pinned or staged, that this core can run: what Update installs. */
159
+ available?: string;
160
+ /** The newest release in the range, newer than what is pinned or staged, when it needs a newer ngdpbase. */
161
+ needsCore?: {
162
+ version: string;
163
+ minVersion: string;
164
+ };
165
+ registryUnreachable?: boolean;
166
+ /** Why there is nothing to compare: not installed, or a configuration problem. */
167
+ problem?: string;
168
+ /** The last update is not settled (the lock still names the release it replaced): no Update until a restart. */
169
+ blocked?: string;
170
+ }
171
+ /**
172
+ * Ask the registry, for every installed add-on, which releases in its range
173
+ * are newer than the one pinned. Reads the packument only; writes nothing.
174
+ * Called only when an administrator opens the add-ons page or presses Check,
175
+ * never at start.
176
+ */
177
+ export declare function checkForUpdates(specs: Record<string, unknown>, opts: InstallerOptions): Promise<UpdateCheck[]>;
178
+ /** What an Update did. */
179
+ export interface UpdateOutcome {
180
+ slug: string;
181
+ /** `staged`: verified and downloaded; it runs from the next start. `refused`: nothing changed; `reason` says why. */
182
+ status: 'staged' | 'refused';
183
+ package?: string;
184
+ version?: string;
185
+ /** The release that runs until the next start. */
186
+ previousVersion?: string;
187
+ reason?: string;
188
+ registryUnreachable?: boolean;
189
+ failedCheck?: FailedCheck;
190
+ }
191
+ /**
192
+ * Download `version` of an installed add-on and stage it for the next start
193
+ * (#2100). It must be a release in the configured range, newer than the
194
+ * pinned one, that this core can run, and it passes the same checks as a
195
+ * first install ({@link fetchVerified}): sha512, provenance, contents,
196
+ * package.json. `beforeInstall` (the audit record) runs after the checks and
197
+ * before anything is written. The running add-on's folder is not touched.
198
+ */
199
+ export declare function stageUpdate(slug: string, rawSpec: unknown, version: string, opts: InstallerOptions): Promise<UpdateOutcome>;
@@ -7,9 +7,8 @@
7
7
  * folders in `<data>/addons` are installed add-ons at all: a folder the lock
8
8
  * does not name is never an add-on, whatever it contains.
9
9
  */
10
- /** What the lock records for one installed add-on. */
11
- export interface LockEntry {
12
- package: string;
10
+ /** One verified release of an add-on: what it is and what it was accepted on. */
11
+ export interface PinnedVersion {
13
12
  version: string;
14
13
  integrity: string;
15
14
  installedAt: string;
@@ -19,6 +18,23 @@ export interface LockEntry {
19
18
  verifiedAt: string;
20
19
  };
21
20
  }
21
+ /** What the lock records for one installed add-on. */
22
+ export interface LockEntry extends PinnedVersion {
23
+ package: string;
24
+ /**
25
+ * #2100: a newer release an administrator's Update downloaded and verified.
26
+ * Its tarball waits in `.packages/`; the next start switches to it. Nothing
27
+ * else reads it, so until then the site runs `version`.
28
+ */
29
+ pending?: PinnedVersion;
30
+ /**
31
+ * #2100: the release this one replaced at the start that switched to it,
32
+ * kept (its folder as `.previous-<slug>`, its tarball in `.packages/`) until
33
+ * this one has loaded. Present at a later start only when that start never
34
+ * confirmed the switch, so the installer falls back to it.
35
+ */
36
+ previous?: PinnedVersion;
37
+ }
22
38
  export interface LockFile {
23
39
  lockfileVersion: 1;
24
40
  addons: Record<string, LockEntry>;
@@ -26,6 +42,8 @@ export interface LockFile {
26
42
  export declare const LOCK_FILE_NAME = "addons-lock.json";
27
43
  /** Verified tarballs, kept so a restart needs no registry. Dot-named: discovery skips it. */
28
44
  export declare const PACKAGES_DIR_NAME = ".packages";
45
+ /** #2100: the folder a switched-from release waits in until the new one has loaded. Dot-named: discovery skips it. */
46
+ export declare const PREVIOUS_DIR_PREFIX = ".previous-";
29
47
  export declare function lockFilePath(addonsDir: string): string;
30
48
  /** The lock file, or an empty one when there is none. Throws when it exists and is not JSON. */
31
49
  export declare function readLockSync(addonsDir: string): LockFile;
@@ -12,6 +12,8 @@ import path from 'path';
12
12
  export const LOCK_FILE_NAME = 'addons-lock.json';
13
13
  /** Verified tarballs, kept so a restart needs no registry. Dot-named: discovery skips it. */
14
14
  export const PACKAGES_DIR_NAME = '.packages';
15
+ /** #2100: the folder a switched-from release waits in until the new one has loaded. Dot-named: discovery skips it. */
16
+ export const PREVIOUS_DIR_PREFIX = '.previous-';
15
17
  export function lockFilePath(addonsDir) {
16
18
  return path.join(addonsDir, LOCK_FILE_NAME);
17
19
  }
@@ -4,7 +4,7 @@ export declare const AUDIT_EVENT_NAME_PATTERN: RegExp;
4
4
  export declare const AUDIT_EVENT: {
5
5
  /** An add-on named in ngdpbase.addons.install installed into the data folder: package, version, sha512 and the repository its provenance names; recorded before it is written, which cannot proceed without it (#1830) */
6
6
  readonly ADDON_INSTALL: "addon-install";
7
- /** An installed add-on moved to another version by an administrator's Update: who, and from which version to which (#1830; the Update action is not built yet) */
7
+ /** The release an installed add-on runs changed: an administrator's Update (who, the add-on, from and to version), recorded before the release is written, which cannot proceed without it; or core falling back to the previous release because the new one did not load (#2100) */
8
8
  readonly ADDON_UPDATE: "addon-update";
9
9
  /** An add-on refused or set aside by a check: its sha512, provenance, tarball contents, package.json, the lock file, or a changed installed folder (#1830) */
10
10
  readonly ADDON_VERIFY_FAILED: "addon-verify-failed";
@@ -11,7 +11,7 @@ export const AUDIT_EVENT_NAME_PATTERN = /^[a-z]+(-[a-z]+)+$/;
11
11
  export const AUDIT_EVENT = {
12
12
  /** An add-on named in ngdpbase.addons.install installed into the data folder: package, version, sha512 and the repository its provenance names; recorded before it is written, which cannot proceed without it (#1830) */
13
13
  ADDON_INSTALL: 'addon-install',
14
- /** An installed add-on moved to another version by an administrator's Update: who, and from which version to which (#1830; the Update action is not built yet) */
14
+ /** The release an installed add-on runs changed: an administrator's Update (who, the add-on, from and to version), recorded before the release is written, which cannot proceed without it; or core falling back to the previous release because the new one did not load (#2100) */
15
15
  ADDON_UPDATE: 'addon-update',
16
16
  /** An add-on refused or set aside by a check: its sha512, provenance, tarball contents, package.json, the lock file, or a changed installed folder (#1830) */
17
17
  ADDON_VERIFY_FAILED: 'addon-verify-failed',
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The identity store could not answer a conversion's lookup (#2097).
3
+ *
4
+ * A start-up conversion tells this apart from every other failure: this one
5
+ * postpones the whole pass (no username is orphaned because it could not be
6
+ * looked up), while a page that fails for its own reason (a file it cannot
7
+ * read or write, a full disk, a vault it cannot open) is skipped, counted and
8
+ * the pass goes on. `IdentityManager.conversionPass` throws it; the providers
9
+ * and managers running a pass rethrow it and catch anything else per record.
10
+ */
11
+ export declare class IdentityStoreUnavailableError extends Error {
12
+ constructor(cause: unknown);
13
+ }
14
+ /**
15
+ * How a failure is named in a counts-only report: its error code (`EACCES`,
16
+ * `ENOSPC`) or class name, never its message (which may hold a path or a
17
+ * name).
18
+ */
19
+ export declare function errorClassOf(error: unknown): string;
@@ -70,5 +70,21 @@ export declare const IDENTITY_ID_PREFIX = "urn:";
70
70
  * start with it.
71
71
  */
72
72
  export declare const ORPHAN_PREFIX = "orphan:";
73
- /** True when the name belongs to a principal rather than a user, or has the contact, `@id` or orphan prefix. */
73
+ /**
74
+ * Values older records hold where no user was recorded (#2096, #2097): the
75
+ * footnote migration's `migrated`, the editor fallback `unknown` (and the
76
+ * attachment pool's `Unknown`), the JSPWiki import's `import`. Never a
77
+ * username: a start-up conversion never looks them up (an account that later
78
+ * takes one of these names must not gain the records), stores them as
79
+ * `orphan:<value>` and does not count them as needing attention.
80
+ */
81
+ export declare const NO_RECORDED_USER: readonly string[];
82
+ /** True when `value` is one of {@link NO_RECORDED_USER}, bare or as its orphan form. */
83
+ export declare function isNoRecordedUser(value: unknown): boolean;
84
+ /**
85
+ * True when the name belongs to a principal rather than a user, is a "no user
86
+ * recorded" value ({@link NO_RECORDED_USER}, #2097: an account by that name
87
+ * would take over the records that hold it), or has the contact, `@id` or
88
+ * orphan prefix.
89
+ */
74
90
  export declare function isReservedUsername(username: unknown): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilleke/ngdpbase-types",
3
- "version": "5.34.0",
3
+ "version": "5.36.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",