@hollware/core 0.9.1 → 0.11.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.
- package/dist/index.d.mts +383 -3
- package/dist/index.mjs +673 -248
- package/package.json +6 -5
package/dist/index.mjs
CHANGED
|
@@ -55,6 +55,203 @@ var Problems = class {
|
|
|
55
55
|
}
|
|
56
56
|
};
|
|
57
57
|
//#endregion
|
|
58
|
+
//#region src/config/values.ts
|
|
59
|
+
/** A value with keys: not null, not an array. */
|
|
60
|
+
function isRecord$4(value) {
|
|
61
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
62
|
+
}
|
|
63
|
+
/** A string as the messages show it: quoted, with what could break a line escaped. */
|
|
64
|
+
function quote$1(text) {
|
|
65
|
+
return JSON.stringify(text);
|
|
66
|
+
}
|
|
67
|
+
/** Text of an error that someone else wrote, on one line, so that a problem stays one line. */
|
|
68
|
+
function oneLine(text) {
|
|
69
|
+
return text.replace(/\s+/g, " ").trim();
|
|
70
|
+
}
|
|
71
|
+
/** What a value is, for a message: `"es"`, `42`, `null`, `an array`. */
|
|
72
|
+
function found(value) {
|
|
73
|
+
if (value === null) return "null";
|
|
74
|
+
switch (typeof value) {
|
|
75
|
+
case "string": return quote$1(value);
|
|
76
|
+
case "number":
|
|
77
|
+
case "boolean":
|
|
78
|
+
case "bigint": return String(value);
|
|
79
|
+
case "function": return "a function";
|
|
80
|
+
case "symbol": return "a symbol";
|
|
81
|
+
default: return Array.isArray(value) ? "an array" : "an object";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** `is 42; it must be a string`, or `is missing; …` when the value is not there. */
|
|
85
|
+
function wrongType(value, expected) {
|
|
86
|
+
return `${value === void 0 ? "is missing" : `is ${found(value)}`}; it must be ${expected}`;
|
|
87
|
+
}
|
|
88
|
+
/** An optional string: `null` when it is absent, and a problem when it is anything else. */
|
|
89
|
+
function readOptionalString(path, value, fix, problems) {
|
|
90
|
+
if (value === void 0) return null;
|
|
91
|
+
if (typeof value === "string") return value;
|
|
92
|
+
problems.add(path, wrongType(value, "a string"), fix);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const PLAIN_KEY = /^[A-Za-z0-9_-]+$/;
|
|
96
|
+
/** The path of a key inside `parent`: `sites.demo`, or `sites["my site"]` for an odd key. */
|
|
97
|
+
function keyPath(parent, key) {
|
|
98
|
+
return PLAIN_KEY.test(key) ? `${parent}.${key}` : `${parent}[${quote$1(key)}]`;
|
|
99
|
+
}
|
|
100
|
+
/** The path of an item of an array: `sites.demo.locales[1]`. */
|
|
101
|
+
function itemPath(parent, index) {
|
|
102
|
+
return `${parent}[${index}]`;
|
|
103
|
+
}
|
|
104
|
+
//#endregion
|
|
105
|
+
//#region src/config/events.ts
|
|
106
|
+
/** The name of a Worker secret or variable: what `wrangler secret put` takes. */
|
|
107
|
+
const BINDING_NAME = /^[A-Z][A-Z0-9_]*$/;
|
|
108
|
+
/**
|
|
109
|
+
* The Worker secrets a definition may not name: Hollware's own (its master secret, its analytics
|
|
110
|
+
* token) and Cloudflare's. A site's definition is written by its owner or an agent with
|
|
111
|
+
* schema:admin, who need not hold the account, so it names only secrets made for its webhooks.
|
|
112
|
+
*/
|
|
113
|
+
const RESERVED_SECRET = /^(HOLLWARE|CLOUDFLARE|CF)_/;
|
|
114
|
+
/**
|
|
115
|
+
* An IndexNow key, which is public by design (search engines fetch it at `/<key>.txt`): the
|
|
116
|
+
* definition holds the key itself, never the name of a Worker secret, so no value of the Worker
|
|
117
|
+
* can be sent to IndexNow (T-69 of docs/security/threat-model.md).
|
|
118
|
+
*/
|
|
119
|
+
const INDEXNOW_KEY = /^[A-Za-z0-9-]{8,128}$/;
|
|
120
|
+
/** A subscriber's name: lowercase words joined by hyphens. */
|
|
121
|
+
const NAME$1 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
122
|
+
/** The public URL of a site: an absolute http or https URL with no path, query or fragment. */
|
|
123
|
+
function readSiteUrl(path, raw, problems) {
|
|
124
|
+
if (raw === void 0) return null;
|
|
125
|
+
const fix = "Write the public origin of the site, such as \"https://example.com\".";
|
|
126
|
+
if (typeof raw !== "string" || !URL.canParse(raw)) {
|
|
127
|
+
problems.add(path, wrongType(raw, "an absolute URL"), fix);
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
const url = new URL(raw);
|
|
131
|
+
if (url.protocol !== "https:" && url.protocol !== "http:" || url.pathname !== "/" || url.search !== "" || url.hash !== "") {
|
|
132
|
+
problems.add(path, `${quote$1(raw)} is not an origin`, fix);
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
return url.origin;
|
|
136
|
+
}
|
|
137
|
+
/** The origins of a site's pages that may read its published content from the browser. */
|
|
138
|
+
function readCors(path, raw, problems) {
|
|
139
|
+
if (raw === void 0) return { origins: [] };
|
|
140
|
+
if (!isRecord$4(raw) || !Array.isArray(raw["origins"])) {
|
|
141
|
+
problems.add(path, wrongType(raw, "an object with origins"), "Declare it as { origins: [\"https://example.com\"] }, or remove it.");
|
|
142
|
+
return { origins: [] };
|
|
143
|
+
}
|
|
144
|
+
const origins = [];
|
|
145
|
+
for (const [index, origin] of raw["origins"].entries()) {
|
|
146
|
+
const read = readSiteUrl(itemPath(keyPath(path, "origins"), index), origin, problems);
|
|
147
|
+
if (read !== null && !origins.includes(read)) origins.push(read);
|
|
148
|
+
}
|
|
149
|
+
return { origins };
|
|
150
|
+
}
|
|
151
|
+
function readBindingName(path, raw, problems) {
|
|
152
|
+
if (typeof raw === "string" && BINDING_NAME.test(raw) && RESERVED_SECRET.test(raw)) {
|
|
153
|
+
problems.add(path, `names ${quote$1(raw)}, a secret of Hollware or of Cloudflare, which no webhook may use`, "Leave the secret out, and Hollware makes one for the webhook (make_webhook_secret).");
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
if (typeof raw === "string" && BINDING_NAME.test(raw)) return raw;
|
|
157
|
+
problems.add(path, wrongType(raw, "the name of a Worker secret"), "Name the secret that holds the value, such as \"WEBHOOK_SECRET\", and set it with wrangler secret put.");
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
function readWebhook(path, raw, problems) {
|
|
161
|
+
if (!isRecord$4(raw)) {
|
|
162
|
+
problems.add(path, wrongType(raw, "a webhook"), "Declare it as { name: \"newsletter\", url: \"https://…\", secret: \"WEBHOOK_SECRET\" }.");
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
const name = raw["name"];
|
|
166
|
+
if (typeof name !== "string" || !NAME$1.test(name)) problems.add(keyPath(path, "name"), wrongType(name, "a name"), "Name it with lowercase words joined by hyphens, such as \"newsletter\".");
|
|
167
|
+
const url = raw["url"];
|
|
168
|
+
const urlOk = typeof url === "string" && URL.canParse(url) && new URL(url).protocol === "https:";
|
|
169
|
+
if (!urlOk) problems.add(keyPath(path, "url"), wrongType(url, "an https URL"), "Give the https URL that receives the events.");
|
|
170
|
+
const secret = raw["secret"] === void 0 ? null : readBindingName(keyPath(path, "secret"), raw["secret"], problems);
|
|
171
|
+
if (typeof name !== "string" || !NAME$1.test(name) || !urlOk) return null;
|
|
172
|
+
if (raw["secret"] !== void 0 && secret === null) return null;
|
|
173
|
+
return {
|
|
174
|
+
name,
|
|
175
|
+
url: String(url),
|
|
176
|
+
secret
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/** Who hears about publications (ADR-025): webhooks, and IndexNow when the site has a URL. */
|
|
180
|
+
function readEvents(path, raw, siteUrl, problems) {
|
|
181
|
+
const events = {
|
|
182
|
+
webhooks: [],
|
|
183
|
+
indexNow: null
|
|
184
|
+
};
|
|
185
|
+
if (raw === void 0) return events;
|
|
186
|
+
if (!isRecord$4(raw)) {
|
|
187
|
+
problems.add(path, wrongType(raw, "an object"), "Declare { webhooks: [ … ], indexNow: { … } }.");
|
|
188
|
+
return events;
|
|
189
|
+
}
|
|
190
|
+
const webhooks = raw["webhooks"];
|
|
191
|
+
if (webhooks !== void 0) {
|
|
192
|
+
if (!Array.isArray(webhooks)) problems.add(keyPath(path, "webhooks"), wrongType(webhooks, "a list"), "List the webhooks.");
|
|
193
|
+
else {
|
|
194
|
+
const seen = /* @__PURE__ */ new Set();
|
|
195
|
+
webhooks.forEach((item, index) => {
|
|
196
|
+
const webhook = readWebhook(itemPath(keyPath(path, "webhooks"), index), item, problems);
|
|
197
|
+
if (webhook === null) return;
|
|
198
|
+
if (seen.has(webhook.name)) {
|
|
199
|
+
problems.add(itemPath(keyPath(path, "webhooks"), index), `repeats the name ${quote$1(webhook.name)}`, "Give each webhook a name of its own.");
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
seen.add(webhook.name);
|
|
203
|
+
events.webhooks.push(webhook);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const indexNow = raw["indexNow"];
|
|
208
|
+
if (indexNow !== void 0) {
|
|
209
|
+
const indexPath = keyPath(path, "indexNow");
|
|
210
|
+
if (!isRecord$4(indexNow)) problems.add(indexPath, wrongType(indexNow, "an object"), "Declare { key: \"<your IndexNow key>\" }.");
|
|
211
|
+
else {
|
|
212
|
+
const raw = indexNow["key"];
|
|
213
|
+
const key = typeof raw === "string" && INDEXNOW_KEY.test(raw) ? raw : null;
|
|
214
|
+
if (key === null) problems.add(keyPath(indexPath, "key"), wrongType(raw, "an IndexNow key: 8 to 128 letters, digits or hyphens"), "Put the key itself, the one your site serves at /<key>.txt, not the name of a secret.");
|
|
215
|
+
if (siteUrl === null) problems.add(indexPath, "needs the URL of the site", "Set url on the site: IndexNow is told the public URLs of the entries.");
|
|
216
|
+
else if (key !== null) events.indexNow = { key };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return events;
|
|
220
|
+
}
|
|
221
|
+
//#endregion
|
|
222
|
+
//#region src/config/audit.ts
|
|
223
|
+
/** The fewest days a site may keep its activity log for, when it sets a retention (ADR-037). */
|
|
224
|
+
const AUDIT_RETENTION_MIN_DAYS = 30;
|
|
225
|
+
/** The most days a retention may be: ten years. Beyond that, keep it with no retention. */
|
|
226
|
+
const AUDIT_RETENTION_MAX_DAYS = 3650;
|
|
227
|
+
/** What a site that says nothing keeps: no addresses, and the log for as long as the site exists. */
|
|
228
|
+
const DEFAULT_AUDIT = {
|
|
229
|
+
ipAddresses: "off",
|
|
230
|
+
retentionDays: null
|
|
231
|
+
};
|
|
232
|
+
/** What the activity log of a site keeps, as its owner chose (ADR-037). */
|
|
233
|
+
function readAudit(path, raw, problems) {
|
|
234
|
+
if (raw === void 0 || raw === null) return DEFAULT_AUDIT;
|
|
235
|
+
if (!isRecord$4(raw)) {
|
|
236
|
+
problems.add(path, wrongType(raw, "an object"), "Declare it as { ipAddresses: \"off\", retentionDays: null }, or remove it.");
|
|
237
|
+
return DEFAULT_AUDIT;
|
|
238
|
+
}
|
|
239
|
+
let ipAddresses = "off";
|
|
240
|
+
const addresses = raw["ipAddresses"];
|
|
241
|
+
if (addresses === "hashed") ipAddresses = "hashed";
|
|
242
|
+
else if (addresses !== void 0 && addresses !== "off") problems.add(keyPath(path, "ipAddresses"), wrongType(addresses, "\"off\" or \"hashed\""), "Use \"off\" to keep no address, or \"hashed\" to keep a keyed hash of each.");
|
|
243
|
+
let retentionDays = null;
|
|
244
|
+
const days = raw["retentionDays"];
|
|
245
|
+
if (days !== void 0 && days !== null) {
|
|
246
|
+
if (typeof days !== "number" || !Number.isInteger(days) || days < 30 || days > 3650) problems.add(keyPath(path, "retentionDays"), `${typeof days === "number" ? String(days) : wrongType(days, "a number")} is not a whole number of days from 30 to ${AUDIT_RETENTION_MAX_DAYS}`, "Give the days to keep each line, or null to keep the log for as long as the site exists.");
|
|
247
|
+
else retentionDays = days;
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
ipAddresses,
|
|
251
|
+
retentionDays
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
//#endregion
|
|
58
255
|
//#region src/config/constants.ts
|
|
59
256
|
/** The largest Markdown file a collection accepts when its config does not say, 512 KiB. */
|
|
60
257
|
const DEFAULT_MAX_MARKDOWN_BYTES = 524288;
|
|
@@ -70,7 +267,7 @@ const MAX_MARKDOWN_BYTES_CEILING = 786432;
|
|
|
70
267
|
* letter, at most 63 characters. A component name is what Markdown writes after the colons
|
|
71
268
|
* (`::callout`). A site key has its own rule (`site-key.ts`).
|
|
72
269
|
*/
|
|
73
|
-
const NAME
|
|
270
|
+
const NAME = /^[a-z][a-z0-9-]{0,62}$/;
|
|
74
271
|
/** A language tag: a language, then an optional script and region: `es`, `pt-BR`, `zh-Hant-TW`. */
|
|
75
272
|
const LOCALE_TAG = /^[a-z]{2,3}(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|[0-9]{3}))?$/;
|
|
76
273
|
/** The id of a contract: lowercase words joined by single hyphens. */
|
|
@@ -140,7 +337,7 @@ function clip(text, max = MAX_ECHO_LENGTH) {
|
|
|
140
337
|
return `${last >= 55296 && last <= 56319 ? head.slice(0, -1) : head}…`;
|
|
141
338
|
}
|
|
142
339
|
/** Text from the entry as a message shows it: clipped, in JSON quotes, control characters escaped. */
|
|
143
|
-
function quote
|
|
340
|
+
function quote(text) {
|
|
144
341
|
return JSON.stringify(clip(text));
|
|
145
342
|
}
|
|
146
343
|
/** A JSON Pointer (RFC 6901) from its segments: `['tags', '0']` is `/tags/0`, none is the root. */
|
|
@@ -264,7 +461,7 @@ function unknownKeys(fragment, value) {
|
|
|
264
461
|
}
|
|
265
462
|
/** The fix for one issue: imperative, naming the field and what the schema accepts there. */
|
|
266
463
|
function fixForIssue({ subject, schema, input, segments, message }) {
|
|
267
|
-
const target = segments.length === 0 ? subject.container : `the ${subject.noun} ${quote
|
|
464
|
+
const target = segments.length === 0 ? subject.container : `the ${subject.noun} ${quote(label(segments))}`;
|
|
268
465
|
const fragment = schema === null ? void 0 : fragmentAt(schema, segments);
|
|
269
466
|
if (fragment !== void 0) {
|
|
270
467
|
const unknown = unknownKeys(fragment, valueAt(input, segments));
|
|
@@ -272,7 +469,7 @@ function fixForIssue({ subject, schema, input, segments, message }) {
|
|
|
272
469
|
const allowed = isRecord$3(fragment.properties) ? Object.keys(fragment.properties) : [];
|
|
273
470
|
const nouns = unknown.length === 1 ? subject.noun : `${subject.noun}s`;
|
|
274
471
|
const where = allowed.length === 0 ? `no ${subject.noun}s are allowed there` : `the ${subject.noun}s allowed there are: ${allowed.join(", ")}`;
|
|
275
|
-
return `Remove the ${nouns} ${unknown.map((key) => quote
|
|
472
|
+
return `Remove the ${nouns} ${unknown.map((key) => quote(key)).join(", ")} from ${target}; ${where}.`;
|
|
276
473
|
}
|
|
277
474
|
}
|
|
278
475
|
const expected = fragment === void 0 ? void 0 : expectation(fragment);
|
|
@@ -354,15 +551,15 @@ const COMPONENTS = registerBuiltIn({
|
|
|
354
551
|
const result = await definition.props["~standard"].validate(use.props);
|
|
355
552
|
if (!result.issues) continue;
|
|
356
553
|
const subject = {
|
|
357
|
-
container: `the component ${quote
|
|
554
|
+
container: `the component ${quote(use.name)}`,
|
|
358
555
|
noun: "prop"
|
|
359
556
|
};
|
|
360
557
|
for (const issue of result.issues) {
|
|
361
558
|
const segments = issueSegments(issue);
|
|
362
|
-
const which = segments.length === 0 ? "The props" : `The prop ${quote
|
|
559
|
+
const which = segments.length === 0 ? "The props" : `The prop ${quote(label(segments))}`;
|
|
363
560
|
findings.push({
|
|
364
561
|
path: "/body",
|
|
365
|
-
message: `${which} of the component ${quote
|
|
562
|
+
message: `${which} of the component ${quote(use.name)} ${segments.length === 0 ? "are" : "is"} invalid: ${issue.message}`,
|
|
366
563
|
fix: fixForIssue({
|
|
367
564
|
subject,
|
|
368
565
|
schema: schemas.get(use.name) ?? null,
|
|
@@ -429,16 +626,16 @@ const RAW_HTML = markerContract("raw-html", "The body holds Markdown and the com
|
|
|
429
626
|
const UNKNOWN_COMPONENT = markerContract("unknown-component", "The body uses only the components that the collection declares.", (marker, collection) => {
|
|
430
627
|
const components = allowedComponents(collection);
|
|
431
628
|
return {
|
|
432
|
-
message: `The component ${quote
|
|
629
|
+
message: `The component ${quote(marker.name)} does not exist in this collection.`,
|
|
433
630
|
fix: components.length === 0 ? "This collection allows no components: remove it and use Markdown." : `Use one of the allowed components: ${components.join(", ")}.`
|
|
434
631
|
};
|
|
435
632
|
});
|
|
436
633
|
const DISALLOWED_ATTRIBUTE = markerContract("disallowed-attribute", "Links, images and code carry only the attributes Markdown produces, and a component only the props its schema declares.", (marker) => ({
|
|
437
|
-
message: `The attribute ${quote
|
|
438
|
-
fix: `Remove the attribute ${quote
|
|
634
|
+
message: `The attribute ${quote(marker.name)} is not allowed.`,
|
|
635
|
+
fix: `Remove the attribute ${quote(marker.name)}. Links keep href and title, images keep src, alt, title, width and height, and a component takes only the props its schema declares.`
|
|
439
636
|
}));
|
|
440
637
|
const UNSAFE_URL = markerContract("unsafe-url", "Links and images use an https:, http: or mailto: URL, a path of the site or an anchor.", (marker) => ({
|
|
441
|
-
message: `The URL ${quote
|
|
638
|
+
message: `The URL ${quote(marker.name)} is not allowed.`,
|
|
442
639
|
fix: "Use an https: or http: URL, a mailto: address, a path of this site that starts with /, or an anchor that starts with #."
|
|
443
640
|
}));
|
|
444
641
|
//#endregion
|
|
@@ -633,11 +830,11 @@ function lengthOf(value) {
|
|
|
633
830
|
/** A text, or a list, of a field has between `min` and `max` characters or items. */
|
|
634
831
|
function fieldLength(params, options = {}) {
|
|
635
832
|
const bounds = [params.min === void 0 ? null : `at least ${params.min}`, params.max === void 0 ? null : `at most ${params.max}`].filter((bound) => bound !== null);
|
|
636
|
-
const unless = params.unless === void 0 ? "" : `, unless ${quote
|
|
833
|
+
const unless = params.unless === void 0 ? "" : `, unless ${quote(params.unless)} is given`;
|
|
637
834
|
return registerBuiltIn({
|
|
638
835
|
id: options.id ?? "field-length",
|
|
639
836
|
severity: options.severity ?? "error",
|
|
640
|
-
description: `${quote
|
|
837
|
+
description: `${quote(params.field)} has ${bounds.join(" and ")} characters or items${unless}.`,
|
|
641
838
|
rule: {
|
|
642
839
|
name: "field-length",
|
|
643
840
|
params: { ...params }
|
|
@@ -651,13 +848,13 @@ function fieldLength(params, options = {}) {
|
|
|
651
848
|
const path = `/frontmatter/${params.field}`;
|
|
652
849
|
if (params.max !== void 0 && length > params.max) return [{
|
|
653
850
|
path,
|
|
654
|
-
message: `${quote
|
|
655
|
-
fix: `Shorten ${quote
|
|
851
|
+
message: `${quote(params.field)} has ${count(length, unit)}; the most is ${params.max}.`,
|
|
852
|
+
fix: `Shorten ${quote(params.field)} to ${count(params.max, unit)} or fewer${params.unless === void 0 ? "" : `, or add ${quote(params.unless)}`}.`
|
|
656
853
|
}];
|
|
657
854
|
if (params.min !== void 0 && length < params.min) return [{
|
|
658
855
|
path,
|
|
659
|
-
message: `${quote
|
|
660
|
-
fix: `Lengthen ${quote
|
|
856
|
+
message: `${quote(params.field)} has ${count(length, unit)}; the least is ${params.min}.`,
|
|
857
|
+
fix: `Lengthen ${quote(params.field)} to ${count(params.min, unit)} or more.`
|
|
661
858
|
}];
|
|
662
859
|
return [];
|
|
663
860
|
}
|
|
@@ -669,7 +866,7 @@ function fieldPattern(params, options = {}) {
|
|
|
669
866
|
return registerBuiltIn({
|
|
670
867
|
id: options.id ?? "field-pattern",
|
|
671
868
|
severity: options.severity ?? "error",
|
|
672
|
-
description: `${quote
|
|
869
|
+
description: `${quote(params.field)} is ${params.description}.`,
|
|
673
870
|
rule: {
|
|
674
871
|
name: "field-pattern",
|
|
675
872
|
params: { ...params }
|
|
@@ -679,8 +876,8 @@ function fieldPattern(params, options = {}) {
|
|
|
679
876
|
if (typeof value !== "string" || pattern.test(value)) return [];
|
|
680
877
|
return [{
|
|
681
878
|
path: `/frontmatter/${params.field}`,
|
|
682
|
-
message: `${quote
|
|
683
|
-
fix: `Write ${quote
|
|
879
|
+
message: `${quote(params.field)} is not ${params.description}.`,
|
|
880
|
+
fix: `Write ${quote(params.field)} as ${params.description}.`
|
|
684
881
|
}];
|
|
685
882
|
}
|
|
686
883
|
}, { readsFrontmatter: true });
|
|
@@ -692,15 +889,15 @@ function requiredToPublish(params, options = {}) {
|
|
|
692
889
|
id: options.id ?? "required-to-publish",
|
|
693
890
|
severity: options.severity ?? "error",
|
|
694
891
|
phases: ["publish"],
|
|
695
|
-
description: `To publish, ${params.fields.map(quote
|
|
892
|
+
description: `To publish, ${params.fields.map(quote).join(", ")} must have a value.`,
|
|
696
893
|
rule: {
|
|
697
894
|
name: "required-to-publish",
|
|
698
895
|
params: { fields: [...params.fields] }
|
|
699
896
|
},
|
|
700
897
|
check: ({ frontmatter }) => params.fields.filter((field) => empty(present(frontmatter, field))).map((field) => ({
|
|
701
898
|
path: `/frontmatter/${field}`,
|
|
702
|
-
message: `${quote
|
|
703
|
-
fix: `Give ${quote
|
|
899
|
+
message: `${quote(field)} is empty, and the entry cannot be published without it.`,
|
|
900
|
+
fix: `Give ${quote(field)} a value, then publish.`
|
|
704
901
|
}))
|
|
705
902
|
}, { readsFrontmatter: true });
|
|
706
903
|
}
|
|
@@ -716,7 +913,7 @@ function bannedWords(params, options = {}) {
|
|
|
716
913
|
return registerBuiltIn({
|
|
717
914
|
id: options.id ?? "banned-words",
|
|
718
915
|
severity: options.severity ?? "error",
|
|
719
|
-
description: `None of these words: ${words.map(quote
|
|
916
|
+
description: `None of these words: ${words.map(quote).join(", ")}${because}.`,
|
|
720
917
|
rule: {
|
|
721
918
|
name: "banned-words",
|
|
722
919
|
params: {
|
|
@@ -732,14 +929,14 @@ function bannedWords(params, options = {}) {
|
|
|
732
929
|
if (typeof value !== "string") continue;
|
|
733
930
|
for (const word of found(value)) findings.push({
|
|
734
931
|
path: `/frontmatter/${field}`,
|
|
735
|
-
message: `${quote
|
|
736
|
-
fix: `Write ${quote
|
|
932
|
+
message: `${quote(field)} uses ${quote(word)}.`,
|
|
933
|
+
fix: `Write ${quote(field)} without ${quote(word)}${because}.`
|
|
737
934
|
});
|
|
738
935
|
}
|
|
739
936
|
for (const word of found(documentText(context.markdown.document))) findings.push({
|
|
740
937
|
path: "/body",
|
|
741
|
-
message: `The body uses ${quote
|
|
742
|
-
fix: `Rewrite the body without ${quote
|
|
938
|
+
message: `The body uses ${quote(word)}.`,
|
|
939
|
+
fix: `Rewrite the body without ${quote(word)}${because}.`
|
|
743
940
|
});
|
|
744
941
|
return findings;
|
|
745
942
|
}
|
|
@@ -778,8 +975,8 @@ function translationPair(options = {}) {
|
|
|
778
975
|
const present = /* @__PURE__ */ new Set([context.locale, ...found.map((entry) => entry.locale)]);
|
|
779
976
|
return site.locales.filter((locale) => !present.has(locale)).map((locale) => ({
|
|
780
977
|
path,
|
|
781
|
-
message: `The ${locale} version of the translation group ${quote
|
|
782
|
-
fix: `Create the entry in ${locale} with ${field}: ${quote
|
|
978
|
+
message: `The ${locale} version of the translation group ${quote(group)} does not exist.`,
|
|
979
|
+
fix: `Create the entry in ${locale} with ${field}: ${quote(group)} in its frontmatter before publishing.`
|
|
783
980
|
}));
|
|
784
981
|
}
|
|
785
982
|
}, { readsFrontmatter: true });
|
|
@@ -826,7 +1023,7 @@ function internalLinksResolve(options = {}) {
|
|
|
826
1023
|
const entry = `the entry ${clip(`${target.collection}/${target.locale}/${target.slug}`)}`;
|
|
827
1024
|
findings.push({
|
|
828
1025
|
path: "/body",
|
|
829
|
-
message: found === null ? `The link ${quote
|
|
1026
|
+
message: found === null ? `The link ${quote(link.href)} points at ${entry}, which does not exist.` : `The link ${quote(link.href)} points at ${entry}, which is not published yet (${found.status}).`,
|
|
830
1027
|
fix: found === null ? `Create and publish ${entry}, point the link at an entry that is published, or remove the link.` : `Publish ${entry} first, point the link at an entry that is published, or remove the link.`,
|
|
831
1028
|
line: link.line
|
|
832
1029
|
});
|
|
@@ -859,7 +1056,7 @@ function imagesHaveAlt(options = {}) {
|
|
|
859
1056
|
description: "Every image has alt text that describes it.",
|
|
860
1057
|
check: (context) => context.markdown.extracted.images.filter((image) => image.alt.trim() === "").map((image) => ({
|
|
861
1058
|
path: "/body",
|
|
862
|
-
message: `The image ${quote
|
|
1059
|
+
message: `The image ${quote(image.src)} has no alt text.`,
|
|
863
1060
|
fix: `Add alt text that describes the image, as in }).`,
|
|
864
1061
|
line: image.line
|
|
865
1062
|
}))
|
|
@@ -1026,7 +1223,7 @@ function toViolations(contract, findings) {
|
|
|
1026
1223
|
severity: contract.severity,
|
|
1027
1224
|
path: finding.path,
|
|
1028
1225
|
message: finding.message,
|
|
1029
|
-
fix: finding.fix.trim() === "" ? `Change the entry so that the rule ${quote
|
|
1226
|
+
fix: finding.fix.trim() === "" ? `Change the entry so that the rule ${quote(contract.id)} passes: ${contract.description}` : finding.fix,
|
|
1030
1227
|
...finding.line === void 0 ? {} : { line: finding.line }
|
|
1031
1228
|
}));
|
|
1032
1229
|
}
|
|
@@ -1043,8 +1240,8 @@ function thrown(contract, error) {
|
|
|
1043
1240
|
contract: contract.id,
|
|
1044
1241
|
severity: "error",
|
|
1045
1242
|
path: "",
|
|
1046
|
-
message: `The contract ${quote
|
|
1047
|
-
fix: `This is a problem in the config of the site, not in the entry: report it to whoever maintains the config, naming the contract ${quote
|
|
1243
|
+
message: `The contract ${quote(contract.id)} failed with an error while it checked this entry: ${clip(describeError(error), 200)}`,
|
|
1244
|
+
fix: `This is a problem in the config of the site, not in the entry: report it to whoever maintains the config, naming the contract ${quote(contract.id)}.`
|
|
1048
1245
|
};
|
|
1049
1246
|
}
|
|
1050
1247
|
async function settle(contract, context) {
|
|
@@ -1169,57 +1366,10 @@ function uniqueTranslationViolation(holder, field) {
|
|
|
1169
1366
|
severity: "error",
|
|
1170
1367
|
path: `/frontmatter${pointer([field])}`,
|
|
1171
1368
|
message: `Another entry of this collection and locale, ${holder}, already belongs to this translation group.`,
|
|
1172
|
-
fix: `Give ${quote
|
|
1369
|
+
fix: `Give ${quote(field)} another value, or change the entry ${holder} with update_draft: a translation group holds one entry per locale.`
|
|
1173
1370
|
};
|
|
1174
1371
|
}
|
|
1175
1372
|
//#endregion
|
|
1176
|
-
//#region src/config/values.ts
|
|
1177
|
-
/** A value with keys: not null, not an array. */
|
|
1178
|
-
function isRecord$2(value) {
|
|
1179
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1180
|
-
}
|
|
1181
|
-
/** A string as the messages show it: quoted, with what could break a line escaped. */
|
|
1182
|
-
function quote(text) {
|
|
1183
|
-
return JSON.stringify(text);
|
|
1184
|
-
}
|
|
1185
|
-
/** Text of an error that someone else wrote, on one line, so that a problem stays one line. */
|
|
1186
|
-
function oneLine(text) {
|
|
1187
|
-
return text.replace(/\s+/g, " ").trim();
|
|
1188
|
-
}
|
|
1189
|
-
/** What a value is, for a message: `"es"`, `42`, `null`, `an array`. */
|
|
1190
|
-
function found(value) {
|
|
1191
|
-
if (value === null) return "null";
|
|
1192
|
-
switch (typeof value) {
|
|
1193
|
-
case "string": return quote(value);
|
|
1194
|
-
case "number":
|
|
1195
|
-
case "boolean":
|
|
1196
|
-
case "bigint": return String(value);
|
|
1197
|
-
case "function": return "a function";
|
|
1198
|
-
case "symbol": return "a symbol";
|
|
1199
|
-
default: return Array.isArray(value) ? "an array" : "an object";
|
|
1200
|
-
}
|
|
1201
|
-
}
|
|
1202
|
-
/** `is 42; it must be a string`, or `is missing; …` when the value is not there. */
|
|
1203
|
-
function wrongType(value, expected) {
|
|
1204
|
-
return `${value === void 0 ? "is missing" : `is ${found(value)}`}; it must be ${expected}`;
|
|
1205
|
-
}
|
|
1206
|
-
/** An optional string: `null` when it is absent, and a problem when it is anything else. */
|
|
1207
|
-
function readOptionalString(path, value, fix, problems) {
|
|
1208
|
-
if (value === void 0) return null;
|
|
1209
|
-
if (typeof value === "string") return value;
|
|
1210
|
-
problems.add(path, wrongType(value, "a string"), fix);
|
|
1211
|
-
return null;
|
|
1212
|
-
}
|
|
1213
|
-
const PLAIN_KEY = /^[A-Za-z0-9_-]+$/;
|
|
1214
|
-
/** The path of a key inside `parent`: `sites.demo`, or `sites["my site"]` for an odd key. */
|
|
1215
|
-
function keyPath(parent, key) {
|
|
1216
|
-
return PLAIN_KEY.test(key) ? `${parent}.${key}` : `${parent}[${quote(key)}]`;
|
|
1217
|
-
}
|
|
1218
|
-
/** The path of an item of an array: `sites.demo.locales[1]`. */
|
|
1219
|
-
function itemPath(parent, index) {
|
|
1220
|
-
return `${parent}[${index}]`;
|
|
1221
|
-
}
|
|
1222
|
-
//#endregion
|
|
1223
1373
|
//#region src/config/json-schema.ts
|
|
1224
1374
|
/**
|
|
1225
1375
|
* JSON Schema as the config uses it: what agents see of a schema, and what the checks read of it
|
|
@@ -1233,7 +1383,7 @@ const TARGET = "draft-2020-12";
|
|
|
1233
1383
|
*/
|
|
1234
1384
|
function toJsonSchema$1(schema) {
|
|
1235
1385
|
const converted = schema["~standard"].jsonSchema.input({ target: TARGET });
|
|
1236
|
-
if (!isRecord$
|
|
1386
|
+
if (!isRecord$4(converted)) throw new TypeError(`the library ${wrongType(converted, "a JSON Schema object")}`);
|
|
1237
1387
|
return JSON.parse(JSON.stringify(converted));
|
|
1238
1388
|
}
|
|
1239
1389
|
/** Reference chains longer than this are cycles. */
|
|
@@ -1245,7 +1395,7 @@ function pointTo(root, ref) {
|
|
|
1245
1395
|
let node = root;
|
|
1246
1396
|
for (const part of ref.slice(2).split("/")) {
|
|
1247
1397
|
const key = part.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
1248
|
-
if (!isRecord$
|
|
1398
|
+
if (!isRecord$4(node) || !Object.hasOwn(node, key)) return void 0;
|
|
1249
1399
|
node = node[key];
|
|
1250
1400
|
}
|
|
1251
1401
|
return node;
|
|
@@ -1257,7 +1407,7 @@ function pointTo(root, ref) {
|
|
|
1257
1407
|
function resolveSchema(root, schema) {
|
|
1258
1408
|
let node = schema;
|
|
1259
1409
|
for (let hop = 0; hop <= MAX_REF_HOPS; hop += 1) {
|
|
1260
|
-
if (!isRecord$
|
|
1410
|
+
if (!isRecord$4(node)) return null;
|
|
1261
1411
|
const ref = node["$ref"];
|
|
1262
1412
|
if (typeof ref !== "string") return node;
|
|
1263
1413
|
node = pointTo(root, ref);
|
|
@@ -1374,14 +1524,14 @@ function standardProps(value) {
|
|
|
1374
1524
|
/** A Standard Schema that also converts to JSON Schema, which is what a collection needs. */
|
|
1375
1525
|
function isDescribedSchema(value) {
|
|
1376
1526
|
const standard = standardProps(value);
|
|
1377
|
-
if (!isRecord$
|
|
1527
|
+
if (!isRecord$4(standard) || typeof standard["validate"] !== "function") return false;
|
|
1378
1528
|
const converter = standard["jsonSchema"];
|
|
1379
|
-
return isRecord$
|
|
1529
|
+
return isRecord$4(converter) && typeof converter["input"] === "function";
|
|
1380
1530
|
}
|
|
1381
1531
|
/** What `isDescribedSchema` found missing, for the message. */
|
|
1382
1532
|
function whyNotDescribed(value) {
|
|
1383
1533
|
const standard = standardProps(value);
|
|
1384
|
-
if (!isRecord$
|
|
1534
|
+
if (!isRecord$4(standard)) return wrongType(value, "a Standard Schema with JSON Schema");
|
|
1385
1535
|
if (typeof standard["validate"] !== "function") return "has no ~standard.validate function, so it is no Standard Schema";
|
|
1386
1536
|
return "has no ~standard.jsonSchema.input function, so describe_site could not show it to agents";
|
|
1387
1537
|
}
|
|
@@ -1429,12 +1579,12 @@ const SLUG_PLACEHOLDER = /:slug(?![A-Za-z0-9_])/g;
|
|
|
1429
1579
|
function isRoutePattern(path, route, problems) {
|
|
1430
1580
|
let valid = true;
|
|
1431
1581
|
if (!route.startsWith("/")) {
|
|
1432
|
-
problems.add(path, `${quote(route)} does not start with "/"`, "Write the path from the root of the site, such as \"/blog/:slug/\".");
|
|
1582
|
+
problems.add(path, `${quote$1(route)} does not start with "/"`, "Write the path from the root of the site, such as \"/blog/:slug/\".");
|
|
1433
1583
|
valid = false;
|
|
1434
1584
|
}
|
|
1435
1585
|
const slugs = [...route.matchAll(SLUG_PLACEHOLDER)].length;
|
|
1436
1586
|
if (slugs !== 1) {
|
|
1437
|
-
problems.add(path, slugs === 0 ? `${quote(route)} has no :slug` : `${quote(route)} has :slug ${slugs} times`, "Use :slug exactly once, where the slug of the entry goes, such as \"/blog/:slug/\".");
|
|
1587
|
+
problems.add(path, slugs === 0 ? `${quote$1(route)} has no :slug` : `${quote$1(route)} has :slug ${slugs} times`, "Use :slug exactly once, where the slug of the entry goes, such as \"/blog/:slug/\".");
|
|
1438
1588
|
valid = false;
|
|
1439
1589
|
}
|
|
1440
1590
|
return valid;
|
|
@@ -1447,11 +1597,11 @@ function claimRoute(route, path, site, problems) {
|
|
|
1447
1597
|
site.routes.set(pattern, path);
|
|
1448
1598
|
return;
|
|
1449
1599
|
}
|
|
1450
|
-
problems.add(path, `${quote(route)} is the same pattern as the route at ${owner}`, "Give every route of the site its own path: internal links are matched against them.");
|
|
1600
|
+
problems.add(path, `${quote$1(route)} is the same pattern as the route at ${owner}`, "Give every route of the site its own path: internal links are matched against them.");
|
|
1451
1601
|
}
|
|
1452
1602
|
/** The route of each locale, in the order of the site's locales. */
|
|
1453
1603
|
function readRoutes(path, raw, collection, site, problems) {
|
|
1454
|
-
if (!isRecord$
|
|
1604
|
+
if (!isRecord$4(raw)) {
|
|
1455
1605
|
problems.add(path, wrongType(raw, "an object with one path per locale"), ROUTES_FIX);
|
|
1456
1606
|
return {};
|
|
1457
1607
|
}
|
|
@@ -1459,12 +1609,12 @@ function readRoutes(path, raw, collection, site, problems) {
|
|
|
1459
1609
|
const routePath = keyPath(path, locale);
|
|
1460
1610
|
const route = raw[locale];
|
|
1461
1611
|
const known = !site.localesOk || site.locales.includes(locale);
|
|
1462
|
-
if (!known) problems.add(routePath, `is a route for ${quote(locale)}, which is not a locale of the site (${site.locales.join(", ")})`, `Remove it, or add ${quote(locale)} to the locales of the site.`);
|
|
1612
|
+
if (!known) problems.add(routePath, `is a route for ${quote$1(locale)}, which is not a locale of the site (${site.locales.join(", ")})`, `Remove it, or add ${quote$1(locale)} to the locales of the site.`);
|
|
1463
1613
|
if (typeof route !== "string") problems.add(routePath, wrongType(route, "a path such as \"/blog/:slug/\""), ROUTES_FIX);
|
|
1464
1614
|
else if (isRoutePattern(routePath, route, problems) && known) claimRoute(route, routePath, site, problems);
|
|
1465
1615
|
}
|
|
1466
1616
|
if (site.localesOk) {
|
|
1467
|
-
for (const locale of site.locales) if (!Object.hasOwn(raw, locale)) problems.add(path, `has no route for the locale ${quote(locale)}`, `Add ${keyPath("routes", locale)}, a path such as ${quote(`/${locale}/${collection}/:slug/`)}.`);
|
|
1617
|
+
for (const locale of site.locales) if (!Object.hasOwn(raw, locale)) problems.add(path, `has no route for the locale ${quote$1(locale)}`, `Add ${keyPath("routes", locale)}, a path such as ${quote$1(`/${locale}/${collection}/:slug/`)}.`);
|
|
1468
1618
|
}
|
|
1469
1619
|
const routes = {};
|
|
1470
1620
|
for (const locale of site.locales) {
|
|
@@ -1476,12 +1626,12 @@ function readRoutes(path, raw, collection, site, problems) {
|
|
|
1476
1626
|
const GROUP_BY = {
|
|
1477
1627
|
mustBe: "a string",
|
|
1478
1628
|
accepts: isStringSchema,
|
|
1479
|
-
fix: (field) => `Declare ${quote(field)} as z.string() in the schema, or name another field.`
|
|
1629
|
+
fix: (field) => `Declare ${quote$1(field)} as z.string() in the schema, or name another field.`
|
|
1480
1630
|
};
|
|
1481
1631
|
const TAGS = {
|
|
1482
1632
|
mustBe: "an array of strings",
|
|
1483
1633
|
accepts: isStringArraySchema,
|
|
1484
|
-
fix: (field) => `Declare ${quote(field)} as z.array(z.string()) in the schema, or name another field.`
|
|
1634
|
+
fix: (field) => `Declare ${quote$1(field)} as z.array(z.string()) in the schema, or name another field.`
|
|
1485
1635
|
};
|
|
1486
1636
|
/**
|
|
1487
1637
|
* Listings sort by this field as text, which is chronological only for ISO 8601 dates. A string
|
|
@@ -1490,7 +1640,7 @@ const TAGS = {
|
|
|
1490
1640
|
const SORT_BY = {
|
|
1491
1641
|
mustBe: "a string with the \"date\" or \"date-time\" format",
|
|
1492
1642
|
accepts: isDateSchema,
|
|
1493
|
-
fix: (field) => `Declare ${quote(field)} as z.iso.date() or z.iso.datetime() in the schema, or name another field.`
|
|
1643
|
+
fix: (field) => `Declare ${quote$1(field)} as z.iso.date() or z.iso.datetime() in the schema, or name another field.`
|
|
1494
1644
|
};
|
|
1495
1645
|
/** The name of a field of the frontmatter, checked against the schema when there is one. */
|
|
1496
1646
|
function readField(path, raw, schema, kind, problems) {
|
|
@@ -1499,16 +1649,16 @@ function readField(path, raw, schema, kind, problems) {
|
|
|
1499
1649
|
return null;
|
|
1500
1650
|
}
|
|
1501
1651
|
if (schema === null) return raw;
|
|
1502
|
-
const properties = isRecord$
|
|
1652
|
+
const properties = isRecord$4(schema.object["properties"]) ? schema.object["properties"] : {};
|
|
1503
1653
|
if (!Object.hasOwn(properties, raw)) {
|
|
1504
1654
|
const names = Object.keys(properties);
|
|
1505
|
-
problems.add(path, `${quote(raw)} is not a property of the schema${names.length === 0 ? ", which declares none" : ` (its properties are ${names.map(quote).join(", ")})`}`, `Name a field that the schema declares, or add ${quote(raw)} to the schema.`);
|
|
1506
|
-
} else if (!kind.accepts(schema.document, properties[raw])) problems.add(path, `${quote(raw)} is declared as ${describeShape(schema.document, properties[raw])}, but it must be ${kind.mustBe}`, kind.fix(raw));
|
|
1655
|
+
problems.add(path, `${quote$1(raw)} is not a property of the schema${names.length === 0 ? ", which declares none" : ` (its properties are ${names.map(quote$1).join(", ")})`}`, `Name a field that the schema declares, or add ${quote$1(raw)} to the schema.`);
|
|
1656
|
+
} else if (!kind.accepts(schema.document, properties[raw])) problems.add(path, `${quote$1(raw)} is declared as ${describeShape(schema.document, properties[raw])}, but it must be ${kind.mustBe}`, kind.fix(raw));
|
|
1507
1657
|
return raw;
|
|
1508
1658
|
}
|
|
1509
1659
|
function readTranslations(path, raw, schema, problems) {
|
|
1510
1660
|
if (raw === void 0) return null;
|
|
1511
|
-
if (!isRecord$
|
|
1661
|
+
if (!isRecord$4(raw)) {
|
|
1512
1662
|
problems.add(path, wrongType(raw, "an object like { groupBy: \"translationGroup\" }"), "Set it to { groupBy: \"<field>\" }, the field of the frontmatter that holds the key the translations of an entry share, or remove it.");
|
|
1513
1663
|
return null;
|
|
1514
1664
|
}
|
|
@@ -1516,8 +1666,8 @@ function readTranslations(path, raw, schema, problems) {
|
|
|
1516
1666
|
}
|
|
1517
1667
|
const COMPONENT_FIX = "Declare it with defineComponent({ props: z.strictObject({ … }) }).";
|
|
1518
1668
|
function readComponent(path, name, raw, problems) {
|
|
1519
|
-
if (!NAME
|
|
1520
|
-
if (!isRecord$
|
|
1669
|
+
if (!NAME.test(name)) problems.add(path, `${quote$1(name)} is not a valid component name`, nameFix("callout"));
|
|
1670
|
+
if (!isRecord$4(raw)) {
|
|
1521
1671
|
problems.add(path, wrongType(raw, "a component"), COMPONENT_FIX);
|
|
1522
1672
|
return null;
|
|
1523
1673
|
}
|
|
@@ -1535,7 +1685,7 @@ function readComponent(path, name, raw, problems) {
|
|
|
1535
1685
|
function readComponents(path, raw, problems) {
|
|
1536
1686
|
const components = /* @__PURE__ */ new Map();
|
|
1537
1687
|
if (raw === void 0) return components;
|
|
1538
|
-
if (!isRecord$
|
|
1688
|
+
if (!isRecord$4(raw)) {
|
|
1539
1689
|
problems.add(path, wrongType(raw, "an object that maps each component name to its definition"), COMPONENT_FIX);
|
|
1540
1690
|
return components;
|
|
1541
1691
|
}
|
|
@@ -1567,7 +1717,7 @@ function checkPhases(path, raw, problems) {
|
|
|
1567
1717
|
for (let index = 0; index < phases.length; index += 1) {
|
|
1568
1718
|
const phase = phases[index];
|
|
1569
1719
|
if (!isPhase(phase)) problems.add(itemPath(path, index), wrongType(phase, "\"write\" or \"publish\""), PHASES_FIX);
|
|
1570
|
-
else if (seen.has(phase)) problems.add(itemPath(path, index), `repeats ${quote(phase)}`, PHASES_FIX);
|
|
1720
|
+
else if (seen.has(phase)) problems.add(itemPath(path, index), `repeats ${quote$1(phase)}`, PHASES_FIX);
|
|
1571
1721
|
seen.add(phase);
|
|
1572
1722
|
}
|
|
1573
1723
|
}
|
|
@@ -1576,7 +1726,7 @@ function checkPhases(path, raw, problems) {
|
|
|
1576
1726
|
* what makes it a `Contract`. Whether it may have its id is a question for `checkIdentity`.
|
|
1577
1727
|
*/
|
|
1578
1728
|
function isContract(item, path, problems) {
|
|
1579
|
-
if (!isRecord$
|
|
1729
|
+
if (!isRecord$4(item)) {
|
|
1580
1730
|
problems.add(path, wrongType(item, "a contract"), "Write it as { id, severity, description, check }.");
|
|
1581
1731
|
return false;
|
|
1582
1732
|
}
|
|
@@ -1599,7 +1749,7 @@ function isContract(item, path, problems) {
|
|
|
1599
1749
|
*/
|
|
1600
1750
|
function checkIdentity(id, own, path, ids, problems) {
|
|
1601
1751
|
if (RESERVED.has(id) && !own) {
|
|
1602
|
-
problems.add(keyPath(path, "id"), `${quote(id)} is the id of a built-in contract`, `Choose another id: ${BUILT_IN_CONTRACT_IDS.join(", ")} belong to Hollware's built-in contracts, and the opt-in ones are listed as contracts.translationPair(), contracts.internalLinksResolve() and contracts.imagesHaveAlt().`);
|
|
1752
|
+
problems.add(keyPath(path, "id"), `${quote$1(id)} is the id of a built-in contract`, `Choose another id: ${BUILT_IN_CONTRACT_IDS.join(", ")} belong to Hollware's built-in contracts, and the opt-in ones are listed as contracts.translationPair(), contracts.internalLinksResolve() and contracts.imagesHaveAlt().`);
|
|
1603
1753
|
return;
|
|
1604
1754
|
}
|
|
1605
1755
|
const first = ids.get(id);
|
|
@@ -1607,7 +1757,7 @@ function checkIdentity(id, own, path, ids, problems) {
|
|
|
1607
1757
|
ids.set(id, path);
|
|
1608
1758
|
return;
|
|
1609
1759
|
}
|
|
1610
|
-
problems.add(keyPath(path, "id"), `${quote(id)} is already the id of the contract at ${first}`, "Give every contract of the collection its own id.");
|
|
1760
|
+
problems.add(keyPath(path, "id"), `${quote$1(id)} is already the id of the contract at ${first}`, "Give every contract of the collection its own id.");
|
|
1611
1761
|
}
|
|
1612
1762
|
function readContracts(path, raw, problems) {
|
|
1613
1763
|
const contracts = [];
|
|
@@ -1624,7 +1774,7 @@ function readContracts(path, raw, problems) {
|
|
|
1624
1774
|
if (isContract(item, itemAt, problems)) {
|
|
1625
1775
|
checkIdentity(item.id, isBuiltInContract(item), itemAt, ids, problems);
|
|
1626
1776
|
contracts.push(item);
|
|
1627
|
-
} else if (isRecord$
|
|
1777
|
+
} else if (isRecord$4(item) && typeof item["id"] === "string" && CONTRACT_ID.test(item["id"])) checkIdentity(item["id"], false, itemAt, ids, problems);
|
|
1628
1778
|
}
|
|
1629
1779
|
return contracts;
|
|
1630
1780
|
}
|
|
@@ -1639,8 +1789,8 @@ function readMaxMarkdownBytes(path, raw, problems) {
|
|
|
1639
1789
|
* problem already: `resolveConfig` throws when there is any, so nothing else keeps track of them.
|
|
1640
1790
|
*/
|
|
1641
1791
|
function readCollection(path, name, raw, site, problems) {
|
|
1642
|
-
if (!NAME
|
|
1643
|
-
if (!isRecord$
|
|
1792
|
+
if (!NAME.test(name)) problems.add(path, `${quote$1(name)} is not a valid collection name`, nameFix("blog"));
|
|
1793
|
+
if (!isRecord$4(raw)) {
|
|
1644
1794
|
problems.add(path, wrongType(raw, "a collection"), "Declare it with defineCollection({ schema, routes, … }).");
|
|
1645
1795
|
return null;
|
|
1646
1796
|
}
|
|
@@ -1668,105 +1818,6 @@ function readCollection(path, name, raw, site, problems) {
|
|
|
1668
1818
|
};
|
|
1669
1819
|
}
|
|
1670
1820
|
//#endregion
|
|
1671
|
-
//#region src/config/events.ts
|
|
1672
|
-
/** The name of a Worker secret or variable: what `wrangler secret put` takes. */
|
|
1673
|
-
const BINDING_NAME = /^[A-Z][A-Z0-9_]*$/;
|
|
1674
|
-
/** A subscriber's name: lowercase words joined by hyphens. */
|
|
1675
|
-
const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
1676
|
-
/** The public URL of a site: an absolute http or https URL with no path, query or fragment. */
|
|
1677
|
-
function readSiteUrl(path, raw, problems) {
|
|
1678
|
-
if (raw === void 0) return null;
|
|
1679
|
-
const fix = "Write the public origin of the site, such as \"https://example.com\".";
|
|
1680
|
-
if (typeof raw !== "string" || !URL.canParse(raw)) {
|
|
1681
|
-
problems.add(path, wrongType(raw, "an absolute URL"), fix);
|
|
1682
|
-
return null;
|
|
1683
|
-
}
|
|
1684
|
-
const url = new URL(raw);
|
|
1685
|
-
if (url.protocol !== "https:" && url.protocol !== "http:" || url.pathname !== "/" || url.search !== "" || url.hash !== "") {
|
|
1686
|
-
problems.add(path, `${quote(raw)} is not an origin`, fix);
|
|
1687
|
-
return null;
|
|
1688
|
-
}
|
|
1689
|
-
return url.origin;
|
|
1690
|
-
}
|
|
1691
|
-
/** The origins of a site's pages that may read its published content from the browser. */
|
|
1692
|
-
function readCors(path, raw, problems) {
|
|
1693
|
-
if (raw === void 0) return { origins: [] };
|
|
1694
|
-
if (!isRecord$2(raw) || !Array.isArray(raw["origins"])) {
|
|
1695
|
-
problems.add(path, wrongType(raw, "an object with origins"), "Declare it as { origins: [\"https://example.com\"] }, or remove it.");
|
|
1696
|
-
return { origins: [] };
|
|
1697
|
-
}
|
|
1698
|
-
const origins = [];
|
|
1699
|
-
for (const [index, origin] of raw["origins"].entries()) {
|
|
1700
|
-
const read = readSiteUrl(itemPath(keyPath(path, "origins"), index), origin, problems);
|
|
1701
|
-
if (read !== null && !origins.includes(read)) origins.push(read);
|
|
1702
|
-
}
|
|
1703
|
-
return { origins };
|
|
1704
|
-
}
|
|
1705
|
-
function readBindingName(path, raw, problems) {
|
|
1706
|
-
if (typeof raw === "string" && BINDING_NAME.test(raw)) return raw;
|
|
1707
|
-
problems.add(path, wrongType(raw, "the name of a Worker secret"), "Name the secret that holds the value, such as \"WEBHOOK_SECRET\", and set it with wrangler secret put.");
|
|
1708
|
-
return null;
|
|
1709
|
-
}
|
|
1710
|
-
function readWebhook(path, raw, problems) {
|
|
1711
|
-
if (!isRecord$2(raw)) {
|
|
1712
|
-
problems.add(path, wrongType(raw, "a webhook"), "Declare it as { name: \"newsletter\", url: \"https://…\", secret: \"WEBHOOK_SECRET\" }.");
|
|
1713
|
-
return null;
|
|
1714
|
-
}
|
|
1715
|
-
const name = raw["name"];
|
|
1716
|
-
if (typeof name !== "string" || !NAME.test(name)) problems.add(keyPath(path, "name"), wrongType(name, "a name"), "Name it with lowercase words joined by hyphens, such as \"newsletter\".");
|
|
1717
|
-
const url = raw["url"];
|
|
1718
|
-
const urlOk = typeof url === "string" && URL.canParse(url) && new URL(url).protocol === "https:";
|
|
1719
|
-
if (!urlOk) problems.add(keyPath(path, "url"), wrongType(url, "an https URL"), "Give the https URL that receives the events.");
|
|
1720
|
-
const secret = raw["secret"] === void 0 ? null : readBindingName(keyPath(path, "secret"), raw["secret"], problems);
|
|
1721
|
-
if (typeof name !== "string" || !NAME.test(name) || !urlOk) return null;
|
|
1722
|
-
if (raw["secret"] !== void 0 && secret === null) return null;
|
|
1723
|
-
return {
|
|
1724
|
-
name,
|
|
1725
|
-
url: String(url),
|
|
1726
|
-
secret
|
|
1727
|
-
};
|
|
1728
|
-
}
|
|
1729
|
-
/** Who hears about publications (ADR-025): webhooks, and IndexNow when the site has a URL. */
|
|
1730
|
-
function readEvents(path, raw, siteUrl, problems) {
|
|
1731
|
-
const events = {
|
|
1732
|
-
webhooks: [],
|
|
1733
|
-
indexNow: null
|
|
1734
|
-
};
|
|
1735
|
-
if (raw === void 0) return events;
|
|
1736
|
-
if (!isRecord$2(raw)) {
|
|
1737
|
-
problems.add(path, wrongType(raw, "an object"), "Declare { webhooks: [ … ], indexNow: { … } }.");
|
|
1738
|
-
return events;
|
|
1739
|
-
}
|
|
1740
|
-
const webhooks = raw["webhooks"];
|
|
1741
|
-
if (webhooks !== void 0) {
|
|
1742
|
-
if (!Array.isArray(webhooks)) problems.add(keyPath(path, "webhooks"), wrongType(webhooks, "a list"), "List the webhooks.");
|
|
1743
|
-
else {
|
|
1744
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1745
|
-
webhooks.forEach((item, index) => {
|
|
1746
|
-
const webhook = readWebhook(itemPath(keyPath(path, "webhooks"), index), item, problems);
|
|
1747
|
-
if (webhook === null) return;
|
|
1748
|
-
if (seen.has(webhook.name)) {
|
|
1749
|
-
problems.add(itemPath(keyPath(path, "webhooks"), index), `repeats the name ${quote(webhook.name)}`, "Give each webhook a name of its own.");
|
|
1750
|
-
return;
|
|
1751
|
-
}
|
|
1752
|
-
seen.add(webhook.name);
|
|
1753
|
-
events.webhooks.push(webhook);
|
|
1754
|
-
});
|
|
1755
|
-
}
|
|
1756
|
-
}
|
|
1757
|
-
const indexNow = raw["indexNow"];
|
|
1758
|
-
if (indexNow !== void 0) {
|
|
1759
|
-
const indexPath = keyPath(path, "indexNow");
|
|
1760
|
-
if (!isRecord$2(indexNow)) problems.add(indexPath, wrongType(indexNow, "an object"), "Declare { key: \"INDEXNOW_KEY\" }.");
|
|
1761
|
-
else {
|
|
1762
|
-
const key = readBindingName(keyPath(indexPath, "key"), indexNow["key"], problems);
|
|
1763
|
-
if (siteUrl === null) problems.add(indexPath, "needs the URL of the site", "Set url on the site: IndexNow is told the public URLs of the entries.");
|
|
1764
|
-
else if (key !== null) events.indexNow = { key };
|
|
1765
|
-
}
|
|
1766
|
-
}
|
|
1767
|
-
return events;
|
|
1768
|
-
}
|
|
1769
|
-
//#endregion
|
|
1770
1821
|
//#region src/config/site-key.ts
|
|
1771
1822
|
/**
|
|
1772
1823
|
* The key of a site, which is the id of its row in the database: lowercase letters and digits
|
|
@@ -1816,14 +1867,14 @@ function readLocales(path, raw, problems) {
|
|
|
1816
1867
|
}
|
|
1817
1868
|
locales.push(tag);
|
|
1818
1869
|
if (!LOCALE_TAG.test(tag)) {
|
|
1819
|
-
problems.add(tagPath, `${quote(tag)} is not a language tag`, LOCALE_FIX);
|
|
1870
|
+
problems.add(tagPath, `${quote$1(tag)} is not a language tag`, LOCALE_FIX);
|
|
1820
1871
|
ok = false;
|
|
1821
1872
|
continue;
|
|
1822
1873
|
}
|
|
1823
1874
|
const first = firstAt.get(tag);
|
|
1824
1875
|
if (first === void 0) firstAt.set(tag, index);
|
|
1825
1876
|
else {
|
|
1826
|
-
problems.add(tagPath, `${quote(tag)} is already listed at ${itemPath(path, first)}`, "List each locale once.");
|
|
1877
|
+
problems.add(tagPath, `${quote$1(tag)} is already listed at ${itemPath(path, first)}`, "List each locale once.");
|
|
1827
1878
|
ok = false;
|
|
1828
1879
|
}
|
|
1829
1880
|
}
|
|
@@ -1837,13 +1888,13 @@ function readDefaultLocale(path, raw, locales, problems) {
|
|
|
1837
1888
|
problems.add(path, wrongType(raw, "one of the locales"), "Set it to the main language of the site, one of its locales.");
|
|
1838
1889
|
return "";
|
|
1839
1890
|
}
|
|
1840
|
-
if (locales.length > 0 && !locales.includes(raw)) problems.add(path, `is ${quote(raw)}, which is not one of the locales (${locales.map(quote).join(", ")})`, `Set it to one of ${locales.map(quote).join(", ")}, or add ${quote(raw)} to locales.`);
|
|
1891
|
+
if (locales.length > 0 && !locales.includes(raw)) problems.add(path, `is ${quote$1(raw)}, which is not one of the locales (${locales.map(quote$1).join(", ")})`, `Set it to one of ${locales.map(quote$1).join(", ")}, or add ${quote$1(raw)} to locales.`);
|
|
1841
1892
|
return raw;
|
|
1842
1893
|
}
|
|
1843
1894
|
const COLLECTIONS_FIX = "Declare at least one: collections: { blog: defineCollection({ schema, routes }) }.";
|
|
1844
1895
|
function readCollections(path, raw, site, problems) {
|
|
1845
1896
|
const collections = /* @__PURE__ */ new Map();
|
|
1846
|
-
if (!isRecord$
|
|
1897
|
+
if (!isRecord$4(raw)) {
|
|
1847
1898
|
problems.add(path, wrongType(raw, "an object that maps each collection name to its collection"), COLLECTIONS_FIX);
|
|
1848
1899
|
return collections;
|
|
1849
1900
|
}
|
|
@@ -1858,8 +1909,8 @@ function readCollections(path, raw, site, problems) {
|
|
|
1858
1909
|
/** Checks one site; `null` when it is no object, which is a problem already. */
|
|
1859
1910
|
function readSite(key, raw, problems) {
|
|
1860
1911
|
const path = keyPath("sites", key);
|
|
1861
|
-
if (!isSiteKey(key)) problems.add(path, `${quote(key)} is not a valid site key`, SITE_KEY_FIX);
|
|
1862
|
-
if (!isRecord$
|
|
1912
|
+
if (!isSiteKey(key)) problems.add(path, `${quote$1(key)} is not a valid site key`, SITE_KEY_FIX);
|
|
1913
|
+
if (!isRecord$4(raw)) {
|
|
1863
1914
|
problems.add(path, wrongType(raw, "a site"), "Declare it as { locales: [\"en\"], defaultLocale: \"en\", collections: { … } }.");
|
|
1864
1915
|
return null;
|
|
1865
1916
|
}
|
|
@@ -1881,12 +1932,13 @@ function readSite(key, raw, problems) {
|
|
|
1881
1932
|
collections,
|
|
1882
1933
|
url,
|
|
1883
1934
|
cors: readCors(keyPath(path, "cors"), raw["cors"], problems),
|
|
1884
|
-
events: readEvents(keyPath(path, "events"), raw["events"], url, problems)
|
|
1935
|
+
events: readEvents(keyPath(path, "events"), raw["events"], url, problems),
|
|
1936
|
+
audit: readAudit(keyPath(path, "audit"), raw["audit"], problems)
|
|
1885
1937
|
};
|
|
1886
1938
|
}
|
|
1887
1939
|
function readSites(raw, problems) {
|
|
1888
1940
|
const sites = /* @__PURE__ */ new Map();
|
|
1889
|
-
if (!isRecord$
|
|
1941
|
+
if (!isRecord$4(raw)) {
|
|
1890
1942
|
problems.add("sites", wrongType(raw, "an object that maps the site key to the site"), SITE_FIX);
|
|
1891
1943
|
return sites;
|
|
1892
1944
|
}
|
|
@@ -1901,7 +1953,7 @@ function readSites(raw, problems) {
|
|
|
1901
1953
|
/** `auth` of the config: absent, or an object whose `dynamicClientRegistration` is a boolean. */
|
|
1902
1954
|
function readAuth(raw, problems) {
|
|
1903
1955
|
if (raw === void 0) return { dynamicClientRegistration: false };
|
|
1904
|
-
if (!isRecord$
|
|
1956
|
+
if (!isRecord$4(raw)) {
|
|
1905
1957
|
problems.add("auth", wrongType(raw, "an object"), "Write auth: { dynamicClientRegistration: true }, or leave auth out.");
|
|
1906
1958
|
return { dynamicClientRegistration: false };
|
|
1907
1959
|
}
|
|
@@ -1943,7 +1995,7 @@ function resolveConfig(config) {
|
|
|
1943
1995
|
const problems = new Problems();
|
|
1944
1996
|
let sites = /* @__PURE__ */ new Map();
|
|
1945
1997
|
let auth = { dynamicClientRegistration: false };
|
|
1946
|
-
if (isRecord$
|
|
1998
|
+
if (isRecord$4(config)) {
|
|
1947
1999
|
sites = readSites(config["sites"], problems);
|
|
1948
2000
|
auth = readAuth(config["auth"], problems);
|
|
1949
2001
|
} else problems.add("config", wrongType(config, "an object"), "Export the result of defineConfig({ sites: { … } }) from hollware.config.ts.");
|
|
@@ -1954,6 +2006,69 @@ function resolveConfig(config) {
|
|
|
1954
2006
|
};
|
|
1955
2007
|
}
|
|
1956
2008
|
//#endregion
|
|
2009
|
+
//#region src/audit/index.ts
|
|
2010
|
+
/**
|
|
2011
|
+
* The actions of the activity log (ADR-037). A line names one of these, and nothing else: a new
|
|
2012
|
+
* operation or route that changes something is not logged until it is named here.
|
|
2013
|
+
*/
|
|
2014
|
+
/** Every operation that is not a read: each call of one is a line, whatever its outcome. */
|
|
2015
|
+
const OPERATION_AUDIT_ACTIONS = [
|
|
2016
|
+
"create_draft",
|
|
2017
|
+
"update_draft",
|
|
2018
|
+
"publish",
|
|
2019
|
+
"unpublish",
|
|
2020
|
+
"delete_entry",
|
|
2021
|
+
"restore_entry",
|
|
2022
|
+
"approve_review",
|
|
2023
|
+
"request_review",
|
|
2024
|
+
"comment_review",
|
|
2025
|
+
"request_changes",
|
|
2026
|
+
"update_site",
|
|
2027
|
+
"set_collection",
|
|
2028
|
+
"create_site",
|
|
2029
|
+
"delete_site",
|
|
2030
|
+
"restore_site",
|
|
2031
|
+
"make_webhook_secret"
|
|
2032
|
+
];
|
|
2033
|
+
/** What people do outside the operations: their tokens, members, passkeys, sessions and apps. */
|
|
2034
|
+
const IDENTITY_AUDIT_ACTIONS = [
|
|
2035
|
+
"token.create",
|
|
2036
|
+
"preview.create",
|
|
2037
|
+
"token.revoke",
|
|
2038
|
+
"member.role",
|
|
2039
|
+
"member.remove",
|
|
2040
|
+
"invitation.create",
|
|
2041
|
+
"invitation.accept",
|
|
2042
|
+
"session.sign_in",
|
|
2043
|
+
"session.sign_out",
|
|
2044
|
+
"session.end_all",
|
|
2045
|
+
"passkey.remove",
|
|
2046
|
+
"app.allow",
|
|
2047
|
+
"app.revoke",
|
|
2048
|
+
"app.refresh_reused",
|
|
2049
|
+
"schedule.publish"
|
|
2050
|
+
];
|
|
2051
|
+
const AUDIT_ACTIONS = [...OPERATION_AUDIT_ACTIONS, ...IDENTITY_AUDIT_ACTIONS];
|
|
2052
|
+
/** `denied`: a scope or a role was missing; `failed`: any other error the caller got. */
|
|
2053
|
+
const AUDIT_OUTCOMES = [
|
|
2054
|
+
"ok",
|
|
2055
|
+
"denied",
|
|
2056
|
+
"failed"
|
|
2057
|
+
];
|
|
2058
|
+
/**
|
|
2059
|
+
* Who acted: a person in the admin, a personal token, an app a person allowed through OAuth, or
|
|
2060
|
+
* the deployment itself (its cron).
|
|
2061
|
+
*/
|
|
2062
|
+
const AUDIT_ACTOR_KINDS = [
|
|
2063
|
+
"user",
|
|
2064
|
+
"token",
|
|
2065
|
+
"oauth_client",
|
|
2066
|
+
"system"
|
|
2067
|
+
];
|
|
2068
|
+
function isAuditAction(value) {
|
|
2069
|
+
return AUDIT_ACTIONS.includes(value);
|
|
2070
|
+
}
|
|
2071
|
+
//#endregion
|
|
1957
2072
|
//#region src/structure/definition.ts
|
|
1958
2073
|
/**
|
|
1959
2074
|
* The definition of a site as data (ADR-033): what D1 stores for it and what the operations that
|
|
@@ -2003,6 +2118,13 @@ const siteDefinitionData = z.strictObject({
|
|
|
2003
2118
|
webhooks: [],
|
|
2004
2119
|
indexNow: null
|
|
2005
2120
|
}),
|
|
2121
|
+
audit: z.strictObject({
|
|
2122
|
+
ipAddresses: z.enum(["off", "hashed"]).default("off").describe("Whether the activity log keeps the address of each request, as a hash keyed by the site, or nothing of it."),
|
|
2123
|
+
retentionDays: z.number().int().min(30).max(AUDIT_RETENTION_MAX_DAYS).nullable().default(null).describe("The days each line of the activity log is kept; null keeps it as long as the site.")
|
|
2124
|
+
}).default({
|
|
2125
|
+
ipAddresses: "off",
|
|
2126
|
+
retentionDays: null
|
|
2127
|
+
}).describe("What the activity log of the site keeps (ADR-037). Only an owner should change it."),
|
|
2006
2128
|
collections: z.record(z.string(), collectionDefinitionData)
|
|
2007
2129
|
});
|
|
2008
2130
|
//#endregion
|
|
@@ -2025,19 +2147,19 @@ const SUMMARIES = /* @__PURE__ */ new Set([
|
|
|
2025
2147
|
function segmentsOf$1(location) {
|
|
2026
2148
|
return location.replace(/^#\/?/, "").split("/").filter((segment) => segment !== "").map((segment) => decodeURIComponent(segment).replaceAll("~1", "/").replaceAll("~0", "~"));
|
|
2027
2149
|
}
|
|
2028
|
-
function isRecord$
|
|
2150
|
+
function isRecord$2(value) {
|
|
2029
2151
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2030
2152
|
}
|
|
2031
2153
|
/** The schema of the object at `segments`, to tell a declared key from an unknown one. */
|
|
2032
2154
|
function declaredKeys(schema, segments) {
|
|
2033
2155
|
let node = schema;
|
|
2034
2156
|
for (const segment of segments) {
|
|
2035
|
-
if (!isRecord$
|
|
2157
|
+
if (!isRecord$2(node)) return /* @__PURE__ */ new Set();
|
|
2036
2158
|
const properties = node["properties"];
|
|
2037
|
-
node = isRecord$
|
|
2159
|
+
node = isRecord$2(properties) && Object.hasOwn(properties, segment) ? properties[segment] : node["items"];
|
|
2038
2160
|
}
|
|
2039
|
-
const properties = isRecord$
|
|
2040
|
-
return new Set(isRecord$
|
|
2161
|
+
const properties = isRecord$2(node) ? node["properties"] : void 0;
|
|
2162
|
+
return new Set(isRecord$2(properties) ? Object.keys(properties) : []);
|
|
2041
2163
|
}
|
|
2042
2164
|
/** The issues of a validation: one for each part that failed, at the path of the value. */
|
|
2043
2165
|
function issuesOf(schema, errors) {
|
|
@@ -2149,7 +2271,8 @@ function compileSite(path, site, problems) {
|
|
|
2149
2271
|
...webhook.secret === void 0 ? {} : { secret: webhook.secret }
|
|
2150
2272
|
})),
|
|
2151
2273
|
...site.events.indexNow === null ? {} : { indexNow: site.events.indexNow }
|
|
2152
|
-
}
|
|
2274
|
+
},
|
|
2275
|
+
audit: site.audit
|
|
2153
2276
|
};
|
|
2154
2277
|
}
|
|
2155
2278
|
/** The definitions of the sites, read from D1 as JSON, checked and resolved. */
|
|
@@ -2223,6 +2346,10 @@ function definitionsFromConfig(config) {
|
|
|
2223
2346
|
webhooks: (site.events?.webhooks ?? []).map((webhook) => ({ ...webhook })),
|
|
2224
2347
|
indexNow: site.events?.indexNow ?? null
|
|
2225
2348
|
},
|
|
2349
|
+
...site.audit === void 0 ? {} : { audit: {
|
|
2350
|
+
ipAddresses: site.audit.ipAddresses ?? "off",
|
|
2351
|
+
retentionDays: site.audit.retentionDays ?? null
|
|
2352
|
+
} },
|
|
2226
2353
|
collections: Object.fromEntries(Object.entries(site.collections).map(([name, collection]) => [name, collectionOf(keyPath(keyPath(path, "collections"), name), collection, problems)]))
|
|
2227
2354
|
};
|
|
2228
2355
|
}
|
|
@@ -2370,6 +2497,10 @@ function sitePreset(preset, locales, defaultLocale) {
|
|
|
2370
2497
|
webhooks: [],
|
|
2371
2498
|
indexNow: null
|
|
2372
2499
|
},
|
|
2500
|
+
audit: {
|
|
2501
|
+
ipAddresses: "off",
|
|
2502
|
+
retentionDays: null
|
|
2503
|
+
},
|
|
2373
2504
|
collections
|
|
2374
2505
|
};
|
|
2375
2506
|
}
|
|
@@ -2384,6 +2515,7 @@ const ERROR_CODES = [
|
|
|
2384
2515
|
"invalid_input",
|
|
2385
2516
|
"unknown_collection",
|
|
2386
2517
|
"unknown_locale",
|
|
2518
|
+
"invalid_preview",
|
|
2387
2519
|
"insufficient_scope",
|
|
2388
2520
|
"output_too_large",
|
|
2389
2521
|
"timeout",
|
|
@@ -2413,6 +2545,7 @@ var HollwareError = class extends Error {
|
|
|
2413
2545
|
const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
2414
2546
|
/** The kinds of ids and their prefixes. A new kind adds its prefix here. */
|
|
2415
2547
|
const ID_PREFIXES = {
|
|
2548
|
+
auditLine: "aud",
|
|
2416
2549
|
challenge: "chl",
|
|
2417
2550
|
delivery: "dlv",
|
|
2418
2551
|
embedding: "emb",
|
|
@@ -3515,7 +3648,7 @@ function utf8ByteLength(text) {
|
|
|
3515
3648
|
}
|
|
3516
3649
|
return bytes;
|
|
3517
3650
|
}
|
|
3518
|
-
function isRecord(value) {
|
|
3651
|
+
function isRecord$1(value) {
|
|
3519
3652
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3520
3653
|
}
|
|
3521
3654
|
function describe(value) {
|
|
@@ -3548,8 +3681,8 @@ function linesBeforeBody(source, body) {
|
|
|
3548
3681
|
function yamlViolation(error, source) {
|
|
3549
3682
|
const reason = Reflect.get(error, "reason");
|
|
3550
3683
|
const mark = Reflect.get(error, "mark");
|
|
3551
|
-
const markLine = isRecord(mark) ? mark.line : void 0;
|
|
3552
|
-
const markColumn = isRecord(mark) ? mark.column : void 0;
|
|
3684
|
+
const markLine = isRecord$1(mark) ? mark.line : void 0;
|
|
3685
|
+
const markColumn = isRecord$1(mark) ? mark.column : void 0;
|
|
3553
3686
|
const firstLine = source.startsWith("---\r\n") ? 1 : 2;
|
|
3554
3687
|
return yamlSyntax(typeof reason === "string" ? reason : error.message.split("\n")[0] ?? "it does not parse", typeof markLine === "number" ? markLine + firstLine : void 0, typeof markColumn === "number" ? markColumn + 1 : void 0);
|
|
3555
3688
|
}
|
|
@@ -3575,7 +3708,7 @@ async function run(markdown, options) {
|
|
|
3575
3708
|
};
|
|
3576
3709
|
}
|
|
3577
3710
|
const frontmatter = split.data;
|
|
3578
|
-
if (!isRecord(frontmatter)) return {
|
|
3711
|
+
if (!isRecord$1(frontmatter)) return {
|
|
3579
3712
|
ok: false,
|
|
3580
3713
|
violations: [frontmatterNotMapping(describe(frontmatter))]
|
|
3581
3714
|
};
|
|
@@ -3682,7 +3815,20 @@ const URL_ATTRIBUTES = /* @__PURE__ */ new Set([
|
|
|
3682
3815
|
function isProps(value) {
|
|
3683
3816
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3684
3817
|
}
|
|
3685
|
-
|
|
3818
|
+
/** The value a path such as `frontmatter.url` names in the data, or `undefined`. */
|
|
3819
|
+
function resolvePath(data, path) {
|
|
3820
|
+
let value = data;
|
|
3821
|
+
for (const part of path.split(".")) {
|
|
3822
|
+
if (typeof value !== "object" || value === null || !Object.hasOwn(value, part)) return void 0;
|
|
3823
|
+
value = Reflect.get(value, part);
|
|
3824
|
+
}
|
|
3825
|
+
return value;
|
|
3826
|
+
}
|
|
3827
|
+
/** The name of an attribute without the `:` or `v-bind:` of a binding. */
|
|
3828
|
+
function unbound(key) {
|
|
3829
|
+
return key.startsWith("v-bind:") ? key.slice(7) : key.replace(/^:/, "");
|
|
3830
|
+
}
|
|
3831
|
+
function sanitizeProps(raw, data) {
|
|
3686
3832
|
const props = {};
|
|
3687
3833
|
if (!isProps(raw)) return props;
|
|
3688
3834
|
for (const key of Object.keys(raw)) {
|
|
@@ -3691,8 +3837,15 @@ function sanitizeProps(raw) {
|
|
|
3691
3837
|
if (isForbiddenAttribute(name)) continue;
|
|
3692
3838
|
const value = raw[key];
|
|
3693
3839
|
if (URL_ATTRIBUTES.has(name)) {
|
|
3840
|
+
const kind = name === "src" ? "image" : "link";
|
|
3694
3841
|
const url = urlValue(key, value);
|
|
3695
|
-
if (url === void 0
|
|
3842
|
+
if (url === void 0) continue;
|
|
3843
|
+
if (isBinding(key) && url === value) {
|
|
3844
|
+
const bound = resolvePath(data, url);
|
|
3845
|
+
if (typeof bound === "string" && isSafeUrl(bound, kind)) setOwn(props, unbound(key), bound.replaceAll("&", "&"));
|
|
3846
|
+
continue;
|
|
3847
|
+
}
|
|
3848
|
+
if (!isSafeUrl(url, kind)) continue;
|
|
3696
3849
|
}
|
|
3697
3850
|
setOwn(props, key, typeof value === "string" && !isBinding(key) ? value.replaceAll("&", "&") : value);
|
|
3698
3851
|
}
|
|
@@ -3704,19 +3857,19 @@ function marker(name) {
|
|
|
3704
3857
|
name: TAG_NAME.test(name) ? shorten(name) : "?"
|
|
3705
3858
|
}];
|
|
3706
3859
|
}
|
|
3707
|
-
function sanitizeElement(node, depth) {
|
|
3860
|
+
function sanitizeElement(node, depth, data) {
|
|
3708
3861
|
const tag = node[0];
|
|
3709
3862
|
if (typeof tag !== "string") return void 0;
|
|
3710
3863
|
if (!NATIVE_TAGS.has(tag) && tag !== "hollware-marker") {
|
|
3711
3864
|
if (!TAG_NAME.test(tag) || DANGEROUS_TAGS.has(tag.toLowerCase())) return marker(tag);
|
|
3712
3865
|
}
|
|
3713
|
-
const element = [tag, sanitizeProps(node[1])];
|
|
3866
|
+
const element = [tag, sanitizeProps(node[1], data)];
|
|
3714
3867
|
if (depth >= 100) return element;
|
|
3715
3868
|
for (let at = 2; at < node.length; at++) {
|
|
3716
3869
|
const child = node[at];
|
|
3717
3870
|
if (typeof child === "string") element.push(child);
|
|
3718
3871
|
else if (Array.isArray(child)) {
|
|
3719
|
-
const clean = sanitizeElement(child, depth + 1);
|
|
3872
|
+
const clean = sanitizeElement(child, depth + 1, data);
|
|
3720
3873
|
if (clean !== void 0) element.push(clean);
|
|
3721
3874
|
}
|
|
3722
3875
|
}
|
|
@@ -3725,10 +3878,14 @@ function sanitizeElement(node, depth) {
|
|
|
3725
3878
|
/** Copies a document through the rules of this file. */
|
|
3726
3879
|
function sanitizeDocument(document) {
|
|
3727
3880
|
const nodes = [];
|
|
3881
|
+
const data = {
|
|
3882
|
+
frontmatter: document.frontmatter,
|
|
3883
|
+
meta: document.meta
|
|
3884
|
+
};
|
|
3728
3885
|
if (Array.isArray(document.nodes)) {
|
|
3729
3886
|
for (const node of document.nodes) if (typeof node === "string") nodes.push(node);
|
|
3730
3887
|
else if (Array.isArray(node)) {
|
|
3731
|
-
const clean = sanitizeElement(node, 1);
|
|
3888
|
+
const clean = sanitizeElement(node, 1, data);
|
|
3732
3889
|
if (clean !== void 0) nodes.push(clean);
|
|
3733
3890
|
}
|
|
3734
3891
|
}
|
|
@@ -3828,6 +3985,7 @@ const entrySummary = z.object({
|
|
|
3828
3985
|
hasUnpublishedChanges: z.boolean().describe("Whether the latest revision differs from the published one."),
|
|
3829
3986
|
publishedAt: z.string().nullable().describe("When the entry was first published, or null."),
|
|
3830
3987
|
scheduledAt: z.string().nullable().describe("When a scheduled entry will be published, or null for one that is not scheduled."),
|
|
3988
|
+
deletedAt: z.string().nullable().describe("When the entry went to the trash, or null for one that is not in the trash."),
|
|
3831
3989
|
updatedAt: z.string()
|
|
3832
3990
|
});
|
|
3833
3991
|
const publication = z.object({
|
|
@@ -3958,12 +4116,13 @@ const describeSite = defineOperation({
|
|
|
3958
4116
|
const listEntries = defineOperation({
|
|
3959
4117
|
name: "list_entries",
|
|
3960
4118
|
title: "List entries",
|
|
3961
|
-
description: "Lists the entries of a collection, newest first, filtered by locale, tag or status. Pass the nextCursor of an answer as cursor to get the next page.",
|
|
4119
|
+
description: "Lists the entries of a collection, newest first, filtered by locale, tag or status. Pass the nextCursor of an answer as cursor to get the next page. Entries in the trash are left out, unless trashed asks for them.",
|
|
3962
4120
|
input: z.strictObject({
|
|
3963
4121
|
collection: collectionName,
|
|
3964
4122
|
locale: locale.optional(),
|
|
3965
4123
|
tag: z.string().min(1).optional(),
|
|
3966
4124
|
status: entryStatus.optional(),
|
|
4125
|
+
trashed: z.boolean().optional().describe("true to list the entries in the trash, last trashed first, instead of the others."),
|
|
3967
4126
|
limit: z.number().int().min(1).max(100).default(20),
|
|
3968
4127
|
cursor: z.string().min(1).optional()
|
|
3969
4128
|
}),
|
|
@@ -4046,7 +4205,7 @@ const getTranslations = defineOperation({
|
|
|
4046
4205
|
const createDraft = defineOperation({
|
|
4047
4206
|
name: "create_draft",
|
|
4048
4207
|
title: "Create a draft",
|
|
4049
|
-
description: "Creates an entry from a Markdown file and saves it as a draft at version 1. Contract errors do not stop the draft: it is saved, and the verdict says what to fix before publishing. It is refused only when it cannot be stored: the file is too large, its frontmatter or syntax does not parse, or the slug or translation is taken.",
|
|
4208
|
+
description: "Creates an entry from a Markdown file and saves it as a draft at version 1. Contract errors do not stop the draft: it is saved, and the verdict says what to fix before publishing. It is refused only when it cannot be stored: the file is too large, its frontmatter or syntax does not parse, or the slug or translation is taken. An entry in the trash keeps its slug and translation: the answer is in_trash with that entry, and restore_entry brings it back.",
|
|
4050
4209
|
input: z.strictObject({
|
|
4051
4210
|
collection: collectionName,
|
|
4052
4211
|
locale,
|
|
@@ -4054,15 +4213,20 @@ const createDraft = defineOperation({
|
|
|
4054
4213
|
markdown
|
|
4055
4214
|
}),
|
|
4056
4215
|
output: z.object({
|
|
4057
|
-
outcome: z.enum([
|
|
4058
|
-
|
|
4216
|
+
outcome: z.enum([
|
|
4217
|
+
"saved",
|
|
4218
|
+
"refused",
|
|
4219
|
+
"in_trash"
|
|
4220
|
+
]),
|
|
4221
|
+
entry: entrySummary.optional().describe("With saved, the new draft; with in_trash, the entry in the trash that holds it."),
|
|
4059
4222
|
verdict: verdict.optional()
|
|
4060
4223
|
}),
|
|
4061
4224
|
scopes: ["content:write"],
|
|
4062
4225
|
effect: "write",
|
|
4063
4226
|
outcomes: {
|
|
4064
4227
|
saved: { isError: false },
|
|
4065
|
-
refused: { isError: true }
|
|
4228
|
+
refused: { isError: true },
|
|
4229
|
+
in_trash: { isError: true }
|
|
4066
4230
|
}
|
|
4067
4231
|
});
|
|
4068
4232
|
const updateDraft = defineOperation({
|
|
@@ -4172,6 +4336,54 @@ const unpublish = defineOperation({
|
|
|
4172
4336
|
not_found: { isError: true }
|
|
4173
4337
|
}
|
|
4174
4338
|
});
|
|
4339
|
+
const deleteEntry = defineOperation({
|
|
4340
|
+
name: "delete_entry",
|
|
4341
|
+
title: "Move an entry to the trash",
|
|
4342
|
+
description: "Moves a draft to the trash, if it is still at the version you read: every operation treats it as gone, its pending review is cancelled, and nothing is deleted. It keeps its slug and translation, and restore_entry brings it back as the draft it was. A published or scheduled entry answers published: unpublish it first.",
|
|
4343
|
+
input: z.strictObject({
|
|
4344
|
+
id: entryId,
|
|
4345
|
+
version
|
|
4346
|
+
}),
|
|
4347
|
+
output: z.object({
|
|
4348
|
+
outcome: z.enum([
|
|
4349
|
+
"trashed",
|
|
4350
|
+
"published",
|
|
4351
|
+
"conflict",
|
|
4352
|
+
"not_found"
|
|
4353
|
+
]),
|
|
4354
|
+
entry: entrySummary.optional(),
|
|
4355
|
+
conflict: conflict.optional()
|
|
4356
|
+
}),
|
|
4357
|
+
scopes: ["content:write"],
|
|
4358
|
+
effect: "destructive",
|
|
4359
|
+
outcomes: {
|
|
4360
|
+
trashed: { isError: false },
|
|
4361
|
+
published: { isError: true },
|
|
4362
|
+
conflict: { isError: true },
|
|
4363
|
+
not_found: { isError: true }
|
|
4364
|
+
}
|
|
4365
|
+
});
|
|
4366
|
+
const restoreEntry = defineOperation({
|
|
4367
|
+
name: "restore_entry",
|
|
4368
|
+
title: "Bring an entry back from the trash",
|
|
4369
|
+
description: "Brings an entry back from the trash as the draft it was, with its revisions, at the next version. An entry that is not in the trash answers not_trashed.",
|
|
4370
|
+
input: z.strictObject({ id: entryId }),
|
|
4371
|
+
output: z.object({
|
|
4372
|
+
outcome: z.enum([
|
|
4373
|
+
"restored",
|
|
4374
|
+
"not_trashed",
|
|
4375
|
+
"not_found"
|
|
4376
|
+
]),
|
|
4377
|
+
entry: entrySummary.optional()
|
|
4378
|
+
}),
|
|
4379
|
+
scopes: ["content:write"],
|
|
4380
|
+
effect: "write",
|
|
4381
|
+
outcomes: {
|
|
4382
|
+
restored: { isError: false },
|
|
4383
|
+
not_trashed: { isError: true },
|
|
4384
|
+
not_found: { isError: true }
|
|
4385
|
+
}
|
|
4386
|
+
});
|
|
4175
4387
|
const listReviews = defineOperation({
|
|
4176
4388
|
name: "list_reviews",
|
|
4177
4389
|
title: "List the reviews",
|
|
@@ -4487,6 +4699,7 @@ const OPERATIONS = [
|
|
|
4487
4699
|
contentStats,
|
|
4488
4700
|
createDraft,
|
|
4489
4701
|
createSite,
|
|
4702
|
+
deleteEntry,
|
|
4490
4703
|
deleteSite,
|
|
4491
4704
|
describeSite,
|
|
4492
4705
|
getEntry,
|
|
@@ -4518,6 +4731,7 @@ const OPERATIONS = [
|
|
|
4518
4731
|
publish,
|
|
4519
4732
|
requestChanges,
|
|
4520
4733
|
requestReview,
|
|
4734
|
+
restoreEntry,
|
|
4521
4735
|
restoreSite,
|
|
4522
4736
|
search,
|
|
4523
4737
|
setCollection,
|
|
@@ -4606,13 +4820,13 @@ const TIMESTAMP_HEADER = "hollware-timestamp";
|
|
|
4606
4820
|
const EVENT_ID_HEADER = "hollware-event-id";
|
|
4607
4821
|
/** How far a timestamp may be from the receiver's clock, by default: five minutes. */
|
|
4608
4822
|
const TOLERANCE_SECONDS = 300;
|
|
4609
|
-
const encoder = new TextEncoder();
|
|
4823
|
+
const encoder$1 = new TextEncoder();
|
|
4610
4824
|
async function hmac(secret, text) {
|
|
4611
|
-
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), {
|
|
4825
|
+
const key = await crypto.subtle.importKey("raw", encoder$1.encode(secret), {
|
|
4612
4826
|
name: "HMAC",
|
|
4613
4827
|
hash: "SHA-256"
|
|
4614
4828
|
}, false, ["sign"]);
|
|
4615
|
-
return [...new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(text)))].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
4829
|
+
return [...new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder$1.encode(text)))].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
4616
4830
|
}
|
|
4617
4831
|
/** The value of `hollware-signature` for a body sent at `timestamp` (seconds). */
|
|
4618
4832
|
async function signWebhook(secret, timestamp, body) {
|
|
@@ -4625,9 +4839,20 @@ function sameText(a, b) {
|
|
|
4625
4839
|
for (let index = 0; index < a.length; index++) difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
|
|
4626
4840
|
return difference === 0;
|
|
4627
4841
|
}
|
|
4842
|
+
/** The id of the event a body holds, or `null` when it holds none. */
|
|
4843
|
+
function eventIdOf(body) {
|
|
4844
|
+
try {
|
|
4845
|
+
const value = JSON.parse(body);
|
|
4846
|
+
const id = typeof value === "object" && value !== null ? Reflect.get(value, "id") : null;
|
|
4847
|
+
return typeof id === "string" && id !== "" ? id : null;
|
|
4848
|
+
} catch {
|
|
4849
|
+
return null;
|
|
4850
|
+
}
|
|
4851
|
+
}
|
|
4628
4852
|
/**
|
|
4629
4853
|
* Whether a request is a webhook of Hollware: its signature matches the body and the secret, and
|
|
4630
|
-
* its timestamp is within the tolerance, so a captured request cannot be replayed later
|
|
4854
|
+
* its timestamp is within the tolerance, so a captured request cannot be replayed later; with
|
|
4855
|
+
* `isNew`, also that its event was not received before, so it cannot be replayed sooner either.
|
|
4631
4856
|
*/
|
|
4632
4857
|
async function verifyWebhook(check) {
|
|
4633
4858
|
const signature = check.headers.get(SIGNATURE_HEADER);
|
|
@@ -4635,7 +4860,196 @@ async function verifyWebhook(check) {
|
|
|
4635
4860
|
if (signature === null || !Number.isInteger(timestamp)) return false;
|
|
4636
4861
|
const now = check.now ?? Math.floor(Date.now() / 1e3);
|
|
4637
4862
|
if (Math.abs(now - timestamp) > (check.toleranceSeconds ?? TOLERANCE_SECONDS)) return false;
|
|
4638
|
-
|
|
4863
|
+
if (!sameText(signature, await signWebhook(check.secret, timestamp, check.body))) return false;
|
|
4864
|
+
if (check.isNew === void 0) return true;
|
|
4865
|
+
const id = eventIdOf(check.body);
|
|
4866
|
+
return id !== null && await check.isNew(id);
|
|
4867
|
+
}
|
|
4868
|
+
//#endregion
|
|
4869
|
+
//#region src/preview.ts
|
|
4870
|
+
/**
|
|
4871
|
+
* The preview token (T-103 of docs/security/threat-model.md): a signed, stateless proof that a person of a site may read the
|
|
4872
|
+
* latest revision of one entry, for one hour. A site that reads published content only (the
|
|
4873
|
+
* binding of RPC, the read API) reads that entry's head instead when it presents one.
|
|
4874
|
+
*
|
|
4875
|
+
* hw_prv_<payload>.<signature>
|
|
4876
|
+
*
|
|
4877
|
+
* The payload is base64url of `{ "s": site, "e": entryId, "x": expiresAtSeconds }`, and the
|
|
4878
|
+
* signature the base64url HMAC-SHA256 of the payload text under a key that HKDF-SHA256 derives
|
|
4879
|
+
* from the Worker secret `HOLLWARE_SECRET`, bound to the site. Nothing is stored: a token cannot
|
|
4880
|
+
* be revoked, only wait out its hour, and rotating the secret ends every one.
|
|
4881
|
+
*/
|
|
4882
|
+
const PREVIEW_TOKEN_PREFIX = "hw_prv_";
|
|
4883
|
+
/** The query parameter a site's page takes the token from. */
|
|
4884
|
+
const PREVIEW_QUERY_PARAM = "hollware-preview";
|
|
4885
|
+
/** How long a token lasts, in seconds. */
|
|
4886
|
+
const PREVIEW_TTL_SECONDS = 3600;
|
|
4887
|
+
/** The least characters of `HOLLWARE_SECRET` that Hollware signs anything with. */
|
|
4888
|
+
const PREVIEW_SECRET_MIN_LENGTH = 32;
|
|
4889
|
+
/** Whether a Worker's secret can sign previews: present and long enough. */
|
|
4890
|
+
function canSignPreviews(secret) {
|
|
4891
|
+
return secret !== void 0 && secret.length >= 32;
|
|
4892
|
+
}
|
|
4893
|
+
const encoder = new TextEncoder();
|
|
4894
|
+
function toBase64Url(bytes) {
|
|
4895
|
+
let binary = "";
|
|
4896
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
4897
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
4898
|
+
}
|
|
4899
|
+
function fromBase64Url(text) {
|
|
4900
|
+
if (!/^[A-Za-z0-9_-]*$/.test(text)) return null;
|
|
4901
|
+
try {
|
|
4902
|
+
const binary = atob(text.replaceAll("-", "+").replaceAll("_", "/"));
|
|
4903
|
+
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
4904
|
+
} catch {
|
|
4905
|
+
return null;
|
|
4906
|
+
}
|
|
4907
|
+
}
|
|
4908
|
+
async function previewKey(secret, site) {
|
|
4909
|
+
const base = await crypto.subtle.importKey("raw", encoder.encode(secret), "HKDF", false, ["deriveKey"]);
|
|
4910
|
+
return crypto.subtle.deriveKey({
|
|
4911
|
+
name: "HKDF",
|
|
4912
|
+
hash: "SHA-256",
|
|
4913
|
+
salt: /* @__PURE__ */ new Uint8Array(0),
|
|
4914
|
+
info: encoder.encode(`hollware preview\n${site}`)
|
|
4915
|
+
}, base, {
|
|
4916
|
+
name: "HMAC",
|
|
4917
|
+
hash: "SHA-256",
|
|
4918
|
+
length: 256
|
|
4919
|
+
}, false, ["sign", "verify"]);
|
|
4920
|
+
}
|
|
4921
|
+
/** A token for one entry of one site, valid for `PREVIEW_TTL_SECONDS` from `now`. */
|
|
4922
|
+
async function makePreviewToken(input) {
|
|
4923
|
+
if (!canSignPreviews(input.secret)) throw new Error(`HOLLWARE_SECRET must be at least 32 characters.`);
|
|
4924
|
+
const expires = Math.floor(input.now.getTime() / 1e3) + PREVIEW_TTL_SECONDS;
|
|
4925
|
+
const payload = toBase64Url(encoder.encode(JSON.stringify({
|
|
4926
|
+
s: input.site,
|
|
4927
|
+
e: input.entryId,
|
|
4928
|
+
x: expires
|
|
4929
|
+
})));
|
|
4930
|
+
const mac = await crypto.subtle.sign("HMAC", await previewKey(input.secret, input.site), encoder.encode(payload));
|
|
4931
|
+
return {
|
|
4932
|
+
token: `${PREVIEW_TOKEN_PREFIX}${payload}.${toBase64Url(new Uint8Array(mac))}`,
|
|
4933
|
+
expiresAt: (/* @__PURE__ */ new Date(expires * 1e3)).toISOString()
|
|
4934
|
+
};
|
|
4935
|
+
}
|
|
4936
|
+
function isRecord(value) {
|
|
4937
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4938
|
+
}
|
|
4939
|
+
/**
|
|
4940
|
+
* Checks a token for a site. The signature is verified (in constant time, by `crypto.subtle`)
|
|
4941
|
+
* before the payload is parsed, under the key of the site that is asked: a token made for another
|
|
4942
|
+
* site fails there. The entry the token names is the caller's to compare with the entry it read.
|
|
4943
|
+
*/
|
|
4944
|
+
async function verifyPreviewToken(input) {
|
|
4945
|
+
if (!canSignPreviews(input.secret)) return {
|
|
4946
|
+
ok: false,
|
|
4947
|
+
reason: "no_secret"
|
|
4948
|
+
};
|
|
4949
|
+
const { token } = input;
|
|
4950
|
+
if (!token.startsWith("hw_prv_")) return {
|
|
4951
|
+
ok: false,
|
|
4952
|
+
reason: "malformed"
|
|
4953
|
+
};
|
|
4954
|
+
const parts = token.slice(7).split(".");
|
|
4955
|
+
const [payload, signature] = parts;
|
|
4956
|
+
if (parts.length !== 2 || payload === void 0 || signature === void 0) return {
|
|
4957
|
+
ok: false,
|
|
4958
|
+
reason: "malformed"
|
|
4959
|
+
};
|
|
4960
|
+
const mac = fromBase64Url(signature);
|
|
4961
|
+
const body = fromBase64Url(payload);
|
|
4962
|
+
if (mac === null || body === null) return {
|
|
4963
|
+
ok: false,
|
|
4964
|
+
reason: "malformed"
|
|
4965
|
+
};
|
|
4966
|
+
if (!await crypto.subtle.verify("HMAC", await previewKey(input.secret, input.site), mac, encoder.encode(payload))) return {
|
|
4967
|
+
ok: false,
|
|
4968
|
+
reason: "signature"
|
|
4969
|
+
};
|
|
4970
|
+
let claims;
|
|
4971
|
+
try {
|
|
4972
|
+
claims = JSON.parse(new TextDecoder().decode(body));
|
|
4973
|
+
} catch {
|
|
4974
|
+
return {
|
|
4975
|
+
ok: false,
|
|
4976
|
+
reason: "malformed"
|
|
4977
|
+
};
|
|
4978
|
+
}
|
|
4979
|
+
if (!isRecord(claims) || typeof claims["s"] !== "string" || typeof claims["e"] !== "string" || typeof claims["x"] !== "number") return {
|
|
4980
|
+
ok: false,
|
|
4981
|
+
reason: "malformed"
|
|
4982
|
+
};
|
|
4983
|
+
if (claims["s"] !== input.site) return {
|
|
4984
|
+
ok: false,
|
|
4985
|
+
reason: "site"
|
|
4986
|
+
};
|
|
4987
|
+
if (Math.floor(input.now.getTime() / 1e3) >= claims["x"]) return {
|
|
4988
|
+
ok: false,
|
|
4989
|
+
reason: "expired"
|
|
4990
|
+
};
|
|
4991
|
+
return {
|
|
4992
|
+
ok: true,
|
|
4993
|
+
entryId: claims["e"],
|
|
4994
|
+
expiresAt: (/* @__PURE__ */ new Date(claims["x"] * 1e3)).toISOString()
|
|
4995
|
+
};
|
|
4996
|
+
}
|
|
4997
|
+
//#endregion
|
|
4998
|
+
//#region src/events.ts
|
|
4999
|
+
/**
|
|
5000
|
+
* What Hollware sends to a site's webhooks (ADR-025), signed as `signWebhook` signs it: an event
|
|
5001
|
+
* about an entry that was published or unpublished, or about a change of the site's structure
|
|
5002
|
+
* (ADR-033). Every event carries the cache tags it invalidates (`@hollware/core`'s cache tags), so
|
|
5003
|
+
* a site that caches its pages purges them with no lookup. A receiver checks the signature with
|
|
5004
|
+
* `verifyWebhook`, then reads the body with `parseHollwareEvent`. Fields may be added in a minor
|
|
5005
|
+
* version, and kinds too: a receiver ignores a kind it does not know.
|
|
5006
|
+
*/
|
|
5007
|
+
const base = {
|
|
5008
|
+
/** The id of the event: the same on every retry of its delivery. */
|
|
5009
|
+
id: z.string(),
|
|
5010
|
+
/** The key of the site. */
|
|
5011
|
+
site: z.string(),
|
|
5012
|
+
/** When the change was written, in ISO 8601. */
|
|
5013
|
+
at: z.string(),
|
|
5014
|
+
/** The cache tags the change invalidates. */
|
|
5015
|
+
cacheTags: z.array(z.string())
|
|
5016
|
+
};
|
|
5017
|
+
/** A publish or an unpublish: the entry, and where it lives on the site. */
|
|
5018
|
+
const entryEvent = z.object({
|
|
5019
|
+
...base,
|
|
5020
|
+
kind: z.enum(["entry.published", "entry.unpublished"]),
|
|
5021
|
+
entry: z.object({
|
|
5022
|
+
id: z.string(),
|
|
5023
|
+
collection: z.string(),
|
|
5024
|
+
locale: z.string(),
|
|
5025
|
+
slug: z.string(),
|
|
5026
|
+
translationGroup: z.string().nullable()
|
|
5027
|
+
}),
|
|
5028
|
+
/** The path of the entry on the site, from the route of its collection and locale. */
|
|
5029
|
+
path: z.string().nullable(),
|
|
5030
|
+
/** The URL of the entry, when the site has a `url`. */
|
|
5031
|
+
url: z.string().nullable()
|
|
5032
|
+
});
|
|
5033
|
+
/** A change of the site's structure: no entry, the tags to purge. */
|
|
5034
|
+
const siteEvent = z.object({
|
|
5035
|
+
...base,
|
|
5036
|
+
kind: z.literal("site.changed")
|
|
5037
|
+
});
|
|
5038
|
+
/** Every event a webhook gets. */
|
|
5039
|
+
const hollwareEvent = z.discriminatedUnion("kind", [entryEvent, siteEvent]);
|
|
5040
|
+
/**
|
|
5041
|
+
* The event of a webhook's body, once its signature holds, or `null` for a body that is not one
|
|
5042
|
+
* of the events this version knows (a newer kind, say): a receiver skips it.
|
|
5043
|
+
*/
|
|
5044
|
+
function parseHollwareEvent(body) {
|
|
5045
|
+
let json;
|
|
5046
|
+
try {
|
|
5047
|
+
json = JSON.parse(body);
|
|
5048
|
+
} catch {
|
|
5049
|
+
return null;
|
|
5050
|
+
}
|
|
5051
|
+
const parsed = hollwareEvent.safeParse(json);
|
|
5052
|
+
return parsed.success ? parsed.data : null;
|
|
4639
5053
|
}
|
|
4640
5054
|
//#endregion
|
|
4641
5055
|
//#region src/cache.ts
|
|
@@ -4688,8 +5102,19 @@ function tagsOfChange(entry) {
|
|
|
4688
5102
|
...entry.translationGroup === null ? [] : [groupTag(entry.collection, entry.translationGroup)]
|
|
4689
5103
|
];
|
|
4690
5104
|
}
|
|
5105
|
+
/**
|
|
5106
|
+
* The tags a change of a site's structure invalidates (ADR-033): the pages that show the structure
|
|
5107
|
+
* (`site`, such as a navigation), and every list of its collections in each locale and in every
|
|
5108
|
+
* locale, as a list may change with a schema, a route or a collection that goes. `collections` and
|
|
5109
|
+
* `locales` are those of the definition before and after the change, together.
|
|
5110
|
+
*/
|
|
5111
|
+
function tagsOfSiteChange(collections, locales) {
|
|
5112
|
+
const tags = /* @__PURE__ */ new Set([SITE_TAG]);
|
|
5113
|
+
for (const collection of collections) for (const locale of [...locales, "*"]) tags.add(listTag(collection, locale));
|
|
5114
|
+
return [...tags];
|
|
5115
|
+
}
|
|
4691
5116
|
//#endregion
|
|
4692
5117
|
//#region src/index.ts
|
|
4693
5118
|
const name = "@hollware/core";
|
|
4694
5119
|
//#endregion
|
|
4695
|
-
export { BUILT_IN_CONTRACT_IDS, CATALOG, CHUNKER_VERSION, CHUNK_LIMITS, COMARK_VERSION, ConfigError, DEFAULT_MAX_MARKDOWN_BYTES, ERROR_CODES, EVENT_ID_HEADER, EVERY_LOCALE, HollwareError, ID_PREFIXES, MAX_MARKDOWN_BYTES_CEILING, OPERATIONS, PERSONAL_TOKEN_PREFIX, PERSONAL_TOKEN_TTL_DAYS, PIPELINE_VERSION, PRESETS, READ_TOKEN_PREFIX, REFUSAL_CONTRACT_IDS, SCOPES, SIGNATURE_HEADER, SITE_KEY_MAX_LENGTH, SITE_KEY_PATTERN, SITE_TAG, TIMESTAMP_HEADER, approveReview, canonicalJson, catalogRule, chunksOf, collectionDefinitionData, collectionPreset, commentReview, compileSites, componentDefinitionData, contentStats, contracts, createDraft, defineCollection, defineComponent, defineConfig, defineContract, defineOperation, definitionsFromConfig, describeBuiltInContracts, describeCatalog, describeContract, describeResolvedSite, describeSite, describedSchemaOf, entryTag, generatePersonalToken, generateReadToken, getEntry, getTranslations, groupTag, hashPersonalToken, isBuiltInContract, isId, isPersonalToken, isReadToken, isScope, isSiteKey, listEntries, listReviews, listTag, name, newId, processMarkdown, publish, renderHtml, requestChanges, requestReview, resolveConfig, routesFor, ruleDefinition, runContracts, schemas_exports as schemas, search, signWebhook, siteDefinitionData, sitePreset, sizeLimitViolation, tagsOfChange, tagsOfEntries, ulid, uniqueSlugViolation, uniqueTranslationViolation, unpublish, updateDraft, validate, verifyWebhook, webhookDefinition };
|
|
5120
|
+
export { AUDIT_ACTIONS, AUDIT_ACTOR_KINDS, AUDIT_OUTCOMES, AUDIT_RETENTION_MAX_DAYS, AUDIT_RETENTION_MIN_DAYS, BUILT_IN_CONTRACT_IDS, CATALOG, CHUNKER_VERSION, CHUNK_LIMITS, COMARK_VERSION, ConfigError, DEFAULT_AUDIT, DEFAULT_MAX_MARKDOWN_BYTES, ERROR_CODES, EVENT_ID_HEADER, EVERY_LOCALE, HollwareError, IDENTITY_AUDIT_ACTIONS, ID_PREFIXES, INDEXNOW_KEY, MAX_MARKDOWN_BYTES_CEILING, OPERATIONS, OPERATION_AUDIT_ACTIONS, PERSONAL_TOKEN_PREFIX, PERSONAL_TOKEN_TTL_DAYS, PIPELINE_VERSION, PRESETS, PREVIEW_QUERY_PARAM, PREVIEW_SECRET_MIN_LENGTH, PREVIEW_TOKEN_PREFIX, PREVIEW_TTL_SECONDS, READ_TOKEN_PREFIX, REFUSAL_CONTRACT_IDS, SCOPES, SIGNATURE_HEADER, SITE_KEY_MAX_LENGTH, SITE_KEY_PATTERN, SITE_TAG, TIMESTAMP_HEADER, approveReview, canSignPreviews, canonicalJson, catalogRule, chunksOf, collectionDefinitionData, collectionPreset, commentReview, compileSites, componentDefinitionData, contentStats, contracts, createDraft, defineCollection, defineComponent, defineConfig, defineContract, defineOperation, definitionsFromConfig, deleteEntry, describeBuiltInContracts, describeCatalog, describeContract, describeResolvedSite, describeSite, describedSchemaOf, entryEvent, entryTag, generatePersonalToken, generateReadToken, getEntry, getTranslations, groupTag, hashPersonalToken, hollwareEvent, isAuditAction, isBuiltInContract, isId, isPersonalToken, isReadToken, isScope, isSiteKey, listEntries, listReviews, listTag, makePreviewToken, name, newId, parseHollwareEvent, processMarkdown, publish, renderHtml, requestChanges, requestReview, resolveConfig, restoreEntry, routesFor, ruleDefinition, runContracts, schemas_exports as schemas, search, signWebhook, siteDefinitionData, siteEvent, sitePreset, sizeLimitViolation, tagsOfChange, tagsOfEntries, tagsOfSiteChange, ulid, uniqueSlugViolation, uniqueTranslationViolation, unpublish, updateDraft, validate, verifyPreviewToken, verifyWebhook, webhookDefinition };
|