@hollware/server 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { A as users, C as reviewComments, D as schedules, E as revisions, M as effectiveScopes, N as isRole, O as sites, S as publications, T as revisionTags, _ as entries, b as members, g as deliveries, j as ROLE_SCOPES, k as tokens, l as membershipsOf, n as createInvitation, s as findSession, v as entryEmbeddings, w as reviews, x as outbox, y as invitations } from "./store-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 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-BPr9-wKy.mjs";
2
+ import { CHUNKER_VERSION, ConfigError, EVENT_ID_HEADER, HollwareError, INDEXNOW_KEY, 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, tagsOfSiteChange, uniqueSlugViolation, uniqueTranslationViolation } from "@hollware/core";
3
3
  import { Hono } from "hono";
4
4
  import { drizzle } from "drizzle-orm/d1";
5
5
  import { and, asc, desc, eq, getTableColumns, gt, inArray, isNotNull, isNull, lte, ne, or, sql } from "drizzle-orm";
@@ -10,7 +10,7 @@ import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, McpServer, accepted
10
10
  import * as z from "zod";
11
11
  import { WorkerEntrypoint } from "cloudflare:workers";
12
12
  //#region package.json
13
- var version = "0.9.1";
13
+ var version = "0.10.0";
14
14
  //#endregion
15
15
  //#region src/auth/responses.ts
16
16
  /** The challenge of every 401 and 403 (RFC 6750). */
@@ -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
@@ -692,7 +700,7 @@ async function relatedOf(db, site, entryId) {
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
702
  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 {
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
  };
@@ -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,
@@ -2038,9 +2127,9 @@ function metaOf(actor) {
2038
2127
  return actor.meta === null ? null : JSON.stringify(actor.meta);
2039
2128
  }
2040
2129
  /** 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) {
2130
+ function appendChange(db, site, actor, now, before, after, id = newId("sit")) {
2042
2131
  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);
2132
+ 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
2133
  }
2045
2134
  function createStructureStore(db, now) {
2046
2135
  return {
@@ -2053,10 +2142,17 @@ function createStructureStore(db, now) {
2053
2142
  deletedAt: row.deleted_at
2054
2143
  };
2055
2144
  },
2056
- async write({ site, expected, definition, actor }) {
2145
+ async write({ site, expected, definition, actor, event }) {
2057
2146
  const at = now().toISOString();
2147
+ const change = newId("sit");
2058
2148
  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")]);
2149
+ const [moved] = await db.batch([
2150
+ 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),
2151
+ appendChange(db, site, actor, at, before ?? null, "definition", change),
2152
+ ...event === void 0 ? [] : [db.prepare(`INSERT INTO outbox (id, site_id, kind, entry_id, payload, sent_at, created_at, updated_at)
2153
+ SELECT ?1, ?2, 'site.changed', NULL, ?3, NULL, ?4, ?4
2154
+ WHERE EXISTS (SELECT 1 FROM site_changes WHERE id = ?5 AND site_id = ?2)`).bind(event.id, site, event.payload, at, change)]
2155
+ ]);
2060
2156
  return (moved?.meta.changes ?? 0) === 1 ? "written" : "conflict";
2061
2157
  },
2062
2158
  async create({ site, definition, actor }) {
@@ -3157,15 +3253,31 @@ function setupPage(nonce) {
3157
3253
  } catch (error) { fail(error) }
3158
3254
  })`);
3159
3255
  }
3256
+ /**
3257
+ * Where the sign-in page sends the person after their passkey: the `return_to` it was given, if it
3258
+ * is a page of this origin, else the admin. The target is read as the browser will read it, as a
3259
+ * URL against the page's own origin, so no way of writing another host (`//`, `/\`, a tab, a
3260
+ * scheme) gets through, and only the path, query and fragment it resolves to are used (T-43).
3261
+ * The page runs its source as it is: it uses nothing but its arguments and the URL of the platform.
3262
+ */
3263
+ function sameOriginTarget(target, origin) {
3264
+ if (target === null) return "/admin/";
3265
+ try {
3266
+ const url = new URL(target, origin);
3267
+ return url.origin === origin ? `${url.pathname}${url.search}${url.hash}` : "/admin/";
3268
+ } catch {
3269
+ return "/admin/";
3270
+ }
3271
+ }
3160
3272
  function signInPage(nonce) {
3161
3273
  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 () => {
3274
+ <button id="signin" type="button">Sign in with a passkey</button>`, `const sameOriginTarget = ${sameOriginTarget.toString()}
3275
+ document.getElementById('signin').addEventListener('click', async () => {
3163
3276
  try {
3164
3277
  const { challengeId, options } = await post('/auth/signin/options', {})
3165
3278
  const credential = await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options) })
3166
3279
  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/'
3280
+ location.href = sameOriginTarget(new URLSearchParams(location.search).get('return_to'), location.origin)
3169
3281
  } catch (error) { fail(error) }
3170
3282
  })`);
3171
3283
  }
@@ -3267,7 +3379,7 @@ function plainPageHeaders() {
3267
3379
  * The ceremonies, with SimpleWebAuthn and its parsers, in a chunk of their own: only these four
3268
3380
  * routes use them, and a Worker that serves MCP never pays for them at startup (`budgets.json`).
3269
3381
  */
3270
- const ceremonies = () => import("./ceremonies-D5dIEbha.mjs");
3382
+ const ceremonies = () => import("./ceremonies-DDoGdUD7.mjs");
3271
3383
  /** The cookie of a signed-in person: only `/authorize` reads it (ADR-026). */
3272
3384
  const SESSION_COOKIE = "hollware_session";
3273
3385
  function sessionCookie(secret) {
@@ -4161,6 +4273,8 @@ const OUTPUT_TOO_LARGE_FIX = {
4161
4273
  update_draft: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
4162
4274
  create_site: "The site was made and holds little: read it with get_structure from a token of that site.",
4163
4275
  delete_site: "Nothing to ask for less of: the answer is a count. Try again.",
4276
+ delete_entry: "The operation ran and its change may have been applied: list the trash with list_entries and trashed before you repeat it.",
4277
+ restore_entry: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
4164
4278
  make_webhook_secret: "Nothing to ask for less of: the answer is one secret. Try again.",
4165
4279
  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
4280
  restore_site: "Nothing to ask for less of: the answer is an outcome. Try again.",
@@ -4773,7 +4887,7 @@ function createSiteRegistry(options) {
4773
4887
  for (const row of fetched) {
4774
4888
  if (row.definition === null || (next.get(row.id)?.version ?? -1) >= row.version) continue;
4775
4889
  try {
4776
- const definition = JSON.parse(row.definition);
4890
+ const definition = withoutStaleIndexNow(row.id, JSON.parse(row.definition), options.log);
4777
4891
  const site = compileSites({ [row.id]: definition }).sites.get(row.id);
4778
4892
  if (site !== void 0) next.set(row.id, {
4779
4893
  version: row.version,
@@ -4805,6 +4919,33 @@ function createSiteRegistry(options) {
4805
4919
  refresh
4806
4920
  };
4807
4921
  }
4922
+ /**
4923
+ * A definition stored before 0.10 named the Worker secret of its IndexNow key; since then it holds
4924
+ * the key itself (T-69 of docs/security/threat-model.md). A stored one of the old form is served
4925
+ * with IndexNow off, and says so, instead of failing to compile and leaving its site unserved: its
4926
+ * owner sets the key itself in the admin or with update_site, which refuses the old form.
4927
+ */
4928
+ function withoutStaleIndexNow(site, definition, log) {
4929
+ if (typeof definition !== "object" || definition === null) return definition;
4930
+ const events = Reflect.get(definition, "events");
4931
+ if (typeof events !== "object" || events === null) return definition;
4932
+ const indexNow = Reflect.get(events, "indexNow");
4933
+ if (typeof indexNow !== "object" || indexNow === null) return definition;
4934
+ const key = Reflect.get(indexNow, "key");
4935
+ if (typeof key === "string" && INDEXNOW_KEY.test(key)) return definition;
4936
+ log({
4937
+ kind: "indexnow_off",
4938
+ site,
4939
+ reason: "the definition names a secret, not the key itself"
4940
+ });
4941
+ return {
4942
+ ...definition,
4943
+ events: {
4944
+ ...events,
4945
+ indexNow: null
4946
+ }
4947
+ };
4948
+ }
4808
4949
  //#endregion
4809
4950
  //#region src/compose.ts
4810
4951
  const systemClock = { now: () => /* @__PURE__ */ new Date() };
@@ -4991,6 +5132,7 @@ function entrySummary(view) {
4991
5132
  hasUnpublishedChanges: entry.publishedRevision !== entry.headRevision,
4992
5133
  publishedAt: entry.publishedAt,
4993
5134
  scheduledAt: entry.scheduledAt,
5135
+ deletedAt: entry.deletedAt,
4994
5136
  updatedAt: entry.updatedAt
4995
5137
  };
4996
5138
  }
@@ -5326,6 +5468,10 @@ const createDraft = async (input, context) => {
5326
5468
  entry: entrySummary(written.view),
5327
5469
  verdict: verdictView(verdict)
5328
5470
  };
5471
+ case "in_trash": return {
5472
+ outcome: "in_trash",
5473
+ entry: entrySummary(written.view)
5474
+ };
5329
5475
  case "taken":
5330
5476
  case "too_large": return {
5331
5477
  outcome: "refused",
@@ -5413,6 +5559,7 @@ const listEntries$1 = async (input, context) => {
5413
5559
  locale: input.locale,
5414
5560
  tag: input.tag,
5415
5561
  status: input.status,
5562
+ trashed: input.trashed,
5416
5563
  view: context.view,
5417
5564
  limit: input.limit,
5418
5565
  cursor: input.cursor
@@ -5510,6 +5657,50 @@ const search$1 = async (input, context) => {
5510
5657
  }))
5511
5658
  };
5512
5659
  };
5660
+ //#endregion
5661
+ //#region src/ops/event.ts
5662
+ /** The path of an entry: the route of its locale, with its slug in place of `:slug`. */
5663
+ function entryPath(collection, locale, slug) {
5664
+ const route = collection.routes[locale];
5665
+ return route === void 0 ? null : route.replace(/:slug(?![A-Za-z0-9_])/, slug);
5666
+ }
5667
+ /** The event of a publish or an unpublish, ready for the outbox. */
5668
+ function eventFor(input) {
5669
+ const id = newId("evt");
5670
+ const path = entryPath(input.collection, input.entry.locale, input.entry.slug);
5671
+ const payload = {
5672
+ id,
5673
+ kind: input.kind,
5674
+ site: input.site.key,
5675
+ at: input.at,
5676
+ entry: input.entry,
5677
+ path,
5678
+ url: path === null || input.site.url === null ? null : `${input.site.url}${path}`,
5679
+ cacheTags: tagsOfChange(input.entry)
5680
+ };
5681
+ return {
5682
+ id,
5683
+ payload: JSON.stringify(payload)
5684
+ };
5685
+ }
5686
+ /**
5687
+ * The event of a change of a site's structure, ready for the outbox: the tags of the site and of
5688
+ * every list of the collections and locales of its definition before and after the change.
5689
+ */
5690
+ function siteEventFor(input) {
5691
+ const id = newId("evt");
5692
+ const payload = {
5693
+ id,
5694
+ kind: "site.changed",
5695
+ site: input.site,
5696
+ at: input.at,
5697
+ cacheTags: tagsOfSiteChange(input.collections, input.locales)
5698
+ };
5699
+ return {
5700
+ id,
5701
+ payload: JSON.stringify(payload)
5702
+ };
5703
+ }
5513
5704
  /** How many slugs of refused entries an answer names. */
5514
5705
  const EXAMPLES = 3;
5515
5706
  function structureOf(context) {
@@ -5596,15 +5787,23 @@ async function change(context, version, next, confirm) {
5596
5787
  outcome: "needs_confirmation",
5597
5788
  impact
5598
5789
  };
5790
+ const event = siteEventFor({
5791
+ site: context.site.key,
5792
+ at: context.clock.now().toISOString(),
5793
+ collections: [.../* @__PURE__ */ new Set([...Object.keys(current.definition.collections), ...Object.keys(next.collections)])],
5794
+ locales: [.../* @__PURE__ */ new Set([...current.definition.locales, ...next.locales])]
5795
+ });
5599
5796
  if (await structure.store.write({
5600
5797
  site: context.site.key,
5601
5798
  expected: version,
5602
5799
  definition: next,
5603
- actor: actor(context)
5800
+ actor: actor(context),
5801
+ event
5604
5802
  }) === "conflict") return {
5605
5803
  outcome: "conflict",
5606
5804
  currentVersion: (await currentOf(context)).version
5607
5805
  };
5806
+ context.notify?.([event.id]);
5608
5807
  await structure.changed();
5609
5808
  return {
5610
5809
  outcome: "updated",
@@ -5689,32 +5888,6 @@ const makeWebhookSecret = async (input, context) => {
5689
5888
  };
5690
5889
  };
5691
5890
  //#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
5891
  //#region src/ops/publish.ts
5719
5892
  /** A warning by what an acknowledgement names: its contract and its path, never its line. */
5720
5893
  function warningKey(warning) {
@@ -6010,7 +6183,7 @@ const listReviews = async (input, context) => {
6010
6183
  const reviews = [];
6011
6184
  for (const review of found) {
6012
6185
  const view = await context.store.findEntryById(context.site.key, review.entryId, "head");
6013
- if (view === null) throw unexpectedAnswer(context, "list_reviews", { outcome: "no entry" });
6186
+ if (view === null) continue;
6014
6187
  reviews.push({
6015
6188
  ...reviewView(review),
6016
6189
  entry: entrySummary(view),
@@ -6131,6 +6304,52 @@ const requestReview = async (input, context) => {
6131
6304
  }
6132
6305
  };
6133
6306
  //#endregion
6307
+ //#region src/ops/trash.ts
6308
+ /**
6309
+ * The trash of entries. Only a draft goes there: a published or scheduled entry is unpublished
6310
+ * first. Nothing is deleted, since revisions, tags and publications are append-only (ADR-021): the
6311
+ * entry gets its `deletedAt`, every read leaves it out, and it keeps its slug and translation
6312
+ * until it is restored as the draft it was.
6313
+ */
6314
+ /**
6315
+ * Moves a draft to the trash, if it is still at the version the caller read. The store checks it
6316
+ * all in the write: an entry that is not there (or is in the trash already) is `not_found`, another
6317
+ * version is `conflict`, and what is published or scheduled is taken off the site first.
6318
+ */
6319
+ const deleteEntry = async (input, context) => {
6320
+ const written = await context.store.trashEntry(context.site.key, {
6321
+ entryId: input.id,
6322
+ expectedVersion: input.version
6323
+ });
6324
+ switch (written.outcome) {
6325
+ case "trashed": return {
6326
+ outcome: "trashed",
6327
+ entry: entrySummary(written.view)
6328
+ };
6329
+ case "not_draft": return {
6330
+ outcome: "published",
6331
+ entry: entrySummary(written.view)
6332
+ };
6333
+ case "conflict": return {
6334
+ outcome: "conflict",
6335
+ conflict: conflictAt(written.currentVersion, written.headRevision)
6336
+ };
6337
+ case "not_found": return { outcome: "not_found" };
6338
+ }
6339
+ };
6340
+ /** Brings an entry back from the trash as the draft it was. */
6341
+ const restoreEntry = async (input, context) => {
6342
+ const written = await context.store.restoreEntry(context.site.key, input.id);
6343
+ switch (written.outcome) {
6344
+ case "restored":
6345
+ case "not_trashed": return {
6346
+ outcome: written.outcome,
6347
+ entry: entrySummary(written.view)
6348
+ };
6349
+ case "not_found": return { outcome: "not_found" };
6350
+ }
6351
+ };
6352
+ //#endregion
6134
6353
  //#region src/ops/unpublish.ts
6135
6354
  /**
6136
6355
  * Takes an entry off the public site and keeps it, with its revisions, as a draft. The store
@@ -6279,6 +6498,7 @@ const handlers = defineHandlers({
6279
6498
  content_stats: contentStats,
6280
6499
  create_draft: createDraft,
6281
6500
  create_site: createSite,
6501
+ delete_entry: deleteEntry,
6282
6502
  delete_site: deleteSite,
6283
6503
  describe_site: describeSite$1,
6284
6504
  get_entry: getEntry$1,
@@ -6291,6 +6511,7 @@ const handlers = defineHandlers({
6291
6511
  publish: publish$1,
6292
6512
  request_changes: requestChanges,
6293
6513
  request_review: requestReview,
6514
+ restore_entry: restoreEntry,
6294
6515
  restore_site: restoreSite,
6295
6516
  set_collection: setCollection,
6296
6517
  unpublish,
@@ -189,6 +189,11 @@ const entries = sqliteTable("entries", {
189
189
  publishedAt: text("published_at"),
190
190
  publishedSortKey: text("published_sort_key"),
191
191
  scheduledAt: text("scheduled_at"),
192
+ /**
193
+ * When the entry went to the trash, or null. A trashed entry keeps its row, its slug and its
194
+ * translation group: only a draft goes there, and it comes back as the draft it was.
195
+ */
196
+ deletedAt: text("deleted_at"),
192
197
  ...timestamps()
193
198
  }, (t) => [
194
199
  unique("entries_id_site").on(t.id, t.siteId),
@@ -381,14 +386,16 @@ const schedules = sqliteTable("schedules", {
381
386
  /**
382
387
  * The effects of a publication (ADR-025), written in the batch that publishes or unpublishes: one
383
388
  * row per event. The Queue consumer delivers each event to every subscriber, and records it in
384
- * `deliveries`; `sent_at` says that the event was put on the Queue.
389
+ * `deliveries`; `sent_at` says that the event was put on the Queue. A change of the structure of
390
+ * the site (ADR-033) is an event too, `site.changed`, about no entry.
385
391
  */
386
392
  const outbox = sqliteTable("outbox", {
387
393
  id: id("evt"),
388
394
  siteId: siteId(),
389
- /** `entry.published` or `entry.unpublished`. */
395
+ /** `entry.published`, `entry.unpublished`, or `site.changed`. */
390
396
  kind: text("kind").notNull(),
391
- entryId: text("entry_id").notNull(),
397
+ /** The entry of an event about one, and none for `site.changed`. */
398
+ entryId: text("entry_id"),
392
399
  /** A JSON object: what subscribers get. */
393
400
  payload: text("payload").notNull(),
394
401
  sentAt: text("sent_at"),
@@ -401,7 +408,8 @@ const outbox = sqliteTable("outbox", {
401
408
  columns: [t.siteId],
402
409
  foreignColumns: [sites.id]
403
410
  }),
404
- check("outbox_kind", sql`${t.kind} IN ('entry.published', 'entry.unpublished')`),
411
+ check("outbox_kind", sql`${t.kind} IN ('entry.published', 'entry.unpublished', 'site.changed')`),
412
+ check("outbox_entry", sql`(${t.kind} = 'site.changed') = (${t.entryId} IS NULL)`),
405
413
  check("outbox_json", sql`json_valid(${t.payload}) AND json_type(${t.payload}) = 'object'`)
406
414
  ]);
407
415
  /** Each delivery of an event to a subscriber, so that a retry never delivers it twice. */