@jwilleke/ngdpbase-types 5.30.0 → 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.
- package/README.md +1 -1
- package/lib/packages/ngdpbase-types/index.d.ts +3 -2
- package/lib/src/context/JobContext.d.ts +9 -0
- package/lib/src/context/JobContext.js +3 -1
- package/lib/src/context/bootActions.js +2 -0
- package/lib/src/ice/fileHandling.d.ts +24 -2
- package/lib/src/ice/originalKeep.d.ts +40 -0
- package/lib/src/ice/types.d.ts +33 -1
- package/lib/src/managers/AttachmentManager.d.ts +12 -8
- package/lib/src/managers/BackgroundJobManager.d.ts +8 -0
- package/lib/src/managers/ImportManager.d.ts +2 -5
- package/lib/src/managers/PolicyEvaluator.d.ts +7 -0
- package/lib/src/managers/UserManager.d.ts +2 -0
- package/lib/src/providers/BaseAttachmentProvider.d.ts +2 -8
- package/lib/src/providers/BasicAttachmentProvider.d.ts +3 -8
- package/lib/src/security/PolicyDecisionPoint.d.ts +8 -0
- package/lib/src/security/PolicyInformationPoint.d.ts +1 -1
- package/lib/src/types/Policy.d.ts +2 -2
- package/lib/src/types/Provider.d.ts +21 -0
- package/lib/src/utils/addonConfigLayer.d.ts +16 -0
- package/lib/src/utils/addonConfigLayer.js +40 -3
- package/lib/src/utils/addonHost.d.ts +21 -3
- package/lib/src/utils/addonHost.js +10 -4
- package/lib/src/utils/availability.d.ts +120 -0
- package/lib/src/utils/availability.js +174 -0
- package/lib/src/utils/keyedSerial.js +41 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ import type { AddonModule, AddonHost, WikiEngine } from '@jwilleke/ngdpbase-type
|
|
|
11
11
|
import { buildAddonHost, openAddonTestDatabase, toCsv, formatters } from '@jwilleke/ngdpbase-types/testing';
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
- The main entry is types only. Importing it at run time fails, on purpose. An add-on takes core's values from `host` (`AddonHost`: the request and audit helpers, the logger, `guardedFetch`, the egress tools, `isAtLeast`) and a manager's job from `engine.getManager` (such as `PageManager.pageUrl` or `PolicyDecisionPoint.usersHolding`); it never imports core's values ([#1813](https://github.com/jwilleke/ngdpbase/issues/1813)).
|
|
14
|
+
- The main entry is types only. Importing it at run time fails, on purpose. An add-on takes core's values from `host` (`AddonHost`: the request and audit helpers, the logger, `guardedFetch`, the egress tools, `isAtLeast`, `zonedParts`) and a manager's job from `engine.getManager` (such as `PageManager.pageUrl` or `PolicyDecisionPoint.usersHolding`); it never imports core's values ([#1813](https://github.com/jwilleke/ngdpbase/issues/1813)).
|
|
15
15
|
- `/testing` is for tests. `buildAddonHost(slug, defaults, setEnabledForPerson?, engine?)` is the real host; `host.setEnabledForPerson` calls the setter you pass (it refuses without one), and `engine` names the system principal `host.systemContext(reason)` uses (core's fallback without one). `openAddonTestDatabase(owner, { migrations, ledgerTable })` is the handle `openAddonDatabase` returns, in memory. `toCsv` is `ExportManager.toCsv`. `formatters` is a plugin's `context.formatters`.
|
|
16
16
|
|
|
17
17
|
The add-on guide is [docs/guides/addons-developer-guide.md](https://github.com/jwilleke/ngdpbase/blob/master/docs/guides/addons-developer-guide.md).
|
|
@@ -23,8 +23,9 @@ export type { PermissionSubject } from '../../src/managers/UserManager.js';
|
|
|
23
23
|
export type { Decision } from '../../src/types/Policy.js';
|
|
24
24
|
export type { PluginContext, PluginParams, PluginFormatters } from '../../src/managers/PluginManager.js';
|
|
25
25
|
export type { SimplePlugin } from '../../src/plugins/types.js';
|
|
26
|
+
export type { KeyedSerial } from '../../src/utils/keyedSerial.js';
|
|
26
27
|
export type { AuditEvent } from '../../src/utils/auditEvents.js';
|
|
27
|
-
export type { JobRunContext } from '../../src/managers/BackgroundJobManager.js';
|
|
28
|
+
export type { default as BackgroundJobManager, JobDefinition, JobResult, JobRunContext } from '../../src/managers/BackgroundJobManager.js';
|
|
28
29
|
export type { default as DatabaseManager, AddonDatabaseOptions } from '../../src/managers/DatabaseManager.js';
|
|
29
30
|
export type { Migration } from '../../src/providers/sqliteMigrations.js';
|
|
30
31
|
export type { AddonDatabaseDeclaration, DatabaseEncrypt } from '../../src/utils/addonDatabaseRules.js';
|
|
@@ -36,7 +37,7 @@ export type { PageAction } from '../../src/utils/pageUrl.js';
|
|
|
36
37
|
export type { default as ConsentManager, ConsentScopeStatus } from '../../src/managers/ConsentManager.js';
|
|
37
38
|
export type { AddonConsentRegistration, ConsentDocument, ConsentRecord, ConsentRequiredError, ConsentScope, ConsentSeen, ConsentStatus } from '../../src/types/Consent.js';
|
|
38
39
|
export type { default as ImportManager } from '../../src/managers/ImportManager.js';
|
|
39
|
-
export type { IceShim, IceReceiver, IceItem, IceKind, IceWarning, ShimDirection, ShimAccepts, ShimTools, ShimReadResult, ReceiveInfo, ReceiveOutcome, IceCitation, KeptOriginal, OriginalChoice, IceOriginalChoices, IceUpload, IceSource, IceRunRequest, IceRunReport, IceFileReport, IceFileStatus } from '../../src/ice/types.js';
|
|
40
|
+
export type { IceShim, IceReceiver, IceItem, IceKind, IceWarning, ShimDirection, ShimAccepts, ShimTools, ShimReadResult, ReceiveInfo, ReceiveOutcome, IceCitation, KeptOriginal, OriginalChoice, IceOriginalChoices, OriginalKeep, IceUpload, IceSource, IceRunRequest, IceRunReport, IceFileReport, IceFileStatus } from '../../src/ice/types.js';
|
|
40
41
|
export type { default as UserManager, UserUpdateInput, UserUpdateFn } from '../../src/managers/UserManager.js';
|
|
41
42
|
export type { User, UserPreferences } from '../../src/types/User.js';
|
|
42
43
|
export type { default as AuthManager, InviteOutcome, InvitationView } from '../../src/managers/AuthManager.js';
|
|
@@ -71,6 +71,15 @@ export interface JobContext {
|
|
|
71
71
|
requestedAt: string;
|
|
72
72
|
/** Why, for origins with no person to ask. Free text, for the audit record. */
|
|
73
73
|
reason?: string;
|
|
74
|
+
/**
|
|
75
|
+
* The scheduled job this run acts as (#1999): its id. Set by
|
|
76
|
+
* BackgroundJobManager on a scheduled slot's context, never on a run
|
|
77
|
+
* started by hand. When a policy names the job (`{ "type": "job", "value":
|
|
78
|
+
* "<id>" }`), its decisions are made as `job:<id>`, holding only what such
|
|
79
|
+
* policies grant; when none does, as the system principal, as before. Only
|
|
80
|
+
* the system principal's context can act as a job.
|
|
81
|
+
*/
|
|
82
|
+
job?: string;
|
|
74
83
|
}
|
|
75
84
|
/** The shape a request-bound context exposes; both WikiContext and ApiContext satisfy it. */
|
|
76
85
|
export interface RequestIdentity {
|
|
@@ -147,7 +147,9 @@ export function toPermissionSubject(ctx) {
|
|
|
147
147
|
resolveRolesNow: true,
|
|
148
148
|
...(ctx.viaToken ? { viaToken: ctx.viaToken } : {}),
|
|
149
149
|
...(ctx.viaShare ? { viaShare: ctx.viaShare } : {}),
|
|
150
|
-
...(ctx.viaProxy ? { viaProxy: ctx.viaProxy } : {})
|
|
150
|
+
...(ctx.viaProxy ? { viaProxy: ctx.viaProxy } : {}),
|
|
151
|
+
// #1999: the job a scheduled run acts as, for the PIP to resolve.
|
|
152
|
+
...(ctx.job ? { job: ctx.job } : {})
|
|
151
153
|
};
|
|
152
154
|
}
|
|
153
155
|
/** One line for a log or an audit record: who, from where, and how. */
|
|
@@ -64,6 +64,8 @@ export function attributedTo(ctx) {
|
|
|
64
64
|
origin: ctx.origin,
|
|
65
65
|
reason: ctx.reason ?? null,
|
|
66
66
|
requestedAt: ctx.requestedAt,
|
|
67
|
+
// #1999: a scheduled run names the job it acts as on every row it records.
|
|
68
|
+
...(ctx.job ? { job: ctx.job } : {}),
|
|
67
69
|
// #2059: the delegation a request-origin job was queued under, by kind,
|
|
68
70
|
// with the same precedence as a request's own record (`actorOf`).
|
|
69
71
|
...(ctx.viaToken ? { delegated: 'token' } : ctx.viaShare ? { delegated: 'share' } : proxyMeta)
|
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
import { type ZipReadLimits } from '../utils/zipArchive.js';
|
|
2
2
|
import type { IceUpload } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* A file's media type by its extension (#2061): how the engine labels a file
|
|
5
|
+
* it unpacks from a zip, and how the folder import labels a file it reads.
|
|
6
|
+
* A typed table, not configuration: it states what each format is, which is
|
|
7
|
+
* not a site's choice. What a site decides is which types it takes, and that
|
|
8
|
+
* is already its attachment allow-list (and a receiver's `originals.types`).
|
|
9
|
+
*/
|
|
10
|
+
export declare const MEDIA_TYPE_BY_EXTENSION: Readonly<Record<string, string>>;
|
|
11
|
+
/** A file's media type by its extension alone: {@link MEDIA_TYPE_BY_EXTENSION}, else `application/octet-stream`. */
|
|
12
|
+
export declare function mediaTypeByExtension(name: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* The media type of a file unpacked from a zip (#2061): its extension's,
|
|
15
|
+
* checked against its bytes where that is cheap. JSON must start with `{` or
|
|
16
|
+
* `[` (NDJSON with `{`), XML with `<`; one that does not is
|
|
17
|
+
* `application/octet-stream`. Then the upload door's own rule
|
|
18
|
+
* (`resolveUploadMime`): bytes that show a type decide (an XML file with an
|
|
19
|
+
* `<?xml` prolog is `text/xml`), and a claim they do not bear out is
|
|
20
|
+
* `application/octet-stream`. So the type a receiver is handed is the type
|
|
21
|
+
* the door stores.
|
|
22
|
+
*/
|
|
23
|
+
export declare function memberMediaType(name: string, bytes: Buffer): string;
|
|
3
24
|
/** A file's fingerprint: SHA-256 of its bytes, hex. */
|
|
4
25
|
export declare function fingerprint(bytes: Buffer): string;
|
|
5
26
|
/**
|
|
@@ -15,8 +36,9 @@ export declare function isNoise(filePath: string, patterns: readonly string[]):
|
|
|
15
36
|
export declare function isZip(upload: IceUpload): boolean;
|
|
16
37
|
/**
|
|
17
38
|
* Unpack the zips among the uploads into the files they hold, named
|
|
18
|
-
* `archive.zip/inner/path
|
|
19
|
-
*
|
|
39
|
+
* `archive.zip/inner/path`, each with its media type ({@link memberMediaType},
|
|
40
|
+
* #2061). A nested zip is unpacked too. Everything else is passed through,
|
|
41
|
+
* with the type its request gave it. Limits are per archive (`ZipReadLimits`), so a small
|
|
20
42
|
* archive that expands enormously stops at the budget. An archive that cannot
|
|
21
43
|
* be read is returned in `unreadable`, with why; the rest go on.
|
|
22
44
|
*/
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The allowance a keep at upload carries to the upload door (#2061).
|
|
3
|
+
*
|
|
4
|
+
* A receiver that keeps originals in its own encrypted vault (phr) declares
|
|
5
|
+
* the media types it keeps (`originals.types`). The site's attachment
|
|
6
|
+
* allow-list does not name them (FHIR JSON, C-CDA XML), so the door would
|
|
7
|
+
* refuse the keep. The exemption is narrow, and lives here, not in a flag:
|
|
8
|
+
*
|
|
9
|
+
* - the engine opens one {@link OriginalKeep} for one receiver, one user and
|
|
10
|
+
* one `keepOriginal` call, and closes it when the call returns;
|
|
11
|
+
* - the receiver passes it to `AttachmentManager.uploadAttachment` as its
|
|
12
|
+
* `keep` option, unchanged;
|
|
13
|
+
* - the door honours it only while it is open, and only for a file going
|
|
14
|
+
* into that user's own vault of a system-category the receiver's add-on
|
|
15
|
+
* declares ({@link keepAllowsInto}). Anywhere else it changes nothing.
|
|
16
|
+
*
|
|
17
|
+
* An object the engine did not open is never honoured, whatever it says.
|
|
18
|
+
*/
|
|
19
|
+
import type { ActorContext } from '../context/ActorContext.js';
|
|
20
|
+
import type { OriginalKeep, RegisteredReceiver } from './types.js';
|
|
21
|
+
/** Open a keep for one call of `receiver.keepOriginal` with `ctx`. Close it with {@link closeOriginalKeep}. */
|
|
22
|
+
export declare function openOriginalKeep(receiver: RegisteredReceiver, ctx: ActorContext): OriginalKeep;
|
|
23
|
+
/** The call is over: the keep is honoured no more. */
|
|
24
|
+
export declare function closeOriginalKeep(keep: OriginalKeep): void;
|
|
25
|
+
/** Where an upload is going, as the door resolved it. */
|
|
26
|
+
export interface KeepDestination {
|
|
27
|
+
/** Who is uploading: the request's username. */
|
|
28
|
+
uploader: string;
|
|
29
|
+
/** The vault's owner. */
|
|
30
|
+
owner: string;
|
|
31
|
+
/** The add-on whose system-category declares the vault (`StoreKind.owner`); null when no category does. */
|
|
32
|
+
vaultAddon: string | null;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Does this keep apply to this upload? Only an open keep, for a file going
|
|
36
|
+
* into its user's own vault of a system-category its receiver's add-on
|
|
37
|
+
* declares, uploaded by that user. A `false` leaves the upload exactly as
|
|
38
|
+
* any other: the site's allow-list decides.
|
|
39
|
+
*/
|
|
40
|
+
export declare function keepAllowsInto(keep: OriginalKeep | undefined, to: KeepDestination): boolean;
|
package/lib/src/ice/types.d.ts
CHANGED
|
@@ -130,6 +130,34 @@ export interface IceOriginalChoices {
|
|
|
130
130
|
allowed: OriginalChoice[];
|
|
131
131
|
/** One of `allowed`. Default: `ngdpbase.import.engine.default-original` when allowed, else the first allowed. */
|
|
132
132
|
default?: OriginalChoice;
|
|
133
|
+
/**
|
|
134
|
+
* The media types this receiver keeps (#2061), exactly, e.g.
|
|
135
|
+
* `['application/json', 'application/x-ndjson', 'text/xml']`: no wildcards.
|
|
136
|
+
* For a keep at upload into the receiver's own vault, the upload door
|
|
137
|
+
* allows these even when the site's attachment allow-list does not
|
|
138
|
+
* ({@link OriginalKeep}). Every other upload still uses the site's list.
|
|
139
|
+
*/
|
|
140
|
+
types?: string[];
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* What a keep at upload carries to the upload door (#2061): the engine opens
|
|
144
|
+
* one for one receiver, one user and one call of its `keepOriginal`, and
|
|
145
|
+
* closes it when the call returns. Pass it on unchanged as the upload's
|
|
146
|
+
* `keep` option. The door honours only one the engine has open, and only
|
|
147
|
+
* for a file going into this user's own vault of a system-category the
|
|
148
|
+
* receiver's add-on declares: there it allows `types` beyond the site's
|
|
149
|
+
* attachment allow-list, and the file is always served as a download.
|
|
150
|
+
* Anywhere else it changes nothing.
|
|
151
|
+
*/
|
|
152
|
+
export interface OriginalKeep {
|
|
153
|
+
/** The receiver keeping, `<slug>.<name>`. */
|
|
154
|
+
readonly receiver: string;
|
|
155
|
+
/** The add-on that registered it: only a vault its own system-category declares. */
|
|
156
|
+
readonly addon: string;
|
|
157
|
+
/** The user the upload is for: only their own vault. */
|
|
158
|
+
readonly user: string;
|
|
159
|
+
/** The receiver's `originals.types`. */
|
|
160
|
+
readonly types: readonly string[];
|
|
133
161
|
}
|
|
134
162
|
/** A citation to the origin (D11): the receiver renders it, as a footnote on a page. */
|
|
135
163
|
export interface IceCitation {
|
|
@@ -180,8 +208,12 @@ export interface IceReceiver {
|
|
|
180
208
|
/**
|
|
181
209
|
* Keep the original file itself (D11: phr keeps originals in its own
|
|
182
210
|
* store). Without this, `keep` hands the original to the files receiver.
|
|
211
|
+
* `original.mediaType` is the engine's (#2061): the request's type for a
|
|
212
|
+
* file uploaded on its own, the engine's for one unpacked from a zip.
|
|
213
|
+
* `keep` is given for a keep at upload (`keepsEncrypted`): pass it to the
|
|
214
|
+
* upload door unchanged, as the upload's `keep` option.
|
|
183
215
|
*/
|
|
184
|
-
keepOriginal?(original: IceItem, ctx: ActorContext): Promise<ReceiveOutcome>;
|
|
216
|
+
keepOriginal?(original: IceItem, ctx: ActorContext, keep?: OriginalKeep): Promise<ReceiveOutcome>;
|
|
185
217
|
/**
|
|
186
218
|
* `keepOriginal` writes into an encrypted store (phr's vault), which only a
|
|
187
219
|
* request holding the user's unlocked key can write (#2034). The engine then
|
|
@@ -10,7 +10,8 @@ import type { WikiEngine } from '../types/WikiEngine.js';
|
|
|
10
10
|
import type { CatalogSource, CatalogQuery, CatalogPage, CreativeWork, SchemaType, RebuildOpts } from '../types/Schema.js';
|
|
11
11
|
import type { User } from '../providers/BaseAttachmentProvider.js';
|
|
12
12
|
import { type AssetQuery, type AssetRecord } from '../types/Asset.js';
|
|
13
|
-
import type { StoreFileEntry, StoreFileLocation } from '../types/Provider.js';
|
|
13
|
+
import type { StoreFileEntry, StoreFileInput, StoreFileLocation } from '../types/Provider.js';
|
|
14
|
+
import type { OriginalKeep } from '../ice/types.js';
|
|
14
15
|
/**
|
|
15
16
|
* Base attachment provider interface
|
|
16
17
|
*/
|
|
@@ -44,13 +45,7 @@ interface BaseAttachmentProvider {
|
|
|
44
45
|
getProviderInfo(): {
|
|
45
46
|
features: string[];
|
|
46
47
|
};
|
|
47
|
-
storeFileInStore(location: StoreFileLocation, bytes: Buffer, file:
|
|
48
|
-
originalName: string;
|
|
49
|
-
mimeType: string;
|
|
50
|
-
description: string;
|
|
51
|
-
author?: string;
|
|
52
|
-
pageName?: string;
|
|
53
|
-
}): Promise<StoreFileEntry>;
|
|
48
|
+
storeFileInStore(location: StoreFileLocation, bytes: Buffer, file: StoreFileInput): Promise<StoreFileEntry>;
|
|
54
49
|
getFileInStore(location: StoreFileLocation, id: string): Promise<{
|
|
55
50
|
entry: StoreFileEntry;
|
|
56
51
|
bytes: Buffer;
|
|
@@ -104,6 +99,15 @@ export interface UploadOptions {
|
|
|
104
99
|
private?: boolean;
|
|
105
100
|
/** Store id when private with no private page; default from ConfigurationManager defaultstoreid */
|
|
106
101
|
store?: string;
|
|
102
|
+
/**
|
|
103
|
+
* An import's keep at upload (#2061): the `OriginalKeep` the engine handed
|
|
104
|
+
* the receiver's `keepOriginal`, passed on unchanged. Honoured only while
|
|
105
|
+
* the engine holds it open, for a file going into its user's own vault of a
|
|
106
|
+
* system-category the receiver's add-on declares: there the receiver's
|
|
107
|
+
* declared types are allowed beyond the site's allow-list, and the file is
|
|
108
|
+
* served as a download. Anywhere else it changes nothing.
|
|
109
|
+
*/
|
|
110
|
+
keep?: OriginalKeep;
|
|
107
111
|
}
|
|
108
112
|
/**
|
|
109
113
|
* Mention object (WebPage reference)
|
|
@@ -282,6 +282,14 @@ declare class BackgroundJobManager extends BaseManager {
|
|
|
282
282
|
private compileScheduled;
|
|
283
283
|
/** A run's time limit: the job's own, else `fallback`, capped by `ngdpbase.jobs.max-timeout-ms` when set. */
|
|
284
284
|
private timeoutFor;
|
|
285
|
+
/**
|
|
286
|
+
* The time zone the scheduler runs a schedule in when the schedule names
|
|
287
|
+
* none (#1998): `ngdpbase.default.timezone`, UTC when it is not set. Read
|
|
288
|
+
* each call, as the schedules are. An add-on's job passes it, with its
|
|
289
|
+
* `ctx.slot`, to `host.zonedParts` to learn the slot's local month or day.
|
|
290
|
+
* A schedule that names its own `tz` runs in that zone instead.
|
|
291
|
+
*/
|
|
292
|
+
scheduleTimeZone(): string;
|
|
285
293
|
private compile;
|
|
286
294
|
/**
|
|
287
295
|
* `ngdpbase.jobs.<id>.schedule` in place of the registered schedule. One
|
|
@@ -267,7 +267,8 @@ export type TakeoutImportReport = {
|
|
|
267
267
|
export interface UsableReceiver {
|
|
268
268
|
id: string;
|
|
269
269
|
label?: string;
|
|
270
|
-
|
|
270
|
+
/** What the screen offers; the kept types (`types`, #2061) are the upload door's business, not the screen's. */
|
|
271
|
+
originals: Required<Omit<IceOriginalChoices, 'types'>>;
|
|
271
272
|
}
|
|
272
273
|
/**
|
|
273
274
|
* Import Manager class
|
|
@@ -546,10 +547,6 @@ declare class ImportManager extends BaseManager {
|
|
|
546
547
|
* @returns Stats about imported attachments
|
|
547
548
|
*/
|
|
548
549
|
private importPageAttachments;
|
|
549
|
-
/**
|
|
550
|
-
* Get MIME type from file extension
|
|
551
|
-
*/
|
|
552
|
-
private getMimeType;
|
|
553
550
|
/**
|
|
554
551
|
* Get file extensions to process based on options
|
|
555
552
|
*/
|
|
@@ -34,6 +34,13 @@ interface AccessContext {
|
|
|
34
34
|
* policy naming it decides nothing (docs/access-policies.md).
|
|
35
35
|
*/
|
|
36
36
|
export declare const RESOURCE_MATCHERS: Record<string, (pattern: string, pageName: string, attributes?: ResourceAttributes) => boolean>;
|
|
37
|
+
/**
|
|
38
|
+
* How each policy subject type matches (#1999), one matcher per type. These
|
|
39
|
+
* are the only subject types a decision matches, and the only ones
|
|
40
|
+
* PolicyValidator accepts: a type with no matcher here would be a policy that
|
|
41
|
+
* grants nothing.
|
|
42
|
+
*/
|
|
43
|
+
export declare const SUBJECT_MATCHERS: Record<string, (value: string, userContext: UserContext | undefined) => boolean>;
|
|
37
44
|
/**
|
|
38
45
|
* The resource types configuration declares (#1504), for the evaluator and the
|
|
39
46
|
* validator alike. When the key is not set at all — a configuration older than
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AttachmentMetadata, AttachmentProvider } from '../types/index.js';
|
|
2
|
-
import type { StoreFileEntry, StoreFileLocation } from '../types/Provider.js';
|
|
2
|
+
import type { StoreFileEntry, StoreFileInput, StoreFileLocation } from '../types/Provider.js';
|
|
3
3
|
import type { WikiEngine } from '../types/WikiEngine.js';
|
|
4
4
|
import { ProviderInfo } from './BasePageProvider.js';
|
|
5
5
|
import BaseProvider from './BaseProvider.js';
|
|
@@ -150,13 +150,7 @@ declare abstract class BaseAttachmentProvider extends BaseProvider implements At
|
|
|
150
150
|
*/
|
|
151
151
|
getAttachmentByFilename(_filename: string): Promise<AttachmentMetadata | null>;
|
|
152
152
|
/** Store a file in a store. The same bytes already in THAT store return the existing entry. */
|
|
153
|
-
storeFileInStore(_location: StoreFileLocation, _bytes: Buffer, _file:
|
|
154
|
-
originalName: string;
|
|
155
|
-
mimeType: string;
|
|
156
|
-
description: string;
|
|
157
|
-
author?: string;
|
|
158
|
-
pageName?: string;
|
|
159
|
-
}): Promise<StoreFileEntry>;
|
|
153
|
+
storeFileInStore(_location: StoreFileLocation, _bytes: Buffer, _file: StoreFileInput): Promise<StoreFileEntry>;
|
|
160
154
|
/** One file of a store, with its bytes; null when the store does not list it. */
|
|
161
155
|
getFileInStore(_location: StoreFileLocation, _id: string): Promise<{
|
|
162
156
|
entry: StoreFileEntry;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import BaseAttachmentProvider, { FileInfo, User, AttachmentResult } from './BaseAttachmentProvider.js';
|
|
2
|
-
import type { ProviderInfo, StoreFileEntry, StoreFileLocation } from '../types/Provider.js';
|
|
2
|
+
import type { ProviderInfo, StoreFileEntry, StoreFileInput, StoreFileLocation } from '../types/Provider.js';
|
|
3
3
|
import { AttachmentMetadata } from '../types/index.js';
|
|
4
4
|
import type { AssetProvider, AssetRecord, AssetQuery, AssetPage, AssetInput, AssetMetadata, AssetMetadataPatch } from '../types/Asset.js';
|
|
5
5
|
import type { CreativeWork } from '../types/Schema.js';
|
|
@@ -213,6 +213,7 @@ declare class BasicAttachmentProvider extends BaseAttachmentProvider implements
|
|
|
213
213
|
/**
|
|
214
214
|
* Validate file against constraints
|
|
215
215
|
* @param fileInfo - File information
|
|
216
|
+
* @param alsoAllowed - Types allowed beyond the site's list: only an import's kept original into its receiver's own vault (#2061)
|
|
216
217
|
* @private
|
|
217
218
|
*/
|
|
218
219
|
private validateFile;
|
|
@@ -261,13 +262,7 @@ declare class BasicAttachmentProvider extends BaseAttachmentProvider implements
|
|
|
261
262
|
private requirePagesDirectory;
|
|
262
263
|
private readStoreIndex;
|
|
263
264
|
private writeStoreIndex;
|
|
264
|
-
storeFileInStore(location: StoreFileLocation, bytes: Buffer, file:
|
|
265
|
-
originalName: string;
|
|
266
|
-
mimeType: string;
|
|
267
|
-
description: string;
|
|
268
|
-
author?: string;
|
|
269
|
-
pageName?: string;
|
|
270
|
-
}): Promise<StoreFileEntry>;
|
|
265
|
+
storeFileInStore(location: StoreFileLocation, bytes: Buffer, file: StoreFileInput): Promise<StoreFileEntry>;
|
|
271
266
|
getFileInStore(location: StoreFileLocation, id: string): Promise<{
|
|
272
267
|
entry: StoreFileEntry;
|
|
273
268
|
bytes: Buffer;
|
|
@@ -59,6 +59,14 @@ export declare class PolicyDecisionPoint extends BaseManager {
|
|
|
59
59
|
* not something to resolve silently here.
|
|
60
60
|
*/
|
|
61
61
|
policies(): Policy[];
|
|
62
|
+
/**
|
|
63
|
+
* Whether a policy in force names the scheduled job `jobId` as a subject
|
|
64
|
+
* (`{ "type": "job", "value": jobId }`, #1999), allow or deny. When one
|
|
65
|
+
* does, the job's runs act as the job itself; when none does, as the system
|
|
66
|
+
* principal. Read live, so a site that removes the policy gives the job the
|
|
67
|
+
* system roles back at its next decision.
|
|
68
|
+
*/
|
|
69
|
+
namesJob(jobId: string): boolean;
|
|
62
70
|
/**
|
|
63
71
|
* What each role permits: role name → the actions its holders are granted.
|
|
64
72
|
*
|
|
@@ -178,7 +178,7 @@ declare class PolicyInformationPoint extends BaseManager {
|
|
|
178
178
|
* resolves to the anonymous subject: the job then holds exactly what a
|
|
179
179
|
* visitor holds, which is the safe answer for someone who no longer exists.
|
|
180
180
|
*/
|
|
181
|
-
resolveSubjectNow(username: string | undefined): Promise<UserContext>;
|
|
181
|
+
resolveSubjectNow(username: string | undefined, job?: string): Promise<UserContext>;
|
|
182
182
|
/**
|
|
183
183
|
* The subject as a page's own rules match it (#2022): for a job's subject
|
|
184
184
|
* (`resolveRolesNow`, #1212), its username with the roles its account
|
|
@@ -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`
|
|
15
|
+
/** Who a policy grants to. The evaluator matches `role` and `job` (#1999), its `SUBJECT_MATCHERS`. */
|
|
16
16
|
export interface PolicySubject {
|
|
17
|
-
/** `role`,
|
|
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`)
|
|
22
|
-
* are core's own pure functions, handed on
|
|
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`)
|
|
22
|
-
* are core's own pure functions, handed on
|
|
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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jwilleke/ngdpbase-types",
|
|
3
|
-
"version": "5.30.
|
|
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",
|