@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/{ceremonies-D7tsWU3S.mjs → ceremonies-BM9G03lZ.mjs} +9 -16
- package/dist/index.d.mts +151 -2
- package/dist/index.mjs +1142 -193
- package/dist/{store-Sq0pwUQv.mjs → store-DiS8UFI2.mjs} +85 -54
- package/migrations/0006_people_across_sites.sql +54 -0
- package/migrations/0007_read_tokens.sql +4 -0
- package/migrations/0008_search.sql +47 -0
- package/migrations/0009_embeddings.sql +19 -0
- package/migrations/meta/0006_snapshot.json +2102 -0
- package/migrations/meta/0007_snapshot.json +2110 -0
- package/migrations/meta/0008_snapshot.json +2110 -0
- package/migrations/meta/0009_snapshot.json +2257 -0
- package/migrations/meta/_journal.json +28 -0
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { A as
|
|
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.
|
|
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
|
|
145
|
-
*
|
|
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
|
|
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
|
|
197
|
-
* without a query. Otherwise one query finds the token by the hash of the whole
|
|
198
|
-
*
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
237
|
-
|
|
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
|
-
*
|
|
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,
|
|
1908
|
+
async function consumeEvents(messages, depsOf) {
|
|
1642
1909
|
for (const message of messages) {
|
|
1643
1910
|
const { body } = message;
|
|
1644
|
-
|
|
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
|
|
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
|
-
/**
|
|
1886
|
-
|
|
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/
|
|
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,
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
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(
|
|
2137
|
-
app.get("/v1/site", async (c) => answer(c, await options.read(c.env).describeSite(
|
|
2138
|
-
app.get("/v1
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
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}
|
|
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-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
3151
|
+
const membership = named ?? first;
|
|
2532
3152
|
const props = {
|
|
2533
3153
|
userId: signedIn.userId,
|
|
2534
|
-
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:
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
2713
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
3635
|
+
sites: siteKeys,
|
|
2920
3636
|
clock,
|
|
2921
3637
|
oauth,
|
|
2922
|
-
protect: (c, 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
|
-
|
|
3642
|
+
sites,
|
|
2927
3643
|
handlers: options.handlers,
|
|
2928
3644
|
clock,
|
|
2929
3645
|
log,
|
|
2930
|
-
protect: (c, 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
|
-
|
|
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: {
|
|
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
|
|
3442
|
-
*
|
|
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
|
|
3445
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
4488
|
+
sites,
|
|
3713
4489
|
mcp,
|
|
3714
4490
|
clock,
|
|
3715
4491
|
log: writeLog,
|
|
3716
4492
|
read: (env) => createRpc({
|
|
3717
|
-
|
|
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
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|