@hollware/server 0.6.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { A as users, C as reviewComments, D as schedules, E as revisions, M as effectiveScopes, N as isRole, O as sites, S as publications, T as revisionTags, _ as entries, b as members, g as deliveries, j as ROLE_SCOPES, k as tokens, l as membershipsOf, n as createInvitation, s as findSession, v as entryEmbeddings, w as reviews, x as outbox, y as invitations } from "./store-CYMjP0iN.mjs";
2
- import { CHUNKER_VERSION, EVENT_ID_HEADER, HollwareError, OPERATIONS, SCOPES, SIGNATURE_HEADER, TIMESTAMP_HEADER, chunksOf, describeBuiltInContracts, describeResolvedSite, describeSite, generatePersonalToken, generateReadToken, getEntry, getTranslations, hashPersonalToken, isPersonalToken, isReadToken, isScope, isSiteKey, listEntries, newId, processMarkdown, publish, renderHtml, resolveConfig, runContracts, search, signWebhook, sizeLimitViolation, uniqueSlugViolation, uniqueTranslationViolation } from "@hollware/core";
1
+ import { A as users, C as reviewComments, D as schedules, E as revisions, M as effectiveScopes, N as isRole, O as sites, S as publications, T as revisionTags, _ as entries, b as members, g as deliveries, j as ROLE_SCOPES, k as tokens, l as membershipsOf, n as createInvitation, s as findSession, v as entryEmbeddings, w as reviews, x as outbox, y as invitations } from "./store-C2hiR0yd.mjs";
2
+ import { CHUNKER_VERSION, ConfigError, EVENT_ID_HEADER, HollwareError, OPERATIONS, SCOPES, SIGNATURE_HEADER, TIMESTAMP_HEADER, canonicalJson, chunksOf, compileSites, definitionsFromConfig, describeBuiltInContracts, describeCatalog, describeResolvedSite, describeSite, generatePersonalToken, generateReadToken, getEntry, getTranslations, hashPersonalToken, isPersonalToken, isReadToken, isScope, isSiteKey, listEntries, newId, processMarkdown, publish, renderHtml, resolveConfig, runContracts, search, signWebhook, siteDefinitionData, sizeLimitViolation, tagsOfChange, uniqueSlugViolation, uniqueTranslationViolation } 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 = "0.6.0";
13
+ var version = "0.9.0";
14
14
  //#endregion
15
15
  //#region src/auth/responses.ts
16
16
  /** The challenge of every 401 and 403 (RFC 6750). */
@@ -389,6 +389,7 @@ const entryFields = {
389
389
  headRev: entries.headRev,
390
390
  publishedRev: entries.publishedRev,
391
391
  publishedAt: entries.publishedAt,
392
+ scheduledAt: entries.scheduledAt,
392
393
  updatedAt: entries.updatedAt
393
394
  };
394
395
  /** The columns of `revisions` that `StoredRevision` holds, except its tags. */
@@ -463,6 +464,7 @@ function toEntry(row) {
463
464
  headRevision: row.headRev,
464
465
  publishedRevision: row.publishedRev,
465
466
  publishedAt: row.publishedAt,
467
+ scheduledAt: row.scheduledAt,
466
468
  updatedAt: row.updatedAt
467
469
  };
468
470
  }
@@ -1103,7 +1105,7 @@ async function requestChanges$1(db, clock, site, write) {
1103
1105
  review
1104
1106
  };
1105
1107
  }
1106
- function metaOf(text) {
1108
+ function metaOf$1(text) {
1107
1109
  if (text === null) return null;
1108
1110
  const parsed = JSON.parse(text);
1109
1111
  return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? Object.fromEntries(Object.entries(parsed)) : null;
@@ -1118,7 +1120,7 @@ function toComment(row) {
1118
1120
  reviewId: row.reviewId,
1119
1121
  authorId: row.authorId,
1120
1122
  authorName: row.authorName,
1121
- authorMeta: metaOf(row.authorMeta),
1123
+ authorMeta: metaOf$1(row.authorMeta),
1122
1124
  body: row.body,
1123
1125
  createdAt: row.createdAt
1124
1126
  };
@@ -1316,6 +1318,7 @@ async function createEntry(db, clock, site, entry) {
1316
1318
  headRevision: revisionId,
1317
1319
  publishedRevision: null,
1318
1320
  publishedAt: null,
1321
+ scheduledAt: null,
1319
1322
  updatedAt: now
1320
1323
  },
1321
1324
  revision: storedRevision({
@@ -1826,8 +1829,8 @@ function subscribersOf(deps) {
1826
1829
  const subscribers = site.events.webhooks.map((webhook) => ({
1827
1830
  name: `webhook:${webhook.name}`,
1828
1831
  async send(payload, body) {
1829
- const secret = deps.secret(webhook.secret);
1830
- if (secret === void 0) throw new Error(`the secret ${webhook.secret} is not set`);
1832
+ const secret = webhook.secret === null ? await deps.ownSecret?.(webhook.name) : deps.secret(webhook.secret);
1833
+ if (secret === void 0) throw new Error(webhook.secret === null ? `the webhook ${webhook.name} has no secret of Hollware's yet: make it with make_webhook_secret` : `the secret ${webhook.secret} is not set`);
1831
1834
  const timestamp = Math.floor(deps.clock.now().getTime() / 1e3);
1832
1835
  const request = new Request(webhook.url, {
1833
1836
  method: "POST",
@@ -1973,6 +1976,148 @@ async function consumeEvents(messages, depsOf) {
1973
1976
  }
1974
1977
  }
1975
1978
  //#endregion
1979
+ //#region src/structure/seal.ts
1980
+ /**
1981
+ * Sealing a secret of the structure (ADR-033) under the Worker secret `HOLLWARE_SECRET`, so that
1982
+ * the database alone never holds one in clear: AES-GCM with a 256-bit key that is the SHA-256 of
1983
+ * that secret, and a fresh 96-bit nonce each time. What is stored is `v1.` and the base64url of
1984
+ * the nonce and the ciphertext with its tag. A sealed value that was tampered with, or sealed
1985
+ * under another `HOLLWARE_SECRET`, does not open.
1986
+ */
1987
+ const PREFIX = "v1.";
1988
+ function base64url(bytes) {
1989
+ return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1990
+ }
1991
+ function fromBase64url(text) {
1992
+ const padded = text.replace(/-/g, "+").replace(/_/g, "/");
1993
+ return Uint8Array.from(atob(padded + "=".repeat((4 - padded.length % 4) % 4)), (c) => c.charCodeAt(0));
1994
+ }
1995
+ async function keyOf(master) {
1996
+ if (master.length < 32) throw new Error("HOLLWARE_SECRET is shorter than 32 characters: hollware deploy makes one");
1997
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(master));
1998
+ return crypto.subtle.importKey("raw", digest, "AES-GCM", false, ["encrypt", "decrypt"]);
1999
+ }
2000
+ async function seal(master, value) {
2001
+ const nonce = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
2002
+ const cipher = new Uint8Array(await crypto.subtle.encrypt({
2003
+ name: "AES-GCM",
2004
+ iv: nonce
2005
+ }, await keyOf(master), new TextEncoder().encode(value)));
2006
+ const sealed = new Uint8Array(nonce.length + cipher.length);
2007
+ sealed.set(nonce);
2008
+ sealed.set(cipher, nonce.length);
2009
+ return `${PREFIX}${base64url(sealed)}`;
2010
+ }
2011
+ /** The value of a sealed secret, or `null` when it does not open under `master`. */
2012
+ async function unseal(master, sealed) {
2013
+ if (!sealed.startsWith(PREFIX)) return null;
2014
+ try {
2015
+ const bytes = fromBase64url(sealed.slice(3));
2016
+ const plain = await crypto.subtle.decrypt({
2017
+ name: "AES-GCM",
2018
+ iv: bytes.slice(0, 12)
2019
+ }, await keyOf(master), bytes.slice(12));
2020
+ return new TextDecoder().decode(plain);
2021
+ } catch {
2022
+ return null;
2023
+ }
2024
+ }
2025
+ /** A new secret for a webhook: 32 random bytes, hex, behind `whsec_`. */
2026
+ function newWebhookSecret() {
2027
+ const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32));
2028
+ return `whsec_${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
2029
+ }
2030
+ //#endregion
2031
+ //#region src/structure/store.ts
2032
+ /**
2033
+ * The structure of the sites on D1 (ADR-033). Each change is one batch: the row of `sites`, moved
2034
+ * only if it is where the caller saw it, and its line of `site_changes`, written only if the row
2035
+ * moved, at the version it moved to.
2036
+ */
2037
+ function metaOf(actor) {
2038
+ return actor.meta === null ? null : JSON.stringify(actor.meta);
2039
+ }
2040
+ /** The change a statement before it made, at the site's version now, if that version is new. */
2041
+ function appendChange(db, site, actor, now, before, after) {
2042
+ return db.prepare(`INSERT OR IGNORE INTO site_changes (id, site_id, version, actor_user_id, actor_meta, before, after, created_at, updated_at)
2043
+ SELECT ?1, id, version, ?2, ?3, ?4, ${after === "definition" ? "definition" : "NULL"}, ?5, ?5 FROM sites WHERE id = ?6 AND updated_at = ?5`).bind(newId("sit"), actor.userId, metaOf(actor), before, now, site);
2044
+ }
2045
+ function createStructureStore(db, now) {
2046
+ return {
2047
+ async read(site) {
2048
+ const row = await db.prepare("SELECT version, definition, deleted_at FROM sites WHERE id = ?1").bind(site).first();
2049
+ if (row === null || row.definition === null) return null;
2050
+ return {
2051
+ version: row.version,
2052
+ definition: JSON.parse(row.definition),
2053
+ deletedAt: row.deleted_at
2054
+ };
2055
+ },
2056
+ async write({ site, expected, definition, actor }) {
2057
+ const at = now().toISOString();
2058
+ const before = await db.prepare("SELECT definition FROM sites WHERE id = ?1").bind(site).first("definition");
2059
+ const [moved] = await db.batch([db.prepare("UPDATE sites SET definition = ?1, version = version + 1, updated_at = ?2 WHERE id = ?3 AND version = ?4 AND deleted_at IS NULL").bind(JSON.stringify(definition), at, site, expected), appendChange(db, site, actor, at, before ?? null, "definition")]);
2060
+ return (moved?.meta.changes ?? 0) === 1 ? "written" : "conflict";
2061
+ },
2062
+ async create({ site, definition, actor }) {
2063
+ const at = now().toISOString();
2064
+ const [made] = await db.batch([
2065
+ db.prepare("INSERT INTO sites (id, definition, version, created_at, updated_at) VALUES (?1, ?2, 1, ?3, ?3) ON CONFLICT (id) DO NOTHING").bind(site, JSON.stringify(definition), at),
2066
+ db.prepare(`INSERT INTO members (id, site_id, user_id, role, created_at, updated_at)
2067
+ SELECT ?1, ?2, ?3, 'owner', ?4, ?4 WHERE EXISTS (SELECT 1 FROM sites WHERE id = ?2 AND created_at = ?4 AND version = 1)
2068
+ ON CONFLICT (site_id, user_id) DO NOTHING`).bind(newId("mbr"), site, actor.userId, at),
2069
+ appendChange(db, site, actor, at, null, "definition")
2070
+ ]);
2071
+ return (made?.meta.changes ?? 0) === 1 ? "created" : "taken";
2072
+ },
2073
+ async trash(site, actor) {
2074
+ const at = now().toISOString();
2075
+ const before = await db.prepare("SELECT definition FROM sites WHERE id = ?1").bind(site).first("definition");
2076
+ const [moved] = await db.batch([db.prepare("UPDATE sites SET deleted_at = ?1, version = version + 1, updated_at = ?1 WHERE id = ?2 AND deleted_at IS NULL AND definition IS NOT NULL").bind(at, site), appendChange(db, site, actor, at, before ?? null, "null")]);
2077
+ return (moved?.meta.changes ?? 0) === 1;
2078
+ },
2079
+ async restore(site, actor) {
2080
+ const at = now().toISOString();
2081
+ const [moved] = await db.batch([db.prepare("UPDATE sites SET deleted_at = NULL, version = version + 1, updated_at = ?1 WHERE id = ?2 AND deleted_at IS NOT NULL").bind(at, site), appendChange(db, site, actor, at, null, "definition")]);
2082
+ return (moved?.meta.changes ?? 0) === 1;
2083
+ },
2084
+ async roleOf(site, userId) {
2085
+ const role = await db.prepare("SELECT role FROM members WHERE site_id = ?1 AND user_id = ?2").bind(site, userId).first("role");
2086
+ return role === "owner" || role === "editor" || role === "author" ? role : null;
2087
+ },
2088
+ async counts(site) {
2089
+ const [collections, locales] = await db.batch([db.prepare("SELECT collection AS key, count(*) AS n FROM entries WHERE site_id = ?1 GROUP BY collection").bind(site), db.prepare("SELECT locale AS key, count(*) AS n FROM entries WHERE site_id = ?1 GROUP BY locale").bind(site)]);
2090
+ const mapOf = (rows) => new Map((rows ?? []).map((row) => [row.key, row.n]));
2091
+ return {
2092
+ byCollection: mapOf(collections?.results),
2093
+ byLocale: mapOf(locales?.results)
2094
+ };
2095
+ },
2096
+ async frontmatters(site, collection, limit) {
2097
+ const { results } = await db.prepare(`SELECT e.slug AS slug, r.frontmatter AS frontmatter FROM entries e
2098
+ JOIN revisions r ON r.id = e.head_rev AND r.site_id = e.site_id
2099
+ WHERE e.site_id = ?1 AND e.collection = ?2 ORDER BY e.slug LIMIT ?3`).bind(site, collection, limit).all();
2100
+ return results.map((row) => ({
2101
+ slug: row.slug,
2102
+ frontmatter: JSON.parse(row.frontmatter)
2103
+ }));
2104
+ }
2105
+ };
2106
+ }
2107
+ /** The sealed secrets of the structure of a site (ADR-033), by name. */
2108
+ function createSecretStore(db, now) {
2109
+ return {
2110
+ async read(site, name) {
2111
+ return db.prepare("SELECT sealed FROM site_secrets WHERE site_id = ?1 AND name = ?2").bind(site, name).first("sealed");
2112
+ },
2113
+ async write(site, name, sealed) {
2114
+ const at = now().toISOString();
2115
+ await db.prepare(`INSERT INTO site_secrets (id, site_id, name, sealed, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?5)
2116
+ ON CONFLICT (site_id, name) DO UPDATE SET sealed = excluded.sealed, updated_at = excluded.updated_at`).bind(newId("sec"), site, name, sealed, at).run();
2117
+ }
2118
+ };
2119
+ }
2120
+ //#endregion
1976
2121
  //#region src/mcp/errors.ts
1977
2122
  /**
1978
2123
  * The error a caller gets for anything that is not a `HollwareError`. The message is fixed: what
@@ -2169,6 +2314,24 @@ function installAdmin(app, options) {
2169
2314
  });
2170
2315
  }
2171
2316
  //#endregion
2317
+ //#region src/identity/people.ts
2318
+ /** The owners of the site besides `userId`, counted in the statement that changes `userId`. */
2319
+ const OTHER_OWNERS = "(SELECT count(*) FROM members WHERE site_id = ?1 AND role = 'owner' AND user_id <> ?2) > 0";
2320
+ async function roleOf$1(db, site, userId) {
2321
+ return db.prepare("SELECT role FROM members WHERE site_id = ?1 AND user_id = ?2").bind(site, userId).first("role");
2322
+ }
2323
+ async function setRole(db, input) {
2324
+ const current = await roleOf$1(db, input.site, input.userId);
2325
+ if (current === null) return "not_member";
2326
+ if (current === input.role) return "unchanged";
2327
+ return (await db.prepare(`UPDATE members SET role = ?3, updated_at = ?4 WHERE site_id = ?1 AND user_id = ?2
2328
+ AND (role <> 'owner' OR ?3 = 'owner' OR ${OTHER_OWNERS})`).bind(input.site, input.userId, input.role, input.now).run()).meta.changes === 1 ? "changed" : "last_owner";
2329
+ }
2330
+ async function removeMember(db, input) {
2331
+ if (await roleOf$1(db, input.site, input.userId) === null) return "not_member";
2332
+ return (await db.prepare(`DELETE FROM members WHERE site_id = ?1 AND user_id = ?2 AND (role <> 'owner' OR ${OTHER_OWNERS})`).bind(input.site, input.userId).run()).meta.changes === 1 ? "changed" : "last_owner";
2333
+ }
2334
+ //#endregion
2172
2335
  //#region src/http/admin-api.ts
2173
2336
  /**
2174
2337
  * What only a person in the admin may do (ADR-026): see, make and revoke personal tokens, and
@@ -2186,9 +2349,16 @@ function json$1(body, status = 200) {
2186
2349
  headers: { "cache-control": "no-store" }
2187
2350
  });
2188
2351
  }
2352
+ /** The code of each refusal: what the input breaks, who may not, what is not there, what clashes. */
2353
+ const REFUSAL_CODES = {
2354
+ 400: "invalid_input",
2355
+ 403: "forbidden",
2356
+ 404: "not_found",
2357
+ 409: "conflict"
2358
+ };
2189
2359
  function refused(message, fix, status = 400) {
2190
2360
  return json$1({
2191
- code: status === 400 ? "invalid_input" : "forbidden",
2361
+ code: REFUSAL_CODES[status],
2192
2362
  message,
2193
2363
  fix
2194
2364
  }, status);
@@ -2226,12 +2396,20 @@ function installAdminApi(app, options) {
2226
2396
  id: users.id,
2227
2397
  name: users.name
2228
2398
  }).from(users).where(eq(users.id, person.userId));
2229
- const sites = (await membershipsOf(db, person.userId)).filter((membership) => options.sites.has(membership.site));
2399
+ const memberships = await membershipsOf(db, person.userId);
2400
+ const sites = memberships.filter((membership) => options.sites.has(membership.site));
2401
+ const { results: trashed } = await c.env.DB.prepare("SELECT id, deleted_at FROM sites WHERE deleted_at IS NOT NULL").all();
2402
+ const owned = new Set(memberships.filter((membership) => membership.role === "owner").map((m) => m.site));
2403
+ const trash = trashed.filter((row) => owned.has(row.id)).map((row) => ({
2404
+ site: row.id,
2405
+ deletedAt: row.deleted_at
2406
+ }));
2230
2407
  return json$1({
2231
2408
  user,
2232
2409
  site: person.site,
2233
2410
  scopes: person.scopes,
2234
- sites
2411
+ sites,
2412
+ trash
2235
2413
  });
2236
2414
  }, { anyone: true }));
2237
2415
  app.get("/v1/admin/tokens", admin(async (c, person) => {
@@ -2326,6 +2504,36 @@ function installAdminApi(app, options) {
2326
2504
  }).from(invitations).where(and(eq(invitations.siteId, person.site), isNull(invitations.usedAt), isNull(invitations.userId), gt(invitations.expiresAt, now))).orderBy(desc(invitations.createdAt))
2327
2505
  });
2328
2506
  }));
2507
+ /** What a change of a member answers, by what it did. */
2508
+ const answerOf = (change) => {
2509
+ switch (change) {
2510
+ case "changed":
2511
+ case "unchanged": return json$1({ outcome: change });
2512
+ case "not_member": return refused("That person is no member of this site.", "Pick someone from the list of people.", 404);
2513
+ case "last_owner": return refused("A site keeps at least one owner, and this is its last one.", "Make another person an owner first, then change this one.", 409);
2514
+ }
2515
+ };
2516
+ app.post("/v1/admin/people/role", admin(async (c, person) => {
2517
+ const input = await body(c);
2518
+ const role = input["role"];
2519
+ const userId = input["userId"];
2520
+ if (typeof userId !== "string" || typeof role !== "string" || !isRole(role)) return refused("Give the userId of a member and a role.", `Send { "userId": "usr_…", "role": "owner" | "editor" | "author" }.`);
2521
+ const now = options.clock.now().toISOString();
2522
+ return answerOf(await setRole(c.env.DB, {
2523
+ site: person.site,
2524
+ userId,
2525
+ role,
2526
+ now
2527
+ }));
2528
+ }));
2529
+ app.post("/v1/admin/people/remove", admin(async (c, person) => {
2530
+ const userId = (await body(c))["userId"];
2531
+ if (typeof userId !== "string") return refused("Give the userId of a member.", "Send { \"userId\": \"usr_…\" }.");
2532
+ return answerOf(await removeMember(c.env.DB, {
2533
+ site: person.site,
2534
+ userId
2535
+ }));
2536
+ }));
2329
2537
  app.post("/v1/admin/invitations", admin(async (c, person) => {
2330
2538
  const input = await body(c);
2331
2539
  const role = input["role"];
@@ -2598,7 +2806,8 @@ function installOpsApi(app, options) {
2598
2806
  requestId,
2599
2807
  notify: options.notify(c, requestId, site.key),
2600
2808
  semantic: semanticOfEnv(c.env),
2601
- analytics: analyticsReaderOf(c.env)
2809
+ analytics: analyticsReaderOf(c.env),
2810
+ ...options.structure === void 0 ? {} : { structure: options.structure(c.env.DB, c.env.HOLLWARE_SECRET) }
2602
2811
  };
2603
2812
  try {
2604
2813
  return json(spec.output.parse(await invoke(options.handlers, spec, parsed.data, context)));
@@ -2723,7 +2932,7 @@ function installReadApi(app, options) {
2723
2932
  if (crossOrigin !== null && !options.corsOrigins(principal.site).includes(crossOrigin)) return c.json({
2724
2933
  code: "forbidden_origin",
2725
2934
  message: `The site "${principal.site}" does not let pages of ${crossOrigin} read it.`,
2726
- fix: `Add "${crossOrigin}" to cors.origins of the site in hollware.config.ts, or read from a server.`
2935
+ fix: `Add "${crossOrigin}" to the cors.origins of the site (in the admin, Site, or with update_site), or read from a server.`
2727
2936
  }, 403, {
2728
2937
  vary: "Origin",
2729
2938
  "access-control-allow-origin": new URL(c.env.HOLLWARE_PUBLIC_URL).origin
@@ -2885,10 +3094,10 @@ function openApiDocument() {
2885
3094
  */
2886
3095
  /**
2887
3096
  * The look of hollware.com and the admin, with nothing to fetch: the page's policy loads no font,
2888
- * so the type is the system's. Ink greys and the mint accent, dark when the device is.
3097
+ * so the type is the system's. Dark only, the neutral greys of the theme, white as the primary
3098
+ * color and emerald for the mark (tests/conventions/brand.test.ts).
2889
3099
  */
2890
- const STYLE = `:root{color-scheme:light dark;--bg:#f6f7f8;--card:#fff;--fg:#15181b;--muted:#545c66;--line:#d6dadf;--accent:#0c825d;--on-accent:#fff;--danger:#b42318}
2891
- @media (prefers-color-scheme:dark){:root{--bg:#08090a;--card:#0f1113;--fg:#eceef0;--muted:#8a939e;--line:rgb(255 255 255/.1);--accent:#3ee6a8;--on-accent:#04140d;--danger:#ff8a80}}
3100
+ const STYLE = `:root{color-scheme:dark;--bg:#0a0a0a;--card:#171717;--fg:#f5f5f5;--muted:#a3a3a3;--line:rgb(255 255 255/.1);--accent:#fafafa;--on-accent:#0a0a0a;--mark:#34d399;--danger:#ff8a80}
2892
3101
  *{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:2rem 1rem;background:var(--bg);color:var(--fg);font:15px/1.6 system-ui,-apple-system,'Segoe UI',sans-serif;-webkit-font-smoothing:antialiased}
2893
3102
  main{width:100%;max-width:26rem;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:2rem}
2894
3103
  .brand{display:flex;align-items:center;gap:.6rem;margin-bottom:1.75rem;font-weight:600;letter-spacing:-.01em}.brand svg{width:26px;height:26px}
@@ -2901,7 +3110,7 @@ button{margin-top:1.25rem;padding:.6rem 1rem;font:inherit;font-weight:500;cursor
2901
3110
  button:not([value=deny]){background:var(--accent);border-color:var(--accent);color:var(--on-accent)}
2902
3111
  [role=alert]{color:var(--danger)}`;
2903
3112
  /** The mark of Hollware, before the title of every page. */
2904
- const BRAND = `<div class="brand"><svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="8" fill="currentColor" opacity=".08"/><path d="M9 8v16M23 8v16M9 16h14" stroke="var(--accent)" stroke-width="3" stroke-linecap="round" fill="none"/></svg>Hollware</div>`;
3113
+ const BRAND = `<div class="brand"><svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="8" fill="currentColor" opacity=".08"/><path d="M9 8v16M23 8v16M9 16h14" stroke="var(--mark)" stroke-width="3" stroke-linecap="round" fill="none"/></svg>Hollware</div>`;
2905
3114
  const POST = `async function post(path, body) {
2906
3115
  const response = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
2907
3116
  const data = await response.json()
@@ -3058,7 +3267,7 @@ function plainPageHeaders() {
3058
3267
  * The ceremonies, with SimpleWebAuthn and its parsers, in a chunk of their own: only these four
3059
3268
  * routes use them, and a Worker that serves MCP never pays for them at startup (`budgets.json`).
3060
3269
  */
3061
- const ceremonies = () => import("./ceremonies-6mZ3u32c.mjs");
3270
+ const ceremonies = () => import("./ceremonies-D5dIEbha.mjs");
3062
3271
  /** The cookie of a signed-in person: only `/authorize` reads it (ADR-026). */
3063
3272
  const SESSION_COOKIE = "hollware_session";
3064
3273
  function sessionCookie(secret) {
@@ -3496,7 +3705,7 @@ function installEvents(app, options) {
3496
3705
  const path = typeof body === "object" && body !== null && "path" in body ? pathOf(body.path) : null;
3497
3706
  if (site === void 0 || path === null) return refusal(400, "invalid_input", "A beacon is JSON with the key of a site this Hollware serves and the path of a page.", "Send { \"site\": \"<site key>\", \"path\": \"/<path of the page>\" }.");
3498
3707
  const origin = c.req.header("origin") ?? null;
3499
- if (origin === null || !pageOriginsOf(site).includes(origin)) return refusal(403, "forbidden_origin", `Only pages of the site "${site.key}" count its views.`, `Set url of the site in hollware.config.ts to the origin of its pages, or add the origin to cors.origins.`);
3708
+ if (origin === null || !pageOriginsOf(site).includes(origin)) return refusal(403, "forbidden_origin", `Only pages of the site "${site.key}" count its views.`, `Set the url of the site to the origin of its pages, or add the origin to its cors.origins: in the admin, Site, or with update_site.`);
3500
3709
  const cf = Reflect.get(c.req.raw, "cf");
3501
3710
  const country = typeof cf === "object" && cf !== null && "country" in cf && typeof cf.country === "string" ? cf.country : "XX";
3502
3711
  analyticsWriterOf(c.env)?.record({
@@ -3552,7 +3761,7 @@ function withRefusalBody(response, refused) {
3552
3761
  */
3553
3762
  function createGateway(options) {
3554
3763
  const { sites, mcp, clock, log } = options;
3555
- const siteKeys = new Set(sites.keys());
3764
+ const siteKeys = options.siteKeys ?? new Set(sites.keys());
3556
3765
  const app = new Hono();
3557
3766
  /**
3558
3767
  * The Worker's own context of each request. Hono hands its routes the same object under a
@@ -3589,12 +3798,26 @@ function createGateway(options) {
3589
3798
  });
3590
3799
  }));
3591
3800
  };
3801
+ /** The structure of the sites in D1, and how this isolate sees a change of it at once. */
3802
+ const structureOf = (db, master) => ({
3803
+ store: createStructureStore(db, () => clock.now()),
3804
+ changed: async () => {
3805
+ await options.refresh?.(db, { force: true });
3806
+ },
3807
+ secrets: { make: async (site, name) => {
3808
+ if (master === void 0) return null;
3809
+ const secret = newWebhookSecret();
3810
+ await createSecretStore(db, () => clock.now()).write(site, name, await seal(master, secret));
3811
+ return secret;
3812
+ } }
3813
+ });
3592
3814
  /**
3593
3815
  * A protected route: the resource server of `resource` answers a request without a token and a
3594
3816
  * token it refuses (401, logged with the reason), and `proceed` gets the principal of the rest.
3595
3817
  */
3596
3818
  const protect = async (c, resource, proceed) => {
3597
3819
  const requestId = c.get("requestId");
3820
+ if (c.req.header("authorization") !== void 0) await options.refresh?.(c.env.DB);
3598
3821
  /** What the check said besides the answer: why it refused, or what it threw. */
3599
3822
  const outcome = {
3600
3823
  refusal: null,
@@ -3621,8 +3844,17 @@ function createGateway(options) {
3621
3844
  });
3622
3845
  return withRefusalBody(response, outcome.refusal !== null);
3623
3846
  };
3624
- /** Every origin that the config of some site lets read the site from a page. */
3625
- const corsOrigins = new Set([...sites.values()].flatMap((site) => site.cors.origins));
3847
+ /** A read of the read API, the one kind of request a page of another origin may make. */
3848
+ const isCorsRead = (request) => {
3849
+ const { pathname } = new URL(request.url);
3850
+ return [
3851
+ "GET",
3852
+ "HEAD",
3853
+ "OPTIONS"
3854
+ ].includes(request.method) && pathname.startsWith("/v1/") && !pathname.startsWith("/v1/admin/");
3855
+ };
3856
+ /** Whether the definition of some site lets pages of `origin` read it. */
3857
+ const anyCorsOrigin = (origin) => [...sites.values()].some((site) => site.cors.origins.includes(origin));
3626
3858
  /**
3627
3859
  * The guard lets a page of such an origin through to the read API alone, and only to read: the
3628
3860
  * read API then checks the origin against the site of the token.
@@ -3630,11 +3862,7 @@ function createGateway(options) {
3630
3862
  const readsFromAnyCorsOrigin = (request, origin) => {
3631
3863
  const { pathname } = new URL(request.url);
3632
3864
  if (request.method === "POST" && pathname === "/events") return true;
3633
- return corsOrigins.has(origin) && [
3634
- "GET",
3635
- "HEAD",
3636
- "OPTIONS"
3637
- ].includes(request.method) && pathname.startsWith("/v1/") && !pathname.startsWith("/v1/admin/");
3865
+ return anyCorsOrigin(origin) && isCorsRead(request);
3638
3866
  };
3639
3867
  /**
3640
3868
  * A route for a person or their personal token: a read token reads over the read API alone, so
@@ -3653,10 +3881,20 @@ function createGateway(options) {
3653
3881
  await next();
3654
3882
  });
3655
3883
  app.use("*", async (c, next) => {
3884
+ const origin = c.req.header("origin");
3885
+ if (origin !== void 0 && (isCorsRead(c.req.raw) || readsFromAnyCorsOrigin(c.req.raw, origin))) await options.refresh?.(c.env.DB);
3656
3886
  const refusal = checkHostAndOrigin(c.req.raw, c.env.HOLLWARE_PUBLIC_URL, readsFromAnyCorsOrigin);
3657
3887
  if (refusal !== null) return refusal;
3658
3888
  await next();
3659
3889
  });
3890
+ for (const path of [
3891
+ "/authorize",
3892
+ "/oauth/*",
3893
+ "/events"
3894
+ ]) app.use(path, async (c, next) => {
3895
+ await options.refresh?.(c.env.DB);
3896
+ await next();
3897
+ });
3660
3898
  app.get("/health", () => Response.json({ ok: true }, { headers: { "cache-control": "no-store" } }));
3661
3899
  app.all("/mcp", async (c) => protectWrites(c, "mcp", async (principal) => {
3662
3900
  const requestId = c.get("requestId");
@@ -3673,7 +3911,8 @@ function createGateway(options) {
3673
3911
  clock,
3674
3912
  notify: notify(c, requestId, site.key),
3675
3913
  semantic: semanticOfEnv(c.env),
3676
- analytics: analyticsReaderOf(c.env)
3914
+ analytics: analyticsReaderOf(c.env),
3915
+ structure: structureOf(db, c.env.HOLLWARE_SECRET)
3677
3916
  }
3678
3917
  });
3679
3918
  }));
@@ -3707,12 +3946,13 @@ function createGateway(options) {
3707
3946
  log,
3708
3947
  protect: (c, proceed) => protectWrites(c, "v1", proceed),
3709
3948
  metadataUrl: (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1,
3710
- notify
3949
+ notify,
3950
+ structure: structureOf
3711
3951
  });
3712
3952
  if (options.read !== void 0) installReadApi(app, {
3713
3953
  read: options.read,
3714
3954
  corsOrigins: (site) => sites.get(site)?.cors.origins ?? [],
3715
- anyCorsOrigin: (origin) => corsOrigins.has(origin),
3955
+ anyCorsOrigin,
3716
3956
  protect: (c, proceed) => protect(c, "v1", proceed),
3717
3957
  metadataUrl: (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1
3718
3958
  });
@@ -3919,6 +4159,13 @@ const OUTPUT_TOO_LARGE_FIX = {
3919
4159
  search: "Ask for less: pass a smaller limit.",
3920
4160
  unpublish: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
3921
4161
  update_draft: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
4162
+ create_site: "The site was made and holds little: read it with get_structure from a token of that site.",
4163
+ delete_site: "Nothing to ask for less of: the answer is a count. Try again.",
4164
+ make_webhook_secret: "Nothing to ask for less of: the answer is one secret. Try again.",
4165
+ get_structure: "The structure does not fit in one answer: read the collections with describe_site instead, and change them one at a time with set_collection.",
4166
+ restore_site: "Nothing to ask for less of: the answer is an outcome. Try again.",
4167
+ set_collection: "Too many problems for one answer: fix the first ones and send the collection again.",
4168
+ update_site: "Too many problems for one answer: fix the first ones, or change one collection at a time with set_collection.",
3922
4169
  validate: "The verdict lists more problems than fit in one answer. Fix the first ones and validate again."
3923
4170
  };
3924
4171
  /**
@@ -4318,10 +4565,15 @@ function forCaller(deps, requestId, method, error) {
4318
4565
  * a call that fails never opens a session.
4319
4566
  */
4320
4567
  async function run(deps, env, method, site, schema, build, handler) {
4568
+ const requestId = crypto.randomUUID();
4569
+ try {
4570
+ await deps.refresh?.(env.DB);
4571
+ } catch (error) {
4572
+ throw forCaller(deps, requestId, method, error);
4573
+ }
4321
4574
  const served = deps.sites.get(site);
4322
4575
  if (served === void 0) throw unknownSite(site, deps.sites.keys());
4323
4576
  const input = parseInput(schema, build());
4324
- const requestId = crypto.randomUUID();
4325
4577
  const context = {
4326
4578
  site: served,
4327
4579
  principal: BINDING_PRINCIPAL,
@@ -4486,6 +4738,73 @@ async function runDueSchedules(input) {
4486
4738
  cancelled
4487
4739
  };
4488
4740
  }
4741
+ async function moveConfig(db, seed, now) {
4742
+ const statements = Object.entries(seed).flatMap(([key, definition]) => {
4743
+ const json = JSON.stringify(definition);
4744
+ return [db.prepare(`INSERT INTO sites (id, definition, version, created_at, updated_at) VALUES (?1, ?2, 1, ?3, ?3)
4745
+ ON CONFLICT (id) DO UPDATE SET definition = excluded.definition, version = 1, updated_at = excluded.updated_at
4746
+ WHERE sites.definition IS NULL`).bind(key, json, now), db.prepare(`INSERT OR IGNORE INTO site_changes (id, site_id, version, actor_user_id, actor_meta, before, after, created_at, updated_at)
4747
+ SELECT ?1, ?2, 1, NULL, NULL, NULL, definition, ?3, ?3 FROM sites WHERE id = ?2 AND version = 1`).bind(newId("sit"), key, now)];
4748
+ });
4749
+ if (statements.length > 0) await db.batch(statements);
4750
+ }
4751
+ function createSiteRegistry(options) {
4752
+ const pollMs = options.pollMs ?? 1e3;
4753
+ const sites = /* @__PURE__ */ new Map();
4754
+ const keys = /* @__PURE__ */ new Set();
4755
+ let compiled = /* @__PURE__ */ new Map();
4756
+ let polledAt = Number.NEGATIVE_INFINITY;
4757
+ let moved = options.seed === void 0;
4758
+ const refresh = async (db, refreshOptions = {}) => {
4759
+ const now = options.now();
4760
+ if (refreshOptions.force !== true && now.getTime() - polledAt < pollMs) return;
4761
+ if (!moved && options.seed !== void 0) {
4762
+ await moveConfig(db, options.seed, now.toISOString());
4763
+ moved = true;
4764
+ }
4765
+ const { results: versions } = await db.prepare("SELECT id, version, (definition IS NOT NULL AND deleted_at IS NULL) AS served FROM sites").all();
4766
+ const stale = versions.filter((row) => row.served === 1 && compiled.get(row.id)?.version !== row.version);
4767
+ const fetched = stale.length === 0 ? [] : (await db.prepare(`SELECT id, version, definition FROM sites WHERE id IN (${stale.map((_, index) => `?${index + 1}`).join(", ")})`).bind(...stale.map((row) => row.id)).all()).results;
4768
+ const next = /* @__PURE__ */ new Map();
4769
+ for (const row of versions) {
4770
+ const current = compiled.get(row.id);
4771
+ if (row.served === 1 && current !== void 0 && current.version >= row.version) next.set(row.id, current);
4772
+ }
4773
+ for (const row of fetched) {
4774
+ if (row.definition === null || (next.get(row.id)?.version ?? -1) >= row.version) continue;
4775
+ try {
4776
+ const definition = JSON.parse(row.definition);
4777
+ const site = compileSites({ [row.id]: definition }).sites.get(row.id);
4778
+ if (site !== void 0) next.set(row.id, {
4779
+ version: row.version,
4780
+ site
4781
+ });
4782
+ } catch (error) {
4783
+ options.log({
4784
+ kind: "site_definition_invalid",
4785
+ site: row.id,
4786
+ version: row.version,
4787
+ message: error instanceof ConfigError ? error.message : String(error)
4788
+ });
4789
+ const previous = compiled.get(row.id);
4790
+ if (previous !== void 0) next.set(row.id, previous);
4791
+ }
4792
+ }
4793
+ compiled = next;
4794
+ sites.clear();
4795
+ keys.clear();
4796
+ for (const [key, value] of next) {
4797
+ sites.set(key, value.site);
4798
+ keys.add(key);
4799
+ }
4800
+ polledAt = now.getTime();
4801
+ };
4802
+ return {
4803
+ sites,
4804
+ keys,
4805
+ refresh
4806
+ };
4807
+ }
4489
4808
  //#endregion
4490
4809
  //#region src/compose.ts
4491
4810
  const systemClock = { now: () => /* @__PURE__ */ new Date() };
@@ -4509,9 +4828,10 @@ function writeLog(entry) {
4509
4828
  *
4510
4829
  * Everything that can be built once is built here, when the user's module loads, and never again:
4511
4830
  *
4512
- * - the config is resolved with `resolveConfig`, so a broken `hollware.config.ts` throws its
4513
- * `ConfigError` here, and the Worker fails at startup with a message that says what to change.
4514
- * The deployment serves every site of the config, and a token, a grant or a call names one;
4831
+ * - the registry of the sites, which reads them from D1 (ADR-033) at the start of each request,
4832
+ * call, cron run and batch. A config of 0.x is turned into definitions here, so one that cannot
4833
+ * move throws its `ConfigError` at startup, and is written to D1 on the first request. The
4834
+ * deployment serves every site of D1 with a definition, and a token, a grant or a call names one;
4515
4835
  * - the MCP endpoint, whose tool schemas are converted once, and the Hono app of the gateway,
4516
4836
  * which is `fetch`: MCP at `/mcp` behind the guard and the token check, and the other routes
4517
4837
  * (`http/gateway.ts`);
@@ -4523,9 +4843,16 @@ function writeLog(entry) {
4523
4843
  * included, is one JSON line written with `console.log`.
4524
4844
  */
4525
4845
  function createHollwareWith(config, handlers, options = {}) {
4526
- const resolved = resolveConfig(config);
4527
- const sites = resolved.sites;
4528
4846
  const clock = options.clock ?? systemClock;
4847
+ const moving = config?.sites !== void 0;
4848
+ if (moving) resolveConfig(config);
4849
+ const registry = createSiteRegistry({
4850
+ ...moving ? { seed: definitionsFromConfig(config) } : {},
4851
+ now: () => clock.now(),
4852
+ log: writeLog
4853
+ });
4854
+ const { sites } = registry;
4855
+ const refresh = (db, options) => registry.refresh(db, options);
4529
4856
  const mcp = createMcpEndpoint({
4530
4857
  handlers,
4531
4858
  serverInfo: SERVER_INFO,
@@ -4540,11 +4867,19 @@ function createHollwareWith(config, handlers, options = {}) {
4540
4867
  const value = Reflect.get(env, name);
4541
4868
  return typeof value === "string" ? value : void 0;
4542
4869
  },
4543
- semantic: semanticOfEnv(env)
4870
+ semantic: semanticOfEnv(env),
4871
+ ownSecret: async (webhook) => {
4872
+ const master = env.HOLLWARE_SECRET;
4873
+ if (master === void 0) return void 0;
4874
+ const sealed = await createSecretStore(env.DB, () => clock.now()).read(site.key, `webhook:${webhook}`);
4875
+ return sealed === null ? void 0 : await unseal(master, sealed) ?? void 0;
4876
+ }
4544
4877
  });
4545
4878
  return {
4546
4879
  fetch: createGateway({
4547
4880
  sites,
4881
+ siteKeys: registry.keys,
4882
+ refresh,
4548
4883
  mcp,
4549
4884
  clock,
4550
4885
  log: writeLog,
@@ -4554,7 +4889,7 @@ function createHollwareWith(config, handlers, options = {}) {
4554
4889
  clock,
4555
4890
  log: writeLog
4556
4891
  }, env),
4557
- auth: resolved.auth,
4892
+ auth: { dynamicClientRegistration: config?.auth?.dynamicClientRegistration ?? false },
4558
4893
  handlers
4559
4894
  }),
4560
4895
  scheduled: async (controller, env, ctx) => {
@@ -4592,6 +4927,7 @@ function createHollwareWith(config, handlers, options = {}) {
4592
4927
  });
4593
4928
  }));
4594
4929
  ctx.waitUntil((async () => {
4930
+ await refresh(env.DB);
4595
4931
  for (const site of sites.values()) await each(site).catch((error) => {
4596
4932
  writeLog({
4597
4933
  kind: "cron_failed",
@@ -4602,6 +4938,7 @@ function createHollwareWith(config, handlers, options = {}) {
4602
4938
  })());
4603
4939
  },
4604
4940
  queue: async (batch, env) => {
4941
+ await refresh(env.DB);
4605
4942
  await consumeEvents(batch.messages, (key) => {
4606
4943
  const site = sites.get(key);
4607
4944
  return site === void 0 ? void 0 : deliveryDeps(env, site);
@@ -4609,6 +4946,7 @@ function createHollwareWith(config, handlers, options = {}) {
4609
4946
  },
4610
4947
  Hollware: createEntrypoint({
4611
4948
  sites,
4949
+ refresh,
4612
4950
  handlers,
4613
4951
  clock,
4614
4952
  log: writeLog
@@ -4652,6 +4990,7 @@ function entrySummary(view) {
4652
4990
  tags: [...revision.tags],
4653
4991
  hasUnpublishedChanges: entry.publishedRevision !== entry.headRevision,
4654
4992
  publishedAt: entry.publishedAt,
4993
+ scheduledAt: entry.scheduledAt,
4655
4994
  updatedAt: entry.updatedAt
4656
4995
  };
4657
4996
  }
@@ -4863,7 +5202,7 @@ function requireLocale(site, locale) {
4863
5202
  function collectionOfEntry(site, entry) {
4864
5203
  const collection = site.collections.get(entry.collection);
4865
5204
  if (collection !== void 0) return collection;
4866
- throw new HollwareError("unknown_collection", `The entry ${entry.id} belongs to the collection ${shown(entry.collection)}, which the config of the site no longer declares.`, `Ask whoever maintains hollware.config.ts to declare the collection ${shown(entry.collection)} again: the entry and its revisions are kept.`);
5205
+ throw new HollwareError("unknown_collection", `The entry ${entry.id} belongs to the collection ${shown(entry.collection)}, which the config of the site no longer declares.`, `Ask an owner of the site to add the collection ${shown(entry.collection)} again, with set_collection or in the admin: the entry and its revisions are kept.`);
4867
5206
  }
4868
5207
  //#endregion
4869
5208
  //#region src/ops/draft.ts
@@ -5171,6 +5510,184 @@ const search$1 = async (input, context) => {
5171
5510
  }))
5172
5511
  };
5173
5512
  };
5513
+ /** How many slugs of refused entries an answer names. */
5514
+ const EXAMPLES = 3;
5515
+ function structureOf(context) {
5516
+ if (context.structure === void 0) throw internalError(context, "An operation on the structure ran where the structure is not reachable.");
5517
+ return context.structure;
5518
+ }
5519
+ function actor(context) {
5520
+ const { userId, meta } = actorOf(context);
5521
+ return {
5522
+ userId,
5523
+ meta
5524
+ };
5525
+ }
5526
+ /** The problems of a definition for `key`, or none: it compiles as the registry will compile it. */
5527
+ function problemsOf(key, definition) {
5528
+ try {
5529
+ compileSites({ [key]: definition });
5530
+ return [];
5531
+ } catch (error) {
5532
+ if (error instanceof ConfigError) return error.problems.map((problem) => ({ ...problem }));
5533
+ throw error;
5534
+ }
5535
+ }
5536
+ async function impactOf(context, store, current, next) {
5537
+ const site = context.site.key;
5538
+ const counts = await store.counts(site);
5539
+ const removedCollections = Object.keys(current.collections).filter((name) => next.collections[name] === void 0).map((name) => ({
5540
+ name,
5541
+ entries: counts.byCollection.get(name) ?? 0
5542
+ })).filter((removed) => removed.entries > 0);
5543
+ const removedLocales = current.locales.filter((locale) => !next.locales.includes(locale)).map((locale) => ({
5544
+ locale,
5545
+ entries: counts.byLocale.get(locale) ?? 0
5546
+ })).filter((removed) => removed.entries > 0);
5547
+ const compiled = compileSites({ [site]: next }).sites.get(site);
5548
+ const refused = [];
5549
+ for (const [name, collection] of Object.entries(next.collections)) {
5550
+ const before = current.collections[name];
5551
+ if (before === void 0 || canonicalJson(before.schema) === canonicalJson(collection.schema)) continue;
5552
+ const schema = compiled?.collections.get(name)?.schema;
5553
+ if (schema === void 0) continue;
5554
+ const slugs = [];
5555
+ for (const entry of await store.frontmatters(site, name, 500)) if ((await schema["~standard"].validate(entry.frontmatter)).issues !== void 0) slugs.push(entry.slug);
5556
+ if (slugs.length > 0) refused.push({
5557
+ collection: name,
5558
+ entries: slugs.length,
5559
+ examples: slugs.slice(0, EXAMPLES)
5560
+ });
5561
+ }
5562
+ return {
5563
+ removedCollections,
5564
+ removedLocales,
5565
+ refused
5566
+ };
5567
+ }
5568
+ /** The definition of the site of the call, as stored, with its version. */
5569
+ async function currentOf(context) {
5570
+ const stored = await structureOf(context).store.read(context.site.key);
5571
+ if (stored === null) throw internalError(context, `The site ${context.site.key} is served and has no definition in D1.`);
5572
+ return {
5573
+ version: stored.version,
5574
+ definition: siteDefinitionData.parse(stored.definition)
5575
+ };
5576
+ }
5577
+ /** Replaces the definition of the site of the call with `next`, as update_site answers. */
5578
+ async function change(context, version, next, confirm) {
5579
+ const structure = structureOf(context);
5580
+ const problems = problemsOf(context.site.key, next);
5581
+ if (problems.length > 0) return {
5582
+ outcome: "invalid",
5583
+ problems
5584
+ };
5585
+ const current = await currentOf(context);
5586
+ if (current.version !== version) return {
5587
+ outcome: "conflict",
5588
+ currentVersion: current.version
5589
+ };
5590
+ if (canonicalJson(current.definition) === canonicalJson(next)) return {
5591
+ outcome: "unchanged",
5592
+ version
5593
+ };
5594
+ const impact = await impactOf(context, structure.store, current.definition, next);
5595
+ if (impact.removedCollections.length + impact.removedLocales.length + impact.refused.length > 0 && !confirm) return {
5596
+ outcome: "needs_confirmation",
5597
+ impact
5598
+ };
5599
+ if (await structure.store.write({
5600
+ site: context.site.key,
5601
+ expected: version,
5602
+ definition: next,
5603
+ actor: actor(context)
5604
+ }) === "conflict") return {
5605
+ outcome: "conflict",
5606
+ currentVersion: (await currentOf(context)).version
5607
+ };
5608
+ await structure.changed();
5609
+ return {
5610
+ outcome: "updated",
5611
+ version: version + 1
5612
+ };
5613
+ }
5614
+ const getStructure = async (_input, context) => {
5615
+ const current = await currentOf(context);
5616
+ return {
5617
+ outcome: "ok",
5618
+ site: context.site.key,
5619
+ version: current.version,
5620
+ definition: current.definition,
5621
+ catalog: describeCatalog()
5622
+ };
5623
+ };
5624
+ const updateSite = (input, context) => change(context, input.version, input.definition, input.confirm);
5625
+ const setCollection = async (input, context) => {
5626
+ const current = await currentOf(context);
5627
+ const collections = { ...current.definition.collections };
5628
+ if (input.collection === null) delete collections[input.name];
5629
+ else collections[input.name] = input.collection;
5630
+ return change(context, input.version, {
5631
+ ...current.definition,
5632
+ collections
5633
+ }, input.confirm);
5634
+ };
5635
+ const createSite = async (input, context) => {
5636
+ const structure = structureOf(context);
5637
+ const problems = problemsOf(input.key, input.definition);
5638
+ if (problems.length > 0) return {
5639
+ outcome: "invalid",
5640
+ problems
5641
+ };
5642
+ if (await structure.store.create({
5643
+ site: input.key,
5644
+ definition: input.definition,
5645
+ actor: actor(context)
5646
+ }) === "taken") return { outcome: "taken" };
5647
+ await structure.changed();
5648
+ return {
5649
+ outcome: "created",
5650
+ version: 1
5651
+ };
5652
+ };
5653
+ /** Whether the person of the call owns `site`; a site they do not own is one they cannot see. */
5654
+ async function owns(context, site) {
5655
+ return await structureOf(context).store.roleOf(site, actor(context).userId) === "owner";
5656
+ }
5657
+ const deleteSite = async (input, context) => {
5658
+ const structure = structureOf(context);
5659
+ const stored = await structure.store.read(input.key);
5660
+ if (stored === null || stored.deletedAt !== null || !await owns(context, input.key)) return { outcome: "not_found" };
5661
+ if (!input.confirm) {
5662
+ const { byCollection } = await structure.store.counts(input.key);
5663
+ return {
5664
+ outcome: "needs_confirmation",
5665
+ entries: [...byCollection.values()].reduce((sum, count) => sum + count, 0)
5666
+ };
5667
+ }
5668
+ if (!await structure.store.trash(input.key, actor(context))) return { outcome: "not_found" };
5669
+ await structure.changed();
5670
+ return { outcome: "trashed" };
5671
+ };
5672
+ const restoreSite = async (input, context) => {
5673
+ const structure = structureOf(context);
5674
+ const stored = await structure.store.read(input.key);
5675
+ if (stored === null || stored.deletedAt === null || !await owns(context, input.key)) return { outcome: "not_found" };
5676
+ if (!await structure.store.restore(input.key, actor(context))) return { outcome: "not_found" };
5677
+ await structure.changed();
5678
+ return { outcome: "restored" };
5679
+ };
5680
+ const makeWebhookSecret = async (input, context) => {
5681
+ const structure = structureOf(context);
5682
+ const { definition } = await currentOf(context);
5683
+ const webhook = definition.events.webhooks.find((candidate) => candidate.name === input.webhook);
5684
+ if (webhook === void 0 || webhook.secret !== void 0) return { outcome: "not_found" };
5685
+ const secret = await structure.secrets?.make(context.site.key, `webhook:${webhook.name}`) ?? null;
5686
+ return secret === null ? { outcome: "unavailable" } : {
5687
+ outcome: "made",
5688
+ secret
5689
+ };
5690
+ };
5174
5691
  //#endregion
5175
5692
  //#region src/ops/event.ts
5176
5693
  /** The path of an entry: the route of its locale, with its slug in place of `:slug`. */
@@ -5189,7 +5706,8 @@ function eventFor(input) {
5189
5706
  at: input.at,
5190
5707
  entry: input.entry,
5191
5708
  path,
5192
- url: path === null || input.site.url === null ? null : `${input.site.url}${path}`
5709
+ url: path === null || input.site.url === null ? null : `${input.site.url}${path}`,
5710
+ cacheTags: tagsOfChange(input.entry)
5193
5711
  };
5194
5712
  return {
5195
5713
  id,
@@ -5760,16 +6278,23 @@ const handlers = defineHandlers({
5760
6278
  comment_review: commentReview,
5761
6279
  content_stats: contentStats,
5762
6280
  create_draft: createDraft,
6281
+ create_site: createSite,
6282
+ delete_site: deleteSite,
5763
6283
  describe_site: describeSite$1,
5764
6284
  get_entry: getEntry$1,
6285
+ get_structure: getStructure,
5765
6286
  get_translations: getTranslations$1,
5766
6287
  list_entries: listEntries$1,
5767
6288
  search: search$1,
5768
6289
  list_reviews: listReviews,
6290
+ make_webhook_secret: makeWebhookSecret,
5769
6291
  publish: publish$1,
5770
6292
  request_changes: requestChanges,
5771
6293
  request_review: requestReview,
6294
+ restore_site: restoreSite,
6295
+ set_collection: setCollection,
5772
6296
  unpublish,
6297
+ update_site: updateSite,
5773
6298
  update_draft: updateDraft,
5774
6299
  validate
5775
6300
  });