@jwilleke/ngdpbase-types 5.30.0 → 5.31.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 (32) hide show
  1. package/README.md +1 -1
  2. package/lib/packages/ngdpbase-types/index.d.ts +5 -4
  3. package/lib/src/context/JobContext.d.ts +9 -0
  4. package/lib/src/context/JobContext.js +3 -1
  5. package/lib/src/context/bootActions.js +2 -0
  6. package/lib/src/ice/fileHandling.d.ts +24 -2
  7. package/lib/src/ice/originalKeep.d.ts +40 -0
  8. package/lib/src/ice/types.d.ts +33 -1
  9. package/lib/src/managers/AttachmentManager.d.ts +12 -8
  10. package/lib/src/managers/BackgroundJobManager.d.ts +17 -0
  11. package/lib/src/managers/IdentityManager.d.ts +64 -4
  12. package/lib/src/managers/ImportManager.d.ts +2 -5
  13. package/lib/src/managers/OrganizationManager.d.ts +9 -5
  14. package/lib/src/managers/PolicyEvaluator.d.ts +7 -0
  15. package/lib/src/managers/UserManager.d.ts +13 -0
  16. package/lib/src/providers/BaseAttachmentProvider.d.ts +2 -8
  17. package/lib/src/providers/BasicAttachmentProvider.d.ts +3 -8
  18. package/lib/src/security/PolicyDecisionPoint.d.ts +8 -0
  19. package/lib/src/security/PolicyInformationPoint.d.ts +1 -1
  20. package/lib/src/types/Person.d.ts +39 -6
  21. package/lib/src/types/Policy.d.ts +2 -2
  22. package/lib/src/types/Provider.d.ts +21 -0
  23. package/lib/src/utils/addonConfigLayer.d.ts +16 -0
  24. package/lib/src/utils/addonConfigLayer.js +40 -3
  25. package/lib/src/utils/addonHost.d.ts +21 -3
  26. package/lib/src/utils/addonHost.js +10 -4
  27. package/lib/src/utils/availability.d.ts +120 -0
  28. package/lib/src/utils/availability.js +174 -0
  29. package/lib/src/utils/keyedSerial.js +41 -0
  30. package/lib/src/utils/roleGrants.d.ts +18 -3
  31. package/lib/src/utils/sessionSecurity.d.ts +116 -0
  32. package/package.json +1 -1
@@ -17,15 +17,40 @@
17
17
  */
18
18
  import type { PostalAddress, ContactPointEntry } from './Organization.js';
19
19
  /**
20
- * One organization a person is affiliated with (#1930): schema.org
21
- * `affiliation`, with the person's role or title there when one is known.
20
+ * One affiliation of a digital identity (#2039): schema.org `affiliation`
21
+ * with an `OrganizationRole`, as the identity model says
22
+ * (`docs/planning/identity-model.md`, "Affiliation shape"). It puts the
23
+ * identity in a group, the organization plus the title, and never grants a
24
+ * permission.
25
+ *
22
26
  * The organization is named by its `@id`; OrganizationManager lists an
23
27
  * organization's people by reading these, so the link is stored once, here.
28
+ * With the site's own organization it is a fact, set only under
29
+ * `identity-manage`; with any other it is a claim, which a user may also set
30
+ * on their own identity record. IdentityManager's door decides.
24
31
  */
25
32
  export interface PersonAffiliation {
26
- /** The organization's `@id`. */
33
+ '@type': 'OrganizationRole';
34
+ /** The organization, by its `@id`. */
35
+ memberOf: {
36
+ '@id': string;
37
+ };
38
+ /** The affiliation's title there (Owner, Treasurer, Vendor): free text, never a Role. Left out when none is known. */
39
+ roleName?: string;
40
+ /** ISO date (`YYYY-MM-DD`) the affiliation began; left out when not known. */
41
+ startDate?: string;
42
+ /** ISO date (`YYYY-MM-DD`) it ended or ends, the day included; left out while it is ongoing. */
43
+ endDate?: string;
44
+ }
45
+ /**
46
+ * An affiliation in its shape before #2039, `{ organization, role? }`.
47
+ * Converted (organization → `memberOf`, role → `roleName`) when read, once on
48
+ * disk at start-up, and when an add-on still writes it (for one release).
49
+ *
50
+ * @deprecated Write {@link PersonAffiliation}.
51
+ */
52
+ export interface LegacyPersonAffiliation {
27
53
  organization: string;
28
- /** The person's role or title there (schema.org `roleName`), when known. */
29
54
  role?: string;
30
55
  }
31
56
  /**
@@ -59,7 +84,7 @@ export interface Person {
59
84
  image?: string;
60
85
  /** Other URLs that identify this person (LinkedIn, GitHub, etc.) */
61
86
  sameAs?: string[];
62
- /** Organizations this person is affiliated with, each with an optional role (#1930). */
87
+ /** Affiliations: each a schema.org OrganizationRole, an organization with a title and dates (#2039). */
63
88
  affiliation?: PersonAffiliation[];
64
89
  /** Allow extension fields (additionalProperty, etc.) */
65
90
  [key: string]: unknown;
@@ -69,12 +94,20 @@ export interface Person {
69
94
  * `@context`/`@type` are immutable; `@id` and `identifier` cannot be changed via update.
70
95
  */
71
96
  export type PersonUpdate = Partial<Omit<Person, '@context' | '@type' | '@id' | 'identifier'>>;
97
+ /**
98
+ * What `IdentityManager.update` takes: a {@link PersonUpdate} whose
99
+ * `affiliation` (the whole list) may still hold entries in the old
100
+ * `{ organization, role? }` shape, converted at the door, for one release (#2039).
101
+ */
102
+ export type PersonChange = Omit<PersonUpdate, 'affiliation'> & {
103
+ affiliation?: Array<PersonAffiliation | LegacyPersonAffiliation>;
104
+ };
72
105
  /**
73
106
  * What a caller gives `IdentityManager.create` (#1930): the person's fields.
74
107
  * `identifier` is left out for a person with no account (one is generated);
75
108
  * UserManager passes the account's username when it creates the person for a
76
109
  * new account.
77
110
  */
78
- export type PersonInput = PersonUpdate & {
111
+ export type PersonInput = PersonChange & {
79
112
  identifier?: string;
80
113
  };
@@ -12,9 +12,9 @@
12
12
  * again because a `.d.ts` may not import a `.ts`. Four copies of one shape is
13
13
  * how a field gets added to one and missed in the others.
14
14
  */
15
- /** Who a policy grants to. `role` is the only type the evaluator matches today. */
15
+ /** Who a policy grants to. The evaluator matches `role` and `job` (#1999), its `SUBJECT_MATCHERS`. */
16
16
  export interface PolicySubject {
17
- /** `role`, and in future `user` or `group`. */
17
+ /** `role`, or `job` for a scheduled job acting as itself (#1999). */
18
18
  type: string;
19
19
  value: string;
20
20
  }
@@ -582,6 +582,27 @@ export interface StoreFileEntry {
582
582
  dateModified: string;
583
583
  /** Pages the file was uploaded onto. */
584
584
  mentions: string[];
585
+ /**
586
+ * An import's kept original (#2061): always served as a download
587
+ * (`Content-Disposition: attachment`, `nosniff`), never shown inline.
588
+ */
589
+ download?: true;
590
+ }
591
+ /** What `storeFileInStore` is told about a file it stores in a private store. */
592
+ export interface StoreFileInput {
593
+ originalName: string;
594
+ mimeType: string;
595
+ description: string;
596
+ author?: string;
597
+ pageName?: string;
598
+ /**
599
+ * Set only by AttachmentManager for an import's keep into the receiver's
600
+ * own vault (#2061): `types` are allowed beyond the site's allow-list, and
601
+ * the file is marked {@link StoreFileEntry.download}.
602
+ */
603
+ keptOriginal?: {
604
+ types: readonly string[];
605
+ };
585
606
  }
586
607
  /**
587
608
  * A page in a private store (#1456): its entry in the store's OWN page index,
@@ -51,6 +51,11 @@
51
51
  * classify only its own keys, `ngdpbase.addons.<slug>` and what is under it
52
52
  * (paths inside its values included). An entry for any other key refuses the
53
53
  * addon, since it could unhide core's or another addon's secret.
54
+ *
55
+ * A fifth, `ngdpbase.access.policies` job subjects (#1999): an enabled addon's
56
+ * policy may name only its own scheduled jobs, `{ "type": "job", "value":
57
+ * "<slug>.<job>" }`. A policy naming core's job or another addon's refuses
58
+ * the addon, since it would grant that job permissions its owner never chose.
54
59
  */
55
60
  import { type AddonDatabaseDeclaration } from './addonDatabaseRules.js';
56
61
  export declare const ADDONS_PATH_KEY = "ngdpbase.managers.addons-manager.addons-path";
@@ -119,6 +124,17 @@ export interface AddonSensitiveValuesLayer {
119
124
  * enabled addon's entries reach the merge, so only an enabled addon is checked.
120
125
  */
121
126
  export declare function addonSensitiveValuesLayer(addons: readonly AddonDefaults[]): AddonSensitiveValuesLayer;
127
+ export interface AddonJobPolicyLayer {
128
+ /** Addons whose policies name a job that is not their own, by slug, with why: never loaded. */
129
+ refused: Map<string, string>;
130
+ problems: string[];
131
+ }
132
+ /**
133
+ * Which jobs an addon's policies may name (#1999): its own, ids starting
134
+ * `<slug>.`, the prefix its job ids carry. Only an enabled addon's policies
135
+ * reach the merge, so only an enabled addon is checked.
136
+ */
137
+ export declare function addonJobPolicyLayer(addons: readonly AddonDefaults[]): AddonJobPolicyLayer;
122
138
  /** The configuration key that makes an addon site-critical (#1883). */
123
139
  export declare function addonSiteCriticalKey(slug: string): string;
124
140
  export interface AddonSiteCriticalLayer {
@@ -51,6 +51,11 @@
51
51
  * classify only its own keys, `ngdpbase.addons.<slug>` and what is under it
52
52
  * (paths inside its values included). An entry for any other key refuses the
53
53
  * addon, since it could unhide core's or another addon's secret.
54
+ *
55
+ * A fifth, `ngdpbase.access.policies` job subjects (#1999): an enabled addon's
56
+ * policy may name only its own scheduled jobs, `{ "type": "job", "value":
57
+ * "<slug>.<job>" }`. A policy naming core's job or another addon's refuses
58
+ * the addon, since it would grant that job permissions its owner never chose.
54
59
  */
55
60
  import fs from 'fs';
56
61
  import path from 'path';
@@ -313,6 +318,37 @@ export function addonSensitiveValuesLayer(addons) {
313
318
  }
314
319
  return layer;
315
320
  }
321
+ /**
322
+ * Which jobs an addon's policies may name (#1999): its own, ids starting
323
+ * `<slug>.`, the prefix its job ids carry. Only an enabled addon's policies
324
+ * reach the merge, so only an enabled addon is checked.
325
+ */
326
+ export function addonJobPolicyLayer(addons) {
327
+ const layer = { refused: new Map(), problems: [] };
328
+ for (const addon of addons) {
329
+ if (!addon.enabled)
330
+ continue;
331
+ const policies = addon.defaults['ngdpbase.access.policies'];
332
+ if (!Array.isArray(policies))
333
+ continue;
334
+ const foreign = [];
335
+ for (const policy of policies) {
336
+ const subjects = isPlainObject(policy) && Array.isArray(policy.subjects) ? policy.subjects : [];
337
+ for (const subject of subjects) {
338
+ if (!isPlainObject(subject) || subject.type !== 'job')
339
+ continue;
340
+ if (typeof subject.value !== 'string' || !subject.value.startsWith(`${addon.slug}.`))
341
+ foreign.push(`'${String(subject.value)}' (policy '${String(policy.id)}')`);
342
+ }
343
+ }
344
+ if (foreign.length === 0)
345
+ continue;
346
+ const reason = `its policies name the job(s) ${foreign.join(', ')}, which are not its own; an add-on's policy names only its own jobs, ${addon.slug}.<job>`;
347
+ layer.refused.set(addon.slug, reason);
348
+ layer.problems.push(`${addonProblemSubject(addon.slug, addon.enabled)}: ${reason}; the add-on is not loaded`);
349
+ }
350
+ return layer;
351
+ }
316
352
  /** The configuration key that makes an addon site-critical (#1883). */
317
353
  export function addonSiteCriticalKey(slug) {
318
354
  return `ngdpbase.addons.${slug}.site-critical`;
@@ -402,9 +438,10 @@ export function mergeWithAddonLayer(shipped, addons, custom) {
402
438
  const databaseLayer = addonDatabaseLayer(addons, custom);
403
439
  const criticalLayer = addonSiteCriticalLayer(addons, custom);
404
440
  const sensitiveLayer = addonSensitiveValuesLayer(addons);
441
+ const jobPolicyLayer = addonJobPolicyLayer(addons);
405
442
  const siteCritical = criticalLayer.siteCritical;
406
443
  const refused = new Map(layer.refused);
407
- for (const [slug, reason] of [...databaseLayer.refused, ...criticalLayer.refused, ...sensitiveLayer.refused])
444
+ for (const [slug, reason] of [...databaseLayer.refused, ...criticalLayer.refused, ...sensitiveLayer.refused, ...jobPolicyLayer.refused])
408
445
  refused.set(slug, refused.has(slug) ? `${refused.get(slug)}; ${reason}` : reason);
409
446
  const databases = databaseLayer.declarations.map((d) => ({ ...d, enabled: d.enabled && !refused.has(d.declaredBy) }));
410
447
  const withAddons = addons
@@ -418,14 +455,14 @@ export function mergeWithAddonLayer(shipped, addons, custom) {
418
455
  return deepMergeObjects(acc, rest);
419
456
  }, shipped);
420
457
  if (Object.keys(layer.entries).length === 0) {
421
- return { merged: deepMergeConfigs(withAddons, custom), problems: [...layer.problems, ...databaseLayer.problems, ...criticalLayer.problems, ...sensitiveLayer.problems], refused, databases, siteCritical };
458
+ return { merged: deepMergeConfigs(withAddons, custom), problems: [...layer.problems, ...databaseLayer.problems, ...criticalLayer.problems, ...sensitiveLayer.problems, ...jobPolicyLayer.problems], refused, databases, siteCritical };
422
459
  }
423
460
  const shippedCategories = isPlainObject(withAddons[SYSTEM_CATEGORY_KEY]) ? withAddons[SYSTEM_CATEGORY_KEY] : {};
424
461
  const layered = { ...withAddons, [SYSTEM_CATEGORY_KEY]: deepMergeObjects(shippedCategories, layer.entries) };
425
462
  const merged = deepMergeObjects(layered, custom);
426
463
  const stood = ownerDeclarationsStand(merged[SYSTEM_CATEGORY_KEY], layer, customCategories);
427
464
  merged[SYSTEM_CATEGORY_KEY] = stood.categories;
428
- return { merged: merged, problems: [...layer.problems, ...stood.problems, ...databaseLayer.problems, ...criticalLayer.problems, ...sensitiveLayer.problems], refused, databases, siteCritical };
465
+ return { merged: merged, problems: [...layer.problems, ...stood.problems, ...databaseLayer.problems, ...criticalLayer.problems, ...sensitiveLayer.problems, ...jobPolicyLayer.problems], refused, databases, siteCritical };
429
466
  }
430
467
  /**
431
468
  * The full merge for a caller with the two files already read (the manager,
@@ -18,9 +18,11 @@
18
18
  * - `registerShim` and `registerReceiver` (#1979, ICE) register into the import
19
19
  * engine under this add-on's slug, and only the names its manifest declares;
20
20
  * - the egress tools (`resolveEgressPolicy`, `validateUrl`,
21
- * `createGuardedElasticsearchClient`, `refusedNodeMessage`) and `isAtLeast`
22
- * are core's own pure functions, handed on unchanged, so an add-on never
23
- * imports them from `dist/` (#1813);
21
+ * `createGuardedElasticsearchClient`, `refusedNodeMessage`), `isAtLeast`
22
+ * and `zonedParts` (#1998) are core's own pure functions, handed on
23
+ * unchanged, so an add-on never imports them from `dist/` (#1813);
24
+ * - `keyedSerial()` returns a new `KeyedSerial`, core's own per-key queue, so
25
+ * an add-on's manager changes each of its records one at a time (#2065);
24
26
  * - the object is `Object.freeze`d, so no add-on can replace a value.
25
27
  *
26
28
  * Additive only: nothing here is removed or renamed without a major release.
@@ -36,6 +38,8 @@ import { resolveEgressPolicy } from '../http/egressPolicy.js';
36
38
  import { validateUrl } from '../http/ssrf.js';
37
39
  import { createGuardedElasticsearchClient, refusedNodeMessage } from '../http/guardedElasticsearch.js';
38
40
  import { isAtLeast } from './version.js';
41
+ import { zonedParts } from './availability.js';
42
+ import { KeyedSerial } from './keyedSerial.js';
39
43
  import { recordAuditEvent } from './auditEvents.js';
40
44
  import { addonAuditEventName } from './auditEventNames.js';
41
45
  import type { IceReceiver, IceShim } from '../ice/types.js';
@@ -80,6 +84,20 @@ export interface AddonHost {
80
84
  readonly refusedNodeMessage: typeof refusedNodeMessage;
81
85
  /** Whether an `x.y.z` version is at least another; throws on a version that is not `x.y.z`. */
82
86
  readonly isAtLeast: typeof isAtLeast;
87
+ /**
88
+ * #1998: an instant in a named time zone, as its local parts: `year`,
89
+ * `month` (1–12), `date` (`YYYY-MM-DD`), `day` (weekday name) and `minutes`
90
+ * since midnight. A scheduled job passes its `ctx.slot` and
91
+ * `BackgroundJobManager.scheduleTimeZone()` to learn which local month or
92
+ * day the slot falls in. An unknown zone answers in UTC.
93
+ */
94
+ readonly zonedParts: typeof zonedParts;
95
+ /**
96
+ * #2065: a new `KeyedSerial` (core's own) at each call; take one per manager.
97
+ * Run each read-modify-write of one of your records inside `run(recordId, fn)`,
98
+ * the read included. Not re-entrant: `fn` must not `run` the same key again.
99
+ */
100
+ readonly keyedSerial: () => KeyedSerial;
83
101
  /**
84
102
  * #1979 (ICE, D6): register an import shim, as `<slug>.<shim.name>`. Call it
85
103
  * in `register`. Refused when the manifest's `shims` does not name it.
@@ -18,9 +18,11 @@
18
18
  * - `registerShim` and `registerReceiver` (#1979, ICE) register into the import
19
19
  * engine under this add-on's slug, and only the names its manifest declares;
20
20
  * - the egress tools (`resolveEgressPolicy`, `validateUrl`,
21
- * `createGuardedElasticsearchClient`, `refusedNodeMessage`) and `isAtLeast`
22
- * are core's own pure functions, handed on unchanged, so an add-on never
23
- * imports them from `dist/` (#1813);
21
+ * `createGuardedElasticsearchClient`, `refusedNodeMessage`), `isAtLeast`
22
+ * and `zonedParts` (#1998) are core's own pure functions, handed on
23
+ * unchanged, so an add-on never imports them from `dist/` (#1813);
24
+ * - `keyedSerial()` returns a new `KeyedSerial`, core's own per-key queue, so
25
+ * an add-on's manager changes each of its records one at a time (#2065);
24
26
  * - the object is `Object.freeze`d, so no add-on can replace a value.
25
27
  *
26
28
  * Additive only: nothing here is removed or renamed without a major release.
@@ -35,6 +37,8 @@ import { resolveEgressPolicy } from '../http/egressPolicy.js';
35
37
  import { validateUrl } from '../http/ssrf.js';
36
38
  import { createGuardedElasticsearchClient, refusedNodeMessage } from '../http/guardedElasticsearch.js';
37
39
  import { isAtLeast } from './version.js';
40
+ import { zonedParts } from './availability.js';
41
+ import { KeyedSerial } from './keyedSerial.js';
38
42
  import { recordAuditEvent } from './auditEvents.js';
39
43
  import { addonAuditEventName } from './auditEventNames.js';
40
44
  import { auditDeclarationsFrom } from './auditRegistry.js';
@@ -117,6 +121,8 @@ export function buildAddonHost(slug, addonDefaults, setEnabledForPerson = noPers
117
121
  validateUrl,
118
122
  createGuardedElasticsearchClient,
119
123
  refusedNodeMessage,
120
- isAtLeast
124
+ isAtLeast,
125
+ zonedParts,
126
+ keyedSerial: () => new KeyedSerial()
121
127
  });
122
128
  }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Is the instance open to ordinary traffic, and if not, why (#1432)?
3
+ *
4
+ * "Is the site open" is not "may this identity do this". The second is the
5
+ * permission door; this is the first, and it has several reasons — maintenance,
6
+ * a schedule such as business hours, and a holiday. They shared one property:
7
+ * the configuration shipped switches for all of them and only maintenance was
8
+ * ever enforced, so an operator could turn business hours on and nothing
9
+ * happened. `PolicyInformationPoint` carried the unreachable implementations
10
+ * (`checkBusinessHours`, `checkHolidayRestrictions`, `checkEnhancedTimeRestrictions`),
11
+ * which is also the wrong manager for the question.
12
+ *
13
+ * One resolver, one gate, one dialog, different messages. This returns the
14
+ * FIRST reason that closes the instance, with the operator's own wording for
15
+ * it, and the gate middleware renders it.
16
+ *
17
+ * Read per request, never cached at boot, so an admin toggle takes effect
18
+ * without a restart. Maintenance itself is `MaintenanceState`'s (#1906): the
19
+ * gate passes its visitor view in, and this only ranks it first.
20
+ */
21
+ import type { MaintenanceVisitorView } from './maintenanceState.js';
22
+ /**
23
+ * Read one configuration value. No fallback parameter, deliberately.
24
+ *
25
+ * A fallback in the call is a second place the value can come from, and it is
26
+ * invisible to the operator: the shipped `config/app-default-config.json` can
27
+ * lose a key and nothing says so, because the caller quietly supplied one.
28
+ * Every key this module reads IS declared there, so absence means a broken
29
+ * configuration, not a value to invent.
30
+ *
31
+ * What this module does with an absent value:
32
+ *
33
+ * - __a switch__ (`…enabled`) reads as OFF, and says so in the log. A missing
34
+ * switch must never CLOSE the instance — that would lock everyone out over a
35
+ * typo — but it is still reported rather than assumed.
36
+ * - __a window, schedule or holiday map__ absent means nothing to enforce.
37
+ * - __a message__ falls back to a constant, because something has to render on
38
+ * the page. That is the only fallback here, and it is display text.
39
+ */
40
+ export type ReadProperty = (key: string) => unknown;
41
+ /** Which reason closed the instance. */
42
+ export type AvailabilityKind = 'maintenance' | 'schedule' | 'holiday' | 'misconfigured';
43
+ export interface AvailabilityState {
44
+ /** Whether ordinary traffic is refused. */
45
+ blocked: boolean;
46
+ /** Which reason closed it; null when open. */
47
+ kind: AvailabilityKind | null;
48
+ /** Shown on the page. Never empty when blocked. */
49
+ message: string;
50
+ /** Whether an administrator may still reach the instance. */
51
+ allowAdmins: boolean;
52
+ /** Operator-supplied estimate, maintenance only. */
53
+ estimatedDuration: string | null;
54
+ }
55
+ export declare const BUSINESS_HOURS_ENABLED_KEY = "ngdpbase.access-control.business-hours.enabled";
56
+ export declare const BUSINESS_HOURS_MESSAGE_KEY = "ngdpbase.access-control.business-hours.message";
57
+ export declare const CUSTOM_SCHEDULES_ENABLED_KEY = "ngdpbase.access-control.custom-schedules.enabled";
58
+ export declare const CUSTOM_SCHEDULES_FILE_KEY = "ngdpbase.access-control.custom-schedules.schedules";
59
+ export declare const SCHEDULES_KEY = "ngdpbase.schedules";
60
+ export declare const HOLIDAYS_ENABLED_KEY = "ngdpbase.holidays.enabled";
61
+ export declare const HOLIDAYS_DATES_KEY = "ngdpbase.holidays.dates";
62
+ export declare const HOLIDAYS_MESSAGE_KEY = "ngdpbase.holidays.message";
63
+ export declare const TIME_ZONE_KEY = "ngdpbase.time-zone";
64
+ /** A named schedule from `ngdpbase.schedules`. */
65
+ export interface ScheduleRule {
66
+ days?: string[];
67
+ startTime?: string;
68
+ endTime?: string;
69
+ /** `allow` opens the window; `deny` closes it. Absent reads as `allow`. */
70
+ type?: string;
71
+ }
72
+ export interface Schedule {
73
+ name?: string;
74
+ description?: string;
75
+ timeZone?: string;
76
+ rules?: ScheduleRule[];
77
+ exceptions?: string[];
78
+ enabled?: boolean;
79
+ message?: string;
80
+ }
81
+ /** A holiday entry. The key carries the date — `2026-07-04` or `*-12-25`. */
82
+ export interface HolidayEntry {
83
+ name?: string;
84
+ message?: string;
85
+ enabled?: boolean;
86
+ }
87
+ /**
88
+ * The instant, in a named time zone, as the parts a schedule compares:
89
+ * the weekday and minutes since midnight, plus the ISO date for a holiday,
90
+ * and the local year and month (1–12), which a month boundary needs (#1998).
91
+ * An add-on reaches it as `host.zonedParts`.
92
+ *
93
+ * Uses `Intl` rather than arithmetic on the epoch so daylight saving is the
94
+ * platform's problem, not ours.
95
+ */
96
+ export declare function zonedParts(now: Date, timeZone: string): {
97
+ day: string;
98
+ minutes: number;
99
+ date: string;
100
+ year: number;
101
+ month: number;
102
+ };
103
+ /**
104
+ * Whether a schedule is open at `now`.
105
+ *
106
+ * A `deny` rule matching the day closes it outright. Otherwise an `allow` rule
107
+ * must match both the day and the time window. A schedule with no rules is
108
+ * open — an empty schedule is not a lockout.
109
+ */
110
+ export declare function scheduleIsOpen(schedule: Schedule, now: Date, fallbackZone: string): boolean;
111
+ /** Whether `date` (ISO `YYYY-MM-DD`) matches a holiday key, including `*-MM-DD`. */
112
+ export declare function holidayKeyMatches(key: string, date: string): boolean;
113
+ /**
114
+ * The first reason the instance is closed, or an open state.
115
+ *
116
+ * Order is maintenance, then schedule, then holiday: the operator's own switch
117
+ * before anything time-based, so "I closed it" is never reported as "it is a
118
+ * holiday".
119
+ */
120
+ export declare function resolveAvailability(get: ReadProperty, now?: Date, maintenance?: MaintenanceVisitorView | null): AvailabilityState;
@@ -0,0 +1,174 @@
1
+ 'use strict';
2
+ export const BUSINESS_HOURS_ENABLED_KEY = 'ngdpbase.access-control.business-hours.enabled';
3
+ export const BUSINESS_HOURS_MESSAGE_KEY = 'ngdpbase.access-control.business-hours.message';
4
+ export const CUSTOM_SCHEDULES_ENABLED_KEY = 'ngdpbase.access-control.custom-schedules.enabled';
5
+ export const CUSTOM_SCHEDULES_FILE_KEY = 'ngdpbase.access-control.custom-schedules.schedules';
6
+ export const SCHEDULES_KEY = 'ngdpbase.schedules';
7
+ export const HOLIDAYS_ENABLED_KEY = 'ngdpbase.holidays.enabled';
8
+ export const HOLIDAYS_DATES_KEY = 'ngdpbase.holidays.dates';
9
+ export const HOLIDAYS_MESSAGE_KEY = 'ngdpbase.holidays.message';
10
+ export const TIME_ZONE_KEY = 'ngdpbase.time-zone';
11
+ const DEFAULT_SCHEDULE_MESSAGE = 'The site is closed at this time. Please try again during opening hours.';
12
+ const DEFAULT_HOLIDAY_MESSAGE = 'The site is closed for a holiday. Please try again later.';
13
+ function toBoolean(value, fallback) {
14
+ if (typeof value === 'boolean')
15
+ return value;
16
+ if (typeof value === 'string') {
17
+ const normalized = value.trim().toLowerCase();
18
+ if (normalized === 'true')
19
+ return true;
20
+ if (normalized === 'false')
21
+ return false;
22
+ return fallback;
23
+ }
24
+ if (typeof value === 'number')
25
+ return value !== 0;
26
+ return fallback;
27
+ }
28
+ function text(value, fallback) {
29
+ return typeof value === 'string' && value.trim() !== '' ? value : fallback;
30
+ }
31
+ /**
32
+ * The instant, in a named time zone, as the parts a schedule compares:
33
+ * the weekday and minutes since midnight, plus the ISO date for a holiday,
34
+ * and the local year and month (1–12), which a month boundary needs (#1998).
35
+ * An add-on reaches it as `host.zonedParts`.
36
+ *
37
+ * Uses `Intl` rather than arithmetic on the epoch so daylight saving is the
38
+ * platform's problem, not ours.
39
+ */
40
+ export function zonedParts(now, timeZone) {
41
+ let parts;
42
+ try {
43
+ parts = new Intl.DateTimeFormat('en-US', {
44
+ timeZone,
45
+ hour12: false,
46
+ weekday: 'long',
47
+ year: 'numeric',
48
+ month: '2-digit',
49
+ day: '2-digit',
50
+ hour: '2-digit',
51
+ minute: '2-digit'
52
+ }).formatToParts(now);
53
+ }
54
+ catch {
55
+ // An unknown zone must not decide the site is closed: fall back to UTC.
56
+ return zonedParts(now, 'UTC');
57
+ }
58
+ const find = (type) => parts.find((p) => p.type === type)?.value ?? '';
59
+ const hour = Number(find('hour'));
60
+ return {
61
+ day: find('weekday').toLowerCase(),
62
+ // Intl renders midnight as 24 in some locales with hour12:false.
63
+ minutes: (hour === 24 ? 0 : hour) * 60 + Number(find('minute')),
64
+ date: `${find('year')}-${find('month')}-${find('day')}`,
65
+ year: Number(find('year')),
66
+ month: Number(find('month'))
67
+ };
68
+ }
69
+ function minutesOf(hhmm) {
70
+ if (typeof hhmm !== 'string')
71
+ return null;
72
+ const match = /^(\d{1,2}):(\d{2})$/.exec(hhmm.trim());
73
+ if (!match)
74
+ return null;
75
+ const hours = Number(match[1]);
76
+ const mins = Number(match[2]);
77
+ if (hours > 23 || mins > 59)
78
+ return null;
79
+ return hours * 60 + mins;
80
+ }
81
+ /**
82
+ * Whether a schedule is open at `now`.
83
+ *
84
+ * A `deny` rule matching the day closes it outright. Otherwise an `allow` rule
85
+ * must match both the day and the time window. A schedule with no rules is
86
+ * open — an empty schedule is not a lockout.
87
+ */
88
+ export function scheduleIsOpen(schedule, now, fallbackZone) {
89
+ const rules = Array.isArray(schedule.rules) ? schedule.rules : [];
90
+ if (rules.length === 0)
91
+ return true;
92
+ const { day, minutes } = zonedParts(now, text(schedule.timeZone, fallbackZone));
93
+ const matchesDay = (rule) => {
94
+ if (!Array.isArray(rule.days) || rule.days.length === 0)
95
+ return true;
96
+ return rule.days.some((d) => typeof d === 'string' && d.trim().toLowerCase() === day);
97
+ };
98
+ for (const rule of rules) {
99
+ if (rule.type === 'deny' && matchesDay(rule))
100
+ return false;
101
+ }
102
+ let sawAllow = false;
103
+ for (const rule of rules) {
104
+ if (rule.type === 'deny')
105
+ continue;
106
+ sawAllow = true;
107
+ if (!matchesDay(rule))
108
+ continue;
109
+ const start = minutesOf(rule.startTime);
110
+ const end = minutesOf(rule.endTime);
111
+ if (start === null || end === null)
112
+ return true; // a malformed window does not close the site
113
+ if (start <= end ? minutes >= start && minutes < end : minutes >= start || minutes < end)
114
+ return true;
115
+ }
116
+ return !sawAllow;
117
+ }
118
+ /** Whether `date` (ISO `YYYY-MM-DD`) matches a holiday key, including `*-MM-DD`. */
119
+ export function holidayKeyMatches(key, date) {
120
+ if (key === date)
121
+ return true;
122
+ if (key.startsWith('*-'))
123
+ return date.slice(5) === key.slice(2);
124
+ return false;
125
+ }
126
+ /**
127
+ * The first reason the instance is closed, or an open state.
128
+ *
129
+ * Order is maintenance, then schedule, then holiday: the operator's own switch
130
+ * before anything time-based, so "I closed it" is never reported as "it is a
131
+ * holiday".
132
+ */
133
+ export function resolveAvailability(get, now = new Date(), maintenance = null) {
134
+ // #1906: `engine.maintenance.visitorView(…)`, null when the site is not in
135
+ // maintenance mode. Its message is already the one visitors may see.
136
+ if (maintenance) {
137
+ return {
138
+ blocked: true,
139
+ kind: 'maintenance',
140
+ message: maintenance.message,
141
+ allowAdmins: maintenance.adminsMayEnter,
142
+ estimatedDuration: maintenance.estimatedDuration
143
+ };
144
+ }
145
+ const open = {
146
+ blocked: false, kind: null, message: '', allowAdmins: true, estimatedDuration: null
147
+ };
148
+ const fallbackZone = text(get(TIME_ZONE_KEY), 'UTC');
149
+ // An administrator can always reach a time-closed instance: a schedule that
150
+ // locks out the only person who can change it is a trap, and unlike
151
+ // maintenance there is no switch to flip from outside.
152
+ const closed = (kind, message) => ({
153
+ blocked: true, kind, message, allowAdmins: true, estimatedDuration: null
154
+ });
155
+ const schedules = (get(SCHEDULES_KEY) ?? {});
156
+ if (toBoolean(get(BUSINESS_HOURS_ENABLED_KEY), false)) {
157
+ const schedule = schedules['business-hours'];
158
+ if (schedule && toBoolean(schedule.enabled, true) && !scheduleIsOpen(schedule, now, fallbackZone)) {
159
+ return closed('schedule', text(schedule.message ?? get(BUSINESS_HOURS_MESSAGE_KEY), DEFAULT_SCHEDULE_MESSAGE));
160
+ }
161
+ }
162
+ if (toBoolean(get(HOLIDAYS_ENABLED_KEY), false)) {
163
+ const dates = (get(HOLIDAYS_DATES_KEY) ?? {});
164
+ const { date } = zonedParts(now, fallbackZone);
165
+ for (const [key, entry] of Object.entries(dates)) {
166
+ if (!entry || !toBoolean(entry.enabled, true))
167
+ continue;
168
+ if (!holidayKeyMatches(key, date))
169
+ continue;
170
+ return closed('holiday', text(entry.message ?? get(HOLIDAYS_MESSAGE_KEY), DEFAULT_HOLIDAY_MESSAGE));
171
+ }
172
+ }
173
+ return open;
174
+ }
@@ -0,0 +1,41 @@
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 class KeyedSerial {
24
+ /** The settled tail of each key's queue: never rejects. */
25
+ tails = new Map();
26
+ /** Run `task` after every earlier task on `key` has settled; resolves or rejects as `task` does. */
27
+ run(key, task) {
28
+ const before = this.tails.get(key) ?? Promise.resolve();
29
+ const result = before.then(task);
30
+ const tail = result.then(() => undefined, () => undefined);
31
+ this.tails.set(key, tail);
32
+ void tail.then(() => { if (this.tails.get(key) === tail)
33
+ this.tails.delete(key); });
34
+ return result;
35
+ }
36
+ /** Resolves once every task queued so far, on every key, has settled (shutdown). */
37
+ async settled() {
38
+ await Promise.all([...this.tails.values()]);
39
+ }
40
+ }
41
+ export default KeyedSerial;