@jwilleke/ngdpbase-types 5.42.0 → 5.44.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 (74) hide show
  1. package/lib/src/context/ApiContext.d.ts +10 -3
  2. package/lib/src/context/ApiContext.js +37 -4
  3. package/lib/src/context/BaseContext.d.ts +20 -20
  4. package/lib/src/context/BaseContext.js +41 -24
  5. package/lib/src/context/JobContext.d.ts +31 -0
  6. package/lib/src/context/JobContext.js +34 -1
  7. package/lib/src/ice/runExport.d.ts +8 -3
  8. package/lib/src/ice/searchExportSource.d.ts +44 -0
  9. package/lib/src/ice/types.d.ts +6 -0
  10. package/lib/src/managers/AddonsManager.d.ts +0 -28
  11. package/lib/src/managers/AgentTokenManager.d.ts +8 -0
  12. package/lib/src/managers/AttachmentManager.d.ts +11 -0
  13. package/lib/src/managers/AuditManager.d.ts +0 -1
  14. package/lib/src/managers/AuthManager.d.ts +38 -0
  15. package/lib/src/managers/AuthManager.js +84 -12
  16. package/lib/src/managers/BackupManager.d.ts +9 -2
  17. package/lib/src/managers/BackupManager.js +42 -19
  18. package/lib/src/managers/BaseManager.d.ts +17 -3
  19. package/lib/src/managers/BaseManager.js +12 -8
  20. package/lib/src/managers/ConfigurationManager.d.ts +28 -0
  21. package/lib/src/managers/ConsentManager.d.ts +1 -1
  22. package/lib/src/managers/DatabaseManager.js +17 -8
  23. package/lib/src/managers/EmailManager.d.ts +1 -1
  24. package/lib/src/managers/ExportManager.d.ts +1 -44
  25. package/lib/src/managers/ExportManager.js +3 -115
  26. package/lib/src/managers/ImportManager.d.ts +7 -0
  27. package/lib/src/managers/PageManager.d.ts +194 -23
  28. package/lib/src/managers/PolicyEvaluator.d.ts +182 -24
  29. package/lib/src/managers/ShareManager.d.ts +3 -2
  30. package/lib/src/managers/ThemeManager.d.ts +20 -1
  31. package/lib/src/managers/UserManager.d.ts +19 -5
  32. package/lib/src/managers/ValidationManager.d.ts +3 -1
  33. package/lib/src/providers/AgentTokenAuthProvider.js +1 -1
  34. package/lib/src/providers/BaseAuthProvider.d.ts +2 -0
  35. package/lib/src/providers/FileLoggingProvider.d.ts +12 -1
  36. package/lib/src/providers/FileLoggingProvider.js +37 -4
  37. package/lib/src/security/PolicyDecisionPoint.d.ts +107 -4
  38. package/lib/src/security/PolicyInformationPoint.d.ts +113 -7
  39. package/lib/src/security/ownedRecords.d.ts +69 -0
  40. package/lib/src/security/permissions.generated.d.ts +8 -0
  41. package/lib/src/security/signedOutWrites.d.ts +69 -0
  42. package/lib/src/security/signedOutWrites.js +146 -0
  43. package/lib/src/types/Page.d.ts +0 -7
  44. package/lib/src/types/Policy.d.ts +65 -8
  45. package/lib/src/types/Policy.js +6 -0
  46. package/lib/src/types/Provider.d.ts +12 -0
  47. package/lib/src/types/User.d.ts +6 -0
  48. package/lib/src/utils/addonConfigLayer.d.ts +6 -3
  49. package/lib/src/utils/addonConfigLayer.js +61 -3
  50. package/lib/src/utils/addonHost.d.ts +25 -0
  51. package/lib/src/utils/addonHost.js +29 -1
  52. package/lib/src/utils/addonPageSync.d.ts +0 -12
  53. package/lib/src/utils/auditEventNames.generated.d.ts +2 -0
  54. package/lib/src/utils/auditEventNames.generated.js +2 -0
  55. package/lib/src/utils/auditEvents.d.ts +2 -0
  56. package/lib/src/utils/auditEvents.js +3 -1
  57. package/lib/src/utils/availability.d.ts +15 -0
  58. package/lib/src/utils/availability.js +88 -0
  59. package/lib/src/utils/frontmatterAccess.d.ts +15 -24
  60. package/lib/src/utils/legacyStoreKeys.d.ts +16 -0
  61. package/lib/src/utils/pageTitleConflict.d.ts +32 -0
  62. package/lib/src/utils/pageWritePolicies.d.ts +74 -0
  63. package/lib/src/utils/permissionCatalog.d.ts +2 -0
  64. package/lib/src/utils/permissionCatalog.js +19 -0
  65. package/lib/src/utils/plainAttention.d.ts +19 -0
  66. package/lib/src/utils/plainAttention.js +18 -0
  67. package/lib/src/utils/policyAttributes.d.ts +232 -0
  68. package/lib/src/utils/policyAttributes.js +404 -0
  69. package/lib/src/utils/privateStoreAccess.d.ts +24 -4
  70. package/lib/src/utils/privateStoreAccess.js +19 -4
  71. package/lib/src/utils/roleGrants.d.ts +1 -1
  72. package/lib/src/utils/username.d.ts +13 -2
  73. package/lib/src/utils/username.js +18 -3
  74. package/package.json +7 -7
@@ -28,8 +28,11 @@ import type { WikiEngine } from '../types/WikiEngine.js';
28
28
  import type { Decision, DecisionRequest, Policy } from '../types/Policy.js';
29
29
  import { type RoleGrant } from '../utils/roleGrants.js';
30
30
  import { type ActorContext } from '../context/ActorContext.js';
31
+ import { type OwnedListAsk, type OwnedListDecision, type OwnedRecordAsk } from './ownedRecords.js';
31
32
  export declare const POLICIES_KEY = "ngdpbase.access.policies";
32
33
  export declare const POLICIES_ENABLED_KEY = "ngdpbase.access.policies.enabled";
34
+ /** Whether an administrator may enter another person's unencrypted vault (#2255). */
35
+ export declare const VAULTS_ADMIN_ACCESS_KEY = "ngdpbase.vaults.admin-access";
33
36
  /**
34
37
  * What `decide`, `permits` and `holds` take (#2022): the caller's own context
35
38
  * — a request's `PermissionSubject` or a job's `JobContext` — or a subject
@@ -48,7 +51,8 @@ export declare class PolicyDecisionPoint extends BaseManager {
48
51
  /** Warn, once per policy and name, that a policy names a retired permission and what it is read as (#2038). */
49
52
  private warnRetiredOnce;
50
53
  /**
51
- * The policies in force right now, highest priority first.
54
+ * The policies in force right now, in configuration order. Order carries no
55
+ * meaning: the rule-combining algorithm is `deny-overrides` (#2330).
52
56
  *
53
57
  * - `ngdpbase.access.policies.enabled` false — its default when unset —
54
58
  * means none;
@@ -71,8 +75,8 @@ export declare class PolicyDecisionPoint extends BaseManager {
71
75
  * What each role permits: role name → the actions its holders are granted.
72
76
  *
73
77
  * It mirrors the evaluator's ROLE matching — an allow policy naming a role
74
- * grants its actions, a deny policy naming it takes them away, lower
75
- * priority first so a higher-priority deny lands last and wins. It does not
78
+ * grants its actions, a deny policy naming it takes them away, whatever the
79
+ * order (deny-overrides, #2330). It does not
76
80
  * evaluate resource patterns or the ceilings, so it is a summary for a human
77
81
  * reading a table (the admin Security Policy Summary, a profile), never an
78
82
  * authorisation answer — that is {@link permits} with the caller's subject.
@@ -256,7 +260,20 @@ export declare class PolicyDecisionPoint extends BaseManager {
256
260
  * own sign-in, once policy has said yes.
257
261
  */
258
262
  private proxyCeiling;
259
- /** Does the named actor HOLD `action` (policy alone, live)? Step-up is the proxy subject's question, not theirs. */
263
+ /**
264
+ * Does the named actor HOLD the action (policy alone, live)? Step-up is the
265
+ * proxy subject's question, not theirs. Asked about the same page when the
266
+ * request names one (#2255), so an actor who may edit only some categories
267
+ * is asked about this page's.
268
+ *
269
+ * Inside a vault or a shared folder the actor is asked the bare capability,
270
+ * as before #2255 (#2267): the `vault` and `shared-folder` matchers assume
271
+ * the container rule proved the subject may be there, and for the actor it
272
+ * did not — the person represented passed it. Asked with the container, a
273
+ * reader actor would hold everything `vault-owner` holds in someone else's
274
+ * vault. The door (`permitsInVault`), the page door and the list filter all
275
+ * come through here, so they agree.
276
+ */
260
277
  private actorHolds;
261
278
  /**
262
279
  * Decide. The order is load-bearing:
@@ -277,5 +294,91 @@ export declare class PolicyDecisionPoint extends BaseManager {
277
294
  decide(subject: DecisionInput, request: DecisionRequest): Promise<Decision>;
278
295
  /** `decide`, with the step-up rule applied or not — {@link holds} is the only caller that leaves it out. */
279
296
  private evaluate;
297
+ /**
298
+ * The first declared system category on which the policies permit a page
299
+ * write (#2255, decision 10), or null. Asked once per category, so a deny
300
+ * on one still wins there. Owner conditions are unknown here (no page), so
301
+ * a grant that needs the author does not count. Only enabled categories
302
+ * that are not bound to a location count (#2268): `shared` is a shared
303
+ * folder's, decided with the folder, and every account holds `vault-owner`,
304
+ * so counting it would give a reader every page write as a capability.
305
+ */
306
+ private onSomeCategory;
307
+ /**
308
+ * Every system category the catalogue declares, core and add-ons, enabled
309
+ * or not, lowercased; with `inUse`, only those enabled and not bound to a
310
+ * shared folder (`storageLocation.sharedfolder`).
311
+ */
312
+ private declaredCategories;
313
+ /**
314
+ * May an administrator enter `owner`'s vault (#2255,
315
+ * `ngdpbase.vaults.admin-access`)? Asked only after the container rule has
316
+ * refused someone who is not the owner. Yes when all hold:
317
+ *
318
+ * - the key is true, read now, on every request (shipped true);
319
+ * - the caller is a signed-in session or a job, not a share link, a proxy
320
+ * or an agent token;
321
+ * - the caller holds `admin-system`;
322
+ * - the vault is not encrypted: an encrypted vault always needs its key,
323
+ * whatever the key says, so it is not entered at all.
324
+ *
325
+ * `record` true (a door) writes `vault-admin-access` with the administrator
326
+ * as the actor and the owner, vault and page named; an affordance passes
327
+ * false and records nothing. Lists and search never ask this, so an
328
+ * administrator does not see other people's vault pages listed.
329
+ */
330
+ adminMayEnterVault(ctx: ActorContext | null | undefined, owner: string, vault: string, resource: string, record?: boolean): Promise<boolean>;
331
+ /**
332
+ * The declared system categories on which an allow policy grants `action`
333
+ * through the `system-category` resource type (#2255). The sitemap asks it
334
+ * with `pushed-pages-edit` to leave pushed pages out. A summary of the
335
+ * policies, never an answer to "may this subject".
336
+ */
337
+ /**
338
+ * May this subject act on one stored add-on record (#1823)?
339
+ *
340
+ * The manager passes attributes it read from the row. `owner` is the
341
+ * identity @id. A type that declares `owner` is allowed only when that @id
342
+ * is the subject's (the person a proxy grant represents, not the actor),
343
+ * and policy also allows the action. A role does not bypass the owner. A
344
+ * type with no `owner` is policy alone. A core type (`page`, `vault`, …)
345
+ * is not decided here. Missing data throws {@link OwnedRecordError}; the
346
+ * caller fails closed. This records nothing.
347
+ */
348
+ decideOwnedRecord(input: DecisionInput, request: OwnedRecordAsk): Promise<Decision>;
349
+ /**
350
+ * May this subject list a type, and which rows (#1823)?
351
+ *
352
+ * An allow for a type that declares `owner` carries one filter, `owner`
353
+ * equals the subject's identity @id. The manager binds that value; it is
354
+ * not SQL. An allow for a type with no owner carries an empty filter: policy
355
+ * alone, no row constraint. A refusal is `{ decision: 'deny' }`, never an
356
+ * empty filter and never an empty row set from this method. Missing data
357
+ * throws {@link OwnedRecordError}. This records nothing: the list is logged
358
+ * once by the manager that applies it.
359
+ */
360
+ decideOwnedList(input: DecisionInput, request: OwnedListAsk): Promise<OwnedListDecision>;
361
+ /**
362
+ * The ceilings, the declared type, and a reader for conditions. A ceiling
363
+ * denial is the whole answer. Anything that cannot be decided throws.
364
+ */
365
+ private prepareOwned;
366
+ private ownedTypeEntry;
367
+ private typeDeclaresOwner;
368
+ /** The owner the manager read. Missing on an owner-typed record cannot be decided. */
369
+ private storedOwner;
370
+ private ownerMismatch;
371
+ /** The subject's identity @id, or a denial when nobody is signed in. A signed-in subject with no identity cannot be decided. */
372
+ private subjectOwnerId;
373
+ private ownedUserContext;
374
+ /**
375
+ * Condition values (#1823). A `resource:` attribute is the value the manager
376
+ * read from the stored row, never a request field. Subject and environment
377
+ * attributes come from the PIP.
378
+ */
379
+ private ownedReader;
380
+ private ownedTypeResult;
381
+ private decisionFrom;
382
+ categoriesGranting(action: string): string[];
280
383
  }
281
384
  export default PolicyDecisionPoint;
@@ -1,4 +1,5 @@
1
1
  import BaseManager from '../managers/BaseManager.js';
2
+ import { type AalFacts } from '../utils/policyAttributes.js';
2
3
  import { type ActorContext } from '../context/ActorContext.js';
3
4
  import { WikiEngine } from '../types/WikiEngine.js';
4
5
  import type { AgentTokenGrant, ProxyClaim } from '../managers/UserManager.js';
@@ -52,6 +53,10 @@ interface UserContext {
52
53
  * form. They convert it themselves, through {@link asSubject}.
53
54
  */
54
55
  type PageQuestionCaller = ActorContext | UserContext | null | undefined;
56
+ /** What a subject attribute is read from (#2261): who, and what bears on `subject:aal`. */
57
+ export type SubjectFacts = AalFacts & {
58
+ username?: string;
59
+ };
55
60
  /**
56
61
  * Access decision log entry
57
62
  */
@@ -61,8 +66,24 @@ interface AccessDecisionLog {
61
66
  action?: string;
62
67
  allowed?: boolean;
63
68
  reason?: string;
69
+ /** The policies that decided (#2255), recorded on a denial. */
70
+ policyIds?: string[];
71
+ /** Allows that matched but whose condition was not met (#2288), recorded on a denial. */
72
+ conditionNotMet?: string[];
64
73
  context?: Record<string, unknown>;
65
74
  }
75
+ /**
76
+ * What the policies may read about a page (#2255), resolved by the PIP from
77
+ * the page itself. `attributes` holds what a resource type matches on
78
+ * (`system-category`), read eagerly; `read` answers a condition's
79
+ * `resource:<name>` for an attribute declared on the `page` resource type,
80
+ * lazily, so a lookup (the owner's account) is made only when a policy needs
81
+ * it. `undefined` is a missing attribute.
82
+ */
83
+ export interface PageAttributes {
84
+ attributes: Record<string, unknown>;
85
+ read(name: string): unknown;
86
+ }
66
87
  /**
67
88
  * PolicyInformationPoint — what a page decision needs to know about the page
68
89
  * (#1431; was ACLManager until step 8).
@@ -117,6 +138,7 @@ interface AccessDecisionLog {
117
138
  * if (await pip.checkPagePermissionWithContext(wikiContext, 'edit')) { ... }
118
139
  */
119
140
  declare class PolicyInformationPoint extends BaseManager {
141
+ private readonly clock;
120
142
  /** Read at the moment of use: this manager starts before PolicyEvaluator (#1431 step 13). */
121
143
  private get policyEvaluator();
122
144
  /**
@@ -124,8 +146,9 @@ declare class PolicyInformationPoint extends BaseManager {
124
146
  *
125
147
  * @constructor
126
148
  * @param {WikiEngine} engine - The wiki engine instance
149
+ * @param {() => Date} [clock] - The time `environment:` attributes are read at; injectable for tests
127
150
  */
128
- constructor(engine: WikiEngine);
151
+ constructor(engine: WikiEngine, clock?: () => Date);
129
152
  /**
130
153
  * Initializes the PolicyInformationPoint by loading policies and configurations
131
154
  *
@@ -353,10 +376,12 @@ declare class PolicyInformationPoint extends BaseManager {
353
376
  * Tier 1 is skipped for a vault page: `audience` grants nothing there.
354
377
  * - __Tier 0.5, author-lock__, `edit` only. It only denies: an author, or a
355
378
  * subject holding `admin-system` (#1431 7b), falls through to Tier 1+.
356
- * - __Tier 1, frontmatter__ `access[action]` / `audience`. Page-level, and
357
- * overrides global policy when it states a rule (#1054). An `audience`
358
- * covers every action, and a subject holding `admin-system` is its one
359
- * exception; `access` has none (#1964).
379
+ * - __Tier 1, frontmatter__ `audience`. Page-level, and decides a view
380
+ * when it states a rule (#1054). An `audience` covers every action, and a
381
+ * subject holding `admin-system` is its one exception (#1964). `access`
382
+ * is no longer read (#2255).
383
+ * - A page in a shared folder is decided by the folder's permission, and a
384
+ * change also by the `shared` category's policy and author-lock (#2255).
360
385
  * - __Tier 2.__ For a share visitor the share IS the policy (#1222); for
361
386
  * anyone else, global policy.
362
387
  * - __Default deny.__ Nothing below Tier 2 grants — JSPWiki's page-body ACL
@@ -477,14 +502,82 @@ declare class PolicyInformationPoint extends BaseManager {
477
502
  */
478
503
  canUserAccessMediaItem(caller: PageQuestionCaller, item: MediaItem): Promise<boolean>;
479
504
  /**
480
- * Check front matter audience / access fields (Tier 1).
505
+ * Check the front matter audience (Tier 1).
481
506
  * Returns a decision object; decided=false means no front matter restriction — fall through.
482
507
  *
483
508
  * An audience that leaves the subject out refuses every action, unless the
484
509
  * subject holds `admin-system` (#1964, operator 2026-10-23): asked only
485
- * then. `access[action]` has no such exception.
510
+ * then. `access` is no longer read (#2255).
486
511
  */
487
512
  private checkFrontmatterAccess;
513
+ /**
514
+ * A page's attributes for the policies (#2255, XACML's PIP): the PDP asks
515
+ * this from the page id, so no route passes them. `knownMetadata` is the
516
+ * frontmatter when this PIP already holds it (the page door); otherwise the
517
+ * page is read with the caller's own context, so a sealed page stays closed
518
+ * to anyone without its key (#1422). A page that cannot be read has no
519
+ * attributes: no category policy matches it and every condition on it is
520
+ * unknown.
521
+ */
522
+ pageAttributes(caller: PageQuestionCaller, pageName: string, knownMetadata?: Record<string, unknown> | null): Promise<PageAttributes>;
523
+ /**
524
+ * {@link pageAttributes} from frontmatter already in hand (the list
525
+ * filter). Attributes are those declared on the `page` resource type in
526
+ * `ngdpbase.access.resource-types`, each read from its `from` source; the
527
+ * sources are code (`frontmatter:<field>` today).
528
+ *
529
+ * - `system-category` is lowercased; a page with none is in the default
530
+ * category (the entry with `default: true`); a page in a shared folder is
531
+ * `shared`, whatever its frontmatter says (#2255).
532
+ * - An `identity` attribute (`owner`, from `author`) is compared by the
533
+ * account it names: an @id or a username not yet converted both read as
534
+ * that account's username, an orphan as missing (#2097).
535
+ */
536
+ pageAttributesFrom(pageName: string, metadata: PageFrontmatter | null | undefined): PageAttributes;
537
+ /**
538
+ * A subject attribute a condition reads (#2255, #2261): `subject:<name>`,
539
+ * declared in `ngdpbase.access.subject-attributes` (`id` always is) and read
540
+ * from its `from` source here, the only place a subject attribute is read. A
541
+ * request cannot send one. Missing when it is not declared, cannot be read, or
542
+ * the subject has none (`subject:aal` of a visitor who is not signed in, of a
543
+ * token made before it was stored); the evaluator then fails closed. Returns
544
+ * the value, or a Promise of it for a field read from the account (the
545
+ * evaluator awaits every read).
546
+ */
547
+ subjectAttribute(subject: SubjectFacts | null | undefined, name: string): unknown;
548
+ /**
549
+ * An environment attribute a condition reads (#2261): `environment:<name>`,
550
+ * declared in `ngdpbase.access.environment-attributes` and read from its
551
+ * `from` source here, the only place one is read. `schedule:<name>` is whether
552
+ * that schedule in `ngdpbase.schedules` is open at `now`; `config:<key>` is
553
+ * the boolean stored at that configuration key, any key, secrets included.
554
+ * Missing when it is not declared, the schedule is absent, the stored value is
555
+ * not a boolean, or the read fails; the evaluator then fails closed. The value
556
+ * is returned to the evaluator and goes nowhere else: it is never logged.
557
+ */
558
+ environmentAttribute(name: string, now?: Date): boolean | undefined;
559
+ /**
560
+ * A reader of environment attributes for one decision or one listing
561
+ * (#2261): the clock is read once, on the first attribute asked for, and
562
+ * each attribute once, so every policy and every page sees one instant.
563
+ */
564
+ environmentReader(): (name: string) => unknown;
565
+ /** One read of a declared subject attribute from its source. A value of the wrong type, or a source that throws, is missing. */
566
+ private readSubjectSource;
567
+ /** The page's category for the policies (#2255): lowercased; the default when it has none; `shared` in a shared folder. */
568
+ private pageCategory;
569
+ /** The attributes declared on the `page` resource type (#2255). */
570
+ private declaredPageAttributes;
571
+ /** An identity reference as the policies compare it: the username of the account it names, or missing (#2097). */
572
+ private identityValue;
573
+ /** Pages already warned about for an `access:` that is not the legacy stamp (#2255): once per page per process. */
574
+ private readonly warnedAccess;
575
+ /**
576
+ * `access:` frontmatter no longer has any effect (#2255). The stamp install
577
+ * wrote (`{ edit: [admin] }`) is left alone silently; any other value is
578
+ * someone's rule that is now ignored, so it is said once per page.
579
+ */
580
+ private warnLegacyAccess;
488
581
  /**
489
582
  * Does the page's `audience` admit this subject (#1964)? True when the page
490
583
  * names no audience, the subject matches it, or the subject holds
@@ -501,6 +594,19 @@ declare class PolicyInformationPoint extends BaseManager {
501
594
  * Accepts either a single object or positional args for backward compatibility.
502
595
  */
503
596
  logAccessDecision(userOrObj: UserContext | AccessDecisionLog, pageName?: string, action?: string, allowed?: boolean, reason?: string, _context?: Record<string, unknown>): void;
597
+ /**
598
+ * Record a page write a door refused on a capability alone (#2272): the
599
+ * route's `page-edit` / `page-create` check asks the PDP, which records
600
+ * nothing. Same record as every other page denial, `policyIds` included.
601
+ */
602
+ recordPageRefusal(subject: UserContext | null | undefined, pageName: string, action: string, reason: string, policyIds?: readonly string[], conditionNotMet?: readonly string[]): Promise<void>;
603
+ /**
604
+ * Record a write the signed-out write gate refused before any route ran
605
+ * (#2298). The resource is the route as asked (`POST /save/Home`): method
606
+ * and path, never the query or the body, which can carry a password or a
607
+ * token. Standard tier and best-effort, like every other denial.
608
+ */
609
+ recordRouteRefusal(subject: UserContext | null | undefined, route: string): Promise<void>;
504
610
  /** The name a decision is recorded under: the subject's, or `anonymous`. */
505
611
  private static recordedUser;
506
612
  /**
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Add-on record decisions (#1823).
3
+ *
4
+ * The add-on's manager is the enforcement point. It reads `owner` from the
5
+ * stored row and asks {@link PolicyDecisionPoint.decideOwnedRecord} or
6
+ * {@link PolicyDecisionPoint.decideOwnedList}. The answer is never null.
7
+ * A type that declares `owner` (an identity @id) is refused to anyone but
8
+ * that owner: under a proxy grant the subject is the person represented, so
9
+ * the delegate is allowed and a role is not. A type with no `owner` is
10
+ * decided by policy alone. A list that is refused is a denial, not an empty
11
+ * page of rows.
12
+ */
13
+ /** The PDP cannot decide. The caller fails closed; this is not a deny. */
14
+ export declare class OwnedRecordError extends Error {
15
+ constructor(message: string);
16
+ }
17
+ /** One record, with the attributes the manager read from its stored row. */
18
+ export interface OwnedRecordAsk {
19
+ action: string;
20
+ resource: {
21
+ type: string;
22
+ id: string;
23
+ /** From the stored row. `owner`, when the type declares it, is the identity @id. */
24
+ attributes?: Record<string, unknown>;
25
+ };
26
+ }
27
+ /** A list of one type. There is no owner argument: the filter comes from the subject. */
28
+ export interface OwnedListAsk {
29
+ action: string;
30
+ resource: {
31
+ type: string;
32
+ };
33
+ }
34
+ /**
35
+ * A constraint the manager turns into a bound parameter. Never SQL.
36
+ * `equals` means the column must equal the one value.
37
+ */
38
+ export interface OwnedRecordFilter {
39
+ attribute: string;
40
+ operator: 'equals';
41
+ values: readonly string[];
42
+ }
43
+ /**
44
+ * The resource a refusal is audited against (#1823). `owner` is the identity
45
+ * @id from the stored row, `ownerUser` that person's username. Both come from
46
+ * storage, never from the request body.
47
+ */
48
+ export interface OwnedAuditResource {
49
+ type: string;
50
+ id?: string;
51
+ owner?: string;
52
+ ownerUser?: string;
53
+ }
54
+ /** An explicit list answer (#1823). `filter` is empty only when the type has no owner. */
55
+ export type OwnedListDecision = {
56
+ decision: 'allow';
57
+ filter: readonly OwnedRecordFilter[];
58
+ } | {
59
+ decision: 'deny';
60
+ reason: string;
61
+ policyIds: readonly string[];
62
+ };
63
+ /**
64
+ * What is wrong with a resource type's `owner` declaration, or null (#1823).
65
+ * `owner`, when present at all, must be exactly `{ "type": "identity" }`.
66
+ * Anything else, `null` included, would quietly make an owner type
67
+ * policy-alone, so the decision throws and start-up refuses the config.
68
+ */
69
+ export declare function ownerDeclarationProblem(type: string, entry: unknown): string | null;
@@ -30,6 +30,8 @@ export type CorePermission =
30
30
  | 'identity-read'
31
31
  /** Browse and view the media library (not delegated to anonymous visitors) */
32
32
  | 'media-read'
33
+ /** Set or change a page's system-category (#2255). A page created in a shared folder gets the shared category without it */
34
+ | 'page-categorize'
33
35
  /** Create new pages */
34
36
  | 'page-create'
35
37
  /** Delete pages */
@@ -50,6 +52,12 @@ export type CorePermission =
50
52
  | 'profile-manage'
51
53
  /** Let another person act for you, within permissions you hold, and revoke it (#1762). Every signed-in role except demo-admin; an administrator making or revoking a grant for someone else also needs user-edit, and that grant never carries a copy of the person's vault key. A grant that includes proxy-grant lets the proxy give others access too. Asks for a fresh sign-in */
52
54
  | 'proxy-grant'
55
+ /** Delete pushed pages (#2255) */
56
+ | 'pushed-pages-delete'
57
+ /** Edit pushed pages: pages shipped by the system or an add-on (the categories a policy grants it on; system, documentation and addon as shipped) (#2255) */
58
+ | 'pushed-pages-edit'
59
+ /** Rename pushed pages (#2255) */
60
+ | 'pushed-pages-rename'
53
61
  /** Search pages */
54
62
  | 'search-page'
55
63
  /** Search users */
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The signed-out write list (#2298): the one place that names which routes may
3
+ * be written to by someone who is not signed in.
4
+ *
5
+ * Every request from a caller who is not signed in, other than GET, HEAD and
6
+ * OPTIONS, is refused by `signedOutWriteGate` (src/middleware/signedOutWriteGate.ts),
7
+ * before any route runs, unless the route is on this list. A new write route
8
+ * is therefore closed to signed-out callers until someone adds it here with a
9
+ * reason, which is the point (#2294 came from routes that were open until
10
+ * their author remembered to close them).
11
+ *
12
+ * What belongs here: a route a person reaches BEFORE they are signed in (the
13
+ * steps of signing in, registering, recovering a password, accepting an
14
+ * invitation), a link followed from an email in any browser, and a public form.
15
+ * What does not: anything that asks for a permission a signed-out caller
16
+ * never holds. A route that answers its own token (a Bearer token, which the
17
+ * gate counts as signed in) needs no entry.
18
+ *
19
+ * Core's routes are listed below. An add-on lists its own through the add-on
20
+ * host, `host.allowSignedOutWrite(...)`, which accepts only a route under the
21
+ * add-on's own `/api/<slug>/` or `/addons/<slug>/`, and refuses a slug that
22
+ * names one of core's own `/api/` or `/addons/` prefixes. Those are the only
23
+ * ways onto the list, and it is sealed once the add-ons have loaded; outside
24
+ * tests (`NODE_ENV=test`) the seal cannot be undone.
25
+ *
26
+ * Permission to write is still asked by each route, as always: an entry only
27
+ * lets the request reach the route, it grants nothing.
28
+ */
29
+ export type WriteMethod = 'POST' | 'PUT' | 'PATCH' | 'DELETE';
30
+ export interface SignedOutWriteRoute {
31
+ method: WriteMethod;
32
+ /** An Express path: literal segments and `:name` parameters only. */
33
+ path: string;
34
+ /** Why a caller who is not signed in must reach it. Required. */
35
+ reason: string;
36
+ }
37
+ /** Whether the method may change things, and so is held by the gate: anything but GET, HEAD and OPTIONS. */
38
+ export declare function isWriteMethod(method: string): boolean;
39
+ /**
40
+ * Slugs an add-on may not use to list a route, because core already answers
41
+ * under `/api/<slug>/` or `/addons/<slug>/`: an add-on in a directory named
42
+ * `admin` must not open `/api/admin/...`. Kept in step with core's route
43
+ * files; `signedOutWriteGate.test.ts` checks it against the route inventory.
44
+ */
45
+ export declare const RESERVED_ADDON_SLUGS: ReadonlySet<string>;
46
+ /**
47
+ * Core's list. Each reason says why the route cannot wait for a sign-in.
48
+ * Routes that need a signed-in session by their nature (consent, the
49
+ * account-setup password, the second-factor enrolment, reauthentication) are
50
+ * not here: their callers are signed in when they reach them.
51
+ */
52
+ export declare const CORE_SIGNED_OUT_WRITES: readonly SignedOutWriteRoute[];
53
+ /**
54
+ * An add-on's declaration of a route a signed-out caller may write to.
55
+ * Called through `AddonHost.allowSignedOutWrite`, bound to the add-on's slug.
56
+ * Throws on anything outside the add-on's own paths, so one add-on can never
57
+ * open another's, or core's.
58
+ */
59
+ export declare function declareAddonSignedOutWrite(slug: string, route: SignedOutWriteRoute): void;
60
+ /** Close the list to add-ons. Called once, after they have loaded. */
61
+ export declare function sealSignedOutWrites(): void;
62
+ /** Test support: forget add-on declarations and reopen the list. Refuses outside `NODE_ENV=test`, so a sealed list stays sealed. */
63
+ export declare function resetSignedOutWrites(): void;
64
+ /** Every route on the list, core's then the add-ons'. */
65
+ export declare function signedOutWriteRoutes(): Array<SignedOutWriteRoute & {
66
+ owner: string;
67
+ }>;
68
+ /** The reason the route is on the list, or null when a signed-out caller may not write to it. */
69
+ export declare function signedOutWriteReason(method: string, path: string): string | null;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The signed-out write list (#2298): the one place that names which routes may
3
+ * be written to by someone who is not signed in.
4
+ *
5
+ * Every request from a caller who is not signed in, other than GET, HEAD and
6
+ * OPTIONS, is refused by `signedOutWriteGate` (src/middleware/signedOutWriteGate.ts),
7
+ * before any route runs, unless the route is on this list. A new write route
8
+ * is therefore closed to signed-out callers until someone adds it here with a
9
+ * reason, which is the point (#2294 came from routes that were open until
10
+ * their author remembered to close them).
11
+ *
12
+ * What belongs here: a route a person reaches BEFORE they are signed in (the
13
+ * steps of signing in, registering, recovering a password, accepting an
14
+ * invitation), a link followed from an email in any browser, and a public form.
15
+ * What does not: anything that asks for a permission a signed-out caller
16
+ * never holds. A route that answers its own token (a Bearer token, which the
17
+ * gate counts as signed in) needs no entry.
18
+ *
19
+ * Core's routes are listed below. An add-on lists its own through the add-on
20
+ * host, `host.allowSignedOutWrite(...)`, which accepts only a route under the
21
+ * add-on's own `/api/<slug>/` or `/addons/<slug>/`, and refuses a slug that
22
+ * names one of core's own `/api/` or `/addons/` prefixes. Those are the only
23
+ * ways onto the list, and it is sealed once the add-ons have loaded; outside
24
+ * tests (`NODE_ENV=test`) the seal cannot be undone.
25
+ *
26
+ * Permission to write is still asked by each route, as always: an entry only
27
+ * lets the request reach the route, it grants nothing.
28
+ */
29
+ /** The methods that only read. Every other method is held by the gate, so a new one (PROPFIND, MERGE, SEARCH…) fails closed. */
30
+ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
31
+ /** The methods an add-on may list. */
32
+ const LISTABLE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
33
+ /** Whether the method may change things, and so is held by the gate: anything but GET, HEAD and OPTIONS. */
34
+ export function isWriteMethod(method) {
35
+ return !SAFE_METHODS.has(method.toUpperCase());
36
+ }
37
+ /**
38
+ * Slugs an add-on may not use to list a route, because core already answers
39
+ * under `/api/<slug>/` or `/addons/<slug>/`: an add-on in a directory named
40
+ * `admin` must not open `/api/admin/...`. Kept in step with core's route
41
+ * files; `signedOutWriteGate.test.ts` checks it against the route inventory.
42
+ */
43
+ export const RESERVED_ADDON_SLUGS = new Set([
44
+ 'addons', 'admin', 'agent', 'agents', 'assets', 'attachments', 'audit', 'auth', 'backup', 'catalog', 'check-updates',
45
+ 'comments', 'config', 'consent', 'core', 'export', 'footnotes', 'import', 'install', 'keywords', 'login', 'logout',
46
+ 'media', 'notifications', 'oidc', 'page', 'page-metadata', 'page-source', 'page-suggestions', 'pages', 'policies',
47
+ 'policy', 'preview', 'profile', 'register', 'search', 'session', 'session-count', 'session-users', 'sessions', 'share',
48
+ 'system', 'test', 'tokens', 'user', 'user-keywords', 'users', 'vault', 'wiki'
49
+ ]);
50
+ /**
51
+ * Core's list. Each reason says why the route cannot wait for a sign-in.
52
+ * Routes that need a signed-in session by their nature (consent, the
53
+ * account-setup password, the second-factor enrolment, reauthentication) are
54
+ * not here: their callers are signed in when they reach them.
55
+ */
56
+ export const CORE_SIGNED_OUT_WRITES = Object.freeze([
57
+ // Signing in. The caller has no session yet.
58
+ { method: 'POST', path: '/login', reason: 'password step of signing in' },
59
+ { method: 'POST', path: '/auth/magic-link', reason: 'asks for a sign-in link by email' },
60
+ { method: 'POST', path: '/auth/magic-link/verify', reason: 'completes a sign-in from the emailed link' },
61
+ { method: 'POST', path: '/auth/passkey/authenticate/verify', reason: 'signs in with a passkey' },
62
+ { method: 'POST', path: '/auth/oauth/:name', reason: 'starts a sign-in with an outside provider' },
63
+ { method: 'POST', path: '/auth/choose-account', reason: 'finishes an outside sign-in whose email is shared by several accounts' },
64
+ { method: 'POST', path: '/login/connect/cancel', reason: 'drops an outside identity waiting to be connected, from the login page' },
65
+ // The second factor. It needs the pending sign-in handle from the password step, not a session.
66
+ { method: 'POST', path: '/login/second-factor/email', reason: 'second factor: sends the approval email for the waiting sign-in' },
67
+ { method: 'POST', path: '/login/second-factor/complete', reason: 'second factor: completes the waiting sign-in' },
68
+ { method: 'POST', path: '/login/second-factor/cancel', reason: 'second factor: abandons the waiting sign-in' },
69
+ { method: 'POST', path: '/login/second-factor/totp', reason: 'second factor: authenticator-app code for the waiting sign-in' },
70
+ { method: 'POST', path: '/login/second-factor/device', reason: 'second factor: proof from a remembered browser for the waiting sign-in' },
71
+ { method: 'POST', path: '/login/approve', reason: 'approves or refuses a waiting sign-in from the emailed link, opened in any browser' },
72
+ // Signing out. A session that expired still has to be able to end.
73
+ { method: 'POST', path: '/logout', reason: 'ends the session; a signed-out caller is sent on to sign in' },
74
+ // Becoming a user, and getting back in.
75
+ { method: 'POST', path: '/register', reason: 'registration (the handler answers 404 when it is off)' },
76
+ { method: 'POST', path: '/recover-password', reason: 'password reset with the recovery words' },
77
+ { method: 'POST', path: '/invitation', reason: 'accepts an invitation by its token, which signs the person in' },
78
+ { method: 'POST', path: '/account/email/confirm', reason: 'confirms a changed email address from the emailed link, opened in any browser' },
79
+ // Public forms.
80
+ { method: 'POST', path: '/contact', reason: 'the public contact form (the handler answers 404 when it is off)' },
81
+ // First run: no account exists to sign in with. The handlers refuse once the site is installed.
82
+ { method: 'POST', path: '/install', reason: 'first-run installation, before any account exists' },
83
+ { method: 'POST', path: '/install/reset', reason: 'first-run installation, before any account exists' }
84
+ ]);
85
+ /** What a route path means to Express: `:name` is one segment; the match ignores case and a trailing slash. */
86
+ function matcherFor(path) {
87
+ const body = path
88
+ .split('/')
89
+ .map((segment) => (segment.startsWith(':') ? '[^/]+' : segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
90
+ .join('/');
91
+ return new RegExp(`^${body}/?$`, 'i');
92
+ }
93
+ const entryOf = (route, owner) => ({ ...route, owner, matches: matcherFor(route.path) });
94
+ const SEGMENT = /^(?:[A-Za-z0-9_~-][A-Za-z0-9._~-]*|:[A-Za-z][A-Za-z0-9_]*)$/;
95
+ let addonEntries = [];
96
+ let sealed = false;
97
+ const coreEntries = CORE_SIGNED_OUT_WRITES.map((r) => entryOf(r, 'core'));
98
+ /**
99
+ * An add-on's declaration of a route a signed-out caller may write to.
100
+ * Called through `AddonHost.allowSignedOutWrite`, bound to the add-on's slug.
101
+ * Throws on anything outside the add-on's own paths, so one add-on can never
102
+ * open another's, or core's.
103
+ */
104
+ export function declareAddonSignedOutWrite(slug, route) {
105
+ const refuse = (why) => {
106
+ throw new Error(`Add-on '${slug}' cannot allow a signed-out ${String(route?.method)} ${String(route?.path)}: ${why}`);
107
+ };
108
+ if (sealed)
109
+ refuse('the list is sealed once add-ons have loaded; declare it in register()');
110
+ if (RESERVED_ADDON_SLUGS.has(slug.toLowerCase()))
111
+ refuse(`'${slug}' is one of core's own route prefixes`);
112
+ if (!route || typeof route.method !== 'string' || !LISTABLE_METHODS.has(route.method.toUpperCase()))
113
+ refuse('the method must be POST, PUT, PATCH or DELETE');
114
+ if (typeof route.reason !== 'string' || route.reason.trim() === '')
115
+ refuse('say why in `reason`');
116
+ if (typeof route.path !== 'string')
117
+ return refuse('the path must be a string');
118
+ const own = [`/api/${slug}/`, `/addons/${slug}/`];
119
+ if (!own.some((prefix) => route.path.startsWith(prefix)))
120
+ refuse(`the path must be under ${own.join(' or ')}`);
121
+ const segments = route.path.split('/').slice(1);
122
+ if (segments.some((s) => !SEGMENT.test(s)))
123
+ refuse('the path may hold only literal segments and :name parameters');
124
+ addonEntries.push(entryOf({ ...route, method: route.method.toUpperCase() }, slug));
125
+ }
126
+ /** Close the list to add-ons. Called once, after they have loaded. */
127
+ export function sealSignedOutWrites() {
128
+ sealed = true;
129
+ }
130
+ /** Test support: forget add-on declarations and reopen the list. Refuses outside `NODE_ENV=test`, so a sealed list stays sealed. */
131
+ export function resetSignedOutWrites() {
132
+ if (process.env.NODE_ENV !== 'test' && !process.env.VITEST)
133
+ throw new Error('resetSignedOutWrites is for tests only (NODE_ENV=test)');
134
+ addonEntries = [];
135
+ sealed = false;
136
+ }
137
+ /** Every route on the list, core's then the add-ons'. */
138
+ export function signedOutWriteRoutes() {
139
+ return [...coreEntries, ...addonEntries].map(({ method, path, reason, owner }) => ({ method, path, reason, owner }));
140
+ }
141
+ /** The reason the route is on the list, or null when a signed-out caller may not write to it. */
142
+ export function signedOutWriteReason(method, path) {
143
+ const m = method.toUpperCase();
144
+ const hit = [...coreEntries, ...addonEntries].find((e) => e.method === m && e.matches.test(path));
145
+ return hit ? hit.reason : null;
146
+ }
@@ -95,13 +95,6 @@ export interface PageFrontmatter {
95
95
  * in Slice E (v3.7.0). All datasets migrated; this field is the only source of truth.
96
96
  */
97
97
  private?: boolean;
98
- /** Per-action principal lists — overrides audience for the named action */
99
- access?: {
100
- view?: string[];
101
- edit?: string[];
102
- delete?: string[];
103
- [key: string]: string[] | undefined;
104
- };
105
98
  /** Additional custom metadata */
106
99
  [key: string]: unknown;
107
100
  }