@botiverse/porthole 0.1.0 → 0.1.2

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.
Files changed (84) hide show
  1. package/dist/bridge/bridge.js +20 -6
  2. package/dist/bridge/types.d.ts +19 -3
  3. package/dist/compile/policy.d.ts +6 -5
  4. package/dist/compile/policy.js +10 -12
  5. package/dist/config/schema.d.ts +29 -0
  6. package/dist/config/schema.js +16 -1
  7. package/dist/config/validate.d.ts +3 -1
  8. package/dist/config/validate.js +55 -10
  9. package/dist/conformance/behavior.js +50 -0
  10. package/dist/conformance/index.d.ts +11 -5
  11. package/dist/conformance/index.js +2 -2
  12. package/dist/conformance/probes.d.ts +26 -2
  13. package/dist/conformance/probes.js +51 -40
  14. package/dist/conformance/served.d.ts +7 -0
  15. package/dist/conformance/served.js +31 -7
  16. package/dist/guide/guide.js +6 -2
  17. package/dist/host/create-host.js +46 -2
  18. package/dist/host/locate.js +6 -1
  19. package/dist/host/outcome.d.ts +25 -0
  20. package/dist/host/outcome.js +147 -0
  21. package/dist/host/types.d.ts +39 -2
  22. package/dist/index.d.ts +6 -3
  23. package/dist/index.js +4 -1
  24. package/dist/isolation/served.d.ts +3 -0
  25. package/dist/isolation/served.js +20 -0
  26. package/dist/isolation/types.d.ts +9 -0
  27. package/dist/isolation/types.js +2 -1
  28. package/dist/platform/types.d.ts +7 -0
  29. package/dist/platform/web.js +15 -2
  30. package/dist/presets/presets.d.ts +11 -3
  31. package/dist/presets/presets.js +17 -1
  32. package/dist/react/frame.d.ts +16 -0
  33. package/dist/react/frame.js +44 -10
  34. package/dist/server/crypto.d.ts +19 -0
  35. package/dist/server/crypto.js +144 -0
  36. package/dist/server/index.d.ts +12 -4
  37. package/dist/server/index.js +11 -3
  38. package/dist/server/keys.js +12 -9
  39. package/dist/server/naming.d.ts +17 -0
  40. package/dist/server/naming.js +34 -0
  41. package/dist/server/pages.d.ts +11 -6
  42. package/dist/server/pages.js +86 -12
  43. package/dist/server/server.js +191 -23
  44. package/dist/server/tickets.d.ts +24 -0
  45. package/dist/server/tickets.js +46 -0
  46. package/dist/server/types.d.ts +76 -5
  47. package/dist/shell/document.d.ts +3 -2
  48. package/dist/shell/document.js +4 -3
  49. package/dist/shell/plan.d.ts +2 -0
  50. package/dist/shell/plan.js +1 -0
  51. package/dist/shell/runtime.js +20 -4
  52. package/package.json +2 -9
  53. package/src/bridge/bridge.ts +28 -6
  54. package/src/bridge/types.ts +21 -3
  55. package/src/compile/policy.ts +10 -12
  56. package/src/config/schema.ts +38 -0
  57. package/src/config/validate.ts +63 -14
  58. package/src/conformance/behavior.ts +55 -0
  59. package/src/conformance/index.ts +18 -4
  60. package/src/conformance/probes.ts +88 -52
  61. package/src/conformance/served.ts +37 -8
  62. package/src/guide/guide.ts +8 -2
  63. package/src/host/create-host.ts +47 -2
  64. package/src/host/locate.ts +6 -1
  65. package/src/host/outcome.ts +177 -0
  66. package/src/host/types.ts +42 -2
  67. package/src/index.ts +19 -1
  68. package/src/isolation/served.ts +24 -0
  69. package/src/isolation/types.ts +15 -1
  70. package/src/platform/types.ts +7 -0
  71. package/src/platform/web.ts +15 -2
  72. package/src/presets/presets.ts +31 -3
  73. package/src/react/frame.tsx +78 -14
  74. package/src/server/crypto.ts +166 -0
  75. package/src/server/index.ts +20 -3
  76. package/src/server/keys.ts +21 -13
  77. package/src/server/naming.ts +42 -0
  78. package/src/server/pages.ts +106 -11
  79. package/src/server/server.ts +233 -23
  80. package/src/server/tickets.ts +65 -0
  81. package/src/server/types.ts +84 -7
  82. package/src/shell/document.ts +7 -6
  83. package/src/shell/plan.ts +3 -0
  84. package/src/shell/runtime.ts +20 -4
@@ -1,20 +1,94 @@
1
+ import { FRAME_STATUS_METHOD } from "../isolation/types.js";
1
2
  /**
2
- * The server's own pages on a content origin: why it shows nothing of the content. Plain, no
3
- * script, framable only by the host origins, never cached. Their words are the host-agnostic
4
- * mechanism's; a host that wants its own wording serves its own page in front.
3
+ * The server's own pages on a content origin: why it shows nothing of the content, and the page a
4
+ * framed app is entered through. Plain, framable only by the host origins, never cached. Each tells
5
+ * the page that frames it how loading went (`porthole/notifications/frame-status`, P040), posted to
6
+ * the host origins only, so a host can show its own fallback ("Open in a new tab"). Their words are
7
+ * the host-agnostic mechanism's; a host that wants its own wording serves its own page in front.
5
8
  */
6
9
  const escape = (text) => text.replace(/[&<>"']/gu, (char) => `&#${char.charCodeAt(0)};`);
7
10
  const STYLE = ":root{color-scheme:light dark;font-family:system-ui,sans-serif}body{margin:0;min-height:100vh;display:grid;place-items:center;background:Canvas;color:CanvasText}main{max-width:420px;padding:24px}h1{font-size:17px;margin:0 0 8px}p{margin:0;opacity:.75;line-height:1.5}";
8
- export function statusPage(status, title, detail, frameAncestors, method = "GET") {
9
- const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escape(title)}</title><style>${STYLE}</style></head><body><main data-porthole-status="${status}"><h1>${escape(title)}</h1><p>${escape(detail)}</p></main></body></html>`;
11
+ /** Where the pages' scripts are served, on every content origin. */
12
+ export const STATUS_SCRIPT_PATH = "/__porthole/status.js";
13
+ export const ENTER_SCRIPT_PATH = "/__porthole/enter.js";
14
+ /** Answers 204 when the request carries a good session for its origin's app, 401 otherwise. */
15
+ export const SESSION_PATH = "/__porthole/session";
16
+ const say = () => `const say = (params) => { if (window.parent === window) return; for (const origin of hosts) window.parent.postMessage({ jsonrpc: "2.0", method: ${JSON.stringify(FRAME_STATUS_METHOD)}, params }, origin); };`;
17
+ /** Tells the host the page is blocked, and why (from the page's data attributes). */
18
+ const STATUS_SCRIPT = `(() => {
19
+ const main = document.querySelector("[data-porthole-status]");
20
+ if (main === null) return;
21
+ const hosts = JSON.parse(main.dataset.portholeHosts || "[]");
22
+ ${say()}
23
+ const blocked = { state: "blocked", reason: main.dataset.portholeReason || "error", status: Number(main.dataset.portholeStatus) };
24
+ if (main.dataset.portholeCode) blocked.code = main.dataset.portholeCode;
25
+ say(blocked);
26
+ })();`;
27
+ /**
28
+ * Enters a framed app: says so, checks that the browser kept the session cookie this page's own
29
+ * answer set (WebKit keeps none in a cross-site frame, P025), then, once this page has loaded,
30
+ * goes on to the app, or says it is blocked and why.
31
+ */
32
+ const ENTER_SCRIPT = `(async () => {
33
+ const main = document.querySelector("[data-porthole-enter]");
34
+ if (main === null) return;
35
+ const hosts = JSON.parse(main.dataset.portholeHosts || "[]");
36
+ ${say()}
37
+ say({ state: "entering" });
38
+ let kept = false;
39
+ try {
40
+ kept = (await fetch(${JSON.stringify(SESSION_PATH)}, { credentials: "same-origin", cache: "no-store" })).ok;
41
+ } catch (_) {}
42
+ if (kept) {
43
+ // The host counts this page's load, then the app's (P040): leave only once this page has
44
+ // loaded, or a fast answer would leave first and its load would never be seen.
45
+ if (document.readyState !== "complete") {
46
+ await new Promise((resolve) => addEventListener("load", resolve, { once: true }));
47
+ }
48
+ await new Promise((resolve) => setTimeout(resolve, 0));
49
+ location.replace(main.dataset.portholeTarget || "/");
50
+ return;
51
+ }
52
+ say({ state: "blocked", reason: "cookies", status: 401 });
53
+ main.querySelector("h1").textContent = "Open this app in a new tab";
54
+ main.querySelector("p").textContent = "This browser keeps no sign-in for it inside a frame.";
55
+ })();`;
56
+ /** The pages' scripts, by path: served with the pages' own policy in mind (`script-src 'self'`). */
57
+ export function pageScript(pathname) {
58
+ const body = pathname === STATUS_SCRIPT_PATH
59
+ ? STATUS_SCRIPT
60
+ : pathname === ENTER_SCRIPT_PATH
61
+ ? ENTER_SCRIPT
62
+ : null;
63
+ return body === null
64
+ ? null
65
+ : new Response(body, {
66
+ headers: {
67
+ "content-type": "text/javascript; charset=utf-8",
68
+ "cache-control": "no-store",
69
+ "x-content-type-options": "nosniff",
70
+ },
71
+ });
72
+ }
73
+ const policy = (frameAncestors, connect) => `default-src 'none'; style-src 'unsafe-inline'; script-src 'self'; ${connect ? "connect-src 'self'; " : ""}base-uri 'none'; form-action 'none'; frame-ancestors ${frameAncestors}`;
74
+ const pageHeaders = (frameAncestors, connect) => ({
75
+ "content-type": "text/html; charset=utf-8",
76
+ "cache-control": "no-store",
77
+ "content-security-policy": policy(frameAncestors, connect),
78
+ "referrer-policy": "no-referrer",
79
+ "x-content-type-options": "nosniff",
80
+ });
81
+ export function statusPage(status, title, detail, frameAncestors, method = "GET", reason = "error", hostOrigins = [], code) {
82
+ const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escape(title)}</title><style>${STYLE}</style></head><body><main data-porthole-status="${status}" data-porthole-reason="${escape(reason)}"${code === undefined ? "" : ` data-porthole-code="${escape(code)}"`} data-porthole-hosts="${escape(JSON.stringify(hostOrigins))}"><h1>${escape(title)}</h1><p>${escape(detail)}</p></main><script src="${STATUS_SCRIPT_PATH}"></script></body></html>`;
10
83
  return new Response(method === "HEAD" ? null : body, {
11
84
  status,
12
- headers: {
13
- "content-type": "text/html; charset=utf-8",
14
- "cache-control": "no-store",
15
- "content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors ${frameAncestors}`,
16
- "referrer-policy": "no-referrer",
17
- "x-content-type-options": "nosniff",
18
- },
85
+ headers: pageHeaders(frameAncestors, false),
19
86
  });
20
87
  }
88
+ /** The page a framed app is entered through (its answer also sets the session cookie). */
89
+ export function enterPage(target, frameAncestors, hostOrigins, cookie) {
90
+ const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Opening the app</title><style>${STYLE}</style></head><body><main data-porthole-enter="" data-porthole-target="${escape(target)}" data-porthole-hosts="${escape(JSON.stringify(hostOrigins))}"><h1>Opening the app…</h1><p></p></main><script src="${ENTER_SCRIPT_PATH}"></script></body></html>`;
91
+ const headers = new Headers(pageHeaders(frameAncestors, true));
92
+ headers.set("set-cookie", cookie);
93
+ return new Response(body, { status: 200, headers });
94
+ }
@@ -1,8 +1,9 @@
1
1
  import { compile } from "../compile/compile.js";
2
- import { isExactOrigin, maybeSameSite, originMatchesSource, parseOrigin, } from "../isolation/served.js";
2
+ import { hostOriginProblem, maybeSameSite, originMatchesSource, parseOrigin, } from "../isolation/served.js";
3
3
  import { sandboxProxyDocument } from "../mcp/proxy.js";
4
4
  import { createKeys, newTicket, ticketId } from "./keys.js";
5
- import { statusPage } from "./pages.js";
5
+ import { isHostLabel } from "./naming.js";
6
+ import { enterPage, pageScript, SESSION_PATH, statusPage } from "./pages.js";
6
7
  import { memoryTicketStore } from "./tickets.js";
7
8
  /**
8
9
  * Paths on a content origin. Porthole's own live under `/__porthole/`; on a url-mode app's origin
@@ -14,6 +15,9 @@ const DOCUMENT_PATH = /^\/__porthole\/d\/([a-z2-7]{16})$/u;
14
15
  /** Where a url-mode app's ticket is taken, before the app's own landing path. */
15
16
  const ENTER_PATH = "/__porthole/enter";
16
17
  const LABEL = /^[a-z2-7]{24}$/u;
18
+ /** A host's opaque subject and reason codes. */
19
+ const SUBJECT_MAX = 512;
20
+ const CODE = /^[a-z0-9-]{1,40}$/u;
17
21
  const DNS_NAME = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/u;
18
22
  const NAME = /^[A-Za-z0-9._:-]{1,200}$/u;
19
23
  const DEFAULT_TICKET_SECONDS = 60;
@@ -144,6 +148,25 @@ export function createServer(options) {
144
148
  throw new TypeError("content.port must be a port number");
145
149
  }
146
150
  const keys = createKeys(options.secret);
151
+ const reserved = new Set(content.reserved ?? []);
152
+ for (const label of reserved) {
153
+ if (!isHostLabel(label)) {
154
+ throw new TypeError(`content.reserved: ${JSON.stringify(label)} is not a lowercase DNS label`);
155
+ }
156
+ }
157
+ /** The label a scope is served on: the host's naming, checked, or the keyed default. */
158
+ const nameOf = (scope) => {
159
+ if (content.name === undefined) {
160
+ return keys.label(scope);
161
+ }
162
+ const label = content.name(scope);
163
+ if (!isHostLabel(label) || reserved.has(label)) {
164
+ throw new TypeError(`content.name gave ${JSON.stringify(label)} for a scope: it must be one lowercase DNS label (a-z 0-9 -, at most 63, not xn--), not reserved`);
165
+ }
166
+ return label;
167
+ };
168
+ /** Whether a request's label may be one this server named. */
169
+ const ours = (label) => !reserved.has(label) && (content.name === undefined ? LABEL.test(label) : isHostLabel(label));
147
170
  const secure = content.scheme === "https";
148
171
  const defaultPort = secure ? 443 : 80;
149
172
  const portPart = content.port === undefined || content.port === defaultPort ? "" : `:${content.port}`;
@@ -151,13 +174,24 @@ export function createServer(options) {
151
174
  const contentSource = `${content.scheme}://*.${domain}${portPart}`;
152
175
  const sampleHost = `${keys.label("")}.${domain}`;
153
176
  const sampleOrigin = originOfLabel(keys.label(""));
177
+ const authorize = options.authorize;
178
+ const authorizeTtl = authorize?.ttlSeconds ?? 0;
179
+ if (!Number.isInteger(authorizeTtl) || authorizeTtl < 0 || authorizeTtl > 300) {
180
+ throw new TypeError("authorize.ttlSeconds must be an integer from 0 to 300");
181
+ }
182
+ const answers = new Map();
154
183
  if (options.hostOrigins.length === 0) {
155
184
  throw new TypeError("hostOrigins must name the origins that embed content");
156
185
  }
157
186
  for (const origin of options.hostOrigins) {
158
- const parsed = isExactOrigin(origin) ? parseOrigin(origin) : null;
187
+ const problem = hostOriginProblem(origin);
188
+ if (problem !== null) {
189
+ throw new TypeError(`hostOrigins: ${problem}`);
190
+ }
191
+ // A desktop app's own scheme (ferry://app) is never the same site as web content.
192
+ const parsed = parseOrigin(origin);
159
193
  if (parsed === null) {
160
- throw new TypeError(`hostOrigins: ${JSON.stringify(origin)} is not an exact origin`);
194
+ continue;
161
195
  }
162
196
  if (parsed.host === domain ||
163
197
  parsed.host.endsWith(`.${domain}`) ||
@@ -171,7 +205,55 @@ export function createServer(options) {
171
205
  compiled.set(name, compile(config));
172
206
  }
173
207
  const tickets = options.tickets ?? memoryTicketStore();
174
- const page = (status, title, detail, method) => statusPage(status, title, detail, frameAncestors, method);
208
+ /** A page saying why there is nothing to show, told to the host too (its reason, P040). */
209
+ const page = (status, title, detail, method, reason = status === 404 ? "not-found" : status === 502 ? "upstream" : "error", code) => statusPage(status, title, detail, frameAncestors, method, reason, options.hostOrigins, code);
210
+ /**
211
+ * The host's check on a request its cookie (or ticket) let in (P050): null to go on, or the
212
+ * page saying it's denied. Answers are reused only for the host's own `ttlSeconds`.
213
+ */
214
+ async function denied(request, access) {
215
+ if (authorize === undefined) {
216
+ return null;
217
+ }
218
+ const key = JSON.stringify([
219
+ keys.instanceKey(access.instance),
220
+ access.viewer,
221
+ access.subject ?? null,
222
+ access.expiresAt,
223
+ ]);
224
+ const at = Date.now();
225
+ let answer = authorizeTtl > 0 ? answers.get(key) : undefined;
226
+ if (answer === undefined || answer.until <= at) {
227
+ let given;
228
+ try {
229
+ given = await authorize.check({
230
+ scope: access.instance.scope,
231
+ instance: access.instance,
232
+ viewer: access.viewer,
233
+ anonymous: access.anonymous === true,
234
+ ...(access.subject === undefined ? {} : { subject: access.subject }),
235
+ request,
236
+ });
237
+ }
238
+ catch {
239
+ given = { allow: false };
240
+ }
241
+ answer = { answer: given, until: at + authorizeTtl * 1_000 };
242
+ if (authorizeTtl > 0) {
243
+ if (answers.size > 10_000) {
244
+ answers.clear();
245
+ }
246
+ answers.set(key, answer);
247
+ }
248
+ }
249
+ // Only an explicit allow lets the request on: anything else the check gives is a deny.
250
+ const given = answer.answer;
251
+ if (given?.allow === true) {
252
+ return null;
253
+ }
254
+ const code = typeof given?.code === "string" && CODE.test(given.code) ? given.code : undefined;
255
+ return page(403, "You can't open this anymore", "The app that showed it no longer lets you see it here.", request.method.toUpperCase(), "denied", code);
256
+ }
175
257
  /** The config a served document is served under, checked to fit it and this server. */
176
258
  function configFor(name, kind) {
177
259
  const each = compiled.get(name);
@@ -265,6 +347,8 @@ export function createServer(options) {
265
347
  i: record.instance,
266
348
  w: record.viewer,
267
349
  x: Date.now() + sessionSeconds * 1_000,
350
+ ...(record.anonymous === true ? { a: 1 } : {}),
351
+ ...(record.subject === undefined ? {} : { s: record.subject }),
268
352
  };
269
353
  const framed = request.headers.get("sec-fetch-dest") !== "document";
270
354
  return [
@@ -288,7 +372,8 @@ export function createServer(options) {
288
372
  if (!isRecord(session) ||
289
373
  typeof session.w !== "string" ||
290
374
  typeof session.x !== "number" ||
291
- session.x <= Date.now()) {
375
+ session.x <= Date.now() ||
376
+ (session.s !== undefined && typeof session.s !== "string")) {
292
377
  continue;
293
378
  }
294
379
  let instance;
@@ -298,12 +383,26 @@ export function createServer(options) {
298
383
  catch {
299
384
  continue;
300
385
  }
301
- if (keys.label(instance.scope) !== label ||
386
+ let named;
387
+ try {
388
+ named = nameOf(instance.scope);
389
+ }
390
+ catch {
391
+ continue;
392
+ }
393
+ if (named !== label ||
302
394
  (key === null ? instance.document.kind !== "external" : keys.instanceKey(instance) !== key)) {
303
395
  continue;
304
396
  }
305
397
  if (best === null || session.x > best.expiresAt) {
306
- best = { instance, viewer: session.w, origin: originOfLabel(label), expiresAt: session.x };
398
+ best = {
399
+ instance,
400
+ viewer: session.w,
401
+ origin: originOfLabel(label),
402
+ expiresAt: session.x,
403
+ ...(session.a === 1 ? { anonymous: true } : {}),
404
+ ...(typeof session.s === "string" ? { subject: session.s } : {}),
405
+ };
307
406
  }
308
407
  }
309
408
  return best;
@@ -319,7 +418,7 @@ export function createServer(options) {
319
418
  }
320
419
  try {
321
420
  const instance = cleanInstance(record.instance);
322
- return keys.label(instance.scope) === label ? { ...record, instance } : null;
421
+ return nameOf(instance.scope) === label ? { ...record, instance } : null;
323
422
  }
324
423
  catch {
325
424
  return null;
@@ -353,11 +452,17 @@ export function createServer(options) {
353
452
  viewer: record.viewer,
354
453
  origin: originOfLabel(label),
355
454
  expiresAt: Date.now() + (record.sessionSeconds ?? DEFAULT_SESSION_SECONDS) * 1_000,
455
+ ...(record.anonymous === true ? { anonymous: true } : {}),
456
+ ...(record.subject === undefined ? {} : { subject: record.subject }),
356
457
  };
357
458
  }
358
459
  access ??= accessOf(request, label, key);
359
460
  if (access === null) {
360
- return page(401, "This link has been used", "Content here opens once per link. Open it again from the app that showed it.", method);
461
+ return page(401, "This link has been used", "Content here opens once per link. Open it again from the app that showed it.", method, "session");
462
+ }
463
+ const refusal = await denied(request, access);
464
+ if (refusal !== null) {
465
+ return refusal;
361
466
  }
362
467
  const served = await documentOf(access.instance);
363
468
  if (served === null) {
@@ -376,13 +481,30 @@ export function createServer(options) {
376
481
  }
377
482
  const record = await take(url.searchParams.get("ticket"), label);
378
483
  if (record === null || record.instance.document.kind !== "external") {
379
- return page(403, "This link has been used", "A link into an app works once, for a short time. Open it again from the app that showed it.");
484
+ return page(403, "This link has been used", "A link into an app works once, for a short time. Open it again from the app that showed it.", method, "session");
485
+ }
486
+ const refusal = await denied(request, {
487
+ instance: record.instance,
488
+ viewer: record.viewer,
489
+ origin: originOfLabel(label),
490
+ expiresAt: record.expiresAt,
491
+ ...(record.subject === undefined ? {} : { subject: record.subject }),
492
+ });
493
+ if (refusal !== null) {
494
+ return refusal;
380
495
  }
381
496
  const target = new URL(record.instance.document.url);
497
+ const landing = `${target.pathname}${target.search}${target.hash}`;
498
+ // In a frame, a page enters the app: it can tell whether the browser kept the cookie (and tell
499
+ // the host), where a redirect would leave a frame without one showing a refusal. On a tab of
500
+ // its own, a redirect.
501
+ if (request.headers.get("sec-fetch-dest") !== "document") {
502
+ return enterPage(landing, frameAncestors, options.hostOrigins, sessionCookie(request, record));
503
+ }
382
504
  return new Response(null, {
383
505
  status: 303,
384
506
  headers: {
385
- location: `${target.pathname}${target.search}${target.hash}`,
507
+ location: landing,
386
508
  "set-cookie": sessionCookie(request, record),
387
509
  "cache-control": "no-store",
388
510
  "referrer-policy": "no-referrer",
@@ -425,7 +547,11 @@ export function createServer(options) {
425
547
  const method = request.method.toUpperCase();
426
548
  const access = accessOf(request, label, null);
427
549
  if (access === null) {
428
- return page(401, "Open this from the app", "This address shows an app only to the people the app opens it for.", method);
550
+ return page(401, "Open this from the app", "This address shows an app only to the people the app opens it for.", method, "session");
551
+ }
552
+ const refusal = await denied(request, access);
553
+ if (refusal !== null) {
554
+ return refusal;
429
555
  }
430
556
  if (options.upstream === undefined) {
431
557
  return page(502, "Nothing serves this app", "The server has no upstream for apps.", method);
@@ -450,42 +576,74 @@ export function createServer(options) {
450
576
  if (host !== domain && !host.endsWith(`.${domain}`)) {
451
577
  return null;
452
578
  }
453
- return { url, label: host.slice(0, Math.max(0, host.length - domain.length - 1)) };
579
+ const label = host.slice(0, Math.max(0, host.length - domain.length - 1));
580
+ // A reserved label is another server's namespace (`f` for `<x>.f.<domain>`): it and the names
581
+ // under it go to the host's own routes. Any other deeper name is ours to refuse.
582
+ if (reserved.has(label.split(".").at(-1) ?? "")) {
583
+ return null;
584
+ }
585
+ return { url, label };
454
586
  }
455
587
  return {
456
588
  contentSource,
457
- originFor: (scope) => originOfLabel(keys.label(scope)),
589
+ originFor: (scope) => originOfLabel(nameOf(scope)),
458
590
  async issue(instance, grant) {
459
591
  const clean = cleanInstance(instance);
460
592
  check(clean);
461
- if (typeof grant.viewer !== "string" ||
593
+ const anonymous = grant.anonymous === true;
594
+ if (anonymous) {
595
+ if (grant.viewer !== undefined) {
596
+ throw new TypeError("An anonymous grant names no viewer");
597
+ }
598
+ if (clean.document.kind === "external") {
599
+ throw new TypeError("A url-mode app is never opened anonymously (P046)");
600
+ }
601
+ }
602
+ else if (typeof grant.viewer !== "string" ||
462
603
  grant.viewer.length === 0 ||
463
604
  grant.viewer.length > 512) {
464
- throw new TypeError("A grant's viewer is 1 to 512 characters");
605
+ throw new TypeError("A grant's viewer is 1 to 512 characters (or the grant is anonymous)");
606
+ }
607
+ if (grant.subject !== undefined &&
608
+ (typeof grant.subject !== "string" || grant.subject.length > SUBJECT_MAX)) {
609
+ throw new TypeError(`A grant's subject is a string of at most ${SUBJECT_MAX} characters`);
465
610
  }
466
611
  const ttl = seconds(grant.ttlSeconds, DEFAULT_TICKET_SECONDS, MAX_TICKET_SECONDS, "ttlSeconds");
467
612
  const sessionSeconds = seconds(grant.sessionSeconds, DEFAULT_SESSION_SECONDS, MAX_SESSION_SECONDS, "sessionSeconds");
468
613
  const key = keys.instanceKey(clean);
469
- const probe = keys.sign({ i: clean, w: grant.viewer, x: Number.MAX_SAFE_INTEGER });
614
+ const viewer = anonymous ? "" : grant.viewer;
615
+ const probe = keys.sign({
616
+ i: clean,
617
+ w: viewer,
618
+ x: Number.MAX_SAFE_INTEGER,
619
+ ...(anonymous ? { a: 1 } : {}),
620
+ ...(grant.subject === undefined ? {} : { s: grant.subject }),
621
+ });
470
622
  if (cookieName(key).length + probe.length + 120 > MAX_COOKIE_BYTES) {
471
623
  throw new TypeError("This instance is too large to carry in a cookie (fewer origins?)");
472
624
  }
473
625
  const ticket = newTicket();
474
626
  await tickets.put(ticketId(ticket), {
475
627
  instance: clean,
476
- viewer: grant.viewer,
628
+ viewer,
477
629
  expiresAt: Date.now() + ttl * 1_000,
478
630
  ...(grant.sessionSeconds === undefined ? {} : { sessionSeconds }),
631
+ ...(anonymous ? { anonymous: true } : {}),
632
+ ...(grant.subject === undefined ? {} : { subject: grant.subject }),
479
633
  });
480
- const origin = originOfLabel(keys.label(clean.scope));
634
+ const origin = originOfLabel(nameOf(clean.scope));
481
635
  const path = clean.document.kind === "external"
482
636
  ? `${ENTER_PATH}?ticket=${ticket}`
483
637
  : `${PREFIX}d/${key}?ticket=${ticket}`;
484
- return { url: `${origin}${path}`, origin };
638
+ return { url: `${origin}${path}`, origin, reports: true };
485
639
  },
486
640
  async access(request) {
487
641
  const at = labelOf(request);
488
- return at === null || !LABEL.test(at.label) ? null : accessOf(request, at.label, null);
642
+ if (at === null || !ours(at.label)) {
643
+ return null;
644
+ }
645
+ const access = accessOf(request, at.label, null);
646
+ return access === null || (await denied(request, access)) !== null ? null : access;
489
647
  },
490
648
  async handle(request) {
491
649
  const at = labelOf(request);
@@ -495,12 +653,22 @@ export function createServer(options) {
495
653
  const { url, label } = at;
496
654
  const method = request.method.toUpperCase();
497
655
  try {
498
- if (!LABEL.test(label)) {
656
+ if (!ours(label)) {
499
657
  return page(404, "Nothing here", "This address names no content.", method);
500
658
  }
501
659
  if (url.pathname === ENTER_PATH) {
502
660
  return await enter(request, url, label);
503
661
  }
662
+ const script = pageScript(url.pathname);
663
+ if (script !== null) {
664
+ return script;
665
+ }
666
+ if (url.pathname === SESSION_PATH) {
667
+ // Whether this request's cookie opens its origin's app: the entry page asks.
668
+ const access = accessOf(request, label, null);
669
+ const status = access === null ? 401 : (await denied(request, access)) === null ? 204 : 403;
670
+ return new Response(null, { status, headers: { "cache-control": "no-store" } });
671
+ }
504
672
  const document = DOCUMENT_PATH.exec(url.pathname);
505
673
  if (document?.[1] !== undefined) {
506
674
  return await serveDocument(request, url, label, document[1]);
@@ -6,3 +6,27 @@ import type { TicketStore } from "./types.ts";
6
6
  export declare function memoryTicketStore(options?: {
7
7
  readonly now?: () => number;
8
8
  }): TicketStore;
9
+ /**
10
+ * The part of a Cloudflare D1 database (or any SQLite-like binding with the same shape) the ticket
11
+ * store uses: a prepared statement, bound, run or read once.
12
+ */
13
+ export interface D1Like {
14
+ prepare(query: string): D1StatementLike;
15
+ }
16
+ export interface D1StatementLike {
17
+ bind(...values: unknown[]): D1StatementLike;
18
+ run(): Promise<unknown>;
19
+ first<T = Record<string, unknown>>(): Promise<T | null>;
20
+ }
21
+ /** The table a D1 ticket store needs (run once, as a migration). */
22
+ export declare function d1TicketSchema(table?: string): string;
23
+ /**
24
+ * Tickets in a D1 database (P045): shared by every Worker isolate, and taken once by an atomic
25
+ * `DELETE … RETURNING` (one statement on D1's single primary, so two requests racing for a ticket
26
+ * can't both get it). Expired tickets are swept as new ones go in. Workers KV is no place for
27
+ * tickets: it is eventually consistent, so a ticket read in two places could be used twice.
28
+ */
29
+ export declare function d1TicketStore(db: D1Like, options?: {
30
+ readonly table?: string;
31
+ readonly now?: () => number;
32
+ }): TicketStore;
@@ -26,3 +26,49 @@ export function memoryTicketStore(options = {}) {
26
26
  },
27
27
  };
28
28
  }
29
+ const TABLE = /^[a-z_][a-z0-9_]{0,62}$/u;
30
+ /** The table a D1 ticket store needs (run once, as a migration). */
31
+ export function d1TicketSchema(table = "porthole_tickets") {
32
+ if (!TABLE.test(table)) {
33
+ throw new TypeError("A ticket table is named with a-z 0-9 _");
34
+ }
35
+ return `CREATE TABLE IF NOT EXISTS ${table} (id TEXT PRIMARY KEY, record TEXT NOT NULL, expires_at INTEGER NOT NULL);
36
+ CREATE INDEX IF NOT EXISTS ${table}_expires_at ON ${table} (expires_at);`;
37
+ }
38
+ /**
39
+ * Tickets in a D1 database (P045): shared by every Worker isolate, and taken once by an atomic
40
+ * `DELETE … RETURNING` (one statement on D1's single primary, so two requests racing for a ticket
41
+ * can't both get it). Expired tickets are swept as new ones go in. Workers KV is no place for
42
+ * tickets: it is eventually consistent, so a ticket read in two places could be used twice.
43
+ */
44
+ export function d1TicketStore(db, options = {}) {
45
+ const table = options.table ?? "porthole_tickets";
46
+ if (!TABLE.test(table)) {
47
+ throw new TypeError("A ticket table is named with a-z 0-9 _");
48
+ }
49
+ const now = options.now ?? Date.now;
50
+ return {
51
+ async put(id, record) {
52
+ await db.prepare(`DELETE FROM ${table} WHERE expires_at <= ?`).bind(now()).run();
53
+ await db
54
+ .prepare(`INSERT INTO ${table} (id, record, expires_at) VALUES (?, ?, ?)`)
55
+ .bind(id, JSON.stringify(record), record.expiresAt)
56
+ .run();
57
+ },
58
+ async take(id) {
59
+ const row = await db
60
+ .prepare(`DELETE FROM ${table} WHERE id = ? RETURNING record, expires_at`)
61
+ .bind(id)
62
+ .first();
63
+ if (row === null || row.expires_at <= now()) {
64
+ return null;
65
+ }
66
+ try {
67
+ return JSON.parse(row.record);
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ },
73
+ };
74
+ }