@hollware/server 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{ceremonies-D7tsWU3S.mjs → ceremonies-V_1BbmZQ.mjs} +9 -16
- package/dist/index.d.mts +11 -0
- package/dist/index.mjs +297 -134
- package/dist/{store-Sq0pwUQv.mjs → store-CsuER6J7.mjs} +44 -54
- package/migrations/0006_people_across_sites.sql +54 -0
- package/migrations/0007_read_tokens.sql +4 -0
- package/migrations/meta/0006_snapshot.json +2102 -0
- package/migrations/meta/0007_snapshot.json +2110 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { A as
|
|
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";
|
|
1
|
+
import { A as effectiveScopes, C as revisionTags, D as tokens, E as sites, O as users, S as reviews, T as schedules, _ as entries, b as outbox, g as deliveries, j as isRole, k as ROLE_SCOPES, l as membershipsOf, n as createInvitation, s as findSession, v as invitations, w as revisions, x as publications, y as members } from "./store-CsuER6J7.mjs";
|
|
2
|
+
import { EVENT_ID_HEADER, HollwareError, OPERATIONS, SCOPES, SIGNATURE_HEADER, TIMESTAMP_HEADER, describeBuiltInContracts, describeResolvedSite, describeSite, generatePersonalToken, generateReadToken, getEntry, getTranslations, hashPersonalToken, isPersonalToken, isReadToken, isScope, listEntries, newId, processMarkdown, publish, renderHtml, resolveConfig, runContracts, signWebhook, sizeLimitViolation, uniqueSlugViolation, uniqueTranslationViolation } from "@hollware/core";
|
|
3
3
|
import { Hono } from "hono";
|
|
4
4
|
import { drizzle } from "drizzle-orm/d1";
|
|
5
5
|
import { and, desc, eq, getTableColumns, gt, inArray, isNull, lte, ne, or, sql } from "drizzle-orm";
|
|
@@ -10,7 +10,7 @@ import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, McpServer, accepted
|
|
|
10
10
|
import * as z from "zod";
|
|
11
11
|
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
12
12
|
//#region package.json
|
|
13
|
-
var version = "0.
|
|
13
|
+
var version = "0.4.0";
|
|
14
14
|
//#endregion
|
|
15
15
|
//#region src/auth/responses.ts
|
|
16
16
|
/** The challenge of every 401 and 403 (RFC 6750). */
|
|
@@ -93,7 +93,9 @@ function readPublicUrl(value) {
|
|
|
93
93
|
* Any other port is another host.
|
|
94
94
|
* - An `Origin` header that is present and is not the origin of the public URL, scheme and port
|
|
95
95
|
* included: 403. That covers the opaque `null` origin and an empty value. A request without
|
|
96
|
-
* `Origin` passes: only browsers send it, and they are what the check is for.
|
|
96
|
+
* `Origin` passes: only browsers send it, and they are what the check is for. The one exception
|
|
97
|
+
* is `crossOrigin`: an origin it accepts passes too (the read API, for the origins a site's
|
|
98
|
+
* config lists; the read API then checks the origin against the site of the token).
|
|
97
99
|
*
|
|
98
100
|
* It returns the response to send, or `null` when the request may go on. No answer repeats the
|
|
99
101
|
* request: the 403s say what to send instead, and the 500 only says what is missing.
|
|
@@ -103,8 +105,9 @@ function readPublicUrl(value) {
|
|
|
103
105
|
* they accept a `Host` such as `user@cms.example.com` or `cms.example.com/x`.
|
|
104
106
|
*
|
|
105
107
|
* @param publicUrl The `HOLLWARE_PUBLIC_URL` variable as the environment holds it.
|
|
108
|
+
* @param crossOrigin Whether a request may come from a page of another origin; never, by default.
|
|
106
109
|
*/
|
|
107
|
-
function checkHostAndOrigin(request, publicUrl) {
|
|
110
|
+
function checkHostAndOrigin(request, publicUrl, crossOrigin = () => false) {
|
|
108
111
|
const expected = readPublicUrl(publicUrl);
|
|
109
112
|
if (expected === null) return authErrorResponse(500, {
|
|
110
113
|
code: "misconfigured",
|
|
@@ -118,7 +121,7 @@ function checkHostAndOrigin(request, publicUrl) {
|
|
|
118
121
|
fix: `Send the request to ${expected.origin}, the public URL of this Hollware (HOLLWARE_PUBLIC_URL).`
|
|
119
122
|
});
|
|
120
123
|
const origin = request.headers.get("origin");
|
|
121
|
-
if (origin !== null && origin.toLowerCase() !== expected.origin) return authErrorResponse(403, {
|
|
124
|
+
if (origin !== null && origin.toLowerCase() !== expected.origin && !crossOrigin(request, origin)) return authErrorResponse(403, {
|
|
122
125
|
code: "forbidden_origin",
|
|
123
126
|
message: "The Origin header of the request is not the public URL of this Hollware.",
|
|
124
127
|
fix: `Call Hollware from a client that sends no Origin header, or from a page served at ${expected.origin}.`
|
|
@@ -141,14 +144,14 @@ function missingScopes(principal, required) {
|
|
|
141
144
|
/** `last_used_at` of a token is written at most once in this time. */
|
|
142
145
|
const TOUCH_INTERVAL_MS = 36e5;
|
|
143
146
|
/**
|
|
144
|
-
* The token with the role of its user in the token's site, by the hash of the token, in the
|
|
145
|
-
*
|
|
147
|
+
* The token with the role of its user in the token's site, by the hash of the token, in the
|
|
148
|
+
* deployment. A user who is no member of the site has no role there, and the token is unknown.
|
|
146
149
|
*/
|
|
147
150
|
const LOOKUP = `SELECT t.id AS token_id, t.site_id AS site_id, t.user_id AS user_id,
|
|
148
|
-
t.scopes AS scopes, t.expires_at AS expires_at, t.revoked_at AS revoked_at,
|
|
151
|
+
t.kind AS kind, t.scopes AS scopes, t.expires_at AS expires_at, t.revoked_at AS revoked_at,
|
|
149
152
|
t.last_used_at AS last_used_at, m.role AS role
|
|
150
153
|
FROM tokens AS t JOIN members AS m ON m.user_id = t.user_id AND m.site_id = t.site_id
|
|
151
|
-
WHERE t.hash = ?1
|
|
154
|
+
WHERE t.hash = ?1`;
|
|
152
155
|
/**
|
|
153
156
|
* The scopes a token carries, from the JSON array the `tokens` table stores. Anything that is not
|
|
154
157
|
* one of the scopes is left out, and a value that is not an array of scopes has none: a token
|
|
@@ -193,12 +196,12 @@ async function recordTokenUse(db, token, now) {
|
|
|
193
196
|
}
|
|
194
197
|
}
|
|
195
198
|
/**
|
|
196
|
-
* Authenticates a personal token (ADR-019). A string without the shape of
|
|
197
|
-
* without a query. Otherwise one query finds the token by the hash of the whole
|
|
198
|
-
*
|
|
199
|
+
* Authenticates a personal token (ADR-019) or a read token. A string without the shape of either
|
|
200
|
+
* is `unknown` without a query. Otherwise one query finds the token by the hash of the whole
|
|
201
|
+
* string, together with the role of its user in the token's site; the answer is, in this order:
|
|
199
202
|
*
|
|
200
|
-
* - `unknown`: no such token in this
|
|
201
|
-
* nothing says it exists elsewhere.
|
|
203
|
+
* - `unknown`: no such token in the sites of this deployment. A token of another site is the same as
|
|
204
|
+
* no token, and nothing says it exists elsewhere.
|
|
202
205
|
* - `revoked`: it has a revocation date, whatever the date says: it is never read, so a clock that
|
|
203
206
|
* runs ahead of the Worker's, where the token was revoked, cannot keep it alive for a while.
|
|
204
207
|
* - `expired`: its expiry is now or past (see `hasExpired`).
|
|
@@ -211,12 +214,13 @@ async function recordTokenUse(db, token, now) {
|
|
|
211
214
|
* must never read that as a valid token.
|
|
212
215
|
*/
|
|
213
216
|
async function checkPersonalToken(db, token, options) {
|
|
214
|
-
|
|
217
|
+
const kind = isPersonalToken(token) ? "personal" : isReadToken(token) ? "read" : null;
|
|
218
|
+
if (kind === null) return {
|
|
215
219
|
ok: false,
|
|
216
220
|
reason: "unknown"
|
|
217
221
|
};
|
|
218
|
-
const row = await db.prepare(LOOKUP).bind(await hashPersonalToken(token)
|
|
219
|
-
if (row === null) return {
|
|
222
|
+
const row = await db.prepare(LOOKUP).bind(await hashPersonalToken(token)).first();
|
|
223
|
+
if (row === null || row.kind !== kind || !options.sites.has(row.site_id)) return {
|
|
220
224
|
ok: false,
|
|
221
225
|
reason: "unknown"
|
|
222
226
|
};
|
|
@@ -229,14 +233,22 @@ async function checkPersonalToken(db, token, options) {
|
|
|
229
233
|
reason: "expired"
|
|
230
234
|
};
|
|
231
235
|
const recent = row.last_used_at !== null && row.last_used_at >= anHourBefore(options.now);
|
|
236
|
+
const scopes = effectiveScopes(row.role, parseTokenScopes(row.scopes));
|
|
237
|
+
const identity = {
|
|
238
|
+
userId: row.user_id,
|
|
239
|
+
tokenId: row.token_id,
|
|
240
|
+
site: row.site_id
|
|
241
|
+
};
|
|
232
242
|
return {
|
|
233
243
|
ok: true,
|
|
234
|
-
principal: {
|
|
244
|
+
principal: kind === "read" ? {
|
|
245
|
+
kind: "read",
|
|
246
|
+
...identity,
|
|
247
|
+
scopes: scopes.filter((scope) => scope === "content:read")
|
|
248
|
+
} : {
|
|
235
249
|
kind: "token",
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
site: row.site_id,
|
|
239
|
-
scopes: effectiveScopes(row.role, parseTokenScopes(row.scopes))
|
|
250
|
+
...identity,
|
|
251
|
+
scopes
|
|
240
252
|
},
|
|
241
253
|
touch: recent ? null : recordTokenUse(db, {
|
|
242
254
|
id: row.token_id,
|
|
@@ -1636,12 +1648,14 @@ function isEventMessage(body) {
|
|
|
1636
1648
|
/**
|
|
1637
1649
|
* The Queue consumer: delivers each event, acknowledges it once every subscriber has it, and asks
|
|
1638
1650
|
* for a retry otherwise; after the Queue's retries it lands in the dead-letter queue. A message of
|
|
1639
|
-
*
|
|
1651
|
+
* a site this deployment does not serve (`depsOf` has none for it), or that is no event, is
|
|
1652
|
+
* acknowledged and dropped.
|
|
1640
1653
|
*/
|
|
1641
|
-
async function consumeEvents(messages,
|
|
1654
|
+
async function consumeEvents(messages, depsOf) {
|
|
1642
1655
|
for (const message of messages) {
|
|
1643
1656
|
const { body } = message;
|
|
1644
|
-
|
|
1657
|
+
const deps = isEventMessage(body) ? depsOf(body.site) : void 0;
|
|
1658
|
+
if (!isEventMessage(body) || deps === void 0) {
|
|
1645
1659
|
message.ack();
|
|
1646
1660
|
continue;
|
|
1647
1661
|
}
|
|
@@ -1849,7 +1863,8 @@ function installAdmin(app, options) {
|
|
|
1849
1863
|
//#region src/http/admin-api.ts
|
|
1850
1864
|
/**
|
|
1851
1865
|
* What only a person in the admin may do (ADR-026): see, make and revoke personal tokens, and
|
|
1852
|
-
* invite people
|
|
1866
|
+
* invite people, on the site of their token; and learn who they are and which sites they may switch
|
|
1867
|
+
* to (`/v1/admin/me`, the one route for every role). These are no operations, so no agent sees them over MCP, and a request needs an
|
|
1853
1868
|
* access token of the admin's own client with `schema:admin`: a personal token, or a token of
|
|
1854
1869
|
* another client, never reaches them, even an owner's. A leaked token cannot mint the next one.
|
|
1855
1870
|
*/
|
|
@@ -1882,20 +1897,38 @@ function daysFrom(value, fallback) {
|
|
|
1882
1897
|
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 365 ? value : null;
|
|
1883
1898
|
}
|
|
1884
1899
|
function installAdminApi(app, options) {
|
|
1885
|
-
/**
|
|
1886
|
-
|
|
1900
|
+
/**
|
|
1901
|
+
* Runs `handle` for a person in the admin with `schema:admin`, or with any scope when `anyone`;
|
|
1902
|
+
* anyone else is refused.
|
|
1903
|
+
*/
|
|
1904
|
+
const admin = (handle, { anyone = false } = {}) => (c) => options.protect(c, async (principal) => {
|
|
1887
1905
|
const ours = (await adminClient(options.oauth(c.env.HOLLWARE_PUBLIC_URL), c.env)).clientId;
|
|
1888
1906
|
if (principal.kind !== "oauth" || principal.clientId !== ours) return refused("Only the admin may do this.", "Sign in to the admin at /admin/ and do it there.", 403);
|
|
1889
|
-
if (!principal.scopes.includes("schema:admin")) return insufficientScope(["schema:admin"], options.metadataUrl(c));
|
|
1907
|
+
if (!anyone && !principal.scopes.includes("schema:admin")) return insufficientScope(["schema:admin"], options.metadataUrl(c));
|
|
1890
1908
|
return handle(c, principal);
|
|
1891
1909
|
});
|
|
1892
1910
|
const body = async (c) => {
|
|
1893
1911
|
const value = await c.req.json().catch(() => null);
|
|
1894
1912
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {};
|
|
1895
1913
|
};
|
|
1896
|
-
app.get("/v1/admin/
|
|
1914
|
+
app.get("/v1/admin/me", admin(async (c, person) => {
|
|
1915
|
+
const db = connect(c.env.DB);
|
|
1916
|
+
const [user] = await db.select({
|
|
1917
|
+
id: users.id,
|
|
1918
|
+
name: users.name
|
|
1919
|
+
}).from(users).where(eq(users.id, person.userId));
|
|
1920
|
+
const sites = (await membershipsOf(db, person.userId)).filter((membership) => options.sites.has(membership.site));
|
|
1921
|
+
return json$1({
|
|
1922
|
+
user,
|
|
1923
|
+
site: person.site,
|
|
1924
|
+
scopes: person.scopes,
|
|
1925
|
+
sites
|
|
1926
|
+
});
|
|
1927
|
+
}, { anyone: true }));
|
|
1928
|
+
app.get("/v1/admin/tokens", admin(async (c, person) => {
|
|
1897
1929
|
return json$1({ tokens: (await connect(c.env.DB).select({
|
|
1898
1930
|
id: tokens.id,
|
|
1931
|
+
kind: tokens.kind,
|
|
1899
1932
|
label: tokens.label,
|
|
1900
1933
|
scopes: tokens.scopes,
|
|
1901
1934
|
user: users.name,
|
|
@@ -1903,7 +1936,7 @@ function installAdminApi(app, options) {
|
|
|
1903
1936
|
revokedAt: tokens.revokedAt,
|
|
1904
1937
|
lastUsedAt: tokens.lastUsedAt,
|
|
1905
1938
|
createdAt: tokens.createdAt
|
|
1906
|
-
}).from(tokens).innerJoin(users, eq(users.id, tokens.userId)).where(eq(tokens.siteId,
|
|
1939
|
+
}).from(tokens).innerJoin(users, eq(users.id, tokens.userId)).where(eq(tokens.siteId, person.site)).orderBy(desc(tokens.createdAt), desc(tokens.id))).map((row) => ({
|
|
1907
1940
|
...row,
|
|
1908
1941
|
scopes: parseScopes(row.scopes)
|
|
1909
1942
|
})) });
|
|
@@ -1912,16 +1945,20 @@ function installAdminApi(app, options) {
|
|
|
1912
1945
|
const input = await body(c);
|
|
1913
1946
|
const label = typeof input["label"] === "string" ? input["label"].trim() : "";
|
|
1914
1947
|
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
|
|
1948
|
+
const asKind = input["kind"];
|
|
1949
|
+
const kind = asKind === void 0 || asKind === "personal" ? "personal" : asKind === "read" ? "read" : null;
|
|
1950
|
+
if (kind === null) return refused("kind is \"personal\" or \"read\".", "Leave it out for a personal token; \"read\" makes a token that reads published content.");
|
|
1951
|
+
const asked = kind === "read" ? ["content:read"] : Array.isArray(input["scopes"]) ? input["scopes"].filter((s) => typeof s === "string") : [];
|
|
1916
1952
|
const scopes = person.scopes.filter((scope) => asked.includes(scope));
|
|
1917
1953
|
if (scopes.length === 0) return refused("A token needs at least one scope that you have.", `Choose among ${person.scopes.join(", ")}.`);
|
|
1918
1954
|
const days = daysFrom(input["expiresInDays"], TOKEN_DAYS);
|
|
1919
1955
|
if (days === null) return refused("expiresInDays is a whole number from 1 to 365.", "Leave it out for 90 days.");
|
|
1920
1956
|
const now = options.clock.now();
|
|
1921
|
-
const secret = generatePersonalToken();
|
|
1957
|
+
const secret = kind === "read" ? generateReadToken() : generatePersonalToken();
|
|
1922
1958
|
const row = {
|
|
1923
1959
|
id: newId("tok"),
|
|
1924
|
-
|
|
1960
|
+
kind,
|
|
1961
|
+
siteId: person.site,
|
|
1925
1962
|
userId: person.userId,
|
|
1926
1963
|
label,
|
|
1927
1964
|
hash: await hashPersonalToken(secret),
|
|
@@ -1935,20 +1972,21 @@ function installAdminApi(app, options) {
|
|
|
1935
1972
|
await connect(c.env.DB).insert(tokens).values(row);
|
|
1936
1973
|
return json$1({
|
|
1937
1974
|
id: row.id,
|
|
1975
|
+
kind,
|
|
1938
1976
|
label,
|
|
1939
1977
|
scopes,
|
|
1940
1978
|
expiresAt: row.expiresAt,
|
|
1941
1979
|
secret
|
|
1942
1980
|
}, 201);
|
|
1943
1981
|
}));
|
|
1944
|
-
app.post("/v1/admin/tokens/:id/revoke", admin(async (c) => {
|
|
1982
|
+
app.post("/v1/admin/tokens/:id/revoke", admin(async (c, person) => {
|
|
1945
1983
|
const now = options.clock.now().toISOString();
|
|
1946
1984
|
const db = connect(c.env.DB);
|
|
1947
1985
|
const id = c.req.param("id") ?? "";
|
|
1948
1986
|
const [revoked] = await db.update(tokens).set({
|
|
1949
1987
|
revokedAt: now,
|
|
1950
1988
|
updatedAt: now
|
|
1951
|
-
}).where(and(eq(tokens.siteId,
|
|
1989
|
+
}).where(and(eq(tokens.siteId, person.site), eq(tokens.id, id), isNull(tokens.revokedAt))).returning({ id: tokens.id });
|
|
1952
1990
|
if (revoked !== void 0) return json$1({
|
|
1953
1991
|
id: revoked.id,
|
|
1954
1992
|
revokedAt: now
|
|
@@ -1956,13 +1994,13 @@ function installAdminApi(app, options) {
|
|
|
1956
1994
|
const [row] = await db.select({
|
|
1957
1995
|
id: tokens.id,
|
|
1958
1996
|
revokedAt: tokens.revokedAt
|
|
1959
|
-
}).from(tokens).where(and(eq(tokens.siteId,
|
|
1997
|
+
}).from(tokens).where(and(eq(tokens.siteId, person.site), eq(tokens.id, id)));
|
|
1960
1998
|
return row === void 0 ? refused("There is no such token.", "List the tokens and pick one of them.", 404) : json$1({
|
|
1961
1999
|
id: row.id,
|
|
1962
2000
|
revokedAt: row.revokedAt
|
|
1963
2001
|
});
|
|
1964
2002
|
}));
|
|
1965
|
-
app.get("/v1/admin/people", admin(async (c) => {
|
|
2003
|
+
app.get("/v1/admin/people", admin(async (c, person) => {
|
|
1966
2004
|
const db = connect(c.env.DB);
|
|
1967
2005
|
const now = options.clock.now().toISOString();
|
|
1968
2006
|
return json$1({
|
|
@@ -1971,22 +2009,22 @@ function installAdminApi(app, options) {
|
|
|
1971
2009
|
name: users.name,
|
|
1972
2010
|
role: members.role,
|
|
1973
2011
|
since: members.createdAt
|
|
1974
|
-
}).from(members).innerJoin(users, eq(users.id, members.userId)).where(eq(members.siteId,
|
|
2012
|
+
}).from(members).innerJoin(users, eq(users.id, members.userId)).where(eq(members.siteId, person.site)).orderBy(members.createdAt, users.id),
|
|
1975
2013
|
invitations: await db.select({
|
|
1976
2014
|
id: invitations.id,
|
|
1977
2015
|
role: invitations.role,
|
|
1978
2016
|
expiresAt: invitations.expiresAt
|
|
1979
|
-
}).from(invitations).where(and(eq(invitations.siteId,
|
|
2017
|
+
}).from(invitations).where(and(eq(invitations.siteId, person.site), isNull(invitations.usedAt), isNull(invitations.userId), gt(invitations.expiresAt, now))).orderBy(desc(invitations.createdAt))
|
|
1980
2018
|
});
|
|
1981
2019
|
}));
|
|
1982
|
-
app.post("/v1/admin/invitations", admin(async (c) => {
|
|
2020
|
+
app.post("/v1/admin/invitations", admin(async (c, person) => {
|
|
1983
2021
|
const input = await body(c);
|
|
1984
2022
|
const role = input["role"];
|
|
1985
2023
|
if (typeof role !== "string" || !isRole(role) || role === "owner") return refused("An invitation is for an editor or an author.", `Use role "editor" (${ROLE_SCOPES.editor.join(", ")}) or "author" (${ROLE_SCOPES.author.join(", ")}).`);
|
|
1986
2024
|
const days = daysFrom(input["days"], INVITATION_DAYS);
|
|
1987
2025
|
if (days === null) return refused("days is a whole number from 1 to 365.", "Leave it out for 7 days.");
|
|
1988
2026
|
const made = await createInvitation(connect(c.env.DB), {
|
|
1989
|
-
site:
|
|
2027
|
+
site: person.site,
|
|
1990
2028
|
role,
|
|
1991
2029
|
userId: null,
|
|
1992
2030
|
now: options.clock.now(),
|
|
@@ -2041,15 +2079,17 @@ function installOpsApi(app, options) {
|
|
|
2041
2079
|
fix: "Send the input of the operation as its input schema describes it."
|
|
2042
2080
|
}, 400);
|
|
2043
2081
|
}
|
|
2082
|
+
const site = options.sites.get(principal.site);
|
|
2083
|
+
if (site === void 0) throw new Error(`No site ${principal.site} for a checked token`);
|
|
2044
2084
|
const context = {
|
|
2045
|
-
site
|
|
2085
|
+
site,
|
|
2046
2086
|
principal,
|
|
2047
2087
|
view: "head",
|
|
2048
2088
|
store: createD1Store(c.env.DB, { clock: options.clock }),
|
|
2049
2089
|
clock: options.clock,
|
|
2050
2090
|
client: { name: "hollware-admin" },
|
|
2051
2091
|
requestId,
|
|
2052
|
-
notify: options.notify(c, requestId)
|
|
2092
|
+
notify: options.notify(c, requestId, site.key)
|
|
2053
2093
|
};
|
|
2054
2094
|
try {
|
|
2055
2095
|
return json(spec.output.parse(await invoke$1(options.handlers, spec, parsed.data, context)));
|
|
@@ -2076,6 +2116,14 @@ function installOpsApi(app, options) {
|
|
|
2076
2116
|
}
|
|
2077
2117
|
//#endregion
|
|
2078
2118
|
//#region src/http/read-api.ts
|
|
2119
|
+
/** How long a browser keeps the answer of a preflight, in seconds. */
|
|
2120
|
+
const PREFLIGHT_MAX_AGE = 600;
|
|
2121
|
+
/** The origin of a request from a page of another origin than Hollware's own, or `null`. */
|
|
2122
|
+
function crossOriginOf(c) {
|
|
2123
|
+
const origin = c.req.header("origin");
|
|
2124
|
+
if (origin === void 0) return null;
|
|
2125
|
+
return origin.toLowerCase() === new URL(c.env.HOLLWARE_PUBLIC_URL).origin ? null : origin;
|
|
2126
|
+
}
|
|
2079
2127
|
const STATUS = {
|
|
2080
2128
|
invalid_input: 400,
|
|
2081
2129
|
unknown_collection: 404,
|
|
@@ -2112,47 +2160,82 @@ async function answer(c, value) {
|
|
|
2112
2160
|
function installReadApi(app, options) {
|
|
2113
2161
|
app.use("/v1/*", async (c, next) => {
|
|
2114
2162
|
if (c.req.path.startsWith("/v1/ops/") || c.req.path.startsWith("/v1/admin/")) return next();
|
|
2163
|
+
const crossOrigin = crossOriginOf(c);
|
|
2164
|
+
if (c.req.method === "OPTIONS") {
|
|
2165
|
+
const headers = new Headers({
|
|
2166
|
+
allow: "GET, HEAD, OPTIONS",
|
|
2167
|
+
vary: "Origin"
|
|
2168
|
+
});
|
|
2169
|
+
if (crossOrigin !== null && options.anyCorsOrigin(crossOrigin)) {
|
|
2170
|
+
headers.set("access-control-allow-origin", crossOrigin);
|
|
2171
|
+
headers.set("access-control-allow-methods", "GET, HEAD");
|
|
2172
|
+
headers.set("access-control-allow-headers", "authorization, if-none-match");
|
|
2173
|
+
headers.set("access-control-max-age", String(PREFLIGHT_MAX_AGE));
|
|
2174
|
+
}
|
|
2175
|
+
return new Response(null, {
|
|
2176
|
+
status: 204,
|
|
2177
|
+
headers
|
|
2178
|
+
});
|
|
2179
|
+
}
|
|
2115
2180
|
if (c.req.method !== "GET" && c.req.method !== "HEAD") return c.json({
|
|
2116
2181
|
code: "invalid_input",
|
|
2117
2182
|
message: "The read API only reads.",
|
|
2118
2183
|
fix: "Use GET."
|
|
2119
2184
|
}, 405, { allow: "GET, HEAD" });
|
|
2120
2185
|
if (c.req.path === "/v1/openapi.json") return next();
|
|
2121
|
-
|
|
2186
|
+
c.res = await options.protect(c, async (principal) => {
|
|
2122
2187
|
if (!principal.scopes.includes("content:read")) return insufficientScope(["content:read"], options.metadataUrl(c));
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2188
|
+
c.set("site", principal.site);
|
|
2189
|
+
if (crossOrigin !== null && !options.corsOrigins(principal.site).includes(crossOrigin)) return c.json({
|
|
2190
|
+
code: "forbidden_origin",
|
|
2191
|
+
message: `The site "${principal.site}" does not let pages of ${crossOrigin} read it.`,
|
|
2192
|
+
fix: `Add "${crossOrigin}" to cors.origins of the site in hollware.config.ts, or read from a server.`
|
|
2193
|
+
}, 403, {
|
|
2194
|
+
vary: "Origin",
|
|
2195
|
+
"access-control-allow-origin": new URL(c.env.HOLLWARE_PUBLIC_URL).origin
|
|
2196
|
+
});
|
|
2197
|
+
const answered = await (async () => {
|
|
2198
|
+
try {
|
|
2199
|
+
await next();
|
|
2200
|
+
return c.res;
|
|
2201
|
+
} catch (error) {
|
|
2202
|
+
if (!(error instanceof HollwareError)) throw error;
|
|
2203
|
+
return c.json({
|
|
2204
|
+
code: error.code,
|
|
2205
|
+
message: error.message,
|
|
2206
|
+
fix: error.fix
|
|
2207
|
+
}, STATUS[error.code] ?? 500);
|
|
2208
|
+
}
|
|
2209
|
+
})();
|
|
2210
|
+
if (crossOrigin === null) return answered;
|
|
2211
|
+
const shared = new Response(answered.body, answered);
|
|
2212
|
+
shared.headers.set("access-control-allow-origin", crossOrigin);
|
|
2213
|
+
shared.headers.set("access-control-expose-headers", "etag");
|
|
2214
|
+
shared.headers.append("vary", "Origin");
|
|
2215
|
+
return shared;
|
|
2134
2216
|
});
|
|
2217
|
+
return c.res;
|
|
2135
2218
|
});
|
|
2136
|
-
app.get("/v1/openapi.json", (c) => c.json(openApiDocument(
|
|
2137
|
-
app.get("/v1/site", async (c) => answer(c, await options.read(c.env).describeSite(
|
|
2219
|
+
app.get("/v1/openapi.json", (c) => c.json(openApiDocument()));
|
|
2220
|
+
app.get("/v1/site", async (c) => answer(c, await options.read(c.env).describeSite(c.get("site"))));
|
|
2138
2221
|
app.get("/v1/:collection", async (c) => {
|
|
2139
2222
|
const query = c.req.query();
|
|
2140
2223
|
const limit = query["limit"] === void 0 ? void 0 : Number(query["limit"]);
|
|
2141
|
-
return answer(c, await options.read(c.env).listEntries(
|
|
2224
|
+
return answer(c, await options.read(c.env).listEntries(c.get("site"), c.req.param("collection"), {
|
|
2142
2225
|
...query["locale"] === void 0 ? {} : { locale: query["locale"] },
|
|
2143
2226
|
...query["tag"] === void 0 ? {} : { tag: query["tag"] },
|
|
2144
2227
|
...limit === void 0 ? {} : { limit },
|
|
2145
2228
|
...query["cursor"] === void 0 ? {} : { cursor: query["cursor"] }
|
|
2146
2229
|
}));
|
|
2147
2230
|
});
|
|
2148
|
-
app.get("/v1/:collection/translations/:group", async (c) => answer(c, await options.read(c.env).getTranslations(
|
|
2231
|
+
app.get("/v1/:collection/translations/:group", async (c) => answer(c, await options.read(c.env).getTranslations(c.get("site"), c.req.param("collection"), c.req.param("group"))));
|
|
2149
2232
|
app.get("/v1/:collection/:locale/:slug", async (c) => {
|
|
2150
2233
|
const formats = c.req.query("formats");
|
|
2151
|
-
return answer(c, await options.read(c.env).getEntry(
|
|
2234
|
+
return answer(c, await options.read(c.env).getEntry(c.get("site"), c.req.param("collection"), c.req.param("slug"), c.req.param("locale"), formats === void 0 ? {} : { formats: formats.split(",").flatMap((format) => format === "markdown" || format === "document" || format === "html" ? [format] : []) }));
|
|
2152
2235
|
});
|
|
2153
2236
|
}
|
|
2154
2237
|
/** The OpenAPI 3.1 document of the read API. */
|
|
2155
|
-
function openApiDocument(
|
|
2238
|
+
function openApiDocument() {
|
|
2156
2239
|
const ok = (description) => ({
|
|
2157
2240
|
description,
|
|
2158
2241
|
headers: { ETag: { schema: { type: "string" } } },
|
|
@@ -2172,7 +2255,7 @@ function openApiDocument(site) {
|
|
|
2172
2255
|
return {
|
|
2173
2256
|
openapi: "3.1.0",
|
|
2174
2257
|
info: {
|
|
2175
|
-
title:
|
|
2258
|
+
title: "Hollware read API",
|
|
2176
2259
|
version: "1"
|
|
2177
2260
|
},
|
|
2178
2261
|
components: { securitySchemes: { token: {
|
|
@@ -2237,9 +2320,25 @@ function openApiDocument(site) {
|
|
|
2237
2320
|
* (`PublicKeyCredential.parseCreationOptionsFromJSON`, `credential.toJSON()`), and reads the
|
|
2238
2321
|
* invitation from the address bar, so the page never prints it.
|
|
2239
2322
|
*/
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2323
|
+
/**
|
|
2324
|
+
* The look of hollware.com and the admin, with nothing to fetch: the page's policy loads no font,
|
|
2325
|
+
* so the type is the system's. Ink greys and the mint accent, dark when the device is.
|
|
2326
|
+
*/
|
|
2327
|
+
const STYLE = `:root{color-scheme:light dark;--bg:#f6f7f8;--card:#fff;--fg:#15181b;--muted:#545c66;--line:#d6dadf;--accent:#0c825d;--on-accent:#fff;--danger:#b42318}
|
|
2328
|
+
@media (prefers-color-scheme:dark){:root{--bg:#08090a;--card:#0f1113;--fg:#eceef0;--muted:#8a939e;--line:rgb(255 255 255/.1);--accent:#3ee6a8;--on-accent:#04140d;--danger:#ff8a80}}
|
|
2329
|
+
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:2rem 1rem;background:var(--bg);color:var(--fg);font:15px/1.6 system-ui,-apple-system,'Segoe UI',sans-serif;-webkit-font-smoothing:antialiased}
|
|
2330
|
+
main{width:100%;max-width:26rem;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:2rem}
|
|
2331
|
+
.brand{display:flex;align-items:center;gap:.6rem;margin-bottom:1.75rem;font-weight:600;letter-spacing:-.01em}.brand svg{width:26px;height:26px}
|
|
2332
|
+
h1{font-size:1.4rem;line-height:1.25;letter-spacing:-.02em;margin:0 0 .5rem}p{color:var(--muted);margin:.5rem 0}strong{color:var(--fg)}
|
|
2333
|
+
label{display:block;margin:1rem 0 .35rem;font-size:.9rem;font-weight:500}fieldset label,form>label:has(input[type=checkbox]){font-weight:400;display:flex;align-items:center;gap:.5rem;margin:.5rem 0}
|
|
2334
|
+
input{width:100%;padding:.6rem .75rem;font:inherit;color:inherit;background:transparent;border:1px solid var(--line);border-radius:8px}input:focus{outline:2px solid var(--accent);outline-offset:1px}
|
|
2335
|
+
label input{width:auto;margin:0}fieldset{border:1px solid var(--line);border-radius:8px;margin:1rem 0;padding:.5rem .9rem}legend{color:var(--muted);font-size:.85rem;padding:0 .3rem}
|
|
2336
|
+
code{font:.82em ui-monospace,'SF Mono',Menlo,monospace;color:var(--muted)}
|
|
2337
|
+
button{margin-top:1.25rem;padding:.6rem 1rem;font:inherit;font-weight:500;cursor:pointer;border-radius:8px;border:1px solid var(--line);background:transparent;color:var(--fg)}
|
|
2338
|
+
button:not([value=deny]){background:var(--accent);border-color:var(--accent);color:var(--on-accent)}
|
|
2339
|
+
[role=alert]{color:var(--danger)}`;
|
|
2340
|
+
/** The mark of Hollware, before the title of every page. */
|
|
2341
|
+
const BRAND = `<div class="brand"><svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="8" fill="currentColor" opacity=".08"/><path d="M9 8v16M23 8v16M9 16h14" stroke="var(--accent)" stroke-width="3" stroke-linecap="round" fill="none"/></svg>Hollware</div>`;
|
|
2243
2342
|
const POST = `async function post(path, body) {
|
|
2244
2343
|
const response = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
2245
2344
|
const data = await response.json()
|
|
@@ -2258,6 +2357,7 @@ function page(title, nonce, body, script) {
|
|
|
2258
2357
|
</head>
|
|
2259
2358
|
<body>
|
|
2260
2359
|
<main>
|
|
2360
|
+
${BRAND}
|
|
2261
2361
|
<h1>${title}</h1>
|
|
2262
2362
|
${body}
|
|
2263
2363
|
<p id="error" role="alert"></p>
|
|
@@ -2327,6 +2427,7 @@ function consentPage(view) {
|
|
|
2327
2427
|
const name = escapeHtml(view.clientName);
|
|
2328
2428
|
const origin = view.clientDomain === void 0 ? "<p>This app registered itself: its name is not verified.</p>" : `<p>Published by <strong>${escapeHtml(view.clientDomain)}</strong>.</p>`;
|
|
2329
2429
|
const loopback = view.redirectIsLoopback ? "<p><strong>This sends access to an app on this computer.</strong> Continue only if you just started to connect it.</p>" : "";
|
|
2430
|
+
const sites = view.sites.length === 1 ? `<input type="hidden" name="site" value="${escapeHtml(view.sites[0] ?? "")}">` : `<fieldset><legend>For the site</legend>${view.sites.map((site, index) => `<label><input type="radio" name="site" value="${escapeHtml(site)}"${index === 0 ? " checked" : ""}> ${escapeHtml(site)}</label>`).join("\n")}</fieldset>`;
|
|
2330
2431
|
const scopes = view.scopes.map((scope) => `<label><input type="checkbox" name="scope" value="${escapeHtml(scope)}" checked> ${escapeHtml(SCOPE_WORDS[scope] ?? scope)} <code>${escapeHtml(scope)}</code></label>`).join("\n");
|
|
2331
2432
|
return `<!doctype html>
|
|
2332
2433
|
<html lang="en">
|
|
@@ -2334,16 +2435,18 @@ function consentPage(view) {
|
|
|
2334
2435
|
<meta charset="utf-8">
|
|
2335
2436
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2336
2437
|
<title>Allow ${name} · Hollware</title>
|
|
2337
|
-
<style>${STYLE}
|
|
2438
|
+
<style>${STYLE}</style>
|
|
2338
2439
|
</head>
|
|
2339
2440
|
<body>
|
|
2340
2441
|
<main>
|
|
2442
|
+
${BRAND}
|
|
2341
2443
|
<h1>Allow ${name} to use this Hollware?</h1>
|
|
2342
2444
|
${origin}
|
|
2343
2445
|
<p>Signed in as <strong>${escapeHtml(view.person)}</strong>. Access goes to <strong>${escapeHtml(view.redirectHost)}</strong>.</p>
|
|
2344
2446
|
${loopback}
|
|
2345
2447
|
<form method="post" action="/authorize">
|
|
2346
2448
|
<input type="hidden" name="handle" value="${escapeHtml(view.handle)}">
|
|
2449
|
+
${sites}
|
|
2347
2450
|
${scopes}
|
|
2348
2451
|
<p><button name="decision" value="approve">Allow</button> <button name="decision" value="deny">Deny</button></p>
|
|
2349
2452
|
</form>
|
|
@@ -2364,6 +2467,7 @@ function messagePage(title, message) {
|
|
|
2364
2467
|
</head>
|
|
2365
2468
|
<body>
|
|
2366
2469
|
<main>
|
|
2470
|
+
${BRAND}
|
|
2367
2471
|
<h1>${escapeHtml(title)}</h1>
|
|
2368
2472
|
<p>${escapeHtml(message)}</p>
|
|
2369
2473
|
</main>
|
|
@@ -2391,7 +2495,7 @@ function plainPageHeaders() {
|
|
|
2391
2495
|
* The ceremonies, with SimpleWebAuthn and its parsers, in a chunk of their own: only these four
|
|
2392
2496
|
* routes use them, and a Worker that serves MCP never pays for them at startup (`budgets.json`).
|
|
2393
2497
|
*/
|
|
2394
|
-
const ceremonies = () => import("./ceremonies-
|
|
2498
|
+
const ceremonies = () => import("./ceremonies-V_1BbmZQ.mjs");
|
|
2395
2499
|
/** The cookie of a signed-in person: only `/authorize` reads it (ADR-026). */
|
|
2396
2500
|
const SESSION_COOKIE = "hollware_session";
|
|
2397
2501
|
function sessionCookie(secret) {
|
|
@@ -2404,7 +2508,6 @@ function isRecord$1(value) {
|
|
|
2404
2508
|
function installIdentity(app, options) {
|
|
2405
2509
|
const ceremony = (c) => ({
|
|
2406
2510
|
db: connect(c.env.DB),
|
|
2407
|
-
site: options.site,
|
|
2408
2511
|
publicUrl: c.env.HOLLWARE_PUBLIC_URL,
|
|
2409
2512
|
now: options.clock.now()
|
|
2410
2513
|
});
|
|
@@ -2508,36 +2611,52 @@ function answerError(error) {
|
|
|
2508
2611
|
* `access_denied`.
|
|
2509
2612
|
* - The grant stores the person and the site. What its tokens may do is read again on each request
|
|
2510
2613
|
* against the person's role then (`server.ts`).
|
|
2614
|
+
* - The site is the one `?site=` names, if the person is a member of it; else the only site the
|
|
2615
|
+
* person is a member of; else the consent page asks which (v0.4). The admin names its site, and
|
|
2616
|
+
* takes the first one when it names none.
|
|
2511
2617
|
*/
|
|
2512
2618
|
function installAuthorize(app, options) {
|
|
2513
2619
|
const person = async (c) => {
|
|
2514
2620
|
const secret = getCookie(c, SESSION_COOKIE);
|
|
2515
2621
|
if (secret === void 0) return null;
|
|
2516
|
-
|
|
2622
|
+
const db = connect(c.env.DB);
|
|
2623
|
+
const found = await findSession(db, secret, options.clock.now());
|
|
2624
|
+
if (found === null) return null;
|
|
2625
|
+
const memberships = (await membershipsOf(db, found.userId)).filter((membership) => options.sites.has(membership.site));
|
|
2626
|
+
return {
|
|
2627
|
+
...found,
|
|
2628
|
+
memberships
|
|
2629
|
+
};
|
|
2517
2630
|
};
|
|
2518
2631
|
const signIn = (c) => {
|
|
2519
2632
|
const url = new URL(c.req.url);
|
|
2520
2633
|
const back = `${url.pathname}${url.search}`;
|
|
2521
2634
|
return c.redirect(`/signin?return_to=${encodeURIComponent(back)}`, 302);
|
|
2522
2635
|
};
|
|
2636
|
+
const notMember = (site) => html(messagePage("Not a member", site === void 0 ? "You are not a member of any site of this Hollware. Ask an owner for an invitation." : `You are not a member of the site "${site}". Ask its owner for an invitation.`), 403);
|
|
2523
2637
|
app.get("/authorize", async (c) => {
|
|
2524
|
-
const
|
|
2638
|
+
const server = options.oauth(c.env.HOLLWARE_PUBLIC_URL);
|
|
2639
|
+
const oauth = server.authorizationServer.getOAuthApi(c.env);
|
|
2525
2640
|
try {
|
|
2526
2641
|
const request = await oauth.parseAuthRequest(c.req.raw);
|
|
2527
2642
|
const signedIn = await person(c);
|
|
2528
2643
|
if (signedIn === null) return signIn(c);
|
|
2529
|
-
const
|
|
2644
|
+
const [first] = signedIn.memberships;
|
|
2645
|
+
if (first === void 0) return notMember(void 0);
|
|
2646
|
+
const asked = c.req.query("site");
|
|
2647
|
+
const named = asked === void 0 ? void 0 : signedIn.memberships.find((m) => m.site === asked);
|
|
2648
|
+
if (asked !== void 0 && named === void 0) return notMember(asked);
|
|
2530
2649
|
if (request.clientId === (await adminClient(server, c.env)).clientId) {
|
|
2531
|
-
const
|
|
2650
|
+
const membership = named ?? first;
|
|
2532
2651
|
const props = {
|
|
2533
2652
|
userId: signedIn.userId,
|
|
2534
|
-
site:
|
|
2653
|
+
site: membership.site
|
|
2535
2654
|
};
|
|
2536
2655
|
const { redirectTo } = await oauth.completeAuthorization({
|
|
2537
2656
|
request,
|
|
2538
2657
|
userId: signedIn.userId,
|
|
2539
|
-
metadata: { site:
|
|
2540
|
-
scope,
|
|
2658
|
+
metadata: { site: membership.site },
|
|
2659
|
+
scope: grantableScopes(membership.role, request.scope),
|
|
2541
2660
|
props
|
|
2542
2661
|
});
|
|
2543
2662
|
return new Response(null, {
|
|
@@ -2545,7 +2664,9 @@ function installAuthorize(app, options) {
|
|
|
2545
2664
|
headers: { location: redirectTo }
|
|
2546
2665
|
});
|
|
2547
2666
|
}
|
|
2548
|
-
const
|
|
2667
|
+
const choices = named !== void 0 ? [named] : signedIn.memberships;
|
|
2668
|
+
const asking = request.scope.length === 0 ? BASELINE : request.scope;
|
|
2669
|
+
const scopes = [...new Set(choices.flatMap((m) => grantableScopes(m.role, asking)))];
|
|
2549
2670
|
if (scopes.length === 0) {
|
|
2550
2671
|
const location = authorizationErrorRedirect(request, "access_denied", "Your role allows none of the scopes this client asked for.");
|
|
2551
2672
|
return new Response(null, {
|
|
@@ -2562,6 +2683,7 @@ function installAuthorize(app, options) {
|
|
|
2562
2683
|
redirectIsLoopback: details.redirectIsLoopback,
|
|
2563
2684
|
person: signedIn.name ?? "you",
|
|
2564
2685
|
scopes,
|
|
2686
|
+
sites: choices.map((m) => m.site),
|
|
2565
2687
|
handle: consent.handle
|
|
2566
2688
|
}), 200, consent.headers);
|
|
2567
2689
|
} catch (error) {
|
|
@@ -2583,7 +2705,10 @@ function installAuthorize(app, options) {
|
|
|
2583
2705
|
headers: denied.headers
|
|
2584
2706
|
});
|
|
2585
2707
|
}
|
|
2586
|
-
const
|
|
2708
|
+
const chosen = form.get("site");
|
|
2709
|
+
const membership = signedIn.memberships.find((m) => m.site === chosen);
|
|
2710
|
+
if (membership === void 0) return notMember(typeof chosen === "string" ? chosen : void 0);
|
|
2711
|
+
const scope = grantableScopes(membership.role, form.getAll("scope").map(String));
|
|
2587
2712
|
const approved = await oauth.approveConsent(c.req.raw, handle, { scope });
|
|
2588
2713
|
if (scope.length === 0) {
|
|
2589
2714
|
const location = authorizationErrorRedirect(approved.request, "access_denied", "No scope was allowed.");
|
|
@@ -2595,12 +2720,12 @@ function installAuthorize(app, options) {
|
|
|
2595
2720
|
}
|
|
2596
2721
|
const props = {
|
|
2597
2722
|
userId: signedIn.userId,
|
|
2598
|
-
site:
|
|
2723
|
+
site: membership.site
|
|
2599
2724
|
};
|
|
2600
2725
|
const { redirectTo } = await oauth.completeAuthorization({
|
|
2601
2726
|
request: approved.request,
|
|
2602
2727
|
userId: signedIn.userId,
|
|
2603
|
-
metadata: { site:
|
|
2728
|
+
metadata: { site: membership.site },
|
|
2604
2729
|
scope,
|
|
2605
2730
|
props
|
|
2606
2731
|
});
|
|
@@ -2689,9 +2814,9 @@ function build(issuer, options) {
|
|
|
2689
2814
|
pending.get(request)?.refused(reason);
|
|
2690
2815
|
return null;
|
|
2691
2816
|
};
|
|
2692
|
-
if (isPersonalToken(token)) {
|
|
2817
|
+
if (isPersonalToken(token) || isReadToken(token)) {
|
|
2693
2818
|
const check = await checkPersonalToken(env.DB, token, {
|
|
2694
|
-
|
|
2819
|
+
sites: options.sites,
|
|
2695
2820
|
now: options.clock.now()
|
|
2696
2821
|
});
|
|
2697
2822
|
if (!check.ok) return refuse(check.reason);
|
|
@@ -2709,14 +2834,15 @@ function build(issuer, options) {
|
|
|
2709
2834
|
const granted = await authorizationServer.validateToken(resource, token, env);
|
|
2710
2835
|
if (granted === null) return refuse("unknown");
|
|
2711
2836
|
if (!isGrantProps(granted.props)) return refuse("unknown");
|
|
2712
|
-
|
|
2713
|
-
|
|
2837
|
+
const site = granted.props.site;
|
|
2838
|
+
if (!options.sites.has(site)) return refuse("other_site");
|
|
2839
|
+
const role = await roleOf(env.DB, site, granted.props.userId);
|
|
2714
2840
|
if (role === null) return refuse("no_user");
|
|
2715
2841
|
const principal = {
|
|
2716
2842
|
kind: "oauth",
|
|
2717
2843
|
userId: granted.props.userId,
|
|
2718
2844
|
clientId: granted.clientId,
|
|
2719
|
-
site
|
|
2845
|
+
site,
|
|
2720
2846
|
scopes: effectiveScopes(role, granted.scope.filter(isScope))
|
|
2721
2847
|
};
|
|
2722
2848
|
return {
|
|
@@ -2802,7 +2928,8 @@ function withRefusalBody(response, refused) {
|
|
|
2802
2928
|
* nothing. A failed D1 query while the token is checked is one of those: it is never a 401.
|
|
2803
2929
|
*/
|
|
2804
2930
|
function createGateway(options) {
|
|
2805
|
-
const {
|
|
2931
|
+
const { sites, mcp, clock, log } = options;
|
|
2932
|
+
const siteKeys = new Set(sites.keys());
|
|
2806
2933
|
const app = new Hono();
|
|
2807
2934
|
/**
|
|
2808
2935
|
* The Worker's own context of each request. Hono hands its routes the same object under a
|
|
@@ -2815,7 +2942,7 @@ function createGateway(options) {
|
|
|
2815
2942
|
return ctx;
|
|
2816
2943
|
};
|
|
2817
2944
|
const oauth = createOAuth({
|
|
2818
|
-
|
|
2945
|
+
sites: siteKeys,
|
|
2819
2946
|
clock,
|
|
2820
2947
|
dynamicClientRegistration: options.auth?.dynamicClientRegistration ?? false,
|
|
2821
2948
|
log
|
|
@@ -2824,11 +2951,11 @@ function createGateway(options) {
|
|
|
2824
2951
|
* What a write hands to the Queue: the events it committed, after the answer (ADR-025). The
|
|
2825
2952
|
* cron trigger's sweep catches any that this misses.
|
|
2826
2953
|
*/
|
|
2827
|
-
const notify = (c, requestId) => (ids) => {
|
|
2954
|
+
const notify = (c, requestId, site) => (ids) => {
|
|
2828
2955
|
c.executionCtx.waitUntil(enqueue({
|
|
2829
2956
|
queue: c.env.EVENTS,
|
|
2830
2957
|
db: connect(c.env.DB),
|
|
2831
|
-
site
|
|
2958
|
+
site,
|
|
2832
2959
|
ids,
|
|
2833
2960
|
clock
|
|
2834
2961
|
}).catch((error) => {
|
|
@@ -2871,19 +2998,47 @@ function createGateway(options) {
|
|
|
2871
2998
|
});
|
|
2872
2999
|
return withRefusalBody(response, outcome.refusal !== null);
|
|
2873
3000
|
};
|
|
3001
|
+
/** Every origin that the config of some site lets read the site from a page. */
|
|
3002
|
+
const corsOrigins = new Set([...sites.values()].flatMap((site) => site.cors.origins));
|
|
3003
|
+
/**
|
|
3004
|
+
* The guard lets a page of such an origin through to the read API alone, and only to read: the
|
|
3005
|
+
* read API then checks the origin against the site of the token.
|
|
3006
|
+
*/
|
|
3007
|
+
const readsFromAnyCorsOrigin = (request, origin) => {
|
|
3008
|
+
const { pathname } = new URL(request.url);
|
|
3009
|
+
return corsOrigins.has(origin) && [
|
|
3010
|
+
"GET",
|
|
3011
|
+
"HEAD",
|
|
3012
|
+
"OPTIONS"
|
|
3013
|
+
].includes(request.method) && pathname.startsWith("/v1/") && !pathname.startsWith("/v1/admin/");
|
|
3014
|
+
};
|
|
3015
|
+
/**
|
|
3016
|
+
* A route for a person or their personal token: a read token reads over the read API alone, so
|
|
3017
|
+
* it is refused here, before anything runs (403, as a token that lacks a scope).
|
|
3018
|
+
*/
|
|
3019
|
+
const protectWrites = (c, resource, proceed) => protect(c, resource, async (principal, auth) => principal.kind === "read" ? Response.json({
|
|
3020
|
+
code: "forbidden",
|
|
3021
|
+
message: "A read token reads published content over GET /v1, and nothing else.",
|
|
3022
|
+
fix: "Use a personal token (hw_pat_) or sign in with OAuth for this."
|
|
3023
|
+
}, {
|
|
3024
|
+
status: 403,
|
|
3025
|
+
headers: { "cache-control": "no-store" }
|
|
3026
|
+
}) : proceed(principal, auth));
|
|
2874
3027
|
app.use("*", async (c, next) => {
|
|
2875
3028
|
c.set("requestId", crypto.randomUUID());
|
|
2876
3029
|
await next();
|
|
2877
3030
|
});
|
|
2878
3031
|
app.use("*", async (c, next) => {
|
|
2879
|
-
const refusal = checkHostAndOrigin(c.req.raw, c.env.HOLLWARE_PUBLIC_URL);
|
|
3032
|
+
const refusal = checkHostAndOrigin(c.req.raw, c.env.HOLLWARE_PUBLIC_URL, readsFromAnyCorsOrigin);
|
|
2880
3033
|
if (refusal !== null) return refusal;
|
|
2881
3034
|
await next();
|
|
2882
3035
|
});
|
|
2883
3036
|
app.get("/health", () => Response.json({ ok: true }, { headers: { "cache-control": "no-store" } }));
|
|
2884
|
-
app.all("/mcp", async (c) =>
|
|
3037
|
+
app.all("/mcp", async (c) => protectWrites(c, "mcp", async (principal) => {
|
|
2885
3038
|
const requestId = c.get("requestId");
|
|
2886
3039
|
const db = c.env.DB;
|
|
3040
|
+
const site = sites.get(principal.site);
|
|
3041
|
+
if (site === void 0) throw new Error(`No site ${principal.site} for a checked token`);
|
|
2887
3042
|
return await mcp.fetch(c.req.raw, {
|
|
2888
3043
|
principal,
|
|
2889
3044
|
requestId,
|
|
@@ -2892,7 +3047,7 @@ function createGateway(options) {
|
|
|
2892
3047
|
site,
|
|
2893
3048
|
store: createD1Store(db, { clock }),
|
|
2894
3049
|
clock,
|
|
2895
|
-
notify: notify(c, requestId)
|
|
3050
|
+
notify: notify(c, requestId, site.key)
|
|
2896
3051
|
}
|
|
2897
3052
|
});
|
|
2898
3053
|
}));
|
|
@@ -2903,37 +3058,35 @@ function createGateway(options) {
|
|
|
2903
3058
|
failed: () => void 0
|
|
2904
3059
|
}));
|
|
2905
3060
|
installAuthorize(app, {
|
|
2906
|
-
|
|
3061
|
+
sites: siteKeys,
|
|
2907
3062
|
clock,
|
|
2908
3063
|
oauth
|
|
2909
3064
|
});
|
|
2910
3065
|
const authorizationServer = (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).authorizationServer.fetch(c.req.raw, c.env, workerContext(c));
|
|
2911
3066
|
app.all("/.well-known/oauth-authorization-server", authorizationServer);
|
|
2912
3067
|
app.all("/oauth/*", authorizationServer);
|
|
2913
|
-
installIdentity(app, {
|
|
2914
|
-
site: site.key,
|
|
2915
|
-
clock
|
|
2916
|
-
});
|
|
3068
|
+
installIdentity(app, { clock });
|
|
2917
3069
|
installAdmin(app, { oauth });
|
|
2918
3070
|
installAdminApi(app, {
|
|
2919
|
-
|
|
3071
|
+
sites: siteKeys,
|
|
2920
3072
|
clock,
|
|
2921
3073
|
oauth,
|
|
2922
|
-
protect: (c, proceed) =>
|
|
3074
|
+
protect: (c, proceed) => protectWrites(c, "v1", proceed),
|
|
2923
3075
|
metadataUrl: (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1
|
|
2924
3076
|
});
|
|
2925
3077
|
if (options.handlers !== void 0) installOpsApi(app, {
|
|
2926
|
-
|
|
3078
|
+
sites,
|
|
2927
3079
|
handlers: options.handlers,
|
|
2928
3080
|
clock,
|
|
2929
3081
|
log,
|
|
2930
|
-
protect: (c, proceed) =>
|
|
3082
|
+
protect: (c, proceed) => protectWrites(c, "v1", proceed),
|
|
2931
3083
|
metadataUrl: (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1,
|
|
2932
3084
|
notify
|
|
2933
3085
|
});
|
|
2934
3086
|
if (options.read !== void 0) installReadApi(app, {
|
|
2935
3087
|
read: options.read,
|
|
2936
|
-
|
|
3088
|
+
corsOrigins: (site) => sites.get(site)?.cors.origins ?? [],
|
|
3089
|
+
anyCorsOrigin: (origin) => corsOrigins.has(origin),
|
|
2937
3090
|
protect: (c, proceed) => protect(c, "v1", proceed),
|
|
2938
3091
|
metadataUrl: (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1
|
|
2939
3092
|
});
|
|
@@ -3294,7 +3447,7 @@ const TOOLS_LIST_CACHE_HINT = {
|
|
|
3294
3447
|
function authInfoOf(principal, resourceMetadataUrl) {
|
|
3295
3448
|
return {
|
|
3296
3449
|
token: "",
|
|
3297
|
-
clientId: principal.kind === "token" ? principal.tokenId : principal.kind === "oauth" ? principal.clientId : principal.kind,
|
|
3450
|
+
clientId: principal.kind === "token" || principal.kind === "read" ? principal.tokenId : principal.kind === "oauth" ? principal.clientId : principal.kind,
|
|
3298
3451
|
scopes: [...principal.scopes],
|
|
3299
3452
|
...resourceMetadataUrl === null ? {} : { resourceMetadataUrl }
|
|
3300
3453
|
};
|
|
@@ -3438,11 +3591,12 @@ const BINDING_PRINCIPAL = Object.freeze({
|
|
|
3438
3591
|
scopes: Object.freeze(["content:read"])
|
|
3439
3592
|
});
|
|
3440
3593
|
/**
|
|
3441
|
-
* The site of a call is
|
|
3442
|
-
*
|
|
3594
|
+
* The site of a call is none of the sites of this deployment. The message and the fix both say
|
|
3595
|
+
* which sites it serves, for a caller that only gets one of them.
|
|
3443
3596
|
*/
|
|
3444
|
-
function
|
|
3445
|
-
|
|
3597
|
+
function unknownSite(site, served) {
|
|
3598
|
+
const keys = [...served].map((key) => `"${key}"`).join(", ");
|
|
3599
|
+
return new HollwareError("invalid_input", `This Hollware serves no site "${site}": it serves ${keys}.`, `Pass one of ${keys}, the sites this deployment serves, as the site argument.`);
|
|
3446
3600
|
}
|
|
3447
3601
|
/**
|
|
3448
3602
|
* The options object of a call, without the keys that hold `undefined`, so that the handler sees
|
|
@@ -3488,7 +3642,7 @@ function forCaller(deps, requestId, method, error) {
|
|
|
3488
3642
|
return new HollwareError(known.code, known.message, known.fix);
|
|
3489
3643
|
}
|
|
3490
3644
|
/**
|
|
3491
|
-
* One call of an operation over RPC. In this order: the site is
|
|
3645
|
+
* One call of an operation over RPC. In this order: the site is one of this deployment, the
|
|
3492
3646
|
* input is built and parsed by the spec's schema, and the handler runs with
|
|
3493
3647
|
*
|
|
3494
3648
|
* - `view: 'published'`, so a draft is never read, and the principal `{ kind: 'binding', scopes:
|
|
@@ -3501,11 +3655,12 @@ function forCaller(deps, requestId, method, error) {
|
|
|
3501
3655
|
* a call that fails never opens a session.
|
|
3502
3656
|
*/
|
|
3503
3657
|
async function run(deps, env, method, site, schema, build, handler) {
|
|
3504
|
-
|
|
3658
|
+
const served = deps.sites.get(site);
|
|
3659
|
+
if (served === void 0) throw unknownSite(site, deps.sites.keys());
|
|
3505
3660
|
const input = parseInput(schema, build());
|
|
3506
3661
|
const requestId = crypto.randomUUID();
|
|
3507
3662
|
const context = {
|
|
3508
|
-
site:
|
|
3663
|
+
site: served,
|
|
3509
3664
|
principal: BINDING_PRINCIPAL,
|
|
3510
3665
|
view: "published",
|
|
3511
3666
|
store: createD1Store(env.DB.withSession("first-unconstrained")),
|
|
@@ -3676,7 +3831,7 @@ function writeLog(entry) {
|
|
|
3676
3831
|
*
|
|
3677
3832
|
* - the config is resolved with `resolveConfig`, so a broken `hollware.config.ts` throws its
|
|
3678
3833
|
* `ConfigError` here, and the Worker fails at startup with a message that says what to change.
|
|
3679
|
-
*
|
|
3834
|
+
* The deployment serves every site of the config, and a token, a grant or a call names one;
|
|
3680
3835
|
* - the MCP endpoint, whose tool schemas are converted once, and the Hono app of the gateway,
|
|
3681
3836
|
* which is `fetch`: MCP at `/mcp` behind the guard and the token check, and the other routes
|
|
3682
3837
|
* (`http/gateway.ts`);
|
|
@@ -3689,15 +3844,14 @@ function writeLog(entry) {
|
|
|
3689
3844
|
*/
|
|
3690
3845
|
function createHollwareWith(config, handlers, options = {}) {
|
|
3691
3846
|
const resolved = resolveConfig(config);
|
|
3692
|
-
const
|
|
3693
|
-
if (site === void 0) throw new Error("The resolved config has no site, which resolveConfig never lets through");
|
|
3847
|
+
const sites = resolved.sites;
|
|
3694
3848
|
const clock = options.clock ?? systemClock;
|
|
3695
3849
|
const mcp = createMcpEndpoint({
|
|
3696
3850
|
handlers,
|
|
3697
3851
|
serverInfo: SERVER_INFO,
|
|
3698
3852
|
log: writeLog
|
|
3699
3853
|
});
|
|
3700
|
-
const deliveryDeps = (env) => ({
|
|
3854
|
+
const deliveryDeps = (env, site) => ({
|
|
3701
3855
|
db: connect(env.DB),
|
|
3702
3856
|
site,
|
|
3703
3857
|
clock,
|
|
@@ -3709,12 +3863,12 @@ function createHollwareWith(config, handlers, options = {}) {
|
|
|
3709
3863
|
});
|
|
3710
3864
|
return {
|
|
3711
3865
|
fetch: createGateway({
|
|
3712
|
-
|
|
3866
|
+
sites,
|
|
3713
3867
|
mcp,
|
|
3714
3868
|
clock,
|
|
3715
3869
|
log: writeLog,
|
|
3716
3870
|
read: (env) => createRpc({
|
|
3717
|
-
|
|
3871
|
+
sites,
|
|
3718
3872
|
handlers,
|
|
3719
3873
|
clock,
|
|
3720
3874
|
log: writeLog
|
|
@@ -3723,28 +3877,37 @@ function createHollwareWith(config, handlers, options = {}) {
|
|
|
3723
3877
|
handlers
|
|
3724
3878
|
}),
|
|
3725
3879
|
scheduled: async (_controller, env, ctx) => {
|
|
3726
|
-
const
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
}).then(() => sweepOutbox({
|
|
3734
|
-
queue: env.EVENTS,
|
|
3735
|
-
deps
|
|
3736
|
-
})).then(() => void 0, (error) => {
|
|
3737
|
-
writeLog({
|
|
3738
|
-
kind: "cron_failed",
|
|
3739
|
-
message: String(error)
|
|
3880
|
+
const each = async (site) => {
|
|
3881
|
+
await runDueSchedules({
|
|
3882
|
+
site,
|
|
3883
|
+
store: createD1Store(env.DB, { clock }),
|
|
3884
|
+
handlers,
|
|
3885
|
+
clock,
|
|
3886
|
+
log: writeLog
|
|
3740
3887
|
});
|
|
3741
|
-
|
|
3888
|
+
await sweepOutbox({
|
|
3889
|
+
queue: env.EVENTS,
|
|
3890
|
+
deps: deliveryDeps(env, site)
|
|
3891
|
+
});
|
|
3892
|
+
};
|
|
3893
|
+
ctx.waitUntil((async () => {
|
|
3894
|
+
for (const site of sites.values()) await each(site).catch((error) => {
|
|
3895
|
+
writeLog({
|
|
3896
|
+
kind: "cron_failed",
|
|
3897
|
+
site: site.key,
|
|
3898
|
+
message: String(error)
|
|
3899
|
+
});
|
|
3900
|
+
});
|
|
3901
|
+
})());
|
|
3742
3902
|
},
|
|
3743
3903
|
queue: async (batch, env) => {
|
|
3744
|
-
await consumeEvents(batch.messages,
|
|
3904
|
+
await consumeEvents(batch.messages, (key) => {
|
|
3905
|
+
const site = sites.get(key);
|
|
3906
|
+
return site === void 0 ? void 0 : deliveryDeps(env, site);
|
|
3907
|
+
});
|
|
3745
3908
|
},
|
|
3746
3909
|
Hollware: createEntrypoint({
|
|
3747
|
-
|
|
3910
|
+
sites,
|
|
3748
3911
|
handlers,
|
|
3749
3912
|
clock,
|
|
3750
3913
|
log: writeLog
|