@hollware/server 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,35 +1,16 @@
1
- import { EVENT_ID_HEADER, HollwareError, OPERATIONS, SIGNATURE_HEADER, TIMESTAMP_HEADER, describeBuiltInContracts, describeResolvedSite, describeSite, getEntry, getTranslations, hashPersonalToken, isPersonalToken, isScope, listEntries, newId, processMarkdown, publish, renderHtml, resolveConfig, runContracts, signWebhook, sizeLimitViolation, uniqueSlugViolation, uniqueTranslationViolation } from "@hollware/core";
1
+ import { A as effectiveScopes, C as revisionTags, D as tokens, E as sites, O as users, S as reviews, T as schedules, _ as entries, b as outbox, g as deliveries, j as isRole, k as ROLE_SCOPES, l as membershipsOf, n as createInvitation, s as findSession, v as invitations, w as revisions, x as publications, y as members } from "./store-CsuER6J7.mjs";
2
+ import { EVENT_ID_HEADER, HollwareError, OPERATIONS, SCOPES, SIGNATURE_HEADER, TIMESTAMP_HEADER, describeBuiltInContracts, describeResolvedSite, describeSite, generatePersonalToken, generateReadToken, getEntry, getTranslations, hashPersonalToken, isPersonalToken, isReadToken, isScope, listEntries, newId, processMarkdown, publish, renderHtml, resolveConfig, runContracts, signWebhook, sizeLimitViolation, uniqueSlugViolation, uniqueTranslationViolation } from "@hollware/core";
2
3
  import { Hono } from "hono";
3
4
  import { drizzle } from "drizzle-orm/d1";
4
- import { and, desc, eq, getTableColumns, inArray, isNull, lte, ne, or, sql } from "drizzle-orm";
5
- import { alias, check, foreignKey, index, integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
5
+ import { and, desc, eq, getTableColumns, gt, inArray, isNull, lte, ne, or, sql } from "drizzle-orm";
6
+ import { alias } from "drizzle-orm/sqlite-core";
7
+ import { AuthorizationError, CimdFetchError, OAuthAuthorizationServer, OAuthResourceServer, authorizationErrorRedirect } from "@cloudflare/workers-oauth-provider";
8
+ import { getCookie } from "hono/cookie";
6
9
  import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, McpServer, acceptedContent, createMcpHandler, inputRequired, inputResponse } from "@modelcontextprotocol/server";
7
10
  import * as z from "zod";
8
11
  import { WorkerEntrypoint } from "cloudflare:workers";
9
12
  //#region package.json
10
- var version = "0.2.0";
11
- //#endregion
12
- //#region src/auth/bearer.ts
13
- /**
14
- * `Bearer`, one or more spaces, then one token with no whitespace in it, and nothing else. The
15
- * scheme is case-insensitive (RFC 7235); the runtime has already trimmed the whole value.
16
- */
17
- const BEARER = /^bearer +(\S+)$/i;
18
- /**
19
- * The token of an `Authorization: Bearer <token>` header, as sent, or `null` when the request
20
- * presents none: no header, another scheme, no token, or more than one (two headers arrive joined
21
- * with a comma, so they read as two tokens). It never looks at the URL: a token in a query string
22
- * ends up in logs, referrers and history, so Hollware does not accept one.
23
- *
24
- * It does not judge the token. A string that is not a personal token is a token that was
25
- * presented and refused, which `checkPersonalToken` says and `unauthorized` answers differently
26
- * from a request without credentials.
27
- */
28
- function readBearerToken(request) {
29
- const header = request.headers.get("authorization");
30
- if (header === null) return null;
31
- return BEARER.exec(header)?.[1] ?? null;
32
- }
13
+ var version = "0.4.0";
33
14
  //#endregion
34
15
  //#region src/auth/responses.ts
35
16
  /** The challenge of every 401 and 403 (RFC 6750). */
@@ -56,25 +37,27 @@ function authErrorResponse(status, body, challenge) {
56
37
  function unauthorized(reason) {
57
38
  if (reason === void 0) return authErrorResponse(401, {
58
39
  code: "unauthorized",
59
- message: "The request carries no personal token.",
60
- fix: "Send a personal token as \"Authorization: Bearer <token>\". Create one with `hollware token create`."
40
+ message: "The request carries no token.",
41
+ fix: "Send a personal token as \"Authorization: Bearer <token>\" (create one with `hollware token create`), or sign in through OAuth: the challenge names where."
61
42
  }, REALM);
62
43
  return authErrorResponse(401, {
63
44
  code: "unauthorized",
64
- message: "The personal token was not accepted.",
65
- fix: "Tokens expire and can be revoked. Create a new one with `hollware token create` and send it as \"Authorization: Bearer <token>\"."
45
+ message: "The token was not accepted.",
46
+ fix: "Tokens expire and can be revoked. Create a new personal token with `hollware token create`, or sign in through OAuth again, and send it as \"Authorization: Bearer <token>\"."
66
47
  }, `${REALM}, error="invalid_token"`);
67
48
  }
68
49
  /**
69
50
  * 403 for a token that authenticated but lacks scopes. It names every scope that is missing, in
70
- * the body and in the challenge (RFC 6750), so that a client asks for all of them at once.
51
+ * the body and in the challenge (RFC 6750), so that a client asks for all of them at once, and,
52
+ * when there is one, the protected resource metadata where it learns how (RFC 9728).
71
53
  */
72
- function insufficientScope(missing) {
54
+ function insufficientScope(missing, resourceMetadataUrl) {
55
+ const metadata = resourceMetadataUrl === void 0 ? "" : `, resource_metadata="${resourceMetadataUrl}"`;
73
56
  return authErrorResponse(403, {
74
57
  code: "insufficient_scope",
75
58
  message: `The token lacks a scope this operation requires. Missing: ${missing.join(", ")}.`,
76
- fix: "Send a token that carries every missing scope. Create one with `hollware token create`; a token only gets the scopes its user's role allows."
77
- }, `${REALM}, error="insufficient_scope", scope="${missing.join(" ")}"`);
59
+ fix: "Send a token that carries every missing scope: create one with `hollware token create`, or authorize the client again for them. A token only gets the scopes its user's role allows."
60
+ }, `${REALM}${metadata}, error="insufficient_scope", scope="${missing.join(" ")}"`);
78
61
  }
79
62
  //#endregion
80
63
  //#region src/auth/guard.ts
@@ -110,7 +93,9 @@ function readPublicUrl(value) {
110
93
  * Any other port is another host.
111
94
  * - An `Origin` header that is present and is not the origin of the public URL, scheme and port
112
95
  * included: 403. That covers the opaque `null` origin and an empty value. A request without
113
- * `Origin` passes: only browsers send it, and they are what the check is for.
96
+ * `Origin` passes: only browsers send it, and they are what the check is for. The one exception
97
+ * is `crossOrigin`: an origin it accepts passes too (the read API, for the origins a site's
98
+ * config lists; the read API then checks the origin against the site of the token).
114
99
  *
115
100
  * It returns the response to send, or `null` when the request may go on. No answer repeats the
116
101
  * request: the 403s say what to send instead, and the 500 only says what is missing.
@@ -120,8 +105,9 @@ function readPublicUrl(value) {
120
105
  * they accept a `Host` such as `user@cms.example.com` or `cms.example.com/x`.
121
106
  *
122
107
  * @param publicUrl The `HOLLWARE_PUBLIC_URL` variable as the environment holds it.
108
+ * @param crossOrigin Whether a request may come from a page of another origin; never, by default.
123
109
  */
124
- function checkHostAndOrigin(request, publicUrl) {
110
+ function checkHostAndOrigin(request, publicUrl, crossOrigin = () => false) {
125
111
  const expected = readPublicUrl(publicUrl);
126
112
  if (expected === null) return authErrorResponse(500, {
127
113
  code: "misconfigured",
@@ -135,7 +121,7 @@ function checkHostAndOrigin(request, publicUrl) {
135
121
  fix: `Send the request to ${expected.origin}, the public URL of this Hollware (HOLLWARE_PUBLIC_URL).`
136
122
  });
137
123
  const origin = request.headers.get("origin");
138
- if (origin !== null && origin.toLowerCase() !== expected.origin) return authErrorResponse(403, {
124
+ if (origin !== null && origin.toLowerCase() !== expected.origin && !crossOrigin(request, origin)) return authErrorResponse(403, {
139
125
  code: "forbidden_origin",
140
126
  message: "The Origin header of the request is not the public URL of this Hollware.",
141
127
  fix: `Call Hollware from a client that sends no Origin header, or from a page served at ${expected.origin}.`
@@ -143,61 +129,29 @@ function checkHostAndOrigin(request, publicUrl) {
143
129
  return null;
144
130
  }
145
131
  //#endregion
146
- //#region src/auth/roles.ts
147
- /**
148
- * The roles of a user, as the `users_role` CHECK of the schema lists them. In v0.1 only the owner
149
- * exists: `hollware init` creates it. The other two are in the CHECK already, so that v0.3 can
150
- * invite people without a rebuild of the table (ADR-023).
151
- */
152
- const ROLES = [
153
- "owner",
154
- "editor",
155
- "reviewer"
156
- ];
157
- /** Whether a string is one of the roles. */
158
- function isRole(value) {
159
- return ROLES.some((role) => role === value);
160
- }
161
- /**
162
- * The scopes each role allows. What a token may do is its own scopes intersected with these,
163
- * read from the user's current role on every request (ADR-005, ADR-019). An operation asks for
164
- * scopes, never for a role.
165
- *
166
- * - `owner` may do everything: all four scopes.
167
- * - `editor` and `reviewer` allow none in v0.1. Nothing creates such a user yet, and which scopes
168
- * each one gets (an editor writes, a reviewer approves) is for v0.3 to decide, together with
169
- * the admin that invites them. Until then a token of such a user authenticates, but every
170
- * operation answers 403 `insufficient_scope`.
171
- */
172
- const ROLE_SCOPES = {
173
- owner: [
174
- "content:read",
175
- "content:write",
176
- "content:publish",
177
- "schema:admin"
178
- ],
179
- editor: [],
180
- reviewer: []
181
- };
132
+ //#region src/auth/scopes.ts
182
133
  /**
183
- * The scopes a token really has: those it carries that the role of its user allows, in the order
184
- * of the role. A role that is not one of the roles allows nothing, so a role added to the schema
185
- * before this table knows it grants no access.
134
+ * The scopes an operation requires that the principal lacks, in the order the operation lists
135
+ * them. Empty means the principal may call it; otherwise the answer is `insufficientScope` with
136
+ * all of them, so that a client asks for everything it needs in one go.
186
137
  */
187
- function effectiveScopes(role, tokenScopes) {
188
- if (!isRole(role)) return [];
189
- return ROLE_SCOPES[role].filter((scope) => tokenScopes.includes(scope));
138
+ function missingScopes(principal, required) {
139
+ const granted = principal.scopes;
140
+ return required.filter((scope) => !granted.includes(scope));
190
141
  }
191
142
  //#endregion
192
143
  //#region src/auth/token.ts
193
144
  /** `last_used_at` of a token is written at most once in this time. */
194
145
  const TOUCH_INTERVAL_MS = 36e5;
195
- /** The token with the role of its user, by the hash of the token, in the one site. */
146
+ /**
147
+ * The token with the role of its user in the token's site, by the hash of the token, in the
148
+ * deployment. A user who is no member of the site has no role there, and the token is unknown.
149
+ */
196
150
  const LOOKUP = `SELECT t.id AS token_id, t.site_id AS site_id, t.user_id AS user_id,
197
- t.scopes AS scopes, t.expires_at AS expires_at, t.revoked_at AS revoked_at,
198
- t.last_used_at AS last_used_at, u.role AS role
199
- FROM tokens AS t JOIN users AS u ON u.id = t.user_id
200
- WHERE t.hash = ?1 AND t.site_id = ?2`;
151
+ t.kind AS kind, t.scopes AS scopes, t.expires_at AS expires_at, t.revoked_at AS revoked_at,
152
+ t.last_used_at AS last_used_at, m.role AS role
153
+ FROM tokens AS t JOIN members AS m ON m.user_id = t.user_id AND m.site_id = t.site_id
154
+ WHERE t.hash = ?1`;
201
155
  /**
202
156
  * The scopes a token carries, from the JSON array the `tokens` table stores. Anything that is not
203
157
  * one of the scopes is left out, and a value that is not an array of scopes has none: a token
@@ -242,12 +196,12 @@ async function recordTokenUse(db, token, now) {
242
196
  }
243
197
  }
244
198
  /**
245
- * Authenticates a personal token (ADR-019). A string without the shape of a token is `unknown`
246
- * without a query. Otherwise one query finds the token by the hash of the whole string, in this
247
- * site only, together with the role of its user; the answer is, in this order:
199
+ * Authenticates a personal token (ADR-019) or a read token. A string without the shape of either
200
+ * is `unknown` without a query. Otherwise one query finds the token by the hash of the whole
201
+ * string, together with the role of its user in the token's site; the answer is, in this order:
248
202
  *
249
- * - `unknown`: no such token in this site. A token of another site is the same as no token, and
250
- * nothing says it exists elsewhere.
203
+ * - `unknown`: no such token in the sites of this deployment. A token of another site is the same as
204
+ * no token, and nothing says it exists elsewhere.
251
205
  * - `revoked`: it has a revocation date, whatever the date says: it is never read, so a clock that
252
206
  * runs ahead of the Worker's, where the token was revoked, cannot keep it alive for a while.
253
207
  * - `expired`: its expiry is now or past (see `hasExpired`).
@@ -260,12 +214,13 @@ async function recordTokenUse(db, token, now) {
260
214
  * must never read that as a valid token.
261
215
  */
262
216
  async function checkPersonalToken(db, token, options) {
263
- if (!isPersonalToken(token)) return {
217
+ const kind = isPersonalToken(token) ? "personal" : isReadToken(token) ? "read" : null;
218
+ if (kind === null) return {
264
219
  ok: false,
265
220
  reason: "unknown"
266
221
  };
267
- const row = await db.prepare(LOOKUP).bind(await hashPersonalToken(token), options.site).first();
268
- if (row === null) return {
222
+ const row = await db.prepare(LOOKUP).bind(await hashPersonalToken(token)).first();
223
+ if (row === null || row.kind !== kind || !options.sites.has(row.site_id)) return {
269
224
  ok: false,
270
225
  reason: "unknown"
271
226
  };
@@ -278,14 +233,22 @@ async function checkPersonalToken(db, token, options) {
278
233
  reason: "expired"
279
234
  };
280
235
  const recent = row.last_used_at !== null && row.last_used_at >= anHourBefore(options.now);
236
+ const scopes = effectiveScopes(row.role, parseTokenScopes(row.scopes));
237
+ const identity = {
238
+ userId: row.user_id,
239
+ tokenId: row.token_id,
240
+ site: row.site_id
241
+ };
281
242
  return {
282
243
  ok: true,
283
- principal: {
244
+ principal: kind === "read" ? {
245
+ kind: "read",
246
+ ...identity,
247
+ scopes: scopes.filter((scope) => scope === "content:read")
248
+ } : {
284
249
  kind: "token",
285
- userId: row.user_id,
286
- tokenId: row.token_id,
287
- site: row.site_id,
288
- scopes: effectiveScopes(row.role, parseTokenScopes(row.scopes))
250
+ ...identity,
251
+ scopes
289
252
  },
290
253
  touch: recent ? null : recordTokenUse(db, {
291
254
  id: row.token_id,
@@ -408,336 +371,6 @@ function decodeCursor(cursor, view) {
408
371
  throw invalidCursor();
409
372
  }
410
373
  //#endregion
411
- //#region src/db/columns.ts
412
- /**
413
- * The columns every table shares. Each helper returns a new builder, because a Drizzle column
414
- * belongs to one table. Only the repository functions write rows, so these defaults are what
415
- * every row gets.
416
- */
417
- /** The current time in UTC, as ISO 8601 with milliseconds: `2026-10-12T18:30:00.000Z`. */
418
- function nowIso() {
419
- return (/* @__PURE__ */ new Date()).toISOString();
420
- }
421
- /** The primary key: a prefixed ULID, such as `ent_01JAB3XQ8ZK4M6N2P5R7S9T0VW`. */
422
- function id(prefix) {
423
- return text("id").primaryKey().$defaultFn(() => newId(prefix));
424
- }
425
- /**
426
- * The site a row belongs to: the site's key in `hollware.config.ts`, which is also the id of its
427
- * row in `sites`. Every table but the deployment-wide ones carries it (ADR-006).
428
- */
429
- function siteId() {
430
- return text("site_id").notNull();
431
- }
432
- /** `created_at` and `updated_at`, in UTC; `updated_at` moves on every update. */
433
- function timestamps() {
434
- return {
435
- createdAt: text("created_at").notNull().$defaultFn(nowIso),
436
- updatedAt: text("updated_at").notNull().$defaultFn(nowIso).$onUpdateFn(nowIso)
437
- };
438
- }
439
- sqliteTable("_hollware_meta", {
440
- key: text("key").primaryKey().notNull(),
441
- value: text("value").notNull()
442
- });
443
- /** The sites of the deployment. The id is the site's key in `hollware.config.ts`. */
444
- const sites = sqliteTable("sites", {
445
- id: text("id").primaryKey(),
446
- ...timestamps()
447
- });
448
- /** People. A user belongs to the deployment; tokens carry the site. */
449
- const users = sqliteTable("users", {
450
- id: id("usr"),
451
- name: text("name"),
452
- role: text("role").notNull(),
453
- ...timestamps()
454
- }, (t) => [check("users_role", sql`${t.role} IN ('owner', 'editor', 'reviewer')`)]);
455
- /** Personal tokens (ADR-019): only the hash is stored. */
456
- const tokens = sqliteTable("tokens", {
457
- id: id("tok"),
458
- siteId: siteId(),
459
- userId: text("user_id").notNull(),
460
- label: text("label").notNull(),
461
- hash: text("hash").notNull(),
462
- /** A JSON array of scopes. */
463
- scopes: text("scopes").notNull(),
464
- expiresAt: text("expires_at"),
465
- revokedAt: text("revoked_at"),
466
- lastUsedAt: text("last_used_at"),
467
- ...timestamps()
468
- }, (t) => [
469
- unique("tokens_id_site").on(t.id, t.siteId),
470
- unique("tokens_hash").on(t.hash),
471
- foreignKey({
472
- name: "tokens_site",
473
- columns: [t.siteId],
474
- foreignColumns: [sites.id]
475
- }),
476
- foreignKey({
477
- name: "tokens_user",
478
- columns: [t.userId],
479
- foreignColumns: [users.id]
480
- }),
481
- check("tokens_scopes", sql`json_valid(${t.scopes}) AND json_type(${t.scopes}) = 'array'`)
482
- ]);
483
- /** One entry per site, collection, locale and slug; its content lives in `revisions`. */
484
- const entries = sqliteTable("entries", {
485
- id: id("ent"),
486
- siteId: siteId(),
487
- collection: text("collection").notNull(),
488
- locale: text("locale").notNull(),
489
- slug: text("slug").notNull(),
490
- translationGroup: text("translation_group"),
491
- status: text("status").notNull(),
492
- /** Bumped by every change of the entry: the optimistic write compares it. */
493
- version: integer("version").notNull(),
494
- headRev: text("head_rev").notNull(),
495
- publishedRev: text("published_rev"),
496
- publishedAt: text("published_at"),
497
- publishedSortKey: text("published_sort_key"),
498
- scheduledAt: text("scheduled_at"),
499
- ...timestamps()
500
- }, (t) => [
501
- unique("entries_id_site").on(t.id, t.siteId),
502
- unique("entries_slug").on(t.siteId, t.collection, t.locale, t.slug),
503
- unique("entries_translation").on(t.siteId, t.collection, t.translationGroup, t.locale),
504
- index("entries_head").on(t.siteId, t.collection, t.updatedAt, t.id),
505
- index("entries_head_locale").on(t.siteId, t.collection, t.locale, t.updatedAt, t.id),
506
- index("entries_published").on(t.siteId, t.collection, t.locale, t.publishedSortKey, t.id).where(sql`${t.status} = 'published'`),
507
- foreignKey({
508
- name: "entries_site",
509
- columns: [t.siteId],
510
- foreignColumns: [sites.id]
511
- }),
512
- check("entries_status", sql`${t.status} IN ('draft', 'scheduled', 'published')`),
513
- check("entries_version", sql`${t.version} >= 1`),
514
- check("entries_draft", sql`${t.status} <> 'draft' OR (${t.publishedRev} IS NULL AND ${t.publishedSortKey} IS NULL AND ${t.scheduledAt} IS NULL)`),
515
- check("entries_published", sql`${t.status} <> 'published' OR (${t.publishedRev} IS NOT NULL AND ${t.publishedAt} IS NOT NULL AND ${t.publishedSortKey} IS NOT NULL)`),
516
- check("entries_scheduled", sql`${t.status} <> 'scheduled' OR ${t.scheduledAt} IS NOT NULL`)
517
- ]);
518
- /**
519
- * Every version of an entry, appended and never changed (ADR-021). `markdown` is the whole file,
520
- * frontmatter included; the derived columns can be rebuilt by `reparse`.
521
- */
522
- const revisions = sqliteTable("revisions", {
523
- id: id("rev"),
524
- siteId: siteId(),
525
- entryId: text("entry_id").notNull(),
526
- markdown: text("markdown").notNull(),
527
- /** Derived: the frontmatter as JSON. */
528
- frontmatter: text("frontmatter").notNull(),
529
- /** Derived: the Comark document as JSON. */
530
- document: text("document").notNull(),
531
- comarkVersion: text("comark_version").notNull(),
532
- pipelineVersion: text("pipeline_version").notNull(),
533
- wordCount: integer("word_count").notNull(),
534
- /** The verdict of the contracts when the revision was written, as JSON. */
535
- verdict: text("verdict").notNull(),
536
- actorKind: text("actor_kind").notNull(),
537
- actorUserId: text("actor_user_id").notNull(),
538
- actorTokenId: text("actor_token_id"),
539
- /** The client and, when it says, the model behind the write, as JSON. */
540
- actorMeta: text("actor_meta"),
541
- ...timestamps()
542
- }, (t) => [
543
- unique("revisions_id_site").on(t.id, t.siteId),
544
- index("revisions_entry").on(t.entryId, t.createdAt),
545
- foreignKey({
546
- name: "revisions_site",
547
- columns: [t.siteId],
548
- foreignColumns: [sites.id]
549
- }),
550
- foreignKey({
551
- name: "revisions_entry",
552
- columns: [t.entryId, t.siteId],
553
- foreignColumns: [entries.id, entries.siteId]
554
- }),
555
- foreignKey({
556
- name: "revisions_user",
557
- columns: [t.actorUserId],
558
- foreignColumns: [users.id]
559
- }),
560
- foreignKey({
561
- name: "revisions_token",
562
- columns: [t.actorTokenId, t.siteId],
563
- foreignColumns: [tokens.id, tokens.siteId]
564
- }),
565
- check("revisions_actor_kind", sql`${t.actorKind} IN ('token', 'user', 'oauth_client')`),
566
- check("revisions_json", sql`json_valid(${t.frontmatter}) AND json_valid(${t.document}) AND json_valid(${t.verdict}) AND (${t.actorMeta} IS NULL OR json_valid(${t.actorMeta}))`),
567
- check("revisions_word_count", sql`${t.wordCount} >= 0`)
568
- ]);
569
- /** The tags of a revision, for listing published entries by tag. Append-only, like revisions. */
570
- const revisionTags = sqliteTable("revision_tags", {
571
- id: id("tag"),
572
- siteId: siteId(),
573
- revisionId: text("revision_id").notNull(),
574
- tag: text("tag").notNull(),
575
- ...timestamps()
576
- }, (t) => [
577
- unique("revision_tags_id_site").on(t.id, t.siteId),
578
- unique("revision_tags_tag").on(t.revisionId, t.tag),
579
- index("revision_tags_by_tag").on(t.siteId, t.tag),
580
- foreignKey({
581
- name: "revision_tags_site",
582
- columns: [t.siteId],
583
- foreignColumns: [sites.id]
584
- }),
585
- foreignKey({
586
- name: "revision_tags_revision",
587
- columns: [t.revisionId, t.siteId],
588
- foreignColumns: [revisions.id, revisions.siteId]
589
- })
590
- ]);
591
- /**
592
- * Every publish and unpublish, appended: the verdict of the publish phase and the warnings the
593
- * caller acknowledged (ADR-021).
594
- */
595
- const publications = sqliteTable("publications", {
596
- id: id("pub"),
597
- siteId: siteId(),
598
- entryId: text("entry_id").notNull(),
599
- action: text("action").notNull(),
600
- revisionId: text("revision_id"),
601
- verdict: text("verdict"),
602
- /** A JSON array: the warnings the caller acknowledged to publish. */
603
- acknowledged: text("acknowledged").notNull(),
604
- actorKind: text("actor_kind").notNull(),
605
- actorUserId: text("actor_user_id").notNull(),
606
- actorTokenId: text("actor_token_id"),
607
- actorMeta: text("actor_meta"),
608
- ...timestamps()
609
- }, (t) => [
610
- unique("publications_id_site").on(t.id, t.siteId),
611
- index("publications_entry").on(t.entryId, t.createdAt),
612
- foreignKey({
613
- name: "publications_site",
614
- columns: [t.siteId],
615
- foreignColumns: [sites.id]
616
- }),
617
- foreignKey({
618
- name: "publications_entry",
619
- columns: [t.entryId, t.siteId],
620
- foreignColumns: [entries.id, entries.siteId]
621
- }),
622
- foreignKey({
623
- name: "publications_revision",
624
- columns: [t.revisionId, t.siteId],
625
- foreignColumns: [revisions.id, revisions.siteId]
626
- }),
627
- foreignKey({
628
- name: "publications_user",
629
- columns: [t.actorUserId],
630
- foreignColumns: [users.id]
631
- }),
632
- foreignKey({
633
- name: "publications_token",
634
- columns: [t.actorTokenId, t.siteId],
635
- foreignColumns: [tokens.id, tokens.siteId]
636
- }),
637
- check("publications_action", sql`${t.action} IN ('publish', 'unpublish')`),
638
- check("publications_shape", sql`(${t.action} = 'publish' AND ${t.revisionId} IS NOT NULL AND ${t.verdict} IS NOT NULL) OR (${t.action} = 'unpublish' AND ${t.revisionId} IS NULL AND ${t.verdict} IS NULL)`),
639
- check("publications_actor_kind", sql`${t.actorKind} IN ('token', 'user', 'oauth_client')`),
640
- check("publications_json", sql`json_valid(${t.acknowledged}) AND json_type(${t.acknowledged}) = 'array' AND (${t.verdict} IS NULL OR json_valid(${t.verdict})) AND (${t.actorMeta} IS NULL OR json_valid(${t.actorMeta}))`)
641
- ]);
642
- /**
643
- * Scheduled publications (ADR-025): one per entry at most. The cron trigger publishes the entry when
644
- * `scheduled_at` has passed, if it is still at `version`, with the warnings that were acknowledged
645
- * when it was scheduled, and in the name of who scheduled it.
646
- */
647
- const schedules = sqliteTable("schedules", {
648
- id: id("sch"),
649
- siteId: siteId(),
650
- entryId: text("entry_id").notNull(),
651
- scheduledAt: text("scheduled_at").notNull(),
652
- /** The version of the entry once scheduled: a later change cancels the schedule. */
653
- version: integer("version").notNull(),
654
- /** A JSON array: the warnings acknowledged when the publication was scheduled. */
655
- acknowledged: text("acknowledged").notNull(),
656
- actorKind: text("actor_kind").notNull(),
657
- actorUserId: text("actor_user_id").notNull(),
658
- actorTokenId: text("actor_token_id"),
659
- actorMeta: text("actor_meta"),
660
- ...timestamps()
661
- }, (t) => [
662
- unique("schedules_id_site").on(t.id, t.siteId),
663
- unique("schedules_entry").on(t.siteId, t.entryId),
664
- index("schedules_due").on(t.scheduledAt),
665
- foreignKey({
666
- name: "schedules_site",
667
- columns: [t.siteId],
668
- foreignColumns: [sites.id]
669
- }),
670
- foreignKey({
671
- name: "schedules_entry_fk",
672
- columns: [t.entryId, t.siteId],
673
- foreignColumns: [entries.id, entries.siteId]
674
- }),
675
- foreignKey({
676
- name: "schedules_user",
677
- columns: [t.actorUserId],
678
- foreignColumns: [users.id]
679
- }),
680
- foreignKey({
681
- name: "schedules_token",
682
- columns: [t.actorTokenId, t.siteId],
683
- foreignColumns: [tokens.id, tokens.siteId]
684
- }),
685
- check("schedules_actor_kind", sql`${t.actorKind} IN ('token', 'user', 'oauth_client')`),
686
- check("schedules_json", sql`json_valid(${t.acknowledged}) AND json_type(${t.acknowledged}) = 'array' AND (${t.actorMeta} IS NULL OR json_valid(${t.actorMeta}))`)
687
- ]);
688
- /**
689
- * The effects of a publication (ADR-025), written in the batch that publishes or unpublishes: one
690
- * row per event. The Queue consumer delivers each event to every subscriber, and records it in
691
- * `deliveries`; `sent_at` says that the event was put on the Queue.
692
- */
693
- const outbox = sqliteTable("outbox", {
694
- id: id("evt"),
695
- siteId: siteId(),
696
- /** `entry.published` or `entry.unpublished`. */
697
- kind: text("kind").notNull(),
698
- entryId: text("entry_id").notNull(),
699
- /** A JSON object: what subscribers get. */
700
- payload: text("payload").notNull(),
701
- sentAt: text("sent_at"),
702
- ...timestamps()
703
- }, (t) => [
704
- unique("outbox_id_site").on(t.id, t.siteId),
705
- index("outbox_unsent").on(t.sentAt, t.createdAt),
706
- foreignKey({
707
- name: "outbox_site",
708
- columns: [t.siteId],
709
- foreignColumns: [sites.id]
710
- }),
711
- check("outbox_kind", sql`${t.kind} IN ('entry.published', 'entry.unpublished')`),
712
- check("outbox_json", sql`json_valid(${t.payload}) AND json_type(${t.payload}) = 'object'`)
713
- ]);
714
- /** Each delivery of an event to a subscriber, so that a retry never delivers it twice. */
715
- const deliveries = sqliteTable("deliveries", {
716
- id: id("dlv"),
717
- siteId: siteId(),
718
- eventId: text("event_id").notNull(),
719
- /** The subscriber, by its name in the config, such as `webhook:newsletter` or `indexnow`. */
720
- subscriber: text("subscriber").notNull(),
721
- attempts: integer("attempts").notNull(),
722
- deliveredAt: text("delivered_at"),
723
- lastError: text("last_error"),
724
- ...timestamps()
725
- }, (t) => [
726
- unique("deliveries_id_site").on(t.id, t.siteId),
727
- unique("deliveries_event_subscriber").on(t.eventId, t.subscriber),
728
- foreignKey({
729
- name: "deliveries_site",
730
- columns: [t.siteId],
731
- foreignColumns: [sites.id]
732
- }),
733
- foreignKey({
734
- name: "deliveries_event",
735
- columns: [t.eventId, t.siteId],
736
- foreignColumns: [outbox.id, outbox.siteId]
737
- }),
738
- check("deliveries_attempts", sql`${t.attempts} >= 0`)
739
- ]);
740
- //#endregion
741
374
  //#region src/db/rows.ts
742
375
  /**
743
376
  * The columns the store reads and how they become the types of `ports.ts`. The JSON columns come
@@ -1094,6 +727,21 @@ function lookup(db, site) {
1094
727
  };
1095
728
  }
1096
729
  //#endregion
730
+ //#region src/db/errors.ts
731
+ /**
732
+ * What D1 says when a statement breaks a unique key: `D1_ERROR: UNIQUE constraint failed:
733
+ * entries.site_id, …: SQLITE_CONSTRAINT`, with the same text again in the `cause`. Inside a batch
734
+ * the error arrives as it is, and the batch has rolled back.
735
+ */
736
+ function isUniqueViolation(error) {
737
+ let current = error;
738
+ for (let depth = 0; depth < 4 && current instanceof Error; depth += 1) {
739
+ if (current.message.includes("UNIQUE constraint failed")) return true;
740
+ current = current.cause;
741
+ }
742
+ return false;
743
+ }
744
+ //#endregion
1097
745
  //#region src/db/statements.ts
1098
746
  function actorColumns(actor) {
1099
747
  return {
@@ -1241,7 +889,7 @@ async function dueSchedules(db, site, now) {
1241
889
  version: row.version,
1242
890
  acknowledged: parseAcknowledged(row.acknowledged),
1243
891
  actor: {
1244
- kind: "token",
892
+ kind: actorKindOf(row.actorKind),
1245
893
  userId: row.actorUserId,
1246
894
  tokenId: row.actorTokenId,
1247
895
  meta: row.actorMeta === null ? null : parseMeta(row.actorMeta)
@@ -1265,24 +913,140 @@ function parseAcknowledged(text) {
1265
913
  path: item.path
1266
914
  }] : []);
1267
915
  }
916
+ /** The kind of an actor as the `*_actor_kind` checks allow it. */
917
+ function actorKindOf(value) {
918
+ if (value === "token" || value === "user" || value === "oauth_client") return value;
919
+ throw new Error(`A schedule has the actor kind ${value}, which is no kind`);
920
+ }
1268
921
  function parseMeta(text) {
1269
922
  const value = JSON.parse(text);
1270
923
  return typeof value === "object" && value !== null && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : null;
1271
924
  }
1272
925
  //#endregion
1273
- //#region src/db/errors.ts
926
+ //#region src/db/reviews.ts
1274
927
  /**
1275
- * What D1 says when a statement breaks a unique key: `D1_ERROR: UNIQUE constraint failed:
1276
- * entries.site_id, …: SQLITE_CONSTRAINT`, with the same text again in the `cause`. Inside a batch
1277
- * the error arrives as it is, and the batch has rolled back.
928
+ * Reviews (v0.3). A person who may write but not publish asks for the head revision of an entry
929
+ * to be published; a person who may publish approves it, which publishes it, or asks for changes.
930
+ * The partial unique index `reviews_pending` keeps one pending review per entry. A write that
931
+ * moves the entry closes the pending review in its own batch: a new revision cancels it
932
+ * (`cancelPendingReview`), a publication approves it (`approvePendingReview`).
1278
933
  */
1279
- function isUniqueViolation(error) {
1280
- let current = error;
1281
- for (let depth = 0; depth < 4 && current instanceof Error; depth += 1) {
1282
- if (current.message.includes("UNIQUE constraint failed")) return true;
1283
- current = current.cause;
934
+ const STATUSES = [
935
+ "pending",
936
+ "approved",
937
+ "changes_requested",
938
+ "cancelled"
939
+ ];
940
+ function statusOf(value) {
941
+ const status = STATUSES.find((known) => known === value);
942
+ if (status === void 0) throw new Error(`A review has the status ${value}, which is no status`);
943
+ return status;
944
+ }
945
+ /** The columns of a review, and the name of who asked for it. */
946
+ const COLUMNS = {
947
+ ...getTableColumns(reviews),
948
+ requestedByName: users.name
949
+ };
950
+ function toReview(row) {
951
+ return {
952
+ id: row.id,
953
+ entryId: row.entryId,
954
+ revisionId: row.revisionId,
955
+ status: statusOf(row.status),
956
+ acknowledged: parseAcknowledged(row.acknowledged),
957
+ requestedBy: row.requestedBy,
958
+ requestedByName: row.requestedByName,
959
+ requestedAt: row.createdAt,
960
+ decidedBy: row.decidedBy,
961
+ decidedAt: row.decidedAt,
962
+ note: row.note
963
+ };
964
+ }
965
+ /** The entry is still at `version`: the condition of every statement a write adds to its batch. */
966
+ function entryAt(site, entryId, version) {
967
+ return sql`exists (select 1 from ${entries} where ${and(eq(entries.id, entryId), eq(entries.siteId, site), eq(entries.version, version))})`;
968
+ }
969
+ /** In the batch of a new revision: the pending review asked for another one, so it is cancelled. */
970
+ function cancelPendingReview(db, site, entryId, version, now) {
971
+ return db.update(reviews).set({
972
+ status: "cancelled",
973
+ updatedAt: now
974
+ }).where(and(eq(reviews.siteId, site), eq(reviews.entryId, entryId), eq(reviews.status, "pending"), entryAt(site, entryId, version)));
975
+ }
976
+ /** In the batch of a publication: what the pending review asked for is published, by `userId`. */
977
+ function approvePendingReview(db, site, entryId, version, userId, now) {
978
+ return db.update(reviews).set({
979
+ status: "approved",
980
+ decidedBy: userId,
981
+ decidedAt: now,
982
+ updatedAt: now
983
+ }).where(and(eq(reviews.siteId, site), eq(reviews.entryId, entryId), eq(reviews.status, "pending"), sql`${reviews.revisionId} = (select ${entries.headRev} from ${entries} where ${and(eq(entries.id, entryId), eq(entries.siteId, site), eq(entries.version, version))})`));
984
+ }
985
+ async function pendingOf(db, site, entryId) {
986
+ const [row] = await db.select(COLUMNS).from(reviews).innerJoin(users, eq(users.id, reviews.requestedBy)).where(and(eq(reviews.siteId, site), eq(reviews.entryId, entryId), eq(reviews.status, "pending"))).limit(1);
987
+ return row === void 0 ? null : toReview(row);
988
+ }
989
+ async function requestReview(db, clock, site, write, whyNothingChanged) {
990
+ const now = clock.now().toISOString();
991
+ const row = {
992
+ id: newId("rvw"),
993
+ siteId: site,
994
+ entryId: write.entryId,
995
+ revisionId: entries.headRev,
996
+ status: "pending",
997
+ acknowledged: JSON.stringify(write.acknowledged),
998
+ requestedBy: write.actor.userId,
999
+ requestedMeta: write.actor.meta === null ? null : JSON.stringify(write.actor.meta),
1000
+ decidedBy: null,
1001
+ decidedAt: null,
1002
+ note: null,
1003
+ createdAt: now,
1004
+ updatedAt: now
1005
+ };
1006
+ let inserted;
1007
+ try {
1008
+ inserted = await db.insert(reviews).select(sql`select ${valuesOf(reviews, row)} from ${entries} where ${and(eq(entries.id, write.entryId), eq(entries.siteId, site), eq(entries.version, write.expectedVersion))}`).returning({ id: reviews.id });
1009
+ } catch (error) {
1010
+ const pending = isUniqueViolation(error) ? await pendingOf(db, site, write.entryId) : null;
1011
+ if (pending === null) throw error;
1012
+ return {
1013
+ outcome: "pending_review",
1014
+ review: pending
1015
+ };
1284
1016
  }
1285
- return false;
1017
+ if (inserted.length === 0) return whyNothingChanged();
1018
+ const review = await findReview(db, site, row.id);
1019
+ if (review === null) throw new Error(`The review ${row.id} that was just written is not there`);
1020
+ return {
1021
+ outcome: "pending_review",
1022
+ review
1023
+ };
1024
+ }
1025
+ async function findReview(db, site, id) {
1026
+ const [row] = await db.select(COLUMNS).from(reviews).innerJoin(users, eq(users.id, reviews.requestedBy)).where(and(eq(reviews.siteId, site), eq(reviews.id, id))).limit(1);
1027
+ return row === void 0 ? null : toReview(row);
1028
+ }
1029
+ async function listReviews$1(db, site, query) {
1030
+ return (await db.select(COLUMNS).from(reviews).innerJoin(users, eq(users.id, reviews.requestedBy)).where(and(eq(reviews.siteId, site), eq(reviews.status, query.status))).orderBy(desc(reviews.createdAt), desc(reviews.id)).limit(query.limit)).map(toReview);
1031
+ }
1032
+ async function requestChanges$1(db, clock, site, write) {
1033
+ const now = clock.now().toISOString();
1034
+ const [row] = await db.update(reviews).set({
1035
+ status: "changes_requested",
1036
+ note: write.note,
1037
+ decidedBy: write.userId,
1038
+ decidedAt: now,
1039
+ updatedAt: now
1040
+ }).where(and(eq(reviews.siteId, site), eq(reviews.id, write.id), eq(reviews.status, "pending"))).returning({ id: reviews.id });
1041
+ const review = await findReview(db, site, write.id);
1042
+ if (row !== void 0 && review !== null) return {
1043
+ outcome: "changes_requested",
1044
+ review
1045
+ };
1046
+ return review === null ? { outcome: "not_found" } : {
1047
+ outcome: "not_pending",
1048
+ review
1049
+ };
1286
1050
  }
1287
1051
  //#endregion
1288
1052
  //#region src/db/size.ts
@@ -1477,6 +1241,7 @@ async function appendRevision(db, clock, site, write) {
1477
1241
  tags,
1478
1242
  now
1479
1243
  }),
1244
+ cancelPendingReview(db, site, write.entryId, write.expectedVersion, now),
1480
1245
  db.update(entries).set({
1481
1246
  version: sql`${entries.version} + 1`,
1482
1247
  headRev: revisionId,
@@ -1521,6 +1286,7 @@ async function publish$2(db, clock, site, write) {
1521
1286
  insertPublishAtVersion(db, row, write.expectedVersion),
1522
1287
  dropScheduleAtVersion(db, site, write.entryId, write.expectedVersion),
1523
1288
  insertEventWhere(db, site, write.entryId, write.event ?? null, "entry.published", now, [eq(entries.version, write.expectedVersion)]),
1289
+ approvePendingReview(db, site, write.entryId, write.expectedVersion, write.actor.userId, now),
1524
1290
  db.update(entries).set({
1525
1291
  status: "published",
1526
1292
  scheduledAt: null,
@@ -1530,7 +1296,7 @@ async function publish$2(db, clock, site, write) {
1530
1296
  version: sql`${entries.version} + 1`,
1531
1297
  updatedAt: now
1532
1298
  }).where(and(eq(entries.id, write.entryId), eq(entries.siteId, site), eq(entries.version, write.expectedVersion))).returning(entryFields)
1533
- ]))[3];
1299
+ ]))[4];
1534
1300
  if (changed === void 0) return whyNothingChanged(db, site, write.entryId);
1535
1301
  const entry = toEntry(changed);
1536
1302
  const revision = entry.publishedRevision === null ? null : await findRevision(db, site, entry.publishedRevision);
@@ -1573,7 +1339,7 @@ async function publishGroup$1(db, clock, site, group) {
1573
1339
  entryId: write.entryId
1574
1340
  };
1575
1341
  appended.push(row.id);
1576
- statements.push(insertPublishAtVersion(db, row, write.expectedVersion), dropScheduleAtVersion(db, site, write.entryId, write.expectedVersion), insertEventWhere(db, site, write.entryId, write.event ?? null, "entry.published", now, [eq(entries.version, write.expectedVersion)]), db.update(entries).set({
1342
+ statements.push(insertPublishAtVersion(db, row, write.expectedVersion), dropScheduleAtVersion(db, site, write.entryId, write.expectedVersion), insertEventWhere(db, site, write.entryId, write.event ?? null, "entry.published", now, [eq(entries.version, write.expectedVersion)]), approvePendingReview(db, site, write.entryId, write.expectedVersion, write.actor.userId, now), db.update(entries).set({
1577
1343
  status: "published",
1578
1344
  scheduledAt: null,
1579
1345
  publishedRev: entries.headRev,
@@ -1686,7 +1452,11 @@ function createD1Store(db, options = {}) {
1686
1452
  schedule: (site, write) => schedule(database, clock, site, write),
1687
1453
  dueSchedules: (site, now) => dueSchedules(database, site, now),
1688
1454
  cancelSchedule: (site, entryId) => cancelSchedule(database, clock, site, entryId),
1689
- unpublish: (site, write) => unpublish$1(database, clock, site, write)
1455
+ unpublish: (site, write) => unpublish$1(database, clock, site, write),
1456
+ requestReview: (site, write) => requestReview(database, clock, site, write, () => whyNothingChanged(database, site, write.entryId)),
1457
+ findReview: (site, id) => findReview(database, site, id),
1458
+ listReviews: (site, query) => listReviews$1(database, site, query),
1459
+ requestChanges: (site, write) => requestChanges$1(database, clock, site, write)
1690
1460
  };
1691
1461
  }
1692
1462
  //#endregion
@@ -1878,12 +1648,14 @@ function isEventMessage(body) {
1878
1648
  /**
1879
1649
  * The Queue consumer: delivers each event, acknowledges it once every subscriber has it, and asks
1880
1650
  * for a retry otherwise; after the Queue's retries it lands in the dead-letter queue. A message of
1881
- * another site, or that is no event, is acknowledged and dropped.
1651
+ * a site this deployment does not serve (`depsOf` has none for it), or that is no event, is
1652
+ * acknowledged and dropped.
1882
1653
  */
1883
- async function consumeEvents(messages, deps) {
1654
+ async function consumeEvents(messages, depsOf) {
1884
1655
  for (const message of messages) {
1885
1656
  const { body } = message;
1886
- if (!isEventMessage(body) || body.site !== deps.site.key) {
1657
+ const deps = isEventMessage(body) ? depsOf(body.site) : void 0;
1658
+ if (!isEventMessage(body) || deps === void 0) {
1887
1659
  message.ack();
1888
1660
  continue;
1889
1661
  }
@@ -1983,7 +1755,375 @@ function installErrorHandling(app, log) {
1983
1755
  }));
1984
1756
  }
1985
1757
  //#endregion
1758
+ //#region src/oauth/admin.ts
1759
+ /**
1760
+ * The admin as a client of the OAuth server (ADR-026): a public client, with PKCE, that a person
1761
+ * never has to allow. It is registered once per issuer, with `createClient`, and its id is kept in
1762
+ * `OAUTH_KV` beside the library's own records. A Client ID Metadata Document would need an `https`
1763
+ * URL that the Worker fetches from itself, which local development does not have.
1764
+ */
1765
+ /** Where the admin's id is kept, by issuer. The library's own keys never start with it. */
1766
+ const KEY = "hollware:admin-client:";
1767
+ /**
1768
+ * The admin's client, registered on first use. Two first requests at once may both register one:
1769
+ * the last id written wins, and the other client is never used.
1770
+ */
1771
+ async function adminClient(oauth, env) {
1772
+ const redirectUri = `${oauth.issuer}/admin/callback`;
1773
+ const stored = await env.OAUTH_KV.get(`${KEY}${oauth.issuer}`);
1774
+ if (stored !== null) return {
1775
+ clientId: stored,
1776
+ redirectUri
1777
+ };
1778
+ const client = await oauth.authorizationServer.getOAuthApi(env).createClient({
1779
+ clientName: "Hollware admin",
1780
+ redirectUris: [redirectUri],
1781
+ tokenEndpointAuthMethod: "none"
1782
+ });
1783
+ await env.OAUTH_KV.put(`${KEY}${oauth.issuer}`, client.clientId);
1784
+ return {
1785
+ clientId: client.clientId,
1786
+ redirectUri
1787
+ };
1788
+ }
1789
+ //#endregion
1790
+ //#region src/http/admin.ts
1791
+ /**
1792
+ * The admin at `/admin/` (ADR-026). Its hashed files are static assets that never reach the
1793
+ * Worker (`run_worker_first` in wrangler.jsonc); its page does, so that it goes out with the
1794
+ * policy below, and so does every path of the single-page app, which all get the same page. The
1795
+ * page learns how to sign in from `/admin/config.json`.
1796
+ */
1797
+ /**
1798
+ * What the admin's page may do: its own scripts and styles (CodeMirror and Nuxt UI write style
1799
+ * elements and attributes, hence `unsafe-inline` for styles only), images of the content from
1800
+ * anywhere over https, and requests to this origin alone. Nothing frames it.
1801
+ */
1802
+ const POLICY = [
1803
+ "default-src 'self'",
1804
+ "script-src 'self'",
1805
+ "style-src 'self' 'unsafe-inline'",
1806
+ "img-src 'self' data: https:",
1807
+ "connect-src 'self'",
1808
+ "font-src 'self' data:",
1809
+ "base-uri 'none'",
1810
+ "form-action 'self'",
1811
+ "frame-ancestors 'none'"
1812
+ ].join("; ");
1813
+ function notInstalled() {
1814
+ return Response.json({
1815
+ code: "not_found",
1816
+ message: "The admin is not installed in this Worker.",
1817
+ fix: "Bind the static assets as ASSETS and serve the files of @hollware/admin (adminAssets()), as the starter does."
1818
+ }, {
1819
+ status: 404,
1820
+ headers: { "cache-control": "no-store" }
1821
+ });
1822
+ }
1823
+ function installAdmin(app, options) {
1824
+ app.get("/admin/config.json", async (c) => {
1825
+ const oauth = options.oauth(c.env.HOLLWARE_PUBLIC_URL);
1826
+ const client = await adminClient(oauth, c.env);
1827
+ return c.json({
1828
+ issuer: oauth.issuer,
1829
+ clientId: client.clientId,
1830
+ redirectUri: client.redirectUri,
1831
+ resource: oauth.resources.v1,
1832
+ scope: SCOPES.join(" ")
1833
+ }, 200, { "cache-control": "no-store" });
1834
+ });
1835
+ const page = async (c) => {
1836
+ const assets = c.env.ASSETS;
1837
+ if (assets === void 0) return notInstalled();
1838
+ const origin = options.oauth(c.env.HOLLWARE_PUBLIC_URL).issuer;
1839
+ const answer = await assets.fetch(new Request(`${origin}/admin/`));
1840
+ if (!answer.ok) return notInstalled();
1841
+ const headers = new Headers(answer.headers);
1842
+ headers.set("content-security-policy", POLICY);
1843
+ headers.set("cache-control", "no-store");
1844
+ headers.set("x-content-type-options", "nosniff");
1845
+ headers.set("referrer-policy", "no-referrer");
1846
+ return new Response(answer.body, {
1847
+ status: 200,
1848
+ headers
1849
+ });
1850
+ };
1851
+ app.get("/admin", (c) => c.redirect("/admin/", 301));
1852
+ app.get("/admin/", page);
1853
+ app.get("/admin/*", async (c) => {
1854
+ if (/\.[a-z0-9]+$/i.test(c.req.path)) {
1855
+ const assets = c.env.ASSETS;
1856
+ if (assets === void 0) return notInstalled();
1857
+ return assets.fetch(c.req.raw);
1858
+ }
1859
+ return page(c);
1860
+ });
1861
+ }
1862
+ //#endregion
1863
+ //#region src/http/admin-api.ts
1864
+ /**
1865
+ * What only a person in the admin may do (ADR-026): see, make and revoke personal tokens, and
1866
+ * invite people, on the site of their token; and learn who they are and which sites they may switch
1867
+ * to (`/v1/admin/me`, the one route for every role). These are no operations, so no agent sees them over MCP, and a request needs an
1868
+ * access token of the admin's own client with `schema:admin`: a personal token, or a token of
1869
+ * another client, never reaches them, even an owner's. A leaked token cannot mint the next one.
1870
+ */
1871
+ /** A personal token lasts this long unless the person says otherwise (as `hollware token create`). */
1872
+ const TOKEN_DAYS = 90;
1873
+ const INVITATION_DAYS = 7;
1874
+ function json$1(body, status = 200) {
1875
+ return Response.json(body, {
1876
+ status,
1877
+ headers: { "cache-control": "no-store" }
1878
+ });
1879
+ }
1880
+ function refused(message, fix, status = 400) {
1881
+ return json$1({
1882
+ code: status === 400 ? "invalid_input" : "forbidden",
1883
+ message,
1884
+ fix
1885
+ }, status);
1886
+ }
1887
+ function parseScopes(json) {
1888
+ try {
1889
+ const value = JSON.parse(json);
1890
+ return Array.isArray(value) ? value.filter((scope) => typeof scope === "string" && isScope(scope)) : [];
1891
+ } catch {
1892
+ return [];
1893
+ }
1894
+ }
1895
+ function daysFrom(value, fallback) {
1896
+ if (value === void 0) return fallback;
1897
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 365 ? value : null;
1898
+ }
1899
+ function installAdminApi(app, options) {
1900
+ /**
1901
+ * Runs `handle` for a person in the admin with `schema:admin`, or with any scope when `anyone`;
1902
+ * anyone else is refused.
1903
+ */
1904
+ const admin = (handle, { anyone = false } = {}) => (c) => options.protect(c, async (principal) => {
1905
+ const ours = (await adminClient(options.oauth(c.env.HOLLWARE_PUBLIC_URL), c.env)).clientId;
1906
+ 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);
1907
+ if (!anyone && !principal.scopes.includes("schema:admin")) return insufficientScope(["schema:admin"], options.metadataUrl(c));
1908
+ return handle(c, principal);
1909
+ });
1910
+ const body = async (c) => {
1911
+ const value = await c.req.json().catch(() => null);
1912
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {};
1913
+ };
1914
+ app.get("/v1/admin/me", admin(async (c, person) => {
1915
+ const db = connect(c.env.DB);
1916
+ const [user] = await db.select({
1917
+ id: users.id,
1918
+ name: users.name
1919
+ }).from(users).where(eq(users.id, person.userId));
1920
+ const sites = (await membershipsOf(db, person.userId)).filter((membership) => options.sites.has(membership.site));
1921
+ return json$1({
1922
+ user,
1923
+ site: person.site,
1924
+ scopes: person.scopes,
1925
+ sites
1926
+ });
1927
+ }, { anyone: true }));
1928
+ app.get("/v1/admin/tokens", admin(async (c, person) => {
1929
+ return json$1({ tokens: (await connect(c.env.DB).select({
1930
+ id: tokens.id,
1931
+ kind: tokens.kind,
1932
+ label: tokens.label,
1933
+ scopes: tokens.scopes,
1934
+ user: users.name,
1935
+ expiresAt: tokens.expiresAt,
1936
+ revokedAt: tokens.revokedAt,
1937
+ lastUsedAt: tokens.lastUsedAt,
1938
+ createdAt: tokens.createdAt
1939
+ }).from(tokens).innerJoin(users, eq(users.id, tokens.userId)).where(eq(tokens.siteId, person.site)).orderBy(desc(tokens.createdAt), desc(tokens.id))).map((row) => ({
1940
+ ...row,
1941
+ scopes: parseScopes(row.scopes)
1942
+ })) });
1943
+ }));
1944
+ app.post("/v1/admin/tokens", admin(async (c, person) => {
1945
+ const input = await body(c);
1946
+ const label = typeof input["label"] === "string" ? input["label"].trim() : "";
1947
+ if (label === "" || label.length > 100) return refused("A token needs a label of 1 to 100 characters.", "Say what the token is for.");
1948
+ const asKind = input["kind"];
1949
+ const kind = asKind === void 0 || asKind === "personal" ? "personal" : asKind === "read" ? "read" : null;
1950
+ if (kind === null) return refused("kind is \"personal\" or \"read\".", "Leave it out for a personal token; \"read\" makes a token that reads published content.");
1951
+ const asked = kind === "read" ? ["content:read"] : Array.isArray(input["scopes"]) ? input["scopes"].filter((s) => typeof s === "string") : [];
1952
+ const scopes = person.scopes.filter((scope) => asked.includes(scope));
1953
+ if (scopes.length === 0) return refused("A token needs at least one scope that you have.", `Choose among ${person.scopes.join(", ")}.`);
1954
+ const days = daysFrom(input["expiresInDays"], TOKEN_DAYS);
1955
+ if (days === null) return refused("expiresInDays is a whole number from 1 to 365.", "Leave it out for 90 days.");
1956
+ const now = options.clock.now();
1957
+ const secret = kind === "read" ? generateReadToken() : generatePersonalToken();
1958
+ const row = {
1959
+ id: newId("tok"),
1960
+ kind,
1961
+ siteId: person.site,
1962
+ userId: person.userId,
1963
+ label,
1964
+ hash: await hashPersonalToken(secret),
1965
+ scopes: JSON.stringify(scopes),
1966
+ expiresAt: new Date(now.getTime() + days * 864e5).toISOString(),
1967
+ revokedAt: null,
1968
+ lastUsedAt: null,
1969
+ createdAt: now.toISOString(),
1970
+ updatedAt: now.toISOString()
1971
+ };
1972
+ await connect(c.env.DB).insert(tokens).values(row);
1973
+ return json$1({
1974
+ id: row.id,
1975
+ kind,
1976
+ label,
1977
+ scopes,
1978
+ expiresAt: row.expiresAt,
1979
+ secret
1980
+ }, 201);
1981
+ }));
1982
+ app.post("/v1/admin/tokens/:id/revoke", admin(async (c, person) => {
1983
+ const now = options.clock.now().toISOString();
1984
+ const db = connect(c.env.DB);
1985
+ const id = c.req.param("id") ?? "";
1986
+ const [revoked] = await db.update(tokens).set({
1987
+ revokedAt: now,
1988
+ updatedAt: now
1989
+ }).where(and(eq(tokens.siteId, person.site), eq(tokens.id, id), isNull(tokens.revokedAt))).returning({ id: tokens.id });
1990
+ if (revoked !== void 0) return json$1({
1991
+ id: revoked.id,
1992
+ revokedAt: now
1993
+ });
1994
+ const [row] = await db.select({
1995
+ id: tokens.id,
1996
+ revokedAt: tokens.revokedAt
1997
+ }).from(tokens).where(and(eq(tokens.siteId, person.site), eq(tokens.id, id)));
1998
+ return row === void 0 ? refused("There is no such token.", "List the tokens and pick one of them.", 404) : json$1({
1999
+ id: row.id,
2000
+ revokedAt: row.revokedAt
2001
+ });
2002
+ }));
2003
+ app.get("/v1/admin/people", admin(async (c, person) => {
2004
+ const db = connect(c.env.DB);
2005
+ const now = options.clock.now().toISOString();
2006
+ return json$1({
2007
+ people: await db.select({
2008
+ id: users.id,
2009
+ name: users.name,
2010
+ role: members.role,
2011
+ since: members.createdAt
2012
+ }).from(members).innerJoin(users, eq(users.id, members.userId)).where(eq(members.siteId, person.site)).orderBy(members.createdAt, users.id),
2013
+ invitations: await db.select({
2014
+ id: invitations.id,
2015
+ role: invitations.role,
2016
+ expiresAt: invitations.expiresAt
2017
+ }).from(invitations).where(and(eq(invitations.siteId, person.site), isNull(invitations.usedAt), isNull(invitations.userId), gt(invitations.expiresAt, now))).orderBy(desc(invitations.createdAt))
2018
+ });
2019
+ }));
2020
+ app.post("/v1/admin/invitations", admin(async (c, person) => {
2021
+ const input = await body(c);
2022
+ const role = input["role"];
2023
+ 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(", ")}).`);
2024
+ const days = daysFrom(input["days"], INVITATION_DAYS);
2025
+ if (days === null) return refused("days is a whole number from 1 to 365.", "Leave it out for 7 days.");
2026
+ const made = await createInvitation(connect(c.env.DB), {
2027
+ site: person.site,
2028
+ role,
2029
+ userId: null,
2030
+ now: options.clock.now(),
2031
+ days
2032
+ });
2033
+ const origin = options.oauth(c.env.HOLLWARE_PUBLIC_URL).issuer;
2034
+ return json$1({
2035
+ id: made.id,
2036
+ role,
2037
+ expiresAt: made.expiresAt,
2038
+ link: `${origin}/setup?invitation=${made.secret}`
2039
+ }, 201);
2040
+ }));
2041
+ }
2042
+ //#endregion
2043
+ //#region src/http/ops-api.ts
2044
+ const STATUS$1 = {
2045
+ invalid_input: 400,
2046
+ unknown_collection: 404,
2047
+ unknown_locale: 404,
2048
+ timeout: 504
2049
+ };
2050
+ function json(body, status = 200) {
2051
+ return Response.json(body, {
2052
+ status,
2053
+ headers: { "cache-control": "no-store" }
2054
+ });
2055
+ }
2056
+ /** Calls the handler of an operation with input its spec has parsed. */
2057
+ function invoke$1(handlers, spec, input, context) {
2058
+ const handler = handlers[spec.name];
2059
+ return handler(input, context);
2060
+ }
2061
+ function installOpsApi(app, options) {
2062
+ app.post("/v1/ops/:name", async (c) => options.protect(c, async (principal) => {
2063
+ const requestId = c.get("requestId");
2064
+ const spec = OPERATIONS.find((operation) => operation.name === c.req.param("name"));
2065
+ if (spec === void 0) return json({
2066
+ code: "invalid_input",
2067
+ message: `There is no operation ${c.req.param("name")}.`,
2068
+ fix: "Use the name of an operation, as tools/list of MCP lists them."
2069
+ }, 404);
2070
+ const scopes = spec.scopes;
2071
+ if (missingScopes(principal, scopes).length > 0) return insufficientScope(scopes, options.metadataUrl(c));
2072
+ const body = await c.req.json().catch(() => void 0);
2073
+ const parsed = spec.input.safeParse(body ?? {});
2074
+ if (!parsed.success) {
2075
+ const issues = parsed.error.issues.map((issue) => `${issue.path.join(".") || "input"}: ${issue.message}`);
2076
+ return json({
2077
+ code: "invalid_input",
2078
+ message: `The input of ${spec.name} is not valid: ${issues.join("; ")}`,
2079
+ fix: "Send the input of the operation as its input schema describes it."
2080
+ }, 400);
2081
+ }
2082
+ const site = options.sites.get(principal.site);
2083
+ if (site === void 0) throw new Error(`No site ${principal.site} for a checked token`);
2084
+ const context = {
2085
+ site,
2086
+ principal,
2087
+ view: "head",
2088
+ store: createD1Store(c.env.DB, { clock: options.clock }),
2089
+ clock: options.clock,
2090
+ client: { name: "hollware-admin" },
2091
+ requestId,
2092
+ notify: options.notify(c, requestId, site.key)
2093
+ };
2094
+ try {
2095
+ return json(spec.output.parse(await invoke$1(options.handlers, spec, parsed.data, context)));
2096
+ } catch (error) {
2097
+ if (error instanceof HollwareError) return json({
2098
+ code: error.code,
2099
+ message: error.message,
2100
+ fix: error.fix
2101
+ }, STATUS$1[error.code] ?? 500);
2102
+ options.log({
2103
+ kind: "request_error",
2104
+ requestId,
2105
+ method: "POST",
2106
+ path: c.req.path,
2107
+ detail: describeError(error)
2108
+ });
2109
+ return json({
2110
+ code: "internal",
2111
+ message: "The operation failed on the server.",
2112
+ fix: `Try again. If it keeps failing, give the site operator this request id: ${requestId}.`
2113
+ }, 500);
2114
+ }
2115
+ }));
2116
+ }
2117
+ //#endregion
1986
2118
  //#region src/http/read-api.ts
2119
+ /** How long a browser keeps the answer of a preflight, in seconds. */
2120
+ const PREFLIGHT_MAX_AGE = 600;
2121
+ /** The origin of a request from a page of another origin than Hollware's own, or `null`. */
2122
+ function crossOriginOf(c) {
2123
+ const origin = c.req.header("origin");
2124
+ if (origin === void 0) return null;
2125
+ return origin.toLowerCase() === new URL(c.env.HOLLWARE_PUBLIC_URL).origin ? null : origin;
2126
+ }
1987
2127
  const STATUS = {
1988
2128
  invalid_input: 400,
1989
2129
  unknown_collection: 404,
@@ -2019,67 +2159,83 @@ async function answer(c, value) {
2019
2159
  }
2020
2160
  function installReadApi(app, options) {
2021
2161
  app.use("/v1/*", async (c, next) => {
2162
+ if (c.req.path.startsWith("/v1/ops/") || c.req.path.startsWith("/v1/admin/")) return next();
2163
+ const crossOrigin = crossOriginOf(c);
2164
+ if (c.req.method === "OPTIONS") {
2165
+ const headers = new Headers({
2166
+ allow: "GET, HEAD, OPTIONS",
2167
+ vary: "Origin"
2168
+ });
2169
+ if (crossOrigin !== null && options.anyCorsOrigin(crossOrigin)) {
2170
+ headers.set("access-control-allow-origin", crossOrigin);
2171
+ headers.set("access-control-allow-methods", "GET, HEAD");
2172
+ headers.set("access-control-allow-headers", "authorization, if-none-match");
2173
+ headers.set("access-control-max-age", String(PREFLIGHT_MAX_AGE));
2174
+ }
2175
+ return new Response(null, {
2176
+ status: 204,
2177
+ headers
2178
+ });
2179
+ }
2022
2180
  if (c.req.method !== "GET" && c.req.method !== "HEAD") return c.json({
2023
2181
  code: "invalid_input",
2024
2182
  message: "The read API only reads.",
2025
2183
  fix: "Use GET."
2026
2184
  }, 405, { allow: "GET, HEAD" });
2027
2185
  if (c.req.path === "/v1/openapi.json") return next();
2028
- const requestId = c.get("requestId");
2029
- const token = readBearerToken(c.req.raw);
2030
- if (token === null) {
2031
- options.log({
2032
- kind: "auth_refused",
2033
- requestId,
2034
- reason: "no_token"
2186
+ c.res = await options.protect(c, async (principal) => {
2187
+ if (!principal.scopes.includes("content:read")) return insufficientScope(["content:read"], options.metadataUrl(c));
2188
+ c.set("site", principal.site);
2189
+ if (crossOrigin !== null && !options.corsOrigins(principal.site).includes(crossOrigin)) return c.json({
2190
+ code: "forbidden_origin",
2191
+ message: `The site "${principal.site}" does not let pages of ${crossOrigin} read it.`,
2192
+ fix: `Add "${crossOrigin}" to cors.origins of the site in hollware.config.ts, or read from a server.`
2193
+ }, 403, {
2194
+ vary: "Origin",
2195
+ "access-control-allow-origin": new URL(c.env.HOLLWARE_PUBLIC_URL).origin
2035
2196
  });
2036
- return unauthorized();
2037
- }
2038
- const check = await checkPersonalToken(c.env.DB, token, {
2039
- site: options.site,
2040
- now: options.clock.now()
2197
+ const answered = await (async () => {
2198
+ try {
2199
+ await next();
2200
+ return c.res;
2201
+ } catch (error) {
2202
+ if (!(error instanceof HollwareError)) throw error;
2203
+ return c.json({
2204
+ code: error.code,
2205
+ message: error.message,
2206
+ fix: error.fix
2207
+ }, STATUS[error.code] ?? 500);
2208
+ }
2209
+ })();
2210
+ if (crossOrigin === null) return answered;
2211
+ const shared = new Response(answered.body, answered);
2212
+ shared.headers.set("access-control-allow-origin", crossOrigin);
2213
+ shared.headers.set("access-control-expose-headers", "etag");
2214
+ shared.headers.append("vary", "Origin");
2215
+ return shared;
2041
2216
  });
2042
- if (!check.ok) {
2043
- options.log({
2044
- kind: "auth_refused",
2045
- requestId,
2046
- reason: check.reason
2047
- });
2048
- return unauthorized(check.reason);
2049
- }
2050
- if (!check.principal.scopes.includes("content:read")) return insufficientScope(["content:read"]);
2051
- if (check.touch !== null) c.executionCtx.waitUntil(check.touch);
2052
- try {
2053
- return await next();
2054
- } catch (error) {
2055
- if (!(error instanceof HollwareError)) throw error;
2056
- return c.json({
2057
- code: error.code,
2058
- message: error.message,
2059
- fix: error.fix
2060
- }, STATUS[error.code] ?? 500);
2061
- }
2217
+ return c.res;
2062
2218
  });
2063
- app.get("/v1/openapi.json", (c) => c.json(openApiDocument(options.site)));
2064
- app.get("/v1/site", async (c) => answer(c, await options.read(c.env).describeSite(options.site)));
2219
+ app.get("/v1/openapi.json", (c) => c.json(openApiDocument()));
2220
+ app.get("/v1/site", async (c) => answer(c, await options.read(c.env).describeSite(c.get("site"))));
2065
2221
  app.get("/v1/:collection", async (c) => {
2066
2222
  const query = c.req.query();
2067
2223
  const limit = query["limit"] === void 0 ? void 0 : Number(query["limit"]);
2068
- return answer(c, await options.read(c.env).listEntries(options.site, c.req.param("collection"), {
2224
+ return answer(c, await options.read(c.env).listEntries(c.get("site"), c.req.param("collection"), {
2069
2225
  ...query["locale"] === void 0 ? {} : { locale: query["locale"] },
2070
2226
  ...query["tag"] === void 0 ? {} : { tag: query["tag"] },
2071
2227
  ...limit === void 0 ? {} : { limit },
2072
2228
  ...query["cursor"] === void 0 ? {} : { cursor: query["cursor"] }
2073
2229
  }));
2074
2230
  });
2075
- app.get("/v1/:collection/translations/:group", async (c) => answer(c, await options.read(c.env).getTranslations(options.site, c.req.param("collection"), c.req.param("group"))));
2231
+ app.get("/v1/:collection/translations/:group", async (c) => answer(c, await options.read(c.env).getTranslations(c.get("site"), c.req.param("collection"), c.req.param("group"))));
2076
2232
  app.get("/v1/:collection/:locale/:slug", async (c) => {
2077
2233
  const formats = c.req.query("formats");
2078
- return answer(c, await options.read(c.env).getEntry(options.site, c.req.param("collection"), c.req.param("slug"), c.req.param("locale"), formats === void 0 ? {} : { formats: formats.split(",").flatMap((format) => format === "markdown" || format === "document" || format === "html" ? [format] : []) }));
2234
+ 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 ? {} : { formats: formats.split(",").flatMap((format) => format === "markdown" || format === "document" || format === "html" ? [format] : []) }));
2079
2235
  });
2080
2236
  }
2081
2237
  /** The OpenAPI 3.1 document of the read API. */
2082
- function openApiDocument(site) {
2238
+ function openApiDocument() {
2083
2239
  const ok = (description) => ({
2084
2240
  description,
2085
2241
  headers: { ETag: { schema: { type: "string" } } },
@@ -2087,7 +2243,7 @@ function openApiDocument(site) {
2087
2243
  });
2088
2244
  const common = {
2089
2245
  "304": { description: "Not modified: the ETag of If-None-Match is current." },
2090
- "401": { description: "No token, or one that is unknown, expired or revoked." },
2246
+ "401": { description: "No token, or one that is unknown, expired or revoked. The challenge names the protected resource metadata." },
2091
2247
  "403": { description: "The token lacks content:read." }
2092
2248
  };
2093
2249
  const param = (name, location, required) => ({
@@ -2099,7 +2255,7 @@ function openApiDocument(site) {
2099
2255
  return {
2100
2256
  openapi: "3.1.0",
2101
2257
  info: {
2102
- title: `Hollware read API (${site})`,
2258
+ title: "Hollware read API",
2103
2259
  version: "1"
2104
2260
  },
2105
2261
  components: { securitySchemes: { token: {
@@ -2156,8 +2312,599 @@ function openApiDocument(site) {
2156
2312
  };
2157
2313
  }
2158
2314
  //#endregion
2315
+ //#region src/identity/pages.ts
2316
+ /**
2317
+ * The pages a person sees to get in (ADR-026): set up a passkey from a link, and sign in. They
2318
+ * are rendered by the Worker, outside the admin's bundle, with one inline script under a nonce
2319
+ * and nothing framing them. The script uses the WebAuthn JSON methods of the browser
2320
+ * (`PublicKeyCredential.parseCreationOptionsFromJSON`, `credential.toJSON()`), and reads the
2321
+ * invitation from the address bar, so the page never prints it.
2322
+ */
2323
+ /**
2324
+ * The look of hollware.com and the admin, with nothing to fetch: the page's policy loads no font,
2325
+ * so the type is the system's. Ink greys and the mint accent, dark when the device is.
2326
+ */
2327
+ const STYLE = `:root{color-scheme:light dark;--bg:#f6f7f8;--card:#fff;--fg:#15181b;--muted:#545c66;--line:#d6dadf;--accent:#0c825d;--on-accent:#fff;--danger:#b42318}
2328
+ @media (prefers-color-scheme:dark){:root{--bg:#08090a;--card:#0f1113;--fg:#eceef0;--muted:#8a939e;--line:rgb(255 255 255/.1);--accent:#3ee6a8;--on-accent:#04140d;--danger:#ff8a80}}
2329
+ *{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}
2330
+ main{width:100%;max-width:26rem;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:2rem}
2331
+ .brand{display:flex;align-items:center;gap:.6rem;margin-bottom:1.75rem;font-weight:600;letter-spacing:-.01em}.brand svg{width:26px;height:26px}
2332
+ 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)}
2333
+ 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}
2334
+ 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}
2335
+ 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}
2336
+ code{font:.82em ui-monospace,'SF Mono',Menlo,monospace;color:var(--muted)}
2337
+ 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)}
2338
+ button:not([value=deny]){background:var(--accent);border-color:var(--accent);color:var(--on-accent)}
2339
+ [role=alert]{color:var(--danger)}`;
2340
+ /** The mark of Hollware, before the title of every page. */
2341
+ const BRAND = `<div class="brand"><svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="8" fill="currentColor" opacity=".08"/><path d="M9 8v16M23 8v16M9 16h14" stroke="var(--accent)" stroke-width="3" stroke-linecap="round" fill="none"/></svg>Hollware</div>`;
2342
+ const POST = `async function post(path, body) {
2343
+ const response = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
2344
+ const data = await response.json()
2345
+ if (!response.ok) throw new Error(data.message + ' ' + (data.fix ?? ''))
2346
+ return data
2347
+ }
2348
+ function fail(error) { document.getElementById('error').textContent = error.message }`;
2349
+ function page(title, nonce, body, script) {
2350
+ return `<!doctype html>
2351
+ <html lang="en">
2352
+ <head>
2353
+ <meta charset="utf-8">
2354
+ <meta name="viewport" content="width=device-width, initial-scale=1">
2355
+ <title>${title} · Hollware</title>
2356
+ <style>${STYLE}</style>
2357
+ </head>
2358
+ <body>
2359
+ <main>
2360
+ ${BRAND}
2361
+ <h1>${title}</h1>
2362
+ ${body}
2363
+ <p id="error" role="alert"></p>
2364
+ </main>
2365
+ <script nonce="${nonce}">${POST}
2366
+ ${script}<\/script>
2367
+ </body>
2368
+ </html>
2369
+ `;
2370
+ }
2371
+ function setupPage(nonce) {
2372
+ return page("Set up your passkey", nonce, `<p>A passkey lets you into this Hollware with your device's lock: no password.</p>
2373
+ <form id="form">
2374
+ <label for="name">Your name</label><input id="name" name="name" autocomplete="name">
2375
+ <label for="label">This device</label><input id="label" name="label" value="Passkey">
2376
+ <button type="submit">Create the passkey</button>
2377
+ </form>`, `document.getElementById('form').addEventListener('submit', async (event) => {
2378
+ event.preventDefault()
2379
+ try {
2380
+ const invitation = new URLSearchParams(location.search).get('invitation') ?? ''
2381
+ const { challengeId, options } = await post('/auth/register/options', { invitation })
2382
+ const credential = await navigator.credentials.create({ publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options) })
2383
+ await post('/auth/register/verify', { challengeId, response: credential.toJSON(), name: document.getElementById('name').value, label: document.getElementById('label').value })
2384
+ location.href = '/admin/'
2385
+ } catch (error) { fail(error) }
2386
+ })`);
2387
+ }
2388
+ function signInPage(nonce) {
2389
+ return page("Sign in", nonce, `<p>Sign in to this Hollware with your passkey.</p>
2390
+ <button id="signin" type="button">Sign in with a passkey</button>`, `document.getElementById('signin').addEventListener('click', async () => {
2391
+ try {
2392
+ const { challengeId, options } = await post('/auth/signin/options', {})
2393
+ const credential = await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options) })
2394
+ await post('/auth/signin/verify', { challengeId, response: credential.toJSON() })
2395
+ const target = new URLSearchParams(location.search).get('return_to') ?? '/admin/'
2396
+ location.href = target.startsWith('/') && !target.startsWith('//') ? target : '/admin/'
2397
+ } catch (error) { fail(error) }
2398
+ })`);
2399
+ }
2400
+ /** The headers of a page: no framing, only its own script, and no caching. */
2401
+ function pageHeaders(nonce) {
2402
+ return {
2403
+ "content-type": "text/html; charset=utf-8",
2404
+ "cache-control": "no-store",
2405
+ "content-security-policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; connect-src 'self'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'`,
2406
+ "x-content-type-options": "nosniff",
2407
+ "referrer-policy": "no-referrer"
2408
+ };
2409
+ }
2410
+ /** Text for HTML: every string a client chose (its name, its redirect host, a scope) goes through it. */
2411
+ function escapeHtml(value) {
2412
+ return value.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
2413
+ }
2414
+ /** What a scope lets a client do, in the words of the consent page. */
2415
+ const SCOPE_WORDS = {
2416
+ "content:read": "Read the content, drafts included",
2417
+ "content:write": "Write drafts",
2418
+ "content:publish": "Publish and unpublish",
2419
+ "schema:admin": "Change the site settings"
2420
+ };
2421
+ /**
2422
+ * The consent page of `/authorize` (ADR-026): which client asks, where the tokens go, and what it
2423
+ * may do, with Allow and Deny. It has no script, and it posts the handle that `beginConsent` bound
2424
+ * to this browser. Everything the client chose is escaped.
2425
+ */
2426
+ function consentPage(view) {
2427
+ const name = escapeHtml(view.clientName);
2428
+ 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>`;
2429
+ 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>" : "";
2430
+ 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>`;
2431
+ 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");
2432
+ return `<!doctype html>
2433
+ <html lang="en">
2434
+ <head>
2435
+ <meta charset="utf-8">
2436
+ <meta name="viewport" content="width=device-width, initial-scale=1">
2437
+ <title>Allow ${name} · Hollware</title>
2438
+ <style>${STYLE}</style>
2439
+ </head>
2440
+ <body>
2441
+ <main>
2442
+ ${BRAND}
2443
+ <h1>Allow ${name} to use this Hollware?</h1>
2444
+ ${origin}
2445
+ <p>Signed in as <strong>${escapeHtml(view.person)}</strong>. Access goes to <strong>${escapeHtml(view.redirectHost)}</strong>.</p>
2446
+ ${loopback}
2447
+ <form method="post" action="/authorize">
2448
+ <input type="hidden" name="handle" value="${escapeHtml(view.handle)}">
2449
+ ${sites}
2450
+ ${scopes}
2451
+ <p><button name="decision" value="approve">Allow</button> <button name="decision" value="deny">Deny</button></p>
2452
+ </form>
2453
+ </main>
2454
+ </body>
2455
+ </html>
2456
+ `;
2457
+ }
2458
+ /** A page that only says what went wrong and what to do. */
2459
+ function messagePage(title, message) {
2460
+ return `<!doctype html>
2461
+ <html lang="en">
2462
+ <head>
2463
+ <meta charset="utf-8">
2464
+ <meta name="viewport" content="width=device-width, initial-scale=1">
2465
+ <title>${escapeHtml(title)} · Hollware</title>
2466
+ <style>${STYLE}</style>
2467
+ </head>
2468
+ <body>
2469
+ <main>
2470
+ ${BRAND}
2471
+ <h1>${escapeHtml(title)}</h1>
2472
+ <p>${escapeHtml(message)}</p>
2473
+ </main>
2474
+ </body>
2475
+ </html>
2476
+ `;
2477
+ }
2478
+ /**
2479
+ * The headers of a page without a script: no framing and no caching. The form of the consent page
2480
+ * posts to `/authorize`, which redirects to the client, so `form-action` is not restricted: a
2481
+ * browser applies it to the redirect too.
2482
+ */
2483
+ function plainPageHeaders() {
2484
+ return {
2485
+ "content-type": "text/html; charset=utf-8",
2486
+ "cache-control": "no-store",
2487
+ "content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'`,
2488
+ "x-content-type-options": "nosniff",
2489
+ "referrer-policy": "no-referrer"
2490
+ };
2491
+ }
2492
+ //#endregion
2493
+ //#region src/identity/routes.ts
2494
+ /**
2495
+ * The ceremonies, with SimpleWebAuthn and its parsers, in a chunk of their own: only these four
2496
+ * routes use them, and a Worker that serves MCP never pays for them at startup (`budgets.json`).
2497
+ */
2498
+ const ceremonies = () => import("./ceremonies-V_1BbmZQ.mjs");
2499
+ /** The cookie of a signed-in person: only `/authorize` reads it (ADR-026). */
2500
+ const SESSION_COOKIE = "hollware_session";
2501
+ function sessionCookie(secret) {
2502
+ return `${SESSION_COOKIE}=${secret}; HttpOnly; Secure; SameSite=Lax; Path=/authorize; Max-Age=43200`;
2503
+ }
2504
+ function isRecord$1(value) {
2505
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2506
+ }
2507
+ /** The setup and sign-in pages, and the four steps of the passkey ceremonies. */
2508
+ function installIdentity(app, options) {
2509
+ const ceremony = (c) => ({
2510
+ db: connect(c.env.DB),
2511
+ publicUrl: c.env.HOLLWARE_PUBLIC_URL,
2512
+ now: options.clock.now()
2513
+ });
2514
+ const body = async (c) => {
2515
+ const value = await c.req.json().catch(() => null);
2516
+ if (!isRecord$1(value)) throw new HollwareError("invalid_input", "The body is not a JSON object.", "Send a JSON object.");
2517
+ return value;
2518
+ };
2519
+ const text = (value) => typeof value === "string" ? value : "";
2520
+ const handle = (step) => async (c) => {
2521
+ try {
2522
+ return await step(c);
2523
+ } catch (error) {
2524
+ if (!(error instanceof HollwareError)) throw error;
2525
+ return c.json({
2526
+ code: error.code,
2527
+ message: error.message,
2528
+ fix: error.fix
2529
+ }, 400);
2530
+ }
2531
+ };
2532
+ const html = (render) => () => {
2533
+ const nonce = crypto.randomUUID().replace(/-/g, "");
2534
+ return new Response(render(nonce), { headers: pageHeaders(nonce) });
2535
+ };
2536
+ app.get("/setup", html(setupPage));
2537
+ app.get("/signin", html(signInPage));
2538
+ app.post("/auth/register/options", handle(async (c) => c.json(await (await ceremonies()).registrationOptions(ceremony(c), text((await body(c))["invitation"])))));
2539
+ app.post("/auth/register/verify", handle(async (c) => {
2540
+ const input = await body(c);
2541
+ const response = input["response"];
2542
+ if (!isRecord$1(response)) throw new HollwareError("invalid_input", "The body has no passkey.", "Send the credential as JSON.");
2543
+ const answer = await (await ceremonies()).verifyRegistration(ceremony(c), {
2544
+ challengeId: text(input["challengeId"]),
2545
+ response: JSON.parse(JSON.stringify(response)),
2546
+ name: text(input["name"]),
2547
+ label: text(input["label"])
2548
+ });
2549
+ return c.json({ ok: true }, 200, { "set-cookie": sessionCookie(answer.session) });
2550
+ }));
2551
+ app.post("/auth/signin/options", handle(async (c) => c.json(await (await ceremonies()).authenticationOptions(ceremony(c)))));
2552
+ app.post("/auth/signin/verify", handle(async (c) => {
2553
+ const input = await body(c);
2554
+ const response = input["response"];
2555
+ if (!isRecord$1(response)) throw new HollwareError("invalid_input", "The body has no passkey.", "Send the credential as JSON.");
2556
+ const answer = await (await ceremonies()).verifyAuthentication(ceremony(c), {
2557
+ challengeId: text(input["challengeId"]),
2558
+ response: JSON.parse(JSON.stringify(response))
2559
+ });
2560
+ return c.json({ ok: true }, 200, { "set-cookie": sessionCookie(answer.session) });
2561
+ }));
2562
+ }
2563
+ //#endregion
2564
+ //#region src/oauth/authorize.ts
2565
+ /** What a client gets when it asks for no scope: the baseline that `/mcp` and `/v1` name. */
2566
+ const BASELINE = ["content:read"];
2567
+ /**
2568
+ * The scopes a person may grant: those asked for that the person's role allows, in the order of
2569
+ * the role. Anything that is not a scope of Hollware is left out.
2570
+ */
2571
+ function grantableScopes(role, asked) {
2572
+ if (!isRole(role)) return [];
2573
+ const scopes = asked.filter(isScope);
2574
+ return ROLE_SCOPES[role].filter((scope) => scopes.includes(scope));
2575
+ }
2576
+ function html(body, status = 200, extra) {
2577
+ const headers = new Headers(plainPageHeaders());
2578
+ extra?.forEach((value, name) => {
2579
+ if (name.toLowerCase() === "set-cookie") headers.append(name, value);
2580
+ });
2581
+ return new Response(body, {
2582
+ status,
2583
+ headers
2584
+ });
2585
+ }
2586
+ /**
2587
+ * An error of the authorization endpoint, as the consent page guide says: a redirect back to the
2588
+ * client only when the library validated the client and its exact redirect URI, and a page of our
2589
+ * own otherwise. Anything else is a bug or an outage, and goes on to the error handler.
2590
+ */
2591
+ function answerError(error) {
2592
+ if (error instanceof AuthorizationError && error.redirectTo !== void 0) return new Response(null, {
2593
+ status: 302,
2594
+ headers: { location: error.redirectTo }
2595
+ });
2596
+ if (error instanceof AuthorizationError) return html(messagePage("This request cannot go on", error.description), 400);
2597
+ if (error instanceof CimdFetchError) return html(messagePage("This app could not be verified", "Its metadata document could not be fetched or is not valid. Try again later, or ask its developer."), 400);
2598
+ throw error;
2599
+ }
2600
+ /**
2601
+ * The authorization endpoint of the OAuth server (ADR-026), which is Hollware's page and not the
2602
+ * library's: the library parses and validates the request, and this page signs the person in,
2603
+ * shows what the client asks for, and completes the authorization with what the person allowed.
2604
+ *
2605
+ * - A person without a session is sent to `/signin`, which comes back here once they are in. The
2606
+ * session cookie is only sent to `/authorize`.
2607
+ * - The admin, Hollware's own client (`admin.ts`), gets what it asks for within the role, with no
2608
+ * consent page.
2609
+ * - The page offers the scopes the client asked for that the person's role allows, each checked;
2610
+ * the person may untick any. A request that would get no scope at all is refused with
2611
+ * `access_denied`.
2612
+ * - The grant stores the person and the site. What its tokens may do is read again on each request
2613
+ * against the person's role then (`server.ts`).
2614
+ * - The site is the one `?site=` names, if the person is a member of it; else the only site the
2615
+ * person is a member of; else the consent page asks which (v0.4). The admin names its site, and
2616
+ * takes the first one when it names none.
2617
+ */
2618
+ function installAuthorize(app, options) {
2619
+ const person = async (c) => {
2620
+ const secret = getCookie(c, SESSION_COOKIE);
2621
+ if (secret === void 0) return null;
2622
+ const db = connect(c.env.DB);
2623
+ const found = await findSession(db, secret, options.clock.now());
2624
+ if (found === null) return null;
2625
+ const memberships = (await membershipsOf(db, found.userId)).filter((membership) => options.sites.has(membership.site));
2626
+ return {
2627
+ ...found,
2628
+ memberships
2629
+ };
2630
+ };
2631
+ const signIn = (c) => {
2632
+ const url = new URL(c.req.url);
2633
+ const back = `${url.pathname}${url.search}`;
2634
+ return c.redirect(`/signin?return_to=${encodeURIComponent(back)}`, 302);
2635
+ };
2636
+ const notMember = (site) => html(messagePage("Not a member", site === void 0 ? "You are not a member of any site of this Hollware. Ask an owner for an invitation." : `You are not a member of the site "${site}". Ask its owner for an invitation.`), 403);
2637
+ app.get("/authorize", async (c) => {
2638
+ const server = options.oauth(c.env.HOLLWARE_PUBLIC_URL);
2639
+ const oauth = server.authorizationServer.getOAuthApi(c.env);
2640
+ try {
2641
+ const request = await oauth.parseAuthRequest(c.req.raw);
2642
+ const signedIn = await person(c);
2643
+ if (signedIn === null) return signIn(c);
2644
+ const [first] = signedIn.memberships;
2645
+ if (first === void 0) return notMember(void 0);
2646
+ const asked = c.req.query("site");
2647
+ const named = asked === void 0 ? void 0 : signedIn.memberships.find((m) => m.site === asked);
2648
+ if (asked !== void 0 && named === void 0) return notMember(asked);
2649
+ if (request.clientId === (await adminClient(server, c.env)).clientId) {
2650
+ const membership = named ?? first;
2651
+ const props = {
2652
+ userId: signedIn.userId,
2653
+ site: membership.site
2654
+ };
2655
+ const { redirectTo } = await oauth.completeAuthorization({
2656
+ request,
2657
+ userId: signedIn.userId,
2658
+ metadata: { site: membership.site },
2659
+ scope: grantableScopes(membership.role, request.scope),
2660
+ props
2661
+ });
2662
+ return new Response(null, {
2663
+ status: 302,
2664
+ headers: { location: redirectTo }
2665
+ });
2666
+ }
2667
+ const choices = named !== void 0 ? [named] : signedIn.memberships;
2668
+ const asking = request.scope.length === 0 ? BASELINE : request.scope;
2669
+ const scopes = [...new Set(choices.flatMap((m) => grantableScopes(m.role, asking)))];
2670
+ if (scopes.length === 0) {
2671
+ const location = authorizationErrorRedirect(request, "access_denied", "Your role allows none of the scopes this client asked for.");
2672
+ return new Response(null, {
2673
+ status: 302,
2674
+ headers: { location }
2675
+ });
2676
+ }
2677
+ const details = await oauth.describeConsent(request);
2678
+ const consent = await oauth.beginConsent(request);
2679
+ return html(consentPage({
2680
+ clientName: details.clientName,
2681
+ clientDomain: details.clientDomain,
2682
+ redirectHost: details.redirectHost,
2683
+ redirectIsLoopback: details.redirectIsLoopback,
2684
+ person: signedIn.name ?? "you",
2685
+ scopes,
2686
+ sites: choices.map((m) => m.site),
2687
+ handle: consent.handle
2688
+ }), 200, consent.headers);
2689
+ } catch (error) {
2690
+ return answerError(error);
2691
+ }
2692
+ });
2693
+ app.post("/authorize", async (c) => {
2694
+ const oauth = options.oauth(c.env.HOLLWARE_PUBLIC_URL).authorizationServer.getOAuthApi(c.env);
2695
+ try {
2696
+ const signedIn = await person(c);
2697
+ if (signedIn === null) return html(messagePage("Your session ended", "Sign in again, then start from the app."), 401);
2698
+ const form = await c.req.formData();
2699
+ const value = form.get("handle");
2700
+ const handle = typeof value === "string" ? value : "";
2701
+ if (form.get("decision") !== "approve") {
2702
+ const denied = await oauth.denyConsent(c.req.raw, handle);
2703
+ return new Response(null, {
2704
+ status: 302,
2705
+ headers: denied.headers
2706
+ });
2707
+ }
2708
+ const chosen = form.get("site");
2709
+ const membership = signedIn.memberships.find((m) => m.site === chosen);
2710
+ if (membership === void 0) return notMember(typeof chosen === "string" ? chosen : void 0);
2711
+ const scope = grantableScopes(membership.role, form.getAll("scope").map(String));
2712
+ const approved = await oauth.approveConsent(c.req.raw, handle, { scope });
2713
+ if (scope.length === 0) {
2714
+ const location = authorizationErrorRedirect(approved.request, "access_denied", "No scope was allowed.");
2715
+ approved.headers.set("location", location);
2716
+ return new Response(null, {
2717
+ status: 302,
2718
+ headers: approved.headers
2719
+ });
2720
+ }
2721
+ const props = {
2722
+ userId: signedIn.userId,
2723
+ site: membership.site
2724
+ };
2725
+ const { redirectTo } = await oauth.completeAuthorization({
2726
+ request: approved.request,
2727
+ userId: signedIn.userId,
2728
+ metadata: { site: membership.site },
2729
+ scope,
2730
+ props
2731
+ });
2732
+ approved.headers.set("location", redirectTo);
2733
+ return new Response(null, {
2734
+ status: 302,
2735
+ headers: approved.headers
2736
+ });
2737
+ } catch (error) {
2738
+ return answerError(error);
2739
+ }
2740
+ });
2741
+ }
2742
+ //#endregion
2743
+ //#region src/oauth/server.ts
2744
+ /** Access tokens live an hour, and a grant thirty days from its code, refreshed or not (ADR-026). */
2745
+ const ACCESS_TOKEN_TTL_SECONDS = 3600;
2746
+ const REFRESH_TOKEN_TTL_SECONDS = 2592e3;
2747
+ /** The role of a member of the site, read on every request: a change of role takes effect at once. */
2748
+ async function roleOf(db, site, userId) {
2749
+ return (await db.prepare("SELECT role FROM members WHERE site_id = ?1 AND user_id = ?2").bind(site, userId).first())?.role ?? null;
2750
+ }
2751
+ function isGrantProps(value) {
2752
+ return typeof value === "object" && value !== null && typeof Reflect.get(value, "userId") === "string" && typeof Reflect.get(value, "site") === "string";
2753
+ }
2754
+ /**
2755
+ * Builds the OAuth server of a deployment (ADR-026), once for each public URL it is asked about:
2756
+ * the issuer and the resources are that URL's, and the Worker learns it from its environment, on
2757
+ * the first request. One Worker holds both roles: an `OAuthAuthorizationServer`, whose
2758
+ * `/authorize` is Hollware's own page (`authorize.ts`), and an `OAuthResourceServer` for `/mcp`
2759
+ * and for `/v1`.
2760
+ *
2761
+ * A resource accepts two kinds of token, told apart by their shape. A personal token
2762
+ * (`hw_pat_…`) is looked up in D1 as before, and never reaches the OAuth server; any other token
2763
+ * is the OAuth server's to validate, for that resource alone. Either way the principal's scopes
2764
+ * are what the token carries intersected with what the user's role allows now.
2765
+ */
2766
+ function createOAuth(options) {
2767
+ const built = /* @__PURE__ */ new Map();
2768
+ return (publicUrl) => {
2769
+ const issuer = new URL(publicUrl).origin;
2770
+ const existing = built.get(issuer);
2771
+ if (existing !== void 0) return existing;
2772
+ const oauth = build(issuer, options);
2773
+ built.set(issuer, oauth);
2774
+ return oauth;
2775
+ };
2776
+ }
2777
+ function build(issuer, options) {
2778
+ const resources = {
2779
+ mcp: `${issuer}/mcp`,
2780
+ v1: `${issuer}/v1`
2781
+ };
2782
+ const metadataUrls = {
2783
+ mcp: `${issuer}/.well-known/oauth-protected-resource/mcp`,
2784
+ v1: `${issuer}/.well-known/oauth-protected-resource/v1`
2785
+ };
2786
+ const authorizationServer = new OAuthAuthorizationServer({
2787
+ issuer,
2788
+ resources: [resources.mcp, resources.v1],
2789
+ scopesSupported: [...SCOPES],
2790
+ accessTokenTTL: ACCESS_TOKEN_TTL_SECONDS,
2791
+ refreshTokenTTL: REFRESH_TOKEN_TTL_SECONDS,
2792
+ clientIdMetadataDocumentEnabled: true,
2793
+ ...options.dynamicClientRegistration ? { clientRegistrationEndpoint: "/oauth/register" } : {},
2794
+ onError: ({ code, internal }) => {
2795
+ options.log({
2796
+ kind: "oauth_error",
2797
+ code,
2798
+ reason: internal?.reason ?? null
2799
+ });
2800
+ }
2801
+ });
2802
+ /** The protected routes in flight, by their request: the resource server hands it on as it is. */
2803
+ const pending = /* @__PURE__ */ new WeakMap();
2804
+ const validate = (env, request) => async (resource, token) => {
2805
+ try {
2806
+ return await check(env, request, resource, token);
2807
+ } catch (error) {
2808
+ pending.get(request)?.failed(error);
2809
+ throw error;
2810
+ }
2811
+ };
2812
+ const check = async (env, request, resource, token) => {
2813
+ const refuse = (reason) => {
2814
+ pending.get(request)?.refused(reason);
2815
+ return null;
2816
+ };
2817
+ if (isPersonalToken(token) || isReadToken(token)) {
2818
+ const check = await checkPersonalToken(env.DB, token, {
2819
+ sites: options.sites,
2820
+ now: options.clock.now()
2821
+ });
2822
+ if (!check.ok) return refuse(check.reason);
2823
+ if (check.touch !== null) {
2824
+ const route = pending.get(request);
2825
+ if (route === void 0) await check.touch;
2826
+ else route.defer(check.touch);
2827
+ }
2828
+ return {
2829
+ props: check.principal,
2830
+ audience: resource,
2831
+ scope: [...check.principal.scopes]
2832
+ };
2833
+ }
2834
+ const granted = await authorizationServer.validateToken(resource, token, env);
2835
+ if (granted === null) return refuse("unknown");
2836
+ if (!isGrantProps(granted.props)) return refuse("unknown");
2837
+ const site = granted.props.site;
2838
+ if (!options.sites.has(site)) return refuse("other_site");
2839
+ const role = await roleOf(env.DB, site, granted.props.userId);
2840
+ if (role === null) return refuse("no_user");
2841
+ const principal = {
2842
+ kind: "oauth",
2843
+ userId: granted.props.userId,
2844
+ clientId: granted.clientId,
2845
+ site,
2846
+ scopes: effectiveScopes(role, granted.scope.filter(isScope))
2847
+ };
2848
+ return {
2849
+ props: principal,
2850
+ audience: granted.audience,
2851
+ expiresAt: granted.expiresAt,
2852
+ scope: [...principal.scopes],
2853
+ userId: principal.userId,
2854
+ clientId: principal.clientId
2855
+ };
2856
+ };
2857
+ const resourceServer = (name) => new OAuthResourceServer({
2858
+ resourceMetadata: {
2859
+ resource: resources[name],
2860
+ authorization_servers: [issuer],
2861
+ resource_name: name === "mcp" ? "Hollware MCP" : "Hollware read API"
2862
+ },
2863
+ requiredScopes: ["content:read"],
2864
+ validateToken: validate,
2865
+ handler: { fetch: async (request, _env, ctx) => {
2866
+ const route = pending.get(request);
2867
+ if (route === void 0) throw new Error("A protected route was reached without the gateway in front of it");
2868
+ return await route.proceed(ctx.props, ctx.auth);
2869
+ } }
2870
+ });
2871
+ const servers = {
2872
+ mcp: resourceServer("mcp"),
2873
+ v1: resourceServer("v1")
2874
+ };
2875
+ return {
2876
+ issuer,
2877
+ authorizationServer,
2878
+ resources,
2879
+ metadataUrls,
2880
+ protect: async (resource, request, env, ctx, route) => {
2881
+ pending.set(request, route);
2882
+ try {
2883
+ return await servers[resource].fetch(request, env, ctx);
2884
+ } finally {
2885
+ pending.delete(request);
2886
+ }
2887
+ }
2888
+ };
2889
+ }
2890
+ //#endregion
2159
2891
  //#region src/http/gateway.ts
2160
2892
  /**
2893
+ * A 401 of a resource server, with Hollware's body: the same JSON as every other refusal, and the
2894
+ * challenge of the resource server, which names its metadata (RFC 9728) and the baseline scope.
2895
+ */
2896
+ function withRefusalBody(response, refused) {
2897
+ const ours = unauthorized(refused ? "unknown" : void 0);
2898
+ const headers = new Headers(response.headers);
2899
+ headers.set("content-type", "application/json");
2900
+ const challenge = headers.get("www-authenticate");
2901
+ if (challenge !== null) headers.set("www-authenticate", challenge.replace("realm=\"OAuth\"", "realm=\"hollware\""));
2902
+ return new Response(ours.body, {
2903
+ status: 401,
2904
+ headers
2905
+ });
2906
+ }
2907
+ /**
2161
2908
  * The HTTP gateway of the Worker (ADR-019): a Hono 5 app, built once, that runs in this order for
2162
2909
  * every request:
2163
2910
  *
@@ -2181,76 +2928,173 @@ function openApiDocument(site) {
2181
2928
  * nothing. A failed D1 query while the token is checked is one of those: it is never a 401.
2182
2929
  */
2183
2930
  function createGateway(options) {
2184
- const { site, mcp, clock, log } = options;
2931
+ const { sites, mcp, clock, log } = options;
2932
+ const siteKeys = new Set(sites.keys());
2185
2933
  const app = new Hono();
2934
+ /**
2935
+ * The Worker's own context of each request. Hono hands its routes the same object under a
2936
+ * narrower type of its own, and the OAuth library wants the Worker's.
2937
+ */
2938
+ const contexts = /* @__PURE__ */ new WeakMap();
2939
+ const workerContext = (c) => {
2940
+ const ctx = contexts.get(c.req.raw);
2941
+ if (ctx === void 0) throw new Error("A request reached a route without its context");
2942
+ return ctx;
2943
+ };
2944
+ const oauth = createOAuth({
2945
+ sites: siteKeys,
2946
+ clock,
2947
+ dynamicClientRegistration: options.auth?.dynamicClientRegistration ?? false,
2948
+ log
2949
+ });
2950
+ /**
2951
+ * What a write hands to the Queue: the events it committed, after the answer (ADR-025). The
2952
+ * cron trigger's sweep catches any that this misses.
2953
+ */
2954
+ const notify = (c, requestId, site) => (ids) => {
2955
+ c.executionCtx.waitUntil(enqueue({
2956
+ queue: c.env.EVENTS,
2957
+ db: connect(c.env.DB),
2958
+ site,
2959
+ ids,
2960
+ clock
2961
+ }).catch((error) => {
2962
+ log({
2963
+ kind: "enqueue_failed",
2964
+ requestId,
2965
+ message: String(error)
2966
+ });
2967
+ }));
2968
+ };
2969
+ /**
2970
+ * A protected route: the resource server of `resource` answers a request without a token and a
2971
+ * token it refuses (401, logged with the reason), and `proceed` gets the principal of the rest.
2972
+ */
2973
+ const protect = async (c, resource, proceed) => {
2974
+ const requestId = c.get("requestId");
2975
+ /** What the check said besides the answer: why it refused, or what it threw. */
2976
+ const outcome = {
2977
+ refusal: null,
2978
+ failure: null
2979
+ };
2980
+ const response = await oauth(c.env.HOLLWARE_PUBLIC_URL).protect(resource, c.req.raw, c.env, workerContext(c), {
2981
+ proceed,
2982
+ refused: (reason) => {
2983
+ outcome.refusal = reason;
2984
+ },
2985
+ defer: (promise) => {
2986
+ workerContext(c).waitUntil(promise);
2987
+ },
2988
+ failed: (error) => {
2989
+ outcome.failure = { error };
2990
+ }
2991
+ });
2992
+ if (outcome.failure !== null) throw outcome.failure.error;
2993
+ if (response.status !== 401) return response;
2994
+ log({
2995
+ kind: "auth_refused",
2996
+ requestId,
2997
+ reason: outcome.refusal ?? "no_token"
2998
+ });
2999
+ return withRefusalBody(response, outcome.refusal !== null);
3000
+ };
3001
+ /** Every origin that the config of some site lets read the site from a page. */
3002
+ const corsOrigins = new Set([...sites.values()].flatMap((site) => site.cors.origins));
3003
+ /**
3004
+ * The guard lets a page of such an origin through to the read API alone, and only to read: the
3005
+ * read API then checks the origin against the site of the token.
3006
+ */
3007
+ const readsFromAnyCorsOrigin = (request, origin) => {
3008
+ const { pathname } = new URL(request.url);
3009
+ return corsOrigins.has(origin) && [
3010
+ "GET",
3011
+ "HEAD",
3012
+ "OPTIONS"
3013
+ ].includes(request.method) && pathname.startsWith("/v1/") && !pathname.startsWith("/v1/admin/");
3014
+ };
3015
+ /**
3016
+ * A route for a person or their personal token: a read token reads over the read API alone, so
3017
+ * it is refused here, before anything runs (403, as a token that lacks a scope).
3018
+ */
3019
+ const protectWrites = (c, resource, proceed) => protect(c, resource, async (principal, auth) => principal.kind === "read" ? Response.json({
3020
+ code: "forbidden",
3021
+ message: "A read token reads published content over GET /v1, and nothing else.",
3022
+ fix: "Use a personal token (hw_pat_) or sign in with OAuth for this."
3023
+ }, {
3024
+ status: 403,
3025
+ headers: { "cache-control": "no-store" }
3026
+ }) : proceed(principal, auth));
2186
3027
  app.use("*", async (c, next) => {
2187
3028
  c.set("requestId", crypto.randomUUID());
2188
3029
  await next();
2189
3030
  });
2190
3031
  app.use("*", async (c, next) => {
2191
- const refusal = checkHostAndOrigin(c.req.raw, c.env.HOLLWARE_PUBLIC_URL);
3032
+ const refusal = checkHostAndOrigin(c.req.raw, c.env.HOLLWARE_PUBLIC_URL, readsFromAnyCorsOrigin);
2192
3033
  if (refusal !== null) return refusal;
2193
3034
  await next();
2194
3035
  });
2195
3036
  app.get("/health", () => Response.json({ ok: true }, { headers: { "cache-control": "no-store" } }));
2196
- app.all("/mcp", async (c) => {
3037
+ app.all("/mcp", async (c) => protectWrites(c, "mcp", async (principal) => {
2197
3038
  const requestId = c.get("requestId");
2198
- const token = readBearerToken(c.req.raw);
2199
- if (token === null) {
2200
- log({
2201
- kind: "auth_refused",
2202
- requestId,
2203
- reason: "no_token"
2204
- });
2205
- return unauthorized();
2206
- }
2207
- const check = await checkPersonalToken(c.env.DB, token, {
2208
- site: site.key,
2209
- now: clock.now()
2210
- });
2211
- if (!check.ok) {
2212
- log({
2213
- kind: "auth_refused",
2214
- requestId,
2215
- reason: check.reason
2216
- });
2217
- return unauthorized(check.reason);
2218
- }
2219
- if (check.touch !== null) c.executionCtx.waitUntil(check.touch);
2220
3039
  const db = c.env.DB;
3040
+ const site = sites.get(principal.site);
3041
+ if (site === void 0) throw new Error(`No site ${principal.site} for a checked token`);
2221
3042
  return await mcp.fetch(c.req.raw, {
2222
- principal: check.principal,
3043
+ principal,
2223
3044
  requestId,
3045
+ resourceMetadataUrl: oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.mcp,
2224
3046
  context: {
2225
3047
  site,
2226
3048
  store: createD1Store(db, { clock }),
2227
3049
  clock,
2228
- notify: (ids) => {
2229
- c.executionCtx.waitUntil(enqueue({
2230
- queue: c.env.EVENTS,
2231
- db: connect(db),
2232
- site: site.key,
2233
- ids,
2234
- clock
2235
- }).catch((error) => {
2236
- log({
2237
- kind: "enqueue_failed",
2238
- requestId,
2239
- message: String(error)
2240
- });
2241
- }));
2242
- }
3050
+ notify: notify(c, requestId, site.key)
2243
3051
  }
2244
3052
  });
3053
+ }));
3054
+ 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), {
3055
+ proceed: () => Promise.resolve(new Response(null, { status: 404 })),
3056
+ refused: () => void 0,
3057
+ defer: () => void 0,
3058
+ failed: () => void 0
3059
+ }));
3060
+ installAuthorize(app, {
3061
+ sites: siteKeys,
3062
+ clock,
3063
+ oauth
3064
+ });
3065
+ const authorizationServer = (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).authorizationServer.fetch(c.req.raw, c.env, workerContext(c));
3066
+ app.all("/.well-known/oauth-authorization-server", authorizationServer);
3067
+ app.all("/oauth/*", authorizationServer);
3068
+ installIdentity(app, { clock });
3069
+ installAdmin(app, { oauth });
3070
+ installAdminApi(app, {
3071
+ sites: siteKeys,
3072
+ clock,
3073
+ oauth,
3074
+ protect: (c, proceed) => protectWrites(c, "v1", proceed),
3075
+ metadataUrl: (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1
3076
+ });
3077
+ if (options.handlers !== void 0) installOpsApi(app, {
3078
+ sites,
3079
+ handlers: options.handlers,
3080
+ clock,
3081
+ log,
3082
+ protect: (c, proceed) => protectWrites(c, "v1", proceed),
3083
+ metadataUrl: (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1,
3084
+ notify
2245
3085
  });
2246
3086
  if (options.read !== void 0) installReadApi(app, {
2247
3087
  read: options.read,
2248
- site: site.key,
2249
- clock,
2250
- log
3088
+ corsOrigins: (site) => sites.get(site)?.cors.origins ?? [],
3089
+ anyCorsOrigin: (origin) => corsOrigins.has(origin),
3090
+ protect: (c, proceed) => protect(c, "v1", proceed),
3091
+ metadataUrl: (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1
2251
3092
  });
2252
3093
  installErrorHandling(app, log);
2253
- return async (request, env, ctx) => await app.fetch(request, env, ctx);
3094
+ return async (request, env, ctx) => {
3095
+ contexts.set(request, ctx);
3096
+ return await app.fetch(request, env, ctx);
3097
+ };
2254
3098
  }
2255
3099
  //#endregion
2256
3100
  //#region src/mcp/annotations.ts
@@ -2433,12 +3277,15 @@ const encoder = new TextEncoder();
2433
3277
  * has already run when its answer turns out too large, so it says to look at the state it left.
2434
3278
  */
2435
3279
  const OUTPUT_TOO_LARGE_FIX = {
3280
+ approve_review: "The operation ran and its change may have been applied: read the review with list_reviews before you repeat it.",
2436
3281
  create_draft: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
2437
3282
  describe_site: "The description of the site does not fit in one answer. Document fewer collections, components and contracts, or shorten their descriptions.",
2438
3283
  get_entry: "Ask for one format at a time with formats: markdown, document or html, instead of several together.",
2439
3284
  get_translations: "The translations do not fit in one answer. Read each one with get_entry instead.",
2440
3285
  list_entries: "Ask for less: pass a smaller limit, or narrow the list with locale, tag or status.",
3286
+ list_reviews: "Ask for less: pass a smaller limit.",
2441
3287
  publish: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
3288
+ request_changes: "The operation ran and its change may have been applied: read the review with list_reviews before you repeat it.",
2442
3289
  unpublish: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
2443
3290
  update_draft: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
2444
3291
  validate: "The verdict lists more problems than fit in one answer. Fix the first ones and validate again."
@@ -2593,14 +3440,16 @@ const TOOLS_LIST_CACHE_HINT = {
2593
3440
  cacheScope: "public"
2594
3441
  };
2595
3442
  /**
2596
- * What the SDK is told about a principal: its scopes, which are the effective ones, and the id of
2597
- * its token as the client. It carries no token: the gateway checked it, and nothing here needs it.
3443
+ * What the SDK is told about a principal: its scopes, which are the effective ones, the id of its
3444
+ * token or of its OAuth client as the client, and where its protected resource metadata is. It
3445
+ * carries no token: the gateway checked it, and nothing here needs it.
2598
3446
  */
2599
- function authInfoOf(principal) {
3447
+ function authInfoOf(principal, resourceMetadataUrl) {
2600
3448
  return {
2601
3449
  token: "",
2602
- clientId: principal.kind === "token" ? principal.tokenId : principal.kind,
2603
- scopes: [...principal.scopes]
3450
+ clientId: principal.kind === "token" || principal.kind === "read" ? principal.tokenId : principal.kind === "oauth" ? principal.clientId : principal.kind,
3451
+ scopes: [...principal.scopes],
3452
+ ...resourceMetadataUrl === null ? {} : { resourceMetadataUrl }
2604
3453
  };
2605
3454
  }
2606
3455
  /**
@@ -2726,7 +3575,7 @@ function createMcpEndpoint(options) {
2726
3575
  }) });
2727
3576
  return { async fetch(request, call) {
2728
3577
  if (!call?.principal) throw new Error("The MCP endpoint serves a request only as the principal the gateway found");
2729
- const authInfo = authInfoOf(call.principal);
3578
+ const authInfo = authInfoOf(call.principal, call.resourceMetadataUrl ?? null);
2730
3579
  calls.set(authInfo, call);
2731
3580
  return handler.fetch(request, { authInfo });
2732
3581
  } };
@@ -2742,11 +3591,12 @@ const BINDING_PRINCIPAL = Object.freeze({
2742
3591
  scopes: Object.freeze(["content:read"])
2743
3592
  });
2744
3593
  /**
2745
- * The site of a call is not the site of this deployment. v0.1 serves one site, and the message
2746
- * and the fix both say which, for a caller that only gets one of them.
3594
+ * The site of a call is none of the sites of this deployment. The message and the fix both say
3595
+ * which sites it serves, for a caller that only gets one of them.
2747
3596
  */
2748
- function wrongSite(served) {
2749
- return new HollwareError("invalid_input", `This Hollware serves the site "${served}", not the one the call names.`, `Pass "${served}", the site this deployment serves, as the site argument.`);
3597
+ function unknownSite(site, served) {
3598
+ const keys = [...served].map((key) => `"${key}"`).join(", ");
3599
+ 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.`);
2750
3600
  }
2751
3601
  /**
2752
3602
  * The options object of a call, without the keys that hold `undefined`, so that the handler sees
@@ -2792,7 +3642,7 @@ function forCaller(deps, requestId, method, error) {
2792
3642
  return new HollwareError(known.code, known.message, known.fix);
2793
3643
  }
2794
3644
  /**
2795
- * One call of an operation over RPC. In this order: the site is the one of this deployment, the
3645
+ * One call of an operation over RPC. In this order: the site is one of this deployment, the
2796
3646
  * input is built and parsed by the spec's schema, and the handler runs with
2797
3647
  *
2798
3648
  * - `view: 'published'`, so a draft is never read, and the principal `{ kind: 'binding', scopes:
@@ -2805,11 +3655,12 @@ function forCaller(deps, requestId, method, error) {
2805
3655
  * a call that fails never opens a session.
2806
3656
  */
2807
3657
  async function run(deps, env, method, site, schema, build, handler) {
2808
- if (site !== deps.site.key) throw wrongSite(deps.site.key);
3658
+ const served = deps.sites.get(site);
3659
+ if (served === void 0) throw unknownSite(site, deps.sites.keys());
2809
3660
  const input = parseInput(schema, build());
2810
3661
  const requestId = crypto.randomUUID();
2811
3662
  const context = {
2812
- site: deps.site,
3663
+ site: served,
2813
3664
  principal: BINDING_PRINCIPAL,
2814
3665
  view: "published",
2815
3666
  store: createD1Store(env.DB.withSession("first-unconstrained")),
@@ -2890,6 +3741,30 @@ const SCHEDULER_SCOPES = [
2890
3741
  "content:publish"
2891
3742
  ];
2892
3743
  /**
3744
+ * The principal a schedule publishes as: the token, or the OAuth client, that scheduled it, with
3745
+ * the scopes a publication needs. An OAuth client is known by the id its actor kept (ADR-026).
3746
+ */
3747
+ function principalOf(actor, site) {
3748
+ if (actor.kind === "oauth_client") {
3749
+ const client = actor.meta?.["oauthClient"];
3750
+ return {
3751
+ kind: "oauth",
3752
+ userId: actor.userId,
3753
+ clientId: typeof client === "string" ? client : "unknown",
3754
+ site,
3755
+ scopes: SCHEDULER_SCOPES
3756
+ };
3757
+ }
3758
+ if (actor.tokenId === null) throw new Error(`A schedule of a ${actor.kind} has no token to publish with`);
3759
+ return {
3760
+ kind: "token",
3761
+ userId: actor.userId,
3762
+ tokenId: actor.tokenId,
3763
+ site,
3764
+ scopes: SCHEDULER_SCOPES
3765
+ };
3766
+ }
3767
+ /**
2893
3768
  * Publishes the scheduled entries whose time has come (ADR-025), one by one, through the same
2894
3769
  * handler as `publish`, in the name of who scheduled each one and with the warnings acknowledged
2895
3770
  * then. An entry that cannot be published (it changed after it was scheduled, or the contracts now
@@ -2910,13 +3785,7 @@ async function runDueSchedules(input) {
2910
3785
  group: []
2911
3786
  }, {
2912
3787
  site,
2913
- principal: {
2914
- kind: "token",
2915
- userId: schedule.actor.userId,
2916
- tokenId: schedule.actor.tokenId ?? "",
2917
- site: site.key,
2918
- scopes: SCHEDULER_SCOPES
2919
- },
3788
+ principal: principalOf(schedule.actor, site.key),
2920
3789
  view: "head",
2921
3790
  store,
2922
3791
  clock,
@@ -2962,7 +3831,7 @@ function writeLog(entry) {
2962
3831
  *
2963
3832
  * - the config is resolved with `resolveConfig`, so a broken `hollware.config.ts` throws its
2964
3833
  * `ConfigError` here, and the Worker fails at startup with a message that says what to change.
2965
- * v0.1 serves the one site of the config;
3834
+ * The deployment serves every site of the config, and a token, a grant or a call names one;
2966
3835
  * - the MCP endpoint, whose tool schemas are converted once, and the Hono app of the gateway,
2967
3836
  * which is `fetch`: MCP at `/mcp` behind the guard and the token check, and the other routes
2968
3837
  * (`http/gateway.ts`);
@@ -2974,15 +3843,15 @@ function writeLog(entry) {
2974
3843
  * included, is one JSON line written with `console.log`.
2975
3844
  */
2976
3845
  function createHollwareWith(config, handlers, options = {}) {
2977
- const [site] = resolveConfig(config).sites.values();
2978
- if (site === void 0) throw new Error("The resolved config has no site, which resolveConfig never lets through");
3846
+ const resolved = resolveConfig(config);
3847
+ const sites = resolved.sites;
2979
3848
  const clock = options.clock ?? systemClock;
2980
3849
  const mcp = createMcpEndpoint({
2981
3850
  handlers,
2982
3851
  serverInfo: SERVER_INFO,
2983
3852
  log: writeLog
2984
3853
  });
2985
- const deliveryDeps = (env) => ({
3854
+ const deliveryDeps = (env, site) => ({
2986
3855
  db: connect(env.DB),
2987
3856
  site,
2988
3857
  clock,
@@ -2994,40 +3863,51 @@ function createHollwareWith(config, handlers, options = {}) {
2994
3863
  });
2995
3864
  return {
2996
3865
  fetch: createGateway({
2997
- site,
3866
+ sites,
2998
3867
  mcp,
2999
3868
  clock,
3000
3869
  log: writeLog,
3001
3870
  read: (env) => createRpc({
3002
- site,
3871
+ sites,
3003
3872
  handlers,
3004
3873
  clock,
3005
3874
  log: writeLog
3006
- }, env)
3875
+ }, env),
3876
+ auth: resolved.auth,
3877
+ handlers
3007
3878
  }),
3008
3879
  scheduled: async (_controller, env, ctx) => {
3009
- const deps = deliveryDeps(env);
3010
- ctx.waitUntil(runDueSchedules({
3011
- site,
3012
- store: createD1Store(env.DB, { clock }),
3013
- handlers,
3014
- clock,
3015
- log: writeLog
3016
- }).then(() => sweepOutbox({
3017
- queue: env.EVENTS,
3018
- deps
3019
- })).then(() => void 0, (error) => {
3020
- writeLog({
3021
- kind: "cron_failed",
3022
- message: String(error)
3880
+ const each = async (site) => {
3881
+ await runDueSchedules({
3882
+ site,
3883
+ store: createD1Store(env.DB, { clock }),
3884
+ handlers,
3885
+ clock,
3886
+ log: writeLog
3023
3887
  });
3024
- }));
3888
+ await sweepOutbox({
3889
+ queue: env.EVENTS,
3890
+ deps: deliveryDeps(env, site)
3891
+ });
3892
+ };
3893
+ ctx.waitUntil((async () => {
3894
+ for (const site of sites.values()) await each(site).catch((error) => {
3895
+ writeLog({
3896
+ kind: "cron_failed",
3897
+ site: site.key,
3898
+ message: String(error)
3899
+ });
3900
+ });
3901
+ })());
3025
3902
  },
3026
3903
  queue: async (batch, env) => {
3027
- await consumeEvents(batch.messages, deliveryDeps(env));
3904
+ await consumeEvents(batch.messages, (key) => {
3905
+ const site = sites.get(key);
3906
+ return site === void 0 ? void 0 : deliveryDeps(env, site);
3907
+ });
3028
3908
  },
3029
3909
  Hollware: createEntrypoint({
3030
- site,
3910
+ sites,
3031
3911
  handlers,
3032
3912
  clock,
3033
3913
  log: writeLog
@@ -3055,23 +3935,49 @@ function unexpectedAnswer(context, call, answer) {
3055
3935
  //#endregion
3056
3936
  //#region src/ops/actor.ts
3057
3937
  /**
3058
- * Who a write is made by: the token of the call, with what its client said about itself. Only a
3059
- * personal token writes (ADR-019). The adapters check the scopes of the operation before any
3060
- * handler runs and never route a write for another principal, so reaching one with a principal
3061
- * that is not a token is a bug, answered as `internal` before anything is read or written.
3938
+ * Who a write is made by: the person behind the token of the call, with what its client said
3939
+ * about itself. A personal token writes as itself (ADR-019); an OAuth access token writes as the
3940
+ * client the person allowed, whose id goes with what the client said (ADR-026). The adapters check
3941
+ * the scopes of the operation before any handler runs and never route a write for the RPC
3942
+ * binding, so reaching one with it is a bug, answered as `internal` before anything is read or
3943
+ * written.
3062
3944
  */
3063
3945
  function actorOf(context) {
3064
3946
  const { principal } = context;
3065
- if (principal.kind !== "token") throw internalError(context, `A write was routed for a principal of kind ${principal.kind}; only a token writes.`);
3066
- return {
3947
+ if (principal.kind === "token") return {
3067
3948
  kind: "token",
3068
3949
  userId: principal.userId,
3069
3950
  tokenId: principal.tokenId,
3070
3951
  meta: context.client
3071
3952
  };
3953
+ if (principal.kind === "oauth") return {
3954
+ kind: "oauth_client",
3955
+ userId: principal.userId,
3956
+ tokenId: null,
3957
+ meta: {
3958
+ ...context.client,
3959
+ oauthClient: principal.clientId
3960
+ }
3961
+ };
3962
+ throw internalError(context, `A write was routed for a principal of kind ${principal.kind}; only a person writes.`);
3072
3963
  }
3073
3964
  //#endregion
3074
3965
  //#region src/ops/answers.ts
3966
+ /** A review as an output. */
3967
+ function reviewView(review) {
3968
+ return {
3969
+ id: review.id,
3970
+ entryId: review.entryId,
3971
+ revision: review.revisionId,
3972
+ status: review.status,
3973
+ requestedBy: review.requestedBy,
3974
+ requestedByName: review.requestedByName,
3975
+ requestedAt: review.requestedAt,
3976
+ decidedBy: review.decidedBy,
3977
+ decidedAt: review.decidedAt,
3978
+ note: review.note
3979
+ };
3980
+ }
3075
3981
  /**
3076
3982
  * The summary of an entry, from a listing (`EntrySummaryView`) or from a read of one entry
3077
3983
  * (`EntryView`, which has all of the summary's fields and more). `hasUnpublishedChanges` says
@@ -3341,8 +4247,8 @@ const describeSite$1 = async (_input, context) => ({
3341
4247
  //#endregion
3342
4248
  //#region src/ops/get-entry.ts
3343
4249
  /**
3344
- * Reads an entry in the view of the call, `head` over MCP and `published` over RPC, with only the
3345
- * formats that were asked for.
4250
+ * Reads an entry in the view of the call, `head` over MCP and `published` over RPC, or in the
4251
+ * published view when the input asks for it, with only the formats that were asked for.
3346
4252
  */
3347
4253
  const getEntry$1 = async (input, context) => {
3348
4254
  requireCollection(context.site, input.collection);
@@ -3351,7 +4257,7 @@ const getEntry$1 = async (input, context) => {
3351
4257
  collection: input.collection,
3352
4258
  locale: input.locale,
3353
4259
  slug: input.slug
3354
- }, context.view);
4260
+ }, input.published ? "published" : context.view);
3355
4261
  if (found === null) return { outcome: "not_found" };
3356
4262
  const { entry, revision } = found;
3357
4263
  const publication = await context.store.lastPublication(context.site.key, entry.id);
@@ -3439,8 +4345,14 @@ function warningKey(warning) {
3439
4345
  * changed since the draft was saved: an error blocks, and a warning has to be acknowledged, by
3440
4346
  * contract and path, before the publication is made. The publication records every warning of its
3441
4347
  * verdict as acknowledged (ADR-021).
4348
+ *
4349
+ * A caller without `content:publish` goes through the same checks, and then asks for a review
4350
+ * instead of publishing (v0.3): the answer is `pending_review`, and a person who may publish
4351
+ * decides. A review is for one entry, now.
3442
4352
  */
3443
4353
  const publish$1 = async (input, context) => {
4354
+ const mayPublish = context.principal.scopes.includes("content:publish");
4355
+ if (!mayPublish && (input.group.length > 0 || input.publishAt !== void 0)) throw new HollwareError("invalid_input", "Without content:publish, publish asks for a review of one entry, now: not of a group, and not at a time.", "Publish each entry on its own, without group and publishAt; a person who may publish approves it.");
3444
4356
  if (input.group.length > 0) return publishGroup(input, context);
3445
4357
  const actor = actorOf(context);
3446
4358
  const found = await context.store.findEntryById(context.site.key, input.id, "head");
@@ -3478,6 +4390,28 @@ const publish$1 = async (input, context) => {
3478
4390
  contract,
3479
4391
  path
3480
4392
  }));
4393
+ if (!mayPublish) {
4394
+ const asked = await context.store.requestReview(context.site.key, {
4395
+ entryId: entry.id,
4396
+ expectedVersion: input.version,
4397
+ acknowledged: acknowledgedWarnings,
4398
+ actor
4399
+ });
4400
+ switch (asked.outcome) {
4401
+ case "pending_review": return {
4402
+ outcome: "pending_review",
4403
+ entry: summary,
4404
+ verdict: verdictView(verdict),
4405
+ review: reviewView(asked.review)
4406
+ };
4407
+ case "conflict": return {
4408
+ outcome: "conflict",
4409
+ conflict: conflictAt(asked.currentVersion, asked.headRevision)
4410
+ };
4411
+ case "not_found": return { outcome: "not_found" };
4412
+ default: throw unexpectedAnswer(context, "publish", asked);
4413
+ }
4414
+ }
3481
4415
  if (input.publishAt !== void 0 && Date.parse(input.publishAt) > context.clock.now().getTime()) {
3482
4416
  if (entry.status === "published") throw new HollwareError("invalid_input", "The entry is published, so its publication cannot be scheduled.", "Publish its changes now, without publishAt, or unpublish it first and then schedule it.");
3483
4417
  const scheduled = await context.store.schedule(context.site.key, {
@@ -3637,15 +4571,13 @@ async function publishGroup(input, context) {
3637
4571
  }
3638
4572
  const [main] = checked;
3639
4573
  if (main === void 0) throw unexpectedAnswer(context, "publish", { outcome: "empty group" });
3640
- const warnings = new Map(checked.flatMap(({ verdict }) => verdict.warnings).map((warning) => [warningKey(warning), warning]));
3641
4574
  const acknowledged = new Set(input.acknowledgeWarnings.map(warningKey));
3642
- if ([...warnings.keys()].some((key) => !acknowledged.has(key))) return {
4575
+ const pending = checked.find(({ verdict }) => verdict.warnings.some((warning) => !acknowledged.has(warningKey(warning))));
4576
+ if (pending !== void 0) return {
3643
4577
  outcome: "needs_acknowledgement",
3644
- entry: main.summary,
3645
- verdict: verdictView({
3646
- ...main.verdict,
3647
- warnings: [...warnings.values()]
3648
- })
4578
+ entry: pending.summary,
4579
+ verdict: verdictView(pending.verdict),
4580
+ failedEntry: pending.write.entryId
3649
4581
  };
3650
4582
  const written = await context.store.publishGroup(context.site.key, checked.map(({ write }) => write));
3651
4583
  switch (written.outcome) {
@@ -3681,6 +4613,91 @@ async function publishGroup(input, context) {
3681
4613
  }
3682
4614
  }
3683
4615
  //#endregion
4616
+ //#region src/ops/reviews.ts
4617
+ /**
4618
+ * The reviews of v0.3: what a person who may publish does with the requests of those who may
4619
+ * only write. `publish` makes a request (`pending_review`); these list them, approve one, which
4620
+ * publishes it through `publish` itself, or close one with a note.
4621
+ */
4622
+ /** The requests of one status, newest first, each with its entry as it is now. */
4623
+ const listReviews = async (input, context) => {
4624
+ const found = await context.store.listReviews(context.site.key, {
4625
+ status: input.status,
4626
+ limit: input.limit
4627
+ });
4628
+ const reviews = [];
4629
+ for (const review of found) {
4630
+ const view = await context.store.findEntryById(context.site.key, review.entryId, "head");
4631
+ if (view === null) throw unexpectedAnswer(context, "list_reviews", { outcome: "no entry" });
4632
+ reviews.push({
4633
+ ...reviewView(review),
4634
+ entry: entrySummary(view)
4635
+ });
4636
+ }
4637
+ return {
4638
+ outcome: "ok",
4639
+ reviews
4640
+ };
4641
+ };
4642
+ /**
4643
+ * Publishes what a pending review asked for, through `publish`, in the approver's name: the
4644
+ * contracts run again, and the warnings the request acknowledged count as acknowledged. The
4645
+ * publication approves the review in its own batch. A review that is no longer pending answers
4646
+ * `not_pending`.
4647
+ */
4648
+ const approveReview = async (input, context) => {
4649
+ const site = context.site.key;
4650
+ const review = await context.store.findReview(site, input.id);
4651
+ if (review === null) return { outcome: "not_found" };
4652
+ if (review.status !== "pending") return {
4653
+ outcome: "not_pending",
4654
+ review: reviewView(review)
4655
+ };
4656
+ const found = await context.store.findEntryById(site, review.entryId, "head");
4657
+ if (found === null) return { outcome: "not_found" };
4658
+ const answer = await publish$1({
4659
+ id: found.entry.id,
4660
+ version: found.entry.version,
4661
+ acknowledgeWarnings: [...review.acknowledged, ...input.acknowledgeWarnings],
4662
+ group: []
4663
+ }, context);
4664
+ switch (answer.outcome) {
4665
+ case "published": return {
4666
+ outcome: "published",
4667
+ review: reviewView(await context.store.findReview(site, review.id) ?? review),
4668
+ ...answer.entry === void 0 ? {} : { entry: answer.entry },
4669
+ ...answer.verdict === void 0 ? {} : { verdict: answer.verdict }
4670
+ };
4671
+ case "blocked":
4672
+ case "needs_acknowledgement": return {
4673
+ outcome: answer.outcome,
4674
+ review: reviewView(review),
4675
+ ...answer.entry === void 0 ? {} : { entry: answer.entry },
4676
+ ...answer.verdict === void 0 ? {} : { verdict: answer.verdict }
4677
+ };
4678
+ case "conflict": return {
4679
+ outcome: "not_pending",
4680
+ review: reviewView(await context.store.findReview(site, review.id) ?? review)
4681
+ };
4682
+ case "not_found": return { outcome: "not_found" };
4683
+ default: throw unexpectedAnswer(context, "approve_review", answer);
4684
+ }
4685
+ };
4686
+ /** Closes a pending review with a note, without publishing. */
4687
+ const requestChanges = async (input, context) => {
4688
+ const { userId } = actorOf(context);
4689
+ const closed = await context.store.requestChanges(context.site.key, {
4690
+ id: input.id,
4691
+ note: input.note,
4692
+ userId
4693
+ });
4694
+ if (closed.outcome === "not_found") return { outcome: "not_found" };
4695
+ return {
4696
+ outcome: closed.outcome,
4697
+ review: reviewView(closed.review)
4698
+ };
4699
+ };
4700
+ //#endregion
3684
4701
  //#region src/ops/unpublish.ts
3685
4702
  /**
3686
4703
  * Takes an entry off the public site and keeps it, with its revisions, as a draft. The store
@@ -3819,17 +4836,20 @@ function defineHandlers(handlers) {
3819
4836
  //#endregion
3820
4837
  //#region src/ops/index.ts
3821
4838
  /**
3822
- * The handler of every operation of v0.1, paired with its spec of `@hollware/core` by type
4839
+ * The handler of every operation, paired with its spec of `@hollware/core` by type
3823
4840
  * (ADR-017). Adapters check the scopes and build the `OperationContext`; a handler never sees a
3824
4841
  * request, and reads and writes only the site of its context.
3825
4842
  */
3826
4843
  const handlers = defineHandlers({
4844
+ approve_review: approveReview,
3827
4845
  create_draft: createDraft,
3828
4846
  describe_site: describeSite$1,
3829
4847
  get_entry: getEntry$1,
3830
4848
  get_translations: getTranslations$1,
3831
4849
  list_entries: listEntries$1,
4850
+ list_reviews: listReviews,
3832
4851
  publish: publish$1,
4852
+ request_changes: requestChanges,
3833
4853
  unpublish,
3834
4854
  update_draft: updateDraft,
3835
4855
  validate