@hollware/core 0.10.0 → 1.0.0-rc.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 CHANGED
@@ -315,11 +315,18 @@ interface SiteDefinition {
315
315
  /** The Worker secret that signs; Hollware's own sealed one when it is left out. */
316
316
  readonly secret?: string;
317
317
  }[];
318
- /** Tells IndexNow the URLs that changed; `key` names the Worker secret with the key. */
318
+ /** Tells IndexNow the URLs that changed; `key` is the key itself, which is public. */
319
319
  readonly indexNow?: {
320
320
  readonly key: string;
321
321
  };
322
322
  };
323
+ /** What the site's activity log keeps (ADR-037): no addresses, and forever, by default. */
324
+ readonly audit?: {
325
+ /** `hashed` keeps a keyed hash of the address of each request; `off` keeps nothing of it. */
326
+ readonly ipAddresses?: 'off' | 'hashed';
327
+ /** How many days a line is kept, 30 to 3650; `null` keeps the log as long as the site. */
328
+ readonly retentionDays?: number | null;
329
+ };
323
330
  }
324
331
  /** What `hollware.config.ts` exports. */
325
332
  interface HollwareConfig {
@@ -369,6 +376,12 @@ interface ResolvedSite {
369
376
  readonly origins: readonly string[];
370
377
  };
371
378
  readonly events: ResolvedEvents;
379
+ readonly audit: ResolvedAudit;
380
+ }
381
+ /** What the activity log of a site keeps (ADR-037). */
382
+ interface ResolvedAudit {
383
+ readonly ipAddresses: 'off' | 'hashed';
384
+ readonly retentionDays: number | null;
372
385
  }
373
386
  /**
374
387
  * A webhook of a site. `secret` names the Worker secret that signs its deliveries; `null` when the
@@ -450,6 +463,14 @@ export declare class ConfigError extends Error {
450
463
  */
451
464
  export declare const INDEXNOW_KEY: RegExp;
452
465
  //#endregion
466
+ //#region src/config/audit.d.ts
467
+ /** The fewest days a site may keep its activity log for, when it sets a retention (ADR-037). */
468
+ export declare const AUDIT_RETENTION_MIN_DAYS = 30;
469
+ /** The most days a retention may be: ten years. Beyond that, keep it with no retention. */
470
+ export declare const AUDIT_RETENTION_MAX_DAYS = 3650;
471
+ /** What a site that says nothing keeps: no addresses, and the log for as long as the site exists. */
472
+ export declare const DEFAULT_AUDIT: ResolvedAudit;
473
+ //#endregion
453
474
  //#region src/config/constants.d.ts
454
475
  /** The largest Markdown file a collection accepts when its config does not say, 512 KiB. */
455
476
  export declare const DEFAULT_MAX_MARKDOWN_BYTES = 524288;
@@ -1124,6 +1145,34 @@ export declare const SITE_KEY_MAX_LENGTH = 64;
1124
1145
  /** Whether a value is a site key: a string that fits `SITE_KEY_PATTERN` and is not too long. */
1125
1146
  export declare function isSiteKey(value: unknown): value is string;
1126
1147
  //#endregion
1148
+ //#region src/audit/index.d.ts
1149
+ /**
1150
+ * The actions of the activity log (ADR-037). A line names one of these, and nothing else: a new
1151
+ * operation or route that changes something is not logged until it is named here.
1152
+ */
1153
+ /** Every operation that is not a read: each call of one is a line, whatever its outcome. */
1154
+ export declare const OPERATION_AUDIT_ACTIONS: readonly ['create_draft', 'update_draft', 'publish', 'unpublish', 'delete_entry', 'restore_entry', 'approve_review', 'request_review', 'comment_review', 'request_changes', 'update_site', 'set_collection', 'create_site', 'delete_site', 'restore_site', 'make_webhook_secret'];
1155
+ /** What people do outside the operations: their tokens, members, passkeys, sessions and apps. */
1156
+ export declare const IDENTITY_AUDIT_ACTIONS: readonly ['token.create',
1157
+ /** A person asked for a link that shows an entry's latest revision on the site (T-103). */
1158
+ 'preview.create', 'token.revoke', 'member.role', 'member.remove', 'invitation.create', 'invitation.accept', 'session.sign_in', 'session.sign_out', 'session.end_all', 'passkey.remove', 'app.allow', 'app.revoke',
1159
+ /** A refresh token was used again after it was spent, so its grant was revoked (T-34). */
1160
+ 'app.refresh_reused',
1161
+ /** The cron published an entry whose time had come, for the person who scheduled it. */
1162
+ 'schedule.publish'];
1163
+ export declare const AUDIT_ACTIONS: readonly ["create_draft", "update_draft", "publish", "unpublish", "delete_entry", "restore_entry", "approve_review", "request_review", "comment_review", "request_changes", "update_site", "set_collection", "create_site", "delete_site", "restore_site", "make_webhook_secret", "token.create", "preview.create", "token.revoke", "member.role", "member.remove", "invitation.create", "invitation.accept", "session.sign_in", "session.sign_out", "session.end_all", "passkey.remove", "app.allow", "app.revoke", "app.refresh_reused", "schedule.publish"];
1164
+ export type AuditAction = (typeof AUDIT_ACTIONS)[number];
1165
+ /** `denied`: a scope or a role was missing; `failed`: any other error the caller got. */
1166
+ export declare const AUDIT_OUTCOMES: readonly ['ok', 'denied', 'failed'];
1167
+ export type AuditOutcome = (typeof AUDIT_OUTCOMES)[number];
1168
+ /**
1169
+ * Who acted: a person in the admin, a personal token, an app a person allowed through OAuth, or
1170
+ * the deployment itself (its cron).
1171
+ */
1172
+ export declare const AUDIT_ACTOR_KINDS: readonly ['user', 'token', 'oauth_client', 'system'];
1173
+ export type AuditActorKind = (typeof AUDIT_ACTOR_KINDS)[number];
1174
+ export declare function isAuditAction(value: string): value is AuditAction;
1175
+ //#endregion
1127
1176
  //#region src/structure/compile.d.ts
1128
1177
  /** The definitions of the sites, read from D1 as JSON, checked and resolved. */
1129
1178
  export declare function compileSites(definitions: Readonly<Record<string, unknown>>): ResolvedConfig;
@@ -1189,6 +1238,13 @@ export declare const siteDefinitionData: z.ZodObject<{
1189
1238
  key: z.ZodString;
1190
1239
  }, z.core.$strict>>>;
1191
1240
  }, z.core.$strict>>;
1241
+ audit: z.ZodDefault<z.ZodObject<{
1242
+ ipAddresses: z.ZodDefault<z.ZodEnum<{
1243
+ hashed: "hashed";
1244
+ off: "off";
1245
+ }>>;
1246
+ retentionDays: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
1247
+ }, z.core.$strict>>;
1192
1248
  collections: z.ZodRecord<z.ZodString, z.ZodObject<{
1193
1249
  schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
1194
1250
  routes: z.ZodRecord<z.ZodString, z.ZodString>;
@@ -1263,6 +1319,8 @@ export declare const ERROR_CODES: readonly [
1263
1319
  'unknown_collection',
1264
1320
  /** The input names a locale the site does not have. */
1265
1321
  'unknown_locale',
1322
+ /** A preview link that has expired, or is not one Hollware made for this site (T-103). */
1323
+ 'invalid_preview',
1266
1324
  /** The token lacks a scope the operation requires; the message names every missing scope. */
1267
1325
  'insufficient_scope',
1268
1326
  /** The answer would exceed the output cap. Hollware never truncates content. */
@@ -1293,6 +1351,7 @@ export declare class HollwareError extends Error {
1293
1351
  */
1294
1352
  /** The kinds of ids and their prefixes. A new kind adds its prefix here. */
1295
1353
  export declare const ID_PREFIXES: {
1354
+ readonly auditLine: 'aud';
1296
1355
  readonly challenge: 'chl';
1297
1356
  readonly delivery: 'dlv';
1298
1357
  readonly embedding: 'emb';
@@ -2574,6 +2633,13 @@ export declare const OPERATIONS: readonly [OperationSpec<"approve_review", z.Zod
2574
2633
  key: z.ZodString;
2575
2634
  }, z.core.$strict>>>;
2576
2635
  }, z.core.$strict>>;
2636
+ audit: z.ZodDefault<z.ZodObject<{
2637
+ ipAddresses: z.ZodDefault<z.ZodEnum<{
2638
+ hashed: "hashed";
2639
+ off: "off";
2640
+ }>>;
2641
+ retentionDays: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
2642
+ }, z.core.$strict>>;
2577
2643
  collections: z.ZodRecord<z.ZodString, z.ZodObject<{
2578
2644
  schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
2579
2645
  routes: z.ZodRecord<z.ZodString, z.ZodString>;
@@ -2849,6 +2915,13 @@ export declare const OPERATIONS: readonly [OperationSpec<"approve_review", z.Zod
2849
2915
  key: z.ZodString;
2850
2916
  }, z.core.$strict>>>;
2851
2917
  }, z.core.$strict>>;
2918
+ audit: z.ZodDefault<z.ZodObject<{
2919
+ ipAddresses: z.ZodDefault<z.ZodEnum<{
2920
+ hashed: "hashed";
2921
+ off: "off";
2922
+ }>>;
2923
+ retentionDays: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
2924
+ }, z.core.$strict>>;
2852
2925
  collections: z.ZodRecord<z.ZodString, z.ZodObject<{
2853
2926
  schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
2854
2927
  routes: z.ZodRecord<z.ZodString, z.ZodString>;
@@ -3463,6 +3536,13 @@ export declare const OPERATIONS: readonly [OperationSpec<"approve_review", z.Zod
3463
3536
  key: z.ZodString;
3464
3537
  }, z.core.$strict>>>;
3465
3538
  }, z.core.$strict>>;
3539
+ audit: z.ZodDefault<z.ZodObject<{
3540
+ ipAddresses: z.ZodDefault<z.ZodEnum<{
3541
+ hashed: "hashed";
3542
+ off: "off";
3543
+ }>>;
3544
+ retentionDays: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
3545
+ }, z.core.$strict>>;
3466
3546
  collections: z.ZodRecord<z.ZodString, z.ZodObject<{
3467
3547
  schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
3468
3548
  routes: z.ZodRecord<z.ZodString, z.ZodString>;
@@ -3615,13 +3695,76 @@ export interface WebhookCheck {
3615
3695
  /** Now, in seconds since the epoch; the system clock when absent. */
3616
3696
  readonly now?: number;
3617
3697
  readonly toleranceSeconds?: number;
3698
+ /**
3699
+ * Whether the receiver sees this event for the first time, given its id, and records it (T-67):
3700
+ * a request captured and sent again within the tolerance is then refused. The id is read from
3701
+ * the signed body, never from `hollware-event-id`, which no signature covers. A retry of a
3702
+ * delivery carries the same id, so a receiver that records an id only once it has handled the
3703
+ * event lets a failed delivery be retried. Keep each id for longer than the tolerance.
3704
+ */
3705
+ readonly isNew?: (eventId: string) => boolean | Promise<boolean>;
3618
3706
  }
3619
3707
  /**
3620
3708
  * Whether a request is a webhook of Hollware: its signature matches the body and the secret, and
3621
- * its timestamp is within the tolerance, so a captured request cannot be replayed later.
3709
+ * its timestamp is within the tolerance, so a captured request cannot be replayed later; with
3710
+ * `isNew`, also that its event was not received before, so it cannot be replayed sooner either.
3622
3711
  */
3623
3712
  export declare function verifyWebhook(check: WebhookCheck): Promise<boolean>;
3624
3713
  //#endregion
3714
+ //#region src/preview.d.ts
3715
+ /**
3716
+ * The preview token (T-103 of docs/security/threat-model.md): a signed, stateless proof that a person of a site may read the
3717
+ * latest revision of one entry, for one hour. A site that reads published content only (the
3718
+ * binding of RPC, the read API) reads that entry's head instead when it presents one.
3719
+ *
3720
+ * hw_prv_<payload>.<signature>
3721
+ *
3722
+ * The payload is base64url of `{ "s": site, "e": entryId, "x": expiresAtSeconds }`, and the
3723
+ * signature the base64url HMAC-SHA256 of the payload text under a key that HKDF-SHA256 derives
3724
+ * from the Worker secret `HOLLWARE_SECRET`, bound to the site. Nothing is stored: a token cannot
3725
+ * be revoked, only wait out its hour, and rotating the secret ends every one.
3726
+ */
3727
+ export declare const PREVIEW_TOKEN_PREFIX = "hw_prv_";
3728
+ /** The query parameter a site's page takes the token from. */
3729
+ export declare const PREVIEW_QUERY_PARAM = "hollware-preview";
3730
+ /** How long a token lasts, in seconds. */
3731
+ export declare const PREVIEW_TTL_SECONDS = 3600;
3732
+ /** The least characters of `HOLLWARE_SECRET` that Hollware signs anything with. */
3733
+ export declare const PREVIEW_SECRET_MIN_LENGTH = 32;
3734
+ /** Why a token was not accepted: for the log of the operator, never for the caller. */
3735
+ type PreviewRefusal = 'no_secret' | 'malformed' | 'signature' | 'site' | 'expired';
3736
+ type PreviewCheck = {
3737
+ readonly ok: true;
3738
+ readonly entryId: string;
3739
+ readonly expiresAt: string;
3740
+ } | {
3741
+ readonly ok: false;
3742
+ readonly reason: PreviewRefusal;
3743
+ };
3744
+ /** Whether a Worker's secret can sign previews: present and long enough. */
3745
+ export declare function canSignPreviews(secret: string | undefined): secret is string;
3746
+ /** A token for one entry of one site, valid for `PREVIEW_TTL_SECONDS` from `now`. */
3747
+ export declare function makePreviewToken(input: {
3748
+ readonly secret: string;
3749
+ readonly site: string;
3750
+ readonly entryId: string;
3751
+ readonly now: Date;
3752
+ }): Promise<{
3753
+ readonly token: string;
3754
+ readonly expiresAt: string;
3755
+ }>;
3756
+ /**
3757
+ * Checks a token for a site. The signature is verified (in constant time, by `crypto.subtle`)
3758
+ * before the payload is parsed, under the key of the site that is asked: a token made for another
3759
+ * site fails there. The entry the token names is the caller's to compare with the entry it read.
3760
+ */
3761
+ export declare function verifyPreviewToken(input: {
3762
+ readonly secret: string | undefined;
3763
+ readonly site: string;
3764
+ readonly token: string;
3765
+ readonly now: Date;
3766
+ }): Promise<PreviewCheck>;
3767
+ //#endregion
3625
3768
  //#region src/events.d.ts
3626
3769
  /** A publish or an unpublish: the entry, and where it lives on the site. */
3627
3770
  export declare const entryEvent: z.ZodObject<{
@@ -3762,4 +3905,4 @@ export declare function tagsOfSiteChange(collections: readonly string[], locales
3762
3905
  //#region src/index.d.ts
3763
3906
  export declare const name = "@hollware/core";
3764
3907
  //#endregion
3765
- export { type AcknowledgedWarning, type AuthDefinition, type BuiltInContractId, type BuiltInOptions, type CatalogRule, type Chunk, type CollectionDefinition, type CollectionDefinitionData, type ComponentDefinition, type ComponentUse, type ConfigProblem, type Contract, type ContractContext, type ContractDescription, type ContractFinding, type ContractText, type CustomContractOptions, type DescribedSchema, type DisallowedMarker, type Effect, type EntryDocument, type EntryEvent, type EntryFacts, type EntryLookup, type EntryRef, type ErrorCode, type ExtractedContent, type Heading, type HollwareConfig, type HollwareEvent, type IdPrefix, type ImageRef, type LinkRef, type LinkTarget, type Operation, type OperationByName, type OperationName, type OperationSpec, type OutcomeInfo, type OutcomeOf, type Phase, type Preset, type ProcessMarkdown, type ProcessMarkdownOptions, type ProcessResult, type ProcessedMarkdown, type RenderHtml, type ResolveConfig, type ResolvedAuth, type ResolvedCollection, type ResolvedConfig, type ResolvedSite, type RuleDefinition, type RunContractsInput, type SchemaOutput, type Scope, type Severity, type SiteDefinition, type SiteDefinitionData, type SiteDefinitionInput, type SiteDescription, type SiteEvent, type TaggedEntry, type Verdict, type Violation, schemas_d_exports as schemas };
3908
+ export { type AcknowledgedWarning, type AuthDefinition, type BuiltInContractId, type BuiltInOptions, type CatalogRule, type Chunk, type CollectionDefinition, type CollectionDefinitionData, type ComponentDefinition, type ComponentUse, type ConfigProblem, type Contract, type ContractContext, type ContractDescription, type ContractFinding, type ContractText, type CustomContractOptions, type DescribedSchema, type DisallowedMarker, type Effect, type EntryDocument, type EntryEvent, type EntryFacts, type EntryLookup, type EntryRef, type ErrorCode, type ExtractedContent, type Heading, type HollwareConfig, type HollwareEvent, type IdPrefix, type ImageRef, type LinkRef, type LinkTarget, type Operation, type OperationByName, type OperationName, type OperationSpec, type OutcomeInfo, type OutcomeOf, type Phase, type Preset, type PreviewCheck, type PreviewRefusal, type ProcessMarkdown, type ProcessMarkdownOptions, type ProcessResult, type ProcessedMarkdown, type RenderHtml, type ResolveConfig, type ResolvedAudit, type ResolvedAuth, type ResolvedCollection, type ResolvedConfig, type ResolvedSite, type RuleDefinition, type RunContractsInput, type SchemaOutput, type Scope, type Severity, type SiteDefinition, type SiteDefinitionData, type SiteDefinitionInput, type SiteDescription, type SiteEvent, type TaggedEntry, type Verdict, type Violation, schemas_d_exports as schemas };
package/dist/index.mjs CHANGED
@@ -57,7 +57,7 @@ var Problems = class {
57
57
  //#endregion
58
58
  //#region src/config/values.ts
59
59
  /** A value with keys: not null, not an array. */
60
- function isRecord$3(value) {
60
+ function isRecord$4(value) {
61
61
  return typeof value === "object" && value !== null && !Array.isArray(value);
62
62
  }
63
63
  /** A string as the messages show it: quoted, with what could break a line escaped. */
@@ -137,7 +137,7 @@ function readSiteUrl(path, raw, problems) {
137
137
  /** The origins of a site's pages that may read its published content from the browser. */
138
138
  function readCors(path, raw, problems) {
139
139
  if (raw === void 0) return { origins: [] };
140
- if (!isRecord$3(raw) || !Array.isArray(raw["origins"])) {
140
+ if (!isRecord$4(raw) || !Array.isArray(raw["origins"])) {
141
141
  problems.add(path, wrongType(raw, "an object with origins"), "Declare it as { origins: [\"https://example.com\"] }, or remove it.");
142
142
  return { origins: [] };
143
143
  }
@@ -158,7 +158,7 @@ function readBindingName(path, raw, problems) {
158
158
  return null;
159
159
  }
160
160
  function readWebhook(path, raw, problems) {
161
- if (!isRecord$3(raw)) {
161
+ if (!isRecord$4(raw)) {
162
162
  problems.add(path, wrongType(raw, "a webhook"), "Declare it as { name: \"newsletter\", url: \"https://…\", secret: \"WEBHOOK_SECRET\" }.");
163
163
  return null;
164
164
  }
@@ -183,7 +183,7 @@ function readEvents(path, raw, siteUrl, problems) {
183
183
  indexNow: null
184
184
  };
185
185
  if (raw === void 0) return events;
186
- if (!isRecord$3(raw)) {
186
+ if (!isRecord$4(raw)) {
187
187
  problems.add(path, wrongType(raw, "an object"), "Declare { webhooks: [ … ], indexNow: { … } }.");
188
188
  return events;
189
189
  }
@@ -207,7 +207,7 @@ function readEvents(path, raw, siteUrl, problems) {
207
207
  const indexNow = raw["indexNow"];
208
208
  if (indexNow !== void 0) {
209
209
  const indexPath = keyPath(path, "indexNow");
210
- if (!isRecord$3(indexNow)) problems.add(indexPath, wrongType(indexNow, "an object"), "Declare { key: \"<your IndexNow key>\" }.");
210
+ if (!isRecord$4(indexNow)) problems.add(indexPath, wrongType(indexNow, "an object"), "Declare { key: \"<your IndexNow key>\" }.");
211
211
  else {
212
212
  const raw = indexNow["key"];
213
213
  const key = typeof raw === "string" && INDEXNOW_KEY.test(raw) ? raw : null;
@@ -219,6 +219,39 @@ function readEvents(path, raw, siteUrl, problems) {
219
219
  return events;
220
220
  }
221
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
222
255
  //#region src/config/constants.ts
223
256
  /** The largest Markdown file a collection accepts when its config does not say, 512 KiB. */
224
257
  const DEFAULT_MAX_MARKDOWN_BYTES = 524288;
@@ -289,7 +322,7 @@ function runtimeOf(contractId, context) {
289
322
  */
290
323
  /** The most characters of the entry's own text that a message repeats. */
291
324
  const MAX_ECHO_LENGTH = 80;
292
- function isRecord$2(value) {
325
+ function isRecord$3(value) {
293
326
  return typeof value === "object" && value !== null && !Array.isArray(value);
294
327
  }
295
328
  /** Orders two texts by UTF-16 code units: the same in every runtime, unlike `localeCompare`. */
@@ -342,11 +375,11 @@ function fragmentAt(schema, segments) {
342
375
  let node = schema;
343
376
  for (const segment of segments) {
344
377
  const properties = node.properties;
345
- if (isRecord$2(properties) && Object.hasOwn(properties, segment)) {
378
+ if (isRecord$3(properties) && Object.hasOwn(properties, segment)) {
346
379
  const next = properties[segment];
347
- if (!isRecord$2(next)) return void 0;
380
+ if (!isRecord$3(next)) return void 0;
348
381
  node = next;
349
- } else if (/^\d+$/.test(segment) && isRecord$2(node.items)) node = node.items;
382
+ } else if (/^\d+$/.test(segment) && isRecord$3(node.items)) node = node.items;
350
383
  else return;
351
384
  }
352
385
  return node;
@@ -354,7 +387,7 @@ function fragmentAt(schema, segments) {
354
387
  function valueAt(input, segments) {
355
388
  let node = input;
356
389
  for (const segment of segments) if (Array.isArray(node) && /^\d+$/.test(segment)) node = node[Number(segment)];
357
- else if (isRecord$2(node) && Object.hasOwn(node, segment)) node = node[segment];
390
+ else if (isRecord$3(node) && Object.hasOwn(node, segment)) node = node[segment];
358
391
  else return void 0;
359
392
  return node;
360
393
  }
@@ -390,11 +423,11 @@ function numberExpectation(type, schema) {
390
423
  }
391
424
  function arrayExpectation(schema) {
392
425
  const size = range(schema.minItems, schema.maxItems, "item");
393
- const items = isRecord$2(schema.items) ? expectation(schema.items) : void 0;
426
+ const items = isRecord$3(schema.items) ? expectation(schema.items) : void 0;
394
427
  return `an array${size === void 0 ? "" : ` of ${size}`}${items === void 0 ? "" : `, each ${items}`}`;
395
428
  }
396
429
  function objectExpectation(schema) {
397
- const keys = isRecord$2(schema.properties) ? Object.keys(schema.properties) : [];
430
+ const keys = isRecord$3(schema.properties) ? Object.keys(schema.properties) : [];
398
431
  return keys.length === 0 ? "an object" : `an object with the keys ${keys.join(", ")}`;
399
432
  }
400
433
  function typeExpectation(type, schema) {
@@ -417,13 +450,13 @@ function expectation(schema) {
417
450
  if ("const" in schema) return `exactly ${JSON.stringify(schema.const)}`;
418
451
  if (Array.isArray(schema.enum)) return `one of ${listValues(schema.enum)}`;
419
452
  const alternatives = schema.anyOf ?? schema.oneOf;
420
- const parts = Array.isArray(alternatives) ? alternatives.map((option) => isRecord$2(option) ? expectation(option) : void 0) : (Array.isArray(schema.type) ? schema.type : [schema.type]).map((type) => typeExpectation(type, schema));
453
+ const parts = Array.isArray(alternatives) ? alternatives.map((option) => isRecord$3(option) ? expectation(option) : void 0) : (Array.isArray(schema.type) ? schema.type : [schema.type]).map((type) => typeExpectation(type, schema));
421
454
  return parts.every((part) => part !== void 0) ? parts.join(" or ") : void 0;
422
455
  }
423
456
  /** The keys of `value` that an object schema closed to other keys does not list. */
424
457
  function unknownKeys(fragment, value) {
425
458
  const { additionalProperties, properties } = fragment;
426
- if (additionalProperties !== false || !isRecord$2(properties) || !isRecord$2(value)) return [];
459
+ if (additionalProperties !== false || !isRecord$3(properties) || !isRecord$3(value)) return [];
427
460
  return Object.keys(value).filter((key) => !Object.hasOwn(properties, key));
428
461
  }
429
462
  /** The fix for one issue: imperative, naming the field and what the schema accepts there. */
@@ -433,7 +466,7 @@ function fixForIssue({ subject, schema, input, segments, message }) {
433
466
  if (fragment !== void 0) {
434
467
  const unknown = unknownKeys(fragment, valueAt(input, segments));
435
468
  if (unknown.length > 0) {
436
- const allowed = isRecord$2(fragment.properties) ? Object.keys(fragment.properties) : [];
469
+ const allowed = isRecord$3(fragment.properties) ? Object.keys(fragment.properties) : [];
437
470
  const nouns = unknown.length === 1 ? subject.noun : `${subject.noun}s`;
438
471
  const where = allowed.length === 0 ? `no ${subject.noun}s are allowed there` : `the ${subject.noun}s allowed there are: ${allowed.join(", ")}`;
439
472
  return `Remove the ${nouns} ${unknown.map((key) => quote(key)).join(", ")} from ${target}; ${where}.`;
@@ -786,7 +819,7 @@ function chunksOf(entry) {
786
819
  //#endregion
787
820
  //#region src/contracts/rules.ts
788
821
  function present(frontmatter, field) {
789
- return isRecord$2(frontmatter) ? frontmatter[field] : void 0;
822
+ return isRecord$3(frontmatter) ? frontmatter[field] : void 0;
790
823
  }
791
824
  const GRAPHEMES = new Intl.Segmenter(void 0, { granularity: "grapheme" });
792
825
  /** The length of a value as a reader counts it: characters of a text (an emoji is one), items of a list. */
@@ -891,7 +924,7 @@ function bannedWords(params, options = {}) {
891
924
  check: (context) => {
892
925
  if (pattern === null) return [];
893
926
  const findings = [];
894
- const frontmatter = isRecord$2(context.frontmatter) ? context.frontmatter : {};
927
+ const frontmatter = isRecord$3(context.frontmatter) ? context.frontmatter : {};
895
928
  for (const [field, value] of Object.entries(frontmatter)) {
896
929
  if (typeof value !== "string") continue;
897
930
  for (const word of found(value)) findings.push({
@@ -932,7 +965,7 @@ function translationPair(options = {}) {
932
965
  const field = collection.translationGroupField;
933
966
  if (field === null) return [];
934
967
  const path = `/frontmatter${pointer([field])}`;
935
- const group = isRecord$2(context.frontmatter) ? context.frontmatter[field] : void 0;
968
+ const group = isRecord$3(context.frontmatter) ? context.frontmatter[field] : void 0;
936
969
  if (typeof group !== "string" || group === "") return [{
937
970
  path,
938
971
  message: `The entry has no translation group: its frontmatter field ${field} is empty.`,
@@ -1350,7 +1383,7 @@ const TARGET = "draft-2020-12";
1350
1383
  */
1351
1384
  function toJsonSchema$1(schema) {
1352
1385
  const converted = schema["~standard"].jsonSchema.input({ target: TARGET });
1353
- if (!isRecord$3(converted)) throw new TypeError(`the library ${wrongType(converted, "a JSON Schema object")}`);
1386
+ if (!isRecord$4(converted)) throw new TypeError(`the library ${wrongType(converted, "a JSON Schema object")}`);
1354
1387
  return JSON.parse(JSON.stringify(converted));
1355
1388
  }
1356
1389
  /** Reference chains longer than this are cycles. */
@@ -1362,7 +1395,7 @@ function pointTo(root, ref) {
1362
1395
  let node = root;
1363
1396
  for (const part of ref.slice(2).split("/")) {
1364
1397
  const key = part.replaceAll("~1", "/").replaceAll("~0", "~");
1365
- if (!isRecord$3(node) || !Object.hasOwn(node, key)) return void 0;
1398
+ if (!isRecord$4(node) || !Object.hasOwn(node, key)) return void 0;
1366
1399
  node = node[key];
1367
1400
  }
1368
1401
  return node;
@@ -1374,7 +1407,7 @@ function pointTo(root, ref) {
1374
1407
  function resolveSchema(root, schema) {
1375
1408
  let node = schema;
1376
1409
  for (let hop = 0; hop <= MAX_REF_HOPS; hop += 1) {
1377
- if (!isRecord$3(node)) return null;
1410
+ if (!isRecord$4(node)) return null;
1378
1411
  const ref = node["$ref"];
1379
1412
  if (typeof ref !== "string") return node;
1380
1413
  node = pointTo(root, ref);
@@ -1491,14 +1524,14 @@ function standardProps(value) {
1491
1524
  /** A Standard Schema that also converts to JSON Schema, which is what a collection needs. */
1492
1525
  function isDescribedSchema(value) {
1493
1526
  const standard = standardProps(value);
1494
- if (!isRecord$3(standard) || typeof standard["validate"] !== "function") return false;
1527
+ if (!isRecord$4(standard) || typeof standard["validate"] !== "function") return false;
1495
1528
  const converter = standard["jsonSchema"];
1496
- return isRecord$3(converter) && typeof converter["input"] === "function";
1529
+ return isRecord$4(converter) && typeof converter["input"] === "function";
1497
1530
  }
1498
1531
  /** What `isDescribedSchema` found missing, for the message. */
1499
1532
  function whyNotDescribed(value) {
1500
1533
  const standard = standardProps(value);
1501
- if (!isRecord$3(standard)) return wrongType(value, "a Standard Schema with JSON Schema");
1534
+ if (!isRecord$4(standard)) return wrongType(value, "a Standard Schema with JSON Schema");
1502
1535
  if (typeof standard["validate"] !== "function") return "has no ~standard.validate function, so it is no Standard Schema";
1503
1536
  return "has no ~standard.jsonSchema.input function, so describe_site could not show it to agents";
1504
1537
  }
@@ -1568,7 +1601,7 @@ function claimRoute(route, path, site, problems) {
1568
1601
  }
1569
1602
  /** The route of each locale, in the order of the site's locales. */
1570
1603
  function readRoutes(path, raw, collection, site, problems) {
1571
- if (!isRecord$3(raw)) {
1604
+ if (!isRecord$4(raw)) {
1572
1605
  problems.add(path, wrongType(raw, "an object with one path per locale"), ROUTES_FIX);
1573
1606
  return {};
1574
1607
  }
@@ -1616,7 +1649,7 @@ function readField(path, raw, schema, kind, problems) {
1616
1649
  return null;
1617
1650
  }
1618
1651
  if (schema === null) return raw;
1619
- const properties = isRecord$3(schema.object["properties"]) ? schema.object["properties"] : {};
1652
+ const properties = isRecord$4(schema.object["properties"]) ? schema.object["properties"] : {};
1620
1653
  if (!Object.hasOwn(properties, raw)) {
1621
1654
  const names = Object.keys(properties);
1622
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.`);
@@ -1625,7 +1658,7 @@ function readField(path, raw, schema, kind, problems) {
1625
1658
  }
1626
1659
  function readTranslations(path, raw, schema, problems) {
1627
1660
  if (raw === void 0) return null;
1628
- if (!isRecord$3(raw)) {
1661
+ if (!isRecord$4(raw)) {
1629
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.");
1630
1663
  return null;
1631
1664
  }
@@ -1634,7 +1667,7 @@ function readTranslations(path, raw, schema, problems) {
1634
1667
  const COMPONENT_FIX = "Declare it with defineComponent({ props: z.strictObject({ … }) }).";
1635
1668
  function readComponent(path, name, raw, problems) {
1636
1669
  if (!NAME.test(name)) problems.add(path, `${quote$1(name)} is not a valid component name`, nameFix("callout"));
1637
- if (!isRecord$3(raw)) {
1670
+ if (!isRecord$4(raw)) {
1638
1671
  problems.add(path, wrongType(raw, "a component"), COMPONENT_FIX);
1639
1672
  return null;
1640
1673
  }
@@ -1652,7 +1685,7 @@ function readComponent(path, name, raw, problems) {
1652
1685
  function readComponents(path, raw, problems) {
1653
1686
  const components = /* @__PURE__ */ new Map();
1654
1687
  if (raw === void 0) return components;
1655
- if (!isRecord$3(raw)) {
1688
+ if (!isRecord$4(raw)) {
1656
1689
  problems.add(path, wrongType(raw, "an object that maps each component name to its definition"), COMPONENT_FIX);
1657
1690
  return components;
1658
1691
  }
@@ -1693,7 +1726,7 @@ function checkPhases(path, raw, problems) {
1693
1726
  * what makes it a `Contract`. Whether it may have its id is a question for `checkIdentity`.
1694
1727
  */
1695
1728
  function isContract(item, path, problems) {
1696
- if (!isRecord$3(item)) {
1729
+ if (!isRecord$4(item)) {
1697
1730
  problems.add(path, wrongType(item, "a contract"), "Write it as { id, severity, description, check }.");
1698
1731
  return false;
1699
1732
  }
@@ -1741,7 +1774,7 @@ function readContracts(path, raw, problems) {
1741
1774
  if (isContract(item, itemAt, problems)) {
1742
1775
  checkIdentity(item.id, isBuiltInContract(item), itemAt, ids, problems);
1743
1776
  contracts.push(item);
1744
- } else if (isRecord$3(item) && typeof item["id"] === "string" && CONTRACT_ID.test(item["id"])) checkIdentity(item["id"], false, itemAt, ids, problems);
1777
+ } else if (isRecord$4(item) && typeof item["id"] === "string" && CONTRACT_ID.test(item["id"])) checkIdentity(item["id"], false, itemAt, ids, problems);
1745
1778
  }
1746
1779
  return contracts;
1747
1780
  }
@@ -1757,7 +1790,7 @@ function readMaxMarkdownBytes(path, raw, problems) {
1757
1790
  */
1758
1791
  function readCollection(path, name, raw, site, problems) {
1759
1792
  if (!NAME.test(name)) problems.add(path, `${quote$1(name)} is not a valid collection name`, nameFix("blog"));
1760
- if (!isRecord$3(raw)) {
1793
+ if (!isRecord$4(raw)) {
1761
1794
  problems.add(path, wrongType(raw, "a collection"), "Declare it with defineCollection({ schema, routes, … }).");
1762
1795
  return null;
1763
1796
  }
@@ -1861,7 +1894,7 @@ function readDefaultLocale(path, raw, locales, problems) {
1861
1894
  const COLLECTIONS_FIX = "Declare at least one: collections: { blog: defineCollection({ schema, routes }) }.";
1862
1895
  function readCollections(path, raw, site, problems) {
1863
1896
  const collections = /* @__PURE__ */ new Map();
1864
- if (!isRecord$3(raw)) {
1897
+ if (!isRecord$4(raw)) {
1865
1898
  problems.add(path, wrongType(raw, "an object that maps each collection name to its collection"), COLLECTIONS_FIX);
1866
1899
  return collections;
1867
1900
  }
@@ -1877,7 +1910,7 @@ function readCollections(path, raw, site, problems) {
1877
1910
  function readSite(key, raw, problems) {
1878
1911
  const path = keyPath("sites", key);
1879
1912
  if (!isSiteKey(key)) problems.add(path, `${quote$1(key)} is not a valid site key`, SITE_KEY_FIX);
1880
- if (!isRecord$3(raw)) {
1913
+ if (!isRecord$4(raw)) {
1881
1914
  problems.add(path, wrongType(raw, "a site"), "Declare it as { locales: [\"en\"], defaultLocale: \"en\", collections: { … } }.");
1882
1915
  return null;
1883
1916
  }
@@ -1899,12 +1932,13 @@ function readSite(key, raw, problems) {
1899
1932
  collections,
1900
1933
  url,
1901
1934
  cors: readCors(keyPath(path, "cors"), raw["cors"], problems),
1902
- 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)
1903
1937
  };
1904
1938
  }
1905
1939
  function readSites(raw, problems) {
1906
1940
  const sites = /* @__PURE__ */ new Map();
1907
- if (!isRecord$3(raw)) {
1941
+ if (!isRecord$4(raw)) {
1908
1942
  problems.add("sites", wrongType(raw, "an object that maps the site key to the site"), SITE_FIX);
1909
1943
  return sites;
1910
1944
  }
@@ -1919,7 +1953,7 @@ function readSites(raw, problems) {
1919
1953
  /** `auth` of the config: absent, or an object whose `dynamicClientRegistration` is a boolean. */
1920
1954
  function readAuth(raw, problems) {
1921
1955
  if (raw === void 0) return { dynamicClientRegistration: false };
1922
- if (!isRecord$3(raw)) {
1956
+ if (!isRecord$4(raw)) {
1923
1957
  problems.add("auth", wrongType(raw, "an object"), "Write auth: { dynamicClientRegistration: true }, or leave auth out.");
1924
1958
  return { dynamicClientRegistration: false };
1925
1959
  }
@@ -1961,7 +1995,7 @@ function resolveConfig(config) {
1961
1995
  const problems = new Problems();
1962
1996
  let sites = /* @__PURE__ */ new Map();
1963
1997
  let auth = { dynamicClientRegistration: false };
1964
- if (isRecord$3(config)) {
1998
+ if (isRecord$4(config)) {
1965
1999
  sites = readSites(config["sites"], problems);
1966
2000
  auth = readAuth(config["auth"], problems);
1967
2001
  } else problems.add("config", wrongType(config, "an object"), "Export the result of defineConfig({ sites: { … } }) from hollware.config.ts.");
@@ -1972,6 +2006,69 @@ function resolveConfig(config) {
1972
2006
  };
1973
2007
  }
1974
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
1975
2072
  //#region src/structure/definition.ts
1976
2073
  /**
1977
2074
  * The definition of a site as data (ADR-033): what D1 stores for it and what the operations that
@@ -2021,6 +2118,13 @@ const siteDefinitionData = z.strictObject({
2021
2118
  webhooks: [],
2022
2119
  indexNow: null
2023
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."),
2024
2128
  collections: z.record(z.string(), collectionDefinitionData)
2025
2129
  });
2026
2130
  //#endregion
@@ -2043,19 +2147,19 @@ const SUMMARIES = /* @__PURE__ */ new Set([
2043
2147
  function segmentsOf$1(location) {
2044
2148
  return location.replace(/^#\/?/, "").split("/").filter((segment) => segment !== "").map((segment) => decodeURIComponent(segment).replaceAll("~1", "/").replaceAll("~0", "~"));
2045
2149
  }
2046
- function isRecord$1(value) {
2150
+ function isRecord$2(value) {
2047
2151
  return typeof value === "object" && value !== null && !Array.isArray(value);
2048
2152
  }
2049
2153
  /** The schema of the object at `segments`, to tell a declared key from an unknown one. */
2050
2154
  function declaredKeys(schema, segments) {
2051
2155
  let node = schema;
2052
2156
  for (const segment of segments) {
2053
- if (!isRecord$1(node)) return /* @__PURE__ */ new Set();
2157
+ if (!isRecord$2(node)) return /* @__PURE__ */ new Set();
2054
2158
  const properties = node["properties"];
2055
- node = isRecord$1(properties) && Object.hasOwn(properties, segment) ? properties[segment] : node["items"];
2159
+ node = isRecord$2(properties) && Object.hasOwn(properties, segment) ? properties[segment] : node["items"];
2056
2160
  }
2057
- const properties = isRecord$1(node) ? node["properties"] : void 0;
2058
- return new Set(isRecord$1(properties) ? Object.keys(properties) : []);
2161
+ const properties = isRecord$2(node) ? node["properties"] : void 0;
2162
+ return new Set(isRecord$2(properties) ? Object.keys(properties) : []);
2059
2163
  }
2060
2164
  /** The issues of a validation: one for each part that failed, at the path of the value. */
2061
2165
  function issuesOf(schema, errors) {
@@ -2167,7 +2271,8 @@ function compileSite(path, site, problems) {
2167
2271
  ...webhook.secret === void 0 ? {} : { secret: webhook.secret }
2168
2272
  })),
2169
2273
  ...site.events.indexNow === null ? {} : { indexNow: site.events.indexNow }
2170
- }
2274
+ },
2275
+ audit: site.audit
2171
2276
  };
2172
2277
  }
2173
2278
  /** The definitions of the sites, read from D1 as JSON, checked and resolved. */
@@ -2241,6 +2346,10 @@ function definitionsFromConfig(config) {
2241
2346
  webhooks: (site.events?.webhooks ?? []).map((webhook) => ({ ...webhook })),
2242
2347
  indexNow: site.events?.indexNow ?? null
2243
2348
  },
2349
+ ...site.audit === void 0 ? {} : { audit: {
2350
+ ipAddresses: site.audit.ipAddresses ?? "off",
2351
+ retentionDays: site.audit.retentionDays ?? null
2352
+ } },
2244
2353
  collections: Object.fromEntries(Object.entries(site.collections).map(([name, collection]) => [name, collectionOf(keyPath(keyPath(path, "collections"), name), collection, problems)]))
2245
2354
  };
2246
2355
  }
@@ -2388,6 +2497,10 @@ function sitePreset(preset, locales, defaultLocale) {
2388
2497
  webhooks: [],
2389
2498
  indexNow: null
2390
2499
  },
2500
+ audit: {
2501
+ ipAddresses: "off",
2502
+ retentionDays: null
2503
+ },
2391
2504
  collections
2392
2505
  };
2393
2506
  }
@@ -2402,6 +2515,7 @@ const ERROR_CODES = [
2402
2515
  "invalid_input",
2403
2516
  "unknown_collection",
2404
2517
  "unknown_locale",
2518
+ "invalid_preview",
2405
2519
  "insufficient_scope",
2406
2520
  "output_too_large",
2407
2521
  "timeout",
@@ -2431,6 +2545,7 @@ var HollwareError = class extends Error {
2431
2545
  const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
2432
2546
  /** The kinds of ids and their prefixes. A new kind adds its prefix here. */
2433
2547
  const ID_PREFIXES = {
2548
+ auditLine: "aud",
2434
2549
  challenge: "chl",
2435
2550
  delivery: "dlv",
2436
2551
  embedding: "emb",
@@ -3533,7 +3648,7 @@ function utf8ByteLength(text) {
3533
3648
  }
3534
3649
  return bytes;
3535
3650
  }
3536
- function isRecord(value) {
3651
+ function isRecord$1(value) {
3537
3652
  return typeof value === "object" && value !== null && !Array.isArray(value);
3538
3653
  }
3539
3654
  function describe(value) {
@@ -3566,8 +3681,8 @@ function linesBeforeBody(source, body) {
3566
3681
  function yamlViolation(error, source) {
3567
3682
  const reason = Reflect.get(error, "reason");
3568
3683
  const mark = Reflect.get(error, "mark");
3569
- const markLine = isRecord(mark) ? mark.line : void 0;
3570
- 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;
3571
3686
  const firstLine = source.startsWith("---\r\n") ? 1 : 2;
3572
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);
3573
3688
  }
@@ -3593,7 +3708,7 @@ async function run(markdown, options) {
3593
3708
  };
3594
3709
  }
3595
3710
  const frontmatter = split.data;
3596
- if (!isRecord(frontmatter)) return {
3711
+ if (!isRecord$1(frontmatter)) return {
3597
3712
  ok: false,
3598
3713
  violations: [frontmatterNotMapping(describe(frontmatter))]
3599
3714
  };
@@ -3700,7 +3815,20 @@ const URL_ATTRIBUTES = /* @__PURE__ */ new Set([
3700
3815
  function isProps(value) {
3701
3816
  return typeof value === "object" && value !== null && !Array.isArray(value);
3702
3817
  }
3703
- function sanitizeProps(raw) {
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) {
3704
3832
  const props = {};
3705
3833
  if (!isProps(raw)) return props;
3706
3834
  for (const key of Object.keys(raw)) {
@@ -3709,8 +3837,15 @@ function sanitizeProps(raw) {
3709
3837
  if (isForbiddenAttribute(name)) continue;
3710
3838
  const value = raw[key];
3711
3839
  if (URL_ATTRIBUTES.has(name)) {
3840
+ const kind = name === "src" ? "image" : "link";
3712
3841
  const url = urlValue(key, value);
3713
- if (url === void 0 || !isSafeUrl(url, name === "src" ? "image" : "link")) continue;
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("&", "&amp;"));
3846
+ continue;
3847
+ }
3848
+ if (!isSafeUrl(url, kind)) continue;
3714
3849
  }
3715
3850
  setOwn(props, key, typeof value === "string" && !isBinding(key) ? value.replaceAll("&", "&amp;") : value);
3716
3851
  }
@@ -3722,19 +3857,19 @@ function marker(name) {
3722
3857
  name: TAG_NAME.test(name) ? shorten(name) : "?"
3723
3858
  }];
3724
3859
  }
3725
- function sanitizeElement(node, depth) {
3860
+ function sanitizeElement(node, depth, data) {
3726
3861
  const tag = node[0];
3727
3862
  if (typeof tag !== "string") return void 0;
3728
3863
  if (!NATIVE_TAGS.has(tag) && tag !== "hollware-marker") {
3729
3864
  if (!TAG_NAME.test(tag) || DANGEROUS_TAGS.has(tag.toLowerCase())) return marker(tag);
3730
3865
  }
3731
- const element = [tag, sanitizeProps(node[1])];
3866
+ const element = [tag, sanitizeProps(node[1], data)];
3732
3867
  if (depth >= 100) return element;
3733
3868
  for (let at = 2; at < node.length; at++) {
3734
3869
  const child = node[at];
3735
3870
  if (typeof child === "string") element.push(child);
3736
3871
  else if (Array.isArray(child)) {
3737
- const clean = sanitizeElement(child, depth + 1);
3872
+ const clean = sanitizeElement(child, depth + 1, data);
3738
3873
  if (clean !== void 0) element.push(clean);
3739
3874
  }
3740
3875
  }
@@ -3743,10 +3878,14 @@ function sanitizeElement(node, depth) {
3743
3878
  /** Copies a document through the rules of this file. */
3744
3879
  function sanitizeDocument(document) {
3745
3880
  const nodes = [];
3881
+ const data = {
3882
+ frontmatter: document.frontmatter,
3883
+ meta: document.meta
3884
+ };
3746
3885
  if (Array.isArray(document.nodes)) {
3747
3886
  for (const node of document.nodes) if (typeof node === "string") nodes.push(node);
3748
3887
  else if (Array.isArray(node)) {
3749
- const clean = sanitizeElement(node, 1);
3888
+ const clean = sanitizeElement(node, 1, data);
3750
3889
  if (clean !== void 0) nodes.push(clean);
3751
3890
  }
3752
3891
  }
@@ -4681,13 +4820,13 @@ const TIMESTAMP_HEADER = "hollware-timestamp";
4681
4820
  const EVENT_ID_HEADER = "hollware-event-id";
4682
4821
  /** How far a timestamp may be from the receiver's clock, by default: five minutes. */
4683
4822
  const TOLERANCE_SECONDS = 300;
4684
- const encoder = new TextEncoder();
4823
+ const encoder$1 = new TextEncoder();
4685
4824
  async function hmac(secret, text) {
4686
- const key = await crypto.subtle.importKey("raw", encoder.encode(secret), {
4825
+ const key = await crypto.subtle.importKey("raw", encoder$1.encode(secret), {
4687
4826
  name: "HMAC",
4688
4827
  hash: "SHA-256"
4689
4828
  }, false, ["sign"]);
4690
- 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("");
4691
4830
  }
4692
4831
  /** The value of `hollware-signature` for a body sent at `timestamp` (seconds). */
4693
4832
  async function signWebhook(secret, timestamp, body) {
@@ -4700,9 +4839,20 @@ function sameText(a, b) {
4700
4839
  for (let index = 0; index < a.length; index++) difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
4701
4840
  return difference === 0;
4702
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
+ }
4703
4852
  /**
4704
4853
  * Whether a request is a webhook of Hollware: its signature matches the body and the secret, and
4705
- * 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.
4706
4856
  */
4707
4857
  async function verifyWebhook(check) {
4708
4858
  const signature = check.headers.get(SIGNATURE_HEADER);
@@ -4710,7 +4860,139 @@ async function verifyWebhook(check) {
4710
4860
  if (signature === null || !Number.isInteger(timestamp)) return false;
4711
4861
  const now = check.now ?? Math.floor(Date.now() / 1e3);
4712
4862
  if (Math.abs(now - timestamp) > (check.toleranceSeconds ?? TOLERANCE_SECONDS)) return false;
4713
- return sameText(signature, await signWebhook(check.secret, timestamp, check.body));
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
+ };
4714
4996
  }
4715
4997
  //#endregion
4716
4998
  //#region src/events.ts
@@ -4835,4 +5117,4 @@ function tagsOfSiteChange(collections, locales) {
4835
5117
  //#region src/index.ts
4836
5118
  const name = "@hollware/core";
4837
5119
  //#endregion
4838
- 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, INDEXNOW_KEY, 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, deleteEntry, describeBuiltInContracts, describeCatalog, describeContract, describeResolvedSite, describeSite, describedSchemaOf, entryEvent, entryTag, generatePersonalToken, generateReadToken, getEntry, getTranslations, groupTag, hashPersonalToken, hollwareEvent, isBuiltInContract, isId, isPersonalToken, isReadToken, isScope, isSiteKey, listEntries, listReviews, listTag, 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, 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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hollware/core",
3
- "version": "0.10.0",
3
+ "version": "1.0.0-rc.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": {
@@ -16,6 +16,7 @@
16
16
  "dist"
17
17
  ],
18
18
  "type": "module",
19
+ "sideEffects": false,
19
20
  "exports": {
20
21
  ".": {
21
22
  "types": "./dist/index.d.mts",