@botiverse/porthole 0.1.1 → 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 (41) hide show
  1. package/dist/compile/policy.d.ts +3 -2
  2. package/dist/compile/policy.js +4 -3
  3. package/dist/config/schema.d.ts +8 -0
  4. package/dist/config/schema.js +5 -0
  5. package/dist/config/validate.js +15 -1
  6. package/dist/host/outcome.d.ts +1 -0
  7. package/dist/host/outcome.js +19 -6
  8. package/dist/host/types.d.ts +10 -5
  9. package/dist/index.d.ts +3 -2
  10. package/dist/index.js +2 -1
  11. package/dist/presets/presets.d.ts +3 -1
  12. package/dist/presets/presets.js +1 -0
  13. package/dist/server/crypto.d.ts +19 -0
  14. package/dist/server/crypto.js +144 -0
  15. package/dist/server/index.d.ts +12 -4
  16. package/dist/server/index.js +11 -3
  17. package/dist/server/keys.js +12 -9
  18. package/dist/server/naming.d.ts +17 -0
  19. package/dist/server/naming.js +34 -0
  20. package/dist/server/pages.d.ts +1 -1
  21. package/dist/server/pages.js +13 -5
  22. package/dist/server/server.js +162 -18
  23. package/dist/server/tickets.d.ts +24 -0
  24. package/dist/server/tickets.js +46 -0
  25. package/dist/server/types.d.ts +76 -5
  26. package/package.json +2 -1
  27. package/src/compile/policy.ts +4 -3
  28. package/src/config/schema.ts +10 -0
  29. package/src/config/validate.ts +16 -1
  30. package/src/host/outcome.ts +25 -7
  31. package/src/host/types.ts +14 -5
  32. package/src/index.ts +9 -1
  33. package/src/presets/presets.ts +4 -0
  34. package/src/server/crypto.ts +166 -0
  35. package/src/server/index.ts +20 -3
  36. package/src/server/keys.ts +21 -13
  37. package/src/server/naming.ts +42 -0
  38. package/src/server/pages.ts +13 -4
  39. package/src/server/server.ts +190 -20
  40. package/src/server/tickets.ts +65 -0
  41. package/src/server/types.ts +84 -7
@@ -20,12 +20,14 @@ const STATUS_SCRIPT = `(() => {
20
20
  if (main === null) return;
21
21
  const hosts = JSON.parse(main.dataset.portholeHosts || "[]");
22
22
  ${say()}
23
- say({ state: "blocked", reason: main.dataset.portholeReason || "error", status: Number(main.dataset.portholeStatus) });
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);
24
26
  })();`;
25
27
  /**
26
28
  * Enters a framed app: says so, checks that the browser kept the session cookie this page's own
27
- * answer set (WebKit keeps none in a cross-site frame, P025), then goes on to the app, or says it
28
- * is blocked and why.
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.
29
31
  */
30
32
  const ENTER_SCRIPT = `(async () => {
31
33
  const main = document.querySelector("[data-porthole-enter]");
@@ -38,6 +40,12 @@ const ENTER_SCRIPT = `(async () => {
38
40
  kept = (await fetch(${JSON.stringify(SESSION_PATH)}, { credentials: "same-origin", cache: "no-store" })).ok;
39
41
  } catch (_) {}
40
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));
41
49
  location.replace(main.dataset.portholeTarget || "/");
42
50
  return;
43
51
  }
@@ -70,8 +78,8 @@ const pageHeaders = (frameAncestors, connect) => ({
70
78
  "referrer-policy": "no-referrer",
71
79
  "x-content-type-options": "nosniff",
72
80
  });
73
- export function statusPage(status, title, detail, frameAncestors, method = "GET", reason = "error", hostOrigins = []) {
74
- 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)}" data-porthole-hosts="${escape(JSON.stringify(hostOrigins))}"><h1>${escape(title)}</h1><p>${escape(detail)}</p></main><script src="${STATUS_SCRIPT_PATH}"></script></body></html>`;
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>`;
75
83
  return new Response(method === "HEAD" ? null : body, {
76
84
  status,
77
85
  headers: pageHeaders(frameAncestors, false),
@@ -2,6 +2,7 @@ import { compile } from "../compile/compile.js";
2
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 { isHostLabel } from "./naming.js";
5
6
  import { enterPage, pageScript, SESSION_PATH, statusPage } from "./pages.js";
6
7
  import { memoryTicketStore } from "./tickets.js";
7
8
  /**
@@ -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,6 +174,12 @@ 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
  }
@@ -177,7 +206,54 @@ export function createServer(options) {
177
206
  }
178
207
  const tickets = options.tickets ?? memoryTicketStore();
179
208
  /** A page saying why there is nothing to show, told to the host too (its reason, P040). */
180
- const page = (status, title, detail, method, reason = status === 404 ? "not-found" : status === 502 ? "upstream" : "error") => statusPage(status, title, detail, frameAncestors, method, reason, options.hostOrigins);
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
+ }
181
257
  /** The config a served document is served under, checked to fit it and this server. */
182
258
  function configFor(name, kind) {
183
259
  const each = compiled.get(name);
@@ -271,6 +347,8 @@ export function createServer(options) {
271
347
  i: record.instance,
272
348
  w: record.viewer,
273
349
  x: Date.now() + sessionSeconds * 1_000,
350
+ ...(record.anonymous === true ? { a: 1 } : {}),
351
+ ...(record.subject === undefined ? {} : { s: record.subject }),
274
352
  };
275
353
  const framed = request.headers.get("sec-fetch-dest") !== "document";
276
354
  return [
@@ -294,7 +372,8 @@ export function createServer(options) {
294
372
  if (!isRecord(session) ||
295
373
  typeof session.w !== "string" ||
296
374
  typeof session.x !== "number" ||
297
- session.x <= Date.now()) {
375
+ session.x <= Date.now() ||
376
+ (session.s !== undefined && typeof session.s !== "string")) {
298
377
  continue;
299
378
  }
300
379
  let instance;
@@ -304,12 +383,26 @@ export function createServer(options) {
304
383
  catch {
305
384
  continue;
306
385
  }
307
- 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 ||
308
394
  (key === null ? instance.document.kind !== "external" : keys.instanceKey(instance) !== key)) {
309
395
  continue;
310
396
  }
311
397
  if (best === null || session.x > best.expiresAt) {
312
- 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
+ };
313
406
  }
314
407
  }
315
408
  return best;
@@ -325,7 +418,7 @@ export function createServer(options) {
325
418
  }
326
419
  try {
327
420
  const instance = cleanInstance(record.instance);
328
- return keys.label(instance.scope) === label ? { ...record, instance } : null;
421
+ return nameOf(instance.scope) === label ? { ...record, instance } : null;
329
422
  }
330
423
  catch {
331
424
  return null;
@@ -359,12 +452,18 @@ export function createServer(options) {
359
452
  viewer: record.viewer,
360
453
  origin: originOfLabel(label),
361
454
  expiresAt: Date.now() + (record.sessionSeconds ?? DEFAULT_SESSION_SECONDS) * 1_000,
455
+ ...(record.anonymous === true ? { anonymous: true } : {}),
456
+ ...(record.subject === undefined ? {} : { subject: record.subject }),
362
457
  };
363
458
  }
364
459
  access ??= accessOf(request, label, key);
365
460
  if (access === null) {
366
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");
367
462
  }
463
+ const refusal = await denied(request, access);
464
+ if (refusal !== null) {
465
+ return refusal;
466
+ }
368
467
  const served = await documentOf(access.instance);
369
468
  if (served === null) {
370
469
  return page(404, "Not found", "This page, or this version of it, doesn't exist.", method);
@@ -384,6 +483,16 @@ export function createServer(options) {
384
483
  if (record === null || record.instance.document.kind !== "external") {
385
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");
386
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;
495
+ }
387
496
  const target = new URL(record.instance.document.url);
388
497
  const landing = `${target.pathname}${target.search}${target.hash}`;
389
498
  // In a frame, a page enters the app: it can tell whether the browser kept the cookie (and tell
@@ -440,6 +549,10 @@ export function createServer(options) {
440
549
  if (access === null) {
441
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");
442
551
  }
552
+ const refusal = await denied(request, access);
553
+ if (refusal !== null) {
554
+ return refusal;
555
+ }
443
556
  if (options.upstream === undefined) {
444
557
  return page(502, "Nothing serves this app", "The server has no upstream for apps.", method);
445
558
  }
@@ -463,34 +576,62 @@ export function createServer(options) {
463
576
  if (host !== domain && !host.endsWith(`.${domain}`)) {
464
577
  return null;
465
578
  }
466
- 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 };
467
586
  }
468
587
  return {
469
588
  contentSource,
470
- originFor: (scope) => originOfLabel(keys.label(scope)),
589
+ originFor: (scope) => originOfLabel(nameOf(scope)),
471
590
  async issue(instance, grant) {
472
591
  const clean = cleanInstance(instance);
473
592
  check(clean);
474
- 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" ||
475
603
  grant.viewer.length === 0 ||
476
604
  grant.viewer.length > 512) {
477
- 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`);
478
610
  }
479
611
  const ttl = seconds(grant.ttlSeconds, DEFAULT_TICKET_SECONDS, MAX_TICKET_SECONDS, "ttlSeconds");
480
612
  const sessionSeconds = seconds(grant.sessionSeconds, DEFAULT_SESSION_SECONDS, MAX_SESSION_SECONDS, "sessionSeconds");
481
613
  const key = keys.instanceKey(clean);
482
- 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
+ });
483
622
  if (cookieName(key).length + probe.length + 120 > MAX_COOKIE_BYTES) {
484
623
  throw new TypeError("This instance is too large to carry in a cookie (fewer origins?)");
485
624
  }
486
625
  const ticket = newTicket();
487
626
  await tickets.put(ticketId(ticket), {
488
627
  instance: clean,
489
- viewer: grant.viewer,
628
+ viewer,
490
629
  expiresAt: Date.now() + ttl * 1_000,
491
630
  ...(grant.sessionSeconds === undefined ? {} : { sessionSeconds }),
631
+ ...(anonymous ? { anonymous: true } : {}),
632
+ ...(grant.subject === undefined ? {} : { subject: grant.subject }),
492
633
  });
493
- const origin = originOfLabel(keys.label(clean.scope));
634
+ const origin = originOfLabel(nameOf(clean.scope));
494
635
  const path = clean.document.kind === "external"
495
636
  ? `${ENTER_PATH}?ticket=${ticket}`
496
637
  : `${PREFIX}d/${key}?ticket=${ticket}`;
@@ -498,7 +639,11 @@ export function createServer(options) {
498
639
  },
499
640
  async access(request) {
500
641
  const at = labelOf(request);
501
- 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;
502
647
  },
503
648
  async handle(request) {
504
649
  const at = labelOf(request);
@@ -508,7 +653,7 @@ export function createServer(options) {
508
653
  const { url, label } = at;
509
654
  const method = request.method.toUpperCase();
510
655
  try {
511
- if (!LABEL.test(label)) {
656
+ if (!ours(label)) {
512
657
  return page(404, "Nothing here", "This address names no content.", method);
513
658
  }
514
659
  if (url.pathname === ENTER_PATH) {
@@ -520,10 +665,9 @@ export function createServer(options) {
520
665
  }
521
666
  if (url.pathname === SESSION_PATH) {
522
667
  // Whether this request's cookie opens its origin's app: the entry page asks.
523
- return new Response(null, {
524
- status: accessOf(request, label, null) === null ? 401 : 204,
525
- headers: { "cache-control": "no-store" },
526
- });
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" } });
527
671
  }
528
672
  const document = DOCUMENT_PATH.exec(url.pathname);
529
673
  if (document?.[1] !== undefined) {
@@ -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
+ }
@@ -10,11 +10,41 @@ export interface ContentDomain {
10
10
  /** `http` only for `localhost`. */
11
11
  readonly scheme: "https" | "http";
12
12
  readonly port?: number;
13
+ /**
14
+ * The label under `domain` a storage scope is served on (P047). Default: 24 base32 characters
15
+ * keyed from the scope with the server's secret (stable, not guessable). A host's own (flat
16
+ * names like `a-<id>`) must return one lowercase DNS label, the same for the same scope, a
17
+ * different one for a different scope, and never a `reserved` one.
18
+ */
19
+ readonly name?: (scope: string) => string;
20
+ /**
21
+ * Labels this server never serves or names (another server's namespace under the same domain,
22
+ * `f` for `<instance>.f.<domain>`, or `www`). Requests for them get null (the host's routes).
23
+ */
24
+ readonly reserved?: readonly string[];
13
25
  }
14
- /** Who may open an instance, decided by the host before it calls `issue`. */
15
- export interface Grant {
26
+ /**
27
+ * Who may open an instance, decided by the host before it calls `issue`: a viewer the host
28
+ * knows, or (a public link) nobody in particular. Porthole never interprets either: identity and
29
+ * authorization policy are the host's (P050).
30
+ */
31
+ export type Grant = ({
16
32
  /** The host's id for the viewer: recorded with the ticket, never shown to content. */
17
33
  readonly viewer: string;
34
+ readonly anonymous?: never;
35
+ } | {
36
+ /**
37
+ * A public link: no viewer (P046). Only for documents (a shell, an artifact version, the
38
+ * sandbox proxy), never for a url-mode app.
39
+ */
40
+ readonly anonymous: true;
41
+ readonly viewer?: never;
42
+ }) & {
43
+ /**
44
+ * The host's own opaque claim, carried in the ticket and the session cookie and handed back to
45
+ * `authorize` on every request (a session id, a share id). At most 512 characters.
46
+ */
47
+ readonly subject?: string;
18
48
  /** How long the ticket is good for, seconds (default 60). */
19
49
  readonly ttlSeconds?: number;
20
50
  /**
@@ -22,10 +52,13 @@ export interface Grant {
22
52
  * url-mode app's every request, rides on.
23
53
  */
24
54
  readonly sessionSeconds?: number;
25
- }
55
+ };
26
56
  export interface TicketRecord {
27
57
  readonly instance: InstanceRef;
58
+ /** The grant's viewer; "" for an anonymous grant. */
28
59
  readonly viewer: string;
60
+ readonly anonymous?: boolean;
61
+ readonly subject?: string;
29
62
  /** Epoch milliseconds. */
30
63
  readonly expiresAt: number;
31
64
  /** The grant's `sessionSeconds`, if it set one. */
@@ -46,7 +79,11 @@ export interface ArtifactStore {
46
79
  /** What a request's cookie lets in: one instance, for one viewer, on one origin, until a time. */
47
80
  export interface Access {
48
81
  readonly instance: InstanceRef;
82
+ /** The grant's viewer; "" for an anonymous one. */
49
83
  readonly viewer: string;
84
+ readonly anonymous?: boolean;
85
+ /** The grant's opaque subject, if it had one. */
86
+ readonly subject?: string;
50
87
  /** The content origin the access is for. */
51
88
  readonly origin: string;
52
89
  /** When the cookie stops being good, epoch milliseconds. */
@@ -58,6 +95,37 @@ export interface Access {
58
95
  * Porthole's cookie; the answer gets `frame-ancestors` added, and loses any cookie named as ours.
59
96
  */
60
97
  export type Upstream = (request: Request, access: Access) => Response | Promise<Response>;
98
+ /** What `authorize` is asked about: a request the cookie (or ticket) let in. */
99
+ export interface AuthorizeRequest {
100
+ readonly scope: string;
101
+ readonly instance: InstanceRef;
102
+ readonly viewer: string;
103
+ readonly anonymous: boolean;
104
+ readonly subject?: string;
105
+ /** The request itself (its path, method; never its Porthole cookie's value). */
106
+ readonly request: Request;
107
+ }
108
+ /** The host's answer: let it in, or not (with a short reason code for the host's own UI). */
109
+ export type AuthorizeAnswer = {
110
+ readonly allow: true;
111
+ } | {
112
+ readonly allow: false;
113
+ /** A short code (`revoked`, `left-workspace`): lowercase letters, digits and dashes. */
114
+ readonly code?: string;
115
+ };
116
+ /**
117
+ * The host's check on every request its cookie lets in (P050): revocation, membership,
118
+ * liveness. Porthole calls it after the cookie (or ticket) verifies and before anything of the
119
+ * content is served; a deny is a 403 page that tells the host why (`blocked: denied`).
120
+ */
121
+ export interface Authorizer {
122
+ check(request: AuthorizeRequest): AuthorizeAnswer | Promise<AuthorizeAnswer>;
123
+ /**
124
+ * How long an answer may be reused for the same session and scope, seconds. Default 0: asked
125
+ * on every request. A host opts into a short time for apps with many requests.
126
+ */
127
+ readonly ttlSeconds?: number;
128
+ }
61
129
  export interface ServerOptions {
62
130
  readonly content: ContentDomain;
63
131
  /** At least 32 random bytes: keys the labels and signs tickets and cookies. */
@@ -73,6 +141,8 @@ export interface ServerOptions {
73
141
  readonly tickets?: TicketStore;
74
142
  /** Url mode: where an external instance's requests go once let in. Absent: nothing serves them. */
75
143
  readonly upstream?: Upstream;
144
+ /** The host's per-request check (P050). Absent: a good cookie is enough until it ends. */
145
+ readonly authorize?: Authorizer;
76
146
  }
77
147
  export interface PortholeServer {
78
148
  /**
@@ -88,8 +158,9 @@ export interface PortholeServer {
88
158
  */
89
159
  issue(instance: InstanceRef, grant: Grant): Promise<FrameLocation>;
90
160
  /**
91
- * The access a request's cookie carries for a url-mode app on the request's origin, or null:
92
- * for a host's own WebSocket upgrade path, which a fetch-style handler can't take.
161
+ * The access a request's cookie carries for a url-mode app on the request's origin, or null
162
+ * (no good cookie, or the host's check denies it): for a host's own WebSocket upgrade path,
163
+ * which a fetch-style handler can't take.
93
164
  */
94
165
  access(request: Request): Promise<Access | null>;
95
166
  /** The frame source every content origin matches (`https://*.example-usercontent.com`). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botiverse/porthole",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "A host-side sandbox for agent-made HTML: isolation, egress hardening, a themed shell, gated host functions, and the agent manual, from one config.",
5
5
  "license": "Apache-2.0",
6
6
  "files": [
@@ -41,6 +41,7 @@
41
41
  "@types/react": "19.3.0",
42
42
  "@types/react-dom": "19.3.0",
43
43
  "esbuild": "0.28.2",
44
+ "miniflare": "4.20260730.0",
44
45
  "react": "19.3.0",
45
46
  "react-dom": "19.3.0",
46
47
  "typescript": "7.0.2"
@@ -100,10 +100,11 @@ export function sandboxTokens(config: PortholeConfig): string[] {
100
100
 
101
101
  /**
102
102
  * The frame's `allow` attribute (Permissions Policy). Empty: no camera, microphone, geolocation,
103
- * clipboard, fullscreen or other powerful feature is delegated to the frame.
103
+ * clipboard, fullscreen or other powerful feature is delegated to the frame; a url-mode config
104
+ * may delegate clipboard writes and full screen to the app's own origin (P049).
104
105
  */
105
- export function allowAttribute(_config: PortholeConfig): string {
106
- return "";
106
+ export function allowAttribute(config: PortholeConfig): string {
107
+ return config.mode === "url" ? (config.permissions ?? []).join("; ") : "";
107
108
  }
108
109
 
109
110
  /**
@@ -116,6 +116,14 @@ export const URL_SANDBOX_EXTRAS = [
116
116
 
117
117
  export type UrlSandboxExtra = (typeof URL_SANDBOX_EXTRAS)[number];
118
118
 
119
+ /**
120
+ * Powerful features a url-mode frame may be delegated, by the host's choice (P049): writing the
121
+ * clipboard (a "copy" button in the person's app) and going full screen. Nothing for other modes.
122
+ */
123
+ export const URL_PERMISSIONS = ["clipboard-write", "fullscreen"] as const;
124
+
125
+ export type UrlPermission = (typeof URL_PERMISSIONS)[number];
126
+
119
127
  /** Where the host shows the content, for the manual's size rules. */
120
128
  export interface LayoutConfig {
121
129
  /** The narrowest the frame ever is (a small phone). */
@@ -165,4 +173,6 @@ export interface PortholeConfig {
165
173
  readonly embedder: EmbedderConfig;
166
174
  /** Url mode only: sandbox tokens added to its baseline (`URL_SANDBOX_EXTRAS`). */
167
175
  readonly sandbox?: readonly UrlSandboxExtra[];
176
+ /** Url mode only: features delegated to the frame (`URL_PERMISSIONS`); none by default. */
177
+ readonly permissions?: readonly UrlPermission[];
168
178
  }
@@ -1,5 +1,5 @@
1
1
  import { CAPABILITY_NAMES, isCapabilityName, type Gate } from "./capabilities.ts";
2
- import { URL_SANDBOX_EXTRAS, type PortholeConfig } from "./schema.ts";
2
+ import { URL_PERMISSIONS, URL_SANDBOX_EXTRAS, type PortholeConfig } from "./schema.ts";
3
3
 
4
4
  /**
5
5
  * Checks a config before anything is derived from it. Every string that ends up in a CSP, an
@@ -130,6 +130,7 @@ export function validateConfig(input: unknown): PortholeConfig {
130
130
  "contentOrigins",
131
131
  "embedder",
132
132
  "sandbox",
133
+ "permissions",
133
134
  ],
134
135
  "config",
135
136
  );
@@ -314,5 +315,19 @@ export function validateConfig(input: unknown): PortholeConfig {
314
315
  });
315
316
  }
316
317
 
318
+ if (config.permissions !== undefined) {
319
+ if (config.mode !== "url") {
320
+ fail("config.permissions", "only url mode may be delegated features");
321
+ }
322
+ if (!Array.isArray(config.permissions)) {
323
+ fail("config.permissions", "must be an array of features");
324
+ }
325
+ (config.permissions as unknown[]).forEach((feature, index) => {
326
+ if (!(URL_PERMISSIONS as readonly unknown[]).includes(feature)) {
327
+ fail(`config.permissions[${index}]`, `must be one of ${URL_PERMISSIONS.join(", ")}`);
328
+ }
329
+ });
330
+ }
331
+
317
332
  return input as PortholeConfig;
318
333
  }