@jwilleke/ngdpbase-types 5.33.0 → 5.34.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.
@@ -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,43 @@ 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;
275
312
  private requireProvider;
276
313
  private getMetrics;
277
314
  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>;
@@ -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;
@@ -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;
@@ -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
  /**
@@ -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
@@ -55,5 +55,20 @@ 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
+ /** True when the name belongs to a principal rather than a user, or has the contact, `@id` or orphan prefix. */
59
74
  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.34.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",