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