@hollware/server 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{ceremonies-DDoGdUD7.mjs → ceremonies-CI3DkOEL.mjs} +20 -6
- package/dist/index.d.mts +21 -0
- package/dist/index.mjs +1446 -364
- package/dist/{store-BPr9-wKy.mjs → store-DjA9s95l.mjs} +76 -2
- package/migrations/0015_site_changes_append_only.sql +28 -0
- package/migrations/0016_audit_log.sql +65 -0
- package/migrations/meta/0015_snapshot.json +2626 -0
- package/migrations/meta/0016_snapshot.json +2823 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +3 -3
package/dist/index.mjs
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import { A as
|
|
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.
|
|
13
|
+
var version = "0.11.0";
|
|
14
14
|
//#endregion
|
|
15
15
|
//#region src/auth/responses.ts
|
|
16
16
|
/** The challenge of every 401 and 403 (RFC 6750). */
|
|
@@ -350,7 +350,7 @@ function parseJson(text) {
|
|
|
350
350
|
}
|
|
351
351
|
}
|
|
352
352
|
/** The cursor that continues a `view` listing after `position`. */
|
|
353
|
-
function encodeCursor(view, position) {
|
|
353
|
+
function encodeCursor$1(view, position) {
|
|
354
354
|
return encodeBase64Url(JSON.stringify({
|
|
355
355
|
v: view,
|
|
356
356
|
k: position.key,
|
|
@@ -361,7 +361,7 @@ function encodeCursor(view, position) {
|
|
|
361
361
|
* The position a cursor stands for. A cursor that no listing of this view returned, whatever is
|
|
362
362
|
* wrong with it, is the error `invalid_input` with a fix.
|
|
363
363
|
*/
|
|
364
|
-
function decodeCursor(cursor, view) {
|
|
364
|
+
function decodeCursor$1(cursor, view) {
|
|
365
365
|
const text = decodeBase64Url(cursor);
|
|
366
366
|
const value = text === null ? void 0 : parseJson(text);
|
|
367
367
|
if (typeof value === "object" && value !== null && "v" in value && "k" in value && "i" in value && value.v === view && typeof value.k === "string" && typeof value.i === "string") return {
|
|
@@ -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 `
|
|
2073
|
-
* the nonce and the ciphertext with its tag
|
|
2074
|
-
*
|
|
2075
|
-
|
|
2076
|
-
|
|
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
|
-
|
|
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/
|
|
2432
|
-
/**
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
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
|
-
|
|
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
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
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.
|
|
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"]
|
|
2538
|
-
if (days ===
|
|
3195
|
+
const days = daysFrom(input["expiresInDays"]);
|
|
3196
|
+
if (days === void 0) return refused("expiresInDays is a whole number from 1 to 365.", "Leave it out for a token that never expires.");
|
|
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 =
|
|
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
|
|
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)
|
|
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:
|
|
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
|
-
|
|
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
|
|
3073
|
-
|
|
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
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
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
|
-
|
|
3295
|
-
|
|
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
|
-
*
|
|
3364
|
-
*
|
|
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
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
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-
|
|
3383
|
-
/**
|
|
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.
|
|
3808
|
-
if (
|
|
4665
|
+
const text = await boundedText(c.req.raw, MAX_BEACON_BYTES);
|
|
4666
|
+
if (text === null) return refusal(413, "too_large", `A beacon is at most ${MAX_BEACON_BYTES} bytes.`, "Send the site and the path of the page, and nothing else.");
|
|
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 (
|
|
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",
|
|
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
|
-
|
|
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/*",
|
|
4045
|
-
|
|
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, () =>
|
|
4573
|
-
|
|
4574
|
-
|
|
4575
|
-
|
|
4576
|
-
|
|
4577
|
-
|
|
4578
|
-
|
|
4579
|
-
|
|
4580
|
-
|
|
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
|
|
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) =>
|
|
4716
|
-
|
|
4717
|
-
|
|
4718
|
-
|
|
4719
|
-
|
|
4720
|
-
|
|
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
|
|
5016
|
-
|
|
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
|
|
6901
|
+
definition,
|
|
5820
6902
|
catalog: describeCatalog()
|
|
5821
6903
|
};
|
|
5822
6904
|
};
|