@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
@@ -9,10 +9,18 @@ import {
9
9
  import type { FrameLocation, InstanceRef, ServedDocument } from "../isolation/types.ts";
10
10
  import { sandboxProxyDocument } from "../mcp/proxy.ts";
11
11
  import { createKeys, newTicket, ticketId } from "./keys.ts";
12
+ import { isHostLabel } from "./naming.ts";
12
13
  import { enterPage, pageScript, SESSION_PATH, statusPage } from "./pages.ts";
13
14
  import type { FrameBlock } from "../host/types.ts";
14
15
  import { memoryTicketStore } from "./tickets.ts";
15
- import type { Access, PortholeServer, ServerOptions, TicketRecord, TicketStore } from "./types.ts";
16
+ import type {
17
+ Access,
18
+ AuthorizeAnswer,
19
+ PortholeServer,
20
+ ServerOptions,
21
+ TicketRecord,
22
+ TicketStore,
23
+ } from "./types.ts";
16
24
 
17
25
  /**
18
26
  * Paths on a content origin. Porthole's own live under `/__porthole/`; on a url-mode app's origin
@@ -25,6 +33,9 @@ const DOCUMENT_PATH = /^\/__porthole\/d\/([a-z2-7]{16})$/u;
25
33
  const ENTER_PATH = "/__porthole/enter";
26
34
 
27
35
  const LABEL = /^[a-z2-7]{24}$/u;
36
+ /** A host's opaque subject and reason codes. */
37
+ const SUBJECT_MAX = 512;
38
+ const CODE = /^[a-z0-9-]{1,40}$/u;
28
39
  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;
29
40
  const NAME = /^[A-Za-z0-9._:-]{1,200}$/u;
30
41
 
@@ -35,11 +46,16 @@ const MAX_SESSION_SECONDS = 30 * 24 * 3_600;
35
46
  /** Browsers keep a cookie of up to 4 KB, name and attributes included. */
36
47
  const MAX_COOKIE_BYTES = 3_800;
37
48
 
38
- /** What a session cookie carries, signed: the instance, its viewer, and when it stops. */
49
+ /**
50
+ * What a session cookie carries, signed: the instance, its viewer (or anonymous), the host's
51
+ * opaque subject, and when it stops.
52
+ */
39
53
  interface Session {
40
54
  readonly i: InstanceRef;
41
55
  readonly w: string;
42
56
  readonly x: number;
57
+ readonly a?: 1;
58
+ readonly s?: string;
43
59
  }
44
60
 
45
61
  const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -174,6 +190,30 @@ export function createServer(options: ServerOptions): PortholeServer {
174
190
  throw new TypeError("content.port must be a port number");
175
191
  }
176
192
  const keys = createKeys(options.secret);
193
+ const reserved = new Set(content.reserved ?? []);
194
+ for (const label of reserved) {
195
+ if (!isHostLabel(label)) {
196
+ throw new TypeError(
197
+ `content.reserved: ${JSON.stringify(label)} is not a lowercase DNS label`,
198
+ );
199
+ }
200
+ }
201
+ /** The label a scope is served on: the host's naming, checked, or the keyed default. */
202
+ const nameOf = (scope: string): string => {
203
+ if (content.name === undefined) {
204
+ return keys.label(scope);
205
+ }
206
+ const label = content.name(scope);
207
+ if (!isHostLabel(label) || reserved.has(label)) {
208
+ throw new TypeError(
209
+ `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`,
210
+ );
211
+ }
212
+ return label;
213
+ };
214
+ /** Whether a request's label may be one this server named. */
215
+ const ours = (label: string): boolean =>
216
+ !reserved.has(label) && (content.name === undefined ? LABEL.test(label) : isHostLabel(label));
177
217
  const secure = content.scheme === "https";
178
218
  const defaultPort = secure ? 443 : 80;
179
219
  const portPart =
@@ -183,6 +223,12 @@ export function createServer(options: ServerOptions): PortholeServer {
183
223
  const contentSource = `${content.scheme}://*.${domain}${portPart}`;
184
224
  const sampleHost = `${keys.label("")}.${domain}`;
185
225
  const sampleOrigin = originOfLabel(keys.label(""));
226
+ const authorize = options.authorize;
227
+ const authorizeTtl = authorize?.ttlSeconds ?? 0;
228
+ if (!Number.isInteger(authorizeTtl) || authorizeTtl < 0 || authorizeTtl > 300) {
229
+ throw new TypeError("authorize.ttlSeconds must be an integer from 0 to 300");
230
+ }
231
+ const answers = new Map<string, { readonly answer: AuthorizeAnswer; readonly until: number }>();
186
232
 
187
233
  if (options.hostOrigins.length === 0) {
188
234
  throw new TypeError("hostOrigins must name the origins that embed content");
@@ -222,8 +268,63 @@ export function createServer(options: ServerOptions): PortholeServer {
222
268
  detail: string,
223
269
  method?: string,
224
270
  reason: FrameBlock = status === 404 ? "not-found" : status === 502 ? "upstream" : "error",
271
+ code?: string,
225
272
  ): Response =>
226
- statusPage(status, title, detail, frameAncestors, method, reason, options.hostOrigins);
273
+ statusPage(status, title, detail, frameAncestors, method, reason, options.hostOrigins, code);
274
+
275
+ /**
276
+ * The host's check on a request its cookie (or ticket) let in (P050): null to go on, or the
277
+ * page saying it's denied. Answers are reused only for the host's own `ttlSeconds`.
278
+ */
279
+ async function denied(request: Request, access: Access): Promise<Response | null> {
280
+ if (authorize === undefined) {
281
+ return null;
282
+ }
283
+ const key = JSON.stringify([
284
+ keys.instanceKey(access.instance),
285
+ access.viewer,
286
+ access.subject ?? null,
287
+ access.expiresAt,
288
+ ]);
289
+ const at = Date.now();
290
+ let answer = authorizeTtl > 0 ? answers.get(key) : undefined;
291
+ if (answer === undefined || answer.until <= at) {
292
+ let given: AuthorizeAnswer;
293
+ try {
294
+ given = await authorize.check({
295
+ scope: access.instance.scope,
296
+ instance: access.instance,
297
+ viewer: access.viewer,
298
+ anonymous: access.anonymous === true,
299
+ ...(access.subject === undefined ? {} : { subject: access.subject }),
300
+ request,
301
+ });
302
+ } catch {
303
+ given = { allow: false };
304
+ }
305
+ answer = { answer: given, until: at + authorizeTtl * 1_000 };
306
+ if (authorizeTtl > 0) {
307
+ if (answers.size > 10_000) {
308
+ answers.clear();
309
+ }
310
+ answers.set(key, answer);
311
+ }
312
+ }
313
+ // Only an explicit allow lets the request on: anything else the check gives is a deny.
314
+ const given = answer.answer as Partial<{ allow: unknown; code: unknown }> | null | undefined;
315
+ if (given?.allow === true) {
316
+ return null;
317
+ }
318
+ const code = typeof given?.code === "string" && CODE.test(given.code) ? given.code : undefined;
319
+ return page(
320
+ 403,
321
+ "You can't open this anymore",
322
+ "The app that showed it no longer lets you see it here.",
323
+ request.method.toUpperCase(),
324
+ "denied",
325
+ code,
326
+ );
327
+ }
227
328
 
228
329
  /** The config a served document is served under, checked to fit it and this server. */
229
330
  function configFor(name: string, kind: keyof typeof MODES): CompiledConfig {
@@ -331,6 +432,8 @@ export function createServer(options: ServerOptions): PortholeServer {
331
432
  i: record.instance,
332
433
  w: record.viewer,
333
434
  x: Date.now() + sessionSeconds * 1_000,
435
+ ...(record.anonymous === true ? { a: 1 as const } : {}),
436
+ ...(record.subject === undefined ? {} : { s: record.subject }),
334
437
  };
335
438
  const framed = request.headers.get("sec-fetch-dest") !== "document";
336
439
  return [
@@ -356,7 +459,8 @@ export function createServer(options: ServerOptions): PortholeServer {
356
459
  !isRecord(session) ||
357
460
  typeof session.w !== "string" ||
358
461
  typeof session.x !== "number" ||
359
- session.x <= Date.now()
462
+ session.x <= Date.now() ||
463
+ (session.s !== undefined && typeof session.s !== "string")
360
464
  ) {
361
465
  continue;
362
466
  }
@@ -366,14 +470,27 @@ export function createServer(options: ServerOptions): PortholeServer {
366
470
  } catch {
367
471
  continue;
368
472
  }
473
+ let named: string;
474
+ try {
475
+ named = nameOf(instance.scope);
476
+ } catch {
477
+ continue;
478
+ }
369
479
  if (
370
- keys.label(instance.scope) !== label ||
480
+ named !== label ||
371
481
  (key === null ? instance.document.kind !== "external" : keys.instanceKey(instance) !== key)
372
482
  ) {
373
483
  continue;
374
484
  }
375
485
  if (best === null || session.x > best.expiresAt) {
376
- best = { instance, viewer: session.w, origin: originOfLabel(label), expiresAt: session.x };
486
+ best = {
487
+ instance,
488
+ viewer: session.w,
489
+ origin: originOfLabel(label),
490
+ expiresAt: session.x,
491
+ ...(session.a === 1 ? { anonymous: true } : {}),
492
+ ...(typeof session.s === "string" ? { subject: session.s } : {}),
493
+ };
377
494
  }
378
495
  }
379
496
  return best;
@@ -390,7 +507,7 @@ export function createServer(options: ServerOptions): PortholeServer {
390
507
  }
391
508
  try {
392
509
  const instance = cleanInstance(record.instance);
393
- return keys.label(instance.scope) === label ? { ...record, instance } : null;
510
+ return nameOf(instance.scope) === label ? { ...record, instance } : null;
394
511
  } catch {
395
512
  return null;
396
513
  }
@@ -432,6 +549,8 @@ export function createServer(options: ServerOptions): PortholeServer {
432
549
  viewer: record.viewer,
433
550
  origin: originOfLabel(label),
434
551
  expiresAt: Date.now() + (record.sessionSeconds ?? DEFAULT_SESSION_SECONDS) * 1_000,
552
+ ...(record.anonymous === true ? { anonymous: true } : {}),
553
+ ...(record.subject === undefined ? {} : { subject: record.subject }),
435
554
  };
436
555
  }
437
556
  access ??= accessOf(request, label, key);
@@ -444,6 +563,10 @@ export function createServer(options: ServerOptions): PortholeServer {
444
563
  "session",
445
564
  );
446
565
  }
566
+ const refusal = await denied(request, access);
567
+ if (refusal !== null) {
568
+ return refusal;
569
+ }
447
570
  const served = await documentOf(access.instance);
448
571
  if (served === null) {
449
572
  return page(404, "Not found", "This page, or this version of it, doesn't exist.", method);
@@ -470,6 +593,16 @@ export function createServer(options: ServerOptions): PortholeServer {
470
593
  "session",
471
594
  );
472
595
  }
596
+ const refusal = await denied(request, {
597
+ instance: record.instance,
598
+ viewer: record.viewer,
599
+ origin: originOfLabel(label),
600
+ expiresAt: record.expiresAt,
601
+ ...(record.subject === undefined ? {} : { subject: record.subject }),
602
+ });
603
+ if (refusal !== null) {
604
+ return refusal;
605
+ }
473
606
  const target = new URL(record.instance.document.url);
474
607
  const landing = `${target.pathname}${target.search}${target.hash}`;
475
608
  // In a frame, a page enters the app: it can tell whether the browser kept the cookie (and tell
@@ -539,6 +672,10 @@ export function createServer(options: ServerOptions): PortholeServer {
539
672
  "session",
540
673
  );
541
674
  }
675
+ const refusal = await denied(request, access);
676
+ if (refusal !== null) {
677
+ return refusal;
678
+ }
542
679
  if (options.upstream === undefined) {
543
680
  return page(502, "Nothing serves this app", "The server has no upstream for apps.", method);
544
681
  }
@@ -561,23 +698,43 @@ export function createServer(options: ServerOptions): PortholeServer {
561
698
  if (host !== domain && !host.endsWith(`.${domain}`)) {
562
699
  return null;
563
700
  }
564
- return { url, label: host.slice(0, Math.max(0, host.length - domain.length - 1)) };
701
+ const label = host.slice(0, Math.max(0, host.length - domain.length - 1));
702
+ // A reserved label is another server's namespace (`f` for `<x>.f.<domain>`): it and the names
703
+ // under it go to the host's own routes. Any other deeper name is ours to refuse.
704
+ if (reserved.has(label.split(".").at(-1) ?? "")) {
705
+ return null;
706
+ }
707
+ return { url, label };
565
708
  }
566
709
 
567
710
  return {
568
711
  contentSource,
569
712
 
570
- originFor: (scope) => originOfLabel(keys.label(scope)),
713
+ originFor: (scope) => originOfLabel(nameOf(scope)),
571
714
 
572
715
  async issue(instance, grant): Promise<FrameLocation> {
573
716
  const clean = cleanInstance(instance);
574
717
  check(clean);
575
- if (
718
+ const anonymous = grant.anonymous === true;
719
+ if (anonymous) {
720
+ if (grant.viewer !== undefined) {
721
+ throw new TypeError("An anonymous grant names no viewer");
722
+ }
723
+ if (clean.document.kind === "external") {
724
+ throw new TypeError("A url-mode app is never opened anonymously (P046)");
725
+ }
726
+ } else if (
576
727
  typeof grant.viewer !== "string" ||
577
728
  grant.viewer.length === 0 ||
578
729
  grant.viewer.length > 512
579
730
  ) {
580
- throw new TypeError("A grant's viewer is 1 to 512 characters");
731
+ throw new TypeError("A grant's viewer is 1 to 512 characters (or the grant is anonymous)");
732
+ }
733
+ if (
734
+ grant.subject !== undefined &&
735
+ (typeof grant.subject !== "string" || grant.subject.length > SUBJECT_MAX)
736
+ ) {
737
+ throw new TypeError(`A grant's subject is a string of at most ${SUBJECT_MAX} characters`);
581
738
  }
582
739
  const ttl = seconds(
583
740
  grant.ttlSeconds,
@@ -592,18 +749,27 @@ export function createServer(options: ServerOptions): PortholeServer {
592
749
  "sessionSeconds",
593
750
  );
594
751
  const key = keys.instanceKey(clean);
595
- const probe = keys.sign({ i: clean, w: grant.viewer, x: Number.MAX_SAFE_INTEGER });
752
+ const viewer = anonymous ? "" : grant.viewer;
753
+ const probe = keys.sign({
754
+ i: clean,
755
+ w: viewer,
756
+ x: Number.MAX_SAFE_INTEGER,
757
+ ...(anonymous ? { a: 1 } : {}),
758
+ ...(grant.subject === undefined ? {} : { s: grant.subject }),
759
+ });
596
760
  if (cookieName(key).length + probe.length + 120 > MAX_COOKIE_BYTES) {
597
761
  throw new TypeError("This instance is too large to carry in a cookie (fewer origins?)");
598
762
  }
599
763
  const ticket = newTicket();
600
764
  await tickets.put(ticketId(ticket), {
601
765
  instance: clean,
602
- viewer: grant.viewer,
766
+ viewer,
603
767
  expiresAt: Date.now() + ttl * 1_000,
604
768
  ...(grant.sessionSeconds === undefined ? {} : { sessionSeconds }),
769
+ ...(anonymous ? { anonymous: true } : {}),
770
+ ...(grant.subject === undefined ? {} : { subject: grant.subject }),
605
771
  });
606
- const origin = originOfLabel(keys.label(clean.scope));
772
+ const origin = originOfLabel(nameOf(clean.scope));
607
773
  const path =
608
774
  clean.document.kind === "external"
609
775
  ? `${ENTER_PATH}?ticket=${ticket}`
@@ -613,7 +779,11 @@ export function createServer(options: ServerOptions): PortholeServer {
613
779
 
614
780
  async access(request) {
615
781
  const at = labelOf(request);
616
- return at === null || !LABEL.test(at.label) ? null : accessOf(request, at.label, null);
782
+ if (at === null || !ours(at.label)) {
783
+ return null;
784
+ }
785
+ const access = accessOf(request, at.label, null);
786
+ return access === null || (await denied(request, access)) !== null ? null : access;
617
787
  },
618
788
 
619
789
  async handle(request) {
@@ -624,7 +794,7 @@ export function createServer(options: ServerOptions): PortholeServer {
624
794
  const { url, label } = at;
625
795
  const method = request.method.toUpperCase();
626
796
  try {
627
- if (!LABEL.test(label)) {
797
+ if (!ours(label)) {
628
798
  return page(404, "Nothing here", "This address names no content.", method);
629
799
  }
630
800
  if (url.pathname === ENTER_PATH) {
@@ -636,10 +806,10 @@ export function createServer(options: ServerOptions): PortholeServer {
636
806
  }
637
807
  if (url.pathname === SESSION_PATH) {
638
808
  // Whether this request's cookie opens its origin's app: the entry page asks.
639
- return new Response(null, {
640
- status: accessOf(request, label, null) === null ? 401 : 204,
641
- headers: { "cache-control": "no-store" },
642
- });
809
+ const access = accessOf(request, label, null);
810
+ const status =
811
+ access === null ? 401 : (await denied(request, access)) === null ? 204 : 403;
812
+ return new Response(null, { status, headers: { "cache-control": "no-store" } });
643
813
  }
644
814
  const document = DOCUMENT_PATH.exec(url.pathname);
645
815
  if (document?.[1] !== undefined) {
@@ -28,3 +28,68 @@ export function memoryTicketStore(options: { readonly now?: () => number } = {})
28
28
  },
29
29
  };
30
30
  }
31
+
32
+ /**
33
+ * The part of a Cloudflare D1 database (or any SQLite-like binding with the same shape) the ticket
34
+ * store uses: a prepared statement, bound, run or read once.
35
+ */
36
+ export interface D1Like {
37
+ prepare(query: string): D1StatementLike;
38
+ }
39
+
40
+ export interface D1StatementLike {
41
+ bind(...values: unknown[]): D1StatementLike;
42
+ run(): Promise<unknown>;
43
+ first<T = Record<string, unknown>>(): Promise<T | null>;
44
+ }
45
+
46
+ const TABLE = /^[a-z_][a-z0-9_]{0,62}$/u;
47
+
48
+ /** The table a D1 ticket store needs (run once, as a migration). */
49
+ export function d1TicketSchema(table = "porthole_tickets"): string {
50
+ if (!TABLE.test(table)) {
51
+ throw new TypeError("A ticket table is named with a-z 0-9 _");
52
+ }
53
+ return `CREATE TABLE IF NOT EXISTS ${table} (id TEXT PRIMARY KEY, record TEXT NOT NULL, expires_at INTEGER NOT NULL);
54
+ CREATE INDEX IF NOT EXISTS ${table}_expires_at ON ${table} (expires_at);`;
55
+ }
56
+
57
+ /**
58
+ * Tickets in a D1 database (P045): shared by every Worker isolate, and taken once by an atomic
59
+ * `DELETE … RETURNING` (one statement on D1's single primary, so two requests racing for a ticket
60
+ * can't both get it). Expired tickets are swept as new ones go in. Workers KV is no place for
61
+ * tickets: it is eventually consistent, so a ticket read in two places could be used twice.
62
+ */
63
+ export function d1TicketStore(
64
+ db: D1Like,
65
+ options: { readonly table?: string; readonly now?: () => number } = {},
66
+ ): TicketStore {
67
+ const table = options.table ?? "porthole_tickets";
68
+ if (!TABLE.test(table)) {
69
+ throw new TypeError("A ticket table is named with a-z 0-9 _");
70
+ }
71
+ const now = options.now ?? Date.now;
72
+ return {
73
+ async put(id, record) {
74
+ await db.prepare(`DELETE FROM ${table} WHERE expires_at <= ?`).bind(now()).run();
75
+ await db
76
+ .prepare(`INSERT INTO ${table} (id, record, expires_at) VALUES (?, ?, ?)`)
77
+ .bind(id, JSON.stringify(record), record.expiresAt)
78
+ .run();
79
+ },
80
+ async take(id) {
81
+ const row = await db
82
+ .prepare(`DELETE FROM ${table} WHERE id = ? RETURNING record, expires_at`)
83
+ .bind(id)
84
+ .first<{ record: string; expires_at: number }>();
85
+ if (row === null || row.expires_at <= now()) {
86
+ return null;
87
+ }
88
+ try {
89
+ return JSON.parse(row.record) as TicketRecord;
90
+ } catch {
91
+ return null;
92
+ }
93
+ },
94
+ };
95
+ }
@@ -11,12 +11,45 @@ export interface ContentDomain {
11
11
  /** `http` only for `localhost`. */
12
12
  readonly scheme: "https" | "http";
13
13
  readonly port?: number;
14
+ /**
15
+ * The label under `domain` a storage scope is served on (P047). Default: 24 base32 characters
16
+ * keyed from the scope with the server's secret (stable, not guessable). A host's own (flat
17
+ * names like `a-<id>`) must return one lowercase DNS label, the same for the same scope, a
18
+ * different one for a different scope, and never a `reserved` one.
19
+ */
20
+ readonly name?: (scope: string) => string;
21
+ /**
22
+ * Labels this server never serves or names (another server's namespace under the same domain,
23
+ * `f` for `<instance>.f.<domain>`, or `www`). Requests for them get null (the host's routes).
24
+ */
25
+ readonly reserved?: readonly string[];
14
26
  }
15
27
 
16
- /** Who may open an instance, decided by the host before it calls `issue`. */
17
- export interface Grant {
18
- /** The host's id for the viewer: recorded with the ticket, never shown to content. */
19
- readonly viewer: string;
28
+ /**
29
+ * Who may open an instance, decided by the host before it calls `issue`: a viewer the host
30
+ * knows, or (a public link) nobody in particular. Porthole never interprets either: identity and
31
+ * authorization policy are the host's (P050).
32
+ */
33
+ export type Grant = (
34
+ | {
35
+ /** The host's id for the viewer: recorded with the ticket, never shown to content. */
36
+ readonly viewer: string;
37
+ readonly anonymous?: never;
38
+ }
39
+ | {
40
+ /**
41
+ * A public link: no viewer (P046). Only for documents (a shell, an artifact version, the
42
+ * sandbox proxy), never for a url-mode app.
43
+ */
44
+ readonly anonymous: true;
45
+ readonly viewer?: never;
46
+ }
47
+ ) & {
48
+ /**
49
+ * The host's own opaque claim, carried in the ticket and the session cookie and handed back to
50
+ * `authorize` on every request (a session id, a share id). At most 512 characters.
51
+ */
52
+ readonly subject?: string;
20
53
  /** How long the ticket is good for, seconds (default 60). */
21
54
  readonly ttlSeconds?: number;
22
55
  /**
@@ -24,11 +57,14 @@ export interface Grant {
24
57
  * url-mode app's every request, rides on.
25
58
  */
26
59
  readonly sessionSeconds?: number;
27
- }
60
+ };
28
61
 
29
62
  export interface TicketRecord {
30
63
  readonly instance: InstanceRef;
64
+ /** The grant's viewer; "" for an anonymous grant. */
31
65
  readonly viewer: string;
66
+ readonly anonymous?: boolean;
67
+ readonly subject?: string;
32
68
  /** Epoch milliseconds. */
33
69
  readonly expiresAt: number;
34
70
  /** The grant's `sessionSeconds`, if it set one. */
@@ -50,7 +86,11 @@ export interface ArtifactStore {
50
86
  /** What a request's cookie lets in: one instance, for one viewer, on one origin, until a time. */
51
87
  export interface Access {
52
88
  readonly instance: InstanceRef;
89
+ /** The grant's viewer; "" for an anonymous one. */
53
90
  readonly viewer: string;
91
+ readonly anonymous?: boolean;
92
+ /** The grant's opaque subject, if it had one. */
93
+ readonly subject?: string;
54
94
  /** The content origin the access is for. */
55
95
  readonly origin: string;
56
96
  /** When the cookie stops being good, epoch milliseconds. */
@@ -64,6 +104,40 @@ export interface Access {
64
104
  */
65
105
  export type Upstream = (request: Request, access: Access) => Response | Promise<Response>;
66
106
 
107
+ /** What `authorize` is asked about: a request the cookie (or ticket) let in. */
108
+ export interface AuthorizeRequest {
109
+ readonly scope: string;
110
+ readonly instance: InstanceRef;
111
+ readonly viewer: string;
112
+ readonly anonymous: boolean;
113
+ readonly subject?: string;
114
+ /** The request itself (its path, method; never its Porthole cookie's value). */
115
+ readonly request: Request;
116
+ }
117
+
118
+ /** The host's answer: let it in, or not (with a short reason code for the host's own UI). */
119
+ export type AuthorizeAnswer =
120
+ | { readonly allow: true }
121
+ | {
122
+ readonly allow: false;
123
+ /** A short code (`revoked`, `left-workspace`): lowercase letters, digits and dashes. */
124
+ readonly code?: string;
125
+ };
126
+
127
+ /**
128
+ * The host's check on every request its cookie lets in (P050): revocation, membership,
129
+ * liveness. Porthole calls it after the cookie (or ticket) verifies and before anything of the
130
+ * content is served; a deny is a 403 page that tells the host why (`blocked: denied`).
131
+ */
132
+ export interface Authorizer {
133
+ check(request: AuthorizeRequest): AuthorizeAnswer | Promise<AuthorizeAnswer>;
134
+ /**
135
+ * How long an answer may be reused for the same session and scope, seconds. Default 0: asked
136
+ * on every request. A host opts into a short time for apps with many requests.
137
+ */
138
+ readonly ttlSeconds?: number;
139
+ }
140
+
67
141
  export interface ServerOptions {
68
142
  readonly content: ContentDomain;
69
143
  /** At least 32 random bytes: keys the labels and signs tickets and cookies. */
@@ -79,6 +153,8 @@ export interface ServerOptions {
79
153
  readonly tickets?: TicketStore;
80
154
  /** Url mode: where an external instance's requests go once let in. Absent: nothing serves them. */
81
155
  readonly upstream?: Upstream;
156
+ /** The host's per-request check (P050). Absent: a good cookie is enough until it ends. */
157
+ readonly authorize?: Authorizer;
82
158
  }
83
159
 
84
160
  export interface PortholeServer {
@@ -95,8 +171,9 @@ export interface PortholeServer {
95
171
  */
96
172
  issue(instance: InstanceRef, grant: Grant): Promise<FrameLocation>;
97
173
  /**
98
- * The access a request's cookie carries for a url-mode app on the request's origin, or null:
99
- * for a host's own WebSocket upgrade path, which a fetch-style handler can't take.
174
+ * The access a request's cookie carries for a url-mode app on the request's origin, or null
175
+ * (no good cookie, or the host's check denies it): for a host's own WebSocket upgrade path,
176
+ * which a fetch-style handler can't take.
100
177
  */
101
178
  access(request: Request): Promise<Access | null>;
102
179
  /** The frame source every content origin matches (`https://*.example-usercontent.com`). */