@hollware/server 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ceremonies-D7tsWU3S.mjs +157 -0
- package/dist/index.d.mts +70 -3
- package/dist/index.mjs +1388 -531
- package/dist/store-Sq0pwUQv.mjs +770 -0
- package/migrations/0004_identity.sql +90 -0
- package/migrations/0005_reviews.sql +27 -0
- package/migrations/meta/0004_snapshot.json +1977 -0
- package/migrations/meta/0005_snapshot.json +2188 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +4 -2
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, inArray, isNull, lte, 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,25 +37,27 @@ function authErrorResponse(status, body, challenge) {
|
|
|
56
37
|
function unauthorized(reason) {
|
|
57
38
|
if (reason === void 0) return authErrorResponse(401, {
|
|
58
39
|
code: "unauthorized",
|
|
59
|
-
message: "The request carries no
|
|
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
|
}
|
|
68
49
|
/**
|
|
69
50
|
* 403 for a token that authenticated but lacks scopes. It names every scope that is missing, in
|
|
70
|
-
* the body and in the challenge (RFC 6750), so that a client asks for all of them at once
|
|
51
|
+
* the body and in the challenge (RFC 6750), so that a client asks for all of them at once, and,
|
|
52
|
+
* when there is one, the protected resource metadata where it learns how (RFC 9728).
|
|
71
53
|
*/
|
|
72
|
-
function insufficientScope(missing) {
|
|
54
|
+
function insufficientScope(missing, resourceMetadataUrl) {
|
|
55
|
+
const metadata = resourceMetadataUrl === void 0 ? "" : `, resource_metadata="${resourceMetadataUrl}"`;
|
|
73
56
|
return authErrorResponse(403, {
|
|
74
57
|
code: "insufficient_scope",
|
|
75
58
|
message: `The token lacks a scope this operation requires. Missing: ${missing.join(", ")}.`,
|
|
76
|
-
fix: "Send a token that carries every missing scope
|
|
77
|
-
}, `${REALM}, error="insufficient_scope", scope="${missing.join(" ")}"`);
|
|
59
|
+
fix: "Send a token that carries every missing scope: create one with `hollware token create`, or authorize the client again for them. A token only gets the scopes its user's role allows."
|
|
60
|
+
}, `${REALM}${metadata}, error="insufficient_scope", scope="${missing.join(" ")}"`);
|
|
78
61
|
}
|
|
79
62
|
//#endregion
|
|
80
63
|
//#region src/auth/guard.ts
|
|
@@ -143,60 +126,28 @@ function checkHostAndOrigin(request, publicUrl) {
|
|
|
143
126
|
return null;
|
|
144
127
|
}
|
|
145
128
|
//#endregion
|
|
146
|
-
//#region src/auth/
|
|
129
|
+
//#region src/auth/scopes.ts
|
|
147
130
|
/**
|
|
148
|
-
* The
|
|
149
|
-
*
|
|
150
|
-
*
|
|
131
|
+
* The scopes an operation requires that the principal lacks, in the order the operation lists
|
|
132
|
+
* them. Empty means the principal may call it; otherwise the answer is `insufficientScope` with
|
|
133
|
+
* all of them, so that a client asks for everything it needs in one go.
|
|
151
134
|
*/
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
"reviewer"
|
|
156
|
-
];
|
|
157
|
-
/** Whether a string is one of the roles. */
|
|
158
|
-
function isRole(value) {
|
|
159
|
-
return ROLES.some((role) => role === value);
|
|
160
|
-
}
|
|
161
|
-
/**
|
|
162
|
-
* The scopes each role allows. What a token may do is its own scopes intersected with these,
|
|
163
|
-
* read from the user's current role on every request (ADR-005, ADR-019). An operation asks for
|
|
164
|
-
* scopes, never for a role.
|
|
165
|
-
*
|
|
166
|
-
* - `owner` may do everything: all four scopes.
|
|
167
|
-
* - `editor` and `reviewer` allow none in v0.1. Nothing creates such a user yet, and which scopes
|
|
168
|
-
* each one gets (an editor writes, a reviewer approves) is for v0.3 to decide, together with
|
|
169
|
-
* the admin that invites them. Until then a token of such a user authenticates, but every
|
|
170
|
-
* operation answers 403 `insufficient_scope`.
|
|
171
|
-
*/
|
|
172
|
-
const ROLE_SCOPES = {
|
|
173
|
-
owner: [
|
|
174
|
-
"content:read",
|
|
175
|
-
"content:write",
|
|
176
|
-
"content:publish",
|
|
177
|
-
"schema:admin"
|
|
178
|
-
],
|
|
179
|
-
editor: [],
|
|
180
|
-
reviewer: []
|
|
181
|
-
};
|
|
182
|
-
/**
|
|
183
|
-
* The scopes a token really has: those it carries that the role of its user allows, in the order
|
|
184
|
-
* of the role. A role that is not one of the roles allows nothing, so a role added to the schema
|
|
185
|
-
* before this table knows it grants no access.
|
|
186
|
-
*/
|
|
187
|
-
function effectiveScopes(role, tokenScopes) {
|
|
188
|
-
if (!isRole(role)) return [];
|
|
189
|
-
return ROLE_SCOPES[role].filter((scope) => tokenScopes.includes(scope));
|
|
135
|
+
function missingScopes(principal, required) {
|
|
136
|
+
const granted = principal.scopes;
|
|
137
|
+
return required.filter((scope) => !granted.includes(scope));
|
|
190
138
|
}
|
|
191
139
|
//#endregion
|
|
192
140
|
//#region src/auth/token.ts
|
|
193
141
|
/** `last_used_at` of a token is written at most once in this time. */
|
|
194
142
|
const TOUCH_INTERVAL_MS = 36e5;
|
|
195
|
-
/**
|
|
143
|
+
/**
|
|
144
|
+
* The token with the role of its user in the token's site, by the hash of the token, in the one
|
|
145
|
+
* site. A user who is no member of the site has no role there, and the token is unknown.
|
|
146
|
+
*/
|
|
196
147
|
const LOOKUP = `SELECT t.id AS token_id, t.site_id AS site_id, t.user_id AS user_id,
|
|
197
148
|
t.scopes AS scopes, t.expires_at AS expires_at, t.revoked_at AS revoked_at,
|
|
198
|
-
t.last_used_at AS last_used_at,
|
|
199
|
-
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
|
|
200
151
|
WHERE t.hash = ?1 AND t.site_id = ?2`;
|
|
201
152
|
/**
|
|
202
153
|
* The scopes a token carries, from the JSON array the `tokens` table stores. Anything that is not
|
|
@@ -408,336 +359,6 @@ function decodeCursor(cursor, view) {
|
|
|
408
359
|
throw invalidCursor();
|
|
409
360
|
}
|
|
410
361
|
//#endregion
|
|
411
|
-
//#region src/db/columns.ts
|
|
412
|
-
/**
|
|
413
|
-
* The columns every table shares. Each helper returns a new builder, because a Drizzle column
|
|
414
|
-
* belongs to one table. Only the repository functions write rows, so these defaults are what
|
|
415
|
-
* every row gets.
|
|
416
|
-
*/
|
|
417
|
-
/** The current time in UTC, as ISO 8601 with milliseconds: `2026-10-12T18:30:00.000Z`. */
|
|
418
|
-
function nowIso() {
|
|
419
|
-
return (/* @__PURE__ */ new Date()).toISOString();
|
|
420
|
-
}
|
|
421
|
-
/** The primary key: a prefixed ULID, such as `ent_01JAB3XQ8ZK4M6N2P5R7S9T0VW`. */
|
|
422
|
-
function id(prefix) {
|
|
423
|
-
return text("id").primaryKey().$defaultFn(() => newId(prefix));
|
|
424
|
-
}
|
|
425
|
-
/**
|
|
426
|
-
* The site a row belongs to: the site's key in `hollware.config.ts`, which is also the id of its
|
|
427
|
-
* row in `sites`. Every table but the deployment-wide ones carries it (ADR-006).
|
|
428
|
-
*/
|
|
429
|
-
function siteId() {
|
|
430
|
-
return text("site_id").notNull();
|
|
431
|
-
}
|
|
432
|
-
/** `created_at` and `updated_at`, in UTC; `updated_at` moves on every update. */
|
|
433
|
-
function timestamps() {
|
|
434
|
-
return {
|
|
435
|
-
createdAt: text("created_at").notNull().$defaultFn(nowIso),
|
|
436
|
-
updatedAt: text("updated_at").notNull().$defaultFn(nowIso).$onUpdateFn(nowIso)
|
|
437
|
-
};
|
|
438
|
-
}
|
|
439
|
-
sqliteTable("_hollware_meta", {
|
|
440
|
-
key: text("key").primaryKey().notNull(),
|
|
441
|
-
value: text("value").notNull()
|
|
442
|
-
});
|
|
443
|
-
/** The sites of the deployment. The id is the site's key in `hollware.config.ts`. */
|
|
444
|
-
const sites = sqliteTable("sites", {
|
|
445
|
-
id: text("id").primaryKey(),
|
|
446
|
-
...timestamps()
|
|
447
|
-
});
|
|
448
|
-
/** People. A user belongs to the deployment; tokens carry the site. */
|
|
449
|
-
const users = sqliteTable("users", {
|
|
450
|
-
id: id("usr"),
|
|
451
|
-
name: text("name"),
|
|
452
|
-
role: text("role").notNull(),
|
|
453
|
-
...timestamps()
|
|
454
|
-
}, (t) => [check("users_role", sql`${t.role} IN ('owner', 'editor', 'reviewer')`)]);
|
|
455
|
-
/** Personal tokens (ADR-019): only the hash is stored. */
|
|
456
|
-
const tokens = sqliteTable("tokens", {
|
|
457
|
-
id: id("tok"),
|
|
458
|
-
siteId: siteId(),
|
|
459
|
-
userId: text("user_id").notNull(),
|
|
460
|
-
label: text("label").notNull(),
|
|
461
|
-
hash: text("hash").notNull(),
|
|
462
|
-
/** A JSON array of scopes. */
|
|
463
|
-
scopes: text("scopes").notNull(),
|
|
464
|
-
expiresAt: text("expires_at"),
|
|
465
|
-
revokedAt: text("revoked_at"),
|
|
466
|
-
lastUsedAt: text("last_used_at"),
|
|
467
|
-
...timestamps()
|
|
468
|
-
}, (t) => [
|
|
469
|
-
unique("tokens_id_site").on(t.id, t.siteId),
|
|
470
|
-
unique("tokens_hash").on(t.hash),
|
|
471
|
-
foreignKey({
|
|
472
|
-
name: "tokens_site",
|
|
473
|
-
columns: [t.siteId],
|
|
474
|
-
foreignColumns: [sites.id]
|
|
475
|
-
}),
|
|
476
|
-
foreignKey({
|
|
477
|
-
name: "tokens_user",
|
|
478
|
-
columns: [t.userId],
|
|
479
|
-
foreignColumns: [users.id]
|
|
480
|
-
}),
|
|
481
|
-
check("tokens_scopes", sql`json_valid(${t.scopes}) AND json_type(${t.scopes}) = 'array'`)
|
|
482
|
-
]);
|
|
483
|
-
/** One entry per site, collection, locale and slug; its content lives in `revisions`. */
|
|
484
|
-
const entries = sqliteTable("entries", {
|
|
485
|
-
id: id("ent"),
|
|
486
|
-
siteId: siteId(),
|
|
487
|
-
collection: text("collection").notNull(),
|
|
488
|
-
locale: text("locale").notNull(),
|
|
489
|
-
slug: text("slug").notNull(),
|
|
490
|
-
translationGroup: text("translation_group"),
|
|
491
|
-
status: text("status").notNull(),
|
|
492
|
-
/** Bumped by every change of the entry: the optimistic write compares it. */
|
|
493
|
-
version: integer("version").notNull(),
|
|
494
|
-
headRev: text("head_rev").notNull(),
|
|
495
|
-
publishedRev: text("published_rev"),
|
|
496
|
-
publishedAt: text("published_at"),
|
|
497
|
-
publishedSortKey: text("published_sort_key"),
|
|
498
|
-
scheduledAt: text("scheduled_at"),
|
|
499
|
-
...timestamps()
|
|
500
|
-
}, (t) => [
|
|
501
|
-
unique("entries_id_site").on(t.id, t.siteId),
|
|
502
|
-
unique("entries_slug").on(t.siteId, t.collection, t.locale, t.slug),
|
|
503
|
-
unique("entries_translation").on(t.siteId, t.collection, t.translationGroup, t.locale),
|
|
504
|
-
index("entries_head").on(t.siteId, t.collection, t.updatedAt, t.id),
|
|
505
|
-
index("entries_head_locale").on(t.siteId, t.collection, t.locale, t.updatedAt, t.id),
|
|
506
|
-
index("entries_published").on(t.siteId, t.collection, t.locale, t.publishedSortKey, t.id).where(sql`${t.status} = 'published'`),
|
|
507
|
-
foreignKey({
|
|
508
|
-
name: "entries_site",
|
|
509
|
-
columns: [t.siteId],
|
|
510
|
-
foreignColumns: [sites.id]
|
|
511
|
-
}),
|
|
512
|
-
check("entries_status", sql`${t.status} IN ('draft', 'scheduled', 'published')`),
|
|
513
|
-
check("entries_version", sql`${t.version} >= 1`),
|
|
514
|
-
check("entries_draft", sql`${t.status} <> 'draft' OR (${t.publishedRev} IS NULL AND ${t.publishedSortKey} IS NULL AND ${t.scheduledAt} IS NULL)`),
|
|
515
|
-
check("entries_published", sql`${t.status} <> 'published' OR (${t.publishedRev} IS NOT NULL AND ${t.publishedAt} IS NOT NULL AND ${t.publishedSortKey} IS NOT NULL)`),
|
|
516
|
-
check("entries_scheduled", sql`${t.status} <> 'scheduled' OR ${t.scheduledAt} IS NOT NULL`)
|
|
517
|
-
]);
|
|
518
|
-
/**
|
|
519
|
-
* Every version of an entry, appended and never changed (ADR-021). `markdown` is the whole file,
|
|
520
|
-
* frontmatter included; the derived columns can be rebuilt by `reparse`.
|
|
521
|
-
*/
|
|
522
|
-
const revisions = sqliteTable("revisions", {
|
|
523
|
-
id: id("rev"),
|
|
524
|
-
siteId: siteId(),
|
|
525
|
-
entryId: text("entry_id").notNull(),
|
|
526
|
-
markdown: text("markdown").notNull(),
|
|
527
|
-
/** Derived: the frontmatter as JSON. */
|
|
528
|
-
frontmatter: text("frontmatter").notNull(),
|
|
529
|
-
/** Derived: the Comark document as JSON. */
|
|
530
|
-
document: text("document").notNull(),
|
|
531
|
-
comarkVersion: text("comark_version").notNull(),
|
|
532
|
-
pipelineVersion: text("pipeline_version").notNull(),
|
|
533
|
-
wordCount: integer("word_count").notNull(),
|
|
534
|
-
/** The verdict of the contracts when the revision was written, as JSON. */
|
|
535
|
-
verdict: text("verdict").notNull(),
|
|
536
|
-
actorKind: text("actor_kind").notNull(),
|
|
537
|
-
actorUserId: text("actor_user_id").notNull(),
|
|
538
|
-
actorTokenId: text("actor_token_id"),
|
|
539
|
-
/** The client and, when it says, the model behind the write, as JSON. */
|
|
540
|
-
actorMeta: text("actor_meta"),
|
|
541
|
-
...timestamps()
|
|
542
|
-
}, (t) => [
|
|
543
|
-
unique("revisions_id_site").on(t.id, t.siteId),
|
|
544
|
-
index("revisions_entry").on(t.entryId, t.createdAt),
|
|
545
|
-
foreignKey({
|
|
546
|
-
name: "revisions_site",
|
|
547
|
-
columns: [t.siteId],
|
|
548
|
-
foreignColumns: [sites.id]
|
|
549
|
-
}),
|
|
550
|
-
foreignKey({
|
|
551
|
-
name: "revisions_entry",
|
|
552
|
-
columns: [t.entryId, t.siteId],
|
|
553
|
-
foreignColumns: [entries.id, entries.siteId]
|
|
554
|
-
}),
|
|
555
|
-
foreignKey({
|
|
556
|
-
name: "revisions_user",
|
|
557
|
-
columns: [t.actorUserId],
|
|
558
|
-
foreignColumns: [users.id]
|
|
559
|
-
}),
|
|
560
|
-
foreignKey({
|
|
561
|
-
name: "revisions_token",
|
|
562
|
-
columns: [t.actorTokenId, t.siteId],
|
|
563
|
-
foreignColumns: [tokens.id, tokens.siteId]
|
|
564
|
-
}),
|
|
565
|
-
check("revisions_actor_kind", sql`${t.actorKind} IN ('token', 'user', 'oauth_client')`),
|
|
566
|
-
check("revisions_json", sql`json_valid(${t.frontmatter}) AND json_valid(${t.document}) AND json_valid(${t.verdict}) AND (${t.actorMeta} IS NULL OR json_valid(${t.actorMeta}))`),
|
|
567
|
-
check("revisions_word_count", sql`${t.wordCount} >= 0`)
|
|
568
|
-
]);
|
|
569
|
-
/** The tags of a revision, for listing published entries by tag. Append-only, like revisions. */
|
|
570
|
-
const revisionTags = sqliteTable("revision_tags", {
|
|
571
|
-
id: id("tag"),
|
|
572
|
-
siteId: siteId(),
|
|
573
|
-
revisionId: text("revision_id").notNull(),
|
|
574
|
-
tag: text("tag").notNull(),
|
|
575
|
-
...timestamps()
|
|
576
|
-
}, (t) => [
|
|
577
|
-
unique("revision_tags_id_site").on(t.id, t.siteId),
|
|
578
|
-
unique("revision_tags_tag").on(t.revisionId, t.tag),
|
|
579
|
-
index("revision_tags_by_tag").on(t.siteId, t.tag),
|
|
580
|
-
foreignKey({
|
|
581
|
-
name: "revision_tags_site",
|
|
582
|
-
columns: [t.siteId],
|
|
583
|
-
foreignColumns: [sites.id]
|
|
584
|
-
}),
|
|
585
|
-
foreignKey({
|
|
586
|
-
name: "revision_tags_revision",
|
|
587
|
-
columns: [t.revisionId, t.siteId],
|
|
588
|
-
foreignColumns: [revisions.id, revisions.siteId]
|
|
589
|
-
})
|
|
590
|
-
]);
|
|
591
|
-
/**
|
|
592
|
-
* Every publish and unpublish, appended: the verdict of the publish phase and the warnings the
|
|
593
|
-
* caller acknowledged (ADR-021).
|
|
594
|
-
*/
|
|
595
|
-
const publications = sqliteTable("publications", {
|
|
596
|
-
id: id("pub"),
|
|
597
|
-
siteId: siteId(),
|
|
598
|
-
entryId: text("entry_id").notNull(),
|
|
599
|
-
action: text("action").notNull(),
|
|
600
|
-
revisionId: text("revision_id"),
|
|
601
|
-
verdict: text("verdict"),
|
|
602
|
-
/** A JSON array: the warnings the caller acknowledged to publish. */
|
|
603
|
-
acknowledged: text("acknowledged").notNull(),
|
|
604
|
-
actorKind: text("actor_kind").notNull(),
|
|
605
|
-
actorUserId: text("actor_user_id").notNull(),
|
|
606
|
-
actorTokenId: text("actor_token_id"),
|
|
607
|
-
actorMeta: text("actor_meta"),
|
|
608
|
-
...timestamps()
|
|
609
|
-
}, (t) => [
|
|
610
|
-
unique("publications_id_site").on(t.id, t.siteId),
|
|
611
|
-
index("publications_entry").on(t.entryId, t.createdAt),
|
|
612
|
-
foreignKey({
|
|
613
|
-
name: "publications_site",
|
|
614
|
-
columns: [t.siteId],
|
|
615
|
-
foreignColumns: [sites.id]
|
|
616
|
-
}),
|
|
617
|
-
foreignKey({
|
|
618
|
-
name: "publications_entry",
|
|
619
|
-
columns: [t.entryId, t.siteId],
|
|
620
|
-
foreignColumns: [entries.id, entries.siteId]
|
|
621
|
-
}),
|
|
622
|
-
foreignKey({
|
|
623
|
-
name: "publications_revision",
|
|
624
|
-
columns: [t.revisionId, t.siteId],
|
|
625
|
-
foreignColumns: [revisions.id, revisions.siteId]
|
|
626
|
-
}),
|
|
627
|
-
foreignKey({
|
|
628
|
-
name: "publications_user",
|
|
629
|
-
columns: [t.actorUserId],
|
|
630
|
-
foreignColumns: [users.id]
|
|
631
|
-
}),
|
|
632
|
-
foreignKey({
|
|
633
|
-
name: "publications_token",
|
|
634
|
-
columns: [t.actorTokenId, t.siteId],
|
|
635
|
-
foreignColumns: [tokens.id, tokens.siteId]
|
|
636
|
-
}),
|
|
637
|
-
check("publications_action", sql`${t.action} IN ('publish', 'unpublish')`),
|
|
638
|
-
check("publications_shape", sql`(${t.action} = 'publish' AND ${t.revisionId} IS NOT NULL AND ${t.verdict} IS NOT NULL) OR (${t.action} = 'unpublish' AND ${t.revisionId} IS NULL AND ${t.verdict} IS NULL)`),
|
|
639
|
-
check("publications_actor_kind", sql`${t.actorKind} IN ('token', 'user', 'oauth_client')`),
|
|
640
|
-
check("publications_json", sql`json_valid(${t.acknowledged}) AND json_type(${t.acknowledged}) = 'array' AND (${t.verdict} IS NULL OR json_valid(${t.verdict})) AND (${t.actorMeta} IS NULL OR json_valid(${t.actorMeta}))`)
|
|
641
|
-
]);
|
|
642
|
-
/**
|
|
643
|
-
* Scheduled publications (ADR-025): one per entry at most. The cron trigger publishes the entry when
|
|
644
|
-
* `scheduled_at` has passed, if it is still at `version`, with the warnings that were acknowledged
|
|
645
|
-
* when it was scheduled, and in the name of who scheduled it.
|
|
646
|
-
*/
|
|
647
|
-
const schedules = sqliteTable("schedules", {
|
|
648
|
-
id: id("sch"),
|
|
649
|
-
siteId: siteId(),
|
|
650
|
-
entryId: text("entry_id").notNull(),
|
|
651
|
-
scheduledAt: text("scheduled_at").notNull(),
|
|
652
|
-
/** The version of the entry once scheduled: a later change cancels the schedule. */
|
|
653
|
-
version: integer("version").notNull(),
|
|
654
|
-
/** A JSON array: the warnings acknowledged when the publication was scheduled. */
|
|
655
|
-
acknowledged: text("acknowledged").notNull(),
|
|
656
|
-
actorKind: text("actor_kind").notNull(),
|
|
657
|
-
actorUserId: text("actor_user_id").notNull(),
|
|
658
|
-
actorTokenId: text("actor_token_id"),
|
|
659
|
-
actorMeta: text("actor_meta"),
|
|
660
|
-
...timestamps()
|
|
661
|
-
}, (t) => [
|
|
662
|
-
unique("schedules_id_site").on(t.id, t.siteId),
|
|
663
|
-
unique("schedules_entry").on(t.siteId, t.entryId),
|
|
664
|
-
index("schedules_due").on(t.scheduledAt),
|
|
665
|
-
foreignKey({
|
|
666
|
-
name: "schedules_site",
|
|
667
|
-
columns: [t.siteId],
|
|
668
|
-
foreignColumns: [sites.id]
|
|
669
|
-
}),
|
|
670
|
-
foreignKey({
|
|
671
|
-
name: "schedules_entry_fk",
|
|
672
|
-
columns: [t.entryId, t.siteId],
|
|
673
|
-
foreignColumns: [entries.id, entries.siteId]
|
|
674
|
-
}),
|
|
675
|
-
foreignKey({
|
|
676
|
-
name: "schedules_user",
|
|
677
|
-
columns: [t.actorUserId],
|
|
678
|
-
foreignColumns: [users.id]
|
|
679
|
-
}),
|
|
680
|
-
foreignKey({
|
|
681
|
-
name: "schedules_token",
|
|
682
|
-
columns: [t.actorTokenId, t.siteId],
|
|
683
|
-
foreignColumns: [tokens.id, tokens.siteId]
|
|
684
|
-
}),
|
|
685
|
-
check("schedules_actor_kind", sql`${t.actorKind} IN ('token', 'user', 'oauth_client')`),
|
|
686
|
-
check("schedules_json", sql`json_valid(${t.acknowledged}) AND json_type(${t.acknowledged}) = 'array' AND (${t.actorMeta} IS NULL OR json_valid(${t.actorMeta}))`)
|
|
687
|
-
]);
|
|
688
|
-
/**
|
|
689
|
-
* The effects of a publication (ADR-025), written in the batch that publishes or unpublishes: one
|
|
690
|
-
* row per event. The Queue consumer delivers each event to every subscriber, and records it in
|
|
691
|
-
* `deliveries`; `sent_at` says that the event was put on the Queue.
|
|
692
|
-
*/
|
|
693
|
-
const outbox = sqliteTable("outbox", {
|
|
694
|
-
id: id("evt"),
|
|
695
|
-
siteId: siteId(),
|
|
696
|
-
/** `entry.published` or `entry.unpublished`. */
|
|
697
|
-
kind: text("kind").notNull(),
|
|
698
|
-
entryId: text("entry_id").notNull(),
|
|
699
|
-
/** A JSON object: what subscribers get. */
|
|
700
|
-
payload: text("payload").notNull(),
|
|
701
|
-
sentAt: text("sent_at"),
|
|
702
|
-
...timestamps()
|
|
703
|
-
}, (t) => [
|
|
704
|
-
unique("outbox_id_site").on(t.id, t.siteId),
|
|
705
|
-
index("outbox_unsent").on(t.sentAt, t.createdAt),
|
|
706
|
-
foreignKey({
|
|
707
|
-
name: "outbox_site",
|
|
708
|
-
columns: [t.siteId],
|
|
709
|
-
foreignColumns: [sites.id]
|
|
710
|
-
}),
|
|
711
|
-
check("outbox_kind", sql`${t.kind} IN ('entry.published', 'entry.unpublished')`),
|
|
712
|
-
check("outbox_json", sql`json_valid(${t.payload}) AND json_type(${t.payload}) = 'object'`)
|
|
713
|
-
]);
|
|
714
|
-
/** Each delivery of an event to a subscriber, so that a retry never delivers it twice. */
|
|
715
|
-
const deliveries = sqliteTable("deliveries", {
|
|
716
|
-
id: id("dlv"),
|
|
717
|
-
siteId: siteId(),
|
|
718
|
-
eventId: text("event_id").notNull(),
|
|
719
|
-
/** The subscriber, by its name in the config, such as `webhook:newsletter` or `indexnow`. */
|
|
720
|
-
subscriber: text("subscriber").notNull(),
|
|
721
|
-
attempts: integer("attempts").notNull(),
|
|
722
|
-
deliveredAt: text("delivered_at"),
|
|
723
|
-
lastError: text("last_error"),
|
|
724
|
-
...timestamps()
|
|
725
|
-
}, (t) => [
|
|
726
|
-
unique("deliveries_id_site").on(t.id, t.siteId),
|
|
727
|
-
unique("deliveries_event_subscriber").on(t.eventId, t.subscriber),
|
|
728
|
-
foreignKey({
|
|
729
|
-
name: "deliveries_site",
|
|
730
|
-
columns: [t.siteId],
|
|
731
|
-
foreignColumns: [sites.id]
|
|
732
|
-
}),
|
|
733
|
-
foreignKey({
|
|
734
|
-
name: "deliveries_event",
|
|
735
|
-
columns: [t.eventId, t.siteId],
|
|
736
|
-
foreignColumns: [outbox.id, outbox.siteId]
|
|
737
|
-
}),
|
|
738
|
-
check("deliveries_attempts", sql`${t.attempts} >= 0`)
|
|
739
|
-
]);
|
|
740
|
-
//#endregion
|
|
741
362
|
//#region src/db/rows.ts
|
|
742
363
|
/**
|
|
743
364
|
* The columns the store reads and how they become the types of `ports.ts`. The JSON columns come
|
|
@@ -1094,6 +715,21 @@ function lookup(db, site) {
|
|
|
1094
715
|
};
|
|
1095
716
|
}
|
|
1096
717
|
//#endregion
|
|
718
|
+
//#region src/db/errors.ts
|
|
719
|
+
/**
|
|
720
|
+
* What D1 says when a statement breaks a unique key: `D1_ERROR: UNIQUE constraint failed:
|
|
721
|
+
* entries.site_id, …: SQLITE_CONSTRAINT`, with the same text again in the `cause`. Inside a batch
|
|
722
|
+
* the error arrives as it is, and the batch has rolled back.
|
|
723
|
+
*/
|
|
724
|
+
function isUniqueViolation(error) {
|
|
725
|
+
let current = error;
|
|
726
|
+
for (let depth = 0; depth < 4 && current instanceof Error; depth += 1) {
|
|
727
|
+
if (current.message.includes("UNIQUE constraint failed")) return true;
|
|
728
|
+
current = current.cause;
|
|
729
|
+
}
|
|
730
|
+
return false;
|
|
731
|
+
}
|
|
732
|
+
//#endregion
|
|
1097
733
|
//#region src/db/statements.ts
|
|
1098
734
|
function actorColumns(actor) {
|
|
1099
735
|
return {
|
|
@@ -1241,7 +877,7 @@ async function dueSchedules(db, site, now) {
|
|
|
1241
877
|
version: row.version,
|
|
1242
878
|
acknowledged: parseAcknowledged(row.acknowledged),
|
|
1243
879
|
actor: {
|
|
1244
|
-
kind:
|
|
880
|
+
kind: actorKindOf(row.actorKind),
|
|
1245
881
|
userId: row.actorUserId,
|
|
1246
882
|
tokenId: row.actorTokenId,
|
|
1247
883
|
meta: row.actorMeta === null ? null : parseMeta(row.actorMeta)
|
|
@@ -1265,24 +901,140 @@ function parseAcknowledged(text) {
|
|
|
1265
901
|
path: item.path
|
|
1266
902
|
}] : []);
|
|
1267
903
|
}
|
|
904
|
+
/** The kind of an actor as the `*_actor_kind` checks allow it. */
|
|
905
|
+
function actorKindOf(value) {
|
|
906
|
+
if (value === "token" || value === "user" || value === "oauth_client") return value;
|
|
907
|
+
throw new Error(`A schedule has the actor kind ${value}, which is no kind`);
|
|
908
|
+
}
|
|
1268
909
|
function parseMeta(text) {
|
|
1269
910
|
const value = JSON.parse(text);
|
|
1270
911
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : null;
|
|
1271
912
|
}
|
|
1272
913
|
//#endregion
|
|
1273
|
-
//#region src/db/
|
|
914
|
+
//#region src/db/reviews.ts
|
|
1274
915
|
/**
|
|
1275
|
-
*
|
|
1276
|
-
*
|
|
1277
|
-
*
|
|
916
|
+
* Reviews (v0.3). A person who may write but not publish asks for the head revision of an entry
|
|
917
|
+
* to be published; a person who may publish approves it, which publishes it, or asks for changes.
|
|
918
|
+
* The partial unique index `reviews_pending` keeps one pending review per entry. A write that
|
|
919
|
+
* moves the entry closes the pending review in its own batch: a new revision cancels it
|
|
920
|
+
* (`cancelPendingReview`), a publication approves it (`approvePendingReview`).
|
|
1278
921
|
*/
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
922
|
+
const STATUSES = [
|
|
923
|
+
"pending",
|
|
924
|
+
"approved",
|
|
925
|
+
"changes_requested",
|
|
926
|
+
"cancelled"
|
|
927
|
+
];
|
|
928
|
+
function statusOf(value) {
|
|
929
|
+
const status = STATUSES.find((known) => known === value);
|
|
930
|
+
if (status === void 0) throw new Error(`A review has the status ${value}, which is no status`);
|
|
931
|
+
return status;
|
|
932
|
+
}
|
|
933
|
+
/** The columns of a review, and the name of who asked for it. */
|
|
934
|
+
const COLUMNS = {
|
|
935
|
+
...getTableColumns(reviews),
|
|
936
|
+
requestedByName: users.name
|
|
937
|
+
};
|
|
938
|
+
function toReview(row) {
|
|
939
|
+
return {
|
|
940
|
+
id: row.id,
|
|
941
|
+
entryId: row.entryId,
|
|
942
|
+
revisionId: row.revisionId,
|
|
943
|
+
status: statusOf(row.status),
|
|
944
|
+
acknowledged: parseAcknowledged(row.acknowledged),
|
|
945
|
+
requestedBy: row.requestedBy,
|
|
946
|
+
requestedByName: row.requestedByName,
|
|
947
|
+
requestedAt: row.createdAt,
|
|
948
|
+
decidedBy: row.decidedBy,
|
|
949
|
+
decidedAt: row.decidedAt,
|
|
950
|
+
note: row.note
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
/** The entry is still at `version`: the condition of every statement a write adds to its batch. */
|
|
954
|
+
function entryAt(site, entryId, version) {
|
|
955
|
+
return sql`exists (select 1 from ${entries} where ${and(eq(entries.id, entryId), eq(entries.siteId, site), eq(entries.version, version))})`;
|
|
956
|
+
}
|
|
957
|
+
/** In the batch of a new revision: the pending review asked for another one, so it is cancelled. */
|
|
958
|
+
function cancelPendingReview(db, site, entryId, version, now) {
|
|
959
|
+
return db.update(reviews).set({
|
|
960
|
+
status: "cancelled",
|
|
961
|
+
updatedAt: now
|
|
962
|
+
}).where(and(eq(reviews.siteId, site), eq(reviews.entryId, entryId), eq(reviews.status, "pending"), entryAt(site, entryId, version)));
|
|
963
|
+
}
|
|
964
|
+
/** In the batch of a publication: what the pending review asked for is published, by `userId`. */
|
|
965
|
+
function approvePendingReview(db, site, entryId, version, userId, now) {
|
|
966
|
+
return db.update(reviews).set({
|
|
967
|
+
status: "approved",
|
|
968
|
+
decidedBy: userId,
|
|
969
|
+
decidedAt: now,
|
|
970
|
+
updatedAt: now
|
|
971
|
+
}).where(and(eq(reviews.siteId, site), eq(reviews.entryId, entryId), eq(reviews.status, "pending"), sql`${reviews.revisionId} = (select ${entries.headRev} from ${entries} where ${and(eq(entries.id, entryId), eq(entries.siteId, site), eq(entries.version, version))})`));
|
|
972
|
+
}
|
|
973
|
+
async function pendingOf(db, site, entryId) {
|
|
974
|
+
const [row] = await db.select(COLUMNS).from(reviews).innerJoin(users, eq(users.id, reviews.requestedBy)).where(and(eq(reviews.siteId, site), eq(reviews.entryId, entryId), eq(reviews.status, "pending"))).limit(1);
|
|
975
|
+
return row === void 0 ? null : toReview(row);
|
|
976
|
+
}
|
|
977
|
+
async function requestReview(db, clock, site, write, whyNothingChanged) {
|
|
978
|
+
const now = clock.now().toISOString();
|
|
979
|
+
const row = {
|
|
980
|
+
id: newId("rvw"),
|
|
981
|
+
siteId: site,
|
|
982
|
+
entryId: write.entryId,
|
|
983
|
+
revisionId: entries.headRev,
|
|
984
|
+
status: "pending",
|
|
985
|
+
acknowledged: JSON.stringify(write.acknowledged),
|
|
986
|
+
requestedBy: write.actor.userId,
|
|
987
|
+
requestedMeta: write.actor.meta === null ? null : JSON.stringify(write.actor.meta),
|
|
988
|
+
decidedBy: null,
|
|
989
|
+
decidedAt: null,
|
|
990
|
+
note: null,
|
|
991
|
+
createdAt: now,
|
|
992
|
+
updatedAt: now
|
|
993
|
+
};
|
|
994
|
+
let inserted;
|
|
995
|
+
try {
|
|
996
|
+
inserted = await db.insert(reviews).select(sql`select ${valuesOf(reviews, row)} from ${entries} where ${and(eq(entries.id, write.entryId), eq(entries.siteId, site), eq(entries.version, write.expectedVersion))}`).returning({ id: reviews.id });
|
|
997
|
+
} catch (error) {
|
|
998
|
+
const pending = isUniqueViolation(error) ? await pendingOf(db, site, write.entryId) : null;
|
|
999
|
+
if (pending === null) throw error;
|
|
1000
|
+
return {
|
|
1001
|
+
outcome: "pending_review",
|
|
1002
|
+
review: pending
|
|
1003
|
+
};
|
|
1284
1004
|
}
|
|
1285
|
-
return
|
|
1005
|
+
if (inserted.length === 0) return whyNothingChanged();
|
|
1006
|
+
const review = await findReview(db, site, row.id);
|
|
1007
|
+
if (review === null) throw new Error(`The review ${row.id} that was just written is not there`);
|
|
1008
|
+
return {
|
|
1009
|
+
outcome: "pending_review",
|
|
1010
|
+
review
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
async function findReview(db, site, id) {
|
|
1014
|
+
const [row] = await db.select(COLUMNS).from(reviews).innerJoin(users, eq(users.id, reviews.requestedBy)).where(and(eq(reviews.siteId, site), eq(reviews.id, id))).limit(1);
|
|
1015
|
+
return row === void 0 ? null : toReview(row);
|
|
1016
|
+
}
|
|
1017
|
+
async function listReviews$1(db, site, query) {
|
|
1018
|
+
return (await db.select(COLUMNS).from(reviews).innerJoin(users, eq(users.id, reviews.requestedBy)).where(and(eq(reviews.siteId, site), eq(reviews.status, query.status))).orderBy(desc(reviews.createdAt), desc(reviews.id)).limit(query.limit)).map(toReview);
|
|
1019
|
+
}
|
|
1020
|
+
async function requestChanges$1(db, clock, site, write) {
|
|
1021
|
+
const now = clock.now().toISOString();
|
|
1022
|
+
const [row] = await db.update(reviews).set({
|
|
1023
|
+
status: "changes_requested",
|
|
1024
|
+
note: write.note,
|
|
1025
|
+
decidedBy: write.userId,
|
|
1026
|
+
decidedAt: now,
|
|
1027
|
+
updatedAt: now
|
|
1028
|
+
}).where(and(eq(reviews.siteId, site), eq(reviews.id, write.id), eq(reviews.status, "pending"))).returning({ id: reviews.id });
|
|
1029
|
+
const review = await findReview(db, site, write.id);
|
|
1030
|
+
if (row !== void 0 && review !== null) return {
|
|
1031
|
+
outcome: "changes_requested",
|
|
1032
|
+
review
|
|
1033
|
+
};
|
|
1034
|
+
return review === null ? { outcome: "not_found" } : {
|
|
1035
|
+
outcome: "not_pending",
|
|
1036
|
+
review
|
|
1037
|
+
};
|
|
1286
1038
|
}
|
|
1287
1039
|
//#endregion
|
|
1288
1040
|
//#region src/db/size.ts
|
|
@@ -1477,6 +1229,7 @@ async function appendRevision(db, clock, site, write) {
|
|
|
1477
1229
|
tags,
|
|
1478
1230
|
now
|
|
1479
1231
|
}),
|
|
1232
|
+
cancelPendingReview(db, site, write.entryId, write.expectedVersion, now),
|
|
1480
1233
|
db.update(entries).set({
|
|
1481
1234
|
version: sql`${entries.version} + 1`,
|
|
1482
1235
|
headRev: revisionId,
|
|
@@ -1521,6 +1274,7 @@ async function publish$2(db, clock, site, write) {
|
|
|
1521
1274
|
insertPublishAtVersion(db, row, write.expectedVersion),
|
|
1522
1275
|
dropScheduleAtVersion(db, site, write.entryId, write.expectedVersion),
|
|
1523
1276
|
insertEventWhere(db, site, write.entryId, write.event ?? null, "entry.published", now, [eq(entries.version, write.expectedVersion)]),
|
|
1277
|
+
approvePendingReview(db, site, write.entryId, write.expectedVersion, write.actor.userId, now),
|
|
1524
1278
|
db.update(entries).set({
|
|
1525
1279
|
status: "published",
|
|
1526
1280
|
scheduledAt: null,
|
|
@@ -1530,7 +1284,7 @@ async function publish$2(db, clock, site, write) {
|
|
|
1530
1284
|
version: sql`${entries.version} + 1`,
|
|
1531
1285
|
updatedAt: now
|
|
1532
1286
|
}).where(and(eq(entries.id, write.entryId), eq(entries.siteId, site), eq(entries.version, write.expectedVersion))).returning(entryFields)
|
|
1533
|
-
]))[
|
|
1287
|
+
]))[4];
|
|
1534
1288
|
if (changed === void 0) return whyNothingChanged(db, site, write.entryId);
|
|
1535
1289
|
const entry = toEntry(changed);
|
|
1536
1290
|
const revision = entry.publishedRevision === null ? null : await findRevision(db, site, entry.publishedRevision);
|
|
@@ -1573,7 +1327,7 @@ async function publishGroup$1(db, clock, site, group) {
|
|
|
1573
1327
|
entryId: write.entryId
|
|
1574
1328
|
};
|
|
1575
1329
|
appended.push(row.id);
|
|
1576
|
-
statements.push(insertPublishAtVersion(db, row, write.expectedVersion), dropScheduleAtVersion(db, site, write.entryId, write.expectedVersion), insertEventWhere(db, site, write.entryId, write.event ?? null, "entry.published", now, [eq(entries.version, write.expectedVersion)]), db.update(entries).set({
|
|
1330
|
+
statements.push(insertPublishAtVersion(db, row, write.expectedVersion), dropScheduleAtVersion(db, site, write.entryId, write.expectedVersion), insertEventWhere(db, site, write.entryId, write.event ?? null, "entry.published", now, [eq(entries.version, write.expectedVersion)]), approvePendingReview(db, site, write.entryId, write.expectedVersion, write.actor.userId, now), db.update(entries).set({
|
|
1577
1331
|
status: "published",
|
|
1578
1332
|
scheduledAt: null,
|
|
1579
1333
|
publishedRev: entries.headRev,
|
|
@@ -1686,7 +1440,11 @@ function createD1Store(db, options = {}) {
|
|
|
1686
1440
|
schedule: (site, write) => schedule(database, clock, site, write),
|
|
1687
1441
|
dueSchedules: (site, now) => dueSchedules(database, site, now),
|
|
1688
1442
|
cancelSchedule: (site, entryId) => cancelSchedule(database, clock, site, entryId),
|
|
1689
|
-
unpublish: (site, write) => unpublish$1(database, clock, site, write)
|
|
1443
|
+
unpublish: (site, write) => unpublish$1(database, clock, site, write),
|
|
1444
|
+
requestReview: (site, write) => requestReview(database, clock, site, write, () => whyNothingChanged(database, site, write.entryId)),
|
|
1445
|
+
findReview: (site, id) => findReview(database, site, id),
|
|
1446
|
+
listReviews: (site, query) => listReviews$1(database, site, query),
|
|
1447
|
+
requestChanges: (site, write) => requestChanges$1(database, clock, site, write)
|
|
1690
1448
|
};
|
|
1691
1449
|
}
|
|
1692
1450
|
//#endregion
|
|
@@ -1983,6 +1741,340 @@ function installErrorHandling(app, log) {
|
|
|
1983
1741
|
}));
|
|
1984
1742
|
}
|
|
1985
1743
|
//#endregion
|
|
1744
|
+
//#region src/oauth/admin.ts
|
|
1745
|
+
/**
|
|
1746
|
+
* The admin as a client of the OAuth server (ADR-026): a public client, with PKCE, that a person
|
|
1747
|
+
* never has to allow. It is registered once per issuer, with `createClient`, and its id is kept in
|
|
1748
|
+
* `OAUTH_KV` beside the library's own records. A Client ID Metadata Document would need an `https`
|
|
1749
|
+
* URL that the Worker fetches from itself, which local development does not have.
|
|
1750
|
+
*/
|
|
1751
|
+
/** Where the admin's id is kept, by issuer. The library's own keys never start with it. */
|
|
1752
|
+
const KEY = "hollware:admin-client:";
|
|
1753
|
+
/**
|
|
1754
|
+
* The admin's client, registered on first use. Two first requests at once may both register one:
|
|
1755
|
+
* the last id written wins, and the other client is never used.
|
|
1756
|
+
*/
|
|
1757
|
+
async function adminClient(oauth, env) {
|
|
1758
|
+
const redirectUri = `${oauth.issuer}/admin/callback`;
|
|
1759
|
+
const stored = await env.OAUTH_KV.get(`${KEY}${oauth.issuer}`);
|
|
1760
|
+
if (stored !== null) return {
|
|
1761
|
+
clientId: stored,
|
|
1762
|
+
redirectUri
|
|
1763
|
+
};
|
|
1764
|
+
const client = await oauth.authorizationServer.getOAuthApi(env).createClient({
|
|
1765
|
+
clientName: "Hollware admin",
|
|
1766
|
+
redirectUris: [redirectUri],
|
|
1767
|
+
tokenEndpointAuthMethod: "none"
|
|
1768
|
+
});
|
|
1769
|
+
await env.OAUTH_KV.put(`${KEY}${oauth.issuer}`, client.clientId);
|
|
1770
|
+
return {
|
|
1771
|
+
clientId: client.clientId,
|
|
1772
|
+
redirectUri
|
|
1773
|
+
};
|
|
1774
|
+
}
|
|
1775
|
+
//#endregion
|
|
1776
|
+
//#region src/http/admin.ts
|
|
1777
|
+
/**
|
|
1778
|
+
* The admin at `/admin/` (ADR-026). Its hashed files are static assets that never reach the
|
|
1779
|
+
* Worker (`run_worker_first` in wrangler.jsonc); its page does, so that it goes out with the
|
|
1780
|
+
* policy below, and so does every path of the single-page app, which all get the same page. The
|
|
1781
|
+
* page learns how to sign in from `/admin/config.json`.
|
|
1782
|
+
*/
|
|
1783
|
+
/**
|
|
1784
|
+
* What the admin's page may do: its own scripts and styles (CodeMirror and Nuxt UI write style
|
|
1785
|
+
* elements and attributes, hence `unsafe-inline` for styles only), images of the content from
|
|
1786
|
+
* anywhere over https, and requests to this origin alone. Nothing frames it.
|
|
1787
|
+
*/
|
|
1788
|
+
const POLICY = [
|
|
1789
|
+
"default-src 'self'",
|
|
1790
|
+
"script-src 'self'",
|
|
1791
|
+
"style-src 'self' 'unsafe-inline'",
|
|
1792
|
+
"img-src 'self' data: https:",
|
|
1793
|
+
"connect-src 'self'",
|
|
1794
|
+
"font-src 'self' data:",
|
|
1795
|
+
"base-uri 'none'",
|
|
1796
|
+
"form-action 'self'",
|
|
1797
|
+
"frame-ancestors 'none'"
|
|
1798
|
+
].join("; ");
|
|
1799
|
+
function notInstalled() {
|
|
1800
|
+
return Response.json({
|
|
1801
|
+
code: "not_found",
|
|
1802
|
+
message: "The admin is not installed in this Worker.",
|
|
1803
|
+
fix: "Bind the static assets as ASSETS and serve the files of @hollware/admin (adminAssets()), as the starter does."
|
|
1804
|
+
}, {
|
|
1805
|
+
status: 404,
|
|
1806
|
+
headers: { "cache-control": "no-store" }
|
|
1807
|
+
});
|
|
1808
|
+
}
|
|
1809
|
+
function installAdmin(app, options) {
|
|
1810
|
+
app.get("/admin/config.json", async (c) => {
|
|
1811
|
+
const oauth = options.oauth(c.env.HOLLWARE_PUBLIC_URL);
|
|
1812
|
+
const client = await adminClient(oauth, c.env);
|
|
1813
|
+
return c.json({
|
|
1814
|
+
issuer: oauth.issuer,
|
|
1815
|
+
clientId: client.clientId,
|
|
1816
|
+
redirectUri: client.redirectUri,
|
|
1817
|
+
resource: oauth.resources.v1,
|
|
1818
|
+
scope: SCOPES.join(" ")
|
|
1819
|
+
}, 200, { "cache-control": "no-store" });
|
|
1820
|
+
});
|
|
1821
|
+
const page = async (c) => {
|
|
1822
|
+
const assets = c.env.ASSETS;
|
|
1823
|
+
if (assets === void 0) return notInstalled();
|
|
1824
|
+
const origin = options.oauth(c.env.HOLLWARE_PUBLIC_URL).issuer;
|
|
1825
|
+
const answer = await assets.fetch(new Request(`${origin}/admin/`));
|
|
1826
|
+
if (!answer.ok) return notInstalled();
|
|
1827
|
+
const headers = new Headers(answer.headers);
|
|
1828
|
+
headers.set("content-security-policy", POLICY);
|
|
1829
|
+
headers.set("cache-control", "no-store");
|
|
1830
|
+
headers.set("x-content-type-options", "nosniff");
|
|
1831
|
+
headers.set("referrer-policy", "no-referrer");
|
|
1832
|
+
return new Response(answer.body, {
|
|
1833
|
+
status: 200,
|
|
1834
|
+
headers
|
|
1835
|
+
});
|
|
1836
|
+
};
|
|
1837
|
+
app.get("/admin", (c) => c.redirect("/admin/", 301));
|
|
1838
|
+
app.get("/admin/", page);
|
|
1839
|
+
app.get("/admin/*", async (c) => {
|
|
1840
|
+
if (/\.[a-z0-9]+$/i.test(c.req.path)) {
|
|
1841
|
+
const assets = c.env.ASSETS;
|
|
1842
|
+
if (assets === void 0) return notInstalled();
|
|
1843
|
+
return assets.fetch(c.req.raw);
|
|
1844
|
+
}
|
|
1845
|
+
return page(c);
|
|
1846
|
+
});
|
|
1847
|
+
}
|
|
1848
|
+
//#endregion
|
|
1849
|
+
//#region src/http/admin-api.ts
|
|
1850
|
+
/**
|
|
1851
|
+
* What only a person in the admin may do (ADR-026): see, make and revoke personal tokens, and
|
|
1852
|
+
* invite people. These are no operations, so no agent sees them over MCP, and a request needs an
|
|
1853
|
+
* access token of the admin's own client with `schema:admin`: a personal token, or a token of
|
|
1854
|
+
* another client, never reaches them, even an owner's. A leaked token cannot mint the next one.
|
|
1855
|
+
*/
|
|
1856
|
+
/** A personal token lasts this long unless the person says otherwise (as `hollware token create`). */
|
|
1857
|
+
const TOKEN_DAYS = 90;
|
|
1858
|
+
const INVITATION_DAYS = 7;
|
|
1859
|
+
function json$1(body, status = 200) {
|
|
1860
|
+
return Response.json(body, {
|
|
1861
|
+
status,
|
|
1862
|
+
headers: { "cache-control": "no-store" }
|
|
1863
|
+
});
|
|
1864
|
+
}
|
|
1865
|
+
function refused(message, fix, status = 400) {
|
|
1866
|
+
return json$1({
|
|
1867
|
+
code: status === 400 ? "invalid_input" : "forbidden",
|
|
1868
|
+
message,
|
|
1869
|
+
fix
|
|
1870
|
+
}, status);
|
|
1871
|
+
}
|
|
1872
|
+
function parseScopes(json) {
|
|
1873
|
+
try {
|
|
1874
|
+
const value = JSON.parse(json);
|
|
1875
|
+
return Array.isArray(value) ? value.filter((scope) => typeof scope === "string" && isScope(scope)) : [];
|
|
1876
|
+
} catch {
|
|
1877
|
+
return [];
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
function daysFrom(value, fallback) {
|
|
1881
|
+
if (value === void 0) return fallback;
|
|
1882
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 365 ? value : null;
|
|
1883
|
+
}
|
|
1884
|
+
function installAdminApi(app, options) {
|
|
1885
|
+
/** Runs `handle` for a person in the admin with `schema:admin`; anyone else is refused. */
|
|
1886
|
+
const admin = (handle) => (c) => options.protect(c, async (principal) => {
|
|
1887
|
+
const ours = (await adminClient(options.oauth(c.env.HOLLWARE_PUBLIC_URL), c.env)).clientId;
|
|
1888
|
+
if (principal.kind !== "oauth" || principal.clientId !== ours) return refused("Only the admin may do this.", "Sign in to the admin at /admin/ and do it there.", 403);
|
|
1889
|
+
if (!principal.scopes.includes("schema:admin")) return insufficientScope(["schema:admin"], options.metadataUrl(c));
|
|
1890
|
+
return handle(c, principal);
|
|
1891
|
+
});
|
|
1892
|
+
const body = async (c) => {
|
|
1893
|
+
const value = await c.req.json().catch(() => null);
|
|
1894
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {};
|
|
1895
|
+
};
|
|
1896
|
+
app.get("/v1/admin/tokens", admin(async (c) => {
|
|
1897
|
+
return json$1({ tokens: (await connect(c.env.DB).select({
|
|
1898
|
+
id: tokens.id,
|
|
1899
|
+
label: tokens.label,
|
|
1900
|
+
scopes: tokens.scopes,
|
|
1901
|
+
user: users.name,
|
|
1902
|
+
expiresAt: tokens.expiresAt,
|
|
1903
|
+
revokedAt: tokens.revokedAt,
|
|
1904
|
+
lastUsedAt: tokens.lastUsedAt,
|
|
1905
|
+
createdAt: tokens.createdAt
|
|
1906
|
+
}).from(tokens).innerJoin(users, eq(users.id, tokens.userId)).where(eq(tokens.siteId, options.site)).orderBy(desc(tokens.createdAt), desc(tokens.id))).map((row) => ({
|
|
1907
|
+
...row,
|
|
1908
|
+
scopes: parseScopes(row.scopes)
|
|
1909
|
+
})) });
|
|
1910
|
+
}));
|
|
1911
|
+
app.post("/v1/admin/tokens", admin(async (c, person) => {
|
|
1912
|
+
const input = await body(c);
|
|
1913
|
+
const label = typeof input["label"] === "string" ? input["label"].trim() : "";
|
|
1914
|
+
if (label === "" || label.length > 100) return refused("A token needs a label of 1 to 100 characters.", "Say what the token is for.");
|
|
1915
|
+
const asked = Array.isArray(input["scopes"]) ? input["scopes"].filter((s) => typeof s === "string") : [];
|
|
1916
|
+
const scopes = person.scopes.filter((scope) => asked.includes(scope));
|
|
1917
|
+
if (scopes.length === 0) return refused("A token needs at least one scope that you have.", `Choose among ${person.scopes.join(", ")}.`);
|
|
1918
|
+
const days = daysFrom(input["expiresInDays"], TOKEN_DAYS);
|
|
1919
|
+
if (days === null) return refused("expiresInDays is a whole number from 1 to 365.", "Leave it out for 90 days.");
|
|
1920
|
+
const now = options.clock.now();
|
|
1921
|
+
const secret = generatePersonalToken();
|
|
1922
|
+
const row = {
|
|
1923
|
+
id: newId("tok"),
|
|
1924
|
+
siteId: options.site,
|
|
1925
|
+
userId: person.userId,
|
|
1926
|
+
label,
|
|
1927
|
+
hash: await hashPersonalToken(secret),
|
|
1928
|
+
scopes: JSON.stringify(scopes),
|
|
1929
|
+
expiresAt: new Date(now.getTime() + days * 864e5).toISOString(),
|
|
1930
|
+
revokedAt: null,
|
|
1931
|
+
lastUsedAt: null,
|
|
1932
|
+
createdAt: now.toISOString(),
|
|
1933
|
+
updatedAt: now.toISOString()
|
|
1934
|
+
};
|
|
1935
|
+
await connect(c.env.DB).insert(tokens).values(row);
|
|
1936
|
+
return json$1({
|
|
1937
|
+
id: row.id,
|
|
1938
|
+
label,
|
|
1939
|
+
scopes,
|
|
1940
|
+
expiresAt: row.expiresAt,
|
|
1941
|
+
secret
|
|
1942
|
+
}, 201);
|
|
1943
|
+
}));
|
|
1944
|
+
app.post("/v1/admin/tokens/:id/revoke", admin(async (c) => {
|
|
1945
|
+
const now = options.clock.now().toISOString();
|
|
1946
|
+
const db = connect(c.env.DB);
|
|
1947
|
+
const id = c.req.param("id") ?? "";
|
|
1948
|
+
const [revoked] = await db.update(tokens).set({
|
|
1949
|
+
revokedAt: now,
|
|
1950
|
+
updatedAt: now
|
|
1951
|
+
}).where(and(eq(tokens.siteId, options.site), eq(tokens.id, id), isNull(tokens.revokedAt))).returning({ id: tokens.id });
|
|
1952
|
+
if (revoked !== void 0) return json$1({
|
|
1953
|
+
id: revoked.id,
|
|
1954
|
+
revokedAt: now
|
|
1955
|
+
});
|
|
1956
|
+
const [row] = await db.select({
|
|
1957
|
+
id: tokens.id,
|
|
1958
|
+
revokedAt: tokens.revokedAt
|
|
1959
|
+
}).from(tokens).where(and(eq(tokens.siteId, options.site), eq(tokens.id, id)));
|
|
1960
|
+
return row === void 0 ? refused("There is no such token.", "List the tokens and pick one of them.", 404) : json$1({
|
|
1961
|
+
id: row.id,
|
|
1962
|
+
revokedAt: row.revokedAt
|
|
1963
|
+
});
|
|
1964
|
+
}));
|
|
1965
|
+
app.get("/v1/admin/people", admin(async (c) => {
|
|
1966
|
+
const db = connect(c.env.DB);
|
|
1967
|
+
const now = options.clock.now().toISOString();
|
|
1968
|
+
return json$1({
|
|
1969
|
+
people: await db.select({
|
|
1970
|
+
id: users.id,
|
|
1971
|
+
name: users.name,
|
|
1972
|
+
role: members.role,
|
|
1973
|
+
since: members.createdAt
|
|
1974
|
+
}).from(members).innerJoin(users, eq(users.id, members.userId)).where(eq(members.siteId, options.site)).orderBy(members.createdAt, users.id),
|
|
1975
|
+
invitations: await db.select({
|
|
1976
|
+
id: invitations.id,
|
|
1977
|
+
role: invitations.role,
|
|
1978
|
+
expiresAt: invitations.expiresAt
|
|
1979
|
+
}).from(invitations).where(and(eq(invitations.siteId, options.site), isNull(invitations.usedAt), isNull(invitations.userId), gt(invitations.expiresAt, now))).orderBy(desc(invitations.createdAt))
|
|
1980
|
+
});
|
|
1981
|
+
}));
|
|
1982
|
+
app.post("/v1/admin/invitations", admin(async (c) => {
|
|
1983
|
+
const input = await body(c);
|
|
1984
|
+
const role = input["role"];
|
|
1985
|
+
if (typeof role !== "string" || !isRole(role) || role === "owner") return refused("An invitation is for an editor or an author.", `Use role "editor" (${ROLE_SCOPES.editor.join(", ")}) or "author" (${ROLE_SCOPES.author.join(", ")}).`);
|
|
1986
|
+
const days = daysFrom(input["days"], INVITATION_DAYS);
|
|
1987
|
+
if (days === null) return refused("days is a whole number from 1 to 365.", "Leave it out for 7 days.");
|
|
1988
|
+
const made = await createInvitation(connect(c.env.DB), {
|
|
1989
|
+
site: options.site,
|
|
1990
|
+
role,
|
|
1991
|
+
userId: null,
|
|
1992
|
+
now: options.clock.now(),
|
|
1993
|
+
days
|
|
1994
|
+
});
|
|
1995
|
+
const origin = options.oauth(c.env.HOLLWARE_PUBLIC_URL).issuer;
|
|
1996
|
+
return json$1({
|
|
1997
|
+
id: made.id,
|
|
1998
|
+
role,
|
|
1999
|
+
expiresAt: made.expiresAt,
|
|
2000
|
+
link: `${origin}/setup?invitation=${made.secret}`
|
|
2001
|
+
}, 201);
|
|
2002
|
+
}));
|
|
2003
|
+
}
|
|
2004
|
+
//#endregion
|
|
2005
|
+
//#region src/http/ops-api.ts
|
|
2006
|
+
const STATUS$1 = {
|
|
2007
|
+
invalid_input: 400,
|
|
2008
|
+
unknown_collection: 404,
|
|
2009
|
+
unknown_locale: 404,
|
|
2010
|
+
timeout: 504
|
|
2011
|
+
};
|
|
2012
|
+
function json(body, status = 200) {
|
|
2013
|
+
return Response.json(body, {
|
|
2014
|
+
status,
|
|
2015
|
+
headers: { "cache-control": "no-store" }
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2018
|
+
/** Calls the handler of an operation with input its spec has parsed. */
|
|
2019
|
+
function invoke$1(handlers, spec, input, context) {
|
|
2020
|
+
const handler = handlers[spec.name];
|
|
2021
|
+
return handler(input, context);
|
|
2022
|
+
}
|
|
2023
|
+
function installOpsApi(app, options) {
|
|
2024
|
+
app.post("/v1/ops/:name", async (c) => options.protect(c, async (principal) => {
|
|
2025
|
+
const requestId = c.get("requestId");
|
|
2026
|
+
const spec = OPERATIONS.find((operation) => operation.name === c.req.param("name"));
|
|
2027
|
+
if (spec === void 0) return json({
|
|
2028
|
+
code: "invalid_input",
|
|
2029
|
+
message: `There is no operation ${c.req.param("name")}.`,
|
|
2030
|
+
fix: "Use the name of an operation, as tools/list of MCP lists them."
|
|
2031
|
+
}, 404);
|
|
2032
|
+
const scopes = spec.scopes;
|
|
2033
|
+
if (missingScopes(principal, scopes).length > 0) return insufficientScope(scopes, options.metadataUrl(c));
|
|
2034
|
+
const body = await c.req.json().catch(() => void 0);
|
|
2035
|
+
const parsed = spec.input.safeParse(body ?? {});
|
|
2036
|
+
if (!parsed.success) {
|
|
2037
|
+
const issues = parsed.error.issues.map((issue) => `${issue.path.join(".") || "input"}: ${issue.message}`);
|
|
2038
|
+
return json({
|
|
2039
|
+
code: "invalid_input",
|
|
2040
|
+
message: `The input of ${spec.name} is not valid: ${issues.join("; ")}`,
|
|
2041
|
+
fix: "Send the input of the operation as its input schema describes it."
|
|
2042
|
+
}, 400);
|
|
2043
|
+
}
|
|
2044
|
+
const context = {
|
|
2045
|
+
site: options.site,
|
|
2046
|
+
principal,
|
|
2047
|
+
view: "head",
|
|
2048
|
+
store: createD1Store(c.env.DB, { clock: options.clock }),
|
|
2049
|
+
clock: options.clock,
|
|
2050
|
+
client: { name: "hollware-admin" },
|
|
2051
|
+
requestId,
|
|
2052
|
+
notify: options.notify(c, requestId)
|
|
2053
|
+
};
|
|
2054
|
+
try {
|
|
2055
|
+
return json(spec.output.parse(await invoke$1(options.handlers, spec, parsed.data, context)));
|
|
2056
|
+
} catch (error) {
|
|
2057
|
+
if (error instanceof HollwareError) return json({
|
|
2058
|
+
code: error.code,
|
|
2059
|
+
message: error.message,
|
|
2060
|
+
fix: error.fix
|
|
2061
|
+
}, STATUS$1[error.code] ?? 500);
|
|
2062
|
+
options.log({
|
|
2063
|
+
kind: "request_error",
|
|
2064
|
+
requestId,
|
|
2065
|
+
method: "POST",
|
|
2066
|
+
path: c.req.path,
|
|
2067
|
+
detail: describeError(error)
|
|
2068
|
+
});
|
|
2069
|
+
return json({
|
|
2070
|
+
code: "internal",
|
|
2071
|
+
message: "The operation failed on the server.",
|
|
2072
|
+
fix: `Try again. If it keeps failing, give the site operator this request id: ${requestId}.`
|
|
2073
|
+
}, 500);
|
|
2074
|
+
}
|
|
2075
|
+
}));
|
|
2076
|
+
}
|
|
2077
|
+
//#endregion
|
|
1986
2078
|
//#region src/http/read-api.ts
|
|
1987
2079
|
const STATUS = {
|
|
1988
2080
|
invalid_input: 400,
|
|
@@ -2019,46 +2111,27 @@ async function answer(c, value) {
|
|
|
2019
2111
|
}
|
|
2020
2112
|
function installReadApi(app, options) {
|
|
2021
2113
|
app.use("/v1/*", async (c, next) => {
|
|
2114
|
+
if (c.req.path.startsWith("/v1/ops/") || c.req.path.startsWith("/v1/admin/")) return next();
|
|
2022
2115
|
if (c.req.method !== "GET" && c.req.method !== "HEAD") return c.json({
|
|
2023
2116
|
code: "invalid_input",
|
|
2024
2117
|
message: "The read API only reads.",
|
|
2025
2118
|
fix: "Use GET."
|
|
2026
2119
|
}, 405, { allow: "GET, HEAD" });
|
|
2027
2120
|
if (c.req.path === "/v1/openapi.json") return next();
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2121
|
+
return options.protect(c, async (principal) => {
|
|
2122
|
+
if (!principal.scopes.includes("content:read")) return insufficientScope(["content:read"], options.metadataUrl(c));
|
|
2123
|
+
try {
|
|
2124
|
+
await next();
|
|
2125
|
+
return c.res;
|
|
2126
|
+
} catch (error) {
|
|
2127
|
+
if (!(error instanceof HollwareError)) throw error;
|
|
2128
|
+
return c.json({
|
|
2129
|
+
code: error.code,
|
|
2130
|
+
message: error.message,
|
|
2131
|
+
fix: error.fix
|
|
2132
|
+
}, STATUS[error.code] ?? 500);
|
|
2133
|
+
}
|
|
2041
2134
|
});
|
|
2042
|
-
if (!check.ok) {
|
|
2043
|
-
options.log({
|
|
2044
|
-
kind: "auth_refused",
|
|
2045
|
-
requestId,
|
|
2046
|
-
reason: check.reason
|
|
2047
|
-
});
|
|
2048
|
-
return unauthorized(check.reason);
|
|
2049
|
-
}
|
|
2050
|
-
if (!check.principal.scopes.includes("content:read")) return insufficientScope(["content:read"]);
|
|
2051
|
-
if (check.touch !== null) c.executionCtx.waitUntil(check.touch);
|
|
2052
|
-
try {
|
|
2053
|
-
return await next();
|
|
2054
|
-
} catch (error) {
|
|
2055
|
-
if (!(error instanceof HollwareError)) throw error;
|
|
2056
|
-
return c.json({
|
|
2057
|
-
code: error.code,
|
|
2058
|
-
message: error.message,
|
|
2059
|
-
fix: error.fix
|
|
2060
|
-
}, STATUS[error.code] ?? 500);
|
|
2061
|
-
}
|
|
2062
2135
|
});
|
|
2063
2136
|
app.get("/v1/openapi.json", (c) => c.json(openApiDocument(options.site)));
|
|
2064
2137
|
app.get("/v1/site", async (c) => answer(c, await options.read(c.env).describeSite(options.site)));
|
|
@@ -2087,7 +2160,7 @@ function openApiDocument(site) {
|
|
|
2087
2160
|
});
|
|
2088
2161
|
const common = {
|
|
2089
2162
|
"304": { description: "Not modified: the ETag of If-None-Match is current." },
|
|
2090
|
-
"401": { description: "No token, or one that is unknown, expired or revoked." },
|
|
2163
|
+
"401": { description: "No token, or one that is unknown, expired or revoked. The challenge names the protected resource metadata." },
|
|
2091
2164
|
"403": { description: "The token lacks content:read." }
|
|
2092
2165
|
};
|
|
2093
2166
|
const param = (name, location, required) => ({
|
|
@@ -2156,8 +2229,556 @@ function openApiDocument(site) {
|
|
|
2156
2229
|
};
|
|
2157
2230
|
}
|
|
2158
2231
|
//#endregion
|
|
2232
|
+
//#region src/identity/pages.ts
|
|
2233
|
+
/**
|
|
2234
|
+
* The pages a person sees to get in (ADR-026): set up a passkey from a link, and sign in. They
|
|
2235
|
+
* are rendered by the Worker, outside the admin's bundle, with one inline script under a nonce
|
|
2236
|
+
* and nothing framing them. The script uses the WebAuthn JSON methods of the browser
|
|
2237
|
+
* (`PublicKeyCredential.parseCreationOptionsFromJSON`, `credential.toJSON()`), and reads the
|
|
2238
|
+
* invitation from the address bar, so the page never prints it.
|
|
2239
|
+
*/
|
|
2240
|
+
const STYLE = `body{font:1rem/1.5 system-ui,sans-serif;max-width:28rem;margin:4rem auto;padding:0 1rem;color:#1d1d1f}
|
|
2241
|
+
label{display:block;margin:.75rem 0 .25rem}input{width:100%;padding:.5rem;font:inherit}
|
|
2242
|
+
button{margin-top:1rem;padding:.6rem 1rem;font:inherit;cursor:pointer}[role=alert]{color:#b00020}`;
|
|
2243
|
+
const POST = `async function post(path, body) {
|
|
2244
|
+
const response = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
2245
|
+
const data = await response.json()
|
|
2246
|
+
if (!response.ok) throw new Error(data.message + ' ' + (data.fix ?? ''))
|
|
2247
|
+
return data
|
|
2248
|
+
}
|
|
2249
|
+
function fail(error) { document.getElementById('error').textContent = error.message }`;
|
|
2250
|
+
function page(title, nonce, body, script) {
|
|
2251
|
+
return `<!doctype html>
|
|
2252
|
+
<html lang="en">
|
|
2253
|
+
<head>
|
|
2254
|
+
<meta charset="utf-8">
|
|
2255
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2256
|
+
<title>${title} · Hollware</title>
|
|
2257
|
+
<style>${STYLE}</style>
|
|
2258
|
+
</head>
|
|
2259
|
+
<body>
|
|
2260
|
+
<main>
|
|
2261
|
+
<h1>${title}</h1>
|
|
2262
|
+
${body}
|
|
2263
|
+
<p id="error" role="alert"></p>
|
|
2264
|
+
</main>
|
|
2265
|
+
<script nonce="${nonce}">${POST}
|
|
2266
|
+
${script}<\/script>
|
|
2267
|
+
</body>
|
|
2268
|
+
</html>
|
|
2269
|
+
`;
|
|
2270
|
+
}
|
|
2271
|
+
function setupPage(nonce) {
|
|
2272
|
+
return page("Set up your passkey", nonce, `<p>A passkey lets you into this Hollware with your device's lock: no password.</p>
|
|
2273
|
+
<form id="form">
|
|
2274
|
+
<label for="name">Your name</label><input id="name" name="name" autocomplete="name">
|
|
2275
|
+
<label for="label">This device</label><input id="label" name="label" value="Passkey">
|
|
2276
|
+
<button type="submit">Create the passkey</button>
|
|
2277
|
+
</form>`, `document.getElementById('form').addEventListener('submit', async (event) => {
|
|
2278
|
+
event.preventDefault()
|
|
2279
|
+
try {
|
|
2280
|
+
const invitation = new URLSearchParams(location.search).get('invitation') ?? ''
|
|
2281
|
+
const { challengeId, options } = await post('/auth/register/options', { invitation })
|
|
2282
|
+
const credential = await navigator.credentials.create({ publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options) })
|
|
2283
|
+
await post('/auth/register/verify', { challengeId, response: credential.toJSON(), name: document.getElementById('name').value, label: document.getElementById('label').value })
|
|
2284
|
+
location.href = '/admin/'
|
|
2285
|
+
} catch (error) { fail(error) }
|
|
2286
|
+
})`);
|
|
2287
|
+
}
|
|
2288
|
+
function signInPage(nonce) {
|
|
2289
|
+
return page("Sign in", nonce, `<p>Sign in to this Hollware with your passkey.</p>
|
|
2290
|
+
<button id="signin" type="button">Sign in with a passkey</button>`, `document.getElementById('signin').addEventListener('click', async () => {
|
|
2291
|
+
try {
|
|
2292
|
+
const { challengeId, options } = await post('/auth/signin/options', {})
|
|
2293
|
+
const credential = await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options) })
|
|
2294
|
+
await post('/auth/signin/verify', { challengeId, response: credential.toJSON() })
|
|
2295
|
+
const target = new URLSearchParams(location.search).get('return_to') ?? '/admin/'
|
|
2296
|
+
location.href = target.startsWith('/') && !target.startsWith('//') ? target : '/admin/'
|
|
2297
|
+
} catch (error) { fail(error) }
|
|
2298
|
+
})`);
|
|
2299
|
+
}
|
|
2300
|
+
/** The headers of a page: no framing, only its own script, and no caching. */
|
|
2301
|
+
function pageHeaders(nonce) {
|
|
2302
|
+
return {
|
|
2303
|
+
"content-type": "text/html; charset=utf-8",
|
|
2304
|
+
"cache-control": "no-store",
|
|
2305
|
+
"content-security-policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; connect-src 'self'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'`,
|
|
2306
|
+
"x-content-type-options": "nosniff",
|
|
2307
|
+
"referrer-policy": "no-referrer"
|
|
2308
|
+
};
|
|
2309
|
+
}
|
|
2310
|
+
/** Text for HTML: every string a client chose (its name, its redirect host, a scope) goes through it. */
|
|
2311
|
+
function escapeHtml(value) {
|
|
2312
|
+
return value.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
|
|
2313
|
+
}
|
|
2314
|
+
/** What a scope lets a client do, in the words of the consent page. */
|
|
2315
|
+
const SCOPE_WORDS = {
|
|
2316
|
+
"content:read": "Read the content, drafts included",
|
|
2317
|
+
"content:write": "Write drafts",
|
|
2318
|
+
"content:publish": "Publish and unpublish",
|
|
2319
|
+
"schema:admin": "Change the site settings"
|
|
2320
|
+
};
|
|
2321
|
+
/**
|
|
2322
|
+
* The consent page of `/authorize` (ADR-026): which client asks, where the tokens go, and what it
|
|
2323
|
+
* may do, with Allow and Deny. It has no script, and it posts the handle that `beginConsent` bound
|
|
2324
|
+
* to this browser. Everything the client chose is escaped.
|
|
2325
|
+
*/
|
|
2326
|
+
function consentPage(view) {
|
|
2327
|
+
const name = escapeHtml(view.clientName);
|
|
2328
|
+
const origin = view.clientDomain === void 0 ? "<p>This app registered itself: its name is not verified.</p>" : `<p>Published by <strong>${escapeHtml(view.clientDomain)}</strong>.</p>`;
|
|
2329
|
+
const loopback = view.redirectIsLoopback ? "<p><strong>This sends access to an app on this computer.</strong> Continue only if you just started to connect it.</p>" : "";
|
|
2330
|
+
const scopes = view.scopes.map((scope) => `<label><input type="checkbox" name="scope" value="${escapeHtml(scope)}" checked> ${escapeHtml(SCOPE_WORDS[scope] ?? scope)} <code>${escapeHtml(scope)}</code></label>`).join("\n");
|
|
2331
|
+
return `<!doctype html>
|
|
2332
|
+
<html lang="en">
|
|
2333
|
+
<head>
|
|
2334
|
+
<meta charset="utf-8">
|
|
2335
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2336
|
+
<title>Allow ${name} · Hollware</title>
|
|
2337
|
+
<style>${STYLE}label input{width:auto;margin-right:.5rem}</style>
|
|
2338
|
+
</head>
|
|
2339
|
+
<body>
|
|
2340
|
+
<main>
|
|
2341
|
+
<h1>Allow ${name} to use this Hollware?</h1>
|
|
2342
|
+
${origin}
|
|
2343
|
+
<p>Signed in as <strong>${escapeHtml(view.person)}</strong>. Access goes to <strong>${escapeHtml(view.redirectHost)}</strong>.</p>
|
|
2344
|
+
${loopback}
|
|
2345
|
+
<form method="post" action="/authorize">
|
|
2346
|
+
<input type="hidden" name="handle" value="${escapeHtml(view.handle)}">
|
|
2347
|
+
${scopes}
|
|
2348
|
+
<p><button name="decision" value="approve">Allow</button> <button name="decision" value="deny">Deny</button></p>
|
|
2349
|
+
</form>
|
|
2350
|
+
</main>
|
|
2351
|
+
</body>
|
|
2352
|
+
</html>
|
|
2353
|
+
`;
|
|
2354
|
+
}
|
|
2355
|
+
/** A page that only says what went wrong and what to do. */
|
|
2356
|
+
function messagePage(title, message) {
|
|
2357
|
+
return `<!doctype html>
|
|
2358
|
+
<html lang="en">
|
|
2359
|
+
<head>
|
|
2360
|
+
<meta charset="utf-8">
|
|
2361
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2362
|
+
<title>${escapeHtml(title)} · Hollware</title>
|
|
2363
|
+
<style>${STYLE}</style>
|
|
2364
|
+
</head>
|
|
2365
|
+
<body>
|
|
2366
|
+
<main>
|
|
2367
|
+
<h1>${escapeHtml(title)}</h1>
|
|
2368
|
+
<p>${escapeHtml(message)}</p>
|
|
2369
|
+
</main>
|
|
2370
|
+
</body>
|
|
2371
|
+
</html>
|
|
2372
|
+
`;
|
|
2373
|
+
}
|
|
2374
|
+
/**
|
|
2375
|
+
* The headers of a page without a script: no framing and no caching. The form of the consent page
|
|
2376
|
+
* posts to `/authorize`, which redirects to the client, so `form-action` is not restricted: a
|
|
2377
|
+
* browser applies it to the redirect too.
|
|
2378
|
+
*/
|
|
2379
|
+
function plainPageHeaders() {
|
|
2380
|
+
return {
|
|
2381
|
+
"content-type": "text/html; charset=utf-8",
|
|
2382
|
+
"cache-control": "no-store",
|
|
2383
|
+
"content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'`,
|
|
2384
|
+
"x-content-type-options": "nosniff",
|
|
2385
|
+
"referrer-policy": "no-referrer"
|
|
2386
|
+
};
|
|
2387
|
+
}
|
|
2388
|
+
//#endregion
|
|
2389
|
+
//#region src/identity/routes.ts
|
|
2390
|
+
/**
|
|
2391
|
+
* The ceremonies, with SimpleWebAuthn and its parsers, in a chunk of their own: only these four
|
|
2392
|
+
* routes use them, and a Worker that serves MCP never pays for them at startup (`budgets.json`).
|
|
2393
|
+
*/
|
|
2394
|
+
const ceremonies = () => import("./ceremonies-D7tsWU3S.mjs");
|
|
2395
|
+
/** The cookie of a signed-in person: only `/authorize` reads it (ADR-026). */
|
|
2396
|
+
const SESSION_COOKIE = "hollware_session";
|
|
2397
|
+
function sessionCookie(secret) {
|
|
2398
|
+
return `${SESSION_COOKIE}=${secret}; HttpOnly; Secure; SameSite=Lax; Path=/authorize; Max-Age=43200`;
|
|
2399
|
+
}
|
|
2400
|
+
function isRecord$1(value) {
|
|
2401
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2402
|
+
}
|
|
2403
|
+
/** The setup and sign-in pages, and the four steps of the passkey ceremonies. */
|
|
2404
|
+
function installIdentity(app, options) {
|
|
2405
|
+
const ceremony = (c) => ({
|
|
2406
|
+
db: connect(c.env.DB),
|
|
2407
|
+
site: options.site,
|
|
2408
|
+
publicUrl: c.env.HOLLWARE_PUBLIC_URL,
|
|
2409
|
+
now: options.clock.now()
|
|
2410
|
+
});
|
|
2411
|
+
const body = async (c) => {
|
|
2412
|
+
const value = await c.req.json().catch(() => null);
|
|
2413
|
+
if (!isRecord$1(value)) throw new HollwareError("invalid_input", "The body is not a JSON object.", "Send a JSON object.");
|
|
2414
|
+
return value;
|
|
2415
|
+
};
|
|
2416
|
+
const text = (value) => typeof value === "string" ? value : "";
|
|
2417
|
+
const handle = (step) => async (c) => {
|
|
2418
|
+
try {
|
|
2419
|
+
return await step(c);
|
|
2420
|
+
} catch (error) {
|
|
2421
|
+
if (!(error instanceof HollwareError)) throw error;
|
|
2422
|
+
return c.json({
|
|
2423
|
+
code: error.code,
|
|
2424
|
+
message: error.message,
|
|
2425
|
+
fix: error.fix
|
|
2426
|
+
}, 400);
|
|
2427
|
+
}
|
|
2428
|
+
};
|
|
2429
|
+
const html = (render) => () => {
|
|
2430
|
+
const nonce = crypto.randomUUID().replace(/-/g, "");
|
|
2431
|
+
return new Response(render(nonce), { headers: pageHeaders(nonce) });
|
|
2432
|
+
};
|
|
2433
|
+
app.get("/setup", html(setupPage));
|
|
2434
|
+
app.get("/signin", html(signInPage));
|
|
2435
|
+
app.post("/auth/register/options", handle(async (c) => c.json(await (await ceremonies()).registrationOptions(ceremony(c), text((await body(c))["invitation"])))));
|
|
2436
|
+
app.post("/auth/register/verify", handle(async (c) => {
|
|
2437
|
+
const input = await body(c);
|
|
2438
|
+
const response = input["response"];
|
|
2439
|
+
if (!isRecord$1(response)) throw new HollwareError("invalid_input", "The body has no passkey.", "Send the credential as JSON.");
|
|
2440
|
+
const answer = await (await ceremonies()).verifyRegistration(ceremony(c), {
|
|
2441
|
+
challengeId: text(input["challengeId"]),
|
|
2442
|
+
response: JSON.parse(JSON.stringify(response)),
|
|
2443
|
+
name: text(input["name"]),
|
|
2444
|
+
label: text(input["label"])
|
|
2445
|
+
});
|
|
2446
|
+
return c.json({ ok: true }, 200, { "set-cookie": sessionCookie(answer.session) });
|
|
2447
|
+
}));
|
|
2448
|
+
app.post("/auth/signin/options", handle(async (c) => c.json(await (await ceremonies()).authenticationOptions(ceremony(c)))));
|
|
2449
|
+
app.post("/auth/signin/verify", handle(async (c) => {
|
|
2450
|
+
const input = await body(c);
|
|
2451
|
+
const response = input["response"];
|
|
2452
|
+
if (!isRecord$1(response)) throw new HollwareError("invalid_input", "The body has no passkey.", "Send the credential as JSON.");
|
|
2453
|
+
const answer = await (await ceremonies()).verifyAuthentication(ceremony(c), {
|
|
2454
|
+
challengeId: text(input["challengeId"]),
|
|
2455
|
+
response: JSON.parse(JSON.stringify(response))
|
|
2456
|
+
});
|
|
2457
|
+
return c.json({ ok: true }, 200, { "set-cookie": sessionCookie(answer.session) });
|
|
2458
|
+
}));
|
|
2459
|
+
}
|
|
2460
|
+
//#endregion
|
|
2461
|
+
//#region src/oauth/authorize.ts
|
|
2462
|
+
/** What a client gets when it asks for no scope: the baseline that `/mcp` and `/v1` name. */
|
|
2463
|
+
const BASELINE = ["content:read"];
|
|
2464
|
+
/**
|
|
2465
|
+
* The scopes a person may grant: those asked for that the person's role allows, in the order of
|
|
2466
|
+
* the role. Anything that is not a scope of Hollware is left out.
|
|
2467
|
+
*/
|
|
2468
|
+
function grantableScopes(role, asked) {
|
|
2469
|
+
if (!isRole(role)) return [];
|
|
2470
|
+
const scopes = asked.filter(isScope);
|
|
2471
|
+
return ROLE_SCOPES[role].filter((scope) => scopes.includes(scope));
|
|
2472
|
+
}
|
|
2473
|
+
function html(body, status = 200, extra) {
|
|
2474
|
+
const headers = new Headers(plainPageHeaders());
|
|
2475
|
+
extra?.forEach((value, name) => {
|
|
2476
|
+
if (name.toLowerCase() === "set-cookie") headers.append(name, value);
|
|
2477
|
+
});
|
|
2478
|
+
return new Response(body, {
|
|
2479
|
+
status,
|
|
2480
|
+
headers
|
|
2481
|
+
});
|
|
2482
|
+
}
|
|
2483
|
+
/**
|
|
2484
|
+
* An error of the authorization endpoint, as the consent page guide says: a redirect back to the
|
|
2485
|
+
* client only when the library validated the client and its exact redirect URI, and a page of our
|
|
2486
|
+
* own otherwise. Anything else is a bug or an outage, and goes on to the error handler.
|
|
2487
|
+
*/
|
|
2488
|
+
function answerError(error) {
|
|
2489
|
+
if (error instanceof AuthorizationError && error.redirectTo !== void 0) return new Response(null, {
|
|
2490
|
+
status: 302,
|
|
2491
|
+
headers: { location: error.redirectTo }
|
|
2492
|
+
});
|
|
2493
|
+
if (error instanceof AuthorizationError) return html(messagePage("This request cannot go on", error.description), 400);
|
|
2494
|
+
if (error instanceof CimdFetchError) return html(messagePage("This app could not be verified", "Its metadata document could not be fetched or is not valid. Try again later, or ask its developer."), 400);
|
|
2495
|
+
throw error;
|
|
2496
|
+
}
|
|
2497
|
+
/**
|
|
2498
|
+
* The authorization endpoint of the OAuth server (ADR-026), which is Hollware's page and not the
|
|
2499
|
+
* library's: the library parses and validates the request, and this page signs the person in,
|
|
2500
|
+
* shows what the client asks for, and completes the authorization with what the person allowed.
|
|
2501
|
+
*
|
|
2502
|
+
* - A person without a session is sent to `/signin`, which comes back here once they are in. The
|
|
2503
|
+
* session cookie is only sent to `/authorize`.
|
|
2504
|
+
* - The admin, Hollware's own client (`admin.ts`), gets what it asks for within the role, with no
|
|
2505
|
+
* consent page.
|
|
2506
|
+
* - The page offers the scopes the client asked for that the person's role allows, each checked;
|
|
2507
|
+
* the person may untick any. A request that would get no scope at all is refused with
|
|
2508
|
+
* `access_denied`.
|
|
2509
|
+
* - The grant stores the person and the site. What its tokens may do is read again on each request
|
|
2510
|
+
* against the person's role then (`server.ts`).
|
|
2511
|
+
*/
|
|
2512
|
+
function installAuthorize(app, options) {
|
|
2513
|
+
const person = async (c) => {
|
|
2514
|
+
const secret = getCookie(c, SESSION_COOKIE);
|
|
2515
|
+
if (secret === void 0) return null;
|
|
2516
|
+
return await findSession(connect(c.env.DB), options.site, secret, options.clock.now());
|
|
2517
|
+
};
|
|
2518
|
+
const signIn = (c) => {
|
|
2519
|
+
const url = new URL(c.req.url);
|
|
2520
|
+
const back = `${url.pathname}${url.search}`;
|
|
2521
|
+
return c.redirect(`/signin?return_to=${encodeURIComponent(back)}`, 302);
|
|
2522
|
+
};
|
|
2523
|
+
app.get("/authorize", async (c) => {
|
|
2524
|
+
const oauth = options.oauth(c.env.HOLLWARE_PUBLIC_URL).authorizationServer.getOAuthApi(c.env);
|
|
2525
|
+
try {
|
|
2526
|
+
const request = await oauth.parseAuthRequest(c.req.raw);
|
|
2527
|
+
const signedIn = await person(c);
|
|
2528
|
+
if (signedIn === null) return signIn(c);
|
|
2529
|
+
const server = options.oauth(c.env.HOLLWARE_PUBLIC_URL);
|
|
2530
|
+
if (request.clientId === (await adminClient(server, c.env)).clientId) {
|
|
2531
|
+
const scope = grantableScopes(signedIn.role, request.scope);
|
|
2532
|
+
const props = {
|
|
2533
|
+
userId: signedIn.userId,
|
|
2534
|
+
site: options.site
|
|
2535
|
+
};
|
|
2536
|
+
const { redirectTo } = await oauth.completeAuthorization({
|
|
2537
|
+
request,
|
|
2538
|
+
userId: signedIn.userId,
|
|
2539
|
+
metadata: { site: options.site },
|
|
2540
|
+
scope,
|
|
2541
|
+
props
|
|
2542
|
+
});
|
|
2543
|
+
return new Response(null, {
|
|
2544
|
+
status: 302,
|
|
2545
|
+
headers: { location: redirectTo }
|
|
2546
|
+
});
|
|
2547
|
+
}
|
|
2548
|
+
const scopes = grantableScopes(signedIn.role, request.scope.length === 0 ? BASELINE : request.scope);
|
|
2549
|
+
if (scopes.length === 0) {
|
|
2550
|
+
const location = authorizationErrorRedirect(request, "access_denied", "Your role allows none of the scopes this client asked for.");
|
|
2551
|
+
return new Response(null, {
|
|
2552
|
+
status: 302,
|
|
2553
|
+
headers: { location }
|
|
2554
|
+
});
|
|
2555
|
+
}
|
|
2556
|
+
const details = await oauth.describeConsent(request);
|
|
2557
|
+
const consent = await oauth.beginConsent(request);
|
|
2558
|
+
return html(consentPage({
|
|
2559
|
+
clientName: details.clientName,
|
|
2560
|
+
clientDomain: details.clientDomain,
|
|
2561
|
+
redirectHost: details.redirectHost,
|
|
2562
|
+
redirectIsLoopback: details.redirectIsLoopback,
|
|
2563
|
+
person: signedIn.name ?? "you",
|
|
2564
|
+
scopes,
|
|
2565
|
+
handle: consent.handle
|
|
2566
|
+
}), 200, consent.headers);
|
|
2567
|
+
} catch (error) {
|
|
2568
|
+
return answerError(error);
|
|
2569
|
+
}
|
|
2570
|
+
});
|
|
2571
|
+
app.post("/authorize", async (c) => {
|
|
2572
|
+
const oauth = options.oauth(c.env.HOLLWARE_PUBLIC_URL).authorizationServer.getOAuthApi(c.env);
|
|
2573
|
+
try {
|
|
2574
|
+
const signedIn = await person(c);
|
|
2575
|
+
if (signedIn === null) return html(messagePage("Your session ended", "Sign in again, then start from the app."), 401);
|
|
2576
|
+
const form = await c.req.formData();
|
|
2577
|
+
const value = form.get("handle");
|
|
2578
|
+
const handle = typeof value === "string" ? value : "";
|
|
2579
|
+
if (form.get("decision") !== "approve") {
|
|
2580
|
+
const denied = await oauth.denyConsent(c.req.raw, handle);
|
|
2581
|
+
return new Response(null, {
|
|
2582
|
+
status: 302,
|
|
2583
|
+
headers: denied.headers
|
|
2584
|
+
});
|
|
2585
|
+
}
|
|
2586
|
+
const scope = grantableScopes(signedIn.role, form.getAll("scope").map(String));
|
|
2587
|
+
const approved = await oauth.approveConsent(c.req.raw, handle, { scope });
|
|
2588
|
+
if (scope.length === 0) {
|
|
2589
|
+
const location = authorizationErrorRedirect(approved.request, "access_denied", "No scope was allowed.");
|
|
2590
|
+
approved.headers.set("location", location);
|
|
2591
|
+
return new Response(null, {
|
|
2592
|
+
status: 302,
|
|
2593
|
+
headers: approved.headers
|
|
2594
|
+
});
|
|
2595
|
+
}
|
|
2596
|
+
const props = {
|
|
2597
|
+
userId: signedIn.userId,
|
|
2598
|
+
site: options.site
|
|
2599
|
+
};
|
|
2600
|
+
const { redirectTo } = await oauth.completeAuthorization({
|
|
2601
|
+
request: approved.request,
|
|
2602
|
+
userId: signedIn.userId,
|
|
2603
|
+
metadata: { site: options.site },
|
|
2604
|
+
scope,
|
|
2605
|
+
props
|
|
2606
|
+
});
|
|
2607
|
+
approved.headers.set("location", redirectTo);
|
|
2608
|
+
return new Response(null, {
|
|
2609
|
+
status: 302,
|
|
2610
|
+
headers: approved.headers
|
|
2611
|
+
});
|
|
2612
|
+
} catch (error) {
|
|
2613
|
+
return answerError(error);
|
|
2614
|
+
}
|
|
2615
|
+
});
|
|
2616
|
+
}
|
|
2617
|
+
//#endregion
|
|
2618
|
+
//#region src/oauth/server.ts
|
|
2619
|
+
/** Access tokens live an hour, and a grant thirty days from its code, refreshed or not (ADR-026). */
|
|
2620
|
+
const ACCESS_TOKEN_TTL_SECONDS = 3600;
|
|
2621
|
+
const REFRESH_TOKEN_TTL_SECONDS = 2592e3;
|
|
2622
|
+
/** The role of a member of the site, read on every request: a change of role takes effect at once. */
|
|
2623
|
+
async function roleOf(db, site, userId) {
|
|
2624
|
+
return (await db.prepare("SELECT role FROM members WHERE site_id = ?1 AND user_id = ?2").bind(site, userId).first())?.role ?? null;
|
|
2625
|
+
}
|
|
2626
|
+
function isGrantProps(value) {
|
|
2627
|
+
return typeof value === "object" && value !== null && typeof Reflect.get(value, "userId") === "string" && typeof Reflect.get(value, "site") === "string";
|
|
2628
|
+
}
|
|
2629
|
+
/**
|
|
2630
|
+
* Builds the OAuth server of a deployment (ADR-026), once for each public URL it is asked about:
|
|
2631
|
+
* the issuer and the resources are that URL's, and the Worker learns it from its environment, on
|
|
2632
|
+
* the first request. One Worker holds both roles: an `OAuthAuthorizationServer`, whose
|
|
2633
|
+
* `/authorize` is Hollware's own page (`authorize.ts`), and an `OAuthResourceServer` for `/mcp`
|
|
2634
|
+
* and for `/v1`.
|
|
2635
|
+
*
|
|
2636
|
+
* A resource accepts two kinds of token, told apart by their shape. A personal token
|
|
2637
|
+
* (`hw_pat_…`) is looked up in D1 as before, and never reaches the OAuth server; any other token
|
|
2638
|
+
* is the OAuth server's to validate, for that resource alone. Either way the principal's scopes
|
|
2639
|
+
* are what the token carries intersected with what the user's role allows now.
|
|
2640
|
+
*/
|
|
2641
|
+
function createOAuth(options) {
|
|
2642
|
+
const built = /* @__PURE__ */ new Map();
|
|
2643
|
+
return (publicUrl) => {
|
|
2644
|
+
const issuer = new URL(publicUrl).origin;
|
|
2645
|
+
const existing = built.get(issuer);
|
|
2646
|
+
if (existing !== void 0) return existing;
|
|
2647
|
+
const oauth = build(issuer, options);
|
|
2648
|
+
built.set(issuer, oauth);
|
|
2649
|
+
return oauth;
|
|
2650
|
+
};
|
|
2651
|
+
}
|
|
2652
|
+
function build(issuer, options) {
|
|
2653
|
+
const resources = {
|
|
2654
|
+
mcp: `${issuer}/mcp`,
|
|
2655
|
+
v1: `${issuer}/v1`
|
|
2656
|
+
};
|
|
2657
|
+
const metadataUrls = {
|
|
2658
|
+
mcp: `${issuer}/.well-known/oauth-protected-resource/mcp`,
|
|
2659
|
+
v1: `${issuer}/.well-known/oauth-protected-resource/v1`
|
|
2660
|
+
};
|
|
2661
|
+
const authorizationServer = new OAuthAuthorizationServer({
|
|
2662
|
+
issuer,
|
|
2663
|
+
resources: [resources.mcp, resources.v1],
|
|
2664
|
+
scopesSupported: [...SCOPES],
|
|
2665
|
+
accessTokenTTL: ACCESS_TOKEN_TTL_SECONDS,
|
|
2666
|
+
refreshTokenTTL: REFRESH_TOKEN_TTL_SECONDS,
|
|
2667
|
+
clientIdMetadataDocumentEnabled: true,
|
|
2668
|
+
...options.dynamicClientRegistration ? { clientRegistrationEndpoint: "/oauth/register" } : {},
|
|
2669
|
+
onError: ({ code, internal }) => {
|
|
2670
|
+
options.log({
|
|
2671
|
+
kind: "oauth_error",
|
|
2672
|
+
code,
|
|
2673
|
+
reason: internal?.reason ?? null
|
|
2674
|
+
});
|
|
2675
|
+
}
|
|
2676
|
+
});
|
|
2677
|
+
/** The protected routes in flight, by their request: the resource server hands it on as it is. */
|
|
2678
|
+
const pending = /* @__PURE__ */ new WeakMap();
|
|
2679
|
+
const validate = (env, request) => async (resource, token) => {
|
|
2680
|
+
try {
|
|
2681
|
+
return await check(env, request, resource, token);
|
|
2682
|
+
} catch (error) {
|
|
2683
|
+
pending.get(request)?.failed(error);
|
|
2684
|
+
throw error;
|
|
2685
|
+
}
|
|
2686
|
+
};
|
|
2687
|
+
const check = async (env, request, resource, token) => {
|
|
2688
|
+
const refuse = (reason) => {
|
|
2689
|
+
pending.get(request)?.refused(reason);
|
|
2690
|
+
return null;
|
|
2691
|
+
};
|
|
2692
|
+
if (isPersonalToken(token)) {
|
|
2693
|
+
const check = await checkPersonalToken(env.DB, token, {
|
|
2694
|
+
site: options.site,
|
|
2695
|
+
now: options.clock.now()
|
|
2696
|
+
});
|
|
2697
|
+
if (!check.ok) return refuse(check.reason);
|
|
2698
|
+
if (check.touch !== null) {
|
|
2699
|
+
const route = pending.get(request);
|
|
2700
|
+
if (route === void 0) await check.touch;
|
|
2701
|
+
else route.defer(check.touch);
|
|
2702
|
+
}
|
|
2703
|
+
return {
|
|
2704
|
+
props: check.principal,
|
|
2705
|
+
audience: resource,
|
|
2706
|
+
scope: [...check.principal.scopes]
|
|
2707
|
+
};
|
|
2708
|
+
}
|
|
2709
|
+
const granted = await authorizationServer.validateToken(resource, token, env);
|
|
2710
|
+
if (granted === null) return refuse("unknown");
|
|
2711
|
+
if (!isGrantProps(granted.props)) return refuse("unknown");
|
|
2712
|
+
if (granted.props.site !== options.site) return refuse("other_site");
|
|
2713
|
+
const role = await roleOf(env.DB, options.site, granted.props.userId);
|
|
2714
|
+
if (role === null) return refuse("no_user");
|
|
2715
|
+
const principal = {
|
|
2716
|
+
kind: "oauth",
|
|
2717
|
+
userId: granted.props.userId,
|
|
2718
|
+
clientId: granted.clientId,
|
|
2719
|
+
site: options.site,
|
|
2720
|
+
scopes: effectiveScopes(role, granted.scope.filter(isScope))
|
|
2721
|
+
};
|
|
2722
|
+
return {
|
|
2723
|
+
props: principal,
|
|
2724
|
+
audience: granted.audience,
|
|
2725
|
+
expiresAt: granted.expiresAt,
|
|
2726
|
+
scope: [...principal.scopes],
|
|
2727
|
+
userId: principal.userId,
|
|
2728
|
+
clientId: principal.clientId
|
|
2729
|
+
};
|
|
2730
|
+
};
|
|
2731
|
+
const resourceServer = (name) => new OAuthResourceServer({
|
|
2732
|
+
resourceMetadata: {
|
|
2733
|
+
resource: resources[name],
|
|
2734
|
+
authorization_servers: [issuer],
|
|
2735
|
+
resource_name: name === "mcp" ? "Hollware MCP" : "Hollware read API"
|
|
2736
|
+
},
|
|
2737
|
+
requiredScopes: ["content:read"],
|
|
2738
|
+
validateToken: validate,
|
|
2739
|
+
handler: { fetch: async (request, _env, ctx) => {
|
|
2740
|
+
const route = pending.get(request);
|
|
2741
|
+
if (route === void 0) throw new Error("A protected route was reached without the gateway in front of it");
|
|
2742
|
+
return await route.proceed(ctx.props, ctx.auth);
|
|
2743
|
+
} }
|
|
2744
|
+
});
|
|
2745
|
+
const servers = {
|
|
2746
|
+
mcp: resourceServer("mcp"),
|
|
2747
|
+
v1: resourceServer("v1")
|
|
2748
|
+
};
|
|
2749
|
+
return {
|
|
2750
|
+
issuer,
|
|
2751
|
+
authorizationServer,
|
|
2752
|
+
resources,
|
|
2753
|
+
metadataUrls,
|
|
2754
|
+
protect: async (resource, request, env, ctx, route) => {
|
|
2755
|
+
pending.set(request, route);
|
|
2756
|
+
try {
|
|
2757
|
+
return await servers[resource].fetch(request, env, ctx);
|
|
2758
|
+
} finally {
|
|
2759
|
+
pending.delete(request);
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2762
|
+
};
|
|
2763
|
+
}
|
|
2764
|
+
//#endregion
|
|
2159
2765
|
//#region src/http/gateway.ts
|
|
2160
2766
|
/**
|
|
2767
|
+
* A 401 of a resource server, with Hollware's body: the same JSON as every other refusal, and the
|
|
2768
|
+
* challenge of the resource server, which names its metadata (RFC 9728) and the baseline scope.
|
|
2769
|
+
*/
|
|
2770
|
+
function withRefusalBody(response, refused) {
|
|
2771
|
+
const ours = unauthorized(refused ? "unknown" : void 0);
|
|
2772
|
+
const headers = new Headers(response.headers);
|
|
2773
|
+
headers.set("content-type", "application/json");
|
|
2774
|
+
const challenge = headers.get("www-authenticate");
|
|
2775
|
+
if (challenge !== null) headers.set("www-authenticate", challenge.replace("realm=\"OAuth\"", "realm=\"hollware\""));
|
|
2776
|
+
return new Response(ours.body, {
|
|
2777
|
+
status: 401,
|
|
2778
|
+
headers
|
|
2779
|
+
});
|
|
2780
|
+
}
|
|
2781
|
+
/**
|
|
2161
2782
|
* The HTTP gateway of the Worker (ADR-019): a Hono 5 app, built once, that runs in this order for
|
|
2162
2783
|
* every request:
|
|
2163
2784
|
*
|
|
@@ -2183,6 +2804,73 @@ function openApiDocument(site) {
|
|
|
2183
2804
|
function createGateway(options) {
|
|
2184
2805
|
const { site, mcp, clock, log } = options;
|
|
2185
2806
|
const app = new Hono();
|
|
2807
|
+
/**
|
|
2808
|
+
* The Worker's own context of each request. Hono hands its routes the same object under a
|
|
2809
|
+
* narrower type of its own, and the OAuth library wants the Worker's.
|
|
2810
|
+
*/
|
|
2811
|
+
const contexts = /* @__PURE__ */ new WeakMap();
|
|
2812
|
+
const workerContext = (c) => {
|
|
2813
|
+
const ctx = contexts.get(c.req.raw);
|
|
2814
|
+
if (ctx === void 0) throw new Error("A request reached a route without its context");
|
|
2815
|
+
return ctx;
|
|
2816
|
+
};
|
|
2817
|
+
const oauth = createOAuth({
|
|
2818
|
+
site: site.key,
|
|
2819
|
+
clock,
|
|
2820
|
+
dynamicClientRegistration: options.auth?.dynamicClientRegistration ?? false,
|
|
2821
|
+
log
|
|
2822
|
+
});
|
|
2823
|
+
/**
|
|
2824
|
+
* What a write hands to the Queue: the events it committed, after the answer (ADR-025). The
|
|
2825
|
+
* cron trigger's sweep catches any that this misses.
|
|
2826
|
+
*/
|
|
2827
|
+
const notify = (c, requestId) => (ids) => {
|
|
2828
|
+
c.executionCtx.waitUntil(enqueue({
|
|
2829
|
+
queue: c.env.EVENTS,
|
|
2830
|
+
db: connect(c.env.DB),
|
|
2831
|
+
site: site.key,
|
|
2832
|
+
ids,
|
|
2833
|
+
clock
|
|
2834
|
+
}).catch((error) => {
|
|
2835
|
+
log({
|
|
2836
|
+
kind: "enqueue_failed",
|
|
2837
|
+
requestId,
|
|
2838
|
+
message: String(error)
|
|
2839
|
+
});
|
|
2840
|
+
}));
|
|
2841
|
+
};
|
|
2842
|
+
/**
|
|
2843
|
+
* A protected route: the resource server of `resource` answers a request without a token and a
|
|
2844
|
+
* token it refuses (401, logged with the reason), and `proceed` gets the principal of the rest.
|
|
2845
|
+
*/
|
|
2846
|
+
const protect = async (c, resource, proceed) => {
|
|
2847
|
+
const requestId = c.get("requestId");
|
|
2848
|
+
/** What the check said besides the answer: why it refused, or what it threw. */
|
|
2849
|
+
const outcome = {
|
|
2850
|
+
refusal: null,
|
|
2851
|
+
failure: null
|
|
2852
|
+
};
|
|
2853
|
+
const response = await oauth(c.env.HOLLWARE_PUBLIC_URL).protect(resource, c.req.raw, c.env, workerContext(c), {
|
|
2854
|
+
proceed,
|
|
2855
|
+
refused: (reason) => {
|
|
2856
|
+
outcome.refusal = reason;
|
|
2857
|
+
},
|
|
2858
|
+
defer: (promise) => {
|
|
2859
|
+
workerContext(c).waitUntil(promise);
|
|
2860
|
+
},
|
|
2861
|
+
failed: (error) => {
|
|
2862
|
+
outcome.failure = { error };
|
|
2863
|
+
}
|
|
2864
|
+
});
|
|
2865
|
+
if (outcome.failure !== null) throw outcome.failure.error;
|
|
2866
|
+
if (response.status !== 401) return response;
|
|
2867
|
+
log({
|
|
2868
|
+
kind: "auth_refused",
|
|
2869
|
+
requestId,
|
|
2870
|
+
reason: outcome.refusal ?? "no_token"
|
|
2871
|
+
});
|
|
2872
|
+
return withRefusalBody(response, outcome.refusal !== null);
|
|
2873
|
+
};
|
|
2186
2874
|
app.use("*", async (c, next) => {
|
|
2187
2875
|
c.set("requestId", crypto.randomUUID());
|
|
2188
2876
|
await next();
|
|
@@ -2193,64 +2881,67 @@ function createGateway(options) {
|
|
|
2193
2881
|
await next();
|
|
2194
2882
|
});
|
|
2195
2883
|
app.get("/health", () => Response.json({ ok: true }, { headers: { "cache-control": "no-store" } }));
|
|
2196
|
-
app.all("/mcp", async (c) => {
|
|
2884
|
+
app.all("/mcp", async (c) => protect(c, "mcp", async (principal) => {
|
|
2197
2885
|
const requestId = c.get("requestId");
|
|
2198
|
-
const token = readBearerToken(c.req.raw);
|
|
2199
|
-
if (token === null) {
|
|
2200
|
-
log({
|
|
2201
|
-
kind: "auth_refused",
|
|
2202
|
-
requestId,
|
|
2203
|
-
reason: "no_token"
|
|
2204
|
-
});
|
|
2205
|
-
return unauthorized();
|
|
2206
|
-
}
|
|
2207
|
-
const check = await checkPersonalToken(c.env.DB, token, {
|
|
2208
|
-
site: site.key,
|
|
2209
|
-
now: clock.now()
|
|
2210
|
-
});
|
|
2211
|
-
if (!check.ok) {
|
|
2212
|
-
log({
|
|
2213
|
-
kind: "auth_refused",
|
|
2214
|
-
requestId,
|
|
2215
|
-
reason: check.reason
|
|
2216
|
-
});
|
|
2217
|
-
return unauthorized(check.reason);
|
|
2218
|
-
}
|
|
2219
|
-
if (check.touch !== null) c.executionCtx.waitUntil(check.touch);
|
|
2220
2886
|
const db = c.env.DB;
|
|
2221
2887
|
return await mcp.fetch(c.req.raw, {
|
|
2222
|
-
principal
|
|
2888
|
+
principal,
|
|
2223
2889
|
requestId,
|
|
2890
|
+
resourceMetadataUrl: oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.mcp,
|
|
2224
2891
|
context: {
|
|
2225
2892
|
site,
|
|
2226
2893
|
store: createD1Store(db, { clock }),
|
|
2227
2894
|
clock,
|
|
2228
|
-
notify: (
|
|
2229
|
-
c.executionCtx.waitUntil(enqueue({
|
|
2230
|
-
queue: c.env.EVENTS,
|
|
2231
|
-
db: connect(db),
|
|
2232
|
-
site: site.key,
|
|
2233
|
-
ids,
|
|
2234
|
-
clock
|
|
2235
|
-
}).catch((error) => {
|
|
2236
|
-
log({
|
|
2237
|
-
kind: "enqueue_failed",
|
|
2238
|
-
requestId,
|
|
2239
|
-
message: String(error)
|
|
2240
|
-
});
|
|
2241
|
-
}));
|
|
2242
|
-
}
|
|
2895
|
+
notify: notify(c, requestId)
|
|
2243
2896
|
}
|
|
2244
2897
|
});
|
|
2898
|
+
}));
|
|
2899
|
+
for (const resource of ["mcp", "v1"]) app.all(`/.well-known/oauth-protected-resource/${resource}`, async (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).protect(resource, c.req.raw, c.env, workerContext(c), {
|
|
2900
|
+
proceed: () => Promise.resolve(new Response(null, { status: 404 })),
|
|
2901
|
+
refused: () => void 0,
|
|
2902
|
+
defer: () => void 0,
|
|
2903
|
+
failed: () => void 0
|
|
2904
|
+
}));
|
|
2905
|
+
installAuthorize(app, {
|
|
2906
|
+
site: site.key,
|
|
2907
|
+
clock,
|
|
2908
|
+
oauth
|
|
2909
|
+
});
|
|
2910
|
+
const authorizationServer = (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).authorizationServer.fetch(c.req.raw, c.env, workerContext(c));
|
|
2911
|
+
app.all("/.well-known/oauth-authorization-server", authorizationServer);
|
|
2912
|
+
app.all("/oauth/*", authorizationServer);
|
|
2913
|
+
installIdentity(app, {
|
|
2914
|
+
site: site.key,
|
|
2915
|
+
clock
|
|
2916
|
+
});
|
|
2917
|
+
installAdmin(app, { oauth });
|
|
2918
|
+
installAdminApi(app, {
|
|
2919
|
+
site: site.key,
|
|
2920
|
+
clock,
|
|
2921
|
+
oauth,
|
|
2922
|
+
protect: (c, proceed) => protect(c, "v1", proceed),
|
|
2923
|
+
metadataUrl: (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1
|
|
2924
|
+
});
|
|
2925
|
+
if (options.handlers !== void 0) installOpsApi(app, {
|
|
2926
|
+
site,
|
|
2927
|
+
handlers: options.handlers,
|
|
2928
|
+
clock,
|
|
2929
|
+
log,
|
|
2930
|
+
protect: (c, proceed) => protect(c, "v1", proceed),
|
|
2931
|
+
metadataUrl: (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1,
|
|
2932
|
+
notify
|
|
2245
2933
|
});
|
|
2246
2934
|
if (options.read !== void 0) installReadApi(app, {
|
|
2247
2935
|
read: options.read,
|
|
2248
2936
|
site: site.key,
|
|
2249
|
-
|
|
2250
|
-
|
|
2937
|
+
protect: (c, proceed) => protect(c, "v1", proceed),
|
|
2938
|
+
metadataUrl: (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1
|
|
2251
2939
|
});
|
|
2252
2940
|
installErrorHandling(app, log);
|
|
2253
|
-
return async (request, env, ctx) =>
|
|
2941
|
+
return async (request, env, ctx) => {
|
|
2942
|
+
contexts.set(request, ctx);
|
|
2943
|
+
return await app.fetch(request, env, ctx);
|
|
2944
|
+
};
|
|
2254
2945
|
}
|
|
2255
2946
|
//#endregion
|
|
2256
2947
|
//#region src/mcp/annotations.ts
|
|
@@ -2433,12 +3124,15 @@ const encoder = new TextEncoder();
|
|
|
2433
3124
|
* has already run when its answer turns out too large, so it says to look at the state it left.
|
|
2434
3125
|
*/
|
|
2435
3126
|
const OUTPUT_TOO_LARGE_FIX = {
|
|
3127
|
+
approve_review: "The operation ran and its change may have been applied: read the review with list_reviews before you repeat it.",
|
|
2436
3128
|
create_draft: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
|
|
2437
3129
|
describe_site: "The description of the site does not fit in one answer. Document fewer collections, components and contracts, or shorten their descriptions.",
|
|
2438
3130
|
get_entry: "Ask for one format at a time with formats: markdown, document or html, instead of several together.",
|
|
2439
3131
|
get_translations: "The translations do not fit in one answer. Read each one with get_entry instead.",
|
|
2440
3132
|
list_entries: "Ask for less: pass a smaller limit, or narrow the list with locale, tag or status.",
|
|
3133
|
+
list_reviews: "Ask for less: pass a smaller limit.",
|
|
2441
3134
|
publish: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
|
|
3135
|
+
request_changes: "The operation ran and its change may have been applied: read the review with list_reviews before you repeat it.",
|
|
2442
3136
|
unpublish: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
|
|
2443
3137
|
update_draft: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
|
|
2444
3138
|
validate: "The verdict lists more problems than fit in one answer. Fix the first ones and validate again."
|
|
@@ -2593,14 +3287,16 @@ const TOOLS_LIST_CACHE_HINT = {
|
|
|
2593
3287
|
cacheScope: "public"
|
|
2594
3288
|
};
|
|
2595
3289
|
/**
|
|
2596
|
-
* What the SDK is told about a principal: its scopes, which are the effective ones,
|
|
2597
|
-
*
|
|
3290
|
+
* What the SDK is told about a principal: its scopes, which are the effective ones, the id of its
|
|
3291
|
+
* token or of its OAuth client as the client, and where its protected resource metadata is. It
|
|
3292
|
+
* carries no token: the gateway checked it, and nothing here needs it.
|
|
2598
3293
|
*/
|
|
2599
|
-
function authInfoOf(principal) {
|
|
3294
|
+
function authInfoOf(principal, resourceMetadataUrl) {
|
|
2600
3295
|
return {
|
|
2601
3296
|
token: "",
|
|
2602
|
-
clientId: principal.kind === "token" ? principal.tokenId : principal.kind,
|
|
2603
|
-
scopes: [...principal.scopes]
|
|
3297
|
+
clientId: principal.kind === "token" ? principal.tokenId : principal.kind === "oauth" ? principal.clientId : principal.kind,
|
|
3298
|
+
scopes: [...principal.scopes],
|
|
3299
|
+
...resourceMetadataUrl === null ? {} : { resourceMetadataUrl }
|
|
2604
3300
|
};
|
|
2605
3301
|
}
|
|
2606
3302
|
/**
|
|
@@ -2726,7 +3422,7 @@ function createMcpEndpoint(options) {
|
|
|
2726
3422
|
}) });
|
|
2727
3423
|
return { async fetch(request, call) {
|
|
2728
3424
|
if (!call?.principal) throw new Error("The MCP endpoint serves a request only as the principal the gateway found");
|
|
2729
|
-
const authInfo = authInfoOf(call.principal);
|
|
3425
|
+
const authInfo = authInfoOf(call.principal, call.resourceMetadataUrl ?? null);
|
|
2730
3426
|
calls.set(authInfo, call);
|
|
2731
3427
|
return handler.fetch(request, { authInfo });
|
|
2732
3428
|
} };
|
|
@@ -2890,6 +3586,30 @@ const SCHEDULER_SCOPES = [
|
|
|
2890
3586
|
"content:publish"
|
|
2891
3587
|
];
|
|
2892
3588
|
/**
|
|
3589
|
+
* The principal a schedule publishes as: the token, or the OAuth client, that scheduled it, with
|
|
3590
|
+
* the scopes a publication needs. An OAuth client is known by the id its actor kept (ADR-026).
|
|
3591
|
+
*/
|
|
3592
|
+
function principalOf(actor, site) {
|
|
3593
|
+
if (actor.kind === "oauth_client") {
|
|
3594
|
+
const client = actor.meta?.["oauthClient"];
|
|
3595
|
+
return {
|
|
3596
|
+
kind: "oauth",
|
|
3597
|
+
userId: actor.userId,
|
|
3598
|
+
clientId: typeof client === "string" ? client : "unknown",
|
|
3599
|
+
site,
|
|
3600
|
+
scopes: SCHEDULER_SCOPES
|
|
3601
|
+
};
|
|
3602
|
+
}
|
|
3603
|
+
if (actor.tokenId === null) throw new Error(`A schedule of a ${actor.kind} has no token to publish with`);
|
|
3604
|
+
return {
|
|
3605
|
+
kind: "token",
|
|
3606
|
+
userId: actor.userId,
|
|
3607
|
+
tokenId: actor.tokenId,
|
|
3608
|
+
site,
|
|
3609
|
+
scopes: SCHEDULER_SCOPES
|
|
3610
|
+
};
|
|
3611
|
+
}
|
|
3612
|
+
/**
|
|
2893
3613
|
* Publishes the scheduled entries whose time has come (ADR-025), one by one, through the same
|
|
2894
3614
|
* handler as `publish`, in the name of who scheduled each one and with the warnings acknowledged
|
|
2895
3615
|
* then. An entry that cannot be published (it changed after it was scheduled, or the contracts now
|
|
@@ -2910,13 +3630,7 @@ async function runDueSchedules(input) {
|
|
|
2910
3630
|
group: []
|
|
2911
3631
|
}, {
|
|
2912
3632
|
site,
|
|
2913
|
-
principal:
|
|
2914
|
-
kind: "token",
|
|
2915
|
-
userId: schedule.actor.userId,
|
|
2916
|
-
tokenId: schedule.actor.tokenId ?? "",
|
|
2917
|
-
site: site.key,
|
|
2918
|
-
scopes: SCHEDULER_SCOPES
|
|
2919
|
-
},
|
|
3633
|
+
principal: principalOf(schedule.actor, site.key),
|
|
2920
3634
|
view: "head",
|
|
2921
3635
|
store,
|
|
2922
3636
|
clock,
|
|
@@ -2974,7 +3688,8 @@ function writeLog(entry) {
|
|
|
2974
3688
|
* included, is one JSON line written with `console.log`.
|
|
2975
3689
|
*/
|
|
2976
3690
|
function createHollwareWith(config, handlers, options = {}) {
|
|
2977
|
-
const
|
|
3691
|
+
const resolved = resolveConfig(config);
|
|
3692
|
+
const [site] = resolved.sites.values();
|
|
2978
3693
|
if (site === void 0) throw new Error("The resolved config has no site, which resolveConfig never lets through");
|
|
2979
3694
|
const clock = options.clock ?? systemClock;
|
|
2980
3695
|
const mcp = createMcpEndpoint({
|
|
@@ -3003,7 +3718,9 @@ function createHollwareWith(config, handlers, options = {}) {
|
|
|
3003
3718
|
handlers,
|
|
3004
3719
|
clock,
|
|
3005
3720
|
log: writeLog
|
|
3006
|
-
}, env)
|
|
3721
|
+
}, env),
|
|
3722
|
+
auth: resolved.auth,
|
|
3723
|
+
handlers
|
|
3007
3724
|
}),
|
|
3008
3725
|
scheduled: async (_controller, env, ctx) => {
|
|
3009
3726
|
const deps = deliveryDeps(env);
|
|
@@ -3055,23 +3772,49 @@ function unexpectedAnswer(context, call, answer) {
|
|
|
3055
3772
|
//#endregion
|
|
3056
3773
|
//#region src/ops/actor.ts
|
|
3057
3774
|
/**
|
|
3058
|
-
* Who a write is made by: the token of the call, with what its client said
|
|
3059
|
-
* personal token writes (ADR-019)
|
|
3060
|
-
*
|
|
3061
|
-
*
|
|
3775
|
+
* Who a write is made by: the person behind the token of the call, with what its client said
|
|
3776
|
+
* about itself. A personal token writes as itself (ADR-019); an OAuth access token writes as the
|
|
3777
|
+
* client the person allowed, whose id goes with what the client said (ADR-026). The adapters check
|
|
3778
|
+
* the scopes of the operation before any handler runs and never route a write for the RPC
|
|
3779
|
+
* binding, so reaching one with it is a bug, answered as `internal` before anything is read or
|
|
3780
|
+
* written.
|
|
3062
3781
|
*/
|
|
3063
3782
|
function actorOf(context) {
|
|
3064
3783
|
const { principal } = context;
|
|
3065
|
-
if (principal.kind
|
|
3066
|
-
return {
|
|
3784
|
+
if (principal.kind === "token") return {
|
|
3067
3785
|
kind: "token",
|
|
3068
3786
|
userId: principal.userId,
|
|
3069
3787
|
tokenId: principal.tokenId,
|
|
3070
3788
|
meta: context.client
|
|
3071
3789
|
};
|
|
3790
|
+
if (principal.kind === "oauth") return {
|
|
3791
|
+
kind: "oauth_client",
|
|
3792
|
+
userId: principal.userId,
|
|
3793
|
+
tokenId: null,
|
|
3794
|
+
meta: {
|
|
3795
|
+
...context.client,
|
|
3796
|
+
oauthClient: principal.clientId
|
|
3797
|
+
}
|
|
3798
|
+
};
|
|
3799
|
+
throw internalError(context, `A write was routed for a principal of kind ${principal.kind}; only a person writes.`);
|
|
3072
3800
|
}
|
|
3073
3801
|
//#endregion
|
|
3074
3802
|
//#region src/ops/answers.ts
|
|
3803
|
+
/** A review as an output. */
|
|
3804
|
+
function reviewView(review) {
|
|
3805
|
+
return {
|
|
3806
|
+
id: review.id,
|
|
3807
|
+
entryId: review.entryId,
|
|
3808
|
+
revision: review.revisionId,
|
|
3809
|
+
status: review.status,
|
|
3810
|
+
requestedBy: review.requestedBy,
|
|
3811
|
+
requestedByName: review.requestedByName,
|
|
3812
|
+
requestedAt: review.requestedAt,
|
|
3813
|
+
decidedBy: review.decidedBy,
|
|
3814
|
+
decidedAt: review.decidedAt,
|
|
3815
|
+
note: review.note
|
|
3816
|
+
};
|
|
3817
|
+
}
|
|
3075
3818
|
/**
|
|
3076
3819
|
* The summary of an entry, from a listing (`EntrySummaryView`) or from a read of one entry
|
|
3077
3820
|
* (`EntryView`, which has all of the summary's fields and more). `hasUnpublishedChanges` says
|
|
@@ -3341,8 +4084,8 @@ const describeSite$1 = async (_input, context) => ({
|
|
|
3341
4084
|
//#endregion
|
|
3342
4085
|
//#region src/ops/get-entry.ts
|
|
3343
4086
|
/**
|
|
3344
|
-
* Reads an entry in the view of the call, `head` over MCP and `published` over RPC,
|
|
3345
|
-
* formats that were asked for.
|
|
4087
|
+
* Reads an entry in the view of the call, `head` over MCP and `published` over RPC, or in the
|
|
4088
|
+
* published view when the input asks for it, with only the formats that were asked for.
|
|
3346
4089
|
*/
|
|
3347
4090
|
const getEntry$1 = async (input, context) => {
|
|
3348
4091
|
requireCollection(context.site, input.collection);
|
|
@@ -3351,7 +4094,7 @@ const getEntry$1 = async (input, context) => {
|
|
|
3351
4094
|
collection: input.collection,
|
|
3352
4095
|
locale: input.locale,
|
|
3353
4096
|
slug: input.slug
|
|
3354
|
-
}, context.view);
|
|
4097
|
+
}, input.published ? "published" : context.view);
|
|
3355
4098
|
if (found === null) return { outcome: "not_found" };
|
|
3356
4099
|
const { entry, revision } = found;
|
|
3357
4100
|
const publication = await context.store.lastPublication(context.site.key, entry.id);
|
|
@@ -3439,8 +4182,14 @@ function warningKey(warning) {
|
|
|
3439
4182
|
* changed since the draft was saved: an error blocks, and a warning has to be acknowledged, by
|
|
3440
4183
|
* contract and path, before the publication is made. The publication records every warning of its
|
|
3441
4184
|
* verdict as acknowledged (ADR-021).
|
|
4185
|
+
*
|
|
4186
|
+
* A caller without `content:publish` goes through the same checks, and then asks for a review
|
|
4187
|
+
* instead of publishing (v0.3): the answer is `pending_review`, and a person who may publish
|
|
4188
|
+
* decides. A review is for one entry, now.
|
|
3442
4189
|
*/
|
|
3443
4190
|
const publish$1 = async (input, context) => {
|
|
4191
|
+
const mayPublish = context.principal.scopes.includes("content:publish");
|
|
4192
|
+
if (!mayPublish && (input.group.length > 0 || input.publishAt !== void 0)) throw new HollwareError("invalid_input", "Without content:publish, publish asks for a review of one entry, now: not of a group, and not at a time.", "Publish each entry on its own, without group and publishAt; a person who may publish approves it.");
|
|
3444
4193
|
if (input.group.length > 0) return publishGroup(input, context);
|
|
3445
4194
|
const actor = actorOf(context);
|
|
3446
4195
|
const found = await context.store.findEntryById(context.site.key, input.id, "head");
|
|
@@ -3478,6 +4227,28 @@ const publish$1 = async (input, context) => {
|
|
|
3478
4227
|
contract,
|
|
3479
4228
|
path
|
|
3480
4229
|
}));
|
|
4230
|
+
if (!mayPublish) {
|
|
4231
|
+
const asked = await context.store.requestReview(context.site.key, {
|
|
4232
|
+
entryId: entry.id,
|
|
4233
|
+
expectedVersion: input.version,
|
|
4234
|
+
acknowledged: acknowledgedWarnings,
|
|
4235
|
+
actor
|
|
4236
|
+
});
|
|
4237
|
+
switch (asked.outcome) {
|
|
4238
|
+
case "pending_review": return {
|
|
4239
|
+
outcome: "pending_review",
|
|
4240
|
+
entry: summary,
|
|
4241
|
+
verdict: verdictView(verdict),
|
|
4242
|
+
review: reviewView(asked.review)
|
|
4243
|
+
};
|
|
4244
|
+
case "conflict": return {
|
|
4245
|
+
outcome: "conflict",
|
|
4246
|
+
conflict: conflictAt(asked.currentVersion, asked.headRevision)
|
|
4247
|
+
};
|
|
4248
|
+
case "not_found": return { outcome: "not_found" };
|
|
4249
|
+
default: throw unexpectedAnswer(context, "publish", asked);
|
|
4250
|
+
}
|
|
4251
|
+
}
|
|
3481
4252
|
if (input.publishAt !== void 0 && Date.parse(input.publishAt) > context.clock.now().getTime()) {
|
|
3482
4253
|
if (entry.status === "published") throw new HollwareError("invalid_input", "The entry is published, so its publication cannot be scheduled.", "Publish its changes now, without publishAt, or unpublish it first and then schedule it.");
|
|
3483
4254
|
const scheduled = await context.store.schedule(context.site.key, {
|
|
@@ -3637,15 +4408,13 @@ async function publishGroup(input, context) {
|
|
|
3637
4408
|
}
|
|
3638
4409
|
const [main] = checked;
|
|
3639
4410
|
if (main === void 0) throw unexpectedAnswer(context, "publish", { outcome: "empty group" });
|
|
3640
|
-
const warnings = new Map(checked.flatMap(({ verdict }) => verdict.warnings).map((warning) => [warningKey(warning), warning]));
|
|
3641
4411
|
const acknowledged = new Set(input.acknowledgeWarnings.map(warningKey));
|
|
3642
|
-
|
|
4412
|
+
const pending = checked.find(({ verdict }) => verdict.warnings.some((warning) => !acknowledged.has(warningKey(warning))));
|
|
4413
|
+
if (pending !== void 0) return {
|
|
3643
4414
|
outcome: "needs_acknowledgement",
|
|
3644
|
-
entry:
|
|
3645
|
-
verdict: verdictView(
|
|
3646
|
-
|
|
3647
|
-
warnings: [...warnings.values()]
|
|
3648
|
-
})
|
|
4415
|
+
entry: pending.summary,
|
|
4416
|
+
verdict: verdictView(pending.verdict),
|
|
4417
|
+
failedEntry: pending.write.entryId
|
|
3649
4418
|
};
|
|
3650
4419
|
const written = await context.store.publishGroup(context.site.key, checked.map(({ write }) => write));
|
|
3651
4420
|
switch (written.outcome) {
|
|
@@ -3681,6 +4450,91 @@ async function publishGroup(input, context) {
|
|
|
3681
4450
|
}
|
|
3682
4451
|
}
|
|
3683
4452
|
//#endregion
|
|
4453
|
+
//#region src/ops/reviews.ts
|
|
4454
|
+
/**
|
|
4455
|
+
* The reviews of v0.3: what a person who may publish does with the requests of those who may
|
|
4456
|
+
* only write. `publish` makes a request (`pending_review`); these list them, approve one, which
|
|
4457
|
+
* publishes it through `publish` itself, or close one with a note.
|
|
4458
|
+
*/
|
|
4459
|
+
/** The requests of one status, newest first, each with its entry as it is now. */
|
|
4460
|
+
const listReviews = async (input, context) => {
|
|
4461
|
+
const found = await context.store.listReviews(context.site.key, {
|
|
4462
|
+
status: input.status,
|
|
4463
|
+
limit: input.limit
|
|
4464
|
+
});
|
|
4465
|
+
const reviews = [];
|
|
4466
|
+
for (const review of found) {
|
|
4467
|
+
const view = await context.store.findEntryById(context.site.key, review.entryId, "head");
|
|
4468
|
+
if (view === null) throw unexpectedAnswer(context, "list_reviews", { outcome: "no entry" });
|
|
4469
|
+
reviews.push({
|
|
4470
|
+
...reviewView(review),
|
|
4471
|
+
entry: entrySummary(view)
|
|
4472
|
+
});
|
|
4473
|
+
}
|
|
4474
|
+
return {
|
|
4475
|
+
outcome: "ok",
|
|
4476
|
+
reviews
|
|
4477
|
+
};
|
|
4478
|
+
};
|
|
4479
|
+
/**
|
|
4480
|
+
* Publishes what a pending review asked for, through `publish`, in the approver's name: the
|
|
4481
|
+
* contracts run again, and the warnings the request acknowledged count as acknowledged. The
|
|
4482
|
+
* publication approves the review in its own batch. A review that is no longer pending answers
|
|
4483
|
+
* `not_pending`.
|
|
4484
|
+
*/
|
|
4485
|
+
const approveReview = async (input, context) => {
|
|
4486
|
+
const site = context.site.key;
|
|
4487
|
+
const review = await context.store.findReview(site, input.id);
|
|
4488
|
+
if (review === null) return { outcome: "not_found" };
|
|
4489
|
+
if (review.status !== "pending") return {
|
|
4490
|
+
outcome: "not_pending",
|
|
4491
|
+
review: reviewView(review)
|
|
4492
|
+
};
|
|
4493
|
+
const found = await context.store.findEntryById(site, review.entryId, "head");
|
|
4494
|
+
if (found === null) return { outcome: "not_found" };
|
|
4495
|
+
const answer = await publish$1({
|
|
4496
|
+
id: found.entry.id,
|
|
4497
|
+
version: found.entry.version,
|
|
4498
|
+
acknowledgeWarnings: [...review.acknowledged, ...input.acknowledgeWarnings],
|
|
4499
|
+
group: []
|
|
4500
|
+
}, context);
|
|
4501
|
+
switch (answer.outcome) {
|
|
4502
|
+
case "published": return {
|
|
4503
|
+
outcome: "published",
|
|
4504
|
+
review: reviewView(await context.store.findReview(site, review.id) ?? review),
|
|
4505
|
+
...answer.entry === void 0 ? {} : { entry: answer.entry },
|
|
4506
|
+
...answer.verdict === void 0 ? {} : { verdict: answer.verdict }
|
|
4507
|
+
};
|
|
4508
|
+
case "blocked":
|
|
4509
|
+
case "needs_acknowledgement": return {
|
|
4510
|
+
outcome: answer.outcome,
|
|
4511
|
+
review: reviewView(review),
|
|
4512
|
+
...answer.entry === void 0 ? {} : { entry: answer.entry },
|
|
4513
|
+
...answer.verdict === void 0 ? {} : { verdict: answer.verdict }
|
|
4514
|
+
};
|
|
4515
|
+
case "conflict": return {
|
|
4516
|
+
outcome: "not_pending",
|
|
4517
|
+
review: reviewView(await context.store.findReview(site, review.id) ?? review)
|
|
4518
|
+
};
|
|
4519
|
+
case "not_found": return { outcome: "not_found" };
|
|
4520
|
+
default: throw unexpectedAnswer(context, "approve_review", answer);
|
|
4521
|
+
}
|
|
4522
|
+
};
|
|
4523
|
+
/** Closes a pending review with a note, without publishing. */
|
|
4524
|
+
const requestChanges = async (input, context) => {
|
|
4525
|
+
const { userId } = actorOf(context);
|
|
4526
|
+
const closed = await context.store.requestChanges(context.site.key, {
|
|
4527
|
+
id: input.id,
|
|
4528
|
+
note: input.note,
|
|
4529
|
+
userId
|
|
4530
|
+
});
|
|
4531
|
+
if (closed.outcome === "not_found") return { outcome: "not_found" };
|
|
4532
|
+
return {
|
|
4533
|
+
outcome: closed.outcome,
|
|
4534
|
+
review: reviewView(closed.review)
|
|
4535
|
+
};
|
|
4536
|
+
};
|
|
4537
|
+
//#endregion
|
|
3684
4538
|
//#region src/ops/unpublish.ts
|
|
3685
4539
|
/**
|
|
3686
4540
|
* Takes an entry off the public site and keeps it, with its revisions, as a draft. The store
|
|
@@ -3819,17 +4673,20 @@ function defineHandlers(handlers) {
|
|
|
3819
4673
|
//#endregion
|
|
3820
4674
|
//#region src/ops/index.ts
|
|
3821
4675
|
/**
|
|
3822
|
-
* The handler of every operation
|
|
4676
|
+
* The handler of every operation, paired with its spec of `@hollware/core` by type
|
|
3823
4677
|
* (ADR-017). Adapters check the scopes and build the `OperationContext`; a handler never sees a
|
|
3824
4678
|
* request, and reads and writes only the site of its context.
|
|
3825
4679
|
*/
|
|
3826
4680
|
const handlers = defineHandlers({
|
|
4681
|
+
approve_review: approveReview,
|
|
3827
4682
|
create_draft: createDraft,
|
|
3828
4683
|
describe_site: describeSite$1,
|
|
3829
4684
|
get_entry: getEntry$1,
|
|
3830
4685
|
get_translations: getTranslations$1,
|
|
3831
4686
|
list_entries: listEntries$1,
|
|
4687
|
+
list_reviews: listReviews,
|
|
3832
4688
|
publish: publish$1,
|
|
4689
|
+
request_changes: requestChanges,
|
|
3833
4690
|
unpublish,
|
|
3834
4691
|
update_draft: updateDraft,
|
|
3835
4692
|
validate
|