@hollware/server 0.5.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{ceremonies-BM9G03lZ.mjs → ceremonies-D5dIEbha.mjs} +1 -1
- package/dist/index.d.mts +108 -3
- package/dist/index.mjs +715 -57
- package/dist/{store-DiS8UFI2.mjs → store-C2hiR0yd.mjs} +84 -3
- package/migrations/0010_review_comments.sql +17 -0
- package/migrations/0011_structure.sql +20 -0
- package/migrations/0012_site_secrets.sql +12 -0
- package/migrations/meta/0010_snapshot.json +2388 -0
- package/migrations/meta/0011_snapshot.json +2532 -0
- package/migrations/meta/0012_snapshot.json +2615 -0
- package/migrations/meta/_journal.json +21 -0
- package/package.json +3 -2
package/dist/index.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { A as
|
|
2
|
-
import { CHUNKER_VERSION, EVENT_ID_HEADER, HollwareError, OPERATIONS, SCOPES, SIGNATURE_HEADER, TIMESTAMP_HEADER, chunksOf, describeBuiltInContracts, describeResolvedSite, describeSite, generatePersonalToken, generateReadToken, getEntry, getTranslations, hashPersonalToken, isPersonalToken, isReadToken, isScope, isSiteKey, listEntries, newId, processMarkdown, publish, renderHtml, resolveConfig, runContracts, search, signWebhook, sizeLimitViolation, uniqueSlugViolation, uniqueTranslationViolation } from "@hollware/core";
|
|
1
|
+
import { A as users, C as reviewComments, D as schedules, E as revisions, M as effectiveScopes, N as isRole, O as sites, S as publications, T as revisionTags, _ as entries, b as members, g as deliveries, j as ROLE_SCOPES, k as tokens, l as membershipsOf, n as createInvitation, s as findSession, v as entryEmbeddings, w as reviews, x as outbox, y as invitations } from "./store-C2hiR0yd.mjs";
|
|
2
|
+
import { CHUNKER_VERSION, ConfigError, EVENT_ID_HEADER, HollwareError, OPERATIONS, SCOPES, SIGNATURE_HEADER, TIMESTAMP_HEADER, canonicalJson, chunksOf, compileSites, definitionsFromConfig, describeBuiltInContracts, describeCatalog, describeResolvedSite, describeSite, generatePersonalToken, generateReadToken, getEntry, getTranslations, hashPersonalToken, isPersonalToken, isReadToken, isScope, isSiteKey, listEntries, newId, processMarkdown, publish, renderHtml, resolveConfig, runContracts, search, signWebhook, siteDefinitionData, sizeLimitViolation, tagsOfChange, uniqueSlugViolation, uniqueTranslationViolation } from "@hollware/core";
|
|
3
3
|
import { Hono } from "hono";
|
|
4
4
|
import { drizzle } from "drizzle-orm/d1";
|
|
5
|
-
import { and, desc, eq, getTableColumns, gt, inArray, isNotNull, isNull, lte, ne, or, sql } from "drizzle-orm";
|
|
5
|
+
import { and, asc, desc, eq, getTableColumns, gt, inArray, isNotNull, isNull, lte, ne, or, sql } from "drizzle-orm";
|
|
6
6
|
import { alias } from "drizzle-orm/sqlite-core";
|
|
7
7
|
import { AuthorizationError, CimdFetchError, OAuthAuthorizationServer, OAuthResourceServer, authorizationErrorRedirect } from "@cloudflare/workers-oauth-provider";
|
|
8
8
|
import { getCookie } from "hono/cookie";
|
|
@@ -10,7 +10,7 @@ import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, McpServer, accepted
|
|
|
10
10
|
import * as z from "zod";
|
|
11
11
|
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
12
12
|
//#region package.json
|
|
13
|
-
var version = "0.
|
|
13
|
+
var version = "0.8.0";
|
|
14
14
|
//#endregion
|
|
15
15
|
//#region src/auth/responses.ts
|
|
16
16
|
/** The challenge of every 401 and 403 (RFC 6750). */
|
|
@@ -389,6 +389,7 @@ const entryFields = {
|
|
|
389
389
|
headRev: entries.headRev,
|
|
390
390
|
publishedRev: entries.publishedRev,
|
|
391
391
|
publishedAt: entries.publishedAt,
|
|
392
|
+
scheduledAt: entries.scheduledAt,
|
|
392
393
|
updatedAt: entries.updatedAt
|
|
393
394
|
};
|
|
394
395
|
/** The columns of `revisions` that `StoredRevision` holds, except its tags. */
|
|
@@ -463,6 +464,7 @@ function toEntry(row) {
|
|
|
463
464
|
headRevision: row.headRev,
|
|
464
465
|
publishedRevision: row.publishedRev,
|
|
465
466
|
publishedAt: row.publishedAt,
|
|
467
|
+
scheduledAt: row.scheduledAt,
|
|
466
468
|
updatedAt: row.updatedAt
|
|
467
469
|
};
|
|
468
470
|
}
|
|
@@ -1041,7 +1043,7 @@ async function pendingOf(db, site, entryId) {
|
|
|
1041
1043
|
const [row] = await db.select(COLUMNS).from(reviews).innerJoin(users, eq(users.id, reviews.requestedBy)).where(and(eq(reviews.siteId, site), eq(reviews.entryId, entryId), eq(reviews.status, "pending"))).limit(1);
|
|
1042
1044
|
return row === void 0 ? null : toReview(row);
|
|
1043
1045
|
}
|
|
1044
|
-
async function requestReview(db, clock, site, write, whyNothingChanged) {
|
|
1046
|
+
async function requestReview$1(db, clock, site, write, whyNothingChanged) {
|
|
1045
1047
|
const now = clock.now().toISOString();
|
|
1046
1048
|
const row = {
|
|
1047
1049
|
id: newId("rvw"),
|
|
@@ -1103,6 +1105,59 @@ async function requestChanges$1(db, clock, site, write) {
|
|
|
1103
1105
|
review
|
|
1104
1106
|
};
|
|
1105
1107
|
}
|
|
1108
|
+
function metaOf$1(text) {
|
|
1109
|
+
if (text === null) return null;
|
|
1110
|
+
const parsed = JSON.parse(text);
|
|
1111
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? Object.fromEntries(Object.entries(parsed)) : null;
|
|
1112
|
+
}
|
|
1113
|
+
const COMMENT_COLUMNS = {
|
|
1114
|
+
...getTableColumns(reviewComments),
|
|
1115
|
+
authorName: users.name
|
|
1116
|
+
};
|
|
1117
|
+
function toComment(row) {
|
|
1118
|
+
return {
|
|
1119
|
+
id: row.id,
|
|
1120
|
+
reviewId: row.reviewId,
|
|
1121
|
+
authorId: row.authorId,
|
|
1122
|
+
authorName: row.authorName,
|
|
1123
|
+
authorMeta: metaOf$1(row.authorMeta),
|
|
1124
|
+
body: row.body,
|
|
1125
|
+
createdAt: row.createdAt
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* Adds a comment to a review in one statement that inserts only while the review is pending, so a
|
|
1130
|
+
* decision that lands first leaves no comment behind it.
|
|
1131
|
+
*/
|
|
1132
|
+
async function commentReview$1(db, clock, site, write) {
|
|
1133
|
+
const id = newId("rvc");
|
|
1134
|
+
const now = clock.now().toISOString();
|
|
1135
|
+
const meta = write.actor.meta === null ? null : JSON.stringify(write.actor.meta);
|
|
1136
|
+
if ((await db.all(sql`
|
|
1137
|
+
insert into ${reviewComments}
|
|
1138
|
+
(id, site_id, review_id, author_id, author_meta, body, created_at, updated_at)
|
|
1139
|
+
select ${id}, ${site}, ${write.reviewId}, ${write.actor.userId}, ${meta}, ${write.body}, ${now}, ${now}
|
|
1140
|
+
where exists (select 1 from ${reviews} where ${and(eq(reviews.id, write.reviewId), eq(reviews.siteId, site), eq(reviews.status, "pending"))})
|
|
1141
|
+
returning id
|
|
1142
|
+
`)).length === 0) return { outcome: await findReview(db, site, write.reviewId) === null ? "not_found" : "not_pending" };
|
|
1143
|
+
const [row] = await db.select(COMMENT_COLUMNS).from(reviewComments).innerJoin(users, eq(users.id, reviewComments.authorId)).where(and(eq(reviewComments.siteId, site), eq(reviewComments.id, id))).limit(1);
|
|
1144
|
+
if (row === void 0) throw new Error(`The comment ${id} that was just written is not there`);
|
|
1145
|
+
return {
|
|
1146
|
+
outcome: "commented",
|
|
1147
|
+
comment: toComment(row)
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
async function reviewCommentsOf(db, site, reviewIds) {
|
|
1151
|
+
const byReview = /* @__PURE__ */ new Map();
|
|
1152
|
+
if (reviewIds.length === 0) return byReview;
|
|
1153
|
+
const rows = await db.select(COMMENT_COLUMNS).from(reviewComments).innerJoin(users, eq(users.id, reviewComments.authorId)).where(and(eq(reviewComments.siteId, site), inArray(reviewComments.reviewId, [...reviewIds]))).orderBy(asc(reviewComments.createdAt), asc(reviewComments.id));
|
|
1154
|
+
for (const row of rows) {
|
|
1155
|
+
const list = byReview.get(row.reviewId) ?? [];
|
|
1156
|
+
list.push(toComment(row));
|
|
1157
|
+
byReview.set(row.reviewId, list);
|
|
1158
|
+
}
|
|
1159
|
+
return byReview;
|
|
1160
|
+
}
|
|
1106
1161
|
//#endregion
|
|
1107
1162
|
//#region src/db/size.ts
|
|
1108
1163
|
/**
|
|
@@ -1263,6 +1318,7 @@ async function createEntry(db, clock, site, entry) {
|
|
|
1263
1318
|
headRevision: revisionId,
|
|
1264
1319
|
publishedRevision: null,
|
|
1265
1320
|
publishedAt: null,
|
|
1321
|
+
scheduledAt: null,
|
|
1266
1322
|
updatedAt: now
|
|
1267
1323
|
},
|
|
1268
1324
|
revision: storedRevision({
|
|
@@ -1511,10 +1567,12 @@ function createD1Store(db, options = {}) {
|
|
|
1511
1567
|
dueSchedules: (site, now) => dueSchedules(database, site, now),
|
|
1512
1568
|
cancelSchedule: (site, entryId) => cancelSchedule(database, clock, site, entryId),
|
|
1513
1569
|
unpublish: (site, write) => unpublish$1(database, clock, site, write),
|
|
1514
|
-
requestReview: (site, write) => requestReview(database, clock, site, write, () => whyNothingChanged(database, site, write.entryId)),
|
|
1570
|
+
requestReview: (site, write) => requestReview$1(database, clock, site, write, () => whyNothingChanged(database, site, write.entryId)),
|
|
1515
1571
|
findReview: (site, id) => findReview(database, site, id),
|
|
1516
1572
|
listReviews: (site, query) => listReviews$1(database, site, query),
|
|
1517
|
-
requestChanges: (site, write) => requestChanges$1(database, clock, site, write)
|
|
1573
|
+
requestChanges: (site, write) => requestChanges$1(database, clock, site, write),
|
|
1574
|
+
commentReview: (site, write) => commentReview$1(database, clock, site, write),
|
|
1575
|
+
reviewComments: (site, reviewIds) => reviewCommentsOf(database, site, reviewIds)
|
|
1518
1576
|
};
|
|
1519
1577
|
}
|
|
1520
1578
|
//#endregion
|
|
@@ -1771,8 +1829,8 @@ function subscribersOf(deps) {
|
|
|
1771
1829
|
const subscribers = site.events.webhooks.map((webhook) => ({
|
|
1772
1830
|
name: `webhook:${webhook.name}`,
|
|
1773
1831
|
async send(payload, body) {
|
|
1774
|
-
const secret = deps.secret(webhook.secret);
|
|
1775
|
-
if (secret === void 0) throw new Error(`the secret ${webhook.secret} is not set`);
|
|
1832
|
+
const secret = webhook.secret === null ? await deps.ownSecret?.(webhook.name) : deps.secret(webhook.secret);
|
|
1833
|
+
if (secret === void 0) throw new Error(webhook.secret === null ? `the webhook ${webhook.name} has no secret of Hollware's yet: make it with make_webhook_secret` : `the secret ${webhook.secret} is not set`);
|
|
1776
1834
|
const timestamp = Math.floor(deps.clock.now().getTime() / 1e3);
|
|
1777
1835
|
const request = new Request(webhook.url, {
|
|
1778
1836
|
method: "POST",
|
|
@@ -1918,6 +1976,148 @@ async function consumeEvents(messages, depsOf) {
|
|
|
1918
1976
|
}
|
|
1919
1977
|
}
|
|
1920
1978
|
//#endregion
|
|
1979
|
+
//#region src/structure/seal.ts
|
|
1980
|
+
/**
|
|
1981
|
+
* Sealing a secret of the structure (ADR-033) under the Worker secret `HOLLWARE_SECRET`, so that
|
|
1982
|
+
* the database alone never holds one in clear: AES-GCM with a 256-bit key that is the SHA-256 of
|
|
1983
|
+
* that secret, and a fresh 96-bit nonce each time. What is stored is `v1.` and the base64url of
|
|
1984
|
+
* the nonce and the ciphertext with its tag. A sealed value that was tampered with, or sealed
|
|
1985
|
+
* under another `HOLLWARE_SECRET`, does not open.
|
|
1986
|
+
*/
|
|
1987
|
+
const PREFIX = "v1.";
|
|
1988
|
+
function base64url(bytes) {
|
|
1989
|
+
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1990
|
+
}
|
|
1991
|
+
function fromBase64url(text) {
|
|
1992
|
+
const padded = text.replace(/-/g, "+").replace(/_/g, "/");
|
|
1993
|
+
return Uint8Array.from(atob(padded + "=".repeat((4 - padded.length % 4) % 4)), (c) => c.charCodeAt(0));
|
|
1994
|
+
}
|
|
1995
|
+
async function keyOf(master) {
|
|
1996
|
+
if (master.length < 32) throw new Error("HOLLWARE_SECRET is shorter than 32 characters: hollware deploy makes one");
|
|
1997
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(master));
|
|
1998
|
+
return crypto.subtle.importKey("raw", digest, "AES-GCM", false, ["encrypt", "decrypt"]);
|
|
1999
|
+
}
|
|
2000
|
+
async function seal(master, value) {
|
|
2001
|
+
const nonce = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
|
|
2002
|
+
const cipher = new Uint8Array(await crypto.subtle.encrypt({
|
|
2003
|
+
name: "AES-GCM",
|
|
2004
|
+
iv: nonce
|
|
2005
|
+
}, await keyOf(master), new TextEncoder().encode(value)));
|
|
2006
|
+
const sealed = new Uint8Array(nonce.length + cipher.length);
|
|
2007
|
+
sealed.set(nonce);
|
|
2008
|
+
sealed.set(cipher, nonce.length);
|
|
2009
|
+
return `${PREFIX}${base64url(sealed)}`;
|
|
2010
|
+
}
|
|
2011
|
+
/** The value of a sealed secret, or `null` when it does not open under `master`. */
|
|
2012
|
+
async function unseal(master, sealed) {
|
|
2013
|
+
if (!sealed.startsWith(PREFIX)) return null;
|
|
2014
|
+
try {
|
|
2015
|
+
const bytes = fromBase64url(sealed.slice(3));
|
|
2016
|
+
const plain = await crypto.subtle.decrypt({
|
|
2017
|
+
name: "AES-GCM",
|
|
2018
|
+
iv: bytes.slice(0, 12)
|
|
2019
|
+
}, await keyOf(master), bytes.slice(12));
|
|
2020
|
+
return new TextDecoder().decode(plain);
|
|
2021
|
+
} catch {
|
|
2022
|
+
return null;
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
/** A new secret for a webhook: 32 random bytes, hex, behind `whsec_`. */
|
|
2026
|
+
function newWebhookSecret() {
|
|
2027
|
+
const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32));
|
|
2028
|
+
return `whsec_${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
2029
|
+
}
|
|
2030
|
+
//#endregion
|
|
2031
|
+
//#region src/structure/store.ts
|
|
2032
|
+
/**
|
|
2033
|
+
* The structure of the sites on D1 (ADR-033). Each change is one batch: the row of `sites`, moved
|
|
2034
|
+
* only if it is where the caller saw it, and its line of `site_changes`, written only if the row
|
|
2035
|
+
* moved, at the version it moved to.
|
|
2036
|
+
*/
|
|
2037
|
+
function metaOf(actor) {
|
|
2038
|
+
return actor.meta === null ? null : JSON.stringify(actor.meta);
|
|
2039
|
+
}
|
|
2040
|
+
/** The change a statement before it made, at the site's version now, if that version is new. */
|
|
2041
|
+
function appendChange(db, site, actor, now, before, after) {
|
|
2042
|
+
return db.prepare(`INSERT OR IGNORE INTO site_changes (id, site_id, version, actor_user_id, actor_meta, before, after, created_at, updated_at)
|
|
2043
|
+
SELECT ?1, id, version, ?2, ?3, ?4, ${after === "definition" ? "definition" : "NULL"}, ?5, ?5 FROM sites WHERE id = ?6 AND updated_at = ?5`).bind(newId("sit"), actor.userId, metaOf(actor), before, now, site);
|
|
2044
|
+
}
|
|
2045
|
+
function createStructureStore(db, now) {
|
|
2046
|
+
return {
|
|
2047
|
+
async read(site) {
|
|
2048
|
+
const row = await db.prepare("SELECT version, definition, deleted_at FROM sites WHERE id = ?1").bind(site).first();
|
|
2049
|
+
if (row === null || row.definition === null) return null;
|
|
2050
|
+
return {
|
|
2051
|
+
version: row.version,
|
|
2052
|
+
definition: JSON.parse(row.definition),
|
|
2053
|
+
deletedAt: row.deleted_at
|
|
2054
|
+
};
|
|
2055
|
+
},
|
|
2056
|
+
async write({ site, expected, definition, actor }) {
|
|
2057
|
+
const at = now().toISOString();
|
|
2058
|
+
const before = await db.prepare("SELECT definition FROM sites WHERE id = ?1").bind(site).first("definition");
|
|
2059
|
+
const [moved] = await db.batch([db.prepare("UPDATE sites SET definition = ?1, version = version + 1, updated_at = ?2 WHERE id = ?3 AND version = ?4 AND deleted_at IS NULL").bind(JSON.stringify(definition), at, site, expected), appendChange(db, site, actor, at, before ?? null, "definition")]);
|
|
2060
|
+
return (moved?.meta.changes ?? 0) === 1 ? "written" : "conflict";
|
|
2061
|
+
},
|
|
2062
|
+
async create({ site, definition, actor }) {
|
|
2063
|
+
const at = now().toISOString();
|
|
2064
|
+
const [made] = await db.batch([
|
|
2065
|
+
db.prepare("INSERT INTO sites (id, definition, version, created_at, updated_at) VALUES (?1, ?2, 1, ?3, ?3) ON CONFLICT (id) DO NOTHING").bind(site, JSON.stringify(definition), at),
|
|
2066
|
+
db.prepare(`INSERT INTO members (id, site_id, user_id, role, created_at, updated_at)
|
|
2067
|
+
SELECT ?1, ?2, ?3, 'owner', ?4, ?4 WHERE EXISTS (SELECT 1 FROM sites WHERE id = ?2 AND created_at = ?4 AND version = 1)
|
|
2068
|
+
ON CONFLICT (site_id, user_id) DO NOTHING`).bind(newId("mbr"), site, actor.userId, at),
|
|
2069
|
+
appendChange(db, site, actor, at, null, "definition")
|
|
2070
|
+
]);
|
|
2071
|
+
return (made?.meta.changes ?? 0) === 1 ? "created" : "taken";
|
|
2072
|
+
},
|
|
2073
|
+
async trash(site, actor) {
|
|
2074
|
+
const at = now().toISOString();
|
|
2075
|
+
const before = await db.prepare("SELECT definition FROM sites WHERE id = ?1").bind(site).first("definition");
|
|
2076
|
+
const [moved] = await db.batch([db.prepare("UPDATE sites SET deleted_at = ?1, version = version + 1, updated_at = ?1 WHERE id = ?2 AND deleted_at IS NULL AND definition IS NOT NULL").bind(at, site), appendChange(db, site, actor, at, before ?? null, "null")]);
|
|
2077
|
+
return (moved?.meta.changes ?? 0) === 1;
|
|
2078
|
+
},
|
|
2079
|
+
async restore(site, actor) {
|
|
2080
|
+
const at = now().toISOString();
|
|
2081
|
+
const [moved] = await db.batch([db.prepare("UPDATE sites SET deleted_at = NULL, version = version + 1, updated_at = ?1 WHERE id = ?2 AND deleted_at IS NOT NULL").bind(at, site), appendChange(db, site, actor, at, null, "definition")]);
|
|
2082
|
+
return (moved?.meta.changes ?? 0) === 1;
|
|
2083
|
+
},
|
|
2084
|
+
async roleOf(site, userId) {
|
|
2085
|
+
const role = await db.prepare("SELECT role FROM members WHERE site_id = ?1 AND user_id = ?2").bind(site, userId).first("role");
|
|
2086
|
+
return role === "owner" || role === "editor" || role === "author" ? role : null;
|
|
2087
|
+
},
|
|
2088
|
+
async counts(site) {
|
|
2089
|
+
const [collections, locales] = await db.batch([db.prepare("SELECT collection AS key, count(*) AS n FROM entries WHERE site_id = ?1 GROUP BY collection").bind(site), db.prepare("SELECT locale AS key, count(*) AS n FROM entries WHERE site_id = ?1 GROUP BY locale").bind(site)]);
|
|
2090
|
+
const mapOf = (rows) => new Map((rows ?? []).map((row) => [row.key, row.n]));
|
|
2091
|
+
return {
|
|
2092
|
+
byCollection: mapOf(collections?.results),
|
|
2093
|
+
byLocale: mapOf(locales?.results)
|
|
2094
|
+
};
|
|
2095
|
+
},
|
|
2096
|
+
async frontmatters(site, collection, limit) {
|
|
2097
|
+
const { results } = await db.prepare(`SELECT e.slug AS slug, r.frontmatter AS frontmatter FROM entries e
|
|
2098
|
+
JOIN revisions r ON r.id = e.head_rev AND r.site_id = e.site_id
|
|
2099
|
+
WHERE e.site_id = ?1 AND e.collection = ?2 ORDER BY e.slug LIMIT ?3`).bind(site, collection, limit).all();
|
|
2100
|
+
return results.map((row) => ({
|
|
2101
|
+
slug: row.slug,
|
|
2102
|
+
frontmatter: JSON.parse(row.frontmatter)
|
|
2103
|
+
}));
|
|
2104
|
+
}
|
|
2105
|
+
};
|
|
2106
|
+
}
|
|
2107
|
+
/** The sealed secrets of the structure of a site (ADR-033), by name. */
|
|
2108
|
+
function createSecretStore(db, now) {
|
|
2109
|
+
return {
|
|
2110
|
+
async read(site, name) {
|
|
2111
|
+
return db.prepare("SELECT sealed FROM site_secrets WHERE site_id = ?1 AND name = ?2").bind(site, name).first("sealed");
|
|
2112
|
+
},
|
|
2113
|
+
async write(site, name, sealed) {
|
|
2114
|
+
const at = now().toISOString();
|
|
2115
|
+
await db.prepare(`INSERT INTO site_secrets (id, site_id, name, sealed, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?5)
|
|
2116
|
+
ON CONFLICT (site_id, name) DO UPDATE SET sealed = excluded.sealed, updated_at = excluded.updated_at`).bind(newId("sec"), site, name, sealed, at).run();
|
|
2117
|
+
}
|
|
2118
|
+
};
|
|
2119
|
+
}
|
|
2120
|
+
//#endregion
|
|
1921
2121
|
//#region src/mcp/errors.ts
|
|
1922
2122
|
/**
|
|
1923
2123
|
* The error a caller gets for anything that is not a `HollwareError`. The message is fixed: what
|
|
@@ -2114,6 +2314,24 @@ function installAdmin(app, options) {
|
|
|
2114
2314
|
});
|
|
2115
2315
|
}
|
|
2116
2316
|
//#endregion
|
|
2317
|
+
//#region src/identity/people.ts
|
|
2318
|
+
/** The owners of the site besides `userId`, counted in the statement that changes `userId`. */
|
|
2319
|
+
const OTHER_OWNERS = "(SELECT count(*) FROM members WHERE site_id = ?1 AND role = 'owner' AND user_id <> ?2) > 0";
|
|
2320
|
+
async function roleOf$1(db, site, userId) {
|
|
2321
|
+
return db.prepare("SELECT role FROM members WHERE site_id = ?1 AND user_id = ?2").bind(site, userId).first("role");
|
|
2322
|
+
}
|
|
2323
|
+
async function setRole(db, input) {
|
|
2324
|
+
const current = await roleOf$1(db, input.site, input.userId);
|
|
2325
|
+
if (current === null) return "not_member";
|
|
2326
|
+
if (current === input.role) return "unchanged";
|
|
2327
|
+
return (await db.prepare(`UPDATE members SET role = ?3, updated_at = ?4 WHERE site_id = ?1 AND user_id = ?2
|
|
2328
|
+
AND (role <> 'owner' OR ?3 = 'owner' OR ${OTHER_OWNERS})`).bind(input.site, input.userId, input.role, input.now).run()).meta.changes === 1 ? "changed" : "last_owner";
|
|
2329
|
+
}
|
|
2330
|
+
async function removeMember(db, input) {
|
|
2331
|
+
if (await roleOf$1(db, input.site, input.userId) === null) return "not_member";
|
|
2332
|
+
return (await db.prepare(`DELETE FROM members WHERE site_id = ?1 AND user_id = ?2 AND (role <> 'owner' OR ${OTHER_OWNERS})`).bind(input.site, input.userId).run()).meta.changes === 1 ? "changed" : "last_owner";
|
|
2333
|
+
}
|
|
2334
|
+
//#endregion
|
|
2117
2335
|
//#region src/http/admin-api.ts
|
|
2118
2336
|
/**
|
|
2119
2337
|
* What only a person in the admin may do (ADR-026): see, make and revoke personal tokens, and
|
|
@@ -2131,9 +2349,16 @@ function json$1(body, status = 200) {
|
|
|
2131
2349
|
headers: { "cache-control": "no-store" }
|
|
2132
2350
|
});
|
|
2133
2351
|
}
|
|
2352
|
+
/** The code of each refusal: what the input breaks, who may not, what is not there, what clashes. */
|
|
2353
|
+
const REFUSAL_CODES = {
|
|
2354
|
+
400: "invalid_input",
|
|
2355
|
+
403: "forbidden",
|
|
2356
|
+
404: "not_found",
|
|
2357
|
+
409: "conflict"
|
|
2358
|
+
};
|
|
2134
2359
|
function refused(message, fix, status = 400) {
|
|
2135
2360
|
return json$1({
|
|
2136
|
-
code: status
|
|
2361
|
+
code: REFUSAL_CODES[status],
|
|
2137
2362
|
message,
|
|
2138
2363
|
fix
|
|
2139
2364
|
}, status);
|
|
@@ -2171,12 +2396,20 @@ function installAdminApi(app, options) {
|
|
|
2171
2396
|
id: users.id,
|
|
2172
2397
|
name: users.name
|
|
2173
2398
|
}).from(users).where(eq(users.id, person.userId));
|
|
2174
|
-
const
|
|
2399
|
+
const memberships = await membershipsOf(db, person.userId);
|
|
2400
|
+
const sites = memberships.filter((membership) => options.sites.has(membership.site));
|
|
2401
|
+
const { results: trashed } = await c.env.DB.prepare("SELECT id, deleted_at FROM sites WHERE deleted_at IS NOT NULL").all();
|
|
2402
|
+
const owned = new Set(memberships.filter((membership) => membership.role === "owner").map((m) => m.site));
|
|
2403
|
+
const trash = trashed.filter((row) => owned.has(row.id)).map((row) => ({
|
|
2404
|
+
site: row.id,
|
|
2405
|
+
deletedAt: row.deleted_at
|
|
2406
|
+
}));
|
|
2175
2407
|
return json$1({
|
|
2176
2408
|
user,
|
|
2177
2409
|
site: person.site,
|
|
2178
2410
|
scopes: person.scopes,
|
|
2179
|
-
sites
|
|
2411
|
+
sites,
|
|
2412
|
+
trash
|
|
2180
2413
|
});
|
|
2181
2414
|
}, { anyone: true }));
|
|
2182
2415
|
app.get("/v1/admin/tokens", admin(async (c, person) => {
|
|
@@ -2271,6 +2504,36 @@ function installAdminApi(app, options) {
|
|
|
2271
2504
|
}).from(invitations).where(and(eq(invitations.siteId, person.site), isNull(invitations.usedAt), isNull(invitations.userId), gt(invitations.expiresAt, now))).orderBy(desc(invitations.createdAt))
|
|
2272
2505
|
});
|
|
2273
2506
|
}));
|
|
2507
|
+
/** What a change of a member answers, by what it did. */
|
|
2508
|
+
const answerOf = (change) => {
|
|
2509
|
+
switch (change) {
|
|
2510
|
+
case "changed":
|
|
2511
|
+
case "unchanged": return json$1({ outcome: change });
|
|
2512
|
+
case "not_member": return refused("That person is no member of this site.", "Pick someone from the list of people.", 404);
|
|
2513
|
+
case "last_owner": return refused("A site keeps at least one owner, and this is its last one.", "Make another person an owner first, then change this one.", 409);
|
|
2514
|
+
}
|
|
2515
|
+
};
|
|
2516
|
+
app.post("/v1/admin/people/role", admin(async (c, person) => {
|
|
2517
|
+
const input = await body(c);
|
|
2518
|
+
const role = input["role"];
|
|
2519
|
+
const userId = input["userId"];
|
|
2520
|
+
if (typeof userId !== "string" || typeof role !== "string" || !isRole(role)) return refused("Give the userId of a member and a role.", `Send { "userId": "usr_…", "role": "owner" | "editor" | "author" }.`);
|
|
2521
|
+
const now = options.clock.now().toISOString();
|
|
2522
|
+
return answerOf(await setRole(c.env.DB, {
|
|
2523
|
+
site: person.site,
|
|
2524
|
+
userId,
|
|
2525
|
+
role,
|
|
2526
|
+
now
|
|
2527
|
+
}));
|
|
2528
|
+
}));
|
|
2529
|
+
app.post("/v1/admin/people/remove", admin(async (c, person) => {
|
|
2530
|
+
const userId = (await body(c))["userId"];
|
|
2531
|
+
if (typeof userId !== "string") return refused("Give the userId of a member.", "Send { \"userId\": \"usr_…\" }.");
|
|
2532
|
+
return answerOf(await removeMember(c.env.DB, {
|
|
2533
|
+
site: person.site,
|
|
2534
|
+
userId
|
|
2535
|
+
}));
|
|
2536
|
+
}));
|
|
2274
2537
|
app.post("/v1/admin/invitations", admin(async (c, person) => {
|
|
2275
2538
|
const input = await body(c);
|
|
2276
2539
|
const role = input["role"];
|
|
@@ -2485,6 +2748,17 @@ function analyticsReaderOf(env) {
|
|
|
2485
2748
|
});
|
|
2486
2749
|
}
|
|
2487
2750
|
//#endregion
|
|
2751
|
+
//#region src/ops/invoke.ts
|
|
2752
|
+
/**
|
|
2753
|
+
* Calls the handler of an operation. This is the one place where the typed pairing of specs and
|
|
2754
|
+
* handlers meets a dynamic caller (MCP, the ops API): the caller parsed `input` with the input
|
|
2755
|
+
* schema of `spec`, which is the schema that types the handler of the same name.
|
|
2756
|
+
*/
|
|
2757
|
+
function invoke(handlers, spec, input, context) {
|
|
2758
|
+
const handler = handlers[spec.name];
|
|
2759
|
+
return handler(input, context);
|
|
2760
|
+
}
|
|
2761
|
+
//#endregion
|
|
2488
2762
|
//#region src/http/ops-api.ts
|
|
2489
2763
|
const STATUS$1 = {
|
|
2490
2764
|
invalid_input: 400,
|
|
@@ -2499,10 +2773,6 @@ function json(body, status = 200) {
|
|
|
2499
2773
|
});
|
|
2500
2774
|
}
|
|
2501
2775
|
/** Calls the handler of an operation with input its spec has parsed. */
|
|
2502
|
-
function invoke$1(handlers, spec, input, context) {
|
|
2503
|
-
const handler = handlers[spec.name];
|
|
2504
|
-
return handler(input, context);
|
|
2505
|
-
}
|
|
2506
2776
|
function installOpsApi(app, options) {
|
|
2507
2777
|
app.post("/v1/ops/:name", async (c) => options.protect(c, async (principal) => {
|
|
2508
2778
|
const requestId = c.get("requestId");
|
|
@@ -2536,10 +2806,11 @@ function installOpsApi(app, options) {
|
|
|
2536
2806
|
requestId,
|
|
2537
2807
|
notify: options.notify(c, requestId, site.key),
|
|
2538
2808
|
semantic: semanticOfEnv(c.env),
|
|
2539
|
-
analytics: analyticsReaderOf(c.env)
|
|
2809
|
+
analytics: analyticsReaderOf(c.env),
|
|
2810
|
+
...options.structure === void 0 ? {} : { structure: options.structure(c.env.DB, c.env.HOLLWARE_SECRET) }
|
|
2540
2811
|
};
|
|
2541
2812
|
try {
|
|
2542
|
-
return json(spec.output.parse(await invoke
|
|
2813
|
+
return json(spec.output.parse(await invoke(options.handlers, spec, parsed.data, context)));
|
|
2543
2814
|
} catch (error) {
|
|
2544
2815
|
if (error instanceof HollwareError) return json({
|
|
2545
2816
|
code: error.code,
|
|
@@ -2661,7 +2932,7 @@ function installReadApi(app, options) {
|
|
|
2661
2932
|
if (crossOrigin !== null && !options.corsOrigins(principal.site).includes(crossOrigin)) return c.json({
|
|
2662
2933
|
code: "forbidden_origin",
|
|
2663
2934
|
message: `The site "${principal.site}" does not let pages of ${crossOrigin} read it.`,
|
|
2664
|
-
fix: `Add "${crossOrigin}" to cors.origins of the site in
|
|
2935
|
+
fix: `Add "${crossOrigin}" to the cors.origins of the site (in the admin, Site, or with update_site), or read from a server.`
|
|
2665
2936
|
}, 403, {
|
|
2666
2937
|
vary: "Origin",
|
|
2667
2938
|
"access-control-allow-origin": new URL(c.env.HOLLWARE_PUBLIC_URL).origin
|
|
@@ -2823,10 +3094,10 @@ function openApiDocument() {
|
|
|
2823
3094
|
*/
|
|
2824
3095
|
/**
|
|
2825
3096
|
* The look of hollware.com and the admin, with nothing to fetch: the page's policy loads no font,
|
|
2826
|
-
* so the type is the system's.
|
|
3097
|
+
* so the type is the system's. Dark only, the neutral greys of the theme, white as the primary
|
|
3098
|
+
* color and emerald for the mark (tests/conventions/brand.test.ts).
|
|
2827
3099
|
*/
|
|
2828
|
-
const STYLE = `:root{color-scheme:
|
|
2829
|
-
@media (prefers-color-scheme:dark){:root{--bg:#08090a;--card:#0f1113;--fg:#eceef0;--muted:#8a939e;--line:rgb(255 255 255/.1);--accent:#3ee6a8;--on-accent:#04140d;--danger:#ff8a80}}
|
|
3100
|
+
const STYLE = `:root{color-scheme:dark;--bg:#0a0a0a;--card:#171717;--fg:#f5f5f5;--muted:#a3a3a3;--line:rgb(255 255 255/.1);--accent:#fafafa;--on-accent:#0a0a0a;--mark:#34d399;--danger:#ff8a80}
|
|
2830
3101
|
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:2rem 1rem;background:var(--bg);color:var(--fg);font:15px/1.6 system-ui,-apple-system,'Segoe UI',sans-serif;-webkit-font-smoothing:antialiased}
|
|
2831
3102
|
main{width:100%;max-width:26rem;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:2rem}
|
|
2832
3103
|
.brand{display:flex;align-items:center;gap:.6rem;margin-bottom:1.75rem;font-weight:600;letter-spacing:-.01em}.brand svg{width:26px;height:26px}
|
|
@@ -2839,7 +3110,7 @@ button{margin-top:1.25rem;padding:.6rem 1rem;font:inherit;font-weight:500;cursor
|
|
|
2839
3110
|
button:not([value=deny]){background:var(--accent);border-color:var(--accent);color:var(--on-accent)}
|
|
2840
3111
|
[role=alert]{color:var(--danger)}`;
|
|
2841
3112
|
/** The mark of Hollware, before the title of every page. */
|
|
2842
|
-
const BRAND = `<div class="brand"><svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="8" fill="currentColor" opacity=".08"/><path d="M9 8v16M23 8v16M9 16h14" stroke="var(--
|
|
3113
|
+
const BRAND = `<div class="brand"><svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="8" fill="currentColor" opacity=".08"/><path d="M9 8v16M23 8v16M9 16h14" stroke="var(--mark)" stroke-width="3" stroke-linecap="round" fill="none"/></svg>Hollware</div>`;
|
|
2843
3114
|
const POST = `async function post(path, body) {
|
|
2844
3115
|
const response = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
2845
3116
|
const data = await response.json()
|
|
@@ -2996,7 +3267,7 @@ function plainPageHeaders() {
|
|
|
2996
3267
|
* The ceremonies, with SimpleWebAuthn and its parsers, in a chunk of their own: only these four
|
|
2997
3268
|
* routes use them, and a Worker that serves MCP never pays for them at startup (`budgets.json`).
|
|
2998
3269
|
*/
|
|
2999
|
-
const ceremonies = () => import("./ceremonies-
|
|
3270
|
+
const ceremonies = () => import("./ceremonies-D5dIEbha.mjs");
|
|
3000
3271
|
/** The cookie of a signed-in person: only `/authorize` reads it (ADR-026). */
|
|
3001
3272
|
const SESSION_COOKIE = "hollware_session";
|
|
3002
3273
|
function sessionCookie(secret) {
|
|
@@ -3434,7 +3705,7 @@ function installEvents(app, options) {
|
|
|
3434
3705
|
const path = typeof body === "object" && body !== null && "path" in body ? pathOf(body.path) : null;
|
|
3435
3706
|
if (site === void 0 || path === null) return refusal(400, "invalid_input", "A beacon is JSON with the key of a site this Hollware serves and the path of a page.", "Send { \"site\": \"<site key>\", \"path\": \"/<path of the page>\" }.");
|
|
3436
3707
|
const origin = c.req.header("origin") ?? null;
|
|
3437
|
-
if (origin === null || !pageOriginsOf(site).includes(origin)) return refusal(403, "forbidden_origin", `Only pages of the site "${site.key}" count its views.`, `Set url of the site
|
|
3708
|
+
if (origin === null || !pageOriginsOf(site).includes(origin)) return refusal(403, "forbidden_origin", `Only pages of the site "${site.key}" count its views.`, `Set the url of the site to the origin of its pages, or add the origin to its cors.origins: in the admin, Site, or with update_site.`);
|
|
3438
3709
|
const cf = Reflect.get(c.req.raw, "cf");
|
|
3439
3710
|
const country = typeof cf === "object" && cf !== null && "country" in cf && typeof cf.country === "string" ? cf.country : "XX";
|
|
3440
3711
|
analyticsWriterOf(c.env)?.record({
|
|
@@ -3490,7 +3761,7 @@ function withRefusalBody(response, refused) {
|
|
|
3490
3761
|
*/
|
|
3491
3762
|
function createGateway(options) {
|
|
3492
3763
|
const { sites, mcp, clock, log } = options;
|
|
3493
|
-
const siteKeys = new Set(sites.keys());
|
|
3764
|
+
const siteKeys = options.siteKeys ?? new Set(sites.keys());
|
|
3494
3765
|
const app = new Hono();
|
|
3495
3766
|
/**
|
|
3496
3767
|
* The Worker's own context of each request. Hono hands its routes the same object under a
|
|
@@ -3527,12 +3798,26 @@ function createGateway(options) {
|
|
|
3527
3798
|
});
|
|
3528
3799
|
}));
|
|
3529
3800
|
};
|
|
3801
|
+
/** The structure of the sites in D1, and how this isolate sees a change of it at once. */
|
|
3802
|
+
const structureOf = (db, master) => ({
|
|
3803
|
+
store: createStructureStore(db, () => clock.now()),
|
|
3804
|
+
changed: async () => {
|
|
3805
|
+
await options.refresh?.(db, { force: true });
|
|
3806
|
+
},
|
|
3807
|
+
secrets: { make: async (site, name) => {
|
|
3808
|
+
if (master === void 0) return null;
|
|
3809
|
+
const secret = newWebhookSecret();
|
|
3810
|
+
await createSecretStore(db, () => clock.now()).write(site, name, await seal(master, secret));
|
|
3811
|
+
return secret;
|
|
3812
|
+
} }
|
|
3813
|
+
});
|
|
3530
3814
|
/**
|
|
3531
3815
|
* A protected route: the resource server of `resource` answers a request without a token and a
|
|
3532
3816
|
* token it refuses (401, logged with the reason), and `proceed` gets the principal of the rest.
|
|
3533
3817
|
*/
|
|
3534
3818
|
const protect = async (c, resource, proceed) => {
|
|
3535
3819
|
const requestId = c.get("requestId");
|
|
3820
|
+
if (c.req.header("authorization") !== void 0) await options.refresh?.(c.env.DB);
|
|
3536
3821
|
/** What the check said besides the answer: why it refused, or what it threw. */
|
|
3537
3822
|
const outcome = {
|
|
3538
3823
|
refusal: null,
|
|
@@ -3559,8 +3844,17 @@ function createGateway(options) {
|
|
|
3559
3844
|
});
|
|
3560
3845
|
return withRefusalBody(response, outcome.refusal !== null);
|
|
3561
3846
|
};
|
|
3562
|
-
/**
|
|
3563
|
-
const
|
|
3847
|
+
/** A read of the read API, the one kind of request a page of another origin may make. */
|
|
3848
|
+
const isCorsRead = (request) => {
|
|
3849
|
+
const { pathname } = new URL(request.url);
|
|
3850
|
+
return [
|
|
3851
|
+
"GET",
|
|
3852
|
+
"HEAD",
|
|
3853
|
+
"OPTIONS"
|
|
3854
|
+
].includes(request.method) && pathname.startsWith("/v1/") && !pathname.startsWith("/v1/admin/");
|
|
3855
|
+
};
|
|
3856
|
+
/** Whether the definition of some site lets pages of `origin` read it. */
|
|
3857
|
+
const anyCorsOrigin = (origin) => [...sites.values()].some((site) => site.cors.origins.includes(origin));
|
|
3564
3858
|
/**
|
|
3565
3859
|
* The guard lets a page of such an origin through to the read API alone, and only to read: the
|
|
3566
3860
|
* read API then checks the origin against the site of the token.
|
|
@@ -3568,11 +3862,7 @@ function createGateway(options) {
|
|
|
3568
3862
|
const readsFromAnyCorsOrigin = (request, origin) => {
|
|
3569
3863
|
const { pathname } = new URL(request.url);
|
|
3570
3864
|
if (request.method === "POST" && pathname === "/events") return true;
|
|
3571
|
-
return
|
|
3572
|
-
"GET",
|
|
3573
|
-
"HEAD",
|
|
3574
|
-
"OPTIONS"
|
|
3575
|
-
].includes(request.method) && pathname.startsWith("/v1/") && !pathname.startsWith("/v1/admin/");
|
|
3865
|
+
return anyCorsOrigin(origin) && isCorsRead(request);
|
|
3576
3866
|
};
|
|
3577
3867
|
/**
|
|
3578
3868
|
* A route for a person or their personal token: a read token reads over the read API alone, so
|
|
@@ -3591,10 +3881,20 @@ function createGateway(options) {
|
|
|
3591
3881
|
await next();
|
|
3592
3882
|
});
|
|
3593
3883
|
app.use("*", async (c, next) => {
|
|
3884
|
+
const origin = c.req.header("origin");
|
|
3885
|
+
if (origin !== void 0 && (isCorsRead(c.req.raw) || readsFromAnyCorsOrigin(c.req.raw, origin))) await options.refresh?.(c.env.DB);
|
|
3594
3886
|
const refusal = checkHostAndOrigin(c.req.raw, c.env.HOLLWARE_PUBLIC_URL, readsFromAnyCorsOrigin);
|
|
3595
3887
|
if (refusal !== null) return refusal;
|
|
3596
3888
|
await next();
|
|
3597
3889
|
});
|
|
3890
|
+
for (const path of [
|
|
3891
|
+
"/authorize",
|
|
3892
|
+
"/oauth/*",
|
|
3893
|
+
"/events"
|
|
3894
|
+
]) app.use(path, async (c, next) => {
|
|
3895
|
+
await options.refresh?.(c.env.DB);
|
|
3896
|
+
await next();
|
|
3897
|
+
});
|
|
3598
3898
|
app.get("/health", () => Response.json({ ok: true }, { headers: { "cache-control": "no-store" } }));
|
|
3599
3899
|
app.all("/mcp", async (c) => protectWrites(c, "mcp", async (principal) => {
|
|
3600
3900
|
const requestId = c.get("requestId");
|
|
@@ -3611,7 +3911,8 @@ function createGateway(options) {
|
|
|
3611
3911
|
clock,
|
|
3612
3912
|
notify: notify(c, requestId, site.key),
|
|
3613
3913
|
semantic: semanticOfEnv(c.env),
|
|
3614
|
-
analytics: analyticsReaderOf(c.env)
|
|
3914
|
+
analytics: analyticsReaderOf(c.env),
|
|
3915
|
+
structure: structureOf(db, c.env.HOLLWARE_SECRET)
|
|
3615
3916
|
}
|
|
3616
3917
|
});
|
|
3617
3918
|
}));
|
|
@@ -3645,12 +3946,13 @@ function createGateway(options) {
|
|
|
3645
3946
|
log,
|
|
3646
3947
|
protect: (c, proceed) => protectWrites(c, "v1", proceed),
|
|
3647
3948
|
metadataUrl: (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1,
|
|
3648
|
-
notify
|
|
3949
|
+
notify,
|
|
3950
|
+
structure: structureOf
|
|
3649
3951
|
});
|
|
3650
3952
|
if (options.read !== void 0) installReadApi(app, {
|
|
3651
3953
|
read: options.read,
|
|
3652
3954
|
corsOrigins: (site) => sites.get(site)?.cors.origins ?? [],
|
|
3653
|
-
anyCorsOrigin
|
|
3955
|
+
anyCorsOrigin,
|
|
3654
3956
|
protect: (c, proceed) => protect(c, "v1", proceed),
|
|
3655
3957
|
metadataUrl: (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1
|
|
3656
3958
|
});
|
|
@@ -3843,6 +4145,7 @@ const encoder = new TextEncoder();
|
|
|
3843
4145
|
*/
|
|
3844
4146
|
const OUTPUT_TOO_LARGE_FIX = {
|
|
3845
4147
|
approve_review: "The operation ran and its change may have been applied: read the review with list_reviews before you repeat it.",
|
|
4148
|
+
comment_review: "The operation ran and its comment may have been added: read the review with list_reviews before you repeat it.",
|
|
3846
4149
|
content_stats: "Ask for less: pass a smaller limit.",
|
|
3847
4150
|
create_draft: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
|
|
3848
4151
|
describe_site: "The description of the site does not fit in one answer. Document fewer collections, components and contracts, or shorten their descriptions.",
|
|
@@ -3852,9 +4155,17 @@ const OUTPUT_TOO_LARGE_FIX = {
|
|
|
3852
4155
|
list_reviews: "Ask for less: pass a smaller limit.",
|
|
3853
4156
|
publish: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
|
|
3854
4157
|
request_changes: "The operation ran and its change may have been applied: read the review with list_reviews before you repeat it.",
|
|
4158
|
+
request_review: "The operation ran and its review may have been asked: read it with list_reviews before you repeat it.",
|
|
3855
4159
|
search: "Ask for less: pass a smaller limit.",
|
|
3856
4160
|
unpublish: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
|
|
3857
4161
|
update_draft: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
|
|
4162
|
+
create_site: "The site was made and holds little: read it with get_structure from a token of that site.",
|
|
4163
|
+
delete_site: "Nothing to ask for less of: the answer is a count. Try again.",
|
|
4164
|
+
make_webhook_secret: "Nothing to ask for less of: the answer is one secret. Try again.",
|
|
4165
|
+
get_structure: "The structure does not fit in one answer: read the collections with describe_site instead, and change them one at a time with set_collection.",
|
|
4166
|
+
restore_site: "Nothing to ask for less of: the answer is an outcome. Try again.",
|
|
4167
|
+
set_collection: "Too many problems for one answer: fix the first ones and send the collection again.",
|
|
4168
|
+
update_site: "Too many problems for one answer: fix the first ones, or change one collection at a time with set_collection.",
|
|
3858
4169
|
validate: "The verdict lists more problems than fit in one answer. Fix the first ones and validate again."
|
|
3859
4170
|
};
|
|
3860
4171
|
/**
|
|
@@ -4047,15 +4358,6 @@ function authInfoOf(principal, resourceMetadataUrl) {
|
|
|
4047
4358
|
};
|
|
4048
4359
|
}
|
|
4049
4360
|
/**
|
|
4050
|
-
* Calls the handler of an operation. This is the one place where the typed pairing of specs and
|
|
4051
|
-
* handlers meets the dynamic protocol: the SDK parsed `input` with the input schema of `spec`,
|
|
4052
|
-
* which is the schema that types the handler of the same name.
|
|
4053
|
-
*/
|
|
4054
|
-
function invoke(handlers, spec, input, context) {
|
|
4055
|
-
const handler = handlers[spec.name];
|
|
4056
|
-
return handler(input, context);
|
|
4057
|
-
}
|
|
4058
|
-
/**
|
|
4059
4361
|
* The MCP endpoint of Hollware: one tool for each operation, served in both protocol eras, for
|
|
4060
4362
|
* the gateway to mount at `/mcp` after it has checked the Host, the Origin and the token.
|
|
4061
4363
|
*
|
|
@@ -4263,10 +4565,15 @@ function forCaller(deps, requestId, method, error) {
|
|
|
4263
4565
|
* a call that fails never opens a session.
|
|
4264
4566
|
*/
|
|
4265
4567
|
async function run(deps, env, method, site, schema, build, handler) {
|
|
4568
|
+
const requestId = crypto.randomUUID();
|
|
4569
|
+
try {
|
|
4570
|
+
await deps.refresh?.(env.DB);
|
|
4571
|
+
} catch (error) {
|
|
4572
|
+
throw forCaller(deps, requestId, method, error);
|
|
4573
|
+
}
|
|
4266
4574
|
const served = deps.sites.get(site);
|
|
4267
4575
|
if (served === void 0) throw unknownSite(site, deps.sites.keys());
|
|
4268
4576
|
const input = parseInput(schema, build());
|
|
4269
|
-
const requestId = crypto.randomUUID();
|
|
4270
4577
|
const context = {
|
|
4271
4578
|
site: served,
|
|
4272
4579
|
principal: BINDING_PRINCIPAL,
|
|
@@ -4431,9 +4738,80 @@ async function runDueSchedules(input) {
|
|
|
4431
4738
|
cancelled
|
|
4432
4739
|
};
|
|
4433
4740
|
}
|
|
4741
|
+
async function moveConfig(db, seed, now) {
|
|
4742
|
+
const statements = Object.entries(seed).flatMap(([key, definition]) => {
|
|
4743
|
+
const json = JSON.stringify(definition);
|
|
4744
|
+
return [db.prepare(`INSERT INTO sites (id, definition, version, created_at, updated_at) VALUES (?1, ?2, 1, ?3, ?3)
|
|
4745
|
+
ON CONFLICT (id) DO UPDATE SET definition = excluded.definition, version = 1, updated_at = excluded.updated_at
|
|
4746
|
+
WHERE sites.definition IS NULL`).bind(key, json, now), db.prepare(`INSERT OR IGNORE INTO site_changes (id, site_id, version, actor_user_id, actor_meta, before, after, created_at, updated_at)
|
|
4747
|
+
SELECT ?1, ?2, 1, NULL, NULL, NULL, definition, ?3, ?3 FROM sites WHERE id = ?2 AND version = 1`).bind(newId("sit"), key, now)];
|
|
4748
|
+
});
|
|
4749
|
+
if (statements.length > 0) await db.batch(statements);
|
|
4750
|
+
}
|
|
4751
|
+
function createSiteRegistry(options) {
|
|
4752
|
+
const pollMs = options.pollMs ?? 1e3;
|
|
4753
|
+
const sites = /* @__PURE__ */ new Map();
|
|
4754
|
+
const keys = /* @__PURE__ */ new Set();
|
|
4755
|
+
let compiled = /* @__PURE__ */ new Map();
|
|
4756
|
+
let polledAt = Number.NEGATIVE_INFINITY;
|
|
4757
|
+
let moved = options.seed === void 0;
|
|
4758
|
+
const refresh = async (db, refreshOptions = {}) => {
|
|
4759
|
+
const now = options.now();
|
|
4760
|
+
if (refreshOptions.force !== true && now.getTime() - polledAt < pollMs) return;
|
|
4761
|
+
if (!moved && options.seed !== void 0) {
|
|
4762
|
+
await moveConfig(db, options.seed, now.toISOString());
|
|
4763
|
+
moved = true;
|
|
4764
|
+
}
|
|
4765
|
+
const { results: versions } = await db.prepare("SELECT id, version, (definition IS NOT NULL AND deleted_at IS NULL) AS served FROM sites").all();
|
|
4766
|
+
const stale = versions.filter((row) => row.served === 1 && compiled.get(row.id)?.version !== row.version);
|
|
4767
|
+
const fetched = stale.length === 0 ? [] : (await db.prepare(`SELECT id, version, definition FROM sites WHERE id IN (${stale.map((_, index) => `?${index + 1}`).join(", ")})`).bind(...stale.map((row) => row.id)).all()).results;
|
|
4768
|
+
const next = /* @__PURE__ */ new Map();
|
|
4769
|
+
for (const row of versions) {
|
|
4770
|
+
const current = compiled.get(row.id);
|
|
4771
|
+
if (row.served === 1 && current !== void 0 && current.version >= row.version) next.set(row.id, current);
|
|
4772
|
+
}
|
|
4773
|
+
for (const row of fetched) {
|
|
4774
|
+
if (row.definition === null || (next.get(row.id)?.version ?? -1) >= row.version) continue;
|
|
4775
|
+
try {
|
|
4776
|
+
const definition = JSON.parse(row.definition);
|
|
4777
|
+
const site = compileSites({ [row.id]: definition }).sites.get(row.id);
|
|
4778
|
+
if (site !== void 0) next.set(row.id, {
|
|
4779
|
+
version: row.version,
|
|
4780
|
+
site
|
|
4781
|
+
});
|
|
4782
|
+
} catch (error) {
|
|
4783
|
+
options.log({
|
|
4784
|
+
kind: "site_definition_invalid",
|
|
4785
|
+
site: row.id,
|
|
4786
|
+
version: row.version,
|
|
4787
|
+
message: error instanceof ConfigError ? error.message : String(error)
|
|
4788
|
+
});
|
|
4789
|
+
const previous = compiled.get(row.id);
|
|
4790
|
+
if (previous !== void 0) next.set(row.id, previous);
|
|
4791
|
+
}
|
|
4792
|
+
}
|
|
4793
|
+
compiled = next;
|
|
4794
|
+
sites.clear();
|
|
4795
|
+
keys.clear();
|
|
4796
|
+
for (const [key, value] of next) {
|
|
4797
|
+
sites.set(key, value.site);
|
|
4798
|
+
keys.add(key);
|
|
4799
|
+
}
|
|
4800
|
+
polledAt = now.getTime();
|
|
4801
|
+
};
|
|
4802
|
+
return {
|
|
4803
|
+
sites,
|
|
4804
|
+
keys,
|
|
4805
|
+
refresh
|
|
4806
|
+
};
|
|
4807
|
+
}
|
|
4434
4808
|
//#endregion
|
|
4435
4809
|
//#region src/compose.ts
|
|
4436
4810
|
const systemClock = { now: () => /* @__PURE__ */ new Date() };
|
|
4811
|
+
/** Whether the run of the cron at `time` wakes the agents: on the minutes that divide by ten. */
|
|
4812
|
+
function wakesAgents(time) {
|
|
4813
|
+
return Math.floor(time / 6e4) % 10 === 0;
|
|
4814
|
+
}
|
|
4437
4815
|
/** What the MCP endpoint calls the server, in `server/discover` and `initialize`. */
|
|
4438
4816
|
const SERVER_INFO = {
|
|
4439
4817
|
name: "hollware",
|
|
@@ -4450,9 +4828,10 @@ function writeLog(entry) {
|
|
|
4450
4828
|
*
|
|
4451
4829
|
* Everything that can be built once is built here, when the user's module loads, and never again:
|
|
4452
4830
|
*
|
|
4453
|
-
* - the
|
|
4454
|
-
*
|
|
4455
|
-
*
|
|
4831
|
+
* - the registry of the sites, which reads them from D1 (ADR-033) at the start of each request,
|
|
4832
|
+
* call, cron run and batch. A config of 0.x is turned into definitions here, so one that cannot
|
|
4833
|
+
* move throws its `ConfigError` at startup, and is written to D1 on the first request. The
|
|
4834
|
+
* deployment serves every site of D1 with a definition, and a token, a grant or a call names one;
|
|
4456
4835
|
* - the MCP endpoint, whose tool schemas are converted once, and the Hono app of the gateway,
|
|
4457
4836
|
* which is `fetch`: MCP at `/mcp` behind the guard and the token check, and the other routes
|
|
4458
4837
|
* (`http/gateway.ts`);
|
|
@@ -4464,9 +4843,16 @@ function writeLog(entry) {
|
|
|
4464
4843
|
* included, is one JSON line written with `console.log`.
|
|
4465
4844
|
*/
|
|
4466
4845
|
function createHollwareWith(config, handlers, options = {}) {
|
|
4467
|
-
const resolved = resolveConfig(config);
|
|
4468
|
-
const sites = resolved.sites;
|
|
4469
4846
|
const clock = options.clock ?? systemClock;
|
|
4847
|
+
const moving = config?.sites !== void 0;
|
|
4848
|
+
if (moving) resolveConfig(config);
|
|
4849
|
+
const registry = createSiteRegistry({
|
|
4850
|
+
...moving ? { seed: definitionsFromConfig(config) } : {},
|
|
4851
|
+
now: () => clock.now(),
|
|
4852
|
+
log: writeLog
|
|
4853
|
+
});
|
|
4854
|
+
const { sites } = registry;
|
|
4855
|
+
const refresh = (db, options) => registry.refresh(db, options);
|
|
4470
4856
|
const mcp = createMcpEndpoint({
|
|
4471
4857
|
handlers,
|
|
4472
4858
|
serverInfo: SERVER_INFO,
|
|
@@ -4481,11 +4867,19 @@ function createHollwareWith(config, handlers, options = {}) {
|
|
|
4481
4867
|
const value = Reflect.get(env, name);
|
|
4482
4868
|
return typeof value === "string" ? value : void 0;
|
|
4483
4869
|
},
|
|
4484
|
-
semantic: semanticOfEnv(env)
|
|
4870
|
+
semantic: semanticOfEnv(env),
|
|
4871
|
+
ownSecret: async (webhook) => {
|
|
4872
|
+
const master = env.HOLLWARE_SECRET;
|
|
4873
|
+
if (master === void 0) return void 0;
|
|
4874
|
+
const sealed = await createSecretStore(env.DB, () => clock.now()).read(site.key, `webhook:${webhook}`);
|
|
4875
|
+
return sealed === null ? void 0 : await unseal(master, sealed) ?? void 0;
|
|
4876
|
+
}
|
|
4485
4877
|
});
|
|
4486
4878
|
return {
|
|
4487
4879
|
fetch: createGateway({
|
|
4488
4880
|
sites,
|
|
4881
|
+
siteKeys: registry.keys,
|
|
4882
|
+
refresh,
|
|
4489
4883
|
mcp,
|
|
4490
4884
|
clock,
|
|
4491
4885
|
log: writeLog,
|
|
@@ -4495,10 +4889,10 @@ function createHollwareWith(config, handlers, options = {}) {
|
|
|
4495
4889
|
clock,
|
|
4496
4890
|
log: writeLog
|
|
4497
4891
|
}, env),
|
|
4498
|
-
auth:
|
|
4892
|
+
auth: { dynamicClientRegistration: config?.auth?.dynamicClientRegistration ?? false },
|
|
4499
4893
|
handlers
|
|
4500
4894
|
}),
|
|
4501
|
-
scheduled: async (
|
|
4895
|
+
scheduled: async (controller, env, ctx) => {
|
|
4502
4896
|
const each = async (site) => {
|
|
4503
4897
|
await runDueSchedules({
|
|
4504
4898
|
site,
|
|
@@ -4524,7 +4918,16 @@ function createHollwareWith(config, handlers, options = {}) {
|
|
|
4524
4918
|
});
|
|
4525
4919
|
}
|
|
4526
4920
|
};
|
|
4921
|
+
const agents = env.AGENTS;
|
|
4922
|
+
const run = typeof agents === "object" && agents !== null ? Reflect.get(agents, "run") : void 0;
|
|
4923
|
+
if (typeof run === "function" && wakesAgents(controller.scheduledTime)) ctx.waitUntil(Promise.resolve(Reflect.apply(run, agents, [])).catch((error) => {
|
|
4924
|
+
writeLog({
|
|
4925
|
+
kind: "agents_failed",
|
|
4926
|
+
message: String(error)
|
|
4927
|
+
});
|
|
4928
|
+
}));
|
|
4527
4929
|
ctx.waitUntil((async () => {
|
|
4930
|
+
await refresh(env.DB);
|
|
4528
4931
|
for (const site of sites.values()) await each(site).catch((error) => {
|
|
4529
4932
|
writeLog({
|
|
4530
4933
|
kind: "cron_failed",
|
|
@@ -4535,6 +4938,7 @@ function createHollwareWith(config, handlers, options = {}) {
|
|
|
4535
4938
|
})());
|
|
4536
4939
|
},
|
|
4537
4940
|
queue: async (batch, env) => {
|
|
4941
|
+
await refresh(env.DB);
|
|
4538
4942
|
await consumeEvents(batch.messages, (key) => {
|
|
4539
4943
|
const site = sites.get(key);
|
|
4540
4944
|
return site === void 0 ? void 0 : deliveryDeps(env, site);
|
|
@@ -4542,6 +4946,7 @@ function createHollwareWith(config, handlers, options = {}) {
|
|
|
4542
4946
|
},
|
|
4543
4947
|
Hollware: createEntrypoint({
|
|
4544
4948
|
sites,
|
|
4949
|
+
refresh,
|
|
4545
4950
|
handlers,
|
|
4546
4951
|
clock,
|
|
4547
4952
|
log: writeLog
|
|
@@ -4585,6 +4990,7 @@ function entrySummary(view) {
|
|
|
4585
4990
|
tags: [...revision.tags],
|
|
4586
4991
|
hasUnpublishedChanges: entry.publishedRevision !== entry.headRevision,
|
|
4587
4992
|
publishedAt: entry.publishedAt,
|
|
4993
|
+
scheduledAt: entry.scheduledAt,
|
|
4588
4994
|
updatedAt: entry.updatedAt
|
|
4589
4995
|
};
|
|
4590
4996
|
}
|
|
@@ -4649,6 +5055,18 @@ function publicationView(publication) {
|
|
|
4649
5055
|
at: publication.createdAt
|
|
4650
5056
|
};
|
|
4651
5057
|
}
|
|
5058
|
+
/** A comment on a review as the operations answer it; `client` is the name its client gave. */
|
|
5059
|
+
function reviewCommentView(comment) {
|
|
5060
|
+
const name = comment.authorMeta?.["name"];
|
|
5061
|
+
return {
|
|
5062
|
+
id: comment.id,
|
|
5063
|
+
author: comment.authorId,
|
|
5064
|
+
authorName: comment.authorName,
|
|
5065
|
+
client: typeof name === "string" ? name : null,
|
|
5066
|
+
body: comment.body,
|
|
5067
|
+
at: comment.createdAt
|
|
5068
|
+
};
|
|
5069
|
+
}
|
|
4652
5070
|
//#endregion
|
|
4653
5071
|
//#region src/ops/content-stats.ts
|
|
4654
5072
|
/** A path without its trailing slash, so that `/blog/a` and `/blog/a/` are one page. */
|
|
@@ -4784,7 +5202,7 @@ function requireLocale(site, locale) {
|
|
|
4784
5202
|
function collectionOfEntry(site, entry) {
|
|
4785
5203
|
const collection = site.collections.get(entry.collection);
|
|
4786
5204
|
if (collection !== void 0) return collection;
|
|
4787
|
-
throw new HollwareError("unknown_collection", `The entry ${entry.id} belongs to the collection ${shown(entry.collection)}, which the config of the site no longer declares.`, `Ask
|
|
5205
|
+
throw new HollwareError("unknown_collection", `The entry ${entry.id} belongs to the collection ${shown(entry.collection)}, which the config of the site no longer declares.`, `Ask an owner of the site to add the collection ${shown(entry.collection)} again, with set_collection or in the admin: the entry and its revisions are kept.`);
|
|
4788
5206
|
}
|
|
4789
5207
|
//#endregion
|
|
4790
5208
|
//#region src/ops/draft.ts
|
|
@@ -5092,6 +5510,184 @@ const search$1 = async (input, context) => {
|
|
|
5092
5510
|
}))
|
|
5093
5511
|
};
|
|
5094
5512
|
};
|
|
5513
|
+
/** How many slugs of refused entries an answer names. */
|
|
5514
|
+
const EXAMPLES = 3;
|
|
5515
|
+
function structureOf(context) {
|
|
5516
|
+
if (context.structure === void 0) throw internalError(context, "An operation on the structure ran where the structure is not reachable.");
|
|
5517
|
+
return context.structure;
|
|
5518
|
+
}
|
|
5519
|
+
function actor(context) {
|
|
5520
|
+
const { userId, meta } = actorOf(context);
|
|
5521
|
+
return {
|
|
5522
|
+
userId,
|
|
5523
|
+
meta
|
|
5524
|
+
};
|
|
5525
|
+
}
|
|
5526
|
+
/** The problems of a definition for `key`, or none: it compiles as the registry will compile it. */
|
|
5527
|
+
function problemsOf(key, definition) {
|
|
5528
|
+
try {
|
|
5529
|
+
compileSites({ [key]: definition });
|
|
5530
|
+
return [];
|
|
5531
|
+
} catch (error) {
|
|
5532
|
+
if (error instanceof ConfigError) return error.problems.map((problem) => ({ ...problem }));
|
|
5533
|
+
throw error;
|
|
5534
|
+
}
|
|
5535
|
+
}
|
|
5536
|
+
async function impactOf(context, store, current, next) {
|
|
5537
|
+
const site = context.site.key;
|
|
5538
|
+
const counts = await store.counts(site);
|
|
5539
|
+
const removedCollections = Object.keys(current.collections).filter((name) => next.collections[name] === void 0).map((name) => ({
|
|
5540
|
+
name,
|
|
5541
|
+
entries: counts.byCollection.get(name) ?? 0
|
|
5542
|
+
})).filter((removed) => removed.entries > 0);
|
|
5543
|
+
const removedLocales = current.locales.filter((locale) => !next.locales.includes(locale)).map((locale) => ({
|
|
5544
|
+
locale,
|
|
5545
|
+
entries: counts.byLocale.get(locale) ?? 0
|
|
5546
|
+
})).filter((removed) => removed.entries > 0);
|
|
5547
|
+
const compiled = compileSites({ [site]: next }).sites.get(site);
|
|
5548
|
+
const refused = [];
|
|
5549
|
+
for (const [name, collection] of Object.entries(next.collections)) {
|
|
5550
|
+
const before = current.collections[name];
|
|
5551
|
+
if (before === void 0 || canonicalJson(before.schema) === canonicalJson(collection.schema)) continue;
|
|
5552
|
+
const schema = compiled?.collections.get(name)?.schema;
|
|
5553
|
+
if (schema === void 0) continue;
|
|
5554
|
+
const slugs = [];
|
|
5555
|
+
for (const entry of await store.frontmatters(site, name, 500)) if ((await schema["~standard"].validate(entry.frontmatter)).issues !== void 0) slugs.push(entry.slug);
|
|
5556
|
+
if (slugs.length > 0) refused.push({
|
|
5557
|
+
collection: name,
|
|
5558
|
+
entries: slugs.length,
|
|
5559
|
+
examples: slugs.slice(0, EXAMPLES)
|
|
5560
|
+
});
|
|
5561
|
+
}
|
|
5562
|
+
return {
|
|
5563
|
+
removedCollections,
|
|
5564
|
+
removedLocales,
|
|
5565
|
+
refused
|
|
5566
|
+
};
|
|
5567
|
+
}
|
|
5568
|
+
/** The definition of the site of the call, as stored, with its version. */
|
|
5569
|
+
async function currentOf(context) {
|
|
5570
|
+
const stored = await structureOf(context).store.read(context.site.key);
|
|
5571
|
+
if (stored === null) throw internalError(context, `The site ${context.site.key} is served and has no definition in D1.`);
|
|
5572
|
+
return {
|
|
5573
|
+
version: stored.version,
|
|
5574
|
+
definition: siteDefinitionData.parse(stored.definition)
|
|
5575
|
+
};
|
|
5576
|
+
}
|
|
5577
|
+
/** Replaces the definition of the site of the call with `next`, as update_site answers. */
|
|
5578
|
+
async function change(context, version, next, confirm) {
|
|
5579
|
+
const structure = structureOf(context);
|
|
5580
|
+
const problems = problemsOf(context.site.key, next);
|
|
5581
|
+
if (problems.length > 0) return {
|
|
5582
|
+
outcome: "invalid",
|
|
5583
|
+
problems
|
|
5584
|
+
};
|
|
5585
|
+
const current = await currentOf(context);
|
|
5586
|
+
if (current.version !== version) return {
|
|
5587
|
+
outcome: "conflict",
|
|
5588
|
+
currentVersion: current.version
|
|
5589
|
+
};
|
|
5590
|
+
if (canonicalJson(current.definition) === canonicalJson(next)) return {
|
|
5591
|
+
outcome: "unchanged",
|
|
5592
|
+
version
|
|
5593
|
+
};
|
|
5594
|
+
const impact = await impactOf(context, structure.store, current.definition, next);
|
|
5595
|
+
if (impact.removedCollections.length + impact.removedLocales.length + impact.refused.length > 0 && !confirm) return {
|
|
5596
|
+
outcome: "needs_confirmation",
|
|
5597
|
+
impact
|
|
5598
|
+
};
|
|
5599
|
+
if (await structure.store.write({
|
|
5600
|
+
site: context.site.key,
|
|
5601
|
+
expected: version,
|
|
5602
|
+
definition: next,
|
|
5603
|
+
actor: actor(context)
|
|
5604
|
+
}) === "conflict") return {
|
|
5605
|
+
outcome: "conflict",
|
|
5606
|
+
currentVersion: (await currentOf(context)).version
|
|
5607
|
+
};
|
|
5608
|
+
await structure.changed();
|
|
5609
|
+
return {
|
|
5610
|
+
outcome: "updated",
|
|
5611
|
+
version: version + 1
|
|
5612
|
+
};
|
|
5613
|
+
}
|
|
5614
|
+
const getStructure = async (_input, context) => {
|
|
5615
|
+
const current = await currentOf(context);
|
|
5616
|
+
return {
|
|
5617
|
+
outcome: "ok",
|
|
5618
|
+
site: context.site.key,
|
|
5619
|
+
version: current.version,
|
|
5620
|
+
definition: current.definition,
|
|
5621
|
+
catalog: describeCatalog()
|
|
5622
|
+
};
|
|
5623
|
+
};
|
|
5624
|
+
const updateSite = (input, context) => change(context, input.version, input.definition, input.confirm);
|
|
5625
|
+
const setCollection = async (input, context) => {
|
|
5626
|
+
const current = await currentOf(context);
|
|
5627
|
+
const collections = { ...current.definition.collections };
|
|
5628
|
+
if (input.collection === null) delete collections[input.name];
|
|
5629
|
+
else collections[input.name] = input.collection;
|
|
5630
|
+
return change(context, input.version, {
|
|
5631
|
+
...current.definition,
|
|
5632
|
+
collections
|
|
5633
|
+
}, input.confirm);
|
|
5634
|
+
};
|
|
5635
|
+
const createSite = async (input, context) => {
|
|
5636
|
+
const structure = structureOf(context);
|
|
5637
|
+
const problems = problemsOf(input.key, input.definition);
|
|
5638
|
+
if (problems.length > 0) return {
|
|
5639
|
+
outcome: "invalid",
|
|
5640
|
+
problems
|
|
5641
|
+
};
|
|
5642
|
+
if (await structure.store.create({
|
|
5643
|
+
site: input.key,
|
|
5644
|
+
definition: input.definition,
|
|
5645
|
+
actor: actor(context)
|
|
5646
|
+
}) === "taken") return { outcome: "taken" };
|
|
5647
|
+
await structure.changed();
|
|
5648
|
+
return {
|
|
5649
|
+
outcome: "created",
|
|
5650
|
+
version: 1
|
|
5651
|
+
};
|
|
5652
|
+
};
|
|
5653
|
+
/** Whether the person of the call owns `site`; a site they do not own is one they cannot see. */
|
|
5654
|
+
async function owns(context, site) {
|
|
5655
|
+
return await structureOf(context).store.roleOf(site, actor(context).userId) === "owner";
|
|
5656
|
+
}
|
|
5657
|
+
const deleteSite = async (input, context) => {
|
|
5658
|
+
const structure = structureOf(context);
|
|
5659
|
+
const stored = await structure.store.read(input.key);
|
|
5660
|
+
if (stored === null || stored.deletedAt !== null || !await owns(context, input.key)) return { outcome: "not_found" };
|
|
5661
|
+
if (!input.confirm) {
|
|
5662
|
+
const { byCollection } = await structure.store.counts(input.key);
|
|
5663
|
+
return {
|
|
5664
|
+
outcome: "needs_confirmation",
|
|
5665
|
+
entries: [...byCollection.values()].reduce((sum, count) => sum + count, 0)
|
|
5666
|
+
};
|
|
5667
|
+
}
|
|
5668
|
+
if (!await structure.store.trash(input.key, actor(context))) return { outcome: "not_found" };
|
|
5669
|
+
await structure.changed();
|
|
5670
|
+
return { outcome: "trashed" };
|
|
5671
|
+
};
|
|
5672
|
+
const restoreSite = async (input, context) => {
|
|
5673
|
+
const structure = structureOf(context);
|
|
5674
|
+
const stored = await structure.store.read(input.key);
|
|
5675
|
+
if (stored === null || stored.deletedAt === null || !await owns(context, input.key)) return { outcome: "not_found" };
|
|
5676
|
+
if (!await structure.store.restore(input.key, actor(context))) return { outcome: "not_found" };
|
|
5677
|
+
await structure.changed();
|
|
5678
|
+
return { outcome: "restored" };
|
|
5679
|
+
};
|
|
5680
|
+
const makeWebhookSecret = async (input, context) => {
|
|
5681
|
+
const structure = structureOf(context);
|
|
5682
|
+
const { definition } = await currentOf(context);
|
|
5683
|
+
const webhook = definition.events.webhooks.find((candidate) => candidate.name === input.webhook);
|
|
5684
|
+
if (webhook === void 0 || webhook.secret !== void 0) return { outcome: "not_found" };
|
|
5685
|
+
const secret = await structure.secrets?.make(context.site.key, `webhook:${webhook.name}`) ?? null;
|
|
5686
|
+
return secret === null ? { outcome: "unavailable" } : {
|
|
5687
|
+
outcome: "made",
|
|
5688
|
+
secret
|
|
5689
|
+
};
|
|
5690
|
+
};
|
|
5095
5691
|
//#endregion
|
|
5096
5692
|
//#region src/ops/event.ts
|
|
5097
5693
|
/** The path of an entry: the route of its locale, with its slug in place of `:slug`. */
|
|
@@ -5110,7 +5706,8 @@ function eventFor(input) {
|
|
|
5110
5706
|
at: input.at,
|
|
5111
5707
|
entry: input.entry,
|
|
5112
5708
|
path,
|
|
5113
|
-
url: path === null || input.site.url === null ? null : `${input.site.url}${path}
|
|
5709
|
+
url: path === null || input.site.url === null ? null : `${input.site.url}${path}`,
|
|
5710
|
+
cacheTags: tagsOfChange(input.entry)
|
|
5114
5711
|
};
|
|
5115
5712
|
return {
|
|
5116
5713
|
id,
|
|
@@ -5409,13 +6006,15 @@ const listReviews = async (input, context) => {
|
|
|
5409
6006
|
status: input.status,
|
|
5410
6007
|
limit: input.limit
|
|
5411
6008
|
});
|
|
6009
|
+
const comments = await context.store.reviewComments(context.site.key, found.map((review) => review.id));
|
|
5412
6010
|
const reviews = [];
|
|
5413
6011
|
for (const review of found) {
|
|
5414
6012
|
const view = await context.store.findEntryById(context.site.key, review.entryId, "head");
|
|
5415
6013
|
if (view === null) throw unexpectedAnswer(context, "list_reviews", { outcome: "no entry" });
|
|
5416
6014
|
reviews.push({
|
|
5417
6015
|
...reviewView(review),
|
|
5418
|
-
entry: entrySummary(view)
|
|
6016
|
+
entry: entrySummary(view),
|
|
6017
|
+
comments: (comments.get(review.id) ?? []).map(reviewCommentView)
|
|
5419
6018
|
});
|
|
5420
6019
|
}
|
|
5421
6020
|
return {
|
|
@@ -5481,6 +6080,56 @@ const requestChanges = async (input, context) => {
|
|
|
5481
6080
|
review: reviewView(closed.review)
|
|
5482
6081
|
};
|
|
5483
6082
|
};
|
|
6083
|
+
/**
|
|
6084
|
+
* A comment on a pending review, in the name of the caller: for the person who decides and for
|
|
6085
|
+
* the author. It decides nothing; a review that is no longer pending takes none.
|
|
6086
|
+
*/
|
|
6087
|
+
const commentReview = async (input, context) => {
|
|
6088
|
+
const answer = await context.store.commentReview(context.site.key, {
|
|
6089
|
+
reviewId: input.id,
|
|
6090
|
+
actor: actorOf(context),
|
|
6091
|
+
body: input.body
|
|
6092
|
+
});
|
|
6093
|
+
return answer.outcome === "commented" ? {
|
|
6094
|
+
outcome: "commented",
|
|
6095
|
+
comment: reviewCommentView(answer.comment)
|
|
6096
|
+
} : { outcome: answer.outcome };
|
|
6097
|
+
};
|
|
6098
|
+
/**
|
|
6099
|
+
* Asks for a review of the head revision of an entry through `publish`, with `content:publish`
|
|
6100
|
+
* taken from the caller for this call: the same checks run, and the one thing that cannot happen
|
|
6101
|
+
* is a publication. It is how an agent sends its work to a person, whatever its token holds.
|
|
6102
|
+
*/
|
|
6103
|
+
const requestReview = async (input, context) => {
|
|
6104
|
+
const { principal: given } = context;
|
|
6105
|
+
const principal = given.kind === "binding" ? given : {
|
|
6106
|
+
...given,
|
|
6107
|
+
scopes: given.scopes.filter((scope) => scope !== "content:publish")
|
|
6108
|
+
};
|
|
6109
|
+
const answer = await publish$1({
|
|
6110
|
+
id: input.id,
|
|
6111
|
+
version: input.version,
|
|
6112
|
+
acknowledgeWarnings: input.acknowledgeWarnings,
|
|
6113
|
+
group: []
|
|
6114
|
+
}, {
|
|
6115
|
+
...context,
|
|
6116
|
+
principal
|
|
6117
|
+
});
|
|
6118
|
+
switch (answer.outcome) {
|
|
6119
|
+
case "pending_review":
|
|
6120
|
+
case "blocked":
|
|
6121
|
+
case "needs_acknowledgement":
|
|
6122
|
+
case "conflict":
|
|
6123
|
+
case "not_found": return {
|
|
6124
|
+
outcome: answer.outcome,
|
|
6125
|
+
...answer.entry === void 0 ? {} : { entry: answer.entry },
|
|
6126
|
+
...answer.verdict === void 0 ? {} : { verdict: answer.verdict },
|
|
6127
|
+
...answer.conflict === void 0 ? {} : { conflict: answer.conflict },
|
|
6128
|
+
...answer.review === void 0 ? {} : { review: answer.review }
|
|
6129
|
+
};
|
|
6130
|
+
default: throw unexpectedAnswer(context, "request_review", answer);
|
|
6131
|
+
}
|
|
6132
|
+
};
|
|
5484
6133
|
//#endregion
|
|
5485
6134
|
//#region src/ops/unpublish.ts
|
|
5486
6135
|
/**
|
|
@@ -5626,17 +6275,26 @@ function defineHandlers(handlers) {
|
|
|
5626
6275
|
*/
|
|
5627
6276
|
const handlers = defineHandlers({
|
|
5628
6277
|
approve_review: approveReview,
|
|
6278
|
+
comment_review: commentReview,
|
|
5629
6279
|
content_stats: contentStats,
|
|
5630
6280
|
create_draft: createDraft,
|
|
6281
|
+
create_site: createSite,
|
|
6282
|
+
delete_site: deleteSite,
|
|
5631
6283
|
describe_site: describeSite$1,
|
|
5632
6284
|
get_entry: getEntry$1,
|
|
6285
|
+
get_structure: getStructure,
|
|
5633
6286
|
get_translations: getTranslations$1,
|
|
5634
6287
|
list_entries: listEntries$1,
|
|
5635
6288
|
search: search$1,
|
|
5636
6289
|
list_reviews: listReviews,
|
|
6290
|
+
make_webhook_secret: makeWebhookSecret,
|
|
5637
6291
|
publish: publish$1,
|
|
5638
6292
|
request_changes: requestChanges,
|
|
6293
|
+
request_review: requestReview,
|
|
6294
|
+
restore_site: restoreSite,
|
|
6295
|
+
set_collection: setCollection,
|
|
5639
6296
|
unpublish,
|
|
6297
|
+
update_site: updateSite,
|
|
5640
6298
|
update_draft: updateDraft,
|
|
5641
6299
|
validate
|
|
5642
6300
|
});
|