@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/{ceremonies-D5dIEbha.mjs → ceremonies-DDoGdUD7.mjs} +1 -1
- package/dist/index.d.mts +48 -4
- package/dist/index.mjs +289 -68
- package/dist/{store-C2hiR0yd.mjs → store-BPr9-wKy.mjs} +12 -4
- package/migrations/0013_site_events.sql +31 -0
- package/migrations/0014_entry_trash.sql +1 -0
- package/migrations/meta/0013_snapshot.json +2619 -0
- package/migrations/meta/0014_snapshot.json +2626 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +8 -8
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-
|
|
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.
|
|
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
|
-
/**
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
/**
|
|
1023
|
-
|
|
1024
|
-
|
|
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
|
-
/**
|
|
1027
|
-
|
|
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
|
-
/**
|
|
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
|
|
1213
|
-
if (
|
|
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:
|
|
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:
|
|
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 =
|
|
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(
|
|
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([
|
|
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>`, `
|
|
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
|
-
|
|
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-
|
|
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)
|
|
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 `
|
|
395
|
+
/** `entry.published`, `entry.unpublished`, or `site.changed`. */
|
|
390
396
|
kind: text("kind").notNull(),
|
|
391
|
-
|
|
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. */
|