@hollware/server 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,8 +1,8 @@
1
- import { A as isRole, C as revisions, D as users, E as tokens, O as ROLE_SCOPES, S as revisionTags, T as sites, _ as invitations, b as publications, g as entries, h as deliveries, k as effectiveScopes, n as createInvitation, s as findSession, v as members, w as schedules, x as reviews, y as outbox } from "./store-Sq0pwUQv.mjs";
2
- import { EVENT_ID_HEADER, HollwareError, OPERATIONS, SCOPES, SIGNATURE_HEADER, TIMESTAMP_HEADER, describeBuiltInContracts, describeResolvedSite, describeSite, generatePersonalToken, getEntry, getTranslations, hashPersonalToken, isPersonalToken, isScope, listEntries, newId, processMarkdown, publish, renderHtml, resolveConfig, runContracts, signWebhook, sizeLimitViolation, uniqueSlugViolation, uniqueTranslationViolation } from "@hollware/core";
1
+ import { A as ROLE_SCOPES, C as reviews, D as sites, E as schedules, M as isRole, O as tokens, S as publications, T as revisions, _ as entries, b as members, g as deliveries, j as effectiveScopes, k as users, l as membershipsOf, n as createInvitation, s as findSession, v as entryEmbeddings, w as revisionTags, x as outbox, y as invitations } from "./store-DiS8UFI2.mjs";
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";
3
3
  import { Hono } from "hono";
4
4
  import { drizzle } from "drizzle-orm/d1";
5
- import { and, desc, eq, getTableColumns, gt, inArray, isNull, lte, ne, or, sql } from "drizzle-orm";
5
+ import { and, 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.3.0";
13
+ var version = "0.5.0";
14
14
  //#endregion
15
15
  //#region src/auth/responses.ts
16
16
  /** The challenge of every 401 and 403 (RFC 6750). */
@@ -93,7 +93,9 @@ function readPublicUrl(value) {
93
93
  * Any other port is another host.
94
94
  * - An `Origin` header that is present and is not the origin of the public URL, scheme and port
95
95
  * included: 403. That covers the opaque `null` origin and an empty value. A request without
96
- * `Origin` passes: only browsers send it, and they are what the check is for.
96
+ * `Origin` passes: only browsers send it, and they are what the check is for. The one exception
97
+ * is `crossOrigin`: an origin it accepts passes too (the read API, for the origins a site's
98
+ * config lists; the read API then checks the origin against the site of the token).
97
99
  *
98
100
  * It returns the response to send, or `null` when the request may go on. No answer repeats the
99
101
  * request: the 403s say what to send instead, and the 500 only says what is missing.
@@ -103,8 +105,9 @@ function readPublicUrl(value) {
103
105
  * they accept a `Host` such as `user@cms.example.com` or `cms.example.com/x`.
104
106
  *
105
107
  * @param publicUrl The `HOLLWARE_PUBLIC_URL` variable as the environment holds it.
108
+ * @param crossOrigin Whether a request may come from a page of another origin; never, by default.
106
109
  */
107
- function checkHostAndOrigin(request, publicUrl) {
110
+ function checkHostAndOrigin(request, publicUrl, crossOrigin = () => false) {
108
111
  const expected = readPublicUrl(publicUrl);
109
112
  if (expected === null) return authErrorResponse(500, {
110
113
  code: "misconfigured",
@@ -118,7 +121,7 @@ function checkHostAndOrigin(request, publicUrl) {
118
121
  fix: `Send the request to ${expected.origin}, the public URL of this Hollware (HOLLWARE_PUBLIC_URL).`
119
122
  });
120
123
  const origin = request.headers.get("origin");
121
- if (origin !== null && origin.toLowerCase() !== expected.origin) return authErrorResponse(403, {
124
+ if (origin !== null && origin.toLowerCase() !== expected.origin && !crossOrigin(request, origin)) return authErrorResponse(403, {
122
125
  code: "forbidden_origin",
123
126
  message: "The Origin header of the request is not the public URL of this Hollware.",
124
127
  fix: `Call Hollware from a client that sends no Origin header, or from a page served at ${expected.origin}.`
@@ -141,14 +144,14 @@ function missingScopes(principal, required) {
141
144
  /** `last_used_at` of a token is written at most once in this time. */
142
145
  const TOUCH_INTERVAL_MS = 36e5;
143
146
  /**
144
- * The token with the role of its user in the token's site, by the hash of the token, in the one
145
- * site. A user who is no member of the site has no role there, and the token is unknown.
147
+ * The token with the role of its user in the token's site, by the hash of the token, in the
148
+ * deployment. A user who is no member of the site has no role there, and the token is unknown.
146
149
  */
147
150
  const LOOKUP = `SELECT t.id AS token_id, t.site_id AS site_id, t.user_id AS user_id,
148
- t.scopes AS scopes, t.expires_at AS expires_at, t.revoked_at AS revoked_at,
151
+ t.kind AS kind, t.scopes AS scopes, t.expires_at AS expires_at, t.revoked_at AS revoked_at,
149
152
  t.last_used_at AS last_used_at, m.role AS role
150
153
  FROM tokens AS t JOIN members AS m ON m.user_id = t.user_id AND m.site_id = t.site_id
151
- WHERE t.hash = ?1 AND t.site_id = ?2`;
154
+ WHERE t.hash = ?1`;
152
155
  /**
153
156
  * The scopes a token carries, from the JSON array the `tokens` table stores. Anything that is not
154
157
  * one of the scopes is left out, and a value that is not an array of scopes has none: a token
@@ -193,12 +196,12 @@ async function recordTokenUse(db, token, now) {
193
196
  }
194
197
  }
195
198
  /**
196
- * Authenticates a personal token (ADR-019). A string without the shape of a token is `unknown`
197
- * without a query. Otherwise one query finds the token by the hash of the whole string, in this
198
- * site only, together with the role of its user; the answer is, in this order:
199
+ * Authenticates a personal token (ADR-019) or a read token. A string without the shape of either
200
+ * is `unknown` without a query. Otherwise one query finds the token by the hash of the whole
201
+ * string, together with the role of its user in the token's site; the answer is, in this order:
199
202
  *
200
- * - `unknown`: no such token in this site. A token of another site is the same as no token, and
201
- * nothing says it exists elsewhere.
203
+ * - `unknown`: no such token in the sites of this deployment. A token of another site is the same as
204
+ * no token, and nothing says it exists elsewhere.
202
205
  * - `revoked`: it has a revocation date, whatever the date says: it is never read, so a clock that
203
206
  * runs ahead of the Worker's, where the token was revoked, cannot keep it alive for a while.
204
207
  * - `expired`: its expiry is now or past (see `hasExpired`).
@@ -211,12 +214,13 @@ async function recordTokenUse(db, token, now) {
211
214
  * must never read that as a valid token.
212
215
  */
213
216
  async function checkPersonalToken(db, token, options) {
214
- if (!isPersonalToken(token)) return {
217
+ const kind = isPersonalToken(token) ? "personal" : isReadToken(token) ? "read" : null;
218
+ if (kind === null) return {
215
219
  ok: false,
216
220
  reason: "unknown"
217
221
  };
218
- const row = await db.prepare(LOOKUP).bind(await hashPersonalToken(token), options.site).first();
219
- if (row === null) return {
222
+ const row = await db.prepare(LOOKUP).bind(await hashPersonalToken(token)).first();
223
+ if (row === null || row.kind !== kind || !options.sites.has(row.site_id)) return {
220
224
  ok: false,
221
225
  reason: "unknown"
222
226
  };
@@ -229,14 +233,22 @@ async function checkPersonalToken(db, token, options) {
229
233
  reason: "expired"
230
234
  };
231
235
  const recent = row.last_used_at !== null && row.last_used_at >= anHourBefore(options.now);
236
+ const scopes = effectiveScopes(row.role, parseTokenScopes(row.scopes));
237
+ const identity = {
238
+ userId: row.user_id,
239
+ tokenId: row.token_id,
240
+ site: row.site_id
241
+ };
232
242
  return {
233
243
  ok: true,
234
- principal: {
244
+ principal: kind === "read" ? {
245
+ kind: "read",
246
+ ...identity,
247
+ scopes: scopes.filter((scope) => scope === "content:read")
248
+ } : {
235
249
  kind: "token",
236
- userId: row.user_id,
237
- tokenId: row.token_id,
238
- site: row.site_id,
239
- scopes: effectiveScopes(row.role, parseTokenScopes(row.scopes))
250
+ ...identity,
251
+ scopes
240
252
  },
241
253
  touch: recent ? null : recordTokenUse(db, {
242
254
  id: row.token_id,
@@ -620,6 +632,61 @@ function listEntriesQuery(db, site, query, cursor) {
620
632
  cursor === null ? void 0 : sql`(${sortKey}, ${entries.id}) < (${cursor.key}, ${cursor.id})`
621
633
  ]).orderBy(desc(sortKey), desc(entries.id)).limit(query.limit + 1);
622
634
  }
635
+ /**
636
+ * The FTS5 query of what someone typed: each of its words as a quoted string, so that FTS5's own
637
+ * syntax (`AND`, `NEAR`, `*`, quotes) is never read from it, and every word must match. Empty when
638
+ * it holds no word.
639
+ */
640
+ function ftsQuery(text) {
641
+ return (text.match(/[\p{L}\p{N}_]+/gu) ?? []).map((word) => `"${word}"`).join(" ");
642
+ }
643
+ /** The weights of the columns of `entries_fts` in bm25: title, tags, then the text. */
644
+ const BM25 = sql.raw("bm25(entries_fts, 10.0, 4.0, 1.0)");
645
+ async function searchEntries(db, site, query) {
646
+ const match = ftsQuery(query.text);
647
+ if (match === "") return [];
648
+ const hits = await db.all(sql`
649
+ select entry_id, snippet(entries_fts, 2, '[', ']', '…', 16) as snippet, ${BM25} as rank
650
+ from entries_fts
651
+ where entries_fts match ${match} and site_id = ${site}
652
+ ${query.collection === void 0 ? sql`` : sql`and collection = ${query.collection}`}
653
+ ${query.locale === void 0 ? sql`` : sql`and locale = ${query.locale}`}
654
+ order by rank
655
+ limit ${query.limit}
656
+ `);
657
+ if (hits.length === 0) return [];
658
+ const rows = await summaryQuery(db, "published", [eq(entries.siteId, site), inArray(entries.id, hits.map((hit) => hit.entry_id))]);
659
+ const byId = new Map(rows.map((row) => [row.entry.id, toEntrySummaryView(row)]));
660
+ return hits.flatMap((hit) => {
661
+ const entry = byId.get(hit.entry_id);
662
+ return entry === void 0 ? [] : [{
663
+ entry,
664
+ snippet: hit.snippet,
665
+ score: -hit.rank
666
+ }];
667
+ });
668
+ }
669
+ /** The entries of those ids that the view has, in the order of the ids; the others are left out. */
670
+ async function findSummaries(db, site, ids, view) {
671
+ if (ids.length === 0) return [];
672
+ const rows = await summaryQuery(db, view, [eq(entries.siteId, site), inArray(entries.id, [...ids])]);
673
+ const byId = new Map(rows.map((row) => [row.entry.id, toEntrySummaryView(row)]));
674
+ return ids.flatMap((id) => {
675
+ const entry = byId.get(id);
676
+ return entry === void 0 ? [] : [entry];
677
+ });
678
+ }
679
+ /** The entries closest to one, closest first, as its vectors found them (ADR-030); `[]` without. */
680
+ async function relatedOf(db, site, entryId) {
681
+ const [row] = await db.select({ related: entryEmbeddings.related }).from(entryEmbeddings).where(and(eq(entryEmbeddings.siteId, site), eq(entryEmbeddings.entryId, entryId))).limit(1);
682
+ if (row === void 0) return [];
683
+ const parsed = JSON.parse(row.related);
684
+ if (!Array.isArray(parsed)) return [];
685
+ return parsed.flatMap((item) => typeof item === "object" && item !== null && "id" in item && "score" in item && typeof item.id === "string" && typeof item.score === "number" ? [{
686
+ id: item.id,
687
+ score: item.score
688
+ }] : []);
689
+ }
623
690
  async function listEntries$2(db, site, query) {
624
691
  if (!Number.isInteger(query.limit) || query.limit < 1) throw new HollwareError("invalid_input", `The limit ${query.limit} is not a whole number of at least 1.`, "Ask for at least one entry per page.");
625
692
  const cursor = query.cursor === void 0 ? null : decodeCursor(query.cursor, query.view);
@@ -1430,6 +1497,9 @@ function createD1Store(db, options = {}) {
1430
1497
  findEntry: (site, ref, view) => findEntry(database, site, ref, view),
1431
1498
  findEntryById: (site, id, view) => findEntryById(database, site, id, view),
1432
1499
  listEntries: (site, query) => listEntries$2(database, site, query),
1500
+ searchEntries: (site, query) => searchEntries(database, site, query),
1501
+ findSummaries: (site, ids, view) => findSummaries(database, site, ids, view),
1502
+ relatedOf: (site, entryId) => relatedOf(database, site, entryId),
1433
1503
  listTranslations: (site, collection, translationGroup, view) => listTranslations(database, site, collection, translationGroup, view),
1434
1504
  lookup: (site) => lookup(database, site),
1435
1505
  lastPublication: (site, entryId) => lastPublication(database, site, entryId),
@@ -1503,6 +1573,191 @@ function sqlIncrement() {
1503
1573
  return sql`${deliveries.attempts} + 1`;
1504
1574
  }
1505
1575
  //#endregion
1576
+ //#region src/semantic/index-entry.ts
1577
+ /** The id of a piece's vector. */
1578
+ function vectorId(entryId, piece) {
1579
+ return `${entryId}:${piece}`;
1580
+ }
1581
+ function excerptOf(text) {
1582
+ if (text.length <= 200) return text;
1583
+ const cut = text.slice(0, 200);
1584
+ const lastSpace = cut.lastIndexOf(" ");
1585
+ return `${(lastSpace > 0 ? cut.slice(0, lastSpace) : cut).trim()}…`;
1586
+ }
1587
+ function stringOf(value) {
1588
+ return typeof value === "string" && value.trim() !== "" ? value : void 0;
1589
+ }
1590
+ /** The ids of the vectors of `pieces` pieces of an entry, from `from` on. */
1591
+ function idsOf(entryId, from, pieces) {
1592
+ return Array.from({ length: Math.max(0, pieces - from) }, (_, offset) => vectorId(entryId, from + offset));
1593
+ }
1594
+ /** The entries nearest to an entry's summary, in its collection and locale; itself left out. */
1595
+ async function nearestTo(semantic, input) {
1596
+ return (await semantic.index.query(input.summary, {
1597
+ topK: 6,
1598
+ site: input.site,
1599
+ collection: input.collection,
1600
+ locale: input.locale,
1601
+ summariesOnly: true
1602
+ })).filter((match) => match.metadata.entry !== input.entryId).slice(0, 5).map((match) => ({
1603
+ id: match.metadata.entry,
1604
+ score: match.score
1605
+ }));
1606
+ }
1607
+ /**
1608
+ * Brings the vectors of one entry in line with what it has published: the pieces of its published
1609
+ * revision, embedded with the model of `semantic`. It reads the entry as it is now, so it is right
1610
+ * whichever event called it and however late: an entry whose vectors already are of its published
1611
+ * revision, model and chunker is `current` and costs no embedding; an entry with nothing published
1612
+ * (unpublished, deleted, gone) loses its vectors and its row.
1613
+ */
1614
+ async function syncEntryVectors(db, semantic, input) {
1615
+ const { site, entryId, now } = input;
1616
+ const [published] = await db.select({
1617
+ collection: entries.collection,
1618
+ locale: entries.locale,
1619
+ revisionId: revisions.id,
1620
+ frontmatter: revisions.frontmatter,
1621
+ document: revisions.document
1622
+ }).from(entries).innerJoin(revisions, and(eq(revisions.id, entries.publishedRev), eq(revisions.siteId, entries.siteId))).where(and(eq(entries.siteId, site), eq(entries.id, entryId))).limit(1);
1623
+ const [stored] = await db.select({
1624
+ revisionId: entryEmbeddings.revisionId,
1625
+ model: entryEmbeddings.model,
1626
+ chunkerVersion: entryEmbeddings.chunkerVersion,
1627
+ pieces: entryEmbeddings.pieces
1628
+ }).from(entryEmbeddings).where(and(eq(entryEmbeddings.siteId, site), eq(entryEmbeddings.entryId, entryId))).limit(1);
1629
+ if (published === void 0) {
1630
+ if (stored === void 0) return "absent";
1631
+ await semantic.index.remove(idsOf(entryId, 0, stored.pieces));
1632
+ await db.delete(entryEmbeddings).where(and(eq(entryEmbeddings.siteId, site), eq(entryEmbeddings.entryId, entryId)));
1633
+ return "removed";
1634
+ }
1635
+ if (stored !== void 0 && stored.revisionId === published.revisionId && stored.model === semantic.embedder.model && stored.chunkerVersion === CHUNKER_VERSION) return "current";
1636
+ const frontmatter = JSON.parse(published.frontmatter);
1637
+ const document = JSON.parse(published.document);
1638
+ const description = stringOf(frontmatter["description"]);
1639
+ const chunks = chunksOf({
1640
+ title: stringOf(frontmatter["title"]) ?? "",
1641
+ ...description === void 0 ? {} : { description },
1642
+ document
1643
+ });
1644
+ const vectors = await semantic.embedder.embed(chunks.map((chunk) => chunk.text));
1645
+ await semantic.index.upsert(chunks.map((chunk) => ({
1646
+ id: vectorId(entryId, chunk.index),
1647
+ values: vectors[chunk.index] ?? [],
1648
+ metadata: {
1649
+ site,
1650
+ entry: entryId,
1651
+ collection: published.collection,
1652
+ locale: published.locale,
1653
+ piece: chunk.index,
1654
+ excerpt: excerptOf(chunk.text)
1655
+ }
1656
+ })));
1657
+ if (stored !== void 0) await semantic.index.remove(idsOf(entryId, chunks.length, stored.pieces));
1658
+ const related = await nearestTo(semantic, {
1659
+ site,
1660
+ entryId,
1661
+ collection: published.collection,
1662
+ locale: published.locale,
1663
+ summary: vectors[0] ?? []
1664
+ });
1665
+ const row = {
1666
+ related: JSON.stringify(related),
1667
+ revisionId: published.revisionId,
1668
+ model: semantic.embedder.model,
1669
+ chunkerVersion: CHUNKER_VERSION,
1670
+ pieces: chunks.length,
1671
+ updatedAt: now
1672
+ };
1673
+ await db.insert(entryEmbeddings).values({
1674
+ id: newId("emb"),
1675
+ siteId: site,
1676
+ entryId,
1677
+ createdAt: now,
1678
+ ...row
1679
+ }).onConflictDoUpdate({
1680
+ target: [entryEmbeddings.siteId, entryEmbeddings.entryId],
1681
+ set: row
1682
+ });
1683
+ return "indexed";
1684
+ }
1685
+ /**
1686
+ * The entries whose vectors are not those of what they publish: published with no vectors (before
1687
+ * the Worker had semantic search, or an event that never reached the subscriber), of another
1688
+ * revision, model or chunker; and entries with vectors and nothing published. At most `limit`.
1689
+ */
1690
+ async function staleEntries(db, input) {
1691
+ const { site, model, limit } = input;
1692
+ const published = await db.select({ id: entries.id }).from(entries).leftJoin(entryEmbeddings, and(eq(entryEmbeddings.entryId, entries.id), eq(entryEmbeddings.siteId, entries.siteId))).where(and(eq(entries.siteId, site), isNotNull(entries.publishedRev), or(isNull(entryEmbeddings.id), ne(entryEmbeddings.revisionId, entries.publishedRev), ne(entryEmbeddings.model, model), ne(entryEmbeddings.chunkerVersion, CHUNKER_VERSION)))).limit(limit);
1693
+ const left = limit - published.length;
1694
+ const orphans = left <= 0 ? [] : await db.select({ id: entryEmbeddings.entryId }).from(entryEmbeddings).innerJoin(entries, and(eq(entries.id, entryEmbeddings.entryId), eq(entries.siteId, entryEmbeddings.siteId))).where(and(eq(entryEmbeddings.siteId, site), isNull(entries.publishedRev))).limit(left);
1695
+ return [...published, ...orphans].map((row) => row.id);
1696
+ }
1697
+ /**
1698
+ * What the cron trigger does for the vectors of a site: brings at most `limit` stale entries in
1699
+ * line, so that a site that turns semantic search on, or changes its model, is indexed a few
1700
+ * entries a minute without a command. An entry that fails is left for the next run.
1701
+ */
1702
+ async function healVectors(db, semantic, input) {
1703
+ const ids = await staleEntries(db, {
1704
+ site: input.site,
1705
+ model: semantic.embedder.model,
1706
+ limit: input.limit ?? 20
1707
+ });
1708
+ let failed = 0;
1709
+ for (const entryId of ids) await syncEntryVectors(db, semantic, {
1710
+ site: input.site,
1711
+ entryId,
1712
+ now: input.now
1713
+ }).catch(() => {
1714
+ failed += 1;
1715
+ });
1716
+ return {
1717
+ synced: ids.length - failed,
1718
+ failed
1719
+ };
1720
+ }
1721
+ /** How long an entry with no related entries waits before they are looked for again. */
1722
+ const RELATED_RETRY_MS = 6e5;
1723
+ /** How old the related entries of an entry may grow before they are looked for again. */
1724
+ const RELATED_REFRESH_MS = 864e5;
1725
+ /**
1726
+ * What the cron trigger does for related entries: it finds them again for at most `limit`
1727
+ * entries of the site, oldest first, whose related entries are a day old, or empty for ten
1728
+ * minutes. Vectorize shows a vector seconds after it was written, so entries indexed together
1729
+ * miss each other at first; and an entry published later belongs among the related entries of
1730
+ * those before it. Each keeps its vectors: only its summary is read back and compared.
1731
+ */
1732
+ async function refreshRelated(db, semantic, input) {
1733
+ const now = Date.parse(input.now);
1734
+ const retryBefore = (/* @__PURE__ */ new Date(now - RELATED_RETRY_MS)).toISOString();
1735
+ const refreshBefore = (/* @__PURE__ */ new Date(now - RELATED_REFRESH_MS)).toISOString();
1736
+ const rows = await db.select({
1737
+ entryId: entryEmbeddings.entryId,
1738
+ collection: entries.collection,
1739
+ locale: entries.locale
1740
+ }).from(entryEmbeddings).innerJoin(entries, and(eq(entries.id, entryEmbeddings.entryId), eq(entries.siteId, entryEmbeddings.siteId))).where(and(eq(entryEmbeddings.siteId, input.site), isNotNull(entries.publishedRev), or(and(eq(entryEmbeddings.related, "[]"), lte(entryEmbeddings.updatedAt, retryBefore)), lte(entryEmbeddings.updatedAt, refreshBefore)))).orderBy(entryEmbeddings.updatedAt).limit(input.limit ?? 10);
1741
+ if (rows.length === 0) return 0;
1742
+ const summaries = new Map((await semantic.index.get(rows.map((row) => vectorId(row.entryId, 0)))).map((vector) => [vector.id, vector.values]));
1743
+ let refreshed = 0;
1744
+ for (const row of rows) {
1745
+ const summary = summaries.get(vectorId(row.entryId, 0));
1746
+ if (summary === void 0) continue;
1747
+ const related = await nearestTo(semantic, {
1748
+ site: input.site,
1749
+ ...row,
1750
+ summary
1751
+ });
1752
+ await db.update(entryEmbeddings).set({
1753
+ related: JSON.stringify(related),
1754
+ updatedAt: input.now
1755
+ }).where(and(eq(entryEmbeddings.siteId, input.site), eq(entryEmbeddings.entryId, row.entryId)));
1756
+ refreshed += 1;
1757
+ }
1758
+ return refreshed;
1759
+ }
1760
+ //#endregion
1506
1761
  //#region src/events/deliver.ts
1507
1762
  /** Where IndexNow takes the URLs that changed. */
1508
1763
  const INDEXNOW_ENDPOINT = "https://api.indexnow.org/indexnow";
@@ -1554,6 +1809,17 @@ function subscribersOf(deps) {
1554
1809
  }
1555
1810
  });
1556
1811
  }
1812
+ const semantic = deps.semantic;
1813
+ if (semantic !== void 0 && semantic !== null) subscribers.push({
1814
+ name: "vectors",
1815
+ async send(payload) {
1816
+ await syncEntryVectors(deps.db, semantic, {
1817
+ site: site.key,
1818
+ entryId: payload.entry.id,
1819
+ now: deps.clock.now().toISOString()
1820
+ });
1821
+ }
1822
+ });
1557
1823
  return subscribers;
1558
1824
  }
1559
1825
  /**
@@ -1636,12 +1902,14 @@ function isEventMessage(body) {
1636
1902
  /**
1637
1903
  * The Queue consumer: delivers each event, acknowledges it once every subscriber has it, and asks
1638
1904
  * for a retry otherwise; after the Queue's retries it lands in the dead-letter queue. A message of
1639
- * another site, or that is no event, is acknowledged and dropped.
1905
+ * a site this deployment does not serve (`depsOf` has none for it), or that is no event, is
1906
+ * acknowledged and dropped.
1640
1907
  */
1641
- async function consumeEvents(messages, deps) {
1908
+ async function consumeEvents(messages, depsOf) {
1642
1909
  for (const message of messages) {
1643
1910
  const { body } = message;
1644
- if (!isEventMessage(body) || body.site !== deps.site.key) {
1911
+ const deps = isEventMessage(body) ? depsOf(body.site) : void 0;
1912
+ if (!isEventMessage(body) || deps === void 0) {
1645
1913
  message.ack();
1646
1914
  continue;
1647
1915
  }
@@ -1849,7 +2117,8 @@ function installAdmin(app, options) {
1849
2117
  //#region src/http/admin-api.ts
1850
2118
  /**
1851
2119
  * What only a person in the admin may do (ADR-026): see, make and revoke personal tokens, and
1852
- * invite people. These are no operations, so no agent sees them over MCP, and a request needs an
2120
+ * invite people, on the site of their token; and learn who they are and which sites they may switch
2121
+ * to (`/v1/admin/me`, the one route for every role). These are no operations, so no agent sees them over MCP, and a request needs an
1853
2122
  * access token of the admin's own client with `schema:admin`: a personal token, or a token of
1854
2123
  * another client, never reaches them, even an owner's. A leaked token cannot mint the next one.
1855
2124
  */
@@ -1882,20 +2151,38 @@ function daysFrom(value, fallback) {
1882
2151
  return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 365 ? value : null;
1883
2152
  }
1884
2153
  function installAdminApi(app, options) {
1885
- /** Runs `handle` for a person in the admin with `schema:admin`; anyone else is refused. */
1886
- const admin = (handle) => (c) => options.protect(c, async (principal) => {
2154
+ /**
2155
+ * Runs `handle` for a person in the admin with `schema:admin`, or with any scope when `anyone`;
2156
+ * anyone else is refused.
2157
+ */
2158
+ const admin = (handle, { anyone = false } = {}) => (c) => options.protect(c, async (principal) => {
1887
2159
  const ours = (await adminClient(options.oauth(c.env.HOLLWARE_PUBLIC_URL), c.env)).clientId;
1888
2160
  if (principal.kind !== "oauth" || principal.clientId !== ours) return refused("Only the admin may do this.", "Sign in to the admin at /admin/ and do it there.", 403);
1889
- if (!principal.scopes.includes("schema:admin")) return insufficientScope(["schema:admin"], options.metadataUrl(c));
2161
+ if (!anyone && !principal.scopes.includes("schema:admin")) return insufficientScope(["schema:admin"], options.metadataUrl(c));
1890
2162
  return handle(c, principal);
1891
2163
  });
1892
2164
  const body = async (c) => {
1893
2165
  const value = await c.req.json().catch(() => null);
1894
2166
  return typeof value === "object" && value !== null && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {};
1895
2167
  };
1896
- app.get("/v1/admin/tokens", admin(async (c) => {
2168
+ app.get("/v1/admin/me", admin(async (c, person) => {
2169
+ const db = connect(c.env.DB);
2170
+ const [user] = await db.select({
2171
+ id: users.id,
2172
+ name: users.name
2173
+ }).from(users).where(eq(users.id, person.userId));
2174
+ const sites = (await membershipsOf(db, person.userId)).filter((membership) => options.sites.has(membership.site));
2175
+ return json$1({
2176
+ user,
2177
+ site: person.site,
2178
+ scopes: person.scopes,
2179
+ sites
2180
+ });
2181
+ }, { anyone: true }));
2182
+ app.get("/v1/admin/tokens", admin(async (c, person) => {
1897
2183
  return json$1({ tokens: (await connect(c.env.DB).select({
1898
2184
  id: tokens.id,
2185
+ kind: tokens.kind,
1899
2186
  label: tokens.label,
1900
2187
  scopes: tokens.scopes,
1901
2188
  user: users.name,
@@ -1903,7 +2190,7 @@ function installAdminApi(app, options) {
1903
2190
  revokedAt: tokens.revokedAt,
1904
2191
  lastUsedAt: tokens.lastUsedAt,
1905
2192
  createdAt: tokens.createdAt
1906
- }).from(tokens).innerJoin(users, eq(users.id, tokens.userId)).where(eq(tokens.siteId, options.site)).orderBy(desc(tokens.createdAt), desc(tokens.id))).map((row) => ({
2193
+ }).from(tokens).innerJoin(users, eq(users.id, tokens.userId)).where(eq(tokens.siteId, person.site)).orderBy(desc(tokens.createdAt), desc(tokens.id))).map((row) => ({
1907
2194
  ...row,
1908
2195
  scopes: parseScopes(row.scopes)
1909
2196
  })) });
@@ -1912,16 +2199,20 @@ function installAdminApi(app, options) {
1912
2199
  const input = await body(c);
1913
2200
  const label = typeof input["label"] === "string" ? input["label"].trim() : "";
1914
2201
  if (label === "" || label.length > 100) return refused("A token needs a label of 1 to 100 characters.", "Say what the token is for.");
1915
- const asked = Array.isArray(input["scopes"]) ? input["scopes"].filter((s) => typeof s === "string") : [];
2202
+ const asKind = input["kind"];
2203
+ const kind = asKind === void 0 || asKind === "personal" ? "personal" : asKind === "read" ? "read" : null;
2204
+ if (kind === null) return refused("kind is \"personal\" or \"read\".", "Leave it out for a personal token; \"read\" makes a token that reads published content.");
2205
+ const asked = kind === "read" ? ["content:read"] : Array.isArray(input["scopes"]) ? input["scopes"].filter((s) => typeof s === "string") : [];
1916
2206
  const scopes = person.scopes.filter((scope) => asked.includes(scope));
1917
2207
  if (scopes.length === 0) return refused("A token needs at least one scope that you have.", `Choose among ${person.scopes.join(", ")}.`);
1918
2208
  const days = daysFrom(input["expiresInDays"], TOKEN_DAYS);
1919
2209
  if (days === null) return refused("expiresInDays is a whole number from 1 to 365.", "Leave it out for 90 days.");
1920
2210
  const now = options.clock.now();
1921
- const secret = generatePersonalToken();
2211
+ const secret = kind === "read" ? generateReadToken() : generatePersonalToken();
1922
2212
  const row = {
1923
2213
  id: newId("tok"),
1924
- siteId: options.site,
2214
+ kind,
2215
+ siteId: person.site,
1925
2216
  userId: person.userId,
1926
2217
  label,
1927
2218
  hash: await hashPersonalToken(secret),
@@ -1935,20 +2226,21 @@ function installAdminApi(app, options) {
1935
2226
  await connect(c.env.DB).insert(tokens).values(row);
1936
2227
  return json$1({
1937
2228
  id: row.id,
2229
+ kind,
1938
2230
  label,
1939
2231
  scopes,
1940
2232
  expiresAt: row.expiresAt,
1941
2233
  secret
1942
2234
  }, 201);
1943
2235
  }));
1944
- app.post("/v1/admin/tokens/:id/revoke", admin(async (c) => {
2236
+ app.post("/v1/admin/tokens/:id/revoke", admin(async (c, person) => {
1945
2237
  const now = options.clock.now().toISOString();
1946
2238
  const db = connect(c.env.DB);
1947
2239
  const id = c.req.param("id") ?? "";
1948
2240
  const [revoked] = await db.update(tokens).set({
1949
2241
  revokedAt: now,
1950
2242
  updatedAt: now
1951
- }).where(and(eq(tokens.siteId, options.site), eq(tokens.id, id), isNull(tokens.revokedAt))).returning({ id: tokens.id });
2243
+ }).where(and(eq(tokens.siteId, person.site), eq(tokens.id, id), isNull(tokens.revokedAt))).returning({ id: tokens.id });
1952
2244
  if (revoked !== void 0) return json$1({
1953
2245
  id: revoked.id,
1954
2246
  revokedAt: now
@@ -1956,13 +2248,13 @@ function installAdminApi(app, options) {
1956
2248
  const [row] = await db.select({
1957
2249
  id: tokens.id,
1958
2250
  revokedAt: tokens.revokedAt
1959
- }).from(tokens).where(and(eq(tokens.siteId, options.site), eq(tokens.id, id)));
2251
+ }).from(tokens).where(and(eq(tokens.siteId, person.site), eq(tokens.id, id)));
1960
2252
  return row === void 0 ? refused("There is no such token.", "List the tokens and pick one of them.", 404) : json$1({
1961
2253
  id: row.id,
1962
2254
  revokedAt: row.revokedAt
1963
2255
  });
1964
2256
  }));
1965
- app.get("/v1/admin/people", admin(async (c) => {
2257
+ app.get("/v1/admin/people", admin(async (c, person) => {
1966
2258
  const db = connect(c.env.DB);
1967
2259
  const now = options.clock.now().toISOString();
1968
2260
  return json$1({
@@ -1971,22 +2263,22 @@ function installAdminApi(app, options) {
1971
2263
  name: users.name,
1972
2264
  role: members.role,
1973
2265
  since: members.createdAt
1974
- }).from(members).innerJoin(users, eq(users.id, members.userId)).where(eq(members.siteId, options.site)).orderBy(members.createdAt, users.id),
2266
+ }).from(members).innerJoin(users, eq(users.id, members.userId)).where(eq(members.siteId, person.site)).orderBy(members.createdAt, users.id),
1975
2267
  invitations: await db.select({
1976
2268
  id: invitations.id,
1977
2269
  role: invitations.role,
1978
2270
  expiresAt: invitations.expiresAt
1979
- }).from(invitations).where(and(eq(invitations.siteId, options.site), isNull(invitations.usedAt), isNull(invitations.userId), gt(invitations.expiresAt, now))).orderBy(desc(invitations.createdAt))
2271
+ }).from(invitations).where(and(eq(invitations.siteId, person.site), isNull(invitations.usedAt), isNull(invitations.userId), gt(invitations.expiresAt, now))).orderBy(desc(invitations.createdAt))
1980
2272
  });
1981
2273
  }));
1982
- app.post("/v1/admin/invitations", admin(async (c) => {
2274
+ app.post("/v1/admin/invitations", admin(async (c, person) => {
1983
2275
  const input = await body(c);
1984
2276
  const role = input["role"];
1985
2277
  if (typeof role !== "string" || !isRole(role) || role === "owner") return refused("An invitation is for an editor or an author.", `Use role "editor" (${ROLE_SCOPES.editor.join(", ")}) or "author" (${ROLE_SCOPES.author.join(", ")}).`);
1986
2278
  const days = daysFrom(input["days"], INVITATION_DAYS);
1987
2279
  if (days === null) return refused("days is a whole number from 1 to 365.", "Leave it out for 7 days.");
1988
2280
  const made = await createInvitation(connect(c.env.DB), {
1989
- site: options.site,
2281
+ site: person.site,
1990
2282
  role,
1991
2283
  userId: null,
1992
2284
  now: options.clock.now(),
@@ -2002,6 +2294,197 @@ function installAdminApi(app, options) {
2002
2294
  }));
2003
2295
  }
2004
2296
  //#endregion
2297
+ //#region src/semantic/cloudflare.ts
2298
+ /** The embedding model when the config names none: multilingual, so Spanish finds English. */
2299
+ const DEFAULT_EMBEDDING_MODEL = "@cf/baai/bge-m3";
2300
+ function isRecord$3(value) {
2301
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2302
+ }
2303
+ /** Workers AI as an embedder: `{ data: number[][] }` for a batch of texts. */
2304
+ function workersAiEmbedder(ai, model) {
2305
+ return {
2306
+ model,
2307
+ async embed(texts) {
2308
+ if (texts.length === 0) return [];
2309
+ const answer = await ai.run(model, { text: [...texts] });
2310
+ const data = isRecord$3(answer) ? answer["data"] : void 0;
2311
+ if (!Array.isArray(data) || data.length !== texts.length) throw new Error(`${model} answered no vector for each of ${texts.length} texts`);
2312
+ return data.map((vector) => {
2313
+ if (!Array.isArray(vector)) throw new Error(`${model} answered a vector that is no list`);
2314
+ return vector.map(Number);
2315
+ });
2316
+ }
2317
+ };
2318
+ }
2319
+ function metadataOf(raw) {
2320
+ if (raw === void 0) return null;
2321
+ const { site, entry, collection, locale, piece, excerpt } = raw;
2322
+ if (typeof excerpt !== "string" || typeof site !== "string" || typeof entry !== "string" || typeof collection !== "string" || typeof locale !== "string" || typeof piece !== "number") return null;
2323
+ return {
2324
+ site,
2325
+ entry,
2326
+ collection,
2327
+ locale,
2328
+ piece,
2329
+ excerpt
2330
+ };
2331
+ }
2332
+ /** Vectorize as the index. Its metadata indexes on site, collection, locale and piece filter. */
2333
+ function vectorizeIndex(binding) {
2334
+ return {
2335
+ async upsert(vectors) {
2336
+ if (vectors.length === 0) return;
2337
+ await binding.upsert(vectors.map((vector) => ({
2338
+ id: vector.id,
2339
+ values: [...vector.values],
2340
+ metadata: { ...vector.metadata }
2341
+ })));
2342
+ },
2343
+ async remove(ids) {
2344
+ if (ids.length > 0) await binding.deleteByIds([...ids]);
2345
+ },
2346
+ async get(ids) {
2347
+ if (ids.length === 0) return [];
2348
+ return (await binding.getByIds([...ids])).map((vector) => ({
2349
+ id: vector.id,
2350
+ values: Array.from(vector.values)
2351
+ }));
2352
+ },
2353
+ async query(values, options) {
2354
+ return (await binding.query([...values], {
2355
+ topK: options.topK,
2356
+ returnMetadata: "all",
2357
+ filter: {
2358
+ site: options.site,
2359
+ ...options.collection === void 0 ? {} : { collection: options.collection },
2360
+ ...options.locale === void 0 ? {} : { locale: options.locale },
2361
+ ...options.summariesOnly === true ? { piece: 0 } : {}
2362
+ }
2363
+ })).matches.flatMap((match) => {
2364
+ const metadata = metadataOf(match.metadata);
2365
+ return metadata === null ? [] : [{
2366
+ id: match.id,
2367
+ score: match.score,
2368
+ metadata
2369
+ }];
2370
+ });
2371
+ }
2372
+ };
2373
+ }
2374
+ /** Semantic search over the Worker's bindings, or `null` when it does not have both. */
2375
+ function semanticOf(env, model) {
2376
+ const ai = Reflect.get(env, "AI");
2377
+ const vectorize = Reflect.get(env, "VECTORIZE");
2378
+ if (!isRecord$3(ai) || typeof ai["run"] !== "function") return null;
2379
+ if (!isRecord$3(vectorize) || typeof vectorize["query"] !== "function") return null;
2380
+ return {
2381
+ embedder: workersAiEmbedder(ai, model),
2382
+ index: vectorizeIndex(vectorize)
2383
+ };
2384
+ }
2385
+ /**
2386
+ * Semantic search over the Worker's bindings, or `null` without them. The model is the variable
2387
+ * `HOLLWARE_EMBEDDING_MODEL`, or `DEFAULT_EMBEDDING_MODEL`; the Vectorize index must have been
2388
+ * made for its dimensions, so a new model needs a new index.
2389
+ */
2390
+ function semanticOfEnv(env) {
2391
+ const model = Reflect.get(env, "HOLLWARE_EMBEDDING_MODEL");
2392
+ return semanticOf(env, typeof model === "string" && model !== "" ? model : DEFAULT_EMBEDDING_MODEL);
2393
+ }
2394
+ //#endregion
2395
+ //#region src/analytics/cloudflare.ts
2396
+ /** The dataset of Analytics Engine when the Worker names none. */
2397
+ const DEFAULT_ANALYTICS_DATASET = "hollware_page_views";
2398
+ /** Where the SQL API of Analytics Engine answers. */
2399
+ function sqlApiUrl(accountId) {
2400
+ return `https://api.cloudflare.com/client/v4/accounts/${accountId}/analytics_engine/sql`;
2401
+ }
2402
+ function isRecord$2(value) {
2403
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2404
+ }
2405
+ /**
2406
+ * Analytics Engine as the writer: one data point per view, indexed by the site (its sampling key),
2407
+ * with the path and the country as blobs and a count of 1.
2408
+ */
2409
+ function analyticsEngineWriter(binding) {
2410
+ return { record(view) {
2411
+ binding.writeDataPoint({
2412
+ indexes: [view.site],
2413
+ blobs: [view.path, view.country],
2414
+ doubles: [1]
2415
+ });
2416
+ } };
2417
+ }
2418
+ /**
2419
+ * The query of the most read paths. The SQL API takes no parameters, so every value in it is
2420
+ * checked first: a site key (`[a-z0-9-]`), a dataset name and two whole numbers.
2421
+ */
2422
+ function topPagesSql(dataset, site, query) {
2423
+ if (!/^[a-z_][a-z0-9_]{0,63}$/i.test(dataset)) throw new Error(`No dataset is named ${dataset}`);
2424
+ if (!isSiteKey(site)) throw new Error(`No site key is ${String(site)}`);
2425
+ if (!Number.isInteger(query.days) || query.days < 1 || query.days > 90) throw new Error(`${query.days} days is not a whole number from 1 to 90`);
2426
+ if (!Number.isInteger(query.limit) || query.limit < 1 || query.limit > 50) throw new Error(`${query.limit} pages is not a whole number from 1 to 50`);
2427
+ return [
2428
+ "SELECT blob1 AS path, SUM(_sample_interval) AS views",
2429
+ `FROM ${dataset}`,
2430
+ `WHERE index1 = '${site}' AND timestamp > NOW() - INTERVAL '${query.days}' DAY`,
2431
+ "GROUP BY path",
2432
+ "ORDER BY views DESC",
2433
+ `LIMIT ${query.limit}`,
2434
+ "FORMAT JSON"
2435
+ ].join(" ");
2436
+ }
2437
+ /** The SQL API of Analytics Engine as the reader, its answers kept for ten minutes. */
2438
+ function sqlApiReader(options) {
2439
+ return { async topPages(site, query) {
2440
+ const sql = topPagesSql(options.dataset, site, query);
2441
+ const key = new Request(`https://hollware.invalid/content-stats/${options.dataset}/${site}?days=${query.days}&limit=${query.limit}`);
2442
+ const cached = await options.cache?.match(key);
2443
+ const answer = cached ?? await options.fetch(new Request(sqlApiUrl(options.accountId), {
2444
+ method: "POST",
2445
+ headers: { authorization: `Bearer ${options.token}` },
2446
+ body: sql
2447
+ }));
2448
+ if (!answer.ok) throw new Error(`The SQL API of Analytics Engine answered ${answer.status}`);
2449
+ const text = await answer.text();
2450
+ if (cached === void 0 && options.cache !== null) await options.cache.put(key, new Response(text, { headers: { "cache-control": `max-age=600` } }));
2451
+ const body = JSON.parse(text);
2452
+ return (isRecord$2(body) && Array.isArray(body["data"]) ? body["data"] : []).flatMap((row) => {
2453
+ if (!isRecord$2(row) || typeof row["path"] !== "string") return [];
2454
+ const views = Number(row["views"]);
2455
+ return Number.isFinite(views) ? [{
2456
+ path: row["path"],
2457
+ views
2458
+ }] : [];
2459
+ });
2460
+ } };
2461
+ }
2462
+ /** The writer over the Worker's `ANALYTICS` binding, or `null` without one. */
2463
+ function analyticsWriterOf(env) {
2464
+ const binding = Reflect.get(env, "ANALYTICS");
2465
+ if (!isRecord$2(binding) || typeof binding["writeDataPoint"] !== "function") return null;
2466
+ return analyticsEngineWriter(binding);
2467
+ }
2468
+ /**
2469
+ * The reader over the Worker's variables: `HOLLWARE_ANALYTICS_ACCOUNT_ID` and the secret
2470
+ * `HOLLWARE_ANALYTICS_TOKEN` (an API token with Account Analytics Read), and the dataset of
2471
+ * `HOLLWARE_ANALYTICS_DATASET` or the default one. `null` without the account or the token.
2472
+ */
2473
+ function analyticsReaderOf(env) {
2474
+ const accountId = Reflect.get(env, "HOLLWARE_ANALYTICS_ACCOUNT_ID");
2475
+ const token = Reflect.get(env, "HOLLWARE_ANALYTICS_TOKEN");
2476
+ const dataset = Reflect.get(env, "HOLLWARE_ANALYTICS_DATASET");
2477
+ if (typeof accountId !== "string" || accountId === "") return null;
2478
+ if (typeof token !== "string" || token === "") return null;
2479
+ return sqlApiReader({
2480
+ accountId,
2481
+ token,
2482
+ dataset: typeof dataset === "string" && dataset !== "" ? dataset : DEFAULT_ANALYTICS_DATASET,
2483
+ fetch: (request) => fetch(request),
2484
+ cache: typeof caches === "undefined" ? null : caches.default
2485
+ });
2486
+ }
2487
+ //#endregion
2005
2488
  //#region src/http/ops-api.ts
2006
2489
  const STATUS$1 = {
2007
2490
  invalid_input: 400,
@@ -2041,15 +2524,19 @@ function installOpsApi(app, options) {
2041
2524
  fix: "Send the input of the operation as its input schema describes it."
2042
2525
  }, 400);
2043
2526
  }
2527
+ const site = options.sites.get(principal.site);
2528
+ if (site === void 0) throw new Error(`No site ${principal.site} for a checked token`);
2044
2529
  const context = {
2045
- site: options.site,
2530
+ site,
2046
2531
  principal,
2047
2532
  view: "head",
2048
2533
  store: createD1Store(c.env.DB, { clock: options.clock }),
2049
2534
  clock: options.clock,
2050
2535
  client: { name: "hollware-admin" },
2051
2536
  requestId,
2052
- notify: options.notify(c, requestId)
2537
+ notify: options.notify(c, requestId, site.key),
2538
+ semantic: semanticOfEnv(c.env),
2539
+ analytics: analyticsReaderOf(c.env)
2053
2540
  };
2054
2541
  try {
2055
2542
  return json(spec.output.parse(await invoke$1(options.handlers, spec, parsed.data, context)));
@@ -2076,11 +2563,44 @@ function installOpsApi(app, options) {
2076
2563
  }
2077
2564
  //#endregion
2078
2565
  //#region src/http/read-api.ts
2566
+ /** How long a browser keeps the answer of a preflight, in seconds. */
2567
+ const PREFLIGHT_MAX_AGE = 600;
2568
+ /** The origin of a request from a page of another origin than Hollware's own, or `null`. */
2569
+ function crossOriginOf(c) {
2570
+ const origin = c.req.header("origin");
2571
+ if (origin === void 0) return null;
2572
+ return origin.toLowerCase() === new URL(c.env.HOLLWARE_PUBLIC_URL).origin ? null : origin;
2573
+ }
2079
2574
  const STATUS = {
2080
2575
  invalid_input: 400,
2081
2576
  unknown_collection: 404,
2082
2577
  unknown_locale: 404
2083
2578
  };
2579
+ /**
2580
+ * A route of the read API whose `HollwareError` (an unknown collection, a limit out of range) is
2581
+ * the caller's to read, with its status. It is caught here, in the route: Hono 5 hands an error
2582
+ * that a route throws to the app's error handler before any middleware around it sees it, and
2583
+ * that handler answers every error with the same 500. The answer says how long the read took on
2584
+ * the server, after the token was checked (`Server-Timing: read;dur=<ms>`), so that a client can
2585
+ * tell the server's time from the network's.
2586
+ */
2587
+ function readRoute(handler) {
2588
+ return async (c) => {
2589
+ const started = Date.now();
2590
+ try {
2591
+ const response = await handler(c);
2592
+ response.headers.set("server-timing", `read;dur=${Date.now() - started}`);
2593
+ return response;
2594
+ } catch (error) {
2595
+ if (!(error instanceof HollwareError)) throw error;
2596
+ return c.json({
2597
+ code: error.code,
2598
+ message: error.message,
2599
+ fix: error.fix
2600
+ }, STATUS[error.code] ?? 500);
2601
+ }
2602
+ };
2603
+ }
2084
2604
  /** A weak ETag of a body: the first 128 bits of its SHA-256, in hex. */
2085
2605
  async function etagOf(body) {
2086
2606
  const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(body)));
@@ -2112,47 +2632,96 @@ async function answer(c, value) {
2112
2632
  function installReadApi(app, options) {
2113
2633
  app.use("/v1/*", async (c, next) => {
2114
2634
  if (c.req.path.startsWith("/v1/ops/") || c.req.path.startsWith("/v1/admin/")) return next();
2635
+ const crossOrigin = crossOriginOf(c);
2636
+ if (c.req.method === "OPTIONS") {
2637
+ const headers = new Headers({
2638
+ allow: "GET, HEAD, OPTIONS",
2639
+ vary: "Origin"
2640
+ });
2641
+ if (crossOrigin !== null && options.anyCorsOrigin(crossOrigin)) {
2642
+ headers.set("access-control-allow-origin", crossOrigin);
2643
+ headers.set("access-control-allow-methods", "GET, HEAD");
2644
+ headers.set("access-control-allow-headers", "authorization, if-none-match");
2645
+ headers.set("access-control-max-age", String(PREFLIGHT_MAX_AGE));
2646
+ }
2647
+ return new Response(null, {
2648
+ status: 204,
2649
+ headers
2650
+ });
2651
+ }
2115
2652
  if (c.req.method !== "GET" && c.req.method !== "HEAD") return c.json({
2116
2653
  code: "invalid_input",
2117
2654
  message: "The read API only reads.",
2118
2655
  fix: "Use GET."
2119
2656
  }, 405, { allow: "GET, HEAD" });
2120
2657
  if (c.req.path === "/v1/openapi.json") return next();
2121
- return options.protect(c, async (principal) => {
2658
+ c.res = await options.protect(c, async (principal) => {
2122
2659
  if (!principal.scopes.includes("content:read")) return insufficientScope(["content:read"], options.metadataUrl(c));
2123
- try {
2124
- await next();
2125
- return c.res;
2126
- } catch (error) {
2127
- if (!(error instanceof HollwareError)) throw error;
2128
- return c.json({
2129
- code: error.code,
2130
- message: error.message,
2131
- fix: error.fix
2132
- }, STATUS[error.code] ?? 500);
2133
- }
2660
+ c.set("site", principal.site);
2661
+ if (crossOrigin !== null && !options.corsOrigins(principal.site).includes(crossOrigin)) return c.json({
2662
+ code: "forbidden_origin",
2663
+ message: `The site "${principal.site}" does not let pages of ${crossOrigin} read it.`,
2664
+ fix: `Add "${crossOrigin}" to cors.origins of the site in hollware.config.ts, or read from a server.`
2665
+ }, 403, {
2666
+ vary: "Origin",
2667
+ "access-control-allow-origin": new URL(c.env.HOLLWARE_PUBLIC_URL).origin
2668
+ });
2669
+ await next();
2670
+ const answered = c.res;
2671
+ if (crossOrigin === null) return answered;
2672
+ const shared = new Response(answered.body, answered);
2673
+ shared.headers.set("access-control-allow-origin", crossOrigin);
2674
+ shared.headers.set("access-control-expose-headers", "etag");
2675
+ shared.headers.append("vary", "Origin");
2676
+ return shared;
2134
2677
  });
2678
+ return c.res;
2135
2679
  });
2136
- app.get("/v1/openapi.json", (c) => c.json(openApiDocument(options.site)));
2137
- app.get("/v1/site", async (c) => answer(c, await options.read(c.env).describeSite(options.site)));
2138
- app.get("/v1/:collection", async (c) => {
2680
+ app.get("/v1/openapi.json", (c) => c.json(openApiDocument()));
2681
+ app.get("/v1/site", readRoute(async (c) => answer(c, await options.read(c.env).describeSite(c.get("site")))));
2682
+ app.get("/v1/search", readRoute(async (c) => {
2683
+ const query = c.req.query();
2684
+ const limit = query["limit"] === void 0 ? void 0 : Number(query["limit"]);
2685
+ return answer(c, await options.read(c.env).search(c.get("site"), query["q"] ?? "", {
2686
+ ...query["collection"] === void 0 ? {} : { collection: query["collection"] },
2687
+ ...query["locale"] === void 0 ? {} : { locale: query["locale"] },
2688
+ ...limit === void 0 ? {} : { limit },
2689
+ ...query["mode"] === void 0 ? {} : { mode: searchModeOf(query["mode"]) }
2690
+ }));
2691
+ }));
2692
+ app.get("/v1/:collection", readRoute(async (c) => {
2139
2693
  const query = c.req.query();
2140
2694
  const limit = query["limit"] === void 0 ? void 0 : Number(query["limit"]);
2141
- return answer(c, await options.read(c.env).listEntries(options.site, c.req.param("collection"), {
2695
+ return answer(c, await options.read(c.env).listEntries(c.get("site"), c.req.param("collection"), {
2142
2696
  ...query["locale"] === void 0 ? {} : { locale: query["locale"] },
2143
2697
  ...query["tag"] === void 0 ? {} : { tag: query["tag"] },
2144
2698
  ...limit === void 0 ? {} : { limit },
2145
2699
  ...query["cursor"] === void 0 ? {} : { cursor: query["cursor"] }
2146
2700
  }));
2147
- });
2148
- app.get("/v1/:collection/translations/:group", async (c) => answer(c, await options.read(c.env).getTranslations(options.site, c.req.param("collection"), c.req.param("group"))));
2149
- app.get("/v1/:collection/:locale/:slug", async (c) => {
2701
+ }));
2702
+ app.get("/v1/:collection/translations/:group", readRoute(async (c) => answer(c, await options.read(c.env).getTranslations(c.get("site"), c.req.param("collection"), c.req.param("group")))));
2703
+ app.get("/v1/:collection/:locale/:slug", readRoute(async (c) => {
2150
2704
  const formats = c.req.query("formats");
2151
- return answer(c, await options.read(c.env).getEntry(options.site, c.req.param("collection"), c.req.param("slug"), c.req.param("locale"), formats === void 0 ? {} : { formats: formats.split(",").flatMap((format) => format === "markdown" || format === "document" || format === "html" ? [format] : []) }));
2152
- });
2705
+ const related = c.req.query("related") === "true" ? { related: true } : {};
2706
+ return answer(c, await options.read(c.env).getEntry(c.get("site"), c.req.param("collection"), c.req.param("slug"), c.req.param("locale"), formats === void 0 ? related : {
2707
+ ...related,
2708
+ formats: formats.split(",").flatMap((format) => format === "markdown" || format === "document" || format === "html" ? [format] : [])
2709
+ }));
2710
+ }));
2711
+ }
2712
+ const SEARCH_MODES = [
2713
+ "fulltext",
2714
+ "semantic",
2715
+ "hybrid"
2716
+ ];
2717
+ /** The `mode` of a search from the query string, or the error that names the ones there are. */
2718
+ function searchModeOf(value) {
2719
+ const mode = SEARCH_MODES.find((known) => known === value);
2720
+ if (mode === void 0) throw new HollwareError("invalid_input", `The search mode ${value} is not one Hollware knows.`, `Pass mode as one of ${SEARCH_MODES.join(", ")}, or leave it out for hybrid.`);
2721
+ return mode;
2153
2722
  }
2154
2723
  /** The OpenAPI 3.1 document of the read API. */
2155
- function openApiDocument(site) {
2724
+ function openApiDocument() {
2156
2725
  const ok = (description) => ({
2157
2726
  description,
2158
2727
  headers: { ETag: { schema: { type: "string" } } },
@@ -2172,7 +2741,7 @@ function openApiDocument(site) {
2172
2741
  return {
2173
2742
  openapi: "3.1.0",
2174
2743
  info: {
2175
- title: `Hollware read API (${site})`,
2744
+ title: "Hollware read API",
2176
2745
  version: "1"
2177
2746
  },
2178
2747
  components: { securitySchemes: { token: {
@@ -2188,6 +2757,20 @@ function openApiDocument(site) {
2188
2757
  ...common
2189
2758
  }
2190
2759
  } },
2760
+ "/v1/search": { get: {
2761
+ operationId: "search",
2762
+ parameters: [
2763
+ param("q", "query", true),
2764
+ param("collection", "query", false),
2765
+ param("locale", "query", false),
2766
+ param("limit", "query", false),
2767
+ param("mode", "query", false)
2768
+ ],
2769
+ responses: {
2770
+ "200": ok("The published entries that match q, best first"),
2771
+ ...common
2772
+ }
2773
+ } },
2191
2774
  "/v1/{collection}": { get: {
2192
2775
  operationId: "list_entries",
2193
2776
  parameters: [
@@ -2217,7 +2800,8 @@ function openApiDocument(site) {
2217
2800
  param("collection", "path", true),
2218
2801
  param("locale", "path", true),
2219
2802
  param("slug", "path", true),
2220
- param("formats", "query", false)
2803
+ param("formats", "query", false),
2804
+ param("related", "query", false)
2221
2805
  ],
2222
2806
  responses: {
2223
2807
  "200": ok("A published entry"),
@@ -2237,9 +2821,25 @@ function openApiDocument(site) {
2237
2821
  * (`PublicKeyCredential.parseCreationOptionsFromJSON`, `credential.toJSON()`), and reads the
2238
2822
  * invitation from the address bar, so the page never prints it.
2239
2823
  */
2240
- const STYLE = `body{font:1rem/1.5 system-ui,sans-serif;max-width:28rem;margin:4rem auto;padding:0 1rem;color:#1d1d1f}
2241
- label{display:block;margin:.75rem 0 .25rem}input{width:100%;padding:.5rem;font:inherit}
2242
- button{margin-top:1rem;padding:.6rem 1rem;font:inherit;cursor:pointer}[role=alert]{color:#b00020}`;
2824
+ /**
2825
+ * 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. Ink greys and the mint accent, dark when the device is.
2827
+ */
2828
+ const STYLE = `:root{color-scheme:light dark;--bg:#f6f7f8;--card:#fff;--fg:#15181b;--muted:#545c66;--line:#d6dadf;--accent:#0c825d;--on-accent:#fff;--danger:#b42318}
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}}
2830
+ *{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
+ main{width:100%;max-width:26rem;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:2rem}
2832
+ .brand{display:flex;align-items:center;gap:.6rem;margin-bottom:1.75rem;font-weight:600;letter-spacing:-.01em}.brand svg{width:26px;height:26px}
2833
+ h1{font-size:1.4rem;line-height:1.25;letter-spacing:-.02em;margin:0 0 .5rem}p{color:var(--muted);margin:.5rem 0}strong{color:var(--fg)}
2834
+ label{display:block;margin:1rem 0 .35rem;font-size:.9rem;font-weight:500}fieldset label,form>label:has(input[type=checkbox]){font-weight:400;display:flex;align-items:center;gap:.5rem;margin:.5rem 0}
2835
+ input{width:100%;padding:.6rem .75rem;font:inherit;color:inherit;background:transparent;border:1px solid var(--line);border-radius:8px}input:focus{outline:2px solid var(--accent);outline-offset:1px}
2836
+ label input{width:auto;margin:0}fieldset{border:1px solid var(--line);border-radius:8px;margin:1rem 0;padding:.5rem .9rem}legend{color:var(--muted);font-size:.85rem;padding:0 .3rem}
2837
+ code{font:.82em ui-monospace,'SF Mono',Menlo,monospace;color:var(--muted)}
2838
+ button{margin-top:1.25rem;padding:.6rem 1rem;font:inherit;font-weight:500;cursor:pointer;border-radius:8px;border:1px solid var(--line);background:transparent;color:var(--fg)}
2839
+ button:not([value=deny]){background:var(--accent);border-color:var(--accent);color:var(--on-accent)}
2840
+ [role=alert]{color:var(--danger)}`;
2841
+ /** 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(--accent)" stroke-width="3" stroke-linecap="round" fill="none"/></svg>Hollware</div>`;
2243
2843
  const POST = `async function post(path, body) {
2244
2844
  const response = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
2245
2845
  const data = await response.json()
@@ -2258,6 +2858,7 @@ function page(title, nonce, body, script) {
2258
2858
  </head>
2259
2859
  <body>
2260
2860
  <main>
2861
+ ${BRAND}
2261
2862
  <h1>${title}</h1>
2262
2863
  ${body}
2263
2864
  <p id="error" role="alert"></p>
@@ -2327,6 +2928,7 @@ function consentPage(view) {
2327
2928
  const name = escapeHtml(view.clientName);
2328
2929
  const origin = view.clientDomain === void 0 ? "<p>This app registered itself: its name is not verified.</p>" : `<p>Published by <strong>${escapeHtml(view.clientDomain)}</strong>.</p>`;
2329
2930
  const loopback = view.redirectIsLoopback ? "<p><strong>This sends access to an app on this computer.</strong> Continue only if you just started to connect it.</p>" : "";
2931
+ const sites = view.sites.length === 1 ? `<input type="hidden" name="site" value="${escapeHtml(view.sites[0] ?? "")}">` : `<fieldset><legend>For the site</legend>${view.sites.map((site, index) => `<label><input type="radio" name="site" value="${escapeHtml(site)}"${index === 0 ? " checked" : ""}> ${escapeHtml(site)}</label>`).join("\n")}</fieldset>`;
2330
2932
  const scopes = view.scopes.map((scope) => `<label><input type="checkbox" name="scope" value="${escapeHtml(scope)}" checked> ${escapeHtml(SCOPE_WORDS[scope] ?? scope)} <code>${escapeHtml(scope)}</code></label>`).join("\n");
2331
2933
  return `<!doctype html>
2332
2934
  <html lang="en">
@@ -2334,16 +2936,18 @@ function consentPage(view) {
2334
2936
  <meta charset="utf-8">
2335
2937
  <meta name="viewport" content="width=device-width, initial-scale=1">
2336
2938
  <title>Allow ${name} · Hollware</title>
2337
- <style>${STYLE}label input{width:auto;margin-right:.5rem}</style>
2939
+ <style>${STYLE}</style>
2338
2940
  </head>
2339
2941
  <body>
2340
2942
  <main>
2943
+ ${BRAND}
2341
2944
  <h1>Allow ${name} to use this Hollware?</h1>
2342
2945
  ${origin}
2343
2946
  <p>Signed in as <strong>${escapeHtml(view.person)}</strong>. Access goes to <strong>${escapeHtml(view.redirectHost)}</strong>.</p>
2344
2947
  ${loopback}
2345
2948
  <form method="post" action="/authorize">
2346
2949
  <input type="hidden" name="handle" value="${escapeHtml(view.handle)}">
2950
+ ${sites}
2347
2951
  ${scopes}
2348
2952
  <p><button name="decision" value="approve">Allow</button> <button name="decision" value="deny">Deny</button></p>
2349
2953
  </form>
@@ -2364,6 +2968,7 @@ function messagePage(title, message) {
2364
2968
  </head>
2365
2969
  <body>
2366
2970
  <main>
2971
+ ${BRAND}
2367
2972
  <h1>${escapeHtml(title)}</h1>
2368
2973
  <p>${escapeHtml(message)}</p>
2369
2974
  </main>
@@ -2391,7 +2996,7 @@ function plainPageHeaders() {
2391
2996
  * The ceremonies, with SimpleWebAuthn and its parsers, in a chunk of their own: only these four
2392
2997
  * routes use them, and a Worker that serves MCP never pays for them at startup (`budgets.json`).
2393
2998
  */
2394
- const ceremonies = () => import("./ceremonies-D7tsWU3S.mjs");
2999
+ const ceremonies = () => import("./ceremonies-BM9G03lZ.mjs");
2395
3000
  /** The cookie of a signed-in person: only `/authorize` reads it (ADR-026). */
2396
3001
  const SESSION_COOKIE = "hollware_session";
2397
3002
  function sessionCookie(secret) {
@@ -2404,7 +3009,6 @@ function isRecord$1(value) {
2404
3009
  function installIdentity(app, options) {
2405
3010
  const ceremony = (c) => ({
2406
3011
  db: connect(c.env.DB),
2407
- site: options.site,
2408
3012
  publicUrl: c.env.HOLLWARE_PUBLIC_URL,
2409
3013
  now: options.clock.now()
2410
3014
  });
@@ -2508,36 +3112,52 @@ function answerError(error) {
2508
3112
  * `access_denied`.
2509
3113
  * - The grant stores the person and the site. What its tokens may do is read again on each request
2510
3114
  * against the person's role then (`server.ts`).
3115
+ * - The site is the one `?site=` names, if the person is a member of it; else the only site the
3116
+ * person is a member of; else the consent page asks which (v0.4). The admin names its site, and
3117
+ * takes the first one when it names none.
2511
3118
  */
2512
3119
  function installAuthorize(app, options) {
2513
3120
  const person = async (c) => {
2514
3121
  const secret = getCookie(c, SESSION_COOKIE);
2515
3122
  if (secret === void 0) return null;
2516
- return await findSession(connect(c.env.DB), options.site, secret, options.clock.now());
3123
+ const db = connect(c.env.DB);
3124
+ const found = await findSession(db, secret, options.clock.now());
3125
+ if (found === null) return null;
3126
+ const memberships = (await membershipsOf(db, found.userId)).filter((membership) => options.sites.has(membership.site));
3127
+ return {
3128
+ ...found,
3129
+ memberships
3130
+ };
2517
3131
  };
2518
3132
  const signIn = (c) => {
2519
3133
  const url = new URL(c.req.url);
2520
3134
  const back = `${url.pathname}${url.search}`;
2521
3135
  return c.redirect(`/signin?return_to=${encodeURIComponent(back)}`, 302);
2522
3136
  };
3137
+ const notMember = (site) => html(messagePage("Not a member", site === void 0 ? "You are not a member of any site of this Hollware. Ask an owner for an invitation." : `You are not a member of the site "${site}". Ask its owner for an invitation.`), 403);
2523
3138
  app.get("/authorize", async (c) => {
2524
- const oauth = options.oauth(c.env.HOLLWARE_PUBLIC_URL).authorizationServer.getOAuthApi(c.env);
3139
+ const server = options.oauth(c.env.HOLLWARE_PUBLIC_URL);
3140
+ const oauth = server.authorizationServer.getOAuthApi(c.env);
2525
3141
  try {
2526
3142
  const request = await oauth.parseAuthRequest(c.req.raw);
2527
3143
  const signedIn = await person(c);
2528
3144
  if (signedIn === null) return signIn(c);
2529
- const server = options.oauth(c.env.HOLLWARE_PUBLIC_URL);
3145
+ const [first] = signedIn.memberships;
3146
+ if (first === void 0) return notMember(void 0);
3147
+ const asked = c.req.query("site");
3148
+ const named = asked === void 0 ? void 0 : signedIn.memberships.find((m) => m.site === asked);
3149
+ if (asked !== void 0 && named === void 0) return notMember(asked);
2530
3150
  if (request.clientId === (await adminClient(server, c.env)).clientId) {
2531
- const scope = grantableScopes(signedIn.role, request.scope);
3151
+ const membership = named ?? first;
2532
3152
  const props = {
2533
3153
  userId: signedIn.userId,
2534
- site: options.site
3154
+ site: membership.site
2535
3155
  };
2536
3156
  const { redirectTo } = await oauth.completeAuthorization({
2537
3157
  request,
2538
3158
  userId: signedIn.userId,
2539
- metadata: { site: options.site },
2540
- scope,
3159
+ metadata: { site: membership.site },
3160
+ scope: grantableScopes(membership.role, request.scope),
2541
3161
  props
2542
3162
  });
2543
3163
  return new Response(null, {
@@ -2545,7 +3165,9 @@ function installAuthorize(app, options) {
2545
3165
  headers: { location: redirectTo }
2546
3166
  });
2547
3167
  }
2548
- const scopes = grantableScopes(signedIn.role, request.scope.length === 0 ? BASELINE : request.scope);
3168
+ const choices = named !== void 0 ? [named] : signedIn.memberships;
3169
+ const asking = request.scope.length === 0 ? BASELINE : request.scope;
3170
+ const scopes = [...new Set(choices.flatMap((m) => grantableScopes(m.role, asking)))];
2549
3171
  if (scopes.length === 0) {
2550
3172
  const location = authorizationErrorRedirect(request, "access_denied", "Your role allows none of the scopes this client asked for.");
2551
3173
  return new Response(null, {
@@ -2562,6 +3184,7 @@ function installAuthorize(app, options) {
2562
3184
  redirectIsLoopback: details.redirectIsLoopback,
2563
3185
  person: signedIn.name ?? "you",
2564
3186
  scopes,
3187
+ sites: choices.map((m) => m.site),
2565
3188
  handle: consent.handle
2566
3189
  }), 200, consent.headers);
2567
3190
  } catch (error) {
@@ -2583,7 +3206,10 @@ function installAuthorize(app, options) {
2583
3206
  headers: denied.headers
2584
3207
  });
2585
3208
  }
2586
- const scope = grantableScopes(signedIn.role, form.getAll("scope").map(String));
3209
+ const chosen = form.get("site");
3210
+ const membership = signedIn.memberships.find((m) => m.site === chosen);
3211
+ if (membership === void 0) return notMember(typeof chosen === "string" ? chosen : void 0);
3212
+ const scope = grantableScopes(membership.role, form.getAll("scope").map(String));
2587
3213
  const approved = await oauth.approveConsent(c.req.raw, handle, { scope });
2588
3214
  if (scope.length === 0) {
2589
3215
  const location = authorizationErrorRedirect(approved.request, "access_denied", "No scope was allowed.");
@@ -2595,12 +3221,12 @@ function installAuthorize(app, options) {
2595
3221
  }
2596
3222
  const props = {
2597
3223
  userId: signedIn.userId,
2598
- site: options.site
3224
+ site: membership.site
2599
3225
  };
2600
3226
  const { redirectTo } = await oauth.completeAuthorization({
2601
3227
  request: approved.request,
2602
3228
  userId: signedIn.userId,
2603
- metadata: { site: options.site },
3229
+ metadata: { site: membership.site },
2604
3230
  scope,
2605
3231
  props
2606
3232
  });
@@ -2689,9 +3315,9 @@ function build(issuer, options) {
2689
3315
  pending.get(request)?.refused(reason);
2690
3316
  return null;
2691
3317
  };
2692
- if (isPersonalToken(token)) {
3318
+ if (isPersonalToken(token) || isReadToken(token)) {
2693
3319
  const check = await checkPersonalToken(env.DB, token, {
2694
- site: options.site,
3320
+ sites: options.sites,
2695
3321
  now: options.clock.now()
2696
3322
  });
2697
3323
  if (!check.ok) return refuse(check.reason);
@@ -2709,14 +3335,15 @@ function build(issuer, options) {
2709
3335
  const granted = await authorizationServer.validateToken(resource, token, env);
2710
3336
  if (granted === null) return refuse("unknown");
2711
3337
  if (!isGrantProps(granted.props)) return refuse("unknown");
2712
- if (granted.props.site !== options.site) return refuse("other_site");
2713
- const role = await roleOf(env.DB, options.site, granted.props.userId);
3338
+ const site = granted.props.site;
3339
+ if (!options.sites.has(site)) return refuse("other_site");
3340
+ const role = await roleOf(env.DB, site, granted.props.userId);
2714
3341
  if (role === null) return refuse("no_user");
2715
3342
  const principal = {
2716
3343
  kind: "oauth",
2717
3344
  userId: granted.props.userId,
2718
3345
  clientId: granted.clientId,
2719
- site: options.site,
3346
+ site,
2720
3347
  scopes: effectiveScopes(role, granted.scope.filter(isScope))
2721
3348
  };
2722
3349
  return {
@@ -2762,6 +3389,66 @@ function build(issuer, options) {
2762
3389
  };
2763
3390
  }
2764
3391
  //#endregion
3392
+ //#region src/http/events.ts
3393
+ /** The largest beacon the route reads: a site key and a path, with room to spare. */
3394
+ const MAX_BEACON_BYTES = 2048;
3395
+ /** The origins whose pages may count views of a site: its own URL and its `cors.origins`. */
3396
+ function pageOriginsOf(site) {
3397
+ return [...site.url === null ? [] : [new URL(site.url).origin], ...site.cors.origins];
3398
+ }
3399
+ function refusal(status, code, message, fix) {
3400
+ return Response.json({
3401
+ code,
3402
+ message,
3403
+ fix
3404
+ }, {
3405
+ status,
3406
+ headers: { "cache-control": "no-store" }
3407
+ });
3408
+ }
3409
+ /** The path of a view without its query and fragment, or `null` when it is no path. */
3410
+ function pathOf(value) {
3411
+ if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) return null;
3412
+ const { pathname } = new URL(value, "https://hollware.invalid");
3413
+ return pathname.length > 512 ? null : pathname;
3414
+ }
3415
+ /**
3416
+ * `POST /events`, the beacon of the sites (ADR-031): `{ "site": "<key>", "path": "/<path>" }`, sent
3417
+ * by a page with `navigator.sendBeacon`, so with no token. It counts a view of the path when the
3418
+ * page is of one of the site's origins (its URL or its `cors.origins`), and keeps the site, the
3419
+ * path and the country Cloudflare gives the request: never a cookie, an IP or a user agent. It
3420
+ * answers 204 whether or not the Worker has an `ANALYTICS` binding, so that a page cannot tell.
3421
+ */
3422
+ function installEvents(app, options) {
3423
+ app.post("/events", async (c) => {
3424
+ const text = await c.req.text();
3425
+ if (new TextEncoder().encode(text).byteLength > 2048) return refusal(413, "too_large", `A beacon is at most ${MAX_BEACON_BYTES} bytes.`, "Send the site and the path of the page, and nothing else.");
3426
+ let body;
3427
+ try {
3428
+ body = JSON.parse(text);
3429
+ } catch {
3430
+ body = null;
3431
+ }
3432
+ const key = typeof body === "object" && body !== null && "site" in body ? body.site : null;
3433
+ const site = typeof key === "string" ? options.sites.get(key) : void 0;
3434
+ const path = typeof body === "object" && body !== null && "path" in body ? pathOf(body.path) : null;
3435
+ 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
+ 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 in hollware.config.ts to the origin of its pages, or add the origin to cors.origins.`);
3438
+ const cf = Reflect.get(c.req.raw, "cf");
3439
+ const country = typeof cf === "object" && cf !== null && "country" in cf && typeof cf.country === "string" ? cf.country : "XX";
3440
+ analyticsWriterOf(c.env)?.record({
3441
+ site: site.key,
3442
+ path,
3443
+ country
3444
+ });
3445
+ return new Response(null, {
3446
+ status: 204,
3447
+ headers: { "cache-control": "no-store" }
3448
+ });
3449
+ });
3450
+ }
3451
+ //#endregion
2765
3452
  //#region src/http/gateway.ts
2766
3453
  /**
2767
3454
  * A 401 of a resource server, with Hollware's body: the same JSON as every other refusal, and the
@@ -2802,7 +3489,8 @@ function withRefusalBody(response, refused) {
2802
3489
  * nothing. A failed D1 query while the token is checked is one of those: it is never a 401.
2803
3490
  */
2804
3491
  function createGateway(options) {
2805
- const { site, mcp, clock, log } = options;
3492
+ const { sites, mcp, clock, log } = options;
3493
+ const siteKeys = new Set(sites.keys());
2806
3494
  const app = new Hono();
2807
3495
  /**
2808
3496
  * The Worker's own context of each request. Hono hands its routes the same object under a
@@ -2815,7 +3503,7 @@ function createGateway(options) {
2815
3503
  return ctx;
2816
3504
  };
2817
3505
  const oauth = createOAuth({
2818
- site: site.key,
3506
+ sites: siteKeys,
2819
3507
  clock,
2820
3508
  dynamicClientRegistration: options.auth?.dynamicClientRegistration ?? false,
2821
3509
  log
@@ -2824,11 +3512,11 @@ function createGateway(options) {
2824
3512
  * What a write hands to the Queue: the events it committed, after the answer (ADR-025). The
2825
3513
  * cron trigger's sweep catches any that this misses.
2826
3514
  */
2827
- const notify = (c, requestId) => (ids) => {
3515
+ const notify = (c, requestId, site) => (ids) => {
2828
3516
  c.executionCtx.waitUntil(enqueue({
2829
3517
  queue: c.env.EVENTS,
2830
3518
  db: connect(c.env.DB),
2831
- site: site.key,
3519
+ site,
2832
3520
  ids,
2833
3521
  clock
2834
3522
  }).catch((error) => {
@@ -2871,19 +3559,48 @@ function createGateway(options) {
2871
3559
  });
2872
3560
  return withRefusalBody(response, outcome.refusal !== null);
2873
3561
  };
3562
+ /** Every origin that the config of some site lets read the site from a page. */
3563
+ const corsOrigins = new Set([...sites.values()].flatMap((site) => site.cors.origins));
3564
+ /**
3565
+ * The guard lets a page of such an origin through to the read API alone, and only to read: the
3566
+ * read API then checks the origin against the site of the token.
3567
+ */
3568
+ const readsFromAnyCorsOrigin = (request, origin) => {
3569
+ const { pathname } = new URL(request.url);
3570
+ if (request.method === "POST" && pathname === "/events") return true;
3571
+ return corsOrigins.has(origin) && [
3572
+ "GET",
3573
+ "HEAD",
3574
+ "OPTIONS"
3575
+ ].includes(request.method) && pathname.startsWith("/v1/") && !pathname.startsWith("/v1/admin/");
3576
+ };
3577
+ /**
3578
+ * A route for a person or their personal token: a read token reads over the read API alone, so
3579
+ * it is refused here, before anything runs (403, as a token that lacks a scope).
3580
+ */
3581
+ const protectWrites = (c, resource, proceed) => protect(c, resource, async (principal, auth) => principal.kind === "read" ? Response.json({
3582
+ code: "forbidden",
3583
+ message: "A read token reads published content over GET /v1, and nothing else.",
3584
+ fix: "Use a personal token (hw_pat_) or sign in with OAuth for this."
3585
+ }, {
3586
+ status: 403,
3587
+ headers: { "cache-control": "no-store" }
3588
+ }) : proceed(principal, auth));
2874
3589
  app.use("*", async (c, next) => {
2875
3590
  c.set("requestId", crypto.randomUUID());
2876
3591
  await next();
2877
3592
  });
2878
3593
  app.use("*", async (c, next) => {
2879
- const refusal = checkHostAndOrigin(c.req.raw, c.env.HOLLWARE_PUBLIC_URL);
3594
+ const refusal = checkHostAndOrigin(c.req.raw, c.env.HOLLWARE_PUBLIC_URL, readsFromAnyCorsOrigin);
2880
3595
  if (refusal !== null) return refusal;
2881
3596
  await next();
2882
3597
  });
2883
3598
  app.get("/health", () => Response.json({ ok: true }, { headers: { "cache-control": "no-store" } }));
2884
- app.all("/mcp", async (c) => protect(c, "mcp", async (principal) => {
3599
+ app.all("/mcp", async (c) => protectWrites(c, "mcp", async (principal) => {
2885
3600
  const requestId = c.get("requestId");
2886
3601
  const db = c.env.DB;
3602
+ const site = sites.get(principal.site);
3603
+ if (site === void 0) throw new Error(`No site ${principal.site} for a checked token`);
2887
3604
  return await mcp.fetch(c.req.raw, {
2888
3605
  principal,
2889
3606
  requestId,
@@ -2892,7 +3609,9 @@ function createGateway(options) {
2892
3609
  site,
2893
3610
  store: createD1Store(db, { clock }),
2894
3611
  clock,
2895
- notify: notify(c, requestId)
3612
+ notify: notify(c, requestId, site.key),
3613
+ semantic: semanticOfEnv(c.env),
3614
+ analytics: analyticsReaderOf(c.env)
2896
3615
  }
2897
3616
  });
2898
3617
  }));
@@ -2903,40 +3622,39 @@ function createGateway(options) {
2903
3622
  failed: () => void 0
2904
3623
  }));
2905
3624
  installAuthorize(app, {
2906
- site: site.key,
3625
+ sites: siteKeys,
2907
3626
  clock,
2908
3627
  oauth
2909
3628
  });
2910
3629
  const authorizationServer = (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).authorizationServer.fetch(c.req.raw, c.env, workerContext(c));
2911
3630
  app.all("/.well-known/oauth-authorization-server", authorizationServer);
2912
3631
  app.all("/oauth/*", authorizationServer);
2913
- installIdentity(app, {
2914
- site: site.key,
2915
- clock
2916
- });
3632
+ installIdentity(app, { clock });
2917
3633
  installAdmin(app, { oauth });
2918
3634
  installAdminApi(app, {
2919
- site: site.key,
3635
+ sites: siteKeys,
2920
3636
  clock,
2921
3637
  oauth,
2922
- protect: (c, proceed) => protect(c, "v1", proceed),
3638
+ protect: (c, proceed) => protectWrites(c, "v1", proceed),
2923
3639
  metadataUrl: (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1
2924
3640
  });
2925
3641
  if (options.handlers !== void 0) installOpsApi(app, {
2926
- site,
3642
+ sites,
2927
3643
  handlers: options.handlers,
2928
3644
  clock,
2929
3645
  log,
2930
- protect: (c, proceed) => protect(c, "v1", proceed),
3646
+ protect: (c, proceed) => protectWrites(c, "v1", proceed),
2931
3647
  metadataUrl: (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1,
2932
3648
  notify
2933
3649
  });
2934
3650
  if (options.read !== void 0) installReadApi(app, {
2935
3651
  read: options.read,
2936
- site: site.key,
3652
+ corsOrigins: (site) => sites.get(site)?.cors.origins ?? [],
3653
+ anyCorsOrigin: (origin) => corsOrigins.has(origin),
2937
3654
  protect: (c, proceed) => protect(c, "v1", proceed),
2938
3655
  metadataUrl: (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1
2939
3656
  });
3657
+ installEvents(app, { sites });
2940
3658
  installErrorHandling(app, log);
2941
3659
  return async (request, env, ctx) => {
2942
3660
  contexts.set(request, ctx);
@@ -3125,6 +3843,7 @@ const encoder = new TextEncoder();
3125
3843
  */
3126
3844
  const OUTPUT_TOO_LARGE_FIX = {
3127
3845
  approve_review: "The operation ran and its change may have been applied: read the review with list_reviews before you repeat it.",
3846
+ content_stats: "Ask for less: pass a smaller limit.",
3128
3847
  create_draft: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
3129
3848
  describe_site: "The description of the site does not fit in one answer. Document fewer collections, components and contracts, or shorten their descriptions.",
3130
3849
  get_entry: "Ask for one format at a time with formats: markdown, document or html, instead of several together.",
@@ -3133,6 +3852,7 @@ const OUTPUT_TOO_LARGE_FIX = {
3133
3852
  list_reviews: "Ask for less: pass a smaller limit.",
3134
3853
  publish: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
3135
3854
  request_changes: "The operation ran and its change may have been applied: read the review with list_reviews before you repeat it.",
3855
+ search: "Ask for less: pass a smaller limit.",
3136
3856
  unpublish: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
3137
3857
  update_draft: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
3138
3858
  validate: "The verdict lists more problems than fit in one answer. Fix the first ones and validate again."
@@ -3273,6 +3993,33 @@ function createToolTable(operations = OPERATIONS) {
3273
3993
  });
3274
3994
  }
3275
3995
  //#endregion
3996
+ //#region src/mcp/prompts.ts
3997
+ /**
3998
+ * The prompts of the MCP endpoint: workflows that an agent's user picks by name, written once
3999
+ * here. A prompt only says what to do; every step is a tool, checked as any call is.
4000
+ */
4001
+ const REFRESH_CONTENT = {
4002
+ name: "refresh_content",
4003
+ title: "Refresh what readers read",
4004
+ description: "Pick a much-read entry whose content has aged, find what the site already says on its subject, and update it through the contracts, ready to publish.",
4005
+ args: z.object({ days: z.string().regex(/^[1-9][0-9]?$/).optional().describe("How many days of reading to look at, 1 to 90; 30 when not given.") })
4006
+ };
4007
+ /** The text of `refresh_content`: the steps, each with the tool that takes it. */
4008
+ function refreshContentText(days) {
4009
+ return [
4010
+ `Refresh the content that readers read most, from the last ${days} days.`,
4011
+ "",
4012
+ `1. Call content_stats with days: ${days}. Pick an entry from the pages with the most views: prefer one whose updatedAt is oldest, or whose subject has moved on. Skip pages with no entry.`,
4013
+ "2. Read it with get_entry, with related: true, and read describe_site for its collection: its schema, contracts and style guide.",
4014
+ "3. Call search with the subject of the entry in your own words (mode hybrid) to find what the site already says, so that the update links to it instead of repeating it.",
4015
+ "4. Write the update: correct what is out of date, add what is missing, link the related entries where they help. Keep the voice of the style guide.",
4016
+ "5. Save it with update_draft at the version you read. Fix every error of the verdict, and the warnings you can, saving again; validate checks a version without saving it.",
4017
+ "6. Call publish. Without content:publish it asks for a review: say so, with the link it answers. With warnings left, say which and why before you acknowledge them.",
4018
+ "",
4019
+ "Report the entry you picked and why, what you changed, and how the publish ended."
4020
+ ].join("\n");
4021
+ }
4022
+ //#endregion
3276
4023
  //#region src/mcp/endpoint.ts
3277
4024
  /**
3278
4025
  * `tools/list` is the same for every client and changes only when the Worker is deployed again:
@@ -3294,7 +4041,7 @@ const TOOLS_LIST_CACHE_HINT = {
3294
4041
  function authInfoOf(principal, resourceMetadataUrl) {
3295
4042
  return {
3296
4043
  token: "",
3297
- clientId: principal.kind === "token" ? principal.tokenId : principal.kind === "oauth" ? principal.clientId : principal.kind,
4044
+ clientId: principal.kind === "token" || principal.kind === "read" ? principal.tokenId : principal.kind === "oauth" ? principal.clientId : principal.kind,
3298
4045
  scopes: [...principal.scopes],
3299
4046
  ...resourceMetadataUrl === null ? {} : { resourceMetadataUrl }
3300
4047
  };
@@ -3394,7 +4141,10 @@ function createMcpEndpoint(options) {
3394
4141
  const call = request.authInfo === void 0 ? void 0 : calls.get(request.authInfo);
3395
4142
  if (call === void 0) throw new Error("A request reached the MCP server without the call the gateway made for it");
3396
4143
  const server = new McpServer(options.serverInfo, {
3397
- capabilities: { tools: { listChanged: false } },
4144
+ capabilities: {
4145
+ tools: { listChanged: false },
4146
+ prompts: { listChanged: false }
4147
+ },
3398
4148
  cacheHints: { "tools/list": TOOLS_LIST_CACHE_HINT }
3399
4149
  });
3400
4150
  for (const tool of tools) server.registerTool(tool.spec.name, {
@@ -3414,6 +4164,17 @@ function createMcpEndpoint(options) {
3414
4164
  detail: null
3415
4165
  }))
3416
4166
  }, (input, mcp) => runTool(tool.spec, input, mcp, call, request.era));
4167
+ server.registerPrompt(REFRESH_CONTENT.name, {
4168
+ title: REFRESH_CONTENT.title,
4169
+ description: REFRESH_CONTENT.description,
4170
+ argsSchema: REFRESH_CONTENT.args
4171
+ }, ({ days }) => ({ messages: [{
4172
+ role: "user",
4173
+ content: {
4174
+ type: "text",
4175
+ text: refreshContentText(days === void 0 ? 30 : Number(days))
4176
+ }
4177
+ }] }));
3417
4178
  return server;
3418
4179
  };
3419
4180
  const handler = createMcpHandler(buildServer, { onerror: (error) => emit({
@@ -3438,11 +4199,12 @@ const BINDING_PRINCIPAL = Object.freeze({
3438
4199
  scopes: Object.freeze(["content:read"])
3439
4200
  });
3440
4201
  /**
3441
- * The site of a call is not the site of this deployment. v0.1 serves one site, and the message
3442
- * and the fix both say which, for a caller that only gets one of them.
4202
+ * The site of a call is none of the sites of this deployment. The message and the fix both say
4203
+ * which sites it serves, for a caller that only gets one of them.
3443
4204
  */
3444
- function wrongSite(served) {
3445
- return new HollwareError("invalid_input", `This Hollware serves the site "${served}", not the one the call names.`, `Pass "${served}", the site this deployment serves, as the site argument.`);
4205
+ function unknownSite(site, served) {
4206
+ const keys = [...served].map((key) => `"${key}"`).join(", ");
4207
+ return new HollwareError("invalid_input", `This Hollware serves no site "${site}": it serves ${keys}.`, `Pass one of ${keys}, the sites this deployment serves, as the site argument.`);
3446
4208
  }
3447
4209
  /**
3448
4210
  * The options object of a call, without the keys that hold `undefined`, so that the handler sees
@@ -3488,7 +4250,7 @@ function forCaller(deps, requestId, method, error) {
3488
4250
  return new HollwareError(known.code, known.message, known.fix);
3489
4251
  }
3490
4252
  /**
3491
- * One call of an operation over RPC. In this order: the site is the one of this deployment, the
4253
+ * One call of an operation over RPC. In this order: the site is one of this deployment, the
3492
4254
  * input is built and parsed by the spec's schema, and the handler runs with
3493
4255
  *
3494
4256
  * - `view: 'published'`, so a draft is never read, and the principal `{ kind: 'binding', scopes:
@@ -3501,17 +4263,19 @@ function forCaller(deps, requestId, method, error) {
3501
4263
  * a call that fails never opens a session.
3502
4264
  */
3503
4265
  async function run(deps, env, method, site, schema, build, handler) {
3504
- if (site !== deps.site.key) throw wrongSite(deps.site.key);
4266
+ const served = deps.sites.get(site);
4267
+ if (served === void 0) throw unknownSite(site, deps.sites.keys());
3505
4268
  const input = parseInput(schema, build());
3506
4269
  const requestId = crypto.randomUUID();
3507
4270
  const context = {
3508
- site: deps.site,
4271
+ site: served,
3509
4272
  principal: BINDING_PRINCIPAL,
3510
4273
  view: "published",
3511
4274
  store: createD1Store(env.DB.withSession("first-unconstrained")),
3512
4275
  clock: deps.clock,
3513
4276
  client: null,
3514
- requestId
4277
+ requestId,
4278
+ semantic: semanticOfEnv(env)
3515
4279
  };
3516
4280
  try {
3517
4281
  return await handler(input, context);
@@ -3531,7 +4295,7 @@ function createRpc(deps, env) {
3531
4295
  collection,
3532
4296
  locale,
3533
4297
  slug,
3534
- ...readOptions(options, ["formats"])
4298
+ ...readOptions(options, ["formats", "related"])
3535
4299
  }), deps.handlers.get_entry),
3536
4300
  listEntries: (site, collection, options) => run(deps, env, "listEntries", site, listEntries.input, () => ({
3537
4301
  collection,
@@ -3546,7 +4310,16 @@ function createRpc(deps, env) {
3546
4310
  collection,
3547
4311
  translationGroup
3548
4312
  }), deps.handlers.get_translations),
3549
- describeSite: (site) => run(deps, env, "describeSite", site, describeSite.input, () => ({}), deps.handlers.describe_site)
4313
+ describeSite: (site) => run(deps, env, "describeSite", site, describeSite.input, () => ({}), deps.handlers.describe_site),
4314
+ search: (site, query, options) => run(deps, env, "search", site, search.input, () => ({
4315
+ query,
4316
+ ...readOptions(options, [
4317
+ "collection",
4318
+ "locale",
4319
+ "limit",
4320
+ "mode"
4321
+ ])
4322
+ }), deps.handlers.search)
3550
4323
  };
3551
4324
  }
3552
4325
  //#endregion
@@ -3575,6 +4348,9 @@ function createEntrypoint(deps) {
3575
4348
  describeSite(...args) {
3576
4349
  return createRpc(deps, this.env).describeSite(...args);
3577
4350
  }
4351
+ search(...args) {
4352
+ return createRpc(deps, this.env).search(...args);
4353
+ }
3578
4354
  };
3579
4355
  }
3580
4356
  //#endregion
@@ -3676,7 +4452,7 @@ function writeLog(entry) {
3676
4452
  *
3677
4453
  * - the config is resolved with `resolveConfig`, so a broken `hollware.config.ts` throws its
3678
4454
  * `ConfigError` here, and the Worker fails at startup with a message that says what to change.
3679
- * v0.1 serves the one site of the config;
4455
+ * The deployment serves every site of the config, and a token, a grant or a call names one;
3680
4456
  * - the MCP endpoint, whose tool schemas are converted once, and the Hono app of the gateway,
3681
4457
  * which is `fetch`: MCP at `/mcp` behind the guard and the token check, and the other routes
3682
4458
  * (`http/gateway.ts`);
@@ -3689,15 +4465,14 @@ function writeLog(entry) {
3689
4465
  */
3690
4466
  function createHollwareWith(config, handlers, options = {}) {
3691
4467
  const resolved = resolveConfig(config);
3692
- const [site] = resolved.sites.values();
3693
- if (site === void 0) throw new Error("The resolved config has no site, which resolveConfig never lets through");
4468
+ const sites = resolved.sites;
3694
4469
  const clock = options.clock ?? systemClock;
3695
4470
  const mcp = createMcpEndpoint({
3696
4471
  handlers,
3697
4472
  serverInfo: SERVER_INFO,
3698
4473
  log: writeLog
3699
4474
  });
3700
- const deliveryDeps = (env) => ({
4475
+ const deliveryDeps = (env, site) => ({
3701
4476
  db: connect(env.DB),
3702
4477
  site,
3703
4478
  clock,
@@ -3705,16 +4480,17 @@ function createHollwareWith(config, handlers, options = {}) {
3705
4480
  secret: (name) => {
3706
4481
  const value = Reflect.get(env, name);
3707
4482
  return typeof value === "string" ? value : void 0;
3708
- }
4483
+ },
4484
+ semantic: semanticOfEnv(env)
3709
4485
  });
3710
4486
  return {
3711
4487
  fetch: createGateway({
3712
- site,
4488
+ sites,
3713
4489
  mcp,
3714
4490
  clock,
3715
4491
  log: writeLog,
3716
4492
  read: (env) => createRpc({
3717
- site,
4493
+ sites,
3718
4494
  handlers,
3719
4495
  clock,
3720
4496
  log: writeLog
@@ -3723,28 +4499,49 @@ function createHollwareWith(config, handlers, options = {}) {
3723
4499
  handlers
3724
4500
  }),
3725
4501
  scheduled: async (_controller, env, ctx) => {
3726
- const deps = deliveryDeps(env);
3727
- ctx.waitUntil(runDueSchedules({
3728
- site,
3729
- store: createD1Store(env.DB, { clock }),
3730
- handlers,
3731
- clock,
3732
- log: writeLog
3733
- }).then(() => sweepOutbox({
3734
- queue: env.EVENTS,
3735
- deps
3736
- })).then(() => void 0, (error) => {
3737
- writeLog({
3738
- kind: "cron_failed",
3739
- message: String(error)
4502
+ const each = async (site) => {
4503
+ await runDueSchedules({
4504
+ site,
4505
+ store: createD1Store(env.DB, { clock }),
4506
+ handlers,
4507
+ clock,
4508
+ log: writeLog
3740
4509
  });
3741
- }));
4510
+ await sweepOutbox({
4511
+ queue: env.EVENTS,
4512
+ deps: deliveryDeps(env, site)
4513
+ });
4514
+ const semantic = semanticOfEnv(env);
4515
+ if (semantic !== null) {
4516
+ const now = clock.now().toISOString();
4517
+ await healVectors(connect(env.DB), semantic, {
4518
+ site: site.key,
4519
+ now
4520
+ });
4521
+ await refreshRelated(connect(env.DB), semantic, {
4522
+ site: site.key,
4523
+ now
4524
+ });
4525
+ }
4526
+ };
4527
+ ctx.waitUntil((async () => {
4528
+ for (const site of sites.values()) await each(site).catch((error) => {
4529
+ writeLog({
4530
+ kind: "cron_failed",
4531
+ site: site.key,
4532
+ message: String(error)
4533
+ });
4534
+ });
4535
+ })());
3742
4536
  },
3743
4537
  queue: async (batch, env) => {
3744
- await consumeEvents(batch.messages, deliveryDeps(env));
4538
+ await consumeEvents(batch.messages, (key) => {
4539
+ const site = sites.get(key);
4540
+ return site === void 0 ? void 0 : deliveryDeps(env, site);
4541
+ });
3745
4542
  },
3746
4543
  Hollware: createEntrypoint({
3747
- site,
4544
+ sites,
3748
4545
  handlers,
3749
4546
  clock,
3750
4547
  log: writeLog
@@ -3752,53 +4549,6 @@ function createHollwareWith(config, handlers, options = {}) {
3752
4549
  };
3753
4550
  }
3754
4551
  //#endregion
3755
- //#region src/ops/errors.ts
3756
- /**
3757
- * The error of something that must never happen: a bug of Hollware, not a mistake of the caller.
3758
- * Like the `internal` error of the adapters, its message says nothing about the cause, which goes
3759
- * to the log through `cause`, and its fix names the request id the operator finds the log line by.
3760
- */
3761
- function internalError(context, cause) {
3762
- return new HollwareError("internal", "The operation failed on the server.", `Try again. If it keeps failing, give the site operator this request id: ${context.requestId}.`, { cause: new Error(cause) });
3763
- }
3764
- /**
3765
- * The error for an answer of the store that the call it came from can never get: an entry that
3766
- * is created cannot conflict, a publish has no slug to take. It is a bug, not an outcome of the
3767
- * operation, so it is not one of the outcomes of its spec.
3768
- */
3769
- function unexpectedAnswer(context, call, answer) {
3770
- return internalError(context, `The store answered ${answer.outcome} to ${call}, which it never does.`);
3771
- }
3772
- //#endregion
3773
- //#region src/ops/actor.ts
3774
- /**
3775
- * Who a write is made by: the person behind the token of the call, with what its client said
3776
- * about itself. A personal token writes as itself (ADR-019); an OAuth access token writes as the
3777
- * client the person allowed, whose id goes with what the client said (ADR-026). The adapters check
3778
- * the scopes of the operation before any handler runs and never route a write for the RPC
3779
- * binding, so reaching one with it is a bug, answered as `internal` before anything is read or
3780
- * written.
3781
- */
3782
- function actorOf(context) {
3783
- const { principal } = context;
3784
- if (principal.kind === "token") return {
3785
- kind: "token",
3786
- userId: principal.userId,
3787
- tokenId: principal.tokenId,
3788
- meta: context.client
3789
- };
3790
- if (principal.kind === "oauth") return {
3791
- kind: "oauth_client",
3792
- userId: principal.userId,
3793
- tokenId: null,
3794
- meta: {
3795
- ...context.client,
3796
- oauthClient: principal.clientId
3797
- }
3798
- };
3799
- throw internalError(context, `A write was routed for a principal of kind ${principal.kind}; only a person writes.`);
3800
- }
3801
- //#endregion
3802
4552
  //#region src/ops/answers.ts
3803
4553
  /** A review as an output. */
3804
4554
  function reviewView(review) {
@@ -3900,6 +4650,105 @@ function publicationView(publication) {
3900
4650
  };
3901
4651
  }
3902
4652
  //#endregion
4653
+ //#region src/ops/content-stats.ts
4654
+ /** A path without its trailing slash, so that `/blog/a` and `/blog/a/` are one page. */
4655
+ function trimmed(path) {
4656
+ return path.length > 1 ? path.replace(/\/+$/, "") : path;
4657
+ }
4658
+ /**
4659
+ * The entry that a path of the site shows, by the routes of its collections: the slug is the part
4660
+ * of the path where the route has `:slug`. `null` for a path that no route has.
4661
+ */
4662
+ function entryRefOf(site, path) {
4663
+ const wanted = trimmed(path);
4664
+ for (const [collection, resolved] of site.collections) for (const [locale, route] of Object.entries(resolved.routes)) {
4665
+ const [before, after, ...rest] = trimmed(route).split(":slug");
4666
+ if (before === void 0 || after === void 0 || rest.length > 0) continue;
4667
+ if (!wanted.startsWith(before) || !wanted.endsWith(after)) continue;
4668
+ const slug = wanted.slice(before.length, wanted.length - after.length);
4669
+ if (slug !== "" && !slug.includes("/")) return {
4670
+ collection,
4671
+ locale,
4672
+ slug
4673
+ };
4674
+ }
4675
+ return null;
4676
+ }
4677
+ /**
4678
+ * The most read pages of the site, from the counts of its beacons (ADR-031), each with the
4679
+ * published entry it shows. `unavailable` when the deployment cannot read the counts.
4680
+ */
4681
+ const contentStats = async (input, context) => {
4682
+ const analytics = context.analytics ?? null;
4683
+ if (analytics === null) return { outcome: "unavailable" };
4684
+ const site = context.site;
4685
+ const counts = await analytics.topPages(site.key, {
4686
+ days: input.days,
4687
+ limit: input.limit
4688
+ });
4689
+ const pages = await Promise.all(counts.map(async (count) => {
4690
+ const ref = entryRefOf(site, count.path);
4691
+ const found = ref === null ? null : await context.store.findEntry(site.key, ref, "published");
4692
+ return {
4693
+ path: count.path,
4694
+ views: count.views,
4695
+ entry: found === null ? null : entrySummary(found)
4696
+ };
4697
+ }));
4698
+ return {
4699
+ outcome: "ok",
4700
+ since: (/* @__PURE__ */ new Date(context.clock.now().getTime() - input.days * 864e5)).toISOString(),
4701
+ pages
4702
+ };
4703
+ };
4704
+ //#endregion
4705
+ //#region src/ops/errors.ts
4706
+ /**
4707
+ * The error of something that must never happen: a bug of Hollware, not a mistake of the caller.
4708
+ * Like the `internal` error of the adapters, its message says nothing about the cause, which goes
4709
+ * to the log through `cause`, and its fix names the request id the operator finds the log line by.
4710
+ */
4711
+ function internalError(context, cause) {
4712
+ return new HollwareError("internal", "The operation failed on the server.", `Try again. If it keeps failing, give the site operator this request id: ${context.requestId}.`, { cause: new Error(cause) });
4713
+ }
4714
+ /**
4715
+ * The error for an answer of the store that the call it came from can never get: an entry that
4716
+ * is created cannot conflict, a publish has no slug to take. It is a bug, not an outcome of the
4717
+ * operation, so it is not one of the outcomes of its spec.
4718
+ */
4719
+ function unexpectedAnswer(context, call, answer) {
4720
+ return internalError(context, `The store answered ${answer.outcome} to ${call}, which it never does.`);
4721
+ }
4722
+ //#endregion
4723
+ //#region src/ops/actor.ts
4724
+ /**
4725
+ * Who a write is made by: the person behind the token of the call, with what its client said
4726
+ * about itself. A personal token writes as itself (ADR-019); an OAuth access token writes as the
4727
+ * client the person allowed, whose id goes with what the client said (ADR-026). The adapters check
4728
+ * the scopes of the operation before any handler runs and never route a write for the RPC
4729
+ * binding, so reaching one with it is a bug, answered as `internal` before anything is read or
4730
+ * written.
4731
+ */
4732
+ function actorOf(context) {
4733
+ const { principal } = context;
4734
+ if (principal.kind === "token") return {
4735
+ kind: "token",
4736
+ userId: principal.userId,
4737
+ tokenId: principal.tokenId,
4738
+ meta: context.client
4739
+ };
4740
+ if (principal.kind === "oauth") return {
4741
+ kind: "oauth_client",
4742
+ userId: principal.userId,
4743
+ tokenId: null,
4744
+ meta: {
4745
+ ...context.client,
4746
+ oauthClient: principal.clientId
4747
+ }
4748
+ };
4749
+ throw internalError(context, `A write was routed for a principal of kind ${principal.kind}; only a person writes.`);
4750
+ }
4751
+ //#endregion
3903
4752
  //#region src/ops/collections.ts
3904
4753
  /**
3905
4754
  * Which collection and locale a call names, checked against the config of the site before
@@ -4098,6 +4947,7 @@ const getEntry$1 = async (input, context) => {
4098
4947
  if (found === null) return { outcome: "not_found" };
4099
4948
  const { entry, revision } = found;
4100
4949
  const publication = await context.store.lastPublication(context.site.key, entry.id);
4950
+ const related = input.related ? await relatedEntries(context, entry.id) : void 0;
4101
4951
  return {
4102
4952
  outcome: "ok",
4103
4953
  entry: {
@@ -4108,9 +4958,19 @@ const getEntry$1 = async (input, context) => {
4108
4958
  ...input.formats.includes("markdown") ? { markdown: revision.markdown } : {},
4109
4959
  ...input.formats.includes("document") ? { document: revision.document } : {},
4110
4960
  ...input.formats.includes("html") ? { html: await renderHtml(revision.document) } : {}
4111
- }
4961
+ },
4962
+ ...related === void 0 ? {} : { related }
4112
4963
  };
4113
4964
  };
4965
+ /**
4966
+ * The published entries closest to one, as its vectors found them when it was indexed: none when
4967
+ * the deployment has no semantic search, and none that is no longer published.
4968
+ */
4969
+ async function relatedEntries(context, entryId) {
4970
+ if ((context.semantic ?? null) === null) return [];
4971
+ const related = await context.store.relatedOf(context.site.key, entryId);
4972
+ return (await context.store.findSummaries(context.site.key, related.map((item) => item.id), "published")).map(entrySummary);
4973
+ }
4114
4974
  //#endregion
4115
4975
  //#region src/ops/get-translations.ts
4116
4976
  /** Reads the entries of a translation group in the view of the call, one per locale. */
@@ -4145,6 +5005,93 @@ const listEntries$1 = async (input, context) => {
4145
5005
  nextCursor: page.nextCursor
4146
5006
  };
4147
5007
  };
5008
+ /** The published entries closest to the meaning of the query, closest first. */
5009
+ async function semanticSearch(semantic, store, site, query) {
5010
+ const [vector] = await semantic.embedder.embed([query.text]);
5011
+ const matches = await semantic.index.query(vector ?? [], {
5012
+ topK: 20,
5013
+ site,
5014
+ ...query.collection === void 0 ? {} : { collection: query.collection },
5015
+ ...query.locale === void 0 ? {} : { locale: query.locale }
5016
+ });
5017
+ const best = /* @__PURE__ */ new Map();
5018
+ for (const match of matches) {
5019
+ const kept = best.get(match.metadata.entry);
5020
+ if (kept === void 0 || match.score > kept.score) best.set(match.metadata.entry, match);
5021
+ }
5022
+ const ranked = [...best.values()].sort((a, b) => b.score - a.score).slice(0, query.limit);
5023
+ const summaries = await store.findSummaries(site, ranked.map((match) => match.metadata.entry), "published");
5024
+ const byId = new Map(summaries.map((summary) => [summary.entry.id, summary]));
5025
+ return ranked.flatMap((match) => {
5026
+ const entry = byId.get(match.metadata.entry);
5027
+ return entry === void 0 ? [] : [{
5028
+ entry,
5029
+ snippet: match.metadata.excerpt,
5030
+ score: match.score
5031
+ }];
5032
+ });
5033
+ }
5034
+ /**
5035
+ * Merges rankings by reciprocal rank fusion: an entry scores `1 / (FUSION_K + place)` in each list
5036
+ * it is in, places counted from 1. An entry keeps the snippet of the first list that has it, so
5037
+ * the words found by full text win over the excerpt of a match by meaning.
5038
+ */
5039
+ function fuse(lists, limit) {
5040
+ const merged = /* @__PURE__ */ new Map();
5041
+ for (const list of lists) list.forEach((hit, place) => {
5042
+ const gain = 1 / (60 + place + 1);
5043
+ const kept = merged.get(hit.entry.entry.id);
5044
+ merged.set(hit.entry.entry.id, kept === void 0 ? {
5045
+ ...hit,
5046
+ score: gain
5047
+ } : {
5048
+ ...kept,
5049
+ score: kept.score + gain
5050
+ });
5051
+ });
5052
+ return [...merged.values()].sort((a, b) => b.score - a.score).slice(0, limit);
5053
+ }
5054
+ //#endregion
5055
+ //#region src/ops/search.ts
5056
+ /**
5057
+ * The published entries of the site that match the query, best first: by its words, by meaning,
5058
+ * or both (ADR-030). Without the semantic search of the deployment, every mode is full text, and
5059
+ * the answer's `mode` says which one ran.
5060
+ */
5061
+ const search$1 = async (input, context) => {
5062
+ if (input.collection !== void 0) requireCollection(context.site, input.collection);
5063
+ if (input.locale !== void 0) requireLocale(context.site, input.locale);
5064
+ const query = {
5065
+ text: input.query,
5066
+ collection: input.collection,
5067
+ locale: input.locale,
5068
+ limit: input.limit
5069
+ };
5070
+ const semantic = context.semantic ?? null;
5071
+ const asked = input.mode ?? "hybrid";
5072
+ const mode = semantic === null ? "fulltext" : asked;
5073
+ const site = context.site.key;
5074
+ let hits;
5075
+ if (semantic === null || mode === "fulltext") hits = await context.store.searchEntries(site, query);
5076
+ else if (mode === "semantic") hits = await semanticSearch(semantic, context.store, site, query);
5077
+ else {
5078
+ const wide = {
5079
+ ...query,
5080
+ limit: Math.max(query.limit, 20)
5081
+ };
5082
+ const [words, meaning] = await Promise.all([context.store.searchEntries(site, wide), semanticSearch(semantic, context.store, site, wide)]);
5083
+ hits = fuse([words, meaning], query.limit);
5084
+ }
5085
+ return {
5086
+ outcome: "ok",
5087
+ mode,
5088
+ results: hits.map((hit) => ({
5089
+ entry: entrySummary(hit.entry),
5090
+ snippet: hit.snippet,
5091
+ score: hit.score
5092
+ }))
5093
+ };
5094
+ };
4148
5095
  //#endregion
4149
5096
  //#region src/ops/event.ts
4150
5097
  /** The path of an entry: the route of its locale, with its slug in place of `:slug`. */
@@ -4679,11 +5626,13 @@ function defineHandlers(handlers) {
4679
5626
  */
4680
5627
  const handlers = defineHandlers({
4681
5628
  approve_review: approveReview,
5629
+ content_stats: contentStats,
4682
5630
  create_draft: createDraft,
4683
5631
  describe_site: describeSite$1,
4684
5632
  get_entry: getEntry$1,
4685
5633
  get_translations: getTranslations$1,
4686
5634
  list_entries: listEntries$1,
5635
+ search: search$1,
4687
5636
  list_reviews: listReviews,
4688
5637
  publish: publish$1,
4689
5638
  request_changes: requestChanges,