@hollware/server 0.6.0 → 0.9.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-6mZ3u32c.mjs → ceremonies-D5dIEbha.mjs} +1 -1
- package/dist/index.d.mts +76 -3
- package/dist/index.mjs +564 -39
- package/dist/{store-CYMjP0iN.mjs → store-C2hiR0yd.mjs} +54 -6
- package/migrations/0011_structure.sql +20 -0
- package/migrations/0012_site_secrets.sql +12 -0
- package/migrations/meta/0011_snapshot.json +2532 -0
- package/migrations/meta/0012_snapshot.json +2615 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +3 -3
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, 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
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.9.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
|
}
|
|
@@ -1103,7 +1105,7 @@ async function requestChanges$1(db, clock, site, write) {
|
|
|
1103
1105
|
review
|
|
1104
1106
|
};
|
|
1105
1107
|
}
|
|
1106
|
-
function metaOf(text) {
|
|
1108
|
+
function metaOf$1(text) {
|
|
1107
1109
|
if (text === null) return null;
|
|
1108
1110
|
const parsed = JSON.parse(text);
|
|
1109
1111
|
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? Object.fromEntries(Object.entries(parsed)) : null;
|
|
@@ -1118,7 +1120,7 @@ function toComment(row) {
|
|
|
1118
1120
|
reviewId: row.reviewId,
|
|
1119
1121
|
authorId: row.authorId,
|
|
1120
1122
|
authorName: row.authorName,
|
|
1121
|
-
authorMeta: metaOf(row.authorMeta),
|
|
1123
|
+
authorMeta: metaOf$1(row.authorMeta),
|
|
1122
1124
|
body: row.body,
|
|
1123
1125
|
createdAt: row.createdAt
|
|
1124
1126
|
};
|
|
@@ -1316,6 +1318,7 @@ async function createEntry(db, clock, site, entry) {
|
|
|
1316
1318
|
headRevision: revisionId,
|
|
1317
1319
|
publishedRevision: null,
|
|
1318
1320
|
publishedAt: null,
|
|
1321
|
+
scheduledAt: null,
|
|
1319
1322
|
updatedAt: now
|
|
1320
1323
|
},
|
|
1321
1324
|
revision: storedRevision({
|
|
@@ -1826,8 +1829,8 @@ function subscribersOf(deps) {
|
|
|
1826
1829
|
const subscribers = site.events.webhooks.map((webhook) => ({
|
|
1827
1830
|
name: `webhook:${webhook.name}`,
|
|
1828
1831
|
async send(payload, body) {
|
|
1829
|
-
const secret = deps.secret(webhook.secret);
|
|
1830
|
-
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`);
|
|
1831
1834
|
const timestamp = Math.floor(deps.clock.now().getTime() / 1e3);
|
|
1832
1835
|
const request = new Request(webhook.url, {
|
|
1833
1836
|
method: "POST",
|
|
@@ -1973,6 +1976,148 @@ async function consumeEvents(messages, depsOf) {
|
|
|
1973
1976
|
}
|
|
1974
1977
|
}
|
|
1975
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
|
|
1976
2121
|
//#region src/mcp/errors.ts
|
|
1977
2122
|
/**
|
|
1978
2123
|
* The error a caller gets for anything that is not a `HollwareError`. The message is fixed: what
|
|
@@ -2169,6 +2314,24 @@ function installAdmin(app, options) {
|
|
|
2169
2314
|
});
|
|
2170
2315
|
}
|
|
2171
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
|
|
2172
2335
|
//#region src/http/admin-api.ts
|
|
2173
2336
|
/**
|
|
2174
2337
|
* What only a person in the admin may do (ADR-026): see, make and revoke personal tokens, and
|
|
@@ -2186,9 +2349,16 @@ function json$1(body, status = 200) {
|
|
|
2186
2349
|
headers: { "cache-control": "no-store" }
|
|
2187
2350
|
});
|
|
2188
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
|
+
};
|
|
2189
2359
|
function refused(message, fix, status = 400) {
|
|
2190
2360
|
return json$1({
|
|
2191
|
-
code: status
|
|
2361
|
+
code: REFUSAL_CODES[status],
|
|
2192
2362
|
message,
|
|
2193
2363
|
fix
|
|
2194
2364
|
}, status);
|
|
@@ -2226,12 +2396,20 @@ function installAdminApi(app, options) {
|
|
|
2226
2396
|
id: users.id,
|
|
2227
2397
|
name: users.name
|
|
2228
2398
|
}).from(users).where(eq(users.id, person.userId));
|
|
2229
|
-
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
|
+
}));
|
|
2230
2407
|
return json$1({
|
|
2231
2408
|
user,
|
|
2232
2409
|
site: person.site,
|
|
2233
2410
|
scopes: person.scopes,
|
|
2234
|
-
sites
|
|
2411
|
+
sites,
|
|
2412
|
+
trash
|
|
2235
2413
|
});
|
|
2236
2414
|
}, { anyone: true }));
|
|
2237
2415
|
app.get("/v1/admin/tokens", admin(async (c, person) => {
|
|
@@ -2326,6 +2504,36 @@ function installAdminApi(app, options) {
|
|
|
2326
2504
|
}).from(invitations).where(and(eq(invitations.siteId, person.site), isNull(invitations.usedAt), isNull(invitations.userId), gt(invitations.expiresAt, now))).orderBy(desc(invitations.createdAt))
|
|
2327
2505
|
});
|
|
2328
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
|
+
}));
|
|
2329
2537
|
app.post("/v1/admin/invitations", admin(async (c, person) => {
|
|
2330
2538
|
const input = await body(c);
|
|
2331
2539
|
const role = input["role"];
|
|
@@ -2598,7 +2806,8 @@ function installOpsApi(app, options) {
|
|
|
2598
2806
|
requestId,
|
|
2599
2807
|
notify: options.notify(c, requestId, site.key),
|
|
2600
2808
|
semantic: semanticOfEnv(c.env),
|
|
2601
|
-
analytics: analyticsReaderOf(c.env)
|
|
2809
|
+
analytics: analyticsReaderOf(c.env),
|
|
2810
|
+
...options.structure === void 0 ? {} : { structure: options.structure(c.env.DB, c.env.HOLLWARE_SECRET) }
|
|
2602
2811
|
};
|
|
2603
2812
|
try {
|
|
2604
2813
|
return json(spec.output.parse(await invoke(options.handlers, spec, parsed.data, context)));
|
|
@@ -2723,7 +2932,7 @@ function installReadApi(app, options) {
|
|
|
2723
2932
|
if (crossOrigin !== null && !options.corsOrigins(principal.site).includes(crossOrigin)) return c.json({
|
|
2724
2933
|
code: "forbidden_origin",
|
|
2725
2934
|
message: `The site "${principal.site}" does not let pages of ${crossOrigin} read it.`,
|
|
2726
|
-
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.`
|
|
2727
2936
|
}, 403, {
|
|
2728
2937
|
vary: "Origin",
|
|
2729
2938
|
"access-control-allow-origin": new URL(c.env.HOLLWARE_PUBLIC_URL).origin
|
|
@@ -2885,10 +3094,10 @@ function openApiDocument() {
|
|
|
2885
3094
|
*/
|
|
2886
3095
|
/**
|
|
2887
3096
|
* The look of hollware.com and the admin, with nothing to fetch: the page's policy loads no font,
|
|
2888
|
-
* 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).
|
|
2889
3099
|
*/
|
|
2890
|
-
const STYLE = `:root{color-scheme:
|
|
2891
|
-
@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}
|
|
2892
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}
|
|
2893
3102
|
main{width:100%;max-width:26rem;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:2rem}
|
|
2894
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}
|
|
@@ -2901,7 +3110,7 @@ button{margin-top:1.25rem;padding:.6rem 1rem;font:inherit;font-weight:500;cursor
|
|
|
2901
3110
|
button:not([value=deny]){background:var(--accent);border-color:var(--accent);color:var(--on-accent)}
|
|
2902
3111
|
[role=alert]{color:var(--danger)}`;
|
|
2903
3112
|
/** The mark of Hollware, before the title of every page. */
|
|
2904
|
-
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>`;
|
|
2905
3114
|
const POST = `async function post(path, body) {
|
|
2906
3115
|
const response = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
2907
3116
|
const data = await response.json()
|
|
@@ -3058,7 +3267,7 @@ function plainPageHeaders() {
|
|
|
3058
3267
|
* The ceremonies, with SimpleWebAuthn and its parsers, in a chunk of their own: only these four
|
|
3059
3268
|
* routes use them, and a Worker that serves MCP never pays for them at startup (`budgets.json`).
|
|
3060
3269
|
*/
|
|
3061
|
-
const ceremonies = () => import("./ceremonies-
|
|
3270
|
+
const ceremonies = () => import("./ceremonies-D5dIEbha.mjs");
|
|
3062
3271
|
/** The cookie of a signed-in person: only `/authorize` reads it (ADR-026). */
|
|
3063
3272
|
const SESSION_COOKIE = "hollware_session";
|
|
3064
3273
|
function sessionCookie(secret) {
|
|
@@ -3496,7 +3705,7 @@ function installEvents(app, options) {
|
|
|
3496
3705
|
const path = typeof body === "object" && body !== null && "path" in body ? pathOf(body.path) : null;
|
|
3497
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>\" }.");
|
|
3498
3707
|
const origin = c.req.header("origin") ?? null;
|
|
3499
|
-
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.`);
|
|
3500
3709
|
const cf = Reflect.get(c.req.raw, "cf");
|
|
3501
3710
|
const country = typeof cf === "object" && cf !== null && "country" in cf && typeof cf.country === "string" ? cf.country : "XX";
|
|
3502
3711
|
analyticsWriterOf(c.env)?.record({
|
|
@@ -3552,7 +3761,7 @@ function withRefusalBody(response, refused) {
|
|
|
3552
3761
|
*/
|
|
3553
3762
|
function createGateway(options) {
|
|
3554
3763
|
const { sites, mcp, clock, log } = options;
|
|
3555
|
-
const siteKeys = new Set(sites.keys());
|
|
3764
|
+
const siteKeys = options.siteKeys ?? new Set(sites.keys());
|
|
3556
3765
|
const app = new Hono();
|
|
3557
3766
|
/**
|
|
3558
3767
|
* The Worker's own context of each request. Hono hands its routes the same object under a
|
|
@@ -3589,12 +3798,26 @@ function createGateway(options) {
|
|
|
3589
3798
|
});
|
|
3590
3799
|
}));
|
|
3591
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
|
+
});
|
|
3592
3814
|
/**
|
|
3593
3815
|
* A protected route: the resource server of `resource` answers a request without a token and a
|
|
3594
3816
|
* token it refuses (401, logged with the reason), and `proceed` gets the principal of the rest.
|
|
3595
3817
|
*/
|
|
3596
3818
|
const protect = async (c, resource, proceed) => {
|
|
3597
3819
|
const requestId = c.get("requestId");
|
|
3820
|
+
if (c.req.header("authorization") !== void 0) await options.refresh?.(c.env.DB);
|
|
3598
3821
|
/** What the check said besides the answer: why it refused, or what it threw. */
|
|
3599
3822
|
const outcome = {
|
|
3600
3823
|
refusal: null,
|
|
@@ -3621,8 +3844,17 @@ function createGateway(options) {
|
|
|
3621
3844
|
});
|
|
3622
3845
|
return withRefusalBody(response, outcome.refusal !== null);
|
|
3623
3846
|
};
|
|
3624
|
-
/**
|
|
3625
|
-
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));
|
|
3626
3858
|
/**
|
|
3627
3859
|
* The guard lets a page of such an origin through to the read API alone, and only to read: the
|
|
3628
3860
|
* read API then checks the origin against the site of the token.
|
|
@@ -3630,11 +3862,7 @@ function createGateway(options) {
|
|
|
3630
3862
|
const readsFromAnyCorsOrigin = (request, origin) => {
|
|
3631
3863
|
const { pathname } = new URL(request.url);
|
|
3632
3864
|
if (request.method === "POST" && pathname === "/events") return true;
|
|
3633
|
-
return
|
|
3634
|
-
"GET",
|
|
3635
|
-
"HEAD",
|
|
3636
|
-
"OPTIONS"
|
|
3637
|
-
].includes(request.method) && pathname.startsWith("/v1/") && !pathname.startsWith("/v1/admin/");
|
|
3865
|
+
return anyCorsOrigin(origin) && isCorsRead(request);
|
|
3638
3866
|
};
|
|
3639
3867
|
/**
|
|
3640
3868
|
* A route for a person or their personal token: a read token reads over the read API alone, so
|
|
@@ -3653,10 +3881,20 @@ function createGateway(options) {
|
|
|
3653
3881
|
await next();
|
|
3654
3882
|
});
|
|
3655
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);
|
|
3656
3886
|
const refusal = checkHostAndOrigin(c.req.raw, c.env.HOLLWARE_PUBLIC_URL, readsFromAnyCorsOrigin);
|
|
3657
3887
|
if (refusal !== null) return refusal;
|
|
3658
3888
|
await next();
|
|
3659
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
|
+
});
|
|
3660
3898
|
app.get("/health", () => Response.json({ ok: true }, { headers: { "cache-control": "no-store" } }));
|
|
3661
3899
|
app.all("/mcp", async (c) => protectWrites(c, "mcp", async (principal) => {
|
|
3662
3900
|
const requestId = c.get("requestId");
|
|
@@ -3673,7 +3911,8 @@ function createGateway(options) {
|
|
|
3673
3911
|
clock,
|
|
3674
3912
|
notify: notify(c, requestId, site.key),
|
|
3675
3913
|
semantic: semanticOfEnv(c.env),
|
|
3676
|
-
analytics: analyticsReaderOf(c.env)
|
|
3914
|
+
analytics: analyticsReaderOf(c.env),
|
|
3915
|
+
structure: structureOf(db, c.env.HOLLWARE_SECRET)
|
|
3677
3916
|
}
|
|
3678
3917
|
});
|
|
3679
3918
|
}));
|
|
@@ -3707,12 +3946,13 @@ function createGateway(options) {
|
|
|
3707
3946
|
log,
|
|
3708
3947
|
protect: (c, proceed) => protectWrites(c, "v1", proceed),
|
|
3709
3948
|
metadataUrl: (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1,
|
|
3710
|
-
notify
|
|
3949
|
+
notify,
|
|
3950
|
+
structure: structureOf
|
|
3711
3951
|
});
|
|
3712
3952
|
if (options.read !== void 0) installReadApi(app, {
|
|
3713
3953
|
read: options.read,
|
|
3714
3954
|
corsOrigins: (site) => sites.get(site)?.cors.origins ?? [],
|
|
3715
|
-
anyCorsOrigin
|
|
3955
|
+
anyCorsOrigin,
|
|
3716
3956
|
protect: (c, proceed) => protect(c, "v1", proceed),
|
|
3717
3957
|
metadataUrl: (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1
|
|
3718
3958
|
});
|
|
@@ -3919,6 +4159,13 @@ const OUTPUT_TOO_LARGE_FIX = {
|
|
|
3919
4159
|
search: "Ask for less: pass a smaller limit.",
|
|
3920
4160
|
unpublish: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
|
|
3921
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.",
|
|
3922
4169
|
validate: "The verdict lists more problems than fit in one answer. Fix the first ones and validate again."
|
|
3923
4170
|
};
|
|
3924
4171
|
/**
|
|
@@ -4318,10 +4565,15 @@ function forCaller(deps, requestId, method, error) {
|
|
|
4318
4565
|
* a call that fails never opens a session.
|
|
4319
4566
|
*/
|
|
4320
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
|
+
}
|
|
4321
4574
|
const served = deps.sites.get(site);
|
|
4322
4575
|
if (served === void 0) throw unknownSite(site, deps.sites.keys());
|
|
4323
4576
|
const input = parseInput(schema, build());
|
|
4324
|
-
const requestId = crypto.randomUUID();
|
|
4325
4577
|
const context = {
|
|
4326
4578
|
site: served,
|
|
4327
4579
|
principal: BINDING_PRINCIPAL,
|
|
@@ -4486,6 +4738,73 @@ async function runDueSchedules(input) {
|
|
|
4486
4738
|
cancelled
|
|
4487
4739
|
};
|
|
4488
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
|
+
}
|
|
4489
4808
|
//#endregion
|
|
4490
4809
|
//#region src/compose.ts
|
|
4491
4810
|
const systemClock = { now: () => /* @__PURE__ */ new Date() };
|
|
@@ -4509,9 +4828,10 @@ function writeLog(entry) {
|
|
|
4509
4828
|
*
|
|
4510
4829
|
* Everything that can be built once is built here, when the user's module loads, and never again:
|
|
4511
4830
|
*
|
|
4512
|
-
* - the
|
|
4513
|
-
*
|
|
4514
|
-
*
|
|
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;
|
|
4515
4835
|
* - the MCP endpoint, whose tool schemas are converted once, and the Hono app of the gateway,
|
|
4516
4836
|
* which is `fetch`: MCP at `/mcp` behind the guard and the token check, and the other routes
|
|
4517
4837
|
* (`http/gateway.ts`);
|
|
@@ -4523,9 +4843,16 @@ function writeLog(entry) {
|
|
|
4523
4843
|
* included, is one JSON line written with `console.log`.
|
|
4524
4844
|
*/
|
|
4525
4845
|
function createHollwareWith(config, handlers, options = {}) {
|
|
4526
|
-
const resolved = resolveConfig(config);
|
|
4527
|
-
const sites = resolved.sites;
|
|
4528
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);
|
|
4529
4856
|
const mcp = createMcpEndpoint({
|
|
4530
4857
|
handlers,
|
|
4531
4858
|
serverInfo: SERVER_INFO,
|
|
@@ -4540,11 +4867,19 @@ function createHollwareWith(config, handlers, options = {}) {
|
|
|
4540
4867
|
const value = Reflect.get(env, name);
|
|
4541
4868
|
return typeof value === "string" ? value : void 0;
|
|
4542
4869
|
},
|
|
4543
|
-
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
|
+
}
|
|
4544
4877
|
});
|
|
4545
4878
|
return {
|
|
4546
4879
|
fetch: createGateway({
|
|
4547
4880
|
sites,
|
|
4881
|
+
siteKeys: registry.keys,
|
|
4882
|
+
refresh,
|
|
4548
4883
|
mcp,
|
|
4549
4884
|
clock,
|
|
4550
4885
|
log: writeLog,
|
|
@@ -4554,7 +4889,7 @@ function createHollwareWith(config, handlers, options = {}) {
|
|
|
4554
4889
|
clock,
|
|
4555
4890
|
log: writeLog
|
|
4556
4891
|
}, env),
|
|
4557
|
-
auth:
|
|
4892
|
+
auth: { dynamicClientRegistration: config?.auth?.dynamicClientRegistration ?? false },
|
|
4558
4893
|
handlers
|
|
4559
4894
|
}),
|
|
4560
4895
|
scheduled: async (controller, env, ctx) => {
|
|
@@ -4592,6 +4927,7 @@ function createHollwareWith(config, handlers, options = {}) {
|
|
|
4592
4927
|
});
|
|
4593
4928
|
}));
|
|
4594
4929
|
ctx.waitUntil((async () => {
|
|
4930
|
+
await refresh(env.DB);
|
|
4595
4931
|
for (const site of sites.values()) await each(site).catch((error) => {
|
|
4596
4932
|
writeLog({
|
|
4597
4933
|
kind: "cron_failed",
|
|
@@ -4602,6 +4938,7 @@ function createHollwareWith(config, handlers, options = {}) {
|
|
|
4602
4938
|
})());
|
|
4603
4939
|
},
|
|
4604
4940
|
queue: async (batch, env) => {
|
|
4941
|
+
await refresh(env.DB);
|
|
4605
4942
|
await consumeEvents(batch.messages, (key) => {
|
|
4606
4943
|
const site = sites.get(key);
|
|
4607
4944
|
return site === void 0 ? void 0 : deliveryDeps(env, site);
|
|
@@ -4609,6 +4946,7 @@ function createHollwareWith(config, handlers, options = {}) {
|
|
|
4609
4946
|
},
|
|
4610
4947
|
Hollware: createEntrypoint({
|
|
4611
4948
|
sites,
|
|
4949
|
+
refresh,
|
|
4612
4950
|
handlers,
|
|
4613
4951
|
clock,
|
|
4614
4952
|
log: writeLog
|
|
@@ -4652,6 +4990,7 @@ function entrySummary(view) {
|
|
|
4652
4990
|
tags: [...revision.tags],
|
|
4653
4991
|
hasUnpublishedChanges: entry.publishedRevision !== entry.headRevision,
|
|
4654
4992
|
publishedAt: entry.publishedAt,
|
|
4993
|
+
scheduledAt: entry.scheduledAt,
|
|
4655
4994
|
updatedAt: entry.updatedAt
|
|
4656
4995
|
};
|
|
4657
4996
|
}
|
|
@@ -4863,7 +5202,7 @@ function requireLocale(site, locale) {
|
|
|
4863
5202
|
function collectionOfEntry(site, entry) {
|
|
4864
5203
|
const collection = site.collections.get(entry.collection);
|
|
4865
5204
|
if (collection !== void 0) return collection;
|
|
4866
|
-
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.`);
|
|
4867
5206
|
}
|
|
4868
5207
|
//#endregion
|
|
4869
5208
|
//#region src/ops/draft.ts
|
|
@@ -5171,6 +5510,184 @@ const search$1 = async (input, context) => {
|
|
|
5171
5510
|
}))
|
|
5172
5511
|
};
|
|
5173
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
|
+
};
|
|
5174
5691
|
//#endregion
|
|
5175
5692
|
//#region src/ops/event.ts
|
|
5176
5693
|
/** The path of an entry: the route of its locale, with its slug in place of `:slug`. */
|
|
@@ -5189,7 +5706,8 @@ function eventFor(input) {
|
|
|
5189
5706
|
at: input.at,
|
|
5190
5707
|
entry: input.entry,
|
|
5191
5708
|
path,
|
|
5192
|
-
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)
|
|
5193
5711
|
};
|
|
5194
5712
|
return {
|
|
5195
5713
|
id,
|
|
@@ -5760,16 +6278,23 @@ const handlers = defineHandlers({
|
|
|
5760
6278
|
comment_review: commentReview,
|
|
5761
6279
|
content_stats: contentStats,
|
|
5762
6280
|
create_draft: createDraft,
|
|
6281
|
+
create_site: createSite,
|
|
6282
|
+
delete_site: deleteSite,
|
|
5763
6283
|
describe_site: describeSite$1,
|
|
5764
6284
|
get_entry: getEntry$1,
|
|
6285
|
+
get_structure: getStructure,
|
|
5765
6286
|
get_translations: getTranslations$1,
|
|
5766
6287
|
list_entries: listEntries$1,
|
|
5767
6288
|
search: search$1,
|
|
5768
6289
|
list_reviews: listReviews,
|
|
6290
|
+
make_webhook_secret: makeWebhookSecret,
|
|
5769
6291
|
publish: publish$1,
|
|
5770
6292
|
request_changes: requestChanges,
|
|
5771
6293
|
request_review: requestReview,
|
|
6294
|
+
restore_site: restoreSite,
|
|
6295
|
+
set_collection: setCollection,
|
|
5772
6296
|
unpublish,
|
|
6297
|
+
update_site: updateSite,
|
|
5773
6298
|
update_draft: updateDraft,
|
|
5774
6299
|
validate
|
|
5775
6300
|
});
|