@hollware/server 0.9.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{ceremonies-D5dIEbha.mjs → ceremonies-CI3DkOEL.mjs} +20 -6
- package/dist/index.d.mts +69 -4
- package/dist/index.mjs +1789 -486
- package/dist/{store-C2hiR0yd.mjs → store-DjA9s95l.mjs} +88 -6
- package/migrations/0013_site_events.sql +31 -0
- package/migrations/0014_entry_trash.sql +1 -0
- package/migrations/0015_site_changes_append_only.sql +28 -0
- package/migrations/0016_audit_log.sql +65 -0
- package/migrations/meta/0013_snapshot.json +2619 -0
- package/migrations/meta/0014_snapshot.json +2626 -0
- package/migrations/meta/0015_snapshot.json +2626 -0
- package/migrations/meta/0016_snapshot.json +2823 -0
- package/migrations/meta/_journal.json +28 -0
- package/package.json +8 -8
package/dist/index.mjs
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import { A as
|
|
2
|
-
import { CHUNKER_VERSION, ConfigError, EVENT_ID_HEADER, HollwareError, OPERATIONS, SCOPES, SIGNATURE_HEADER, TIMESTAMP_HEADER, canonicalJson, chunksOf, compileSites, definitionsFromConfig, describeBuiltInContracts, describeCatalog, describeResolvedSite, describeSite, generatePersonalToken, generateReadToken, getEntry, getTranslations, hashPersonalToken, isPersonalToken, isReadToken, isScope, isSiteKey, listEntries, newId, processMarkdown, publish, renderHtml, resolveConfig, runContracts, search, signWebhook, siteDefinitionData, sizeLimitViolation, tagsOfChange, uniqueSlugViolation, uniqueTranslationViolation } from "@hollware/core";
|
|
1
|
+
import { A as revisionTags, C as entryEmbeddings, D as publications, E as outbox, F as users, I as ROLE_SCOPES, L as effectiveScopes, M as schedules, N as sites, O as reviewComments, P as tokens, R as isRole, S as entries, T as members, a as dropExpired, f as membershipsOf, g as removePasskey, j as revisions, k as reviews, n as createInvitation, o as endSession, p as passkeySummaries, s as endSessions, u as findSession, w as invitations, x as deliveries } from "./store-DjA9s95l.mjs";
|
|
2
|
+
import { CHUNKER_VERSION, ConfigError, EVENT_ID_HEADER, HollwareError, INDEXNOW_KEY, OPERATIONS, OPERATION_AUDIT_ACTIONS, PREVIEW_QUERY_PARAM, SCOPES, SIGNATURE_HEADER, TIMESTAMP_HEADER, canSignPreviews, canonicalJson, chunksOf, compileSites, definitionsFromConfig, describeBuiltInContracts, describeCatalog, describeResolvedSite, describeSite, generatePersonalToken, generateReadToken, getEntry, getTranslations, hashPersonalToken, isAuditAction, isId, isPersonalToken, isReadToken, isScope, isSiteKey, listEntries, makePreviewToken, newId, processMarkdown, publish, renderHtml, resolveConfig, runContracts, search, signWebhook, siteDefinitionData, sizeLimitViolation, tagsOfChange, tagsOfSiteChange, uniqueSlugViolation, uniqueTranslationViolation, verifyPreviewToken } from "@hollware/core";
|
|
3
3
|
import { Hono } from "hono";
|
|
4
4
|
import { drizzle } from "drizzle-orm/d1";
|
|
5
5
|
import { and, asc, desc, eq, getTableColumns, gt, inArray, isNotNull, isNull, lte, ne, or, sql } from "drizzle-orm";
|
|
6
6
|
import { alias } from "drizzle-orm/sqlite-core";
|
|
7
|
-
import { AuthorizationError, CimdFetchError, OAuthAuthorizationServer, OAuthResourceServer, authorizationErrorRedirect } from "@cloudflare/workers-oauth-provider";
|
|
8
7
|
import { getCookie } from "hono/cookie";
|
|
8
|
+
import { AuthorizationError, CimdFetchError, OAuthAuthorizationServer, OAuthResourceServer, authorizationErrorRedirect } from "@cloudflare/workers-oauth-provider";
|
|
9
9
|
import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, McpServer, acceptedContent, createMcpHandler, inputRequired, inputResponse } from "@modelcontextprotocol/server";
|
|
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.11.0";
|
|
14
14
|
//#endregion
|
|
15
15
|
//#region src/auth/responses.ts
|
|
16
16
|
/** The challenge of every 401 and 403 (RFC 6750). */
|
|
@@ -350,7 +350,7 @@ function parseJson(text) {
|
|
|
350
350
|
}
|
|
351
351
|
}
|
|
352
352
|
/** The cursor that continues a `view` listing after `position`. */
|
|
353
|
-
function encodeCursor(view, position) {
|
|
353
|
+
function encodeCursor$1(view, position) {
|
|
354
354
|
return encodeBase64Url(JSON.stringify({
|
|
355
355
|
v: view,
|
|
356
356
|
k: position.key,
|
|
@@ -361,7 +361,7 @@ function encodeCursor(view, position) {
|
|
|
361
361
|
* The position a cursor stands for. A cursor that no listing of this view returned, whatever is
|
|
362
362
|
* wrong with it, is the error `invalid_input` with a fix.
|
|
363
363
|
*/
|
|
364
|
-
function decodeCursor(cursor, view) {
|
|
364
|
+
function decodeCursor$1(cursor, view) {
|
|
365
365
|
const text = decodeBase64Url(cursor);
|
|
366
366
|
const value = text === null ? void 0 : parseJson(text);
|
|
367
367
|
if (typeof value === "object" && value !== null && "v" in value && "k" in value && "i" in value && value.v === view && typeof value.k === "string" && typeof value.i === "string") return {
|
|
@@ -390,6 +390,7 @@ const entryFields = {
|
|
|
390
390
|
publishedRev: entries.publishedRev,
|
|
391
391
|
publishedAt: entries.publishedAt,
|
|
392
392
|
scheduledAt: entries.scheduledAt,
|
|
393
|
+
deletedAt: entries.deletedAt,
|
|
393
394
|
updatedAt: entries.updatedAt
|
|
394
395
|
};
|
|
395
396
|
/** The columns of `revisions` that `StoredRevision` holds, except its tags. */
|
|
@@ -465,6 +466,7 @@ function toEntry(row) {
|
|
|
465
466
|
publishedRevision: row.publishedRev,
|
|
466
467
|
publishedAt: row.publishedAt,
|
|
467
468
|
scheduledAt: row.scheduledAt,
|
|
469
|
+
deletedAt: row.deletedAt,
|
|
468
470
|
updatedAt: row.updatedAt
|
|
469
471
|
};
|
|
470
472
|
}
|
|
@@ -544,9 +546,15 @@ function revisionOf$1(view) {
|
|
|
544
546
|
const revisionId = view === "head" ? entries.headRev : entries.publishedRev;
|
|
545
547
|
return and(eq(revisions.id, revisionId), eq(revisions.siteId, entries.siteId));
|
|
546
548
|
}
|
|
547
|
-
/**
|
|
548
|
-
|
|
549
|
-
|
|
549
|
+
/**
|
|
550
|
+
* What picks the entries of a view: the caller's conditions, and in the published view only
|
|
551
|
+
* published ones. The head view leaves out the entries in the trash, or lists only them when
|
|
552
|
+
* `trashed` asks for the trash. The published view needs no such condition: only a draft goes to
|
|
553
|
+
* the trash, and it stays a draft there.
|
|
554
|
+
*/
|
|
555
|
+
function viewWhere(view, conditions, trashed = false) {
|
|
556
|
+
if (view === "published") return and(...conditions, publishedOnly);
|
|
557
|
+
return and(...conditions, trashed ? isNotNull(entries.deletedAt) : isNull(entries.deletedAt));
|
|
550
558
|
}
|
|
551
559
|
/**
|
|
552
560
|
* The entries of a site with the whole revision of a view, for the reads of one entry. The caller
|
|
@@ -570,13 +578,13 @@ function toEntryView(row) {
|
|
|
570
578
|
* the revision only its id, its frontmatter and its tags. The sort key of the published view
|
|
571
579
|
* comes along for the cursor.
|
|
572
580
|
*/
|
|
573
|
-
function summaryQuery(db, view, conditions) {
|
|
581
|
+
function summaryQuery(db, view, conditions, trashed = false) {
|
|
574
582
|
return db.select({
|
|
575
583
|
entry: entryFields,
|
|
576
584
|
revision: revisionSummaryFields,
|
|
577
585
|
tags: tagsOf$1(revisions),
|
|
578
586
|
sortKey: entries.publishedSortKey
|
|
579
|
-
}).from(entries).innerJoin(revisions, revisionOf$1(view)).where(viewWhere(view, conditions));
|
|
587
|
+
}).from(entries).innerJoin(revisions, revisionOf$1(view)).where(viewWhere(view, conditions, trashed));
|
|
580
588
|
}
|
|
581
589
|
function toEntrySummaryView(row) {
|
|
582
590
|
return {
|
|
@@ -632,7 +640,7 @@ function listEntriesQuery(db, site, query, cursor) {
|
|
|
632
640
|
query.view === "head" && query.status !== void 0 ? eq(entries.status, query.status) : void 0,
|
|
633
641
|
query.tag === void 0 ? void 0 : hasTag(site, query.tag),
|
|
634
642
|
cursor === null ? void 0 : sql`(${sortKey}, ${entries.id}) < (${cursor.key}, ${cursor.id})`
|
|
635
|
-
]).orderBy(desc(sortKey), desc(entries.id)).limit(query.limit + 1);
|
|
643
|
+
], query.trashed === true).orderBy(desc(sortKey), desc(entries.id)).limit(query.limit + 1);
|
|
636
644
|
}
|
|
637
645
|
/**
|
|
638
646
|
* The FTS5 query of what someone typed: each of its words as a quoted string, so that FTS5's own
|
|
@@ -691,8 +699,8 @@ async function relatedOf(db, site, entryId) {
|
|
|
691
699
|
}
|
|
692
700
|
async function listEntries$2(db, site, query) {
|
|
693
701
|
if (!Number.isInteger(query.limit) || query.limit < 1) throw new HollwareError("invalid_input", `The limit ${query.limit} is not a whole number of at least 1.`, "Ask for at least one entry per page.");
|
|
694
|
-
const cursor = query.cursor === void 0 ? null : decodeCursor(query.cursor, query.view);
|
|
695
|
-
if (query.view === "published" && query.status !== void 0 && query.status !== "published") return {
|
|
702
|
+
const cursor = query.cursor === void 0 ? null : decodeCursor$1(query.cursor, query.view);
|
|
703
|
+
if (query.view === "published" && (query.trashed === true || query.status !== void 0 && query.status !== "published")) return {
|
|
696
704
|
entries: [],
|
|
697
705
|
nextCursor: null
|
|
698
706
|
};
|
|
@@ -707,7 +715,7 @@ async function listEntries$2(db, site, query) {
|
|
|
707
715
|
if (key === null) throw new Error(`The published entry ${last.entry.id} has no published_sort_key`);
|
|
708
716
|
return {
|
|
709
717
|
entries: page.map(toEntrySummaryView),
|
|
710
|
-
nextCursor: encodeCursor(query.view, {
|
|
718
|
+
nextCursor: encodeCursor$1(query.view, {
|
|
711
719
|
key,
|
|
712
720
|
id: last.entry.id
|
|
713
721
|
})
|
|
@@ -753,7 +761,7 @@ function refKey(ref) {
|
|
|
753
761
|
function refMatches(ref) {
|
|
754
762
|
return and(eq(entries.collection, ref.collection), eq(entries.locale, ref.locale), eq(entries.slug, ref.slug));
|
|
755
763
|
}
|
|
756
|
-
/** What cross-entry contracts read, scoped to one site. */
|
|
764
|
+
/** What cross-entry contracts read, scoped to one site; an entry in the trash is not there. */
|
|
757
765
|
function lookup(db, site) {
|
|
758
766
|
return {
|
|
759
767
|
/**
|
|
@@ -761,7 +769,7 @@ function lookup(db, site) {
|
|
|
761
769
|
* more, under D1's limit of bound values. A ref asked twice is asked once.
|
|
762
770
|
*/
|
|
763
771
|
async lookupMany(refs) {
|
|
764
|
-
const [first, ...rest] = chunk([...new Map(refs.map((ref) => [refKey(ref), ref])).values()], rowsPerStatement(1, 3)).map((group) => db.select(factFields).from(entries).where(and(eq(entries.siteId, site), or(...group.map(refMatches)))));
|
|
772
|
+
const [first, ...rest] = chunk([...new Map(refs.map((ref) => [refKey(ref), ref])).values()], rowsPerStatement(1, 3)).map((group) => db.select(factFields).from(entries).where(and(eq(entries.siteId, site), isNull(entries.deletedAt), or(...group.map(refMatches)))));
|
|
765
773
|
if (first === void 0) return [];
|
|
766
774
|
const found = /* @__PURE__ */ new Map();
|
|
767
775
|
for (const row of (await db.batch([first, ...rest])).flat()) found.set(refKey(row), {
|
|
@@ -772,7 +780,7 @@ function lookup(db, site) {
|
|
|
772
780
|
return refs.map((ref) => found.get(refKey(ref)) ?? null);
|
|
773
781
|
},
|
|
774
782
|
async translations(collection, translationGroup) {
|
|
775
|
-
return (await db.select(factFields).from(entries).where(and(eq(entries.siteId, site), eq(entries.collection, collection), eq(entries.translationGroup, translationGroup))).orderBy(entries.locale)).map((row) => ({
|
|
783
|
+
return (await db.select(factFields).from(entries).where(and(eq(entries.siteId, site), eq(entries.collection, collection), eq(entries.translationGroup, translationGroup), isNull(entries.deletedAt))).orderBy(entries.locale)).map((row) => ({
|
|
776
784
|
collection: row.collection,
|
|
777
785
|
locale: row.locale,
|
|
778
786
|
slug: row.slug,
|
|
@@ -1019,16 +1027,22 @@ function toReview(row) {
|
|
|
1019
1027
|
note: row.note
|
|
1020
1028
|
};
|
|
1021
1029
|
}
|
|
1022
|
-
/**
|
|
1023
|
-
|
|
1024
|
-
|
|
1030
|
+
/**
|
|
1031
|
+
* The entry is still at `version`, and in the state `also` says: the condition of every statement
|
|
1032
|
+
* a write adds to its batch.
|
|
1033
|
+
*/
|
|
1034
|
+
function entryAt(site, entryId, version, also = []) {
|
|
1035
|
+
return sql`exists (select 1 from ${entries} where ${and(eq(entries.id, entryId), eq(entries.siteId, site), eq(entries.version, version), ...also)})`;
|
|
1025
1036
|
}
|
|
1026
|
-
/**
|
|
1027
|
-
|
|
1037
|
+
/**
|
|
1038
|
+
* In the batch of a new revision: the pending review asked for another one, so it is cancelled.
|
|
1039
|
+
* In the batch of a trashing, too, where `also` holds what makes the entry one the trash takes.
|
|
1040
|
+
*/
|
|
1041
|
+
function cancelPendingReview(db, site, entryId, version, now, also = []) {
|
|
1028
1042
|
return db.update(reviews).set({
|
|
1029
1043
|
status: "cancelled",
|
|
1030
1044
|
updatedAt: now
|
|
1031
|
-
}).where(and(eq(reviews.siteId, site), eq(reviews.entryId, entryId), eq(reviews.status, "pending"), entryAt(site, entryId, version)));
|
|
1045
|
+
}).where(and(eq(reviews.siteId, site), eq(reviews.entryId, entryId), eq(reviews.status, "pending"), entryAt(site, entryId, version, also)));
|
|
1032
1046
|
}
|
|
1033
1047
|
/** In the batch of a publication: what the pending review asked for is published, by `userId`. */
|
|
1034
1048
|
function approvePendingReview(db, site, entryId, version, userId, now) {
|
|
@@ -1203,23 +1217,33 @@ async function attempt(batch, holder) {
|
|
|
1203
1217
|
return { taken };
|
|
1204
1218
|
}
|
|
1205
1219
|
}
|
|
1206
|
-
/**
|
|
1220
|
+
/**
|
|
1221
|
+
* The entry that holds the slug or the translation that a new entry asks for; the slug first. An
|
|
1222
|
+
* entry in the trash keeps both, and is named as `in_trash`, so that the caller restores it.
|
|
1223
|
+
*/
|
|
1207
1224
|
async function holderOfNewEntry(db, site, entry) {
|
|
1208
1225
|
const rows = await db.select({
|
|
1209
1226
|
id: entries.id,
|
|
1210
|
-
slug: entries.slug
|
|
1227
|
+
slug: entries.slug,
|
|
1228
|
+
deletedAt: entries.deletedAt
|
|
1211
1229
|
}).from(entries).where(and(eq(entries.siteId, site), eq(entries.collection, entry.collection), eq(entries.locale, entry.locale), or(eq(entries.slug, entry.slug), entry.translationGroup === null ? void 0 : eq(entries.translationGroup, entry.translationGroup))));
|
|
1212
|
-
const
|
|
1213
|
-
if (
|
|
1230
|
+
const holder = rows.find((row) => row.slug === entry.slug) ?? rows[0];
|
|
1231
|
+
if (holder === void 0) return null;
|
|
1232
|
+
if (holder.deletedAt !== null) {
|
|
1233
|
+
const view = await findTrashed(db, site, holder.id);
|
|
1234
|
+
if (view !== null) return {
|
|
1235
|
+
outcome: "in_trash",
|
|
1236
|
+
view
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
return holder.slug === entry.slug ? {
|
|
1214
1240
|
outcome: "taken",
|
|
1215
1241
|
field: "slug",
|
|
1216
|
-
by:
|
|
1217
|
-
}
|
|
1218
|
-
const [byGroup] = rows;
|
|
1219
|
-
return byGroup === void 0 ? null : {
|
|
1242
|
+
by: holder.id
|
|
1243
|
+
} : {
|
|
1220
1244
|
outcome: "taken",
|
|
1221
1245
|
field: "translationGroup",
|
|
1222
|
-
by:
|
|
1246
|
+
by: holder.id
|
|
1223
1247
|
};
|
|
1224
1248
|
}
|
|
1225
1249
|
/** The other entry of the collection and locale of `entryId` that is in `translationGroup`. */
|
|
@@ -1319,6 +1343,7 @@ async function createEntry(db, clock, site, entry) {
|
|
|
1319
1343
|
publishedRevision: null,
|
|
1320
1344
|
publishedAt: null,
|
|
1321
1345
|
scheduledAt: null,
|
|
1346
|
+
deletedAt: null,
|
|
1322
1347
|
updatedAt: now
|
|
1323
1348
|
},
|
|
1324
1349
|
revision: storedRevision({
|
|
@@ -1516,6 +1541,68 @@ async function unpublish$1(db, clock, site, write) {
|
|
|
1516
1541
|
view
|
|
1517
1542
|
};
|
|
1518
1543
|
}
|
|
1544
|
+
/**
|
|
1545
|
+
* Moves a draft to the trash in one batch: its pending review is cancelled, and the entry gets its
|
|
1546
|
+
* `deleted_at` and the next version, both only while it is a draft out of the trash at
|
|
1547
|
+
* `expectedVersion`. When the update touches nothing, one read tells why.
|
|
1548
|
+
*/
|
|
1549
|
+
async function trashEntry(db, clock, site, write) {
|
|
1550
|
+
const now = clock.now().toISOString();
|
|
1551
|
+
const trashable = [eq(entries.status, "draft"), isNull(entries.deletedAt)];
|
|
1552
|
+
const [, updated] = await db.batch([cancelPendingReview(db, site, write.entryId, write.expectedVersion, now, trashable), db.update(entries).set({
|
|
1553
|
+
deletedAt: now,
|
|
1554
|
+
version: sql`${entries.version} + 1`,
|
|
1555
|
+
updatedAt: now
|
|
1556
|
+
}).where(and(eq(entries.id, write.entryId), eq(entries.siteId, site), eq(entries.version, write.expectedVersion), ...trashable)).returning({ id: entries.id })]);
|
|
1557
|
+
if (updated.length === 0) {
|
|
1558
|
+
const view = await findEntryById(db, site, write.entryId, "head");
|
|
1559
|
+
if (view === null) return { outcome: "not_found" };
|
|
1560
|
+
if (view.entry.version !== write.expectedVersion) return {
|
|
1561
|
+
outcome: "conflict",
|
|
1562
|
+
currentVersion: view.entry.version,
|
|
1563
|
+
headRevision: view.entry.headRevision
|
|
1564
|
+
};
|
|
1565
|
+
return {
|
|
1566
|
+
outcome: "not_draft",
|
|
1567
|
+
view
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
const view = await findTrashed(db, site, write.entryId);
|
|
1571
|
+
if (view === null) throw new Error(`Entry ${write.entryId} is not in the trash after its trashing`);
|
|
1572
|
+
return {
|
|
1573
|
+
outcome: "trashed",
|
|
1574
|
+
view
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
/** Brings an entry back from the trash as the draft it was, at the next version. */
|
|
1578
|
+
async function restoreEntry$1(db, clock, site, entryId) {
|
|
1579
|
+
const now = clock.now().toISOString();
|
|
1580
|
+
const updated = await db.update(entries).set({
|
|
1581
|
+
deletedAt: null,
|
|
1582
|
+
version: sql`${entries.version} + 1`,
|
|
1583
|
+
updatedAt: now
|
|
1584
|
+
}).where(and(eq(entries.id, entryId), eq(entries.siteId, site), isNotNull(entries.deletedAt))).returning({ id: entries.id });
|
|
1585
|
+
const view = await findEntryById(db, site, entryId, "head");
|
|
1586
|
+
if (view === null) return { outcome: "not_found" };
|
|
1587
|
+
return {
|
|
1588
|
+
outcome: updated.length === 0 ? "not_trashed" : "restored",
|
|
1589
|
+
view
|
|
1590
|
+
};
|
|
1591
|
+
}
|
|
1592
|
+
/** An entry in the trash with its head revision, which the reads of the store leave out. */
|
|
1593
|
+
async function findTrashed(db, site, entryId) {
|
|
1594
|
+
const [row] = await db.select({
|
|
1595
|
+
entry: entryFields,
|
|
1596
|
+
headRev: entries.headRev
|
|
1597
|
+
}).from(entries).where(and(eq(entries.id, entryId), eq(entries.siteId, site), isNotNull(entries.deletedAt))).limit(1);
|
|
1598
|
+
if (row === void 0) return null;
|
|
1599
|
+
const revision = await findRevision(db, site, row.headRev);
|
|
1600
|
+
if (revision === null) throw new Error(`The head revision of entry ${entryId} is not there`);
|
|
1601
|
+
return {
|
|
1602
|
+
entry: toEntry(row.entry),
|
|
1603
|
+
revision
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1519
1606
|
/** Drops the schedule of an entry in the batch that publishes it, if it is at `version`. */
|
|
1520
1607
|
function dropScheduleAtVersion(db, site, entryId, version) {
|
|
1521
1608
|
return db.delete(schedules).where(and(eq(schedules.siteId, site), eq(schedules.entryId, entryId), sql`exists (select 1 from ${entries} where ${and(eq(entries.id, entryId), eq(entries.siteId, site), eq(entries.version, version))})`));
|
|
@@ -1567,6 +1654,8 @@ function createD1Store(db, options = {}) {
|
|
|
1567
1654
|
dueSchedules: (site, now) => dueSchedules(database, site, now),
|
|
1568
1655
|
cancelSchedule: (site, entryId) => cancelSchedule(database, clock, site, entryId),
|
|
1569
1656
|
unpublish: (site, write) => unpublish$1(database, clock, site, write),
|
|
1657
|
+
trashEntry: (site, write) => trashEntry(database, clock, site, write),
|
|
1658
|
+
restoreEntry: (site, entryId) => restoreEntry$1(database, clock, site, entryId),
|
|
1570
1659
|
requestReview: (site, write) => requestReview$1(database, clock, site, write, () => whyNothingChanged(database, site, write.entryId)),
|
|
1571
1660
|
findReview: (site, id) => findReview(database, site, id),
|
|
1572
1661
|
listReviews: (site, query) => listReviews$1(database, site, query),
|
|
@@ -1851,9 +1940,8 @@ function subscribersOf(deps) {
|
|
|
1851
1940
|
subscribers.push({
|
|
1852
1941
|
name: "indexnow",
|
|
1853
1942
|
async send(payload) {
|
|
1854
|
-
if (payload.url === null) return;
|
|
1855
|
-
const key =
|
|
1856
|
-
if (key === void 0) throw new Error(`the secret ${indexNow.key} is not set`);
|
|
1943
|
+
if (payload.kind === "site.changed" || payload.url === null) return;
|
|
1944
|
+
const key = indexNow.key;
|
|
1857
1945
|
const request = new Request(INDEXNOW_ENDPOINT, {
|
|
1858
1946
|
method: "POST",
|
|
1859
1947
|
headers: { "content-type": "application/json; charset=utf-8" },
|
|
@@ -1871,6 +1959,7 @@ function subscribersOf(deps) {
|
|
|
1871
1959
|
if (semantic !== void 0 && semantic !== null) subscribers.push({
|
|
1872
1960
|
name: "vectors",
|
|
1873
1961
|
async send(payload) {
|
|
1962
|
+
if (payload.kind === "site.changed") return;
|
|
1874
1963
|
await syncEntryVectors(deps.db, semantic, {
|
|
1875
1964
|
site: site.key,
|
|
1876
1965
|
entryId: payload.entry.id,
|
|
@@ -1975,16 +2064,191 @@ async function consumeEvents(messages, depsOf) {
|
|
|
1975
2064
|
else message.retry();
|
|
1976
2065
|
}
|
|
1977
2066
|
}
|
|
2067
|
+
/** JSON of at most `AUDIT_JSON_MAX` characters: a longer value says only that it was too long. */
|
|
2068
|
+
function boundedJson(value) {
|
|
2069
|
+
if (value === null) return null;
|
|
2070
|
+
const text = JSON.stringify(value);
|
|
2071
|
+
return text.length <= 2e3 ? text : JSON.stringify({ truncated: true });
|
|
2072
|
+
}
|
|
2073
|
+
/**
|
|
2074
|
+
* The actor of a principal. A person signed in to the admin is a `user`, with the admin as their
|
|
2075
|
+
* client; any other OAuth client is an `oauth_client`; a personal or read token is a `token`.
|
|
2076
|
+
*/
|
|
2077
|
+
function actorOf$1(principal, meta, adminClientId) {
|
|
2078
|
+
if (principal.kind === "oauth") return {
|
|
2079
|
+
kind: principal.clientId === adminClientId ? "user" : "oauth_client",
|
|
2080
|
+
userId: principal.userId,
|
|
2081
|
+
tokenId: null,
|
|
2082
|
+
clientId: principal.clientId,
|
|
2083
|
+
meta
|
|
2084
|
+
};
|
|
2085
|
+
return {
|
|
2086
|
+
kind: "token",
|
|
2087
|
+
userId: principal.userId,
|
|
2088
|
+
tokenId: principal.tokenId,
|
|
2089
|
+
clientId: null,
|
|
2090
|
+
meta
|
|
2091
|
+
};
|
|
2092
|
+
}
|
|
2093
|
+
/** A person, signed in with their passkey, on the pages of the Worker. */
|
|
2094
|
+
function personActor(userId) {
|
|
2095
|
+
return {
|
|
2096
|
+
kind: "user",
|
|
2097
|
+
userId,
|
|
2098
|
+
tokenId: null,
|
|
2099
|
+
clientId: null,
|
|
2100
|
+
meta: null
|
|
2101
|
+
};
|
|
2102
|
+
}
|
|
2103
|
+
/** The deployment itself, acting for the person who asked it to: its cron. */
|
|
2104
|
+
function systemActor(forUserId) {
|
|
2105
|
+
return {
|
|
2106
|
+
kind: "system",
|
|
2107
|
+
userId: forUserId,
|
|
2108
|
+
tokenId: null,
|
|
2109
|
+
clientId: null,
|
|
2110
|
+
meta: null
|
|
2111
|
+
};
|
|
2112
|
+
}
|
|
2113
|
+
/**
|
|
2114
|
+
* The key of a site that hashes addresses: HKDF-SHA256 of the Worker secret `HOLLWARE_SECRET`,
|
|
2115
|
+
* bound to the site, so one address hashes alike within a site and differently in another, and
|
|
2116
|
+
* no hash can be checked against the 2³² IPv4 addresses without the secret.
|
|
2117
|
+
*/
|
|
2118
|
+
async function addressKey(master, site) {
|
|
2119
|
+
const base = await crypto.subtle.importKey("raw", new TextEncoder().encode(master), "HKDF", false, ["deriveKey"]);
|
|
2120
|
+
return crypto.subtle.deriveKey({
|
|
2121
|
+
name: "HKDF",
|
|
2122
|
+
hash: "SHA-256",
|
|
2123
|
+
salt: /* @__PURE__ */ new Uint8Array(0),
|
|
2124
|
+
info: new TextEncoder().encode(`hollware audit address\n${site}`)
|
|
2125
|
+
}, base, {
|
|
2126
|
+
name: "HMAC",
|
|
2127
|
+
hash: "SHA-256",
|
|
2128
|
+
length: 256
|
|
2129
|
+
}, false, ["sign"]);
|
|
2130
|
+
}
|
|
2131
|
+
/** The hex HMAC-SHA256 of an address under the key of its site. */
|
|
2132
|
+
async function hashAddress(master, site, address) {
|
|
2133
|
+
const mac = await crypto.subtle.sign("HMAC", await addressKey(master, site), new TextEncoder().encode(address));
|
|
2134
|
+
return [...new Uint8Array(mac)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
2135
|
+
}
|
|
2136
|
+
function createAuditLog(request) {
|
|
2137
|
+
const addressOf = async (site) => {
|
|
2138
|
+
if (site.audit.ipAddresses !== "hashed" || request.address === null) return null;
|
|
2139
|
+
if (request.master === void 0 || request.master.length < 32) {
|
|
2140
|
+
request.log({
|
|
2141
|
+
kind: "audit_address_unkeyed",
|
|
2142
|
+
site: site.key,
|
|
2143
|
+
requestId: request.requestId
|
|
2144
|
+
});
|
|
2145
|
+
return null;
|
|
2146
|
+
}
|
|
2147
|
+
return hashAddress(request.master, site.key, request.address);
|
|
2148
|
+
};
|
|
2149
|
+
const statement = async (site, line) => {
|
|
2150
|
+
const at = request.now().toISOString();
|
|
2151
|
+
const { actor } = request;
|
|
2152
|
+
return request.db.prepare(`INSERT INTO audit_log (id, site_id, action, outcome, error_code, actor_kind, actor_user_id, actor_token_id, actor_client_id, actor_meta, target_type, target_id, detail, request_id, ip_hash, created_at, updated_at)
|
|
2153
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?16)`).bind(newId("aud"), site.key, line.action, line.outcome, line.errorCode ?? null, actor.kind, actor.userId, actor.tokenId, actor.clientId, boundedJson(actor.meta), line.target?.type ?? null, line.target?.id ?? null, boundedJson(line.detail ?? null), request.requestId, await addressOf(site), at);
|
|
2154
|
+
};
|
|
2155
|
+
return {
|
|
2156
|
+
statement,
|
|
2157
|
+
async write(site, line) {
|
|
2158
|
+
try {
|
|
2159
|
+
await request.db.batch([await statement(site, line)]);
|
|
2160
|
+
} catch (error) {
|
|
2161
|
+
request.log({
|
|
2162
|
+
kind: "audit_write_failed",
|
|
2163
|
+
site: site.key,
|
|
2164
|
+
action: line.action,
|
|
2165
|
+
requestId: request.requestId,
|
|
2166
|
+
message: String(error)
|
|
2167
|
+
});
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
};
|
|
2171
|
+
}
|
|
2172
|
+
/**
|
|
2173
|
+
* Deletes the lines of a site older than its retention, a bounded number per run (ADR-037). A site
|
|
2174
|
+
* with no retention keeps every line. The trigger of migrations/0016 refuses any other delete.
|
|
2175
|
+
*/
|
|
2176
|
+
async function dropExpiredLines(db, site, now) {
|
|
2177
|
+
const days = site.audit.retentionDays;
|
|
2178
|
+
if (days === null) return 0;
|
|
2179
|
+
const before = (/* @__PURE__ */ new Date(now.getTime() - days * 864e5)).toISOString();
|
|
2180
|
+
const [result] = await db.batch([db.prepare(`DELETE FROM audit_log WHERE id IN (SELECT id FROM audit_log WHERE site_id = ?1 AND created_at < ?2 ORDER BY created_at LIMIT ?3)`).bind(site.key, before, 500)]);
|
|
2181
|
+
return result?.meta.changes ?? 0;
|
|
2182
|
+
}
|
|
2183
|
+
/** The KV key that holds when a grant last rotated its refresh token, in seconds. */
|
|
2184
|
+
const rotatedKey = (grantId) => `hollware:rotated:${grantId}`;
|
|
2185
|
+
async function sha256(text) {
|
|
2186
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
2187
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
2188
|
+
}
|
|
2189
|
+
/** The refresh token of a token request, or `null` for any other request. */
|
|
2190
|
+
async function refreshTokenOf(request) {
|
|
2191
|
+
if (request.method !== "POST" || new URL(request.url).pathname !== "/oauth/token") return null;
|
|
2192
|
+
if (!(request.headers.get("content-type") ?? "").startsWith("application/x-www-form-urlencoded")) return null;
|
|
2193
|
+
const form = new URLSearchParams(await request.clone().text());
|
|
2194
|
+
if (form.get("grant_type") !== "refresh_token") return null;
|
|
2195
|
+
const token = form.get("refresh_token") ?? "";
|
|
2196
|
+
const parts = token.split(":");
|
|
2197
|
+
const [userId, grantId] = parts;
|
|
2198
|
+
if (parts.length !== 3 || userId === void 0 || grantId === void 0) return null;
|
|
2199
|
+
return {
|
|
2200
|
+
token,
|
|
2201
|
+
userId,
|
|
2202
|
+
grantId
|
|
2203
|
+
};
|
|
2204
|
+
}
|
|
2205
|
+
/**
|
|
2206
|
+
* The grant a refresh token belongs to when the token was already spent, past the grace: the
|
|
2207
|
+
* grant holds it as its previous token, and it rotated more than `REUSE_GRACE_SECONDS` ago (or so
|
|
2208
|
+
* long ago that the record of it expired). `null` for a token that is current, or unknown to the
|
|
2209
|
+
* grant, which the library answers as it does.
|
|
2210
|
+
*/
|
|
2211
|
+
async function spentRefresh(kv, refresh, now) {
|
|
2212
|
+
const grant = await kv.get(`grant:${refresh.userId}:${refresh.grantId}`, { type: "json" });
|
|
2213
|
+
if (grant === null || grant["previousRefreshTokenId"] !== await sha256(refresh.token)) return null;
|
|
2214
|
+
const rotated = Number(await kv.get(rotatedKey(refresh.grantId)));
|
|
2215
|
+
if (Number.isFinite(rotated) && rotated > 0 && now - rotated <= 60) return null;
|
|
2216
|
+
const metadata = grant["metadata"];
|
|
2217
|
+
const site = typeof metadata === "object" && metadata !== null ? Reflect.get(metadata, "site") : null;
|
|
2218
|
+
return {
|
|
2219
|
+
userId: refresh.userId,
|
|
2220
|
+
grantId: refresh.grantId,
|
|
2221
|
+
site: typeof site === "string" ? site : null
|
|
2222
|
+
};
|
|
2223
|
+
}
|
|
2224
|
+
/** Records that a grant rotated its refresh token now, for the grace of a retry. */
|
|
2225
|
+
async function recordRotation(kv, grantId, now) {
|
|
2226
|
+
await kv.put(rotatedKey(grantId), String(now), { expirationTtl: 120 });
|
|
2227
|
+
}
|
|
2228
|
+
/** The answer to a spent refresh token, as the library answers an invalid grant. */
|
|
2229
|
+
function reusedAnswer() {
|
|
2230
|
+
return Response.json({
|
|
2231
|
+
error: "invalid_grant",
|
|
2232
|
+
error_description: "This refresh token was already used, so its grant was revoked. Connect the app again."
|
|
2233
|
+
}, {
|
|
2234
|
+
status: 400,
|
|
2235
|
+
headers: { "cache-control": "no-store" }
|
|
2236
|
+
});
|
|
2237
|
+
}
|
|
1978
2238
|
//#endregion
|
|
1979
2239
|
//#region src/structure/seal.ts
|
|
1980
2240
|
/**
|
|
1981
2241
|
* Sealing a secret of the structure (ADR-033) under the Worker secret `HOLLWARE_SECRET`, so that
|
|
1982
2242
|
* the database alone never holds one in clear: AES-GCM with a 256-bit key that is the SHA-256 of
|
|
1983
|
-
* that secret, and a fresh 96-bit nonce each time. What is stored is `
|
|
1984
|
-
* the nonce and the ciphertext with its tag
|
|
1985
|
-
*
|
|
1986
|
-
|
|
1987
|
-
|
|
2243
|
+
* that secret, and a fresh 96-bit nonce each time. What is stored is `v2.` and the base64url of
|
|
2244
|
+
* the nonce and the ciphertext with its tag, sealed with the site and the name of the secret as
|
|
2245
|
+
* associated data: a value moved to another row of D1 does not open there (T-71 of the threat
|
|
2246
|
+
* model). One that was tampered with, or sealed under another `HOLLWARE_SECRET`, does not open.
|
|
2247
|
+
* A `v1.` value, sealed before the place was bound, still opens wherever it is.
|
|
2248
|
+
*/
|
|
2249
|
+
const PREFIX = "v2.";
|
|
2250
|
+
const BEFORE_PLACES = "v1.";
|
|
2251
|
+
const placeOf = (place) => new TextEncoder().encode(`${place.site}\n${place.name}`);
|
|
1988
2252
|
function base64url(bytes) {
|
|
1989
2253
|
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1990
2254
|
}
|
|
@@ -1997,25 +2261,28 @@ async function keyOf(master) {
|
|
|
1997
2261
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(master));
|
|
1998
2262
|
return crypto.subtle.importKey("raw", digest, "AES-GCM", false, ["encrypt", "decrypt"]);
|
|
1999
2263
|
}
|
|
2000
|
-
async function seal(master, value) {
|
|
2264
|
+
async function seal(master, value, place) {
|
|
2001
2265
|
const nonce = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
|
|
2002
2266
|
const cipher = new Uint8Array(await crypto.subtle.encrypt({
|
|
2003
2267
|
name: "AES-GCM",
|
|
2004
|
-
iv: nonce
|
|
2268
|
+
iv: nonce,
|
|
2269
|
+
additionalData: placeOf(place)
|
|
2005
2270
|
}, await keyOf(master), new TextEncoder().encode(value)));
|
|
2006
2271
|
const sealed = new Uint8Array(nonce.length + cipher.length);
|
|
2007
2272
|
sealed.set(nonce);
|
|
2008
2273
|
sealed.set(cipher, nonce.length);
|
|
2009
2274
|
return `${PREFIX}${base64url(sealed)}`;
|
|
2010
2275
|
}
|
|
2011
|
-
/** The value of a sealed secret, or `null` when it does not open under `master`. */
|
|
2012
|
-
async function unseal(master, sealed) {
|
|
2013
|
-
|
|
2276
|
+
/** The value of a sealed secret, or `null` when it does not open under `master` at `place`. */
|
|
2277
|
+
async function unseal(master, sealed, place) {
|
|
2278
|
+
const bound = sealed.startsWith(PREFIX);
|
|
2279
|
+
if (!bound && !sealed.startsWith(BEFORE_PLACES)) return null;
|
|
2014
2280
|
try {
|
|
2015
2281
|
const bytes = fromBase64url(sealed.slice(3));
|
|
2016
2282
|
const plain = await crypto.subtle.decrypt({
|
|
2017
2283
|
name: "AES-GCM",
|
|
2018
|
-
iv: bytes.slice(0, 12)
|
|
2284
|
+
iv: bytes.slice(0, 12),
|
|
2285
|
+
...bound ? { additionalData: placeOf(place) } : {}
|
|
2019
2286
|
}, await keyOf(master), bytes.slice(12));
|
|
2020
2287
|
return new TextDecoder().decode(plain);
|
|
2021
2288
|
} catch {
|
|
@@ -2038,9 +2305,9 @@ function metaOf(actor) {
|
|
|
2038
2305
|
return actor.meta === null ? null : JSON.stringify(actor.meta);
|
|
2039
2306
|
}
|
|
2040
2307
|
/** The change a statement before it made, at the site's version now, if that version is new. */
|
|
2041
|
-
function appendChange(db, site, actor, now, before, after) {
|
|
2308
|
+
function appendChange(db, site, actor, now, before, after, id = newId("sit")) {
|
|
2042
2309
|
return db.prepare(`INSERT OR IGNORE INTO site_changes (id, site_id, version, actor_user_id, actor_meta, before, after, created_at, updated_at)
|
|
2043
|
-
SELECT ?1, id, version, ?2, ?3, ?4, ${after === "definition" ? "definition" : "NULL"}, ?5, ?5 FROM sites WHERE id = ?6 AND updated_at = ?5`).bind(
|
|
2310
|
+
SELECT ?1, id, version, ?2, ?3, ?4, ${after === "definition" ? "definition" : "NULL"}, ?5, ?5 FROM sites WHERE id = ?6 AND updated_at = ?5`).bind(id, actor.userId, metaOf(actor), before, now, site);
|
|
2044
2311
|
}
|
|
2045
2312
|
function createStructureStore(db, now) {
|
|
2046
2313
|
return {
|
|
@@ -2053,10 +2320,17 @@ function createStructureStore(db, now) {
|
|
|
2053
2320
|
deletedAt: row.deleted_at
|
|
2054
2321
|
};
|
|
2055
2322
|
},
|
|
2056
|
-
async write({ site, expected, definition, actor }) {
|
|
2323
|
+
async write({ site, expected, definition, actor, event }) {
|
|
2057
2324
|
const at = now().toISOString();
|
|
2325
|
+
const change = newId("sit");
|
|
2058
2326
|
const before = await db.prepare("SELECT definition FROM sites WHERE id = ?1").bind(site).first("definition");
|
|
2059
|
-
const [moved] = await db.batch([
|
|
2327
|
+
const [moved] = await db.batch([
|
|
2328
|
+
db.prepare("UPDATE sites SET definition = ?1, version = version + 1, updated_at = ?2 WHERE id = ?3 AND version = ?4 AND deleted_at IS NULL").bind(JSON.stringify(definition), at, site, expected),
|
|
2329
|
+
appendChange(db, site, actor, at, before ?? null, "definition", change),
|
|
2330
|
+
...event === void 0 ? [] : [db.prepare(`INSERT INTO outbox (id, site_id, kind, entry_id, payload, sent_at, created_at, updated_at)
|
|
2331
|
+
SELECT ?1, ?2, 'site.changed', NULL, ?3, NULL, ?4, ?4
|
|
2332
|
+
WHERE EXISTS (SELECT 1 FROM site_changes WHERE id = ?5 AND site_id = ?2)`).bind(event.id, site, event.payload, at, change)]
|
|
2333
|
+
]);
|
|
2060
2334
|
return (moved?.meta.changes ?? 0) === 1 ? "written" : "conflict";
|
|
2061
2335
|
},
|
|
2062
2336
|
async create({ site, definition, actor }) {
|
|
@@ -2209,122 +2483,343 @@ function installErrorHandling(app, log) {
|
|
|
2209
2483
|
}));
|
|
2210
2484
|
}
|
|
2211
2485
|
//#endregion
|
|
2212
|
-
//#region src/
|
|
2486
|
+
//#region src/identity/pages.ts
|
|
2213
2487
|
/**
|
|
2214
|
-
* The
|
|
2215
|
-
*
|
|
2216
|
-
*
|
|
2217
|
-
*
|
|
2488
|
+
* The pages a person sees to get in (ADR-026): set up a passkey from a link, and sign in. They
|
|
2489
|
+
* are rendered by the Worker, outside the admin's bundle, with one inline script under a nonce
|
|
2490
|
+
* and nothing framing them. The script uses the WebAuthn JSON methods of the browser
|
|
2491
|
+
* (`PublicKeyCredential.parseCreationOptionsFromJSON`, `credential.toJSON()`), and reads the
|
|
2492
|
+
* invitation from the fragment of the address, which no request carries, so neither the page nor
|
|
2493
|
+
* a log of a request ever holds it (T-55); a link of before 0.11, with it in the query, still works.
|
|
2218
2494
|
*/
|
|
2219
|
-
/** Where the admin's id is kept, by issuer. The library's own keys never start with it. */
|
|
2220
|
-
const KEY = "hollware:admin-client:";
|
|
2221
2495
|
/**
|
|
2222
|
-
* The
|
|
2223
|
-
* the
|
|
2496
|
+
* The look of hollware.com and the admin, with nothing to fetch: the page's policy loads no font,
|
|
2497
|
+
* so the type is the system's. Dark only, the neutral greys of the theme, white as the primary
|
|
2498
|
+
* color and emerald for the mark (tests/conventions/brand.test.ts).
|
|
2224
2499
|
*/
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
await env.OAUTH_KV.put(`${KEY}${oauth.issuer}`, client.clientId);
|
|
2238
|
-
return {
|
|
2239
|
-
clientId: client.clientId,
|
|
2240
|
-
redirectUri
|
|
2241
|
-
};
|
|
2242
|
-
}
|
|
2243
|
-
//#endregion
|
|
2244
|
-
//#region src/http/admin.ts
|
|
2500
|
+
const STYLE = `:root{color-scheme:dark;--bg:#0a0a0a;--card:#171717;--fg:#f5f5f5;--muted:#a3a3a3;--line:rgb(255 255 255/.1);--accent:#fafafa;--on-accent:#0a0a0a;--mark:#34d399;--danger:#ff8a80}
|
|
2501
|
+
*{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}
|
|
2502
|
+
main{width:100%;max-width:26rem;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:2rem}
|
|
2503
|
+
.brand{display:flex;align-items:center;gap:.6rem;margin-bottom:1.75rem;font-weight:600;letter-spacing:-.01em}.brand svg{width:26px;height:26px}
|
|
2504
|
+
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)}
|
|
2505
|
+
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}
|
|
2506
|
+
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}
|
|
2507
|
+
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}
|
|
2508
|
+
code{font:.82em ui-monospace,'SF Mono',Menlo,monospace;color:var(--muted)}
|
|
2509
|
+
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)}
|
|
2510
|
+
button:not([value=deny]){background:var(--accent);border-color:var(--accent);color:var(--on-accent)}
|
|
2511
|
+
[role=alert]{color:var(--danger)}`;
|
|
2245
2512
|
/**
|
|
2246
|
-
* The
|
|
2247
|
-
*
|
|
2248
|
-
* policy below, and so does every path of the single-page app, which all get the same page. The
|
|
2249
|
-
* page learns how to sign in from `/admin/config.json`.
|
|
2513
|
+
* The SHA-256 of STYLE, base64: the policy of every page allows that one stylesheet and no other
|
|
2514
|
+
* inline style (a test computes it again from STYLE, so the two cannot part).
|
|
2250
2515
|
*/
|
|
2516
|
+
const STYLE_HASH = "sha256-XfLw/v21Jfp0bukRAqh1+Rz82lWrWpdy2GbGduQqpqY=";
|
|
2251
2517
|
/**
|
|
2252
|
-
* What the
|
|
2253
|
-
*
|
|
2254
|
-
* anywhere over https, and requests to this origin alone. Nothing frames it.
|
|
2518
|
+
* What every page of the Worker says besides its policy: no window of another origin keeps a hold
|
|
2519
|
+
* on it, no other origin embeds it, and it asks for no device.
|
|
2255
2520
|
*/
|
|
2256
|
-
const
|
|
2257
|
-
"
|
|
2258
|
-
"
|
|
2259
|
-
"
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
return Response.json({
|
|
2269
|
-
code: "not_found",
|
|
2270
|
-
message: "The admin is not installed in this Worker.",
|
|
2271
|
-
fix: "Bind the static assets as ASSETS and serve the files of @hollware/admin (adminAssets()), as the starter does."
|
|
2272
|
-
}, {
|
|
2273
|
-
status: 404,
|
|
2274
|
-
headers: { "cache-control": "no-store" }
|
|
2275
|
-
});
|
|
2521
|
+
const PAGE_ISOLATION = {
|
|
2522
|
+
"cross-origin-opener-policy": "same-origin",
|
|
2523
|
+
"cross-origin-resource-policy": "same-origin",
|
|
2524
|
+
"permissions-policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=()"
|
|
2525
|
+
};
|
|
2526
|
+
/** The mark of Hollware, before the title of every page. */
|
|
2527
|
+
const BRAND = `<div class="brand"><svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="8" fill="currentColor" opacity=".08"/><path d="M9 8v16M23 8v16M9 16h14" stroke="var(--mark)" stroke-width="3" stroke-linecap="round" fill="none"/></svg>Hollware</div>`;
|
|
2528
|
+
const POST = `async function post(path, body) {
|
|
2529
|
+
const response = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
2530
|
+
const data = await response.json()
|
|
2531
|
+
if (!response.ok) throw new Error(data.message + ' ' + (data.fix ?? ''))
|
|
2532
|
+
return data
|
|
2276
2533
|
}
|
|
2277
|
-
function
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
headers.set("referrer-policy", "no-referrer");
|
|
2300
|
-
return new Response(answer.body, {
|
|
2301
|
-
status: 200,
|
|
2302
|
-
headers
|
|
2303
|
-
});
|
|
2304
|
-
};
|
|
2305
|
-
app.get("/admin", (c) => c.redirect("/admin/", 301));
|
|
2306
|
-
app.get("/admin/", page);
|
|
2307
|
-
app.get("/admin/*", async (c) => {
|
|
2308
|
-
if (/\.[a-z0-9]+$/i.test(c.req.path)) {
|
|
2309
|
-
const assets = c.env.ASSETS;
|
|
2310
|
-
if (assets === void 0) return notInstalled();
|
|
2311
|
-
return assets.fetch(c.req.raw);
|
|
2312
|
-
}
|
|
2313
|
-
return page(c);
|
|
2314
|
-
});
|
|
2534
|
+
function fail(error) { document.getElementById('error').textContent = error.message }`;
|
|
2535
|
+
function page(title, nonce, body, script) {
|
|
2536
|
+
return `<!doctype html>
|
|
2537
|
+
<html lang="en">
|
|
2538
|
+
<head>
|
|
2539
|
+
<meta charset="utf-8">
|
|
2540
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2541
|
+
<title>${title} · Hollware</title>
|
|
2542
|
+
<style>${STYLE}</style>
|
|
2543
|
+
</head>
|
|
2544
|
+
<body>
|
|
2545
|
+
<main>
|
|
2546
|
+
${BRAND}
|
|
2547
|
+
<h1>${title}</h1>
|
|
2548
|
+
${body}
|
|
2549
|
+
<p id="error" role="alert"></p>
|
|
2550
|
+
</main>
|
|
2551
|
+
<script nonce="${nonce}">${POST}
|
|
2552
|
+
${script}<\/script>
|
|
2553
|
+
</body>
|
|
2554
|
+
</html>
|
|
2555
|
+
`;
|
|
2315
2556
|
}
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2557
|
+
/**
|
|
2558
|
+
* The invitation of a setup link: in its fragment (`#invitation=…`), which a browser never sends,
|
|
2559
|
+
* so no log of a request holds it (T-55); or, in a link made before 0.11, in its query. The page
|
|
2560
|
+
* runs its source as it is: it uses nothing but its arguments and the URL of the platform.
|
|
2561
|
+
*/
|
|
2562
|
+
function invitationOf(hash, search) {
|
|
2563
|
+
return new URLSearchParams(hash.replace(/^#/, "")).get("invitation") ?? new URLSearchParams(search).get("invitation") ?? "";
|
|
2322
2564
|
}
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2565
|
+
function setupPage(nonce) {
|
|
2566
|
+
return page("Set up your passkey", nonce, `<p>A passkey lets you into this Hollware with your device's lock: no password.</p>
|
|
2567
|
+
<form id="form">
|
|
2568
|
+
<label for="name">Your name</label><input id="name" name="name" autocomplete="name">
|
|
2569
|
+
<label for="label">This device</label><input id="label" name="label" value="Passkey">
|
|
2570
|
+
<button type="submit">Create the passkey</button>
|
|
2571
|
+
</form>`, `const invitationOf = ${invitationOf.toString()}
|
|
2572
|
+
document.getElementById('form').addEventListener('submit', async (event) => {
|
|
2573
|
+
event.preventDefault()
|
|
2574
|
+
try {
|
|
2575
|
+
const invitation = invitationOf(location.hash, location.search)
|
|
2576
|
+
const { challengeId, options } = await post('/auth/register/options', { invitation })
|
|
2577
|
+
const credential = await navigator.credentials.create({ publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options) })
|
|
2578
|
+
await post('/auth/register/verify', { challengeId, response: credential.toJSON(), name: document.getElementById('name').value, label: document.getElementById('label').value })
|
|
2579
|
+
location.href = '/admin/'
|
|
2580
|
+
} catch (error) { fail(error) }
|
|
2581
|
+
})`);
|
|
2582
|
+
}
|
|
2583
|
+
/**
|
|
2584
|
+
* Where the sign-in page sends the person after their passkey: the `return_to` it was given, if it
|
|
2585
|
+
* is a page of this origin, else the admin. The target is read as the browser will read it, as a
|
|
2586
|
+
* URL against the page's own origin, so no way of writing another host (`//`, `/\`, a tab, a
|
|
2587
|
+
* scheme) gets through, and only the path, query and fragment it resolves to are used (T-43).
|
|
2588
|
+
* The page runs its source as it is: it uses nothing but its arguments and the URL of the platform.
|
|
2589
|
+
*/
|
|
2590
|
+
function sameOriginTarget(target, origin) {
|
|
2591
|
+
if (target === null) return "/admin/";
|
|
2592
|
+
try {
|
|
2593
|
+
const url = new URL(target, origin);
|
|
2594
|
+
return url.origin === origin ? `${url.pathname}${url.search}${url.hash}` : "/admin/";
|
|
2595
|
+
} catch {
|
|
2596
|
+
return "/admin/";
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
function signInPage(nonce) {
|
|
2600
|
+
return page("Sign in", nonce, `<p>Sign in to this Hollware with your passkey.</p>
|
|
2601
|
+
<button id="signin" type="button">Sign in with a passkey</button>`, `const sameOriginTarget = ${sameOriginTarget.toString()}
|
|
2602
|
+
document.getElementById('signin').addEventListener('click', async () => {
|
|
2603
|
+
try {
|
|
2604
|
+
const { challengeId, options } = await post('/auth/signin/options', {})
|
|
2605
|
+
const credential = await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options) })
|
|
2606
|
+
await post('/auth/signin/verify', { challengeId, response: credential.toJSON() })
|
|
2607
|
+
location.href = sameOriginTarget(new URLSearchParams(location.search).get('return_to'), location.origin)
|
|
2608
|
+
} catch (error) { fail(error) }
|
|
2609
|
+
})`);
|
|
2610
|
+
}
|
|
2611
|
+
/** The headers of a page: no framing, only its own script, and no caching. */
|
|
2612
|
+
function pageHeaders(nonce) {
|
|
2613
|
+
return {
|
|
2614
|
+
"content-type": "text/html; charset=utf-8",
|
|
2615
|
+
"cache-control": "no-store",
|
|
2616
|
+
"content-security-policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src '${STYLE_HASH}'; connect-src 'self'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'`,
|
|
2617
|
+
"x-content-type-options": "nosniff",
|
|
2618
|
+
"referrer-policy": "no-referrer",
|
|
2619
|
+
...PAGE_ISOLATION
|
|
2620
|
+
};
|
|
2621
|
+
}
|
|
2622
|
+
/** Text for HTML: every string a client chose (its name, its redirect host, a scope) goes through it. */
|
|
2623
|
+
function escapeHtml(value) {
|
|
2624
|
+
return value.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
|
|
2625
|
+
}
|
|
2626
|
+
/** What a scope lets a client do, in the words of the consent page. */
|
|
2627
|
+
const SCOPE_WORDS = {
|
|
2628
|
+
"content:read": "Read the content, drafts included",
|
|
2629
|
+
"content:write": "Write drafts",
|
|
2630
|
+
"content:publish": "Publish and unpublish",
|
|
2631
|
+
"schema:admin": "Change the site settings"
|
|
2632
|
+
};
|
|
2633
|
+
/**
|
|
2634
|
+
* The consent page of `/authorize` (ADR-026): which client asks, where the tokens go, and what it
|
|
2635
|
+
* may do, with Allow and Deny. It has no script, and it posts the handle that `beginConsent` bound
|
|
2636
|
+
* to this browser. Everything the client chose is escaped.
|
|
2637
|
+
*/
|
|
2638
|
+
function consentPage(view) {
|
|
2639
|
+
const name = escapeHtml(view.clientName);
|
|
2640
|
+
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>`;
|
|
2641
|
+
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>" : "";
|
|
2642
|
+
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>`;
|
|
2643
|
+
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");
|
|
2644
|
+
return `<!doctype html>
|
|
2645
|
+
<html lang="en">
|
|
2646
|
+
<head>
|
|
2647
|
+
<meta charset="utf-8">
|
|
2648
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2649
|
+
<title>Allow ${name} · Hollware</title>
|
|
2650
|
+
<style>${STYLE}</style>
|
|
2651
|
+
</head>
|
|
2652
|
+
<body>
|
|
2653
|
+
<main>
|
|
2654
|
+
${BRAND}
|
|
2655
|
+
<h1>Allow ${name} to use this Hollware?</h1>
|
|
2656
|
+
${origin}
|
|
2657
|
+
<p>Signed in as <strong>${escapeHtml(view.person)}</strong>. Access goes to <strong>${escapeHtml(view.redirectHost)}</strong>.</p>
|
|
2658
|
+
${loopback}
|
|
2659
|
+
<form method="post" action="/authorize">
|
|
2660
|
+
<input type="hidden" name="handle" value="${escapeHtml(view.handle)}">
|
|
2661
|
+
${sites}
|
|
2662
|
+
${scopes}
|
|
2663
|
+
<p><button name="decision" value="approve">Allow</button> <button name="decision" value="deny">Deny</button></p>
|
|
2664
|
+
</form>
|
|
2665
|
+
</main>
|
|
2666
|
+
</body>
|
|
2667
|
+
</html>
|
|
2668
|
+
`;
|
|
2669
|
+
}
|
|
2670
|
+
/** A page that only says what went wrong and what to do. */
|
|
2671
|
+
function messagePage(title, message) {
|
|
2672
|
+
return `<!doctype html>
|
|
2673
|
+
<html lang="en">
|
|
2674
|
+
<head>
|
|
2675
|
+
<meta charset="utf-8">
|
|
2676
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2677
|
+
<title>${escapeHtml(title)} · Hollware</title>
|
|
2678
|
+
<style>${STYLE}</style>
|
|
2679
|
+
</head>
|
|
2680
|
+
<body>
|
|
2681
|
+
<main>
|
|
2682
|
+
${BRAND}
|
|
2683
|
+
<h1>${escapeHtml(title)}</h1>
|
|
2684
|
+
<p>${escapeHtml(message)}</p>
|
|
2685
|
+
</main>
|
|
2686
|
+
</body>
|
|
2687
|
+
</html>
|
|
2688
|
+
`;
|
|
2689
|
+
}
|
|
2690
|
+
/**
|
|
2691
|
+
* The headers of a page without a script: no framing and no caching. The form of the consent page
|
|
2692
|
+
* posts to `/authorize`, which redirects to the client, so `form-action` is not restricted: a
|
|
2693
|
+
* browser applies it to the redirect too.
|
|
2694
|
+
*/
|
|
2695
|
+
function plainPageHeaders() {
|
|
2696
|
+
return {
|
|
2697
|
+
"content-type": "text/html; charset=utf-8",
|
|
2698
|
+
"cache-control": "no-store",
|
|
2699
|
+
"content-security-policy": `default-src 'none'; style-src '${STYLE_HASH}'; base-uri 'none'; frame-ancestors 'none'`,
|
|
2700
|
+
"x-content-type-options": "nosniff",
|
|
2701
|
+
"referrer-policy": "no-referrer",
|
|
2702
|
+
...PAGE_ISOLATION
|
|
2703
|
+
};
|
|
2704
|
+
}
|
|
2705
|
+
//#endregion
|
|
2706
|
+
//#region src/oauth/admin.ts
|
|
2707
|
+
/**
|
|
2708
|
+
* The admin as a client of the OAuth server (ADR-026): a public client, with PKCE, that a person
|
|
2709
|
+
* never has to allow. It is registered once per issuer, with `createClient`, and its id is kept in
|
|
2710
|
+
* `OAUTH_KV` beside the library's own records. A Client ID Metadata Document would need an `https`
|
|
2711
|
+
* URL that the Worker fetches from itself, which local development does not have.
|
|
2712
|
+
*/
|
|
2713
|
+
/** Where the admin's id is kept, by issuer. The library's own keys never start with it. */
|
|
2714
|
+
const KEY = "hollware:admin-client:";
|
|
2715
|
+
/**
|
|
2716
|
+
* The admin's client, registered on first use. Two first requests at once may both register one:
|
|
2717
|
+
* the last id written wins, and the other client is never used.
|
|
2718
|
+
*/
|
|
2719
|
+
async function adminClient(oauth, env) {
|
|
2720
|
+
const redirectUri = `${oauth.issuer}/admin/callback`;
|
|
2721
|
+
const stored = await env.OAUTH_KV.get(`${KEY}${oauth.issuer}`);
|
|
2722
|
+
if (stored !== null) return {
|
|
2723
|
+
clientId: stored,
|
|
2724
|
+
redirectUri
|
|
2725
|
+
};
|
|
2726
|
+
const client = await oauth.authorizationServer.getOAuthApi(env).createClient({
|
|
2727
|
+
clientName: "Hollware admin",
|
|
2728
|
+
redirectUris: [redirectUri],
|
|
2729
|
+
tokenEndpointAuthMethod: "none"
|
|
2730
|
+
});
|
|
2731
|
+
await env.OAUTH_KV.put(`${KEY}${oauth.issuer}`, client.clientId);
|
|
2732
|
+
return {
|
|
2733
|
+
clientId: client.clientId,
|
|
2734
|
+
redirectUri
|
|
2735
|
+
};
|
|
2736
|
+
}
|
|
2737
|
+
//#endregion
|
|
2738
|
+
//#region src/http/admin.ts
|
|
2739
|
+
/**
|
|
2740
|
+
* The admin at `/admin/` (ADR-026). Its hashed files are static assets that never reach the
|
|
2741
|
+
* Worker (`run_worker_first` in wrangler.jsonc); its page does, so that it goes out with the
|
|
2742
|
+
* policy below, and so does every path of the single-page app, which all get the same page. The
|
|
2743
|
+
* page learns how to sign in from `/admin/config.json`.
|
|
2744
|
+
*/
|
|
2745
|
+
/**
|
|
2746
|
+
* What the admin's page may do: its own scripts and styles (CodeMirror and Nuxt UI write style
|
|
2747
|
+
* elements and attributes, hence `unsafe-inline` for styles only), images of the content from
|
|
2748
|
+
* anywhere over https, and requests to this origin alone. Nothing frames it.
|
|
2749
|
+
*/
|
|
2750
|
+
const POLICY = [
|
|
2751
|
+
"default-src 'self'",
|
|
2752
|
+
"script-src 'self'",
|
|
2753
|
+
"style-src 'self' 'unsafe-inline'",
|
|
2754
|
+
"img-src 'self' data: https:",
|
|
2755
|
+
"connect-src 'self'",
|
|
2756
|
+
"font-src 'self' data:",
|
|
2757
|
+
"base-uri 'none'",
|
|
2758
|
+
"form-action 'self'",
|
|
2759
|
+
"frame-ancestors 'none'"
|
|
2760
|
+
].join("; ");
|
|
2761
|
+
function notInstalled() {
|
|
2762
|
+
return Response.json({
|
|
2763
|
+
code: "not_found",
|
|
2764
|
+
message: "The admin is not installed in this Worker.",
|
|
2765
|
+
fix: "Bind the static assets as ASSETS and serve the files of @hollware/admin (adminAssets()), as the starter does."
|
|
2766
|
+
}, {
|
|
2767
|
+
status: 404,
|
|
2768
|
+
headers: { "cache-control": "no-store" }
|
|
2769
|
+
});
|
|
2770
|
+
}
|
|
2771
|
+
function installAdmin(app, options) {
|
|
2772
|
+
app.get("/admin/config.json", async (c) => {
|
|
2773
|
+
const oauth = options.oauth(c.env.HOLLWARE_PUBLIC_URL);
|
|
2774
|
+
const client = await adminClient(oauth, c.env);
|
|
2775
|
+
return c.json({
|
|
2776
|
+
issuer: oauth.issuer,
|
|
2777
|
+
clientId: client.clientId,
|
|
2778
|
+
redirectUri: client.redirectUri,
|
|
2779
|
+
resource: oauth.resources.v1,
|
|
2780
|
+
scope: SCOPES.join(" ")
|
|
2781
|
+
}, 200, { "cache-control": "no-store" });
|
|
2782
|
+
});
|
|
2783
|
+
const page = async (c) => {
|
|
2784
|
+
const assets = c.env.ASSETS;
|
|
2785
|
+
if (assets === void 0) return notInstalled();
|
|
2786
|
+
const origin = options.oauth(c.env.HOLLWARE_PUBLIC_URL).issuer;
|
|
2787
|
+
const answer = await assets.fetch(new Request(`${origin}/admin/`));
|
|
2788
|
+
if (!answer.ok) return notInstalled();
|
|
2789
|
+
const headers = new Headers(answer.headers);
|
|
2790
|
+
headers.set("content-security-policy", POLICY);
|
|
2791
|
+
headers.set("cache-control", "no-store");
|
|
2792
|
+
headers.set("x-content-type-options", "nosniff");
|
|
2793
|
+
headers.set("referrer-policy", "no-referrer");
|
|
2794
|
+
for (const [name, value] of Object.entries(PAGE_ISOLATION)) headers.set(name, value);
|
|
2795
|
+
return new Response(answer.body, {
|
|
2796
|
+
status: 200,
|
|
2797
|
+
headers
|
|
2798
|
+
});
|
|
2799
|
+
};
|
|
2800
|
+
app.get("/admin", (c) => c.redirect("/admin/", 301));
|
|
2801
|
+
app.get("/admin/", page);
|
|
2802
|
+
app.get("/admin/*", async (c) => {
|
|
2803
|
+
if (/\.[a-z0-9]+$/i.test(c.req.path)) {
|
|
2804
|
+
const assets = c.env.ASSETS;
|
|
2805
|
+
if (assets === void 0) return notInstalled();
|
|
2806
|
+
return assets.fetch(c.req.raw);
|
|
2807
|
+
}
|
|
2808
|
+
return page(c);
|
|
2809
|
+
});
|
|
2810
|
+
}
|
|
2811
|
+
//#endregion
|
|
2812
|
+
//#region src/identity/people.ts
|
|
2813
|
+
/** The owners of the site besides `userId`, counted in the statement that changes `userId`. */
|
|
2814
|
+
const OTHER_OWNERS = "(SELECT count(*) FROM members WHERE site_id = ?1 AND role = 'owner' AND user_id <> ?2) > 0";
|
|
2815
|
+
async function roleOf$1(db, site, userId) {
|
|
2816
|
+
return db.prepare("SELECT role FROM members WHERE site_id = ?1 AND user_id = ?2").bind(site, userId).first("role");
|
|
2817
|
+
}
|
|
2818
|
+
async function setRole(db, input) {
|
|
2819
|
+
const current = await roleOf$1(db, input.site, input.userId);
|
|
2820
|
+
if (current === null) return "not_member";
|
|
2821
|
+
if (current === input.role) return "unchanged";
|
|
2822
|
+
return (await db.prepare(`UPDATE members SET role = ?3, updated_at = ?4 WHERE site_id = ?1 AND user_id = ?2
|
|
2328
2823
|
AND (role <> 'owner' OR ?3 = 'owner' OR ${OTHER_OWNERS})`).bind(input.site, input.userId, input.role, input.now).run()).meta.changes === 1 ? "changed" : "last_owner";
|
|
2329
2824
|
}
|
|
2330
2825
|
async function removeMember(db, input) {
|
|
@@ -2332,17 +2827,171 @@ async function removeMember(db, input) {
|
|
|
2332
2827
|
return (await db.prepare(`DELETE FROM members WHERE site_id = ?1 AND user_id = ?2 AND (role <> 'owner' OR ${OTHER_OWNERS})`).bind(input.site, input.userId).run()).meta.changes === 1 ? "changed" : "last_owner";
|
|
2333
2828
|
}
|
|
2334
2829
|
//#endregion
|
|
2830
|
+
//#region src/audit/read.ts
|
|
2831
|
+
/** A refusal of a filter or a cursor, with what to do instead. */
|
|
2832
|
+
var ActivityInputError = class extends Error {
|
|
2833
|
+
fix;
|
|
2834
|
+
constructor(message, fix) {
|
|
2835
|
+
super(message);
|
|
2836
|
+
this.fix = fix;
|
|
2837
|
+
}
|
|
2838
|
+
};
|
|
2839
|
+
const INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
|
|
2840
|
+
const ID = /^[a-z]{3}_[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
2841
|
+
function encodeCursor(at, id) {
|
|
2842
|
+
return btoa(`${at}|${id}`).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
2843
|
+
}
|
|
2844
|
+
function decodeCursor(cursor) {
|
|
2845
|
+
try {
|
|
2846
|
+
const [at = "", id = ""] = atob(cursor.replace(/-/g, "+").replace(/_/g, "/")).split("|");
|
|
2847
|
+
if (INSTANT.test(at) && ID.test(id)) return {
|
|
2848
|
+
at,
|
|
2849
|
+
id
|
|
2850
|
+
};
|
|
2851
|
+
} catch {}
|
|
2852
|
+
throw new ActivityInputError("The cursor is not valid for the activity log.", "Pass the nextCursor of the previous page unchanged, or leave it out for the first page.");
|
|
2853
|
+
}
|
|
2854
|
+
function instant(name, value) {
|
|
2855
|
+
if (value === void 0) return null;
|
|
2856
|
+
if (!INSTANT.test(value) || Number.isNaN(Date.parse(value))) throw new ActivityInputError(`${name} is not an instant.`, "Give it in UTC as 2026-10-17T00:00:00.000Z.");
|
|
2857
|
+
return new Date(value).toISOString();
|
|
2858
|
+
}
|
|
2859
|
+
function json$2(text) {
|
|
2860
|
+
if (text === null) return null;
|
|
2861
|
+
try {
|
|
2862
|
+
const value = JSON.parse(text);
|
|
2863
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : null;
|
|
2864
|
+
} catch {
|
|
2865
|
+
return null;
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
/** One page of the log of `site`, newest first; at most `limit` lines, 1 to 500. */
|
|
2869
|
+
async function readActivity(db, site, filters, page) {
|
|
2870
|
+
if (filters.action !== void 0 && !isAuditAction(filters.action)) throw new ActivityInputError(`There is no action ${filters.action}.`, "Use an action of the activity log, such as publish or token.create.");
|
|
2871
|
+
const limit = Math.min(Math.max(Math.trunc(page.limit), 1), 500);
|
|
2872
|
+
const where = ["a.site_id = ?1"];
|
|
2873
|
+
const binds = [site];
|
|
2874
|
+
const add = (clause, value) => {
|
|
2875
|
+
binds.push(value);
|
|
2876
|
+
where.push(clause(binds.length));
|
|
2877
|
+
};
|
|
2878
|
+
if (filters.actor !== void 0) add((i) => `a.actor_user_id = ?${i}`, filters.actor);
|
|
2879
|
+
if (filters.action !== void 0) add((i) => `a.action = ?${i}`, filters.action);
|
|
2880
|
+
if (filters.targetType !== void 0) add((i) => `a.target_type = ?${i}`, filters.targetType);
|
|
2881
|
+
if (filters.targetId !== void 0) add((i) => `a.target_id = ?${i}`, filters.targetId);
|
|
2882
|
+
const from = instant("from", filters.from);
|
|
2883
|
+
if (from !== null) add((i) => `a.created_at >= ?${i}`, from);
|
|
2884
|
+
const to = instant("to", filters.to);
|
|
2885
|
+
if (to !== null) add((i) => `a.created_at < ?${i}`, to);
|
|
2886
|
+
if (page.cursor !== void 0) {
|
|
2887
|
+
const after = decodeCursor(page.cursor);
|
|
2888
|
+
binds.push(after.at, after.id);
|
|
2889
|
+
where.push(`(a.created_at, a.id) < (?${binds.length - 1}, ?${binds.length})`);
|
|
2890
|
+
}
|
|
2891
|
+
binds.push(limit + 1);
|
|
2892
|
+
const [result] = await db.batch([db.prepare(`SELECT a.id, a.created_at, a.action, a.outcome, a.error_code, a.actor_kind, a.actor_user_id,
|
|
2893
|
+
u.name AS actor_name, a.actor_token_id, t.label AS token_label, a.actor_client_id,
|
|
2894
|
+
a.actor_meta, a.target_type, a.target_id, a.detail, a.request_id, a.ip_hash
|
|
2895
|
+
FROM audit_log a
|
|
2896
|
+
LEFT JOIN users u ON u.id = a.actor_user_id
|
|
2897
|
+
LEFT JOIN tokens t ON t.id = a.actor_token_id AND t.site_id = a.site_id
|
|
2898
|
+
WHERE ${where.join(" AND ")}
|
|
2899
|
+
ORDER BY a.created_at DESC, a.id DESC
|
|
2900
|
+
LIMIT ?${binds.length}`).bind(...binds)]);
|
|
2901
|
+
const rows = result?.results ?? [];
|
|
2902
|
+
const lines = rows.slice(0, limit).map((row) => ({
|
|
2903
|
+
id: row.id,
|
|
2904
|
+
at: row.created_at,
|
|
2905
|
+
action: row.action,
|
|
2906
|
+
outcome: row.outcome,
|
|
2907
|
+
errorCode: row.error_code,
|
|
2908
|
+
actor: {
|
|
2909
|
+
kind: row.actor_kind,
|
|
2910
|
+
userId: row.actor_user_id,
|
|
2911
|
+
name: row.actor_name,
|
|
2912
|
+
tokenId: row.actor_token_id,
|
|
2913
|
+
tokenLabel: row.token_label,
|
|
2914
|
+
clientId: row.actor_client_id,
|
|
2915
|
+
declared: json$2(row.actor_meta)
|
|
2916
|
+
},
|
|
2917
|
+
target: row.target_type === null || row.target_id === null ? null : {
|
|
2918
|
+
type: row.target_type,
|
|
2919
|
+
id: row.target_id
|
|
2920
|
+
},
|
|
2921
|
+
detail: json$2(row.detail),
|
|
2922
|
+
requestId: row.request_id,
|
|
2923
|
+
ipHash: row.ip_hash
|
|
2924
|
+
}));
|
|
2925
|
+
const last = lines.at(-1);
|
|
2926
|
+
return {
|
|
2927
|
+
lines,
|
|
2928
|
+
nextCursor: rows.length > limit && last !== void 0 ? encodeCursor(last.at, last.id) : null
|
|
2929
|
+
};
|
|
2930
|
+
}
|
|
2931
|
+
/** The columns of the CSV, in order. */
|
|
2932
|
+
const ACTIVITY_CSV_COLUMNS = [
|
|
2933
|
+
"at",
|
|
2934
|
+
"action",
|
|
2935
|
+
"outcome",
|
|
2936
|
+
"error_code",
|
|
2937
|
+
"actor_kind",
|
|
2938
|
+
"actor_user_id",
|
|
2939
|
+
"actor_name",
|
|
2940
|
+
"actor_token_id",
|
|
2941
|
+
"actor_token_label",
|
|
2942
|
+
"actor_client_id",
|
|
2943
|
+
"actor_declared",
|
|
2944
|
+
"target_type",
|
|
2945
|
+
"target_id",
|
|
2946
|
+
"detail",
|
|
2947
|
+
"request_id",
|
|
2948
|
+
"ip_hash"
|
|
2949
|
+
];
|
|
2950
|
+
/**
|
|
2951
|
+
* A field of CSV (RFC 4180), with a cell that a spreadsheet would run as a formula (`=`, `+`, `-`,
|
|
2952
|
+
* `@`, a tab or a return first) led by an apostrophe: a name or a label is anyone's text.
|
|
2953
|
+
*/
|
|
2954
|
+
function csvField(value) {
|
|
2955
|
+
if (value === null) return "";
|
|
2956
|
+
const safe = /^[=+\-@\t\r]/.test(value) ? `'${value}` : value;
|
|
2957
|
+
return /[",\r\n]/.test(safe) ? `"${safe.replace(/"/g, "\"\"")}"` : safe;
|
|
2958
|
+
}
|
|
2959
|
+
function activityCsvRow(line) {
|
|
2960
|
+
const text = (value) => value === null ? null : JSON.stringify(value);
|
|
2961
|
+
return [
|
|
2962
|
+
line.at,
|
|
2963
|
+
line.action,
|
|
2964
|
+
line.outcome,
|
|
2965
|
+
line.errorCode,
|
|
2966
|
+
line.actor.kind,
|
|
2967
|
+
line.actor.userId,
|
|
2968
|
+
line.actor.name,
|
|
2969
|
+
line.actor.tokenId,
|
|
2970
|
+
line.actor.tokenLabel,
|
|
2971
|
+
line.actor.clientId,
|
|
2972
|
+
text(line.actor.declared),
|
|
2973
|
+
line.target?.type ?? null,
|
|
2974
|
+
line.target?.id ?? null,
|
|
2975
|
+
text(line.detail),
|
|
2976
|
+
line.requestId,
|
|
2977
|
+
line.ipHash
|
|
2978
|
+
].map(csvField).join(",");
|
|
2979
|
+
}
|
|
2980
|
+
//#endregion
|
|
2335
2981
|
//#region src/http/admin-api.ts
|
|
2336
2982
|
/**
|
|
2337
2983
|
* What only a person in the admin may do (ADR-026): see, make and revoke personal tokens, and
|
|
2338
2984
|
* invite people, on the site of their token; and learn who they are and which sites they may switch
|
|
2339
|
-
* to (`/v1/admin/me
|
|
2985
|
+
* to (`/v1/admin/me`), and manage their own sign-in (`/v1/admin/account`: their passkeys, the apps
|
|
2986
|
+
* they connected and their sessions), the routes for every role. These are no operations, so no agent sees them over MCP, and a request needs an
|
|
2340
2987
|
* access token of the admin's own client with `schema:admin`: a personal token, or a token of
|
|
2341
2988
|
* another client, never reaches them, even an owner's. A leaked token cannot mint the next one.
|
|
2342
2989
|
*/
|
|
2343
|
-
/** A personal token lasts this long unless the person says otherwise (as `hollware token create`). */
|
|
2344
|
-
const TOKEN_DAYS = 90;
|
|
2345
2990
|
const INVITATION_DAYS = 7;
|
|
2991
|
+
/** The lines of a page of the activity log, unless the admin asks for another number. */
|
|
2992
|
+
const ACTIVITY_PAGE = 50;
|
|
2993
|
+
/** The most lines one CSV of the activity log holds. */
|
|
2994
|
+
const ACTIVITY_CSV_MAX = 1e4;
|
|
2346
2995
|
function json$1(body, status = 200) {
|
|
2347
2996
|
return Response.json(body, {
|
|
2348
2997
|
status,
|
|
@@ -2356,6 +3005,20 @@ const REFUSAL_CODES = {
|
|
|
2356
3005
|
404: "not_found",
|
|
2357
3006
|
409: "conflict"
|
|
2358
3007
|
};
|
|
3008
|
+
/** The code of a refusal by its status, for the activity log. */
|
|
3009
|
+
function refusalCode(status) {
|
|
3010
|
+
if (status === 503) return "unavailable";
|
|
3011
|
+
return status === 403 || status === 404 || status === 409 ? REFUSAL_CODES[status] : "internal";
|
|
3012
|
+
}
|
|
3013
|
+
/**
|
|
3014
|
+
* The page of an entry on its site, or `null` when the site has no URL or no route for the locale:
|
|
3015
|
+
* the origin and the route of the collection with its slug (the admin's `pageUrl` does the same).
|
|
3016
|
+
*/
|
|
3017
|
+
function pageUrlOf(site, entry) {
|
|
3018
|
+
const route = site.collections.get(entry.collection)?.routes[entry.locale];
|
|
3019
|
+
if (site.url === null || route === void 0) return null;
|
|
3020
|
+
return `${site.url.replace(/\/+$/, "")}${route.replace(/:slug(?![A-Za-z0-9_])/, entry.slug)}`;
|
|
3021
|
+
}
|
|
2359
3022
|
function refused(message, fix, status = 400) {
|
|
2360
3023
|
return json$1({
|
|
2361
3024
|
code: REFUSAL_CODES[status],
|
|
@@ -2371,26 +3034,87 @@ function parseScopes(json) {
|
|
|
2371
3034
|
return [];
|
|
2372
3035
|
}
|
|
2373
3036
|
}
|
|
2374
|
-
|
|
3037
|
+
/** The days an invitation lasts: `fallback` when left out, a whole number from 1 to 365, or null. */
|
|
3038
|
+
function invitationDays(value, fallback) {
|
|
2375
3039
|
if (value === void 0) return fallback;
|
|
2376
3040
|
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 365 ? value : null;
|
|
2377
3041
|
}
|
|
3042
|
+
/**
|
|
3043
|
+
* The days a token lasts: none (`null`) when they are left out, so it never expires (T-26: the
|
|
3044
|
+
* person chooses), or a whole number from 1 to 365; `undefined` for anything else.
|
|
3045
|
+
*/
|
|
3046
|
+
function daysFrom(value) {
|
|
3047
|
+
if (value === void 0 || value === null) return null;
|
|
3048
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 365 ? value : void 0;
|
|
3049
|
+
}
|
|
3050
|
+
/** At most this many grants of a person are read: pages of the library's listing, 100 each. */
|
|
3051
|
+
const GRANT_PAGES = 10;
|
|
3052
|
+
/** Every grant of a person, across the sites of the deployment. */
|
|
3053
|
+
async function grantsOf(helpers, userId) {
|
|
3054
|
+
const grants = [];
|
|
3055
|
+
let cursor;
|
|
3056
|
+
for (let page = 0; page < GRANT_PAGES; page++) {
|
|
3057
|
+
const listed = await helpers.listUserGrants(userId, {
|
|
3058
|
+
limit: 100,
|
|
3059
|
+
...cursor === void 0 ? {} : { cursor }
|
|
3060
|
+
});
|
|
3061
|
+
grants.push(...listed.items);
|
|
3062
|
+
cursor = listed.cursor;
|
|
3063
|
+
if (cursor === void 0) break;
|
|
3064
|
+
}
|
|
3065
|
+
return grants;
|
|
3066
|
+
}
|
|
3067
|
+
/** The name a client gave itself, or its id when it gave none or cannot be read now. */
|
|
3068
|
+
async function clientName(helpers, clientId) {
|
|
3069
|
+
try {
|
|
3070
|
+
return (await helpers.lookupClient(clientId))?.clientName ?? clientId;
|
|
3071
|
+
} catch {
|
|
3072
|
+
return clientId;
|
|
3073
|
+
}
|
|
3074
|
+
}
|
|
3075
|
+
function siteOf(metadata) {
|
|
3076
|
+
if (typeof metadata !== "object" || metadata === null) return null;
|
|
3077
|
+
const site = Reflect.get(metadata, "site");
|
|
3078
|
+
return typeof site === "string" ? site : null;
|
|
3079
|
+
}
|
|
2378
3080
|
function installAdminApi(app, options) {
|
|
2379
3081
|
/**
|
|
2380
3082
|
* Runs `handle` for a person in the admin with `schema:admin`, or with any scope when `anyone`;
|
|
2381
|
-
* anyone else is refused.
|
|
3083
|
+
* anyone else is refused. A route that changes something names its `action`, and gets one line
|
|
3084
|
+
* of the activity log for each answer but a 400 (ADR-037): `ok` with what `handle` noted, `denied`
|
|
3085
|
+
* for a 403, `failed` for a 404 or a 409. A change of the person's own sign-in (`everySite`) is a
|
|
3086
|
+
* line in each site they are a member of, since a person is of the deployment.
|
|
2382
3087
|
*/
|
|
2383
|
-
const admin = (handle, { anyone = false } = {}) => (c) => options.protect(c, async (principal) => {
|
|
2384
|
-
const
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
3088
|
+
const admin = (action, handle, { anyone = false, everySite = false } = {}) => (c) => options.protect(c, async (principal) => {
|
|
3089
|
+
const note = {};
|
|
3090
|
+
const answer = await (async () => {
|
|
3091
|
+
const ours = (await adminClient(options.oauth(c.env.HOLLWARE_PUBLIC_URL), c.env)).clientId;
|
|
3092
|
+
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);
|
|
3093
|
+
if (!anyone && !principal.scopes.includes("schema:admin")) return insufficientScope(["schema:admin"], options.metadataUrl(c));
|
|
3094
|
+
return handle(c, principal, note);
|
|
3095
|
+
})();
|
|
3096
|
+
if (action !== null && answer.status !== 400 && principal.kind !== "read") {
|
|
3097
|
+
const keys = everySite ? (await membershipsOf(connect(c.env.DB), principal.userId)).map((m) => m.site) : [principal.site];
|
|
3098
|
+
const log = (await options.audit(c, principal))(null);
|
|
3099
|
+
const line = {
|
|
3100
|
+
action,
|
|
3101
|
+
outcome: answer.ok ? "ok" : answer.status === 403 ? "denied" : "failed",
|
|
3102
|
+
errorCode: answer.ok ? null : answer.status === 403 && answer.headers.has("www-authenticate") ? "insufficient_scope" : refusalCode(answer.status),
|
|
3103
|
+
target: note.target ?? null,
|
|
3104
|
+
detail: note.detail ?? null
|
|
3105
|
+
};
|
|
3106
|
+
for (const key of new Set(keys)) {
|
|
3107
|
+
const site = options.site(key);
|
|
3108
|
+
if (site !== void 0) await log.write(site, line);
|
|
3109
|
+
}
|
|
3110
|
+
}
|
|
3111
|
+
return answer;
|
|
2388
3112
|
});
|
|
2389
3113
|
const body = async (c) => {
|
|
2390
3114
|
const value = await c.req.json().catch(() => null);
|
|
2391
3115
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {};
|
|
2392
3116
|
};
|
|
2393
|
-
app.get("/v1/admin/me", admin(async (c, person) => {
|
|
3117
|
+
app.get("/v1/admin/me", admin(null, async (c, person) => {
|
|
2394
3118
|
const db = connect(c.env.DB);
|
|
2395
3119
|
const [user] = await db.select({
|
|
2396
3120
|
id: users.id,
|
|
@@ -2412,7 +3136,37 @@ function installAdminApi(app, options) {
|
|
|
2412
3136
|
trash
|
|
2413
3137
|
});
|
|
2414
3138
|
}, { anyone: true }));
|
|
2415
|
-
app.
|
|
3139
|
+
app.post("/v1/admin/preview", admin("preview.create", async (c, person, note) => {
|
|
3140
|
+
const entryId = (await body(c))["entryId"];
|
|
3141
|
+
if (typeof entryId !== "string" || !isId("ent", entryId)) return refused("Give the id of an entry.", "Send { \"entryId\": \"ent_…\" }.");
|
|
3142
|
+
note.target = {
|
|
3143
|
+
type: "entry",
|
|
3144
|
+
id: entryId
|
|
3145
|
+
};
|
|
3146
|
+
const secret = c.env.HOLLWARE_SECRET;
|
|
3147
|
+
if (!canSignPreviews(secret)) return json$1({
|
|
3148
|
+
code: "unavailable",
|
|
3149
|
+
message: "This Hollware cannot make preview links: it has no signing secret.",
|
|
3150
|
+
fix: "Set the Worker secret HOLLWARE_SECRET to at least 32 characters (`hollware deploy` makes one), then try again."
|
|
3151
|
+
}, 503);
|
|
3152
|
+
const site = options.site(person.site);
|
|
3153
|
+
const found = await createD1Store(c.env.DB).findEntryById(person.site, entryId, "head");
|
|
3154
|
+
if (site === void 0 || found === null) return refused("This site has no such entry.", "Open the entry from its collection in the admin.", 404);
|
|
3155
|
+
const made = await makePreviewToken({
|
|
3156
|
+
secret,
|
|
3157
|
+
site: person.site,
|
|
3158
|
+
entryId,
|
|
3159
|
+
now: options.clock.now()
|
|
3160
|
+
});
|
|
3161
|
+
const page = pageUrlOf(site, found.entry);
|
|
3162
|
+
note.detail = { expiresAt: made.expiresAt };
|
|
3163
|
+
return json$1({
|
|
3164
|
+
token: made.token,
|
|
3165
|
+
expiresAt: made.expiresAt,
|
|
3166
|
+
url: page === null ? null : `${page}?${PREVIEW_QUERY_PARAM}=${made.token}`
|
|
3167
|
+
});
|
|
3168
|
+
}, { anyone: true }));
|
|
3169
|
+
app.get("/v1/admin/tokens", admin(null, async (c, person) => {
|
|
2416
3170
|
return json$1({ tokens: (await connect(c.env.DB).select({
|
|
2417
3171
|
id: tokens.id,
|
|
2418
3172
|
kind: tokens.kind,
|
|
@@ -2428,7 +3182,7 @@ function installAdminApi(app, options) {
|
|
|
2428
3182
|
scopes: parseScopes(row.scopes)
|
|
2429
3183
|
})) });
|
|
2430
3184
|
}));
|
|
2431
|
-
app.post("/v1/admin/tokens", admin(async (c, person) => {
|
|
3185
|
+
app.post("/v1/admin/tokens", admin("token.create", async (c, person, note) => {
|
|
2432
3186
|
const input = await body(c);
|
|
2433
3187
|
const label = typeof input["label"] === "string" ? input["label"].trim() : "";
|
|
2434
3188
|
if (label === "" || label.length > 100) return refused("A token needs a label of 1 to 100 characters.", "Say what the token is for.");
|
|
@@ -2438,8 +3192,8 @@ function installAdminApi(app, options) {
|
|
|
2438
3192
|
const asked = kind === "read" ? ["content:read"] : Array.isArray(input["scopes"]) ? input["scopes"].filter((s) => typeof s === "string") : [];
|
|
2439
3193
|
const scopes = person.scopes.filter((scope) => asked.includes(scope));
|
|
2440
3194
|
if (scopes.length === 0) return refused("A token needs at least one scope that you have.", `Choose among ${person.scopes.join(", ")}.`);
|
|
2441
|
-
const days = daysFrom(input["expiresInDays"]
|
|
2442
|
-
if (days ===
|
|
3195
|
+
const days = daysFrom(input["expiresInDays"]);
|
|
3196
|
+
if (days === void 0) return refused("expiresInDays is a whole number from 1 to 365.", "Leave it out for a token that never expires.");
|
|
2443
3197
|
const now = options.clock.now();
|
|
2444
3198
|
const secret = kind === "read" ? generateReadToken() : generatePersonalToken();
|
|
2445
3199
|
const row = {
|
|
@@ -2450,13 +3204,23 @@ function installAdminApi(app, options) {
|
|
|
2450
3204
|
label,
|
|
2451
3205
|
hash: await hashPersonalToken(secret),
|
|
2452
3206
|
scopes: JSON.stringify(scopes),
|
|
2453
|
-
expiresAt: new Date(now.getTime() + days * 864e5).toISOString(),
|
|
3207
|
+
expiresAt: days === null ? null : new Date(now.getTime() + days * 864e5).toISOString(),
|
|
2454
3208
|
revokedAt: null,
|
|
2455
3209
|
lastUsedAt: null,
|
|
2456
3210
|
createdAt: now.toISOString(),
|
|
2457
3211
|
updatedAt: now.toISOString()
|
|
2458
3212
|
};
|
|
2459
3213
|
await connect(c.env.DB).insert(tokens).values(row);
|
|
3214
|
+
note.target = {
|
|
3215
|
+
type: "token",
|
|
3216
|
+
id: row.id
|
|
3217
|
+
};
|
|
3218
|
+
note.detail = {
|
|
3219
|
+
kind,
|
|
3220
|
+
label,
|
|
3221
|
+
scopes,
|
|
3222
|
+
expiresAt: row.expiresAt
|
|
3223
|
+
};
|
|
2460
3224
|
return json$1({
|
|
2461
3225
|
id: row.id,
|
|
2462
3226
|
kind,
|
|
@@ -2466,10 +3230,14 @@ function installAdminApi(app, options) {
|
|
|
2466
3230
|
secret
|
|
2467
3231
|
}, 201);
|
|
2468
3232
|
}));
|
|
2469
|
-
app.post("/v1/admin/tokens/:id/revoke", admin(async (c, person) => {
|
|
3233
|
+
app.post("/v1/admin/tokens/:id/revoke", admin("token.revoke", async (c, person, note) => {
|
|
2470
3234
|
const now = options.clock.now().toISOString();
|
|
2471
3235
|
const db = connect(c.env.DB);
|
|
2472
3236
|
const id = c.req.param("id") ?? "";
|
|
3237
|
+
note.target = {
|
|
3238
|
+
type: "token",
|
|
3239
|
+
id
|
|
3240
|
+
};
|
|
2473
3241
|
const [revoked] = await db.update(tokens).set({
|
|
2474
3242
|
revokedAt: now,
|
|
2475
3243
|
updatedAt: now
|
|
@@ -2487,7 +3255,7 @@ function installAdminApi(app, options) {
|
|
|
2487
3255
|
revokedAt: row.revokedAt
|
|
2488
3256
|
});
|
|
2489
3257
|
}));
|
|
2490
|
-
app.get("/v1/admin/people", admin(async (c, person) => {
|
|
3258
|
+
app.get("/v1/admin/people", admin(null, async (c, person) => {
|
|
2491
3259
|
const db = connect(c.env.DB);
|
|
2492
3260
|
const now = options.clock.now().toISOString();
|
|
2493
3261
|
return json$1({
|
|
@@ -2513,12 +3281,17 @@ function installAdminApi(app, options) {
|
|
|
2513
3281
|
case "last_owner": return refused("A site keeps at least one owner, and this is its last one.", "Make another person an owner first, then change this one.", 409);
|
|
2514
3282
|
}
|
|
2515
3283
|
};
|
|
2516
|
-
app.post("/v1/admin/people/role", admin(async (c, person) => {
|
|
3284
|
+
app.post("/v1/admin/people/role", admin("member.role", async (c, person, note) => {
|
|
2517
3285
|
const input = await body(c);
|
|
2518
3286
|
const role = input["role"];
|
|
2519
3287
|
const userId = input["userId"];
|
|
2520
3288
|
if (typeof userId !== "string" || typeof role !== "string" || !isRole(role)) return refused("Give the userId of a member and a role.", `Send { "userId": "usr_…", "role": "owner" | "editor" | "author" }.`);
|
|
2521
3289
|
const now = options.clock.now().toISOString();
|
|
3290
|
+
note.target = {
|
|
3291
|
+
type: "user",
|
|
3292
|
+
id: userId
|
|
3293
|
+
};
|
|
3294
|
+
note.detail = { role };
|
|
2522
3295
|
return answerOf(await setRole(c.env.DB, {
|
|
2523
3296
|
site: person.site,
|
|
2524
3297
|
userId,
|
|
@@ -2526,19 +3299,23 @@ function installAdminApi(app, options) {
|
|
|
2526
3299
|
now
|
|
2527
3300
|
}));
|
|
2528
3301
|
}));
|
|
2529
|
-
app.post("/v1/admin/people/remove", admin(async (c, person) => {
|
|
3302
|
+
app.post("/v1/admin/people/remove", admin("member.remove", async (c, person, note) => {
|
|
2530
3303
|
const userId = (await body(c))["userId"];
|
|
2531
3304
|
if (typeof userId !== "string") return refused("Give the userId of a member.", "Send { \"userId\": \"usr_…\" }.");
|
|
3305
|
+
note.target = {
|
|
3306
|
+
type: "user",
|
|
3307
|
+
id: userId
|
|
3308
|
+
};
|
|
2532
3309
|
return answerOf(await removeMember(c.env.DB, {
|
|
2533
3310
|
site: person.site,
|
|
2534
3311
|
userId
|
|
2535
3312
|
}));
|
|
2536
3313
|
}));
|
|
2537
|
-
app.post("/v1/admin/invitations", admin(async (c, person) => {
|
|
3314
|
+
app.post("/v1/admin/invitations", admin("invitation.create", async (c, person, note) => {
|
|
2538
3315
|
const input = await body(c);
|
|
2539
3316
|
const role = input["role"];
|
|
2540
3317
|
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(", ")}).`);
|
|
2541
|
-
const days =
|
|
3318
|
+
const days = invitationDays(input["days"], INVITATION_DAYS);
|
|
2542
3319
|
if (days === null) return refused("days is a whole number from 1 to 365.", "Leave it out for 7 days.");
|
|
2543
3320
|
const made = await createInvitation(connect(c.env.DB), {
|
|
2544
3321
|
site: person.site,
|
|
@@ -2547,16 +3324,267 @@ function installAdminApi(app, options) {
|
|
|
2547
3324
|
now: options.clock.now(),
|
|
2548
3325
|
days
|
|
2549
3326
|
});
|
|
2550
|
-
|
|
3327
|
+
note.target = {
|
|
3328
|
+
type: "invitation",
|
|
3329
|
+
id: made.id
|
|
3330
|
+
};
|
|
3331
|
+
note.detail = {
|
|
3332
|
+
role,
|
|
3333
|
+
days
|
|
3334
|
+
};
|
|
3335
|
+
const origin = options.oauth(c.env.HOLLWARE_PUBLIC_URL).issuer;
|
|
3336
|
+
return json$1({
|
|
3337
|
+
id: made.id,
|
|
3338
|
+
role,
|
|
3339
|
+
expiresAt: made.expiresAt,
|
|
3340
|
+
link: `${origin}/setup#invitation=${made.secret}`
|
|
3341
|
+
}, 201);
|
|
3342
|
+
}));
|
|
3343
|
+
const filtersOf = (c) => {
|
|
3344
|
+
const filters = {};
|
|
3345
|
+
for (const name of [
|
|
3346
|
+
"actor",
|
|
3347
|
+
"action",
|
|
3348
|
+
"targetType",
|
|
3349
|
+
"targetId",
|
|
3350
|
+
"from",
|
|
3351
|
+
"to"
|
|
3352
|
+
]) {
|
|
3353
|
+
const value = c.req.query(name);
|
|
3354
|
+
if (value !== void 0 && value !== "") filters[name] = value;
|
|
3355
|
+
}
|
|
3356
|
+
return filters;
|
|
3357
|
+
};
|
|
3358
|
+
/** An answer of the log, or the refusal of a filter or a cursor that is not valid. */
|
|
3359
|
+
const reading = async (read) => {
|
|
3360
|
+
try {
|
|
3361
|
+
return await read();
|
|
3362
|
+
} catch (error) {
|
|
3363
|
+
if (error instanceof ActivityInputError) return refused(error.message, error.fix);
|
|
3364
|
+
throw error;
|
|
3365
|
+
}
|
|
3366
|
+
};
|
|
3367
|
+
app.get("/v1/admin/activity", admin(null, async (c, person) => reading(async () => {
|
|
3368
|
+
const cursor = c.req.query("cursor");
|
|
3369
|
+
const limit = Number(c.req.query("limit") ?? ACTIVITY_PAGE);
|
|
3370
|
+
return json$1(await readActivity(c.env.DB, person.site, filtersOf(c), {
|
|
3371
|
+
...cursor === void 0 ? {} : { cursor },
|
|
3372
|
+
limit: Number.isFinite(limit) ? limit : ACTIVITY_PAGE
|
|
3373
|
+
}));
|
|
3374
|
+
})));
|
|
3375
|
+
app.get("/v1/admin/activity.csv", admin(null, async (c, person) => reading(async () => {
|
|
3376
|
+
const rows = [ACTIVITY_CSV_COLUMNS.join(",")];
|
|
3377
|
+
let cursor;
|
|
3378
|
+
do {
|
|
3379
|
+
const page = await readActivity(c.env.DB, person.site, filtersOf(c), {
|
|
3380
|
+
...cursor === void 0 ? {} : { cursor },
|
|
3381
|
+
limit: 500
|
|
3382
|
+
});
|
|
3383
|
+
rows.push(...page.lines.map(activityCsvRow));
|
|
3384
|
+
cursor = page.nextCursor ?? void 0;
|
|
3385
|
+
} while (cursor !== void 0 && rows.length <= ACTIVITY_CSV_MAX);
|
|
3386
|
+
return new Response(`${rows.join("\r\n")}\r\n`, { headers: {
|
|
3387
|
+
"content-type": "text/csv; charset=utf-8",
|
|
3388
|
+
"content-disposition": `attachment; filename="activity-${person.site}.csv"`,
|
|
3389
|
+
"cache-control": "no-store"
|
|
3390
|
+
} });
|
|
3391
|
+
})));
|
|
3392
|
+
/** The OAuth helpers and the admin's own client, for the public URL of the request. */
|
|
3393
|
+
const grantsApi = async (c) => {
|
|
3394
|
+
const oauth = options.oauth(c.env.HOLLWARE_PUBLIC_URL);
|
|
3395
|
+
return {
|
|
3396
|
+
helpers: oauth.authorizationServer.getOAuthApi(c.env),
|
|
3397
|
+
admin: (await adminClient(oauth, c.env)).clientId
|
|
3398
|
+
};
|
|
3399
|
+
};
|
|
3400
|
+
app.get("/v1/admin/account", admin(null, async (c, person) => {
|
|
3401
|
+
const passkeys = await passkeySummaries(connect(c.env.DB), person.userId);
|
|
3402
|
+
const { helpers, admin: ours } = await grantsApi(c);
|
|
3403
|
+
const grants = await grantsOf(helpers, person.userId);
|
|
3404
|
+
const names = /* @__PURE__ */ new Map();
|
|
3405
|
+
const nameOf = (clientId) => {
|
|
3406
|
+
const known = names.get(clientId);
|
|
3407
|
+
if (known !== void 0) return known;
|
|
3408
|
+
const asked = clientName(helpers, clientId);
|
|
3409
|
+
names.set(clientId, asked);
|
|
3410
|
+
return asked;
|
|
3411
|
+
};
|
|
3412
|
+
const apps = await Promise.all(grants.map(async (grant) => ({
|
|
3413
|
+
grantId: grant.id,
|
|
3414
|
+
clientId: grant.clientId,
|
|
3415
|
+
name: await nameOf(grant.clientId),
|
|
3416
|
+
scopes: grant.scope.filter(isScope),
|
|
3417
|
+
site: siteOf(grant.metadata),
|
|
3418
|
+
createdAt: (/* @__PURE__ */ new Date(grant.createdAt * 1e3)).toISOString(),
|
|
3419
|
+
admin: grant.clientId === ours
|
|
3420
|
+
})));
|
|
3421
|
+
apps.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || a.grantId.localeCompare(b.grantId));
|
|
3422
|
+
return json$1({
|
|
3423
|
+
passkeys,
|
|
3424
|
+
apps
|
|
3425
|
+
});
|
|
3426
|
+
}, { anyone: true }));
|
|
3427
|
+
app.post("/v1/admin/account/passkeys/remove", admin("passkey.remove", async (c, person, note) => {
|
|
3428
|
+
const id = (await body(c))["id"];
|
|
3429
|
+
if (typeof id !== "string") return refused("Give the id of one of your passkeys.", "Send { \"id\": \"pky_…\" }.");
|
|
3430
|
+
note.target = {
|
|
3431
|
+
type: "passkey",
|
|
3432
|
+
id
|
|
3433
|
+
};
|
|
3434
|
+
switch (await removePasskey(c.env.DB, {
|
|
3435
|
+
userId: person.userId,
|
|
3436
|
+
id
|
|
3437
|
+
})) {
|
|
3438
|
+
case "removed": return json$1({
|
|
3439
|
+
outcome: "removed",
|
|
3440
|
+
id
|
|
3441
|
+
});
|
|
3442
|
+
case "not_found": return refused("You have no such passkey.", "List your passkeys and pick one of them.", 404);
|
|
3443
|
+
case "last": return refused("This is your last passkey: without it you could not sign in again.", "Add another passkey first, then remove this one.", 409);
|
|
3444
|
+
}
|
|
3445
|
+
}, {
|
|
3446
|
+
anyone: true,
|
|
3447
|
+
everySite: true
|
|
3448
|
+
}));
|
|
3449
|
+
app.post("/v1/admin/account/apps/revoke", admin("app.revoke", async (c, person, note) => {
|
|
3450
|
+
const grantId = (await body(c))["grantId"];
|
|
3451
|
+
if (typeof grantId !== "string") return refused("Give the grantId of one of your apps.", "Send { \"grantId\": \"…\" }.");
|
|
3452
|
+
note.target = {
|
|
3453
|
+
type: "grant",
|
|
3454
|
+
id: grantId
|
|
3455
|
+
};
|
|
3456
|
+
const { helpers, admin: ours } = await grantsApi(c);
|
|
3457
|
+
const grant = (await grantsOf(helpers, person.userId)).find((g) => g.id === grantId);
|
|
3458
|
+
if (grant === void 0) return refused("You connected no such app.", "List your apps and pick one of them.", 404);
|
|
3459
|
+
if (grant.clientId === ours) return refused("That is this admin: it is not revoked here.", "Sign out from the person menu, or sign out everywhere.", 409);
|
|
3460
|
+
await helpers.revokeGrant(grant.id, person.userId);
|
|
3461
|
+
note.detail = {
|
|
3462
|
+
clientId: grant.clientId,
|
|
3463
|
+
site: siteOf(grant.metadata)
|
|
3464
|
+
};
|
|
2551
3465
|
return json$1({
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
3466
|
+
outcome: "revoked",
|
|
3467
|
+
grantId: grant.id
|
|
3468
|
+
});
|
|
3469
|
+
}, {
|
|
3470
|
+
anyone: true,
|
|
3471
|
+
everySite: true
|
|
3472
|
+
}));
|
|
3473
|
+
app.post("/v1/admin/account/sessions/end", admin("session.end_all", async (c, person, note) => {
|
|
3474
|
+
const ended = await endSessions(connect(c.env.DB), person.userId);
|
|
3475
|
+
const { helpers, admin: ours } = await grantsApi(c);
|
|
3476
|
+
const admins = (await grantsOf(helpers, person.userId)).filter((g) => g.clientId === ours);
|
|
3477
|
+
for (const grant of admins) await helpers.revokeGrant(grant.id, person.userId);
|
|
3478
|
+
note.target = {
|
|
3479
|
+
type: "user",
|
|
3480
|
+
id: person.userId
|
|
3481
|
+
};
|
|
3482
|
+
note.detail = {
|
|
3483
|
+
sessions: ended,
|
|
3484
|
+
adminGrants: admins.length
|
|
3485
|
+
};
|
|
3486
|
+
return json$1({
|
|
3487
|
+
outcome: "ended",
|
|
3488
|
+
sessions: ended,
|
|
3489
|
+
adminGrants: admins.length
|
|
3490
|
+
});
|
|
3491
|
+
}, {
|
|
3492
|
+
anyone: true,
|
|
3493
|
+
everySite: true
|
|
2557
3494
|
}));
|
|
2558
3495
|
}
|
|
2559
3496
|
//#endregion
|
|
3497
|
+
//#region src/audit/operations.ts
|
|
3498
|
+
/**
|
|
3499
|
+
* The line of a call of an operation (ADR-037): every operation that is not a read gets one,
|
|
3500
|
+
* whether it changed something, was refused or failed. Of the input, only the fields that name
|
|
3501
|
+
* what was acted on are kept, each shortened; of the output, its outcome and the id of the entry,
|
|
3502
|
+
* review or comment it answers with. Never the Markdown, the frontmatter, or a secret that
|
|
3503
|
+
* `make_webhook_secret` shows once.
|
|
3504
|
+
*/
|
|
3505
|
+
/** The fields of an input that name what an operation acts on. */
|
|
3506
|
+
const NAMING_FIELDS = [
|
|
3507
|
+
"site",
|
|
3508
|
+
"collection",
|
|
3509
|
+
"locale",
|
|
3510
|
+
"slug",
|
|
3511
|
+
"id",
|
|
3512
|
+
"entryId",
|
|
3513
|
+
"reviewId",
|
|
3514
|
+
"name"
|
|
3515
|
+
];
|
|
3516
|
+
const FIELD_MAX = 200;
|
|
3517
|
+
function isAudited(name) {
|
|
3518
|
+
return OPERATION_AUDIT_ACTIONS.includes(name);
|
|
3519
|
+
}
|
|
3520
|
+
function record(value) {
|
|
3521
|
+
return typeof value === "object" && value !== null ? Object.fromEntries(Object.entries(value)) : {};
|
|
3522
|
+
}
|
|
3523
|
+
function idOf(value) {
|
|
3524
|
+
const id = record(value)["id"];
|
|
3525
|
+
return typeof id === "string" ? id : null;
|
|
3526
|
+
}
|
|
3527
|
+
/** What the call acted on: the entry, review or comment of its answer, else its site. */
|
|
3528
|
+
function targetOf(output, site) {
|
|
3529
|
+
const entry = idOf(output["entry"]);
|
|
3530
|
+
if (entry !== null) return {
|
|
3531
|
+
type: "entry",
|
|
3532
|
+
id: entry
|
|
3533
|
+
};
|
|
3534
|
+
const review = idOf(output["review"]);
|
|
3535
|
+
if (review !== null) return {
|
|
3536
|
+
type: "review",
|
|
3537
|
+
id: review
|
|
3538
|
+
};
|
|
3539
|
+
const comment = idOf(output["comment"]);
|
|
3540
|
+
if (comment !== null) return {
|
|
3541
|
+
type: "comment",
|
|
3542
|
+
id: comment
|
|
3543
|
+
};
|
|
3544
|
+
return {
|
|
3545
|
+
type: "site",
|
|
3546
|
+
id: site
|
|
3547
|
+
};
|
|
3548
|
+
}
|
|
3549
|
+
/** `denied` when a scope was missing; `failed` for any other error the caller got. */
|
|
3550
|
+
function outcomeOfError(code) {
|
|
3551
|
+
return code === "insufficient_scope" ? "denied" : "failed";
|
|
3552
|
+
}
|
|
3553
|
+
/**
|
|
3554
|
+
* The line of a call, or `null` for a read. `result` is the output the caller got, or the code of
|
|
3555
|
+
* the error it got instead.
|
|
3556
|
+
*/
|
|
3557
|
+
function operationLine(spec, input, site, result) {
|
|
3558
|
+
if (!isAudited(spec.name)) return null;
|
|
3559
|
+
const named = {};
|
|
3560
|
+
const given = record(input);
|
|
3561
|
+
for (const field of NAMING_FIELDS) {
|
|
3562
|
+
const value = given[field];
|
|
3563
|
+
if (typeof value === "string") named[field] = value.slice(0, FIELD_MAX);
|
|
3564
|
+
}
|
|
3565
|
+
if ("errorCode" in result) return {
|
|
3566
|
+
action: spec.name,
|
|
3567
|
+
outcome: outcomeOfError(result.errorCode),
|
|
3568
|
+
errorCode: result.errorCode,
|
|
3569
|
+
target: {
|
|
3570
|
+
type: "site",
|
|
3571
|
+
id: site
|
|
3572
|
+
},
|
|
3573
|
+
detail: Object.keys(named).length === 0 ? null : { input: named }
|
|
3574
|
+
};
|
|
3575
|
+
const output = record(result.output);
|
|
3576
|
+
const answer = output["outcome"];
|
|
3577
|
+
return {
|
|
3578
|
+
action: spec.name,
|
|
3579
|
+
outcome: "ok",
|
|
3580
|
+
target: targetOf(output, site),
|
|
3581
|
+
detail: {
|
|
3582
|
+
...typeof answer === "string" ? { answer } : {},
|
|
3583
|
+
...Object.keys(named).length === 0 ? {} : { input: named }
|
|
3584
|
+
}
|
|
3585
|
+
};
|
|
3586
|
+
}
|
|
3587
|
+
//#endregion
|
|
2560
3588
|
//#region src/semantic/cloudflare.ts
|
|
2561
3589
|
/** The embedding model when the config names none: multilingual, so Spanish finds English. */
|
|
2562
3590
|
const DEFAULT_EMBEDDING_MODEL = "@cf/baai/bge-m3";
|
|
@@ -2760,6 +3788,8 @@ function invoke(handlers, spec, input, context) {
|
|
|
2760
3788
|
}
|
|
2761
3789
|
//#endregion
|
|
2762
3790
|
//#region src/http/ops-api.ts
|
|
3791
|
+
/** The client of every call of this API: the admin. */
|
|
3792
|
+
const CLIENT = { name: "hollware-admin" };
|
|
2763
3793
|
const STATUS$1 = {
|
|
2764
3794
|
invalid_input: 400,
|
|
2765
3795
|
unknown_collection: 404,
|
|
@@ -2782,8 +3812,15 @@ function installOpsApi(app, options) {
|
|
|
2782
3812
|
message: `There is no operation ${c.req.param("name")}.`,
|
|
2783
3813
|
fix: "Use the name of an operation, as tools/list of MCP lists them."
|
|
2784
3814
|
}, 404);
|
|
3815
|
+
const site = options.sites.get(principal.site);
|
|
3816
|
+
if (site === void 0) throw new Error(`No site ${principal.site} for a checked token`);
|
|
3817
|
+
const audit = options.audit === void 0 ? null : (await options.audit(c, principal))(CLIENT);
|
|
2785
3818
|
const scopes = spec.scopes;
|
|
2786
|
-
if (missingScopes(principal, scopes).length > 0)
|
|
3819
|
+
if (missingScopes(principal, scopes).length > 0) {
|
|
3820
|
+
const line = operationLine(spec, null, site.key, { errorCode: "insufficient_scope" });
|
|
3821
|
+
if (line !== null) await audit?.write(site, line);
|
|
3822
|
+
return insufficientScope(scopes, options.metadataUrl(c));
|
|
3823
|
+
}
|
|
2787
3824
|
const body = await c.req.json().catch(() => void 0);
|
|
2788
3825
|
const parsed = spec.input.safeParse(body ?? {});
|
|
2789
3826
|
if (!parsed.success) {
|
|
@@ -2794,24 +3831,30 @@ function installOpsApi(app, options) {
|
|
|
2794
3831
|
fix: "Send the input of the operation as its input schema describes it."
|
|
2795
3832
|
}, 400);
|
|
2796
3833
|
}
|
|
2797
|
-
const site = options.sites.get(principal.site);
|
|
2798
|
-
if (site === void 0) throw new Error(`No site ${principal.site} for a checked token`);
|
|
2799
3834
|
const context = {
|
|
2800
3835
|
site,
|
|
2801
3836
|
principal,
|
|
2802
3837
|
view: "head",
|
|
2803
3838
|
store: createD1Store(c.env.DB, { clock: options.clock }),
|
|
2804
3839
|
clock: options.clock,
|
|
2805
|
-
client:
|
|
3840
|
+
client: CLIENT,
|
|
2806
3841
|
requestId,
|
|
2807
3842
|
notify: options.notify(c, requestId, site.key),
|
|
2808
3843
|
semantic: semanticOfEnv(c.env),
|
|
2809
3844
|
analytics: analyticsReaderOf(c.env),
|
|
2810
3845
|
...options.structure === void 0 ? {} : { structure: options.structure(c.env.DB, c.env.HOLLWARE_SECRET) }
|
|
2811
3846
|
};
|
|
3847
|
+
/** The line of the call, after it answered or failed. */
|
|
3848
|
+
const audited = async (result) => {
|
|
3849
|
+
const line = operationLine(spec, parsed.data, site.key, result);
|
|
3850
|
+
if (line !== null) await audit?.write(site, line);
|
|
3851
|
+
};
|
|
2812
3852
|
try {
|
|
2813
|
-
|
|
3853
|
+
const output = spec.output.parse(await invoke(options.handlers, spec, parsed.data, context));
|
|
3854
|
+
await audited({ output });
|
|
3855
|
+
return json(output);
|
|
2814
3856
|
} catch (error) {
|
|
3857
|
+
await audited({ errorCode: error instanceof HollwareError ? error.code : "internal" });
|
|
2815
3858
|
if (error instanceof HollwareError) return json({
|
|
2816
3859
|
code: error.code,
|
|
2817
3860
|
message: error.message,
|
|
@@ -2844,6 +3887,7 @@ function crossOriginOf(c) {
|
|
|
2844
3887
|
}
|
|
2845
3888
|
const STATUS = {
|
|
2846
3889
|
invalid_input: 400,
|
|
3890
|
+
invalid_preview: 400,
|
|
2847
3891
|
unknown_collection: 404,
|
|
2848
3892
|
unknown_locale: 404
|
|
2849
3893
|
};
|
|
@@ -2868,7 +3912,7 @@ function readRoute(handler) {
|
|
|
2868
3912
|
code: error.code,
|
|
2869
3913
|
message: error.message,
|
|
2870
3914
|
fix: error.fix
|
|
2871
|
-
}, STATUS[error.code] ?? 500);
|
|
3915
|
+
}, STATUS[error.code] ?? 500, c.req.query("preview") === void 0 ? {} : { "cache-control": "no-store" });
|
|
2872
3916
|
}
|
|
2873
3917
|
};
|
|
2874
3918
|
}
|
|
@@ -2882,9 +3926,16 @@ function matches(header, etag) {
|
|
|
2882
3926
|
if (header === void 0) return false;
|
|
2883
3927
|
return header.split(",").map((tag) => tag.trim()).some((tag) => tag === "*" || tag === etag || `W/${tag}` === etag);
|
|
2884
3928
|
}
|
|
2885
|
-
async function answer(c, value) {
|
|
3929
|
+
async function answer(c, value, { preview = false } = {}) {
|
|
2886
3930
|
const notFound = typeof value === "object" && value !== null && "outcome" in value && value.outcome === "not_found";
|
|
2887
3931
|
const body = JSON.stringify(value);
|
|
3932
|
+
if (preview) return new Response(body, {
|
|
3933
|
+
status: notFound ? 404 : 200,
|
|
3934
|
+
headers: {
|
|
3935
|
+
"cache-control": "no-store",
|
|
3936
|
+
"content-type": "application/json"
|
|
3937
|
+
}
|
|
3938
|
+
});
|
|
2888
3939
|
const etag = await etagOf(body);
|
|
2889
3940
|
const headers = {
|
|
2890
3941
|
etag,
|
|
@@ -2973,11 +4024,15 @@ function installReadApi(app, options) {
|
|
|
2973
4024
|
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")))));
|
|
2974
4025
|
app.get("/v1/:collection/:locale/:slug", readRoute(async (c) => {
|
|
2975
4026
|
const formats = c.req.query("formats");
|
|
2976
|
-
const
|
|
2977
|
-
|
|
2978
|
-
...related,
|
|
4027
|
+
const preview = c.req.query("preview");
|
|
4028
|
+
const base = {
|
|
4029
|
+
...c.req.query("related") === "true" ? { related: true } : {},
|
|
4030
|
+
...preview === void 0 ? {} : { preview }
|
|
4031
|
+
};
|
|
4032
|
+
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 ? base : {
|
|
4033
|
+
...base,
|
|
2979
4034
|
formats: formats.split(",").flatMap((format) => format === "markdown" || format === "document" || format === "html" ? [format] : [])
|
|
2980
|
-
}));
|
|
4035
|
+
}), { preview: preview !== void 0 });
|
|
2981
4036
|
}));
|
|
2982
4037
|
}
|
|
2983
4038
|
const SEARCH_MODES = [
|
|
@@ -3031,235 +4086,71 @@ function openApiDocument() {
|
|
|
3031
4086
|
"/v1/search": { get: {
|
|
3032
4087
|
operationId: "search",
|
|
3033
4088
|
parameters: [
|
|
3034
|
-
param("q", "query", true),
|
|
3035
|
-
param("collection", "query", false),
|
|
3036
|
-
param("locale", "query", false),
|
|
3037
|
-
param("limit", "query", false),
|
|
3038
|
-
param("mode", "query", false)
|
|
3039
|
-
],
|
|
3040
|
-
responses: {
|
|
3041
|
-
"200": ok("The published entries that match q, best first"),
|
|
3042
|
-
...common
|
|
3043
|
-
}
|
|
3044
|
-
} },
|
|
3045
|
-
"/v1/{collection}": { get: {
|
|
3046
|
-
operationId: "list_entries",
|
|
3047
|
-
parameters: [
|
|
3048
|
-
param("collection", "path", true),
|
|
3049
|
-
param("locale", "query", false),
|
|
3050
|
-
param("tag", "query", false),
|
|
3051
|
-
param("limit", "query", false),
|
|
3052
|
-
param("cursor", "query", false)
|
|
3053
|
-
],
|
|
3054
|
-
responses: {
|
|
3055
|
-
"200": ok("A page of published entries"),
|
|
3056
|
-
...common
|
|
3057
|
-
}
|
|
3058
|
-
} },
|
|
3059
|
-
"/v1/{collection}/translations/{group}": { get: {
|
|
3060
|
-
operationId: "get_translations",
|
|
3061
|
-
parameters: [param("collection", "path", true), param("group", "path", true)],
|
|
3062
|
-
responses: {
|
|
3063
|
-
"200": ok("The published entries of the group"),
|
|
3064
|
-
"404": { description: "No such group" },
|
|
3065
|
-
...common
|
|
3066
|
-
}
|
|
3067
|
-
} },
|
|
3068
|
-
"/v1/{collection}/{locale}/{slug}": { get: {
|
|
3069
|
-
operationId: "get_entry",
|
|
3070
|
-
parameters: [
|
|
3071
|
-
param("collection", "path", true),
|
|
3072
|
-
param("locale", "path", true),
|
|
3073
|
-
param("slug", "path", true),
|
|
3074
|
-
param("formats", "query", false),
|
|
3075
|
-
param("related", "query", false)
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
"
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
}
|
|
3086
|
-
|
|
3087
|
-
//#region src/identity/pages.ts
|
|
3088
|
-
/**
|
|
3089
|
-
* The pages a person sees to get in (ADR-026): set up a passkey from a link, and sign in. They
|
|
3090
|
-
* are rendered by the Worker, outside the admin's bundle, with one inline script under a nonce
|
|
3091
|
-
* and nothing framing them. The script uses the WebAuthn JSON methods of the browser
|
|
3092
|
-
* (`PublicKeyCredential.parseCreationOptionsFromJSON`, `credential.toJSON()`), and reads the
|
|
3093
|
-
* invitation from the address bar, so the page never prints it.
|
|
3094
|
-
*/
|
|
3095
|
-
/**
|
|
3096
|
-
* The look of hollware.com and the admin, with nothing to fetch: the page's policy loads no font,
|
|
3097
|
-
* so the type is the system's. Dark only, the neutral greys of the theme, white as the primary
|
|
3098
|
-
* color and emerald for the mark (tests/conventions/brand.test.ts).
|
|
3099
|
-
*/
|
|
3100
|
-
const STYLE = `:root{color-scheme:dark;--bg:#0a0a0a;--card:#171717;--fg:#f5f5f5;--muted:#a3a3a3;--line:rgb(255 255 255/.1);--accent:#fafafa;--on-accent:#0a0a0a;--mark:#34d399;--danger:#ff8a80}
|
|
3101
|
-
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:2rem 1rem;background:var(--bg);color:var(--fg);font:15px/1.6 system-ui,-apple-system,'Segoe UI',sans-serif;-webkit-font-smoothing:antialiased}
|
|
3102
|
-
main{width:100%;max-width:26rem;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:2rem}
|
|
3103
|
-
.brand{display:flex;align-items:center;gap:.6rem;margin-bottom:1.75rem;font-weight:600;letter-spacing:-.01em}.brand svg{width:26px;height:26px}
|
|
3104
|
-
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)}
|
|
3105
|
-
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}
|
|
3106
|
-
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}
|
|
3107
|
-
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}
|
|
3108
|
-
code{font:.82em ui-monospace,'SF Mono',Menlo,monospace;color:var(--muted)}
|
|
3109
|
-
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)}
|
|
3110
|
-
button:not([value=deny]){background:var(--accent);border-color:var(--accent);color:var(--on-accent)}
|
|
3111
|
-
[role=alert]{color:var(--danger)}`;
|
|
3112
|
-
/** The mark of Hollware, before the title of every page. */
|
|
3113
|
-
const BRAND = `<div class="brand"><svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="8" fill="currentColor" opacity=".08"/><path d="M9 8v16M23 8v16M9 16h14" stroke="var(--mark)" stroke-width="3" stroke-linecap="round" fill="none"/></svg>Hollware</div>`;
|
|
3114
|
-
const POST = `async function post(path, body) {
|
|
3115
|
-
const response = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
3116
|
-
const data = await response.json()
|
|
3117
|
-
if (!response.ok) throw new Error(data.message + ' ' + (data.fix ?? ''))
|
|
3118
|
-
return data
|
|
3119
|
-
}
|
|
3120
|
-
function fail(error) { document.getElementById('error').textContent = error.message }`;
|
|
3121
|
-
function page(title, nonce, body, script) {
|
|
3122
|
-
return `<!doctype html>
|
|
3123
|
-
<html lang="en">
|
|
3124
|
-
<head>
|
|
3125
|
-
<meta charset="utf-8">
|
|
3126
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
3127
|
-
<title>${title} · Hollware</title>
|
|
3128
|
-
<style>${STYLE}</style>
|
|
3129
|
-
</head>
|
|
3130
|
-
<body>
|
|
3131
|
-
<main>
|
|
3132
|
-
${BRAND}
|
|
3133
|
-
<h1>${title}</h1>
|
|
3134
|
-
${body}
|
|
3135
|
-
<p id="error" role="alert"></p>
|
|
3136
|
-
</main>
|
|
3137
|
-
<script nonce="${nonce}">${POST}
|
|
3138
|
-
${script}<\/script>
|
|
3139
|
-
</body>
|
|
3140
|
-
</html>
|
|
3141
|
-
`;
|
|
3142
|
-
}
|
|
3143
|
-
function setupPage(nonce) {
|
|
3144
|
-
return page("Set up your passkey", nonce, `<p>A passkey lets you into this Hollware with your device's lock: no password.</p>
|
|
3145
|
-
<form id="form">
|
|
3146
|
-
<label for="name">Your name</label><input id="name" name="name" autocomplete="name">
|
|
3147
|
-
<label for="label">This device</label><input id="label" name="label" value="Passkey">
|
|
3148
|
-
<button type="submit">Create the passkey</button>
|
|
3149
|
-
</form>`, `document.getElementById('form').addEventListener('submit', async (event) => {
|
|
3150
|
-
event.preventDefault()
|
|
3151
|
-
try {
|
|
3152
|
-
const invitation = new URLSearchParams(location.search).get('invitation') ?? ''
|
|
3153
|
-
const { challengeId, options } = await post('/auth/register/options', { invitation })
|
|
3154
|
-
const credential = await navigator.credentials.create({ publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options) })
|
|
3155
|
-
await post('/auth/register/verify', { challengeId, response: credential.toJSON(), name: document.getElementById('name').value, label: document.getElementById('label').value })
|
|
3156
|
-
location.href = '/admin/'
|
|
3157
|
-
} catch (error) { fail(error) }
|
|
3158
|
-
})`);
|
|
3159
|
-
}
|
|
3160
|
-
function signInPage(nonce) {
|
|
3161
|
-
return page("Sign in", nonce, `<p>Sign in to this Hollware with your passkey.</p>
|
|
3162
|
-
<button id="signin" type="button">Sign in with a passkey</button>`, `document.getElementById('signin').addEventListener('click', async () => {
|
|
3163
|
-
try {
|
|
3164
|
-
const { challengeId, options } = await post('/auth/signin/options', {})
|
|
3165
|
-
const credential = await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options) })
|
|
3166
|
-
await post('/auth/signin/verify', { challengeId, response: credential.toJSON() })
|
|
3167
|
-
const target = new URLSearchParams(location.search).get('return_to') ?? '/admin/'
|
|
3168
|
-
location.href = target.startsWith('/') && !target.startsWith('//') ? target : '/admin/'
|
|
3169
|
-
} catch (error) { fail(error) }
|
|
3170
|
-
})`);
|
|
3171
|
-
}
|
|
3172
|
-
/** The headers of a page: no framing, only its own script, and no caching. */
|
|
3173
|
-
function pageHeaders(nonce) {
|
|
3174
|
-
return {
|
|
3175
|
-
"content-type": "text/html; charset=utf-8",
|
|
3176
|
-
"cache-control": "no-store",
|
|
3177
|
-
"content-security-policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; connect-src 'self'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'`,
|
|
3178
|
-
"x-content-type-options": "nosniff",
|
|
3179
|
-
"referrer-policy": "no-referrer"
|
|
3180
|
-
};
|
|
3181
|
-
}
|
|
3182
|
-
/** Text for HTML: every string a client chose (its name, its redirect host, a scope) goes through it. */
|
|
3183
|
-
function escapeHtml(value) {
|
|
3184
|
-
return value.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
|
|
3185
|
-
}
|
|
3186
|
-
/** What a scope lets a client do, in the words of the consent page. */
|
|
3187
|
-
const SCOPE_WORDS = {
|
|
3188
|
-
"content:read": "Read the content, drafts included",
|
|
3189
|
-
"content:write": "Write drafts",
|
|
3190
|
-
"content:publish": "Publish and unpublish",
|
|
3191
|
-
"schema:admin": "Change the site settings"
|
|
3192
|
-
};
|
|
3193
|
-
/**
|
|
3194
|
-
* The consent page of `/authorize` (ADR-026): which client asks, where the tokens go, and what it
|
|
3195
|
-
* may do, with Allow and Deny. It has no script, and it posts the handle that `beginConsent` bound
|
|
3196
|
-
* to this browser. Everything the client chose is escaped.
|
|
3197
|
-
*/
|
|
3198
|
-
function consentPage(view) {
|
|
3199
|
-
const name = escapeHtml(view.clientName);
|
|
3200
|
-
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>`;
|
|
3201
|
-
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>" : "";
|
|
3202
|
-
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>`;
|
|
3203
|
-
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");
|
|
3204
|
-
return `<!doctype html>
|
|
3205
|
-
<html lang="en">
|
|
3206
|
-
<head>
|
|
3207
|
-
<meta charset="utf-8">
|
|
3208
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
3209
|
-
<title>Allow ${name} · Hollware</title>
|
|
3210
|
-
<style>${STYLE}</style>
|
|
3211
|
-
</head>
|
|
3212
|
-
<body>
|
|
3213
|
-
<main>
|
|
3214
|
-
${BRAND}
|
|
3215
|
-
<h1>Allow ${name} to use this Hollware?</h1>
|
|
3216
|
-
${origin}
|
|
3217
|
-
<p>Signed in as <strong>${escapeHtml(view.person)}</strong>. Access goes to <strong>${escapeHtml(view.redirectHost)}</strong>.</p>
|
|
3218
|
-
${loopback}
|
|
3219
|
-
<form method="post" action="/authorize">
|
|
3220
|
-
<input type="hidden" name="handle" value="${escapeHtml(view.handle)}">
|
|
3221
|
-
${sites}
|
|
3222
|
-
${scopes}
|
|
3223
|
-
<p><button name="decision" value="approve">Allow</button> <button name="decision" value="deny">Deny</button></p>
|
|
3224
|
-
</form>
|
|
3225
|
-
</main>
|
|
3226
|
-
</body>
|
|
3227
|
-
</html>
|
|
3228
|
-
`;
|
|
3229
|
-
}
|
|
3230
|
-
/** A page that only says what went wrong and what to do. */
|
|
3231
|
-
function messagePage(title, message) {
|
|
3232
|
-
return `<!doctype html>
|
|
3233
|
-
<html lang="en">
|
|
3234
|
-
<head>
|
|
3235
|
-
<meta charset="utf-8">
|
|
3236
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
3237
|
-
<title>${escapeHtml(title)} · Hollware</title>
|
|
3238
|
-
<style>${STYLE}</style>
|
|
3239
|
-
</head>
|
|
3240
|
-
<body>
|
|
3241
|
-
<main>
|
|
3242
|
-
${BRAND}
|
|
3243
|
-
<h1>${escapeHtml(title)}</h1>
|
|
3244
|
-
<p>${escapeHtml(message)}</p>
|
|
3245
|
-
</main>
|
|
3246
|
-
</body>
|
|
3247
|
-
</html>
|
|
3248
|
-
`;
|
|
4089
|
+
param("q", "query", true),
|
|
4090
|
+
param("collection", "query", false),
|
|
4091
|
+
param("locale", "query", false),
|
|
4092
|
+
param("limit", "query", false),
|
|
4093
|
+
param("mode", "query", false)
|
|
4094
|
+
],
|
|
4095
|
+
responses: {
|
|
4096
|
+
"200": ok("The published entries that match q, best first"),
|
|
4097
|
+
...common
|
|
4098
|
+
}
|
|
4099
|
+
} },
|
|
4100
|
+
"/v1/{collection}": { get: {
|
|
4101
|
+
operationId: "list_entries",
|
|
4102
|
+
parameters: [
|
|
4103
|
+
param("collection", "path", true),
|
|
4104
|
+
param("locale", "query", false),
|
|
4105
|
+
param("tag", "query", false),
|
|
4106
|
+
param("limit", "query", false),
|
|
4107
|
+
param("cursor", "query", false)
|
|
4108
|
+
],
|
|
4109
|
+
responses: {
|
|
4110
|
+
"200": ok("A page of published entries"),
|
|
4111
|
+
...common
|
|
4112
|
+
}
|
|
4113
|
+
} },
|
|
4114
|
+
"/v1/{collection}/translations/{group}": { get: {
|
|
4115
|
+
operationId: "get_translations",
|
|
4116
|
+
parameters: [param("collection", "path", true), param("group", "path", true)],
|
|
4117
|
+
responses: {
|
|
4118
|
+
"200": ok("The published entries of the group"),
|
|
4119
|
+
"404": { description: "No such group" },
|
|
4120
|
+
...common
|
|
4121
|
+
}
|
|
4122
|
+
} },
|
|
4123
|
+
"/v1/{collection}/{locale}/{slug}": { get: {
|
|
4124
|
+
operationId: "get_entry",
|
|
4125
|
+
parameters: [
|
|
4126
|
+
param("collection", "path", true),
|
|
4127
|
+
param("locale", "path", true),
|
|
4128
|
+
param("slug", "path", true),
|
|
4129
|
+
param("formats", "query", false),
|
|
4130
|
+
param("related", "query", false),
|
|
4131
|
+
param("preview", "query", false)
|
|
4132
|
+
],
|
|
4133
|
+
responses: {
|
|
4134
|
+
"200": ok("A published entry; with a valid preview token, its latest revision, uncacheable and with no ETag"),
|
|
4135
|
+
"400": { description: "The preview token is expired, forged or for another entry" },
|
|
4136
|
+
"404": { description: "No such published entry" },
|
|
4137
|
+
...common
|
|
4138
|
+
}
|
|
4139
|
+
} }
|
|
4140
|
+
}
|
|
4141
|
+
};
|
|
3249
4142
|
}
|
|
4143
|
+
//#endregion
|
|
4144
|
+
//#region src/audit/person.ts
|
|
3250
4145
|
/**
|
|
3251
|
-
*
|
|
3252
|
-
*
|
|
3253
|
-
* browser applies it to the redirect too.
|
|
4146
|
+
* A line about a person's own sign-in, in each site they are a member of (ADR-037): a person is of
|
|
4147
|
+
* the deployment, and each owner sees what touched the people of their site.
|
|
3254
4148
|
*/
|
|
3255
|
-
function
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
"x-content-type-options": "nosniff",
|
|
3261
|
-
"referrer-policy": "no-referrer"
|
|
3262
|
-
};
|
|
4149
|
+
async function writeForPerson(db, log, siteOf, userId, line) {
|
|
4150
|
+
for (const membership of await membershipsOf(connect(db), userId)) {
|
|
4151
|
+
const site = await siteOf(membership.site);
|
|
4152
|
+
if (site !== void 0) await log.write(site, line);
|
|
4153
|
+
}
|
|
3263
4154
|
}
|
|
3264
4155
|
//#endregion
|
|
3265
4156
|
//#region src/identity/routes.ts
|
|
@@ -3267,17 +4158,25 @@ function plainPageHeaders() {
|
|
|
3267
4158
|
* The ceremonies, with SimpleWebAuthn and its parsers, in a chunk of their own: only these four
|
|
3268
4159
|
* routes use them, and a Worker that serves MCP never pays for them at startup (`budgets.json`).
|
|
3269
4160
|
*/
|
|
3270
|
-
const ceremonies = () => import("./ceremonies-
|
|
3271
|
-
/**
|
|
4161
|
+
const ceremonies = () => import("./ceremonies-CI3DkOEL.mjs");
|
|
4162
|
+
/**
|
|
4163
|
+
* The cookie of a signed-in person: only `/authorize` reads it (ADR-026), and the sign-out below
|
|
4164
|
+
* it, `/authorize/signout`, which the path of the cookie reaches too.
|
|
4165
|
+
*/
|
|
3272
4166
|
const SESSION_COOKIE = "hollware_session";
|
|
4167
|
+
/** Where sign-out is: below `/authorize`, the one path a browser sends the cookie to. */
|
|
4168
|
+
const SIGN_OUT_PATH = "/authorize/signout";
|
|
3273
4169
|
function sessionCookie(secret) {
|
|
3274
4170
|
return `${SESSION_COOKIE}=${secret}; HttpOnly; Secure; SameSite=Lax; Path=/authorize; Max-Age=43200`;
|
|
3275
4171
|
}
|
|
4172
|
+
/** The cookie as sign-out leaves it: empty, and gone at once. */
|
|
4173
|
+
const ENDED_COOKIE = `${SESSION_COOKIE}=; HttpOnly; Secure; SameSite=Lax; Path=/authorize; Max-Age=0`;
|
|
3276
4174
|
function isRecord$1(value) {
|
|
3277
4175
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3278
4176
|
}
|
|
3279
|
-
/** The setup and sign-in pages, and the four steps of the passkey ceremonies. */
|
|
3280
4177
|
function installIdentity(app, options) {
|
|
4178
|
+
/** A line of the person's own sign-in, in each of their sites. */
|
|
4179
|
+
const audited = (c, userId, line) => writeForPerson(c.env.DB, options.audit(c, personActor(userId)), (key) => options.site(c, key), userId, line);
|
|
3281
4180
|
const ceremony = (c) => ({
|
|
3282
4181
|
db: connect(c.env.DB),
|
|
3283
4182
|
publicUrl: c.env.HOLLWARE_PUBLIC_URL,
|
|
@@ -3318,6 +4217,14 @@ function installIdentity(app, options) {
|
|
|
3318
4217
|
name: text(input["name"]),
|
|
3319
4218
|
label: text(input["label"])
|
|
3320
4219
|
});
|
|
4220
|
+
await audited(c, answer.userId, {
|
|
4221
|
+
action: "invitation.accept",
|
|
4222
|
+
outcome: "ok",
|
|
4223
|
+
target: {
|
|
4224
|
+
type: "user",
|
|
4225
|
+
id: answer.userId
|
|
4226
|
+
}
|
|
4227
|
+
});
|
|
3321
4228
|
return c.json({ ok: true }, 200, { "set-cookie": sessionCookie(answer.session) });
|
|
3322
4229
|
}));
|
|
3323
4230
|
app.post("/auth/signin/options", handle(async (c) => c.json(await (await ceremonies()).authenticationOptions(ceremony(c)))));
|
|
@@ -3329,8 +4236,32 @@ function installIdentity(app, options) {
|
|
|
3329
4236
|
challengeId: text(input["challengeId"]),
|
|
3330
4237
|
response: JSON.parse(JSON.stringify(response))
|
|
3331
4238
|
});
|
|
4239
|
+
await audited(c, answer.userId, {
|
|
4240
|
+
action: "session.sign_in",
|
|
4241
|
+
outcome: "ok",
|
|
4242
|
+
target: {
|
|
4243
|
+
type: "user",
|
|
4244
|
+
id: answer.userId
|
|
4245
|
+
}
|
|
4246
|
+
});
|
|
3332
4247
|
return c.json({ ok: true }, 200, { "set-cookie": sessionCookie(answer.session) });
|
|
3333
4248
|
}));
|
|
4249
|
+
app.post(SIGN_OUT_PATH, async (c) => {
|
|
4250
|
+
const secret = getCookie(c, SESSION_COOKIE);
|
|
4251
|
+
const userId = secret === void 0 ? null : await endSession(connect(c.env.DB), secret);
|
|
4252
|
+
if (userId !== null) await audited(c, userId, {
|
|
4253
|
+
action: "session.sign_out",
|
|
4254
|
+
outcome: "ok",
|
|
4255
|
+
target: {
|
|
4256
|
+
type: "user",
|
|
4257
|
+
id: userId
|
|
4258
|
+
}
|
|
4259
|
+
});
|
|
4260
|
+
return c.json({ ok: true }, 200, {
|
|
4261
|
+
"set-cookie": ENDED_COOKIE,
|
|
4262
|
+
"cache-control": "no-store"
|
|
4263
|
+
});
|
|
4264
|
+
});
|
|
3334
4265
|
}
|
|
3335
4266
|
//#endregion
|
|
3336
4267
|
//#region src/oauth/authorize.ts
|
|
@@ -3501,6 +4432,16 @@ function installAuthorize(app, options) {
|
|
|
3501
4432
|
scope,
|
|
3502
4433
|
props
|
|
3503
4434
|
});
|
|
4435
|
+
const site = await options.site?.(c, membership.site);
|
|
4436
|
+
if (site !== void 0 && options.audit !== void 0) await options.audit(c, personActor(signedIn.userId)).write(site, {
|
|
4437
|
+
action: "app.allow",
|
|
4438
|
+
outcome: "ok",
|
|
4439
|
+
target: {
|
|
4440
|
+
type: "client",
|
|
4441
|
+
id: approved.request.clientId
|
|
4442
|
+
},
|
|
4443
|
+
detail: { scopes: scope }
|
|
4444
|
+
});
|
|
3504
4445
|
approved.headers.set("location", redirectTo);
|
|
3505
4446
|
return new Response(null, {
|
|
3506
4447
|
status: 302,
|
|
@@ -3677,6 +4618,35 @@ function refusal(status, code, message, fix) {
|
|
|
3677
4618
|
headers: { "cache-control": "no-store" }
|
|
3678
4619
|
});
|
|
3679
4620
|
}
|
|
4621
|
+
/**
|
|
4622
|
+
* The text of a body of at most `limit` bytes, or `null` for a longer one: read no further than
|
|
4623
|
+
* the limit, so a large body costs no more memory than a small one (T-08 of the threat model).
|
|
4624
|
+
*/
|
|
4625
|
+
async function boundedText(request, limit) {
|
|
4626
|
+
const declared = Number(request.headers.get("content-length") ?? NaN);
|
|
4627
|
+
if (Number.isFinite(declared) && declared > limit) return null;
|
|
4628
|
+
if (request.body === null) return "";
|
|
4629
|
+
const reader = request.body.getReader();
|
|
4630
|
+
const chunks = [];
|
|
4631
|
+
let size = 0;
|
|
4632
|
+
for (;;) {
|
|
4633
|
+
const { done, value } = await reader.read();
|
|
4634
|
+
if (done) break;
|
|
4635
|
+
size += value.byteLength;
|
|
4636
|
+
if (size > limit) {
|
|
4637
|
+
await reader.cancel();
|
|
4638
|
+
return null;
|
|
4639
|
+
}
|
|
4640
|
+
chunks.push(value);
|
|
4641
|
+
}
|
|
4642
|
+
const bytes = new Uint8Array(size);
|
|
4643
|
+
let offset = 0;
|
|
4644
|
+
for (const chunk of chunks) {
|
|
4645
|
+
bytes.set(chunk, offset);
|
|
4646
|
+
offset += chunk.byteLength;
|
|
4647
|
+
}
|
|
4648
|
+
return new TextDecoder().decode(bytes);
|
|
4649
|
+
}
|
|
3680
4650
|
/** The path of a view without its query and fragment, or `null` when it is no path. */
|
|
3681
4651
|
function pathOf(value) {
|
|
3682
4652
|
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) return null;
|
|
@@ -3692,8 +4662,8 @@ function pathOf(value) {
|
|
|
3692
4662
|
*/
|
|
3693
4663
|
function installEvents(app, options) {
|
|
3694
4664
|
app.post("/events", async (c) => {
|
|
3695
|
-
const text = await c.req.
|
|
3696
|
-
if (
|
|
4665
|
+
const text = await boundedText(c.req.raw, MAX_BEACON_BYTES);
|
|
4666
|
+
if (text === null) 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.");
|
|
3697
4667
|
let body;
|
|
3698
4668
|
try {
|
|
3699
4669
|
body = JSON.parse(text);
|
|
@@ -3701,11 +4671,11 @@ function installEvents(app, options) {
|
|
|
3701
4671
|
body = null;
|
|
3702
4672
|
}
|
|
3703
4673
|
const key = typeof body === "object" && body !== null && "site" in body ? body.site : null;
|
|
3704
|
-
const site = typeof key === "string" ? options.sites.get(key) : void 0;
|
|
3705
4674
|
const path = typeof body === "object" && body !== null && "path" in body ? pathOf(body.path) : null;
|
|
3706
|
-
if (
|
|
4675
|
+
if (typeof key !== "string" || path === null) return refusal(400, "invalid_input", "A beacon is JSON with the key of a site and the path of a page.", "Send { \"site\": \"<site key>\", \"path\": \"/<path of the page>\" }.");
|
|
4676
|
+
const site = options.sites.get(key);
|
|
3707
4677
|
const origin = c.req.header("origin") ?? null;
|
|
3708
|
-
if (origin === null || !pageOriginsOf(site).includes(origin)) return refusal(403, "forbidden_origin",
|
|
4678
|
+
if (site === void 0 || origin === null || !pageOriginsOf(site).includes(origin)) return refusal(403, "forbidden_origin", "This page does not count views of that site.", "Set the url of the site to the origin of its pages, or add the origin to its cors.origins: in the admin, Site, or with update_site.");
|
|
3709
4679
|
const cf = Reflect.get(c.req.raw, "cf");
|
|
3710
4680
|
const country = typeof cf === "object" && cf !== null && "country" in cf && typeof cf.country === "string" ? cf.country : "XX";
|
|
3711
4681
|
analyticsWriterOf(c.env)?.record({
|
|
@@ -3807,11 +4777,20 @@ function createGateway(options) {
|
|
|
3807
4777
|
secrets: { make: async (site, name) => {
|
|
3808
4778
|
if (master === void 0) return null;
|
|
3809
4779
|
const secret = newWebhookSecret();
|
|
3810
|
-
|
|
4780
|
+
const sealed = await seal(master, secret, {
|
|
4781
|
+
site,
|
|
4782
|
+
name
|
|
4783
|
+
});
|
|
4784
|
+
await createSecretStore(db, () => clock.now()).write(site, name, sealed);
|
|
3811
4785
|
return secret;
|
|
3812
4786
|
} }
|
|
3813
4787
|
});
|
|
3814
4788
|
/**
|
|
4789
|
+
* The activity log of a request (ADR-037), for an actor known once the client has said what it
|
|
4790
|
+
* is. A person in the admin is a `user`; any other OAuth client, an `oauth_client`.
|
|
4791
|
+
*/
|
|
4792
|
+
const auditOf = (c, principal, adminClientId) => (meta) => auditAs(c, actorOf$1(principal, meta, adminClientId));
|
|
4793
|
+
/**
|
|
3815
4794
|
* A protected route: the resource server of `resource` answers a request without a token and a
|
|
3816
4795
|
* token it refuses (401, logged with the reason), and `proceed` gets the principal of the rest.
|
|
3817
4796
|
*/
|
|
@@ -3879,6 +4858,16 @@ function createGateway(options) {
|
|
|
3879
4858
|
app.use("*", async (c, next) => {
|
|
3880
4859
|
c.set("requestId", crypto.randomUUID());
|
|
3881
4860
|
await next();
|
|
4861
|
+
const https = c.env.HOLLWARE_PUBLIC_URL?.startsWith("https://") === true;
|
|
4862
|
+
if (!(!c.res.headers.has("x-content-type-options") || https && !c.res.headers.has("strict-transport-security"))) return;
|
|
4863
|
+
const headers = new Headers(c.res.headers);
|
|
4864
|
+
if (!headers.has("x-content-type-options")) headers.set("x-content-type-options", "nosniff");
|
|
4865
|
+
if (https && !headers.has("strict-transport-security")) headers.set("strict-transport-security", "max-age=31536000");
|
|
4866
|
+
c.res = new Response(c.res.body, {
|
|
4867
|
+
status: c.res.status,
|
|
4868
|
+
statusText: c.res.statusText,
|
|
4869
|
+
headers
|
|
4870
|
+
});
|
|
3882
4871
|
});
|
|
3883
4872
|
app.use("*", async (c, next) => {
|
|
3884
4873
|
const origin = c.req.header("origin");
|
|
@@ -3887,6 +4876,30 @@ function createGateway(options) {
|
|
|
3887
4876
|
if (refusal !== null) return refusal;
|
|
3888
4877
|
await next();
|
|
3889
4878
|
});
|
|
4879
|
+
app.use("*", async (c, next) => {
|
|
4880
|
+
const limiter = c.env.HOLLWARE_RATE_LIMIT;
|
|
4881
|
+
const group = limitedRoute(c.req.method, new URL(c.req.url).pathname);
|
|
4882
|
+
if (limiter === void 0 || group === null) return next();
|
|
4883
|
+
const address = c.req.header("cf-connecting-ip") ?? "unknown";
|
|
4884
|
+
const { success } = await limiter.limit({ key: `${group}:${address}` });
|
|
4885
|
+
if (success) return next();
|
|
4886
|
+
log({
|
|
4887
|
+
kind: "rate_limited",
|
|
4888
|
+
requestId: c.get("requestId"),
|
|
4889
|
+
route: group
|
|
4890
|
+
});
|
|
4891
|
+
return Response.json({
|
|
4892
|
+
code: "rate_limited",
|
|
4893
|
+
message: "Too many requests from this address to this route.",
|
|
4894
|
+
fix: "Wait a minute and try again."
|
|
4895
|
+
}, {
|
|
4896
|
+
status: 429,
|
|
4897
|
+
headers: {
|
|
4898
|
+
"retry-after": "60",
|
|
4899
|
+
"cache-control": "no-store"
|
|
4900
|
+
}
|
|
4901
|
+
});
|
|
4902
|
+
});
|
|
3890
4903
|
for (const path of [
|
|
3891
4904
|
"/authorize",
|
|
3892
4905
|
"/oauth/*",
|
|
@@ -3913,7 +4926,13 @@ function createGateway(options) {
|
|
|
3913
4926
|
semantic: semanticOfEnv(c.env),
|
|
3914
4927
|
analytics: analyticsReaderOf(c.env),
|
|
3915
4928
|
structure: structureOf(db, c.env.HOLLWARE_SECRET)
|
|
3916
|
-
}
|
|
4929
|
+
},
|
|
4930
|
+
...principal.kind === "read" ? {} : { audit: {
|
|
4931
|
+
log: auditOf(c, principal, null),
|
|
4932
|
+
defer: (work) => {
|
|
4933
|
+
c.executionCtx.waitUntil(work);
|
|
4934
|
+
}
|
|
4935
|
+
} }
|
|
3917
4936
|
});
|
|
3918
4937
|
}));
|
|
3919
4938
|
for (const resource of ["mcp", "v1"]) app.all(`/.well-known/oauth-protected-resource/${resource}`, async (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).protect(resource, c.req.raw, c.env, workerContext(c), {
|
|
@@ -3922,18 +4941,68 @@ function createGateway(options) {
|
|
|
3922
4941
|
defer: () => void 0,
|
|
3923
4942
|
failed: () => void 0
|
|
3924
4943
|
}));
|
|
4944
|
+
/** The activity log of a request for an actor the route knows itself (ADR-037). */
|
|
4945
|
+
const auditAs = (c, actor) => createAuditLog({
|
|
4946
|
+
db: c.env.DB,
|
|
4947
|
+
actor,
|
|
4948
|
+
requestId: c.get("requestId"),
|
|
4949
|
+
address: c.req.header("cf-connecting-ip") ?? null,
|
|
4950
|
+
master: c.env.HOLLWARE_SECRET,
|
|
4951
|
+
now: () => clock.now(),
|
|
4952
|
+
log
|
|
4953
|
+
});
|
|
4954
|
+
/** A site as the activity log needs it, current with D1: a route of no token refreshed nothing. */
|
|
4955
|
+
const auditSite = async (c, key) => {
|
|
4956
|
+
await options.refresh?.(c.env.DB);
|
|
4957
|
+
return sites.get(key);
|
|
4958
|
+
};
|
|
3925
4959
|
installAuthorize(app, {
|
|
3926
4960
|
sites: siteKeys,
|
|
3927
4961
|
clock,
|
|
3928
|
-
oauth
|
|
4962
|
+
oauth,
|
|
4963
|
+
site: auditSite,
|
|
4964
|
+
audit: auditAs
|
|
3929
4965
|
});
|
|
3930
4966
|
const authorizationServer = (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).authorizationServer.fetch(c.req.raw, c.env, workerContext(c));
|
|
3931
4967
|
app.all("/.well-known/oauth-authorization-server", authorizationServer);
|
|
3932
|
-
app.all("/oauth/*",
|
|
3933
|
-
|
|
4968
|
+
app.all("/oauth/*", async (c) => {
|
|
4969
|
+
const refresh = await refreshTokenOf(c.req.raw);
|
|
4970
|
+
if (refresh === null) return authorizationServer(c);
|
|
4971
|
+
const now = Math.floor(clock.now().getTime() / 1e3);
|
|
4972
|
+
const reused = await spentRefresh(c.env.OAUTH_KV, refresh, now);
|
|
4973
|
+
if (reused !== null) {
|
|
4974
|
+
await oauth(c.env.HOLLWARE_PUBLIC_URL).authorizationServer.getOAuthApi(c.env).revokeGrant(reused.grantId, reused.userId);
|
|
4975
|
+
log({
|
|
4976
|
+
kind: "refresh_reused",
|
|
4977
|
+
requestId: c.get("requestId"),
|
|
4978
|
+
grantId: reused.grantId
|
|
4979
|
+
});
|
|
4980
|
+
const site = reused.site === null ? void 0 : await auditSite(c, reused.site);
|
|
4981
|
+
if (site !== void 0) await auditAs(c, systemActor(reused.userId)).write(site, {
|
|
4982
|
+
action: "app.refresh_reused",
|
|
4983
|
+
outcome: "denied",
|
|
4984
|
+
errorCode: "invalid_grant",
|
|
4985
|
+
target: {
|
|
4986
|
+
type: "grant",
|
|
4987
|
+
id: reused.grantId
|
|
4988
|
+
}
|
|
4989
|
+
});
|
|
4990
|
+
return reusedAnswer();
|
|
4991
|
+
}
|
|
4992
|
+
const response = await authorizationServer(c);
|
|
4993
|
+
if (response.ok) await recordRotation(c.env.OAUTH_KV, refresh.grantId, now);
|
|
4994
|
+
return response;
|
|
4995
|
+
});
|
|
4996
|
+
installIdentity(app, {
|
|
4997
|
+
clock,
|
|
4998
|
+
site: auditSite,
|
|
4999
|
+
audit: auditAs
|
|
5000
|
+
});
|
|
3934
5001
|
installAdmin(app, { oauth });
|
|
3935
5002
|
installAdminApi(app, {
|
|
3936
5003
|
sites: siteKeys,
|
|
5004
|
+
site: (key) => sites.get(key),
|
|
5005
|
+
audit: async (c, principal) => auditOf(c, principal, (await adminClient(oauth(c.env.HOLLWARE_PUBLIC_URL), c.env)).clientId),
|
|
3937
5006
|
clock,
|
|
3938
5007
|
oauth,
|
|
3939
5008
|
protect: (c, proceed) => protectWrites(c, "v1", proceed),
|
|
@@ -3947,7 +5016,8 @@ function createGateway(options) {
|
|
|
3947
5016
|
protect: (c, proceed) => protectWrites(c, "v1", proceed),
|
|
3948
5017
|
metadataUrl: (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1,
|
|
3949
5018
|
notify,
|
|
3950
|
-
structure: structureOf
|
|
5019
|
+
structure: structureOf,
|
|
5020
|
+
audit: async (c, principal) => auditOf(c, principal, (await adminClient(oauth(c.env.HOLLWARE_PUBLIC_URL), c.env)).clientId)
|
|
3951
5021
|
});
|
|
3952
5022
|
if (options.read !== void 0) installReadApi(app, {
|
|
3953
5023
|
read: options.read,
|
|
@@ -3963,6 +5033,14 @@ function createGateway(options) {
|
|
|
3963
5033
|
return await app.fetch(request, env, ctx);
|
|
3964
5034
|
};
|
|
3965
5035
|
}
|
|
5036
|
+
/** The group of a route that is rate limited, or `null` for one that is not (T-10). */
|
|
5037
|
+
function limitedRoute(method, pathname) {
|
|
5038
|
+
if (method !== "POST") return null;
|
|
5039
|
+
if (pathname.startsWith("/auth/")) return "auth";
|
|
5040
|
+
if (pathname === "/oauth/token" || pathname === "/oauth/register") return "oauth";
|
|
5041
|
+
if (pathname === "/events") return "beacon";
|
|
5042
|
+
return null;
|
|
5043
|
+
}
|
|
3966
5044
|
//#endregion
|
|
3967
5045
|
//#region src/mcp/annotations.ts
|
|
3968
5046
|
/**
|
|
@@ -4161,6 +5239,8 @@ const OUTPUT_TOO_LARGE_FIX = {
|
|
|
4161
5239
|
update_draft: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
|
|
4162
5240
|
create_site: "The site was made and holds little: read it with get_structure from a token of that site.",
|
|
4163
5241
|
delete_site: "Nothing to ask for less of: the answer is a count. Try again.",
|
|
5242
|
+
delete_entry: "The operation ran and its change may have been applied: list the trash with list_entries and trashed before you repeat it.",
|
|
5243
|
+
restore_entry: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
|
|
4164
5244
|
make_webhook_secret: "Nothing to ask for less of: the answer is one secret. Try again.",
|
|
4165
5245
|
get_structure: "The structure does not fit in one answer: read the collections with describe_site instead, and change them one at a time with set_collection.",
|
|
4166
5246
|
restore_site: "Nothing to ask for less of: the answer is an outcome. Try again.",
|
|
@@ -4388,6 +5468,7 @@ function createMcpEndpoint(options) {
|
|
|
4388
5468
|
client: declaredClient(envelope)
|
|
4389
5469
|
};
|
|
4390
5470
|
let outcome = null;
|
|
5471
|
+
let answered = null;
|
|
4391
5472
|
let isError = false;
|
|
4392
5473
|
let failure = null;
|
|
4393
5474
|
let result;
|
|
@@ -4395,6 +5476,7 @@ function createMcpEndpoint(options) {
|
|
|
4395
5476
|
const record = (answer) => {
|
|
4396
5477
|
outcome = answer.outcome;
|
|
4397
5478
|
isError = answer.isError;
|
|
5479
|
+
answered = answer.output;
|
|
4398
5480
|
return answer;
|
|
4399
5481
|
};
|
|
4400
5482
|
/** Runs the handler within the time limit and checks its answer. */
|
|
@@ -4427,6 +5509,8 @@ function createMcpEndpoint(options) {
|
|
|
4427
5509
|
isError = true;
|
|
4428
5510
|
result = errorResult(failure);
|
|
4429
5511
|
}
|
|
5512
|
+
const line = operationLine(spec, input, context.site.key, failure === null ? { output: answered } : { errorCode: failure.code });
|
|
5513
|
+
if (line !== null && call.audit !== void 0) await call.audit.log(context.client).write(context.site, line);
|
|
4430
5514
|
emit({
|
|
4431
5515
|
kind: "call",
|
|
4432
5516
|
requestId: call.requestId,
|
|
@@ -4455,16 +5539,20 @@ function createMcpEndpoint(options) {
|
|
|
4455
5539
|
inputSchema: tool.input,
|
|
4456
5540
|
outputSchema: tool.output,
|
|
4457
5541
|
annotations: EFFECT_ANNOTATIONS[tool.spec.effect],
|
|
4458
|
-
scopeChallenge: scopeChallenge(tool, () =>
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
4465
|
-
|
|
4466
|
-
|
|
4467
|
-
|
|
5542
|
+
scopeChallenge: scopeChallenge(tool, () => {
|
|
5543
|
+
emit({
|
|
5544
|
+
kind: "call",
|
|
5545
|
+
requestId: call.requestId,
|
|
5546
|
+
tool: tool.spec.name,
|
|
5547
|
+
outcome: null,
|
|
5548
|
+
errorCode: "insufficient_scope",
|
|
5549
|
+
isError: true,
|
|
5550
|
+
durationMs: 0,
|
|
5551
|
+
detail: null
|
|
5552
|
+
});
|
|
5553
|
+
const line = operationLine(tool.spec, null, call.context.site.key, { errorCode: "insufficient_scope" });
|
|
5554
|
+
if (line !== null && call.audit !== void 0) call.audit.defer(call.audit.log(null).write(call.context.site, line));
|
|
5555
|
+
})
|
|
4468
5556
|
}, (input, mcp) => runTool(tool.spec, input, mcp, call, request.era));
|
|
4469
5557
|
server.registerPrompt(REFRESH_CONTENT.name, {
|
|
4470
5558
|
title: REFRESH_CONTENT.title,
|
|
@@ -4508,6 +5596,27 @@ function unknownSite(site, served) {
|
|
|
4508
5596
|
const keys = [...served].map((key) => `"${key}"`).join(", ");
|
|
4509
5597
|
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.`);
|
|
4510
5598
|
}
|
|
5599
|
+
/** What a caller that presents a preview token that does not hold is told. */
|
|
5600
|
+
function previewRefused(message) {
|
|
5601
|
+
return new HollwareError("invalid_preview", message, "Ask for a new preview link: in the admin, open the entry and choose \"Preview on the site\".");
|
|
5602
|
+
}
|
|
5603
|
+
/**
|
|
5604
|
+
* The entry a preview token names, once its signature, its site and its hour hold: the caller gets
|
|
5605
|
+
* an error, never the published version in its place, for a token that does not. The reason goes
|
|
5606
|
+
* to the log; the caller only learns that the link is not valid.
|
|
5607
|
+
*/
|
|
5608
|
+
async function checkPreview(deps, env, site, token) {
|
|
5609
|
+
if (typeof token !== "string") throw previewRefused("The preview link is not a string.");
|
|
5610
|
+
const checked = await verifyPreviewToken({
|
|
5611
|
+
secret: env.HOLLWARE_SECRET,
|
|
5612
|
+
site,
|
|
5613
|
+
token,
|
|
5614
|
+
now: deps.clock.now()
|
|
5615
|
+
});
|
|
5616
|
+
if (checked.ok) return checked.entryId;
|
|
5617
|
+
if (checked.reason === "no_secret") throw new HollwareError("invalid_input", "This Hollware cannot check preview links: it has no HOLLWARE_SECRET of 32 characters or more.", "Set the Worker secret HOLLWARE_SECRET (`hollware deploy` makes one), then ask for a new preview link.");
|
|
5618
|
+
throw previewRefused(checked.reason === "expired" ? "This preview link has expired." : "This preview link is not valid.");
|
|
5619
|
+
}
|
|
4511
5620
|
/**
|
|
4512
5621
|
* The options object of a call, without the keys that hold `undefined`, so that the handler sees
|
|
4513
5622
|
* the same input as over MCP. A key that is none of `allowed` is an error: the specs are strict
|
|
@@ -4564,7 +5673,7 @@ function forCaller(deps, requestId, method, error) {
|
|
|
4564
5673
|
* `build` makes the raw input, so a wrong site is answered before anything else is looked at and
|
|
4565
5674
|
* a call that fails never opens a session.
|
|
4566
5675
|
*/
|
|
4567
|
-
async function run(deps, env, method, site, schema, build, handler) {
|
|
5676
|
+
async function run(deps, env, method, site, schema, build, handler, viewOf = () => Promise.resolve("published")) {
|
|
4568
5677
|
const requestId = crypto.randomUUID();
|
|
4569
5678
|
try {
|
|
4570
5679
|
await deps.refresh?.(env.DB);
|
|
@@ -4574,10 +5683,16 @@ async function run(deps, env, method, site, schema, build, handler) {
|
|
|
4574
5683
|
const served = deps.sites.get(site);
|
|
4575
5684
|
if (served === void 0) throw unknownSite(site, deps.sites.keys());
|
|
4576
5685
|
const input = parseInput(schema, build());
|
|
5686
|
+
let view;
|
|
5687
|
+
try {
|
|
5688
|
+
view = await viewOf(site);
|
|
5689
|
+
} catch (error) {
|
|
5690
|
+
throw forCaller(deps, requestId, method, error);
|
|
5691
|
+
}
|
|
4577
5692
|
const context = {
|
|
4578
5693
|
site: served,
|
|
4579
5694
|
principal: BINDING_PRINCIPAL,
|
|
4580
|
-
view
|
|
5695
|
+
view,
|
|
4581
5696
|
store: createD1Store(env.DB.withSession("first-unconstrained")),
|
|
4582
5697
|
clock: deps.clock,
|
|
4583
5698
|
client: null,
|
|
@@ -4598,12 +5713,34 @@ async function run(deps, env, method, site, schema, build, handler) {
|
|
|
4598
5713
|
*/
|
|
4599
5714
|
function createRpc(deps, env) {
|
|
4600
5715
|
return {
|
|
4601
|
-
getEntry: (site, collection, slug, locale, options) =>
|
|
4602
|
-
|
|
4603
|
-
|
|
4604
|
-
|
|
4605
|
-
|
|
4606
|
-
|
|
5716
|
+
getEntry: (site, collection, slug, locale, options) => {
|
|
5717
|
+
const read = {
|
|
5718
|
+
preview: void 0,
|
|
5719
|
+
entryId: null
|
|
5720
|
+
};
|
|
5721
|
+
return run(deps, env, "getEntry", site, getEntry.input, () => {
|
|
5722
|
+
const { preview, ...rest } = readOptions(options, [
|
|
5723
|
+
"formats",
|
|
5724
|
+
"related",
|
|
5725
|
+
"preview"
|
|
5726
|
+
]);
|
|
5727
|
+
read.preview = preview;
|
|
5728
|
+
return {
|
|
5729
|
+
collection,
|
|
5730
|
+
locale,
|
|
5731
|
+
slug,
|
|
5732
|
+
...rest
|
|
5733
|
+
};
|
|
5734
|
+
}, async (input, context) => {
|
|
5735
|
+
const answer = await deps.handlers.get_entry(input, context);
|
|
5736
|
+
if (read.entryId !== null && answer.entry !== void 0 && answer.entry.id !== read.entryId) return { outcome: "not_found" };
|
|
5737
|
+
return answer;
|
|
5738
|
+
}, async (key) => {
|
|
5739
|
+
if (read.preview === void 0) return "published";
|
|
5740
|
+
read.entryId = await checkPreview(deps, env, key, read.preview);
|
|
5741
|
+
return "head";
|
|
5742
|
+
});
|
|
5743
|
+
},
|
|
4607
5744
|
listEntries: (site, collection, options) => run(deps, env, "listEntries", site, listEntries.input, () => ({
|
|
4608
5745
|
collection,
|
|
4609
5746
|
...readOptions(options, [
|
|
@@ -4725,6 +5862,22 @@ async function runDueSchedules(input) {
|
|
|
4725
5862
|
await store.cancelSchedule(site.key, schedule.entryId);
|
|
4726
5863
|
cancelled += 1;
|
|
4727
5864
|
}
|
|
5865
|
+
await input.audit?.(systemActor(schedule.actor.userId), requestId).write(site, {
|
|
5866
|
+
action: "schedule.publish",
|
|
5867
|
+
outcome: answer.outcome === "published" ? "ok" : "failed",
|
|
5868
|
+
errorCode: answer.outcome === "published" ? null : answer.outcome,
|
|
5869
|
+
target: {
|
|
5870
|
+
type: "entry",
|
|
5871
|
+
id: schedule.entryId
|
|
5872
|
+
},
|
|
5873
|
+
detail: {
|
|
5874
|
+
scheduledAt: schedule.scheduledAt,
|
|
5875
|
+
scheduledBy: {
|
|
5876
|
+
kind: schedule.actor.kind,
|
|
5877
|
+
tokenId: schedule.actor.tokenId
|
|
5878
|
+
}
|
|
5879
|
+
}
|
|
5880
|
+
});
|
|
4728
5881
|
log({
|
|
4729
5882
|
kind: "schedule",
|
|
4730
5883
|
requestId,
|
|
@@ -4773,7 +5926,7 @@ function createSiteRegistry(options) {
|
|
|
4773
5926
|
for (const row of fetched) {
|
|
4774
5927
|
if (row.definition === null || (next.get(row.id)?.version ?? -1) >= row.version) continue;
|
|
4775
5928
|
try {
|
|
4776
|
-
const definition = JSON.parse(row.definition);
|
|
5929
|
+
const definition = withoutStaleIndexNow(row.id, JSON.parse(row.definition), options.log);
|
|
4777
5930
|
const site = compileSites({ [row.id]: definition }).sites.get(row.id);
|
|
4778
5931
|
if (site !== void 0) next.set(row.id, {
|
|
4779
5932
|
version: row.version,
|
|
@@ -4805,6 +5958,33 @@ function createSiteRegistry(options) {
|
|
|
4805
5958
|
refresh
|
|
4806
5959
|
};
|
|
4807
5960
|
}
|
|
5961
|
+
/**
|
|
5962
|
+
* A definition stored before 0.10 named the Worker secret of its IndexNow key; since then it holds
|
|
5963
|
+
* the key itself (T-69 of docs/security/threat-model.md). A stored one of the old form is served
|
|
5964
|
+
* with IndexNow off, and says so, instead of failing to compile and leaving its site unserved: its
|
|
5965
|
+
* owner sets the key itself in the admin or with update_site, which refuses the old form.
|
|
5966
|
+
*/
|
|
5967
|
+
function withoutStaleIndexNow(site, definition, log) {
|
|
5968
|
+
if (typeof definition !== "object" || definition === null) return definition;
|
|
5969
|
+
const events = Reflect.get(definition, "events");
|
|
5970
|
+
if (typeof events !== "object" || events === null) return definition;
|
|
5971
|
+
const indexNow = Reflect.get(events, "indexNow");
|
|
5972
|
+
if (typeof indexNow !== "object" || indexNow === null) return definition;
|
|
5973
|
+
const key = Reflect.get(indexNow, "key");
|
|
5974
|
+
if (typeof key === "string" && INDEXNOW_KEY.test(key)) return definition;
|
|
5975
|
+
log({
|
|
5976
|
+
kind: "indexnow_off",
|
|
5977
|
+
site,
|
|
5978
|
+
reason: "the definition names a secret, not the key itself"
|
|
5979
|
+
});
|
|
5980
|
+
return {
|
|
5981
|
+
...definition,
|
|
5982
|
+
events: {
|
|
5983
|
+
...events,
|
|
5984
|
+
indexNow: null
|
|
5985
|
+
}
|
|
5986
|
+
};
|
|
5987
|
+
}
|
|
4808
5988
|
//#endregion
|
|
4809
5989
|
//#region src/compose.ts
|
|
4810
5990
|
const systemClock = { now: () => /* @__PURE__ */ new Date() };
|
|
@@ -4871,8 +6051,12 @@ function createHollwareWith(config, handlers, options = {}) {
|
|
|
4871
6051
|
ownSecret: async (webhook) => {
|
|
4872
6052
|
const master = env.HOLLWARE_SECRET;
|
|
4873
6053
|
if (master === void 0) return void 0;
|
|
4874
|
-
const
|
|
4875
|
-
|
|
6054
|
+
const name = `webhook:${webhook}`;
|
|
6055
|
+
const sealed = await createSecretStore(env.DB, () => clock.now()).read(site.key, name);
|
|
6056
|
+
return sealed === null ? void 0 : await unseal(master, sealed, {
|
|
6057
|
+
site: site.key,
|
|
6058
|
+
name
|
|
6059
|
+
}) ?? void 0;
|
|
4876
6060
|
}
|
|
4877
6061
|
});
|
|
4878
6062
|
return {
|
|
@@ -4899,8 +6083,18 @@ function createHollwareWith(config, handlers, options = {}) {
|
|
|
4899
6083
|
store: createD1Store(env.DB, { clock }),
|
|
4900
6084
|
handlers,
|
|
4901
6085
|
clock,
|
|
4902
|
-
log: writeLog
|
|
6086
|
+
log: writeLog,
|
|
6087
|
+
audit: (actor, requestId) => createAuditLog({
|
|
6088
|
+
db: env.DB,
|
|
6089
|
+
actor,
|
|
6090
|
+
requestId,
|
|
6091
|
+
address: null,
|
|
6092
|
+
master: env.HOLLWARE_SECRET,
|
|
6093
|
+
now: () => clock.now(),
|
|
6094
|
+
log: writeLog
|
|
6095
|
+
})
|
|
4903
6096
|
});
|
|
6097
|
+
await dropExpiredLines(env.DB, site, clock.now());
|
|
4904
6098
|
await sweepOutbox({
|
|
4905
6099
|
queue: env.EVENTS,
|
|
4906
6100
|
deps: deliveryDeps(env, site)
|
|
@@ -4927,6 +6121,13 @@ function createHollwareWith(config, handlers, options = {}) {
|
|
|
4927
6121
|
});
|
|
4928
6122
|
}));
|
|
4929
6123
|
ctx.waitUntil((async () => {
|
|
6124
|
+
await dropExpired(connect(env.DB), clock.now()).catch((error) => {
|
|
6125
|
+
writeLog({
|
|
6126
|
+
kind: "cron_failed",
|
|
6127
|
+
site: null,
|
|
6128
|
+
message: String(error)
|
|
6129
|
+
});
|
|
6130
|
+
});
|
|
4930
6131
|
await refresh(env.DB);
|
|
4931
6132
|
for (const site of sites.values()) await each(site).catch((error) => {
|
|
4932
6133
|
writeLog({
|
|
@@ -4991,6 +6192,7 @@ function entrySummary(view) {
|
|
|
4991
6192
|
hasUnpublishedChanges: entry.publishedRevision !== entry.headRevision,
|
|
4992
6193
|
publishedAt: entry.publishedAt,
|
|
4993
6194
|
scheduledAt: entry.scheduledAt,
|
|
6195
|
+
deletedAt: entry.deletedAt,
|
|
4994
6196
|
updatedAt: entry.updatedAt
|
|
4995
6197
|
};
|
|
4996
6198
|
}
|
|
@@ -5326,6 +6528,10 @@ const createDraft = async (input, context) => {
|
|
|
5326
6528
|
entry: entrySummary(written.view),
|
|
5327
6529
|
verdict: verdictView(verdict)
|
|
5328
6530
|
};
|
|
6531
|
+
case "in_trash": return {
|
|
6532
|
+
outcome: "in_trash",
|
|
6533
|
+
entry: entrySummary(written.view)
|
|
6534
|
+
};
|
|
5329
6535
|
case "taken":
|
|
5330
6536
|
case "too_large": return {
|
|
5331
6537
|
outcome: "refused",
|
|
@@ -5413,6 +6619,7 @@ const listEntries$1 = async (input, context) => {
|
|
|
5413
6619
|
locale: input.locale,
|
|
5414
6620
|
tag: input.tag,
|
|
5415
6621
|
status: input.status,
|
|
6622
|
+
trashed: input.trashed,
|
|
5416
6623
|
view: context.view,
|
|
5417
6624
|
limit: input.limit,
|
|
5418
6625
|
cursor: input.cursor
|
|
@@ -5510,6 +6717,50 @@ const search$1 = async (input, context) => {
|
|
|
5510
6717
|
}))
|
|
5511
6718
|
};
|
|
5512
6719
|
};
|
|
6720
|
+
//#endregion
|
|
6721
|
+
//#region src/ops/event.ts
|
|
6722
|
+
/** The path of an entry: the route of its locale, with its slug in place of `:slug`. */
|
|
6723
|
+
function entryPath(collection, locale, slug) {
|
|
6724
|
+
const route = collection.routes[locale];
|
|
6725
|
+
return route === void 0 ? null : route.replace(/:slug(?![A-Za-z0-9_])/, slug);
|
|
6726
|
+
}
|
|
6727
|
+
/** The event of a publish or an unpublish, ready for the outbox. */
|
|
6728
|
+
function eventFor(input) {
|
|
6729
|
+
const id = newId("evt");
|
|
6730
|
+
const path = entryPath(input.collection, input.entry.locale, input.entry.slug);
|
|
6731
|
+
const payload = {
|
|
6732
|
+
id,
|
|
6733
|
+
kind: input.kind,
|
|
6734
|
+
site: input.site.key,
|
|
6735
|
+
at: input.at,
|
|
6736
|
+
entry: input.entry,
|
|
6737
|
+
path,
|
|
6738
|
+
url: path === null || input.site.url === null ? null : `${input.site.url}${path}`,
|
|
6739
|
+
cacheTags: tagsOfChange(input.entry)
|
|
6740
|
+
};
|
|
6741
|
+
return {
|
|
6742
|
+
id,
|
|
6743
|
+
payload: JSON.stringify(payload)
|
|
6744
|
+
};
|
|
6745
|
+
}
|
|
6746
|
+
/**
|
|
6747
|
+
* The event of a change of a site's structure, ready for the outbox: the tags of the site and of
|
|
6748
|
+
* every list of the collections and locales of its definition before and after the change.
|
|
6749
|
+
*/
|
|
6750
|
+
function siteEventFor(input) {
|
|
6751
|
+
const id = newId("evt");
|
|
6752
|
+
const payload = {
|
|
6753
|
+
id,
|
|
6754
|
+
kind: "site.changed",
|
|
6755
|
+
site: input.site,
|
|
6756
|
+
at: input.at,
|
|
6757
|
+
cacheTags: tagsOfSiteChange(input.collections, input.locales)
|
|
6758
|
+
};
|
|
6759
|
+
return {
|
|
6760
|
+
id,
|
|
6761
|
+
payload: JSON.stringify(payload)
|
|
6762
|
+
};
|
|
6763
|
+
}
|
|
5513
6764
|
/** How many slugs of refused entries an answer names. */
|
|
5514
6765
|
const EXAMPLES = 3;
|
|
5515
6766
|
function structureOf(context) {
|
|
@@ -5596,28 +6847,58 @@ async function change(context, version, next, confirm) {
|
|
|
5596
6847
|
outcome: "needs_confirmation",
|
|
5597
6848
|
impact
|
|
5598
6849
|
};
|
|
6850
|
+
const event = siteEventFor({
|
|
6851
|
+
site: context.site.key,
|
|
6852
|
+
at: context.clock.now().toISOString(),
|
|
6853
|
+
collections: [.../* @__PURE__ */ new Set([...Object.keys(current.definition.collections), ...Object.keys(next.collections)])],
|
|
6854
|
+
locales: [.../* @__PURE__ */ new Set([...current.definition.locales, ...next.locales])]
|
|
6855
|
+
});
|
|
5599
6856
|
if (await structure.store.write({
|
|
5600
6857
|
site: context.site.key,
|
|
5601
6858
|
expected: version,
|
|
5602
6859
|
definition: next,
|
|
5603
|
-
actor: actor(context)
|
|
6860
|
+
actor: actor(context),
|
|
6861
|
+
event
|
|
5604
6862
|
}) === "conflict") return {
|
|
5605
6863
|
outcome: "conflict",
|
|
5606
6864
|
currentVersion: (await currentOf(context)).version
|
|
5607
6865
|
};
|
|
6866
|
+
context.notify?.([event.id]);
|
|
5608
6867
|
await structure.changed();
|
|
5609
6868
|
return {
|
|
5610
6869
|
outcome: "updated",
|
|
5611
6870
|
version: version + 1
|
|
5612
6871
|
};
|
|
5613
6872
|
}
|
|
6873
|
+
/**
|
|
6874
|
+
* The URL of a webhook as a person who cannot change the structure sees it: its origin only. A
|
|
6875
|
+
* webhook's path and query often are its secret (a deploy hook), and `get_structure` asks only
|
|
6876
|
+
* for content:read, which authors have (T-70 of docs/security/threat-model.md).
|
|
6877
|
+
*/
|
|
6878
|
+
function originOnly(url) {
|
|
6879
|
+
try {
|
|
6880
|
+
return new URL(url).origin;
|
|
6881
|
+
} catch {
|
|
6882
|
+
return "";
|
|
6883
|
+
}
|
|
6884
|
+
}
|
|
5614
6885
|
const getStructure = async (_input, context) => {
|
|
5615
6886
|
const current = await currentOf(context);
|
|
6887
|
+
const definition = context.principal.scopes.includes("schema:admin") ? current.definition : {
|
|
6888
|
+
...current.definition,
|
|
6889
|
+
events: {
|
|
6890
|
+
...current.definition.events,
|
|
6891
|
+
webhooks: current.definition.events.webhooks.map((webhook) => ({
|
|
6892
|
+
...webhook,
|
|
6893
|
+
url: originOnly(webhook.url)
|
|
6894
|
+
}))
|
|
6895
|
+
}
|
|
6896
|
+
};
|
|
5616
6897
|
return {
|
|
5617
6898
|
outcome: "ok",
|
|
5618
6899
|
site: context.site.key,
|
|
5619
6900
|
version: current.version,
|
|
5620
|
-
definition
|
|
6901
|
+
definition,
|
|
5621
6902
|
catalog: describeCatalog()
|
|
5622
6903
|
};
|
|
5623
6904
|
};
|
|
@@ -5689,32 +6970,6 @@ const makeWebhookSecret = async (input, context) => {
|
|
|
5689
6970
|
};
|
|
5690
6971
|
};
|
|
5691
6972
|
//#endregion
|
|
5692
|
-
//#region src/ops/event.ts
|
|
5693
|
-
/** The path of an entry: the route of its locale, with its slug in place of `:slug`. */
|
|
5694
|
-
function entryPath(collection, locale, slug) {
|
|
5695
|
-
const route = collection.routes[locale];
|
|
5696
|
-
return route === void 0 ? null : route.replace(/:slug(?![A-Za-z0-9_])/, slug);
|
|
5697
|
-
}
|
|
5698
|
-
/** The event of a publish or an unpublish, ready for the outbox. */
|
|
5699
|
-
function eventFor(input) {
|
|
5700
|
-
const id = newId("evt");
|
|
5701
|
-
const path = entryPath(input.collection, input.entry.locale, input.entry.slug);
|
|
5702
|
-
const payload = {
|
|
5703
|
-
id,
|
|
5704
|
-
kind: input.kind,
|
|
5705
|
-
site: input.site.key,
|
|
5706
|
-
at: input.at,
|
|
5707
|
-
entry: input.entry,
|
|
5708
|
-
path,
|
|
5709
|
-
url: path === null || input.site.url === null ? null : `${input.site.url}${path}`,
|
|
5710
|
-
cacheTags: tagsOfChange(input.entry)
|
|
5711
|
-
};
|
|
5712
|
-
return {
|
|
5713
|
-
id,
|
|
5714
|
-
payload: JSON.stringify(payload)
|
|
5715
|
-
};
|
|
5716
|
-
}
|
|
5717
|
-
//#endregion
|
|
5718
6973
|
//#region src/ops/publish.ts
|
|
5719
6974
|
/** A warning by what an acknowledgement names: its contract and its path, never its line. */
|
|
5720
6975
|
function warningKey(warning) {
|
|
@@ -6010,7 +7265,7 @@ const listReviews = async (input, context) => {
|
|
|
6010
7265
|
const reviews = [];
|
|
6011
7266
|
for (const review of found) {
|
|
6012
7267
|
const view = await context.store.findEntryById(context.site.key, review.entryId, "head");
|
|
6013
|
-
if (view === null)
|
|
7268
|
+
if (view === null) continue;
|
|
6014
7269
|
reviews.push({
|
|
6015
7270
|
...reviewView(review),
|
|
6016
7271
|
entry: entrySummary(view),
|
|
@@ -6131,6 +7386,52 @@ const requestReview = async (input, context) => {
|
|
|
6131
7386
|
}
|
|
6132
7387
|
};
|
|
6133
7388
|
//#endregion
|
|
7389
|
+
//#region src/ops/trash.ts
|
|
7390
|
+
/**
|
|
7391
|
+
* The trash of entries. Only a draft goes there: a published or scheduled entry is unpublished
|
|
7392
|
+
* first. Nothing is deleted, since revisions, tags and publications are append-only (ADR-021): the
|
|
7393
|
+
* entry gets its `deletedAt`, every read leaves it out, and it keeps its slug and translation
|
|
7394
|
+
* until it is restored as the draft it was.
|
|
7395
|
+
*/
|
|
7396
|
+
/**
|
|
7397
|
+
* Moves a draft to the trash, if it is still at the version the caller read. The store checks it
|
|
7398
|
+
* all in the write: an entry that is not there (or is in the trash already) is `not_found`, another
|
|
7399
|
+
* version is `conflict`, and what is published or scheduled is taken off the site first.
|
|
7400
|
+
*/
|
|
7401
|
+
const deleteEntry = async (input, context) => {
|
|
7402
|
+
const written = await context.store.trashEntry(context.site.key, {
|
|
7403
|
+
entryId: input.id,
|
|
7404
|
+
expectedVersion: input.version
|
|
7405
|
+
});
|
|
7406
|
+
switch (written.outcome) {
|
|
7407
|
+
case "trashed": return {
|
|
7408
|
+
outcome: "trashed",
|
|
7409
|
+
entry: entrySummary(written.view)
|
|
7410
|
+
};
|
|
7411
|
+
case "not_draft": return {
|
|
7412
|
+
outcome: "published",
|
|
7413
|
+
entry: entrySummary(written.view)
|
|
7414
|
+
};
|
|
7415
|
+
case "conflict": return {
|
|
7416
|
+
outcome: "conflict",
|
|
7417
|
+
conflict: conflictAt(written.currentVersion, written.headRevision)
|
|
7418
|
+
};
|
|
7419
|
+
case "not_found": return { outcome: "not_found" };
|
|
7420
|
+
}
|
|
7421
|
+
};
|
|
7422
|
+
/** Brings an entry back from the trash as the draft it was. */
|
|
7423
|
+
const restoreEntry = async (input, context) => {
|
|
7424
|
+
const written = await context.store.restoreEntry(context.site.key, input.id);
|
|
7425
|
+
switch (written.outcome) {
|
|
7426
|
+
case "restored":
|
|
7427
|
+
case "not_trashed": return {
|
|
7428
|
+
outcome: written.outcome,
|
|
7429
|
+
entry: entrySummary(written.view)
|
|
7430
|
+
};
|
|
7431
|
+
case "not_found": return { outcome: "not_found" };
|
|
7432
|
+
}
|
|
7433
|
+
};
|
|
7434
|
+
//#endregion
|
|
6134
7435
|
//#region src/ops/unpublish.ts
|
|
6135
7436
|
/**
|
|
6136
7437
|
* Takes an entry off the public site and keeps it, with its revisions, as a draft. The store
|
|
@@ -6279,6 +7580,7 @@ const handlers = defineHandlers({
|
|
|
6279
7580
|
content_stats: contentStats,
|
|
6280
7581
|
create_draft: createDraft,
|
|
6281
7582
|
create_site: createSite,
|
|
7583
|
+
delete_entry: deleteEntry,
|
|
6282
7584
|
delete_site: deleteSite,
|
|
6283
7585
|
describe_site: describeSite$1,
|
|
6284
7586
|
get_entry: getEntry$1,
|
|
@@ -6291,6 +7593,7 @@ const handlers = defineHandlers({
|
|
|
6291
7593
|
publish: publish$1,
|
|
6292
7594
|
request_changes: requestChanges,
|
|
6293
7595
|
request_review: requestReview,
|
|
7596
|
+
restore_entry: restoreEntry,
|
|
6294
7597
|
restore_site: restoreSite,
|
|
6295
7598
|
set_collection: setCollection,
|
|
6296
7599
|
unpublish,
|