@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/index.mjs CHANGED
@@ -1,5 +1,5 @@
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";
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.3.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 one
145
- * site. A user who is no member of the site has no role there, and the token is unknown.
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 AND t.site_id = ?2`;
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 a token is `unknown`
197
- * without a query. Otherwise one query finds the token by the hash of the whole string, in this
198
- * site only, together with the role of its user; the answer is, in this order:
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 site. A token of another site is the same as no token, and
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
- if (!isPersonalToken(token)) return {
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), options.site).first();
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
- userId: row.user_id,
237
- tokenId: row.token_id,
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
- * another site, or that is no event, is acknowledged and dropped.
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, deps) {
1654
+ async function consumeEvents(messages, depsOf) {
1642
1655
  for (const message of messages) {
1643
1656
  const { body } = message;
1644
- if (!isEventMessage(body) || body.site !== deps.site.key) {
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. These are no operations, so no agent sees them over MCP, and a request needs an
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
- /** 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) => {
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/tokens", admin(async (c) => {
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, options.site)).orderBy(desc(tokens.createdAt), desc(tokens.id))).map((row) => ({
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 asked = Array.isArray(input["scopes"]) ? input["scopes"].filter((s) => typeof s === "string") : [];
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
- siteId: options.site,
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, options.site), eq(tokens.id, id), isNull(tokens.revokedAt))).returning({ id: tokens.id });
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, options.site), eq(tokens.id, id)));
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, options.site)).orderBy(members.createdAt, users.id),
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, options.site), isNull(invitations.usedAt), isNull(invitations.userId), gt(invitations.expiresAt, now))).orderBy(desc(invitations.createdAt))
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: options.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: options.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
- return options.protect(c, async (principal) => {
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
- 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
- }
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(options.site)));
2137
- app.get("/v1/site", async (c) => answer(c, await options.read(c.env).describeSite(options.site)));
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(options.site, c.req.param("collection"), {
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(options.site, c.req.param("collection"), c.req.param("group"))));
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(options.site, c.req.param("collection"), c.req.param("slug"), c.req.param("locale"), formats === void 0 ? {} : { formats: formats.split(",").flatMap((format) => format === "markdown" || format === "document" || format === "html" ? [format] : []) }));
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(site) {
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: `Hollware read API (${site})`,
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
- 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}`;
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}label input{width:auto;margin-right:.5rem}</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-D7tsWU3S.mjs");
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
- return await findSession(connect(c.env.DB), options.site, secret, options.clock.now());
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 oauth = options.oauth(c.env.HOLLWARE_PUBLIC_URL).authorizationServer.getOAuthApi(c.env);
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 server = options.oauth(c.env.HOLLWARE_PUBLIC_URL);
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 scope = grantableScopes(signedIn.role, request.scope);
2650
+ const membership = named ?? first;
2532
2651
  const props = {
2533
2652
  userId: signedIn.userId,
2534
- site: options.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: options.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 scopes = grantableScopes(signedIn.role, request.scope.length === 0 ? BASELINE : request.scope);
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 scope = grantableScopes(signedIn.role, form.getAll("scope").map(String));
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: options.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: options.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
- site: options.site,
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
- if (granted.props.site !== options.site) return refuse("other_site");
2713
- const role = await roleOf(env.DB, options.site, granted.props.userId);
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: options.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 { site, mcp, clock, log } = options;
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
- site: site.key,
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: site.key,
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) => protect(c, "mcp", async (principal) => {
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
- site: site.key,
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
- site: site.key,
3071
+ sites: siteKeys,
2920
3072
  clock,
2921
3073
  oauth,
2922
- protect: (c, proceed) => protect(c, "v1", 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
- site,
3078
+ sites,
2927
3079
  handlers: options.handlers,
2928
3080
  clock,
2929
3081
  log,
2930
- protect: (c, proceed) => protect(c, "v1", 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
- site: site.key,
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 not the site of this deployment. v0.1 serves one site, and the message
3442
- * and the fix both say which, for a caller that only gets one of them.
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 wrongSite(served) {
3445
- return new HollwareError("invalid_input", `This Hollware serves the site "${served}", not the one the call names.`, `Pass "${served}", the site this deployment serves, as the site argument.`);
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 the one of this deployment, the
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
- if (site !== deps.site.key) throw wrongSite(deps.site.key);
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: deps.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
- * v0.1 serves the one site of the config;
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 [site] = resolved.sites.values();
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
- site,
3866
+ sites,
3713
3867
  mcp,
3714
3868
  clock,
3715
3869
  log: writeLog,
3716
3870
  read: (env) => createRpc({
3717
- site,
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 deps = deliveryDeps(env);
3727
- ctx.waitUntil(runDueSchedules({
3728
- site,
3729
- store: createD1Store(env.DB, { clock }),
3730
- handlers,
3731
- clock,
3732
- log: writeLog
3733
- }).then(() => sweepOutbox({
3734
- queue: env.EVENTS,
3735
- deps
3736
- })).then(() => void 0, (error) => {
3737
- writeLog({
3738
- kind: "cron_failed",
3739
- message: String(error)
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, deliveryDeps(env));
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
- site,
3910
+ sites,
3748
3911
  handlers,
3749
3912
  clock,
3750
3913
  log: writeLog