@hollware/server 1.0.0-rc.1 → 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.1";
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. */
@@ -3532,7 +3763,8 @@ const NAMING_FIELDS = [
3532
3763
  "id",
3533
3764
  "entryId",
3534
3765
  "reviewId",
3535
- "name"
3766
+ "name",
3767
+ "filename"
3536
3768
  ];
3537
3769
  const FIELD_MAX = 200;
3538
3770
  function isAudited(name) {
@@ -3545,7 +3777,7 @@ function idOf(value) {
3545
3777
  const id = record(value)["id"];
3546
3778
  return typeof id === "string" ? id : null;
3547
3779
  }
3548
- /** 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. */
3549
3781
  function targetOf(output, site) {
3550
3782
  const entry = idOf(output["entry"]);
3551
3783
  if (entry !== null) return {
@@ -3562,6 +3794,11 @@ function targetOf(output, site) {
3562
3794
  type: "comment",
3563
3795
  id: comment
3564
3796
  };
3797
+ const asset = idOf(output["asset"]);
3798
+ if (asset !== null) return {
3799
+ type: "asset",
3800
+ id: asset
3801
+ };
3565
3802
  return {
3566
3803
  type: "site",
3567
3804
  id: site
@@ -3704,6 +3941,116 @@ function semanticOfEnv(env) {
3704
3941
  return semanticOf(env, typeof model === "string" && model !== "" ? model : DEFAULT_EMBEDDING_MODEL);
3705
3942
  }
3706
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
3707
4054
  //#region src/analytics/cloudflare.ts
3708
4055
  /** The dataset of Analytics Engine when the Worker names none. */
3709
4056
  const DEFAULT_ANALYTICS_DATASET = "hollware_page_views";
@@ -3863,6 +4210,7 @@ function installOpsApi(app, options) {
3863
4210
  notify: options.notify(c, requestId, site.key),
3864
4211
  semantic: semanticOfEnv(c.env),
3865
4212
  analytics: analyticsReaderOf(c.env),
4213
+ media: mediaOfEnv(c.env),
3866
4214
  ...options.structure === void 0 ? {} : { structure: options.structure(c.env.DB, c.env.HOLLWARE_SECRET) }
3867
4215
  };
3868
4216
  /** The line of the call, after it answered or failed. */
@@ -4188,7 +4536,7 @@ async function writeForPerson(db, log, siteOf, userId, line) {
4188
4536
  * The ceremonies, with SimpleWebAuthn and its parsers, in a chunk of their own: only these four
4189
4537
  * routes use them, and a Worker that serves MCP never pays for them at startup (`budgets.json`).
4190
4538
  */
4191
- const ceremonies = () => import("./ceremonies-CI3DkOEL.mjs");
4539
+ const ceremonies = () => import("./ceremonies-zTn1U6BD.mjs");
4192
4540
  /**
4193
4541
  * The cookie of a signed-in person: only `/authorize` reads it (ADR-026), and the sign-out below
4194
4542
  * it, `/authorize/signout`, which the path of the cookie reaches too.
@@ -4819,7 +5167,7 @@ function createGateway(options) {
4819
5167
  * The activity log of a request (ADR-037), for an actor known once the client has said what it
4820
5168
  * is. A person in the admin is a `user`; any other OAuth client, an `oauth_client`.
4821
5169
  */
4822
- 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));
4823
5171
  /**
4824
5172
  * A protected route: the resource server of `resource` answers a request without a token and a
4825
5173
  * token it refuses (401, logged with the reason), and `proceed` gets the principal of the rest.
@@ -4889,9 +5237,10 @@ function createGateway(options) {
4889
5237
  c.set("requestId", crypto.randomUUID());
4890
5238
  await next();
4891
5239
  const https = c.env.HOLLWARE_PUBLIC_URL?.startsWith("https://") === true;
4892
- 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;
4893
5241
  const headers = new Headers(c.res.headers);
4894
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");
4895
5244
  if (https && !headers.has("strict-transport-security")) headers.set("strict-transport-security", "max-age=31536000");
4896
5245
  c.res = new Response(c.res.body, {
4897
5246
  status: c.res.status,
@@ -4938,6 +5287,10 @@ function createGateway(options) {
4938
5287
  await options.refresh?.(c.env.DB);
4939
5288
  await next();
4940
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
+ } }));
4941
5294
  app.get("/health", () => Response.json({ ok: true }, { headers: { "cache-control": "no-store" } }));
4942
5295
  app.all("/mcp", async (c) => protectWrites(c, "mcp", async (principal) => {
4943
5296
  const requestId = c.get("requestId");
@@ -4955,7 +5308,8 @@ function createGateway(options) {
4955
5308
  notify: notify(c, requestId, site.key),
4956
5309
  semantic: semanticOfEnv(c.env),
4957
5310
  analytics: analyticsReaderOf(c.env),
4958
- structure: structureOf(db, c.env.HOLLWARE_SECRET)
5311
+ structure: structureOf(db, c.env.HOLLWARE_SECRET),
5312
+ media: mediaOfEnv(c.env)
4959
5313
  },
4960
5314
  ...principal.kind === "read" ? {} : { audit: {
4961
5315
  log: auditOf(c, principal, null),
@@ -5049,6 +5403,45 @@ function createGateway(options) {
5049
5403
  structure: structureOf,
5050
5404
  audit: async (c, principal) => auditOf(c, principal, (await adminClient(oauth(c.env.HOLLWARE_PUBLIC_URL), c.env)).clientId)
5051
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
+ }));
5052
5445
  if (options.read !== void 0) installReadApi(app, {
5053
5446
  read: options.read,
5054
5447
  corsOrigins: (site) => sites.get(site)?.cors.origins ?? [],
@@ -5063,6 +5456,8 @@ function createGateway(options) {
5063
5456
  return await app.fetch(request, env, ctx);
5064
5457
  };
5065
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}$/;
5066
5461
  /** The group of a route that is rate limited, or `null` for one that is not (T-10). */
5067
5462
  function limitedRoute(method, pathname) {
5068
5463
  if (method !== "POST") return null;
@@ -5261,6 +5656,9 @@ const OUTPUT_TOO_LARGE_FIX = {
5261
5656
  get_translations: "The translations do not fit in one answer. Read each one with get_entry instead.",
5262
5657
  list_entries: "Ask for less: pass a smaller limit, or narrow the list with locale, tag or status.",
5263
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.",
5264
5662
  publish: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
5265
5663
  request_changes: "The operation ran and its change may have been applied: read the review with list_reviews before you repeat it.",
5266
5664
  request_review: "The operation ran and its review may have been asked: read it with list_reviews before you repeat it.",
@@ -5535,7 +5933,7 @@ function createMcpEndpoint(options) {
5535
5933
  try {
5536
5934
  result = spec.name === "publish" ? await performPublish() : answerResult(spec, await perform(input));
5537
5935
  } catch (error) {
5538
- failure = error instanceof HollwareError ? error : internalError$1(call.requestId, error);
5936
+ failure = error instanceof HollwareError ? error : internalError(call.requestId, error);
5539
5937
  isError = true;
5540
5938
  result = errorResult(failure);
5541
5939
  }
@@ -5727,7 +6125,8 @@ async function run(deps, env, method, site, schema, build, handler, viewOf = ()
5727
6125
  clock: deps.clock,
5728
6126
  client: null,
5729
6127
  requestId,
5730
- semantic: semanticOfEnv(env)
6128
+ semantic: semanticOfEnv(env),
6129
+ media: mediaOfEnv(env)
5731
6130
  };
5732
6131
  try {
5733
6132
  return await handler(input, context);
@@ -6352,53 +6751,6 @@ const contentStats = async (input, context) => {
6352
6751
  };
6353
6752
  };
6354
6753
  //#endregion
6355
- //#region src/ops/errors.ts
6356
- /**
6357
- * The error of something that must never happen: a bug of Hollware, not a mistake of the caller.
6358
- * Like the `internal` error of the adapters, its message says nothing about the cause, which goes
6359
- * to the log through `cause`, and its fix names the request id the operator finds the log line by.
6360
- */
6361
- function internalError(context, cause) {
6362
- 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) });
6363
- }
6364
- /**
6365
- * The error for an answer of the store that the call it came from can never get: an entry that
6366
- * is created cannot conflict, a publish has no slug to take. It is a bug, not an outcome of the
6367
- * operation, so it is not one of the outcomes of its spec.
6368
- */
6369
- function unexpectedAnswer(context, call, answer) {
6370
- return internalError(context, `The store answered ${answer.outcome} to ${call}, which it never does.`);
6371
- }
6372
- //#endregion
6373
- //#region src/ops/actor.ts
6374
- /**
6375
- * Who a write is made by: the person behind the token of the call, with what its client said
6376
- * about itself. A personal token writes as itself (ADR-019); an OAuth access token writes as the
6377
- * client the person allowed, whose id goes with what the client said (ADR-026). The adapters check
6378
- * the scopes of the operation before any handler runs and never route a write for the RPC
6379
- * binding, so reaching one with it is a bug, answered as `internal` before anything is read or
6380
- * written.
6381
- */
6382
- function actorOf(context) {
6383
- const { principal } = context;
6384
- if (principal.kind === "token") return {
6385
- kind: "token",
6386
- userId: principal.userId,
6387
- tokenId: principal.tokenId,
6388
- meta: context.client
6389
- };
6390
- if (principal.kind === "oauth") return {
6391
- kind: "oauth_client",
6392
- userId: principal.userId,
6393
- tokenId: null,
6394
- meta: {
6395
- ...context.client,
6396
- oauthClient: principal.clientId
6397
- }
6398
- };
6399
- throw internalError(context, `A write was routed for a principal of kind ${principal.kind}; only a person writes.`);
6400
- }
6401
- //#endregion
6402
6754
  //#region src/ops/collections.ts
6403
6755
  /**
6404
6756
  * Which collection and locale a call names, checked against the config of the site before
@@ -6528,7 +6880,7 @@ function revisionOf(input) {
6528
6880
  const createDraft = async (input, context) => {
6529
6881
  const collection = requireCollection(context.site, input.collection);
6530
6882
  requireLocale(context.site, input.locale);
6531
- const actor = actorOf(context);
6883
+ const actor = actorOf$1(context);
6532
6884
  const processed = await processFor(context.site, collection, input.markdown);
6533
6885
  if (!processed.ok) return {
6534
6886
  outcome: "refused",
@@ -6605,13 +6957,13 @@ const getEntry$1 = async (input, context) => {
6605
6957
  return {
6606
6958
  outcome: "ok",
6607
6959
  entry: {
6608
- ...entrySummary(found),
6960
+ ...forReaders(context, entrySummary(found)),
6609
6961
  revision: revision.id,
6610
6962
  verdict: verdictView(revision.verdict),
6611
6963
  lastPublication: publicationView(publication),
6612
6964
  ...input.formats.includes("markdown") ? { markdown: revision.markdown } : {},
6613
- ...input.formats.includes("document") ? { document: revision.document } : {},
6614
- ...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))) } : {}
6615
6967
  },
6616
6968
  ...related === void 0 ? {} : { related }
6617
6969
  };
@@ -6635,7 +6987,7 @@ const getTranslations$1 = async (input, context) => {
6635
6987
  if (group.length === 0) return { outcome: "not_found" };
6636
6988
  return {
6637
6989
  outcome: "ok",
6638
- entries: group.map(entrySummary)
6990
+ entries: group.map((entry) => forReaders(context, entrySummary(entry)))
6639
6991
  };
6640
6992
  };
6641
6993
  //#endregion
@@ -6656,7 +7008,7 @@ const listEntries$1 = async (input, context) => {
6656
7008
  });
6657
7009
  return {
6658
7010
  outcome: "ok",
6659
- entries: page.entries.map(entrySummary),
7011
+ entries: page.entries.map((entry) => forReaders(context, entrySummary(entry))),
6660
7012
  nextCursor: page.nextCursor
6661
7013
  };
6662
7014
  };
@@ -6741,7 +7093,7 @@ const search$1 = async (input, context) => {
6741
7093
  outcome: "ok",
6742
7094
  mode,
6743
7095
  results: hits.map((hit) => ({
6744
- entry: entrySummary(hit.entry),
7096
+ entry: forReaders(context, entrySummary(hit.entry)),
6745
7097
  snippet: hit.snippet,
6746
7098
  score: hit.score
6747
7099
  }))
@@ -6794,11 +7146,11 @@ function siteEventFor(input) {
6794
7146
  /** How many slugs of refused entries an answer names. */
6795
7147
  const EXAMPLES = 3;
6796
7148
  function structureOf(context) {
6797
- 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.");
6798
7150
  return context.structure;
6799
7151
  }
6800
7152
  function actor(context) {
6801
- const { userId, meta } = actorOf(context);
7153
+ const { userId, meta } = actorOf$1(context);
6802
7154
  return {
6803
7155
  userId,
6804
7156
  meta
@@ -6849,7 +7201,7 @@ async function impactOf(context, store, current, next) {
6849
7201
  /** The definition of the site of the call, as stored, with its version. */
6850
7202
  async function currentOf(context) {
6851
7203
  const stored = await structureOf(context).store.read(context.site.key);
6852
- 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.`);
6853
7205
  return {
6854
7206
  version: stored.version,
6855
7207
  definition: siteDefinitionData.parse(stored.definition)
@@ -7027,7 +7379,7 @@ const publish$1 = async (input, context) => {
7027
7379
  const mayPublish = context.principal.scopes.includes("content:publish");
7028
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.");
7029
7381
  if (input.group.length > 0) return publishGroup(input, context);
7030
- const actor = actorOf(context);
7382
+ const actor = actorOf$1(context);
7031
7383
  const found = await context.store.findEntryById(context.site.key, input.id, "head");
7032
7384
  if (found === null) return { outcome: "not_found" };
7033
7385
  const { entry, revision } = found;
@@ -7173,7 +7525,7 @@ function asPublished(lookup, ids) {
7173
7525
  */
7174
7526
  async function publishGroup(input, context) {
7175
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.");
7176
- const actor = actorOf(context);
7528
+ const actor = actorOf$1(context);
7177
7529
  const members = [{
7178
7530
  id: input.id,
7179
7531
  version: input.version
@@ -7360,7 +7712,7 @@ const approveReview = async (input, context) => {
7360
7712
  };
7361
7713
  /** Closes a pending review with a note, without publishing. */
7362
7714
  const requestChanges = async (input, context) => {
7363
- const { userId } = actorOf(context);
7715
+ const { userId } = actorOf$1(context);
7364
7716
  const closed = await context.store.requestChanges(context.site.key, {
7365
7717
  id: input.id,
7366
7718
  note: input.note,
@@ -7379,7 +7731,7 @@ const requestChanges = async (input, context) => {
7379
7731
  const commentReview = async (input, context) => {
7380
7732
  const answer = await context.store.commentReview(context.site.key, {
7381
7733
  reviewId: input.id,
7382
- actor: actorOf(context),
7734
+ actor: actorOf$1(context),
7383
7735
  body: input.body
7384
7736
  });
7385
7737
  return answer.outcome === "commented" ? {
@@ -7475,7 +7827,7 @@ const restoreEntry = async (input, context) => {
7475
7827
  * leaves an entry that is not published as it is.
7476
7828
  */
7477
7829
  const unpublish = async (input, context) => {
7478
- const actor = actorOf(context);
7830
+ const actor = actorOf$1(context);
7479
7831
  const found = await context.store.findEntryById(context.site.key, input.id, "head");
7480
7832
  if (found === null) return { outcome: "not_found" };
7481
7833
  const { entry } = found;
@@ -7519,7 +7871,7 @@ const unpublish = async (input, context) => {
7519
7871
  * no revision and no version, which is what makes importing the same files twice a no-op.
7520
7872
  */
7521
7873
  const updateDraft = async (input, context) => {
7522
- const actor = actorOf(context);
7874
+ const actor = actorOf$1(context);
7523
7875
  const found = await context.store.findEntryById(context.site.key, input.id, "head");
7524
7876
  if (found === null) return { outcome: "not_found" };
7525
7877
  const { entry, revision } = found;
@@ -7620,9 +7972,11 @@ const handlers = defineHandlers({
7620
7972
  delete_entry: deleteEntry,
7621
7973
  delete_site: deleteSite,
7622
7974
  describe_site: describeSite$1,
7975
+ get_asset: getAsset,
7623
7976
  get_entry: getEntry$1,
7624
7977
  get_structure: getStructure,
7625
7978
  get_translations: getTranslations$1,
7979
+ list_assets: listAssets,
7626
7980
  list_entries: listEntries$1,
7627
7981
  search: search$1,
7628
7982
  list_reviews: listReviews,
@@ -7636,6 +7990,7 @@ const handlers = defineHandlers({
7636
7990
  unpublish,
7637
7991
  update_site: updateSite,
7638
7992
  update_draft: updateDraft,
7993
+ upload_asset: uploadAsset,
7639
7994
  validate
7640
7995
  });
7641
7996
  //#endregion