@hollware/server 0.2.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 { EVENT_ID_HEADER, HollwareError, OPERATIONS, SIGNATURE_HEADER, TIMESTAMP_HEADER, describeBuiltInContracts, describeResolvedSite, describeSite, getEntry, getTranslations, hashPersonalToken, isPersonalToken, isScope, listEntries, newId, processMarkdown, publish, renderHtml, resolveConfig, runContracts, signWebhook, sizeLimitViolation, uniqueSlugViolation, uniqueTranslationViolation } from "@hollware/core";
1
+ import { A as 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, inArray, isNull, lte, ne, or, sql } from "drizzle-orm";
5
- import { alias, check, foreignKey, index, integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
5
+ import { and, desc, eq, getTableColumns, gt, inArray, isNull, lte, ne, or, sql } from "drizzle-orm";
6
+ import { alias } from "drizzle-orm/sqlite-core";
7
+ import { AuthorizationError, CimdFetchError, OAuthAuthorizationServer, OAuthResourceServer, authorizationErrorRedirect } from "@cloudflare/workers-oauth-provider";
8
+ import { getCookie } from "hono/cookie";
6
9
  import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, McpServer, acceptedContent, createMcpHandler, inputRequired, inputResponse } from "@modelcontextprotocol/server";
7
10
  import * as z from "zod";
8
11
  import { WorkerEntrypoint } from "cloudflare:workers";
9
12
  //#region package.json
10
- var version = "0.2.0";
11
- //#endregion
12
- //#region src/auth/bearer.ts
13
- /**
14
- * `Bearer`, one or more spaces, then one token with no whitespace in it, and nothing else. The
15
- * scheme is case-insensitive (RFC 7235); the runtime has already trimmed the whole value.
16
- */
17
- const BEARER = /^bearer +(\S+)$/i;
18
- /**
19
- * The token of an `Authorization: Bearer <token>` header, as sent, or `null` when the request
20
- * presents none: no header, another scheme, no token, or more than one (two headers arrive joined
21
- * with a comma, so they read as two tokens). It never looks at the URL: a token in a query string
22
- * ends up in logs, referrers and history, so Hollware does not accept one.
23
- *
24
- * It does not judge the token. A string that is not a personal token is a token that was
25
- * presented and refused, which `checkPersonalToken` says and `unauthorized` answers differently
26
- * from a request without credentials.
27
- */
28
- function readBearerToken(request) {
29
- const header = request.headers.get("authorization");
30
- if (header === null) return null;
31
- return BEARER.exec(header)?.[1] ?? null;
32
- }
13
+ var version = "0.3.0";
33
14
  //#endregion
34
15
  //#region src/auth/responses.ts
35
16
  /** The challenge of every 401 and 403 (RFC 6750). */
@@ -56,25 +37,27 @@ function authErrorResponse(status, body, challenge) {
56
37
  function unauthorized(reason) {
57
38
  if (reason === void 0) return authErrorResponse(401, {
58
39
  code: "unauthorized",
59
- message: "The request carries no personal token.",
60
- fix: "Send a personal token as \"Authorization: Bearer <token>\". Create one with `hollware token create`."
40
+ message: "The request carries no token.",
41
+ fix: "Send a personal token as \"Authorization: Bearer <token>\" (create one with `hollware token create`), or sign in through OAuth: the challenge names where."
61
42
  }, REALM);
62
43
  return authErrorResponse(401, {
63
44
  code: "unauthorized",
64
- message: "The personal token was not accepted.",
65
- fix: "Tokens expire and can be revoked. Create a new one with `hollware token create` and send it as \"Authorization: Bearer <token>\"."
45
+ message: "The token was not accepted.",
46
+ fix: "Tokens expire and can be revoked. Create a new personal token with `hollware token create`, or sign in through OAuth again, and send it as \"Authorization: Bearer <token>\"."
66
47
  }, `${REALM}, error="invalid_token"`);
67
48
  }
68
49
  /**
69
50
  * 403 for a token that authenticated but lacks scopes. It names every scope that is missing, in
70
- * the body and in the challenge (RFC 6750), so that a client asks for all of them at once.
51
+ * the body and in the challenge (RFC 6750), so that a client asks for all of them at once, and,
52
+ * when there is one, the protected resource metadata where it learns how (RFC 9728).
71
53
  */
72
- function insufficientScope(missing) {
54
+ function insufficientScope(missing, resourceMetadataUrl) {
55
+ const metadata = resourceMetadataUrl === void 0 ? "" : `, resource_metadata="${resourceMetadataUrl}"`;
73
56
  return authErrorResponse(403, {
74
57
  code: "insufficient_scope",
75
58
  message: `The token lacks a scope this operation requires. Missing: ${missing.join(", ")}.`,
76
- fix: "Send a token that carries every missing scope. Create one with `hollware token create`; a token only gets the scopes its user's role allows."
77
- }, `${REALM}, error="insufficient_scope", scope="${missing.join(" ")}"`);
59
+ fix: "Send a token that carries every missing scope: create one with `hollware token create`, or authorize the client again for them. A token only gets the scopes its user's role allows."
60
+ }, `${REALM}${metadata}, error="insufficient_scope", scope="${missing.join(" ")}"`);
78
61
  }
79
62
  //#endregion
80
63
  //#region src/auth/guard.ts
@@ -143,60 +126,28 @@ function checkHostAndOrigin(request, publicUrl) {
143
126
  return null;
144
127
  }
145
128
  //#endregion
146
- //#region src/auth/roles.ts
129
+ //#region src/auth/scopes.ts
147
130
  /**
148
- * The roles of a user, as the `users_role` CHECK of the schema lists them. In v0.1 only the owner
149
- * exists: `hollware init` creates it. The other two are in the CHECK already, so that v0.3 can
150
- * invite people without a rebuild of the table (ADR-023).
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.
151
134
  */
152
- const ROLES = [
153
- "owner",
154
- "editor",
155
- "reviewer"
156
- ];
157
- /** Whether a string is one of the roles. */
158
- function isRole(value) {
159
- return ROLES.some((role) => role === value);
160
- }
161
- /**
162
- * The scopes each role allows. What a token may do is its own scopes intersected with these,
163
- * read from the user's current role on every request (ADR-005, ADR-019). An operation asks for
164
- * scopes, never for a role.
165
- *
166
- * - `owner` may do everything: all four scopes.
167
- * - `editor` and `reviewer` allow none in v0.1. Nothing creates such a user yet, and which scopes
168
- * each one gets (an editor writes, a reviewer approves) is for v0.3 to decide, together with
169
- * the admin that invites them. Until then a token of such a user authenticates, but every
170
- * operation answers 403 `insufficient_scope`.
171
- */
172
- const ROLE_SCOPES = {
173
- owner: [
174
- "content:read",
175
- "content:write",
176
- "content:publish",
177
- "schema:admin"
178
- ],
179
- editor: [],
180
- reviewer: []
181
- };
182
- /**
183
- * The scopes a token really has: those it carries that the role of its user allows, in the order
184
- * of the role. A role that is not one of the roles allows nothing, so a role added to the schema
185
- * before this table knows it grants no access.
186
- */
187
- function effectiveScopes(role, tokenScopes) {
188
- if (!isRole(role)) return [];
189
- 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));
190
138
  }
191
139
  //#endregion
192
140
  //#region src/auth/token.ts
193
141
  /** `last_used_at` of a token is written at most once in this time. */
194
142
  const TOUCH_INTERVAL_MS = 36e5;
195
- /** 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
+ */
196
147
  const LOOKUP = `SELECT t.id AS token_id, t.site_id AS site_id, t.user_id AS user_id,
197
148
  t.scopes AS scopes, t.expires_at AS expires_at, t.revoked_at AS revoked_at,
198
- t.last_used_at AS last_used_at, u.role AS role
199
- FROM tokens AS t JOIN users AS u ON u.id = t.user_id
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
200
151
  WHERE t.hash = ?1 AND t.site_id = ?2`;
201
152
  /**
202
153
  * The scopes a token carries, from the JSON array the `tokens` table stores. Anything that is not
@@ -408,336 +359,6 @@ function decodeCursor(cursor, view) {
408
359
  throw invalidCursor();
409
360
  }
410
361
  //#endregion
411
- //#region src/db/columns.ts
412
- /**
413
- * The columns every table shares. Each helper returns a new builder, because a Drizzle column
414
- * belongs to one table. Only the repository functions write rows, so these defaults are what
415
- * every row gets.
416
- */
417
- /** The current time in UTC, as ISO 8601 with milliseconds: `2026-10-12T18:30:00.000Z`. */
418
- function nowIso() {
419
- return (/* @__PURE__ */ new Date()).toISOString();
420
- }
421
- /** The primary key: a prefixed ULID, such as `ent_01JAB3XQ8ZK4M6N2P5R7S9T0VW`. */
422
- function id(prefix) {
423
- return text("id").primaryKey().$defaultFn(() => newId(prefix));
424
- }
425
- /**
426
- * The site a row belongs to: the site's key in `hollware.config.ts`, which is also the id of its
427
- * row in `sites`. Every table but the deployment-wide ones carries it (ADR-006).
428
- */
429
- function siteId() {
430
- return text("site_id").notNull();
431
- }
432
- /** `created_at` and `updated_at`, in UTC; `updated_at` moves on every update. */
433
- function timestamps() {
434
- return {
435
- createdAt: text("created_at").notNull().$defaultFn(nowIso),
436
- updatedAt: text("updated_at").notNull().$defaultFn(nowIso).$onUpdateFn(nowIso)
437
- };
438
- }
439
- sqliteTable("_hollware_meta", {
440
- key: text("key").primaryKey().notNull(),
441
- value: text("value").notNull()
442
- });
443
- /** The sites of the deployment. The id is the site's key in `hollware.config.ts`. */
444
- const sites = sqliteTable("sites", {
445
- id: text("id").primaryKey(),
446
- ...timestamps()
447
- });
448
- /** People. A user belongs to the deployment; tokens carry the site. */
449
- const users = sqliteTable("users", {
450
- id: id("usr"),
451
- name: text("name"),
452
- role: text("role").notNull(),
453
- ...timestamps()
454
- }, (t) => [check("users_role", sql`${t.role} IN ('owner', 'editor', 'reviewer')`)]);
455
- /** Personal tokens (ADR-019): only the hash is stored. */
456
- const tokens = sqliteTable("tokens", {
457
- id: id("tok"),
458
- siteId: siteId(),
459
- userId: text("user_id").notNull(),
460
- label: text("label").notNull(),
461
- hash: text("hash").notNull(),
462
- /** A JSON array of scopes. */
463
- scopes: text("scopes").notNull(),
464
- expiresAt: text("expires_at"),
465
- revokedAt: text("revoked_at"),
466
- lastUsedAt: text("last_used_at"),
467
- ...timestamps()
468
- }, (t) => [
469
- unique("tokens_id_site").on(t.id, t.siteId),
470
- unique("tokens_hash").on(t.hash),
471
- foreignKey({
472
- name: "tokens_site",
473
- columns: [t.siteId],
474
- foreignColumns: [sites.id]
475
- }),
476
- foreignKey({
477
- name: "tokens_user",
478
- columns: [t.userId],
479
- foreignColumns: [users.id]
480
- }),
481
- check("tokens_scopes", sql`json_valid(${t.scopes}) AND json_type(${t.scopes}) = 'array'`)
482
- ]);
483
- /** One entry per site, collection, locale and slug; its content lives in `revisions`. */
484
- const entries = sqliteTable("entries", {
485
- id: id("ent"),
486
- siteId: siteId(),
487
- collection: text("collection").notNull(),
488
- locale: text("locale").notNull(),
489
- slug: text("slug").notNull(),
490
- translationGroup: text("translation_group"),
491
- status: text("status").notNull(),
492
- /** Bumped by every change of the entry: the optimistic write compares it. */
493
- version: integer("version").notNull(),
494
- headRev: text("head_rev").notNull(),
495
- publishedRev: text("published_rev"),
496
- publishedAt: text("published_at"),
497
- publishedSortKey: text("published_sort_key"),
498
- scheduledAt: text("scheduled_at"),
499
- ...timestamps()
500
- }, (t) => [
501
- unique("entries_id_site").on(t.id, t.siteId),
502
- unique("entries_slug").on(t.siteId, t.collection, t.locale, t.slug),
503
- unique("entries_translation").on(t.siteId, t.collection, t.translationGroup, t.locale),
504
- index("entries_head").on(t.siteId, t.collection, t.updatedAt, t.id),
505
- index("entries_head_locale").on(t.siteId, t.collection, t.locale, t.updatedAt, t.id),
506
- index("entries_published").on(t.siteId, t.collection, t.locale, t.publishedSortKey, t.id).where(sql`${t.status} = 'published'`),
507
- foreignKey({
508
- name: "entries_site",
509
- columns: [t.siteId],
510
- foreignColumns: [sites.id]
511
- }),
512
- check("entries_status", sql`${t.status} IN ('draft', 'scheduled', 'published')`),
513
- check("entries_version", sql`${t.version} >= 1`),
514
- check("entries_draft", sql`${t.status} <> 'draft' OR (${t.publishedRev} IS NULL AND ${t.publishedSortKey} IS NULL AND ${t.scheduledAt} IS NULL)`),
515
- check("entries_published", sql`${t.status} <> 'published' OR (${t.publishedRev} IS NOT NULL AND ${t.publishedAt} IS NOT NULL AND ${t.publishedSortKey} IS NOT NULL)`),
516
- check("entries_scheduled", sql`${t.status} <> 'scheduled' OR ${t.scheduledAt} IS NOT NULL`)
517
- ]);
518
- /**
519
- * Every version of an entry, appended and never changed (ADR-021). `markdown` is the whole file,
520
- * frontmatter included; the derived columns can be rebuilt by `reparse`.
521
- */
522
- const revisions = sqliteTable("revisions", {
523
- id: id("rev"),
524
- siteId: siteId(),
525
- entryId: text("entry_id").notNull(),
526
- markdown: text("markdown").notNull(),
527
- /** Derived: the frontmatter as JSON. */
528
- frontmatter: text("frontmatter").notNull(),
529
- /** Derived: the Comark document as JSON. */
530
- document: text("document").notNull(),
531
- comarkVersion: text("comark_version").notNull(),
532
- pipelineVersion: text("pipeline_version").notNull(),
533
- wordCount: integer("word_count").notNull(),
534
- /** The verdict of the contracts when the revision was written, as JSON. */
535
- verdict: text("verdict").notNull(),
536
- actorKind: text("actor_kind").notNull(),
537
- actorUserId: text("actor_user_id").notNull(),
538
- actorTokenId: text("actor_token_id"),
539
- /** The client and, when it says, the model behind the write, as JSON. */
540
- actorMeta: text("actor_meta"),
541
- ...timestamps()
542
- }, (t) => [
543
- unique("revisions_id_site").on(t.id, t.siteId),
544
- index("revisions_entry").on(t.entryId, t.createdAt),
545
- foreignKey({
546
- name: "revisions_site",
547
- columns: [t.siteId],
548
- foreignColumns: [sites.id]
549
- }),
550
- foreignKey({
551
- name: "revisions_entry",
552
- columns: [t.entryId, t.siteId],
553
- foreignColumns: [entries.id, entries.siteId]
554
- }),
555
- foreignKey({
556
- name: "revisions_user",
557
- columns: [t.actorUserId],
558
- foreignColumns: [users.id]
559
- }),
560
- foreignKey({
561
- name: "revisions_token",
562
- columns: [t.actorTokenId, t.siteId],
563
- foreignColumns: [tokens.id, tokens.siteId]
564
- }),
565
- check("revisions_actor_kind", sql`${t.actorKind} IN ('token', 'user', 'oauth_client')`),
566
- check("revisions_json", sql`json_valid(${t.frontmatter}) AND json_valid(${t.document}) AND json_valid(${t.verdict}) AND (${t.actorMeta} IS NULL OR json_valid(${t.actorMeta}))`),
567
- check("revisions_word_count", sql`${t.wordCount} >= 0`)
568
- ]);
569
- /** The tags of a revision, for listing published entries by tag. Append-only, like revisions. */
570
- const revisionTags = sqliteTable("revision_tags", {
571
- id: id("tag"),
572
- siteId: siteId(),
573
- revisionId: text("revision_id").notNull(),
574
- tag: text("tag").notNull(),
575
- ...timestamps()
576
- }, (t) => [
577
- unique("revision_tags_id_site").on(t.id, t.siteId),
578
- unique("revision_tags_tag").on(t.revisionId, t.tag),
579
- index("revision_tags_by_tag").on(t.siteId, t.tag),
580
- foreignKey({
581
- name: "revision_tags_site",
582
- columns: [t.siteId],
583
- foreignColumns: [sites.id]
584
- }),
585
- foreignKey({
586
- name: "revision_tags_revision",
587
- columns: [t.revisionId, t.siteId],
588
- foreignColumns: [revisions.id, revisions.siteId]
589
- })
590
- ]);
591
- /**
592
- * Every publish and unpublish, appended: the verdict of the publish phase and the warnings the
593
- * caller acknowledged (ADR-021).
594
- */
595
- const publications = sqliteTable("publications", {
596
- id: id("pub"),
597
- siteId: siteId(),
598
- entryId: text("entry_id").notNull(),
599
- action: text("action").notNull(),
600
- revisionId: text("revision_id"),
601
- verdict: text("verdict"),
602
- /** A JSON array: the warnings the caller acknowledged to publish. */
603
- acknowledged: text("acknowledged").notNull(),
604
- actorKind: text("actor_kind").notNull(),
605
- actorUserId: text("actor_user_id").notNull(),
606
- actorTokenId: text("actor_token_id"),
607
- actorMeta: text("actor_meta"),
608
- ...timestamps()
609
- }, (t) => [
610
- unique("publications_id_site").on(t.id, t.siteId),
611
- index("publications_entry").on(t.entryId, t.createdAt),
612
- foreignKey({
613
- name: "publications_site",
614
- columns: [t.siteId],
615
- foreignColumns: [sites.id]
616
- }),
617
- foreignKey({
618
- name: "publications_entry",
619
- columns: [t.entryId, t.siteId],
620
- foreignColumns: [entries.id, entries.siteId]
621
- }),
622
- foreignKey({
623
- name: "publications_revision",
624
- columns: [t.revisionId, t.siteId],
625
- foreignColumns: [revisions.id, revisions.siteId]
626
- }),
627
- foreignKey({
628
- name: "publications_user",
629
- columns: [t.actorUserId],
630
- foreignColumns: [users.id]
631
- }),
632
- foreignKey({
633
- name: "publications_token",
634
- columns: [t.actorTokenId, t.siteId],
635
- foreignColumns: [tokens.id, tokens.siteId]
636
- }),
637
- check("publications_action", sql`${t.action} IN ('publish', 'unpublish')`),
638
- check("publications_shape", sql`(${t.action} = 'publish' AND ${t.revisionId} IS NOT NULL AND ${t.verdict} IS NOT NULL) OR (${t.action} = 'unpublish' AND ${t.revisionId} IS NULL AND ${t.verdict} IS NULL)`),
639
- check("publications_actor_kind", sql`${t.actorKind} IN ('token', 'user', 'oauth_client')`),
640
- check("publications_json", sql`json_valid(${t.acknowledged}) AND json_type(${t.acknowledged}) = 'array' AND (${t.verdict} IS NULL OR json_valid(${t.verdict})) AND (${t.actorMeta} IS NULL OR json_valid(${t.actorMeta}))`)
641
- ]);
642
- /**
643
- * Scheduled publications (ADR-025): one per entry at most. The cron trigger publishes the entry when
644
- * `scheduled_at` has passed, if it is still at `version`, with the warnings that were acknowledged
645
- * when it was scheduled, and in the name of who scheduled it.
646
- */
647
- const schedules = sqliteTable("schedules", {
648
- id: id("sch"),
649
- siteId: siteId(),
650
- entryId: text("entry_id").notNull(),
651
- scheduledAt: text("scheduled_at").notNull(),
652
- /** The version of the entry once scheduled: a later change cancels the schedule. */
653
- version: integer("version").notNull(),
654
- /** A JSON array: the warnings acknowledged when the publication was scheduled. */
655
- acknowledged: text("acknowledged").notNull(),
656
- actorKind: text("actor_kind").notNull(),
657
- actorUserId: text("actor_user_id").notNull(),
658
- actorTokenId: text("actor_token_id"),
659
- actorMeta: text("actor_meta"),
660
- ...timestamps()
661
- }, (t) => [
662
- unique("schedules_id_site").on(t.id, t.siteId),
663
- unique("schedules_entry").on(t.siteId, t.entryId),
664
- index("schedules_due").on(t.scheduledAt),
665
- foreignKey({
666
- name: "schedules_site",
667
- columns: [t.siteId],
668
- foreignColumns: [sites.id]
669
- }),
670
- foreignKey({
671
- name: "schedules_entry_fk",
672
- columns: [t.entryId, t.siteId],
673
- foreignColumns: [entries.id, entries.siteId]
674
- }),
675
- foreignKey({
676
- name: "schedules_user",
677
- columns: [t.actorUserId],
678
- foreignColumns: [users.id]
679
- }),
680
- foreignKey({
681
- name: "schedules_token",
682
- columns: [t.actorTokenId, t.siteId],
683
- foreignColumns: [tokens.id, tokens.siteId]
684
- }),
685
- check("schedules_actor_kind", sql`${t.actorKind} IN ('token', 'user', 'oauth_client')`),
686
- check("schedules_json", sql`json_valid(${t.acknowledged}) AND json_type(${t.acknowledged}) = 'array' AND (${t.actorMeta} IS NULL OR json_valid(${t.actorMeta}))`)
687
- ]);
688
- /**
689
- * The effects of a publication (ADR-025), written in the batch that publishes or unpublishes: one
690
- * row per event. The Queue consumer delivers each event to every subscriber, and records it in
691
- * `deliveries`; `sent_at` says that the event was put on the Queue.
692
- */
693
- const outbox = sqliteTable("outbox", {
694
- id: id("evt"),
695
- siteId: siteId(),
696
- /** `entry.published` or `entry.unpublished`. */
697
- kind: text("kind").notNull(),
698
- entryId: text("entry_id").notNull(),
699
- /** A JSON object: what subscribers get. */
700
- payload: text("payload").notNull(),
701
- sentAt: text("sent_at"),
702
- ...timestamps()
703
- }, (t) => [
704
- unique("outbox_id_site").on(t.id, t.siteId),
705
- index("outbox_unsent").on(t.sentAt, t.createdAt),
706
- foreignKey({
707
- name: "outbox_site",
708
- columns: [t.siteId],
709
- foreignColumns: [sites.id]
710
- }),
711
- check("outbox_kind", sql`${t.kind} IN ('entry.published', 'entry.unpublished')`),
712
- check("outbox_json", sql`json_valid(${t.payload}) AND json_type(${t.payload}) = 'object'`)
713
- ]);
714
- /** Each delivery of an event to a subscriber, so that a retry never delivers it twice. */
715
- const deliveries = sqliteTable("deliveries", {
716
- id: id("dlv"),
717
- siteId: siteId(),
718
- eventId: text("event_id").notNull(),
719
- /** The subscriber, by its name in the config, such as `webhook:newsletter` or `indexnow`. */
720
- subscriber: text("subscriber").notNull(),
721
- attempts: integer("attempts").notNull(),
722
- deliveredAt: text("delivered_at"),
723
- lastError: text("last_error"),
724
- ...timestamps()
725
- }, (t) => [
726
- unique("deliveries_id_site").on(t.id, t.siteId),
727
- unique("deliveries_event_subscriber").on(t.eventId, t.subscriber),
728
- foreignKey({
729
- name: "deliveries_site",
730
- columns: [t.siteId],
731
- foreignColumns: [sites.id]
732
- }),
733
- foreignKey({
734
- name: "deliveries_event",
735
- columns: [t.eventId, t.siteId],
736
- foreignColumns: [outbox.id, outbox.siteId]
737
- }),
738
- check("deliveries_attempts", sql`${t.attempts} >= 0`)
739
- ]);
740
- //#endregion
741
362
  //#region src/db/rows.ts
742
363
  /**
743
364
  * The columns the store reads and how they become the types of `ports.ts`. The JSON columns come
@@ -1094,6 +715,21 @@ function lookup(db, site) {
1094
715
  };
1095
716
  }
1096
717
  //#endregion
718
+ //#region src/db/errors.ts
719
+ /**
720
+ * What D1 says when a statement breaks a unique key: `D1_ERROR: UNIQUE constraint failed:
721
+ * entries.site_id, …: SQLITE_CONSTRAINT`, with the same text again in the `cause`. Inside a batch
722
+ * the error arrives as it is, and the batch has rolled back.
723
+ */
724
+ function isUniqueViolation(error) {
725
+ let current = error;
726
+ for (let depth = 0; depth < 4 && current instanceof Error; depth += 1) {
727
+ if (current.message.includes("UNIQUE constraint failed")) return true;
728
+ current = current.cause;
729
+ }
730
+ return false;
731
+ }
732
+ //#endregion
1097
733
  //#region src/db/statements.ts
1098
734
  function actorColumns(actor) {
1099
735
  return {
@@ -1241,7 +877,7 @@ async function dueSchedules(db, site, now) {
1241
877
  version: row.version,
1242
878
  acknowledged: parseAcknowledged(row.acknowledged),
1243
879
  actor: {
1244
- kind: "token",
880
+ kind: actorKindOf(row.actorKind),
1245
881
  userId: row.actorUserId,
1246
882
  tokenId: row.actorTokenId,
1247
883
  meta: row.actorMeta === null ? null : parseMeta(row.actorMeta)
@@ -1265,24 +901,140 @@ function parseAcknowledged(text) {
1265
901
  path: item.path
1266
902
  }] : []);
1267
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
+ }
1268
909
  function parseMeta(text) {
1269
910
  const value = JSON.parse(text);
1270
911
  return typeof value === "object" && value !== null && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : null;
1271
912
  }
1272
913
  //#endregion
1273
- //#region src/db/errors.ts
914
+ //#region src/db/reviews.ts
1274
915
  /**
1275
- * What D1 says when a statement breaks a unique key: `D1_ERROR: UNIQUE constraint failed:
1276
- * entries.site_id, …: SQLITE_CONSTRAINT`, with the same text again in the `cause`. Inside a batch
1277
- * the error arrives as it is, and the batch has rolled back.
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`).
1278
921
  */
1279
- function isUniqueViolation(error) {
1280
- let current = error;
1281
- for (let depth = 0; depth < 4 && current instanceof Error; depth += 1) {
1282
- if (current.message.includes("UNIQUE constraint failed")) return true;
1283
- current = current.cause;
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
+ };
1284
1004
  }
1285
- return false;
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
+ };
1286
1038
  }
1287
1039
  //#endregion
1288
1040
  //#region src/db/size.ts
@@ -1477,6 +1229,7 @@ async function appendRevision(db, clock, site, write) {
1477
1229
  tags,
1478
1230
  now
1479
1231
  }),
1232
+ cancelPendingReview(db, site, write.entryId, write.expectedVersion, now),
1480
1233
  db.update(entries).set({
1481
1234
  version: sql`${entries.version} + 1`,
1482
1235
  headRev: revisionId,
@@ -1521,6 +1274,7 @@ async function publish$2(db, clock, site, write) {
1521
1274
  insertPublishAtVersion(db, row, write.expectedVersion),
1522
1275
  dropScheduleAtVersion(db, site, write.entryId, write.expectedVersion),
1523
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),
1524
1278
  db.update(entries).set({
1525
1279
  status: "published",
1526
1280
  scheduledAt: null,
@@ -1530,7 +1284,7 @@ async function publish$2(db, clock, site, write) {
1530
1284
  version: sql`${entries.version} + 1`,
1531
1285
  updatedAt: now
1532
1286
  }).where(and(eq(entries.id, write.entryId), eq(entries.siteId, site), eq(entries.version, write.expectedVersion))).returning(entryFields)
1533
- ]))[3];
1287
+ ]))[4];
1534
1288
  if (changed === void 0) return whyNothingChanged(db, site, write.entryId);
1535
1289
  const entry = toEntry(changed);
1536
1290
  const revision = entry.publishedRevision === null ? null : await findRevision(db, site, entry.publishedRevision);
@@ -1573,7 +1327,7 @@ async function publishGroup$1(db, clock, site, group) {
1573
1327
  entryId: write.entryId
1574
1328
  };
1575
1329
  appended.push(row.id);
1576
- statements.push(insertPublishAtVersion(db, row, write.expectedVersion), dropScheduleAtVersion(db, site, write.entryId, write.expectedVersion), insertEventWhere(db, site, write.entryId, write.event ?? null, "entry.published", now, [eq(entries.version, write.expectedVersion)]), db.update(entries).set({
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({
1577
1331
  status: "published",
1578
1332
  scheduledAt: null,
1579
1333
  publishedRev: entries.headRev,
@@ -1686,7 +1440,11 @@ function createD1Store(db, options = {}) {
1686
1440
  schedule: (site, write) => schedule(database, clock, site, write),
1687
1441
  dueSchedules: (site, now) => dueSchedules(database, site, now),
1688
1442
  cancelSchedule: (site, entryId) => cancelSchedule(database, clock, site, entryId),
1689
- unpublish: (site, write) => unpublish$1(database, clock, site, write)
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)
1690
1448
  };
1691
1449
  }
1692
1450
  //#endregion
@@ -1983,6 +1741,340 @@ function installErrorHandling(app, log) {
1983
1741
  }));
1984
1742
  }
1985
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
1986
2078
  //#region src/http/read-api.ts
1987
2079
  const STATUS = {
1988
2080
  invalid_input: 400,
@@ -2019,46 +2111,27 @@ async function answer(c, value) {
2019
2111
  }
2020
2112
  function installReadApi(app, options) {
2021
2113
  app.use("/v1/*", async (c, next) => {
2114
+ if (c.req.path.startsWith("/v1/ops/") || c.req.path.startsWith("/v1/admin/")) return next();
2022
2115
  if (c.req.method !== "GET" && c.req.method !== "HEAD") return c.json({
2023
2116
  code: "invalid_input",
2024
2117
  message: "The read API only reads.",
2025
2118
  fix: "Use GET."
2026
2119
  }, 405, { allow: "GET, HEAD" });
2027
2120
  if (c.req.path === "/v1/openapi.json") return next();
2028
- const requestId = c.get("requestId");
2029
- const token = readBearerToken(c.req.raw);
2030
- if (token === null) {
2031
- options.log({
2032
- kind: "auth_refused",
2033
- requestId,
2034
- reason: "no_token"
2035
- });
2036
- return unauthorized();
2037
- }
2038
- const check = await checkPersonalToken(c.env.DB, token, {
2039
- site: options.site,
2040
- now: options.clock.now()
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
+ }
2041
2134
  });
2042
- if (!check.ok) {
2043
- options.log({
2044
- kind: "auth_refused",
2045
- requestId,
2046
- reason: check.reason
2047
- });
2048
- return unauthorized(check.reason);
2049
- }
2050
- if (!check.principal.scopes.includes("content:read")) return insufficientScope(["content:read"]);
2051
- if (check.touch !== null) c.executionCtx.waitUntil(check.touch);
2052
- try {
2053
- return await next();
2054
- } catch (error) {
2055
- if (!(error instanceof HollwareError)) throw error;
2056
- return c.json({
2057
- code: error.code,
2058
- message: error.message,
2059
- fix: error.fix
2060
- }, STATUS[error.code] ?? 500);
2061
- }
2062
2135
  });
2063
2136
  app.get("/v1/openapi.json", (c) => c.json(openApiDocument(options.site)));
2064
2137
  app.get("/v1/site", async (c) => answer(c, await options.read(c.env).describeSite(options.site)));
@@ -2087,7 +2160,7 @@ function openApiDocument(site) {
2087
2160
  });
2088
2161
  const common = {
2089
2162
  "304": { description: "Not modified: the ETag of If-None-Match is current." },
2090
- "401": { description: "No token, or one that is unknown, expired or revoked." },
2163
+ "401": { description: "No token, or one that is unknown, expired or revoked. The challenge names the protected resource metadata." },
2091
2164
  "403": { description: "The token lacks content:read." }
2092
2165
  };
2093
2166
  const param = (name, location, required) => ({
@@ -2156,8 +2229,556 @@ function openApiDocument(site) {
2156
2229
  };
2157
2230
  }
2158
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`.
2635
+ *
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.
2640
+ */
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
+ }
2676
+ });
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
+ };
2763
+ }
2764
+ //#endregion
2159
2765
  //#region src/http/gateway.ts
2160
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
+ /**
2161
2782
  * The HTTP gateway of the Worker (ADR-019): a Hono 5 app, built once, that runs in this order for
2162
2783
  * every request:
2163
2784
  *
@@ -2183,6 +2804,73 @@ function openApiDocument(site) {
2183
2804
  function createGateway(options) {
2184
2805
  const { site, mcp, clock, log } = options;
2185
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
+ };
2186
2874
  app.use("*", async (c, next) => {
2187
2875
  c.set("requestId", crypto.randomUUID());
2188
2876
  await next();
@@ -2193,64 +2881,67 @@ function createGateway(options) {
2193
2881
  await next();
2194
2882
  });
2195
2883
  app.get("/health", () => Response.json({ ok: true }, { headers: { "cache-control": "no-store" } }));
2196
- app.all("/mcp", async (c) => {
2884
+ app.all("/mcp", async (c) => protect(c, "mcp", async (principal) => {
2197
2885
  const requestId = c.get("requestId");
2198
- const token = readBearerToken(c.req.raw);
2199
- if (token === null) {
2200
- log({
2201
- kind: "auth_refused",
2202
- requestId,
2203
- reason: "no_token"
2204
- });
2205
- return unauthorized();
2206
- }
2207
- const check = await checkPersonalToken(c.env.DB, token, {
2208
- site: site.key,
2209
- now: clock.now()
2210
- });
2211
- if (!check.ok) {
2212
- log({
2213
- kind: "auth_refused",
2214
- requestId,
2215
- reason: check.reason
2216
- });
2217
- return unauthorized(check.reason);
2218
- }
2219
- if (check.touch !== null) c.executionCtx.waitUntil(check.touch);
2220
2886
  const db = c.env.DB;
2221
2887
  return await mcp.fetch(c.req.raw, {
2222
- principal: check.principal,
2888
+ principal,
2223
2889
  requestId,
2890
+ resourceMetadataUrl: oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.mcp,
2224
2891
  context: {
2225
2892
  site,
2226
2893
  store: createD1Store(db, { clock }),
2227
2894
  clock,
2228
- notify: (ids) => {
2229
- c.executionCtx.waitUntil(enqueue({
2230
- queue: c.env.EVENTS,
2231
- db: connect(db),
2232
- site: site.key,
2233
- ids,
2234
- clock
2235
- }).catch((error) => {
2236
- log({
2237
- kind: "enqueue_failed",
2238
- requestId,
2239
- message: String(error)
2240
- });
2241
- }));
2242
- }
2895
+ notify: notify(c, requestId)
2243
2896
  }
2244
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
2245
2933
  });
2246
2934
  if (options.read !== void 0) installReadApi(app, {
2247
2935
  read: options.read,
2248
2936
  site: site.key,
2249
- clock,
2250
- log
2937
+ protect: (c, proceed) => protect(c, "v1", proceed),
2938
+ metadataUrl: (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1
2251
2939
  });
2252
2940
  installErrorHandling(app, log);
2253
- 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
+ };
2254
2945
  }
2255
2946
  //#endregion
2256
2947
  //#region src/mcp/annotations.ts
@@ -2433,12 +3124,15 @@ const encoder = new TextEncoder();
2433
3124
  * has already run when its answer turns out too large, so it says to look at the state it left.
2434
3125
  */
2435
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.",
2436
3128
  create_draft: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
2437
3129
  describe_site: "The description of the site does not fit in one answer. Document fewer collections, components and contracts, or shorten their descriptions.",
2438
3130
  get_entry: "Ask for one format at a time with formats: markdown, document or html, instead of several together.",
2439
3131
  get_translations: "The translations do not fit in one answer. Read each one with get_entry instead.",
2440
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.",
2441
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.",
2442
3136
  unpublish: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
2443
3137
  update_draft: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
2444
3138
  validate: "The verdict lists more problems than fit in one answer. Fix the first ones and validate again."
@@ -2593,14 +3287,16 @@ const TOOLS_LIST_CACHE_HINT = {
2593
3287
  cacheScope: "public"
2594
3288
  };
2595
3289
  /**
2596
- * What the SDK is told about a principal: its scopes, which are the effective ones, and the id of
2597
- * its token as the client. It carries no token: the gateway checked it, and nothing here needs it.
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.
2598
3293
  */
2599
- function authInfoOf(principal) {
3294
+ function authInfoOf(principal, resourceMetadataUrl) {
2600
3295
  return {
2601
3296
  token: "",
2602
- clientId: principal.kind === "token" ? principal.tokenId : principal.kind,
2603
- 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 }
2604
3300
  };
2605
3301
  }
2606
3302
  /**
@@ -2726,7 +3422,7 @@ function createMcpEndpoint(options) {
2726
3422
  }) });
2727
3423
  return { async fetch(request, call) {
2728
3424
  if (!call?.principal) throw new Error("The MCP endpoint serves a request only as the principal the gateway found");
2729
- const authInfo = authInfoOf(call.principal);
3425
+ const authInfo = authInfoOf(call.principal, call.resourceMetadataUrl ?? null);
2730
3426
  calls.set(authInfo, call);
2731
3427
  return handler.fetch(request, { authInfo });
2732
3428
  } };
@@ -2890,6 +3586,30 @@ const SCHEDULER_SCOPES = [
2890
3586
  "content:publish"
2891
3587
  ];
2892
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
+ /**
2893
3613
  * Publishes the scheduled entries whose time has come (ADR-025), one by one, through the same
2894
3614
  * handler as `publish`, in the name of who scheduled each one and with the warnings acknowledged
2895
3615
  * then. An entry that cannot be published (it changed after it was scheduled, or the contracts now
@@ -2910,13 +3630,7 @@ async function runDueSchedules(input) {
2910
3630
  group: []
2911
3631
  }, {
2912
3632
  site,
2913
- principal: {
2914
- kind: "token",
2915
- userId: schedule.actor.userId,
2916
- tokenId: schedule.actor.tokenId ?? "",
2917
- site: site.key,
2918
- scopes: SCHEDULER_SCOPES
2919
- },
3633
+ principal: principalOf(schedule.actor, site.key),
2920
3634
  view: "head",
2921
3635
  store,
2922
3636
  clock,
@@ -2974,7 +3688,8 @@ function writeLog(entry) {
2974
3688
  * included, is one JSON line written with `console.log`.
2975
3689
  */
2976
3690
  function createHollwareWith(config, handlers, options = {}) {
2977
- const [site] = resolveConfig(config).sites.values();
3691
+ const resolved = resolveConfig(config);
3692
+ const [site] = resolved.sites.values();
2978
3693
  if (site === void 0) throw new Error("The resolved config has no site, which resolveConfig never lets through");
2979
3694
  const clock = options.clock ?? systemClock;
2980
3695
  const mcp = createMcpEndpoint({
@@ -3003,7 +3718,9 @@ function createHollwareWith(config, handlers, options = {}) {
3003
3718
  handlers,
3004
3719
  clock,
3005
3720
  log: writeLog
3006
- }, env)
3721
+ }, env),
3722
+ auth: resolved.auth,
3723
+ handlers
3007
3724
  }),
3008
3725
  scheduled: async (_controller, env, ctx) => {
3009
3726
  const deps = deliveryDeps(env);
@@ -3055,23 +3772,49 @@ function unexpectedAnswer(context, call, answer) {
3055
3772
  //#endregion
3056
3773
  //#region src/ops/actor.ts
3057
3774
  /**
3058
- * Who a write is made by: the token of the call, with what its client said about itself. Only a
3059
- * personal token writes (ADR-019). The adapters check the scopes of the operation before any
3060
- * handler runs and never route a write for another principal, so reaching one with a principal
3061
- * that is not a token is a bug, answered as `internal` before anything is read or written.
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.
3062
3781
  */
3063
3782
  function actorOf(context) {
3064
3783
  const { principal } = context;
3065
- if (principal.kind !== "token") throw internalError(context, `A write was routed for a principal of kind ${principal.kind}; only a token writes.`);
3066
- return {
3784
+ if (principal.kind === "token") return {
3067
3785
  kind: "token",
3068
3786
  userId: principal.userId,
3069
3787
  tokenId: principal.tokenId,
3070
3788
  meta: context.client
3071
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.`);
3072
3800
  }
3073
3801
  //#endregion
3074
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
+ }
3075
3818
  /**
3076
3819
  * The summary of an entry, from a listing (`EntrySummaryView`) or from a read of one entry
3077
3820
  * (`EntryView`, which has all of the summary's fields and more). `hasUnpublishedChanges` says
@@ -3341,8 +4084,8 @@ const describeSite$1 = async (_input, context) => ({
3341
4084
  //#endregion
3342
4085
  //#region src/ops/get-entry.ts
3343
4086
  /**
3344
- * Reads an entry in the view of the call, `head` over MCP and `published` over RPC, with only the
3345
- * formats that were asked for.
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.
3346
4089
  */
3347
4090
  const getEntry$1 = async (input, context) => {
3348
4091
  requireCollection(context.site, input.collection);
@@ -3351,7 +4094,7 @@ const getEntry$1 = async (input, context) => {
3351
4094
  collection: input.collection,
3352
4095
  locale: input.locale,
3353
4096
  slug: input.slug
3354
- }, context.view);
4097
+ }, input.published ? "published" : context.view);
3355
4098
  if (found === null) return { outcome: "not_found" };
3356
4099
  const { entry, revision } = found;
3357
4100
  const publication = await context.store.lastPublication(context.site.key, entry.id);
@@ -3439,8 +4182,14 @@ function warningKey(warning) {
3439
4182
  * changed since the draft was saved: an error blocks, and a warning has to be acknowledged, by
3440
4183
  * contract and path, before the publication is made. The publication records every warning of its
3441
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.
3442
4189
  */
3443
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.");
3444
4193
  if (input.group.length > 0) return publishGroup(input, context);
3445
4194
  const actor = actorOf(context);
3446
4195
  const found = await context.store.findEntryById(context.site.key, input.id, "head");
@@ -3478,6 +4227,28 @@ const publish$1 = async (input, context) => {
3478
4227
  contract,
3479
4228
  path
3480
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
+ }
3481
4252
  if (input.publishAt !== void 0 && Date.parse(input.publishAt) > context.clock.now().getTime()) {
3482
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.");
3483
4254
  const scheduled = await context.store.schedule(context.site.key, {
@@ -3637,15 +4408,13 @@ async function publishGroup(input, context) {
3637
4408
  }
3638
4409
  const [main] = checked;
3639
4410
  if (main === void 0) throw unexpectedAnswer(context, "publish", { outcome: "empty group" });
3640
- const warnings = new Map(checked.flatMap(({ verdict }) => verdict.warnings).map((warning) => [warningKey(warning), warning]));
3641
4411
  const acknowledged = new Set(input.acknowledgeWarnings.map(warningKey));
3642
- if ([...warnings.keys()].some((key) => !acknowledged.has(key))) return {
4412
+ const pending = checked.find(({ verdict }) => verdict.warnings.some((warning) => !acknowledged.has(warningKey(warning))));
4413
+ if (pending !== void 0) return {
3643
4414
  outcome: "needs_acknowledgement",
3644
- entry: main.summary,
3645
- verdict: verdictView({
3646
- ...main.verdict,
3647
- warnings: [...warnings.values()]
3648
- })
4415
+ entry: pending.summary,
4416
+ verdict: verdictView(pending.verdict),
4417
+ failedEntry: pending.write.entryId
3649
4418
  };
3650
4419
  const written = await context.store.publishGroup(context.site.key, checked.map(({ write }) => write));
3651
4420
  switch (written.outcome) {
@@ -3681,6 +4450,91 @@ async function publishGroup(input, context) {
3681
4450
  }
3682
4451
  }
3683
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
+ };
4537
+ //#endregion
3684
4538
  //#region src/ops/unpublish.ts
3685
4539
  /**
3686
4540
  * Takes an entry off the public site and keeps it, with its revisions, as a draft. The store
@@ -3819,17 +4673,20 @@ function defineHandlers(handlers) {
3819
4673
  //#endregion
3820
4674
  //#region src/ops/index.ts
3821
4675
  /**
3822
- * 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
3823
4677
  * (ADR-017). Adapters check the scopes and build the `OperationContext`; a handler never sees a
3824
4678
  * request, and reads and writes only the site of its context.
3825
4679
  */
3826
4680
  const handlers = defineHandlers({
4681
+ approve_review: approveReview,
3827
4682
  create_draft: createDraft,
3828
4683
  describe_site: describeSite$1,
3829
4684
  get_entry: getEntry$1,
3830
4685
  get_translations: getTranslations$1,
3831
4686
  list_entries: listEntries$1,
4687
+ list_reviews: listReviews,
3832
4688
  publish: publish$1,
4689
+ request_changes: requestChanges,
3833
4690
  unpublish,
3834
4691
  update_draft: updateDraft,
3835
4692
  validate