@jwilleke/ngdpbase-types 5.26.0 → 5.28.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.
Files changed (52) hide show
  1. package/lib/packages/ngdpbase-types/index.d.ts +8 -2
  2. package/lib/packages/ngdpbase-types/testing.d.ts +7 -3
  3. package/lib/packages/ngdpbase-types/testing.js +7 -3
  4. package/lib/src/context/ActorContext.d.ts +15 -2
  5. package/lib/src/context/ActorContext.js +20 -0
  6. package/lib/src/converters/DocxConverter.d.ts +30 -0
  7. package/lib/src/converters/MarkdownConverter.d.ts +31 -0
  8. package/lib/src/export/ChromeServicePdfProvider.d.ts +52 -0
  9. package/lib/src/export/ChromeServicePdfProvider.js +153 -0
  10. package/lib/src/export/LocalChromePdfProvider.d.ts +25 -0
  11. package/lib/src/export/LocalChromePdfProvider.js +33 -0
  12. package/lib/src/export/PdfProvider.d.ts +51 -0
  13. package/lib/src/export/PdfProvider.js +37 -0
  14. package/lib/src/export/pdfEngine.d.ts +113 -0
  15. package/lib/src/export/pdfEngine.js +195 -0
  16. package/lib/src/ice/converterShim.d.ts +5 -0
  17. package/lib/src/ice/fileHandling.d.ts +30 -0
  18. package/lib/src/ice/ledger.d.ts +37 -0
  19. package/lib/src/ice/runImport.d.ts +51 -0
  20. package/lib/src/ice/types.d.ts +284 -0
  21. package/lib/src/mail/MailProvider.d.ts +15 -0
  22. package/lib/src/mail/NodemailerMailProvider.d.ts +1 -1
  23. package/lib/src/managers/AddonsManager.d.ts +25 -5
  24. package/lib/src/managers/AssetManager.d.ts +7 -3
  25. package/lib/src/managers/EmailManager.d.ts +32 -5
  26. package/lib/src/managers/ExportManager.d.ts +62 -0
  27. package/lib/src/managers/ExportManager.js +152 -2
  28. package/lib/src/managers/ImportManager.d.ts +611 -0
  29. package/lib/src/managers/OrganizationManager.d.ts +5 -3
  30. package/lib/src/managers/PageManager.d.ts +77 -1
  31. package/lib/src/managers/PersonManager.d.ts +36 -11
  32. package/lib/src/managers/RoleManager.d.ts +76 -17
  33. package/lib/src/managers/UserManager.d.ts +55 -2
  34. package/lib/src/security/PolicyDecisionPoint.d.ts +18 -9
  35. package/lib/src/security/PolicyInformationPoint.d.ts +48 -10
  36. package/lib/src/types/Config.d.ts +2 -0
  37. package/lib/src/types/User.d.ts +1 -1
  38. package/lib/src/utils/addonHost.d.ts +23 -2
  39. package/lib/src/utils/addonHost.js +25 -2
  40. package/lib/src/utils/auditEventNames.generated.d.ts +10 -10
  41. package/lib/src/utils/auditEventNames.generated.js +10 -10
  42. package/lib/src/utils/contactAccess.d.ts +30 -0
  43. package/lib/src/utils/frontmatterAccess.d.ts +54 -11
  44. package/lib/src/utils/importRunSummary.d.ts +64 -0
  45. package/lib/src/utils/keyedSerial.d.ts +31 -0
  46. package/lib/src/utils/ncmNotify.d.ts +30 -0
  47. package/lib/src/utils/privateStoreAccess.js +1 -3
  48. package/lib/src/utils/privateStoreImport.d.ts +58 -0
  49. package/lib/src/utils/roleChangeError.d.ts +25 -0
  50. package/lib/src/utils/subjectMayDo.d.ts +2 -2
  51. package/lib/src/utils/zipArchive.d.ts +71 -0
  52. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import BaseManager from '../managers/BaseManager.js';
2
- import type { ActorContext } from '../context/ActorContext.js';
2
+ import { type ActorContext } from '../context/ActorContext.js';
3
3
  import { WikiEngine } from '../types/WikiEngine.js';
4
4
  import type { AgentTokenGrant, ProxyClaim } from '../managers/UserManager.js';
5
5
  import type { Request } from 'express';
@@ -46,6 +46,12 @@ interface UserContext {
46
46
  viaProxy?: ProxyClaim;
47
47
  [key: string]: unknown;
48
48
  }
49
+ /**
50
+ * What the page questions take (#2022): the caller's context as given — a
51
+ * request's subject or a job's `JobContext` — or a subject already in decision
52
+ * form. They convert it themselves, through {@link asSubject}.
53
+ */
54
+ type PageQuestionCaller = ActorContext | UserContext | null | undefined;
49
55
  /**
50
56
  * Access decision log entry
51
57
  */
@@ -173,6 +179,15 @@ declare class PolicyInformationPoint extends BaseManager {
173
179
  * visitor holds, which is the safe answer for someone who no longer exists.
174
180
  */
175
181
  resolveSubjectNow(username: string | undefined): Promise<UserContext>;
182
+ /**
183
+ * The subject as a page's own rules match it (#2022): for a job's subject
184
+ * (`resolveRolesNow`, #1212), its username with the roles its account
185
+ * holds NOW, read through {@link resolveSubjectNow} — the path the PDP
186
+ * takes for the same subject; anyone else as carried. A job carries no
187
+ * roles, so matching it without this matched none, and an audience naming
188
+ * a role its user holds left the job out.
189
+ */
190
+ private matchableNow;
176
191
  /**
177
192
  * Who is making this request: the subject the session middleware resolved,
178
193
  * else the session's account resolved now, else the anonymous visitor.
@@ -209,6 +224,12 @@ declare class PolicyInformationPoint extends BaseManager {
209
224
  * none when it is not running.
210
225
  */
211
226
  subjectEnabledAddons(ctx: ActorContext): Promise<string[]>;
227
+ /**
228
+ * {@link subjectEnabledAddons} for a page decision (#1964): none for no
229
+ * subject, and none when the lookup fails — an `addon:<slug>` entry then
230
+ * matches nobody, never everyone.
231
+ */
232
+ private enabledAddonsOf;
212
233
  private userManager;
213
234
  private roleManager;
214
235
  /**
@@ -326,7 +347,9 @@ declare class PolicyInformationPoint extends BaseManager {
326
347
  * - __Tier 0.5, author-lock__, `edit` only. It only denies: an author, or a
327
348
  * subject holding `admin-system` (#1431 7b), falls through to Tier 1+.
328
349
  * - __Tier 1, frontmatter__ `access[action]` / `audience`. Page-level, and
329
- * overrides global policy when it states a rule (#1054).
350
+ * overrides global policy when it states a rule (#1054). An `audience`
351
+ * covers every action, and a subject holding `admin-system` is its one
352
+ * exception; `access` has none (#1964).
330
353
  * - __Tier 2.__ For a share visitor the share IS the policy (#1222); for
331
354
  * anyone else, global policy.
332
355
  * - __Default deny.__ Nothing below Tier 2 grants — JSPWiki's page-body ACL
@@ -340,14 +363,14 @@ declare class PolicyInformationPoint extends BaseManager {
340
363
  * private page gets at Tier 0: the owner, or a delegate of the owner; never
341
364
  * a role. A refusal is recorded like any page refusal (`authorization-deny`).
342
365
  *
343
- * @param userContext - the request's subject, as given (null = anonymous)
366
+ * @param caller - the caller's context, as given: a request's subject or a job's (#2022); null is anonymous
344
367
  * @param owner - the container's owner (a private file's `creator`)
345
368
  * @param resource - what is being reached, for the record (e.g. `attachment:<id>`)
346
369
  * @param action - the action asked for (e.g. `view`)
347
370
  * @param vault - the vault inside the container, when the question is about one:
348
371
  * a share link the owner issued gets in only for a vault it names (#1388)
349
372
  */
350
- canAccessPrivateContainer(userContext: UserContext | null | undefined, owner: string, resource: string, action: string, vault?: string): boolean;
373
+ canAccessPrivateContainer(caller: PageQuestionCaller, owner: string, resource: string, action: string, vault?: string): boolean;
351
374
  /**
352
375
  * Check whether a user can access a given page — for cross-page checks where
353
376
  * the current WikiContext describes a DIFFERENT page than the one being
@@ -373,14 +396,14 @@ declare class PolicyInformationPoint extends BaseManager {
373
396
  * case.
374
397
  * - any tier returns deny.
375
398
  *
376
- * @param userContext - The user requesting access (may be null / anonymous).
399
+ * @param caller - The caller's context, as given: a request's subject or a job's (#2022); null / undefined is anonymous.
377
400
  * @param pageName - Target page to check.
378
401
  * @param action - Action verb (e.g., `'view'`, `'edit'`, `'delete'`).
379
402
  * @param knownMetadata - The page's metadata when the caller has just
380
403
  * resolved it: the page door (#1622), which reads the page by the same
381
404
  * resolution it decides on. Omitted, it is loaded here.
382
405
  */
383
- canUserAccessPage(userContext: UserContext | null | undefined, pageName: string, action: string, knownMetadata?: PageFrontmatter): Promise<boolean>;
406
+ canUserAccessPage(caller: PageQuestionCaller, pageName: string, action: string, knownMetadata?: PageFrontmatter): Promise<boolean>;
384
407
  /**
385
408
  * {@link canUserAccessPage} as an affordance (#1668): would the door allow
386
409
  * this action on that page — asked only to decide whether to show a control
@@ -391,7 +414,7 @@ declare class PolicyInformationPoint extends BaseManager {
391
414
  * @param knownMetadata - As for {@link canUserAccessPage}: the page's
392
415
  * metadata when the caller has just resolved it (`PageManager.mayShowPage`).
393
416
  */
394
- pageAffordanceFor(userContext: UserContext | null | undefined, pageName: string, action: string, knownMetadata?: PageFrontmatter): Promise<boolean>;
417
+ pageAffordanceFor(caller: PageQuestionCaller, pageName: string, action: string, knownMetadata?: PageFrontmatter): Promise<boolean>;
395
418
  /** The cross-page check, for the door (`record`) or an affordance (#1668). */
396
419
  private crossPageDecision;
397
420
  /**
@@ -417,7 +440,7 @@ declare class PolicyInformationPoint extends BaseManager {
417
440
  * A candidate without metadata is not listed (#714's convention). Order is
418
441
  * preserved. Returns titles.
419
442
  */
420
- filterAccessiblePages(userContext: UserContext | null | undefined, action: string, candidates: ReadonlyArray<{
443
+ filterAccessiblePages(caller: PageQuestionCaller, action: string, candidates: ReadonlyArray<{
421
444
  title: string;
422
445
  metadata: PageFrontmatter | null | undefined;
423
446
  }>): Promise<string[]>;
@@ -437,12 +460,27 @@ declare class PolicyInformationPoint extends BaseManager {
437
460
  * A refusal is audited as `authorization-deny` on the media resource, with
438
461
  * the share attribution when there is one.
439
462
  */
440
- canUserAccessMediaItem(userContext: UserContext | null | undefined, item: MediaItem): Promise<boolean>;
463
+ canUserAccessMediaItem(caller: PageQuestionCaller, item: MediaItem): Promise<boolean>;
441
464
  /**
442
- * Check front matter audience / access fields (Tier 1.5).
465
+ * Check front matter audience / access fields (Tier 1).
443
466
  * Returns a decision object; decided=false means no front matter restriction — fall through.
467
+ *
468
+ * An audience that leaves the subject out refuses every action, unless the
469
+ * subject holds `admin-system` (#1964, operator 2026-10-23): asked only
470
+ * then. `access[action]` has no such exception.
444
471
  */
445
472
  private checkFrontmatterAccess;
473
+ /**
474
+ * Does the page's `audience` admit this subject (#1964)? True when the page
475
+ * names no audience, the subject matches it, or the subject holds
476
+ * `admin-system`. The same rule Tier 1 applies, asked by PageManager's
477
+ * write door, so a write cannot reach a page its writer is outside the
478
+ * audience of — whatever route it came by. Records nothing; the door does.
479
+ *
480
+ * @param caller - the caller's context, as given (a job's included, #2022)
481
+ * @param metadata - the page's current frontmatter
482
+ */
483
+ audienceAdmits(caller: PageQuestionCaller, metadata: PageFrontmatter | null | undefined): Promise<boolean>;
446
484
  /**
447
485
  * Record/audit an access decision.
448
486
  * Accepts either a single object or positional args for backward compatibility.
@@ -121,6 +121,8 @@ export interface WikiConfig {
121
121
  * counting every failure inside it (#1916). Default: 60.
122
122
  */
123
123
  'ngdpbase.mail.failure-notice.window-minutes': number;
124
+ /** The most a message's attachments may total, in bytes (#1974). Default: 10485760. */
125
+ 'ngdpbase.mail.attachments.max-bytes': number;
124
126
  /**
125
127
  * Anti-spam: max submissions per IP per `window-minutes` window before
126
128
  * the 429 trips (#670 Phase E). Default: 5.
@@ -73,7 +73,7 @@ export interface User {
73
73
  /**
74
74
  * @deprecated since #617 iteration 3b. Role membership is now stored
75
75
  * canonically as OrganizationRole records owned by `RoleManager`; resolve
76
- * a user's roles via `RoleManager.resolveUserRoles(username)`. The field
76
+ * a user's roles via `RoleManager.resolveUserRoles(ctx, username)`. The field
77
77
  * is retained as optional for backwards compatibility while in-flight
78
78
  * `users.json` files still carry it; the `strip-user-roles.ts` migration
79
79
  * removes it. New code MUST NOT read or write this field directly.
@@ -15,6 +15,8 @@
15
15
  * the add-on (#1973);
16
16
  * - `setEnabledForPerson` sets this add-on's own per-person switch (#1963),
17
17
  * bound to its slug, so it can never set another add-on's;
18
+ * - `registerShim` and `registerReceiver` (#1979, ICE) register into the import
19
+ * engine under this add-on's slug, and only the names its manifest declares;
18
20
  * - the egress tools (`resolveEgressPolicy`, `validateUrl`,
19
21
  * `createGuardedElasticsearchClient`, `refusedNodeMessage`) and `isAtLeast`
20
22
  * are core's own pure functions, handed on unchanged, so an add-on never
@@ -36,6 +38,8 @@ import { createGuardedElasticsearchClient, refusedNodeMessage } from '../http/gu
36
38
  import { isAtLeast } from './version.js';
37
39
  import { recordAuditEvent } from './auditEvents.js';
38
40
  import { addonAuditEventName } from './auditEventNames.js';
41
+ import type { IceReceiver, IceShim } from '../ice/types.js';
42
+ import type { AddonManifest } from '../managers/AddonsManager.js';
39
43
  /** The values core offers an add-on, under the names they were imported as. */
40
44
  export interface AddonHost {
41
45
  readonly ApiContext: typeof ApiContext;
@@ -76,7 +80,21 @@ export interface AddonHost {
76
80
  readonly refusedNodeMessage: typeof refusedNodeMessage;
77
81
  /** Whether an `x.y.z` version is at least another; throws on a version that is not `x.y.z`. */
78
82
  readonly isAtLeast: typeof isAtLeast;
83
+ /**
84
+ * #1979 (ICE, D6): register an import shim, as `<slug>.<shim.name>`. Call it
85
+ * in `register`. Refused when the manifest's `shims` does not name it.
86
+ * Returns the shim's id; site configuration switches it on and off.
87
+ */
88
+ readonly registerShim: (shim: IceShim) => string;
89
+ /**
90
+ * #1979 (ICE, D4): register a receiver — your manager's own door for what
91
+ * shims produce — as `<slug>.<receiver.name>`. Refused when the manifest's
92
+ * `receivers` does not name it. Returns the receiver's id.
93
+ */
94
+ readonly registerReceiver: (receiver: IceReceiver) => string;
79
95
  }
96
+ /** What the host may register for an add-on: the shim and receiver names its manifest declares (#1979). */
97
+ export type AddonIceDeclaration = Pick<AddonManifest, 'shims' | 'receivers'>;
80
98
  /** Sets one add-on's per-person switch; core binds the slug when it builds the host (#1963). */
81
99
  export type PersonSwitchSetter = (ctx: ActorContext, on: boolean) => Promise<void>;
82
100
  /**
@@ -94,6 +112,9 @@ export declare function addonDeclaredAuditEvents(addonDefaults: Record<string, u
94
112
  * @param setEnabledForPerson - This add-on's switch setter, slug already bound
95
113
  * (`AddonsManager` supplies it); without one, the host's setter refuses
96
114
  * @param engine - The running engine, which names the system principal for
97
- * `systemContext`; without one (a test) it is core's fallback principal
115
+ * `systemContext` and holds the import engine `registerShim` reaches;
116
+ * without one (a test) it is core's fallback principal, and no registry
117
+ * @param manifest - The add-on's manifest (`ngdpbase` in its package.json):
118
+ * the shims and receivers it may register (#1979)
98
119
  */
99
- export declare function buildAddonHost(slug: string, addonDefaults: Record<string, unknown>, setEnabledForPerson?: PersonSwitchSetter, engine?: Parameters<typeof systemContext>[0]): AddonHost;
120
+ export declare function buildAddonHost(slug: string, addonDefaults: Record<string, unknown>, setEnabledForPerson?: PersonSwitchSetter, engine?: Parameters<typeof systemContext>[0], manifest?: AddonIceDeclaration | null): AddonHost;
@@ -15,6 +15,8 @@
15
15
  * the add-on (#1973);
16
16
  * - `setEnabledForPerson` sets this add-on's own per-person switch (#1963),
17
17
  * bound to its slug, so it can never set another add-on's;
18
+ * - `registerShim` and `registerReceiver` (#1979, ICE) register into the import
19
+ * engine under this add-on's slug, and only the names its manifest declares;
18
20
  * - the egress tools (`resolveEgressPolicy`, `validateUrl`,
19
21
  * `createGuardedElasticsearchClient`, `refusedNodeMessage`) and `isAtLeast`
20
22
  * are core's own pure functions, handed on unchanged, so an add-on never
@@ -64,10 +66,29 @@ function refuseUndeclared(slug, name, declared) {
64
66
  * @param setEnabledForPerson - This add-on's switch setter, slug already bound
65
67
  * (`AddonsManager` supplies it); without one, the host's setter refuses
66
68
  * @param engine - The running engine, which names the system principal for
67
- * `systemContext`; without one (a test) it is core's fallback principal
69
+ * `systemContext` and holds the import engine `registerShim` reaches;
70
+ * without one (a test) it is core's fallback principal, and no registry
71
+ * @param manifest - The add-on's manifest (`ngdpbase` in its package.json):
72
+ * the shims and receivers it may register (#1979)
68
73
  */
69
- export function buildAddonHost(slug, addonDefaults, setEnabledForPerson = noPersonSwitch, engine) {
74
+ export function buildAddonHost(slug, addonDefaults, setEnabledForPerson = noPersonSwitch, engine, manifest) {
70
75
  const declared = addonDeclaredAuditEvents(addonDefaults);
76
+ const names = (list) => new Set(Array.isArray(list) ? list.filter((n) => typeof n === 'string') : []);
77
+ const iceDeclared = { shim: names(manifest?.shims), receiver: names(manifest?.receivers) };
78
+ const iceRegistry = (what, name) => {
79
+ const allowed = iceDeclared[what];
80
+ if (typeof name !== 'string' || !allowed.has(name)) {
81
+ const message = `Add-on '${slug}' may register only the ${what}s its manifest declares ("ngdpbase": { "${what}s": [...] } in package.json); ` +
82
+ `'${String(name)}' is not one of them (declared: ${allowed.size > 0 ? [...allowed].sort().join(', ') : 'none'}).`;
83
+ logger.error(`[addons] ${message}`);
84
+ throw new Error(message);
85
+ }
86
+ const registry = engine?.getManager('ImportManager');
87
+ if (typeof registry?.registerShim !== 'function' || typeof registry.registerReceiver !== 'function') {
88
+ throw new Error(`register${what === 'shim' ? 'Shim' : 'Receiver'} is not available: this host was built without a site's ImportManager`);
89
+ }
90
+ return registry;
91
+ };
71
92
  const hostAddonAuditEventName = (name) => {
72
93
  const checked = addonAuditEventName(name);
73
94
  refuseUndeclared(slug, name, declared);
@@ -90,6 +111,8 @@ export function buildAddonHost(slug, addonDefaults, setEnabledForPerson = noPers
90
111
  logger: logger.child({ addon: slug }),
91
112
  guardedFetch,
92
113
  setEnabledForPerson,
114
+ registerShim: (shim) => iceRegistry('shim', shim?.name).registerShim(slug, shim),
115
+ registerReceiver: (receiver) => iceRegistry('receiver', receiver?.name).registerReceiver(slug, receiver),
93
116
  resolveEgressPolicy,
94
117
  validateUrl,
95
118
  createGuardedElasticsearchClient,
@@ -50,9 +50,9 @@ export declare const AUDIT_EVENT: {
50
50
  readonly CONFIG_CHANGE: "config-change";
51
51
  /** Every custom configuration value discarded; recorded before the reset, which cannot proceed without it */
52
52
  readonly CONFIG_RESET: "config-reset";
53
- /** A person accepted a document they are asked to accept (#1603): which document (core's terms-of-service or privacy-policy, or an add-on's), its version and effective date, and how (sign-up, sign-in, turning an add-on on). Recorded before the acceptance is stored, which cannot proceed without it: consent must be provable */
53
+ /** A user accepted a document they are asked to accept (#1603): which document (core's terms-of-service or privacy-policy, or an add-on's), its version and effective date, and how (sign-up, sign-in, turning an add-on on). Recorded before the acceptance is stored, which cannot proceed without it: consent must be provable */
54
54
  readonly CONSENT_GRANT: "consent-grant";
55
- /** A person'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 */
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
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
58
  readonly CONTACT_MANAGE: "contact-manage";
@@ -112,21 +112,21 @@ export declare const AUDIT_EVENT: {
112
112
  readonly POLICY_EVALUATE: "policy-evaluate";
113
113
  /** Security posture at startup, compared against the previous start */
114
114
  readonly POSTURE_RECORDED: "posture-recorded";
115
- /** Proxy grant made: someone may act for the person named in user; actor is who made it (#1762) */
115
+ /** Proxy grant made: someone may act for the user named in user; actor is who made it (#1762) */
116
116
  readonly PROXY_GRANT: "proxy-grant";
117
- /** Proxy grant ended: revoked, or the account closed; user is the person represented, actor who ended it (#1762) */
117
+ /** Proxy grant ended: revoked, or the account closed; user is the user represented, actor who ended it (#1762) */
118
118
  readonly PROXY_REVOKE: "proxy-revoke";
119
- /** A signed-in session started or stopped acting for someone under a proxy grant, or opens their private store: chosen, switched-back, ended because the grant no longer allows it (grant-ended, proxy-access-off), or vault-opened; user is the person represented, actor the person signed in (#1762) */
119
+ /** A signed-in session started or stopped acting for someone under a proxy grant, or opens their private store: chosen, switched-back, ended because the grant no longer allows it (grant-ended, proxy-access-off), or vault-opened; user is the user represented, actor the user signed in (#1762) */
120
120
  readonly PROXY_SWITCH: "proxy-switch";
121
- /** Step-up: a re-authentication failed (wrong password, another person's passkey, throttled) */
121
+ /** Step-up: a re-authentication failed (wrong password, another user's passkey, throttled) */
122
122
  readonly REAUTH_FAILURE: "reauth-failure";
123
123
  /** Step-up: an action asked for a fresh sign-in (#1525) — or refused a delegated credential that cannot give one */
124
124
  readonly REAUTH_PROMPT: "reauth-prompt";
125
- /** Step-up: the person re-authenticated, with the factor used */
125
+ /** Step-up: the user re-authenticated, with the factor used */
126
126
  readonly REAUTH_SUCCESS: "reauth-success";
127
127
  /** Page search; not recorded, read volume */
128
128
  readonly SEARCH_PAGE: "search-page";
129
- /** People searched for; enumerating people is disclosive in a way searching pages is not. Off by default as read volume */
129
+ /** Users searched for; enumerating users is disclosive in a way searching pages is not. Off by default as read volume */
130
130
  readonly SEARCH_USER: "search-user";
131
131
  /** A masked configuration value shown to an administrator; the key is recorded, never the value */
132
132
  readonly SECRET_REVEAL: "secret-reveal";
@@ -146,9 +146,9 @@ export declare const AUDIT_EVENT: {
146
146
  readonly SHARE_REVOKE: "share-revoke";
147
147
  /** Sign-in approval: the browser that was waiting gave up the sign-in (#1532) */
148
148
  readonly SIGNINAPPROVAL_ABANDON: "signinapproval-abandon";
149
- /** Sign-in approval: the person pressed Approve (#1532) */
149
+ /** Sign-in approval: the user pressed Approve (#1532) */
150
150
  readonly SIGNINAPPROVAL_APPROVE: "signinapproval-approve";
151
- /** Sign-in approval: the person pressed Deny; that sign-in will not complete (#1532) */
151
+ /** Sign-in approval: the user pressed Deny; that sign-in will not complete (#1532) */
152
152
  readonly SIGNINAPPROVAL_DENY: "signinapproval-deny";
153
153
  /** Sign-in approval: a waiting sign-in expired before it was completed (#1532) */
154
154
  readonly SIGNINAPPROVAL_EXPIRE: "signinapproval-expire";
@@ -57,9 +57,9 @@ export const AUDIT_EVENT = {
57
57
  CONFIG_CHANGE: 'config-change',
58
58
  /** Every custom configuration value discarded; recorded before the reset, which cannot proceed without it */
59
59
  CONFIG_RESET: 'config-reset',
60
- /** A person accepted a document they are asked to accept (#1603): which document (core's terms-of-service or privacy-policy, or an add-on's), its version and effective date, and how (sign-up, sign-in, turning an add-on on). Recorded before the acceptance is stored, which cannot proceed without it: consent must be provable */
60
+ /** A user accepted a document they are asked to accept (#1603): which document (core's terms-of-service or privacy-policy, or an add-on's), its version and effective date, and how (sign-up, sign-in, turning an add-on on). Recorded before the acceptance is stored, which cannot proceed without it: consent must be provable */
61
61
  CONSENT_GRANT: 'consent-grant',
62
- /** A person'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 */
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
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
65
  CONTACT_MANAGE: 'contact-manage',
@@ -119,21 +119,21 @@ export const AUDIT_EVENT = {
119
119
  POLICY_EVALUATE: 'policy-evaluate',
120
120
  /** Security posture at startup, compared against the previous start */
121
121
  POSTURE_RECORDED: 'posture-recorded',
122
- /** Proxy grant made: someone may act for the person named in user; actor is who made it (#1762) */
122
+ /** Proxy grant made: someone may act for the user named in user; actor is who made it (#1762) */
123
123
  PROXY_GRANT: 'proxy-grant',
124
- /** Proxy grant ended: revoked, or the account closed; user is the person represented, actor who ended it (#1762) */
124
+ /** Proxy grant ended: revoked, or the account closed; user is the user represented, actor who ended it (#1762) */
125
125
  PROXY_REVOKE: 'proxy-revoke',
126
- /** A signed-in session started or stopped acting for someone under a proxy grant, or opens their private store: chosen, switched-back, ended because the grant no longer allows it (grant-ended, proxy-access-off), or vault-opened; user is the person represented, actor the person signed in (#1762) */
126
+ /** A signed-in session started or stopped acting for someone under a proxy grant, or opens their private store: chosen, switched-back, ended because the grant no longer allows it (grant-ended, proxy-access-off), or vault-opened; user is the user represented, actor the user signed in (#1762) */
127
127
  PROXY_SWITCH: 'proxy-switch',
128
- /** Step-up: a re-authentication failed (wrong password, another person's passkey, throttled) */
128
+ /** Step-up: a re-authentication failed (wrong password, another user's passkey, throttled) */
129
129
  REAUTH_FAILURE: 'reauth-failure',
130
130
  /** Step-up: an action asked for a fresh sign-in (#1525) — or refused a delegated credential that cannot give one */
131
131
  REAUTH_PROMPT: 'reauth-prompt',
132
- /** Step-up: the person re-authenticated, with the factor used */
132
+ /** Step-up: the user re-authenticated, with the factor used */
133
133
  REAUTH_SUCCESS: 'reauth-success',
134
134
  /** Page search; not recorded, read volume */
135
135
  SEARCH_PAGE: 'search-page',
136
- /** People searched for; enumerating people is disclosive in a way searching pages is not. Off by default as read volume */
136
+ /** Users searched for; enumerating users is disclosive in a way searching pages is not. Off by default as read volume */
137
137
  SEARCH_USER: 'search-user',
138
138
  /** A masked configuration value shown to an administrator; the key is recorded, never the value */
139
139
  SECRET_REVEAL: 'secret-reveal',
@@ -153,9 +153,9 @@ export const AUDIT_EVENT = {
153
153
  SHARE_REVOKE: 'share-revoke',
154
154
  /** Sign-in approval: the browser that was waiting gave up the sign-in (#1532) */
155
155
  SIGNINAPPROVAL_ABANDON: 'signinapproval-abandon',
156
- /** Sign-in approval: the person pressed Approve (#1532) */
156
+ /** Sign-in approval: the user pressed Approve (#1532) */
157
157
  SIGNINAPPROVAL_APPROVE: 'signinapproval-approve',
158
- /** Sign-in approval: the person pressed Deny; that sign-in will not complete (#1532) */
158
+ /** Sign-in approval: the user pressed Deny; that sign-in will not complete (#1532) */
159
159
  SIGNINAPPROVAL_DENY: 'signinapproval-deny',
160
160
  /** Sign-in approval: a waiting sign-in expired before it was completed (#1532) */
161
161
  SIGNINAPPROVAL_EXPIRE: 'signinapproval-expire',
@@ -12,6 +12,12 @@
12
12
  * A route turns it into the answer with `ApiContext.enforceDeny(err.permission,
13
13
  * err.decision)`, the one place a refusal is shaped (#1431); the door never
14
14
  * builds an HTTP answer itself.
15
+ *
16
+ * Every lookup takes a context too (#2018, operator 2026-10-23), through
17
+ * {@link requireRecordAccess}: a user's own identity record under
18
+ * `profile-manage`, anyone else's under `contact-read` / `contact-manage`, and
19
+ * core's own reads under the system context without a policy evaluation, so
20
+ * resolving roles on every request stays cheap.
15
21
  */
16
22
  import { type ActorContext } from '../context/ActorContext.js';
17
23
  import type { CorePermission } from '../security/permissions.generated.js';
@@ -37,6 +43,30 @@ export declare class ContactRefusedError extends Error {
37
43
  * refuses: closed, never open.
38
44
  */
39
45
  export declare function requireContactPermission(engine: EngineLike, ctx: ActorContext, kind: keyof typeof CONTACT_PERMISSION, also?: readonly CorePermission[]): Promise<void>;
46
+ /**
47
+ * Core's own work (#2018): a boot or schedule context naming the system
48
+ * principal, with no token, share or proxy claim. Recognised by its fields
49
+ * alone, with no policy evaluation, because RoleManager resolves roles
50
+ * through these lookups on every request. A request never carries this
51
+ * context: the system principal's name is reserved, so no account holds it.
52
+ */
53
+ export declare function isSystemWork(engine: EngineLike, ctx: ActorContext | null | undefined): boolean;
54
+ /**
55
+ * A lookup or change of one record at a contacts door (#2018). Throws
56
+ * {@link ContactRefusedError} unless `ctx` may:
57
+ *
58
+ * - read it as core's own work ({@link isSystemWork}): allowed, not evaluated;
59
+ * - read or change its own identity record (the context's username is the
60
+ * record's `identifier`): `profile-manage`, which every user holds;
61
+ * - read or change any other record: `contact-read` / `contact-manage`.
62
+ *
63
+ * `record` is the identity record (or what names it, `{ identifier }`); left
64
+ * out for a record that is never anyone's own (an organization, a role).
65
+ * "Own" is decided by comparing usernames, never by a role.
66
+ */
67
+ export declare function requireRecordAccess(engine: EngineLike, ctx: ActorContext, kind: keyof typeof CONTACT_PERMISSION, record?: {
68
+ identifier?: unknown;
69
+ } | null): Promise<void>;
40
70
  /** What a contacts record says was done. Names fields, never their values. */
41
71
  export interface ContactChange {
42
72
  kind: 'person' | 'organization';
@@ -2,8 +2,8 @@
2
2
  * frontmatterAccess — the single implementation of ACL Tier 1 (#1054).
3
3
  *
4
4
  * A page's frontmatter can restrict who may act on it, via `access[<action>]`
5
- * or, for viewing, `audience`. Two subsystems needed that answer and each had
6
- * its own version of it, which is how they came to disagree:
5
+ * or `audience`, which covers every action (#1964). Two subsystems needed
6
+ * that answer and each had its own version of it, which is how they came to disagree:
7
7
  *
8
8
  * - `PolicyInformationPoint.checkFrontmatterAccess` decided a request, correctly.
9
9
  * - `VersioningFileProvider.getRecentChanges` decided a listing, and only
@@ -22,7 +22,7 @@
22
22
  export interface AccessMetadataLike {
23
23
  /** `{ view: ['role'], edit: [...] }` — per-action principal lists. */
24
24
  access?: unknown;
25
- /** Shorthand for `access.view`. */
25
+ /** Who may act on the page at all; administrators excepted (#1964). */
26
26
  audience?: unknown;
27
27
  }
28
28
  export interface FrontmatterDecision {
@@ -34,21 +34,64 @@ export interface FrontmatterDecision {
34
34
  matched?: string;
35
35
  }
36
36
  /**
37
- * Principals permitted to perform `action`, or null when the page states no
38
- * rule and the caller should fall through to global policy.
39
- *
40
- * `access[action]` wins; `audience` is the shorthand and applies to `view`
41
- * only. An empty list counts as no rule — a page saying `audience: []`
42
- * restricts nobody, which matters because a bare `access:` key parses that way.
37
+ * The page's `audience`, or null when it names none (#1964, operator
38
+ * 2026-10-23). An audience restricts EVERY action: someone outside it may
39
+ * neither read nor edit the page.
40
+ */
41
+ export declare function audiencePrincipals(metadata: AccessMetadataLike | null | undefined): string[] | null;
42
+ /**
43
+ * The page's `access[action]` list, or null when it states none. It applies
44
+ * to that action only, and to everyone — administrators included.
45
+ */
46
+ export declare function accessPrincipals(metadata: AccessMetadataLike | null | undefined, action: string): string[] | null;
47
+ /**
48
+ * The entry `addon:<slug>` (#1964): "people who turned on the per-person
49
+ * add-on <slug>". It is matched only against the viewer's enabled add-ons,
50
+ * never against a role or a username.
51
+ */
52
+ export declare const ADDON_PRINCIPAL_PREFIX = "addon:";
53
+ /**
54
+ * Whether deciding `action` on this page needs the viewer's enabled add-ons —
55
+ * so a caller asks for them only when the page names one (#1964).
43
56
  */
44
- export declare function resolveFrontmatterPrincipals(metadata: AccessMetadataLike | null | undefined, action: string): string[] | null;
57
+ export declare function namesAddonPrincipal(metadata: AccessMetadataLike | null | undefined, action: string): boolean;
58
+ /**
59
+ * Every add-on slug the page names in `audience` or in any `access` list, once
60
+ * each, in the order first named — for the warning about a slug that can match
61
+ * nobody (#1964).
62
+ */
63
+ export declare function addonAudienceSlugs(metadata: AccessMetadataLike | null | undefined): string[];
64
+ /**
65
+ * Whether the page's audience leaves this viewer out — true only when it
66
+ * names one and nothing in it matches. The caller then asks whether the
67
+ * viewer is an administrator, the one exception (#1964), and passes the
68
+ * answer to {@link decideFrontmatterAccess}; it asks only when this is true.
69
+ */
70
+ export declare function audienceExcludes(metadata: AccessMetadataLike | null | undefined, viewerPrincipals: readonly string[], enabledAddons?: readonly string[]): boolean;
71
+ /** The `matched` an administrator outside the audience is admitted by. */
72
+ export declare const AUDIENCE_ADMIN_MATCH = "admin-system";
45
73
  /**
46
74
  * Decide Tier 1 for a viewer described as a flat principal list — their roles
47
75
  * plus their username. Callers holding a `UserContext` should pass
48
76
  * `[...roles, username]`; the provider layer already works in these terms.
49
77
  *
78
+ * Two rules, in order (#1964, operator 2026-10-23):
79
+ *
80
+ * - __`audience`__ applies to every action. Someone it leaves out may neither
81
+ * read nor edit the page: every action is refused. The one exception is an
82
+ * administrator — a viewer holding `admin-system`, which the caller decides
83
+ * and passes as `isAdmin` (this module never reads a role for it). For
84
+ * `view`, a viewer the audience admits is allowed; for any other action it
85
+ * only lets them through to `access` and then policy.
86
+ * - __`access[action]`__ applies to that action, to everyone, administrators
87
+ * included. When the page states it, it decides.
88
+ *
89
+ * An `addon:<slug>` entry matches only when `<slug>` is in `enabledAddons`,
90
+ * the viewer's enabled per-person add-ons (#1964); a caller that does not
91
+ * supply them matches no such entry, so it can under-list but never leak.
92
+ *
50
93
  * Denies when the page states a rule and nothing matches. That default is the
51
94
  * point: a listing that treats "no match" as "show it" is the bug this exists
52
95
  * to prevent.
53
96
  */
54
- export declare function decideFrontmatterAccess(metadata: AccessMetadataLike | null | undefined, viewerPrincipals: readonly string[], action: string): FrontmatterDecision;
97
+ export declare function decideFrontmatterAccess(metadata: AccessMetadataLike | null | undefined, viewerPrincipals: readonly string[], action: string, enabledAddons?: readonly string[], isAdmin?: boolean): FrontmatterDecision;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Per-run summary persistence for NCM / import conversion outcomes (#738).
3
+ *
4
+ * Writes one JSON file per import run under FAST_STORAGE (configurable via
5
+ * `ngdpbase.import.runs-dir`, default `./data/import-runs`). Each file's
6
+ * name is the ISO timestamp of `startedAt`, sanitised for filesystem use.
7
+ *
8
+ * The trend view (admin-import.ejs) reads the last N summaries via
9
+ * listRecentRuns() to surface dominant failure causes across runs.
10
+ *
11
+ * Append-only by design. Retention is operator-managed for now — the
12
+ * directory can grow indefinitely; a future cleanup job (or admin button)
13
+ * can prune. Listed as a follow-up in the #738 acceptance criteria.
14
+ */
15
+ /** Compact per-run record persisted to disk. */
16
+ export interface ImportRunSummary {
17
+ /** Stable id; same as the filename without `.json`. */
18
+ runId: string;
19
+ /** ISO-8601 timestamp of when the run started. */
20
+ startedAt: string;
21
+ /** ISO-8601 timestamp of when the run finished (may equal startedAt for trivial runs). */
22
+ finishedAt: string;
23
+ /** What kind of import surfaced this run (`paste`, `url`, `file`, `ingest:<sourceId>`). */
24
+ importType: string;
25
+ /**
26
+ * Who initiated the run — `ImportOptions.actorContext.username` (#1236): the
27
+ * request's subject for a manual import, the system principal for a job.
28
+ */
29
+ actor: string;
30
+ /**
31
+ * True when the run was a job with no person behind it (a JobContext whose
32
+ * origin is boot, schedule or operator) — lets the trend view distinguish
33
+ * "alice triggered an import" from "scheduled feed ingested overnight".
34
+ * False for a request, or a request-origin job.
35
+ */
36
+ isSystem?: boolean;
37
+ /** Where the run came from: `request`, `boot`, `schedule` or `operator` (#1236). Absent on records written before it. */
38
+ origin?: string;
39
+ /** Total items the run attempted. */
40
+ total: number;
41
+ /** Items successfully converted to pages. */
42
+ converted: number;
43
+ /** Items intentionally skipped (e.g. duplicates). */
44
+ skipped: number;
45
+ /** Items that failed to convert. */
46
+ failed: number;
47
+ /**
48
+ * Counts of ConversionWarning.kind values aggregated across all items in the run.
49
+ * Keys are flat (no colon-subkinds today) per the #738 design decision.
50
+ */
51
+ kindCounts: Record<string, number>;
52
+ }
53
+ /** Aggregate a flat list of ConversionWarning-like objects into kindCounts. */
54
+ export declare function tallyWarnings(warnings: Array<{
55
+ kind: string;
56
+ }> | undefined): Record<string, number>;
57
+ /** Write a single per-run summary. Idempotent for same runId. Best-effort — errors logged, never thrown. */
58
+ export declare function writeRunSummary(runsDir: string, summary: ImportRunSummary): Promise<void>;
59
+ /**
60
+ * Return the most-recent N run summaries, newest first. Filenames are ISO
61
+ * timestamps so lexicographic sort is chronological. Missing/empty dir
62
+ * returns []. Malformed individual files are skipped, not fatal.
63
+ */
64
+ export declare function listRecentRuns(runsDir: string, limit?: number): Promise<ImportRunSummary[]>;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * KeyedSerial — one task at a time per key, within this process (#2015).
3
+ *
4
+ * A read-modify-write of one record (read the file, change it, write it back)
5
+ * is only safe while nothing else writes that record in between. Two role
6
+ * changes to one role used to interleave: each read the member list before the
7
+ * other wrote it, and the second write dropped the first one's member. Running
8
+ * every read-modify-write of a key through `run` makes them happen one after
9
+ * another, each reading what the last one wrote. Different keys still run side
10
+ * by side.
11
+ *
12
+ * What it does not do:
13
+ *
14
+ * - **Not across processes.** Two server processes sharing one data folder can
15
+ * still race; no supported deployment runs that way.
16
+ * - **Not re-entrant.** A task must not `run` the same key again and wait for
17
+ * it: the inner task queues behind the outer one, which never finishes.
18
+ *
19
+ * A failed task fails its own caller only; the next task on the key runs as
20
+ * usual. A key's entry is dropped once its queue is empty, so the map holds
21
+ * only keys with work in flight.
22
+ */
23
+ export declare class KeyedSerial {
24
+ /** The settled tail of each key's queue: never rejects. */
25
+ private readonly tails;
26
+ /** Run `task` after every earlier task on `key` has settled; resolves or rejects as `task` does. */
27
+ run<T>(key: string, task: () => Promise<T>): Promise<T>;
28
+ /** Resolves once every task queued so far, on every key, has settled (shutdown). */
29
+ settled(): Promise<void>;
30
+ }
31
+ export default KeyedSerial;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * NCM conversion → /admin/notifications (#728 Phase 2 Slice 5d)
3
+ *
4
+ * Operator decision 2026-05-17: on **non-preview** NCM conversion paths
5
+ * (bulk import, #685 ingestion, MCP) — where no human sees the preview — a
6
+ * summary of the conversion warnings is pushed to the existing
7
+ * `/admin/notifications` centre via `NotificationManager` (the same
8
+ * subsystem `MediaManager` already feeds), so admins get an actionable
9
+ * per-event alert. Distinct from #738 (aggregate metrics/trend).
10
+ *
11
+ * Engine-coupled glue lives here (not in `src/converters/ncm/`, which is
12
+ * kept pure). Best-effort and non-blocking — notification failure must never
13
+ * fail a conversion.
14
+ *
15
+ * @module utils/ncmNotify
16
+ */
17
+ interface EngineLike {
18
+ getManager(name: string): unknown;
19
+ }
20
+ /**
21
+ * Push an admin notification summarising NCM conversion warnings.
22
+ * No-op when there are no warnings or NotificationManager is unavailable.
23
+ *
24
+ * @param engine Wiki engine (structural — just needs getManager)
25
+ * @param source Human label for the path, e.g. "Import jspwiki", "MCP create_page"
26
+ * @param target The page/file that was converted
27
+ * @param warnings Flattened `"kind: detail"` strings
28
+ */
29
+ export declare function notifyNcmConversion(engine: EngineLike, source: string, target: string, warnings: string[]): void;
30
+ export {};