@hollware/server 0.5.0 → 0.8.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,8 +1,8 @@
1
- import { A as ROLE_SCOPES, C as reviews, D as sites, E as schedules, M as isRole, O as tokens, S as publications, T as revisions, _ as entries, b as members, g as deliveries, j as effectiveScopes, k as users, l as membershipsOf, n as createInvitation, s as findSession, v as entryEmbeddings, w as revisionTags, x as outbox, y as invitations } from "./store-DiS8UFI2.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
- import { and, desc, eq, getTableColumns, gt, inArray, isNotNull, isNull, lte, ne, or, sql } from "drizzle-orm";
5
+ import { and, asc, desc, eq, getTableColumns, gt, inArray, isNotNull, isNull, lte, ne, or, sql } from "drizzle-orm";
6
6
  import { alias } from "drizzle-orm/sqlite-core";
7
7
  import { AuthorizationError, CimdFetchError, OAuthAuthorizationServer, OAuthResourceServer, authorizationErrorRedirect } from "@cloudflare/workers-oauth-provider";
8
8
  import { getCookie } from "hono/cookie";
@@ -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.5.0";
13
+ var version = "0.8.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
  }
@@ -1041,7 +1043,7 @@ async function pendingOf(db, site, entryId) {
1041
1043
  const [row] = await db.select(COLUMNS).from(reviews).innerJoin(users, eq(users.id, reviews.requestedBy)).where(and(eq(reviews.siteId, site), eq(reviews.entryId, entryId), eq(reviews.status, "pending"))).limit(1);
1042
1044
  return row === void 0 ? null : toReview(row);
1043
1045
  }
1044
- async function requestReview(db, clock, site, write, whyNothingChanged) {
1046
+ async function requestReview$1(db, clock, site, write, whyNothingChanged) {
1045
1047
  const now = clock.now().toISOString();
1046
1048
  const row = {
1047
1049
  id: newId("rvw"),
@@ -1103,6 +1105,59 @@ async function requestChanges$1(db, clock, site, write) {
1103
1105
  review
1104
1106
  };
1105
1107
  }
1108
+ function metaOf$1(text) {
1109
+ if (text === null) return null;
1110
+ const parsed = JSON.parse(text);
1111
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? Object.fromEntries(Object.entries(parsed)) : null;
1112
+ }
1113
+ const COMMENT_COLUMNS = {
1114
+ ...getTableColumns(reviewComments),
1115
+ authorName: users.name
1116
+ };
1117
+ function toComment(row) {
1118
+ return {
1119
+ id: row.id,
1120
+ reviewId: row.reviewId,
1121
+ authorId: row.authorId,
1122
+ authorName: row.authorName,
1123
+ authorMeta: metaOf$1(row.authorMeta),
1124
+ body: row.body,
1125
+ createdAt: row.createdAt
1126
+ };
1127
+ }
1128
+ /**
1129
+ * Adds a comment to a review in one statement that inserts only while the review is pending, so a
1130
+ * decision that lands first leaves no comment behind it.
1131
+ */
1132
+ async function commentReview$1(db, clock, site, write) {
1133
+ const id = newId("rvc");
1134
+ const now = clock.now().toISOString();
1135
+ const meta = write.actor.meta === null ? null : JSON.stringify(write.actor.meta);
1136
+ if ((await db.all(sql`
1137
+ insert into ${reviewComments}
1138
+ (id, site_id, review_id, author_id, author_meta, body, created_at, updated_at)
1139
+ select ${id}, ${site}, ${write.reviewId}, ${write.actor.userId}, ${meta}, ${write.body}, ${now}, ${now}
1140
+ where exists (select 1 from ${reviews} where ${and(eq(reviews.id, write.reviewId), eq(reviews.siteId, site), eq(reviews.status, "pending"))})
1141
+ returning id
1142
+ `)).length === 0) return { outcome: await findReview(db, site, write.reviewId) === null ? "not_found" : "not_pending" };
1143
+ const [row] = await db.select(COMMENT_COLUMNS).from(reviewComments).innerJoin(users, eq(users.id, reviewComments.authorId)).where(and(eq(reviewComments.siteId, site), eq(reviewComments.id, id))).limit(1);
1144
+ if (row === void 0) throw new Error(`The comment ${id} that was just written is not there`);
1145
+ return {
1146
+ outcome: "commented",
1147
+ comment: toComment(row)
1148
+ };
1149
+ }
1150
+ async function reviewCommentsOf(db, site, reviewIds) {
1151
+ const byReview = /* @__PURE__ */ new Map();
1152
+ if (reviewIds.length === 0) return byReview;
1153
+ const rows = await db.select(COMMENT_COLUMNS).from(reviewComments).innerJoin(users, eq(users.id, reviewComments.authorId)).where(and(eq(reviewComments.siteId, site), inArray(reviewComments.reviewId, [...reviewIds]))).orderBy(asc(reviewComments.createdAt), asc(reviewComments.id));
1154
+ for (const row of rows) {
1155
+ const list = byReview.get(row.reviewId) ?? [];
1156
+ list.push(toComment(row));
1157
+ byReview.set(row.reviewId, list);
1158
+ }
1159
+ return byReview;
1160
+ }
1106
1161
  //#endregion
1107
1162
  //#region src/db/size.ts
1108
1163
  /**
@@ -1263,6 +1318,7 @@ async function createEntry(db, clock, site, entry) {
1263
1318
  headRevision: revisionId,
1264
1319
  publishedRevision: null,
1265
1320
  publishedAt: null,
1321
+ scheduledAt: null,
1266
1322
  updatedAt: now
1267
1323
  },
1268
1324
  revision: storedRevision({
@@ -1511,10 +1567,12 @@ function createD1Store(db, options = {}) {
1511
1567
  dueSchedules: (site, now) => dueSchedules(database, site, now),
1512
1568
  cancelSchedule: (site, entryId) => cancelSchedule(database, clock, site, entryId),
1513
1569
  unpublish: (site, write) => unpublish$1(database, clock, site, write),
1514
- requestReview: (site, write) => requestReview(database, clock, site, write, () => whyNothingChanged(database, site, write.entryId)),
1570
+ requestReview: (site, write) => requestReview$1(database, clock, site, write, () => whyNothingChanged(database, site, write.entryId)),
1515
1571
  findReview: (site, id) => findReview(database, site, id),
1516
1572
  listReviews: (site, query) => listReviews$1(database, site, query),
1517
- requestChanges: (site, write) => requestChanges$1(database, clock, site, write)
1573
+ requestChanges: (site, write) => requestChanges$1(database, clock, site, write),
1574
+ commentReview: (site, write) => commentReview$1(database, clock, site, write),
1575
+ reviewComments: (site, reviewIds) => reviewCommentsOf(database, site, reviewIds)
1518
1576
  };
1519
1577
  }
1520
1578
  //#endregion
@@ -1771,8 +1829,8 @@ function subscribersOf(deps) {
1771
1829
  const subscribers = site.events.webhooks.map((webhook) => ({
1772
1830
  name: `webhook:${webhook.name}`,
1773
1831
  async send(payload, body) {
1774
- const secret = deps.secret(webhook.secret);
1775
- 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`);
1776
1834
  const timestamp = Math.floor(deps.clock.now().getTime() / 1e3);
1777
1835
  const request = new Request(webhook.url, {
1778
1836
  method: "POST",
@@ -1918,6 +1976,148 @@ async function consumeEvents(messages, depsOf) {
1918
1976
  }
1919
1977
  }
1920
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
1921
2121
  //#region src/mcp/errors.ts
1922
2122
  /**
1923
2123
  * The error a caller gets for anything that is not a `HollwareError`. The message is fixed: what
@@ -2114,6 +2314,24 @@ function installAdmin(app, options) {
2114
2314
  });
2115
2315
  }
2116
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
2117
2335
  //#region src/http/admin-api.ts
2118
2336
  /**
2119
2337
  * What only a person in the admin may do (ADR-026): see, make and revoke personal tokens, and
@@ -2131,9 +2349,16 @@ function json$1(body, status = 200) {
2131
2349
  headers: { "cache-control": "no-store" }
2132
2350
  });
2133
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
+ };
2134
2359
  function refused(message, fix, status = 400) {
2135
2360
  return json$1({
2136
- code: status === 400 ? "invalid_input" : "forbidden",
2361
+ code: REFUSAL_CODES[status],
2137
2362
  message,
2138
2363
  fix
2139
2364
  }, status);
@@ -2171,12 +2396,20 @@ function installAdminApi(app, options) {
2171
2396
  id: users.id,
2172
2397
  name: users.name
2173
2398
  }).from(users).where(eq(users.id, person.userId));
2174
- 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
+ }));
2175
2407
  return json$1({
2176
2408
  user,
2177
2409
  site: person.site,
2178
2410
  scopes: person.scopes,
2179
- sites
2411
+ sites,
2412
+ trash
2180
2413
  });
2181
2414
  }, { anyone: true }));
2182
2415
  app.get("/v1/admin/tokens", admin(async (c, person) => {
@@ -2271,6 +2504,36 @@ function installAdminApi(app, options) {
2271
2504
  }).from(invitations).where(and(eq(invitations.siteId, person.site), isNull(invitations.usedAt), isNull(invitations.userId), gt(invitations.expiresAt, now))).orderBy(desc(invitations.createdAt))
2272
2505
  });
2273
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
+ }));
2274
2537
  app.post("/v1/admin/invitations", admin(async (c, person) => {
2275
2538
  const input = await body(c);
2276
2539
  const role = input["role"];
@@ -2485,6 +2748,17 @@ function analyticsReaderOf(env) {
2485
2748
  });
2486
2749
  }
2487
2750
  //#endregion
2751
+ //#region src/ops/invoke.ts
2752
+ /**
2753
+ * Calls the handler of an operation. This is the one place where the typed pairing of specs and
2754
+ * handlers meets a dynamic caller (MCP, the ops API): the caller parsed `input` with the input
2755
+ * schema of `spec`, which is the schema that types the handler of the same name.
2756
+ */
2757
+ function invoke(handlers, spec, input, context) {
2758
+ const handler = handlers[spec.name];
2759
+ return handler(input, context);
2760
+ }
2761
+ //#endregion
2488
2762
  //#region src/http/ops-api.ts
2489
2763
  const STATUS$1 = {
2490
2764
  invalid_input: 400,
@@ -2499,10 +2773,6 @@ function json(body, status = 200) {
2499
2773
  });
2500
2774
  }
2501
2775
  /** Calls the handler of an operation with input its spec has parsed. */
2502
- function invoke$1(handlers, spec, input, context) {
2503
- const handler = handlers[spec.name];
2504
- return handler(input, context);
2505
- }
2506
2776
  function installOpsApi(app, options) {
2507
2777
  app.post("/v1/ops/:name", async (c) => options.protect(c, async (principal) => {
2508
2778
  const requestId = c.get("requestId");
@@ -2536,10 +2806,11 @@ function installOpsApi(app, options) {
2536
2806
  requestId,
2537
2807
  notify: options.notify(c, requestId, site.key),
2538
2808
  semantic: semanticOfEnv(c.env),
2539
- analytics: analyticsReaderOf(c.env)
2809
+ analytics: analyticsReaderOf(c.env),
2810
+ ...options.structure === void 0 ? {} : { structure: options.structure(c.env.DB, c.env.HOLLWARE_SECRET) }
2540
2811
  };
2541
2812
  try {
2542
- return json(spec.output.parse(await invoke$1(options.handlers, spec, parsed.data, context)));
2813
+ return json(spec.output.parse(await invoke(options.handlers, spec, parsed.data, context)));
2543
2814
  } catch (error) {
2544
2815
  if (error instanceof HollwareError) return json({
2545
2816
  code: error.code,
@@ -2661,7 +2932,7 @@ function installReadApi(app, options) {
2661
2932
  if (crossOrigin !== null && !options.corsOrigins(principal.site).includes(crossOrigin)) return c.json({
2662
2933
  code: "forbidden_origin",
2663
2934
  message: `The site "${principal.site}" does not let pages of ${crossOrigin} read it.`,
2664
- 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.`
2665
2936
  }, 403, {
2666
2937
  vary: "Origin",
2667
2938
  "access-control-allow-origin": new URL(c.env.HOLLWARE_PUBLIC_URL).origin
@@ -2823,10 +3094,10 @@ function openApiDocument() {
2823
3094
  */
2824
3095
  /**
2825
3096
  * The look of hollware.com and the admin, with nothing to fetch: the page's policy loads no font,
2826
- * 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).
2827
3099
  */
2828
- const STYLE = `:root{color-scheme:light dark;--bg:#f6f7f8;--card:#fff;--fg:#15181b;--muted:#545c66;--line:#d6dadf;--accent:#0c825d;--on-accent:#fff;--danger:#b42318}
2829
- @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}
2830
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}
2831
3102
  main{width:100%;max-width:26rem;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:2rem}
2832
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}
@@ -2839,7 +3110,7 @@ button{margin-top:1.25rem;padding:.6rem 1rem;font:inherit;font-weight:500;cursor
2839
3110
  button:not([value=deny]){background:var(--accent);border-color:var(--accent);color:var(--on-accent)}
2840
3111
  [role=alert]{color:var(--danger)}`;
2841
3112
  /** The mark of Hollware, before the title of every page. */
2842
- 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>`;
2843
3114
  const POST = `async function post(path, body) {
2844
3115
  const response = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
2845
3116
  const data = await response.json()
@@ -2996,7 +3267,7 @@ function plainPageHeaders() {
2996
3267
  * The ceremonies, with SimpleWebAuthn and its parsers, in a chunk of their own: only these four
2997
3268
  * routes use them, and a Worker that serves MCP never pays for them at startup (`budgets.json`).
2998
3269
  */
2999
- const ceremonies = () => import("./ceremonies-BM9G03lZ.mjs");
3270
+ const ceremonies = () => import("./ceremonies-D5dIEbha.mjs");
3000
3271
  /** The cookie of a signed-in person: only `/authorize` reads it (ADR-026). */
3001
3272
  const SESSION_COOKIE = "hollware_session";
3002
3273
  function sessionCookie(secret) {
@@ -3434,7 +3705,7 @@ function installEvents(app, options) {
3434
3705
  const path = typeof body === "object" && body !== null && "path" in body ? pathOf(body.path) : null;
3435
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>\" }.");
3436
3707
  const origin = c.req.header("origin") ?? null;
3437
- 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.`);
3438
3709
  const cf = Reflect.get(c.req.raw, "cf");
3439
3710
  const country = typeof cf === "object" && cf !== null && "country" in cf && typeof cf.country === "string" ? cf.country : "XX";
3440
3711
  analyticsWriterOf(c.env)?.record({
@@ -3490,7 +3761,7 @@ function withRefusalBody(response, refused) {
3490
3761
  */
3491
3762
  function createGateway(options) {
3492
3763
  const { sites, mcp, clock, log } = options;
3493
- const siteKeys = new Set(sites.keys());
3764
+ const siteKeys = options.siteKeys ?? new Set(sites.keys());
3494
3765
  const app = new Hono();
3495
3766
  /**
3496
3767
  * The Worker's own context of each request. Hono hands its routes the same object under a
@@ -3527,12 +3798,26 @@ function createGateway(options) {
3527
3798
  });
3528
3799
  }));
3529
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
+ });
3530
3814
  /**
3531
3815
  * A protected route: the resource server of `resource` answers a request without a token and a
3532
3816
  * token it refuses (401, logged with the reason), and `proceed` gets the principal of the rest.
3533
3817
  */
3534
3818
  const protect = async (c, resource, proceed) => {
3535
3819
  const requestId = c.get("requestId");
3820
+ if (c.req.header("authorization") !== void 0) await options.refresh?.(c.env.DB);
3536
3821
  /** What the check said besides the answer: why it refused, or what it threw. */
3537
3822
  const outcome = {
3538
3823
  refusal: null,
@@ -3559,8 +3844,17 @@ function createGateway(options) {
3559
3844
  });
3560
3845
  return withRefusalBody(response, outcome.refusal !== null);
3561
3846
  };
3562
- /** Every origin that the config of some site lets read the site from a page. */
3563
- 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));
3564
3858
  /**
3565
3859
  * The guard lets a page of such an origin through to the read API alone, and only to read: the
3566
3860
  * read API then checks the origin against the site of the token.
@@ -3568,11 +3862,7 @@ function createGateway(options) {
3568
3862
  const readsFromAnyCorsOrigin = (request, origin) => {
3569
3863
  const { pathname } = new URL(request.url);
3570
3864
  if (request.method === "POST" && pathname === "/events") return true;
3571
- return corsOrigins.has(origin) && [
3572
- "GET",
3573
- "HEAD",
3574
- "OPTIONS"
3575
- ].includes(request.method) && pathname.startsWith("/v1/") && !pathname.startsWith("/v1/admin/");
3865
+ return anyCorsOrigin(origin) && isCorsRead(request);
3576
3866
  };
3577
3867
  /**
3578
3868
  * A route for a person or their personal token: a read token reads over the read API alone, so
@@ -3591,10 +3881,20 @@ function createGateway(options) {
3591
3881
  await next();
3592
3882
  });
3593
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);
3594
3886
  const refusal = checkHostAndOrigin(c.req.raw, c.env.HOLLWARE_PUBLIC_URL, readsFromAnyCorsOrigin);
3595
3887
  if (refusal !== null) return refusal;
3596
3888
  await next();
3597
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
+ });
3598
3898
  app.get("/health", () => Response.json({ ok: true }, { headers: { "cache-control": "no-store" } }));
3599
3899
  app.all("/mcp", async (c) => protectWrites(c, "mcp", async (principal) => {
3600
3900
  const requestId = c.get("requestId");
@@ -3611,7 +3911,8 @@ function createGateway(options) {
3611
3911
  clock,
3612
3912
  notify: notify(c, requestId, site.key),
3613
3913
  semantic: semanticOfEnv(c.env),
3614
- analytics: analyticsReaderOf(c.env)
3914
+ analytics: analyticsReaderOf(c.env),
3915
+ structure: structureOf(db, c.env.HOLLWARE_SECRET)
3615
3916
  }
3616
3917
  });
3617
3918
  }));
@@ -3645,12 +3946,13 @@ function createGateway(options) {
3645
3946
  log,
3646
3947
  protect: (c, proceed) => protectWrites(c, "v1", proceed),
3647
3948
  metadataUrl: (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1,
3648
- notify
3949
+ notify,
3950
+ structure: structureOf
3649
3951
  });
3650
3952
  if (options.read !== void 0) installReadApi(app, {
3651
3953
  read: options.read,
3652
3954
  corsOrigins: (site) => sites.get(site)?.cors.origins ?? [],
3653
- anyCorsOrigin: (origin) => corsOrigins.has(origin),
3955
+ anyCorsOrigin,
3654
3956
  protect: (c, proceed) => protect(c, "v1", proceed),
3655
3957
  metadataUrl: (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1
3656
3958
  });
@@ -3843,6 +4145,7 @@ const encoder = new TextEncoder();
3843
4145
  */
3844
4146
  const OUTPUT_TOO_LARGE_FIX = {
3845
4147
  approve_review: "The operation ran and its change may have been applied: read the review with list_reviews before you repeat it.",
4148
+ comment_review: "The operation ran and its comment may have been added: read the review with list_reviews before you repeat it.",
3846
4149
  content_stats: "Ask for less: pass a smaller limit.",
3847
4150
  create_draft: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
3848
4151
  describe_site: "The description of the site does not fit in one answer. Document fewer collections, components and contracts, or shorten their descriptions.",
@@ -3852,9 +4155,17 @@ const OUTPUT_TOO_LARGE_FIX = {
3852
4155
  list_reviews: "Ask for less: pass a smaller limit.",
3853
4156
  publish: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
3854
4157
  request_changes: "The operation ran and its change may have been applied: read the review with list_reviews before you repeat it.",
4158
+ request_review: "The operation ran and its review may have been asked: read it with list_reviews before you repeat it.",
3855
4159
  search: "Ask for less: pass a smaller limit.",
3856
4160
  unpublish: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
3857
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.",
3858
4169
  validate: "The verdict lists more problems than fit in one answer. Fix the first ones and validate again."
3859
4170
  };
3860
4171
  /**
@@ -4047,15 +4358,6 @@ function authInfoOf(principal, resourceMetadataUrl) {
4047
4358
  };
4048
4359
  }
4049
4360
  /**
4050
- * Calls the handler of an operation. This is the one place where the typed pairing of specs and
4051
- * handlers meets the dynamic protocol: the SDK parsed `input` with the input schema of `spec`,
4052
- * which is the schema that types the handler of the same name.
4053
- */
4054
- function invoke(handlers, spec, input, context) {
4055
- const handler = handlers[spec.name];
4056
- return handler(input, context);
4057
- }
4058
- /**
4059
4361
  * The MCP endpoint of Hollware: one tool for each operation, served in both protocol eras, for
4060
4362
  * the gateway to mount at `/mcp` after it has checked the Host, the Origin and the token.
4061
4363
  *
@@ -4263,10 +4565,15 @@ function forCaller(deps, requestId, method, error) {
4263
4565
  * a call that fails never opens a session.
4264
4566
  */
4265
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
+ }
4266
4574
  const served = deps.sites.get(site);
4267
4575
  if (served === void 0) throw unknownSite(site, deps.sites.keys());
4268
4576
  const input = parseInput(schema, build());
4269
- const requestId = crypto.randomUUID();
4270
4577
  const context = {
4271
4578
  site: served,
4272
4579
  principal: BINDING_PRINCIPAL,
@@ -4431,9 +4738,80 @@ async function runDueSchedules(input) {
4431
4738
  cancelled
4432
4739
  };
4433
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
+ }
4434
4808
  //#endregion
4435
4809
  //#region src/compose.ts
4436
4810
  const systemClock = { now: () => /* @__PURE__ */ new Date() };
4811
+ /** Whether the run of the cron at `time` wakes the agents: on the minutes that divide by ten. */
4812
+ function wakesAgents(time) {
4813
+ return Math.floor(time / 6e4) % 10 === 0;
4814
+ }
4437
4815
  /** What the MCP endpoint calls the server, in `server/discover` and `initialize`. */
4438
4816
  const SERVER_INFO = {
4439
4817
  name: "hollware",
@@ -4450,9 +4828,10 @@ function writeLog(entry) {
4450
4828
  *
4451
4829
  * Everything that can be built once is built here, when the user's module loads, and never again:
4452
4830
  *
4453
- * - the config is resolved with `resolveConfig`, so a broken `hollware.config.ts` throws its
4454
- * `ConfigError` here, and the Worker fails at startup with a message that says what to change.
4455
- * 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;
4456
4835
  * - the MCP endpoint, whose tool schemas are converted once, and the Hono app of the gateway,
4457
4836
  * which is `fetch`: MCP at `/mcp` behind the guard and the token check, and the other routes
4458
4837
  * (`http/gateway.ts`);
@@ -4464,9 +4843,16 @@ function writeLog(entry) {
4464
4843
  * included, is one JSON line written with `console.log`.
4465
4844
  */
4466
4845
  function createHollwareWith(config, handlers, options = {}) {
4467
- const resolved = resolveConfig(config);
4468
- const sites = resolved.sites;
4469
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);
4470
4856
  const mcp = createMcpEndpoint({
4471
4857
  handlers,
4472
4858
  serverInfo: SERVER_INFO,
@@ -4481,11 +4867,19 @@ function createHollwareWith(config, handlers, options = {}) {
4481
4867
  const value = Reflect.get(env, name);
4482
4868
  return typeof value === "string" ? value : void 0;
4483
4869
  },
4484
- 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
+ }
4485
4877
  });
4486
4878
  return {
4487
4879
  fetch: createGateway({
4488
4880
  sites,
4881
+ siteKeys: registry.keys,
4882
+ refresh,
4489
4883
  mcp,
4490
4884
  clock,
4491
4885
  log: writeLog,
@@ -4495,10 +4889,10 @@ function createHollwareWith(config, handlers, options = {}) {
4495
4889
  clock,
4496
4890
  log: writeLog
4497
4891
  }, env),
4498
- auth: resolved.auth,
4892
+ auth: { dynamicClientRegistration: config?.auth?.dynamicClientRegistration ?? false },
4499
4893
  handlers
4500
4894
  }),
4501
- scheduled: async (_controller, env, ctx) => {
4895
+ scheduled: async (controller, env, ctx) => {
4502
4896
  const each = async (site) => {
4503
4897
  await runDueSchedules({
4504
4898
  site,
@@ -4524,7 +4918,16 @@ function createHollwareWith(config, handlers, options = {}) {
4524
4918
  });
4525
4919
  }
4526
4920
  };
4921
+ const agents = env.AGENTS;
4922
+ const run = typeof agents === "object" && agents !== null ? Reflect.get(agents, "run") : void 0;
4923
+ if (typeof run === "function" && wakesAgents(controller.scheduledTime)) ctx.waitUntil(Promise.resolve(Reflect.apply(run, agents, [])).catch((error) => {
4924
+ writeLog({
4925
+ kind: "agents_failed",
4926
+ message: String(error)
4927
+ });
4928
+ }));
4527
4929
  ctx.waitUntil((async () => {
4930
+ await refresh(env.DB);
4528
4931
  for (const site of sites.values()) await each(site).catch((error) => {
4529
4932
  writeLog({
4530
4933
  kind: "cron_failed",
@@ -4535,6 +4938,7 @@ function createHollwareWith(config, handlers, options = {}) {
4535
4938
  })());
4536
4939
  },
4537
4940
  queue: async (batch, env) => {
4941
+ await refresh(env.DB);
4538
4942
  await consumeEvents(batch.messages, (key) => {
4539
4943
  const site = sites.get(key);
4540
4944
  return site === void 0 ? void 0 : deliveryDeps(env, site);
@@ -4542,6 +4946,7 @@ function createHollwareWith(config, handlers, options = {}) {
4542
4946
  },
4543
4947
  Hollware: createEntrypoint({
4544
4948
  sites,
4949
+ refresh,
4545
4950
  handlers,
4546
4951
  clock,
4547
4952
  log: writeLog
@@ -4585,6 +4990,7 @@ function entrySummary(view) {
4585
4990
  tags: [...revision.tags],
4586
4991
  hasUnpublishedChanges: entry.publishedRevision !== entry.headRevision,
4587
4992
  publishedAt: entry.publishedAt,
4993
+ scheduledAt: entry.scheduledAt,
4588
4994
  updatedAt: entry.updatedAt
4589
4995
  };
4590
4996
  }
@@ -4649,6 +5055,18 @@ function publicationView(publication) {
4649
5055
  at: publication.createdAt
4650
5056
  };
4651
5057
  }
5058
+ /** A comment on a review as the operations answer it; `client` is the name its client gave. */
5059
+ function reviewCommentView(comment) {
5060
+ const name = comment.authorMeta?.["name"];
5061
+ return {
5062
+ id: comment.id,
5063
+ author: comment.authorId,
5064
+ authorName: comment.authorName,
5065
+ client: typeof name === "string" ? name : null,
5066
+ body: comment.body,
5067
+ at: comment.createdAt
5068
+ };
5069
+ }
4652
5070
  //#endregion
4653
5071
  //#region src/ops/content-stats.ts
4654
5072
  /** A path without its trailing slash, so that `/blog/a` and `/blog/a/` are one page. */
@@ -4784,7 +5202,7 @@ function requireLocale(site, locale) {
4784
5202
  function collectionOfEntry(site, entry) {
4785
5203
  const collection = site.collections.get(entry.collection);
4786
5204
  if (collection !== void 0) return collection;
4787
- 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.`);
4788
5206
  }
4789
5207
  //#endregion
4790
5208
  //#region src/ops/draft.ts
@@ -5092,6 +5510,184 @@ const search$1 = async (input, context) => {
5092
5510
  }))
5093
5511
  };
5094
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
+ };
5095
5691
  //#endregion
5096
5692
  //#region src/ops/event.ts
5097
5693
  /** The path of an entry: the route of its locale, with its slug in place of `:slug`. */
@@ -5110,7 +5706,8 @@ function eventFor(input) {
5110
5706
  at: input.at,
5111
5707
  entry: input.entry,
5112
5708
  path,
5113
- 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)
5114
5711
  };
5115
5712
  return {
5116
5713
  id,
@@ -5409,13 +6006,15 @@ const listReviews = async (input, context) => {
5409
6006
  status: input.status,
5410
6007
  limit: input.limit
5411
6008
  });
6009
+ const comments = await context.store.reviewComments(context.site.key, found.map((review) => review.id));
5412
6010
  const reviews = [];
5413
6011
  for (const review of found) {
5414
6012
  const view = await context.store.findEntryById(context.site.key, review.entryId, "head");
5415
6013
  if (view === null) throw unexpectedAnswer(context, "list_reviews", { outcome: "no entry" });
5416
6014
  reviews.push({
5417
6015
  ...reviewView(review),
5418
- entry: entrySummary(view)
6016
+ entry: entrySummary(view),
6017
+ comments: (comments.get(review.id) ?? []).map(reviewCommentView)
5419
6018
  });
5420
6019
  }
5421
6020
  return {
@@ -5481,6 +6080,56 @@ const requestChanges = async (input, context) => {
5481
6080
  review: reviewView(closed.review)
5482
6081
  };
5483
6082
  };
6083
+ /**
6084
+ * A comment on a pending review, in the name of the caller: for the person who decides and for
6085
+ * the author. It decides nothing; a review that is no longer pending takes none.
6086
+ */
6087
+ const commentReview = async (input, context) => {
6088
+ const answer = await context.store.commentReview(context.site.key, {
6089
+ reviewId: input.id,
6090
+ actor: actorOf(context),
6091
+ body: input.body
6092
+ });
6093
+ return answer.outcome === "commented" ? {
6094
+ outcome: "commented",
6095
+ comment: reviewCommentView(answer.comment)
6096
+ } : { outcome: answer.outcome };
6097
+ };
6098
+ /**
6099
+ * Asks for a review of the head revision of an entry through `publish`, with `content:publish`
6100
+ * taken from the caller for this call: the same checks run, and the one thing that cannot happen
6101
+ * is a publication. It is how an agent sends its work to a person, whatever its token holds.
6102
+ */
6103
+ const requestReview = async (input, context) => {
6104
+ const { principal: given } = context;
6105
+ const principal = given.kind === "binding" ? given : {
6106
+ ...given,
6107
+ scopes: given.scopes.filter((scope) => scope !== "content:publish")
6108
+ };
6109
+ const answer = await publish$1({
6110
+ id: input.id,
6111
+ version: input.version,
6112
+ acknowledgeWarnings: input.acknowledgeWarnings,
6113
+ group: []
6114
+ }, {
6115
+ ...context,
6116
+ principal
6117
+ });
6118
+ switch (answer.outcome) {
6119
+ case "pending_review":
6120
+ case "blocked":
6121
+ case "needs_acknowledgement":
6122
+ case "conflict":
6123
+ case "not_found": return {
6124
+ outcome: answer.outcome,
6125
+ ...answer.entry === void 0 ? {} : { entry: answer.entry },
6126
+ ...answer.verdict === void 0 ? {} : { verdict: answer.verdict },
6127
+ ...answer.conflict === void 0 ? {} : { conflict: answer.conflict },
6128
+ ...answer.review === void 0 ? {} : { review: answer.review }
6129
+ };
6130
+ default: throw unexpectedAnswer(context, "request_review", answer);
6131
+ }
6132
+ };
5484
6133
  //#endregion
5485
6134
  //#region src/ops/unpublish.ts
5486
6135
  /**
@@ -5626,17 +6275,26 @@ function defineHandlers(handlers) {
5626
6275
  */
5627
6276
  const handlers = defineHandlers({
5628
6277
  approve_review: approveReview,
6278
+ comment_review: commentReview,
5629
6279
  content_stats: contentStats,
5630
6280
  create_draft: createDraft,
6281
+ create_site: createSite,
6282
+ delete_site: deleteSite,
5631
6283
  describe_site: describeSite$1,
5632
6284
  get_entry: getEntry$1,
6285
+ get_structure: getStructure,
5633
6286
  get_translations: getTranslations$1,
5634
6287
  list_entries: listEntries$1,
5635
6288
  search: search$1,
5636
6289
  list_reviews: listReviews,
6290
+ make_webhook_secret: makeWebhookSecret,
5637
6291
  publish: publish$1,
5638
6292
  request_changes: requestChanges,
6293
+ request_review: requestReview,
6294
+ restore_site: restoreSite,
6295
+ set_collection: setCollection,
5639
6296
  unpublish,
6297
+ update_site: updateSite,
5640
6298
  update_draft: updateDraft,
5641
6299
  validate
5642
6300
  });