@hollware/server 0.1.0 → 0.3.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 { HollwareError, OPERATIONS, describeBuiltInContracts, describeResolvedSite, describeSite, getEntry, getTranslations, hashPersonalToken, isPersonalToken, isScope, listEntries, newId, processMarkdown, publish, renderHtml, resolveConfig, runContracts, sizeLimitViolation, uniqueSlugViolation, uniqueTranslationViolation } from "@hollware/core";
1
+ import { A as isRole, C as revisions, D as users, E as tokens, O as ROLE_SCOPES, S as revisionTags, T as sites, _ as invitations, b as publications, g as entries, h as deliveries, k as effectiveScopes, n as createInvitation, s as findSession, v as members, w as schedules, x as reviews, y as outbox } from "./store-Sq0pwUQv.mjs";
2
+ import { EVENT_ID_HEADER, HollwareError, OPERATIONS, SCOPES, SIGNATURE_HEADER, TIMESTAMP_HEADER, describeBuiltInContracts, describeResolvedSite, describeSite, generatePersonalToken, getEntry, getTranslations, hashPersonalToken, isPersonalToken, isScope, listEntries, newId, processMarkdown, publish, renderHtml, resolveConfig, runContracts, signWebhook, sizeLimitViolation, uniqueSlugViolation, uniqueTranslationViolation } from "@hollware/core";
2
3
  import { Hono } from "hono";
3
4
  import { drizzle } from "drizzle-orm/d1";
4
- import { and, desc, eq, getTableColumns, 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.1.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.3.0";
33
14
  //#endregion
34
15
  //#region src/auth/responses.ts
35
16
  /** The challenge of every 401 and 403 (RFC 6750). */
@@ -56,15 +37,28 @@ 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
  }
49
+ /**
50
+ * 403 for a token that authenticated but lacks scopes. It names every scope that is missing, in
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).
53
+ */
54
+ function insufficientScope(missing, resourceMetadataUrl) {
55
+ const metadata = resourceMetadataUrl === void 0 ? "" : `, resource_metadata="${resourceMetadataUrl}"`;
56
+ return authErrorResponse(403, {
57
+ code: "insufficient_scope",
58
+ message: `The token lacks a scope this operation requires. Missing: ${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(" ")}"`);
61
+ }
68
62
  //#endregion
69
63
  //#region src/auth/guard.ts
70
64
  /**
@@ -132,60 +126,28 @@ function checkHostAndOrigin(request, publicUrl) {
132
126
  return null;
133
127
  }
134
128
  //#endregion
135
- //#region src/auth/roles.ts
129
+ //#region src/auth/scopes.ts
136
130
  /**
137
- * The roles of a user, as the `users_role` CHECK of the schema lists them. In v0.1 only the owner
138
- * exists: `hollware init` creates it. The other two are in the CHECK already, so that v0.3 can
139
- * invite people without a rebuild of the table (ADR-023).
131
+ * The scopes an operation requires that the principal lacks, in the order the operation lists
132
+ * them. Empty means the principal may call it; otherwise the answer is `insufficientScope` with
133
+ * all of them, so that a client asks for everything it needs in one go.
140
134
  */
141
- const ROLES = [
142
- "owner",
143
- "editor",
144
- "reviewer"
145
- ];
146
- /** Whether a string is one of the roles. */
147
- function isRole(value) {
148
- return ROLES.some((role) => role === value);
149
- }
150
- /**
151
- * The scopes each role allows. What a token may do is its own scopes intersected with these,
152
- * read from the user's current role on every request (ADR-005, ADR-019). An operation asks for
153
- * scopes, never for a role.
154
- *
155
- * - `owner` may do everything: all four scopes.
156
- * - `editor` and `reviewer` allow none in v0.1. Nothing creates such a user yet, and which scopes
157
- * each one gets (an editor writes, a reviewer approves) is for v0.3 to decide, together with
158
- * the admin that invites them. Until then a token of such a user authenticates, but every
159
- * operation answers 403 `insufficient_scope`.
160
- */
161
- const ROLE_SCOPES = {
162
- owner: [
163
- "content:read",
164
- "content:write",
165
- "content:publish",
166
- "schema:admin"
167
- ],
168
- editor: [],
169
- reviewer: []
170
- };
171
- /**
172
- * The scopes a token really has: those it carries that the role of its user allows, in the order
173
- * of the role. A role that is not one of the roles allows nothing, so a role added to the schema
174
- * before this table knows it grants no access.
175
- */
176
- function effectiveScopes(role, tokenScopes) {
177
- if (!isRole(role)) return [];
178
- return ROLE_SCOPES[role].filter((scope) => tokenScopes.includes(scope));
135
+ function missingScopes(principal, required) {
136
+ const granted = principal.scopes;
137
+ return required.filter((scope) => !granted.includes(scope));
179
138
  }
180
139
  //#endregion
181
140
  //#region src/auth/token.ts
182
141
  /** `last_used_at` of a token is written at most once in this time. */
183
142
  const TOUCH_INTERVAL_MS = 36e5;
184
- /** The token with the role of its user, by the hash of the token, in the one site. */
143
+ /**
144
+ * The token with the role of its user in the token's site, by the hash of the token, in the one
145
+ * site. A user who is no member of the site has no role there, and the token is unknown.
146
+ */
185
147
  const LOOKUP = `SELECT t.id AS token_id, t.site_id AS site_id, t.user_id AS user_id,
186
148
  t.scopes AS scopes, t.expires_at AS expires_at, t.revoked_at AS revoked_at,
187
- t.last_used_at AS last_used_at, u.role AS role
188
- FROM tokens AS t JOIN users AS u ON u.id = t.user_id
149
+ t.last_used_at AS last_used_at, m.role AS role
150
+ FROM tokens AS t JOIN members AS m ON m.user_id = t.user_id AND m.site_id = t.site_id
189
151
  WHERE t.hash = ?1 AND t.site_id = ?2`;
190
152
  /**
191
153
  * The scopes a token carries, from the JSON array the `tokens` table stores. Anything that is not
@@ -397,238 +359,6 @@ function decodeCursor(cursor, view) {
397
359
  throw invalidCursor();
398
360
  }
399
361
  //#endregion
400
- //#region src/db/columns.ts
401
- /**
402
- * The columns every table shares. Each helper returns a new builder, because a Drizzle column
403
- * belongs to one table. Only the repository functions write rows, so these defaults are what
404
- * every row gets.
405
- */
406
- /** The current time in UTC, as ISO 8601 with milliseconds: `2026-10-12T18:30:00.000Z`. */
407
- function nowIso() {
408
- return (/* @__PURE__ */ new Date()).toISOString();
409
- }
410
- /** The primary key: a prefixed ULID, such as `ent_01JAB3XQ8ZK4M6N2P5R7S9T0VW`. */
411
- function id(prefix) {
412
- return text("id").primaryKey().$defaultFn(() => newId(prefix));
413
- }
414
- /**
415
- * The site a row belongs to: the site's key in `hollware.config.ts`, which is also the id of its
416
- * row in `sites`. Every table but the deployment-wide ones carries it (ADR-006).
417
- */
418
- function siteId() {
419
- return text("site_id").notNull();
420
- }
421
- /** `created_at` and `updated_at`, in UTC; `updated_at` moves on every update. */
422
- function timestamps() {
423
- return {
424
- createdAt: text("created_at").notNull().$defaultFn(nowIso),
425
- updatedAt: text("updated_at").notNull().$defaultFn(nowIso).$onUpdateFn(nowIso)
426
- };
427
- }
428
- sqliteTable("_hollware_meta", {
429
- key: text("key").primaryKey().notNull(),
430
- value: text("value").notNull()
431
- });
432
- /** The sites of the deployment. The id is the site's key in `hollware.config.ts`. */
433
- const sites = sqliteTable("sites", {
434
- id: text("id").primaryKey(),
435
- ...timestamps()
436
- });
437
- /** People. A user belongs to the deployment; tokens carry the site. */
438
- const users = sqliteTable("users", {
439
- id: id("usr"),
440
- name: text("name"),
441
- role: text("role").notNull(),
442
- ...timestamps()
443
- }, (t) => [check("users_role", sql`${t.role} IN ('owner', 'editor', 'reviewer')`)]);
444
- /** Personal tokens (ADR-019): only the hash is stored. */
445
- const tokens = sqliteTable("tokens", {
446
- id: id("tok"),
447
- siteId: siteId(),
448
- userId: text("user_id").notNull(),
449
- label: text("label").notNull(),
450
- hash: text("hash").notNull(),
451
- /** A JSON array of scopes. */
452
- scopes: text("scopes").notNull(),
453
- expiresAt: text("expires_at"),
454
- revokedAt: text("revoked_at"),
455
- lastUsedAt: text("last_used_at"),
456
- ...timestamps()
457
- }, (t) => [
458
- unique("tokens_id_site").on(t.id, t.siteId),
459
- unique("tokens_hash").on(t.hash),
460
- foreignKey({
461
- name: "tokens_site",
462
- columns: [t.siteId],
463
- foreignColumns: [sites.id]
464
- }),
465
- foreignKey({
466
- name: "tokens_user",
467
- columns: [t.userId],
468
- foreignColumns: [users.id]
469
- }),
470
- check("tokens_scopes", sql`json_valid(${t.scopes}) AND json_type(${t.scopes}) = 'array'`)
471
- ]);
472
- /** One entry per site, collection, locale and slug; its content lives in `revisions`. */
473
- const entries = sqliteTable("entries", {
474
- id: id("ent"),
475
- siteId: siteId(),
476
- collection: text("collection").notNull(),
477
- locale: text("locale").notNull(),
478
- slug: text("slug").notNull(),
479
- translationGroup: text("translation_group"),
480
- status: text("status").notNull(),
481
- /** Bumped by every change of the entry: the optimistic write compares it. */
482
- version: integer("version").notNull(),
483
- headRev: text("head_rev").notNull(),
484
- publishedRev: text("published_rev"),
485
- publishedAt: text("published_at"),
486
- publishedSortKey: text("published_sort_key"),
487
- scheduledAt: text("scheduled_at"),
488
- ...timestamps()
489
- }, (t) => [
490
- unique("entries_id_site").on(t.id, t.siteId),
491
- unique("entries_slug").on(t.siteId, t.collection, t.locale, t.slug),
492
- unique("entries_translation").on(t.siteId, t.collection, t.translationGroup, t.locale),
493
- index("entries_head").on(t.siteId, t.collection, t.updatedAt, t.id),
494
- index("entries_head_locale").on(t.siteId, t.collection, t.locale, t.updatedAt, t.id),
495
- index("entries_published").on(t.siteId, t.collection, t.locale, t.publishedSortKey, t.id).where(sql`${t.status} = 'published'`),
496
- foreignKey({
497
- name: "entries_site",
498
- columns: [t.siteId],
499
- foreignColumns: [sites.id]
500
- }),
501
- check("entries_status", sql`${t.status} IN ('draft', 'scheduled', 'published')`),
502
- check("entries_version", sql`${t.version} >= 1`),
503
- check("entries_draft", sql`${t.status} <> 'draft' OR (${t.publishedRev} IS NULL AND ${t.publishedSortKey} IS NULL AND ${t.scheduledAt} IS NULL)`),
504
- 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)`),
505
- check("entries_scheduled", sql`${t.status} <> 'scheduled' OR ${t.scheduledAt} IS NOT NULL`)
506
- ]);
507
- /**
508
- * Every version of an entry, appended and never changed (ADR-021). `markdown` is the whole file,
509
- * frontmatter included; the derived columns can be rebuilt by `reparse`.
510
- */
511
- const revisions = sqliteTable("revisions", {
512
- id: id("rev"),
513
- siteId: siteId(),
514
- entryId: text("entry_id").notNull(),
515
- markdown: text("markdown").notNull(),
516
- /** Derived: the frontmatter as JSON. */
517
- frontmatter: text("frontmatter").notNull(),
518
- /** Derived: the Comark document as JSON. */
519
- document: text("document").notNull(),
520
- comarkVersion: text("comark_version").notNull(),
521
- pipelineVersion: text("pipeline_version").notNull(),
522
- wordCount: integer("word_count").notNull(),
523
- /** The verdict of the contracts when the revision was written, as JSON. */
524
- verdict: text("verdict").notNull(),
525
- actorKind: text("actor_kind").notNull(),
526
- actorUserId: text("actor_user_id").notNull(),
527
- actorTokenId: text("actor_token_id"),
528
- /** The client and, when it says, the model behind the write, as JSON. */
529
- actorMeta: text("actor_meta"),
530
- ...timestamps()
531
- }, (t) => [
532
- unique("revisions_id_site").on(t.id, t.siteId),
533
- index("revisions_entry").on(t.entryId, t.createdAt),
534
- foreignKey({
535
- name: "revisions_site",
536
- columns: [t.siteId],
537
- foreignColumns: [sites.id]
538
- }),
539
- foreignKey({
540
- name: "revisions_entry",
541
- columns: [t.entryId, t.siteId],
542
- foreignColumns: [entries.id, entries.siteId]
543
- }),
544
- foreignKey({
545
- name: "revisions_user",
546
- columns: [t.actorUserId],
547
- foreignColumns: [users.id]
548
- }),
549
- foreignKey({
550
- name: "revisions_token",
551
- columns: [t.actorTokenId, t.siteId],
552
- foreignColumns: [tokens.id, tokens.siteId]
553
- }),
554
- check("revisions_actor_kind", sql`${t.actorKind} IN ('token', 'user', 'oauth_client')`),
555
- 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}))`),
556
- check("revisions_word_count", sql`${t.wordCount} >= 0`)
557
- ]);
558
- /** The tags of a revision, for listing published entries by tag. Append-only, like revisions. */
559
- const revisionTags = sqliteTable("revision_tags", {
560
- id: id("tag"),
561
- siteId: siteId(),
562
- revisionId: text("revision_id").notNull(),
563
- tag: text("tag").notNull(),
564
- ...timestamps()
565
- }, (t) => [
566
- unique("revision_tags_id_site").on(t.id, t.siteId),
567
- unique("revision_tags_tag").on(t.revisionId, t.tag),
568
- index("revision_tags_by_tag").on(t.siteId, t.tag),
569
- foreignKey({
570
- name: "revision_tags_site",
571
- columns: [t.siteId],
572
- foreignColumns: [sites.id]
573
- }),
574
- foreignKey({
575
- name: "revision_tags_revision",
576
- columns: [t.revisionId, t.siteId],
577
- foreignColumns: [revisions.id, revisions.siteId]
578
- })
579
- ]);
580
- /**
581
- * Every publish and unpublish, appended: the verdict of the publish phase and the warnings the
582
- * caller acknowledged (ADR-021).
583
- */
584
- const publications = sqliteTable("publications", {
585
- id: id("pub"),
586
- siteId: siteId(),
587
- entryId: text("entry_id").notNull(),
588
- action: text("action").notNull(),
589
- revisionId: text("revision_id"),
590
- verdict: text("verdict"),
591
- /** A JSON array: the warnings the caller acknowledged to publish. */
592
- acknowledged: text("acknowledged").notNull(),
593
- actorKind: text("actor_kind").notNull(),
594
- actorUserId: text("actor_user_id").notNull(),
595
- actorTokenId: text("actor_token_id"),
596
- actorMeta: text("actor_meta"),
597
- ...timestamps()
598
- }, (t) => [
599
- unique("publications_id_site").on(t.id, t.siteId),
600
- index("publications_entry").on(t.entryId, t.createdAt),
601
- foreignKey({
602
- name: "publications_site",
603
- columns: [t.siteId],
604
- foreignColumns: [sites.id]
605
- }),
606
- foreignKey({
607
- name: "publications_entry",
608
- columns: [t.entryId, t.siteId],
609
- foreignColumns: [entries.id, entries.siteId]
610
- }),
611
- foreignKey({
612
- name: "publications_revision",
613
- columns: [t.revisionId, t.siteId],
614
- foreignColumns: [revisions.id, revisions.siteId]
615
- }),
616
- foreignKey({
617
- name: "publications_user",
618
- columns: [t.actorUserId],
619
- foreignColumns: [users.id]
620
- }),
621
- foreignKey({
622
- name: "publications_token",
623
- columns: [t.actorTokenId, t.siteId],
624
- foreignColumns: [tokens.id, tokens.siteId]
625
- }),
626
- check("publications_action", sql`${t.action} IN ('publish', 'unpublish')`),
627
- 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)`),
628
- check("publications_actor_kind", sql`${t.actorKind} IN ('token', 'user', 'oauth_client')`),
629
- 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}))`)
630
- ]);
631
- //#endregion
632
362
  //#region src/db/rows.ts
633
363
  /**
634
364
  * The columns the store reads and how they become the types of `ports.ts`. The JSON columns come
@@ -1000,35 +730,6 @@ function isUniqueViolation(error) {
1000
730
  return false;
1001
731
  }
1002
732
  //#endregion
1003
- //#region src/db/size.ts
1004
- /**
1005
- * D1 refuses rows over 2 MB, and Miniflare does not enforce it, so the store counts the bytes of
1006
- * a row before writing it and refuses a row over `MAX_ROW_BYTES` (`limits.ts`).
1007
- */
1008
- const encoder$1 = new TextEncoder();
1009
- /** The bytes of `text` in UTF-8, which is how D1 stores it. */
1010
- function utf8Bytes(text) {
1011
- return encoder$1.encode(text).byteLength;
1012
- }
1013
- /** What SQLite keeps for an integer, at most. */
1014
- const INTEGER_BYTES = 8;
1015
- /** The bytes of every column of a row: text in UTF-8, an integer as 8 bytes, a null as none. */
1016
- function rowBytes(row) {
1017
- let bytes = 0;
1018
- for (const value of Object.values(row)) if (typeof value === "string") bytes += utf8Bytes(value);
1019
- else if (value !== null) bytes += INTEGER_BYTES;
1020
- return bytes;
1021
- }
1022
- /** The refusal for a row over the limit, or `null` when it fits. */
1023
- function tooLarge(row) {
1024
- const bytes = rowBytes(row);
1025
- return bytes > 19e5 ? {
1026
- outcome: "too_large",
1027
- bytes,
1028
- limit: MAX_ROW_BYTES
1029
- } : null;
1030
- }
1031
- //#endregion
1032
733
  //#region src/db/statements.ts
1033
734
  function actorColumns(actor) {
1034
735
  return {
@@ -1122,6 +823,249 @@ function insertUnpublishIfPublished(db, row) {
1122
823
  return db.insert(publications).select(sql`select ${valuesOf(publications, row)} from ${entries} where ${and(eq(entries.id, row.entryId), eq(entries.siteId, row.siteId), eq(entries.status, "published"))}`);
1123
824
  }
1124
825
  //#endregion
826
+ //#region src/db/schedules.ts
827
+ /**
828
+ * Scheduled publications (ADR-025). An entry waits in status `scheduled` with its `scheduled_at`,
829
+ * and its row of `schedules` says what was acknowledged and by whom, for the cron trigger that
830
+ * publishes it once the time has passed.
831
+ */
832
+ /** Statements that drop the schedule of an entry, in the batch of a write that ends it. */
833
+ function dropSchedule(db, site, entryId) {
834
+ return db.delete(schedules).where(and(eq(schedules.siteId, site), eq(schedules.entryId, entryId)));
835
+ }
836
+ async function schedule(db, clock, site, write) {
837
+ const now = clock.now().toISOString();
838
+ const row = {
839
+ id: newId("sch"),
840
+ siteId: site,
841
+ entryId: write.entryId,
842
+ scheduledAt: write.scheduledAt,
843
+ version: write.expectedVersion + 1,
844
+ acknowledged: JSON.stringify(write.acknowledged),
845
+ ...actorColumns(write.actor),
846
+ createdAt: now,
847
+ updatedAt: now
848
+ };
849
+ const atVersion = and(eq(entries.id, write.entryId), eq(entries.siteId, site), eq(entries.version, write.expectedVersion), sql`${entries.status} <> 'published'`);
850
+ const results = await db.batch([
851
+ db.delete(schedules).where(and(eq(schedules.siteId, site), eq(schedules.entryId, write.entryId), sql`exists (select 1 from ${entries} where ${atVersion})`)),
852
+ db.insert(schedules).select(sql`select ${valuesOf(schedules, row)} from ${entries} where ${atVersion}`),
853
+ db.update(entries).set({
854
+ status: "scheduled",
855
+ scheduledAt: write.scheduledAt,
856
+ version: sql`${entries.version} + 1`,
857
+ updatedAt: now
858
+ }).where(atVersion).returning({ id: entries.id })
859
+ ]);
860
+ const view = await findEntryById(db, site, write.entryId, "head");
861
+ if (view === null) return { outcome: "not_found" };
862
+ if (results[2].length === 0) return {
863
+ outcome: "conflict",
864
+ currentVersion: view.entry.version,
865
+ headRevision: view.entry.headRevision
866
+ };
867
+ return {
868
+ outcome: "scheduled",
869
+ view
870
+ };
871
+ }
872
+ /** The schedules of a site whose time has come, oldest first. */
873
+ async function dueSchedules(db, site, now) {
874
+ return (await db.select().from(schedules).where(and(eq(schedules.siteId, site), lte(schedules.scheduledAt, now))).orderBy(schedules.scheduledAt).limit(50)).map((row) => ({
875
+ entryId: row.entryId,
876
+ scheduledAt: row.scheduledAt,
877
+ version: row.version,
878
+ acknowledged: parseAcknowledged(row.acknowledged),
879
+ actor: {
880
+ kind: actorKindOf(row.actorKind),
881
+ userId: row.actorUserId,
882
+ tokenId: row.actorTokenId,
883
+ meta: row.actorMeta === null ? null : parseMeta(row.actorMeta)
884
+ }
885
+ }));
886
+ }
887
+ /** Takes an entry out of `scheduled`, back to a draft, and drops its schedule. */
888
+ async function cancelSchedule(db, clock, site, entryId) {
889
+ await db.batch([dropSchedule(db, site, entryId), db.update(entries).set({
890
+ status: "draft",
891
+ scheduledAt: null,
892
+ version: sql`${entries.version} + 1`,
893
+ updatedAt: clock.now().toISOString()
894
+ }).where(and(eq(entries.id, entryId), eq(entries.siteId, site), eq(entries.status, "scheduled")))]);
895
+ }
896
+ function parseAcknowledged(text) {
897
+ const value = JSON.parse(text);
898
+ if (!Array.isArray(value)) return [];
899
+ return value.flatMap((item) => typeof item === "object" && item !== null && "contract" in item && "path" in item && typeof item.contract === "string" && typeof item.path === "string" ? [{
900
+ contract: item.contract,
901
+ path: item.path
902
+ }] : []);
903
+ }
904
+ /** The kind of an actor as the `*_actor_kind` checks allow it. */
905
+ function actorKindOf(value) {
906
+ if (value === "token" || value === "user" || value === "oauth_client") return value;
907
+ throw new Error(`A schedule has the actor kind ${value}, which is no kind`);
908
+ }
909
+ function parseMeta(text) {
910
+ const value = JSON.parse(text);
911
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : null;
912
+ }
913
+ //#endregion
914
+ //#region src/db/reviews.ts
915
+ /**
916
+ * Reviews (v0.3). A person who may write but not publish asks for the head revision of an entry
917
+ * to be published; a person who may publish approves it, which publishes it, or asks for changes.
918
+ * The partial unique index `reviews_pending` keeps one pending review per entry. A write that
919
+ * moves the entry closes the pending review in its own batch: a new revision cancels it
920
+ * (`cancelPendingReview`), a publication approves it (`approvePendingReview`).
921
+ */
922
+ const STATUSES = [
923
+ "pending",
924
+ "approved",
925
+ "changes_requested",
926
+ "cancelled"
927
+ ];
928
+ function statusOf(value) {
929
+ const status = STATUSES.find((known) => known === value);
930
+ if (status === void 0) throw new Error(`A review has the status ${value}, which is no status`);
931
+ return status;
932
+ }
933
+ /** The columns of a review, and the name of who asked for it. */
934
+ const COLUMNS = {
935
+ ...getTableColumns(reviews),
936
+ requestedByName: users.name
937
+ };
938
+ function toReview(row) {
939
+ return {
940
+ id: row.id,
941
+ entryId: row.entryId,
942
+ revisionId: row.revisionId,
943
+ status: statusOf(row.status),
944
+ acknowledged: parseAcknowledged(row.acknowledged),
945
+ requestedBy: row.requestedBy,
946
+ requestedByName: row.requestedByName,
947
+ requestedAt: row.createdAt,
948
+ decidedBy: row.decidedBy,
949
+ decidedAt: row.decidedAt,
950
+ note: row.note
951
+ };
952
+ }
953
+ /** The entry is still at `version`: the condition of every statement a write adds to its batch. */
954
+ function entryAt(site, entryId, version) {
955
+ return sql`exists (select 1 from ${entries} where ${and(eq(entries.id, entryId), eq(entries.siteId, site), eq(entries.version, version))})`;
956
+ }
957
+ /** In the batch of a new revision: the pending review asked for another one, so it is cancelled. */
958
+ function cancelPendingReview(db, site, entryId, version, now) {
959
+ return db.update(reviews).set({
960
+ status: "cancelled",
961
+ updatedAt: now
962
+ }).where(and(eq(reviews.siteId, site), eq(reviews.entryId, entryId), eq(reviews.status, "pending"), entryAt(site, entryId, version)));
963
+ }
964
+ /** In the batch of a publication: what the pending review asked for is published, by `userId`. */
965
+ function approvePendingReview(db, site, entryId, version, userId, now) {
966
+ return db.update(reviews).set({
967
+ status: "approved",
968
+ decidedBy: userId,
969
+ decidedAt: now,
970
+ updatedAt: now
971
+ }).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))})`));
972
+ }
973
+ async function pendingOf(db, site, entryId) {
974
+ 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);
975
+ return row === void 0 ? null : toReview(row);
976
+ }
977
+ async function requestReview(db, clock, site, write, whyNothingChanged) {
978
+ const now = clock.now().toISOString();
979
+ const row = {
980
+ id: newId("rvw"),
981
+ siteId: site,
982
+ entryId: write.entryId,
983
+ revisionId: entries.headRev,
984
+ status: "pending",
985
+ acknowledged: JSON.stringify(write.acknowledged),
986
+ requestedBy: write.actor.userId,
987
+ requestedMeta: write.actor.meta === null ? null : JSON.stringify(write.actor.meta),
988
+ decidedBy: null,
989
+ decidedAt: null,
990
+ note: null,
991
+ createdAt: now,
992
+ updatedAt: now
993
+ };
994
+ let inserted;
995
+ try {
996
+ 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 });
997
+ } catch (error) {
998
+ const pending = isUniqueViolation(error) ? await pendingOf(db, site, write.entryId) : null;
999
+ if (pending === null) throw error;
1000
+ return {
1001
+ outcome: "pending_review",
1002
+ review: pending
1003
+ };
1004
+ }
1005
+ if (inserted.length === 0) return whyNothingChanged();
1006
+ const review = await findReview(db, site, row.id);
1007
+ if (review === null) throw new Error(`The review ${row.id} that was just written is not there`);
1008
+ return {
1009
+ outcome: "pending_review",
1010
+ review
1011
+ };
1012
+ }
1013
+ async function findReview(db, site, id) {
1014
+ 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);
1015
+ return row === void 0 ? null : toReview(row);
1016
+ }
1017
+ async function listReviews$1(db, site, query) {
1018
+ 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);
1019
+ }
1020
+ async function requestChanges$1(db, clock, site, write) {
1021
+ const now = clock.now().toISOString();
1022
+ const [row] = await db.update(reviews).set({
1023
+ status: "changes_requested",
1024
+ note: write.note,
1025
+ decidedBy: write.userId,
1026
+ decidedAt: now,
1027
+ updatedAt: now
1028
+ }).where(and(eq(reviews.siteId, site), eq(reviews.id, write.id), eq(reviews.status, "pending"))).returning({ id: reviews.id });
1029
+ const review = await findReview(db, site, write.id);
1030
+ if (row !== void 0 && review !== null) return {
1031
+ outcome: "changes_requested",
1032
+ review
1033
+ };
1034
+ return review === null ? { outcome: "not_found" } : {
1035
+ outcome: "not_pending",
1036
+ review
1037
+ };
1038
+ }
1039
+ //#endregion
1040
+ //#region src/db/size.ts
1041
+ /**
1042
+ * D1 refuses rows over 2 MB, and Miniflare does not enforce it, so the store counts the bytes of
1043
+ * a row before writing it and refuses a row over `MAX_ROW_BYTES` (`limits.ts`).
1044
+ */
1045
+ const encoder$1 = new TextEncoder();
1046
+ /** The bytes of `text` in UTF-8, which is how D1 stores it. */
1047
+ function utf8Bytes(text) {
1048
+ return encoder$1.encode(text).byteLength;
1049
+ }
1050
+ /** What SQLite keeps for an integer, at most. */
1051
+ const INTEGER_BYTES = 8;
1052
+ /** The bytes of every column of a row: text in UTF-8, an integer as 8 bytes, a null as none. */
1053
+ function rowBytes(row) {
1054
+ let bytes = 0;
1055
+ for (const value of Object.values(row)) if (typeof value === "string") bytes += utf8Bytes(value);
1056
+ else if (value !== null) bytes += INTEGER_BYTES;
1057
+ return bytes;
1058
+ }
1059
+ /** The refusal for a row over the limit, or `null` when it fits. */
1060
+ function tooLarge(row) {
1061
+ const bytes = rowBytes(row);
1062
+ return bytes > 19e5 ? {
1063
+ outcome: "too_large",
1064
+ bytes,
1065
+ limit: MAX_ROW_BYTES
1066
+ } : null;
1067
+ }
1068
+ //#endregion
1125
1069
  //#region src/db/writes.ts
1126
1070
  /**
1127
1071
  * Waits for a write batch. A batch that broke a unique key of `entries` has rolled back, and the
@@ -1285,6 +1229,7 @@ async function appendRevision(db, clock, site, write) {
1285
1229
  tags,
1286
1230
  now
1287
1231
  }),
1232
+ cancelPendingReview(db, site, write.entryId, write.expectedVersion, now),
1288
1233
  db.update(entries).set({
1289
1234
  version: sql`${entries.version} + 1`,
1290
1235
  headRev: revisionId,
@@ -1325,14 +1270,21 @@ async function publish$2(db, clock, site, write) {
1325
1270
  });
1326
1271
  const refusal = tooLarge(row);
1327
1272
  if (refusal !== null) return refusal;
1328
- const [changed] = (await db.batch([insertPublishAtVersion(db, row, write.expectedVersion), db.update(entries).set({
1329
- status: "published",
1330
- publishedRev: entries.headRev,
1331
- publishedAt: sql`coalesce(${entries.publishedAt}, ${now})`,
1332
- publishedSortKey: write.sortKey,
1333
- version: sql`${entries.version} + 1`,
1334
- updatedAt: now
1335
- }).where(and(eq(entries.id, write.entryId), eq(entries.siteId, site), eq(entries.version, write.expectedVersion))).returning(entryFields)]))[1];
1273
+ const [changed] = (await db.batch([
1274
+ insertPublishAtVersion(db, row, write.expectedVersion),
1275
+ dropScheduleAtVersion(db, site, write.entryId, write.expectedVersion),
1276
+ insertEventWhere(db, site, write.entryId, write.event ?? null, "entry.published", now, [eq(entries.version, write.expectedVersion)]),
1277
+ approvePendingReview(db, site, write.entryId, write.expectedVersion, write.actor.userId, now),
1278
+ db.update(entries).set({
1279
+ status: "published",
1280
+ scheduledAt: null,
1281
+ publishedRev: entries.headRev,
1282
+ publishedAt: sql`coalesce(${entries.publishedAt}, ${now})`,
1283
+ publishedSortKey: write.sortKey,
1284
+ version: sql`${entries.version} + 1`,
1285
+ updatedAt: now
1286
+ }).where(and(eq(entries.id, write.entryId), eq(entries.siteId, site), eq(entries.version, write.expectedVersion))).returning(entryFields)
1287
+ ]))[4];
1336
1288
  if (changed === void 0) return whyNothingChanged(db, site, write.entryId);
1337
1289
  const entry = toEntry(changed);
1338
1290
  const revision = entry.publishedRevision === null ? null : await findRevision(db, site, entry.publishedRevision);
@@ -1345,6 +1297,72 @@ async function publish$2(db, clock, site, write) {
1345
1297
  }
1346
1298
  };
1347
1299
  }
1300
+ /** The message of the statement that aborts a group publish: SQLite fails to parse it as JSON. */
1301
+ const GROUP_MOVED = "hollware: an entry of the group is not at its version";
1302
+ /**
1303
+ * Publishes a group of entries in one batch, all or none (ADR-024). Each entry gets the two
1304
+ * statements of `publish`; a last statement fails the batch, which D1 then rolls back, when fewer
1305
+ * publications landed than the group has entries: `json()` of a text that is not JSON raises an
1306
+ * error, and it runs only then. A refusal names the entry.
1307
+ */
1308
+ async function publishGroup$1(db, clock, site, group) {
1309
+ const now = clock.now().toISOString();
1310
+ const statements = [];
1311
+ const appended = [];
1312
+ for (const write of group) {
1313
+ const row = publicationRow({
1314
+ site,
1315
+ entryId: write.entryId,
1316
+ now,
1317
+ actor: write.actor,
1318
+ publish: {
1319
+ revisionId: newId("rev"),
1320
+ verdict: write.verdict,
1321
+ acknowledged: write.acknowledged
1322
+ }
1323
+ });
1324
+ const refusal = tooLarge(row);
1325
+ if (refusal !== null) return {
1326
+ ...refusal,
1327
+ entryId: write.entryId
1328
+ };
1329
+ appended.push(row.id);
1330
+ 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({
1331
+ status: "published",
1332
+ scheduledAt: null,
1333
+ publishedRev: entries.headRev,
1334
+ publishedAt: sql`coalesce(${entries.publishedAt}, ${now})`,
1335
+ publishedSortKey: write.sortKey,
1336
+ version: sql`${entries.version} + 1`,
1337
+ updatedAt: now
1338
+ }).where(and(eq(entries.id, write.entryId), eq(entries.siteId, site), eq(entries.version, write.expectedVersion))));
1339
+ }
1340
+ const guard = db.select({ moved: sql`json(${GROUP_MOVED})` }).from(sites).where(and(eq(sites.id, site), sql`(select count(*) from ${publications} where ${and(eq(publications.siteId, site), inArray(publications.id, appended))}) < ${group.length}`));
1341
+ try {
1342
+ const [first, ...rest] = [...statements, guard];
1343
+ if (first !== void 0) await db.batch([first, ...rest]);
1344
+ } catch (error) {
1345
+ if (!(error instanceof Error) || !/malformed JSON/i.test(`${error.message} ${String(error.cause)}`)) throw error;
1346
+ for (const write of group) {
1347
+ const why = await whyNothingChanged(db, site, write.entryId);
1348
+ if (why.outcome === "not_found" || why.currentVersion !== write.expectedVersion) return {
1349
+ ...why,
1350
+ entryId: write.entryId
1351
+ };
1352
+ }
1353
+ throw new Error("A group publish failed, and every entry of the group is at its version", { cause: error });
1354
+ }
1355
+ const views = [];
1356
+ for (const write of group) {
1357
+ const view = await findEntryById(db, site, write.entryId, "published");
1358
+ if (view === null) throw new Error(`Entry ${write.entryId} is not published after its group`);
1359
+ views.push(view);
1360
+ }
1361
+ return {
1362
+ outcome: "published",
1363
+ views
1364
+ };
1365
+ }
1348
1366
  async function unpublish$1(db, clock, site, write) {
1349
1367
  const now = clock.now().toISOString();
1350
1368
  const row = publicationRow({
@@ -1356,19 +1374,47 @@ async function unpublish$1(db, clock, site, write) {
1356
1374
  });
1357
1375
  const refusal = tooLarge(row);
1358
1376
  if (refusal !== null) return refusal;
1359
- await db.batch([insertUnpublishIfPublished(db, row), db.update(entries).set({
1360
- status: "draft",
1361
- publishedRev: null,
1362
- publishedSortKey: null,
1363
- version: sql`${entries.version} + 1`,
1364
- updatedAt: now
1365
- }).where(and(eq(entries.id, write.entryId), eq(entries.siteId, site), eq(entries.status, "published")))]);
1377
+ await db.batch([
1378
+ insertUnpublishIfPublished(db, row),
1379
+ insertEventWhere(db, site, write.entryId, write.event ?? null, "entry.unpublished", now, [eq(entries.status, "published")]),
1380
+ dropSchedule(db, site, write.entryId),
1381
+ db.update(entries).set({
1382
+ status: "draft",
1383
+ scheduledAt: null,
1384
+ publishedRev: null,
1385
+ publishedSortKey: null,
1386
+ version: sql`${entries.version} + 1`,
1387
+ updatedAt: now
1388
+ }).where(and(eq(entries.id, write.entryId), eq(entries.siteId, site), inArray(entries.status, ["published", "scheduled"])))
1389
+ ]);
1366
1390
  const view = await findEntryById(db, site, write.entryId, "head");
1367
1391
  return view === null ? { outcome: "not_found" } : {
1368
1392
  outcome: "unpublished",
1369
1393
  view
1370
1394
  };
1371
1395
  }
1396
+ /** Drops the schedule of an entry in the batch that publishes it, if it is at `version`. */
1397
+ function dropScheduleAtVersion(db, site, entryId, version) {
1398
+ return db.delete(schedules).where(and(eq(schedules.siteId, site), eq(schedules.entryId, entryId), sql`exists (select 1 from ${entries} where ${and(eq(entries.id, entryId), eq(entries.siteId, site), eq(entries.version, version))})`));
1399
+ }
1400
+ /**
1401
+ * Writes the event of a write to the outbox in its batch, only where the entry is in the state the
1402
+ * write expects, so that an event is there exactly when its write is. Nothing when there is no
1403
+ * event: a statement that selects nothing, to keep the batch the same shape.
1404
+ */
1405
+ function insertEventWhere(db, site, entryId, event, kind, now, conditions) {
1406
+ const row = {
1407
+ id: event?.id ?? "evt_none",
1408
+ siteId: site,
1409
+ kind,
1410
+ entryId,
1411
+ payload: event?.payload ?? "{}",
1412
+ sentAt: null,
1413
+ createdAt: now,
1414
+ updatedAt: now
1415
+ };
1416
+ return db.insert(outbox).select(sql`select ${valuesOf(outbox, row)} from ${entries} where ${and(eq(entries.id, entryId), eq(entries.siteId, site), sql`${event === null ? 0 : 1} = 1`, ...conditions)}`);
1417
+ }
1372
1418
  //#endregion
1373
1419
  //#region src/db/store.ts
1374
1420
  const systemClock$1 = { now: () => /* @__PURE__ */ new Date() };
@@ -1390,10 +1436,220 @@ function createD1Store(db, options = {}) {
1390
1436
  createEntry: (site, entry) => createEntry(database, clock, site, entry),
1391
1437
  appendRevision: (site, write) => appendRevision(database, clock, site, write),
1392
1438
  publish: (site, write) => publish$2(database, clock, site, write),
1393
- unpublish: (site, write) => unpublish$1(database, clock, site, write)
1439
+ publishGroup: (site, group) => publishGroup$1(database, clock, site, group),
1440
+ schedule: (site, write) => schedule(database, clock, site, write),
1441
+ dueSchedules: (site, now) => dueSchedules(database, site, now),
1442
+ cancelSchedule: (site, entryId) => cancelSchedule(database, clock, site, entryId),
1443
+ unpublish: (site, write) => unpublish$1(database, clock, site, write),
1444
+ requestReview: (site, write) => requestReview(database, clock, site, write, () => whyNothingChanged(database, site, write.entryId)),
1445
+ findReview: (site, id) => findReview(database, site, id),
1446
+ listReviews: (site, query) => listReviews$1(database, site, query),
1447
+ requestChanges: (site, write) => requestChanges$1(database, clock, site, write)
1448
+ };
1449
+ }
1450
+ //#endregion
1451
+ //#region src/db/events.ts
1452
+ async function findEvent(db, site, id) {
1453
+ const [row] = await db.select({
1454
+ id: outbox.id,
1455
+ kind: outbox.kind,
1456
+ payload: outbox.payload
1457
+ }).from(outbox).where(and(eq(outbox.siteId, site), eq(outbox.id, id))).limit(1);
1458
+ return row ?? null;
1459
+ }
1460
+ /** The events that were never put on the Queue and are older than `before`, oldest first. */
1461
+ async function unsentEvents(db, site, before, limit = 100) {
1462
+ return (await db.select({ id: outbox.id }).from(outbox).where(and(eq(outbox.siteId, site), isNull(outbox.sentAt), lte(outbox.createdAt, before))).orderBy(outbox.createdAt).limit(limit)).map((row) => row.id);
1463
+ }
1464
+ async function markSent(db, site, ids, now) {
1465
+ if (ids.length === 0) return;
1466
+ await db.update(outbox).set({
1467
+ sentAt: now,
1468
+ updatedAt: now
1469
+ }).where(and(eq(outbox.siteId, site), inArray(outbox.id, [...ids])));
1470
+ }
1471
+ /** The subscribers that already have an event, so that a retry never delivers it twice. */
1472
+ async function deliveredTo(db, site, eventId) {
1473
+ const rows = await db.select({
1474
+ subscriber: deliveries.subscriber,
1475
+ deliveredAt: deliveries.deliveredAt
1476
+ }).from(deliveries).where(and(eq(deliveries.siteId, site), eq(deliveries.eventId, eventId)));
1477
+ return new Set(rows.filter((row) => row.deliveredAt !== null).map((row) => row.subscriber));
1478
+ }
1479
+ /** Records one attempt to deliver an event to a subscriber, and its result. */
1480
+ async function recordAttempt(db, input) {
1481
+ const deliveredAt = input.error === null ? input.now : null;
1482
+ await db.insert(deliveries).values({
1483
+ id: newId("dlv"),
1484
+ siteId: input.site,
1485
+ eventId: input.eventId,
1486
+ subscriber: input.subscriber,
1487
+ attempts: 1,
1488
+ deliveredAt,
1489
+ lastError: input.error,
1490
+ createdAt: input.now,
1491
+ updatedAt: input.now
1492
+ }).onConflictDoUpdate({
1493
+ target: [deliveries.eventId, deliveries.subscriber],
1494
+ set: {
1495
+ attempts: sqlIncrement(),
1496
+ deliveredAt,
1497
+ lastError: input.error,
1498
+ updatedAt: input.now
1499
+ }
1500
+ });
1501
+ }
1502
+ function sqlIncrement() {
1503
+ return sql`${deliveries.attempts} + 1`;
1504
+ }
1505
+ //#endregion
1506
+ //#region src/events/deliver.ts
1507
+ /** Where IndexNow takes the URLs that changed. */
1508
+ const INDEXNOW_ENDPOINT = "https://api.indexnow.org/indexnow";
1509
+ async function ensureOk(response, what) {
1510
+ if (response.ok) return;
1511
+ throw new Error(`${what} answered ${response.status}`);
1512
+ }
1513
+ /** The subscribers that the config of the site declares (ADR-025). */
1514
+ function subscribersOf(deps) {
1515
+ const { site } = deps;
1516
+ const subscribers = site.events.webhooks.map((webhook) => ({
1517
+ name: `webhook:${webhook.name}`,
1518
+ async send(payload, body) {
1519
+ const secret = deps.secret(webhook.secret);
1520
+ if (secret === void 0) throw new Error(`the secret ${webhook.secret} is not set`);
1521
+ const timestamp = Math.floor(deps.clock.now().getTime() / 1e3);
1522
+ const request = new Request(webhook.url, {
1523
+ method: "POST",
1524
+ headers: {
1525
+ "content-type": "application/json",
1526
+ [EVENT_ID_HEADER]: payload.id,
1527
+ [TIMESTAMP_HEADER]: String(timestamp),
1528
+ [SIGNATURE_HEADER]: await signWebhook(secret, timestamp, body)
1529
+ },
1530
+ body
1531
+ });
1532
+ await ensureOk(await deps.fetch(request), `the webhook ${webhook.name}`);
1533
+ }
1534
+ }));
1535
+ const indexNow = site.events.indexNow;
1536
+ if (indexNow !== null && site.url !== null) {
1537
+ const host = new URL(site.url).host;
1538
+ subscribers.push({
1539
+ name: "indexnow",
1540
+ async send(payload) {
1541
+ if (payload.url === null) return;
1542
+ const key = deps.secret(indexNow.key);
1543
+ if (key === void 0) throw new Error(`the secret ${indexNow.key} is not set`);
1544
+ const request = new Request(INDEXNOW_ENDPOINT, {
1545
+ method: "POST",
1546
+ headers: { "content-type": "application/json; charset=utf-8" },
1547
+ body: JSON.stringify({
1548
+ host,
1549
+ key,
1550
+ urlList: [payload.url]
1551
+ })
1552
+ });
1553
+ await ensureOk(await deps.fetch(request), "IndexNow");
1554
+ }
1555
+ });
1556
+ }
1557
+ return subscribers;
1558
+ }
1559
+ /**
1560
+ * Delivers one event of the outbox to every subscriber that does not have it yet, and records each
1561
+ * attempt. A subscriber that fails is tried again on the next delivery of the event, and one that
1562
+ * succeeded is never sent it again: the caller retries the message while `failed` is not empty.
1563
+ * An event that is gone, or a site without subscribers, delivers nothing.
1564
+ */
1565
+ async function deliverEvent(deps, eventId) {
1566
+ const event = await findEvent(deps.db, deps.site.key, eventId);
1567
+ if (event === null) return {
1568
+ delivered: [],
1569
+ failed: []
1570
+ };
1571
+ const payload = JSON.parse(event.payload);
1572
+ const done = await deliveredTo(deps.db, deps.site.key, eventId);
1573
+ const delivered = [];
1574
+ const failed = [];
1575
+ for (const subscriber of subscribersOf(deps)) {
1576
+ if (done.has(subscriber.name)) continue;
1577
+ let error = null;
1578
+ try {
1579
+ await subscriber.send(payload, event.payload);
1580
+ } catch (cause) {
1581
+ error = cause instanceof Error ? cause.message : String(cause);
1582
+ }
1583
+ await recordAttempt(deps.db, {
1584
+ site: deps.site.key,
1585
+ eventId,
1586
+ subscriber: subscriber.name,
1587
+ now: deps.clock.now().toISOString(),
1588
+ error
1589
+ });
1590
+ (error === null ? delivered : failed).push(subscriber.name);
1591
+ }
1592
+ return {
1593
+ delivered,
1594
+ failed
1394
1595
  };
1395
1596
  }
1396
1597
  //#endregion
1598
+ //#region src/events/run.ts
1599
+ /**
1600
+ * Puts events on the Queue and marks them sent. Without a Queue binding nothing is sent: the
1601
+ * sweep of the cron trigger delivers them instead, within a minute.
1602
+ */
1603
+ async function enqueue(input) {
1604
+ if (input.queue === void 0 || input.ids.length === 0) return;
1605
+ await input.queue.sendBatch(input.ids.map((id) => ({ body: {
1606
+ site: input.site,
1607
+ id
1608
+ } })));
1609
+ await markSent(input.db, input.site, input.ids, input.clock.now().toISOString());
1610
+ }
1611
+ /**
1612
+ * What the cron trigger does with the outbox: an event that was never put on the Queue (its
1613
+ * request ended before, or the project has no Queue) is put on it, or, without a Queue, delivered
1614
+ * here. An event delivered here is marked sent only when every subscriber has it.
1615
+ */
1616
+ async function sweepOutbox(input) {
1617
+ const { deps, queue } = input;
1618
+ const now = deps.clock.now().toISOString();
1619
+ const ids = await unsentEvents(deps.db, deps.site.key, now);
1620
+ if (queue !== void 0) {
1621
+ await enqueue({
1622
+ queue,
1623
+ db: deps.db,
1624
+ site: deps.site.key,
1625
+ ids,
1626
+ clock: deps.clock
1627
+ });
1628
+ return { swept: ids.length };
1629
+ }
1630
+ for (const id of ids) if ((await deliverEvent(deps, id)).failed.length === 0) await markSent(deps.db, deps.site.key, [id], now);
1631
+ return { swept: ids.length };
1632
+ }
1633
+ function isEventMessage(body) {
1634
+ return typeof body === "object" && body !== null && "site" in body && "id" in body && typeof body.site === "string" && typeof body.id === "string";
1635
+ }
1636
+ /**
1637
+ * The Queue consumer: delivers each event, acknowledges it once every subscriber has it, and asks
1638
+ * for a retry otherwise; after the Queue's retries it lands in the dead-letter queue. A message of
1639
+ * another site, or that is no event, is acknowledged and dropped.
1640
+ */
1641
+ async function consumeEvents(messages, deps) {
1642
+ for (const message of messages) {
1643
+ const { body } = message;
1644
+ if (!isEventMessage(body) || body.site !== deps.site.key) {
1645
+ message.ack();
1646
+ continue;
1647
+ }
1648
+ if ((await deliverEvent(deps, body.id)).failed.length === 0) message.ack();
1649
+ else message.retry();
1650
+ }
1651
+ }
1652
+ //#endregion
1397
1653
  //#region src/mcp/errors.ts
1398
1654
  /**
1399
1655
  * The error a caller gets for anything that is not a `HollwareError`. The message is fixed: what
@@ -1448,45 +1704,1081 @@ function gatewayError(status, body) {
1448
1704
  });
1449
1705
  }
1450
1706
  /**
1451
- * Everything that differs between Hono 4 and Hono 5 error handling lives in this file (spike S1).
1452
- *
1453
- * Hono 4: `app.onError((error, c) => Response)`, one handler for the app.
1454
- * Hono 5: `app.onError((c, next) => Response)` registers error middleware, and the error is
1455
- * `c.error`. A Hono 4 handler registered on Hono 5 is not rejected: it is called with the context
1456
- * where it expects the error, throws, and the app answers a plain-text 500. So the tests read the
1457
- * JSON body of both answers.
1707
+ * Everything that differs between Hono 4 and Hono 5 error handling lives in this file (spike S1).
1708
+ *
1709
+ * Hono 4: `app.onError((error, c) => Response)`, one handler for the app.
1710
+ * Hono 5: `app.onError((c, next) => Response)` registers error middleware, and the error is
1711
+ * `c.error`. A Hono 4 handler registered on Hono 5 is not rejected: it is called with the context
1712
+ * where it expects the error, throws, and the app answers a plain-text 500. So the tests read the
1713
+ * JSON body of both answers.
1714
+ *
1715
+ * - An error that a route throws is a JSON 500 with a fixed message, and the id of the request
1716
+ * in its fix, which is how the operator finds the line of the log that has the cause. The cause
1717
+ * goes to the log and never to the caller, whose agent could repeat it to a person or to another
1718
+ * system. This holds for every error, a failed D1 query included: nothing turns one into a 401.
1719
+ * - A path that no route has is a JSON 404.
1720
+ */
1721
+ function installErrorHandling(app, log) {
1722
+ app.onError((c) => {
1723
+ const requestId = c.get("requestId");
1724
+ log({
1725
+ kind: "request_error",
1726
+ requestId,
1727
+ method: c.req.method,
1728
+ path: c.req.path,
1729
+ detail: describeError(c.error)
1730
+ });
1731
+ return gatewayError(500, {
1732
+ code: "internal",
1733
+ message: "The request failed on the server.",
1734
+ fix: `Try again. If it keeps failing, give the site operator this request id: ${requestId}.`
1735
+ });
1736
+ });
1737
+ app.notFound(() => gatewayError(404, {
1738
+ code: "not_found",
1739
+ message: "This Hollware has no route for that path.",
1740
+ fix: "Send MCP requests to /mcp with \"Authorization: Bearer <token>\". GET /health tells whether the Worker is up."
1741
+ }));
1742
+ }
1743
+ //#endregion
1744
+ //#region src/oauth/admin.ts
1745
+ /**
1746
+ * The admin as a client of the OAuth server (ADR-026): a public client, with PKCE, that a person
1747
+ * never has to allow. It is registered once per issuer, with `createClient`, and its id is kept in
1748
+ * `OAUTH_KV` beside the library's own records. A Client ID Metadata Document would need an `https`
1749
+ * URL that the Worker fetches from itself, which local development does not have.
1750
+ */
1751
+ /** Where the admin's id is kept, by issuer. The library's own keys never start with it. */
1752
+ const KEY = "hollware:admin-client:";
1753
+ /**
1754
+ * The admin's client, registered on first use. Two first requests at once may both register one:
1755
+ * the last id written wins, and the other client is never used.
1756
+ */
1757
+ async function adminClient(oauth, env) {
1758
+ const redirectUri = `${oauth.issuer}/admin/callback`;
1759
+ const stored = await env.OAUTH_KV.get(`${KEY}${oauth.issuer}`);
1760
+ if (stored !== null) return {
1761
+ clientId: stored,
1762
+ redirectUri
1763
+ };
1764
+ const client = await oauth.authorizationServer.getOAuthApi(env).createClient({
1765
+ clientName: "Hollware admin",
1766
+ redirectUris: [redirectUri],
1767
+ tokenEndpointAuthMethod: "none"
1768
+ });
1769
+ await env.OAUTH_KV.put(`${KEY}${oauth.issuer}`, client.clientId);
1770
+ return {
1771
+ clientId: client.clientId,
1772
+ redirectUri
1773
+ };
1774
+ }
1775
+ //#endregion
1776
+ //#region src/http/admin.ts
1777
+ /**
1778
+ * The admin at `/admin/` (ADR-026). Its hashed files are static assets that never reach the
1779
+ * Worker (`run_worker_first` in wrangler.jsonc); its page does, so that it goes out with the
1780
+ * policy below, and so does every path of the single-page app, which all get the same page. The
1781
+ * page learns how to sign in from `/admin/config.json`.
1782
+ */
1783
+ /**
1784
+ * What the admin's page may do: its own scripts and styles (CodeMirror and Nuxt UI write style
1785
+ * elements and attributes, hence `unsafe-inline` for styles only), images of the content from
1786
+ * anywhere over https, and requests to this origin alone. Nothing frames it.
1787
+ */
1788
+ const POLICY = [
1789
+ "default-src 'self'",
1790
+ "script-src 'self'",
1791
+ "style-src 'self' 'unsafe-inline'",
1792
+ "img-src 'self' data: https:",
1793
+ "connect-src 'self'",
1794
+ "font-src 'self' data:",
1795
+ "base-uri 'none'",
1796
+ "form-action 'self'",
1797
+ "frame-ancestors 'none'"
1798
+ ].join("; ");
1799
+ function notInstalled() {
1800
+ return Response.json({
1801
+ code: "not_found",
1802
+ message: "The admin is not installed in this Worker.",
1803
+ fix: "Bind the static assets as ASSETS and serve the files of @hollware/admin (adminAssets()), as the starter does."
1804
+ }, {
1805
+ status: 404,
1806
+ headers: { "cache-control": "no-store" }
1807
+ });
1808
+ }
1809
+ function installAdmin(app, options) {
1810
+ app.get("/admin/config.json", async (c) => {
1811
+ const oauth = options.oauth(c.env.HOLLWARE_PUBLIC_URL);
1812
+ const client = await adminClient(oauth, c.env);
1813
+ return c.json({
1814
+ issuer: oauth.issuer,
1815
+ clientId: client.clientId,
1816
+ redirectUri: client.redirectUri,
1817
+ resource: oauth.resources.v1,
1818
+ scope: SCOPES.join(" ")
1819
+ }, 200, { "cache-control": "no-store" });
1820
+ });
1821
+ const page = async (c) => {
1822
+ const assets = c.env.ASSETS;
1823
+ if (assets === void 0) return notInstalled();
1824
+ const origin = options.oauth(c.env.HOLLWARE_PUBLIC_URL).issuer;
1825
+ const answer = await assets.fetch(new Request(`${origin}/admin/`));
1826
+ if (!answer.ok) return notInstalled();
1827
+ const headers = new Headers(answer.headers);
1828
+ headers.set("content-security-policy", POLICY);
1829
+ headers.set("cache-control", "no-store");
1830
+ headers.set("x-content-type-options", "nosniff");
1831
+ headers.set("referrer-policy", "no-referrer");
1832
+ return new Response(answer.body, {
1833
+ status: 200,
1834
+ headers
1835
+ });
1836
+ };
1837
+ app.get("/admin", (c) => c.redirect("/admin/", 301));
1838
+ app.get("/admin/", page);
1839
+ app.get("/admin/*", async (c) => {
1840
+ if (/\.[a-z0-9]+$/i.test(c.req.path)) {
1841
+ const assets = c.env.ASSETS;
1842
+ if (assets === void 0) return notInstalled();
1843
+ return assets.fetch(c.req.raw);
1844
+ }
1845
+ return page(c);
1846
+ });
1847
+ }
1848
+ //#endregion
1849
+ //#region src/http/admin-api.ts
1850
+ /**
1851
+ * What only a person in the admin may do (ADR-026): see, make and revoke personal tokens, and
1852
+ * invite people. These are no operations, so no agent sees them over MCP, and a request needs an
1853
+ * access token of the admin's own client with `schema:admin`: a personal token, or a token of
1854
+ * another client, never reaches them, even an owner's. A leaked token cannot mint the next one.
1855
+ */
1856
+ /** A personal token lasts this long unless the person says otherwise (as `hollware token create`). */
1857
+ const TOKEN_DAYS = 90;
1858
+ const INVITATION_DAYS = 7;
1859
+ function json$1(body, status = 200) {
1860
+ return Response.json(body, {
1861
+ status,
1862
+ headers: { "cache-control": "no-store" }
1863
+ });
1864
+ }
1865
+ function refused(message, fix, status = 400) {
1866
+ return json$1({
1867
+ code: status === 400 ? "invalid_input" : "forbidden",
1868
+ message,
1869
+ fix
1870
+ }, status);
1871
+ }
1872
+ function parseScopes(json) {
1873
+ try {
1874
+ const value = JSON.parse(json);
1875
+ return Array.isArray(value) ? value.filter((scope) => typeof scope === "string" && isScope(scope)) : [];
1876
+ } catch {
1877
+ return [];
1878
+ }
1879
+ }
1880
+ function daysFrom(value, fallback) {
1881
+ if (value === void 0) return fallback;
1882
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 365 ? value : null;
1883
+ }
1884
+ function installAdminApi(app, options) {
1885
+ /** Runs `handle` for a person in the admin with `schema:admin`; anyone else is refused. */
1886
+ const admin = (handle) => (c) => options.protect(c, async (principal) => {
1887
+ const ours = (await adminClient(options.oauth(c.env.HOLLWARE_PUBLIC_URL), c.env)).clientId;
1888
+ if (principal.kind !== "oauth" || principal.clientId !== ours) return refused("Only the admin may do this.", "Sign in to the admin at /admin/ and do it there.", 403);
1889
+ if (!principal.scopes.includes("schema:admin")) return insufficientScope(["schema:admin"], options.metadataUrl(c));
1890
+ return handle(c, principal);
1891
+ });
1892
+ const body = async (c) => {
1893
+ const value = await c.req.json().catch(() => null);
1894
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {};
1895
+ };
1896
+ app.get("/v1/admin/tokens", admin(async (c) => {
1897
+ return json$1({ tokens: (await connect(c.env.DB).select({
1898
+ id: tokens.id,
1899
+ label: tokens.label,
1900
+ scopes: tokens.scopes,
1901
+ user: users.name,
1902
+ expiresAt: tokens.expiresAt,
1903
+ revokedAt: tokens.revokedAt,
1904
+ lastUsedAt: tokens.lastUsedAt,
1905
+ createdAt: tokens.createdAt
1906
+ }).from(tokens).innerJoin(users, eq(users.id, tokens.userId)).where(eq(tokens.siteId, options.site)).orderBy(desc(tokens.createdAt), desc(tokens.id))).map((row) => ({
1907
+ ...row,
1908
+ scopes: parseScopes(row.scopes)
1909
+ })) });
1910
+ }));
1911
+ app.post("/v1/admin/tokens", admin(async (c, person) => {
1912
+ const input = await body(c);
1913
+ const label = typeof input["label"] === "string" ? input["label"].trim() : "";
1914
+ if (label === "" || label.length > 100) return refused("A token needs a label of 1 to 100 characters.", "Say what the token is for.");
1915
+ const asked = Array.isArray(input["scopes"]) ? input["scopes"].filter((s) => typeof s === "string") : [];
1916
+ const scopes = person.scopes.filter((scope) => asked.includes(scope));
1917
+ if (scopes.length === 0) return refused("A token needs at least one scope that you have.", `Choose among ${person.scopes.join(", ")}.`);
1918
+ const days = daysFrom(input["expiresInDays"], TOKEN_DAYS);
1919
+ if (days === null) return refused("expiresInDays is a whole number from 1 to 365.", "Leave it out for 90 days.");
1920
+ const now = options.clock.now();
1921
+ const secret = generatePersonalToken();
1922
+ const row = {
1923
+ id: newId("tok"),
1924
+ siteId: options.site,
1925
+ userId: person.userId,
1926
+ label,
1927
+ hash: await hashPersonalToken(secret),
1928
+ scopes: JSON.stringify(scopes),
1929
+ expiresAt: new Date(now.getTime() + days * 864e5).toISOString(),
1930
+ revokedAt: null,
1931
+ lastUsedAt: null,
1932
+ createdAt: now.toISOString(),
1933
+ updatedAt: now.toISOString()
1934
+ };
1935
+ await connect(c.env.DB).insert(tokens).values(row);
1936
+ return json$1({
1937
+ id: row.id,
1938
+ label,
1939
+ scopes,
1940
+ expiresAt: row.expiresAt,
1941
+ secret
1942
+ }, 201);
1943
+ }));
1944
+ app.post("/v1/admin/tokens/:id/revoke", admin(async (c) => {
1945
+ const now = options.clock.now().toISOString();
1946
+ const db = connect(c.env.DB);
1947
+ const id = c.req.param("id") ?? "";
1948
+ const [revoked] = await db.update(tokens).set({
1949
+ revokedAt: now,
1950
+ updatedAt: now
1951
+ }).where(and(eq(tokens.siteId, options.site), eq(tokens.id, id), isNull(tokens.revokedAt))).returning({ id: tokens.id });
1952
+ if (revoked !== void 0) return json$1({
1953
+ id: revoked.id,
1954
+ revokedAt: now
1955
+ });
1956
+ const [row] = await db.select({
1957
+ id: tokens.id,
1958
+ revokedAt: tokens.revokedAt
1959
+ }).from(tokens).where(and(eq(tokens.siteId, options.site), eq(tokens.id, id)));
1960
+ return row === void 0 ? refused("There is no such token.", "List the tokens and pick one of them.", 404) : json$1({
1961
+ id: row.id,
1962
+ revokedAt: row.revokedAt
1963
+ });
1964
+ }));
1965
+ app.get("/v1/admin/people", admin(async (c) => {
1966
+ const db = connect(c.env.DB);
1967
+ const now = options.clock.now().toISOString();
1968
+ return json$1({
1969
+ people: await db.select({
1970
+ id: users.id,
1971
+ name: users.name,
1972
+ role: members.role,
1973
+ since: members.createdAt
1974
+ }).from(members).innerJoin(users, eq(users.id, members.userId)).where(eq(members.siteId, options.site)).orderBy(members.createdAt, users.id),
1975
+ invitations: await db.select({
1976
+ id: invitations.id,
1977
+ role: invitations.role,
1978
+ expiresAt: invitations.expiresAt
1979
+ }).from(invitations).where(and(eq(invitations.siteId, options.site), isNull(invitations.usedAt), isNull(invitations.userId), gt(invitations.expiresAt, now))).orderBy(desc(invitations.createdAt))
1980
+ });
1981
+ }));
1982
+ app.post("/v1/admin/invitations", admin(async (c) => {
1983
+ const input = await body(c);
1984
+ const role = input["role"];
1985
+ if (typeof role !== "string" || !isRole(role) || role === "owner") return refused("An invitation is for an editor or an author.", `Use role "editor" (${ROLE_SCOPES.editor.join(", ")}) or "author" (${ROLE_SCOPES.author.join(", ")}).`);
1986
+ const days = daysFrom(input["days"], INVITATION_DAYS);
1987
+ if (days === null) return refused("days is a whole number from 1 to 365.", "Leave it out for 7 days.");
1988
+ const made = await createInvitation(connect(c.env.DB), {
1989
+ site: options.site,
1990
+ role,
1991
+ userId: null,
1992
+ now: options.clock.now(),
1993
+ days
1994
+ });
1995
+ const origin = options.oauth(c.env.HOLLWARE_PUBLIC_URL).issuer;
1996
+ return json$1({
1997
+ id: made.id,
1998
+ role,
1999
+ expiresAt: made.expiresAt,
2000
+ link: `${origin}/setup?invitation=${made.secret}`
2001
+ }, 201);
2002
+ }));
2003
+ }
2004
+ //#endregion
2005
+ //#region src/http/ops-api.ts
2006
+ const STATUS$1 = {
2007
+ invalid_input: 400,
2008
+ unknown_collection: 404,
2009
+ unknown_locale: 404,
2010
+ timeout: 504
2011
+ };
2012
+ function json(body, status = 200) {
2013
+ return Response.json(body, {
2014
+ status,
2015
+ headers: { "cache-control": "no-store" }
2016
+ });
2017
+ }
2018
+ /** Calls the handler of an operation with input its spec has parsed. */
2019
+ function invoke$1(handlers, spec, input, context) {
2020
+ const handler = handlers[spec.name];
2021
+ return handler(input, context);
2022
+ }
2023
+ function installOpsApi(app, options) {
2024
+ app.post("/v1/ops/:name", async (c) => options.protect(c, async (principal) => {
2025
+ const requestId = c.get("requestId");
2026
+ const spec = OPERATIONS.find((operation) => operation.name === c.req.param("name"));
2027
+ if (spec === void 0) return json({
2028
+ code: "invalid_input",
2029
+ message: `There is no operation ${c.req.param("name")}.`,
2030
+ fix: "Use the name of an operation, as tools/list of MCP lists them."
2031
+ }, 404);
2032
+ const scopes = spec.scopes;
2033
+ if (missingScopes(principal, scopes).length > 0) return insufficientScope(scopes, options.metadataUrl(c));
2034
+ const body = await c.req.json().catch(() => void 0);
2035
+ const parsed = spec.input.safeParse(body ?? {});
2036
+ if (!parsed.success) {
2037
+ const issues = parsed.error.issues.map((issue) => `${issue.path.join(".") || "input"}: ${issue.message}`);
2038
+ return json({
2039
+ code: "invalid_input",
2040
+ message: `The input of ${spec.name} is not valid: ${issues.join("; ")}`,
2041
+ fix: "Send the input of the operation as its input schema describes it."
2042
+ }, 400);
2043
+ }
2044
+ const context = {
2045
+ site: options.site,
2046
+ principal,
2047
+ view: "head",
2048
+ store: createD1Store(c.env.DB, { clock: options.clock }),
2049
+ clock: options.clock,
2050
+ client: { name: "hollware-admin" },
2051
+ requestId,
2052
+ notify: options.notify(c, requestId)
2053
+ };
2054
+ try {
2055
+ return json(spec.output.parse(await invoke$1(options.handlers, spec, parsed.data, context)));
2056
+ } catch (error) {
2057
+ if (error instanceof HollwareError) return json({
2058
+ code: error.code,
2059
+ message: error.message,
2060
+ fix: error.fix
2061
+ }, STATUS$1[error.code] ?? 500);
2062
+ options.log({
2063
+ kind: "request_error",
2064
+ requestId,
2065
+ method: "POST",
2066
+ path: c.req.path,
2067
+ detail: describeError(error)
2068
+ });
2069
+ return json({
2070
+ code: "internal",
2071
+ message: "The operation failed on the server.",
2072
+ fix: `Try again. If it keeps failing, give the site operator this request id: ${requestId}.`
2073
+ }, 500);
2074
+ }
2075
+ }));
2076
+ }
2077
+ //#endregion
2078
+ //#region src/http/read-api.ts
2079
+ const STATUS = {
2080
+ invalid_input: 400,
2081
+ unknown_collection: 404,
2082
+ unknown_locale: 404
2083
+ };
2084
+ /** A weak ETag of a body: the first 128 bits of its SHA-256, in hex. */
2085
+ async function etagOf(body) {
2086
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(body)));
2087
+ return `W/"${Array.from(digest.slice(0, 16), (byte) => byte.toString(16).padStart(2, "0")).join("")}"`;
2088
+ }
2089
+ /** Whether `If-None-Match` names this ETag, or is `*`. */
2090
+ function matches(header, etag) {
2091
+ if (header === void 0) return false;
2092
+ return header.split(",").map((tag) => tag.trim()).some((tag) => tag === "*" || tag === etag || `W/${tag}` === etag);
2093
+ }
2094
+ async function answer(c, value) {
2095
+ const notFound = typeof value === "object" && value !== null && "outcome" in value && value.outcome === "not_found";
2096
+ const body = JSON.stringify(value);
2097
+ const etag = await etagOf(body);
2098
+ const headers = {
2099
+ etag,
2100
+ "cache-control": "private, no-cache",
2101
+ "content-type": "application/json"
2102
+ };
2103
+ if (!notFound && matches(c.req.header("if-none-match"), etag)) return new Response(null, {
2104
+ status: 304,
2105
+ headers
2106
+ });
2107
+ return new Response(body, {
2108
+ status: notFound ? 404 : 200,
2109
+ headers
2110
+ });
2111
+ }
2112
+ function installReadApi(app, options) {
2113
+ app.use("/v1/*", async (c, next) => {
2114
+ if (c.req.path.startsWith("/v1/ops/") || c.req.path.startsWith("/v1/admin/")) return next();
2115
+ if (c.req.method !== "GET" && c.req.method !== "HEAD") return c.json({
2116
+ code: "invalid_input",
2117
+ message: "The read API only reads.",
2118
+ fix: "Use GET."
2119
+ }, 405, { allow: "GET, HEAD" });
2120
+ if (c.req.path === "/v1/openapi.json") return next();
2121
+ return options.protect(c, async (principal) => {
2122
+ if (!principal.scopes.includes("content:read")) return insufficientScope(["content:read"], options.metadataUrl(c));
2123
+ try {
2124
+ await next();
2125
+ return c.res;
2126
+ } catch (error) {
2127
+ if (!(error instanceof HollwareError)) throw error;
2128
+ return c.json({
2129
+ code: error.code,
2130
+ message: error.message,
2131
+ fix: error.fix
2132
+ }, STATUS[error.code] ?? 500);
2133
+ }
2134
+ });
2135
+ });
2136
+ app.get("/v1/openapi.json", (c) => c.json(openApiDocument(options.site)));
2137
+ app.get("/v1/site", async (c) => answer(c, await options.read(c.env).describeSite(options.site)));
2138
+ app.get("/v1/:collection", async (c) => {
2139
+ const query = c.req.query();
2140
+ const limit = query["limit"] === void 0 ? void 0 : Number(query["limit"]);
2141
+ return answer(c, await options.read(c.env).listEntries(options.site, c.req.param("collection"), {
2142
+ ...query["locale"] === void 0 ? {} : { locale: query["locale"] },
2143
+ ...query["tag"] === void 0 ? {} : { tag: query["tag"] },
2144
+ ...limit === void 0 ? {} : { limit },
2145
+ ...query["cursor"] === void 0 ? {} : { cursor: query["cursor"] }
2146
+ }));
2147
+ });
2148
+ 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"))));
2149
+ app.get("/v1/:collection/:locale/:slug", async (c) => {
2150
+ const formats = c.req.query("formats");
2151
+ 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] : []) }));
2152
+ });
2153
+ }
2154
+ /** The OpenAPI 3.1 document of the read API. */
2155
+ function openApiDocument(site) {
2156
+ const ok = (description) => ({
2157
+ description,
2158
+ headers: { ETag: { schema: { type: "string" } } },
2159
+ content: { "application/json": { schema: { type: "object" } } }
2160
+ });
2161
+ const common = {
2162
+ "304": { description: "Not modified: the ETag of If-None-Match is current." },
2163
+ "401": { description: "No token, or one that is unknown, expired or revoked. The challenge names the protected resource metadata." },
2164
+ "403": { description: "The token lacks content:read." }
2165
+ };
2166
+ const param = (name, location, required) => ({
2167
+ name,
2168
+ in: location,
2169
+ required,
2170
+ schema: { type: "string" }
2171
+ });
2172
+ return {
2173
+ openapi: "3.1.0",
2174
+ info: {
2175
+ title: `Hollware read API (${site})`,
2176
+ version: "1"
2177
+ },
2178
+ components: { securitySchemes: { token: {
2179
+ type: "http",
2180
+ scheme: "bearer"
2181
+ } } },
2182
+ security: [{ token: [] }],
2183
+ paths: {
2184
+ "/v1/site": { get: {
2185
+ operationId: "describe_site",
2186
+ responses: {
2187
+ "200": ok("The site"),
2188
+ ...common
2189
+ }
2190
+ } },
2191
+ "/v1/{collection}": { get: {
2192
+ operationId: "list_entries",
2193
+ parameters: [
2194
+ param("collection", "path", true),
2195
+ param("locale", "query", false),
2196
+ param("tag", "query", false),
2197
+ param("limit", "query", false),
2198
+ param("cursor", "query", false)
2199
+ ],
2200
+ responses: {
2201
+ "200": ok("A page of published entries"),
2202
+ ...common
2203
+ }
2204
+ } },
2205
+ "/v1/{collection}/translations/{group}": { get: {
2206
+ operationId: "get_translations",
2207
+ parameters: [param("collection", "path", true), param("group", "path", true)],
2208
+ responses: {
2209
+ "200": ok("The published entries of the group"),
2210
+ "404": { description: "No such group" },
2211
+ ...common
2212
+ }
2213
+ } },
2214
+ "/v1/{collection}/{locale}/{slug}": { get: {
2215
+ operationId: "get_entry",
2216
+ parameters: [
2217
+ param("collection", "path", true),
2218
+ param("locale", "path", true),
2219
+ param("slug", "path", true),
2220
+ param("formats", "query", false)
2221
+ ],
2222
+ responses: {
2223
+ "200": ok("A published entry"),
2224
+ "404": { description: "No such published entry" },
2225
+ ...common
2226
+ }
2227
+ } }
2228
+ }
2229
+ };
2230
+ }
2231
+ //#endregion
2232
+ //#region src/identity/pages.ts
2233
+ /**
2234
+ * The pages a person sees to get in (ADR-026): set up a passkey from a link, and sign in. They
2235
+ * are rendered by the Worker, outside the admin's bundle, with one inline script under a nonce
2236
+ * and nothing framing them. The script uses the WebAuthn JSON methods of the browser
2237
+ * (`PublicKeyCredential.parseCreationOptionsFromJSON`, `credential.toJSON()`), and reads the
2238
+ * invitation from the address bar, so the page never prints it.
2239
+ */
2240
+ const STYLE = `body{font:1rem/1.5 system-ui,sans-serif;max-width:28rem;margin:4rem auto;padding:0 1rem;color:#1d1d1f}
2241
+ label{display:block;margin:.75rem 0 .25rem}input{width:100%;padding:.5rem;font:inherit}
2242
+ button{margin-top:1rem;padding:.6rem 1rem;font:inherit;cursor:pointer}[role=alert]{color:#b00020}`;
2243
+ const POST = `async function post(path, body) {
2244
+ const response = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
2245
+ const data = await response.json()
2246
+ if (!response.ok) throw new Error(data.message + ' ' + (data.fix ?? ''))
2247
+ return data
2248
+ }
2249
+ function fail(error) { document.getElementById('error').textContent = error.message }`;
2250
+ function page(title, nonce, body, script) {
2251
+ return `<!doctype html>
2252
+ <html lang="en">
2253
+ <head>
2254
+ <meta charset="utf-8">
2255
+ <meta name="viewport" content="width=device-width, initial-scale=1">
2256
+ <title>${title} · Hollware</title>
2257
+ <style>${STYLE}</style>
2258
+ </head>
2259
+ <body>
2260
+ <main>
2261
+ <h1>${title}</h1>
2262
+ ${body}
2263
+ <p id="error" role="alert"></p>
2264
+ </main>
2265
+ <script nonce="${nonce}">${POST}
2266
+ ${script}<\/script>
2267
+ </body>
2268
+ </html>
2269
+ `;
2270
+ }
2271
+ function setupPage(nonce) {
2272
+ return page("Set up your passkey", nonce, `<p>A passkey lets you into this Hollware with your device's lock: no password.</p>
2273
+ <form id="form">
2274
+ <label for="name">Your name</label><input id="name" name="name" autocomplete="name">
2275
+ <label for="label">This device</label><input id="label" name="label" value="Passkey">
2276
+ <button type="submit">Create the passkey</button>
2277
+ </form>`, `document.getElementById('form').addEventListener('submit', async (event) => {
2278
+ event.preventDefault()
2279
+ try {
2280
+ const invitation = new URLSearchParams(location.search).get('invitation') ?? ''
2281
+ const { challengeId, options } = await post('/auth/register/options', { invitation })
2282
+ const credential = await navigator.credentials.create({ publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options) })
2283
+ await post('/auth/register/verify', { challengeId, response: credential.toJSON(), name: document.getElementById('name').value, label: document.getElementById('label').value })
2284
+ location.href = '/admin/'
2285
+ } catch (error) { fail(error) }
2286
+ })`);
2287
+ }
2288
+ function signInPage(nonce) {
2289
+ return page("Sign in", nonce, `<p>Sign in to this Hollware with your passkey.</p>
2290
+ <button id="signin" type="button">Sign in with a passkey</button>`, `document.getElementById('signin').addEventListener('click', async () => {
2291
+ try {
2292
+ const { challengeId, options } = await post('/auth/signin/options', {})
2293
+ const credential = await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options) })
2294
+ await post('/auth/signin/verify', { challengeId, response: credential.toJSON() })
2295
+ const target = new URLSearchParams(location.search).get('return_to') ?? '/admin/'
2296
+ location.href = target.startsWith('/') && !target.startsWith('//') ? target : '/admin/'
2297
+ } catch (error) { fail(error) }
2298
+ })`);
2299
+ }
2300
+ /** The headers of a page: no framing, only its own script, and no caching. */
2301
+ function pageHeaders(nonce) {
2302
+ return {
2303
+ "content-type": "text/html; charset=utf-8",
2304
+ "cache-control": "no-store",
2305
+ "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'`,
2306
+ "x-content-type-options": "nosniff",
2307
+ "referrer-policy": "no-referrer"
2308
+ };
2309
+ }
2310
+ /** Text for HTML: every string a client chose (its name, its redirect host, a scope) goes through it. */
2311
+ function escapeHtml(value) {
2312
+ return value.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
2313
+ }
2314
+ /** What a scope lets a client do, in the words of the consent page. */
2315
+ const SCOPE_WORDS = {
2316
+ "content:read": "Read the content, drafts included",
2317
+ "content:write": "Write drafts",
2318
+ "content:publish": "Publish and unpublish",
2319
+ "schema:admin": "Change the site settings"
2320
+ };
2321
+ /**
2322
+ * The consent page of `/authorize` (ADR-026): which client asks, where the tokens go, and what it
2323
+ * may do, with Allow and Deny. It has no script, and it posts the handle that `beginConsent` bound
2324
+ * to this browser. Everything the client chose is escaped.
2325
+ */
2326
+ function consentPage(view) {
2327
+ const name = escapeHtml(view.clientName);
2328
+ const origin = view.clientDomain === void 0 ? "<p>This app registered itself: its name is not verified.</p>" : `<p>Published by <strong>${escapeHtml(view.clientDomain)}</strong>.</p>`;
2329
+ 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>" : "";
2330
+ const scopes = view.scopes.map((scope) => `<label><input type="checkbox" name="scope" value="${escapeHtml(scope)}" checked> ${escapeHtml(SCOPE_WORDS[scope] ?? scope)} <code>${escapeHtml(scope)}</code></label>`).join("\n");
2331
+ return `<!doctype html>
2332
+ <html lang="en">
2333
+ <head>
2334
+ <meta charset="utf-8">
2335
+ <meta name="viewport" content="width=device-width, initial-scale=1">
2336
+ <title>Allow ${name} · Hollware</title>
2337
+ <style>${STYLE}label input{width:auto;margin-right:.5rem}</style>
2338
+ </head>
2339
+ <body>
2340
+ <main>
2341
+ <h1>Allow ${name} to use this Hollware?</h1>
2342
+ ${origin}
2343
+ <p>Signed in as <strong>${escapeHtml(view.person)}</strong>. Access goes to <strong>${escapeHtml(view.redirectHost)}</strong>.</p>
2344
+ ${loopback}
2345
+ <form method="post" action="/authorize">
2346
+ <input type="hidden" name="handle" value="${escapeHtml(view.handle)}">
2347
+ ${scopes}
2348
+ <p><button name="decision" value="approve">Allow</button> <button name="decision" value="deny">Deny</button></p>
2349
+ </form>
2350
+ </main>
2351
+ </body>
2352
+ </html>
2353
+ `;
2354
+ }
2355
+ /** A page that only says what went wrong and what to do. */
2356
+ function messagePage(title, message) {
2357
+ return `<!doctype html>
2358
+ <html lang="en">
2359
+ <head>
2360
+ <meta charset="utf-8">
2361
+ <meta name="viewport" content="width=device-width, initial-scale=1">
2362
+ <title>${escapeHtml(title)} · Hollware</title>
2363
+ <style>${STYLE}</style>
2364
+ </head>
2365
+ <body>
2366
+ <main>
2367
+ <h1>${escapeHtml(title)}</h1>
2368
+ <p>${escapeHtml(message)}</p>
2369
+ </main>
2370
+ </body>
2371
+ </html>
2372
+ `;
2373
+ }
2374
+ /**
2375
+ * The headers of a page without a script: no framing and no caching. The form of the consent page
2376
+ * posts to `/authorize`, which redirects to the client, so `form-action` is not restricted: a
2377
+ * browser applies it to the redirect too.
2378
+ */
2379
+ function plainPageHeaders() {
2380
+ return {
2381
+ "content-type": "text/html; charset=utf-8",
2382
+ "cache-control": "no-store",
2383
+ "content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'`,
2384
+ "x-content-type-options": "nosniff",
2385
+ "referrer-policy": "no-referrer"
2386
+ };
2387
+ }
2388
+ //#endregion
2389
+ //#region src/identity/routes.ts
2390
+ /**
2391
+ * The ceremonies, with SimpleWebAuthn and its parsers, in a chunk of their own: only these four
2392
+ * routes use them, and a Worker that serves MCP never pays for them at startup (`budgets.json`).
2393
+ */
2394
+ const ceremonies = () => import("./ceremonies-D7tsWU3S.mjs");
2395
+ /** The cookie of a signed-in person: only `/authorize` reads it (ADR-026). */
2396
+ const SESSION_COOKIE = "hollware_session";
2397
+ function sessionCookie(secret) {
2398
+ return `${SESSION_COOKIE}=${secret}; HttpOnly; Secure; SameSite=Lax; Path=/authorize; Max-Age=43200`;
2399
+ }
2400
+ function isRecord$1(value) {
2401
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2402
+ }
2403
+ /** The setup and sign-in pages, and the four steps of the passkey ceremonies. */
2404
+ function installIdentity(app, options) {
2405
+ const ceremony = (c) => ({
2406
+ db: connect(c.env.DB),
2407
+ site: options.site,
2408
+ publicUrl: c.env.HOLLWARE_PUBLIC_URL,
2409
+ now: options.clock.now()
2410
+ });
2411
+ const body = async (c) => {
2412
+ const value = await c.req.json().catch(() => null);
2413
+ if (!isRecord$1(value)) throw new HollwareError("invalid_input", "The body is not a JSON object.", "Send a JSON object.");
2414
+ return value;
2415
+ };
2416
+ const text = (value) => typeof value === "string" ? value : "";
2417
+ const handle = (step) => async (c) => {
2418
+ try {
2419
+ return await step(c);
2420
+ } catch (error) {
2421
+ if (!(error instanceof HollwareError)) throw error;
2422
+ return c.json({
2423
+ code: error.code,
2424
+ message: error.message,
2425
+ fix: error.fix
2426
+ }, 400);
2427
+ }
2428
+ };
2429
+ const html = (render) => () => {
2430
+ const nonce = crypto.randomUUID().replace(/-/g, "");
2431
+ return new Response(render(nonce), { headers: pageHeaders(nonce) });
2432
+ };
2433
+ app.get("/setup", html(setupPage));
2434
+ app.get("/signin", html(signInPage));
2435
+ app.post("/auth/register/options", handle(async (c) => c.json(await (await ceremonies()).registrationOptions(ceremony(c), text((await body(c))["invitation"])))));
2436
+ app.post("/auth/register/verify", handle(async (c) => {
2437
+ const input = await body(c);
2438
+ const response = input["response"];
2439
+ if (!isRecord$1(response)) throw new HollwareError("invalid_input", "The body has no passkey.", "Send the credential as JSON.");
2440
+ const answer = await (await ceremonies()).verifyRegistration(ceremony(c), {
2441
+ challengeId: text(input["challengeId"]),
2442
+ response: JSON.parse(JSON.stringify(response)),
2443
+ name: text(input["name"]),
2444
+ label: text(input["label"])
2445
+ });
2446
+ return c.json({ ok: true }, 200, { "set-cookie": sessionCookie(answer.session) });
2447
+ }));
2448
+ app.post("/auth/signin/options", handle(async (c) => c.json(await (await ceremonies()).authenticationOptions(ceremony(c)))));
2449
+ app.post("/auth/signin/verify", handle(async (c) => {
2450
+ const input = await body(c);
2451
+ const response = input["response"];
2452
+ if (!isRecord$1(response)) throw new HollwareError("invalid_input", "The body has no passkey.", "Send the credential as JSON.");
2453
+ const answer = await (await ceremonies()).verifyAuthentication(ceremony(c), {
2454
+ challengeId: text(input["challengeId"]),
2455
+ response: JSON.parse(JSON.stringify(response))
2456
+ });
2457
+ return c.json({ ok: true }, 200, { "set-cookie": sessionCookie(answer.session) });
2458
+ }));
2459
+ }
2460
+ //#endregion
2461
+ //#region src/oauth/authorize.ts
2462
+ /** What a client gets when it asks for no scope: the baseline that `/mcp` and `/v1` name. */
2463
+ const BASELINE = ["content:read"];
2464
+ /**
2465
+ * The scopes a person may grant: those asked for that the person's role allows, in the order of
2466
+ * the role. Anything that is not a scope of Hollware is left out.
2467
+ */
2468
+ function grantableScopes(role, asked) {
2469
+ if (!isRole(role)) return [];
2470
+ const scopes = asked.filter(isScope);
2471
+ return ROLE_SCOPES[role].filter((scope) => scopes.includes(scope));
2472
+ }
2473
+ function html(body, status = 200, extra) {
2474
+ const headers = new Headers(plainPageHeaders());
2475
+ extra?.forEach((value, name) => {
2476
+ if (name.toLowerCase() === "set-cookie") headers.append(name, value);
2477
+ });
2478
+ return new Response(body, {
2479
+ status,
2480
+ headers
2481
+ });
2482
+ }
2483
+ /**
2484
+ * An error of the authorization endpoint, as the consent page guide says: a redirect back to the
2485
+ * client only when the library validated the client and its exact redirect URI, and a page of our
2486
+ * own otherwise. Anything else is a bug or an outage, and goes on to the error handler.
2487
+ */
2488
+ function answerError(error) {
2489
+ if (error instanceof AuthorizationError && error.redirectTo !== void 0) return new Response(null, {
2490
+ status: 302,
2491
+ headers: { location: error.redirectTo }
2492
+ });
2493
+ if (error instanceof AuthorizationError) return html(messagePage("This request cannot go on", error.description), 400);
2494
+ 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);
2495
+ throw error;
2496
+ }
2497
+ /**
2498
+ * The authorization endpoint of the OAuth server (ADR-026), which is Hollware's page and not the
2499
+ * library's: the library parses and validates the request, and this page signs the person in,
2500
+ * shows what the client asks for, and completes the authorization with what the person allowed.
2501
+ *
2502
+ * - A person without a session is sent to `/signin`, which comes back here once they are in. The
2503
+ * session cookie is only sent to `/authorize`.
2504
+ * - The admin, Hollware's own client (`admin.ts`), gets what it asks for within the role, with no
2505
+ * consent page.
2506
+ * - The page offers the scopes the client asked for that the person's role allows, each checked;
2507
+ * the person may untick any. A request that would get no scope at all is refused with
2508
+ * `access_denied`.
2509
+ * - The grant stores the person and the site. What its tokens may do is read again on each request
2510
+ * against the person's role then (`server.ts`).
2511
+ */
2512
+ function installAuthorize(app, options) {
2513
+ const person = async (c) => {
2514
+ const secret = getCookie(c, SESSION_COOKIE);
2515
+ if (secret === void 0) return null;
2516
+ return await findSession(connect(c.env.DB), options.site, secret, options.clock.now());
2517
+ };
2518
+ const signIn = (c) => {
2519
+ const url = new URL(c.req.url);
2520
+ const back = `${url.pathname}${url.search}`;
2521
+ return c.redirect(`/signin?return_to=${encodeURIComponent(back)}`, 302);
2522
+ };
2523
+ app.get("/authorize", async (c) => {
2524
+ const oauth = options.oauth(c.env.HOLLWARE_PUBLIC_URL).authorizationServer.getOAuthApi(c.env);
2525
+ try {
2526
+ const request = await oauth.parseAuthRequest(c.req.raw);
2527
+ const signedIn = await person(c);
2528
+ if (signedIn === null) return signIn(c);
2529
+ const server = options.oauth(c.env.HOLLWARE_PUBLIC_URL);
2530
+ if (request.clientId === (await adminClient(server, c.env)).clientId) {
2531
+ const scope = grantableScopes(signedIn.role, request.scope);
2532
+ const props = {
2533
+ userId: signedIn.userId,
2534
+ site: options.site
2535
+ };
2536
+ const { redirectTo } = await oauth.completeAuthorization({
2537
+ request,
2538
+ userId: signedIn.userId,
2539
+ metadata: { site: options.site },
2540
+ scope,
2541
+ props
2542
+ });
2543
+ return new Response(null, {
2544
+ status: 302,
2545
+ headers: { location: redirectTo }
2546
+ });
2547
+ }
2548
+ const scopes = grantableScopes(signedIn.role, request.scope.length === 0 ? BASELINE : request.scope);
2549
+ if (scopes.length === 0) {
2550
+ const location = authorizationErrorRedirect(request, "access_denied", "Your role allows none of the scopes this client asked for.");
2551
+ return new Response(null, {
2552
+ status: 302,
2553
+ headers: { location }
2554
+ });
2555
+ }
2556
+ const details = await oauth.describeConsent(request);
2557
+ const consent = await oauth.beginConsent(request);
2558
+ return html(consentPage({
2559
+ clientName: details.clientName,
2560
+ clientDomain: details.clientDomain,
2561
+ redirectHost: details.redirectHost,
2562
+ redirectIsLoopback: details.redirectIsLoopback,
2563
+ person: signedIn.name ?? "you",
2564
+ scopes,
2565
+ handle: consent.handle
2566
+ }), 200, consent.headers);
2567
+ } catch (error) {
2568
+ return answerError(error);
2569
+ }
2570
+ });
2571
+ app.post("/authorize", async (c) => {
2572
+ const oauth = options.oauth(c.env.HOLLWARE_PUBLIC_URL).authorizationServer.getOAuthApi(c.env);
2573
+ try {
2574
+ const signedIn = await person(c);
2575
+ if (signedIn === null) return html(messagePage("Your session ended", "Sign in again, then start from the app."), 401);
2576
+ const form = await c.req.formData();
2577
+ const value = form.get("handle");
2578
+ const handle = typeof value === "string" ? value : "";
2579
+ if (form.get("decision") !== "approve") {
2580
+ const denied = await oauth.denyConsent(c.req.raw, handle);
2581
+ return new Response(null, {
2582
+ status: 302,
2583
+ headers: denied.headers
2584
+ });
2585
+ }
2586
+ const scope = grantableScopes(signedIn.role, form.getAll("scope").map(String));
2587
+ const approved = await oauth.approveConsent(c.req.raw, handle, { scope });
2588
+ if (scope.length === 0) {
2589
+ const location = authorizationErrorRedirect(approved.request, "access_denied", "No scope was allowed.");
2590
+ approved.headers.set("location", location);
2591
+ return new Response(null, {
2592
+ status: 302,
2593
+ headers: approved.headers
2594
+ });
2595
+ }
2596
+ const props = {
2597
+ userId: signedIn.userId,
2598
+ site: options.site
2599
+ };
2600
+ const { redirectTo } = await oauth.completeAuthorization({
2601
+ request: approved.request,
2602
+ userId: signedIn.userId,
2603
+ metadata: { site: options.site },
2604
+ scope,
2605
+ props
2606
+ });
2607
+ approved.headers.set("location", redirectTo);
2608
+ return new Response(null, {
2609
+ status: 302,
2610
+ headers: approved.headers
2611
+ });
2612
+ } catch (error) {
2613
+ return answerError(error);
2614
+ }
2615
+ });
2616
+ }
2617
+ //#endregion
2618
+ //#region src/oauth/server.ts
2619
+ /** Access tokens live an hour, and a grant thirty days from its code, refreshed or not (ADR-026). */
2620
+ const ACCESS_TOKEN_TTL_SECONDS = 3600;
2621
+ const REFRESH_TOKEN_TTL_SECONDS = 2592e3;
2622
+ /** The role of a member of the site, read on every request: a change of role takes effect at once. */
2623
+ async function roleOf(db, site, userId) {
2624
+ return (await db.prepare("SELECT role FROM members WHERE site_id = ?1 AND user_id = ?2").bind(site, userId).first())?.role ?? null;
2625
+ }
2626
+ function isGrantProps(value) {
2627
+ return typeof value === "object" && value !== null && typeof Reflect.get(value, "userId") === "string" && typeof Reflect.get(value, "site") === "string";
2628
+ }
2629
+ /**
2630
+ * Builds the OAuth server of a deployment (ADR-026), once for each public URL it is asked about:
2631
+ * the issuer and the resources are that URL's, and the Worker learns it from its environment, on
2632
+ * the first request. One Worker holds both roles: an `OAuthAuthorizationServer`, whose
2633
+ * `/authorize` is Hollware's own page (`authorize.ts`), and an `OAuthResourceServer` for `/mcp`
2634
+ * and for `/v1`.
1458
2635
  *
1459
- * - An error that a route throws is a JSON 500 with a fixed message, and the id of the request
1460
- * in its fix, which is how the operator finds the line of the log that has the cause. The cause
1461
- * goes to the log and never to the caller, whose agent could repeat it to a person or to another
1462
- * system. This holds for every error, a failed D1 query included: nothing turns one into a 401.
1463
- * - A path that no route has is a JSON 404.
2636
+ * A resource accepts two kinds of token, told apart by their shape. A personal token
2637
+ * (`hw_pat_…`) is looked up in D1 as before, and never reaches the OAuth server; any other token
2638
+ * is the OAuth server's to validate, for that resource alone. Either way the principal's scopes
2639
+ * are what the token carries intersected with what the user's role allows now.
1464
2640
  */
1465
- function installErrorHandling(app, log) {
1466
- app.onError((c) => {
1467
- const requestId = c.get("requestId");
1468
- log({
1469
- kind: "request_error",
1470
- requestId,
1471
- method: c.req.method,
1472
- path: c.req.path,
1473
- detail: describeError(c.error)
1474
- });
1475
- return gatewayError(500, {
1476
- code: "internal",
1477
- message: "The request failed on the server.",
1478
- fix: `Try again. If it keeps failing, give the site operator this request id: ${requestId}.`
1479
- });
2641
+ function createOAuth(options) {
2642
+ const built = /* @__PURE__ */ new Map();
2643
+ return (publicUrl) => {
2644
+ const issuer = new URL(publicUrl).origin;
2645
+ const existing = built.get(issuer);
2646
+ if (existing !== void 0) return existing;
2647
+ const oauth = build(issuer, options);
2648
+ built.set(issuer, oauth);
2649
+ return oauth;
2650
+ };
2651
+ }
2652
+ function build(issuer, options) {
2653
+ const resources = {
2654
+ mcp: `${issuer}/mcp`,
2655
+ v1: `${issuer}/v1`
2656
+ };
2657
+ const metadataUrls = {
2658
+ mcp: `${issuer}/.well-known/oauth-protected-resource/mcp`,
2659
+ v1: `${issuer}/.well-known/oauth-protected-resource/v1`
2660
+ };
2661
+ const authorizationServer = new OAuthAuthorizationServer({
2662
+ issuer,
2663
+ resources: [resources.mcp, resources.v1],
2664
+ scopesSupported: [...SCOPES],
2665
+ accessTokenTTL: ACCESS_TOKEN_TTL_SECONDS,
2666
+ refreshTokenTTL: REFRESH_TOKEN_TTL_SECONDS,
2667
+ clientIdMetadataDocumentEnabled: true,
2668
+ ...options.dynamicClientRegistration ? { clientRegistrationEndpoint: "/oauth/register" } : {},
2669
+ onError: ({ code, internal }) => {
2670
+ options.log({
2671
+ kind: "oauth_error",
2672
+ code,
2673
+ reason: internal?.reason ?? null
2674
+ });
2675
+ }
1480
2676
  });
1481
- app.notFound(() => gatewayError(404, {
1482
- code: "not_found",
1483
- message: "This Hollware has no route for that path.",
1484
- fix: "Send MCP requests to /mcp with \"Authorization: Bearer <token>\". GET /health tells whether the Worker is up."
1485
- }));
2677
+ /** The protected routes in flight, by their request: the resource server hands it on as it is. */
2678
+ const pending = /* @__PURE__ */ new WeakMap();
2679
+ const validate = (env, request) => async (resource, token) => {
2680
+ try {
2681
+ return await check(env, request, resource, token);
2682
+ } catch (error) {
2683
+ pending.get(request)?.failed(error);
2684
+ throw error;
2685
+ }
2686
+ };
2687
+ const check = async (env, request, resource, token) => {
2688
+ const refuse = (reason) => {
2689
+ pending.get(request)?.refused(reason);
2690
+ return null;
2691
+ };
2692
+ if (isPersonalToken(token)) {
2693
+ const check = await checkPersonalToken(env.DB, token, {
2694
+ site: options.site,
2695
+ now: options.clock.now()
2696
+ });
2697
+ if (!check.ok) return refuse(check.reason);
2698
+ if (check.touch !== null) {
2699
+ const route = pending.get(request);
2700
+ if (route === void 0) await check.touch;
2701
+ else route.defer(check.touch);
2702
+ }
2703
+ return {
2704
+ props: check.principal,
2705
+ audience: resource,
2706
+ scope: [...check.principal.scopes]
2707
+ };
2708
+ }
2709
+ const granted = await authorizationServer.validateToken(resource, token, env);
2710
+ if (granted === null) return refuse("unknown");
2711
+ if (!isGrantProps(granted.props)) return refuse("unknown");
2712
+ if (granted.props.site !== options.site) return refuse("other_site");
2713
+ const role = await roleOf(env.DB, options.site, granted.props.userId);
2714
+ if (role === null) return refuse("no_user");
2715
+ const principal = {
2716
+ kind: "oauth",
2717
+ userId: granted.props.userId,
2718
+ clientId: granted.clientId,
2719
+ site: options.site,
2720
+ scopes: effectiveScopes(role, granted.scope.filter(isScope))
2721
+ };
2722
+ return {
2723
+ props: principal,
2724
+ audience: granted.audience,
2725
+ expiresAt: granted.expiresAt,
2726
+ scope: [...principal.scopes],
2727
+ userId: principal.userId,
2728
+ clientId: principal.clientId
2729
+ };
2730
+ };
2731
+ const resourceServer = (name) => new OAuthResourceServer({
2732
+ resourceMetadata: {
2733
+ resource: resources[name],
2734
+ authorization_servers: [issuer],
2735
+ resource_name: name === "mcp" ? "Hollware MCP" : "Hollware read API"
2736
+ },
2737
+ requiredScopes: ["content:read"],
2738
+ validateToken: validate,
2739
+ handler: { fetch: async (request, _env, ctx) => {
2740
+ const route = pending.get(request);
2741
+ if (route === void 0) throw new Error("A protected route was reached without the gateway in front of it");
2742
+ return await route.proceed(ctx.props, ctx.auth);
2743
+ } }
2744
+ });
2745
+ const servers = {
2746
+ mcp: resourceServer("mcp"),
2747
+ v1: resourceServer("v1")
2748
+ };
2749
+ return {
2750
+ issuer,
2751
+ authorizationServer,
2752
+ resources,
2753
+ metadataUrls,
2754
+ protect: async (resource, request, env, ctx, route) => {
2755
+ pending.set(request, route);
2756
+ try {
2757
+ return await servers[resource].fetch(request, env, ctx);
2758
+ } finally {
2759
+ pending.delete(request);
2760
+ }
2761
+ }
2762
+ };
1486
2763
  }
1487
2764
  //#endregion
1488
2765
  //#region src/http/gateway.ts
1489
2766
  /**
2767
+ * A 401 of a resource server, with Hollware's body: the same JSON as every other refusal, and the
2768
+ * challenge of the resource server, which names its metadata (RFC 9728) and the baseline scope.
2769
+ */
2770
+ function withRefusalBody(response, refused) {
2771
+ const ours = unauthorized(refused ? "unknown" : void 0);
2772
+ const headers = new Headers(response.headers);
2773
+ headers.set("content-type", "application/json");
2774
+ const challenge = headers.get("www-authenticate");
2775
+ if (challenge !== null) headers.set("www-authenticate", challenge.replace("realm=\"OAuth\"", "realm=\"hollware\""));
2776
+ return new Response(ours.body, {
2777
+ status: 401,
2778
+ headers
2779
+ });
2780
+ }
2781
+ /**
1490
2782
  * The HTTP gateway of the Worker (ADR-019): a Hono 5 app, built once, that runs in this order for
1491
2783
  * every request:
1492
2784
  *
@@ -1512,6 +2804,73 @@ function installErrorHandling(app, log) {
1512
2804
  function createGateway(options) {
1513
2805
  const { site, mcp, clock, log } = options;
1514
2806
  const app = new Hono();
2807
+ /**
2808
+ * The Worker's own context of each request. Hono hands its routes the same object under a
2809
+ * narrower type of its own, and the OAuth library wants the Worker's.
2810
+ */
2811
+ const contexts = /* @__PURE__ */ new WeakMap();
2812
+ const workerContext = (c) => {
2813
+ const ctx = contexts.get(c.req.raw);
2814
+ if (ctx === void 0) throw new Error("A request reached a route without its context");
2815
+ return ctx;
2816
+ };
2817
+ const oauth = createOAuth({
2818
+ site: site.key,
2819
+ clock,
2820
+ dynamicClientRegistration: options.auth?.dynamicClientRegistration ?? false,
2821
+ log
2822
+ });
2823
+ /**
2824
+ * What a write hands to the Queue: the events it committed, after the answer (ADR-025). The
2825
+ * cron trigger's sweep catches any that this misses.
2826
+ */
2827
+ const notify = (c, requestId) => (ids) => {
2828
+ c.executionCtx.waitUntil(enqueue({
2829
+ queue: c.env.EVENTS,
2830
+ db: connect(c.env.DB),
2831
+ site: site.key,
2832
+ ids,
2833
+ clock
2834
+ }).catch((error) => {
2835
+ log({
2836
+ kind: "enqueue_failed",
2837
+ requestId,
2838
+ message: String(error)
2839
+ });
2840
+ }));
2841
+ };
2842
+ /**
2843
+ * A protected route: the resource server of `resource` answers a request without a token and a
2844
+ * token it refuses (401, logged with the reason), and `proceed` gets the principal of the rest.
2845
+ */
2846
+ const protect = async (c, resource, proceed) => {
2847
+ const requestId = c.get("requestId");
2848
+ /** What the check said besides the answer: why it refused, or what it threw. */
2849
+ const outcome = {
2850
+ refusal: null,
2851
+ failure: null
2852
+ };
2853
+ const response = await oauth(c.env.HOLLWARE_PUBLIC_URL).protect(resource, c.req.raw, c.env, workerContext(c), {
2854
+ proceed,
2855
+ refused: (reason) => {
2856
+ outcome.refusal = reason;
2857
+ },
2858
+ defer: (promise) => {
2859
+ workerContext(c).waitUntil(promise);
2860
+ },
2861
+ failed: (error) => {
2862
+ outcome.failure = { error };
2863
+ }
2864
+ });
2865
+ if (outcome.failure !== null) throw outcome.failure.error;
2866
+ if (response.status !== 401) return response;
2867
+ log({
2868
+ kind: "auth_refused",
2869
+ requestId,
2870
+ reason: outcome.refusal ?? "no_token"
2871
+ });
2872
+ return withRefusalBody(response, outcome.refusal !== null);
2873
+ };
1515
2874
  app.use("*", async (c, next) => {
1516
2875
  c.set("requestId", crypto.randomUUID());
1517
2876
  await next();
@@ -1522,42 +2881,67 @@ function createGateway(options) {
1522
2881
  await next();
1523
2882
  });
1524
2883
  app.get("/health", () => Response.json({ ok: true }, { headers: { "cache-control": "no-store" } }));
1525
- app.all("/mcp", async (c) => {
2884
+ app.all("/mcp", async (c) => protect(c, "mcp", async (principal) => {
1526
2885
  const requestId = c.get("requestId");
1527
- const token = readBearerToken(c.req.raw);
1528
- if (token === null) {
1529
- log({
1530
- kind: "auth_refused",
1531
- requestId,
1532
- reason: "no_token"
1533
- });
1534
- return unauthorized();
1535
- }
1536
- const check = await checkPersonalToken(c.env.DB, token, {
1537
- site: site.key,
1538
- now: clock.now()
1539
- });
1540
- if (!check.ok) {
1541
- log({
1542
- kind: "auth_refused",
1543
- requestId,
1544
- reason: check.reason
1545
- });
1546
- return unauthorized(check.reason);
1547
- }
1548
- if (check.touch !== null) c.executionCtx.waitUntil(check.touch);
2886
+ const db = c.env.DB;
1549
2887
  return await mcp.fetch(c.req.raw, {
1550
- principal: check.principal,
2888
+ principal,
1551
2889
  requestId,
2890
+ resourceMetadataUrl: oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.mcp,
1552
2891
  context: {
1553
2892
  site,
1554
- store: createD1Store(c.env.DB, { clock }),
1555
- clock
2893
+ store: createD1Store(db, { clock }),
2894
+ clock,
2895
+ notify: notify(c, requestId)
1556
2896
  }
1557
2897
  });
2898
+ }));
2899
+ 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), {
2900
+ proceed: () => Promise.resolve(new Response(null, { status: 404 })),
2901
+ refused: () => void 0,
2902
+ defer: () => void 0,
2903
+ failed: () => void 0
2904
+ }));
2905
+ installAuthorize(app, {
2906
+ site: site.key,
2907
+ clock,
2908
+ oauth
2909
+ });
2910
+ const authorizationServer = (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).authorizationServer.fetch(c.req.raw, c.env, workerContext(c));
2911
+ app.all("/.well-known/oauth-authorization-server", authorizationServer);
2912
+ app.all("/oauth/*", authorizationServer);
2913
+ installIdentity(app, {
2914
+ site: site.key,
2915
+ clock
2916
+ });
2917
+ installAdmin(app, { oauth });
2918
+ installAdminApi(app, {
2919
+ site: site.key,
2920
+ clock,
2921
+ oauth,
2922
+ protect: (c, proceed) => protect(c, "v1", proceed),
2923
+ metadataUrl: (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1
2924
+ });
2925
+ if (options.handlers !== void 0) installOpsApi(app, {
2926
+ site,
2927
+ handlers: options.handlers,
2928
+ clock,
2929
+ log,
2930
+ protect: (c, proceed) => protect(c, "v1", proceed),
2931
+ metadataUrl: (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1,
2932
+ notify
2933
+ });
2934
+ if (options.read !== void 0) installReadApi(app, {
2935
+ read: options.read,
2936
+ site: site.key,
2937
+ protect: (c, proceed) => protect(c, "v1", proceed),
2938
+ metadataUrl: (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1
1558
2939
  });
1559
2940
  installErrorHandling(app, log);
1560
- return async (request, env, ctx) => await app.fetch(request, env, ctx);
2941
+ return async (request, env, ctx) => {
2942
+ contexts.set(request, ctx);
2943
+ return await app.fetch(request, env, ctx);
2944
+ };
1561
2945
  }
1562
2946
  //#endregion
1563
2947
  //#region src/mcp/annotations.ts
@@ -1740,12 +3124,15 @@ const encoder = new TextEncoder();
1740
3124
  * has already run when its answer turns out too large, so it says to look at the state it left.
1741
3125
  */
1742
3126
  const OUTPUT_TOO_LARGE_FIX = {
3127
+ approve_review: "The operation ran and its change may have been applied: read the review with list_reviews before you repeat it.",
1743
3128
  create_draft: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
1744
3129
  describe_site: "The description of the site does not fit in one answer. Document fewer collections, components and contracts, or shorten their descriptions.",
1745
3130
  get_entry: "Ask for one format at a time with formats: markdown, document or html, instead of several together.",
1746
3131
  get_translations: "The translations do not fit in one answer. Read each one with get_entry instead.",
1747
3132
  list_entries: "Ask for less: pass a smaller limit, or narrow the list with locale, tag or status.",
3133
+ list_reviews: "Ask for less: pass a smaller limit.",
1748
3134
  publish: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
3135
+ request_changes: "The operation ran and its change may have been applied: read the review with list_reviews before you repeat it.",
1749
3136
  unpublish: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
1750
3137
  update_draft: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
1751
3138
  validate: "The verdict lists more problems than fit in one answer. Fix the first ones and validate again."
@@ -1900,14 +3287,16 @@ const TOOLS_LIST_CACHE_HINT = {
1900
3287
  cacheScope: "public"
1901
3288
  };
1902
3289
  /**
1903
- * What the SDK is told about a principal: its scopes, which are the effective ones, and the id of
1904
- * its token as the client. It carries no token: the gateway checked it, and nothing here needs it.
3290
+ * What the SDK is told about a principal: its scopes, which are the effective ones, the id of its
3291
+ * token or of its OAuth client as the client, and where its protected resource metadata is. It
3292
+ * carries no token: the gateway checked it, and nothing here needs it.
1905
3293
  */
1906
- function authInfoOf(principal) {
3294
+ function authInfoOf(principal, resourceMetadataUrl) {
1907
3295
  return {
1908
3296
  token: "",
1909
- clientId: principal.kind === "token" ? principal.tokenId : principal.kind,
1910
- scopes: [...principal.scopes]
3297
+ clientId: principal.kind === "token" ? principal.tokenId : principal.kind === "oauth" ? principal.clientId : principal.kind,
3298
+ scopes: [...principal.scopes],
3299
+ ...resourceMetadataUrl === null ? {} : { resourceMetadataUrl }
1911
3300
  };
1912
3301
  }
1913
3302
  /**
@@ -2033,7 +3422,7 @@ function createMcpEndpoint(options) {
2033
3422
  }) });
2034
3423
  return { async fetch(request, call) {
2035
3424
  if (!call?.principal) throw new Error("The MCP endpoint serves a request only as the principal the gateway found");
2036
- const authInfo = authInfoOf(call.principal);
3425
+ const authInfo = authInfoOf(call.principal, call.resourceMetadataUrl ?? null);
2037
3426
  calls.set(authInfo, call);
2038
3427
  return handler.fetch(request, { authInfo });
2039
3428
  } };
@@ -2189,6 +3578,84 @@ function createEntrypoint(deps) {
2189
3578
  };
2190
3579
  }
2191
3580
  //#endregion
3581
+ //#region src/schedule/run.ts
3582
+ /** What the publication of a schedule may do: what `publish` needs, in the scheduler's name. */
3583
+ const SCHEDULER_SCOPES = [
3584
+ "content:read",
3585
+ "content:write",
3586
+ "content:publish"
3587
+ ];
3588
+ /**
3589
+ * The principal a schedule publishes as: the token, or the OAuth client, that scheduled it, with
3590
+ * the scopes a publication needs. An OAuth client is known by the id its actor kept (ADR-026).
3591
+ */
3592
+ function principalOf(actor, site) {
3593
+ if (actor.kind === "oauth_client") {
3594
+ const client = actor.meta?.["oauthClient"];
3595
+ return {
3596
+ kind: "oauth",
3597
+ userId: actor.userId,
3598
+ clientId: typeof client === "string" ? client : "unknown",
3599
+ site,
3600
+ scopes: SCHEDULER_SCOPES
3601
+ };
3602
+ }
3603
+ if (actor.tokenId === null) throw new Error(`A schedule of a ${actor.kind} has no token to publish with`);
3604
+ return {
3605
+ kind: "token",
3606
+ userId: actor.userId,
3607
+ tokenId: actor.tokenId,
3608
+ site,
3609
+ scopes: SCHEDULER_SCOPES
3610
+ };
3611
+ }
3612
+ /**
3613
+ * Publishes the scheduled entries whose time has come (ADR-025), one by one, through the same
3614
+ * handler as `publish`, in the name of who scheduled each one and with the warnings acknowledged
3615
+ * then. An entry that cannot be published (it changed after it was scheduled, or the contracts now
3616
+ * block it or warn about something new) goes back to a draft, and the log says why. A failure of
3617
+ * the database leaves the schedule for the next run.
3618
+ */
3619
+ async function runDueSchedules(input) {
3620
+ const { site, store, handlers, clock, log } = input;
3621
+ const due = await store.dueSchedules(site.key, clock.now().toISOString());
3622
+ let published = 0;
3623
+ let cancelled = 0;
3624
+ for (const schedule of due) {
3625
+ const requestId = `cron_${crypto.randomUUID()}`;
3626
+ const answer = await handlers.publish({
3627
+ id: schedule.entryId,
3628
+ version: schedule.version,
3629
+ acknowledgeWarnings: [...schedule.acknowledged],
3630
+ group: []
3631
+ }, {
3632
+ site,
3633
+ principal: principalOf(schedule.actor, site.key),
3634
+ view: "head",
3635
+ store,
3636
+ clock,
3637
+ client: schedule.actor.meta,
3638
+ requestId
3639
+ });
3640
+ if (answer.outcome === "published") published += 1;
3641
+ else {
3642
+ await store.cancelSchedule(site.key, schedule.entryId);
3643
+ cancelled += 1;
3644
+ }
3645
+ log({
3646
+ kind: "schedule",
3647
+ requestId,
3648
+ entryId: schedule.entryId,
3649
+ scheduledAt: schedule.scheduledAt,
3650
+ outcome: answer.outcome
3651
+ });
3652
+ }
3653
+ return {
3654
+ published,
3655
+ cancelled
3656
+ };
3657
+ }
3658
+ //#endregion
2192
3659
  //#region src/compose.ts
2193
3660
  const systemClock = { now: () => /* @__PURE__ */ new Date() };
2194
3661
  /** What the MCP endpoint calls the server, in `server/discover` and `initialize`. */
@@ -2221,20 +3688,61 @@ function writeLog(entry) {
2221
3688
  * included, is one JSON line written with `console.log`.
2222
3689
  */
2223
3690
  function createHollwareWith(config, handlers, options = {}) {
2224
- const [site] = resolveConfig(config).sites.values();
3691
+ const resolved = resolveConfig(config);
3692
+ const [site] = resolved.sites.values();
2225
3693
  if (site === void 0) throw new Error("The resolved config has no site, which resolveConfig never lets through");
2226
3694
  const clock = options.clock ?? systemClock;
3695
+ const mcp = createMcpEndpoint({
3696
+ handlers,
3697
+ serverInfo: SERVER_INFO,
3698
+ log: writeLog
3699
+ });
3700
+ const deliveryDeps = (env) => ({
3701
+ db: connect(env.DB),
3702
+ site,
3703
+ clock,
3704
+ fetch: (request) => fetch(request),
3705
+ secret: (name) => {
3706
+ const value = Reflect.get(env, name);
3707
+ return typeof value === "string" ? value : void 0;
3708
+ }
3709
+ });
2227
3710
  return {
2228
3711
  fetch: createGateway({
2229
3712
  site,
2230
- mcp: createMcpEndpoint({
3713
+ mcp,
3714
+ clock,
3715
+ log: writeLog,
3716
+ read: (env) => createRpc({
3717
+ site,
2231
3718
  handlers,
2232
- serverInfo: SERVER_INFO,
3719
+ clock,
2233
3720
  log: writeLog
2234
- }),
2235
- clock,
2236
- log: writeLog
3721
+ }, env),
3722
+ auth: resolved.auth,
3723
+ handlers
2237
3724
  }),
3725
+ scheduled: async (_controller, env, ctx) => {
3726
+ const deps = deliveryDeps(env);
3727
+ ctx.waitUntil(runDueSchedules({
3728
+ site,
3729
+ store: createD1Store(env.DB, { clock }),
3730
+ handlers,
3731
+ clock,
3732
+ log: writeLog
3733
+ }).then(() => sweepOutbox({
3734
+ queue: env.EVENTS,
3735
+ deps
3736
+ })).then(() => void 0, (error) => {
3737
+ writeLog({
3738
+ kind: "cron_failed",
3739
+ message: String(error)
3740
+ });
3741
+ }));
3742
+ },
3743
+ queue: async (batch, env) => {
3744
+ await consumeEvents(batch.messages, deliveryDeps(env));
3745
+ },
2238
3746
  Hollware: createEntrypoint({
2239
3747
  site,
2240
3748
  handlers,
@@ -2264,23 +3772,49 @@ function unexpectedAnswer(context, call, answer) {
2264
3772
  //#endregion
2265
3773
  //#region src/ops/actor.ts
2266
3774
  /**
2267
- * Who a write is made by: the token of the call, with what its client said about itself. Only a
2268
- * personal token writes (ADR-019). The adapters check the scopes of the operation before any
2269
- * handler runs and never route a write for another principal, so reaching one with a principal
2270
- * that is not a token is a bug, answered as `internal` before anything is read or written.
3775
+ * Who a write is made by: the person behind the token of the call, with what its client said
3776
+ * about itself. A personal token writes as itself (ADR-019); an OAuth access token writes as the
3777
+ * client the person allowed, whose id goes with what the client said (ADR-026). The adapters check
3778
+ * the scopes of the operation before any handler runs and never route a write for the RPC
3779
+ * binding, so reaching one with it is a bug, answered as `internal` before anything is read or
3780
+ * written.
2271
3781
  */
2272
3782
  function actorOf(context) {
2273
3783
  const { principal } = context;
2274
- if (principal.kind !== "token") throw internalError(context, `A write was routed for a principal of kind ${principal.kind}; only a token writes.`);
2275
- return {
3784
+ if (principal.kind === "token") return {
2276
3785
  kind: "token",
2277
3786
  userId: principal.userId,
2278
3787
  tokenId: principal.tokenId,
2279
3788
  meta: context.client
2280
3789
  };
3790
+ if (principal.kind === "oauth") return {
3791
+ kind: "oauth_client",
3792
+ userId: principal.userId,
3793
+ tokenId: null,
3794
+ meta: {
3795
+ ...context.client,
3796
+ oauthClient: principal.clientId
3797
+ }
3798
+ };
3799
+ throw internalError(context, `A write was routed for a principal of kind ${principal.kind}; only a person writes.`);
2281
3800
  }
2282
3801
  //#endregion
2283
3802
  //#region src/ops/answers.ts
3803
+ /** A review as an output. */
3804
+ function reviewView(review) {
3805
+ return {
3806
+ id: review.id,
3807
+ entryId: review.entryId,
3808
+ revision: review.revisionId,
3809
+ status: review.status,
3810
+ requestedBy: review.requestedBy,
3811
+ requestedByName: review.requestedByName,
3812
+ requestedAt: review.requestedAt,
3813
+ decidedBy: review.decidedBy,
3814
+ decidedAt: review.decidedAt,
3815
+ note: review.note
3816
+ };
3817
+ }
2284
3818
  /**
2285
3819
  * The summary of an entry, from a listing (`EntrySummaryView`) or from a read of one entry
2286
3820
  * (`EntryView`, which has all of the summary's fields and more). `hasUnpublishedChanges` says
@@ -2431,7 +3965,7 @@ function processFor(site, collection, markdown) {
2431
3965
  * saved under; `publish` is what `validate` and `publish` check, with every contract of the
2432
3966
  * collection, against the other entries of the site as they are now.
2433
3967
  */
2434
- function judge(context, collection, target, markdown, phase) {
3968
+ function judge(context, collection, target, markdown, phase, entries = context.store.lookup(context.site.key)) {
2435
3969
  return runContracts({
2436
3970
  site: context.site,
2437
3971
  collection,
@@ -2440,7 +3974,7 @@ function judge(context, collection, target, markdown, phase) {
2440
3974
  entryId: target.entryId,
2441
3975
  phase,
2442
3976
  markdown,
2443
- entries: context.store.lookup(context.site.key)
3977
+ entries
2444
3978
  });
2445
3979
  }
2446
3980
  /**
@@ -2550,8 +4084,8 @@ const describeSite$1 = async (_input, context) => ({
2550
4084
  //#endregion
2551
4085
  //#region src/ops/get-entry.ts
2552
4086
  /**
2553
- * Reads an entry in the view of the call, `head` over MCP and `published` over RPC, with only the
2554
- * formats that were asked for.
4087
+ * Reads an entry in the view of the call, `head` over MCP and `published` over RPC, or in the
4088
+ * published view when the input asks for it, with only the formats that were asked for.
2555
4089
  */
2556
4090
  const getEntry$1 = async (input, context) => {
2557
4091
  requireCollection(context.site, input.collection);
@@ -2560,7 +4094,7 @@ const getEntry$1 = async (input, context) => {
2560
4094
  collection: input.collection,
2561
4095
  locale: input.locale,
2562
4096
  slug: input.slug
2563
- }, context.view);
4097
+ }, input.published ? "published" : context.view);
2564
4098
  if (found === null) return { outcome: "not_found" };
2565
4099
  const { entry, revision } = found;
2566
4100
  const publication = await context.store.lastPublication(context.site.key, entry.id);
@@ -2612,6 +4146,31 @@ const listEntries$1 = async (input, context) => {
2612
4146
  };
2613
4147
  };
2614
4148
  //#endregion
4149
+ //#region src/ops/event.ts
4150
+ /** The path of an entry: the route of its locale, with its slug in place of `:slug`. */
4151
+ function entryPath(collection, locale, slug) {
4152
+ const route = collection.routes[locale];
4153
+ return route === void 0 ? null : route.replace(/:slug(?![A-Za-z0-9_])/, slug);
4154
+ }
4155
+ /** The event of a publish or an unpublish, ready for the outbox. */
4156
+ function eventFor(input) {
4157
+ const id = newId("evt");
4158
+ const path = entryPath(input.collection, input.entry.locale, input.entry.slug);
4159
+ const payload = {
4160
+ id,
4161
+ kind: input.kind,
4162
+ site: input.site.key,
4163
+ at: input.at,
4164
+ entry: input.entry,
4165
+ path,
4166
+ url: path === null || input.site.url === null ? null : `${input.site.url}${path}`
4167
+ };
4168
+ return {
4169
+ id,
4170
+ payload: JSON.stringify(payload)
4171
+ };
4172
+ }
4173
+ //#endregion
2615
4174
  //#region src/ops/publish.ts
2616
4175
  /** A warning by what an acknowledgement names: its contract and its path, never its line. */
2617
4176
  function warningKey(warning) {
@@ -2623,8 +4182,15 @@ function warningKey(warning) {
2623
4182
  * changed since the draft was saved: an error blocks, and a warning has to be acknowledged, by
2624
4183
  * contract and path, before the publication is made. The publication records every warning of its
2625
4184
  * verdict as acknowledged (ADR-021).
4185
+ *
4186
+ * A caller without `content:publish` goes through the same checks, and then asks for a review
4187
+ * instead of publishing (v0.3): the answer is `pending_review`, and a person who may publish
4188
+ * decides. A review is for one entry, now.
2626
4189
  */
2627
4190
  const publish$1 = async (input, context) => {
4191
+ const mayPublish = context.principal.scopes.includes("content:publish");
4192
+ 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.");
4193
+ if (input.group.length > 0) return publishGroup(input, context);
2628
4194
  const actor = actorOf(context);
2629
4195
  const found = await context.store.findEntryById(context.site.key, input.id, "head");
2630
4196
  if (found === null) return { outcome: "not_found" };
@@ -2657,7 +4223,70 @@ const publish$1 = async (input, context) => {
2657
4223
  entry: summary,
2658
4224
  verdict: verdictView(verdict)
2659
4225
  };
4226
+ const acknowledgedWarnings = verdict.warnings.map(({ contract, path }) => ({
4227
+ contract,
4228
+ path
4229
+ }));
4230
+ if (!mayPublish) {
4231
+ const asked = await context.store.requestReview(context.site.key, {
4232
+ entryId: entry.id,
4233
+ expectedVersion: input.version,
4234
+ acknowledged: acknowledgedWarnings,
4235
+ actor
4236
+ });
4237
+ switch (asked.outcome) {
4238
+ case "pending_review": return {
4239
+ outcome: "pending_review",
4240
+ entry: summary,
4241
+ verdict: verdictView(verdict),
4242
+ review: reviewView(asked.review)
4243
+ };
4244
+ case "conflict": return {
4245
+ outcome: "conflict",
4246
+ conflict: conflictAt(asked.currentVersion, asked.headRevision)
4247
+ };
4248
+ case "not_found": return { outcome: "not_found" };
4249
+ default: throw unexpectedAnswer(context, "publish", asked);
4250
+ }
4251
+ }
4252
+ if (input.publishAt !== void 0 && Date.parse(input.publishAt) > context.clock.now().getTime()) {
4253
+ 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.");
4254
+ const scheduled = await context.store.schedule(context.site.key, {
4255
+ entryId: entry.id,
4256
+ expectedVersion: input.version,
4257
+ scheduledAt: new Date(input.publishAt).toISOString(),
4258
+ acknowledged: acknowledgedWarnings,
4259
+ actor
4260
+ });
4261
+ switch (scheduled.outcome) {
4262
+ case "scheduled": return {
4263
+ outcome: "scheduled",
4264
+ entry: entrySummary(scheduled.view),
4265
+ verdict: verdictView(verdict)
4266
+ };
4267
+ case "conflict": return {
4268
+ outcome: "conflict",
4269
+ conflict: conflictAt(scheduled.currentVersion, scheduled.headRevision)
4270
+ };
4271
+ case "not_found": return { outcome: "not_found" };
4272
+ default: throw unexpectedAnswer(context, "publish", scheduled);
4273
+ }
4274
+ }
4275
+ const event = eventFor({
4276
+ kind: "entry.published",
4277
+ site: context.site,
4278
+ collection,
4279
+ entry: {
4280
+ id: entry.id,
4281
+ collection: entry.collection,
4282
+ locale: entry.locale,
4283
+ slug: entry.slug,
4284
+ translationGroup: entry.translationGroup
4285
+ },
4286
+ at: context.clock.now().toISOString()
4287
+ });
2660
4288
  const written = await context.store.publish(context.site.key, {
4289
+ event,
2661
4290
  entryId: entry.id,
2662
4291
  expectedVersion: input.version,
2663
4292
  verdict,
@@ -2669,11 +4298,13 @@ const publish$1 = async (input, context) => {
2669
4298
  sortKey: sortKeyOf(collection, processed.value.frontmatter) ?? entry.publishedAt ?? context.clock.now().toISOString()
2670
4299
  });
2671
4300
  switch (written.outcome) {
2672
- case "published": return {
2673
- outcome: "published",
2674
- entry: entrySummary(written.view),
2675
- verdict: verdictView(verdict)
2676
- };
4301
+ case "published":
4302
+ context.notify?.([event.id]);
4303
+ return {
4304
+ outcome: "published",
4305
+ entry: entrySummary(written.view),
4306
+ verdict: verdictView(verdict)
4307
+ };
2677
4308
  case "conflict": return {
2678
4309
  outcome: "conflict",
2679
4310
  conflict: conflictAt(written.currentVersion, written.headRevision)
@@ -2687,6 +4318,222 @@ const publish$1 = async (input, context) => {
2687
4318
  case "taken": throw unexpectedAnswer(context, "publish", written);
2688
4319
  }
2689
4320
  };
4321
+ /** The lookup of the contracts, with the entries of a group seen as published (ADR-024). */
4322
+ function asPublished(lookup, ids) {
4323
+ return {
4324
+ lookupMany: async (refs) => (await lookup.lookupMany(refs)).map((facts) => facts !== null && ids.has(facts.id) ? {
4325
+ ...facts,
4326
+ status: "published"
4327
+ } : facts),
4328
+ translations: (collection, group) => lookup.translations(collection, group)
4329
+ };
4330
+ }
4331
+ /**
4332
+ * Publishes an entry and the entries of its group in one step, all or none (ADR-024). Each entry
4333
+ * is checked as `publish` checks one, except that the contracts see every entry of the group as
4334
+ * published, so links between them resolve. The first entry that stops the group is named in
4335
+ * `failedEntry`. An acknowledgement, by contract and path, holds for every entry that reports
4336
+ * that warning, and each publication records its own warnings.
4337
+ */
4338
+ async function publishGroup(input, context) {
4339
+ if (input.publishAt !== void 0) throw new HollwareError("invalid_input", "A group cannot be scheduled: publishAt schedules one entry.", "Publish the group now, without publishAt, or schedule its entries one by one.");
4340
+ const actor = actorOf(context);
4341
+ const members = [{
4342
+ id: input.id,
4343
+ version: input.version
4344
+ }, ...input.group].filter((member, index, all) => all.findIndex((other) => other.id === member.id) === index);
4345
+ const lookup = asPublished(context.store.lookup(context.site.key), new Set(members.map((member) => member.id)));
4346
+ const checked = [];
4347
+ for (const member of members) {
4348
+ const found = await context.store.findEntryById(context.site.key, member.id, "head");
4349
+ if (found === null) return {
4350
+ outcome: "not_found",
4351
+ failedEntry: member.id
4352
+ };
4353
+ const { entry, revision } = found;
4354
+ if (entry.version !== member.version) return {
4355
+ outcome: "conflict",
4356
+ conflict: conflictAt(entry.version, entry.headRevision),
4357
+ failedEntry: member.id
4358
+ };
4359
+ const collection = collectionOfEntry(context.site, entry);
4360
+ const summary = entrySummary(found);
4361
+ const processed = await processFor(context.site, collection, revision.markdown);
4362
+ if (!processed.ok) return {
4363
+ outcome: "blocked",
4364
+ entry: summary,
4365
+ verdict: refusalVerdict("publish", processed.violations),
4366
+ failedEntry: member.id
4367
+ };
4368
+ const verdict = await judge(context, collection, {
4369
+ locale: entry.locale,
4370
+ slug: entry.slug,
4371
+ entryId: entry.id
4372
+ }, processed.value, "publish", lookup);
4373
+ if (!verdict.ok) return {
4374
+ outcome: "blocked",
4375
+ entry: summary,
4376
+ verdict: verdictView(verdict),
4377
+ failedEntry: member.id
4378
+ };
4379
+ checked.push({
4380
+ summary,
4381
+ collection,
4382
+ verdict,
4383
+ write: {
4384
+ event: eventFor({
4385
+ kind: "entry.published",
4386
+ site: context.site,
4387
+ collection,
4388
+ entry: {
4389
+ id: entry.id,
4390
+ collection: entry.collection,
4391
+ locale: entry.locale,
4392
+ slug: entry.slug,
4393
+ translationGroup: entry.translationGroup
4394
+ },
4395
+ at: context.clock.now().toISOString()
4396
+ }),
4397
+ entryId: entry.id,
4398
+ expectedVersion: member.version,
4399
+ verdict,
4400
+ acknowledged: verdict.warnings.map(({ contract, path }) => ({
4401
+ contract,
4402
+ path
4403
+ })),
4404
+ actor,
4405
+ sortKey: sortKeyOf(collection, processed.value.frontmatter) ?? entry.publishedAt ?? context.clock.now().toISOString()
4406
+ }
4407
+ });
4408
+ }
4409
+ const [main] = checked;
4410
+ if (main === void 0) throw unexpectedAnswer(context, "publish", { outcome: "empty group" });
4411
+ const acknowledged = new Set(input.acknowledgeWarnings.map(warningKey));
4412
+ const pending = checked.find(({ verdict }) => verdict.warnings.some((warning) => !acknowledged.has(warningKey(warning))));
4413
+ if (pending !== void 0) return {
4414
+ outcome: "needs_acknowledgement",
4415
+ entry: pending.summary,
4416
+ verdict: verdictView(pending.verdict),
4417
+ failedEntry: pending.write.entryId
4418
+ };
4419
+ const written = await context.store.publishGroup(context.site.key, checked.map(({ write }) => write));
4420
+ switch (written.outcome) {
4421
+ case "published": {
4422
+ const [view] = written.views;
4423
+ if (view === void 0) throw unexpectedAnswer(context, "publish", written);
4424
+ context.notify?.(checked.flatMap(({ write }) => write.event ? [write.event.id] : []));
4425
+ return {
4426
+ outcome: "published",
4427
+ entry: entrySummary(view),
4428
+ verdict: verdictView(main.verdict)
4429
+ };
4430
+ }
4431
+ case "conflict": return {
4432
+ outcome: "conflict",
4433
+ conflict: conflictAt(written.currentVersion, written.headRevision),
4434
+ failedEntry: written.entryId
4435
+ };
4436
+ case "not_found": return {
4437
+ outcome: "not_found",
4438
+ failedEntry: written.entryId
4439
+ };
4440
+ case "too_large": {
4441
+ const failed = checked.find(({ write }) => write.entryId === written.entryId) ?? main;
4442
+ return {
4443
+ outcome: "blocked",
4444
+ entry: failed.summary,
4445
+ verdict: refusalVerdict("publish", [violationOf(written, failed.collection)]),
4446
+ failedEntry: written.entryId
4447
+ };
4448
+ }
4449
+ case "taken": throw unexpectedAnswer(context, "publish", written);
4450
+ }
4451
+ }
4452
+ //#endregion
4453
+ //#region src/ops/reviews.ts
4454
+ /**
4455
+ * The reviews of v0.3: what a person who may publish does with the requests of those who may
4456
+ * only write. `publish` makes a request (`pending_review`); these list them, approve one, which
4457
+ * publishes it through `publish` itself, or close one with a note.
4458
+ */
4459
+ /** The requests of one status, newest first, each with its entry as it is now. */
4460
+ const listReviews = async (input, context) => {
4461
+ const found = await context.store.listReviews(context.site.key, {
4462
+ status: input.status,
4463
+ limit: input.limit
4464
+ });
4465
+ const reviews = [];
4466
+ for (const review of found) {
4467
+ const view = await context.store.findEntryById(context.site.key, review.entryId, "head");
4468
+ if (view === null) throw unexpectedAnswer(context, "list_reviews", { outcome: "no entry" });
4469
+ reviews.push({
4470
+ ...reviewView(review),
4471
+ entry: entrySummary(view)
4472
+ });
4473
+ }
4474
+ return {
4475
+ outcome: "ok",
4476
+ reviews
4477
+ };
4478
+ };
4479
+ /**
4480
+ * Publishes what a pending review asked for, through `publish`, in the approver's name: the
4481
+ * contracts run again, and the warnings the request acknowledged count as acknowledged. The
4482
+ * publication approves the review in its own batch. A review that is no longer pending answers
4483
+ * `not_pending`.
4484
+ */
4485
+ const approveReview = async (input, context) => {
4486
+ const site = context.site.key;
4487
+ const review = await context.store.findReview(site, input.id);
4488
+ if (review === null) return { outcome: "not_found" };
4489
+ if (review.status !== "pending") return {
4490
+ outcome: "not_pending",
4491
+ review: reviewView(review)
4492
+ };
4493
+ const found = await context.store.findEntryById(site, review.entryId, "head");
4494
+ if (found === null) return { outcome: "not_found" };
4495
+ const answer = await publish$1({
4496
+ id: found.entry.id,
4497
+ version: found.entry.version,
4498
+ acknowledgeWarnings: [...review.acknowledged, ...input.acknowledgeWarnings],
4499
+ group: []
4500
+ }, context);
4501
+ switch (answer.outcome) {
4502
+ case "published": return {
4503
+ outcome: "published",
4504
+ review: reviewView(await context.store.findReview(site, review.id) ?? review),
4505
+ ...answer.entry === void 0 ? {} : { entry: answer.entry },
4506
+ ...answer.verdict === void 0 ? {} : { verdict: answer.verdict }
4507
+ };
4508
+ case "blocked":
4509
+ case "needs_acknowledgement": return {
4510
+ outcome: answer.outcome,
4511
+ review: reviewView(review),
4512
+ ...answer.entry === void 0 ? {} : { entry: answer.entry },
4513
+ ...answer.verdict === void 0 ? {} : { verdict: answer.verdict }
4514
+ };
4515
+ case "conflict": return {
4516
+ outcome: "not_pending",
4517
+ review: reviewView(await context.store.findReview(site, review.id) ?? review)
4518
+ };
4519
+ case "not_found": return { outcome: "not_found" };
4520
+ default: throw unexpectedAnswer(context, "approve_review", answer);
4521
+ }
4522
+ };
4523
+ /** Closes a pending review with a note, without publishing. */
4524
+ const requestChanges = async (input, context) => {
4525
+ const { userId } = actorOf(context);
4526
+ const closed = await context.store.requestChanges(context.site.key, {
4527
+ id: input.id,
4528
+ note: input.note,
4529
+ userId
4530
+ });
4531
+ if (closed.outcome === "not_found") return { outcome: "not_found" };
4532
+ return {
4533
+ outcome: closed.outcome,
4534
+ review: reviewView(closed.review)
4535
+ };
4536
+ };
2690
4537
  //#endregion
2691
4538
  //#region src/ops/unpublish.ts
2692
4539
  /**
@@ -2695,15 +4542,34 @@ const publish$1 = async (input, context) => {
2695
4542
  */
2696
4543
  const unpublish = async (input, context) => {
2697
4544
  const actor = actorOf(context);
4545
+ const found = await context.store.findEntryById(context.site.key, input.id, "head");
4546
+ if (found === null) return { outcome: "not_found" };
4547
+ const { entry } = found;
4548
+ const event = eventFor({
4549
+ kind: "entry.unpublished",
4550
+ site: context.site,
4551
+ collection: collectionOfEntry(context.site, entry),
4552
+ entry: {
4553
+ id: entry.id,
4554
+ collection: entry.collection,
4555
+ locale: entry.locale,
4556
+ slug: entry.slug,
4557
+ translationGroup: entry.translationGroup
4558
+ },
4559
+ at: context.clock.now().toISOString()
4560
+ });
2698
4561
  const written = await context.store.unpublish(context.site.key, {
2699
4562
  entryId: input.id,
2700
- actor
4563
+ actor,
4564
+ event
2701
4565
  });
2702
4566
  switch (written.outcome) {
2703
- case "unpublished": return {
2704
- outcome: "unpublished",
2705
- entry: entrySummary(written.view)
2706
- };
4567
+ case "unpublished":
4568
+ if (entry.status === "published") context.notify?.([event.id]);
4569
+ return {
4570
+ outcome: "unpublished",
4571
+ entry: entrySummary(written.view)
4572
+ };
2707
4573
  case "not_found": return { outcome: "not_found" };
2708
4574
  case "conflict":
2709
4575
  case "taken":
@@ -2807,17 +4673,20 @@ function defineHandlers(handlers) {
2807
4673
  //#endregion
2808
4674
  //#region src/ops/index.ts
2809
4675
  /**
2810
- * The handler of every operation of v0.1, paired with its spec of `@hollware/core` by type
4676
+ * The handler of every operation, paired with its spec of `@hollware/core` by type
2811
4677
  * (ADR-017). Adapters check the scopes and build the `OperationContext`; a handler never sees a
2812
4678
  * request, and reads and writes only the site of its context.
2813
4679
  */
2814
4680
  const handlers = defineHandlers({
4681
+ approve_review: approveReview,
2815
4682
  create_draft: createDraft,
2816
4683
  describe_site: describeSite$1,
2817
4684
  get_entry: getEntry$1,
2818
4685
  get_translations: getTranslations$1,
2819
4686
  list_entries: listEntries$1,
4687
+ list_reviews: listReviews,
2820
4688
  publish: publish$1,
4689
+ request_changes: requestChanges,
2821
4690
  unpublish,
2822
4691
  update_draft: updateDraft,
2823
4692
  validate