@jwilleke/ngdpbase-types 5.33.0 → 5.35.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.
@@ -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;
@@ -1200,7 +1200,7 @@ class BackupManager extends BaseManager {
1200
1200
  async notifyAdmins(level, title, message) {
1201
1201
  try {
1202
1202
  await this.engine.getManager('NotificationManager')
1203
- ?.addNotification({ type: 'system', level, title, message });
1203
+ ?.notifyAdmins({ level, title, message });
1204
1204
  }
1205
1205
  catch (err) {
1206
1206
  logger.error(`❌ The notification '${title}' could not be sent:`, err);
@@ -2059,8 +2059,8 @@ class BackupManager extends BaseManager {
2059
2059
  catch (err) {
2060
2060
  jobs.registerJob(def);
2061
2061
  logger.error(`❌ Automatic backups are ON but NOT scheduled: ${err.message}. Fix the setting in Admin → Backup.`);
2062
- void this.engine.getManager('NotificationManager')?.addNotification({
2063
- type: 'system', level: 'error', title: 'Automatic backups are not running',
2062
+ void this.engine.getManager('NotificationManager')?.notifyAdmins({
2063
+ level: 'error', title: 'Automatic backups are not running',
2064
2064
  message: `${err.message}. Fix the setting in Admin → Backup.`
2065
2065
  }).catch(() => undefined);
2066
2066
  }
@@ -10,6 +10,31 @@ export default class CommentManager extends BaseManager {
10
10
  private readonly outsideCheck;
11
11
  constructor(engine: WikiEngine);
12
12
  initialize(): Promise<void>;
13
+ /**
14
+ * Once, at start-up (#2041): every public comment whose `author` is still a
15
+ * username gets `IdentityManager.convertReference`'s answer, one write per
16
+ * comment file, under the system context: that user's identity `@id`, or
17
+ * `orphan:<name>` when the name has no account (nobody's from then on: a
18
+ * later account with that name never takes it over, and only an
19
+ * administrator can delete it). `@id`s, orphans, `anonymous` and the system
20
+ * principal are left alone, so a second start writes nothing. Orphans are
21
+ * reported, counts only: a log line and the admin dashboard's
22
+ * needs-attention list. When the identity store cannot answer, the whole
23
+ * pass is postponed to the next start and reported the same way. A vault
24
+ * page's comments are in its vault, which start-up does not open; they keep
25
+ * their username, and {@link isAuthor} still matches it.
26
+ * `authorDisplayName` and `deletedBy` are not touched.
27
+ */
28
+ private convertAuthorsToIdentityIds;
29
+ /** The identity store could not answer: nothing more is converted this start, and the next start tries again (#2041). */
30
+ private postponeConversion;
31
+ /**
32
+ * Whether `ctx` wrote `comment` (#2041): its `author` is the caller's
33
+ * identity `@id`, or, on a comment written before and not yet converted (a
34
+ * vault page's), the caller's username. An orphan (`orphan:<name>`) is
35
+ * nobody's and never matches.
36
+ */
37
+ isAuthor(comment: Pick<PageComment, 'author'>, ctx: ActorContext): Promise<boolean>;
13
38
  /** `ngdpbase.comments.storagedir`, resolved, or the default when there is no configuration (a test). */
14
39
  private configuredDir;
15
40
  /**
@@ -713,7 +713,7 @@ class DatabaseManager extends BaseManager {
713
713
  async notify(level, title, message) {
714
714
  try {
715
715
  await this.engine.getManager('NotificationManager')
716
- ?.addNotification({ type: 'system', level, title, message });
716
+ ?.notifyAdmins({ level, title, message });
717
717
  }
718
718
  catch (err) {
719
719
  logger.error(`[DatabaseManager] The notification '${title}' could not be sent (#1723):`, err);
@@ -7,6 +7,7 @@ export interface PageFootnote {
7
7
  display: string;
8
8
  url: string;
9
9
  note: string;
10
+ /** Who added it: their identity `@id` (#2041); a username on a note written before and not yet converted (a vault page's), or one with no account. */
10
11
  createdBy: string;
11
12
  createdAt: string;
12
13
  }
@@ -17,6 +18,32 @@ export default class FootnoteManager extends BaseManager {
17
18
  private readonly outsideCheck;
18
19
  constructor(engine: WikiEngine);
19
20
  initialize(): Promise<void>;
21
+ /**
22
+ * Once, at start-up (#2041): every public footnote whose `createdBy` is
23
+ * still a username gets `IdentityManager.convertReference`'s answer, one
24
+ * write per page file, under the system context: that user's identity
25
+ * `@id`, or `orphan:<name>` when the name has no account (nobody's from
26
+ * then on: a later account with that name never takes it over, and only an
27
+ * administrator can delete it). `@id`s, orphans, `anonymous` and the system
28
+ * principal are left alone, so a second start writes nothing. Orphans are
29
+ * reported, counts only (the legacy `migrated` placeholder is `orphan:migrated`
30
+ * and only logged at info, not counted: it has no recorded creator): a log line and the admin dashboard's
31
+ * needs-attention list. When the identity store cannot answer, the whole
32
+ * pass is postponed to the next start and reported the same way. A vault
33
+ * page's footnotes are in its vault, which start-up does not open; they
34
+ * keep their username, and {@link isCreator} still matches it.
35
+ */
36
+ private convertCreatorsToIdentityIds;
37
+ /** The identity store could not answer: nothing more is converted this start, and the next start tries again (#2041). */
38
+ private postponeConversion;
39
+ /**
40
+ * Whether `ctx` added `footnote` (#2041): its `createdBy` is the caller's
41
+ * identity `@id`, or, on a footnote written before and not yet converted
42
+ * (a vault page's), the caller's username. An orphan (`orphan:<name>`)
43
+ * is nobody's and never matches; neither does the legacy `migrated`
44
+ * placeholder (no recorded creator), on a footnote not yet converted.
45
+ */
46
+ isCreator(footnote: Pick<PageFootnote, 'createdBy'>, ctx: ActorContext): Promise<boolean>;
20
47
  /** `ngdpbase.footnotes.storagedir`, resolved, or the default when there is no configuration (a test). */
21
48
  private configuredDir;
22
49
  /**
@@ -272,6 +272,120 @@ declare class IdentityManager extends BaseManager {
272
272
  * (an administrator links it, {@link linkAccount}).
273
273
  */
274
274
  getByUserIdentifier(ctx: ActorContext, username: string): Promise<Person | null>;
275
+ /**
276
+ * The `@id` of the user `username`'s identity record, or null (#2041): null
277
+ * when there is no account by that name, and null when the identity store
278
+ * cannot answer (degraded, unreadable), logged without the name. The door
279
+ * for a write or a record that refers to a user: it then keeps working as
280
+ * it does for a name with no account, and never fails because the identity
281
+ * store is down. Read as {@link getByUserIdentifier}, which a caller that
282
+ * must tell "no account" from "cannot answer" uses instead (a start-up
283
+ * conversion, with {@link canAnswer}). A refusal still throws.
284
+ */
285
+ identityIdOf(ctx: ActorContext, username: string): Promise<string | null>;
286
+ /**
287
+ * What a stored reference to a user should become, for a start-up
288
+ * conversion (#2041). `stored` is what a record holds today:
289
+ *
290
+ * - an `@id` (`urn:`), an orphan (`orphan:<name>`), `anonymous`, the
291
+ * system principal or an empty value: `kept` (`orphan` when it is an
292
+ * orphan, so the caller can count them);
293
+ * - a username with an account: that identity's `@id`, `converted`;
294
+ * - a username with no account: `orphan:<name>`, `orphaned`. The record is
295
+ * then nobody's: a later account with the same name never takes it over,
296
+ * and no "is it mine" check matches it.
297
+ *
298
+ * Throws when the identity store cannot answer (and so does nothing to the
299
+ * record): the caller postpones its whole pass to the next start, rather
300
+ * than orphan a name it could not look up. Ask {@link canAnswer} first.
301
+ */
302
+ convertReference(ctx: ActorContext, stored: string): Promise<{
303
+ value: string;
304
+ outcome: 'kept' | 'orphan' | 'converted' | 'orphaned';
305
+ }>;
306
+ /**
307
+ * Whether the identity store can answer lookups now (#2041): false when it
308
+ * started degraded (no provider). A start-up conversion asks this first and
309
+ * postpones the whole pass when it is false.
310
+ */
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>;
275
389
  private requireProvider;
276
390
  private getMetrics;
277
391
  private normalizeProviderName;
@@ -10,9 +10,11 @@ export interface Notification {
10
10
  title: string;
11
11
  message: string;
12
12
  level: 'info' | 'warning' | 'error' | 'success';
13
+ /** Who it is for, by identity `@id` (#2041); empty means every user. A name with no account is kept as given. */
13
14
  targetUsers: string[];
14
15
  createdAt: Date;
15
16
  expiresAt: Date | null;
17
+ /** Who dismissed it, by identity `@id` (#2041); a name with no account as given. */
16
18
  dismissedBy: string[];
17
19
  }
18
20
  /**
@@ -23,6 +25,7 @@ export interface NotificationInput {
23
25
  title?: string;
24
26
  message?: string;
25
27
  level?: 'info' | 'warning' | 'error' | 'success';
28
+ /** Usernames or identity `@id`s; stored as `@id`s (#2041). Empty or absent: every user. */
26
29
  targetUsers?: string[];
27
30
  expiresAt?: Date | null;
28
31
  /**
@@ -32,6 +35,8 @@ export interface NotificationInput {
32
35
  */
33
36
  escalate?: boolean;
34
37
  }
38
+ /** A notice for administrators: the type and the audience are not the caller's to choose. */
39
+ export type AdminNoticeInput = Omit<NotificationInput, 'type' | 'targetUsers'>;
35
40
  /**
36
41
  * Notification statistics
37
42
  */
@@ -92,6 +97,21 @@ declare class NotificationManager extends BaseManager {
92
97
  * Load notifications from storage
93
98
  */
94
99
  private loadNotifications;
100
+ /**
101
+ * Once, at start-up (#2041): every stored `targetUsers` and `dismissedBy`
102
+ * entry that is still a username gets `IdentityManager.convertReference`'s
103
+ * answer, under the system context, and the file is written once: that
104
+ * user's identity `@id`, or `orphan:<name>` when the name has no account
105
+ * (it then reaches nobody: a later account with that name is never
106
+ * addressed by it). `@id`s, orphans, `anonymous` and the system principal
107
+ * are left alone, so a second start writes nothing. Orphans are reported,
108
+ * counts only: a log line and the admin dashboard's needs-attention list.
109
+ * When the identity store cannot answer, nothing is changed and the pass is
110
+ * postponed to the next start, reported the same way.
111
+ */
112
+ private convertRecipientsToIdentityIds;
113
+ /** The identity store could not answer: nothing is converted this start, and the next start tries again (#2041). */
114
+ private postponeConversion;
95
115
  /**
96
116
  * Save notifications to storage
97
117
  */
@@ -115,6 +135,16 @@ declare class NotificationManager extends BaseManager {
115
135
  * @returns false when there is no such notification
116
136
  */
117
137
  updateNotificationMessage(notificationId: string, message: string): Promise<boolean>;
138
+ /**
139
+ * The one way to tell administrators something (#2040): a `system`
140
+ * notification targeted at the users holding `admin-system`. A notification
141
+ * with no targetUsers reaches every user, so no admin notice is created any
142
+ * other way. Nobody holds admin-system: the log line stands alone and null is
143
+ * returned.
144
+ *
145
+ * @returns the notification id, or null when there was nobody to tell
146
+ */
147
+ notifyAdmins(input: AdminNoticeInput): Promise<string | null>;
118
148
  /**
119
149
  * Add a notification (alias for createNotification for backward compatibility)
120
150
  * @param {NotificationInput} notification - Notification object
@@ -122,12 +152,14 @@ declare class NotificationManager extends BaseManager {
122
152
  */
123
153
  addNotification(notification: NotificationInput): Promise<string>;
124
154
  /**
125
- * Get notifications for a specific user
126
- * @param {string} username - Username to get notifications for
155
+ * The notifications for the caller: addressed to every user, or to them by
156
+ * identity `@id` (#2041), or by username on one with no account; never one
157
+ * they dismissed.
158
+ * @param ctx - Who is asking: the request's subject, forwarded
127
159
  * @param {boolean} includeExpired - Include expired notifications
128
- * @returns {Notification[]} Array of notifications
160
+ * @returns Array of notifications
129
161
  */
130
- getUserNotifications(username: string, includeExpired?: boolean): Notification[];
162
+ getUserNotifications(ctx: ActorContext, includeExpired?: boolean): Promise<Notification[]>;
131
163
  /**
132
164
  * Dismiss a notification for the caller.
133
165
  *
@@ -120,9 +120,10 @@ declare class OrganizationManager extends BaseManager {
120
120
  affiliation: PersonAffiliation;
121
121
  }>>;
122
122
  /**
123
- * Create an organization. `name` is required; `@id` defaults to the `url`,
124
- * else a new `urn:uuid:`. `filename` is for the install anchor (seeding);
125
- * other records take one from the url or name.
123
+ * Create an organization. `name` is required. Its `@id` is always a new
124
+ * `urn:uuid:` (#2041): the web address stays the ordinary `url` field, and
125
+ * an `@id` the caller gives is ignored. `filename` is for the install
126
+ * anchor (seeding); other records take one from the url or name.
126
127
  */
127
128
  create(ctx: ActorContext, org: OrganizationInput, filename?: string): Promise<Organization>;
128
129
  update(ctx: ActorContext, id: string, patch: OrganizationUpdate): Promise<Organization | null>;
@@ -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
  *
@@ -187,7 +187,7 @@ declare class RoleManager extends BaseManager {
187
187
  * unless the caller sends one for a whole batch. Returns the error to throw.
188
188
  */
189
189
  private roleFailure;
190
- /** An error notification for admins; a notification that cannot be sent is logged, never thrown. */
190
+ /** An error notification for the users holding `admin-system`; a notification that cannot be sent is logged, never thrown. */
191
191
  private reportToAdmins;
192
192
  /** #2015: the membership queue's key for one role record. */
193
193
  private membershipKey;
@@ -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
@@ -117,6 +117,20 @@ const DATABASE_MIGRATIONS = [
117
117
  db.exec('CREATE INDEX review_requests_by_status ON review_requests (status, filed_at)');
118
118
  db.exec('CREATE INDEX review_requests_by_decided ON review_requests (decided_at)');
119
119
  }
120
+ },
121
+ {
122
+ // #2041: a row names the owner of its resource by identity @id in
123
+ // `metadata.owner` and by username in `metadata.ownerUser`, so the rows
124
+ // about an account can still be listed after the account (and its
125
+ // identity record) is deleted. This column is the copy of `ownerUser`
126
+ // for the index; rows written before it are null, and their `owner` is
127
+ // the username anyway.
128
+ id: '20261026120000',
129
+ description: 'audit log: audit_events.owner_user',
130
+ up: (db) => {
131
+ db.exec('ALTER TABLE audit_events ADD COLUMN owner_user TEXT');
132
+ db.exec('CREATE INDEX audit_events_by_owner_user ON audit_events (owner_user, timestamp)');
133
+ }
120
134
  }
121
135
  ];
122
136
  export default DATABASE_MIGRATIONS;
@@ -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
@@ -1,6 +1,7 @@
1
1
  export interface PageComment {
2
2
  id: string;
3
3
  pageUuid: string;
4
+ /** Who wrote it: their identity `@id` (#2041); a username on a comment written before and not yet converted (a vault page's), or one with no account. */
4
5
  author: string;
5
6
  authorDisplayName: string;
6
7
  content: string;
@@ -5,9 +5,12 @@
5
5
  * org under `ngdpbase.application.organization.storagedir`. The install's
6
6
  * anchor org is identified by `ngdpbase.application.organization.file`.
7
7
  *
8
- * `@id` is the organization's canonical URL (e.g. https://example.com/),
9
- * NOT a synthetic urn. Use the schema.org `identifier` field for any
10
- * legacy slug compatibility.
8
+ * `@id` is a `urn:uuid:<v4>` made with the record, the organization's id for
9
+ * life (#2041, identity model step 6). Its web address is the ordinary `url`
10
+ * field, never the id. Organizations made before #2041 keep the `@id` they
11
+ * were given (often their URL); nothing refers to an organization by URL as
12
+ * its id from then on. Use the schema.org `identifier` field for any legacy
13
+ * slug compatibility.
11
14
  *
12
15
  * @see https://schema.org/Organization
13
16
  */
@@ -61,13 +64,13 @@ export type ContactPointEntry = ContactPoint | PostalAddress;
61
64
  export interface Organization {
62
65
  '@context': 'https://schema.org';
63
66
  '@type': 'Organization';
64
- /** Canonical URL of the organization (the JSON-LD subject IRI) */
67
+ /** The organization's id for life: `urn:uuid:<v4>` (#2041); an organization made before #2041 keeps its own, often its URL */
65
68
  '@id': string;
66
69
  /** Human-readable name */
67
70
  name: string;
68
71
  /** Legal/registered name if different from `name` */
69
72
  legalName?: string;
70
- /** Public web URL — usually the same as `@id` */
73
+ /** Public web address: an ordinary field, never the id (#2041) */
71
74
  url?: string;
72
75
  /** Logo URL */
73
76
  logo?: string;
@@ -87,10 +90,10 @@ export interface Organization {
87
90
  [key: string]: unknown;
88
91
  }
89
92
  /**
90
- * What a caller gives `OrganizationManager.create` (#1930): the fields, with
91
- * `@id` optional (it defaults to the `url`, else a new `urn:uuid:`).
93
+ * What a caller gives `OrganizationManager.create` (#1930): the fields. The
94
+ * `@id` is never the caller's: `create` makes a new `urn:uuid:` (#2041).
92
95
  */
93
- export type OrganizationInput = Partial<Organization> & {
96
+ export type OrganizationInput = Partial<Omit<Organization, '@id'>> & {
94
97
  name: string;
95
98
  };
96
99
  /**
@@ -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
  *
@@ -126,8 +126,10 @@ export interface TokenInput extends CommonInput {
126
126
  op: TokenOp;
127
127
  /** Token id — the handle every later question about this credential uses. */
128
128
  id: string;
129
- /** Whose token it is, which is not always who acted: an admin may revoke. */
129
+ /** Whose token it is, which is not always who acted (an admin may revoke): their identity @id (#2041), or a name with no account as given. */
130
130
  owner: string;
131
+ /** The token owner's username, written beside `owner` (#2041) so the rows still list after the account is deleted. */
132
+ ownerUser?: string;
131
133
  /** Operator-supplied label. Optional; a token need not be named. */
132
134
  name?: string | null;
133
135
  /** Only meaningful for `mint`; ignored otherwise. */
@@ -149,11 +151,14 @@ export type AttachmentDetail = {
149
151
  filename: string;
150
152
  pageName?: string | null;
151
153
  sizeBytes?: number | null;
152
- } | {
154
+ }
155
+ /** A sealed store's file: `owner` is the store owner's identity @id in the record, `ownerUser` their username beside it (#2041). */
156
+ | {
153
157
  attachmentId: string;
154
158
  sealedStore: {
155
159
  owner: string;
156
160
  store: string;
161
+ ownerUser?: string;
157
162
  };
158
163
  };
159
164
  export type AttachmentInput = CommonInput & {
@@ -148,7 +148,7 @@ export function buildAttachmentAuditEvent(input) {
148
148
  const { op, attachmentId, viaToken } = input;
149
149
  const who = whoOf(input);
150
150
  const metadata = 'sealedStore' in input
151
- ? { attachmentId, owner: input.sealedStore.owner, store: input.sealedStore.store, sealed: true, ...tokenMetadata(viaToken), ...who.metadata }
151
+ ? { attachmentId, owner: input.sealedStore.owner, ...(input.sealedStore.ownerUser ? { ownerUser: input.sealedStore.ownerUser } : {}), store: input.sealedStore.store, sealed: true, ...tokenMetadata(viaToken), ...who.metadata }
152
152
  : { attachmentId, filename: input.filename, ...tokenMetadata(viaToken), ...who.metadata };
153
153
  if (!('sealedStore' in input)) {
154
154
  if (input.pageName)
@@ -273,11 +273,12 @@ export async function recordAuditEvent(sink, event, onError) {
273
273
  * every event is useless as a filter.
274
274
  */
275
275
  export function buildTokenAuditEvent(input) {
276
- const { op, id, owner, name, scopes, expiresAt, previousExpiresAt, revokedBy, viaToken } = input;
276
+ const { op, id, owner, ownerUser, name, scopes, expiresAt, previousExpiresAt, revokedBy, viaToken } = input;
277
277
  const who = whoOf(input);
278
278
  const metadata = {
279
279
  id,
280
280
  owner,
281
+ ...(ownerUser ? { ownerUser } : {}),
281
282
  name: name ?? null,
282
283
  ...tokenMetadata(viaToken),
283
284
  ...who.metadata
@@ -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;
@@ -55,5 +55,36 @@ export declare const RESERVED_USERNAMES: readonly string[];
55
55
  * identifier can never be, or later become, someone's username.
56
56
  */
57
57
  export declare const ACCOUNTLESS_PERSON_PREFIX = "person:";
58
- /** True when the name belongs to a principal rather than a person, or has the account-less person prefix. */
58
+ /**
59
+ * The prefix of an identity's `@id` (#2041): `urn:uuid:<v4>`, and older
60
+ * organizations' `urn:…` ids. No username may start with it, so a stored
61
+ * reference is always told apart: a value starting `urn:` is an `@id`,
62
+ * anything else a username not yet converted.
63
+ */
64
+ export declare const IDENTITY_ID_PREFIX = "urn:";
65
+ /**
66
+ * The prefix a start-up conversion gives a stored username that has no
67
+ * account (#2041): `orphan:<name>`. The record is then nobody's: a later
68
+ * account with the same name never takes it over, no "is it mine" check
69
+ * matches it, and only an administrator can act on it. No username may
70
+ * start with it.
71
+ */
72
+ export declare const ORPHAN_PREFIX = "orphan:";
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
+ */
59
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.33.0",
3
+ "version": "5.35.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",