@hollware/server 0.10.0 → 1.0.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,16 +1,16 @@
1
- import { A as users, C as reviewComments, D as schedules, E as revisions, M as effectiveScopes, N as isRole, O as sites, S as publications, T as revisionTags, _ as entries, b as members, g as deliveries, j as ROLE_SCOPES, k as tokens, l as membershipsOf, n as createInvitation, s as findSession, v as entryEmbeddings, w as reviews, x as outbox, y as invitations } from "./store-BPr9-wKy.mjs";
2
- import { CHUNKER_VERSION, ConfigError, EVENT_ID_HEADER, HollwareError, INDEXNOW_KEY, 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, tagsOfSiteChange, 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.10.0";
13
+ var version = "1.0.0-rc.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 {
@@ -699,7 +699,7 @@ async function relatedOf(db, site, entryId) {
699
699
  }
700
700
  async function listEntries$2(db, site, query) {
701
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.");
702
- const cursor = query.cursor === void 0 ? null : decodeCursor(query.cursor, query.view);
702
+ const cursor = query.cursor === void 0 ? null : decodeCursor$1(query.cursor, query.view);
703
703
  if (query.view === "published" && (query.trashed === true || query.status !== void 0 && query.status !== "published")) return {
704
704
  entries: [],
705
705
  nextCursor: null
@@ -715,7 +715,7 @@ async function listEntries$2(db, site, query) {
715
715
  if (key === null) throw new Error(`The published entry ${last.entry.id} has no published_sort_key`);
716
716
  return {
717
717
  entries: page.map(toEntrySummaryView),
718
- nextCursor: encodeCursor(query.view, {
718
+ nextCursor: encodeCursor$1(query.view, {
719
719
  key,
720
720
  id: last.entry.id
721
721
  })
@@ -2064,16 +2064,191 @@ async function consumeEvents(messages, depsOf) {
2064
2064
  else message.retry();
2065
2065
  }
2066
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
+ }
2067
2238
  //#endregion
2068
2239
  //#region src/structure/seal.ts
2069
2240
  /**
2070
2241
  * Sealing a secret of the structure (ADR-033) under the Worker secret `HOLLWARE_SECRET`, so that
2071
2242
  * the database alone never holds one in clear: AES-GCM with a 256-bit key that is the SHA-256 of
2072
- * that secret, and a fresh 96-bit nonce each time. What is stored is `v1.` and the base64url of
2073
- * the nonce and the ciphertext with its tag. A sealed value that was tampered with, or sealed
2074
- * under another `HOLLWARE_SECRET`, does not open.
2075
- */
2076
- const PREFIX = "v1.";
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}`);
2077
2252
  function base64url(bytes) {
2078
2253
  return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
2079
2254
  }
@@ -2086,25 +2261,28 @@ async function keyOf(master) {
2086
2261
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(master));
2087
2262
  return crypto.subtle.importKey("raw", digest, "AES-GCM", false, ["encrypt", "decrypt"]);
2088
2263
  }
2089
- async function seal(master, value) {
2264
+ async function seal(master, value, place) {
2090
2265
  const nonce = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
2091
2266
  const cipher = new Uint8Array(await crypto.subtle.encrypt({
2092
2267
  name: "AES-GCM",
2093
- iv: nonce
2268
+ iv: nonce,
2269
+ additionalData: placeOf(place)
2094
2270
  }, await keyOf(master), new TextEncoder().encode(value)));
2095
2271
  const sealed = new Uint8Array(nonce.length + cipher.length);
2096
2272
  sealed.set(nonce);
2097
2273
  sealed.set(cipher, nonce.length);
2098
2274
  return `${PREFIX}${base64url(sealed)}`;
2099
2275
  }
2100
- /** The value of a sealed secret, or `null` when it does not open under `master`. */
2101
- async function unseal(master, sealed) {
2102
- if (!sealed.startsWith(PREFIX)) return null;
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;
2103
2280
  try {
2104
2281
  const bytes = fromBase64url(sealed.slice(3));
2105
2282
  const plain = await crypto.subtle.decrypt({
2106
2283
  name: "AES-GCM",
2107
- iv: bytes.slice(0, 12)
2284
+ iv: bytes.slice(0, 12),
2285
+ ...bound ? { additionalData: placeOf(place) } : {}
2108
2286
  }, await keyOf(master), bytes.slice(12));
2109
2287
  return new TextDecoder().decode(plain);
2110
2288
  } catch {
@@ -2305,6 +2483,226 @@ function installErrorHandling(app, log) {
2305
2483
  }));
2306
2484
  }
2307
2485
  //#endregion
2486
+ //#region src/identity/pages.ts
2487
+ /**
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.
2494
+ */
2495
+ /**
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).
2499
+ */
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)}`;
2512
+ /**
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).
2515
+ */
2516
+ const STYLE_HASH = "sha256-XfLw/v21Jfp0bukRAqh1+Rz82lWrWpdy2GbGduQqpqY=";
2517
+ /**
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.
2520
+ */
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
2533
+ }
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
+ `;
2556
+ }
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") ?? "";
2564
+ }
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
2308
2706
  //#region src/oauth/admin.ts
2309
2707
  /**
2310
2708
  * The admin as a client of the OAuth server (ADR-026): a public client, with PKCE, that a person
@@ -2393,6 +2791,7 @@ function installAdmin(app, options) {
2393
2791
  headers.set("cache-control", "no-store");
2394
2792
  headers.set("x-content-type-options", "nosniff");
2395
2793
  headers.set("referrer-policy", "no-referrer");
2794
+ for (const [name, value] of Object.entries(PAGE_ISOLATION)) headers.set(name, value);
2396
2795
  return new Response(answer.body, {
2397
2796
  status: 200,
2398
2797
  headers
@@ -2428,17 +2827,171 @@ async function removeMember(db, input) {
2428
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";
2429
2828
  }
2430
2829
  //#endregion
2431
- //#region src/http/admin-api.ts
2432
- /**
2433
- * What only a person in the admin may do (ADR-026): see, make and revoke personal tokens, and
2434
- * invite people, on the site of their token; and learn who they are and which sites they may switch
2435
- * to (`/v1/admin/me`, the one route for every role). These are no operations, so no agent sees them over MCP, and a request needs an
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
2981
+ //#region src/http/admin-api.ts
2982
+ /**
2983
+ * What only a person in the admin may do (ADR-026): see, make and revoke personal tokens, and
2984
+ * invite people, on the site of their token; and learn who they are and which sites they may switch
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
2436
2987
  * access token of the admin's own client with `schema:admin`: a personal token, or a token of
2437
2988
  * another client, never reaches them, even an owner's. A leaked token cannot mint the next one.
2438
2989
  */
2439
- /** A personal token lasts this long unless the person says otherwise (as `hollware token create`). */
2440
- const TOKEN_DAYS = 90;
2441
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;
2442
2995
  function json$1(body, status = 200) {
2443
2996
  return Response.json(body, {
2444
2997
  status,
@@ -2452,6 +3005,20 @@ const REFUSAL_CODES = {
2452
3005
  404: "not_found",
2453
3006
  409: "conflict"
2454
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
+ }
2455
3022
  function refused(message, fix, status = 400) {
2456
3023
  return json$1({
2457
3024
  code: REFUSAL_CODES[status],
@@ -2467,26 +3034,87 @@ function parseScopes(json) {
2467
3034
  return [];
2468
3035
  }
2469
3036
  }
2470
- function daysFrom(value, fallback) {
3037
+ /** The days an invitation lasts: `fallback` when left out, a whole number from 1 to 365, or null. */
3038
+ function invitationDays(value, fallback) {
2471
3039
  if (value === void 0) return fallback;
2472
3040
  return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 365 ? value : null;
2473
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
+ }
2474
3080
  function installAdminApi(app, options) {
2475
3081
  /**
2476
3082
  * Runs `handle` for a person in the admin with `schema:admin`, or with any scope when `anyone`;
2477
- * 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.
2478
3087
  */
2479
- const admin = (handle, { anyone = false } = {}) => (c) => options.protect(c, async (principal) => {
2480
- const ours = (await adminClient(options.oauth(c.env.HOLLWARE_PUBLIC_URL), c.env)).clientId;
2481
- 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);
2482
- if (!anyone && !principal.scopes.includes("schema:admin")) return insufficientScope(["schema:admin"], options.metadataUrl(c));
2483
- return handle(c, principal);
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;
2484
3112
  });
2485
3113
  const body = async (c) => {
2486
3114
  const value = await c.req.json().catch(() => null);
2487
3115
  return typeof value === "object" && value !== null && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {};
2488
3116
  };
2489
- app.get("/v1/admin/me", admin(async (c, person) => {
3117
+ app.get("/v1/admin/me", admin(null, async (c, person) => {
2490
3118
  const db = connect(c.env.DB);
2491
3119
  const [user] = await db.select({
2492
3120
  id: users.id,
@@ -2508,7 +3136,37 @@ function installAdminApi(app, options) {
2508
3136
  trash
2509
3137
  });
2510
3138
  }, { anyone: true }));
2511
- app.get("/v1/admin/tokens", admin(async (c, person) => {
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) => {
2512
3170
  return json$1({ tokens: (await connect(c.env.DB).select({
2513
3171
  id: tokens.id,
2514
3172
  kind: tokens.kind,
@@ -2524,7 +3182,7 @@ function installAdminApi(app, options) {
2524
3182
  scopes: parseScopes(row.scopes)
2525
3183
  })) });
2526
3184
  }));
2527
- app.post("/v1/admin/tokens", admin(async (c, person) => {
3185
+ app.post("/v1/admin/tokens", admin("token.create", async (c, person, note) => {
2528
3186
  const input = await body(c);
2529
3187
  const label = typeof input["label"] === "string" ? input["label"].trim() : "";
2530
3188
  if (label === "" || label.length > 100) return refused("A token needs a label of 1 to 100 characters.", "Say what the token is for.");
@@ -2534,8 +3192,8 @@ function installAdminApi(app, options) {
2534
3192
  const asked = kind === "read" ? ["content:read"] : Array.isArray(input["scopes"]) ? input["scopes"].filter((s) => typeof s === "string") : [];
2535
3193
  const scopes = person.scopes.filter((scope) => asked.includes(scope));
2536
3194
  if (scopes.length === 0) return refused("A token needs at least one scope that you have.", `Choose among ${person.scopes.join(", ")}.`);
2537
- const days = daysFrom(input["expiresInDays"], TOKEN_DAYS);
2538
- if (days === null) return refused("expiresInDays is a whole number from 1 to 365.", "Leave it out for 90 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.");
2539
3197
  const now = options.clock.now();
2540
3198
  const secret = kind === "read" ? generateReadToken() : generatePersonalToken();
2541
3199
  const row = {
@@ -2546,13 +3204,23 @@ function installAdminApi(app, options) {
2546
3204
  label,
2547
3205
  hash: await hashPersonalToken(secret),
2548
3206
  scopes: JSON.stringify(scopes),
2549
- expiresAt: new Date(now.getTime() + days * 864e5).toISOString(),
3207
+ expiresAt: days === null ? null : new Date(now.getTime() + days * 864e5).toISOString(),
2550
3208
  revokedAt: null,
2551
3209
  lastUsedAt: null,
2552
3210
  createdAt: now.toISOString(),
2553
3211
  updatedAt: now.toISOString()
2554
3212
  };
2555
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
+ };
2556
3224
  return json$1({
2557
3225
  id: row.id,
2558
3226
  kind,
@@ -2562,10 +3230,14 @@ function installAdminApi(app, options) {
2562
3230
  secret
2563
3231
  }, 201);
2564
3232
  }));
2565
- 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) => {
2566
3234
  const now = options.clock.now().toISOString();
2567
3235
  const db = connect(c.env.DB);
2568
3236
  const id = c.req.param("id") ?? "";
3237
+ note.target = {
3238
+ type: "token",
3239
+ id
3240
+ };
2569
3241
  const [revoked] = await db.update(tokens).set({
2570
3242
  revokedAt: now,
2571
3243
  updatedAt: now
@@ -2583,7 +3255,7 @@ function installAdminApi(app, options) {
2583
3255
  revokedAt: row.revokedAt
2584
3256
  });
2585
3257
  }));
2586
- app.get("/v1/admin/people", admin(async (c, person) => {
3258
+ app.get("/v1/admin/people", admin(null, async (c, person) => {
2587
3259
  const db = connect(c.env.DB);
2588
3260
  const now = options.clock.now().toISOString();
2589
3261
  return json$1({
@@ -2609,12 +3281,17 @@ function installAdminApi(app, options) {
2609
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);
2610
3282
  }
2611
3283
  };
2612
- app.post("/v1/admin/people/role", admin(async (c, person) => {
3284
+ app.post("/v1/admin/people/role", admin("member.role", async (c, person, note) => {
2613
3285
  const input = await body(c);
2614
3286
  const role = input["role"];
2615
3287
  const userId = input["userId"];
2616
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" }.`);
2617
3289
  const now = options.clock.now().toISOString();
3290
+ note.target = {
3291
+ type: "user",
3292
+ id: userId
3293
+ };
3294
+ note.detail = { role };
2618
3295
  return answerOf(await setRole(c.env.DB, {
2619
3296
  site: person.site,
2620
3297
  userId,
@@ -2622,19 +3299,23 @@ function installAdminApi(app, options) {
2622
3299
  now
2623
3300
  }));
2624
3301
  }));
2625
- app.post("/v1/admin/people/remove", admin(async (c, person) => {
3302
+ app.post("/v1/admin/people/remove", admin("member.remove", async (c, person, note) => {
2626
3303
  const userId = (await body(c))["userId"];
2627
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
+ };
2628
3309
  return answerOf(await removeMember(c.env.DB, {
2629
3310
  site: person.site,
2630
3311
  userId
2631
3312
  }));
2632
3313
  }));
2633
- app.post("/v1/admin/invitations", admin(async (c, person) => {
3314
+ app.post("/v1/admin/invitations", admin("invitation.create", async (c, person, note) => {
2634
3315
  const input = await body(c);
2635
3316
  const role = input["role"];
2636
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(", ")}).`);
2637
- const days = daysFrom(input["days"], INVITATION_DAYS);
3318
+ const days = invitationDays(input["days"], INVITATION_DAYS);
2638
3319
  if (days === null) return refused("days is a whole number from 1 to 365.", "Leave it out for 7 days.");
2639
3320
  const made = await createInvitation(connect(c.env.DB), {
2640
3321
  site: person.site,
@@ -2643,14 +3324,265 @@ function installAdminApi(app, options) {
2643
3324
  now: options.clock.now(),
2644
3325
  days
2645
3326
  });
3327
+ note.target = {
3328
+ type: "invitation",
3329
+ id: made.id
3330
+ };
3331
+ note.detail = {
3332
+ role,
3333
+ days
3334
+ };
2646
3335
  const origin = options.oauth(c.env.HOLLWARE_PUBLIC_URL).issuer;
2647
3336
  return json$1({
2648
3337
  id: made.id,
2649
3338
  role,
2650
3339
  expiresAt: made.expiresAt,
2651
- link: `${origin}/setup?invitation=${made.secret}`
3340
+ link: `${origin}/setup#invitation=${made.secret}`
2652
3341
  }, 201);
2653
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
+ };
3465
+ return json$1({
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
3494
+ }));
3495
+ }
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
+ };
2654
3586
  }
2655
3587
  //#endregion
2656
3588
  //#region src/semantic/cloudflare.ts
@@ -2856,6 +3788,8 @@ function invoke(handlers, spec, input, context) {
2856
3788
  }
2857
3789
  //#endregion
2858
3790
  //#region src/http/ops-api.ts
3791
+ /** The client of every call of this API: the admin. */
3792
+ const CLIENT = { name: "hollware-admin" };
2859
3793
  const STATUS$1 = {
2860
3794
  invalid_input: 400,
2861
3795
  unknown_collection: 404,
@@ -2878,8 +3812,15 @@ function installOpsApi(app, options) {
2878
3812
  message: `There is no operation ${c.req.param("name")}.`,
2879
3813
  fix: "Use the name of an operation, as tools/list of MCP lists them."
2880
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);
2881
3818
  const scopes = spec.scopes;
2882
- if (missingScopes(principal, scopes).length > 0) return insufficientScope(scopes, options.metadataUrl(c));
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
+ }
2883
3824
  const body = await c.req.json().catch(() => void 0);
2884
3825
  const parsed = spec.input.safeParse(body ?? {});
2885
3826
  if (!parsed.success) {
@@ -2890,24 +3831,30 @@ function installOpsApi(app, options) {
2890
3831
  fix: "Send the input of the operation as its input schema describes it."
2891
3832
  }, 400);
2892
3833
  }
2893
- const site = options.sites.get(principal.site);
2894
- if (site === void 0) throw new Error(`No site ${principal.site} for a checked token`);
2895
3834
  const context = {
2896
3835
  site,
2897
3836
  principal,
2898
3837
  view: "head",
2899
3838
  store: createD1Store(c.env.DB, { clock: options.clock }),
2900
3839
  clock: options.clock,
2901
- client: { name: "hollware-admin" },
3840
+ client: CLIENT,
2902
3841
  requestId,
2903
3842
  notify: options.notify(c, requestId, site.key),
2904
3843
  semantic: semanticOfEnv(c.env),
2905
3844
  analytics: analyticsReaderOf(c.env),
2906
3845
  ...options.structure === void 0 ? {} : { structure: options.structure(c.env.DB, c.env.HOLLWARE_SECRET) }
2907
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
+ };
2908
3852
  try {
2909
- return json(spec.output.parse(await invoke(options.handlers, spec, parsed.data, context)));
3853
+ const output = spec.output.parse(await invoke(options.handlers, spec, parsed.data, context));
3854
+ await audited({ output });
3855
+ return json(output);
2910
3856
  } catch (error) {
3857
+ await audited({ errorCode: error instanceof HollwareError ? error.code : "internal" });
2911
3858
  if (error instanceof HollwareError) return json({
2912
3859
  code: error.code,
2913
3860
  message: error.message,
@@ -2940,6 +3887,7 @@ function crossOriginOf(c) {
2940
3887
  }
2941
3888
  const STATUS = {
2942
3889
  invalid_input: 400,
3890
+ invalid_preview: 400,
2943
3891
  unknown_collection: 404,
2944
3892
  unknown_locale: 404
2945
3893
  };
@@ -2964,7 +3912,7 @@ function readRoute(handler) {
2964
3912
  code: error.code,
2965
3913
  message: error.message,
2966
3914
  fix: error.fix
2967
- }, STATUS[error.code] ?? 500);
3915
+ }, STATUS[error.code] ?? 500, c.req.query("preview") === void 0 ? {} : { "cache-control": "no-store" });
2968
3916
  }
2969
3917
  };
2970
3918
  }
@@ -2978,9 +3926,16 @@ function matches(header, etag) {
2978
3926
  if (header === void 0) return false;
2979
3927
  return header.split(",").map((tag) => tag.trim()).some((tag) => tag === "*" || tag === etag || `W/${tag}` === etag);
2980
3928
  }
2981
- async function answer(c, value) {
3929
+ async function answer(c, value, { preview = false } = {}) {
2982
3930
  const notFound = typeof value === "object" && value !== null && "outcome" in value && value.outcome === "not_found";
2983
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
+ });
2984
3939
  const etag = await etagOf(body);
2985
3940
  const headers = {
2986
3941
  etag,
@@ -3069,11 +4024,15 @@ function installReadApi(app, options) {
3069
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")))));
3070
4025
  app.get("/v1/:collection/:locale/:slug", readRoute(async (c) => {
3071
4026
  const formats = c.req.query("formats");
3072
- const related = c.req.query("related") === "true" ? { related: true } : {};
3073
- return answer(c, await options.read(c.env).getEntry(c.get("site"), c.req.param("collection"), c.req.param("slug"), c.req.param("locale"), formats === void 0 ? related : {
3074
- ...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,
3075
4034
  formats: formats.split(",").flatMap((format) => format === "markdown" || format === "document" || format === "html" ? [format] : [])
3076
- }));
4035
+ }), { preview: preview !== void 0 });
3077
4036
  }));
3078
4037
  }
3079
4038
  const SEARCH_MODES = [
@@ -3096,282 +4055,102 @@ function openApiDocument() {
3096
4055
  });
3097
4056
  const common = {
3098
4057
  "304": { description: "Not modified: the ETag of If-None-Match is current." },
3099
- "401": { description: "No token, or one that is unknown, expired or revoked. The challenge names the protected resource metadata." },
3100
- "403": { description: "The token lacks content:read." }
3101
- };
3102
- const param = (name, location, required) => ({
3103
- name,
3104
- in: location,
3105
- required,
3106
- schema: { type: "string" }
3107
- });
3108
- return {
3109
- openapi: "3.1.0",
3110
- info: {
3111
- title: "Hollware read API",
3112
- version: "1"
3113
- },
3114
- components: { securitySchemes: { token: {
3115
- type: "http",
3116
- scheme: "bearer"
3117
- } } },
3118
- security: [{ token: [] }],
3119
- paths: {
3120
- "/v1/site": { get: {
3121
- operationId: "describe_site",
3122
- responses: {
3123
- "200": ok("The site"),
3124
- ...common
3125
- }
3126
- } },
3127
- "/v1/search": { get: {
3128
- operationId: "search",
3129
- parameters: [
3130
- param("q", "query", true),
3131
- param("collection", "query", false),
3132
- param("locale", "query", false),
3133
- param("limit", "query", false),
3134
- param("mode", "query", false)
3135
- ],
3136
- responses: {
3137
- "200": ok("The published entries that match q, best first"),
3138
- ...common
3139
- }
3140
- } },
3141
- "/v1/{collection}": { get: {
3142
- operationId: "list_entries",
3143
- parameters: [
3144
- param("collection", "path", true),
3145
- param("locale", "query", false),
3146
- param("tag", "query", false),
3147
- param("limit", "query", false),
3148
- param("cursor", "query", false)
3149
- ],
3150
- responses: {
3151
- "200": ok("A page of published entries"),
3152
- ...common
3153
- }
3154
- } },
3155
- "/v1/{collection}/translations/{group}": { get: {
3156
- operationId: "get_translations",
3157
- parameters: [param("collection", "path", true), param("group", "path", true)],
3158
- responses: {
3159
- "200": ok("The published entries of the group"),
3160
- "404": { description: "No such group" },
3161
- ...common
3162
- }
3163
- } },
3164
- "/v1/{collection}/{locale}/{slug}": { get: {
3165
- operationId: "get_entry",
3166
- parameters: [
3167
- param("collection", "path", true),
3168
- param("locale", "path", true),
3169
- param("slug", "path", true),
3170
- param("formats", "query", false),
3171
- param("related", "query", false)
3172
- ],
3173
- responses: {
3174
- "200": ok("A published entry"),
3175
- "404": { description: "No such published entry" },
3176
- ...common
3177
- }
3178
- } }
3179
- }
3180
- };
3181
- }
3182
- //#endregion
3183
- //#region src/identity/pages.ts
3184
- /**
3185
- * The pages a person sees to get in (ADR-026): set up a passkey from a link, and sign in. They
3186
- * are rendered by the Worker, outside the admin's bundle, with one inline script under a nonce
3187
- * and nothing framing them. The script uses the WebAuthn JSON methods of the browser
3188
- * (`PublicKeyCredential.parseCreationOptionsFromJSON`, `credential.toJSON()`), and reads the
3189
- * invitation from the address bar, so the page never prints it.
3190
- */
3191
- /**
3192
- * The look of hollware.com and the admin, with nothing to fetch: the page's policy loads no font,
3193
- * so the type is the system's. Dark only, the neutral greys of the theme, white as the primary
3194
- * color and emerald for the mark (tests/conventions/brand.test.ts).
3195
- */
3196
- 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}
3197
- *{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}
3198
- main{width:100%;max-width:26rem;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:2rem}
3199
- .brand{display:flex;align-items:center;gap:.6rem;margin-bottom:1.75rem;font-weight:600;letter-spacing:-.01em}.brand svg{width:26px;height:26px}
3200
- 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)}
3201
- 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}
3202
- 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}
3203
- 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}
3204
- code{font:.82em ui-monospace,'SF Mono',Menlo,monospace;color:var(--muted)}
3205
- 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)}
3206
- button:not([value=deny]){background:var(--accent);border-color:var(--accent);color:var(--on-accent)}
3207
- [role=alert]{color:var(--danger)}`;
3208
- /** The mark of Hollware, before the title of every page. */
3209
- 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>`;
3210
- const POST = `async function post(path, body) {
3211
- const response = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
3212
- const data = await response.json()
3213
- if (!response.ok) throw new Error(data.message + ' ' + (data.fix ?? ''))
3214
- return data
3215
- }
3216
- function fail(error) { document.getElementById('error').textContent = error.message }`;
3217
- function page(title, nonce, body, script) {
3218
- return `<!doctype html>
3219
- <html lang="en">
3220
- <head>
3221
- <meta charset="utf-8">
3222
- <meta name="viewport" content="width=device-width, initial-scale=1">
3223
- <title>${title} · Hollware</title>
3224
- <style>${STYLE}</style>
3225
- </head>
3226
- <body>
3227
- <main>
3228
- ${BRAND}
3229
- <h1>${title}</h1>
3230
- ${body}
3231
- <p id="error" role="alert"></p>
3232
- </main>
3233
- <script nonce="${nonce}">${POST}
3234
- ${script}<\/script>
3235
- </body>
3236
- </html>
3237
- `;
3238
- }
3239
- function setupPage(nonce) {
3240
- return page("Set up your passkey", nonce, `<p>A passkey lets you into this Hollware with your device's lock: no password.</p>
3241
- <form id="form">
3242
- <label for="name">Your name</label><input id="name" name="name" autocomplete="name">
3243
- <label for="label">This device</label><input id="label" name="label" value="Passkey">
3244
- <button type="submit">Create the passkey</button>
3245
- </form>`, `document.getElementById('form').addEventListener('submit', async (event) => {
3246
- event.preventDefault()
3247
- try {
3248
- const invitation = new URLSearchParams(location.search).get('invitation') ?? ''
3249
- const { challengeId, options } = await post('/auth/register/options', { invitation })
3250
- const credential = await navigator.credentials.create({ publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options) })
3251
- await post('/auth/register/verify', { challengeId, response: credential.toJSON(), name: document.getElementById('name').value, label: document.getElementById('label').value })
3252
- location.href = '/admin/'
3253
- } catch (error) { fail(error) }
3254
- })`);
3255
- }
3256
- /**
3257
- * Where the sign-in page sends the person after their passkey: the `return_to` it was given, if it
3258
- * is a page of this origin, else the admin. The target is read as the browser will read it, as a
3259
- * URL against the page's own origin, so no way of writing another host (`//`, `/\`, a tab, a
3260
- * scheme) gets through, and only the path, query and fragment it resolves to are used (T-43).
3261
- * The page runs its source as it is: it uses nothing but its arguments and the URL of the platform.
3262
- */
3263
- function sameOriginTarget(target, origin) {
3264
- if (target === null) return "/admin/";
3265
- try {
3266
- const url = new URL(target, origin);
3267
- return url.origin === origin ? `${url.pathname}${url.search}${url.hash}` : "/admin/";
3268
- } catch {
3269
- return "/admin/";
3270
- }
3271
- }
3272
- function signInPage(nonce) {
3273
- return page("Sign in", nonce, `<p>Sign in to this Hollware with your passkey.</p>
3274
- <button id="signin" type="button">Sign in with a passkey</button>`, `const sameOriginTarget = ${sameOriginTarget.toString()}
3275
- document.getElementById('signin').addEventListener('click', async () => {
3276
- try {
3277
- const { challengeId, options } = await post('/auth/signin/options', {})
3278
- const credential = await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options) })
3279
- await post('/auth/signin/verify', { challengeId, response: credential.toJSON() })
3280
- location.href = sameOriginTarget(new URLSearchParams(location.search).get('return_to'), location.origin)
3281
- } catch (error) { fail(error) }
3282
- })`);
3283
- }
3284
- /** The headers of a page: no framing, only its own script, and no caching. */
3285
- function pageHeaders(nonce) {
4058
+ "401": { description: "No token, or one that is unknown, expired or revoked. The challenge names the protected resource metadata." },
4059
+ "403": { description: "The token lacks content:read." }
4060
+ };
4061
+ const param = (name, location, required) => ({
4062
+ name,
4063
+ in: location,
4064
+ required,
4065
+ schema: { type: "string" }
4066
+ });
3286
4067
  return {
3287
- "content-type": "text/html; charset=utf-8",
3288
- "cache-control": "no-store",
3289
- "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'`,
3290
- "x-content-type-options": "nosniff",
3291
- "referrer-policy": "no-referrer"
4068
+ openapi: "3.1.0",
4069
+ info: {
4070
+ title: "Hollware read API",
4071
+ version: "1"
4072
+ },
4073
+ components: { securitySchemes: { token: {
4074
+ type: "http",
4075
+ scheme: "bearer"
4076
+ } } },
4077
+ security: [{ token: [] }],
4078
+ paths: {
4079
+ "/v1/site": { get: {
4080
+ operationId: "describe_site",
4081
+ responses: {
4082
+ "200": ok("The site"),
4083
+ ...common
4084
+ }
4085
+ } },
4086
+ "/v1/search": { get: {
4087
+ operationId: "search",
4088
+ parameters: [
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
+ }
3292
4141
  };
3293
4142
  }
3294
- /** Text for HTML: every string a client chose (its name, its redirect host, a scope) goes through it. */
3295
- function escapeHtml(value) {
3296
- return value.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
3297
- }
3298
- /** What a scope lets a client do, in the words of the consent page. */
3299
- const SCOPE_WORDS = {
3300
- "content:read": "Read the content, drafts included",
3301
- "content:write": "Write drafts",
3302
- "content:publish": "Publish and unpublish",
3303
- "schema:admin": "Change the site settings"
3304
- };
3305
- /**
3306
- * The consent page of `/authorize` (ADR-026): which client asks, where the tokens go, and what it
3307
- * may do, with Allow and Deny. It has no script, and it posts the handle that `beginConsent` bound
3308
- * to this browser. Everything the client chose is escaped.
3309
- */
3310
- function consentPage(view) {
3311
- const name = escapeHtml(view.clientName);
3312
- 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>`;
3313
- 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>" : "";
3314
- 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>`;
3315
- 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");
3316
- return `<!doctype html>
3317
- <html lang="en">
3318
- <head>
3319
- <meta charset="utf-8">
3320
- <meta name="viewport" content="width=device-width, initial-scale=1">
3321
- <title>Allow ${name} · Hollware</title>
3322
- <style>${STYLE}</style>
3323
- </head>
3324
- <body>
3325
- <main>
3326
- ${BRAND}
3327
- <h1>Allow ${name} to use this Hollware?</h1>
3328
- ${origin}
3329
- <p>Signed in as <strong>${escapeHtml(view.person)}</strong>. Access goes to <strong>${escapeHtml(view.redirectHost)}</strong>.</p>
3330
- ${loopback}
3331
- <form method="post" action="/authorize">
3332
- <input type="hidden" name="handle" value="${escapeHtml(view.handle)}">
3333
- ${sites}
3334
- ${scopes}
3335
- <p><button name="decision" value="approve">Allow</button> <button name="decision" value="deny">Deny</button></p>
3336
- </form>
3337
- </main>
3338
- </body>
3339
- </html>
3340
- `;
3341
- }
3342
- /** A page that only says what went wrong and what to do. */
3343
- function messagePage(title, message) {
3344
- return `<!doctype html>
3345
- <html lang="en">
3346
- <head>
3347
- <meta charset="utf-8">
3348
- <meta name="viewport" content="width=device-width, initial-scale=1">
3349
- <title>${escapeHtml(title)} · Hollware</title>
3350
- <style>${STYLE}</style>
3351
- </head>
3352
- <body>
3353
- <main>
3354
- ${BRAND}
3355
- <h1>${escapeHtml(title)}</h1>
3356
- <p>${escapeHtml(message)}</p>
3357
- </main>
3358
- </body>
3359
- </html>
3360
- `;
3361
- }
4143
+ //#endregion
4144
+ //#region src/audit/person.ts
3362
4145
  /**
3363
- * The headers of a page without a script: no framing and no caching. The form of the consent page
3364
- * posts to `/authorize`, which redirects to the client, so `form-action` is not restricted: a
3365
- * 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.
3366
4148
  */
3367
- function plainPageHeaders() {
3368
- return {
3369
- "content-type": "text/html; charset=utf-8",
3370
- "cache-control": "no-store",
3371
- "content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'`,
3372
- "x-content-type-options": "nosniff",
3373
- "referrer-policy": "no-referrer"
3374
- };
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
+ }
3375
4154
  }
3376
4155
  //#endregion
3377
4156
  //#region src/identity/routes.ts
@@ -3379,17 +4158,25 @@ function plainPageHeaders() {
3379
4158
  * The ceremonies, with SimpleWebAuthn and its parsers, in a chunk of their own: only these four
3380
4159
  * routes use them, and a Worker that serves MCP never pays for them at startup (`budgets.json`).
3381
4160
  */
3382
- const ceremonies = () => import("./ceremonies-DDoGdUD7.mjs");
3383
- /** The cookie of a signed-in person: only `/authorize` reads it (ADR-026). */
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
+ */
3384
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";
3385
4169
  function sessionCookie(secret) {
3386
4170
  return `${SESSION_COOKIE}=${secret}; HttpOnly; Secure; SameSite=Lax; Path=/authorize; Max-Age=43200`;
3387
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`;
3388
4174
  function isRecord$1(value) {
3389
4175
  return typeof value === "object" && value !== null && !Array.isArray(value);
3390
4176
  }
3391
- /** The setup and sign-in pages, and the four steps of the passkey ceremonies. */
3392
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);
3393
4180
  const ceremony = (c) => ({
3394
4181
  db: connect(c.env.DB),
3395
4182
  publicUrl: c.env.HOLLWARE_PUBLIC_URL,
@@ -3430,6 +4217,14 @@ function installIdentity(app, options) {
3430
4217
  name: text(input["name"]),
3431
4218
  label: text(input["label"])
3432
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
+ });
3433
4228
  return c.json({ ok: true }, 200, { "set-cookie": sessionCookie(answer.session) });
3434
4229
  }));
3435
4230
  app.post("/auth/signin/options", handle(async (c) => c.json(await (await ceremonies()).authenticationOptions(ceremony(c)))));
@@ -3441,8 +4236,32 @@ function installIdentity(app, options) {
3441
4236
  challengeId: text(input["challengeId"]),
3442
4237
  response: JSON.parse(JSON.stringify(response))
3443
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
+ });
3444
4247
  return c.json({ ok: true }, 200, { "set-cookie": sessionCookie(answer.session) });
3445
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
+ });
3446
4265
  }
3447
4266
  //#endregion
3448
4267
  //#region src/oauth/authorize.ts
@@ -3613,6 +4432,16 @@ function installAuthorize(app, options) {
3613
4432
  scope,
3614
4433
  props
3615
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
+ });
3616
4445
  approved.headers.set("location", redirectTo);
3617
4446
  return new Response(null, {
3618
4447
  status: 302,
@@ -3789,6 +4618,35 @@ function refusal(status, code, message, fix) {
3789
4618
  headers: { "cache-control": "no-store" }
3790
4619
  });
3791
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
+ }
3792
4650
  /** The path of a view without its query and fragment, or `null` when it is no path. */
3793
4651
  function pathOf(value) {
3794
4652
  if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) return null;
@@ -3804,8 +4662,8 @@ function pathOf(value) {
3804
4662
  */
3805
4663
  function installEvents(app, options) {
3806
4664
  app.post("/events", async (c) => {
3807
- const text = await c.req.text();
3808
- if (new TextEncoder().encode(text).byteLength > 2048) return refusal(413, "too_large", `A beacon is at most ${MAX_BEACON_BYTES} bytes.`, "Send the site and the path of the page, and nothing else.");
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.");
3809
4667
  let body;
3810
4668
  try {
3811
4669
  body = JSON.parse(text);
@@ -3813,11 +4671,11 @@ function installEvents(app, options) {
3813
4671
  body = null;
3814
4672
  }
3815
4673
  const key = typeof body === "object" && body !== null && "site" in body ? body.site : null;
3816
- const site = typeof key === "string" ? options.sites.get(key) : void 0;
3817
4674
  const path = typeof body === "object" && body !== null && "path" in body ? pathOf(body.path) : null;
3818
- if (site === void 0 || path === null) return refusal(400, "invalid_input", "A beacon is JSON with the key of a site this Hollware serves and the path of a page.", "Send { \"site\": \"<site key>\", \"path\": \"/<path of the page>\" }.");
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);
3819
4677
  const origin = c.req.header("origin") ?? null;
3820
- if (origin === null || !pageOriginsOf(site).includes(origin)) return refusal(403, "forbidden_origin", `Only pages of the site "${site.key}" count its views.`, `Set the url of the site to the origin of its pages, or add the origin to its cors.origins: in the admin, Site, or with update_site.`);
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.");
3821
4679
  const cf = Reflect.get(c.req.raw, "cf");
3822
4680
  const country = typeof cf === "object" && cf !== null && "country" in cf && typeof cf.country === "string" ? cf.country : "XX";
3823
4681
  analyticsWriterOf(c.env)?.record({
@@ -3919,11 +4777,20 @@ function createGateway(options) {
3919
4777
  secrets: { make: async (site, name) => {
3920
4778
  if (master === void 0) return null;
3921
4779
  const secret = newWebhookSecret();
3922
- await createSecretStore(db, () => clock.now()).write(site, name, await seal(master, secret));
4780
+ const sealed = await seal(master, secret, {
4781
+ site,
4782
+ name
4783
+ });
4784
+ await createSecretStore(db, () => clock.now()).write(site, name, sealed);
3923
4785
  return secret;
3924
4786
  } }
3925
4787
  });
3926
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
+ /**
3927
4794
  * A protected route: the resource server of `resource` answers a request without a token and a
3928
4795
  * token it refuses (401, logged with the reason), and `proceed` gets the principal of the rest.
3929
4796
  */
@@ -3991,6 +4858,16 @@ function createGateway(options) {
3991
4858
  app.use("*", async (c, next) => {
3992
4859
  c.set("requestId", crypto.randomUUID());
3993
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
+ });
3994
4871
  });
3995
4872
  app.use("*", async (c, next) => {
3996
4873
  const origin = c.req.header("origin");
@@ -3999,6 +4876,30 @@ function createGateway(options) {
3999
4876
  if (refusal !== null) return refusal;
4000
4877
  await next();
4001
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
+ });
4002
4903
  for (const path of [
4003
4904
  "/authorize",
4004
4905
  "/oauth/*",
@@ -4025,7 +4926,13 @@ function createGateway(options) {
4025
4926
  semantic: semanticOfEnv(c.env),
4026
4927
  analytics: analyticsReaderOf(c.env),
4027
4928
  structure: structureOf(db, c.env.HOLLWARE_SECRET)
4028
- }
4929
+ },
4930
+ ...principal.kind === "read" ? {} : { audit: {
4931
+ log: auditOf(c, principal, null),
4932
+ defer: (work) => {
4933
+ c.executionCtx.waitUntil(work);
4934
+ }
4935
+ } }
4029
4936
  });
4030
4937
  }));
4031
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), {
@@ -4034,18 +4941,68 @@ function createGateway(options) {
4034
4941
  defer: () => void 0,
4035
4942
  failed: () => void 0
4036
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
+ };
4037
4959
  installAuthorize(app, {
4038
4960
  sites: siteKeys,
4039
4961
  clock,
4040
- oauth
4962
+ oauth,
4963
+ site: auditSite,
4964
+ audit: auditAs
4041
4965
  });
4042
4966
  const authorizationServer = (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).authorizationServer.fetch(c.req.raw, c.env, workerContext(c));
4043
4967
  app.all("/.well-known/oauth-authorization-server", authorizationServer);
4044
- app.all("/oauth/*", authorizationServer);
4045
- installIdentity(app, { clock });
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
+ });
4046
5001
  installAdmin(app, { oauth });
4047
5002
  installAdminApi(app, {
4048
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),
4049
5006
  clock,
4050
5007
  oauth,
4051
5008
  protect: (c, proceed) => protectWrites(c, "v1", proceed),
@@ -4059,7 +5016,8 @@ function createGateway(options) {
4059
5016
  protect: (c, proceed) => protectWrites(c, "v1", proceed),
4060
5017
  metadataUrl: (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1,
4061
5018
  notify,
4062
- structure: structureOf
5019
+ structure: structureOf,
5020
+ audit: async (c, principal) => auditOf(c, principal, (await adminClient(oauth(c.env.HOLLWARE_PUBLIC_URL), c.env)).clientId)
4063
5021
  });
4064
5022
  if (options.read !== void 0) installReadApi(app, {
4065
5023
  read: options.read,
@@ -4075,6 +5033,14 @@ function createGateway(options) {
4075
5033
  return await app.fetch(request, env, ctx);
4076
5034
  };
4077
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
+ }
4078
5044
  //#endregion
4079
5045
  //#region src/mcp/annotations.ts
4080
5046
  /**
@@ -4502,6 +5468,7 @@ function createMcpEndpoint(options) {
4502
5468
  client: declaredClient(envelope)
4503
5469
  };
4504
5470
  let outcome = null;
5471
+ let answered = null;
4505
5472
  let isError = false;
4506
5473
  let failure = null;
4507
5474
  let result;
@@ -4509,6 +5476,7 @@ function createMcpEndpoint(options) {
4509
5476
  const record = (answer) => {
4510
5477
  outcome = answer.outcome;
4511
5478
  isError = answer.isError;
5479
+ answered = answer.output;
4512
5480
  return answer;
4513
5481
  };
4514
5482
  /** Runs the handler within the time limit and checks its answer. */
@@ -4541,6 +5509,8 @@ function createMcpEndpoint(options) {
4541
5509
  isError = true;
4542
5510
  result = errorResult(failure);
4543
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);
4544
5514
  emit({
4545
5515
  kind: "call",
4546
5516
  requestId: call.requestId,
@@ -4569,16 +5539,20 @@ function createMcpEndpoint(options) {
4569
5539
  inputSchema: tool.input,
4570
5540
  outputSchema: tool.output,
4571
5541
  annotations: EFFECT_ANNOTATIONS[tool.spec.effect],
4572
- scopeChallenge: scopeChallenge(tool, () => emit({
4573
- kind: "call",
4574
- requestId: call.requestId,
4575
- tool: tool.spec.name,
4576
- outcome: null,
4577
- errorCode: "insufficient_scope",
4578
- isError: true,
4579
- durationMs: 0,
4580
- detail: null
4581
- }))
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
+ })
4582
5556
  }, (input, mcp) => runTool(tool.spec, input, mcp, call, request.era));
4583
5557
  server.registerPrompt(REFRESH_CONTENT.name, {
4584
5558
  title: REFRESH_CONTENT.title,
@@ -4622,6 +5596,27 @@ function unknownSite(site, served) {
4622
5596
  const keys = [...served].map((key) => `"${key}"`).join(", ");
4623
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.`);
4624
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
+ }
4625
5620
  /**
4626
5621
  * The options object of a call, without the keys that hold `undefined`, so that the handler sees
4627
5622
  * the same input as over MCP. A key that is none of `allowed` is an error: the specs are strict
@@ -4678,7 +5673,7 @@ function forCaller(deps, requestId, method, error) {
4678
5673
  * `build` makes the raw input, so a wrong site is answered before anything else is looked at and
4679
5674
  * a call that fails never opens a session.
4680
5675
  */
4681
- 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")) {
4682
5677
  const requestId = crypto.randomUUID();
4683
5678
  try {
4684
5679
  await deps.refresh?.(env.DB);
@@ -4688,10 +5683,16 @@ async function run(deps, env, method, site, schema, build, handler) {
4688
5683
  const served = deps.sites.get(site);
4689
5684
  if (served === void 0) throw unknownSite(site, deps.sites.keys());
4690
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
+ }
4691
5692
  const context = {
4692
5693
  site: served,
4693
5694
  principal: BINDING_PRINCIPAL,
4694
- view: "published",
5695
+ view,
4695
5696
  store: createD1Store(env.DB.withSession("first-unconstrained")),
4696
5697
  clock: deps.clock,
4697
5698
  client: null,
@@ -4712,12 +5713,34 @@ async function run(deps, env, method, site, schema, build, handler) {
4712
5713
  */
4713
5714
  function createRpc(deps, env) {
4714
5715
  return {
4715
- getEntry: (site, collection, slug, locale, options) => run(deps, env, "getEntry", site, getEntry.input, () => ({
4716
- collection,
4717
- locale,
4718
- slug,
4719
- ...readOptions(options, ["formats", "related"])
4720
- }), deps.handlers.get_entry),
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
+ },
4721
5744
  listEntries: (site, collection, options) => run(deps, env, "listEntries", site, listEntries.input, () => ({
4722
5745
  collection,
4723
5746
  ...readOptions(options, [
@@ -4839,6 +5862,22 @@ async function runDueSchedules(input) {
4839
5862
  await store.cancelSchedule(site.key, schedule.entryId);
4840
5863
  cancelled += 1;
4841
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
+ });
4842
5881
  log({
4843
5882
  kind: "schedule",
4844
5883
  requestId,
@@ -5012,8 +6051,12 @@ function createHollwareWith(config, handlers, options = {}) {
5012
6051
  ownSecret: async (webhook) => {
5013
6052
  const master = env.HOLLWARE_SECRET;
5014
6053
  if (master === void 0) return void 0;
5015
- const sealed = await createSecretStore(env.DB, () => clock.now()).read(site.key, `webhook:${webhook}`);
5016
- return sealed === null ? void 0 : await unseal(master, sealed) ?? void 0;
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;
5017
6060
  }
5018
6061
  });
5019
6062
  return {
@@ -5040,8 +6083,18 @@ function createHollwareWith(config, handlers, options = {}) {
5040
6083
  store: createD1Store(env.DB, { clock }),
5041
6084
  handlers,
5042
6085
  clock,
5043
- 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
+ })
5044
6096
  });
6097
+ await dropExpiredLines(env.DB, site, clock.now());
5045
6098
  await sweepOutbox({
5046
6099
  queue: env.EVENTS,
5047
6100
  deps: deliveryDeps(env, site)
@@ -5068,6 +6121,13 @@ function createHollwareWith(config, handlers, options = {}) {
5068
6121
  });
5069
6122
  }));
5070
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
+ });
5071
6131
  await refresh(env.DB);
5072
6132
  for (const site of sites.values()) await each(site).catch((error) => {
5073
6133
  writeLog({
@@ -5810,13 +6870,35 @@ async function change(context, version, next, confirm) {
5810
6870
  version: version + 1
5811
6871
  };
5812
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
+ }
5813
6885
  const getStructure = async (_input, context) => {
5814
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
+ };
5815
6897
  return {
5816
6898
  outcome: "ok",
5817
6899
  site: context.site.key,
5818
6900
  version: current.version,
5819
- definition: current.definition,
6901
+ definition,
5820
6902
  catalog: describeCatalog()
5821
6903
  };
5822
6904
  };