@jwilleke/ngdpbase-types 5.29.1 → 5.30.1

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 (44) hide show
  1. package/README.md +1 -1
  2. package/lib/packages/ngdpbase-types/index.d.ts +8 -3
  3. package/lib/src/context/JobContext.d.ts +20 -1
  4. package/lib/src/context/JobContext.js +18 -2
  5. package/lib/src/context/bootActions.js +5 -1
  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/AuthManager.d.ts +111 -0
  11. package/lib/src/managers/BackgroundJobManager.d.ts +8 -0
  12. package/lib/src/managers/BackupManager.d.ts +6 -0
  13. package/lib/src/managers/BackupManager.js +38 -11
  14. package/lib/src/managers/{PersonManager.d.ts → IdentityManager.d.ts} +20 -16
  15. package/lib/src/managers/ImportManager.d.ts +2 -5
  16. package/lib/src/managers/OrganizationManager.d.ts +2 -2
  17. package/lib/src/managers/PolicyEvaluator.d.ts +7 -0
  18. package/lib/src/managers/RoleManager.d.ts +7 -7
  19. package/lib/src/managers/UserManager.d.ts +17 -5
  20. package/lib/src/providers/BaseAttachmentProvider.d.ts +2 -8
  21. package/lib/src/providers/BaseCredentialsProvider.d.ts +7 -1
  22. package/lib/src/providers/BasicAttachmentProvider.d.ts +3 -8
  23. package/lib/src/security/PolicyDecisionPoint.d.ts +12 -0
  24. package/lib/src/security/PolicyInformationPoint.d.ts +1 -1
  25. package/lib/src/security/permissions.generated.d.ts +4 -4
  26. package/lib/src/types/Config.d.ts +1 -1
  27. package/lib/src/types/Person.d.ts +1 -1
  28. package/lib/src/types/PersonProvider.d.ts +2 -2
  29. package/lib/src/types/Policy.d.ts +2 -2
  30. package/lib/src/types/Provider.d.ts +21 -0
  31. package/lib/src/utils/addonConfigLayer.d.ts +16 -0
  32. package/lib/src/utils/addonConfigLayer.js +40 -3
  33. package/lib/src/utils/addonHost.d.ts +21 -3
  34. package/lib/src/utils/addonHost.js +10 -4
  35. package/lib/src/utils/auditEventNames.generated.d.ts +10 -2
  36. package/lib/src/utils/auditEventNames.generated.js +10 -2
  37. package/lib/src/utils/auditEvents.d.ts +16 -3
  38. package/lib/src/utils/auditEvents.js +10 -11
  39. package/lib/src/utils/availability.d.ts +120 -0
  40. package/lib/src/utils/availability.js +174 -0
  41. package/lib/src/utils/contactAccess.d.ts +9 -9
  42. package/lib/src/utils/keyedSerial.js +41 -0
  43. package/lib/src/utils/privateStoreUnlock.d.ts +1 -1
  44. package/package.json +1 -1
@@ -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
  }
@@ -54,10 +54,18 @@ export declare const AUDIT_EVENT: {
54
54
  readonly CONSENT_GRANT: "consent-grant";
55
55
  /** A user's acceptances ended (#1603): today only when their account is deleted, which removes their consent records; names the documents and versions they had accepted. Recorded before the records are removed */
56
56
  readonly CONSENT_WITHDRAW: "consent-withdraw";
57
- /** Contact created, changed, deleted or linked to an account; metadata names the kind (person or organization), the operation and the fields changed, never their values */
58
- readonly CONTACT_MANAGE: "contact-manage";
59
57
  /** A page's footnote list changed — action add, import, transfer, update or delete; page content written on someone's behalf (#1233) */
60
58
  readonly FOOTNOTE_EDIT: "footnote-edit";
59
+ /** Identity record or organization created, changed, deleted or linked to an account (recorded as contact-manage before #2038); metadata names the kind (person or organization), the operation and the fields changed, never their values */
60
+ readonly IDENTITY_MANAGE: "identity-manage";
61
+ /** Invitation cancelled (#2049): an administrator cancelled it or sent a new one, the email could not be sent, or the account was deleted; the link stops working */
62
+ readonly INVITATION_CANCEL: "invitation-cancel";
63
+ /** Invitation expired (#2049): the link was not used in time and was removed */
64
+ readonly INVITATION_EXPIRE: "invitation-expire";
65
+ /** Invitation sent (#2049): a one-time link emailed to a new user to set up their sign-in; user is who sent it, resource the account invited. A credential nobody knows exists is the worst case, so it is recorded before the link exists */
66
+ readonly INVITATION_SEND: "invitation-send";
67
+ /** Invitation used (#2049): the invited user opened their link and was signed in once; the link is spent */
68
+ readonly INVITATION_USE: "invitation-use";
61
69
  /** A background job finished successfully */
62
70
  readonly JOB_COMPLETED: "job-completed";
63
71
  /** A background job failed */
@@ -61,10 +61,18 @@ export const AUDIT_EVENT = {
61
61
  CONSENT_GRANT: 'consent-grant',
62
62
  /** A user's acceptances ended (#1603): today only when their account is deleted, which removes their consent records; names the documents and versions they had accepted. Recorded before the records are removed */
63
63
  CONSENT_WITHDRAW: 'consent-withdraw',
64
- /** Contact created, changed, deleted or linked to an account; metadata names the kind (person or organization), the operation and the fields changed, never their values */
65
- CONTACT_MANAGE: 'contact-manage',
66
64
  /** A page's footnote list changed — action add, import, transfer, update or delete; page content written on someone's behalf (#1233) */
67
65
  FOOTNOTE_EDIT: 'footnote-edit',
66
+ /** Identity record or organization created, changed, deleted or linked to an account (recorded as contact-manage before #2038); metadata names the kind (person or organization), the operation and the fields changed, never their values */
67
+ IDENTITY_MANAGE: 'identity-manage',
68
+ /** Invitation cancelled (#2049): an administrator cancelled it or sent a new one, the email could not be sent, or the account was deleted; the link stops working */
69
+ INVITATION_CANCEL: 'invitation-cancel',
70
+ /** Invitation expired (#2049): the link was not used in time and was removed */
71
+ INVITATION_EXPIRE: 'invitation-expire',
72
+ /** Invitation sent (#2049): a one-time link emailed to a new user to set up their sign-in; user is who sent it, resource the account invited. A credential nobody knows exists is the worst case, so it is recorded before the link exists */
73
+ INVITATION_SEND: 'invitation-send',
74
+ /** Invitation used (#2049): the invited user opened their link and was signed in once; the link is spent */
75
+ INVITATION_USE: 'invitation-use',
68
76
  /** A background job finished successfully */
69
77
  JOB_COMPLETED: 'job-completed',
70
78
  /** A background job failed */
@@ -139,13 +139,26 @@ export interface TokenInput extends CommonInput {
139
139
  /** Only meaningful for `revoke`; ignored otherwise. */
140
140
  revokedBy?: string;
141
141
  }
142
- export interface AttachmentInput extends CommonInput {
143
- op: AttachmentOp;
142
+ /**
143
+ * What an attachment record says about the file. A file in an ENCRYPTED store
144
+ * is recorded by its id and the store only (#2048): its name, its page and its
145
+ * size are the store's to keep sealed, so the record carries none of them.
146
+ */
147
+ export type AttachmentDetail = {
144
148
  attachmentId: string;
145
149
  filename: string;
146
150
  pageName?: string | null;
147
151
  sizeBytes?: number | null;
148
- }
152
+ } | {
153
+ attachmentId: string;
154
+ sealedStore: {
155
+ owner: string;
156
+ store: string;
157
+ };
158
+ };
159
+ export type AttachmentInput = CommonInput & {
160
+ op: AttachmentOp;
161
+ } & AttachmentDetail;
149
162
  /**
150
163
  * Build the audit event for a page create, edit, rename, or link rewrite.
151
164
  *
@@ -145,18 +145,17 @@ export function buildPageViewAuditEvent(input) {
145
145
  * unattended destruction is the case someone reviewing the log is looking for.
146
146
  */
147
147
  export function buildAttachmentAuditEvent(input) {
148
- const { op, attachmentId, filename, pageName, sizeBytes, viaToken } = input;
148
+ const { op, attachmentId, viaToken } = input;
149
149
  const who = whoOf(input);
150
- const metadata = {
151
- attachmentId,
152
- filename,
153
- ...tokenMetadata(viaToken),
154
- ...who.metadata
155
- };
156
- if (pageName)
157
- metadata.pageName = pageName;
158
- if (typeof sizeBytes === 'number')
159
- metadata.sizeBytes = sizeBytes;
150
+ const metadata = 'sealedStore' in input
151
+ ? { attachmentId, owner: input.sealedStore.owner, store: input.sealedStore.store, sealed: true, ...tokenMetadata(viaToken), ...who.metadata }
152
+ : { attachmentId, filename: input.filename, ...tokenMetadata(viaToken), ...who.metadata };
153
+ if (!('sealedStore' in input)) {
154
+ if (input.pageName)
155
+ metadata.pageName = input.pageName;
156
+ if (typeof input.sizeBytes === 'number')
157
+ metadata.sizeBytes = input.sizeBytes;
158
+ }
160
159
  const severity = op === 'delete'
161
160
  ? (viaToken ? 'high' : 'medium')
162
161
  : (viaToken ? 'medium' : 'low');
@@ -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
+ }
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * The contacts door's one decision and its one record (#1930).
3
3
  *
4
- * PersonManager and OrganizationManager are the enforcement points for
4
+ * IdentityManager and OrganizationManager are the enforcement points for
5
5
  * contacts. Both ask the PDP the same two questions with the caller's
6
- * context: `contact-read` to list, search or view, `contact-manage` to
6
+ * context: `identity-read` to list, search or view, `identity-manage` to
7
7
  * create, change, delete or link; and both record each change as the
8
- * `contact-manage` audit event. That rule lives here once, so the two doors
8
+ * `identity-manage` audit event. That rule lives here once, so the two doors
9
9
  * cannot drift (operator, 2026-10-20).
10
10
  *
11
11
  * A refusal is thrown as {@link ContactRefusedError}, carrying the decision.
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * Every lookup takes a context too (#2018, operator 2026-10-23), through
17
17
  * {@link requireRecordAccess}: a user's own identity record under
18
- * `profile-manage`, anyone else's under `contact-read` / `contact-manage`, and
18
+ * `profile-manage`, anyone else's under `identity-read` / `identity-manage`, and
19
19
  * core's own reads under the system context without a policy evaluation, so
20
20
  * resolving roles on every request stays cheap.
21
21
  */
@@ -27,8 +27,8 @@ type EngineLike = {
27
27
  };
28
28
  /** What a contacts door asks: `read` to list, search or view; `manage` to change. */
29
29
  export declare const CONTACT_PERMISSION: {
30
- readonly read: "contact-read";
31
- readonly manage: "contact-manage";
30
+ readonly read: "identity-read";
31
+ readonly manage: "identity-manage";
32
32
  };
33
33
  /** A contacts door said no. `permission` is what was refused; `decision` is the PDP's answer. */
34
34
  export declare class ContactRefusedError extends Error {
@@ -38,7 +38,7 @@ export declare class ContactRefusedError extends Error {
38
38
  }
39
39
  /**
40
40
  * Throws {@link ContactRefusedError} unless `ctx` may do `kind` (`read` asks
41
- * `contact-read`, `manage` asks `contact-manage`), and each of `also` (a link
41
+ * `identity-read`, `manage` asks `identity-manage`), and each of `also` (a link
42
42
  * to an account also asks `user-edit`). A missing context or decision point
43
43
  * refuses: closed, never open.
44
44
  */
@@ -58,7 +58,7 @@ export declare function isSystemWork(engine: EngineLike, ctx: ActorContext | nul
58
58
  * - read it as core's own work ({@link isSystemWork}): allowed, not evaluated;
59
59
  * - read or change its own identity record (the context's username is the
60
60
  * record's `identifier`): `profile-manage`, which every user holds;
61
- * - read or change any other record: `contact-read` / `contact-manage`.
61
+ * - read or change any other record: `identity-read` / `identity-manage`.
62
62
  *
63
63
  * `record` is the identity record (or what names it, `{ identifier }`); left
64
64
  * out for a record that is never anyone's own (an organization, a role).
@@ -86,7 +86,7 @@ export interface ContactChange {
86
86
  via?: 'admin' | 'email';
87
87
  }
88
88
  /**
89
- * Record a contacts change as `contact-manage`, under the caller's context.
89
+ * Record a contacts change as `identity-manage`, under the caller's context.
90
90
  * `continue`: a lost record is counted, the change stands. A system context
91
91
  * goes through `recordSystemAction`, so the bootstrap admin's person, made
92
92
  * before the audit sink is up, is recorded once it is.
@@ -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;
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { type PrivateStoreLayout } from './privateStorePath.js';
16
16
  import { type UserCatalog } from './privateStoreCatalogs.js';
17
- import type { ActorContext } from '../context/ActorContext.js';
17
+ import { type ActorContext } from '../context/ActorContext.js';
18
18
  import type { LinkPublicKey, Lockbox } from './shareLockbox.js';
19
19
  export declare function unlockPrivateStores(sessionId: string, username: string, kek: Buffer): void;
20
20
  export declare function getSessionUserIndex(sessionId: string): UserCatalog | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilleke/ngdpbase-types",
3
- "version": "5.29.1",
3
+ "version": "5.30.1",
4
4
  "description": "TypeScript types for ngdpbase add-ons, and a test kit of core's own values. A dev dependency, versioned with ngdpbase.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Jim Willeke",