@jwilleke/ngdpbase-types 5.29.0 → 5.30.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.
@@ -39,9 +39,13 @@ export type { default as ImportManager } from '../../src/managers/ImportManager.
39
39
  export type { IceShim, IceReceiver, IceItem, IceKind, IceWarning, ShimDirection, ShimAccepts, ShimTools, ShimReadResult, ReceiveInfo, ReceiveOutcome, IceCitation, KeptOriginal, OriginalChoice, IceOriginalChoices, IceUpload, IceSource, IceRunRequest, IceRunReport, IceFileReport, IceFileStatus } from '../../src/ice/types.js';
40
40
  export type { default as UserManager, UserUpdateInput, UserUpdateFn } from '../../src/managers/UserManager.js';
41
41
  export type { User, UserPreferences } from '../../src/types/User.js';
42
- export type { default as PersonManager, LinkOfferOutcome } from '../../src/managers/PersonManager.js';
42
+ export type { default as AuthManager, InviteOutcome, InvitationView } from '../../src/managers/AuthManager.js';
43
+ export type { default as IdentityManager, LinkOfferOutcome } from '../../src/managers/IdentityManager.js';
44
+ export type {
45
+ /** @deprecated Renamed `IdentityManager` (#2038); this alias goes after one release. */
46
+ default as PersonManager } from '../../src/managers/IdentityManager.js';
43
47
  export type { default as OrganizationManager } from '../../src/managers/OrganizationManager.js';
44
48
  export type { Person, PersonAffiliation, PersonInput, PersonUpdate } from '../../src/types/Person.js';
45
- export type { Organization, OrganizationInput, OrganizationUpdate } from '../../src/types/Organization.js';
49
+ export type { Organization, OrganizationInput, OrganizationUpdate, ContactPoint, ContactPointEntry, PostalAddress } from '../../src/types/Organization.js';
46
50
  export type { ContactRefusedError } from '../../src/utils/contactAccess.js';
47
51
  export type { default as RoleManager } from '../../src/managers/RoleManager.js';
@@ -60,7 +60,10 @@ export interface JobContext {
60
60
  viaToken?: AgentTokenGrant;
61
61
  /** The share the requesting call presented, when it did (#1222). Carried for the same reason. */
62
62
  viaShare?: ShareGrant;
63
- /** The proxy grant the requesting call acted under, when it did (#1762). Carried for the same reason: a job never runs as the represented person unbounded. */
63
+ /**
64
+ * The proxy grant the requesting call acted under, when it did (#1762). Carried for the same reason: a job never runs as the represented user unbounded.
65
+ * Never with its `keyHandle` (#2047): a job carries no key of any kind, so it reaches no key copy and none is written to a run record ({@link withoutKeyHandle}).
66
+ */
64
67
  viaProxy?: ProxyClaim;
65
68
  /** The address the requesting call came from (#1179), when there was one. Provenance for the record. */
66
69
  ipAddress?: string;
@@ -96,6 +99,13 @@ export declare const ANONYMOUS_USERNAME = "Anonymous";
96
99
  * string, so `viaToken` comes along instead of being dropped.
97
100
  */
98
101
  export declare function jobContextFromRequest(identity: RequestIdentity | null | undefined, now?: Date): JobContext;
102
+ /**
103
+ * A job context with no key handle in it (#2047). `jobContextFromRequest`
104
+ * already leaves the handle out; the job manager also passes every context it
105
+ * stores through this, so a context built by hand cannot carry a key copy's
106
+ * handle into a job or onto disk.
107
+ */
108
+ export declare function withoutKeyHandle(ctx: JobContext): JobContext;
99
109
  /**
100
110
  * A request-origin context that also says why (#1179).
101
111
  *
@@ -54,11 +54,25 @@ export function jobContextFromRequest(identity, now = new Date()) {
54
54
  origin: 'request',
55
55
  ...(identity?.viaToken ? { viaToken: identity.viaToken } : {}),
56
56
  ...(identity?.viaShare ? { viaShare: identity.viaShare } : {}),
57
- ...(identity?.viaProxy ? { viaProxy: identity.viaProxy } : {}),
57
+ ...(identity?.viaProxy ? { viaProxy: proxyClaimForJob(identity.viaProxy) } : {}),
58
58
  ...(identity?.ipAddress ? { ipAddress: identity.ipAddress } : {}),
59
59
  requestedAt: now.toISOString()
60
60
  };
61
61
  }
62
+ /** The proxy claim without its key handle (#2047): the actor, grant and scopes stay for the ceiling and the record. */
63
+ function proxyClaimForJob(claim) {
64
+ const { keyHandle: _dropped, ...kept } = claim;
65
+ return kept;
66
+ }
67
+ /**
68
+ * A job context with no key handle in it (#2047). `jobContextFromRequest`
69
+ * already leaves the handle out; the job manager also passes every context it
70
+ * stores through this, so a context built by hand cannot carry a key copy's
71
+ * handle into a job or onto disk.
72
+ */
73
+ export function withoutKeyHandle(ctx) {
74
+ return ctx.viaProxy?.keyHandle === undefined ? ctx : { ...ctx, viaProxy: proxyClaimForJob(ctx.viaProxy) };
75
+ }
62
76
  /**
63
77
  * A request-origin context that also says why (#1179).
64
78
  *
@@ -64,7 +64,9 @@ export function attributedTo(ctx) {
64
64
  origin: ctx.origin,
65
65
  reason: ctx.reason ?? null,
66
66
  requestedAt: ctx.requestedAt,
67
- ...proxyMeta
67
+ // #2059: the delegation a request-origin job was queued under, by kind,
68
+ // with the same precedence as a request's own record (`actorOf`).
69
+ ...(ctx.viaToken ? { delegated: 'token' } : ctx.viaShare ? { delegated: 'share' } : proxyMeta)
68
70
  }
69
71
  };
70
72
  }
@@ -38,6 +38,7 @@ import type { Aal, AuthProvider, AuthInitiateContext, AuthVerifyCredentials, Phi
38
38
  import { type OutsideProviderIcon } from '../providers/OidcAuthProvider.js';
39
39
  import { type PasskeyRelyingParty } from '../providers/PasskeyAuthProvider.js';
40
40
  import { type CredentialKind, type CredentialRecord } from '../providers/BaseCredentialsProvider.js';
41
+ import { type ActorContext } from '../context/ActorContext.js';
41
42
  import { type AuditEventName } from '../utils/auditEventNames.js';
42
43
  import { type ScopeDescription } from '../utils/permissionCatalog.js';
43
44
  import type { Request } from 'express';
@@ -360,6 +361,32 @@ export type ProxyGrantSide = {
360
361
  };
361
362
  /** Where a signed-in person chooses whom to act for, and switches back (#1762 slice 4). */
362
363
  export declare const ACTING_FOR_PATH = "/account/acting-for";
364
+ /** Where an invitation link opens (#2049): `GET` shows it, `POST` uses it. */
365
+ export declare const INVITATION_PATH = "/invitation";
366
+ /** Where an invited user sets up their sign-in after the link signs them in (#2049). */
367
+ export declare const ACCOUNT_SETUP_PATH = "/account/setup";
368
+ /** How many days an invitation link stays usable (#2049); read on every call. */
369
+ export declare const INVITATION_TTL_KEY = "ngdpbase.auth.invitation.ttl-days";
370
+ /** An invitation as an administrator sees it (#2049): never the link or its token. */
371
+ export interface InvitationView {
372
+ id: string;
373
+ /** The account invited. */
374
+ username: string;
375
+ /** Who sent it. */
376
+ createdBy: string;
377
+ /** RFC 3339. */
378
+ createdAt: string;
379
+ /** RFC 3339: after this the link does nothing. */
380
+ expiresAt: string;
381
+ }
382
+ /** What sending an invitation came to (#2049); `reason` is plain words for the administrator. */
383
+ export type InviteOutcome = {
384
+ outcome: 'sent';
385
+ invitation: InvitationView;
386
+ } | {
387
+ outcome: 'email-unavailable' | 'send-failed' | 'not-invitable';
388
+ reason: string;
389
+ };
363
390
  /**
364
391
  * Why a session started or stopped acting for someone (#1762 slice 4), as the
365
392
  * `proxy-switch` record's `reason` says it: the person chose a grant, chose
@@ -1166,6 +1193,83 @@ declare class AuthManager extends BaseManager {
1166
1193
  * the one that started the session. Null when the result is no sign-in.
1167
1194
  */
1168
1195
  reauthenticated(signIn: SignInRecord | undefined, result: AuthenticateResult): SignInRecord | null;
1196
+ /** How long an invitation link stays usable: `ngdpbase.auth.invitation.ttl-days`, read on every call. */
1197
+ invitationTtlMs(): number;
1198
+ /**
1199
+ * Why no invitation can be sent on this site now, in plain words for the
1200
+ * administrator, or null when one can: email set up and working, an
1201
+ * explicit site address for the link, and the credentials store open.
1202
+ */
1203
+ invitationsUnavailable(): string | null;
1204
+ /** Sending, resending and cancelling an invitation is creating an account's way in: `user-create` (#2049). */
1205
+ private mayInvite;
1206
+ /** The live and expired invitation rows of the store, or none while it is closed. */
1207
+ private invitationRows;
1208
+ /**
1209
+ * Why `username` cannot be invited, in plain words, or null when they can:
1210
+ * an active account of this site, with an email address, no password, and
1211
+ * never signed in. An account with a way in needs no invitation.
1212
+ */
1213
+ private notInvitable;
1214
+ /**
1215
+ * Send `username` an invitation (#2049): an email with a one-time link to
1216
+ * set up their sign-in. Needs `user-create` (throws `Permission denied`).
1217
+ * A new invitation replaces any earlier one, whose link stops working
1218
+ * (resend). Recorded first, as `invitation-send` (refuse on failure), so no
1219
+ * link exists that the audit log does not know of. When email cannot be
1220
+ * sent the answer says so and nothing is kept; a send that fails removes
1221
+ * the link again, recorded as `invitation-cancel` with reason `not-sent`.
1222
+ * The message goes through EmailManager with the caller's context.
1223
+ */
1224
+ inviteUser(ctx: ActorContext, username: string): Promise<InviteOutcome>;
1225
+ /**
1226
+ * Cancel `username`'s invitation (#2049): its link stops working. Needs
1227
+ * `user-create` (throws `Permission denied`). Recorded first, as
1228
+ * `invitation-cancel` (refuse on failure). False when none was waiting.
1229
+ */
1230
+ cancelInvitation(ctx: ActorContext, username: string): Promise<boolean>;
1231
+ /**
1232
+ * The invitations waiting to be used, for the administrator's user list
1233
+ * (#2049). Reading is not a change: it asks whether the caller holds
1234
+ * `user-read`. Expired ones are removed first, each recorded as
1235
+ * `invitation-expire`.
1236
+ */
1237
+ listInvitations(ctx: ActorContext): Promise<InvitationView[]>;
1238
+ /** The live invitation a link's token names, or null: unknown, used, or expired (an expired one is removed, recorded). */
1239
+ private liveInvitation;
1240
+ /**
1241
+ * Who an invitation link is for, to show before it is used (#2049), or
1242
+ * null. Opening the link changes nothing (mail scanners open links): the
1243
+ * page's button posts to `useInvitation`, behind the session's CSRF token.
1244
+ */
1245
+ invitationFor(token: unknown): Promise<{
1246
+ username: string;
1247
+ displayName: string;
1248
+ expiresAt: string;
1249
+ } | null>;
1250
+ /**
1251
+ * Use an invitation link (#2049): spend it, then sign its user in once
1252
+ * through `completeSignIn` (a new session; the terms prompt first when
1253
+ * terms are waiting), at AAL1 as an email link, so a role above that
1254
+ * steps down as for any such sign-in. Returns where to go next
1255
+ * (`ACCOUNT_SETUP_PATH`, or the terms prompt on the way there), or null
1256
+ * when the link is unknown, used, expired, or its account is turned off.
1257
+ *
1258
+ * The row's removal is the single-use gate: the store deletes in memory
1259
+ * before it writes, so of two requests with one link exactly one sees
1260
+ * true. Recorded as `invitation-use`; the sign-in itself as
1261
+ * `authentication-success`, method `invitation`.
1262
+ */
1263
+ useInvitation(req: Request, token: unknown): Promise<string | null>;
1264
+ /** Remove every invitation past its end date, each recorded as `invitation-expire`. */
1265
+ private sweepInvitations;
1266
+ private expireInvitation;
1267
+ /** End one invitation the caller may end: recorded first (`invitation-cancel`, refuse), then removed. */
1268
+ private endInvitation;
1269
+ /** An invitation record: `user` who acted (the sender, the canceller, the system), `resource` the account invited. Never the token. */
1270
+ private auditInvitation;
1271
+ /** `invitation-use`: the invited user, before any session exists, from the request's address and browser. */
1272
+ private recordInvitationUse;
1169
1273
  private pendingSignIns;
1170
1274
  private approvalTokens;
1171
1275
  private enrolTokens;
@@ -1241,6 +1345,13 @@ declare class AuthManager extends BaseManager {
1241
1345
  * scanner opens links too.
1242
1346
  */
1243
1347
  approvalDetails(token: string, opener?: ApprovalVisitor): Promise<ApprovalDetails | null>;
1348
+ /**
1349
+ * `at` as `username` reads it: in their own time zone (their `timezone`
1350
+ * preference, else `ngdpbase.default.timezone`), locale and clock, with the
1351
+ * zone named (#1532). For a message sent to them, where no page script can
1352
+ * show it in local time; the approval request and an invitation (#2049).
1353
+ */
1354
+ private localTimeFor;
1244
1355
  /**
1245
1356
  * What an approval request says about a waiting sign-in (#1532), one source
1246
1357
  * for the message and the page: the site's name, when it was asked in the
@@ -124,6 +124,8 @@ export declare function isCompanionOf(filename: string, documentFilename: string
124
124
  * and the Backup help page list the same.
125
125
  */
126
126
  export declare const NEVER_IN_A_BACKUP: readonly string[];
127
+ /** The backup entry a registered manager's part is recorded under (#2038): its name, unless it was renamed. */
128
+ export declare function managerEntryName(registered: string): string;
127
129
  /** The backup document entry, and the site restore part, an add-on's files are recorded under (#1985). */
128
130
  export declare function addonEntryName(slug: string): string;
129
131
  /**
@@ -575,6 +577,10 @@ declare class BackupManager extends BaseManager {
575
577
  */
576
578
  restoreFromFile(ctx: ActorContext, backupPath: string, options?: RestoreOptions): Promise<RestoreResults>;
577
579
  private restoreFromFileNow;
580
+ /** The registered managers, by the entry each is recorded under in a backup (#2038), in registration order. */
581
+ private registeredEntries;
582
+ /** The manager a backup entry belongs to (#2038), or undefined when this site has none. */
583
+ private managerFor;
578
584
  /** Whether this site's manager of that name stages its restore (#1876), or its entry is declared folders (#1985). */
579
585
  private restoresStaged;
580
586
  /**
@@ -61,7 +61,8 @@ const RESTORE_LABELS = {
61
61
  BackgroundJobManager: 'scheduled-job state',
62
62
  AttachmentManager: 'attachment files',
63
63
  RoleManager: 'role membership',
64
- PersonManager: 'persons',
64
+ // IdentityManager's entry keeps its old name (#2038, MANAGER_ENTRY_NAMES).
65
+ PersonManager: 'identity records (of users and contacts)',
65
66
  OrganizationManager: 'organizations',
66
67
  FootnoteManager: 'footnotes on public pages',
67
68
  CommentManager: 'comments on public pages',
@@ -83,6 +84,24 @@ const ADDON_ENTRY_NAMES = {
83
84
  calendar: 'CalendarDataManager',
84
85
  feeds: 'FeedManager'
85
86
  };
87
+ /**
88
+ * A core manager renamed after its entry was first written keeps that entry
89
+ * (#2038): IdentityManager was PersonManager, and its part is still recorded
90
+ * as `PersonManager` in the backup document and the site restore record, so
91
+ * an old backup restores and an older site can restore a new one. Keyed by
92
+ * the registered name; an entry is a stored name, never renamed.
93
+ */
94
+ const MANAGER_ENTRY_NAMES = {
95
+ IdentityManager: 'PersonManager'
96
+ };
97
+ /** The backup entry a registered manager's part is recorded under (#2038): its name, unless it was renamed. */
98
+ export function managerEntryName(registered) {
99
+ return Object.hasOwn(MANAGER_ENTRY_NAMES, registered) ? MANAGER_ENTRY_NAMES[registered] : registered;
100
+ }
101
+ /** The registered manager a backup entry belongs to (#2038): the inverse of {@link managerEntryName}. */
102
+ function registeredNameOf(entry) {
103
+ return Object.entries(MANAGER_ENTRY_NAMES).find(([, e]) => e === entry)?.[0] ?? entry;
104
+ }
86
105
  /** The backup document entry, and the site restore part, an add-on's files are recorded under (#1985). */
87
106
  export function addonEntryName(slug) {
88
107
  return ADDON_ENTRY_NAMES[slug] ?? `addon:${slug}`;
@@ -99,7 +118,7 @@ export function applyStagedPartsAtStart(engine, manager, declared) {
99
118
  const configManager = engine?.getManager?.('ConfigurationManager');
100
119
  if (!configManager)
101
120
  return null;
102
- return applyDeclaredPartsAtStart(manager, declared, configManager);
121
+ return applyDeclaredPartsAtStart(managerEntryName(manager), declared, configManager);
103
122
  }
104
123
  /** The site restore manifest's file, as configured; a module function for the start, before BackupManager exists (#1986). */
105
124
  function manifestFileOf(engine) {
@@ -526,7 +545,7 @@ class BackupManager extends BaseManager {
526
545
  managers: {}
527
546
  };
528
547
  // Get all manager names from engine
529
- const managerNames = this.engine.getRegisteredManagers();
548
+ const managerNames = this.registeredEntries();
530
549
  logger.info(`📋 Found ${managerNames.length} registered managers`);
531
550
  // #1723: a manager may write its own files (the databases) beside the
532
551
  // document; they share its stem and are pruned with it.
@@ -550,7 +569,7 @@ class BackupManager extends BaseManager {
550
569
  continue;
551
570
  }
552
571
  try {
553
- const manager = this.engine.getManager(managerName);
572
+ const manager = this.managerFor(managerName);
554
573
  const parts = declared.get(managerName) ?? [];
555
574
  if (!manager && parts.length === 0) {
556
575
  logger.warn(`⚠️ Manager not found: ${managerName}`);
@@ -1318,7 +1337,7 @@ class BackupManager extends BaseManager {
1318
1337
  continue;
1319
1338
  }
1320
1339
  try {
1321
- const manager = this.engine.getManager(managerName);
1340
+ const manager = this.managerFor(managerName);
1322
1341
  if (!manager) {
1323
1342
  logger.warn(`⚠️ Manager not found: ${managerName}, skipping`);
1324
1343
  results.skipped.push(managerName);
@@ -1361,11 +1380,19 @@ class BackupManager extends BaseManager {
1361
1380
  throw error;
1362
1381
  }
1363
1382
  }
1383
+ /** The registered managers, by the entry each is recorded under in a backup (#2038), in registration order. */
1384
+ registeredEntries() {
1385
+ return this.engine.getRegisteredManagers().map(managerEntryName);
1386
+ }
1387
+ /** The manager a backup entry belongs to (#2038), or undefined when this site has none. */
1388
+ managerFor(entry) {
1389
+ return this.engine.getManager(registeredNameOf(entry));
1390
+ }
1364
1391
  /** Whether this site's manager of that name stages its restore (#1876), or its entry is declared folders (#1985). */
1365
1392
  restoresStaged(name) {
1366
1393
  if (this.declaredFor(name).length > 0)
1367
1394
  return true;
1368
- const manager = this.engine.getManager(name);
1395
+ const manager = this.managerFor(name);
1369
1396
  return typeof manager?.restoreIsStaged === 'function' && manager.restoreIsStaged();
1370
1397
  }
1371
1398
  /**
@@ -1384,7 +1411,7 @@ class BackupManager extends BaseManager {
1384
1411
  }
1385
1412
  /** What a manager declares, and an add-on's folders recorded under the same entry (#1985). */
1386
1413
  declaredFor(name, forBackup = false) {
1387
- const manager = this.engine.getManager(name);
1414
+ const manager = this.managerFor(name);
1388
1415
  let own = [];
1389
1416
  try {
1390
1417
  own = typeof manager?.backupFolders === 'function' ? manager.backupFolders() : [];
@@ -1396,7 +1423,7 @@ class BackupManager extends BaseManager {
1396
1423
  }
1397
1424
  /** Every entry with declared folders or files, in registration order, add-ons' after (#1985). */
1398
1425
  declaredParts(options = {}) {
1399
- const names = [...this.engine.getRegisteredManagers(), ...this.addonParts().map((a) => a.entry)];
1426
+ const names = [...this.registeredEntries(), ...this.addonParts().map((a) => a.entry)];
1400
1427
  const out = new Map();
1401
1428
  for (const name of names) {
1402
1429
  if (out.has(name) || name === 'BackupManager')
@@ -1492,7 +1519,7 @@ class BackupManager extends BaseManager {
1492
1519
  await stageDeclaredParts(name, declared, backupData.managers[name], restoreOptions);
1493
1520
  continue;
1494
1521
  }
1495
- await this.engine.getManager(name).restore(backupData.managers[name], restoreOptions);
1522
+ await this.managerFor(name).restore(backupData.managers[name], restoreOptions);
1496
1523
  }
1497
1524
  // #1876: the instance .env, BackupManager's own part; applied at the very start of the process.
1498
1525
  const env = backupData.managers.BackupManager?.data?.env;
@@ -1729,7 +1756,7 @@ class BackupManager extends BaseManager {
1729
1756
  }
1730
1757
  /** The managers on this site whose restore is staged (#1876), in registration order: what a site restore stages. */
1731
1758
  stagedManagerNames() {
1732
- const names = this.engine.getRegisteredManagers().filter((name) => this.restoresStaged(name));
1759
+ const names = this.registeredEntries().filter((name) => this.restoresStaged(name));
1733
1760
  // #1985: an add-on's files under an entry no manager of that name has (`addon:<slug>`).
1734
1761
  for (const { entry } of this.addonParts())
1735
1762
  if (!names.includes(entry))
@@ -1750,7 +1777,7 @@ class BackupManager extends BaseManager {
1750
1777
  .filter((name) => name !== 'DatabaseManager' || databases?.isEnabled?.() !== false)
1751
1778
  .map(restoreLabel);
1752
1779
  const notRestored = Object.keys(RESTORE_LABELS)
1753
- .filter((name) => name !== 'BackupManager' && this.engine.getManager(name) && !this.restoresStaged(name))
1780
+ .filter((name) => name !== 'BackupManager' && this.managerFor(name) && !this.restoresStaged(name))
1754
1781
  .map(restoreLabel);
1755
1782
  return { restored, notRestored: [...notRestored, ...NEVER_IN_A_BACKUP] };
1756
1783
  }
@@ -11,8 +11,12 @@ export interface LinkOfferDetails {
11
11
  /** The outcome of offering a sign-up link (#1930). */
12
12
  export type LinkOfferOutcome = 'sent' | 'no-match' | 'several' | 'unavailable' | 'failed';
13
13
  /**
14
- * PersonManager — canonical core record for persons (#617), and the door for
15
- * contacts (#1930).
14
+ * IdentityManager — the identity record of every digital identity, user or
15
+ * contact (#617), and the door for them (#1930). Named PersonManager until
16
+ * #2038; `getManager('PersonManager')` still answers with it for one release
17
+ * (`MANAGER_ALIASES` in `src/core/Engine.ts`). What is stored keeps its
18
+ * "person" names: `data/persons/`, `ngdpbase.application.persons.*`, the
19
+ * `Person` types, `person:<uuid>`, the `person-*` audit actions.
16
20
  *
17
21
  * A person may have an account or not. Each account has exactly one person
18
22
  * (`identifier` = the username); a person with no account has the identifier
@@ -20,14 +24,14 @@ export type LinkOfferOutcome = 'sent' | 'no-match' | 'several' | 'unavailable' |
20
24
  * the account lifecycle under a system context.
21
25
  *
22
26
  * The door: `create`, `update`, `delete`, `get`, `list`, `search` and
23
- * `linkAccount` take the caller's context first and ask the PDP: `contact-read`
24
- * to list, search or get; `contact-manage` to change (`linkAccount` also `user-edit`). Each change is recorded as
25
- * the `contact-manage` audit event. A refusal throws `ContactRefusedError`.
27
+ * `linkAccount` take the caller's context first and ask the PDP: `identity-read`
28
+ * to list, search or get; `identity-manage` to change (`linkAccount` also `user-edit`). Each change is recorded as
29
+ * the `identity-manage` audit event. A refusal throws `ContactRefusedError`.
26
30
  *
27
31
  * Every lookup takes the caller's context too (#2018): `getById`,
28
32
  * `getByIdentifier` and `getByUserIdentifier`. A user reads their own identity
29
33
  * record, and `update` changes it, under `profile-manage`; anyone else's needs
30
- * `contact-read` / `contact-manage`. Core's own reads (RoleManager resolving
34
+ * `identity-read` / `identity-manage`. Core's own reads (RoleManager resolving
31
35
  * roles on every request, UserManager's account lifecycle) pass the system
32
36
  * context, allowed without a policy evaluation (`requireRecordAccess`).
33
37
  *
@@ -40,8 +44,8 @@ export type LinkOfferOutcome = 'sent' | 'no-match' | 'several' | 'unavailable' |
40
44
  * `getByUserIdentifier(ctx, username)` returns a user's identity record and
41
45
  * makes it when the user has none (#2037): every user has exactly one.
42
46
  */
43
- declare class PersonManager extends BaseManager {
44
- readonly description = "Canonical Person records (#617)";
47
+ declare class IdentityManager extends BaseManager {
48
+ readonly description = "Identity records of users and contacts (#617)";
45
49
  private provider;
46
50
  private providerClass?;
47
51
  /** `ngdpbase.application.persons.storagedir`, resolved at start: what a backup copies (#1876). */
@@ -65,7 +69,7 @@ declare class PersonManager extends BaseManager {
65
69
  getProvider(): PersonProvider | null;
66
70
  /**
67
71
  * One identity record by `@id` (#2018): the caller's own under
68
- * `profile-manage`, anyone else's under `contact-read`, core's own work
72
+ * `profile-manage`, anyone else's under `identity-read`, core's own work
69
73
  * under the system context. A refusal throws `ContactRefusedError`.
70
74
  */
71
75
  getById(ctx: ActorContext, id: string): Promise<Person | null>;
@@ -80,9 +84,9 @@ declare class PersonManager extends BaseManager {
80
84
  getManagerStats(): Promise<ManagerStats>;
81
85
  /** True when the person has no account: its identifier is `person:<uuid>` (#1930). */
82
86
  static isAccountless(person: Pick<Person, 'identifier'>): boolean;
83
- /** Every person, for a caller who may read contacts (`contact-read`). */
87
+ /** Every person, for a caller who may read contacts (`identity-read`). */
84
88
  list(ctx: ActorContext): Promise<Person[]>;
85
- /** One person by `@id`: {@link getById}, the caller's own under `profile-manage`, anyone else's under `contact-read`. */
89
+ /** One person by `@id`: {@link getById}, the caller's own under `profile-manage`, anyone else's under `identity-read`. */
86
90
  get(ctx: ActorContext, id: string): Promise<Person | null>;
87
91
  /**
88
92
  * Persons whose name (`name`, `givenName`, `familyName`) or `email` contains
@@ -103,13 +107,13 @@ declare class PersonManager extends BaseManager {
103
107
  /**
104
108
  * Change a person's fields, affiliations included. `@id` and `identifier`
105
109
  * never change here. The caller's own identity record under
106
- * `profile-manage`, anyone else's under `contact-manage` (#2018).
110
+ * `profile-manage`, anyone else's under `identity-manage` (#2018).
107
111
  */
108
112
  update(ctx: ActorContext, id: string, patch: PersonUpdate): Promise<Person | null>;
109
113
  delete(ctx: ActorContext, id: string): Promise<boolean>;
110
114
  /**
111
115
  * An administrator joins a person who has no account to an account (#1930),
112
- * for when the email differs or none was given. Asks `contact-manage` and
116
+ * for when the email differs or none was given. Asks `identity-manage` and
113
117
  * `user-edit`. The person keeps its `@id`; the account's own person, if it
114
118
  * has one, is folded in and removed.
115
119
  */
@@ -163,11 +167,11 @@ declare class PersonManager extends BaseManager {
163
167
  * store) gets it here, from the account's display name and email.
164
168
  *
165
169
  * The read is decided as {@link getByIdentifier} (#2018): a signed-in user's
166
- * own under `profile-manage`, anyone else's under `contact-read`, core's own
170
+ * own under `profile-manage`, anyone else's under `identity-read`, core's own
167
171
  * work under the system context. Making the missing record is core's
168
172
  * bookkeeping, not the caller's contact management, so it goes through
169
173
  * {@link create} under the system context, naming the caller, and is
170
- * recorded as `contact-manage`. One at a time per username, so two lookups
174
+ * recorded as `identity-manage`. One at a time per username, so two lookups
171
175
  * never make two records. Null when there is no account by that name.
172
176
  * Never a second record: a contact with the same email is left alone
173
177
  * (an administrator links it, {@link linkAccount}).
@@ -178,4 +182,4 @@ declare class PersonManager extends BaseManager {
178
182
  private normalizeProviderName;
179
183
  }
180
184
  export { ContactRefusedError } from '../utils/contactAccess.js';
181
- export default PersonManager;
185
+ export default IdentityManager;
@@ -88,13 +88,13 @@ declare class OrganizationManager extends BaseManager {
88
88
  * problem actually bites.
89
89
  */
90
90
  getInstallOrg(): Promise<Organization | null>;
91
- /** One organization by `@id`, for a caller who may read contacts (`contact-read`), or core's own work (#2018). */
91
+ /** One organization by `@id`, for a caller who may read contacts (`identity-read`), or core's own work (#2018). */
92
92
  getById(ctx: ActorContext, id: string): Promise<Organization | null>;
93
93
  /** One organization by its file name, decided as {@link getById} (#2018). */
94
94
  getByFile(ctx: ActorContext, filename: string): Promise<Organization | null>;
95
95
  /** #1006: how many organizations this instance holds. Count only. */
96
96
  getManagerStats(): Promise<ManagerStats>;
97
- /** Every organization, for a caller who may read contacts (`contact-read`). */
97
+ /** Every organization, for a caller who may read contacts (`identity-read`). */
98
98
  list(ctx: ActorContext): Promise<Organization[]>;
99
99
  /** One organization by `@id`: {@link getById}. */
100
100
  get(ctx: ActorContext, id: string): Promise<Organization | null>;
@@ -45,7 +45,7 @@ declare class RoleManager extends BaseManager {
45
45
  backupFolders(): BackupFolder[];
46
46
  /** Provider accessor, mainly for tests. */
47
47
  getProvider(): RoleProvider | null;
48
- /** One role record by `@id`: its members are identity records, so `contact-read`, or core's own work (#2018). */
48
+ /** One role record by `@id`: its members are identity records, so `identity-read`, or core's own work (#2018). */
49
49
  getById(ctx: ActorContext, id: string): Promise<Role | null>;
50
50
  getByOrgAndPosition(organizationId: string, namedPosition: string): Promise<Role | null>;
51
51
  listByMember(personId: string): Promise<Role[]>;
@@ -67,13 +67,13 @@ declare class RoleManager extends BaseManager {
67
67
  * The pseudo-roles `'Authenticated'` and `'All'` are NOT added here — the
68
68
  * caller adds those when constructing `userContext.roles`.
69
69
  *
70
- * Returns `[]` when PersonManager is unavailable, there is no account by
70
+ * Returns `[]` when IdentityManager is unavailable, there is no account by
71
71
  * that username, no record lists them, or the lookup throws. An account
72
72
  * missing its identity record gets it here (#2037).
73
73
  *
74
- * `ctx` is who is asking (#2018), decided by PersonManager's door: a user
74
+ * `ctx` is who is asking (#2018), decided by IdentityManager's door: a user
75
75
  * about themselves under `profile-manage`, about anyone else under
76
- * `contact-read`; core's own work passes the system context. A refusal is
76
+ * `identity-read`; core's own work passes the system context. A refusal is
77
77
  * thrown as `ContactRefusedError`, never answered as "no roles".
78
78
  */
79
79
  resolveUserRoles(ctx: ActorContext, username: string): Promise<string[]>;
@@ -114,7 +114,7 @@ declare class RoleManager extends BaseManager {
114
114
  * All or nothing (#2015): when any change cannot be made, the ones already
115
115
  * made are undone and a `RoleChangeError` is thrown, after it is logged as
116
116
  * an error and admins are notified. That includes a change that cannot even
117
- * be attempted: no PersonManager, no Person record for the username, or no
117
+ * be attempted: no IdentityManager, no Person record for the username, or no
118
118
  * anchor Organization.
119
119
  */
120
120
  applyRoleDiff(username: string, oldRoles: string[], newRoles: string[]): Promise<void>;
@@ -139,9 +139,9 @@ declare class RoleManager extends BaseManager {
139
139
  grantToEveryAccountOnce(roleName: string, usernames: readonly string[], ctx: JobContext): Promise<number>;
140
140
  /**
141
141
  * Move every membership held by one Person `@id` to another (#1930): when
142
- * PersonManager joins an account to an existing person, the roles the
142
+ * IdentityManager joins an account to an existing person, the roles the
143
143
  * account's person held go with the account. Returns how many records
144
- * changed. PersonManager's join records the change. All or nothing (#2015).
144
+ * changed. IdentityManager's join records the change. All or nothing (#2015).
145
145
  */
146
146
  moveMemberships(fromPersonId: string, toPersonId: string): Promise<number>;
147
147
  /**
@@ -343,9 +343,9 @@ declare class UserManager extends BaseManager {
343
343
  /**
344
344
  * Every account has exactly one identity record, made by `createUser`
345
345
  * (#2037). One that has none (written straight into the user store, or
346
- * whose record was lost) gets it at boot, once, through PersonManager's
346
+ * whose record was lost) gets it at boot, once, through IdentityManager's
347
347
  * door under the system context, each creation recorded as
348
- * `contact-manage`. The identity records are listed once and only the
348
+ * `identity-manage`. The identity records are listed once and only the
349
349
  * missing ones are made, so a large user store costs one read. A failure
350
350
  * is logged and the boot goes on: the role paths make the record when the
351
351
  * account is next used (`getByUserIdentifier`).
@@ -457,6 +457,16 @@ declare class UserManager extends BaseManager {
457
457
  * which accounts exist.
458
458
  */
459
459
  resetPasswordWithRecoveryWords(username: string, words: string, newPassword: string, ctx: ActorContext): Promise<boolean>;
460
+ /**
461
+ * A user's first password, chosen by themselves (#2049: after an
462
+ * invitation signed them in). Only for their own account, signed in as
463
+ * themselves (no token, share link or proxy), under `account-security`,
464
+ * and only while the account has no password: a password already set is
465
+ * changed on the profile, which asks for the current one. `false`,
466
+ * changing nothing, otherwise. The length rule is the route's, as for
467
+ * every password form. Recorded like any password change (`user-edit`).
468
+ */
469
+ setFirstPassword(username: string, newPassword: string, ctx: PermissionSubject): Promise<boolean>;
460
470
  /**
461
471
  * Change an account (#2025: one change at a time per account).
462
472
  *
@@ -517,6 +527,11 @@ declare class UserManager extends BaseManager {
517
527
  /**
518
528
  * Search users by username, displayName, or email (case-insensitive substring).
519
529
  * Optionally filter by role and active status.
530
+ *
531
+ * Decides `search-user` itself, with `ctx` (#2018): every user holds it as
532
+ * shipped, anonymous does not. A refusal throws `ContactRefusedError`. Core's
533
+ * own lookups (a notice's recipients, a role's holders) pass the system
534
+ * context with a reason, allowed without a policy evaluation.
520
535
  */
521
536
  searchUsers(query: string, options: {
522
537
  role?: string;
@@ -524,10 +539,10 @@ declare class UserManager extends BaseManager {
524
539
  activeOnly?: boolean;
525
540
  }, ctx: ActorContext): Promise<Omit<User, 'password'>[]>;
526
541
  /**
527
- * The account lifecycle reaches PersonManager's door under a system context
542
+ * The account lifecycle reaches IdentityManager's door under a system context
528
543
  * (#1930): keeping an account's person in step with the account is core's
529
544
  * own bookkeeping, not the caller's contact management, so it never depends
530
- * on the caller holding `contact-manage`. The reason names the account and
545
+ * on the caller holding `identity-manage`. The reason names the account and
531
546
  * who changed it, so the record says why.
532
547
  */
533
548
  private personSyncContext;
@@ -542,7 +557,7 @@ declare class UserManager extends BaseManager {
542
557
  private syncPersonOnCreate;
543
558
  /**
544
559
  * At sign-up, offer the new account a person with no account who has the
545
- * same email (#1930): PersonManager mails an "Is this you?" link, and joins
560
+ * same email (#1930): IdentityManager mails an "Is this you?" link, and joins
546
561
  * them only when the account confirms. Skipped when email is off. Never
547
562
  * fails the account write.
548
563
  */
@@ -24,8 +24,14 @@ export declare const CREDENTIALS_KEY_ENV = "NGDPBASE_CREDENTIALS_KEY";
24
24
  * represented, `subject` the grant id, `secret` the grant's terms as JSON
25
25
  * (whom it is for, the permissions, who made it, when it ends). Never a way
26
26
  * in; AuthManager reads and writes it only through its proxy-grant methods.
27
+ *
28
+ * `invitation` (#2049): a one-time link that lets a new user set up their
29
+ * sign-in — `username` the account invited, `subject` the SHA-256 of the
30
+ * link's token (the token itself is never stored), `secret` the terms as JSON
31
+ * (who sent it, when it ends). Not a sign-in method; AuthManager reads and
32
+ * writes it only through its invitation methods.
27
33
  */
28
- export type CredentialKind = 'passkey' | 'totp' | 'email' | 'sms' | 'device' | 'identity' | 'proxy';
34
+ export type CredentialKind = 'passkey' | 'totp' | 'email' | 'sms' | 'device' | 'identity' | 'proxy' | 'invitation';
29
35
  export declare const CREDENTIAL_KINDS: readonly CredentialKind[];
30
36
  /** One credential, as stored. */
31
37
  export interface CredentialRecord {
@@ -43,6 +43,10 @@ export declare class PolicyDecisionPoint extends BaseManager {
43
43
  * derived, never stored.
44
44
  */
45
45
  constructor(engine: WikiEngine);
46
+ /** Each `<policy id> <retired name>` already warned about (#2038): said once per process. */
47
+ private readonly warnedRetired;
48
+ /** Warn, once per policy and name, that a policy names a retired permission and what it is read as (#2038). */
49
+ private warnRetiredOnce;
46
50
  /**
47
51
  * The policies in force right now, highest priority first.
48
52
  *
@@ -22,10 +22,10 @@ export type CorePermission =
22
22
  | 'comment-create'
23
23
  /** Change any configuration setting, and download a backup (#1525). Admin only by default; asks for a fresh sign-in when listed in ngdpbase.auth.step-up */
24
24
  | 'config-manage'
25
- /** Create, edit, delete and link contacts: persons (with or without an account) and organizations, and a person's affiliations, through PersonManager and OrganizationManager (#1930). Reading them is contact-read. Admins by default; an add-on grants it to its own roles in its own policy */
26
- | 'contact-manage'
27
- /** List, search and view contacts: persons (with or without an account) and organizations, and their affiliations, through PersonManager and OrganizationManager, and the /admin/contacts and /admin/organizations views (#1930). Admins and demo admins by default; an add-on grants it to its own roles in its own policy */
28
- | 'contact-read'
25
+ /** Create, edit, delete and link identity records, of users and contacts alike, and organizations, and an identity's affiliations, through IdentityManager and OrganizationManager (#1930). Reading them is identity-read. Was contact-manage until #2038; a policy naming that is still read as this for one release. Admins by default; an add-on grants it to its own roles in its own policy */
26
+ | 'identity-manage'
27
+ /** List, search and view identity records, of users and contacts alike, and organizations, and their affiliations, through IdentityManager and OrganizationManager, and the /admin/contacts and /admin/organizations views (#1930). Was contact-read until #2038; a policy naming that is still read as this for one release. Admins and demo admins by default; an add-on grants it to its own roles in its own policy */
28
+ | 'identity-read'
29
29
  /** Browse and view the media library (not delegated to anonymous visitors) */
30
30
  | 'media-read'
31
31
  /** Create new pages */
@@ -312,7 +312,7 @@ export interface ApplicationOrganizationConfig {
312
312
  'ngdpbase.application.persons.provider.default': string;
313
313
  /** Active Person provider class (lowercased; falls back to .default) */
314
314
  'ngdpbase.application.persons.provider': string;
315
- /** #1930: the most results `PersonManager.search` returns when the caller gives no limit */
315
+ /** #1930: the most results `IdentityManager.search` returns when the caller gives no limit */
316
316
  'ngdpbase.application.persons.search.limit': number;
317
317
  /** #1930: minutes the emailed "Is this you?" sign-up link stays usable */
318
318
  'ngdpbase.application.persons.link-offer.ttl-minutes': number;
@@ -12,11 +12,15 @@
12
12
  * @see https://schema.org/Organization
13
13
  */
14
14
  /**
15
- * Schema.org PostalAddress (subset used by Organization)
15
+ * Schema.org PostalAddress (subset used by Organization/Person).
16
+ *
17
+ * In schema.org a PostalAddress is a ContactPoint, so it carries the
18
+ * ContactPoint fields too: a `contactPoint[]` entry may be one (a mailing or
19
+ * business address, with its `contactType`, `name` and `telephone`).
16
20
  *
17
21
  * @see https://schema.org/PostalAddress
18
22
  */
19
- export interface PostalAddress {
23
+ export interface PostalAddress extends Omit<ContactPoint, '@type'> {
20
24
  '@type': 'PostalAddress';
21
25
  streetAddress?: string | null;
22
26
  addressLocality?: string | null;
@@ -32,11 +36,19 @@ export interface PostalAddress {
32
36
  export interface ContactPoint {
33
37
  '@type': 'ContactPoint';
34
38
  contactType?: string;
39
+ /** Who or what this contact point reaches, e.g. an agent's or an emergency contact's name (schema.org Thing.name). */
40
+ name?: string;
35
41
  email?: string;
36
42
  telephone?: string;
37
43
  url?: string;
38
44
  availableLanguage?: string[];
39
45
  }
46
+ /**
47
+ * One `contactPoint[]` entry: a ContactPoint, or a PostalAddress (schema.org's
48
+ * ContactPoint subtype). Typed by `contactType` (`mailing`, `business`,
49
+ * `agent`, `emergency`, …); the home address stays in `address`.
50
+ */
51
+ export type ContactPointEntry = ContactPoint | PostalAddress;
40
52
  /**
41
53
  * Canonical Organization record.
42
54
  *
@@ -62,8 +74,8 @@ export interface Organization {
62
74
  foundingDate?: string;
63
75
  /** Postal address */
64
76
  address?: PostalAddress;
65
- /** Contact points (support, sales, etc.) */
66
- contactPoint?: ContactPoint[];
77
+ /** Contact points (support, sales, a mailing address, etc.) */
78
+ contactPoint?: ContactPointEntry[];
67
79
  /** Other URLs that identify this org (LinkedIn, Twitter, etc.) */
68
80
  sameAs?: string[];
69
81
  /** Optional legacy slug or external identifier */
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * @see https://schema.org/Person
17
17
  */
18
- import type { PostalAddress, ContactPoint } from './Organization.js';
18
+ import type { PostalAddress, ContactPointEntry } from './Organization.js';
19
19
  /**
20
20
  * One organization a person is affiliated with (#1930): schema.org
21
21
  * `affiliation`, with the person's role or title there when one is known.
@@ -51,8 +51,8 @@ export interface Person {
51
51
  email?: string;
52
52
  /** Postal address */
53
53
  address?: PostalAddress;
54
- /** Contact points (work phone, mobile, etc.) */
55
- contactPoint?: ContactPoint[];
54
+ /** Everything but the home address: phones, a mailing or business address (a PostalAddress), an agent, an emergency contact; each typed by `contactType`. */
55
+ contactPoint?: ContactPointEntry[];
56
56
  /** ISO language tags this person knows */
57
57
  knowsLanguage?: string[];
58
58
  /** Profile/avatar image URL */
@@ -70,7 +70,7 @@ export interface Person {
70
70
  */
71
71
  export type PersonUpdate = Partial<Omit<Person, '@context' | '@type' | '@id' | 'identifier'>>;
72
72
  /**
73
- * What a caller gives `PersonManager.create` (#1930): the person's fields.
73
+ * What a caller gives `IdentityManager.create` (#1930): the person's fields.
74
74
  * `identifier` is left out for a person with no account (one is generated);
75
75
  * UserManager passes the account's username when it creates the person for a
76
76
  * new account.
@@ -2,7 +2,7 @@
2
2
  * Storage seam for Person records (#617)
3
3
  *
4
4
  * File system is the v1 default. Future providers (LDAP, external directory)
5
- * implement the same interface and register without touching PersonManager.
5
+ * implement the same interface and register without touching IdentityManager.
6
6
  */
7
7
  import type { BaseProvider } from './Provider.js';
8
8
  import type { Person, PersonUpdate } from './Person.js';
@@ -20,7 +20,7 @@ export interface PersonProvider extends BaseProvider {
20
20
  create(person: Person): Promise<Person>;
21
21
  /**
22
22
  * Write the whole record in place of the one with the same `@id` (#1930).
23
- * The only write that may change `identifier`: PersonManager uses it to join
23
+ * The only write that may change `identifier`: IdentityManager uses it to join
24
24
  * a person to an account.
25
25
  */
26
26
  replace(person: Person): Promise<Person>;
@@ -54,10 +54,18 @@ export declare const AUDIT_EVENT: {
54
54
  readonly CONSENT_GRANT: "consent-grant";
55
55
  /** A user's acceptances ended (#1603): today only when their account is deleted, which removes their consent records; names the documents and versions they had accepted. Recorded before the records are removed */
56
56
  readonly CONSENT_WITHDRAW: "consent-withdraw";
57
- /** Contact created, changed, deleted or linked to an account; metadata names the kind (person or organization), the operation and the fields changed, never their values */
58
- readonly CONTACT_MANAGE: "contact-manage";
59
57
  /** A page's footnote list changed — action add, import, transfer, update or delete; page content written on someone's behalf (#1233) */
60
58
  readonly FOOTNOTE_EDIT: "footnote-edit";
59
+ /** Identity record or organization created, changed, deleted or linked to an account (recorded as contact-manage before #2038); metadata names the kind (person or organization), the operation and the fields changed, never their values */
60
+ readonly IDENTITY_MANAGE: "identity-manage";
61
+ /** Invitation cancelled (#2049): an administrator cancelled it or sent a new one, the email could not be sent, or the account was deleted; the link stops working */
62
+ readonly INVITATION_CANCEL: "invitation-cancel";
63
+ /** Invitation expired (#2049): the link was not used in time and was removed */
64
+ readonly INVITATION_EXPIRE: "invitation-expire";
65
+ /** Invitation sent (#2049): a one-time link emailed to a new user to set up their sign-in; user is who sent it, resource the account invited. A credential nobody knows exists is the worst case, so it is recorded before the link exists */
66
+ readonly INVITATION_SEND: "invitation-send";
67
+ /** Invitation used (#2049): the invited user opened their link and was signed in once; the link is spent */
68
+ readonly INVITATION_USE: "invitation-use";
61
69
  /** A background job finished successfully */
62
70
  readonly JOB_COMPLETED: "job-completed";
63
71
  /** A background job failed */
@@ -61,10 +61,18 @@ export const AUDIT_EVENT = {
61
61
  CONSENT_GRANT: 'consent-grant',
62
62
  /** A user's acceptances ended (#1603): today only when their account is deleted, which removes their consent records; names the documents and versions they had accepted. Recorded before the records are removed */
63
63
  CONSENT_WITHDRAW: 'consent-withdraw',
64
- /** Contact created, changed, deleted or linked to an account; metadata names the kind (person or organization), the operation and the fields changed, never their values */
65
- CONTACT_MANAGE: 'contact-manage',
66
64
  /** A page's footnote list changed — action add, import, transfer, update or delete; page content written on someone's behalf (#1233) */
67
65
  FOOTNOTE_EDIT: 'footnote-edit',
66
+ /** Identity record or organization created, changed, deleted or linked to an account (recorded as contact-manage before #2038); metadata names the kind (person or organization), the operation and the fields changed, never their values */
67
+ IDENTITY_MANAGE: 'identity-manage',
68
+ /** Invitation cancelled (#2049): an administrator cancelled it or sent a new one, the email could not be sent, or the account was deleted; the link stops working */
69
+ INVITATION_CANCEL: 'invitation-cancel',
70
+ /** Invitation expired (#2049): the link was not used in time and was removed */
71
+ INVITATION_EXPIRE: 'invitation-expire',
72
+ /** Invitation sent (#2049): a one-time link emailed to a new user to set up their sign-in; user is who sent it, resource the account invited. A credential nobody knows exists is the worst case, so it is recorded before the link exists */
73
+ INVITATION_SEND: 'invitation-send',
74
+ /** Invitation used (#2049): the invited user opened their link and was signed in once; the link is spent */
75
+ INVITATION_USE: 'invitation-use',
68
76
  /** A background job finished successfully */
69
77
  JOB_COMPLETED: 'job-completed',
70
78
  /** A background job failed */
@@ -139,13 +139,26 @@ export interface TokenInput extends CommonInput {
139
139
  /** Only meaningful for `revoke`; ignored otherwise. */
140
140
  revokedBy?: string;
141
141
  }
142
- export interface AttachmentInput extends CommonInput {
143
- op: AttachmentOp;
142
+ /**
143
+ * What an attachment record says about the file. A file in an ENCRYPTED store
144
+ * is recorded by its id and the store only (#2048): its name, its page and its
145
+ * size are the store's to keep sealed, so the record carries none of them.
146
+ */
147
+ export type AttachmentDetail = {
144
148
  attachmentId: string;
145
149
  filename: string;
146
150
  pageName?: string | null;
147
151
  sizeBytes?: number | null;
148
- }
152
+ } | {
153
+ attachmentId: string;
154
+ sealedStore: {
155
+ owner: string;
156
+ store: string;
157
+ };
158
+ };
159
+ export type AttachmentInput = CommonInput & {
160
+ op: AttachmentOp;
161
+ } & AttachmentDetail;
149
162
  /**
150
163
  * Build the audit event for a page create, edit, rename, or link rewrite.
151
164
  *
@@ -145,18 +145,17 @@ export function buildPageViewAuditEvent(input) {
145
145
  * unattended destruction is the case someone reviewing the log is looking for.
146
146
  */
147
147
  export function buildAttachmentAuditEvent(input) {
148
- const { op, attachmentId, filename, pageName, sizeBytes, viaToken } = input;
148
+ const { op, attachmentId, viaToken } = input;
149
149
  const who = whoOf(input);
150
- const metadata = {
151
- attachmentId,
152
- filename,
153
- ...tokenMetadata(viaToken),
154
- ...who.metadata
155
- };
156
- if (pageName)
157
- metadata.pageName = pageName;
158
- if (typeof sizeBytes === 'number')
159
- metadata.sizeBytes = sizeBytes;
150
+ const metadata = 'sealedStore' in input
151
+ ? { attachmentId, owner: input.sealedStore.owner, store: input.sealedStore.store, sealed: true, ...tokenMetadata(viaToken), ...who.metadata }
152
+ : { attachmentId, filename: input.filename, ...tokenMetadata(viaToken), ...who.metadata };
153
+ if (!('sealedStore' in input)) {
154
+ if (input.pageName)
155
+ metadata.pageName = input.pageName;
156
+ if (typeof input.sizeBytes === 'number')
157
+ metadata.sizeBytes = input.sizeBytes;
158
+ }
160
159
  const severity = op === 'delete'
161
160
  ? (viaToken ? 'high' : 'medium')
162
161
  : (viaToken ? 'medium' : 'low');
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * The contacts door's one decision and its one record (#1930).
3
3
  *
4
- * PersonManager and OrganizationManager are the enforcement points for
4
+ * IdentityManager and OrganizationManager are the enforcement points for
5
5
  * contacts. Both ask the PDP the same two questions with the caller's
6
- * context: `contact-read` to list, search or view, `contact-manage` to
6
+ * context: `identity-read` to list, search or view, `identity-manage` to
7
7
  * create, change, delete or link; and both record each change as the
8
- * `contact-manage` audit event. That rule lives here once, so the two doors
8
+ * `identity-manage` audit event. That rule lives here once, so the two doors
9
9
  * cannot drift (operator, 2026-10-20).
10
10
  *
11
11
  * A refusal is thrown as {@link ContactRefusedError}, carrying the decision.
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * Every lookup takes a context too (#2018, operator 2026-10-23), through
17
17
  * {@link requireRecordAccess}: a user's own identity record under
18
- * `profile-manage`, anyone else's under `contact-read` / `contact-manage`, and
18
+ * `profile-manage`, anyone else's under `identity-read` / `identity-manage`, and
19
19
  * core's own reads under the system context without a policy evaluation, so
20
20
  * resolving roles on every request stays cheap.
21
21
  */
@@ -27,8 +27,8 @@ type EngineLike = {
27
27
  };
28
28
  /** What a contacts door asks: `read` to list, search or view; `manage` to change. */
29
29
  export declare const CONTACT_PERMISSION: {
30
- readonly read: "contact-read";
31
- readonly manage: "contact-manage";
30
+ readonly read: "identity-read";
31
+ readonly manage: "identity-manage";
32
32
  };
33
33
  /** A contacts door said no. `permission` is what was refused; `decision` is the PDP's answer. */
34
34
  export declare class ContactRefusedError extends Error {
@@ -38,7 +38,7 @@ export declare class ContactRefusedError extends Error {
38
38
  }
39
39
  /**
40
40
  * Throws {@link ContactRefusedError} unless `ctx` may do `kind` (`read` asks
41
- * `contact-read`, `manage` asks `contact-manage`), and each of `also` (a link
41
+ * `identity-read`, `manage` asks `identity-manage`), and each of `also` (a link
42
42
  * to an account also asks `user-edit`). A missing context or decision point
43
43
  * refuses: closed, never open.
44
44
  */
@@ -58,7 +58,7 @@ export declare function isSystemWork(engine: EngineLike, ctx: ActorContext | nul
58
58
  * - read it as core's own work ({@link isSystemWork}): allowed, not evaluated;
59
59
  * - read or change its own identity record (the context's username is the
60
60
  * record's `identifier`): `profile-manage`, which every user holds;
61
- * - read or change any other record: `contact-read` / `contact-manage`.
61
+ * - read or change any other record: `identity-read` / `identity-manage`.
62
62
  *
63
63
  * `record` is the identity record (or what names it, `{ identifier }`); left
64
64
  * out for a record that is never anyone's own (an organization, a role).
@@ -67,6 +67,13 @@ export declare function isSystemWork(engine: EngineLike, ctx: ActorContext | nul
67
67
  export declare function requireRecordAccess(engine: EngineLike, ctx: ActorContext, kind: keyof typeof CONTACT_PERMISSION, record?: {
68
68
  identifier?: unknown;
69
69
  } | null): Promise<void>;
70
+ /**
71
+ * A lookup of users (#2018): `UserManager.searchUsers` asks `permission`
72
+ * (`search-user`) with the caller's context, and throws
73
+ * {@link ContactRefusedError} on a refusal. Core's own work
74
+ * ({@link isSystemWork}) is allowed without a policy evaluation.
75
+ */
76
+ export declare function requireLookupPermission(engine: EngineLike, ctx: ActorContext, permission: CorePermission): Promise<void>;
70
77
  /** What a contacts record says was done. Names fields, never their values. */
71
78
  export interface ContactChange {
72
79
  kind: 'person' | 'organization';
@@ -79,7 +86,7 @@ export interface ContactChange {
79
86
  via?: 'admin' | 'email';
80
87
  }
81
88
  /**
82
- * Record a contacts change as `contact-manage`, under the caller's context.
89
+ * Record a contacts change as `identity-manage`, under the caller's context.
83
90
  * `continue`: a lost record is counted, the change stands. A system context
84
91
  * goes through `recordSystemAction`, so the bootstrap admin's person, made
85
92
  * before the audit sink is up, is recorded once it is.
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { type PrivateStoreLayout } from './privateStorePath.js';
16
16
  import { type UserCatalog } from './privateStoreCatalogs.js';
17
- import type { ActorContext } from '../context/ActorContext.js';
17
+ import { type ActorContext } from '../context/ActorContext.js';
18
18
  import type { LinkPublicKey, Lockbox } from './shareLockbox.js';
19
19
  export declare function unlockPrivateStores(sessionId: string, username: string, kek: Buffer): void;
20
20
  export declare function getSessionUserIndex(sessionId: string): UserCatalog | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilleke/ngdpbase-types",
3
- "version": "5.29.0",
3
+ "version": "5.30.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",