@hollware/core 1.0.0-rc.0 → 1.0.0-rc.2

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
@@ -1,3 +1,4 @@
1
+ import { EVENT_ID_HEADER, SIGNATURE_HEADER, TIMESTAMP_HEADER, WebhookCheck, signWebhook, verifyWebhook } from "./webhooks.mjs";
1
2
  import * as z from "zod";
2
3
  import "@cfworker/json-schema";
3
4
  import { MarkdownDocument } from "comark";
@@ -327,6 +328,12 @@ interface SiteDefinition {
327
328
  /** How many days a line is kept, 30 to 3650; `null` keeps the log as long as the site. */
328
329
  readonly retentionDays?: number | null;
329
330
  };
331
+ /** How the site converts the images it receives (ADR-038): WebP at 85, 2400 pixels wide. */
332
+ readonly media?: {
333
+ readonly format?: 'webp' | 'avif' | 'original';
334
+ readonly quality?: number;
335
+ readonly maxWidth?: number;
336
+ };
330
337
  }
331
338
  /** What `hollware.config.ts` exports. */
332
339
  interface HollwareConfig {
@@ -377,6 +384,16 @@ interface ResolvedSite {
377
384
  };
378
385
  readonly events: ResolvedEvents;
379
386
  readonly audit: ResolvedAudit;
387
+ readonly media: ResolvedMedia;
388
+ }
389
+ /** How a site converts the images it receives (ADR-038). */
390
+ interface ResolvedMedia {
391
+ /** `original` keeps every file as it came; the others convert JPEG, PNG and GIF. */
392
+ readonly format: 'webp' | 'avif' | 'original';
393
+ /** The quality of photographs, 1 to 100; PNG is converted lossless whatever it says. */
394
+ readonly quality: number;
395
+ /** The widest an image is kept, in pixels; a wider one is scaled down, never up. */
396
+ readonly maxWidth: number;
380
397
  }
381
398
  /** What the activity log of a site keeps (ADR-037). */
382
399
  interface ResolvedAudit {
@@ -471,6 +488,16 @@ export declare const AUDIT_RETENTION_MAX_DAYS = 3650;
471
488
  /** What a site that says nothing keeps: no addresses, and the log for as long as the site exists. */
472
489
  export declare const DEFAULT_AUDIT: ResolvedAudit;
473
490
  //#endregion
491
+ //#region src/config/media.d.ts
492
+ /** The formats a site may convert its images to, or keep them as they came (ADR-038). */
493
+ export declare const MEDIA_FORMATS: readonly ['webp', 'avif', 'original'];
494
+ /** The narrowest an image may be scaled down to: smaller would blur on every screen. */
495
+ export declare const MEDIA_MIN_WIDTH = 320;
496
+ /** The widest: past it the binding's area limit comes first, and no page shows more. */
497
+ export declare const MEDIA_MAX_WIDTH = 8192;
498
+ /** What a site that says nothing gets: WebP at 85, and nothing wider than 2400 pixels. */
499
+ export declare const DEFAULT_MEDIA: ResolvedMedia;
500
+ //#endregion
474
501
  //#region src/config/constants.d.ts
475
502
  /** The largest Markdown file a collection accepts when its config does not say, 512 KiB. */
476
503
  export declare const DEFAULT_MAX_MARKDOWN_BYTES = 524288;
@@ -1151,7 +1178,7 @@ export declare function isSiteKey(value: unknown): value is string;
1151
1178
  * operation or route that changes something is not logged until it is named here.
1152
1179
  */
1153
1180
  /** 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'];
1181
+ 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', 'upload_asset'];
1155
1182
  /** What people do outside the operations: their tokens, members, passkeys, sessions and apps. */
1156
1183
  export declare const IDENTITY_AUDIT_ACTIONS: readonly ['token.create',
1157
1184
  /** A person asked for a link that shows an entry's latest revision on the site (T-103). */
@@ -1160,7 +1187,7 @@ export declare const IDENTITY_AUDIT_ACTIONS: readonly ['token.create',
1160
1187
  'app.refresh_reused',
1161
1188
  /** The cron published an entry whose time had come, for the person who scheduled it. */
1162
1189
  '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"];
1190
+ 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", "upload_asset", "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
1191
  export type AuditAction = (typeof AUDIT_ACTIONS)[number];
1165
1192
  /** `denied`: a scope or a role was missing; `failed`: any other error the caller got. */
1166
1193
  export declare const AUDIT_OUTCOMES: readonly ['ok', 'denied', 'failed'];
@@ -1245,6 +1272,15 @@ export declare const siteDefinitionData: z.ZodObject<{
1245
1272
  }>>;
1246
1273
  retentionDays: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
1247
1274
  }, z.core.$strict>>;
1275
+ media: z.ZodDefault<z.ZodObject<{
1276
+ format: z.ZodDefault<z.ZodEnum<{
1277
+ avif: "avif";
1278
+ original: "original";
1279
+ webp: "webp";
1280
+ }>>;
1281
+ quality: z.ZodDefault<z.ZodNumber>;
1282
+ maxWidth: z.ZodDefault<z.ZodNumber>;
1283
+ }, z.core.$strict>>;
1248
1284
  collections: z.ZodRecord<z.ZodString, z.ZodObject<{
1249
1285
  schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
1250
1286
  routes: z.ZodRecord<z.ZodString, z.ZodString>;
@@ -1351,6 +1387,7 @@ export declare class HollwareError extends Error {
1351
1387
  */
1352
1388
  /** The kinds of ids and their prefixes. A new kind adds its prefix here. */
1353
1389
  export declare const ID_PREFIXES: {
1390
+ readonly asset: 'ast';
1354
1391
  readonly auditLine: 'aud';
1355
1392
  readonly challenge: 'chl';
1356
1393
  readonly delivery: 'dlv';
@@ -2420,6 +2457,123 @@ export declare const contentStats: OperationSpec<"content_stats", z.ZodObject<{
2420
2457
  }, z.core.$strip>>;
2421
2458
  }, z.core.$strip>>>;
2422
2459
  }, z.core.$strip>>;
2460
+ export declare const uploadAsset: OperationSpec<"upload_asset", z.ZodObject<{
2461
+ filename: z.ZodString;
2462
+ alt: z.ZodString;
2463
+ data: z.ZodString;
2464
+ }, z.core.$strict>, z.ZodObject<{
2465
+ outcome: z.ZodEnum<{
2466
+ created: "created";
2467
+ exists: "exists";
2468
+ refused: "refused";
2469
+ unavailable: "unavailable";
2470
+ }>;
2471
+ asset: z.ZodOptional<z.ZodObject<{
2472
+ id: z.ZodString;
2473
+ reference: z.ZodString;
2474
+ url: z.ZodString;
2475
+ format: z.ZodEnum<{
2476
+ "image/avif": "image/avif";
2477
+ "image/gif": "image/gif";
2478
+ "image/jpeg": "image/jpeg";
2479
+ "image/png": "image/png";
2480
+ "image/svg+xml": "image/svg+xml";
2481
+ "image/webp": "image/webp";
2482
+ }>;
2483
+ width: z.ZodNullable<z.ZodNumber>;
2484
+ height: z.ZodNullable<z.ZodNumber>;
2485
+ bytes: z.ZodNumber;
2486
+ originalFormat: z.ZodEnum<{
2487
+ "image/avif": "image/avif";
2488
+ "image/gif": "image/gif";
2489
+ "image/jpeg": "image/jpeg";
2490
+ "image/png": "image/png";
2491
+ "image/svg+xml": "image/svg+xml";
2492
+ "image/webp": "image/webp";
2493
+ }>;
2494
+ originalBytes: z.ZodNumber;
2495
+ alt: z.ZodString;
2496
+ filename: z.ZodString;
2497
+ createdAt: z.ZodString;
2498
+ }, z.core.$strip>>;
2499
+ problem: z.ZodOptional<z.ZodObject<{
2500
+ message: z.ZodString;
2501
+ fix: z.ZodString;
2502
+ }, z.core.$strip>>;
2503
+ }, z.core.$strip>>;
2504
+ export declare const listAssets: OperationSpec<"list_assets", z.ZodObject<{
2505
+ limit: z.ZodDefault<z.ZodNumber>;
2506
+ cursor: z.ZodOptional<z.ZodString>;
2507
+ }, z.core.$strict>, z.ZodObject<{
2508
+ outcome: z.ZodEnum<{
2509
+ ok: "ok";
2510
+ }>;
2511
+ assets: z.ZodArray<z.ZodObject<{
2512
+ id: z.ZodString;
2513
+ reference: z.ZodString;
2514
+ url: z.ZodString;
2515
+ format: z.ZodEnum<{
2516
+ "image/avif": "image/avif";
2517
+ "image/gif": "image/gif";
2518
+ "image/jpeg": "image/jpeg";
2519
+ "image/png": "image/png";
2520
+ "image/svg+xml": "image/svg+xml";
2521
+ "image/webp": "image/webp";
2522
+ }>;
2523
+ width: z.ZodNullable<z.ZodNumber>;
2524
+ height: z.ZodNullable<z.ZodNumber>;
2525
+ bytes: z.ZodNumber;
2526
+ originalFormat: z.ZodEnum<{
2527
+ "image/avif": "image/avif";
2528
+ "image/gif": "image/gif";
2529
+ "image/jpeg": "image/jpeg";
2530
+ "image/png": "image/png";
2531
+ "image/svg+xml": "image/svg+xml";
2532
+ "image/webp": "image/webp";
2533
+ }>;
2534
+ originalBytes: z.ZodNumber;
2535
+ alt: z.ZodString;
2536
+ filename: z.ZodString;
2537
+ createdAt: z.ZodString;
2538
+ }, z.core.$strip>>;
2539
+ nextCursor: z.ZodNullable<z.ZodString>;
2540
+ }, z.core.$strip>>;
2541
+ export declare const getAsset: OperationSpec<"get_asset", z.ZodObject<{
2542
+ id: z.ZodString;
2543
+ }, z.core.$strict>, z.ZodObject<{
2544
+ outcome: z.ZodEnum<{
2545
+ not_found: "not_found";
2546
+ ok: "ok";
2547
+ }>;
2548
+ asset: z.ZodOptional<z.ZodObject<{
2549
+ id: z.ZodString;
2550
+ reference: z.ZodString;
2551
+ url: z.ZodString;
2552
+ format: z.ZodEnum<{
2553
+ "image/avif": "image/avif";
2554
+ "image/gif": "image/gif";
2555
+ "image/jpeg": "image/jpeg";
2556
+ "image/png": "image/png";
2557
+ "image/svg+xml": "image/svg+xml";
2558
+ "image/webp": "image/webp";
2559
+ }>;
2560
+ width: z.ZodNullable<z.ZodNumber>;
2561
+ height: z.ZodNullable<z.ZodNumber>;
2562
+ bytes: z.ZodNumber;
2563
+ originalFormat: z.ZodEnum<{
2564
+ "image/avif": "image/avif";
2565
+ "image/gif": "image/gif";
2566
+ "image/jpeg": "image/jpeg";
2567
+ "image/png": "image/png";
2568
+ "image/svg+xml": "image/svg+xml";
2569
+ "image/webp": "image/webp";
2570
+ }>;
2571
+ originalBytes: z.ZodNumber;
2572
+ alt: z.ZodString;
2573
+ filename: z.ZodString;
2574
+ createdAt: z.ZodString;
2575
+ }, z.core.$strip>>;
2576
+ }, z.core.$strip>>;
2423
2577
  /** Every operation, sorted by name: the order of `tools/list`. */
2424
2578
  export declare const OPERATIONS: readonly [OperationSpec<"approve_review", z.ZodObject<{
2425
2579
  id: z.ZodString;
@@ -2640,6 +2794,15 @@ export declare const OPERATIONS: readonly [OperationSpec<"approve_review", z.Zod
2640
2794
  }>>;
2641
2795
  retentionDays: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
2642
2796
  }, z.core.$strict>>;
2797
+ media: z.ZodDefault<z.ZodObject<{
2798
+ format: z.ZodDefault<z.ZodEnum<{
2799
+ avif: "avif";
2800
+ original: "original";
2801
+ webp: "webp";
2802
+ }>>;
2803
+ quality: z.ZodDefault<z.ZodNumber>;
2804
+ maxWidth: z.ZodDefault<z.ZodNumber>;
2805
+ }, z.core.$strict>>;
2643
2806
  collections: z.ZodRecord<z.ZodString, z.ZodObject<{
2644
2807
  schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
2645
2808
  routes: z.ZodRecord<z.ZodString, z.ZodString>;
@@ -2759,6 +2922,41 @@ export declare const OPERATIONS: readonly [OperationSpec<"approve_review", z.Zod
2759
2922
  styleGuide: z.ZodNullable<z.ZodString>;
2760
2923
  }, z.core.$strip>>;
2761
2924
  }, z.core.$strip>;
2925
+ }, z.core.$strip>>, OperationSpec<"get_asset", z.ZodObject<{
2926
+ id: z.ZodString;
2927
+ }, z.core.$strict>, z.ZodObject<{
2928
+ outcome: z.ZodEnum<{
2929
+ not_found: "not_found";
2930
+ ok: "ok";
2931
+ }>;
2932
+ asset: z.ZodOptional<z.ZodObject<{
2933
+ id: z.ZodString;
2934
+ reference: z.ZodString;
2935
+ url: z.ZodString;
2936
+ format: z.ZodEnum<{
2937
+ "image/avif": "image/avif";
2938
+ "image/gif": "image/gif";
2939
+ "image/jpeg": "image/jpeg";
2940
+ "image/png": "image/png";
2941
+ "image/svg+xml": "image/svg+xml";
2942
+ "image/webp": "image/webp";
2943
+ }>;
2944
+ width: z.ZodNullable<z.ZodNumber>;
2945
+ height: z.ZodNullable<z.ZodNumber>;
2946
+ bytes: z.ZodNumber;
2947
+ originalFormat: z.ZodEnum<{
2948
+ "image/avif": "image/avif";
2949
+ "image/gif": "image/gif";
2950
+ "image/jpeg": "image/jpeg";
2951
+ "image/png": "image/png";
2952
+ "image/svg+xml": "image/svg+xml";
2953
+ "image/webp": "image/webp";
2954
+ }>;
2955
+ originalBytes: z.ZodNumber;
2956
+ alt: z.ZodString;
2957
+ filename: z.ZodString;
2958
+ createdAt: z.ZodString;
2959
+ }, z.core.$strip>>;
2762
2960
  }, z.core.$strip>>, OperationSpec<"get_entry", z.ZodObject<{
2763
2961
  collection: z.ZodString;
2764
2962
  locale: z.ZodString;
@@ -2922,6 +3120,15 @@ export declare const OPERATIONS: readonly [OperationSpec<"approve_review", z.Zod
2922
3120
  }>>;
2923
3121
  retentionDays: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
2924
3122
  }, z.core.$strict>>;
3123
+ media: z.ZodDefault<z.ZodObject<{
3124
+ format: z.ZodDefault<z.ZodEnum<{
3125
+ avif: "avif";
3126
+ original: "original";
3127
+ webp: "webp";
3128
+ }>>;
3129
+ quality: z.ZodDefault<z.ZodNumber>;
3130
+ maxWidth: z.ZodDefault<z.ZodNumber>;
3131
+ }, z.core.$strict>>;
2925
3132
  collections: z.ZodRecord<z.ZodString, z.ZodObject<{
2926
3133
  schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
2927
3134
  routes: z.ZodRecord<z.ZodString, z.ZodString>;
@@ -2979,6 +3186,42 @@ export declare const OPERATIONS: readonly [OperationSpec<"approve_review", z.Zod
2979
3186
  deletedAt: z.ZodNullable<z.ZodString>;
2980
3187
  updatedAt: z.ZodString;
2981
3188
  }, z.core.$strip>>>;
3189
+ }, z.core.$strip>>, OperationSpec<"list_assets", z.ZodObject<{
3190
+ limit: z.ZodDefault<z.ZodNumber>;
3191
+ cursor: z.ZodOptional<z.ZodString>;
3192
+ }, z.core.$strict>, z.ZodObject<{
3193
+ outcome: z.ZodEnum<{
3194
+ ok: "ok";
3195
+ }>;
3196
+ assets: z.ZodArray<z.ZodObject<{
3197
+ id: z.ZodString;
3198
+ reference: z.ZodString;
3199
+ url: z.ZodString;
3200
+ format: z.ZodEnum<{
3201
+ "image/avif": "image/avif";
3202
+ "image/gif": "image/gif";
3203
+ "image/jpeg": "image/jpeg";
3204
+ "image/png": "image/png";
3205
+ "image/svg+xml": "image/svg+xml";
3206
+ "image/webp": "image/webp";
3207
+ }>;
3208
+ width: z.ZodNullable<z.ZodNumber>;
3209
+ height: z.ZodNullable<z.ZodNumber>;
3210
+ bytes: z.ZodNumber;
3211
+ originalFormat: z.ZodEnum<{
3212
+ "image/avif": "image/avif";
3213
+ "image/gif": "image/gif";
3214
+ "image/jpeg": "image/jpeg";
3215
+ "image/png": "image/png";
3216
+ "image/svg+xml": "image/svg+xml";
3217
+ "image/webp": "image/webp";
3218
+ }>;
3219
+ originalBytes: z.ZodNumber;
3220
+ alt: z.ZodString;
3221
+ filename: z.ZodString;
3222
+ createdAt: z.ZodString;
3223
+ }, z.core.$strip>>;
3224
+ nextCursor: z.ZodNullable<z.ZodString>;
2982
3225
  }, z.core.$strip>>, OperationSpec<"list_entries", z.ZodObject<{
2983
3226
  collection: z.ZodString;
2984
3227
  locale: z.ZodOptional<z.ZodString>;
@@ -3423,6 +3666,8 @@ export declare const OPERATIONS: readonly [OperationSpec<"approve_review", z.Zod
3423
3666
  }, z.core.$strip>>;
3424
3667
  }, z.core.$strip>>;
3425
3668
  currentVersion: z.ZodOptional<z.ZodNumber>;
3669
+ message: z.ZodOptional<z.ZodString>;
3670
+ fix: z.ZodOptional<z.ZodString>;
3426
3671
  }, z.core.$strip>>, OperationSpec<"unpublish", z.ZodObject<{
3427
3672
  id: z.ZodString;
3428
3673
  }, z.core.$strict>, z.ZodObject<{
@@ -3543,6 +3788,15 @@ export declare const OPERATIONS: readonly [OperationSpec<"approve_review", z.Zod
3543
3788
  }>>;
3544
3789
  retentionDays: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
3545
3790
  }, z.core.$strict>>;
3791
+ media: z.ZodDefault<z.ZodObject<{
3792
+ format: z.ZodDefault<z.ZodEnum<{
3793
+ avif: "avif";
3794
+ original: "original";
3795
+ webp: "webp";
3796
+ }>>;
3797
+ quality: z.ZodDefault<z.ZodNumber>;
3798
+ maxWidth: z.ZodDefault<z.ZodNumber>;
3799
+ }, z.core.$strict>>;
3546
3800
  collections: z.ZodRecord<z.ZodString, z.ZodObject<{
3547
3801
  schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
3548
3802
  routes: z.ZodRecord<z.ZodString, z.ZodString>;
@@ -3598,6 +3852,51 @@ export declare const OPERATIONS: readonly [OperationSpec<"approve_review", z.Zod
3598
3852
  }, z.core.$strip>>;
3599
3853
  }, z.core.$strip>>;
3600
3854
  currentVersion: z.ZodOptional<z.ZodNumber>;
3855
+ message: z.ZodOptional<z.ZodString>;
3856
+ fix: z.ZodOptional<z.ZodString>;
3857
+ }, z.core.$strip>>, OperationSpec<"upload_asset", z.ZodObject<{
3858
+ filename: z.ZodString;
3859
+ alt: z.ZodString;
3860
+ data: z.ZodString;
3861
+ }, z.core.$strict>, z.ZodObject<{
3862
+ outcome: z.ZodEnum<{
3863
+ created: "created";
3864
+ exists: "exists";
3865
+ refused: "refused";
3866
+ unavailable: "unavailable";
3867
+ }>;
3868
+ asset: z.ZodOptional<z.ZodObject<{
3869
+ id: z.ZodString;
3870
+ reference: z.ZodString;
3871
+ url: z.ZodString;
3872
+ format: z.ZodEnum<{
3873
+ "image/avif": "image/avif";
3874
+ "image/gif": "image/gif";
3875
+ "image/jpeg": "image/jpeg";
3876
+ "image/png": "image/png";
3877
+ "image/svg+xml": "image/svg+xml";
3878
+ "image/webp": "image/webp";
3879
+ }>;
3880
+ width: z.ZodNullable<z.ZodNumber>;
3881
+ height: z.ZodNullable<z.ZodNumber>;
3882
+ bytes: z.ZodNumber;
3883
+ originalFormat: z.ZodEnum<{
3884
+ "image/avif": "image/avif";
3885
+ "image/gif": "image/gif";
3886
+ "image/jpeg": "image/jpeg";
3887
+ "image/png": "image/png";
3888
+ "image/svg+xml": "image/svg+xml";
3889
+ "image/webp": "image/webp";
3890
+ }>;
3891
+ originalBytes: z.ZodNumber;
3892
+ alt: z.ZodString;
3893
+ filename: z.ZodString;
3894
+ createdAt: z.ZodString;
3895
+ }, z.core.$strip>>;
3896
+ problem: z.ZodOptional<z.ZodObject<{
3897
+ message: z.ZodString;
3898
+ fix: z.ZodString;
3899
+ }, z.core.$strip>>;
3601
3900
  }, z.core.$strip>>, OperationSpec<"validate", z.ZodObject<{
3602
3901
  collection: z.ZodString;
3603
3902
  locale: z.ZodString;
@@ -3675,41 +3974,56 @@ export declare function isReadToken(value: string): boolean;
3675
3974
  /** The SHA-256 of a token, in lowercase hex: what the `tokens` table stores and looks up. */
3676
3975
  export declare function hashPersonalToken(token: string): Promise<string>;
3677
3976
  //#endregion
3678
- //#region src/webhooks.d.ts
3679
- /**
3680
- * The signature of Hollware's webhooks (ADR-025), for the Worker that sends them and for whoever
3681
- * receives them: HMAC-SHA256 of `<timestamp>.<body>` with the webhook's secret, sent as
3682
- * `hollware-signature: v1=<hex>` with `hollware-timestamp` (seconds since the epoch) and
3683
- * `hollware-event-id`. Web Crypto only, so it runs in any runtime.
3684
- */
3685
- export declare const SIGNATURE_HEADER = "hollware-signature";
3686
- export declare const TIMESTAMP_HEADER = "hollware-timestamp";
3687
- export declare const EVENT_ID_HEADER = "hollware-event-id";
3688
- /** The value of `hollware-signature` for a body sent at `timestamp` (seconds). */
3689
- export declare function signWebhook(secret: string, timestamp: number, body: string): Promise<string>;
3690
- export interface WebhookCheck {
3691
- readonly secret: string;
3692
- /** The raw body, as received: a parsed and re-serialized body no longer matches. */
3693
- readonly body: string;
3694
- readonly headers: Headers;
3695
- /** Now, in seconds since the epoch; the system clock when absent. */
3696
- readonly now?: number;
3697
- readonly toleranceSeconds?: number;
3977
+ //#region src/media/index.d.ts
3978
+ /** The kinds of image Hollware takes, by media type. */
3979
+ export declare const IMAGE_FORMATS: readonly ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif', 'image/svg+xml'];
3980
+ export type ImageFormat = (typeof IMAGE_FORMATS)[number];
3981
+ /** The largest file `upload_asset` takes, in bytes: the binding takes 20 MB, this keeps room. */
3982
+ export declare const MAX_ASSET_BYTES: number;
3983
+ /** The extension a format is served and exported with. */
3984
+ export declare const EXTENSIONS: Readonly<Record<ImageFormat, string>>;
3985
+ /**
3986
+ * The format of a file, read from its bytes and never from its name or a header: `null` for
3987
+ * anything else. An SVG is text that opens on `<svg` or on an XML declaration followed by one.
3988
+ */
3989
+ export declare function sniffImage(bytes: Uint8Array): ImageFormat | null;
3990
+ /** Why an SVG is refused, and what to do: an SVG is taken only when nothing in it can run. */
3991
+ export interface SvgProblem {
3992
+ readonly reason: string;
3993
+ readonly fix: string;
3994
+ }
3995
+ /** `null` when an SVG holds nothing that can run or reach out, else the first thing that can. */
3996
+ export declare function checkSvg(source: string): SvgProblem | null;
3997
+ /** What the binding is asked for, or `null` to keep the file as it came. */
3998
+ export interface Conversion {
3999
+ readonly format: 'image/webp' | 'image/avif';
3698
4000
  /**
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.
4001
+ * Always given: the binding's default is lossless, not 85 (ADR-038). 100 for PNG, which holds
4002
+ * screenshots, diagrams and logos: lossless keeps their edges.
3704
4003
  */
3705
- readonly isNew?: (eventId: string) => boolean | Promise<boolean>;
4004
+ readonly quality: number;
4005
+ /** Always given: without it the binding keeps one frame of an animation. */
4006
+ readonly anim: boolean;
4007
+ /** The width to scale down to, when the image is wider than the site allows. */
4008
+ readonly width?: number;
3706
4009
  }
3707
4010
  /**
3708
- * Whether a request is a webhook of Hollware: its signature matches the body and the secret, and
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.
4011
+ * What a file of `format`, `width` pixels wide (null for an SVG), becomes under `media`: JPEG,
4012
+ * PNG and GIF become the site's format; a WebP or an AVIF keeps its own and is only scaled down
4013
+ * when it is wider than the site allows; `original` and SVG keep every file as it came.
4014
+ */
4015
+ export declare function conversionOf(format: ImageFormat, width: number | null, media: ResolvedMedia): Conversion | null;
4016
+ /** The id of an asset, from the SHA-256 of its original bytes: the same file is the same asset. */
4017
+ export declare function assetIdOf(sha256Hex: string): string;
4018
+ /** What content writes to name an asset: `asset:ast_…`. */
4019
+ export declare const ASSET_SCHEME = "asset:";
4020
+ /** The id an `asset:` reference names, or `null` when the value is no such reference. */
4021
+ export declare function assetIdIn(value: string): string | null;
4022
+ /**
4023
+ * A copy of a value (a frontmatter, a document) with every string that is an `asset:` reference
4024
+ * replaced by the URL `urlOf` gives its id. Everything else is copied as it is.
3711
4025
  */
3712
- export declare function verifyWebhook(check: WebhookCheck): Promise<boolean>;
4026
+ export declare function resolveAssetReferences<T>(value: T, urlOf: (id: string) => string): T;
3713
4027
  //#endregion
3714
4028
  //#region src/preview.d.ts
3715
4029
  /**
@@ -3905,4 +4219,4 @@ export declare function tagsOfSiteChange(collections: readonly string[], locales
3905
4219
  //#region src/index.d.ts
3906
4220
  export declare const name = "@hollware/core";
3907
4221
  //#endregion
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 };
4222
+ 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, EVENT_ID_HEADER, 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, SIGNATURE_HEADER, type SchemaOutput, type Scope, type Severity, type SiteDefinition, type SiteDefinitionData, type SiteDefinitionInput, type SiteDescription, type SiteEvent, TIMESTAMP_HEADER, type TaggedEntry, type Verdict, type Violation, WebhookCheck, schemas_d_exports as schemas, signWebhook, verifyWebhook };
package/dist/index.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
+ import { EVENT_ID_HEADER, SIGNATURE_HEADER, TIMESTAMP_HEADER, signWebhook, verifyWebhook } from "./webhooks.mjs";
2
3
  import * as z from "zod";
3
4
  import { Validator } from "@cfworker/json-schema";
4
5
  //#region src/config/define.ts
@@ -252,6 +253,60 @@ function readAudit(path, raw, problems) {
252
253
  };
253
254
  }
254
255
  //#endregion
256
+ //#region src/config/media.ts
257
+ /** The formats a site may convert its images to, or keep them as they came (ADR-038). */
258
+ const MEDIA_FORMATS = [
259
+ "webp",
260
+ "avif",
261
+ "original"
262
+ ];
263
+ /** The narrowest an image may be scaled down to: smaller would blur on every screen. */
264
+ const MEDIA_MIN_WIDTH = 320;
265
+ /** The widest: past it the binding's area limit comes first, and no page shows more. */
266
+ const MEDIA_MAX_WIDTH = 8192;
267
+ /** What a site that says nothing gets: WebP at 85, and nothing wider than 2400 pixels. */
268
+ const DEFAULT_MEDIA = {
269
+ format: "webp",
270
+ quality: 85,
271
+ maxWidth: 2400
272
+ };
273
+ const FIX = "Declare it as { format: \"webp\", quality: 85, maxWidth: 2400 }, or remove it.";
274
+ /** How a site converts the images it receives (ADR-038). */
275
+ function readMedia(path, raw, problems) {
276
+ if (raw === void 0 || raw === null) return DEFAULT_MEDIA;
277
+ if (!isRecord$4(raw)) {
278
+ problems.add(path, wrongType(raw, "an object"), FIX);
279
+ return DEFAULT_MEDIA;
280
+ }
281
+ let { format, quality, maxWidth } = DEFAULT_MEDIA;
282
+ const rawFormat = raw["format"];
283
+ if (rawFormat !== void 0) {
284
+ if (MEDIA_FORMATS.some((candidate) => candidate === rawFormat)) format = rawFormat;
285
+ else problems.add(keyPath(path, "format"), wrongType(rawFormat, "\"webp\", \"avif\" or \"original\""), "Use \"webp\" or \"avif\" to convert the images a site receives, or \"original\" to keep them as they came.");
286
+ }
287
+ const rawQuality = raw["quality"];
288
+ if (rawQuality !== void 0) {
289
+ if (isWhole(rawQuality) && rawQuality >= 1 && rawQuality <= 100) quality = rawQuality;
290
+ else problems.add(keyPath(path, "quality"), `${show(rawQuality)} is not a whole number from 1 to 100`, "Give the quality of photographs, 85 by default; PNG images are always kept lossless.");
291
+ }
292
+ const rawWidth = raw["maxWidth"];
293
+ if (rawWidth !== void 0) {
294
+ if (isWhole(rawWidth) && rawWidth >= 320 && rawWidth <= 8192) maxWidth = rawWidth;
295
+ else problems.add(keyPath(path, "maxWidth"), `${show(rawWidth)} is not a whole number of pixels from 320 to ${MEDIA_MAX_WIDTH}`, "Give the widest an image may be; a wider one is scaled down, never up.");
296
+ }
297
+ return {
298
+ format,
299
+ quality,
300
+ maxWidth
301
+ };
302
+ }
303
+ function isWhole(value) {
304
+ return typeof value === "number" && Number.isInteger(value);
305
+ }
306
+ function show(value) {
307
+ return typeof value === "number" ? String(value) : wrongType(value, "a number");
308
+ }
309
+ //#endregion
255
310
  //#region src/config/constants.ts
256
311
  /** The largest Markdown file a collection accepts when its config does not say, 512 KiB. */
257
312
  const DEFAULT_MAX_MARKDOWN_BYTES = 524288;
@@ -1933,7 +1988,8 @@ function readSite(key, raw, problems) {
1933
1988
  url,
1934
1989
  cors: readCors(keyPath(path, "cors"), raw["cors"], problems),
1935
1990
  events: readEvents(keyPath(path, "events"), raw["events"], url, problems),
1936
- audit: readAudit(keyPath(path, "audit"), raw["audit"], problems)
1991
+ audit: readAudit(keyPath(path, "audit"), raw["audit"], problems),
1992
+ media: readMedia(keyPath(path, "media"), raw["media"], problems)
1937
1993
  };
1938
1994
  }
1939
1995
  function readSites(raw, problems) {
@@ -2028,7 +2084,8 @@ const OPERATION_AUDIT_ACTIONS = [
2028
2084
  "create_site",
2029
2085
  "delete_site",
2030
2086
  "restore_site",
2031
- "make_webhook_secret"
2087
+ "make_webhook_secret",
2088
+ "upload_asset"
2032
2089
  ];
2033
2090
  /** What people do outside the operations: their tokens, members, passkeys, sessions and apps. */
2034
2091
  const IDENTITY_AUDIT_ACTIONS = [
@@ -2125,6 +2182,11 @@ const siteDefinitionData = z.strictObject({
2125
2182
  ipAddresses: "off",
2126
2183
  retentionDays: null
2127
2184
  }).describe("What the activity log of the site keeps (ADR-037). Only an owner should change it."),
2185
+ media: z.strictObject({
2186
+ format: z.enum(MEDIA_FORMATS).default(DEFAULT_MEDIA.format).describe("What JPEG, PNG and GIF images become when they arrive: \"webp\", \"avif\", or \"original\" to keep them as they came. SVG is never converted."),
2187
+ quality: z.number().int().min(1).max(100).default(DEFAULT_MEDIA.quality).describe("The quality of photographs, 1 to 100. PNG images are always kept lossless."),
2188
+ maxWidth: z.number().int().min(320).max(MEDIA_MAX_WIDTH).default(DEFAULT_MEDIA.maxWidth).describe("The widest an image is kept, in pixels; a wider one is scaled down, never up.")
2189
+ }).default({ ...DEFAULT_MEDIA }).describe("How the site converts the images it receives (ADR-038)."),
2128
2190
  collections: z.record(z.string(), collectionDefinitionData)
2129
2191
  });
2130
2192
  //#endregion
@@ -2272,7 +2334,8 @@ function compileSite(path, site, problems) {
2272
2334
  })),
2273
2335
  ...site.events.indexNow === null ? {} : { indexNow: site.events.indexNow }
2274
2336
  },
2275
- audit: site.audit
2337
+ audit: site.audit,
2338
+ media: site.media
2276
2339
  };
2277
2340
  }
2278
2341
  /** The definitions of the sites, read from D1 as JSON, checked and resolved. */
@@ -2501,6 +2564,11 @@ function sitePreset(preset, locales, defaultLocale) {
2501
2564
  ipAddresses: "off",
2502
2565
  retentionDays: null
2503
2566
  },
2567
+ media: {
2568
+ format: "webp",
2569
+ quality: 85,
2570
+ maxWidth: 2400
2571
+ },
2504
2572
  collections
2505
2573
  };
2506
2574
  }
@@ -2545,6 +2613,7 @@ var HollwareError = class extends Error {
2545
2613
  const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
2546
2614
  /** The kinds of ids and their prefixes. A new kind adds its prefix here. */
2547
2615
  const ID_PREFIXES = {
2616
+ asset: "ast",
2548
2617
  auditLine: "aud",
2549
2618
  challenge: "chl",
2550
2619
  delivery: "dlv",
@@ -2691,7 +2760,7 @@ const ALERT_TYPES = [
2691
2760
  "warning",
2692
2761
  "caution"
2693
2762
  ];
2694
- const text = (value) => typeof value === "string";
2763
+ const text$1 = (value) => typeof value === "string";
2695
2764
  const exactly = (...allowed) => (value) => typeof value === "string" && allowed.includes(value);
2696
2765
  const matching = (pattern) => (value) => typeof value === "string" && pattern.test(value);
2697
2766
  /** Only `language-*` classes, which is what a fenced code block carries. */
@@ -2708,20 +2777,20 @@ const alignment = /* @__PURE__ */ new Map([["style", exactly("text-align:left",
2708
2777
  * and markdown-it add themselves, so a document the parser writes passes its own rules.
2709
2778
  */
2710
2779
  const ATTRIBUTE_RULES = /* @__PURE__ */ new Map([
2711
- ["a", /* @__PURE__ */ new Map([["href", text], ["title", text]])],
2780
+ ["a", /* @__PURE__ */ new Map([["href", text$1], ["title", text$1]])],
2712
2781
  ["img", /* @__PURE__ */ new Map([
2713
- ["src", text],
2714
- ["alt", text],
2715
- ["title", text],
2716
- ["width", text],
2717
- ["height", text]
2782
+ ["src", text$1],
2783
+ ["alt", text$1],
2784
+ ["title", text$1],
2785
+ ["width", text$1],
2786
+ ["height", text$1]
2718
2787
  ])],
2719
2788
  ["code", /* @__PURE__ */ new Map([["class", languageClasses]])],
2720
2789
  ["pre", /* @__PURE__ */ new Map([
2721
- ["language", text],
2722
- ["filename", text],
2790
+ ["language", text$1],
2791
+ ["filename", text$1],
2723
2792
  ["highlights", lineNumbers],
2724
- ["meta", text]
2793
+ ["meta", text$1]
2725
2794
  ])],
2726
2795
  ["ol", /* @__PURE__ */ new Map([["start", matching(/^\d{1,9}$/)]])],
2727
2796
  ["ul", /* @__PURE__ */ new Map([["class", exactly("contains-task-list")]])],
@@ -2811,7 +2880,11 @@ const LINK_SCHEMES = /* @__PURE__ */ new Set([
2811
2880
  "https",
2812
2881
  "mailto"
2813
2882
  ]);
2814
- const IMAGE_SCHEMES = /* @__PURE__ */ new Set(["http", "https"]);
2883
+ const IMAGE_SCHEMES = /* @__PURE__ */ new Set([
2884
+ "http",
2885
+ "https",
2886
+ "asset"
2887
+ ]);
2815
2888
  /**
2816
2889
  * Whether a link or an image may point there. A relative URL (a path, `#anchor`, `?query` or
2817
2890
  * `//host/path`) is always safe: it keeps the scheme of the page. Of the schemes, links may use
@@ -4088,6 +4161,158 @@ const siteDescription = z.object({
4088
4161
  collections: z.array(collectionDescription)
4089
4162
  });
4090
4163
  //#endregion
4164
+ //#region src/media/index.ts
4165
+ /** The kinds of image Hollware takes, by media type. */
4166
+ const IMAGE_FORMATS = [
4167
+ "image/jpeg",
4168
+ "image/png",
4169
+ "image/webp",
4170
+ "image/avif",
4171
+ "image/gif",
4172
+ "image/svg+xml"
4173
+ ];
4174
+ /** The largest file `upload_asset` takes, in bytes: the binding takes 20 MB, this keeps room. */
4175
+ const MAX_ASSET_BYTES = 10485760;
4176
+ /** The extension a format is served and exported with. */
4177
+ const EXTENSIONS = {
4178
+ "image/jpeg": "jpg",
4179
+ "image/png": "png",
4180
+ "image/webp": "webp",
4181
+ "image/avif": "avif",
4182
+ "image/gif": "gif",
4183
+ "image/svg+xml": "svg"
4184
+ };
4185
+ const startsWith = (bytes, at, expected) => expected.every((byte, index) => bytes[at + index] === byte);
4186
+ const ascii = (text) => Array.from({ length: text.length }, (_, at) => text.charCodeAt(at));
4187
+ /**
4188
+ * The format of a file, read from its bytes and never from its name or a header: `null` for
4189
+ * anything else. An SVG is text that opens on `<svg` or on an XML declaration followed by one.
4190
+ */
4191
+ function sniffImage(bytes) {
4192
+ if (startsWith(bytes, 0, [
4193
+ 255,
4194
+ 216,
4195
+ 255
4196
+ ])) return "image/jpeg";
4197
+ if (startsWith(bytes, 0, [
4198
+ 137,
4199
+ 80,
4200
+ 78,
4201
+ 71,
4202
+ 13,
4203
+ 10,
4204
+ 26,
4205
+ 10
4206
+ ])) return "image/png";
4207
+ if (startsWith(bytes, 0, ascii("RIFF")) && startsWith(bytes, 8, ascii("WEBP"))) return "image/webp";
4208
+ if (startsWith(bytes, 4, ascii("ftyp")) && /^avi[fs]$/.test(text(bytes.subarray(8, 12)))) return "image/avif";
4209
+ if (startsWith(bytes, 0, ascii("GIF87a")) || startsWith(bytes, 0, ascii("GIF89a"))) return "image/gif";
4210
+ const head = text(bytes.subarray(0, 512)).replace(BOM, "").trimStart();
4211
+ if (/^<svg[\s>/]/i.test(head) || /^<\?xml[^>]*\?>\s*(<!--[\s\S]*?-->\s*)*<svg[\s>/]/i.test(head)) return "image/svg+xml";
4212
+ return null;
4213
+ }
4214
+ /** A byte order mark, which an editor may put before an SVG's first tag. */
4215
+ const BOM = new RegExp(`^${String.fromCodePoint(65279)}`);
4216
+ function text(bytes) {
4217
+ return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
4218
+ }
4219
+ const SVG_FIX = "Export the SVG again without scripts, embedded HTML, event handlers or external references (any SVG optimizer does it), or upload it as PNG.";
4220
+ const SVG_REFUSALS = [
4221
+ {
4222
+ pattern: /<!DOCTYPE|<!ENTITY/i,
4223
+ reason: "it declares a DOCTYPE or an entity"
4224
+ },
4225
+ {
4226
+ pattern: /<script[\s>/]/i,
4227
+ reason: "it holds a <script>"
4228
+ },
4229
+ {
4230
+ pattern: /<foreignObject[\s>/]/i,
4231
+ reason: "it holds a <foreignObject>, which embeds HTML"
4232
+ },
4233
+ {
4234
+ pattern: /<(?:iframe|embed|object|audio|video)[\s>/]/i,
4235
+ reason: "it embeds another document"
4236
+ },
4237
+ {
4238
+ pattern: /\son[a-z]+\s*=/i,
4239
+ reason: "it has an event handler (an on… attribute)"
4240
+ },
4241
+ {
4242
+ pattern: /\bjavascript\s*:|\bdata\s*:/i,
4243
+ reason: "it holds a javascript: or data: URL"
4244
+ },
4245
+ {
4246
+ pattern: /(?:xlink:)?href\s*=\s*["']?\s*(?!#)[^"'\s>]/i,
4247
+ reason: "it links outside the file (an href that does not start with #)"
4248
+ },
4249
+ {
4250
+ pattern: /@import|url\(\s*["']?\s*(?!["'\s#])/i,
4251
+ reason: "its styles load something from outside"
4252
+ }
4253
+ ];
4254
+ /** `null` when an SVG holds nothing that can run or reach out, else the first thing that can. */
4255
+ function checkSvg(source) {
4256
+ for (const { pattern, reason } of SVG_REFUSALS) if (pattern.test(source)) return {
4257
+ reason,
4258
+ fix: SVG_FIX
4259
+ };
4260
+ return null;
4261
+ }
4262
+ /**
4263
+ * What a file of `format`, `width` pixels wide (null for an SVG), becomes under `media`: JPEG,
4264
+ * PNG and GIF become the site's format; a WebP or an AVIF keeps its own and is only scaled down
4265
+ * when it is wider than the site allows; `original` and SVG keep every file as it came.
4266
+ */
4267
+ function conversionOf(format, width, media) {
4268
+ if (format === "image/svg+xml" || media.format === "original") return null;
4269
+ const tooWide = width !== null && width > media.maxWidth;
4270
+ const modern = format === "image/webp" || format === "image/avif";
4271
+ if (modern && !tooWide) return null;
4272
+ return {
4273
+ format: modern ? format : media.format === "avif" ? "image/avif" : "image/webp",
4274
+ quality: format === "image/png" ? 100 : media.quality,
4275
+ anim: true,
4276
+ ...tooWide ? { width: media.maxWidth } : {}
4277
+ };
4278
+ }
4279
+ /** The id of an asset, from the SHA-256 of its original bytes: the same file is the same asset. */
4280
+ function assetIdOf(sha256Hex) {
4281
+ return `ast_${sha256Hex.slice(0, 26)}`;
4282
+ }
4283
+ /** What content writes to name an asset: `asset:ast_…`. */
4284
+ const ASSET_SCHEME = "asset:";
4285
+ const ASSET_REFERENCE = /^asset:(ast_[0-9a-f]{26})$/;
4286
+ /** The id an `asset:` reference names, or `null` when the value is no such reference. */
4287
+ function assetIdIn(value) {
4288
+ return ASSET_REFERENCE.exec(value.trim())?.[1] ?? null;
4289
+ }
4290
+ /**
4291
+ * A copy of a value (a frontmatter, a document) with every string that is an `asset:` reference
4292
+ * replaced by the URL `urlOf` gives its id. Everything else is copied as it is.
4293
+ */
4294
+ function resolveAssetReferences(value, urlOf) {
4295
+ const visit = (node) => {
4296
+ if (typeof node === "string") {
4297
+ const id = assetIdIn(node);
4298
+ return id === null ? node : urlOf(id);
4299
+ }
4300
+ if (Array.isArray(node)) return node.map(visit);
4301
+ if (typeof node === "object" && node !== null) {
4302
+ const copy = {};
4303
+ for (const [key, child] of Object.entries(node)) Object.defineProperty(copy, key, {
4304
+ value: visit(child),
4305
+ enumerable: true,
4306
+ writable: true,
4307
+ configurable: true
4308
+ });
4309
+ return copy;
4310
+ }
4311
+ return node;
4312
+ };
4313
+ return visit(value);
4314
+ }
4315
+ //#endregion
4091
4316
  //#region src/operations/types.ts
4092
4317
  /** Declares an operation spec, keeping the literal types of its name and schemas. */
4093
4318
  function defineOperation(spec) {
@@ -4572,7 +4797,9 @@ const structureChange = z.object({
4572
4797
  version: z.number().int().optional().describe("The version of the structure now."),
4573
4798
  problems: z.array(problem).optional().describe("With invalid: every problem, each with its fix."),
4574
4799
  impact: impact.optional().describe("With needs_confirmation: what the change would do."),
4575
- currentVersion: z.number().int().optional().describe("With conflict: the version to read again.")
4800
+ currentVersion: z.number().int().optional().describe("With conflict: the version to read again."),
4801
+ message: z.string().optional().describe("With conflict: what happened."),
4802
+ fix: z.string().optional().describe("With conflict: what to do next.")
4576
4803
  });
4577
4804
  const STRUCTURE_OUTCOMES = {
4578
4805
  updated: { isError: false },
@@ -4692,6 +4919,109 @@ const restoreSite = defineOperation({
4692
4919
  not_found: { isError: true }
4693
4920
  }
4694
4921
  });
4922
+ const makeWebhookSecret = defineOperation({
4923
+ name: "make_webhook_secret",
4924
+ title: "Make the secret of a webhook",
4925
+ description: "Makes a new secret for a webhook of the site that names no Worker secret of its own, keeps it sealed, and answers it once: give it to the receiver, which checks each delivery's signature with it. A new one replaces the old at once. unavailable when the deployment has no HOLLWARE_SECRET to seal it with.",
4926
+ input: z.strictObject({ webhook: z.string().min(1).describe("The name of the webhook, as the definition of the site has it.") }),
4927
+ output: z.object({
4928
+ outcome: z.enum([
4929
+ "made",
4930
+ "not_found",
4931
+ "unavailable"
4932
+ ]),
4933
+ secret: z.string().optional().describe("With made: the secret, shown this once.")
4934
+ }),
4935
+ scopes: ["schema:admin"],
4936
+ effect: "write",
4937
+ outcomes: {
4938
+ made: { isError: false },
4939
+ not_found: { isError: true },
4940
+ unavailable: { isError: true }
4941
+ }
4942
+ });
4943
+ const assetId = z.string().regex(/^ast_[0-9a-f]{26}$/).describe("The id of an asset, such as ast_3f1c… (26 hexadecimal characters).");
4944
+ /** An asset as the operations answer it. */
4945
+ const asset = z.object({
4946
+ id: assetId,
4947
+ reference: z.string().describe("What content writes to show it: img: asset:ast_… or ![alt](asset:ast_…)."),
4948
+ url: z.string().describe("Where the Worker serves it, to anyone, cached for a year."),
4949
+ format: z.enum(IMAGE_FORMATS).describe("What it is served as."),
4950
+ width: z.number().int().nullable().describe("In pixels; null for an SVG."),
4951
+ height: z.number().int().nullable(),
4952
+ bytes: z.number().int().describe("The size of the file served."),
4953
+ originalFormat: z.enum(IMAGE_FORMATS).describe("What it was sent as."),
4954
+ originalBytes: z.number().int(),
4955
+ alt: z.string().describe("What it shows, for whoever cannot see it."),
4956
+ filename: z.string().describe("The name it was sent with."),
4957
+ createdAt: z.string()
4958
+ });
4959
+ /** Base64 of at most MAX_ASSET_BYTES, with its padding: four characters for every three bytes. */
4960
+ const MAX_BASE64 = Math.ceil(MAX_ASSET_BYTES / 3) * 4;
4961
+ const uploadAsset = defineOperation({
4962
+ name: "upload_asset",
4963
+ title: "Upload an image",
4964
+ description: "Uploads an image to the site: JPEG, PNG, WebP, AVIF, GIF or SVG, at most 10 MB, sent in base64. What it is comes from its bytes, never from its name. The site's media setting converts it once (to WebP at 85 by default; PNG lossless; GIF with its frames; nothing wider than its maxWidth), and an SVG is kept as it is when nothing in it can run, or refused with what to remove. The same file uploaded again answers exists with the same asset. Write its reference in content: img: asset:ast_… in the frontmatter, or ![alt](asset:ast_…) in Markdown.",
4965
+ input: z.strictObject({
4966
+ filename: z.string().min(1).max(200).describe("Its name, such as continuum.png."),
4967
+ alt: z.string().min(1).max(500).describe("What the image shows, for whoever cannot see it: required."),
4968
+ data: z.string().min(1).max(MAX_BASE64).describe("The file, in base64.")
4969
+ }),
4970
+ output: z.object({
4971
+ outcome: z.enum([
4972
+ "created",
4973
+ "exists",
4974
+ "refused",
4975
+ "unavailable"
4976
+ ]),
4977
+ asset: asset.optional(),
4978
+ problem: z.object({
4979
+ message: z.string(),
4980
+ fix: z.string()
4981
+ }).optional().describe("With refused or unavailable: what is wrong, and what to do.")
4982
+ }),
4983
+ scopes: ["content:write"],
4984
+ effect: "write",
4985
+ outcomes: {
4986
+ created: { isError: false },
4987
+ exists: { isError: false },
4988
+ refused: { isError: true },
4989
+ unavailable: { isError: true }
4990
+ }
4991
+ });
4992
+ const listAssets = defineOperation({
4993
+ name: "list_assets",
4994
+ title: "List the images of the site",
4995
+ description: "Lists the images of the site, newest first, with their reference, URL, size and alt. Pass the nextCursor of an answer as cursor to get the next page.",
4996
+ input: z.strictObject({
4997
+ limit: z.number().int().min(1).max(100).default(20),
4998
+ cursor: z.string().min(1).optional()
4999
+ }),
5000
+ output: z.object({
5001
+ outcome: z.enum(["ok"]),
5002
+ assets: z.array(asset),
5003
+ nextCursor: z.string().nullable().describe("The cursor of the next page, or null.")
5004
+ }),
5005
+ scopes: ["content:read"],
5006
+ effect: "read",
5007
+ outcomes: { ok: { isError: false } }
5008
+ });
5009
+ const getAsset = defineOperation({
5010
+ name: "get_asset",
5011
+ title: "Get an image",
5012
+ description: "Gets an image of the site by its id: its reference, URL, size and alt. The file as it was uploaded is at GET /v1/assets/<id>/original, with a personal token.",
5013
+ input: z.strictObject({ id: assetId }),
5014
+ output: z.object({
5015
+ outcome: z.enum(["ok", "not_found"]),
5016
+ asset: asset.optional()
5017
+ }),
5018
+ scopes: ["content:read"],
5019
+ effect: "read",
5020
+ outcomes: {
5021
+ ok: { isError: false },
5022
+ not_found: { isError: true }
5023
+ }
5024
+ });
4695
5025
  /** Every operation, sorted by name: the order of `tools/list`. */
4696
5026
  const OPERATIONS = [
4697
5027
  approveReview,
@@ -4702,32 +5032,14 @@ const OPERATIONS = [
4702
5032
  deleteEntry,
4703
5033
  deleteSite,
4704
5034
  describeSite,
5035
+ getAsset,
4705
5036
  getEntry,
4706
5037
  getStructure,
4707
5038
  getTranslations,
5039
+ listAssets,
4708
5040
  listEntries,
4709
5041
  listReviews,
4710
- defineOperation({
4711
- name: "make_webhook_secret",
4712
- title: "Make the secret of a webhook",
4713
- description: "Makes a new secret for a webhook of the site that names no Worker secret of its own, keeps it sealed, and answers it once: give it to the receiver, which checks each delivery's signature with it. A new one replaces the old at once. unavailable when the deployment has no HOLLWARE_SECRET to seal it with.",
4714
- input: z.strictObject({ webhook: z.string().min(1).describe("The name of the webhook, as the definition of the site has it.") }),
4715
- output: z.object({
4716
- outcome: z.enum([
4717
- "made",
4718
- "not_found",
4719
- "unavailable"
4720
- ]),
4721
- secret: z.string().optional().describe("With made: the secret, shown this once.")
4722
- }),
4723
- scopes: ["schema:admin"],
4724
- effect: "write",
4725
- outcomes: {
4726
- made: { isError: false },
4727
- not_found: { isError: true },
4728
- unavailable: { isError: true }
4729
- }
4730
- }),
5042
+ makeWebhookSecret,
4731
5043
  publish,
4732
5044
  requestChanges,
4733
5045
  requestReview,
@@ -4738,6 +5050,7 @@ const OPERATIONS = [
4738
5050
  unpublish,
4739
5051
  updateDraft,
4740
5052
  updateSite,
5053
+ uploadAsset,
4741
5054
  validate
4742
5055
  ];
4743
5056
  //#endregion
@@ -4808,64 +5121,6 @@ async function hashPersonalToken(token) {
4808
5121
  return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
4809
5122
  }
4810
5123
  //#endregion
4811
- //#region src/webhooks.ts
4812
- /**
4813
- * The signature of Hollware's webhooks (ADR-025), for the Worker that sends them and for whoever
4814
- * receives them: HMAC-SHA256 of `<timestamp>.<body>` with the webhook's secret, sent as
4815
- * `hollware-signature: v1=<hex>` with `hollware-timestamp` (seconds since the epoch) and
4816
- * `hollware-event-id`. Web Crypto only, so it runs in any runtime.
4817
- */
4818
- const SIGNATURE_HEADER = "hollware-signature";
4819
- const TIMESTAMP_HEADER = "hollware-timestamp";
4820
- const EVENT_ID_HEADER = "hollware-event-id";
4821
- /** How far a timestamp may be from the receiver's clock, by default: five minutes. */
4822
- const TOLERANCE_SECONDS = 300;
4823
- const encoder$1 = new TextEncoder();
4824
- async function hmac(secret, text) {
4825
- const key = await crypto.subtle.importKey("raw", encoder$1.encode(secret), {
4826
- name: "HMAC",
4827
- hash: "SHA-256"
4828
- }, false, ["sign"]);
4829
- return [...new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder$1.encode(text)))].map((byte) => byte.toString(16).padStart(2, "0")).join("");
4830
- }
4831
- /** The value of `hollware-signature` for a body sent at `timestamp` (seconds). */
4832
- async function signWebhook(secret, timestamp, body) {
4833
- return `v1=${await hmac(secret, `${timestamp}.${body}`)}`;
4834
- }
4835
- /** Compares two strings in time that does not depend on where they differ. */
4836
- function sameText(a, b) {
4837
- if (a.length !== b.length) return false;
4838
- let difference = 0;
4839
- for (let index = 0; index < a.length; index++) difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
4840
- return difference === 0;
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
- }
4852
- /**
4853
- * Whether a request is a webhook of Hollware: its signature matches the body and the secret, and
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.
4856
- */
4857
- async function verifyWebhook(check) {
4858
- const signature = check.headers.get(SIGNATURE_HEADER);
4859
- const timestamp = Number(check.headers.get(TIMESTAMP_HEADER));
4860
- if (signature === null || !Number.isInteger(timestamp)) return false;
4861
- const now = check.now ?? Math.floor(Date.now() / 1e3);
4862
- if (Math.abs(now - timestamp) > (check.toleranceSeconds ?? TOLERANCE_SECONDS)) return false;
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
5124
  //#region src/preview.ts
4870
5125
  /**
4871
5126
  * The preview token (T-103 of docs/security/threat-model.md): a signed, stateless proof that a person of a site may read the
@@ -5117,4 +5372,4 @@ function tagsOfSiteChange(collections, locales) {
5117
5372
  //#region src/index.ts
5118
5373
  const name = "@hollware/core";
5119
5374
  //#endregion
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 };
5375
+ export { ASSET_SCHEME, 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, DEFAULT_MEDIA, ERROR_CODES, EVENT_ID_HEADER, EVERY_LOCALE, EXTENSIONS, HollwareError, IDENTITY_AUDIT_ACTIONS, ID_PREFIXES, IMAGE_FORMATS, INDEXNOW_KEY, MAX_ASSET_BYTES, MAX_MARKDOWN_BYTES_CEILING, MEDIA_FORMATS, MEDIA_MAX_WIDTH, MEDIA_MIN_WIDTH, 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, assetIdIn, assetIdOf, canSignPreviews, canonicalJson, catalogRule, checkSvg, chunksOf, collectionDefinitionData, collectionPreset, commentReview, compileSites, componentDefinitionData, contentStats, contracts, conversionOf, createDraft, defineCollection, defineComponent, defineConfig, defineContract, defineOperation, definitionsFromConfig, deleteEntry, describeBuiltInContracts, describeCatalog, describeContract, describeResolvedSite, describeSite, describedSchemaOf, entryEvent, entryTag, generatePersonalToken, generateReadToken, getAsset, getEntry, getTranslations, groupTag, hashPersonalToken, hollwareEvent, isAuditAction, isBuiltInContract, isId, isPersonalToken, isReadToken, isScope, isSiteKey, listAssets, listEntries, listReviews, listTag, makePreviewToken, name, newId, parseHollwareEvent, processMarkdown, publish, renderHtml, requestChanges, requestReview, resolveAssetReferences, resolveConfig, restoreEntry, routesFor, ruleDefinition, runContracts, schemas_exports as schemas, search, signWebhook, siteDefinitionData, siteEvent, sitePreset, sizeLimitViolation, sniffImage, tagsOfChange, tagsOfEntries, tagsOfSiteChange, ulid, uniqueSlugViolation, uniqueTranslationViolation, unpublish, updateDraft, uploadAsset, validate, verifyPreviewToken, verifyWebhook, webhookDefinition };
@@ -0,0 +1,36 @@
1
+ //#region src/webhooks.d.ts
2
+ /**
3
+ * The signature of Hollware's webhooks (ADR-025), for the Worker that sends them and for whoever
4
+ * receives them: HMAC-SHA256 of `<timestamp>.<body>` with the webhook's secret, sent as
5
+ * `hollware-signature: v1=<hex>` with `hollware-timestamp` (seconds since the epoch) and
6
+ * `hollware-event-id`. Web Crypto only, so it runs in any runtime.
7
+ */
8
+ export declare const SIGNATURE_HEADER = "hollware-signature";
9
+ export declare const TIMESTAMP_HEADER = "hollware-timestamp";
10
+ export declare const EVENT_ID_HEADER = "hollware-event-id";
11
+ /** The value of `hollware-signature` for a body sent at `timestamp` (seconds). */
12
+ export declare function signWebhook(secret: string, timestamp: number, body: string): Promise<string>;
13
+ export interface WebhookCheck {
14
+ readonly secret: string;
15
+ /** The raw body, as received: a parsed and re-serialized body no longer matches. */
16
+ readonly body: string;
17
+ readonly headers: Headers;
18
+ /** Now, in seconds since the epoch; the system clock when absent. */
19
+ readonly now?: number;
20
+ readonly toleranceSeconds?: number;
21
+ /**
22
+ * Whether the receiver sees this event for the first time, given its id, and records it (T-67):
23
+ * a request captured and sent again within the tolerance is then refused. The id is read from
24
+ * the signed body, never from `hollware-event-id`, which no signature covers. A retry of a
25
+ * delivery carries the same id, so a receiver that records an id only once it has handled the
26
+ * event lets a failed delivery be retried. Keep each id for longer than the tolerance.
27
+ */
28
+ readonly isNew?: (eventId: string) => boolean | Promise<boolean>;
29
+ }
30
+ /**
31
+ * Whether a request is a webhook of Hollware: its signature matches the body and the secret, and
32
+ * its timestamp is within the tolerance, so a captured request cannot be replayed later; with
33
+ * `isNew`, also that its event was not received before, so it cannot be replayed sooner either.
34
+ */
35
+ export declare function verifyWebhook(check: WebhookCheck): Promise<boolean>;
36
+ //#endregion
@@ -0,0 +1,59 @@
1
+ //#region src/webhooks.ts
2
+ /**
3
+ * The signature of Hollware's webhooks (ADR-025), for the Worker that sends them and for whoever
4
+ * receives them: HMAC-SHA256 of `<timestamp>.<body>` with the webhook's secret, sent as
5
+ * `hollware-signature: v1=<hex>` with `hollware-timestamp` (seconds since the epoch) and
6
+ * `hollware-event-id`. Web Crypto only, so it runs in any runtime.
7
+ */
8
+ const SIGNATURE_HEADER = "hollware-signature";
9
+ const TIMESTAMP_HEADER = "hollware-timestamp";
10
+ const EVENT_ID_HEADER = "hollware-event-id";
11
+ /** How far a timestamp may be from the receiver's clock, by default: five minutes. */
12
+ const TOLERANCE_SECONDS = 300;
13
+ const encoder = new TextEncoder();
14
+ async function hmac(secret, text) {
15
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secret), {
16
+ name: "HMAC",
17
+ hash: "SHA-256"
18
+ }, false, ["sign"]);
19
+ return [...new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(text)))].map((byte) => byte.toString(16).padStart(2, "0")).join("");
20
+ }
21
+ /** The value of `hollware-signature` for a body sent at `timestamp` (seconds). */
22
+ async function signWebhook(secret, timestamp, body) {
23
+ return `v1=${await hmac(secret, `${timestamp}.${body}`)}`;
24
+ }
25
+ /** Compares two strings in time that does not depend on where they differ. */
26
+ function sameText(a, b) {
27
+ if (a.length !== b.length) return false;
28
+ let difference = 0;
29
+ for (let index = 0; index < a.length; index++) difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
30
+ return difference === 0;
31
+ }
32
+ /** The id of the event a body holds, or `null` when it holds none. */
33
+ function eventIdOf(body) {
34
+ try {
35
+ const value = JSON.parse(body);
36
+ const id = typeof value === "object" && value !== null ? Reflect.get(value, "id") : null;
37
+ return typeof id === "string" && id !== "" ? id : null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+ /**
43
+ * Whether a request is a webhook of Hollware: its signature matches the body and the secret, and
44
+ * its timestamp is within the tolerance, so a captured request cannot be replayed later; with
45
+ * `isNew`, also that its event was not received before, so it cannot be replayed sooner either.
46
+ */
47
+ async function verifyWebhook(check) {
48
+ const signature = check.headers.get(SIGNATURE_HEADER);
49
+ const timestamp = Number(check.headers.get(TIMESTAMP_HEADER));
50
+ if (signature === null || !Number.isInteger(timestamp)) return false;
51
+ const now = check.now ?? Math.floor(Date.now() / 1e3);
52
+ if (Math.abs(now - timestamp) > (check.toleranceSeconds ?? TOLERANCE_SECONDS)) return false;
53
+ if (!sameText(signature, await signWebhook(check.secret, timestamp, check.body))) return false;
54
+ if (check.isNew === void 0) return true;
55
+ const id = eventIdOf(check.body);
56
+ return id !== null && await check.isNew(id);
57
+ }
58
+ //#endregion
59
+ export { EVENT_ID_HEADER, SIGNATURE_HEADER, TIMESTAMP_HEADER, signWebhook, verifyWebhook };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hollware/core",
3
- "version": "1.0.0-rc.0",
3
+ "version": "1.0.0-rc.2",
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": {
@@ -22,6 +22,10 @@
22
22
  "types": "./dist/index.d.mts",
23
23
  "default": "./dist/index.mjs"
24
24
  },
25
+ "./webhooks": {
26
+ "types": "./dist/webhooks.d.mts",
27
+ "default": "./dist/webhooks.mjs"
28
+ },
25
29
  "./package.json": "./package.json"
26
30
  },
27
31
  "publishConfig": {