@hollware/server 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.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { A as revisionTags, C as entryEmbeddings, D as publications, E as outbox, F as users, I as ROLE_SCOPES, L as effectiveScopes, M as schedules, N as sites, O as reviewComments, P as tokens, R as isRole, S as entries, T as members, a as dropExpired, f as membershipsOf, g as removePasskey, j as revisions, k as reviews, n as createInvitation, o as endSession, p as passkeySummaries, s as endSessions, u as findSession, w as invitations, x as deliveries } from "./store-DjA9s95l.mjs";
2
- import { CHUNKER_VERSION, ConfigError, EVENT_ID_HEADER, HollwareError, INDEXNOW_KEY, OPERATIONS, OPERATION_AUDIT_ACTIONS, PREVIEW_QUERY_PARAM, SCOPES, SIGNATURE_HEADER, TIMESTAMP_HEADER, canSignPreviews, canonicalJson, chunksOf, compileSites, definitionsFromConfig, describeBuiltInContracts, describeCatalog, describeResolvedSite, describeSite, generatePersonalToken, generateReadToken, getEntry, getTranslations, hashPersonalToken, isAuditAction, isId, isPersonalToken, isReadToken, isScope, isSiteKey, listEntries, makePreviewToken, newId, processMarkdown, publish, renderHtml, resolveConfig, runContracts, search, signWebhook, siteDefinitionData, sizeLimitViolation, tagsOfChange, tagsOfSiteChange, uniqueSlugViolation, uniqueTranslationViolation, verifyPreviewToken } from "@hollware/core";
1
+ import { A as revisionTags, C as entryEmbeddings, D as publications, E as outbox, F as users, I as ROLE_SCOPES, L as effectiveScopes, M as schedules, N as sites, O as reviewComments, P as tokens, R as isRole, S as entries, T as members, a as dropExpired, f as membershipsOf, g as removePasskey, j as revisions, k as reviews, n as createInvitation, o as endSession, p as passkeySummaries, s as endSessions, u as findSession, w as invitations, x as deliveries } from "./store-ApxKV0Sc.mjs";
2
+ import { CHUNKER_VERSION, ConfigError, EVENT_ID_HEADER, HollwareError, INDEXNOW_KEY, MAX_ASSET_BYTES, OPERATIONS, OPERATION_AUDIT_ACTIONS, PREVIEW_QUERY_PARAM, SCOPES, SIGNATURE_HEADER, TIMESTAMP_HEADER, assetIdOf, canSignPreviews, canonicalJson, checkSvg, chunksOf, compileSites, conversionOf, definitionsFromConfig, describeBuiltInContracts, describeCatalog, describeResolvedSite, describeSite, generatePersonalToken, generateReadToken, getEntry, getTranslations, hashPersonalToken, isAuditAction, isId, isPersonalToken, isReadToken, isScope, isSiteKey, listEntries, makePreviewToken, newId, processMarkdown, publish, renderHtml, resolveAssetReferences, resolveConfig, runContracts, search, signWebhook, siteDefinitionData, sizeLimitViolation, sniffImage, tagsOfChange, tagsOfSiteChange, uniqueSlugViolation, uniqueTranslationViolation, verifyPreviewToken } from "@hollware/core";
3
3
  import { Hono } from "hono";
4
4
  import { drizzle } from "drizzle-orm/d1";
5
5
  import { and, asc, desc, eq, getTableColumns, gt, inArray, isNotNull, isNull, lte, ne, or, sql } from "drizzle-orm";
@@ -10,7 +10,7 @@ import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, McpServer, accepted
10
10
  import * as z from "zod";
11
11
  import { WorkerEntrypoint } from "cloudflare:workers";
12
12
  //#region package.json
13
- var version = "1.0.0-rc.0";
13
+ var version = "1.0.0-rc.2";
14
14
  //#endregion
15
15
  //#region src/auth/responses.ts
16
16
  /** The challenge of every 401 and 403 (RFC 6750). */
@@ -257,6 +257,237 @@ async function checkPersonalToken(db, token, options) {
257
257
  };
258
258
  }
259
259
  //#endregion
260
+ //#region src/ops/errors.ts
261
+ /**
262
+ * The error of something that must never happen: a bug of Hollware, not a mistake of the caller.
263
+ * Like the `internal` error of the adapters, its message says nothing about the cause, which goes
264
+ * to the log through `cause`, and its fix names the request id the operator finds the log line by.
265
+ */
266
+ function internalError$1(context, cause) {
267
+ return new HollwareError("internal", "The operation failed on the server.", `Try again. If it keeps failing, give the site operator this request id: ${context.requestId}.`, { cause: new Error(cause) });
268
+ }
269
+ /**
270
+ * The error for an answer of the store that the call it came from can never get: an entry that
271
+ * is created cannot conflict, a publish has no slug to take. It is a bug, not an outcome of the
272
+ * operation, so it is not one of the outcomes of its spec.
273
+ */
274
+ function unexpectedAnswer(context, call, answer) {
275
+ return internalError$1(context, `The store answered ${answer.outcome} to ${call}, which it never does.`);
276
+ }
277
+ //#endregion
278
+ //#region src/ops/actor.ts
279
+ /**
280
+ * Who a write is made by: the person behind the token of the call, with what its client said
281
+ * about itself. A personal token writes as itself (ADR-019); an OAuth access token writes as the
282
+ * client the person allowed, whose id goes with what the client said (ADR-026). The adapters check
283
+ * the scopes of the operation before any handler runs and never route a write for the RPC
284
+ * binding, so reaching one with it is a bug, answered as `internal` before anything is read or
285
+ * written.
286
+ */
287
+ function actorOf$1(context) {
288
+ const { principal } = context;
289
+ if (principal.kind === "token") return {
290
+ kind: "token",
291
+ userId: principal.userId,
292
+ tokenId: principal.tokenId,
293
+ meta: context.client
294
+ };
295
+ if (principal.kind === "oauth") return {
296
+ kind: "oauth_client",
297
+ userId: principal.userId,
298
+ tokenId: null,
299
+ meta: {
300
+ ...context.client,
301
+ oauthClient: principal.clientId
302
+ }
303
+ };
304
+ throw internalError$1(context, `A write was routed for a principal of kind ${principal.kind}; only a person writes.`);
305
+ }
306
+ //#endregion
307
+ //#region src/ops/assets.ts
308
+ const NO_BUCKET = {
309
+ message: "This Hollware keeps no images: its Worker has no R2 bucket bound as MEDIA.",
310
+ fix: "Add { \"binding\": \"MEDIA\", \"bucket_name\": \"<name>\" } to r2_buckets in wrangler.jsonc, and run hollware deploy, which creates the bucket."
311
+ };
312
+ /** The keys of an asset's files in R2: the file served, and the file as it was uploaded. */
313
+ const servedKey = (site, id) => `sites/${site}/${id}`;
314
+ const originalKey = (site, id) => `originals/${site}/${id}`;
315
+ /** An asset as the operations answer it. */
316
+ function assetView(row, media) {
317
+ return {
318
+ id: row.id,
319
+ reference: `asset:${row.id}`,
320
+ url: `${media.baseUrl}${row.site}/${row.id}`,
321
+ format: row.format,
322
+ width: row.width,
323
+ height: row.height,
324
+ bytes: row.bytes,
325
+ originalFormat: row.originalFormat,
326
+ originalBytes: row.originalBytes,
327
+ alt: row.alt,
328
+ filename: row.filename,
329
+ createdAt: row.createdAt
330
+ };
331
+ }
332
+ function bytesOfBase64(data) {
333
+ const clean = data.replace(/\s/g, "");
334
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(clean) || clean.length % 4 !== 0) return null;
335
+ const binary = atob(clean);
336
+ const bytes = new Uint8Array(binary.length);
337
+ for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
338
+ return bytes;
339
+ }
340
+ async function sha256Of(bytes) {
341
+ return [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((byte) => byte.toString(16).padStart(2, "0")).join("");
342
+ }
343
+ /** The error code of the Images binding, such as 9412 (not an image) or 9422 (no allowance left). */
344
+ function imagesCode(error) {
345
+ const code = typeof error === "object" && error !== null ? Reflect.get(error, "code") : null;
346
+ if (typeof code === "number") return code;
347
+ const match = /\b(9\d{3})\b/.exec(error instanceof Error ? error.message : String(error));
348
+ return match === null ? null : Number(match[1]);
349
+ }
350
+ const refused$1 = (message, fix) => ({
351
+ outcome: "refused",
352
+ problem: {
353
+ message,
354
+ fix
355
+ }
356
+ });
357
+ const NOT_READABLE = refused$1("Cloudflare Images could not read this file as an image: it may be damaged or cut short.", "Open it in an image editor, save it again, and upload that.");
358
+ const NO_ALLOWANCE = {
359
+ outcome: "unavailable",
360
+ problem: {
361
+ message: "This month's allowance of image conversions is used up (5,000 on Cloudflare's Free plan): nothing was kept.",
362
+ fix: "Upload it next month, set the media format of the site to \"original\" to keep files as they come, or move the account to the Paid plan of Images."
363
+ }
364
+ };
365
+ const uploadAsset = async (input, context) => {
366
+ const media = context.media ?? null;
367
+ if (media === null) return {
368
+ outcome: "unavailable",
369
+ problem: NO_BUCKET
370
+ };
371
+ const original = bytesOfBase64(input.data);
372
+ if (original === null || original.length === 0) return refused$1("data is not base64.", "Send the file encoded in base64, with its padding.");
373
+ if (original.length > MAX_ASSET_BYTES) return refused$1(`The file is ${original.length} bytes, more than the ${MAX_ASSET_BYTES} an image may be.`, "Make it smaller (fewer pixels, or a format such as WebP) and upload it again.");
374
+ const originalFormat = sniffImage(original);
375
+ if (originalFormat === null) return refused$1("This file is not an image Hollware takes: JPEG, PNG, WebP, AVIF, GIF or SVG, judged by its bytes.", "Export it as one of those formats and upload it again.");
376
+ const sha256 = await sha256Of(original);
377
+ const id = assetIdOf(sha256);
378
+ const site = context.site.key;
379
+ const existing = await media.catalog.byId(site, id);
380
+ if (existing !== null) return {
381
+ outcome: "exists",
382
+ asset: assetView(existing, media)
383
+ };
384
+ let served = original;
385
+ let format = originalFormat;
386
+ let width = null;
387
+ let height = null;
388
+ if (originalFormat === "image/svg+xml") {
389
+ const problem = checkSvg(new TextDecoder().decode(original));
390
+ if (problem !== null) return refused$1(`This SVG is refused: ${problem.reason}.`, problem.fix);
391
+ } else if (media.images !== null) try {
392
+ const info = await media.images.info(original);
393
+ if ("width" in info) {
394
+ width = info.width;
395
+ height = info.height;
396
+ }
397
+ const conversion = conversionOf(originalFormat, width, context.site.media);
398
+ if (conversion !== null) {
399
+ const converted = await media.images.convert(original, conversion);
400
+ served = converted.bytes;
401
+ format = converted.format;
402
+ if (conversion.width !== void 0 && width !== null && height !== null) {
403
+ height = Math.max(1, Math.round(height * conversion.width / width));
404
+ width = conversion.width;
405
+ }
406
+ }
407
+ } catch (error) {
408
+ const code = imagesCode(error);
409
+ if (code === 9422) return NO_ALLOWANCE;
410
+ if (code === 9412) return NOT_READABLE;
411
+ throw error;
412
+ }
413
+ await media.files.put(servedKey(site, id), served, format);
414
+ await media.files.put(originalKey(site, id), original, originalFormat);
415
+ const { row, created } = await media.catalog.insert({
416
+ id,
417
+ site,
418
+ sha256,
419
+ format,
420
+ width,
421
+ height,
422
+ bytes: served.length,
423
+ originalFormat,
424
+ originalBytes: original.length,
425
+ alt: input.alt,
426
+ filename: input.filename,
427
+ createdBy: actorOf$1(context).userId,
428
+ createdAt: context.clock.now().toISOString()
429
+ });
430
+ return {
431
+ outcome: created ? "created" : "exists",
432
+ asset: assetView(row, media)
433
+ };
434
+ };
435
+ /** The cursor of a page: the `createdAt` and id of its last row, in base64url. */
436
+ const cursorOf = (row) => btoa(`${row.createdAt}|${row.id}`).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
437
+ function parseCursor(cursor) {
438
+ if (cursor === void 0) return null;
439
+ try {
440
+ const padded = cursor.replace(/-/g, "+").replace(/_/g, "/");
441
+ const [createdAt, id] = atob(padded + "=".repeat((4 - padded.length % 4) % 4)).split("|");
442
+ return createdAt !== void 0 && id !== void 0 ? {
443
+ createdAt,
444
+ id
445
+ } : null;
446
+ } catch {
447
+ return null;
448
+ }
449
+ }
450
+ const listAssets = async (input, context) => {
451
+ const media = context.media ?? null;
452
+ if (media === null) return {
453
+ outcome: "ok",
454
+ assets: [],
455
+ nextCursor: null
456
+ };
457
+ const rows = await media.catalog.list(context.site.key, input.limit + 1, parseCursor(input.cursor));
458
+ const page = rows.slice(0, input.limit);
459
+ const last = page.at(-1);
460
+ return {
461
+ outcome: "ok",
462
+ assets: page.map((row) => assetView(row, media)),
463
+ nextCursor: rows.length > input.limit && last !== void 0 ? cursorOf(last) : null
464
+ };
465
+ };
466
+ const getAsset = async (input, context) => {
467
+ const media = context.media ?? null;
468
+ if (media === null) return { outcome: "not_found" };
469
+ const row = await media.catalog.byId(context.site.key, input.id);
470
+ return row === null ? { outcome: "not_found" } : {
471
+ outcome: "ok",
472
+ asset: assetView(row, media)
473
+ };
474
+ };
475
+ /**
476
+ * The URL an `asset:` reference of the site resolves to: where the Worker serves the image, or
477
+ * the reference as it is when the Worker serves no media.
478
+ */
479
+ function assetUrlOf(context) {
480
+ const base = context.media?.baseUrl;
481
+ return (id) => base === void 0 ? `asset:${id}` : `${base}${context.site.key}/${id}`;
482
+ }
483
+ /**
484
+ * A value as a read of the published site sees it (RPC, the read API): every `asset:` reference
485
+ * in it as the image's URL. Over MCP, an agent reads what it wrote.
486
+ */
487
+ function forReaders(context, value) {
488
+ return context.view === "published" ? resolveAssetReferences(value, assetUrlOf(context)) : value;
489
+ }
490
+ //#endregion
260
491
  //#region src/db/d1.ts
261
492
  /**
262
493
  * Drizzle's D1 driver is typed over a `D1Database`, but at runtime it only calls `prepare` and
@@ -2074,7 +2305,7 @@ function boundedJson(value) {
2074
2305
  * The actor of a principal. A person signed in to the admin is a `user`, with the admin as their
2075
2306
  * client; any other OAuth client is an `oauth_client`; a personal or read token is a `token`.
2076
2307
  */
2077
- function actorOf$1(principal, meta, adminClientId) {
2308
+ function actorOf(principal, meta, adminClientId) {
2078
2309
  if (principal.kind === "oauth") return {
2079
2310
  kind: principal.clientId === adminClientId ? "user" : "oauth_client",
2080
2311
  userId: principal.userId,
@@ -2398,7 +2629,7 @@ function createSecretStore(db, now) {
2398
2629
  * went wrong goes to the log and never to the caller, whose agent could repeat it to a person or
2399
2630
  * to another system. The request id is how the operator finds the log line.
2400
2631
  */
2401
- function internalError$1(requestId, cause) {
2632
+ function internalError(requestId, cause) {
2402
2633
  return new HollwareError("internal", "The operation failed on the server.", `Try again. If it keeps failing, give the site operator this request id: ${requestId}.`, { cause });
2403
2634
  }
2404
2635
  /** The fix of `timeout`, by effect: a write that ran out of time may still finish. */
@@ -2458,8 +2689,24 @@ function gatewayError(status, body) {
2458
2689
  * in its fix, which is how the operator finds the line of the log that has the cause. The cause
2459
2690
  * goes to the log and never to the caller, whose agent could repeat it to a person or to another
2460
2691
  * system. This holds for every error, a failed D1 query included: nothing turns one into a 401.
2692
+ * - But a database that lacks the migrations of the release is a 503 `not_migrated` that says what
2693
+ * to run: an agent that set the project up can fix it, and the cause still goes to the log only.
2461
2694
  * - A path that no route has is a JSON 404.
2462
2695
  */
2696
+ /** What D1 says when a table or a column of this release is not there: a migration is missing. */
2697
+ const MISSING_SCHEMA = /no such (?:table|column)|has no column named/;
2698
+ /**
2699
+ * Whether an error, or one of its causes, is D1 telling that the database lacks the schema of this
2700
+ * release: the one failure whose fix the caller can be told without anything of its cause.
2701
+ */
2702
+ function isMissingMigration(error) {
2703
+ let current = error;
2704
+ for (let depth = 0; depth < 5 && current instanceof Error; depth++) {
2705
+ if (MISSING_SCHEMA.test(current.message)) return true;
2706
+ current = current.cause;
2707
+ }
2708
+ return false;
2709
+ }
2463
2710
  function installErrorHandling(app, log) {
2464
2711
  app.onError((c) => {
2465
2712
  const requestId = c.get("requestId");
@@ -2470,6 +2717,11 @@ function installErrorHandling(app, log) {
2470
2717
  path: c.req.path,
2471
2718
  detail: describeError(c.error)
2472
2719
  });
2720
+ if (isMissingMigration(c.error)) return gatewayError(503, {
2721
+ code: "not_migrated",
2722
+ message: "The database of this Hollware lacks the migrations of its release.",
2723
+ fix: "Apply them: \"hollware migrate --local\" in local development, \"hollware migrate --remote\" (or \"hollware deploy\") for a deployment. Then try again."
2724
+ });
2473
2725
  return gatewayError(500, {
2474
2726
  code: "internal",
2475
2727
  message: "The request failed on the server.",
@@ -3511,7 +3763,8 @@ const NAMING_FIELDS = [
3511
3763
  "id",
3512
3764
  "entryId",
3513
3765
  "reviewId",
3514
- "name"
3766
+ "name",
3767
+ "filename"
3515
3768
  ];
3516
3769
  const FIELD_MAX = 200;
3517
3770
  function isAudited(name) {
@@ -3524,7 +3777,7 @@ function idOf(value) {
3524
3777
  const id = record(value)["id"];
3525
3778
  return typeof id === "string" ? id : null;
3526
3779
  }
3527
- /** What the call acted on: the entry, review or comment of its answer, else its site. */
3780
+ /** What the call acted on: the entry, review, comment or asset of its answer, else its site. */
3528
3781
  function targetOf(output, site) {
3529
3782
  const entry = idOf(output["entry"]);
3530
3783
  if (entry !== null) return {
@@ -3541,6 +3794,11 @@ function targetOf(output, site) {
3541
3794
  type: "comment",
3542
3795
  id: comment
3543
3796
  };
3797
+ const asset = idOf(output["asset"]);
3798
+ if (asset !== null) return {
3799
+ type: "asset",
3800
+ id: asset
3801
+ };
3544
3802
  return {
3545
3803
  type: "site",
3546
3804
  id: site
@@ -3683,6 +3941,116 @@ function semanticOfEnv(env) {
3683
3941
  return semanticOf(env, typeof model === "string" && model !== "" ? model : DEFAULT_EMBEDDING_MODEL);
3684
3942
  }
3685
3943
  //#endregion
3944
+ //#region src/media/cloudflare.ts
3945
+ const streamOf = (bytes) => new Blob([bytes]).stream();
3946
+ /** R2 as the store of asset files. */
3947
+ function r2Files(bucket) {
3948
+ return {
3949
+ async put(key, bytes, contentType) {
3950
+ await bucket.put(key, bytes, { httpMetadata: { contentType } });
3951
+ },
3952
+ async get(key) {
3953
+ const object = await bucket.get(key);
3954
+ if (object === null) return null;
3955
+ return {
3956
+ body: object.body,
3957
+ contentType: object.httpMetadata?.contentType ?? "application/octet-stream"
3958
+ };
3959
+ }
3960
+ };
3961
+ }
3962
+ function imagesConverter(images) {
3963
+ return {
3964
+ async info(bytes) {
3965
+ const info = await images.info(streamOf(bytes));
3966
+ if (typeof info !== "object" || info === null) throw new Error("Images answered no info");
3967
+ const format = Reflect.get(info, "format");
3968
+ if (format === "image/svg+xml") return { format };
3969
+ const width = Reflect.get(info, "width");
3970
+ const height = Reflect.get(info, "height");
3971
+ if (typeof format !== "string" || typeof width !== "number" || typeof height !== "number") throw new Error("Images answered an info without format, width and height");
3972
+ return {
3973
+ format,
3974
+ width,
3975
+ height
3976
+ };
3977
+ },
3978
+ async convert(bytes, conversion) {
3979
+ let transformer = images.input(streamOf(bytes));
3980
+ if (conversion.width !== void 0) transformer = transformer.transform({
3981
+ width: conversion.width,
3982
+ fit: "scale-down"
3983
+ });
3984
+ const result = await transformer.output({
3985
+ format: conversion.format,
3986
+ quality: conversion.quality,
3987
+ anim: conversion.anim
3988
+ });
3989
+ return {
3990
+ bytes: new Uint8Array(await new Response(result.image()).arrayBuffer()),
3991
+ format: result.contentType()
3992
+ };
3993
+ }
3994
+ };
3995
+ }
3996
+ const rowOf = (row) => ({
3997
+ id: row.id,
3998
+ site: row.site_id,
3999
+ sha256: row.sha256,
4000
+ format: row.format,
4001
+ width: row.width,
4002
+ height: row.height,
4003
+ bytes: row.bytes,
4004
+ originalFormat: row.original_format,
4005
+ originalBytes: row.original_bytes,
4006
+ alt: row.alt,
4007
+ filename: row.filename,
4008
+ createdBy: row.created_by,
4009
+ createdAt: row.created_at
4010
+ });
4011
+ /** The rows of `assets` in D1, always of one site. */
4012
+ function d1Catalog(db) {
4013
+ const byId = async (site, id) => {
4014
+ const row = await db.prepare("SELECT * FROM assets WHERE site_id = ? AND id = ?").bind(site, id).first();
4015
+ return row === null ? null : rowOf(row);
4016
+ };
4017
+ return {
4018
+ byId,
4019
+ async insert(asset) {
4020
+ const written = await db.prepare(`INSERT INTO assets (id, site_id, sha256, format, width, height, bytes, original_format,
4021
+ original_bytes, alt, filename, created_by, created_at, updated_at)
4022
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
4023
+ ON CONFLICT (site_id, sha256) DO NOTHING
4024
+ RETURNING *`).bind(asset.id, asset.site, asset.sha256, asset.format, asset.width, asset.height, asset.bytes, asset.originalFormat, asset.originalBytes, asset.alt, asset.filename, asset.createdBy, asset.createdAt, asset.createdAt).first();
4025
+ if (written !== null) return {
4026
+ row: rowOf(written),
4027
+ created: true
4028
+ };
4029
+ const existing = await db.prepare("SELECT * FROM assets WHERE site_id = ? AND sha256 = ?").bind(asset.site, asset.sha256).first();
4030
+ if (existing === null) throw new Error(`No asset of ${asset.sha256} after a conflict`);
4031
+ return {
4032
+ row: rowOf(existing),
4033
+ created: false
4034
+ };
4035
+ },
4036
+ async list(site, limit, cursor) {
4037
+ const { results } = await (cursor === null ? db.prepare("SELECT * FROM assets WHERE site_id = ? ORDER BY created_at DESC, id DESC LIMIT ?").bind(site, limit) : db.prepare(`SELECT * FROM assets WHERE site_id = ? AND (created_at, id) < (?, ?)
4038
+ ORDER BY created_at DESC, id DESC LIMIT ?`).bind(site, cursor.createdAt, cursor.id, limit)).all();
4039
+ return results.map(rowOf);
4040
+ }
4041
+ };
4042
+ }
4043
+ /** Media of a Worker: `null` without its R2 bucket, and without conversion when `IMAGES` is not bound. */
4044
+ function mediaOfEnv(env) {
4045
+ if (env.MEDIA === void 0 || env.HOLLWARE_PUBLIC_URL === void 0) return null;
4046
+ return {
4047
+ files: r2Files(env.MEDIA),
4048
+ images: env.IMAGES === void 0 ? null : imagesConverter(env.IMAGES),
4049
+ catalog: d1Catalog(env.DB),
4050
+ baseUrl: `${env.HOLLWARE_PUBLIC_URL.replace(/\/$/, "")}/assets/`
4051
+ };
4052
+ }
4053
+ //#endregion
3686
4054
  //#region src/analytics/cloudflare.ts
3687
4055
  /** The dataset of Analytics Engine when the Worker names none. */
3688
4056
  const DEFAULT_ANALYTICS_DATASET = "hollware_page_views";
@@ -3842,6 +4210,7 @@ function installOpsApi(app, options) {
3842
4210
  notify: options.notify(c, requestId, site.key),
3843
4211
  semantic: semanticOfEnv(c.env),
3844
4212
  analytics: analyticsReaderOf(c.env),
4213
+ media: mediaOfEnv(c.env),
3845
4214
  ...options.structure === void 0 ? {} : { structure: options.structure(c.env.DB, c.env.HOLLWARE_SECRET) }
3846
4215
  };
3847
4216
  /** The line of the call, after it answered or failed. */
@@ -3877,6 +4246,8 @@ function installOpsApi(app, options) {
3877
4246
  }
3878
4247
  //#endregion
3879
4248
  //#region src/http/read-api.ts
4249
+ /** The paths of the index of the read API, which answers with no token. */
4250
+ const INDEX_PATHS = /* @__PURE__ */ new Set(["/v1", "/v1/"]);
3880
4251
  /** How long a browser keeps the answer of a preflight, in seconds. */
3881
4252
  const PREFLIGHT_MAX_AGE = 600;
3882
4253
  /** The origin of a request from a page of another origin than Hollware's own, or `null`. */
@@ -3976,7 +4347,7 @@ function installReadApi(app, options) {
3976
4347
  message: "The read API only reads.",
3977
4348
  fix: "Use GET."
3978
4349
  }, 405, { allow: "GET, HEAD" });
3979
- if (c.req.path === "/v1/openapi.json") return next();
4350
+ if (c.req.path === "/v1/openapi.json" || INDEX_PATHS.has(c.req.path)) return next();
3980
4351
  c.res = await options.protect(c, async (principal) => {
3981
4352
  if (!principal.scopes.includes("content:read")) return insufficientScope(["content:read"], options.metadataUrl(c));
3982
4353
  c.set("site", principal.site);
@@ -4000,6 +4371,13 @@ function installReadApi(app, options) {
4000
4371
  return c.res;
4001
4372
  });
4002
4373
  app.get("/v1/openapi.json", (c) => c.json(openApiDocument()));
4374
+ for (const path of INDEX_PATHS) app.get(path, (c) => {
4375
+ const origin = new URL(c.env.HOLLWARE_PUBLIC_URL).origin;
4376
+ return c.json({
4377
+ openapi: `${origin}/v1/openapi.json`,
4378
+ site: `${origin}/v1/site`
4379
+ });
4380
+ });
4003
4381
  app.get("/v1/site", readRoute(async (c) => answer(c, await options.read(c.env).describeSite(c.get("site")))));
4004
4382
  app.get("/v1/search", readRoute(async (c) => {
4005
4383
  const query = c.req.query();
@@ -4158,7 +4536,7 @@ async function writeForPerson(db, log, siteOf, userId, line) {
4158
4536
  * The ceremonies, with SimpleWebAuthn and its parsers, in a chunk of their own: only these four
4159
4537
  * routes use them, and a Worker that serves MCP never pays for them at startup (`budgets.json`).
4160
4538
  */
4161
- const ceremonies = () => import("./ceremonies-CI3DkOEL.mjs");
4539
+ const ceremonies = () => import("./ceremonies-zTn1U6BD.mjs");
4162
4540
  /**
4163
4541
  * The cookie of a signed-in person: only `/authorize` reads it (ADR-026), and the sign-out below
4164
4542
  * it, `/authorize/signout`, which the path of the cookie reaches too.
@@ -4789,7 +5167,7 @@ function createGateway(options) {
4789
5167
  * The activity log of a request (ADR-037), for an actor known once the client has said what it
4790
5168
  * is. A person in the admin is a `user`; any other OAuth client, an `oauth_client`.
4791
5169
  */
4792
- const auditOf = (c, principal, adminClientId) => (meta) => auditAs(c, actorOf$1(principal, meta, adminClientId));
5170
+ const auditOf = (c, principal, adminClientId) => (meta) => auditAs(c, actorOf(principal, meta, adminClientId));
4793
5171
  /**
4794
5172
  * A protected route: the resource server of `resource` answers a request without a token and a
4795
5173
  * token it refuses (401, logged with the reason), and `proceed` gets the principal of the rest.
@@ -4859,9 +5237,10 @@ function createGateway(options) {
4859
5237
  c.set("requestId", crypto.randomUUID());
4860
5238
  await next();
4861
5239
  const https = c.env.HOLLWARE_PUBLIC_URL?.startsWith("https://") === true;
4862
- if (!(!c.res.headers.has("x-content-type-options") || https && !c.res.headers.has("strict-transport-security"))) return;
5240
+ if (!(!c.res.headers.has("x-content-type-options") || !c.res.headers.has("x-robots-tag") || https && !c.res.headers.has("strict-transport-security"))) return;
4863
5241
  const headers = new Headers(c.res.headers);
4864
5242
  if (!headers.has("x-content-type-options")) headers.set("x-content-type-options", "nosniff");
5243
+ if (!headers.has("x-robots-tag")) headers.set("x-robots-tag", "noindex, nofollow");
4865
5244
  if (https && !headers.has("strict-transport-security")) headers.set("strict-transport-security", "max-age=31536000");
4866
5245
  c.res = new Response(c.res.body, {
4867
5246
  status: c.res.status,
@@ -4908,6 +5287,10 @@ function createGateway(options) {
4908
5287
  await options.refresh?.(c.env.DB);
4909
5288
  await next();
4910
5289
  });
5290
+ app.get("/robots.txt", () => new Response("User-agent: *\nDisallow: /\n", { headers: {
5291
+ "content-type": "text/plain; charset=utf-8",
5292
+ "cache-control": "public, max-age=86400"
5293
+ } }));
4911
5294
  app.get("/health", () => Response.json({ ok: true }, { headers: { "cache-control": "no-store" } }));
4912
5295
  app.all("/mcp", async (c) => protectWrites(c, "mcp", async (principal) => {
4913
5296
  const requestId = c.get("requestId");
@@ -4925,7 +5308,8 @@ function createGateway(options) {
4925
5308
  notify: notify(c, requestId, site.key),
4926
5309
  semantic: semanticOfEnv(c.env),
4927
5310
  analytics: analyticsReaderOf(c.env),
4928
- structure: structureOf(db, c.env.HOLLWARE_SECRET)
5311
+ structure: structureOf(db, c.env.HOLLWARE_SECRET),
5312
+ media: mediaOfEnv(c.env)
4929
5313
  },
4930
5314
  ...principal.kind === "read" ? {} : { audit: {
4931
5315
  log: auditOf(c, principal, null),
@@ -5019,6 +5403,45 @@ function createGateway(options) {
5019
5403
  structure: structureOf,
5020
5404
  audit: async (c, principal) => auditOf(c, principal, (await adminClient(oauth(c.env.HOLLWARE_PUBLIC_URL), c.env)).clientId)
5021
5405
  });
5406
+ app.get("/assets/:site/:id", async (c) => {
5407
+ const { site, id } = c.req.param();
5408
+ const media = mediaOfEnv(c.env);
5409
+ const file = media === null || !isSiteKey(site) || !ASSET_ID.test(id) ? null : await media.files.get(servedKey(site, id));
5410
+ if (file === null) return Response.json({
5411
+ code: "not_found",
5412
+ message: "There is no image at this URL.",
5413
+ fix: "Use the url that upload_asset or get_asset gave the image."
5414
+ }, {
5415
+ status: 404,
5416
+ headers: { "cache-control": "no-store" }
5417
+ });
5418
+ const headers = new Headers({
5419
+ "content-type": file.contentType,
5420
+ "cache-control": "public, max-age=31536000, immutable",
5421
+ "cross-origin-resource-policy": "cross-origin"
5422
+ });
5423
+ if (file.contentType === "image/svg+xml") headers.set("content-security-policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox");
5424
+ return new Response(file.body, { headers });
5425
+ });
5426
+ app.get("/v1/assets/:id/original", (c) => protectWrites(c, "v1", async (principal) => {
5427
+ if (missingScopes(principal, ["content:read"]).length > 0) return insufficientScope(["content:read"], oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1);
5428
+ const id = c.req.param("id");
5429
+ const media = mediaOfEnv(c.env);
5430
+ const row = media === null || !ASSET_ID.test(id) ? null : await media.catalog.byId(principal.site, id);
5431
+ const file = media === null || row === null ? null : await media.files.get(originalKey(row.site, row.id));
5432
+ if (file === null) return Response.json({
5433
+ code: "not_found",
5434
+ message: `The site has no image ${id}.`,
5435
+ fix: "List the images of the site with list_assets."
5436
+ }, {
5437
+ status: 404,
5438
+ headers: { "cache-control": "no-store" }
5439
+ });
5440
+ return new Response(file.body, { headers: {
5441
+ "content-type": file.contentType,
5442
+ "cache-control": "private, no-store"
5443
+ } });
5444
+ }));
5022
5445
  if (options.read !== void 0) installReadApi(app, {
5023
5446
  read: options.read,
5024
5447
  corsOrigins: (site) => sites.get(site)?.cors.origins ?? [],
@@ -5033,6 +5456,8 @@ function createGateway(options) {
5033
5456
  return await app.fetch(request, env, ctx);
5034
5457
  };
5035
5458
  }
5459
+ /** The id of an asset: `ast_` and 26 hexadecimal characters, from the hash of its file. */
5460
+ const ASSET_ID = /^ast_[0-9a-f]{26}$/;
5036
5461
  /** The group of a route that is rate limited, or `null` for one that is not (T-10). */
5037
5462
  function limitedRoute(method, pathname) {
5038
5463
  if (method !== "POST") return null;
@@ -5231,6 +5656,9 @@ const OUTPUT_TOO_LARGE_FIX = {
5231
5656
  get_translations: "The translations do not fit in one answer. Read each one with get_entry instead.",
5232
5657
  list_entries: "Ask for less: pass a smaller limit, or narrow the list with locale, tag or status.",
5233
5658
  list_reviews: "Ask for less: pass a smaller limit.",
5659
+ list_assets: "Ask for less: pass a smaller limit.",
5660
+ get_asset: "Nothing to ask for less of: the answer is one asset. Try again.",
5661
+ upload_asset: "The operation ran and the image may have been kept: list the images with list_assets before you upload it again.",
5234
5662
  publish: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
5235
5663
  request_changes: "The operation ran and its change may have been applied: read the review with list_reviews before you repeat it.",
5236
5664
  request_review: "The operation ran and its review may have been asked: read it with list_reviews before you repeat it.",
@@ -5505,7 +5933,7 @@ function createMcpEndpoint(options) {
5505
5933
  try {
5506
5934
  result = spec.name === "publish" ? await performPublish() : answerResult(spec, await perform(input));
5507
5935
  } catch (error) {
5508
- failure = error instanceof HollwareError ? error : internalError$1(call.requestId, error);
5936
+ failure = error instanceof HollwareError ? error : internalError(call.requestId, error);
5509
5937
  isError = true;
5510
5938
  result = errorResult(failure);
5511
5939
  }
@@ -5697,7 +6125,8 @@ async function run(deps, env, method, site, schema, build, handler, viewOf = ()
5697
6125
  clock: deps.clock,
5698
6126
  client: null,
5699
6127
  requestId,
5700
- semantic: semanticOfEnv(env)
6128
+ semantic: semanticOfEnv(env),
6129
+ media: mediaOfEnv(env)
5701
6130
  };
5702
6131
  try {
5703
6132
  return await handler(input, context);
@@ -6322,53 +6751,6 @@ const contentStats = async (input, context) => {
6322
6751
  };
6323
6752
  };
6324
6753
  //#endregion
6325
- //#region src/ops/errors.ts
6326
- /**
6327
- * The error of something that must never happen: a bug of Hollware, not a mistake of the caller.
6328
- * Like the `internal` error of the adapters, its message says nothing about the cause, which goes
6329
- * to the log through `cause`, and its fix names the request id the operator finds the log line by.
6330
- */
6331
- function internalError(context, cause) {
6332
- return new HollwareError("internal", "The operation failed on the server.", `Try again. If it keeps failing, give the site operator this request id: ${context.requestId}.`, { cause: new Error(cause) });
6333
- }
6334
- /**
6335
- * The error for an answer of the store that the call it came from can never get: an entry that
6336
- * is created cannot conflict, a publish has no slug to take. It is a bug, not an outcome of the
6337
- * operation, so it is not one of the outcomes of its spec.
6338
- */
6339
- function unexpectedAnswer(context, call, answer) {
6340
- return internalError(context, `The store answered ${answer.outcome} to ${call}, which it never does.`);
6341
- }
6342
- //#endregion
6343
- //#region src/ops/actor.ts
6344
- /**
6345
- * Who a write is made by: the person behind the token of the call, with what its client said
6346
- * about itself. A personal token writes as itself (ADR-019); an OAuth access token writes as the
6347
- * client the person allowed, whose id goes with what the client said (ADR-026). The adapters check
6348
- * the scopes of the operation before any handler runs and never route a write for the RPC
6349
- * binding, so reaching one with it is a bug, answered as `internal` before anything is read or
6350
- * written.
6351
- */
6352
- function actorOf(context) {
6353
- const { principal } = context;
6354
- if (principal.kind === "token") return {
6355
- kind: "token",
6356
- userId: principal.userId,
6357
- tokenId: principal.tokenId,
6358
- meta: context.client
6359
- };
6360
- if (principal.kind === "oauth") return {
6361
- kind: "oauth_client",
6362
- userId: principal.userId,
6363
- tokenId: null,
6364
- meta: {
6365
- ...context.client,
6366
- oauthClient: principal.clientId
6367
- }
6368
- };
6369
- throw internalError(context, `A write was routed for a principal of kind ${principal.kind}; only a person writes.`);
6370
- }
6371
- //#endregion
6372
6754
  //#region src/ops/collections.ts
6373
6755
  /**
6374
6756
  * Which collection and locale a call names, checked against the config of the site before
@@ -6498,7 +6880,7 @@ function revisionOf(input) {
6498
6880
  const createDraft = async (input, context) => {
6499
6881
  const collection = requireCollection(context.site, input.collection);
6500
6882
  requireLocale(context.site, input.locale);
6501
- const actor = actorOf(context);
6883
+ const actor = actorOf$1(context);
6502
6884
  const processed = await processFor(context.site, collection, input.markdown);
6503
6885
  if (!processed.ok) return {
6504
6886
  outcome: "refused",
@@ -6575,13 +6957,13 @@ const getEntry$1 = async (input, context) => {
6575
6957
  return {
6576
6958
  outcome: "ok",
6577
6959
  entry: {
6578
- ...entrySummary(found),
6960
+ ...forReaders(context, entrySummary(found)),
6579
6961
  revision: revision.id,
6580
6962
  verdict: verdictView(revision.verdict),
6581
6963
  lastPublication: publicationView(publication),
6582
6964
  ...input.formats.includes("markdown") ? { markdown: revision.markdown } : {},
6583
- ...input.formats.includes("document") ? { document: revision.document } : {},
6584
- ...input.formats.includes("html") ? { html: await renderHtml(revision.document) } : {}
6965
+ ...input.formats.includes("document") ? { document: forReaders(context, revision.document) } : {},
6966
+ ...input.formats.includes("html") ? { html: await renderHtml(resolveAssetReferences(revision.document, assetUrlOf(context))) } : {}
6585
6967
  },
6586
6968
  ...related === void 0 ? {} : { related }
6587
6969
  };
@@ -6605,7 +6987,7 @@ const getTranslations$1 = async (input, context) => {
6605
6987
  if (group.length === 0) return { outcome: "not_found" };
6606
6988
  return {
6607
6989
  outcome: "ok",
6608
- entries: group.map(entrySummary)
6990
+ entries: group.map((entry) => forReaders(context, entrySummary(entry)))
6609
6991
  };
6610
6992
  };
6611
6993
  //#endregion
@@ -6626,7 +7008,7 @@ const listEntries$1 = async (input, context) => {
6626
7008
  });
6627
7009
  return {
6628
7010
  outcome: "ok",
6629
- entries: page.entries.map(entrySummary),
7011
+ entries: page.entries.map((entry) => forReaders(context, entrySummary(entry))),
6630
7012
  nextCursor: page.nextCursor
6631
7013
  };
6632
7014
  };
@@ -6711,7 +7093,7 @@ const search$1 = async (input, context) => {
6711
7093
  outcome: "ok",
6712
7094
  mode,
6713
7095
  results: hits.map((hit) => ({
6714
- entry: entrySummary(hit.entry),
7096
+ entry: forReaders(context, entrySummary(hit.entry)),
6715
7097
  snippet: hit.snippet,
6716
7098
  score: hit.score
6717
7099
  }))
@@ -6764,11 +7146,11 @@ function siteEventFor(input) {
6764
7146
  /** How many slugs of refused entries an answer names. */
6765
7147
  const EXAMPLES = 3;
6766
7148
  function structureOf(context) {
6767
- if (context.structure === void 0) throw internalError(context, "An operation on the structure ran where the structure is not reachable.");
7149
+ if (context.structure === void 0) throw internalError$1(context, "An operation on the structure ran where the structure is not reachable.");
6768
7150
  return context.structure;
6769
7151
  }
6770
7152
  function actor(context) {
6771
- const { userId, meta } = actorOf(context);
7153
+ const { userId, meta } = actorOf$1(context);
6772
7154
  return {
6773
7155
  userId,
6774
7156
  meta
@@ -6819,12 +7201,25 @@ async function impactOf(context, store, current, next) {
6819
7201
  /** The definition of the site of the call, as stored, with its version. */
6820
7202
  async function currentOf(context) {
6821
7203
  const stored = await structureOf(context).store.read(context.site.key);
6822
- if (stored === null) throw internalError(context, `The site ${context.site.key} is served and has no definition in D1.`);
7204
+ if (stored === null) throw internalError$1(context, `The site ${context.site.key} is served and has no definition in D1.`);
6823
7205
  return {
6824
7206
  version: stored.version,
6825
7207
  definition: siteDefinitionData.parse(stored.definition)
6826
7208
  };
6827
7209
  }
7210
+ /**
7211
+ * A change sent at a version that is not the current one. It says which version is, and that the
7212
+ * change was not applied: whoever changed the structure since (often the caller's own earlier
7213
+ * update_site) made the definition that was read stale.
7214
+ */
7215
+ function conflict(sent, current) {
7216
+ return {
7217
+ outcome: "conflict",
7218
+ currentVersion: current,
7219
+ message: `The structure is at version ${current}, not ${sent}: it changed since you read it, so nothing was applied.`,
7220
+ fix: `Call get_structure again, apply your change to the definition it returns (version ${current}), and send it with version ${current}. An earlier update_site or set_collection of yours also moves the version.`
7221
+ };
7222
+ }
6828
7223
  /** Replaces the definition of the site of the call with `next`, as update_site answers. */
6829
7224
  async function change(context, version, next, confirm) {
6830
7225
  const structure = structureOf(context);
@@ -6834,10 +7229,7 @@ async function change(context, version, next, confirm) {
6834
7229
  problems
6835
7230
  };
6836
7231
  const current = await currentOf(context);
6837
- if (current.version !== version) return {
6838
- outcome: "conflict",
6839
- currentVersion: current.version
6840
- };
7232
+ if (current.version !== version) return conflict(version, current.version);
6841
7233
  if (canonicalJson(current.definition) === canonicalJson(next)) return {
6842
7234
  outcome: "unchanged",
6843
7235
  version
@@ -6859,10 +7251,7 @@ async function change(context, version, next, confirm) {
6859
7251
  definition: next,
6860
7252
  actor: actor(context),
6861
7253
  event
6862
- }) === "conflict") return {
6863
- outcome: "conflict",
6864
- currentVersion: (await currentOf(context)).version
6865
- };
7254
+ }) === "conflict") return conflict(version, (await currentOf(context)).version);
6866
7255
  context.notify?.([event.id]);
6867
7256
  await structure.changed();
6868
7257
  return {
@@ -6990,7 +7379,7 @@ const publish$1 = async (input, context) => {
6990
7379
  const mayPublish = context.principal.scopes.includes("content:publish");
6991
7380
  if (!mayPublish && (input.group.length > 0 || input.publishAt !== void 0)) throw new HollwareError("invalid_input", "Without content:publish, publish asks for a review of one entry, now: not of a group, and not at a time.", "Publish each entry on its own, without group and publishAt; a person who may publish approves it.");
6992
7381
  if (input.group.length > 0) return publishGroup(input, context);
6993
- const actor = actorOf(context);
7382
+ const actor = actorOf$1(context);
6994
7383
  const found = await context.store.findEntryById(context.site.key, input.id, "head");
6995
7384
  if (found === null) return { outcome: "not_found" };
6996
7385
  const { entry, revision } = found;
@@ -7136,7 +7525,7 @@ function asPublished(lookup, ids) {
7136
7525
  */
7137
7526
  async function publishGroup(input, context) {
7138
7527
  if (input.publishAt !== void 0) throw new HollwareError("invalid_input", "A group cannot be scheduled: publishAt schedules one entry.", "Publish the group now, without publishAt, or schedule its entries one by one.");
7139
- const actor = actorOf(context);
7528
+ const actor = actorOf$1(context);
7140
7529
  const members = [{
7141
7530
  id: input.id,
7142
7531
  version: input.version
@@ -7323,7 +7712,7 @@ const approveReview = async (input, context) => {
7323
7712
  };
7324
7713
  /** Closes a pending review with a note, without publishing. */
7325
7714
  const requestChanges = async (input, context) => {
7326
- const { userId } = actorOf(context);
7715
+ const { userId } = actorOf$1(context);
7327
7716
  const closed = await context.store.requestChanges(context.site.key, {
7328
7717
  id: input.id,
7329
7718
  note: input.note,
@@ -7342,7 +7731,7 @@ const requestChanges = async (input, context) => {
7342
7731
  const commentReview = async (input, context) => {
7343
7732
  const answer = await context.store.commentReview(context.site.key, {
7344
7733
  reviewId: input.id,
7345
- actor: actorOf(context),
7734
+ actor: actorOf$1(context),
7346
7735
  body: input.body
7347
7736
  });
7348
7737
  return answer.outcome === "commented" ? {
@@ -7438,7 +7827,7 @@ const restoreEntry = async (input, context) => {
7438
7827
  * leaves an entry that is not published as it is.
7439
7828
  */
7440
7829
  const unpublish = async (input, context) => {
7441
- const actor = actorOf(context);
7830
+ const actor = actorOf$1(context);
7442
7831
  const found = await context.store.findEntryById(context.site.key, input.id, "head");
7443
7832
  if (found === null) return { outcome: "not_found" };
7444
7833
  const { entry } = found;
@@ -7482,7 +7871,7 @@ const unpublish = async (input, context) => {
7482
7871
  * no revision and no version, which is what makes importing the same files twice a no-op.
7483
7872
  */
7484
7873
  const updateDraft = async (input, context) => {
7485
- const actor = actorOf(context);
7874
+ const actor = actorOf$1(context);
7486
7875
  const found = await context.store.findEntryById(context.site.key, input.id, "head");
7487
7876
  if (found === null) return { outcome: "not_found" };
7488
7877
  const { entry, revision } = found;
@@ -7583,9 +7972,11 @@ const handlers = defineHandlers({
7583
7972
  delete_entry: deleteEntry,
7584
7973
  delete_site: deleteSite,
7585
7974
  describe_site: describeSite$1,
7975
+ get_asset: getAsset,
7586
7976
  get_entry: getEntry$1,
7587
7977
  get_structure: getStructure,
7588
7978
  get_translations: getTranslations$1,
7979
+ list_assets: listAssets,
7589
7980
  list_entries: listEntries$1,
7590
7981
  search: search$1,
7591
7982
  list_reviews: listReviews,
@@ -7599,6 +7990,7 @@ const handlers = defineHandlers({
7599
7990
  unpublish,
7600
7991
  update_site: updateSite,
7601
7992
  update_draft: updateDraft,
7993
+ upload_asset: uploadAsset,
7602
7994
  validate
7603
7995
  });
7604
7996
  //#endregion