@hollware/core 0.1.0 → 0.2.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 +75 -0
- package/dist/index.mjs +152 -9
- package/package.json +2 -2
package/dist/index.d.mts
CHANGED
|
@@ -288,6 +288,21 @@ interface SiteDefinition {
|
|
|
288
288
|
readonly collections: Readonly<Record<string, CollectionDefinition>>;
|
|
289
289
|
/** Guidance for whoever writes anywhere in the site, in Markdown. */
|
|
290
290
|
readonly styleGuide?: string;
|
|
291
|
+
/** The public origin of the site, such as `https://example.com`: entries' URLs start with it. */
|
|
292
|
+
readonly url?: string;
|
|
293
|
+
/** Who hears about publications (ADR-025). */
|
|
294
|
+
readonly events?: {
|
|
295
|
+
/** Each gets every event, signed with HMAC-SHA256 by the Worker secret named in `secret`. */
|
|
296
|
+
readonly webhooks?: readonly {
|
|
297
|
+
readonly name: string;
|
|
298
|
+
readonly url: string;
|
|
299
|
+
readonly secret: string;
|
|
300
|
+
}[];
|
|
301
|
+
/** Tells IndexNow the URLs that changed; `key` names the Worker secret with the key. */
|
|
302
|
+
readonly indexNow?: {
|
|
303
|
+
readonly key: string;
|
|
304
|
+
};
|
|
305
|
+
};
|
|
291
306
|
}
|
|
292
307
|
/** What `hollware.config.ts` exports. */
|
|
293
308
|
interface HollwareConfig {
|
|
@@ -318,6 +333,22 @@ interface ResolvedSite {
|
|
|
318
333
|
readonly defaultLocale: string;
|
|
319
334
|
readonly styleGuide: string | null;
|
|
320
335
|
readonly collections: ReadonlyMap<string, ResolvedCollection>;
|
|
336
|
+
/** The public origin of the site, or `null`. */
|
|
337
|
+
readonly url: string | null;
|
|
338
|
+
readonly events: ResolvedEvents;
|
|
339
|
+
}
|
|
340
|
+
/** A webhook of a site; `secret` names the Worker secret that signs its deliveries. */
|
|
341
|
+
interface ResolvedWebhook {
|
|
342
|
+
readonly name: string;
|
|
343
|
+
readonly url: string;
|
|
344
|
+
readonly secret: string;
|
|
345
|
+
}
|
|
346
|
+
/** Who hears about the publications of a site. */
|
|
347
|
+
interface ResolvedEvents {
|
|
348
|
+
readonly webhooks: readonly ResolvedWebhook[];
|
|
349
|
+
readonly indexNow: {
|
|
350
|
+
readonly key: string;
|
|
351
|
+
} | null;
|
|
321
352
|
}
|
|
322
353
|
/** The config once resolved, at the start of the Worker. v0.1 holds exactly one site. */
|
|
323
354
|
interface ResolvedConfig {
|
|
@@ -960,10 +991,13 @@ export declare class HollwareError extends Error {
|
|
|
960
991
|
*/
|
|
961
992
|
/** The kinds of ids and their prefixes. A new kind adds its prefix here. */
|
|
962
993
|
export declare const ID_PREFIXES: {
|
|
994
|
+
readonly delivery: 'dlv';
|
|
963
995
|
readonly entry: 'ent';
|
|
996
|
+
readonly event: 'evt';
|
|
964
997
|
readonly publication: 'pub';
|
|
965
998
|
readonly revision: 'rev';
|
|
966
999
|
readonly revisionTag: 'tag';
|
|
1000
|
+
readonly schedule: 'sch';
|
|
967
1001
|
readonly token: 'tok';
|
|
968
1002
|
readonly user: 'usr';
|
|
969
1003
|
};
|
|
@@ -1450,6 +1484,11 @@ export declare const publish: OperationSpec<"publish", z.ZodObject<{
|
|
|
1450
1484
|
contract: z.ZodString;
|
|
1451
1485
|
path: z.ZodString;
|
|
1452
1486
|
}, z.core.$strip>>>;
|
|
1487
|
+
group: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
1488
|
+
id: z.ZodString;
|
|
1489
|
+
version: z.ZodNumber;
|
|
1490
|
+
}, z.core.$strict>>>;
|
|
1491
|
+
publishAt: z.ZodOptional<z.ZodISODateTime>;
|
|
1453
1492
|
}, z.core.$strict>, z.ZodObject<{
|
|
1454
1493
|
outcome: z.ZodEnum<{
|
|
1455
1494
|
blocked: "blocked";
|
|
@@ -1457,6 +1496,7 @@ export declare const publish: OperationSpec<"publish", z.ZodObject<{
|
|
|
1457
1496
|
needs_acknowledgement: "needs_acknowledgement";
|
|
1458
1497
|
not_found: "not_found";
|
|
1459
1498
|
published: "published";
|
|
1499
|
+
scheduled: "scheduled";
|
|
1460
1500
|
}>;
|
|
1461
1501
|
entry: z.ZodOptional<z.ZodObject<{
|
|
1462
1502
|
id: z.ZodString;
|
|
@@ -1510,6 +1550,7 @@ export declare const publish: OperationSpec<"publish", z.ZodObject<{
|
|
|
1510
1550
|
currentVersion: z.ZodNumber;
|
|
1511
1551
|
headRevision: z.ZodString;
|
|
1512
1552
|
}, z.core.$strip>>;
|
|
1553
|
+
failedEntry: z.ZodOptional<z.ZodString>;
|
|
1513
1554
|
}, z.core.$strip>>;
|
|
1514
1555
|
export declare const unpublish: OperationSpec<"unpublish", z.ZodObject<{
|
|
1515
1556
|
id: z.ZodString;
|
|
@@ -1809,6 +1850,11 @@ export declare const OPERATIONS: readonly [OperationSpec<"create_draft", z.ZodOb
|
|
|
1809
1850
|
contract: z.ZodString;
|
|
1810
1851
|
path: z.ZodString;
|
|
1811
1852
|
}, z.core.$strip>>>;
|
|
1853
|
+
group: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
1854
|
+
id: z.ZodString;
|
|
1855
|
+
version: z.ZodNumber;
|
|
1856
|
+
}, z.core.$strict>>>;
|
|
1857
|
+
publishAt: z.ZodOptional<z.ZodISODateTime>;
|
|
1812
1858
|
}, z.core.$strict>, z.ZodObject<{
|
|
1813
1859
|
outcome: z.ZodEnum<{
|
|
1814
1860
|
blocked: "blocked";
|
|
@@ -1816,6 +1862,7 @@ export declare const OPERATIONS: readonly [OperationSpec<"create_draft", z.ZodOb
|
|
|
1816
1862
|
needs_acknowledgement: "needs_acknowledgement";
|
|
1817
1863
|
not_found: "not_found";
|
|
1818
1864
|
published: "published";
|
|
1865
|
+
scheduled: "scheduled";
|
|
1819
1866
|
}>;
|
|
1820
1867
|
entry: z.ZodOptional<z.ZodObject<{
|
|
1821
1868
|
id: z.ZodString;
|
|
@@ -1869,6 +1916,7 @@ export declare const OPERATIONS: readonly [OperationSpec<"create_draft", z.ZodOb
|
|
|
1869
1916
|
currentVersion: z.ZodNumber;
|
|
1870
1917
|
headRevision: z.ZodString;
|
|
1871
1918
|
}, z.core.$strip>>;
|
|
1919
|
+
failedEntry: z.ZodOptional<z.ZodString>;
|
|
1872
1920
|
}, z.core.$strip>>, OperationSpec<"unpublish", z.ZodObject<{
|
|
1873
1921
|
id: z.ZodString;
|
|
1874
1922
|
}, z.core.$strict>, z.ZodObject<{
|
|
@@ -2026,6 +2074,33 @@ export declare function isPersonalToken(value: string): boolean;
|
|
|
2026
2074
|
/** The SHA-256 of a token, in lowercase hex: what the `tokens` table stores and looks up. */
|
|
2027
2075
|
export declare function hashPersonalToken(token: string): Promise<string>;
|
|
2028
2076
|
//#endregion
|
|
2077
|
+
//#region src/webhooks.d.ts
|
|
2078
|
+
/**
|
|
2079
|
+
* The signature of Hollware's webhooks (ADR-025), for the Worker that sends them and for whoever
|
|
2080
|
+
* receives them: HMAC-SHA256 of `<timestamp>.<body>` with the webhook's secret, sent as
|
|
2081
|
+
* `hollware-signature: v1=<hex>` with `hollware-timestamp` (seconds since the epoch) and
|
|
2082
|
+
* `hollware-event-id`. Web Crypto only, so it runs in any runtime.
|
|
2083
|
+
*/
|
|
2084
|
+
export declare const SIGNATURE_HEADER = "hollware-signature";
|
|
2085
|
+
export declare const TIMESTAMP_HEADER = "hollware-timestamp";
|
|
2086
|
+
export declare const EVENT_ID_HEADER = "hollware-event-id";
|
|
2087
|
+
/** The value of `hollware-signature` for a body sent at `timestamp` (seconds). */
|
|
2088
|
+
export declare function signWebhook(secret: string, timestamp: number, body: string): Promise<string>;
|
|
2089
|
+
export interface WebhookCheck {
|
|
2090
|
+
readonly secret: string;
|
|
2091
|
+
/** The raw body, as received: a parsed and re-serialized body no longer matches. */
|
|
2092
|
+
readonly body: string;
|
|
2093
|
+
readonly headers: Headers;
|
|
2094
|
+
/** Now, in seconds since the epoch; the system clock when absent. */
|
|
2095
|
+
readonly now?: number;
|
|
2096
|
+
readonly toleranceSeconds?: number;
|
|
2097
|
+
}
|
|
2098
|
+
/**
|
|
2099
|
+
* Whether a request is a webhook of Hollware: its signature matches the body and the secret, and
|
|
2100
|
+
* its timestamp is within the tolerance, so a captured request cannot be replayed later.
|
|
2101
|
+
*/
|
|
2102
|
+
export declare function verifyWebhook(check: WebhookCheck): Promise<boolean>;
|
|
2103
|
+
//#endregion
|
|
2029
2104
|
//#region src/index.d.ts
|
|
2030
2105
|
export declare const name = "@hollware/core";
|
|
2031
2106
|
//#endregion
|
package/dist/index.mjs
CHANGED
|
@@ -69,7 +69,7 @@ const MAX_MARKDOWN_BYTES_CEILING = 786432;
|
|
|
69
69
|
* letter, at most 63 characters. A component name is what Markdown writes after the colons
|
|
70
70
|
* (`::callout`). A site key has its own rule (`site-key.ts`).
|
|
71
71
|
*/
|
|
72
|
-
const NAME = /^[a-z][a-z0-9-]{0,62}$/;
|
|
72
|
+
const NAME$1 = /^[a-z][a-z0-9-]{0,62}$/;
|
|
73
73
|
/** A language tag: a language, then an optional script and region: `es`, `pt-BR`, `zh-Hant-TW`. */
|
|
74
74
|
const LOCALE_TAG = /^[a-z]{2,3}(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|[0-9]{3}))?$/;
|
|
75
75
|
/** The id of a contract: lowercase words joined by single hyphens. */
|
|
@@ -1224,7 +1224,7 @@ function readTranslations(path, raw, schema, problems) {
|
|
|
1224
1224
|
}
|
|
1225
1225
|
const COMPONENT_FIX = "Declare it with defineComponent({ props: z.strictObject({ … }) }).";
|
|
1226
1226
|
function readComponent(path, name, raw, problems) {
|
|
1227
|
-
if (!NAME.test(name)) problems.add(path, `${quote(name)} is not a valid component name`, nameFix("callout"));
|
|
1227
|
+
if (!NAME$1.test(name)) problems.add(path, `${quote(name)} is not a valid component name`, nameFix("callout"));
|
|
1228
1228
|
if (!isRecord$1(raw)) {
|
|
1229
1229
|
problems.add(path, wrongType(raw, "a component"), COMPONENT_FIX);
|
|
1230
1230
|
return null;
|
|
@@ -1347,7 +1347,7 @@ function readMaxMarkdownBytes(path, raw, problems) {
|
|
|
1347
1347
|
* problem already: `resolveConfig` throws when there is any, so nothing else keeps track of them.
|
|
1348
1348
|
*/
|
|
1349
1349
|
function readCollection(path, name, raw, site, problems) {
|
|
1350
|
-
if (!NAME.test(name)) problems.add(path, `${quote(name)} is not a valid collection name`, nameFix("blog"));
|
|
1350
|
+
if (!NAME$1.test(name)) problems.add(path, `${quote(name)} is not a valid collection name`, nameFix("blog"));
|
|
1351
1351
|
if (!isRecord$1(raw)) {
|
|
1352
1352
|
problems.add(path, wrongType(raw, "a collection"), "Declare it with defineCollection({ schema, routes, … }).");
|
|
1353
1353
|
return null;
|
|
@@ -1376,6 +1376,90 @@ function readCollection(path, name, raw, site, problems) {
|
|
|
1376
1376
|
};
|
|
1377
1377
|
}
|
|
1378
1378
|
//#endregion
|
|
1379
|
+
//#region src/config/events.ts
|
|
1380
|
+
/** The name of a Worker secret or variable: what `wrangler secret put` takes. */
|
|
1381
|
+
const BINDING_NAME = /^[A-Z][A-Z0-9_]*$/;
|
|
1382
|
+
/** A subscriber's name: lowercase words joined by hyphens. */
|
|
1383
|
+
const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
1384
|
+
/** The public URL of a site: an absolute http or https URL with no path, query or fragment. */
|
|
1385
|
+
function readSiteUrl(path, raw, problems) {
|
|
1386
|
+
if (raw === void 0) return null;
|
|
1387
|
+
const fix = "Write the public origin of the site, such as \"https://example.com\".";
|
|
1388
|
+
if (typeof raw !== "string" || !URL.canParse(raw)) {
|
|
1389
|
+
problems.add(path, wrongType(raw, "an absolute URL"), fix);
|
|
1390
|
+
return null;
|
|
1391
|
+
}
|
|
1392
|
+
const url = new URL(raw);
|
|
1393
|
+
if (url.protocol !== "https:" && url.protocol !== "http:" || url.pathname !== "/" || url.search !== "" || url.hash !== "") {
|
|
1394
|
+
problems.add(path, `${quote(raw)} is not an origin`, fix);
|
|
1395
|
+
return null;
|
|
1396
|
+
}
|
|
1397
|
+
return url.origin;
|
|
1398
|
+
}
|
|
1399
|
+
function readBindingName(path, raw, problems) {
|
|
1400
|
+
if (typeof raw === "string" && BINDING_NAME.test(raw)) return raw;
|
|
1401
|
+
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.");
|
|
1402
|
+
return null;
|
|
1403
|
+
}
|
|
1404
|
+
function readWebhook(path, raw, problems) {
|
|
1405
|
+
if (!isRecord$1(raw)) {
|
|
1406
|
+
problems.add(path, wrongType(raw, "a webhook"), "Declare it as { name: \"newsletter\", url: \"https://…\", secret: \"WEBHOOK_SECRET\" }.");
|
|
1407
|
+
return null;
|
|
1408
|
+
}
|
|
1409
|
+
const name = raw["name"];
|
|
1410
|
+
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\".");
|
|
1411
|
+
const url = raw["url"];
|
|
1412
|
+
const urlOk = typeof url === "string" && URL.canParse(url) && new URL(url).protocol === "https:";
|
|
1413
|
+
if (!urlOk) problems.add(keyPath(path, "url"), wrongType(url, "an https URL"), "Give the https URL that receives the events.");
|
|
1414
|
+
const secret = readBindingName(keyPath(path, "secret"), raw["secret"], problems);
|
|
1415
|
+
if (typeof name !== "string" || !NAME.test(name) || !urlOk || secret === null) return null;
|
|
1416
|
+
return {
|
|
1417
|
+
name,
|
|
1418
|
+
url: String(url),
|
|
1419
|
+
secret
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
/** Who hears about publications (ADR-025): webhooks, and IndexNow when the site has a URL. */
|
|
1423
|
+
function readEvents(path, raw, siteUrl, problems) {
|
|
1424
|
+
const events = {
|
|
1425
|
+
webhooks: [],
|
|
1426
|
+
indexNow: null
|
|
1427
|
+
};
|
|
1428
|
+
if (raw === void 0) return events;
|
|
1429
|
+
if (!isRecord$1(raw)) {
|
|
1430
|
+
problems.add(path, wrongType(raw, "an object"), "Declare { webhooks: [ … ], indexNow: { … } }.");
|
|
1431
|
+
return events;
|
|
1432
|
+
}
|
|
1433
|
+
const webhooks = raw["webhooks"];
|
|
1434
|
+
if (webhooks !== void 0) {
|
|
1435
|
+
if (!Array.isArray(webhooks)) problems.add(keyPath(path, "webhooks"), wrongType(webhooks, "a list"), "List the webhooks.");
|
|
1436
|
+
else {
|
|
1437
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1438
|
+
webhooks.forEach((item, index) => {
|
|
1439
|
+
const webhook = readWebhook(itemPath(keyPath(path, "webhooks"), index), item, problems);
|
|
1440
|
+
if (webhook === null) return;
|
|
1441
|
+
if (seen.has(webhook.name)) {
|
|
1442
|
+
problems.add(itemPath(keyPath(path, "webhooks"), index), `repeats the name ${quote(webhook.name)}`, "Give each webhook a name of its own.");
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1445
|
+
seen.add(webhook.name);
|
|
1446
|
+
events.webhooks.push(webhook);
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
const indexNow = raw["indexNow"];
|
|
1451
|
+
if (indexNow !== void 0) {
|
|
1452
|
+
const indexPath = keyPath(path, "indexNow");
|
|
1453
|
+
if (!isRecord$1(indexNow)) problems.add(indexPath, wrongType(indexNow, "an object"), "Declare { key: \"INDEXNOW_KEY\" }.");
|
|
1454
|
+
else {
|
|
1455
|
+
const key = readBindingName(keyPath(indexPath, "key"), indexNow["key"], problems);
|
|
1456
|
+
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.");
|
|
1457
|
+
else if (key !== null) events.indexNow = { key };
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
return events;
|
|
1461
|
+
}
|
|
1462
|
+
//#endregion
|
|
1379
1463
|
//#region src/config/site-key.ts
|
|
1380
1464
|
/**
|
|
1381
1465
|
* The key of a site, which is the id of its row in the database: lowercase letters and digits
|
|
@@ -1480,12 +1564,16 @@ function readSite(key, raw, problems) {
|
|
|
1480
1564
|
localesOk: ok,
|
|
1481
1565
|
routes: /* @__PURE__ */ new Map()
|
|
1482
1566
|
}, problems);
|
|
1567
|
+
const styleGuide = readOptionalString(keyPath(path, "styleGuide"), raw["styleGuide"], "Write the guidance as a Markdown string, or remove it.", problems);
|
|
1568
|
+
const url = readSiteUrl(keyPath(path, "url"), raw["url"], problems);
|
|
1483
1569
|
return {
|
|
1484
1570
|
key,
|
|
1485
1571
|
locales,
|
|
1486
1572
|
defaultLocale,
|
|
1487
|
-
styleGuide
|
|
1488
|
-
collections
|
|
1573
|
+
styleGuide,
|
|
1574
|
+
collections,
|
|
1575
|
+
url,
|
|
1576
|
+
events: readEvents(keyPath(path, "events"), raw["events"], url, problems)
|
|
1489
1577
|
};
|
|
1490
1578
|
}
|
|
1491
1579
|
function readSites(raw, problems) {
|
|
@@ -1577,10 +1665,13 @@ var HollwareError = class extends Error {
|
|
|
1577
1665
|
const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
1578
1666
|
/** The kinds of ids and their prefixes. A new kind adds its prefix here. */
|
|
1579
1667
|
const ID_PREFIXES = {
|
|
1668
|
+
delivery: "dlv",
|
|
1580
1669
|
entry: "ent",
|
|
1670
|
+
event: "evt",
|
|
1581
1671
|
publication: "pub",
|
|
1582
1672
|
revision: "rev",
|
|
1583
1673
|
revisionTag: "tag",
|
|
1674
|
+
schedule: "sch",
|
|
1584
1675
|
token: "tok",
|
|
1585
1676
|
user: "usr"
|
|
1586
1677
|
};
|
|
@@ -3196,15 +3287,21 @@ const validate = defineOperation({
|
|
|
3196
3287
|
const publish = defineOperation({
|
|
3197
3288
|
name: "publish",
|
|
3198
3289
|
title: "Publish an entry",
|
|
3199
|
-
description: "Publishes the latest revision of an entry, if the entry is still at the version you read. It is blocked while the contracts report errors. Warnings have to be acknowledged: an answer of needs_acknowledgement lists them, and publish again with them in acknowledgeWarnings.",
|
|
3290
|
+
description: "Publishes the latest revision of an entry, if the entry is still at the version you read, and with group, other entries in the same step. It is blocked while the contracts report errors. Warnings have to be acknowledged: an answer of needs_acknowledgement lists them, and publish again with them in acknowledgeWarnings. Publish entries that link to each other as one group.",
|
|
3200
3291
|
input: z.strictObject({
|
|
3201
3292
|
id: entryId,
|
|
3202
3293
|
version,
|
|
3203
|
-
acknowledgeWarnings: z.array(acknowledgedWarning).default([]).describe("The warnings you accept, as needs_acknowledgement listed them.")
|
|
3294
|
+
acknowledgeWarnings: z.array(acknowledgedWarning).default([]).describe("The warnings you accept, as needs_acknowledgement listed them."),
|
|
3295
|
+
group: z.array(z.strictObject({
|
|
3296
|
+
id: entryId,
|
|
3297
|
+
version
|
|
3298
|
+
})).max(99).default([]).describe("Other entries to publish in the same step, each with the version you read: all of them are published, or none. A link between two of them counts as published, and an acknowledged warning holds for every entry that reports it."),
|
|
3299
|
+
publishAt: z.iso.datetime({ offset: true }).optional().describe("Publish at this time instead of now, as an ISO 8601 date and time: the entry is checked now and waits as scheduled, and it is published within a minute of the time, if nobody changes it before. One entry at a time, never with group.")
|
|
3204
3300
|
}),
|
|
3205
3301
|
output: z.object({
|
|
3206
3302
|
outcome: z.enum([
|
|
3207
3303
|
"published",
|
|
3304
|
+
"scheduled",
|
|
3208
3305
|
"blocked",
|
|
3209
3306
|
"needs_acknowledgement",
|
|
3210
3307
|
"conflict",
|
|
@@ -3212,12 +3309,14 @@ const publish = defineOperation({
|
|
|
3212
3309
|
]),
|
|
3213
3310
|
entry: entrySummary.optional(),
|
|
3214
3311
|
verdict: verdict.optional(),
|
|
3215
|
-
conflict: conflict.optional()
|
|
3312
|
+
conflict: conflict.optional(),
|
|
3313
|
+
failedEntry: entryId.optional().describe("In a group, the entry that kept the group from being published.")
|
|
3216
3314
|
}),
|
|
3217
3315
|
scopes: ["content:publish"],
|
|
3218
3316
|
effect: "write",
|
|
3219
3317
|
outcomes: {
|
|
3220
3318
|
published: { isError: false },
|
|
3319
|
+
scheduled: { isError: false },
|
|
3221
3320
|
blocked: { isError: true },
|
|
3222
3321
|
needs_acknowledgement: { isError: false },
|
|
3223
3322
|
conflict: { isError: true },
|
|
@@ -3303,7 +3402,51 @@ async function hashPersonalToken(token) {
|
|
|
3303
3402
|
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
3304
3403
|
}
|
|
3305
3404
|
//#endregion
|
|
3405
|
+
//#region src/webhooks.ts
|
|
3406
|
+
/**
|
|
3407
|
+
* The signature of Hollware's webhooks (ADR-025), for the Worker that sends them and for whoever
|
|
3408
|
+
* receives them: HMAC-SHA256 of `<timestamp>.<body>` with the webhook's secret, sent as
|
|
3409
|
+
* `hollware-signature: v1=<hex>` with `hollware-timestamp` (seconds since the epoch) and
|
|
3410
|
+
* `hollware-event-id`. Web Crypto only, so it runs in any runtime.
|
|
3411
|
+
*/
|
|
3412
|
+
const SIGNATURE_HEADER = "hollware-signature";
|
|
3413
|
+
const TIMESTAMP_HEADER = "hollware-timestamp";
|
|
3414
|
+
const EVENT_ID_HEADER = "hollware-event-id";
|
|
3415
|
+
/** How far a timestamp may be from the receiver's clock, by default: five minutes. */
|
|
3416
|
+
const TOLERANCE_SECONDS = 300;
|
|
3417
|
+
const encoder = new TextEncoder();
|
|
3418
|
+
async function hmac(secret, text) {
|
|
3419
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), {
|
|
3420
|
+
name: "HMAC",
|
|
3421
|
+
hash: "SHA-256"
|
|
3422
|
+
}, false, ["sign"]);
|
|
3423
|
+
return [...new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(text)))].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
3424
|
+
}
|
|
3425
|
+
/** The value of `hollware-signature` for a body sent at `timestamp` (seconds). */
|
|
3426
|
+
async function signWebhook(secret, timestamp, body) {
|
|
3427
|
+
return `v1=${await hmac(secret, `${timestamp}.${body}`)}`;
|
|
3428
|
+
}
|
|
3429
|
+
/** Compares two strings in time that does not depend on where they differ. */
|
|
3430
|
+
function sameText(a, b) {
|
|
3431
|
+
if (a.length !== b.length) return false;
|
|
3432
|
+
let difference = 0;
|
|
3433
|
+
for (let index = 0; index < a.length; index++) difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
|
|
3434
|
+
return difference === 0;
|
|
3435
|
+
}
|
|
3436
|
+
/**
|
|
3437
|
+
* Whether a request is a webhook of Hollware: its signature matches the body and the secret, and
|
|
3438
|
+
* its timestamp is within the tolerance, so a captured request cannot be replayed later.
|
|
3439
|
+
*/
|
|
3440
|
+
async function verifyWebhook(check) {
|
|
3441
|
+
const signature = check.headers.get(SIGNATURE_HEADER);
|
|
3442
|
+
const timestamp = Number(check.headers.get(TIMESTAMP_HEADER));
|
|
3443
|
+
if (signature === null || !Number.isInteger(timestamp)) return false;
|
|
3444
|
+
const now = check.now ?? Math.floor(Date.now() / 1e3);
|
|
3445
|
+
if (Math.abs(now - timestamp) > (check.toleranceSeconds ?? TOLERANCE_SECONDS)) return false;
|
|
3446
|
+
return sameText(signature, await signWebhook(check.secret, timestamp, check.body));
|
|
3447
|
+
}
|
|
3448
|
+
//#endregion
|
|
3306
3449
|
//#region src/index.ts
|
|
3307
3450
|
const name = "@hollware/core";
|
|
3308
3451
|
//#endregion
|
|
3309
|
-
export { BUILT_IN_CONTRACT_IDS, COMARK_VERSION, ConfigError, DEFAULT_MAX_MARKDOWN_BYTES, ERROR_CODES, HollwareError, ID_PREFIXES, MAX_MARKDOWN_BYTES_CEILING, OPERATIONS, PERSONAL_TOKEN_PREFIX, PERSONAL_TOKEN_TTL_DAYS, PIPELINE_VERSION, REFUSAL_CONTRACT_IDS, SCOPES, SITE_KEY_MAX_LENGTH, SITE_KEY_PATTERN, contracts, createDraft, defineCollection, defineComponent, defineConfig, defineContract, defineOperation, describeBuiltInContracts, describeContract, describeResolvedSite, describeSite, generatePersonalToken, getEntry, getTranslations, hashPersonalToken, isBuiltInContract, isId, isPersonalToken, isScope, isSiteKey, listEntries, name, newId, processMarkdown, publish, renderHtml, resolveConfig, runContracts, schemas_exports as schemas, sizeLimitViolation, ulid, uniqueSlugViolation, uniqueTranslationViolation, unpublish, updateDraft, validate };
|
|
3452
|
+
export { BUILT_IN_CONTRACT_IDS, COMARK_VERSION, ConfigError, DEFAULT_MAX_MARKDOWN_BYTES, ERROR_CODES, EVENT_ID_HEADER, HollwareError, ID_PREFIXES, MAX_MARKDOWN_BYTES_CEILING, OPERATIONS, PERSONAL_TOKEN_PREFIX, PERSONAL_TOKEN_TTL_DAYS, PIPELINE_VERSION, REFUSAL_CONTRACT_IDS, SCOPES, SIGNATURE_HEADER, SITE_KEY_MAX_LENGTH, SITE_KEY_PATTERN, TIMESTAMP_HEADER, contracts, createDraft, defineCollection, defineComponent, defineConfig, defineContract, defineOperation, describeBuiltInContracts, describeContract, describeResolvedSite, describeSite, generatePersonalToken, getEntry, getTranslations, hashPersonalToken, isBuiltInContract, isId, isPersonalToken, isScope, isSiteKey, listEntries, name, newId, processMarkdown, publish, renderHtml, resolveConfig, runContracts, schemas_exports as schemas, signWebhook, sizeLimitViolation, ulid, uniqueSlugViolation, uniqueTranslationViolation, unpublish, updateDraft, validate, verifyWebhook };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hollware/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Contracts, operation specs, schemas and the Markdown pipeline of Hollware. No Cloudflare dependencies.",
|
|
5
5
|
"homepage": "https://hollware.com",
|
|
6
6
|
"bugs": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"comark": "0.7.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"@cloudflare/vitest-plugin": "1.4.
|
|
36
|
+
"@cloudflare/vitest-plugin": "1.4.1",
|
|
37
37
|
"@types/node": "24.19.2",
|
|
38
38
|
"publint": "0.3.25",
|
|
39
39
|
"typescript": "7.0.2",
|