@hollware/server 0.9.1 → 0.11.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,16 +1,16 @@
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";
1
+ import { A as revisionTags, C as entryEmbeddings, D as publications, E as outbox, F as users, I as ROLE_SCOPES, L as effectiveScopes, M as schedules, N as sites, O as reviewComments, P as tokens, R as isRole, S as entries, T as members, a as dropExpired, f as membershipsOf, g as removePasskey, j as revisions, k as reviews, n as createInvitation, o as endSession, p as passkeySummaries, s as endSessions, u as findSession, w as invitations, x as deliveries } from "./store-DjA9s95l.mjs";
2
+ import { CHUNKER_VERSION, ConfigError, EVENT_ID_HEADER, HollwareError, INDEXNOW_KEY, OPERATIONS, OPERATION_AUDIT_ACTIONS, PREVIEW_QUERY_PARAM, SCOPES, SIGNATURE_HEADER, TIMESTAMP_HEADER, canSignPreviews, canonicalJson, chunksOf, compileSites, definitionsFromConfig, describeBuiltInContracts, describeCatalog, describeResolvedSite, describeSite, generatePersonalToken, generateReadToken, getEntry, getTranslations, hashPersonalToken, isAuditAction, isId, isPersonalToken, isReadToken, isScope, isSiteKey, listEntries, makePreviewToken, newId, processMarkdown, publish, renderHtml, resolveConfig, runContracts, search, signWebhook, siteDefinitionData, sizeLimitViolation, tagsOfChange, tagsOfSiteChange, uniqueSlugViolation, uniqueTranslationViolation, verifyPreviewToken } from "@hollware/core";
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";
6
6
  import { alias } from "drizzle-orm/sqlite-core";
7
- import { AuthorizationError, CimdFetchError, OAuthAuthorizationServer, OAuthResourceServer, authorizationErrorRedirect } from "@cloudflare/workers-oauth-provider";
8
7
  import { getCookie } from "hono/cookie";
8
+ import { AuthorizationError, CimdFetchError, OAuthAuthorizationServer, OAuthResourceServer, authorizationErrorRedirect } from "@cloudflare/workers-oauth-provider";
9
9
  import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, McpServer, acceptedContent, createMcpHandler, inputRequired, inputResponse } from "@modelcontextprotocol/server";
10
10
  import * as z from "zod";
11
11
  import { WorkerEntrypoint } from "cloudflare:workers";
12
12
  //#region package.json
13
- var version = "0.9.1";
13
+ var version = "0.11.0";
14
14
  //#endregion
15
15
  //#region src/auth/responses.ts
16
16
  /** The challenge of every 401 and 403 (RFC 6750). */
@@ -350,7 +350,7 @@ function parseJson(text) {
350
350
  }
351
351
  }
352
352
  /** The cursor that continues a `view` listing after `position`. */
353
- function encodeCursor(view, position) {
353
+ function encodeCursor$1(view, position) {
354
354
  return encodeBase64Url(JSON.stringify({
355
355
  v: view,
356
356
  k: position.key,
@@ -361,7 +361,7 @@ function encodeCursor(view, position) {
361
361
  * The position a cursor stands for. A cursor that no listing of this view returned, whatever is
362
362
  * wrong with it, is the error `invalid_input` with a fix.
363
363
  */
364
- function decodeCursor(cursor, view) {
364
+ function decodeCursor$1(cursor, view) {
365
365
  const text = decodeBase64Url(cursor);
366
366
  const value = text === null ? void 0 : parseJson(text);
367
367
  if (typeof value === "object" && value !== null && "v" in value && "k" in value && "i" in value && value.v === view && typeof value.k === "string" && typeof value.i === "string") return {
@@ -390,6 +390,7 @@ const entryFields = {
390
390
  publishedRev: entries.publishedRev,
391
391
  publishedAt: entries.publishedAt,
392
392
  scheduledAt: entries.scheduledAt,
393
+ deletedAt: entries.deletedAt,
393
394
  updatedAt: entries.updatedAt
394
395
  };
395
396
  /** The columns of `revisions` that `StoredRevision` holds, except its tags. */
@@ -465,6 +466,7 @@ function toEntry(row) {
465
466
  publishedRevision: row.publishedRev,
466
467
  publishedAt: row.publishedAt,
467
468
  scheduledAt: row.scheduledAt,
469
+ deletedAt: row.deletedAt,
468
470
  updatedAt: row.updatedAt
469
471
  };
470
472
  }
@@ -544,9 +546,15 @@ function revisionOf$1(view) {
544
546
  const revisionId = view === "head" ? entries.headRev : entries.publishedRev;
545
547
  return and(eq(revisions.id, revisionId), eq(revisions.siteId, entries.siteId));
546
548
  }
547
- /** What picks the entries of a view: the caller's conditions, and in the published view only published ones. */
548
- function viewWhere(view, conditions) {
549
- return and(...conditions, view === "published" ? publishedOnly : void 0);
549
+ /**
550
+ * What picks the entries of a view: the caller's conditions, and in the published view only
551
+ * published ones. The head view leaves out the entries in the trash, or lists only them when
552
+ * `trashed` asks for the trash. The published view needs no such condition: only a draft goes to
553
+ * the trash, and it stays a draft there.
554
+ */
555
+ function viewWhere(view, conditions, trashed = false) {
556
+ if (view === "published") return and(...conditions, publishedOnly);
557
+ return and(...conditions, trashed ? isNotNull(entries.deletedAt) : isNull(entries.deletedAt));
550
558
  }
551
559
  /**
552
560
  * The entries of a site with the whole revision of a view, for the reads of one entry. The caller
@@ -570,13 +578,13 @@ function toEntryView(row) {
570
578
  * the revision only its id, its frontmatter and its tags. The sort key of the published view
571
579
  * comes along for the cursor.
572
580
  */
573
- function summaryQuery(db, view, conditions) {
581
+ function summaryQuery(db, view, conditions, trashed = false) {
574
582
  return db.select({
575
583
  entry: entryFields,
576
584
  revision: revisionSummaryFields,
577
585
  tags: tagsOf$1(revisions),
578
586
  sortKey: entries.publishedSortKey
579
- }).from(entries).innerJoin(revisions, revisionOf$1(view)).where(viewWhere(view, conditions));
587
+ }).from(entries).innerJoin(revisions, revisionOf$1(view)).where(viewWhere(view, conditions, trashed));
580
588
  }
581
589
  function toEntrySummaryView(row) {
582
590
  return {
@@ -632,7 +640,7 @@ function listEntriesQuery(db, site, query, cursor) {
632
640
  query.view === "head" && query.status !== void 0 ? eq(entries.status, query.status) : void 0,
633
641
  query.tag === void 0 ? void 0 : hasTag(site, query.tag),
634
642
  cursor === null ? void 0 : sql`(${sortKey}, ${entries.id}) < (${cursor.key}, ${cursor.id})`
635
- ]).orderBy(desc(sortKey), desc(entries.id)).limit(query.limit + 1);
643
+ ], query.trashed === true).orderBy(desc(sortKey), desc(entries.id)).limit(query.limit + 1);
636
644
  }
637
645
  /**
638
646
  * The FTS5 query of what someone typed: each of its words as a quoted string, so that FTS5's own
@@ -691,8 +699,8 @@ async function relatedOf(db, site, entryId) {
691
699
  }
692
700
  async function listEntries$2(db, site, query) {
693
701
  if (!Number.isInteger(query.limit) || query.limit < 1) throw new HollwareError("invalid_input", `The limit ${query.limit} is not a whole number of at least 1.`, "Ask for at least one entry per page.");
694
- const cursor = query.cursor === void 0 ? null : decodeCursor(query.cursor, query.view);
695
- if (query.view === "published" && query.status !== void 0 && query.status !== "published") return {
702
+ const cursor = query.cursor === void 0 ? null : decodeCursor$1(query.cursor, query.view);
703
+ if (query.view === "published" && (query.trashed === true || query.status !== void 0 && query.status !== "published")) return {
696
704
  entries: [],
697
705
  nextCursor: null
698
706
  };
@@ -707,7 +715,7 @@ async function listEntries$2(db, site, query) {
707
715
  if (key === null) throw new Error(`The published entry ${last.entry.id} has no published_sort_key`);
708
716
  return {
709
717
  entries: page.map(toEntrySummaryView),
710
- nextCursor: encodeCursor(query.view, {
718
+ nextCursor: encodeCursor$1(query.view, {
711
719
  key,
712
720
  id: last.entry.id
713
721
  })
@@ -753,7 +761,7 @@ function refKey(ref) {
753
761
  function refMatches(ref) {
754
762
  return and(eq(entries.collection, ref.collection), eq(entries.locale, ref.locale), eq(entries.slug, ref.slug));
755
763
  }
756
- /** What cross-entry contracts read, scoped to one site. */
764
+ /** What cross-entry contracts read, scoped to one site; an entry in the trash is not there. */
757
765
  function lookup(db, site) {
758
766
  return {
759
767
  /**
@@ -761,7 +769,7 @@ function lookup(db, site) {
761
769
  * more, under D1's limit of bound values. A ref asked twice is asked once.
762
770
  */
763
771
  async lookupMany(refs) {
764
- const [first, ...rest] = chunk([...new Map(refs.map((ref) => [refKey(ref), ref])).values()], rowsPerStatement(1, 3)).map((group) => db.select(factFields).from(entries).where(and(eq(entries.siteId, site), or(...group.map(refMatches)))));
772
+ const [first, ...rest] = chunk([...new Map(refs.map((ref) => [refKey(ref), ref])).values()], rowsPerStatement(1, 3)).map((group) => db.select(factFields).from(entries).where(and(eq(entries.siteId, site), isNull(entries.deletedAt), or(...group.map(refMatches)))));
765
773
  if (first === void 0) return [];
766
774
  const found = /* @__PURE__ */ new Map();
767
775
  for (const row of (await db.batch([first, ...rest])).flat()) found.set(refKey(row), {
@@ -772,7 +780,7 @@ function lookup(db, site) {
772
780
  return refs.map((ref) => found.get(refKey(ref)) ?? null);
773
781
  },
774
782
  async translations(collection, translationGroup) {
775
- return (await db.select(factFields).from(entries).where(and(eq(entries.siteId, site), eq(entries.collection, collection), eq(entries.translationGroup, translationGroup))).orderBy(entries.locale)).map((row) => ({
783
+ return (await db.select(factFields).from(entries).where(and(eq(entries.siteId, site), eq(entries.collection, collection), eq(entries.translationGroup, translationGroup), isNull(entries.deletedAt))).orderBy(entries.locale)).map((row) => ({
776
784
  collection: row.collection,
777
785
  locale: row.locale,
778
786
  slug: row.slug,
@@ -1019,16 +1027,22 @@ function toReview(row) {
1019
1027
  note: row.note
1020
1028
  };
1021
1029
  }
1022
- /** The entry is still at `version`: the condition of every statement a write adds to its batch. */
1023
- function entryAt(site, entryId, version) {
1024
- return sql`exists (select 1 from ${entries} where ${and(eq(entries.id, entryId), eq(entries.siteId, site), eq(entries.version, version))})`;
1030
+ /**
1031
+ * The entry is still at `version`, and in the state `also` says: the condition of every statement
1032
+ * a write adds to its batch.
1033
+ */
1034
+ function entryAt(site, entryId, version, also = []) {
1035
+ return sql`exists (select 1 from ${entries} where ${and(eq(entries.id, entryId), eq(entries.siteId, site), eq(entries.version, version), ...also)})`;
1025
1036
  }
1026
- /** In the batch of a new revision: the pending review asked for another one, so it is cancelled. */
1027
- function cancelPendingReview(db, site, entryId, version, now) {
1037
+ /**
1038
+ * In the batch of a new revision: the pending review asked for another one, so it is cancelled.
1039
+ * In the batch of a trashing, too, where `also` holds what makes the entry one the trash takes.
1040
+ */
1041
+ function cancelPendingReview(db, site, entryId, version, now, also = []) {
1028
1042
  return db.update(reviews).set({
1029
1043
  status: "cancelled",
1030
1044
  updatedAt: now
1031
- }).where(and(eq(reviews.siteId, site), eq(reviews.entryId, entryId), eq(reviews.status, "pending"), entryAt(site, entryId, version)));
1045
+ }).where(and(eq(reviews.siteId, site), eq(reviews.entryId, entryId), eq(reviews.status, "pending"), entryAt(site, entryId, version, also)));
1032
1046
  }
1033
1047
  /** In the batch of a publication: what the pending review asked for is published, by `userId`. */
1034
1048
  function approvePendingReview(db, site, entryId, version, userId, now) {
@@ -1203,23 +1217,33 @@ async function attempt(batch, holder) {
1203
1217
  return { taken };
1204
1218
  }
1205
1219
  }
1206
- /** The entry that holds the slug or the translation that a new entry asks for; the slug first. */
1220
+ /**
1221
+ * The entry that holds the slug or the translation that a new entry asks for; the slug first. An
1222
+ * entry in the trash keeps both, and is named as `in_trash`, so that the caller restores it.
1223
+ */
1207
1224
  async function holderOfNewEntry(db, site, entry) {
1208
1225
  const rows = await db.select({
1209
1226
  id: entries.id,
1210
- slug: entries.slug
1227
+ slug: entries.slug,
1228
+ deletedAt: entries.deletedAt
1211
1229
  }).from(entries).where(and(eq(entries.siteId, site), eq(entries.collection, entry.collection), eq(entries.locale, entry.locale), or(eq(entries.slug, entry.slug), entry.translationGroup === null ? void 0 : eq(entries.translationGroup, entry.translationGroup))));
1212
- const bySlug = rows.find((row) => row.slug === entry.slug);
1213
- if (bySlug !== void 0) return {
1230
+ const holder = rows.find((row) => row.slug === entry.slug) ?? rows[0];
1231
+ if (holder === void 0) return null;
1232
+ if (holder.deletedAt !== null) {
1233
+ const view = await findTrashed(db, site, holder.id);
1234
+ if (view !== null) return {
1235
+ outcome: "in_trash",
1236
+ view
1237
+ };
1238
+ }
1239
+ return holder.slug === entry.slug ? {
1214
1240
  outcome: "taken",
1215
1241
  field: "slug",
1216
- by: bySlug.id
1217
- };
1218
- const [byGroup] = rows;
1219
- return byGroup === void 0 ? null : {
1242
+ by: holder.id
1243
+ } : {
1220
1244
  outcome: "taken",
1221
1245
  field: "translationGroup",
1222
- by: byGroup.id
1246
+ by: holder.id
1223
1247
  };
1224
1248
  }
1225
1249
  /** The other entry of the collection and locale of `entryId` that is in `translationGroup`. */
@@ -1319,6 +1343,7 @@ async function createEntry(db, clock, site, entry) {
1319
1343
  publishedRevision: null,
1320
1344
  publishedAt: null,
1321
1345
  scheduledAt: null,
1346
+ deletedAt: null,
1322
1347
  updatedAt: now
1323
1348
  },
1324
1349
  revision: storedRevision({
@@ -1516,6 +1541,68 @@ async function unpublish$1(db, clock, site, write) {
1516
1541
  view
1517
1542
  };
1518
1543
  }
1544
+ /**
1545
+ * Moves a draft to the trash in one batch: its pending review is cancelled, and the entry gets its
1546
+ * `deleted_at` and the next version, both only while it is a draft out of the trash at
1547
+ * `expectedVersion`. When the update touches nothing, one read tells why.
1548
+ */
1549
+ async function trashEntry(db, clock, site, write) {
1550
+ const now = clock.now().toISOString();
1551
+ const trashable = [eq(entries.status, "draft"), isNull(entries.deletedAt)];
1552
+ const [, updated] = await db.batch([cancelPendingReview(db, site, write.entryId, write.expectedVersion, now, trashable), db.update(entries).set({
1553
+ deletedAt: now,
1554
+ version: sql`${entries.version} + 1`,
1555
+ updatedAt: now
1556
+ }).where(and(eq(entries.id, write.entryId), eq(entries.siteId, site), eq(entries.version, write.expectedVersion), ...trashable)).returning({ id: entries.id })]);
1557
+ if (updated.length === 0) {
1558
+ const view = await findEntryById(db, site, write.entryId, "head");
1559
+ if (view === null) return { outcome: "not_found" };
1560
+ if (view.entry.version !== write.expectedVersion) return {
1561
+ outcome: "conflict",
1562
+ currentVersion: view.entry.version,
1563
+ headRevision: view.entry.headRevision
1564
+ };
1565
+ return {
1566
+ outcome: "not_draft",
1567
+ view
1568
+ };
1569
+ }
1570
+ const view = await findTrashed(db, site, write.entryId);
1571
+ if (view === null) throw new Error(`Entry ${write.entryId} is not in the trash after its trashing`);
1572
+ return {
1573
+ outcome: "trashed",
1574
+ view
1575
+ };
1576
+ }
1577
+ /** Brings an entry back from the trash as the draft it was, at the next version. */
1578
+ async function restoreEntry$1(db, clock, site, entryId) {
1579
+ const now = clock.now().toISOString();
1580
+ const updated = await db.update(entries).set({
1581
+ deletedAt: null,
1582
+ version: sql`${entries.version} + 1`,
1583
+ updatedAt: now
1584
+ }).where(and(eq(entries.id, entryId), eq(entries.siteId, site), isNotNull(entries.deletedAt))).returning({ id: entries.id });
1585
+ const view = await findEntryById(db, site, entryId, "head");
1586
+ if (view === null) return { outcome: "not_found" };
1587
+ return {
1588
+ outcome: updated.length === 0 ? "not_trashed" : "restored",
1589
+ view
1590
+ };
1591
+ }
1592
+ /** An entry in the trash with its head revision, which the reads of the store leave out. */
1593
+ async function findTrashed(db, site, entryId) {
1594
+ const [row] = await db.select({
1595
+ entry: entryFields,
1596
+ headRev: entries.headRev
1597
+ }).from(entries).where(and(eq(entries.id, entryId), eq(entries.siteId, site), isNotNull(entries.deletedAt))).limit(1);
1598
+ if (row === void 0) return null;
1599
+ const revision = await findRevision(db, site, row.headRev);
1600
+ if (revision === null) throw new Error(`The head revision of entry ${entryId} is not there`);
1601
+ return {
1602
+ entry: toEntry(row.entry),
1603
+ revision
1604
+ };
1605
+ }
1519
1606
  /** Drops the schedule of an entry in the batch that publishes it, if it is at `version`. */
1520
1607
  function dropScheduleAtVersion(db, site, entryId, version) {
1521
1608
  return db.delete(schedules).where(and(eq(schedules.siteId, site), eq(schedules.entryId, entryId), sql`exists (select 1 from ${entries} where ${and(eq(entries.id, entryId), eq(entries.siteId, site), eq(entries.version, version))})`));
@@ -1567,6 +1654,8 @@ function createD1Store(db, options = {}) {
1567
1654
  dueSchedules: (site, now) => dueSchedules(database, site, now),
1568
1655
  cancelSchedule: (site, entryId) => cancelSchedule(database, clock, site, entryId),
1569
1656
  unpublish: (site, write) => unpublish$1(database, clock, site, write),
1657
+ trashEntry: (site, write) => trashEntry(database, clock, site, write),
1658
+ restoreEntry: (site, entryId) => restoreEntry$1(database, clock, site, entryId),
1570
1659
  requestReview: (site, write) => requestReview$1(database, clock, site, write, () => whyNothingChanged(database, site, write.entryId)),
1571
1660
  findReview: (site, id) => findReview(database, site, id),
1572
1661
  listReviews: (site, query) => listReviews$1(database, site, query),
@@ -1851,9 +1940,8 @@ function subscribersOf(deps) {
1851
1940
  subscribers.push({
1852
1941
  name: "indexnow",
1853
1942
  async send(payload) {
1854
- if (payload.url === null) return;
1855
- const key = deps.secret(indexNow.key);
1856
- if (key === void 0) throw new Error(`the secret ${indexNow.key} is not set`);
1943
+ if (payload.kind === "site.changed" || payload.url === null) return;
1944
+ const key = indexNow.key;
1857
1945
  const request = new Request(INDEXNOW_ENDPOINT, {
1858
1946
  method: "POST",
1859
1947
  headers: { "content-type": "application/json; charset=utf-8" },
@@ -1871,6 +1959,7 @@ function subscribersOf(deps) {
1871
1959
  if (semantic !== void 0 && semantic !== null) subscribers.push({
1872
1960
  name: "vectors",
1873
1961
  async send(payload) {
1962
+ if (payload.kind === "site.changed") return;
1874
1963
  await syncEntryVectors(deps.db, semantic, {
1875
1964
  site: site.key,
1876
1965
  entryId: payload.entry.id,
@@ -1975,16 +2064,191 @@ async function consumeEvents(messages, depsOf) {
1975
2064
  else message.retry();
1976
2065
  }
1977
2066
  }
2067
+ /** JSON of at most `AUDIT_JSON_MAX` characters: a longer value says only that it was too long. */
2068
+ function boundedJson(value) {
2069
+ if (value === null) return null;
2070
+ const text = JSON.stringify(value);
2071
+ return text.length <= 2e3 ? text : JSON.stringify({ truncated: true });
2072
+ }
2073
+ /**
2074
+ * The actor of a principal. A person signed in to the admin is a `user`, with the admin as their
2075
+ * client; any other OAuth client is an `oauth_client`; a personal or read token is a `token`.
2076
+ */
2077
+ function actorOf$1(principal, meta, adminClientId) {
2078
+ if (principal.kind === "oauth") return {
2079
+ kind: principal.clientId === adminClientId ? "user" : "oauth_client",
2080
+ userId: principal.userId,
2081
+ tokenId: null,
2082
+ clientId: principal.clientId,
2083
+ meta
2084
+ };
2085
+ return {
2086
+ kind: "token",
2087
+ userId: principal.userId,
2088
+ tokenId: principal.tokenId,
2089
+ clientId: null,
2090
+ meta
2091
+ };
2092
+ }
2093
+ /** A person, signed in with their passkey, on the pages of the Worker. */
2094
+ function personActor(userId) {
2095
+ return {
2096
+ kind: "user",
2097
+ userId,
2098
+ tokenId: null,
2099
+ clientId: null,
2100
+ meta: null
2101
+ };
2102
+ }
2103
+ /** The deployment itself, acting for the person who asked it to: its cron. */
2104
+ function systemActor(forUserId) {
2105
+ return {
2106
+ kind: "system",
2107
+ userId: forUserId,
2108
+ tokenId: null,
2109
+ clientId: null,
2110
+ meta: null
2111
+ };
2112
+ }
2113
+ /**
2114
+ * The key of a site that hashes addresses: HKDF-SHA256 of the Worker secret `HOLLWARE_SECRET`,
2115
+ * bound to the site, so one address hashes alike within a site and differently in another, and
2116
+ * no hash can be checked against the 2³² IPv4 addresses without the secret.
2117
+ */
2118
+ async function addressKey(master, site) {
2119
+ const base = await crypto.subtle.importKey("raw", new TextEncoder().encode(master), "HKDF", false, ["deriveKey"]);
2120
+ return crypto.subtle.deriveKey({
2121
+ name: "HKDF",
2122
+ hash: "SHA-256",
2123
+ salt: /* @__PURE__ */ new Uint8Array(0),
2124
+ info: new TextEncoder().encode(`hollware audit address\n${site}`)
2125
+ }, base, {
2126
+ name: "HMAC",
2127
+ hash: "SHA-256",
2128
+ length: 256
2129
+ }, false, ["sign"]);
2130
+ }
2131
+ /** The hex HMAC-SHA256 of an address under the key of its site. */
2132
+ async function hashAddress(master, site, address) {
2133
+ const mac = await crypto.subtle.sign("HMAC", await addressKey(master, site), new TextEncoder().encode(address));
2134
+ return [...new Uint8Array(mac)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
2135
+ }
2136
+ function createAuditLog(request) {
2137
+ const addressOf = async (site) => {
2138
+ if (site.audit.ipAddresses !== "hashed" || request.address === null) return null;
2139
+ if (request.master === void 0 || request.master.length < 32) {
2140
+ request.log({
2141
+ kind: "audit_address_unkeyed",
2142
+ site: site.key,
2143
+ requestId: request.requestId
2144
+ });
2145
+ return null;
2146
+ }
2147
+ return hashAddress(request.master, site.key, request.address);
2148
+ };
2149
+ const statement = async (site, line) => {
2150
+ const at = request.now().toISOString();
2151
+ const { actor } = request;
2152
+ return request.db.prepare(`INSERT INTO audit_log (id, site_id, action, outcome, error_code, actor_kind, actor_user_id, actor_token_id, actor_client_id, actor_meta, target_type, target_id, detail, request_id, ip_hash, created_at, updated_at)
2153
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?16)`).bind(newId("aud"), site.key, line.action, line.outcome, line.errorCode ?? null, actor.kind, actor.userId, actor.tokenId, actor.clientId, boundedJson(actor.meta), line.target?.type ?? null, line.target?.id ?? null, boundedJson(line.detail ?? null), request.requestId, await addressOf(site), at);
2154
+ };
2155
+ return {
2156
+ statement,
2157
+ async write(site, line) {
2158
+ try {
2159
+ await request.db.batch([await statement(site, line)]);
2160
+ } catch (error) {
2161
+ request.log({
2162
+ kind: "audit_write_failed",
2163
+ site: site.key,
2164
+ action: line.action,
2165
+ requestId: request.requestId,
2166
+ message: String(error)
2167
+ });
2168
+ }
2169
+ }
2170
+ };
2171
+ }
2172
+ /**
2173
+ * Deletes the lines of a site older than its retention, a bounded number per run (ADR-037). A site
2174
+ * with no retention keeps every line. The trigger of migrations/0016 refuses any other delete.
2175
+ */
2176
+ async function dropExpiredLines(db, site, now) {
2177
+ const days = site.audit.retentionDays;
2178
+ if (days === null) return 0;
2179
+ const before = (/* @__PURE__ */ new Date(now.getTime() - days * 864e5)).toISOString();
2180
+ const [result] = await db.batch([db.prepare(`DELETE FROM audit_log WHERE id IN (SELECT id FROM audit_log WHERE site_id = ?1 AND created_at < ?2 ORDER BY created_at LIMIT ?3)`).bind(site.key, before, 500)]);
2181
+ return result?.meta.changes ?? 0;
2182
+ }
2183
+ /** The KV key that holds when a grant last rotated its refresh token, in seconds. */
2184
+ const rotatedKey = (grantId) => `hollware:rotated:${grantId}`;
2185
+ async function sha256(text) {
2186
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
2187
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
2188
+ }
2189
+ /** The refresh token of a token request, or `null` for any other request. */
2190
+ async function refreshTokenOf(request) {
2191
+ if (request.method !== "POST" || new URL(request.url).pathname !== "/oauth/token") return null;
2192
+ if (!(request.headers.get("content-type") ?? "").startsWith("application/x-www-form-urlencoded")) return null;
2193
+ const form = new URLSearchParams(await request.clone().text());
2194
+ if (form.get("grant_type") !== "refresh_token") return null;
2195
+ const token = form.get("refresh_token") ?? "";
2196
+ const parts = token.split(":");
2197
+ const [userId, grantId] = parts;
2198
+ if (parts.length !== 3 || userId === void 0 || grantId === void 0) return null;
2199
+ return {
2200
+ token,
2201
+ userId,
2202
+ grantId
2203
+ };
2204
+ }
2205
+ /**
2206
+ * The grant a refresh token belongs to when the token was already spent, past the grace: the
2207
+ * grant holds it as its previous token, and it rotated more than `REUSE_GRACE_SECONDS` ago (or so
2208
+ * long ago that the record of it expired). `null` for a token that is current, or unknown to the
2209
+ * grant, which the library answers as it does.
2210
+ */
2211
+ async function spentRefresh(kv, refresh, now) {
2212
+ const grant = await kv.get(`grant:${refresh.userId}:${refresh.grantId}`, { type: "json" });
2213
+ if (grant === null || grant["previousRefreshTokenId"] !== await sha256(refresh.token)) return null;
2214
+ const rotated = Number(await kv.get(rotatedKey(refresh.grantId)));
2215
+ if (Number.isFinite(rotated) && rotated > 0 && now - rotated <= 60) return null;
2216
+ const metadata = grant["metadata"];
2217
+ const site = typeof metadata === "object" && metadata !== null ? Reflect.get(metadata, "site") : null;
2218
+ return {
2219
+ userId: refresh.userId,
2220
+ grantId: refresh.grantId,
2221
+ site: typeof site === "string" ? site : null
2222
+ };
2223
+ }
2224
+ /** Records that a grant rotated its refresh token now, for the grace of a retry. */
2225
+ async function recordRotation(kv, grantId, now) {
2226
+ await kv.put(rotatedKey(grantId), String(now), { expirationTtl: 120 });
2227
+ }
2228
+ /** The answer to a spent refresh token, as the library answers an invalid grant. */
2229
+ function reusedAnswer() {
2230
+ return Response.json({
2231
+ error: "invalid_grant",
2232
+ error_description: "This refresh token was already used, so its grant was revoked. Connect the app again."
2233
+ }, {
2234
+ status: 400,
2235
+ headers: { "cache-control": "no-store" }
2236
+ });
2237
+ }
1978
2238
  //#endregion
1979
2239
  //#region src/structure/seal.ts
1980
2240
  /**
1981
2241
  * Sealing a secret of the structure (ADR-033) under the Worker secret `HOLLWARE_SECRET`, so that
1982
2242
  * 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.";
2243
+ * that secret, and a fresh 96-bit nonce each time. What is stored is `v2.` and the base64url of
2244
+ * the nonce and the ciphertext with its tag, sealed with the site and the name of the secret as
2245
+ * associated data: a value moved to another row of D1 does not open there (T-71 of the threat
2246
+ * model). One that was tampered with, or sealed under another `HOLLWARE_SECRET`, does not open.
2247
+ * A `v1.` value, sealed before the place was bound, still opens wherever it is.
2248
+ */
2249
+ const PREFIX = "v2.";
2250
+ const BEFORE_PLACES = "v1.";
2251
+ const placeOf = (place) => new TextEncoder().encode(`${place.site}\n${place.name}`);
1988
2252
  function base64url(bytes) {
1989
2253
  return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1990
2254
  }
@@ -1997,25 +2261,28 @@ async function keyOf(master) {
1997
2261
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(master));
1998
2262
  return crypto.subtle.importKey("raw", digest, "AES-GCM", false, ["encrypt", "decrypt"]);
1999
2263
  }
2000
- async function seal(master, value) {
2264
+ async function seal(master, value, place) {
2001
2265
  const nonce = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
2002
2266
  const cipher = new Uint8Array(await crypto.subtle.encrypt({
2003
2267
  name: "AES-GCM",
2004
- iv: nonce
2268
+ iv: nonce,
2269
+ additionalData: placeOf(place)
2005
2270
  }, await keyOf(master), new TextEncoder().encode(value)));
2006
2271
  const sealed = new Uint8Array(nonce.length + cipher.length);
2007
2272
  sealed.set(nonce);
2008
2273
  sealed.set(cipher, nonce.length);
2009
2274
  return `${PREFIX}${base64url(sealed)}`;
2010
2275
  }
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;
2276
+ /** The value of a sealed secret, or `null` when it does not open under `master` at `place`. */
2277
+ async function unseal(master, sealed, place) {
2278
+ const bound = sealed.startsWith(PREFIX);
2279
+ if (!bound && !sealed.startsWith(BEFORE_PLACES)) return null;
2014
2280
  try {
2015
2281
  const bytes = fromBase64url(sealed.slice(3));
2016
2282
  const plain = await crypto.subtle.decrypt({
2017
2283
  name: "AES-GCM",
2018
- iv: bytes.slice(0, 12)
2284
+ iv: bytes.slice(0, 12),
2285
+ ...bound ? { additionalData: placeOf(place) } : {}
2019
2286
  }, await keyOf(master), bytes.slice(12));
2020
2287
  return new TextDecoder().decode(plain);
2021
2288
  } catch {
@@ -2038,9 +2305,9 @@ function metaOf(actor) {
2038
2305
  return actor.meta === null ? null : JSON.stringify(actor.meta);
2039
2306
  }
2040
2307
  /** 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) {
2308
+ function appendChange(db, site, actor, now, before, after, id = newId("sit")) {
2042
2309
  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);
2310
+ SELECT ?1, id, version, ?2, ?3, ?4, ${after === "definition" ? "definition" : "NULL"}, ?5, ?5 FROM sites WHERE id = ?6 AND updated_at = ?5`).bind(id, actor.userId, metaOf(actor), before, now, site);
2044
2311
  }
2045
2312
  function createStructureStore(db, now) {
2046
2313
  return {
@@ -2053,10 +2320,17 @@ function createStructureStore(db, now) {
2053
2320
  deletedAt: row.deleted_at
2054
2321
  };
2055
2322
  },
2056
- async write({ site, expected, definition, actor }) {
2323
+ async write({ site, expected, definition, actor, event }) {
2057
2324
  const at = now().toISOString();
2325
+ const change = newId("sit");
2058
2326
  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")]);
2327
+ const [moved] = await db.batch([
2328
+ 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),
2329
+ appendChange(db, site, actor, at, before ?? null, "definition", change),
2330
+ ...event === void 0 ? [] : [db.prepare(`INSERT INTO outbox (id, site_id, kind, entry_id, payload, sent_at, created_at, updated_at)
2331
+ SELECT ?1, ?2, 'site.changed', NULL, ?3, NULL, ?4, ?4
2332
+ WHERE EXISTS (SELECT 1 FROM site_changes WHERE id = ?5 AND site_id = ?2)`).bind(event.id, site, event.payload, at, change)]
2333
+ ]);
2060
2334
  return (moved?.meta.changes ?? 0) === 1 ? "written" : "conflict";
2061
2335
  },
2062
2336
  async create({ site, definition, actor }) {
@@ -2209,122 +2483,343 @@ function installErrorHandling(app, log) {
2209
2483
  }));
2210
2484
  }
2211
2485
  //#endregion
2212
- //#region src/oauth/admin.ts
2486
+ //#region src/identity/pages.ts
2213
2487
  /**
2214
- * The admin as a client of the OAuth server (ADR-026): a public client, with PKCE, that a person
2215
- * never has to allow. It is registered once per issuer, with `createClient`, and its id is kept in
2216
- * `OAUTH_KV` beside the library's own records. A Client ID Metadata Document would need an `https`
2217
- * URL that the Worker fetches from itself, which local development does not have.
2488
+ * The pages a person sees to get in (ADR-026): set up a passkey from a link, and sign in. They
2489
+ * are rendered by the Worker, outside the admin's bundle, with one inline script under a nonce
2490
+ * and nothing framing them. The script uses the WebAuthn JSON methods of the browser
2491
+ * (`PublicKeyCredential.parseCreationOptionsFromJSON`, `credential.toJSON()`), and reads the
2492
+ * invitation from the fragment of the address, which no request carries, so neither the page nor
2493
+ * a log of a request ever holds it (T-55); a link of before 0.11, with it in the query, still works.
2218
2494
  */
2219
- /** Where the admin's id is kept, by issuer. The library's own keys never start with it. */
2220
- const KEY = "hollware:admin-client:";
2221
2495
  /**
2222
- * The admin's client, registered on first use. Two first requests at once may both register one:
2223
- * the last id written wins, and the other client is never used.
2496
+ * The look of hollware.com and the admin, with nothing to fetch: the page's policy loads no font,
2497
+ * so the type is the system's. Dark only, the neutral greys of the theme, white as the primary
2498
+ * color and emerald for the mark (tests/conventions/brand.test.ts).
2224
2499
  */
2225
- async function adminClient(oauth, env) {
2226
- const redirectUri = `${oauth.issuer}/admin/callback`;
2227
- const stored = await env.OAUTH_KV.get(`${KEY}${oauth.issuer}`);
2228
- if (stored !== null) return {
2229
- clientId: stored,
2230
- redirectUri
2231
- };
2232
- const client = await oauth.authorizationServer.getOAuthApi(env).createClient({
2233
- clientName: "Hollware admin",
2234
- redirectUris: [redirectUri],
2235
- tokenEndpointAuthMethod: "none"
2236
- });
2237
- await env.OAUTH_KV.put(`${KEY}${oauth.issuer}`, client.clientId);
2238
- return {
2239
- clientId: client.clientId,
2240
- redirectUri
2241
- };
2242
- }
2243
- //#endregion
2244
- //#region src/http/admin.ts
2500
+ 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}
2501
+ *{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}
2502
+ main{width:100%;max-width:26rem;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:2rem}
2503
+ .brand{display:flex;align-items:center;gap:.6rem;margin-bottom:1.75rem;font-weight:600;letter-spacing:-.01em}.brand svg{width:26px;height:26px}
2504
+ h1{font-size:1.4rem;line-height:1.25;letter-spacing:-.02em;margin:0 0 .5rem}p{color:var(--muted);margin:.5rem 0}strong{color:var(--fg)}
2505
+ label{display:block;margin:1rem 0 .35rem;font-size:.9rem;font-weight:500}fieldset label,form>label:has(input[type=checkbox]){font-weight:400;display:flex;align-items:center;gap:.5rem;margin:.5rem 0}
2506
+ input{width:100%;padding:.6rem .75rem;font:inherit;color:inherit;background:transparent;border:1px solid var(--line);border-radius:8px}input:focus{outline:2px solid var(--accent);outline-offset:1px}
2507
+ label input{width:auto;margin:0}fieldset{border:1px solid var(--line);border-radius:8px;margin:1rem 0;padding:.5rem .9rem}legend{color:var(--muted);font-size:.85rem;padding:0 .3rem}
2508
+ code{font:.82em ui-monospace,'SF Mono',Menlo,monospace;color:var(--muted)}
2509
+ button{margin-top:1.25rem;padding:.6rem 1rem;font:inherit;font-weight:500;cursor:pointer;border-radius:8px;border:1px solid var(--line);background:transparent;color:var(--fg)}
2510
+ button:not([value=deny]){background:var(--accent);border-color:var(--accent);color:var(--on-accent)}
2511
+ [role=alert]{color:var(--danger)}`;
2245
2512
  /**
2246
- * The admin at `/admin/` (ADR-026). Its hashed files are static assets that never reach the
2247
- * Worker (`run_worker_first` in wrangler.jsonc); its page does, so that it goes out with the
2248
- * policy below, and so does every path of the single-page app, which all get the same page. The
2249
- * page learns how to sign in from `/admin/config.json`.
2513
+ * The SHA-256 of STYLE, base64: the policy of every page allows that one stylesheet and no other
2514
+ * inline style (a test computes it again from STYLE, so the two cannot part).
2250
2515
  */
2516
+ const STYLE_HASH = "sha256-XfLw/v21Jfp0bukRAqh1+Rz82lWrWpdy2GbGduQqpqY=";
2251
2517
  /**
2252
- * What the admin's page may do: its own scripts and styles (CodeMirror and Nuxt UI write style
2253
- * elements and attributes, hence `unsafe-inline` for styles only), images of the content from
2254
- * anywhere over https, and requests to this origin alone. Nothing frames it.
2518
+ * What every page of the Worker says besides its policy: no window of another origin keeps a hold
2519
+ * on it, no other origin embeds it, and it asks for no device.
2255
2520
  */
2256
- const POLICY = [
2257
- "default-src 'self'",
2258
- "script-src 'self'",
2259
- "style-src 'self' 'unsafe-inline'",
2260
- "img-src 'self' data: https:",
2261
- "connect-src 'self'",
2262
- "font-src 'self' data:",
2263
- "base-uri 'none'",
2264
- "form-action 'self'",
2265
- "frame-ancestors 'none'"
2266
- ].join("; ");
2267
- function notInstalled() {
2268
- return Response.json({
2269
- code: "not_found",
2270
- message: "The admin is not installed in this Worker.",
2271
- fix: "Bind the static assets as ASSETS and serve the files of @hollware/admin (adminAssets()), as the starter does."
2272
- }, {
2273
- status: 404,
2274
- headers: { "cache-control": "no-store" }
2275
- });
2521
+ const PAGE_ISOLATION = {
2522
+ "cross-origin-opener-policy": "same-origin",
2523
+ "cross-origin-resource-policy": "same-origin",
2524
+ "permissions-policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=()"
2525
+ };
2526
+ /** The mark of Hollware, before the title of every page. */
2527
+ 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>`;
2528
+ const POST = `async function post(path, body) {
2529
+ const response = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
2530
+ const data = await response.json()
2531
+ if (!response.ok) throw new Error(data.message + ' ' + (data.fix ?? ''))
2532
+ return data
2276
2533
  }
2277
- function installAdmin(app, options) {
2278
- app.get("/admin/config.json", async (c) => {
2279
- const oauth = options.oauth(c.env.HOLLWARE_PUBLIC_URL);
2280
- const client = await adminClient(oauth, c.env);
2281
- return c.json({
2282
- issuer: oauth.issuer,
2283
- clientId: client.clientId,
2284
- redirectUri: client.redirectUri,
2285
- resource: oauth.resources.v1,
2286
- scope: SCOPES.join(" ")
2287
- }, 200, { "cache-control": "no-store" });
2288
- });
2289
- const page = async (c) => {
2290
- const assets = c.env.ASSETS;
2291
- if (assets === void 0) return notInstalled();
2292
- const origin = options.oauth(c.env.HOLLWARE_PUBLIC_URL).issuer;
2293
- const answer = await assets.fetch(new Request(`${origin}/admin/`));
2294
- if (!answer.ok) return notInstalled();
2295
- const headers = new Headers(answer.headers);
2296
- headers.set("content-security-policy", POLICY);
2297
- headers.set("cache-control", "no-store");
2298
- headers.set("x-content-type-options", "nosniff");
2299
- headers.set("referrer-policy", "no-referrer");
2300
- return new Response(answer.body, {
2301
- status: 200,
2302
- headers
2303
- });
2304
- };
2305
- app.get("/admin", (c) => c.redirect("/admin/", 301));
2306
- app.get("/admin/", page);
2307
- app.get("/admin/*", async (c) => {
2308
- if (/\.[a-z0-9]+$/i.test(c.req.path)) {
2309
- const assets = c.env.ASSETS;
2310
- if (assets === void 0) return notInstalled();
2311
- return assets.fetch(c.req.raw);
2312
- }
2313
- return page(c);
2314
- });
2534
+ function fail(error) { document.getElementById('error').textContent = error.message }`;
2535
+ function page(title, nonce, body, script) {
2536
+ return `<!doctype html>
2537
+ <html lang="en">
2538
+ <head>
2539
+ <meta charset="utf-8">
2540
+ <meta name="viewport" content="width=device-width, initial-scale=1">
2541
+ <title>${title} · Hollware</title>
2542
+ <style>${STYLE}</style>
2543
+ </head>
2544
+ <body>
2545
+ <main>
2546
+ ${BRAND}
2547
+ <h1>${title}</h1>
2548
+ ${body}
2549
+ <p id="error" role="alert"></p>
2550
+ </main>
2551
+ <script nonce="${nonce}">${POST}
2552
+ ${script}<\/script>
2553
+ </body>
2554
+ </html>
2555
+ `;
2315
2556
  }
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");
2557
+ /**
2558
+ * The invitation of a setup link: in its fragment (`#invitation=…`), which a browser never sends,
2559
+ * so no log of a request holds it (T-55); or, in a link made before 0.11, in its query. The page
2560
+ * runs its source as it is: it uses nothing but its arguments and the URL of the platform.
2561
+ */
2562
+ function invitationOf(hash, search) {
2563
+ return new URLSearchParams(hash.replace(/^#/, "")).get("invitation") ?? new URLSearchParams(search).get("invitation") ?? "";
2322
2564
  }
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
2565
+ function setupPage(nonce) {
2566
+ return page("Set up your passkey", nonce, `<p>A passkey lets you into this Hollware with your device's lock: no password.</p>
2567
+ <form id="form">
2568
+ <label for="name">Your name</label><input id="name" name="name" autocomplete="name">
2569
+ <label for="label">This device</label><input id="label" name="label" value="Passkey">
2570
+ <button type="submit">Create the passkey</button>
2571
+ </form>`, `const invitationOf = ${invitationOf.toString()}
2572
+ document.getElementById('form').addEventListener('submit', async (event) => {
2573
+ event.preventDefault()
2574
+ try {
2575
+ const invitation = invitationOf(location.hash, location.search)
2576
+ const { challengeId, options } = await post('/auth/register/options', { invitation })
2577
+ const credential = await navigator.credentials.create({ publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options) })
2578
+ await post('/auth/register/verify', { challengeId, response: credential.toJSON(), name: document.getElementById('name').value, label: document.getElementById('label').value })
2579
+ location.href = '/admin/'
2580
+ } catch (error) { fail(error) }
2581
+ })`);
2582
+ }
2583
+ /**
2584
+ * Where the sign-in page sends the person after their passkey: the `return_to` it was given, if it
2585
+ * is a page of this origin, else the admin. The target is read as the browser will read it, as a
2586
+ * URL against the page's own origin, so no way of writing another host (`//`, `/\`, a tab, a
2587
+ * scheme) gets through, and only the path, query and fragment it resolves to are used (T-43).
2588
+ * The page runs its source as it is: it uses nothing but its arguments and the URL of the platform.
2589
+ */
2590
+ function sameOriginTarget(target, origin) {
2591
+ if (target === null) return "/admin/";
2592
+ try {
2593
+ const url = new URL(target, origin);
2594
+ return url.origin === origin ? `${url.pathname}${url.search}${url.hash}` : "/admin/";
2595
+ } catch {
2596
+ return "/admin/";
2597
+ }
2598
+ }
2599
+ function signInPage(nonce) {
2600
+ return page("Sign in", nonce, `<p>Sign in to this Hollware with your passkey.</p>
2601
+ <button id="signin" type="button">Sign in with a passkey</button>`, `const sameOriginTarget = ${sameOriginTarget.toString()}
2602
+ document.getElementById('signin').addEventListener('click', async () => {
2603
+ try {
2604
+ const { challengeId, options } = await post('/auth/signin/options', {})
2605
+ const credential = await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options) })
2606
+ await post('/auth/signin/verify', { challengeId, response: credential.toJSON() })
2607
+ location.href = sameOriginTarget(new URLSearchParams(location.search).get('return_to'), location.origin)
2608
+ } catch (error) { fail(error) }
2609
+ })`);
2610
+ }
2611
+ /** The headers of a page: no framing, only its own script, and no caching. */
2612
+ function pageHeaders(nonce) {
2613
+ return {
2614
+ "content-type": "text/html; charset=utf-8",
2615
+ "cache-control": "no-store",
2616
+ "content-security-policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src '${STYLE_HASH}'; connect-src 'self'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'`,
2617
+ "x-content-type-options": "nosniff",
2618
+ "referrer-policy": "no-referrer",
2619
+ ...PAGE_ISOLATION
2620
+ };
2621
+ }
2622
+ /** Text for HTML: every string a client chose (its name, its redirect host, a scope) goes through it. */
2623
+ function escapeHtml(value) {
2624
+ return value.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
2625
+ }
2626
+ /** What a scope lets a client do, in the words of the consent page. */
2627
+ const SCOPE_WORDS = {
2628
+ "content:read": "Read the content, drafts included",
2629
+ "content:write": "Write drafts",
2630
+ "content:publish": "Publish and unpublish",
2631
+ "schema:admin": "Change the site settings"
2632
+ };
2633
+ /**
2634
+ * The consent page of `/authorize` (ADR-026): which client asks, where the tokens go, and what it
2635
+ * may do, with Allow and Deny. It has no script, and it posts the handle that `beginConsent` bound
2636
+ * to this browser. Everything the client chose is escaped.
2637
+ */
2638
+ function consentPage(view) {
2639
+ const name = escapeHtml(view.clientName);
2640
+ const origin = view.clientDomain === void 0 ? "<p>This app registered itself: its name is not verified.</p>" : `<p>Published by <strong>${escapeHtml(view.clientDomain)}</strong>.</p>`;
2641
+ const loopback = view.redirectIsLoopback ? "<p><strong>This sends access to an app on this computer.</strong> Continue only if you just started to connect it.</p>" : "";
2642
+ const sites = view.sites.length === 1 ? `<input type="hidden" name="site" value="${escapeHtml(view.sites[0] ?? "")}">` : `<fieldset><legend>For the site</legend>${view.sites.map((site, index) => `<label><input type="radio" name="site" value="${escapeHtml(site)}"${index === 0 ? " checked" : ""}> ${escapeHtml(site)}</label>`).join("\n")}</fieldset>`;
2643
+ const scopes = view.scopes.map((scope) => `<label><input type="checkbox" name="scope" value="${escapeHtml(scope)}" checked> ${escapeHtml(SCOPE_WORDS[scope] ?? scope)} <code>${escapeHtml(scope)}</code></label>`).join("\n");
2644
+ return `<!doctype html>
2645
+ <html lang="en">
2646
+ <head>
2647
+ <meta charset="utf-8">
2648
+ <meta name="viewport" content="width=device-width, initial-scale=1">
2649
+ <title>Allow ${name} · Hollware</title>
2650
+ <style>${STYLE}</style>
2651
+ </head>
2652
+ <body>
2653
+ <main>
2654
+ ${BRAND}
2655
+ <h1>Allow ${name} to use this Hollware?</h1>
2656
+ ${origin}
2657
+ <p>Signed in as <strong>${escapeHtml(view.person)}</strong>. Access goes to <strong>${escapeHtml(view.redirectHost)}</strong>.</p>
2658
+ ${loopback}
2659
+ <form method="post" action="/authorize">
2660
+ <input type="hidden" name="handle" value="${escapeHtml(view.handle)}">
2661
+ ${sites}
2662
+ ${scopes}
2663
+ <p><button name="decision" value="approve">Allow</button> <button name="decision" value="deny">Deny</button></p>
2664
+ </form>
2665
+ </main>
2666
+ </body>
2667
+ </html>
2668
+ `;
2669
+ }
2670
+ /** A page that only says what went wrong and what to do. */
2671
+ function messagePage(title, message) {
2672
+ return `<!doctype html>
2673
+ <html lang="en">
2674
+ <head>
2675
+ <meta charset="utf-8">
2676
+ <meta name="viewport" content="width=device-width, initial-scale=1">
2677
+ <title>${escapeHtml(title)} · Hollware</title>
2678
+ <style>${STYLE}</style>
2679
+ </head>
2680
+ <body>
2681
+ <main>
2682
+ ${BRAND}
2683
+ <h1>${escapeHtml(title)}</h1>
2684
+ <p>${escapeHtml(message)}</p>
2685
+ </main>
2686
+ </body>
2687
+ </html>
2688
+ `;
2689
+ }
2690
+ /**
2691
+ * The headers of a page without a script: no framing and no caching. The form of the consent page
2692
+ * posts to `/authorize`, which redirects to the client, so `form-action` is not restricted: a
2693
+ * browser applies it to the redirect too.
2694
+ */
2695
+ function plainPageHeaders() {
2696
+ return {
2697
+ "content-type": "text/html; charset=utf-8",
2698
+ "cache-control": "no-store",
2699
+ "content-security-policy": `default-src 'none'; style-src '${STYLE_HASH}'; base-uri 'none'; frame-ancestors 'none'`,
2700
+ "x-content-type-options": "nosniff",
2701
+ "referrer-policy": "no-referrer",
2702
+ ...PAGE_ISOLATION
2703
+ };
2704
+ }
2705
+ //#endregion
2706
+ //#region src/oauth/admin.ts
2707
+ /**
2708
+ * The admin as a client of the OAuth server (ADR-026): a public client, with PKCE, that a person
2709
+ * never has to allow. It is registered once per issuer, with `createClient`, and its id is kept in
2710
+ * `OAUTH_KV` beside the library's own records. A Client ID Metadata Document would need an `https`
2711
+ * URL that the Worker fetches from itself, which local development does not have.
2712
+ */
2713
+ /** Where the admin's id is kept, by issuer. The library's own keys never start with it. */
2714
+ const KEY = "hollware:admin-client:";
2715
+ /**
2716
+ * The admin's client, registered on first use. Two first requests at once may both register one:
2717
+ * the last id written wins, and the other client is never used.
2718
+ */
2719
+ async function adminClient(oauth, env) {
2720
+ const redirectUri = `${oauth.issuer}/admin/callback`;
2721
+ const stored = await env.OAUTH_KV.get(`${KEY}${oauth.issuer}`);
2722
+ if (stored !== null) return {
2723
+ clientId: stored,
2724
+ redirectUri
2725
+ };
2726
+ const client = await oauth.authorizationServer.getOAuthApi(env).createClient({
2727
+ clientName: "Hollware admin",
2728
+ redirectUris: [redirectUri],
2729
+ tokenEndpointAuthMethod: "none"
2730
+ });
2731
+ await env.OAUTH_KV.put(`${KEY}${oauth.issuer}`, client.clientId);
2732
+ return {
2733
+ clientId: client.clientId,
2734
+ redirectUri
2735
+ };
2736
+ }
2737
+ //#endregion
2738
+ //#region src/http/admin.ts
2739
+ /**
2740
+ * The admin at `/admin/` (ADR-026). Its hashed files are static assets that never reach the
2741
+ * Worker (`run_worker_first` in wrangler.jsonc); its page does, so that it goes out with the
2742
+ * policy below, and so does every path of the single-page app, which all get the same page. The
2743
+ * page learns how to sign in from `/admin/config.json`.
2744
+ */
2745
+ /**
2746
+ * What the admin's page may do: its own scripts and styles (CodeMirror and Nuxt UI write style
2747
+ * elements and attributes, hence `unsafe-inline` for styles only), images of the content from
2748
+ * anywhere over https, and requests to this origin alone. Nothing frames it.
2749
+ */
2750
+ const POLICY = [
2751
+ "default-src 'self'",
2752
+ "script-src 'self'",
2753
+ "style-src 'self' 'unsafe-inline'",
2754
+ "img-src 'self' data: https:",
2755
+ "connect-src 'self'",
2756
+ "font-src 'self' data:",
2757
+ "base-uri 'none'",
2758
+ "form-action 'self'",
2759
+ "frame-ancestors 'none'"
2760
+ ].join("; ");
2761
+ function notInstalled() {
2762
+ return Response.json({
2763
+ code: "not_found",
2764
+ message: "The admin is not installed in this Worker.",
2765
+ fix: "Bind the static assets as ASSETS and serve the files of @hollware/admin (adminAssets()), as the starter does."
2766
+ }, {
2767
+ status: 404,
2768
+ headers: { "cache-control": "no-store" }
2769
+ });
2770
+ }
2771
+ function installAdmin(app, options) {
2772
+ app.get("/admin/config.json", async (c) => {
2773
+ const oauth = options.oauth(c.env.HOLLWARE_PUBLIC_URL);
2774
+ const client = await adminClient(oauth, c.env);
2775
+ return c.json({
2776
+ issuer: oauth.issuer,
2777
+ clientId: client.clientId,
2778
+ redirectUri: client.redirectUri,
2779
+ resource: oauth.resources.v1,
2780
+ scope: SCOPES.join(" ")
2781
+ }, 200, { "cache-control": "no-store" });
2782
+ });
2783
+ const page = async (c) => {
2784
+ const assets = c.env.ASSETS;
2785
+ if (assets === void 0) return notInstalled();
2786
+ const origin = options.oauth(c.env.HOLLWARE_PUBLIC_URL).issuer;
2787
+ const answer = await assets.fetch(new Request(`${origin}/admin/`));
2788
+ if (!answer.ok) return notInstalled();
2789
+ const headers = new Headers(answer.headers);
2790
+ headers.set("content-security-policy", POLICY);
2791
+ headers.set("cache-control", "no-store");
2792
+ headers.set("x-content-type-options", "nosniff");
2793
+ headers.set("referrer-policy", "no-referrer");
2794
+ for (const [name, value] of Object.entries(PAGE_ISOLATION)) headers.set(name, value);
2795
+ return new Response(answer.body, {
2796
+ status: 200,
2797
+ headers
2798
+ });
2799
+ };
2800
+ app.get("/admin", (c) => c.redirect("/admin/", 301));
2801
+ app.get("/admin/", page);
2802
+ app.get("/admin/*", async (c) => {
2803
+ if (/\.[a-z0-9]+$/i.test(c.req.path)) {
2804
+ const assets = c.env.ASSETS;
2805
+ if (assets === void 0) return notInstalled();
2806
+ return assets.fetch(c.req.raw);
2807
+ }
2808
+ return page(c);
2809
+ });
2810
+ }
2811
+ //#endregion
2812
+ //#region src/identity/people.ts
2813
+ /** The owners of the site besides `userId`, counted in the statement that changes `userId`. */
2814
+ const OTHER_OWNERS = "(SELECT count(*) FROM members WHERE site_id = ?1 AND role = 'owner' AND user_id <> ?2) > 0";
2815
+ async function roleOf$1(db, site, userId) {
2816
+ return db.prepare("SELECT role FROM members WHERE site_id = ?1 AND user_id = ?2").bind(site, userId).first("role");
2817
+ }
2818
+ async function setRole(db, input) {
2819
+ const current = await roleOf$1(db, input.site, input.userId);
2820
+ if (current === null) return "not_member";
2821
+ if (current === input.role) return "unchanged";
2822
+ return (await db.prepare(`UPDATE members SET role = ?3, updated_at = ?4 WHERE site_id = ?1 AND user_id = ?2
2328
2823
  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
2824
  }
2330
2825
  async function removeMember(db, input) {
@@ -2332,17 +2827,171 @@ async function removeMember(db, input) {
2332
2827
  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
2828
  }
2334
2829
  //#endregion
2830
+ //#region src/audit/read.ts
2831
+ /** A refusal of a filter or a cursor, with what to do instead. */
2832
+ var ActivityInputError = class extends Error {
2833
+ fix;
2834
+ constructor(message, fix) {
2835
+ super(message);
2836
+ this.fix = fix;
2837
+ }
2838
+ };
2839
+ const INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
2840
+ const ID = /^[a-z]{3}_[0-9A-HJKMNP-TV-Z]{26}$/;
2841
+ function encodeCursor(at, id) {
2842
+ return btoa(`${at}|${id}`).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
2843
+ }
2844
+ function decodeCursor(cursor) {
2845
+ try {
2846
+ const [at = "", id = ""] = atob(cursor.replace(/-/g, "+").replace(/_/g, "/")).split("|");
2847
+ if (INSTANT.test(at) && ID.test(id)) return {
2848
+ at,
2849
+ id
2850
+ };
2851
+ } catch {}
2852
+ throw new ActivityInputError("The cursor is not valid for the activity log.", "Pass the nextCursor of the previous page unchanged, or leave it out for the first page.");
2853
+ }
2854
+ function instant(name, value) {
2855
+ if (value === void 0) return null;
2856
+ if (!INSTANT.test(value) || Number.isNaN(Date.parse(value))) throw new ActivityInputError(`${name} is not an instant.`, "Give it in UTC as 2026-10-17T00:00:00.000Z.");
2857
+ return new Date(value).toISOString();
2858
+ }
2859
+ function json$2(text) {
2860
+ if (text === null) return null;
2861
+ try {
2862
+ const value = JSON.parse(text);
2863
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : null;
2864
+ } catch {
2865
+ return null;
2866
+ }
2867
+ }
2868
+ /** One page of the log of `site`, newest first; at most `limit` lines, 1 to 500. */
2869
+ async function readActivity(db, site, filters, page) {
2870
+ if (filters.action !== void 0 && !isAuditAction(filters.action)) throw new ActivityInputError(`There is no action ${filters.action}.`, "Use an action of the activity log, such as publish or token.create.");
2871
+ const limit = Math.min(Math.max(Math.trunc(page.limit), 1), 500);
2872
+ const where = ["a.site_id = ?1"];
2873
+ const binds = [site];
2874
+ const add = (clause, value) => {
2875
+ binds.push(value);
2876
+ where.push(clause(binds.length));
2877
+ };
2878
+ if (filters.actor !== void 0) add((i) => `a.actor_user_id = ?${i}`, filters.actor);
2879
+ if (filters.action !== void 0) add((i) => `a.action = ?${i}`, filters.action);
2880
+ if (filters.targetType !== void 0) add((i) => `a.target_type = ?${i}`, filters.targetType);
2881
+ if (filters.targetId !== void 0) add((i) => `a.target_id = ?${i}`, filters.targetId);
2882
+ const from = instant("from", filters.from);
2883
+ if (from !== null) add((i) => `a.created_at >= ?${i}`, from);
2884
+ const to = instant("to", filters.to);
2885
+ if (to !== null) add((i) => `a.created_at < ?${i}`, to);
2886
+ if (page.cursor !== void 0) {
2887
+ const after = decodeCursor(page.cursor);
2888
+ binds.push(after.at, after.id);
2889
+ where.push(`(a.created_at, a.id) < (?${binds.length - 1}, ?${binds.length})`);
2890
+ }
2891
+ binds.push(limit + 1);
2892
+ const [result] = await db.batch([db.prepare(`SELECT a.id, a.created_at, a.action, a.outcome, a.error_code, a.actor_kind, a.actor_user_id,
2893
+ u.name AS actor_name, a.actor_token_id, t.label AS token_label, a.actor_client_id,
2894
+ a.actor_meta, a.target_type, a.target_id, a.detail, a.request_id, a.ip_hash
2895
+ FROM audit_log a
2896
+ LEFT JOIN users u ON u.id = a.actor_user_id
2897
+ LEFT JOIN tokens t ON t.id = a.actor_token_id AND t.site_id = a.site_id
2898
+ WHERE ${where.join(" AND ")}
2899
+ ORDER BY a.created_at DESC, a.id DESC
2900
+ LIMIT ?${binds.length}`).bind(...binds)]);
2901
+ const rows = result?.results ?? [];
2902
+ const lines = rows.slice(0, limit).map((row) => ({
2903
+ id: row.id,
2904
+ at: row.created_at,
2905
+ action: row.action,
2906
+ outcome: row.outcome,
2907
+ errorCode: row.error_code,
2908
+ actor: {
2909
+ kind: row.actor_kind,
2910
+ userId: row.actor_user_id,
2911
+ name: row.actor_name,
2912
+ tokenId: row.actor_token_id,
2913
+ tokenLabel: row.token_label,
2914
+ clientId: row.actor_client_id,
2915
+ declared: json$2(row.actor_meta)
2916
+ },
2917
+ target: row.target_type === null || row.target_id === null ? null : {
2918
+ type: row.target_type,
2919
+ id: row.target_id
2920
+ },
2921
+ detail: json$2(row.detail),
2922
+ requestId: row.request_id,
2923
+ ipHash: row.ip_hash
2924
+ }));
2925
+ const last = lines.at(-1);
2926
+ return {
2927
+ lines,
2928
+ nextCursor: rows.length > limit && last !== void 0 ? encodeCursor(last.at, last.id) : null
2929
+ };
2930
+ }
2931
+ /** The columns of the CSV, in order. */
2932
+ const ACTIVITY_CSV_COLUMNS = [
2933
+ "at",
2934
+ "action",
2935
+ "outcome",
2936
+ "error_code",
2937
+ "actor_kind",
2938
+ "actor_user_id",
2939
+ "actor_name",
2940
+ "actor_token_id",
2941
+ "actor_token_label",
2942
+ "actor_client_id",
2943
+ "actor_declared",
2944
+ "target_type",
2945
+ "target_id",
2946
+ "detail",
2947
+ "request_id",
2948
+ "ip_hash"
2949
+ ];
2950
+ /**
2951
+ * A field of CSV (RFC 4180), with a cell that a spreadsheet would run as a formula (`=`, `+`, `-`,
2952
+ * `@`, a tab or a return first) led by an apostrophe: a name or a label is anyone's text.
2953
+ */
2954
+ function csvField(value) {
2955
+ if (value === null) return "";
2956
+ const safe = /^[=+\-@\t\r]/.test(value) ? `'${value}` : value;
2957
+ return /[",\r\n]/.test(safe) ? `"${safe.replace(/"/g, "\"\"")}"` : safe;
2958
+ }
2959
+ function activityCsvRow(line) {
2960
+ const text = (value) => value === null ? null : JSON.stringify(value);
2961
+ return [
2962
+ line.at,
2963
+ line.action,
2964
+ line.outcome,
2965
+ line.errorCode,
2966
+ line.actor.kind,
2967
+ line.actor.userId,
2968
+ line.actor.name,
2969
+ line.actor.tokenId,
2970
+ line.actor.tokenLabel,
2971
+ line.actor.clientId,
2972
+ text(line.actor.declared),
2973
+ line.target?.type ?? null,
2974
+ line.target?.id ?? null,
2975
+ text(line.detail),
2976
+ line.requestId,
2977
+ line.ipHash
2978
+ ].map(csvField).join(",");
2979
+ }
2980
+ //#endregion
2335
2981
  //#region src/http/admin-api.ts
2336
2982
  /**
2337
2983
  * What only a person in the admin may do (ADR-026): see, make and revoke personal tokens, and
2338
2984
  * invite people, on the site of their token; and learn who they are and which sites they may switch
2339
- * to (`/v1/admin/me`, the one route for every role). These are no operations, so no agent sees them over MCP, and a request needs an
2985
+ * to (`/v1/admin/me`), and manage their own sign-in (`/v1/admin/account`: their passkeys, the apps
2986
+ * they connected and their sessions), the routes for every role. These are no operations, so no agent sees them over MCP, and a request needs an
2340
2987
  * access token of the admin's own client with `schema:admin`: a personal token, or a token of
2341
2988
  * another client, never reaches them, even an owner's. A leaked token cannot mint the next one.
2342
2989
  */
2343
- /** A personal token lasts this long unless the person says otherwise (as `hollware token create`). */
2344
- const TOKEN_DAYS = 90;
2345
2990
  const INVITATION_DAYS = 7;
2991
+ /** The lines of a page of the activity log, unless the admin asks for another number. */
2992
+ const ACTIVITY_PAGE = 50;
2993
+ /** The most lines one CSV of the activity log holds. */
2994
+ const ACTIVITY_CSV_MAX = 1e4;
2346
2995
  function json$1(body, status = 200) {
2347
2996
  return Response.json(body, {
2348
2997
  status,
@@ -2356,6 +3005,20 @@ const REFUSAL_CODES = {
2356
3005
  404: "not_found",
2357
3006
  409: "conflict"
2358
3007
  };
3008
+ /** The code of a refusal by its status, for the activity log. */
3009
+ function refusalCode(status) {
3010
+ if (status === 503) return "unavailable";
3011
+ return status === 403 || status === 404 || status === 409 ? REFUSAL_CODES[status] : "internal";
3012
+ }
3013
+ /**
3014
+ * The page of an entry on its site, or `null` when the site has no URL or no route for the locale:
3015
+ * the origin and the route of the collection with its slug (the admin's `pageUrl` does the same).
3016
+ */
3017
+ function pageUrlOf(site, entry) {
3018
+ const route = site.collections.get(entry.collection)?.routes[entry.locale];
3019
+ if (site.url === null || route === void 0) return null;
3020
+ return `${site.url.replace(/\/+$/, "")}${route.replace(/:slug(?![A-Za-z0-9_])/, entry.slug)}`;
3021
+ }
2359
3022
  function refused(message, fix, status = 400) {
2360
3023
  return json$1({
2361
3024
  code: REFUSAL_CODES[status],
@@ -2371,26 +3034,87 @@ function parseScopes(json) {
2371
3034
  return [];
2372
3035
  }
2373
3036
  }
2374
- function daysFrom(value, fallback) {
3037
+ /** The days an invitation lasts: `fallback` when left out, a whole number from 1 to 365, or null. */
3038
+ function invitationDays(value, fallback) {
2375
3039
  if (value === void 0) return fallback;
2376
3040
  return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 365 ? value : null;
2377
3041
  }
3042
+ /**
3043
+ * The days a token lasts: none (`null`) when they are left out, so it never expires (T-26: the
3044
+ * person chooses), or a whole number from 1 to 365; `undefined` for anything else.
3045
+ */
3046
+ function daysFrom(value) {
3047
+ if (value === void 0 || value === null) return null;
3048
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 365 ? value : void 0;
3049
+ }
3050
+ /** At most this many grants of a person are read: pages of the library's listing, 100 each. */
3051
+ const GRANT_PAGES = 10;
3052
+ /** Every grant of a person, across the sites of the deployment. */
3053
+ async function grantsOf(helpers, userId) {
3054
+ const grants = [];
3055
+ let cursor;
3056
+ for (let page = 0; page < GRANT_PAGES; page++) {
3057
+ const listed = await helpers.listUserGrants(userId, {
3058
+ limit: 100,
3059
+ ...cursor === void 0 ? {} : { cursor }
3060
+ });
3061
+ grants.push(...listed.items);
3062
+ cursor = listed.cursor;
3063
+ if (cursor === void 0) break;
3064
+ }
3065
+ return grants;
3066
+ }
3067
+ /** The name a client gave itself, or its id when it gave none or cannot be read now. */
3068
+ async function clientName(helpers, clientId) {
3069
+ try {
3070
+ return (await helpers.lookupClient(clientId))?.clientName ?? clientId;
3071
+ } catch {
3072
+ return clientId;
3073
+ }
3074
+ }
3075
+ function siteOf(metadata) {
3076
+ if (typeof metadata !== "object" || metadata === null) return null;
3077
+ const site = Reflect.get(metadata, "site");
3078
+ return typeof site === "string" ? site : null;
3079
+ }
2378
3080
  function installAdminApi(app, options) {
2379
3081
  /**
2380
3082
  * Runs `handle` for a person in the admin with `schema:admin`, or with any scope when `anyone`;
2381
- * anyone else is refused.
3083
+ * anyone else is refused. A route that changes something names its `action`, and gets one line
3084
+ * of the activity log for each answer but a 400 (ADR-037): `ok` with what `handle` noted, `denied`
3085
+ * for a 403, `failed` for a 404 or a 409. A change of the person's own sign-in (`everySite`) is a
3086
+ * line in each site they are a member of, since a person is of the deployment.
2382
3087
  */
2383
- const admin = (handle, { anyone = false } = {}) => (c) => options.protect(c, async (principal) => {
2384
- const ours = (await adminClient(options.oauth(c.env.HOLLWARE_PUBLIC_URL), c.env)).clientId;
2385
- if (principal.kind !== "oauth" || principal.clientId !== ours) return refused("Only the admin may do this.", "Sign in to the admin at /admin/ and do it there.", 403);
2386
- if (!anyone && !principal.scopes.includes("schema:admin")) return insufficientScope(["schema:admin"], options.metadataUrl(c));
2387
- return handle(c, principal);
3088
+ const admin = (action, handle, { anyone = false, everySite = false } = {}) => (c) => options.protect(c, async (principal) => {
3089
+ const note = {};
3090
+ const answer = await (async () => {
3091
+ const ours = (await adminClient(options.oauth(c.env.HOLLWARE_PUBLIC_URL), c.env)).clientId;
3092
+ if (principal.kind !== "oauth" || principal.clientId !== ours) return refused("Only the admin may do this.", "Sign in to the admin at /admin/ and do it there.", 403);
3093
+ if (!anyone && !principal.scopes.includes("schema:admin")) return insufficientScope(["schema:admin"], options.metadataUrl(c));
3094
+ return handle(c, principal, note);
3095
+ })();
3096
+ if (action !== null && answer.status !== 400 && principal.kind !== "read") {
3097
+ const keys = everySite ? (await membershipsOf(connect(c.env.DB), principal.userId)).map((m) => m.site) : [principal.site];
3098
+ const log = (await options.audit(c, principal))(null);
3099
+ const line = {
3100
+ action,
3101
+ outcome: answer.ok ? "ok" : answer.status === 403 ? "denied" : "failed",
3102
+ errorCode: answer.ok ? null : answer.status === 403 && answer.headers.has("www-authenticate") ? "insufficient_scope" : refusalCode(answer.status),
3103
+ target: note.target ?? null,
3104
+ detail: note.detail ?? null
3105
+ };
3106
+ for (const key of new Set(keys)) {
3107
+ const site = options.site(key);
3108
+ if (site !== void 0) await log.write(site, line);
3109
+ }
3110
+ }
3111
+ return answer;
2388
3112
  });
2389
3113
  const body = async (c) => {
2390
3114
  const value = await c.req.json().catch(() => null);
2391
3115
  return typeof value === "object" && value !== null && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {};
2392
3116
  };
2393
- app.get("/v1/admin/me", admin(async (c, person) => {
3117
+ app.get("/v1/admin/me", admin(null, async (c, person) => {
2394
3118
  const db = connect(c.env.DB);
2395
3119
  const [user] = await db.select({
2396
3120
  id: users.id,
@@ -2412,7 +3136,37 @@ function installAdminApi(app, options) {
2412
3136
  trash
2413
3137
  });
2414
3138
  }, { anyone: true }));
2415
- app.get("/v1/admin/tokens", admin(async (c, person) => {
3139
+ app.post("/v1/admin/preview", admin("preview.create", async (c, person, note) => {
3140
+ const entryId = (await body(c))["entryId"];
3141
+ if (typeof entryId !== "string" || !isId("ent", entryId)) return refused("Give the id of an entry.", "Send { \"entryId\": \"ent_…\" }.");
3142
+ note.target = {
3143
+ type: "entry",
3144
+ id: entryId
3145
+ };
3146
+ const secret = c.env.HOLLWARE_SECRET;
3147
+ if (!canSignPreviews(secret)) return json$1({
3148
+ code: "unavailable",
3149
+ message: "This Hollware cannot make preview links: it has no signing secret.",
3150
+ fix: "Set the Worker secret HOLLWARE_SECRET to at least 32 characters (`hollware deploy` makes one), then try again."
3151
+ }, 503);
3152
+ const site = options.site(person.site);
3153
+ const found = await createD1Store(c.env.DB).findEntryById(person.site, entryId, "head");
3154
+ if (site === void 0 || found === null) return refused("This site has no such entry.", "Open the entry from its collection in the admin.", 404);
3155
+ const made = await makePreviewToken({
3156
+ secret,
3157
+ site: person.site,
3158
+ entryId,
3159
+ now: options.clock.now()
3160
+ });
3161
+ const page = pageUrlOf(site, found.entry);
3162
+ note.detail = { expiresAt: made.expiresAt };
3163
+ return json$1({
3164
+ token: made.token,
3165
+ expiresAt: made.expiresAt,
3166
+ url: page === null ? null : `${page}?${PREVIEW_QUERY_PARAM}=${made.token}`
3167
+ });
3168
+ }, { anyone: true }));
3169
+ app.get("/v1/admin/tokens", admin(null, async (c, person) => {
2416
3170
  return json$1({ tokens: (await connect(c.env.DB).select({
2417
3171
  id: tokens.id,
2418
3172
  kind: tokens.kind,
@@ -2428,7 +3182,7 @@ function installAdminApi(app, options) {
2428
3182
  scopes: parseScopes(row.scopes)
2429
3183
  })) });
2430
3184
  }));
2431
- app.post("/v1/admin/tokens", admin(async (c, person) => {
3185
+ app.post("/v1/admin/tokens", admin("token.create", async (c, person, note) => {
2432
3186
  const input = await body(c);
2433
3187
  const label = typeof input["label"] === "string" ? input["label"].trim() : "";
2434
3188
  if (label === "" || label.length > 100) return refused("A token needs a label of 1 to 100 characters.", "Say what the token is for.");
@@ -2438,8 +3192,8 @@ function installAdminApi(app, options) {
2438
3192
  const asked = kind === "read" ? ["content:read"] : Array.isArray(input["scopes"]) ? input["scopes"].filter((s) => typeof s === "string") : [];
2439
3193
  const scopes = person.scopes.filter((scope) => asked.includes(scope));
2440
3194
  if (scopes.length === 0) return refused("A token needs at least one scope that you have.", `Choose among ${person.scopes.join(", ")}.`);
2441
- const days = daysFrom(input["expiresInDays"], TOKEN_DAYS);
2442
- if (days === null) return refused("expiresInDays is a whole number from 1 to 365.", "Leave it out for 90 days.");
3195
+ const days = daysFrom(input["expiresInDays"]);
3196
+ if (days === void 0) return refused("expiresInDays is a whole number from 1 to 365.", "Leave it out for a token that never expires.");
2443
3197
  const now = options.clock.now();
2444
3198
  const secret = kind === "read" ? generateReadToken() : generatePersonalToken();
2445
3199
  const row = {
@@ -2450,13 +3204,23 @@ function installAdminApi(app, options) {
2450
3204
  label,
2451
3205
  hash: await hashPersonalToken(secret),
2452
3206
  scopes: JSON.stringify(scopes),
2453
- expiresAt: new Date(now.getTime() + days * 864e5).toISOString(),
3207
+ expiresAt: days === null ? null : new Date(now.getTime() + days * 864e5).toISOString(),
2454
3208
  revokedAt: null,
2455
3209
  lastUsedAt: null,
2456
3210
  createdAt: now.toISOString(),
2457
3211
  updatedAt: now.toISOString()
2458
3212
  };
2459
3213
  await connect(c.env.DB).insert(tokens).values(row);
3214
+ note.target = {
3215
+ type: "token",
3216
+ id: row.id
3217
+ };
3218
+ note.detail = {
3219
+ kind,
3220
+ label,
3221
+ scopes,
3222
+ expiresAt: row.expiresAt
3223
+ };
2460
3224
  return json$1({
2461
3225
  id: row.id,
2462
3226
  kind,
@@ -2466,10 +3230,14 @@ function installAdminApi(app, options) {
2466
3230
  secret
2467
3231
  }, 201);
2468
3232
  }));
2469
- app.post("/v1/admin/tokens/:id/revoke", admin(async (c, person) => {
3233
+ app.post("/v1/admin/tokens/:id/revoke", admin("token.revoke", async (c, person, note) => {
2470
3234
  const now = options.clock.now().toISOString();
2471
3235
  const db = connect(c.env.DB);
2472
3236
  const id = c.req.param("id") ?? "";
3237
+ note.target = {
3238
+ type: "token",
3239
+ id
3240
+ };
2473
3241
  const [revoked] = await db.update(tokens).set({
2474
3242
  revokedAt: now,
2475
3243
  updatedAt: now
@@ -2487,7 +3255,7 @@ function installAdminApi(app, options) {
2487
3255
  revokedAt: row.revokedAt
2488
3256
  });
2489
3257
  }));
2490
- app.get("/v1/admin/people", admin(async (c, person) => {
3258
+ app.get("/v1/admin/people", admin(null, async (c, person) => {
2491
3259
  const db = connect(c.env.DB);
2492
3260
  const now = options.clock.now().toISOString();
2493
3261
  return json$1({
@@ -2513,12 +3281,17 @@ function installAdminApi(app, options) {
2513
3281
  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
3282
  }
2515
3283
  };
2516
- app.post("/v1/admin/people/role", admin(async (c, person) => {
3284
+ app.post("/v1/admin/people/role", admin("member.role", async (c, person, note) => {
2517
3285
  const input = await body(c);
2518
3286
  const role = input["role"];
2519
3287
  const userId = input["userId"];
2520
3288
  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
3289
  const now = options.clock.now().toISOString();
3290
+ note.target = {
3291
+ type: "user",
3292
+ id: userId
3293
+ };
3294
+ note.detail = { role };
2522
3295
  return answerOf(await setRole(c.env.DB, {
2523
3296
  site: person.site,
2524
3297
  userId,
@@ -2526,19 +3299,23 @@ function installAdminApi(app, options) {
2526
3299
  now
2527
3300
  }));
2528
3301
  }));
2529
- app.post("/v1/admin/people/remove", admin(async (c, person) => {
3302
+ app.post("/v1/admin/people/remove", admin("member.remove", async (c, person, note) => {
2530
3303
  const userId = (await body(c))["userId"];
2531
3304
  if (typeof userId !== "string") return refused("Give the userId of a member.", "Send { \"userId\": \"usr_…\" }.");
3305
+ note.target = {
3306
+ type: "user",
3307
+ id: userId
3308
+ };
2532
3309
  return answerOf(await removeMember(c.env.DB, {
2533
3310
  site: person.site,
2534
3311
  userId
2535
3312
  }));
2536
3313
  }));
2537
- app.post("/v1/admin/invitations", admin(async (c, person) => {
3314
+ app.post("/v1/admin/invitations", admin("invitation.create", async (c, person, note) => {
2538
3315
  const input = await body(c);
2539
3316
  const role = input["role"];
2540
3317
  if (typeof role !== "string" || !isRole(role) || role === "owner") return refused("An invitation is for an editor or an author.", `Use role "editor" (${ROLE_SCOPES.editor.join(", ")}) or "author" (${ROLE_SCOPES.author.join(", ")}).`);
2541
- const days = daysFrom(input["days"], INVITATION_DAYS);
3318
+ const days = invitationDays(input["days"], INVITATION_DAYS);
2542
3319
  if (days === null) return refused("days is a whole number from 1 to 365.", "Leave it out for 7 days.");
2543
3320
  const made = await createInvitation(connect(c.env.DB), {
2544
3321
  site: person.site,
@@ -2547,16 +3324,267 @@ function installAdminApi(app, options) {
2547
3324
  now: options.clock.now(),
2548
3325
  days
2549
3326
  });
2550
- const origin = options.oauth(c.env.HOLLWARE_PUBLIC_URL).issuer;
3327
+ note.target = {
3328
+ type: "invitation",
3329
+ id: made.id
3330
+ };
3331
+ note.detail = {
3332
+ role,
3333
+ days
3334
+ };
3335
+ const origin = options.oauth(c.env.HOLLWARE_PUBLIC_URL).issuer;
3336
+ return json$1({
3337
+ id: made.id,
3338
+ role,
3339
+ expiresAt: made.expiresAt,
3340
+ link: `${origin}/setup#invitation=${made.secret}`
3341
+ }, 201);
3342
+ }));
3343
+ const filtersOf = (c) => {
3344
+ const filters = {};
3345
+ for (const name of [
3346
+ "actor",
3347
+ "action",
3348
+ "targetType",
3349
+ "targetId",
3350
+ "from",
3351
+ "to"
3352
+ ]) {
3353
+ const value = c.req.query(name);
3354
+ if (value !== void 0 && value !== "") filters[name] = value;
3355
+ }
3356
+ return filters;
3357
+ };
3358
+ /** An answer of the log, or the refusal of a filter or a cursor that is not valid. */
3359
+ const reading = async (read) => {
3360
+ try {
3361
+ return await read();
3362
+ } catch (error) {
3363
+ if (error instanceof ActivityInputError) return refused(error.message, error.fix);
3364
+ throw error;
3365
+ }
3366
+ };
3367
+ app.get("/v1/admin/activity", admin(null, async (c, person) => reading(async () => {
3368
+ const cursor = c.req.query("cursor");
3369
+ const limit = Number(c.req.query("limit") ?? ACTIVITY_PAGE);
3370
+ return json$1(await readActivity(c.env.DB, person.site, filtersOf(c), {
3371
+ ...cursor === void 0 ? {} : { cursor },
3372
+ limit: Number.isFinite(limit) ? limit : ACTIVITY_PAGE
3373
+ }));
3374
+ })));
3375
+ app.get("/v1/admin/activity.csv", admin(null, async (c, person) => reading(async () => {
3376
+ const rows = [ACTIVITY_CSV_COLUMNS.join(",")];
3377
+ let cursor;
3378
+ do {
3379
+ const page = await readActivity(c.env.DB, person.site, filtersOf(c), {
3380
+ ...cursor === void 0 ? {} : { cursor },
3381
+ limit: 500
3382
+ });
3383
+ rows.push(...page.lines.map(activityCsvRow));
3384
+ cursor = page.nextCursor ?? void 0;
3385
+ } while (cursor !== void 0 && rows.length <= ACTIVITY_CSV_MAX);
3386
+ return new Response(`${rows.join("\r\n")}\r\n`, { headers: {
3387
+ "content-type": "text/csv; charset=utf-8",
3388
+ "content-disposition": `attachment; filename="activity-${person.site}.csv"`,
3389
+ "cache-control": "no-store"
3390
+ } });
3391
+ })));
3392
+ /** The OAuth helpers and the admin's own client, for the public URL of the request. */
3393
+ const grantsApi = async (c) => {
3394
+ const oauth = options.oauth(c.env.HOLLWARE_PUBLIC_URL);
3395
+ return {
3396
+ helpers: oauth.authorizationServer.getOAuthApi(c.env),
3397
+ admin: (await adminClient(oauth, c.env)).clientId
3398
+ };
3399
+ };
3400
+ app.get("/v1/admin/account", admin(null, async (c, person) => {
3401
+ const passkeys = await passkeySummaries(connect(c.env.DB), person.userId);
3402
+ const { helpers, admin: ours } = await grantsApi(c);
3403
+ const grants = await grantsOf(helpers, person.userId);
3404
+ const names = /* @__PURE__ */ new Map();
3405
+ const nameOf = (clientId) => {
3406
+ const known = names.get(clientId);
3407
+ if (known !== void 0) return known;
3408
+ const asked = clientName(helpers, clientId);
3409
+ names.set(clientId, asked);
3410
+ return asked;
3411
+ };
3412
+ const apps = await Promise.all(grants.map(async (grant) => ({
3413
+ grantId: grant.id,
3414
+ clientId: grant.clientId,
3415
+ name: await nameOf(grant.clientId),
3416
+ scopes: grant.scope.filter(isScope),
3417
+ site: siteOf(grant.metadata),
3418
+ createdAt: (/* @__PURE__ */ new Date(grant.createdAt * 1e3)).toISOString(),
3419
+ admin: grant.clientId === ours
3420
+ })));
3421
+ apps.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || a.grantId.localeCompare(b.grantId));
3422
+ return json$1({
3423
+ passkeys,
3424
+ apps
3425
+ });
3426
+ }, { anyone: true }));
3427
+ app.post("/v1/admin/account/passkeys/remove", admin("passkey.remove", async (c, person, note) => {
3428
+ const id = (await body(c))["id"];
3429
+ if (typeof id !== "string") return refused("Give the id of one of your passkeys.", "Send { \"id\": \"pky_…\" }.");
3430
+ note.target = {
3431
+ type: "passkey",
3432
+ id
3433
+ };
3434
+ switch (await removePasskey(c.env.DB, {
3435
+ userId: person.userId,
3436
+ id
3437
+ })) {
3438
+ case "removed": return json$1({
3439
+ outcome: "removed",
3440
+ id
3441
+ });
3442
+ case "not_found": return refused("You have no such passkey.", "List your passkeys and pick one of them.", 404);
3443
+ case "last": return refused("This is your last passkey: without it you could not sign in again.", "Add another passkey first, then remove this one.", 409);
3444
+ }
3445
+ }, {
3446
+ anyone: true,
3447
+ everySite: true
3448
+ }));
3449
+ app.post("/v1/admin/account/apps/revoke", admin("app.revoke", async (c, person, note) => {
3450
+ const grantId = (await body(c))["grantId"];
3451
+ if (typeof grantId !== "string") return refused("Give the grantId of one of your apps.", "Send { \"grantId\": \"…\" }.");
3452
+ note.target = {
3453
+ type: "grant",
3454
+ id: grantId
3455
+ };
3456
+ const { helpers, admin: ours } = await grantsApi(c);
3457
+ const grant = (await grantsOf(helpers, person.userId)).find((g) => g.id === grantId);
3458
+ if (grant === void 0) return refused("You connected no such app.", "List your apps and pick one of them.", 404);
3459
+ if (grant.clientId === ours) return refused("That is this admin: it is not revoked here.", "Sign out from the person menu, or sign out everywhere.", 409);
3460
+ await helpers.revokeGrant(grant.id, person.userId);
3461
+ note.detail = {
3462
+ clientId: grant.clientId,
3463
+ site: siteOf(grant.metadata)
3464
+ };
2551
3465
  return json$1({
2552
- id: made.id,
2553
- role,
2554
- expiresAt: made.expiresAt,
2555
- link: `${origin}/setup?invitation=${made.secret}`
2556
- }, 201);
3466
+ outcome: "revoked",
3467
+ grantId: grant.id
3468
+ });
3469
+ }, {
3470
+ anyone: true,
3471
+ everySite: true
3472
+ }));
3473
+ app.post("/v1/admin/account/sessions/end", admin("session.end_all", async (c, person, note) => {
3474
+ const ended = await endSessions(connect(c.env.DB), person.userId);
3475
+ const { helpers, admin: ours } = await grantsApi(c);
3476
+ const admins = (await grantsOf(helpers, person.userId)).filter((g) => g.clientId === ours);
3477
+ for (const grant of admins) await helpers.revokeGrant(grant.id, person.userId);
3478
+ note.target = {
3479
+ type: "user",
3480
+ id: person.userId
3481
+ };
3482
+ note.detail = {
3483
+ sessions: ended,
3484
+ adminGrants: admins.length
3485
+ };
3486
+ return json$1({
3487
+ outcome: "ended",
3488
+ sessions: ended,
3489
+ adminGrants: admins.length
3490
+ });
3491
+ }, {
3492
+ anyone: true,
3493
+ everySite: true
2557
3494
  }));
2558
3495
  }
2559
3496
  //#endregion
3497
+ //#region src/audit/operations.ts
3498
+ /**
3499
+ * The line of a call of an operation (ADR-037): every operation that is not a read gets one,
3500
+ * whether it changed something, was refused or failed. Of the input, only the fields that name
3501
+ * what was acted on are kept, each shortened; of the output, its outcome and the id of the entry,
3502
+ * review or comment it answers with. Never the Markdown, the frontmatter, or a secret that
3503
+ * `make_webhook_secret` shows once.
3504
+ */
3505
+ /** The fields of an input that name what an operation acts on. */
3506
+ const NAMING_FIELDS = [
3507
+ "site",
3508
+ "collection",
3509
+ "locale",
3510
+ "slug",
3511
+ "id",
3512
+ "entryId",
3513
+ "reviewId",
3514
+ "name"
3515
+ ];
3516
+ const FIELD_MAX = 200;
3517
+ function isAudited(name) {
3518
+ return OPERATION_AUDIT_ACTIONS.includes(name);
3519
+ }
3520
+ function record(value) {
3521
+ return typeof value === "object" && value !== null ? Object.fromEntries(Object.entries(value)) : {};
3522
+ }
3523
+ function idOf(value) {
3524
+ const id = record(value)["id"];
3525
+ return typeof id === "string" ? id : null;
3526
+ }
3527
+ /** What the call acted on: the entry, review or comment of its answer, else its site. */
3528
+ function targetOf(output, site) {
3529
+ const entry = idOf(output["entry"]);
3530
+ if (entry !== null) return {
3531
+ type: "entry",
3532
+ id: entry
3533
+ };
3534
+ const review = idOf(output["review"]);
3535
+ if (review !== null) return {
3536
+ type: "review",
3537
+ id: review
3538
+ };
3539
+ const comment = idOf(output["comment"]);
3540
+ if (comment !== null) return {
3541
+ type: "comment",
3542
+ id: comment
3543
+ };
3544
+ return {
3545
+ type: "site",
3546
+ id: site
3547
+ };
3548
+ }
3549
+ /** `denied` when a scope was missing; `failed` for any other error the caller got. */
3550
+ function outcomeOfError(code) {
3551
+ return code === "insufficient_scope" ? "denied" : "failed";
3552
+ }
3553
+ /**
3554
+ * The line of a call, or `null` for a read. `result` is the output the caller got, or the code of
3555
+ * the error it got instead.
3556
+ */
3557
+ function operationLine(spec, input, site, result) {
3558
+ if (!isAudited(spec.name)) return null;
3559
+ const named = {};
3560
+ const given = record(input);
3561
+ for (const field of NAMING_FIELDS) {
3562
+ const value = given[field];
3563
+ if (typeof value === "string") named[field] = value.slice(0, FIELD_MAX);
3564
+ }
3565
+ if ("errorCode" in result) return {
3566
+ action: spec.name,
3567
+ outcome: outcomeOfError(result.errorCode),
3568
+ errorCode: result.errorCode,
3569
+ target: {
3570
+ type: "site",
3571
+ id: site
3572
+ },
3573
+ detail: Object.keys(named).length === 0 ? null : { input: named }
3574
+ };
3575
+ const output = record(result.output);
3576
+ const answer = output["outcome"];
3577
+ return {
3578
+ action: spec.name,
3579
+ outcome: "ok",
3580
+ target: targetOf(output, site),
3581
+ detail: {
3582
+ ...typeof answer === "string" ? { answer } : {},
3583
+ ...Object.keys(named).length === 0 ? {} : { input: named }
3584
+ }
3585
+ };
3586
+ }
3587
+ //#endregion
2560
3588
  //#region src/semantic/cloudflare.ts
2561
3589
  /** The embedding model when the config names none: multilingual, so Spanish finds English. */
2562
3590
  const DEFAULT_EMBEDDING_MODEL = "@cf/baai/bge-m3";
@@ -2760,6 +3788,8 @@ function invoke(handlers, spec, input, context) {
2760
3788
  }
2761
3789
  //#endregion
2762
3790
  //#region src/http/ops-api.ts
3791
+ /** The client of every call of this API: the admin. */
3792
+ const CLIENT = { name: "hollware-admin" };
2763
3793
  const STATUS$1 = {
2764
3794
  invalid_input: 400,
2765
3795
  unknown_collection: 404,
@@ -2782,8 +3812,15 @@ function installOpsApi(app, options) {
2782
3812
  message: `There is no operation ${c.req.param("name")}.`,
2783
3813
  fix: "Use the name of an operation, as tools/list of MCP lists them."
2784
3814
  }, 404);
3815
+ const site = options.sites.get(principal.site);
3816
+ if (site === void 0) throw new Error(`No site ${principal.site} for a checked token`);
3817
+ const audit = options.audit === void 0 ? null : (await options.audit(c, principal))(CLIENT);
2785
3818
  const scopes = spec.scopes;
2786
- if (missingScopes(principal, scopes).length > 0) return insufficientScope(scopes, options.metadataUrl(c));
3819
+ if (missingScopes(principal, scopes).length > 0) {
3820
+ const line = operationLine(spec, null, site.key, { errorCode: "insufficient_scope" });
3821
+ if (line !== null) await audit?.write(site, line);
3822
+ return insufficientScope(scopes, options.metadataUrl(c));
3823
+ }
2787
3824
  const body = await c.req.json().catch(() => void 0);
2788
3825
  const parsed = spec.input.safeParse(body ?? {});
2789
3826
  if (!parsed.success) {
@@ -2794,24 +3831,30 @@ function installOpsApi(app, options) {
2794
3831
  fix: "Send the input of the operation as its input schema describes it."
2795
3832
  }, 400);
2796
3833
  }
2797
- const site = options.sites.get(principal.site);
2798
- if (site === void 0) throw new Error(`No site ${principal.site} for a checked token`);
2799
3834
  const context = {
2800
3835
  site,
2801
3836
  principal,
2802
3837
  view: "head",
2803
3838
  store: createD1Store(c.env.DB, { clock: options.clock }),
2804
3839
  clock: options.clock,
2805
- client: { name: "hollware-admin" },
3840
+ client: CLIENT,
2806
3841
  requestId,
2807
3842
  notify: options.notify(c, requestId, site.key),
2808
3843
  semantic: semanticOfEnv(c.env),
2809
3844
  analytics: analyticsReaderOf(c.env),
2810
3845
  ...options.structure === void 0 ? {} : { structure: options.structure(c.env.DB, c.env.HOLLWARE_SECRET) }
2811
3846
  };
3847
+ /** The line of the call, after it answered or failed. */
3848
+ const audited = async (result) => {
3849
+ const line = operationLine(spec, parsed.data, site.key, result);
3850
+ if (line !== null) await audit?.write(site, line);
3851
+ };
2812
3852
  try {
2813
- return json(spec.output.parse(await invoke(options.handlers, spec, parsed.data, context)));
3853
+ const output = spec.output.parse(await invoke(options.handlers, spec, parsed.data, context));
3854
+ await audited({ output });
3855
+ return json(output);
2814
3856
  } catch (error) {
3857
+ await audited({ errorCode: error instanceof HollwareError ? error.code : "internal" });
2815
3858
  if (error instanceof HollwareError) return json({
2816
3859
  code: error.code,
2817
3860
  message: error.message,
@@ -2844,6 +3887,7 @@ function crossOriginOf(c) {
2844
3887
  }
2845
3888
  const STATUS = {
2846
3889
  invalid_input: 400,
3890
+ invalid_preview: 400,
2847
3891
  unknown_collection: 404,
2848
3892
  unknown_locale: 404
2849
3893
  };
@@ -2868,7 +3912,7 @@ function readRoute(handler) {
2868
3912
  code: error.code,
2869
3913
  message: error.message,
2870
3914
  fix: error.fix
2871
- }, STATUS[error.code] ?? 500);
3915
+ }, STATUS[error.code] ?? 500, c.req.query("preview") === void 0 ? {} : { "cache-control": "no-store" });
2872
3916
  }
2873
3917
  };
2874
3918
  }
@@ -2882,9 +3926,16 @@ function matches(header, etag) {
2882
3926
  if (header === void 0) return false;
2883
3927
  return header.split(",").map((tag) => tag.trim()).some((tag) => tag === "*" || tag === etag || `W/${tag}` === etag);
2884
3928
  }
2885
- async function answer(c, value) {
3929
+ async function answer(c, value, { preview = false } = {}) {
2886
3930
  const notFound = typeof value === "object" && value !== null && "outcome" in value && value.outcome === "not_found";
2887
3931
  const body = JSON.stringify(value);
3932
+ if (preview) return new Response(body, {
3933
+ status: notFound ? 404 : 200,
3934
+ headers: {
3935
+ "cache-control": "no-store",
3936
+ "content-type": "application/json"
3937
+ }
3938
+ });
2888
3939
  const etag = await etagOf(body);
2889
3940
  const headers = {
2890
3941
  etag,
@@ -2973,11 +4024,15 @@ function installReadApi(app, options) {
2973
4024
  app.get("/v1/:collection/translations/:group", readRoute(async (c) => answer(c, await options.read(c.env).getTranslations(c.get("site"), c.req.param("collection"), c.req.param("group")))));
2974
4025
  app.get("/v1/:collection/:locale/:slug", readRoute(async (c) => {
2975
4026
  const formats = c.req.query("formats");
2976
- const related = c.req.query("related") === "true" ? { related: true } : {};
2977
- return answer(c, await options.read(c.env).getEntry(c.get("site"), c.req.param("collection"), c.req.param("slug"), c.req.param("locale"), formats === void 0 ? related : {
2978
- ...related,
4027
+ const preview = c.req.query("preview");
4028
+ const base = {
4029
+ ...c.req.query("related") === "true" ? { related: true } : {},
4030
+ ...preview === void 0 ? {} : { preview }
4031
+ };
4032
+ return answer(c, await options.read(c.env).getEntry(c.get("site"), c.req.param("collection"), c.req.param("slug"), c.req.param("locale"), formats === void 0 ? base : {
4033
+ ...base,
2979
4034
  formats: formats.split(",").flatMap((format) => format === "markdown" || format === "document" || format === "html" ? [format] : [])
2980
- }));
4035
+ }), { preview: preview !== void 0 });
2981
4036
  }));
2982
4037
  }
2983
4038
  const SEARCH_MODES = [
@@ -3031,235 +4086,71 @@ function openApiDocument() {
3031
4086
  "/v1/search": { get: {
3032
4087
  operationId: "search",
3033
4088
  parameters: [
3034
- param("q", "query", true),
3035
- param("collection", "query", false),
3036
- param("locale", "query", false),
3037
- param("limit", "query", false),
3038
- param("mode", "query", false)
3039
- ],
3040
- responses: {
3041
- "200": ok("The published entries that match q, best first"),
3042
- ...common
3043
- }
3044
- } },
3045
- "/v1/{collection}": { get: {
3046
- operationId: "list_entries",
3047
- parameters: [
3048
- param("collection", "path", true),
3049
- param("locale", "query", false),
3050
- param("tag", "query", false),
3051
- param("limit", "query", false),
3052
- param("cursor", "query", false)
3053
- ],
3054
- responses: {
3055
- "200": ok("A page of published entries"),
3056
- ...common
3057
- }
3058
- } },
3059
- "/v1/{collection}/translations/{group}": { get: {
3060
- operationId: "get_translations",
3061
- parameters: [param("collection", "path", true), param("group", "path", true)],
3062
- responses: {
3063
- "200": ok("The published entries of the group"),
3064
- "404": { description: "No such group" },
3065
- ...common
3066
- }
3067
- } },
3068
- "/v1/{collection}/{locale}/{slug}": { get: {
3069
- operationId: "get_entry",
3070
- parameters: [
3071
- param("collection", "path", true),
3072
- param("locale", "path", true),
3073
- param("slug", "path", true),
3074
- param("formats", "query", false),
3075
- param("related", "query", false)
3076
- ],
3077
- responses: {
3078
- "200": ok("A published entry"),
3079
- "404": { description: "No such published entry" },
3080
- ...common
3081
- }
3082
- } }
3083
- }
3084
- };
3085
- }
3086
- //#endregion
3087
- //#region src/identity/pages.ts
3088
- /**
3089
- * The pages a person sees to get in (ADR-026): set up a passkey from a link, and sign in. They
3090
- * are rendered by the Worker, outside the admin's bundle, with one inline script under a nonce
3091
- * and nothing framing them. The script uses the WebAuthn JSON methods of the browser
3092
- * (`PublicKeyCredential.parseCreationOptionsFromJSON`, `credential.toJSON()`), and reads the
3093
- * invitation from the address bar, so the page never prints it.
3094
- */
3095
- /**
3096
- * The look of hollware.com and the admin, with nothing to fetch: the page's policy loads no font,
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).
3099
- */
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}
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}
3102
- main{width:100%;max-width:26rem;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:2rem}
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}
3104
- h1{font-size:1.4rem;line-height:1.25;letter-spacing:-.02em;margin:0 0 .5rem}p{color:var(--muted);margin:.5rem 0}strong{color:var(--fg)}
3105
- label{display:block;margin:1rem 0 .35rem;font-size:.9rem;font-weight:500}fieldset label,form>label:has(input[type=checkbox]){font-weight:400;display:flex;align-items:center;gap:.5rem;margin:.5rem 0}
3106
- input{width:100%;padding:.6rem .75rem;font:inherit;color:inherit;background:transparent;border:1px solid var(--line);border-radius:8px}input:focus{outline:2px solid var(--accent);outline-offset:1px}
3107
- label input{width:auto;margin:0}fieldset{border:1px solid var(--line);border-radius:8px;margin:1rem 0;padding:.5rem .9rem}legend{color:var(--muted);font-size:.85rem;padding:0 .3rem}
3108
- code{font:.82em ui-monospace,'SF Mono',Menlo,monospace;color:var(--muted)}
3109
- button{margin-top:1.25rem;padding:.6rem 1rem;font:inherit;font-weight:500;cursor:pointer;border-radius:8px;border:1px solid var(--line);background:transparent;color:var(--fg)}
3110
- button:not([value=deny]){background:var(--accent);border-color:var(--accent);color:var(--on-accent)}
3111
- [role=alert]{color:var(--danger)}`;
3112
- /** The mark of Hollware, before the title of every page. */
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>`;
3114
- const POST = `async function post(path, body) {
3115
- const response = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
3116
- const data = await response.json()
3117
- if (!response.ok) throw new Error(data.message + ' ' + (data.fix ?? ''))
3118
- return data
3119
- }
3120
- function fail(error) { document.getElementById('error').textContent = error.message }`;
3121
- function page(title, nonce, body, script) {
3122
- return `<!doctype html>
3123
- <html lang="en">
3124
- <head>
3125
- <meta charset="utf-8">
3126
- <meta name="viewport" content="width=device-width, initial-scale=1">
3127
- <title>${title} · Hollware</title>
3128
- <style>${STYLE}</style>
3129
- </head>
3130
- <body>
3131
- <main>
3132
- ${BRAND}
3133
- <h1>${title}</h1>
3134
- ${body}
3135
- <p id="error" role="alert"></p>
3136
- </main>
3137
- <script nonce="${nonce}">${POST}
3138
- ${script}<\/script>
3139
- </body>
3140
- </html>
3141
- `;
3142
- }
3143
- function setupPage(nonce) {
3144
- return page("Set up your passkey", nonce, `<p>A passkey lets you into this Hollware with your device's lock: no password.</p>
3145
- <form id="form">
3146
- <label for="name">Your name</label><input id="name" name="name" autocomplete="name">
3147
- <label for="label">This device</label><input id="label" name="label" value="Passkey">
3148
- <button type="submit">Create the passkey</button>
3149
- </form>`, `document.getElementById('form').addEventListener('submit', async (event) => {
3150
- event.preventDefault()
3151
- try {
3152
- const invitation = new URLSearchParams(location.search).get('invitation') ?? ''
3153
- const { challengeId, options } = await post('/auth/register/options', { invitation })
3154
- const credential = await navigator.credentials.create({ publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options) })
3155
- await post('/auth/register/verify', { challengeId, response: credential.toJSON(), name: document.getElementById('name').value, label: document.getElementById('label').value })
3156
- location.href = '/admin/'
3157
- } catch (error) { fail(error) }
3158
- })`);
3159
- }
3160
- function signInPage(nonce) {
3161
- return page("Sign in", nonce, `<p>Sign in to this Hollware with your passkey.</p>
3162
- <button id="signin" type="button">Sign in with a passkey</button>`, `document.getElementById('signin').addEventListener('click', async () => {
3163
- try {
3164
- const { challengeId, options } = await post('/auth/signin/options', {})
3165
- const credential = await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options) })
3166
- await post('/auth/signin/verify', { challengeId, response: credential.toJSON() })
3167
- const target = new URLSearchParams(location.search).get('return_to') ?? '/admin/'
3168
- location.href = target.startsWith('/') && !target.startsWith('//') ? target : '/admin/'
3169
- } catch (error) { fail(error) }
3170
- })`);
3171
- }
3172
- /** The headers of a page: no framing, only its own script, and no caching. */
3173
- function pageHeaders(nonce) {
3174
- return {
3175
- "content-type": "text/html; charset=utf-8",
3176
- "cache-control": "no-store",
3177
- "content-security-policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; connect-src 'self'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'`,
3178
- "x-content-type-options": "nosniff",
3179
- "referrer-policy": "no-referrer"
3180
- };
3181
- }
3182
- /** Text for HTML: every string a client chose (its name, its redirect host, a scope) goes through it. */
3183
- function escapeHtml(value) {
3184
- return value.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
3185
- }
3186
- /** What a scope lets a client do, in the words of the consent page. */
3187
- const SCOPE_WORDS = {
3188
- "content:read": "Read the content, drafts included",
3189
- "content:write": "Write drafts",
3190
- "content:publish": "Publish and unpublish",
3191
- "schema:admin": "Change the site settings"
3192
- };
3193
- /**
3194
- * The consent page of `/authorize` (ADR-026): which client asks, where the tokens go, and what it
3195
- * may do, with Allow and Deny. It has no script, and it posts the handle that `beginConsent` bound
3196
- * to this browser. Everything the client chose is escaped.
3197
- */
3198
- function consentPage(view) {
3199
- const name = escapeHtml(view.clientName);
3200
- const origin = view.clientDomain === void 0 ? "<p>This app registered itself: its name is not verified.</p>" : `<p>Published by <strong>${escapeHtml(view.clientDomain)}</strong>.</p>`;
3201
- const loopback = view.redirectIsLoopback ? "<p><strong>This sends access to an app on this computer.</strong> Continue only if you just started to connect it.</p>" : "";
3202
- const sites = view.sites.length === 1 ? `<input type="hidden" name="site" value="${escapeHtml(view.sites[0] ?? "")}">` : `<fieldset><legend>For the site</legend>${view.sites.map((site, index) => `<label><input type="radio" name="site" value="${escapeHtml(site)}"${index === 0 ? " checked" : ""}> ${escapeHtml(site)}</label>`).join("\n")}</fieldset>`;
3203
- const scopes = view.scopes.map((scope) => `<label><input type="checkbox" name="scope" value="${escapeHtml(scope)}" checked> ${escapeHtml(SCOPE_WORDS[scope] ?? scope)} <code>${escapeHtml(scope)}</code></label>`).join("\n");
3204
- return `<!doctype html>
3205
- <html lang="en">
3206
- <head>
3207
- <meta charset="utf-8">
3208
- <meta name="viewport" content="width=device-width, initial-scale=1">
3209
- <title>Allow ${name} · Hollware</title>
3210
- <style>${STYLE}</style>
3211
- </head>
3212
- <body>
3213
- <main>
3214
- ${BRAND}
3215
- <h1>Allow ${name} to use this Hollware?</h1>
3216
- ${origin}
3217
- <p>Signed in as <strong>${escapeHtml(view.person)}</strong>. Access goes to <strong>${escapeHtml(view.redirectHost)}</strong>.</p>
3218
- ${loopback}
3219
- <form method="post" action="/authorize">
3220
- <input type="hidden" name="handle" value="${escapeHtml(view.handle)}">
3221
- ${sites}
3222
- ${scopes}
3223
- <p><button name="decision" value="approve">Allow</button> <button name="decision" value="deny">Deny</button></p>
3224
- </form>
3225
- </main>
3226
- </body>
3227
- </html>
3228
- `;
3229
- }
3230
- /** A page that only says what went wrong and what to do. */
3231
- function messagePage(title, message) {
3232
- return `<!doctype html>
3233
- <html lang="en">
3234
- <head>
3235
- <meta charset="utf-8">
3236
- <meta name="viewport" content="width=device-width, initial-scale=1">
3237
- <title>${escapeHtml(title)} · Hollware</title>
3238
- <style>${STYLE}</style>
3239
- </head>
3240
- <body>
3241
- <main>
3242
- ${BRAND}
3243
- <h1>${escapeHtml(title)}</h1>
3244
- <p>${escapeHtml(message)}</p>
3245
- </main>
3246
- </body>
3247
- </html>
3248
- `;
4089
+ param("q", "query", true),
4090
+ param("collection", "query", false),
4091
+ param("locale", "query", false),
4092
+ param("limit", "query", false),
4093
+ param("mode", "query", false)
4094
+ ],
4095
+ responses: {
4096
+ "200": ok("The published entries that match q, best first"),
4097
+ ...common
4098
+ }
4099
+ } },
4100
+ "/v1/{collection}": { get: {
4101
+ operationId: "list_entries",
4102
+ parameters: [
4103
+ param("collection", "path", true),
4104
+ param("locale", "query", false),
4105
+ param("tag", "query", false),
4106
+ param("limit", "query", false),
4107
+ param("cursor", "query", false)
4108
+ ],
4109
+ responses: {
4110
+ "200": ok("A page of published entries"),
4111
+ ...common
4112
+ }
4113
+ } },
4114
+ "/v1/{collection}/translations/{group}": { get: {
4115
+ operationId: "get_translations",
4116
+ parameters: [param("collection", "path", true), param("group", "path", true)],
4117
+ responses: {
4118
+ "200": ok("The published entries of the group"),
4119
+ "404": { description: "No such group" },
4120
+ ...common
4121
+ }
4122
+ } },
4123
+ "/v1/{collection}/{locale}/{slug}": { get: {
4124
+ operationId: "get_entry",
4125
+ parameters: [
4126
+ param("collection", "path", true),
4127
+ param("locale", "path", true),
4128
+ param("slug", "path", true),
4129
+ param("formats", "query", false),
4130
+ param("related", "query", false),
4131
+ param("preview", "query", false)
4132
+ ],
4133
+ responses: {
4134
+ "200": ok("A published entry; with a valid preview token, its latest revision, uncacheable and with no ETag"),
4135
+ "400": { description: "The preview token is expired, forged or for another entry" },
4136
+ "404": { description: "No such published entry" },
4137
+ ...common
4138
+ }
4139
+ } }
4140
+ }
4141
+ };
3249
4142
  }
4143
+ //#endregion
4144
+ //#region src/audit/person.ts
3250
4145
  /**
3251
- * The headers of a page without a script: no framing and no caching. The form of the consent page
3252
- * posts to `/authorize`, which redirects to the client, so `form-action` is not restricted: a
3253
- * browser applies it to the redirect too.
4146
+ * A line about a person's own sign-in, in each site they are a member of (ADR-037): a person is of
4147
+ * the deployment, and each owner sees what touched the people of their site.
3254
4148
  */
3255
- function plainPageHeaders() {
3256
- return {
3257
- "content-type": "text/html; charset=utf-8",
3258
- "cache-control": "no-store",
3259
- "content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'`,
3260
- "x-content-type-options": "nosniff",
3261
- "referrer-policy": "no-referrer"
3262
- };
4149
+ async function writeForPerson(db, log, siteOf, userId, line) {
4150
+ for (const membership of await membershipsOf(connect(db), userId)) {
4151
+ const site = await siteOf(membership.site);
4152
+ if (site !== void 0) await log.write(site, line);
4153
+ }
3263
4154
  }
3264
4155
  //#endregion
3265
4156
  //#region src/identity/routes.ts
@@ -3267,17 +4158,25 @@ function plainPageHeaders() {
3267
4158
  * The ceremonies, with SimpleWebAuthn and its parsers, in a chunk of their own: only these four
3268
4159
  * routes use them, and a Worker that serves MCP never pays for them at startup (`budgets.json`).
3269
4160
  */
3270
- const ceremonies = () => import("./ceremonies-D5dIEbha.mjs");
3271
- /** The cookie of a signed-in person: only `/authorize` reads it (ADR-026). */
4161
+ const ceremonies = () => import("./ceremonies-CI3DkOEL.mjs");
4162
+ /**
4163
+ * The cookie of a signed-in person: only `/authorize` reads it (ADR-026), and the sign-out below
4164
+ * it, `/authorize/signout`, which the path of the cookie reaches too.
4165
+ */
3272
4166
  const SESSION_COOKIE = "hollware_session";
4167
+ /** Where sign-out is: below `/authorize`, the one path a browser sends the cookie to. */
4168
+ const SIGN_OUT_PATH = "/authorize/signout";
3273
4169
  function sessionCookie(secret) {
3274
4170
  return `${SESSION_COOKIE}=${secret}; HttpOnly; Secure; SameSite=Lax; Path=/authorize; Max-Age=43200`;
3275
4171
  }
4172
+ /** The cookie as sign-out leaves it: empty, and gone at once. */
4173
+ const ENDED_COOKIE = `${SESSION_COOKIE}=; HttpOnly; Secure; SameSite=Lax; Path=/authorize; Max-Age=0`;
3276
4174
  function isRecord$1(value) {
3277
4175
  return typeof value === "object" && value !== null && !Array.isArray(value);
3278
4176
  }
3279
- /** The setup and sign-in pages, and the four steps of the passkey ceremonies. */
3280
4177
  function installIdentity(app, options) {
4178
+ /** A line of the person's own sign-in, in each of their sites. */
4179
+ const audited = (c, userId, line) => writeForPerson(c.env.DB, options.audit(c, personActor(userId)), (key) => options.site(c, key), userId, line);
3281
4180
  const ceremony = (c) => ({
3282
4181
  db: connect(c.env.DB),
3283
4182
  publicUrl: c.env.HOLLWARE_PUBLIC_URL,
@@ -3318,6 +4217,14 @@ function installIdentity(app, options) {
3318
4217
  name: text(input["name"]),
3319
4218
  label: text(input["label"])
3320
4219
  });
4220
+ await audited(c, answer.userId, {
4221
+ action: "invitation.accept",
4222
+ outcome: "ok",
4223
+ target: {
4224
+ type: "user",
4225
+ id: answer.userId
4226
+ }
4227
+ });
3321
4228
  return c.json({ ok: true }, 200, { "set-cookie": sessionCookie(answer.session) });
3322
4229
  }));
3323
4230
  app.post("/auth/signin/options", handle(async (c) => c.json(await (await ceremonies()).authenticationOptions(ceremony(c)))));
@@ -3329,8 +4236,32 @@ function installIdentity(app, options) {
3329
4236
  challengeId: text(input["challengeId"]),
3330
4237
  response: JSON.parse(JSON.stringify(response))
3331
4238
  });
4239
+ await audited(c, answer.userId, {
4240
+ action: "session.sign_in",
4241
+ outcome: "ok",
4242
+ target: {
4243
+ type: "user",
4244
+ id: answer.userId
4245
+ }
4246
+ });
3332
4247
  return c.json({ ok: true }, 200, { "set-cookie": sessionCookie(answer.session) });
3333
4248
  }));
4249
+ app.post(SIGN_OUT_PATH, async (c) => {
4250
+ const secret = getCookie(c, SESSION_COOKIE);
4251
+ const userId = secret === void 0 ? null : await endSession(connect(c.env.DB), secret);
4252
+ if (userId !== null) await audited(c, userId, {
4253
+ action: "session.sign_out",
4254
+ outcome: "ok",
4255
+ target: {
4256
+ type: "user",
4257
+ id: userId
4258
+ }
4259
+ });
4260
+ return c.json({ ok: true }, 200, {
4261
+ "set-cookie": ENDED_COOKIE,
4262
+ "cache-control": "no-store"
4263
+ });
4264
+ });
3334
4265
  }
3335
4266
  //#endregion
3336
4267
  //#region src/oauth/authorize.ts
@@ -3501,6 +4432,16 @@ function installAuthorize(app, options) {
3501
4432
  scope,
3502
4433
  props
3503
4434
  });
4435
+ const site = await options.site?.(c, membership.site);
4436
+ if (site !== void 0 && options.audit !== void 0) await options.audit(c, personActor(signedIn.userId)).write(site, {
4437
+ action: "app.allow",
4438
+ outcome: "ok",
4439
+ target: {
4440
+ type: "client",
4441
+ id: approved.request.clientId
4442
+ },
4443
+ detail: { scopes: scope }
4444
+ });
3504
4445
  approved.headers.set("location", redirectTo);
3505
4446
  return new Response(null, {
3506
4447
  status: 302,
@@ -3677,6 +4618,35 @@ function refusal(status, code, message, fix) {
3677
4618
  headers: { "cache-control": "no-store" }
3678
4619
  });
3679
4620
  }
4621
+ /**
4622
+ * The text of a body of at most `limit` bytes, or `null` for a longer one: read no further than
4623
+ * the limit, so a large body costs no more memory than a small one (T-08 of the threat model).
4624
+ */
4625
+ async function boundedText(request, limit) {
4626
+ const declared = Number(request.headers.get("content-length") ?? NaN);
4627
+ if (Number.isFinite(declared) && declared > limit) return null;
4628
+ if (request.body === null) return "";
4629
+ const reader = request.body.getReader();
4630
+ const chunks = [];
4631
+ let size = 0;
4632
+ for (;;) {
4633
+ const { done, value } = await reader.read();
4634
+ if (done) break;
4635
+ size += value.byteLength;
4636
+ if (size > limit) {
4637
+ await reader.cancel();
4638
+ return null;
4639
+ }
4640
+ chunks.push(value);
4641
+ }
4642
+ const bytes = new Uint8Array(size);
4643
+ let offset = 0;
4644
+ for (const chunk of chunks) {
4645
+ bytes.set(chunk, offset);
4646
+ offset += chunk.byteLength;
4647
+ }
4648
+ return new TextDecoder().decode(bytes);
4649
+ }
3680
4650
  /** The path of a view without its query and fragment, or `null` when it is no path. */
3681
4651
  function pathOf(value) {
3682
4652
  if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) return null;
@@ -3692,8 +4662,8 @@ function pathOf(value) {
3692
4662
  */
3693
4663
  function installEvents(app, options) {
3694
4664
  app.post("/events", async (c) => {
3695
- const text = await c.req.text();
3696
- if (new TextEncoder().encode(text).byteLength > 2048) return refusal(413, "too_large", `A beacon is at most ${MAX_BEACON_BYTES} bytes.`, "Send the site and the path of the page, and nothing else.");
4665
+ const text = await boundedText(c.req.raw, MAX_BEACON_BYTES);
4666
+ if (text === null) return refusal(413, "too_large", `A beacon is at most ${MAX_BEACON_BYTES} bytes.`, "Send the site and the path of the page, and nothing else.");
3697
4667
  let body;
3698
4668
  try {
3699
4669
  body = JSON.parse(text);
@@ -3701,11 +4671,11 @@ function installEvents(app, options) {
3701
4671
  body = null;
3702
4672
  }
3703
4673
  const key = typeof body === "object" && body !== null && "site" in body ? body.site : null;
3704
- const site = typeof key === "string" ? options.sites.get(key) : void 0;
3705
4674
  const path = typeof body === "object" && body !== null && "path" in body ? pathOf(body.path) : null;
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>\" }.");
4675
+ if (typeof key !== "string" || path === null) return refusal(400, "invalid_input", "A beacon is JSON with the key of a site and the path of a page.", "Send { \"site\": \"<site key>\", \"path\": \"/<path of the page>\" }.");
4676
+ const site = options.sites.get(key);
3707
4677
  const origin = c.req.header("origin") ?? null;
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.`);
4678
+ if (site === void 0 || origin === null || !pageOriginsOf(site).includes(origin)) return refusal(403, "forbidden_origin", "This page does not count views of that site.", "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.");
3709
4679
  const cf = Reflect.get(c.req.raw, "cf");
3710
4680
  const country = typeof cf === "object" && cf !== null && "country" in cf && typeof cf.country === "string" ? cf.country : "XX";
3711
4681
  analyticsWriterOf(c.env)?.record({
@@ -3807,11 +4777,20 @@ function createGateway(options) {
3807
4777
  secrets: { make: async (site, name) => {
3808
4778
  if (master === void 0) return null;
3809
4779
  const secret = newWebhookSecret();
3810
- await createSecretStore(db, () => clock.now()).write(site, name, await seal(master, secret));
4780
+ const sealed = await seal(master, secret, {
4781
+ site,
4782
+ name
4783
+ });
4784
+ await createSecretStore(db, () => clock.now()).write(site, name, sealed);
3811
4785
  return secret;
3812
4786
  } }
3813
4787
  });
3814
4788
  /**
4789
+ * The activity log of a request (ADR-037), for an actor known once the client has said what it
4790
+ * is. A person in the admin is a `user`; any other OAuth client, an `oauth_client`.
4791
+ */
4792
+ const auditOf = (c, principal, adminClientId) => (meta) => auditAs(c, actorOf$1(principal, meta, adminClientId));
4793
+ /**
3815
4794
  * A protected route: the resource server of `resource` answers a request without a token and a
3816
4795
  * token it refuses (401, logged with the reason), and `proceed` gets the principal of the rest.
3817
4796
  */
@@ -3879,6 +4858,16 @@ function createGateway(options) {
3879
4858
  app.use("*", async (c, next) => {
3880
4859
  c.set("requestId", crypto.randomUUID());
3881
4860
  await next();
4861
+ const https = c.env.HOLLWARE_PUBLIC_URL?.startsWith("https://") === true;
4862
+ if (!(!c.res.headers.has("x-content-type-options") || https && !c.res.headers.has("strict-transport-security"))) return;
4863
+ const headers = new Headers(c.res.headers);
4864
+ if (!headers.has("x-content-type-options")) headers.set("x-content-type-options", "nosniff");
4865
+ if (https && !headers.has("strict-transport-security")) headers.set("strict-transport-security", "max-age=31536000");
4866
+ c.res = new Response(c.res.body, {
4867
+ status: c.res.status,
4868
+ statusText: c.res.statusText,
4869
+ headers
4870
+ });
3882
4871
  });
3883
4872
  app.use("*", async (c, next) => {
3884
4873
  const origin = c.req.header("origin");
@@ -3887,6 +4876,30 @@ function createGateway(options) {
3887
4876
  if (refusal !== null) return refusal;
3888
4877
  await next();
3889
4878
  });
4879
+ app.use("*", async (c, next) => {
4880
+ const limiter = c.env.HOLLWARE_RATE_LIMIT;
4881
+ const group = limitedRoute(c.req.method, new URL(c.req.url).pathname);
4882
+ if (limiter === void 0 || group === null) return next();
4883
+ const address = c.req.header("cf-connecting-ip") ?? "unknown";
4884
+ const { success } = await limiter.limit({ key: `${group}:${address}` });
4885
+ if (success) return next();
4886
+ log({
4887
+ kind: "rate_limited",
4888
+ requestId: c.get("requestId"),
4889
+ route: group
4890
+ });
4891
+ return Response.json({
4892
+ code: "rate_limited",
4893
+ message: "Too many requests from this address to this route.",
4894
+ fix: "Wait a minute and try again."
4895
+ }, {
4896
+ status: 429,
4897
+ headers: {
4898
+ "retry-after": "60",
4899
+ "cache-control": "no-store"
4900
+ }
4901
+ });
4902
+ });
3890
4903
  for (const path of [
3891
4904
  "/authorize",
3892
4905
  "/oauth/*",
@@ -3913,7 +4926,13 @@ function createGateway(options) {
3913
4926
  semantic: semanticOfEnv(c.env),
3914
4927
  analytics: analyticsReaderOf(c.env),
3915
4928
  structure: structureOf(db, c.env.HOLLWARE_SECRET)
3916
- }
4929
+ },
4930
+ ...principal.kind === "read" ? {} : { audit: {
4931
+ log: auditOf(c, principal, null),
4932
+ defer: (work) => {
4933
+ c.executionCtx.waitUntil(work);
4934
+ }
4935
+ } }
3917
4936
  });
3918
4937
  }));
3919
4938
  for (const resource of ["mcp", "v1"]) app.all(`/.well-known/oauth-protected-resource/${resource}`, async (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).protect(resource, c.req.raw, c.env, workerContext(c), {
@@ -3922,18 +4941,68 @@ function createGateway(options) {
3922
4941
  defer: () => void 0,
3923
4942
  failed: () => void 0
3924
4943
  }));
4944
+ /** The activity log of a request for an actor the route knows itself (ADR-037). */
4945
+ const auditAs = (c, actor) => createAuditLog({
4946
+ db: c.env.DB,
4947
+ actor,
4948
+ requestId: c.get("requestId"),
4949
+ address: c.req.header("cf-connecting-ip") ?? null,
4950
+ master: c.env.HOLLWARE_SECRET,
4951
+ now: () => clock.now(),
4952
+ log
4953
+ });
4954
+ /** A site as the activity log needs it, current with D1: a route of no token refreshed nothing. */
4955
+ const auditSite = async (c, key) => {
4956
+ await options.refresh?.(c.env.DB);
4957
+ return sites.get(key);
4958
+ };
3925
4959
  installAuthorize(app, {
3926
4960
  sites: siteKeys,
3927
4961
  clock,
3928
- oauth
4962
+ oauth,
4963
+ site: auditSite,
4964
+ audit: auditAs
3929
4965
  });
3930
4966
  const authorizationServer = (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).authorizationServer.fetch(c.req.raw, c.env, workerContext(c));
3931
4967
  app.all("/.well-known/oauth-authorization-server", authorizationServer);
3932
- app.all("/oauth/*", authorizationServer);
3933
- installIdentity(app, { clock });
4968
+ app.all("/oauth/*", async (c) => {
4969
+ const refresh = await refreshTokenOf(c.req.raw);
4970
+ if (refresh === null) return authorizationServer(c);
4971
+ const now = Math.floor(clock.now().getTime() / 1e3);
4972
+ const reused = await spentRefresh(c.env.OAUTH_KV, refresh, now);
4973
+ if (reused !== null) {
4974
+ await oauth(c.env.HOLLWARE_PUBLIC_URL).authorizationServer.getOAuthApi(c.env).revokeGrant(reused.grantId, reused.userId);
4975
+ log({
4976
+ kind: "refresh_reused",
4977
+ requestId: c.get("requestId"),
4978
+ grantId: reused.grantId
4979
+ });
4980
+ const site = reused.site === null ? void 0 : await auditSite(c, reused.site);
4981
+ if (site !== void 0) await auditAs(c, systemActor(reused.userId)).write(site, {
4982
+ action: "app.refresh_reused",
4983
+ outcome: "denied",
4984
+ errorCode: "invalid_grant",
4985
+ target: {
4986
+ type: "grant",
4987
+ id: reused.grantId
4988
+ }
4989
+ });
4990
+ return reusedAnswer();
4991
+ }
4992
+ const response = await authorizationServer(c);
4993
+ if (response.ok) await recordRotation(c.env.OAUTH_KV, refresh.grantId, now);
4994
+ return response;
4995
+ });
4996
+ installIdentity(app, {
4997
+ clock,
4998
+ site: auditSite,
4999
+ audit: auditAs
5000
+ });
3934
5001
  installAdmin(app, { oauth });
3935
5002
  installAdminApi(app, {
3936
5003
  sites: siteKeys,
5004
+ site: (key) => sites.get(key),
5005
+ audit: async (c, principal) => auditOf(c, principal, (await adminClient(oauth(c.env.HOLLWARE_PUBLIC_URL), c.env)).clientId),
3937
5006
  clock,
3938
5007
  oauth,
3939
5008
  protect: (c, proceed) => protectWrites(c, "v1", proceed),
@@ -3947,7 +5016,8 @@ function createGateway(options) {
3947
5016
  protect: (c, proceed) => protectWrites(c, "v1", proceed),
3948
5017
  metadataUrl: (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1,
3949
5018
  notify,
3950
- structure: structureOf
5019
+ structure: structureOf,
5020
+ audit: async (c, principal) => auditOf(c, principal, (await adminClient(oauth(c.env.HOLLWARE_PUBLIC_URL), c.env)).clientId)
3951
5021
  });
3952
5022
  if (options.read !== void 0) installReadApi(app, {
3953
5023
  read: options.read,
@@ -3963,6 +5033,14 @@ function createGateway(options) {
3963
5033
  return await app.fetch(request, env, ctx);
3964
5034
  };
3965
5035
  }
5036
+ /** The group of a route that is rate limited, or `null` for one that is not (T-10). */
5037
+ function limitedRoute(method, pathname) {
5038
+ if (method !== "POST") return null;
5039
+ if (pathname.startsWith("/auth/")) return "auth";
5040
+ if (pathname === "/oauth/token" || pathname === "/oauth/register") return "oauth";
5041
+ if (pathname === "/events") return "beacon";
5042
+ return null;
5043
+ }
3966
5044
  //#endregion
3967
5045
  //#region src/mcp/annotations.ts
3968
5046
  /**
@@ -4161,6 +5239,8 @@ const OUTPUT_TOO_LARGE_FIX = {
4161
5239
  update_draft: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
4162
5240
  create_site: "The site was made and holds little: read it with get_structure from a token of that site.",
4163
5241
  delete_site: "Nothing to ask for less of: the answer is a count. Try again.",
5242
+ delete_entry: "The operation ran and its change may have been applied: list the trash with list_entries and trashed before you repeat it.",
5243
+ restore_entry: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
4164
5244
  make_webhook_secret: "Nothing to ask for less of: the answer is one secret. Try again.",
4165
5245
  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
5246
  restore_site: "Nothing to ask for less of: the answer is an outcome. Try again.",
@@ -4388,6 +5468,7 @@ function createMcpEndpoint(options) {
4388
5468
  client: declaredClient(envelope)
4389
5469
  };
4390
5470
  let outcome = null;
5471
+ let answered = null;
4391
5472
  let isError = false;
4392
5473
  let failure = null;
4393
5474
  let result;
@@ -4395,6 +5476,7 @@ function createMcpEndpoint(options) {
4395
5476
  const record = (answer) => {
4396
5477
  outcome = answer.outcome;
4397
5478
  isError = answer.isError;
5479
+ answered = answer.output;
4398
5480
  return answer;
4399
5481
  };
4400
5482
  /** Runs the handler within the time limit and checks its answer. */
@@ -4427,6 +5509,8 @@ function createMcpEndpoint(options) {
4427
5509
  isError = true;
4428
5510
  result = errorResult(failure);
4429
5511
  }
5512
+ const line = operationLine(spec, input, context.site.key, failure === null ? { output: answered } : { errorCode: failure.code });
5513
+ if (line !== null && call.audit !== void 0) await call.audit.log(context.client).write(context.site, line);
4430
5514
  emit({
4431
5515
  kind: "call",
4432
5516
  requestId: call.requestId,
@@ -4455,16 +5539,20 @@ function createMcpEndpoint(options) {
4455
5539
  inputSchema: tool.input,
4456
5540
  outputSchema: tool.output,
4457
5541
  annotations: EFFECT_ANNOTATIONS[tool.spec.effect],
4458
- scopeChallenge: scopeChallenge(tool, () => emit({
4459
- kind: "call",
4460
- requestId: call.requestId,
4461
- tool: tool.spec.name,
4462
- outcome: null,
4463
- errorCode: "insufficient_scope",
4464
- isError: true,
4465
- durationMs: 0,
4466
- detail: null
4467
- }))
5542
+ scopeChallenge: scopeChallenge(tool, () => {
5543
+ emit({
5544
+ kind: "call",
5545
+ requestId: call.requestId,
5546
+ tool: tool.spec.name,
5547
+ outcome: null,
5548
+ errorCode: "insufficient_scope",
5549
+ isError: true,
5550
+ durationMs: 0,
5551
+ detail: null
5552
+ });
5553
+ const line = operationLine(tool.spec, null, call.context.site.key, { errorCode: "insufficient_scope" });
5554
+ if (line !== null && call.audit !== void 0) call.audit.defer(call.audit.log(null).write(call.context.site, line));
5555
+ })
4468
5556
  }, (input, mcp) => runTool(tool.spec, input, mcp, call, request.era));
4469
5557
  server.registerPrompt(REFRESH_CONTENT.name, {
4470
5558
  title: REFRESH_CONTENT.title,
@@ -4508,6 +5596,27 @@ function unknownSite(site, served) {
4508
5596
  const keys = [...served].map((key) => `"${key}"`).join(", ");
4509
5597
  return new HollwareError("invalid_input", `This Hollware serves no site "${site}": it serves ${keys}.`, `Pass one of ${keys}, the sites this deployment serves, as the site argument.`);
4510
5598
  }
5599
+ /** What a caller that presents a preview token that does not hold is told. */
5600
+ function previewRefused(message) {
5601
+ return new HollwareError("invalid_preview", message, "Ask for a new preview link: in the admin, open the entry and choose \"Preview on the site\".");
5602
+ }
5603
+ /**
5604
+ * The entry a preview token names, once its signature, its site and its hour hold: the caller gets
5605
+ * an error, never the published version in its place, for a token that does not. The reason goes
5606
+ * to the log; the caller only learns that the link is not valid.
5607
+ */
5608
+ async function checkPreview(deps, env, site, token) {
5609
+ if (typeof token !== "string") throw previewRefused("The preview link is not a string.");
5610
+ const checked = await verifyPreviewToken({
5611
+ secret: env.HOLLWARE_SECRET,
5612
+ site,
5613
+ token,
5614
+ now: deps.clock.now()
5615
+ });
5616
+ if (checked.ok) return checked.entryId;
5617
+ if (checked.reason === "no_secret") throw new HollwareError("invalid_input", "This Hollware cannot check preview links: it has no HOLLWARE_SECRET of 32 characters or more.", "Set the Worker secret HOLLWARE_SECRET (`hollware deploy` makes one), then ask for a new preview link.");
5618
+ throw previewRefused(checked.reason === "expired" ? "This preview link has expired." : "This preview link is not valid.");
5619
+ }
4511
5620
  /**
4512
5621
  * The options object of a call, without the keys that hold `undefined`, so that the handler sees
4513
5622
  * the same input as over MCP. A key that is none of `allowed` is an error: the specs are strict
@@ -4564,7 +5673,7 @@ function forCaller(deps, requestId, method, error) {
4564
5673
  * `build` makes the raw input, so a wrong site is answered before anything else is looked at and
4565
5674
  * a call that fails never opens a session.
4566
5675
  */
4567
- async function run(deps, env, method, site, schema, build, handler) {
5676
+ async function run(deps, env, method, site, schema, build, handler, viewOf = () => Promise.resolve("published")) {
4568
5677
  const requestId = crypto.randomUUID();
4569
5678
  try {
4570
5679
  await deps.refresh?.(env.DB);
@@ -4574,10 +5683,16 @@ async function run(deps, env, method, site, schema, build, handler) {
4574
5683
  const served = deps.sites.get(site);
4575
5684
  if (served === void 0) throw unknownSite(site, deps.sites.keys());
4576
5685
  const input = parseInput(schema, build());
5686
+ let view;
5687
+ try {
5688
+ view = await viewOf(site);
5689
+ } catch (error) {
5690
+ throw forCaller(deps, requestId, method, error);
5691
+ }
4577
5692
  const context = {
4578
5693
  site: served,
4579
5694
  principal: BINDING_PRINCIPAL,
4580
- view: "published",
5695
+ view,
4581
5696
  store: createD1Store(env.DB.withSession("first-unconstrained")),
4582
5697
  clock: deps.clock,
4583
5698
  client: null,
@@ -4598,12 +5713,34 @@ async function run(deps, env, method, site, schema, build, handler) {
4598
5713
  */
4599
5714
  function createRpc(deps, env) {
4600
5715
  return {
4601
- getEntry: (site, collection, slug, locale, options) => run(deps, env, "getEntry", site, getEntry.input, () => ({
4602
- collection,
4603
- locale,
4604
- slug,
4605
- ...readOptions(options, ["formats", "related"])
4606
- }), deps.handlers.get_entry),
5716
+ getEntry: (site, collection, slug, locale, options) => {
5717
+ const read = {
5718
+ preview: void 0,
5719
+ entryId: null
5720
+ };
5721
+ return run(deps, env, "getEntry", site, getEntry.input, () => {
5722
+ const { preview, ...rest } = readOptions(options, [
5723
+ "formats",
5724
+ "related",
5725
+ "preview"
5726
+ ]);
5727
+ read.preview = preview;
5728
+ return {
5729
+ collection,
5730
+ locale,
5731
+ slug,
5732
+ ...rest
5733
+ };
5734
+ }, async (input, context) => {
5735
+ const answer = await deps.handlers.get_entry(input, context);
5736
+ if (read.entryId !== null && answer.entry !== void 0 && answer.entry.id !== read.entryId) return { outcome: "not_found" };
5737
+ return answer;
5738
+ }, async (key) => {
5739
+ if (read.preview === void 0) return "published";
5740
+ read.entryId = await checkPreview(deps, env, key, read.preview);
5741
+ return "head";
5742
+ });
5743
+ },
4607
5744
  listEntries: (site, collection, options) => run(deps, env, "listEntries", site, listEntries.input, () => ({
4608
5745
  collection,
4609
5746
  ...readOptions(options, [
@@ -4725,6 +5862,22 @@ async function runDueSchedules(input) {
4725
5862
  await store.cancelSchedule(site.key, schedule.entryId);
4726
5863
  cancelled += 1;
4727
5864
  }
5865
+ await input.audit?.(systemActor(schedule.actor.userId), requestId).write(site, {
5866
+ action: "schedule.publish",
5867
+ outcome: answer.outcome === "published" ? "ok" : "failed",
5868
+ errorCode: answer.outcome === "published" ? null : answer.outcome,
5869
+ target: {
5870
+ type: "entry",
5871
+ id: schedule.entryId
5872
+ },
5873
+ detail: {
5874
+ scheduledAt: schedule.scheduledAt,
5875
+ scheduledBy: {
5876
+ kind: schedule.actor.kind,
5877
+ tokenId: schedule.actor.tokenId
5878
+ }
5879
+ }
5880
+ });
4728
5881
  log({
4729
5882
  kind: "schedule",
4730
5883
  requestId,
@@ -4773,7 +5926,7 @@ function createSiteRegistry(options) {
4773
5926
  for (const row of fetched) {
4774
5927
  if (row.definition === null || (next.get(row.id)?.version ?? -1) >= row.version) continue;
4775
5928
  try {
4776
- const definition = JSON.parse(row.definition);
5929
+ const definition = withoutStaleIndexNow(row.id, JSON.parse(row.definition), options.log);
4777
5930
  const site = compileSites({ [row.id]: definition }).sites.get(row.id);
4778
5931
  if (site !== void 0) next.set(row.id, {
4779
5932
  version: row.version,
@@ -4805,6 +5958,33 @@ function createSiteRegistry(options) {
4805
5958
  refresh
4806
5959
  };
4807
5960
  }
5961
+ /**
5962
+ * A definition stored before 0.10 named the Worker secret of its IndexNow key; since then it holds
5963
+ * the key itself (T-69 of docs/security/threat-model.md). A stored one of the old form is served
5964
+ * with IndexNow off, and says so, instead of failing to compile and leaving its site unserved: its
5965
+ * owner sets the key itself in the admin or with update_site, which refuses the old form.
5966
+ */
5967
+ function withoutStaleIndexNow(site, definition, log) {
5968
+ if (typeof definition !== "object" || definition === null) return definition;
5969
+ const events = Reflect.get(definition, "events");
5970
+ if (typeof events !== "object" || events === null) return definition;
5971
+ const indexNow = Reflect.get(events, "indexNow");
5972
+ if (typeof indexNow !== "object" || indexNow === null) return definition;
5973
+ const key = Reflect.get(indexNow, "key");
5974
+ if (typeof key === "string" && INDEXNOW_KEY.test(key)) return definition;
5975
+ log({
5976
+ kind: "indexnow_off",
5977
+ site,
5978
+ reason: "the definition names a secret, not the key itself"
5979
+ });
5980
+ return {
5981
+ ...definition,
5982
+ events: {
5983
+ ...events,
5984
+ indexNow: null
5985
+ }
5986
+ };
5987
+ }
4808
5988
  //#endregion
4809
5989
  //#region src/compose.ts
4810
5990
  const systemClock = { now: () => /* @__PURE__ */ new Date() };
@@ -4871,8 +6051,12 @@ function createHollwareWith(config, handlers, options = {}) {
4871
6051
  ownSecret: async (webhook) => {
4872
6052
  const master = env.HOLLWARE_SECRET;
4873
6053
  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;
6054
+ const name = `webhook:${webhook}`;
6055
+ const sealed = await createSecretStore(env.DB, () => clock.now()).read(site.key, name);
6056
+ return sealed === null ? void 0 : await unseal(master, sealed, {
6057
+ site: site.key,
6058
+ name
6059
+ }) ?? void 0;
4876
6060
  }
4877
6061
  });
4878
6062
  return {
@@ -4899,8 +6083,18 @@ function createHollwareWith(config, handlers, options = {}) {
4899
6083
  store: createD1Store(env.DB, { clock }),
4900
6084
  handlers,
4901
6085
  clock,
4902
- log: writeLog
6086
+ log: writeLog,
6087
+ audit: (actor, requestId) => createAuditLog({
6088
+ db: env.DB,
6089
+ actor,
6090
+ requestId,
6091
+ address: null,
6092
+ master: env.HOLLWARE_SECRET,
6093
+ now: () => clock.now(),
6094
+ log: writeLog
6095
+ })
4903
6096
  });
6097
+ await dropExpiredLines(env.DB, site, clock.now());
4904
6098
  await sweepOutbox({
4905
6099
  queue: env.EVENTS,
4906
6100
  deps: deliveryDeps(env, site)
@@ -4927,6 +6121,13 @@ function createHollwareWith(config, handlers, options = {}) {
4927
6121
  });
4928
6122
  }));
4929
6123
  ctx.waitUntil((async () => {
6124
+ await dropExpired(connect(env.DB), clock.now()).catch((error) => {
6125
+ writeLog({
6126
+ kind: "cron_failed",
6127
+ site: null,
6128
+ message: String(error)
6129
+ });
6130
+ });
4930
6131
  await refresh(env.DB);
4931
6132
  for (const site of sites.values()) await each(site).catch((error) => {
4932
6133
  writeLog({
@@ -4991,6 +6192,7 @@ function entrySummary(view) {
4991
6192
  hasUnpublishedChanges: entry.publishedRevision !== entry.headRevision,
4992
6193
  publishedAt: entry.publishedAt,
4993
6194
  scheduledAt: entry.scheduledAt,
6195
+ deletedAt: entry.deletedAt,
4994
6196
  updatedAt: entry.updatedAt
4995
6197
  };
4996
6198
  }
@@ -5326,6 +6528,10 @@ const createDraft = async (input, context) => {
5326
6528
  entry: entrySummary(written.view),
5327
6529
  verdict: verdictView(verdict)
5328
6530
  };
6531
+ case "in_trash": return {
6532
+ outcome: "in_trash",
6533
+ entry: entrySummary(written.view)
6534
+ };
5329
6535
  case "taken":
5330
6536
  case "too_large": return {
5331
6537
  outcome: "refused",
@@ -5413,6 +6619,7 @@ const listEntries$1 = async (input, context) => {
5413
6619
  locale: input.locale,
5414
6620
  tag: input.tag,
5415
6621
  status: input.status,
6622
+ trashed: input.trashed,
5416
6623
  view: context.view,
5417
6624
  limit: input.limit,
5418
6625
  cursor: input.cursor
@@ -5510,6 +6717,50 @@ const search$1 = async (input, context) => {
5510
6717
  }))
5511
6718
  };
5512
6719
  };
6720
+ //#endregion
6721
+ //#region src/ops/event.ts
6722
+ /** The path of an entry: the route of its locale, with its slug in place of `:slug`. */
6723
+ function entryPath(collection, locale, slug) {
6724
+ const route = collection.routes[locale];
6725
+ return route === void 0 ? null : route.replace(/:slug(?![A-Za-z0-9_])/, slug);
6726
+ }
6727
+ /** The event of a publish or an unpublish, ready for the outbox. */
6728
+ function eventFor(input) {
6729
+ const id = newId("evt");
6730
+ const path = entryPath(input.collection, input.entry.locale, input.entry.slug);
6731
+ const payload = {
6732
+ id,
6733
+ kind: input.kind,
6734
+ site: input.site.key,
6735
+ at: input.at,
6736
+ entry: input.entry,
6737
+ path,
6738
+ url: path === null || input.site.url === null ? null : `${input.site.url}${path}`,
6739
+ cacheTags: tagsOfChange(input.entry)
6740
+ };
6741
+ return {
6742
+ id,
6743
+ payload: JSON.stringify(payload)
6744
+ };
6745
+ }
6746
+ /**
6747
+ * The event of a change of a site's structure, ready for the outbox: the tags of the site and of
6748
+ * every list of the collections and locales of its definition before and after the change.
6749
+ */
6750
+ function siteEventFor(input) {
6751
+ const id = newId("evt");
6752
+ const payload = {
6753
+ id,
6754
+ kind: "site.changed",
6755
+ site: input.site,
6756
+ at: input.at,
6757
+ cacheTags: tagsOfSiteChange(input.collections, input.locales)
6758
+ };
6759
+ return {
6760
+ id,
6761
+ payload: JSON.stringify(payload)
6762
+ };
6763
+ }
5513
6764
  /** How many slugs of refused entries an answer names. */
5514
6765
  const EXAMPLES = 3;
5515
6766
  function structureOf(context) {
@@ -5596,28 +6847,58 @@ async function change(context, version, next, confirm) {
5596
6847
  outcome: "needs_confirmation",
5597
6848
  impact
5598
6849
  };
6850
+ const event = siteEventFor({
6851
+ site: context.site.key,
6852
+ at: context.clock.now().toISOString(),
6853
+ collections: [.../* @__PURE__ */ new Set([...Object.keys(current.definition.collections), ...Object.keys(next.collections)])],
6854
+ locales: [.../* @__PURE__ */ new Set([...current.definition.locales, ...next.locales])]
6855
+ });
5599
6856
  if (await structure.store.write({
5600
6857
  site: context.site.key,
5601
6858
  expected: version,
5602
6859
  definition: next,
5603
- actor: actor(context)
6860
+ actor: actor(context),
6861
+ event
5604
6862
  }) === "conflict") return {
5605
6863
  outcome: "conflict",
5606
6864
  currentVersion: (await currentOf(context)).version
5607
6865
  };
6866
+ context.notify?.([event.id]);
5608
6867
  await structure.changed();
5609
6868
  return {
5610
6869
  outcome: "updated",
5611
6870
  version: version + 1
5612
6871
  };
5613
6872
  }
6873
+ /**
6874
+ * The URL of a webhook as a person who cannot change the structure sees it: its origin only. A
6875
+ * webhook's path and query often are its secret (a deploy hook), and `get_structure` asks only
6876
+ * for content:read, which authors have (T-70 of docs/security/threat-model.md).
6877
+ */
6878
+ function originOnly(url) {
6879
+ try {
6880
+ return new URL(url).origin;
6881
+ } catch {
6882
+ return "";
6883
+ }
6884
+ }
5614
6885
  const getStructure = async (_input, context) => {
5615
6886
  const current = await currentOf(context);
6887
+ const definition = context.principal.scopes.includes("schema:admin") ? current.definition : {
6888
+ ...current.definition,
6889
+ events: {
6890
+ ...current.definition.events,
6891
+ webhooks: current.definition.events.webhooks.map((webhook) => ({
6892
+ ...webhook,
6893
+ url: originOnly(webhook.url)
6894
+ }))
6895
+ }
6896
+ };
5616
6897
  return {
5617
6898
  outcome: "ok",
5618
6899
  site: context.site.key,
5619
6900
  version: current.version,
5620
- definition: current.definition,
6901
+ definition,
5621
6902
  catalog: describeCatalog()
5622
6903
  };
5623
6904
  };
@@ -5689,32 +6970,6 @@ const makeWebhookSecret = async (input, context) => {
5689
6970
  };
5690
6971
  };
5691
6972
  //#endregion
5692
- //#region src/ops/event.ts
5693
- /** The path of an entry: the route of its locale, with its slug in place of `:slug`. */
5694
- function entryPath(collection, locale, slug) {
5695
- const route = collection.routes[locale];
5696
- return route === void 0 ? null : route.replace(/:slug(?![A-Za-z0-9_])/, slug);
5697
- }
5698
- /** The event of a publish or an unpublish, ready for the outbox. */
5699
- function eventFor(input) {
5700
- const id = newId("evt");
5701
- const path = entryPath(input.collection, input.entry.locale, input.entry.slug);
5702
- const payload = {
5703
- id,
5704
- kind: input.kind,
5705
- site: input.site.key,
5706
- at: input.at,
5707
- entry: input.entry,
5708
- path,
5709
- url: path === null || input.site.url === null ? null : `${input.site.url}${path}`,
5710
- cacheTags: tagsOfChange(input.entry)
5711
- };
5712
- return {
5713
- id,
5714
- payload: JSON.stringify(payload)
5715
- };
5716
- }
5717
- //#endregion
5718
6973
  //#region src/ops/publish.ts
5719
6974
  /** A warning by what an acknowledgement names: its contract and its path, never its line. */
5720
6975
  function warningKey(warning) {
@@ -6010,7 +7265,7 @@ const listReviews = async (input, context) => {
6010
7265
  const reviews = [];
6011
7266
  for (const review of found) {
6012
7267
  const view = await context.store.findEntryById(context.site.key, review.entryId, "head");
6013
- if (view === null) throw unexpectedAnswer(context, "list_reviews", { outcome: "no entry" });
7268
+ if (view === null) continue;
6014
7269
  reviews.push({
6015
7270
  ...reviewView(review),
6016
7271
  entry: entrySummary(view),
@@ -6131,6 +7386,52 @@ const requestReview = async (input, context) => {
6131
7386
  }
6132
7387
  };
6133
7388
  //#endregion
7389
+ //#region src/ops/trash.ts
7390
+ /**
7391
+ * The trash of entries. Only a draft goes there: a published or scheduled entry is unpublished
7392
+ * first. Nothing is deleted, since revisions, tags and publications are append-only (ADR-021): the
7393
+ * entry gets its `deletedAt`, every read leaves it out, and it keeps its slug and translation
7394
+ * until it is restored as the draft it was.
7395
+ */
7396
+ /**
7397
+ * Moves a draft to the trash, if it is still at the version the caller read. The store checks it
7398
+ * all in the write: an entry that is not there (or is in the trash already) is `not_found`, another
7399
+ * version is `conflict`, and what is published or scheduled is taken off the site first.
7400
+ */
7401
+ const deleteEntry = async (input, context) => {
7402
+ const written = await context.store.trashEntry(context.site.key, {
7403
+ entryId: input.id,
7404
+ expectedVersion: input.version
7405
+ });
7406
+ switch (written.outcome) {
7407
+ case "trashed": return {
7408
+ outcome: "trashed",
7409
+ entry: entrySummary(written.view)
7410
+ };
7411
+ case "not_draft": return {
7412
+ outcome: "published",
7413
+ entry: entrySummary(written.view)
7414
+ };
7415
+ case "conflict": return {
7416
+ outcome: "conflict",
7417
+ conflict: conflictAt(written.currentVersion, written.headRevision)
7418
+ };
7419
+ case "not_found": return { outcome: "not_found" };
7420
+ }
7421
+ };
7422
+ /** Brings an entry back from the trash as the draft it was. */
7423
+ const restoreEntry = async (input, context) => {
7424
+ const written = await context.store.restoreEntry(context.site.key, input.id);
7425
+ switch (written.outcome) {
7426
+ case "restored":
7427
+ case "not_trashed": return {
7428
+ outcome: written.outcome,
7429
+ entry: entrySummary(written.view)
7430
+ };
7431
+ case "not_found": return { outcome: "not_found" };
7432
+ }
7433
+ };
7434
+ //#endregion
6134
7435
  //#region src/ops/unpublish.ts
6135
7436
  /**
6136
7437
  * Takes an entry off the public site and keeps it, with its revisions, as a draft. The store
@@ -6279,6 +7580,7 @@ const handlers = defineHandlers({
6279
7580
  content_stats: contentStats,
6280
7581
  create_draft: createDraft,
6281
7582
  create_site: createSite,
7583
+ delete_entry: deleteEntry,
6282
7584
  delete_site: deleteSite,
6283
7585
  describe_site: describeSite$1,
6284
7586
  get_entry: getEntry$1,
@@ -6291,6 +7593,7 @@ const handlers = defineHandlers({
6291
7593
  publish: publish$1,
6292
7594
  request_changes: requestChanges,
6293
7595
  request_review: requestReview,
7596
+ restore_entry: restoreEntry,
6294
7597
  restore_site: restoreSite,
6295
7598
  set_collection: setCollection,
6296
7599
  unpublish,