@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
@@ -34,9 +34,10 @@ export declare function serializeCsp(directives: readonly CspDirective[]): strin
34
34
  export declare function sandboxTokens(config: PortholeConfig): string[];
35
35
  /**
36
36
  * The frame's `allow` attribute (Permissions Policy). Empty: no camera, microphone, geolocation,
37
- * clipboard, fullscreen or other powerful feature is delegated to the frame.
37
+ * clipboard, fullscreen or other powerful feature is delegated to the frame; a url-mode config
38
+ * may delegate clipboard writes and full screen to the app's own origin (P049).
38
39
  */
39
- export declare function allowAttribute(_config: PortholeConfig): string;
40
+ export declare function allowAttribute(config: PortholeConfig): string;
40
41
  /**
41
42
  * The `frame-src` sources the EMBEDDING page must allow, and nothing else: a frame's own CSP can't
42
43
  * stop it navigating itself to an external URL with data in the query, but the embedding page's
@@ -75,10 +75,11 @@ export function sandboxTokens(config) {
75
75
  }
76
76
  /**
77
77
  * The frame's `allow` attribute (Permissions Policy). Empty: no camera, microphone, geolocation,
78
- * clipboard, fullscreen or other powerful feature is delegated to the frame.
78
+ * clipboard, fullscreen or other powerful feature is delegated to the frame; a url-mode config
79
+ * may delegate clipboard writes and full screen to the app's own origin (P049).
79
80
  */
80
- export function allowAttribute(_config) {
81
- return "";
81
+ export function allowAttribute(config) {
82
+ return config.mode === "url" ? (config.permissions ?? []).join("; ") : "";
82
83
  }
83
84
  /**
84
85
  * The `frame-src` sources the EMBEDDING page must allow, and nothing else: a frame's own CSP can't
@@ -103,6 +103,12 @@ export interface Limits {
103
103
  */
104
104
  export declare const URL_SANDBOX_EXTRAS: readonly ["allow-popups", "allow-popups-to-escape-sandbox", "allow-modals", "allow-downloads"];
105
105
  export type UrlSandboxExtra = (typeof URL_SANDBOX_EXTRAS)[number];
106
+ /**
107
+ * Powerful features a url-mode frame may be delegated, by the host's choice (P049): writing the
108
+ * clipboard (a "copy" button in the person's app) and going full screen. Nothing for other modes.
109
+ */
110
+ export declare const URL_PERMISSIONS: readonly ["clipboard-write", "fullscreen"];
111
+ export type UrlPermission = (typeof URL_PERMISSIONS)[number];
106
112
  /** Where the host shows the content, for the manual's size rules. */
107
113
  export interface LayoutConfig {
108
114
  /** The narrowest the frame ever is (a small phone). */
@@ -149,4 +155,6 @@ export interface PortholeConfig {
149
155
  readonly embedder: EmbedderConfig;
150
156
  /** Url mode only: sandbox tokens added to its baseline (`URL_SANDBOX_EXTRAS`). */
151
157
  readonly sandbox?: readonly UrlSandboxExtra[];
158
+ /** Url mode only: features delegated to the frame (`URL_PERMISSIONS`); none by default. */
159
+ readonly permissions?: readonly UrlPermission[];
152
160
  }
@@ -9,3 +9,8 @@ export const URL_SANDBOX_EXTRAS = [
9
9
  "allow-modals",
10
10
  "allow-downloads",
11
11
  ];
12
+ /**
13
+ * Powerful features a url-mode frame may be delegated, by the host's choice (P049): writing the
14
+ * clipboard (a "copy" button in the person's app) and going full screen. Nothing for other modes.
15
+ */
16
+ export const URL_PERMISSIONS = ["clipboard-write", "fullscreen"];
@@ -1,5 +1,5 @@
1
1
  import { CAPABILITY_NAMES, isCapabilityName } from "./capabilities.js";
2
- import { URL_SANDBOX_EXTRAS } from "./schema.js";
2
+ import { URL_PERMISSIONS, URL_SANDBOX_EXTRAS } from "./schema.js";
3
3
  /**
4
4
  * Checks a config before anything is derived from it. Every string that ends up in a CSP, an
5
5
  * attribute or the shell is checked here, so a malformed origin can't inject a directive
@@ -110,6 +110,7 @@ export function validateConfig(input) {
110
110
  "contentOrigins",
111
111
  "embedder",
112
112
  "sandbox",
113
+ "permissions",
113
114
  ], "config");
114
115
  if (!MODES.includes(config.mode)) {
115
116
  fail("config.mode", `must be one of ${MODES.join(", ")}`);
@@ -257,5 +258,18 @@ export function validateConfig(input) {
257
258
  }
258
259
  });
259
260
  }
261
+ if (config.permissions !== undefined) {
262
+ if (config.mode !== "url") {
263
+ fail("config.permissions", "only url mode may be delegated features");
264
+ }
265
+ if (!Array.isArray(config.permissions)) {
266
+ fail("config.permissions", "must be an array of features");
267
+ }
268
+ config.permissions.forEach((feature, index) => {
269
+ if (!URL_PERMISSIONS.includes(feature)) {
270
+ fail(`config.permissions[${index}]`, `must be one of ${URL_PERMISSIONS.join(", ")}`);
271
+ }
272
+ });
273
+ }
260
274
  return input;
261
275
  }
@@ -5,6 +5,7 @@ interface FrameStatus {
5
5
  readonly state: "entering" | "blocked";
6
6
  readonly reason?: FrameBlock;
7
7
  readonly status?: number;
8
+ readonly code?: string;
8
9
  }
9
10
  /** A frame-status message, or null for anything else. */
10
11
  export declare function parseFrameStatus(data: unknown): FrameStatus | null;
@@ -20,6 +20,7 @@ const BLOCKS = [
20
20
  "cookies",
21
21
  "session",
22
22
  "upstream",
23
+ "denied",
23
24
  "not-found",
24
25
  "error",
25
26
  ];
@@ -51,12 +52,16 @@ export function parseFrameStatus(data) {
51
52
  ...(typeof params.status === "number" && Number.isInteger(params.status)
52
53
  ? { status: params.status }
53
54
  : {}),
55
+ ...(typeof params.code === "string" && /^[a-z0-9-]{1,40}$/u.test(params.code)
56
+ ? { code: params.code }
57
+ : {}),
54
58
  };
55
59
  }
56
60
  export function watchOutcome(options) {
57
61
  const { surface, source, speaks } = options;
58
62
  const reports = source.kind === "url" && source.reports === true;
59
- let decided = false;
63
+ // The first outcome; after a load, only a later block (a status page, access revoked) is told.
64
+ let decided = null;
60
65
  let heard = false;
61
66
  let entering = false;
62
67
  let loads = 0;
@@ -68,18 +73,25 @@ export function watchOutcome(options) {
68
73
  }, ms);
69
74
  timers.add(timer);
70
75
  };
71
- const stop = () => {
76
+ const clearTimers = () => {
72
77
  for (const timer of timers) {
73
78
  clearTimeout(timer);
74
79
  }
75
80
  timers.clear();
81
+ };
82
+ const stop = () => {
83
+ clearTimers();
76
84
  unlisten();
77
85
  unload();
78
86
  };
79
87
  const decide = (outcome) => {
80
- if (!decided) {
81
- decided = true;
82
- stop();
88
+ if (decided === null) {
89
+ decided = outcome.state;
90
+ clearTimers();
91
+ options.onOutcome(outcome);
92
+ }
93
+ else if (decided === "loaded" && outcome.state === "blocked") {
94
+ decided = "blocked";
83
95
  options.onOutcome(outcome);
84
96
  }
85
97
  };
@@ -94,6 +106,7 @@ export function watchOutcome(options) {
94
106
  state: "blocked",
95
107
  reason: status.reason ?? "error",
96
108
  ...(status.status === undefined ? {} : { status: status.status }),
109
+ ...(status.code === undefined ? {} : { code: status.code }),
97
110
  });
98
111
  }
99
112
  });
@@ -104,7 +117,7 @@ export function watchOutcome(options) {
104
117
  });
105
118
  const unload = surface.onLoad(() => {
106
119
  loads += 1;
107
- if (decided) {
120
+ if (decided !== null) {
108
121
  return;
109
122
  }
110
123
  if (speaks) {
@@ -60,12 +60,15 @@ export type FrameBlock =
60
60
  /** Its ticket was used or its session ended. */
61
61
  | "session"
62
62
  /** Its app didn't answer. */
63
- | "upstream" | "not-found" | "error";
63
+ | "upstream"
64
+ /** The host's own check refused it (revoked, no longer a member): `code` says which. */
65
+ | "denied" | "not-found" | "error";
64
66
  /**
65
- * How a display's first document went, once per run: it loaded (a shell or view finished its
66
- * handshake; a url-mode app's document loaded), it was blocked (and why), or nothing settled in
67
- * `limits.loadTimeoutMs`. A host shows its own fallback ("Open in a new tab") on anything but
68
- * loaded.
67
+ * How a display's document went: it loaded (a shell or view finished its handshake; a url-mode
68
+ * app's document loaded), it was blocked (and why), or nothing settled in
69
+ * `limits.loadTimeoutMs`. Once per run, plus a later `blocked` if the frame shows a status page
70
+ * after it loaded (access revoked mid-session). A host shows its own fallback ("Open in a new
71
+ * tab") on anything but loaded.
69
72
  */
70
73
  export type FrameOutcome = {
71
74
  readonly state: "loaded";
@@ -73,6 +76,8 @@ export type FrameOutcome = {
73
76
  readonly state: "blocked";
74
77
  readonly reason: FrameBlock;
75
78
  readonly status?: number;
79
+ /** The host's own reason code, for `denied` (`Authorizer`). */
80
+ readonly code?: string;
76
81
  } | {
77
82
  readonly state: "timeout";
78
83
  };
package/dist/index.d.ts CHANGED
@@ -5,8 +5,9 @@
5
5
  export { CAPABILITIES, CAPABILITY_NAMES, capabilitySpec, isCapabilityName, type CapabilityArgs, type CapabilityName, type CapabilityResults, type CapabilitySpec, type Gate, type HandledCapability, } from "./config/capabilities.ts";
6
6
  export type { CapabilityConfig, CapabilityMap, ColorScheme, DisplayMode, EgressConfig, EmbedderConfig, GuideConfig, IsolationKind, LayoutConfig, Limits, PortholeConfig, ThemeConfig, ThemeValue, } from "./config/schema.ts";
7
7
  export { BUILT_IN_TOPICS, ConfigError, isOriginSource, originSourceProblem, validateConfig, } from "./config/validate.ts";
8
- export { URL_SANDBOX_EXTRAS, type UrlSandboxExtra } from "./config/schema.ts";
8
+ export { URL_PERMISSIONS, URL_SANDBOX_EXTRAS, type UrlPermission, type UrlSandboxExtra, } from "./config/schema.ts";
9
9
  export { hostOriginProblem, isHostOrigin } from "./isolation/served.ts";
10
+ export { FRAME_STATUS_METHOD } from "./isolation/types.ts";
10
11
  export { LIBRARY_CDNS, presets, type PresetName, type PresetOptions, type UrlPresetOptions, } from "./presets/presets.ts";
11
12
  export { compile, type CompiledConfig } from "./compile/compile.ts";
12
13
  export { allEgressOrigins, declaredCapabilities, effectiveGates, egressOrigins, embedderCsp, type CspDirective, type EgressKind, } from "./compile/policy.ts";
@@ -22,4 +23,4 @@ export type { AdapterConnection, AdapterEvent, AdapterSession, ProtocolAdapter,
22
23
  export { MCP_APPS_METHODS, MCP_APPS_PROTOCOL_VERSION, MCP_APPS_STYLE_VARIABLES, PORTHOLE_STYLES, type HostContext, } from "./protocol/mcp-apps.ts";
23
24
  export type { CallContext, CallOutcome, CapabilityHandler, CapabilityHandlers, Confirm, ConfirmAnswer, ConfirmRequest, Proceed, HostFunctionBridge, Refusal, } from "./bridge/types.ts";
24
25
  export { createHost } from "./host/create-host.ts";
25
- export type { DisplayControls, DisplayEvents, DisplayHandle, HostOptions, MountOptions, PortholeHost, } from "./host/types.ts";
26
+ export type { DisplayControls, DisplayEvents, FrameBlock, FrameOutcome, DisplayHandle, HostOptions, MountOptions, PortholeHost, } from "./host/types.ts";
package/dist/index.js CHANGED
@@ -5,8 +5,9 @@
5
5
  // The config and its vocabulary.
6
6
  export { CAPABILITIES, CAPABILITY_NAMES, capabilitySpec, isCapabilityName, } from "./config/capabilities.js";
7
7
  export { BUILT_IN_TOPICS, ConfigError, isOriginSource, originSourceProblem, validateConfig, } from "./config/validate.js";
8
- export { URL_SANDBOX_EXTRAS } from "./config/schema.js";
8
+ export { URL_PERMISSIONS, URL_SANDBOX_EXTRAS, } from "./config/schema.js";
9
9
  export { hostOriginProblem, isHostOrigin } from "./isolation/served.js";
10
+ export { FRAME_STATUS_METHOD } from "./isolation/types.js";
10
11
  // Presets.
11
12
  export { LIBRARY_CDNS, presets, } from "./presets/presets.js";
12
13
  // The compiler.
@@ -1,5 +1,5 @@
1
1
  import type { CapabilityName } from "../config/capabilities.ts";
2
- import type { CapabilityConfig, EgressConfig, EmbedderConfig, GuideConfig, IsolationKind, LayoutConfig, Limits, PortholeConfig, ThemeValue, UrlSandboxExtra } from "../config/schema.ts";
2
+ import type { CapabilityConfig, EgressConfig, EmbedderConfig, GuideConfig, IsolationKind, LayoutConfig, Limits, PortholeConfig, ThemeValue, UrlPermission, UrlSandboxExtra } from "../config/schema.ts";
3
3
  /**
4
4
  * The preset policy layer (P001): named configs with safe defaults. Every preset starts with
5
5
  * egress closed and only the capabilities that carry nothing out of the frame (its size, its
@@ -36,6 +36,8 @@ export interface UrlPresetOptions {
36
36
  readonly contentOrigins: readonly string[];
37
37
  /** Sandbox tokens to add for the app (popups for an OAuth flow, modals, downloads; P037). */
38
38
  readonly sandbox?: readonly UrlSandboxExtra[];
39
+ /** Features to delegate to the app (clipboard writes, full screen; P049). */
40
+ readonly permissions?: readonly UrlPermission[];
39
41
  readonly limits?: Partial<Limits>;
40
42
  readonly layout?: Partial<LayoutConfig>;
41
43
  readonly embedder?: EmbedderConfig;
@@ -136,5 +136,6 @@ export const presets = {
136
136
  contentOrigins: options.contentOrigins,
137
137
  embedder: options.embedder ?? {},
138
138
  ...(options.sandbox === undefined ? {} : { sandbox: options.sandbox }),
139
+ ...(options.permissions === undefined ? {} : { permissions: options.permissions }),
139
140
  }),
140
141
  };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The hashing `/server` needs, in plain JavaScript over web-standard types (`Uint8Array`,
3
+ * `TextEncoder`, `crypto.getRandomValues`), so it runs the same on Node, Cloudflare Workers and
4
+ * any other runtime (P044). Synchronous on purpose: Web Crypto's HMAC is async, and an origin
5
+ * (`originFor`) is computed synchronously. SHA-256 per FIPS 180-4, HMAC per RFC 2104; checked
6
+ * against the standard vectors and `node:crypto` in `test/crypto.test.ts`.
7
+ */
8
+ export declare function sha256(data: Uint8Array): Uint8Array;
9
+ export declare function hmacSha256(key: Uint8Array, message: Uint8Array): Uint8Array;
10
+ export declare const utf8: (text: string) => Uint8Array;
11
+ export declare const fromUtf8: (bytes: Uint8Array) => string;
12
+ /** Base64url without padding (RFC 4648 §5). */
13
+ export declare function base64url(bytes: Uint8Array): string;
14
+ /** Decodes base64url without padding; null for anything else. */
15
+ export declare function fromBase64url(text: string): Uint8Array | null;
16
+ /** Equal bytes, compared in time that depends only on the length. */
17
+ export declare function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean;
18
+ /** Random bytes from the runtime's CSPRNG (`crypto.getRandomValues`: Node, Workers, browsers). */
19
+ export declare function randomBytes(length: number): Uint8Array;
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The hashing `/server` needs, in plain JavaScript over web-standard types (`Uint8Array`,
3
+ * `TextEncoder`, `crypto.getRandomValues`), so it runs the same on Node, Cloudflare Workers and
4
+ * any other runtime (P044). Synchronous on purpose: Web Crypto's HMAC is async, and an origin
5
+ * (`originFor`) is computed synchronously. SHA-256 per FIPS 180-4, HMAC per RFC 2104; checked
6
+ * against the standard vectors and `node:crypto` in `test/crypto.test.ts`.
7
+ */
8
+ const K = new Uint32Array([
9
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
10
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
11
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
12
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
13
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
14
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
15
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
16
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
17
+ ]);
18
+ const rotr = (x, n) => (x >>> n) | (x << (32 - n));
19
+ export function sha256(data) {
20
+ const length = data.length;
21
+ // Padded to a multiple of 64 bytes: a 1 bit, zeros, then the length in bits (64-bit big-endian).
22
+ const padded = new Uint8Array(Math.ceil((length + 9) / 64) * 64);
23
+ padded.set(data);
24
+ padded[length] = 0x80;
25
+ const view = new DataView(padded.buffer);
26
+ view.setUint32(padded.length - 8, Math.floor(length / 0x20000000), false);
27
+ view.setUint32(padded.length - 4, (length * 8) >>> 0, false);
28
+ const h = new Uint32Array([
29
+ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
30
+ ]);
31
+ const w = new Uint32Array(64);
32
+ for (let offset = 0; offset < padded.length; offset += 64) {
33
+ for (let i = 0; i < 16; i += 1) {
34
+ w[i] = view.getUint32(offset + i * 4, false);
35
+ }
36
+ for (let i = 16; i < 64; i += 1) {
37
+ const a = w[i - 15];
38
+ const b = w[i - 2];
39
+ const s0 = rotr(a, 7) ^ rotr(a, 18) ^ (a >>> 3);
40
+ const s1 = rotr(b, 17) ^ rotr(b, 19) ^ (b >>> 10);
41
+ w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
42
+ }
43
+ let [a, b, c, d, e, f, g, hh] = h;
44
+ for (let i = 0; i < 64; i += 1) {
45
+ const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
46
+ const ch = (e & f) ^ (~e & g);
47
+ const t1 = (hh + s1 + ch + K[i] + w[i]) >>> 0;
48
+ const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
49
+ const maj = (a & b) ^ (a & c) ^ (b & c);
50
+ const t2 = (s0 + maj) >>> 0;
51
+ hh = g;
52
+ g = f;
53
+ f = e;
54
+ e = (d + t1) >>> 0;
55
+ d = c;
56
+ c = b;
57
+ b = a;
58
+ a = (t1 + t2) >>> 0;
59
+ }
60
+ const next = [a, b, c, d, e, f, g, hh];
61
+ for (let i = 0; i < 8; i += 1) {
62
+ h[i] = (h[i] + next[i]) >>> 0;
63
+ }
64
+ }
65
+ const out = new Uint8Array(32);
66
+ const outView = new DataView(out.buffer);
67
+ for (let i = 0; i < 8; i += 1) {
68
+ outView.setUint32(i * 4, h[i], false);
69
+ }
70
+ return out;
71
+ }
72
+ const BLOCK = 64;
73
+ export function hmacSha256(key, message) {
74
+ const block = new Uint8Array(BLOCK);
75
+ block.set(key.length > BLOCK ? sha256(key) : key);
76
+ const inner = new Uint8Array(BLOCK + message.length);
77
+ const outer = new Uint8Array(BLOCK + 32);
78
+ for (let i = 0; i < BLOCK; i += 1) {
79
+ inner[i] = block[i] ^ 0x36;
80
+ outer[i] = block[i] ^ 0x5c;
81
+ }
82
+ inner.set(message, BLOCK);
83
+ outer.set(sha256(inner), BLOCK);
84
+ return sha256(outer);
85
+ }
86
+ const encoder = new TextEncoder();
87
+ const decoder = new TextDecoder();
88
+ export const utf8 = (text) => encoder.encode(text);
89
+ export const fromUtf8 = (bytes) => decoder.decode(bytes);
90
+ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
91
+ /** Base64url without padding (RFC 4648 §5). */
92
+ export function base64url(bytes) {
93
+ let out = "";
94
+ for (let i = 0; i < bytes.length; i += 3) {
95
+ const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0);
96
+ out += B64[(n >>> 18) & 63];
97
+ out += B64[(n >>> 12) & 63];
98
+ if (i + 1 < bytes.length) {
99
+ out += B64[(n >>> 6) & 63];
100
+ }
101
+ if (i + 2 < bytes.length) {
102
+ out += B64[n & 63];
103
+ }
104
+ }
105
+ return out;
106
+ }
107
+ /** Decodes base64url without padding; null for anything else. */
108
+ export function fromBase64url(text) {
109
+ if (!/^[A-Za-z0-9_-]*$/u.test(text) || text.length % 4 === 1) {
110
+ return null;
111
+ }
112
+ const out = new Uint8Array(Math.floor((text.length * 3) / 4));
113
+ let bits = 0;
114
+ let buffer = 0;
115
+ let index = 0;
116
+ for (const char of text) {
117
+ buffer = (buffer << 6) | B64.indexOf(char);
118
+ bits += 6;
119
+ if (bits >= 8) {
120
+ bits -= 8;
121
+ out[index] = (buffer >>> bits) & 0xff;
122
+ index += 1;
123
+ }
124
+ }
125
+ // The leftover bits of a canonical encoding are zero.
126
+ return (buffer & ((1 << bits) - 1)) === 0 ? out : null;
127
+ }
128
+ /** Equal bytes, compared in time that depends only on the length. */
129
+ export function timingSafeEqual(a, b) {
130
+ if (a.length !== b.length) {
131
+ return false;
132
+ }
133
+ let difference = 0;
134
+ for (let i = 0; i < a.length; i += 1) {
135
+ difference |= a[i] ^ b[i];
136
+ }
137
+ return difference === 0;
138
+ }
139
+ /** Random bytes from the runtime's CSPRNG (`crypto.getRandomValues`: Node, Workers, browsers). */
140
+ export function randomBytes(length) {
141
+ const bytes = new Uint8Array(length);
142
+ globalThis.crypto.getRandomValues(bytes);
143
+ return bytes;
144
+ }
@@ -9,12 +9,18 @@
9
9
  *
10
10
  * and calls `issue` from its own authenticated API when a viewer opens served content.
11
11
  *
12
+ * Runs on any runtime with web-standard `Request`/`Response` and `crypto.getRandomValues`: Node,
13
+ * Cloudflare Workers (no `nodejs_compat` needed), Deno, Bun (P044). On Workers, keep tickets in D1
14
+ * (`d1TicketStore`, P045).
15
+ *
12
16
  * Origins: each storage scope gets `<label>.<domain>`, the label keyed from the scope by the
13
- * secret (stable, DNS-safe, not guessable from the scope). The domain is a registrable domain the
17
+ * secret (stable, DNS-safe, not guessable from the scope), or a host's own flat name
18
+ * (`content.name`, `prefixedName("a-")` for `a-<id>`), with other servers' namespaces reserved (P047). The domain is a registrable domain the
14
19
  * host uses for nothing else, so no content origin is ever same-site with the host. Documents go
15
20
  * out with their CSP (from the config) plus `frame-ancestors` (the host origins) in headers.
16
21
  *
17
- * Tickets: `issue` mints a one-time ticket for an instance and a viewer, good for a short time;
22
+ * Tickets: `issue` mints a one-time ticket for an instance and a viewer (or anonymously, for a
23
+ * public link, P046), good for a short time;
18
24
  * the frame's first request carries it, and the content origin takes it (once). A served document
19
25
  * comes back in that same answer, with a cookie of that origin only (`__Host-`, `Secure`,
20
26
  * `HttpOnly`, `SameSite=None; Partitioned` in a frame) for what follows; a url-mode app's ticket
@@ -22,6 +28,8 @@
22
28
  * content without a valid ticket or cookie gets nothing of the content.
23
29
  */
24
30
  export { createServer } from "./server.ts";
25
- export { memoryTicketStore } from "./tickets.ts";
31
+ export { d1TicketSchema, d1TicketStore, memoryTicketStore, type D1Like, type D1StatementLike, } from "./tickets.ts";
32
+ export { FRAME_STATUS_METHOD } from "../isolation/types.ts";
33
+ export { isHostLabel, prefixedName } from "./naming.ts";
26
34
  export { proxyTo, type ProxyOptions } from "./proxy.ts";
27
- export type { Access, ArtifactStore, ContentDomain, Grant, PortholeServer, ServerOptions, TicketRecord, TicketStore, Upstream, } from "./types.ts";
35
+ export type { Access, ArtifactStore, AuthorizeAnswer, AuthorizeRequest, Authorizer, ContentDomain, Grant, PortholeServer, ServerOptions, TicketRecord, TicketStore, Upstream, } from "./types.ts";
@@ -9,12 +9,18 @@
9
9
  *
10
10
  * and calls `issue` from its own authenticated API when a viewer opens served content.
11
11
  *
12
+ * Runs on any runtime with web-standard `Request`/`Response` and `crypto.getRandomValues`: Node,
13
+ * Cloudflare Workers (no `nodejs_compat` needed), Deno, Bun (P044). On Workers, keep tickets in D1
14
+ * (`d1TicketStore`, P045).
15
+ *
12
16
  * Origins: each storage scope gets `<label>.<domain>`, the label keyed from the scope by the
13
- * secret (stable, DNS-safe, not guessable from the scope). The domain is a registrable domain the
17
+ * secret (stable, DNS-safe, not guessable from the scope), or a host's own flat name
18
+ * (`content.name`, `prefixedName("a-")` for `a-<id>`), with other servers' namespaces reserved (P047). The domain is a registrable domain the
14
19
  * host uses for nothing else, so no content origin is ever same-site with the host. Documents go
15
20
  * out with their CSP (from the config) plus `frame-ancestors` (the host origins) in headers.
16
21
  *
17
- * Tickets: `issue` mints a one-time ticket for an instance and a viewer, good for a short time;
22
+ * Tickets: `issue` mints a one-time ticket for an instance and a viewer (or anonymously, for a
23
+ * public link, P046), good for a short time;
18
24
  * the frame's first request carries it, and the content origin takes it (once). A served document
19
25
  * comes back in that same answer, with a cookie of that origin only (`__Host-`, `Secure`,
20
26
  * `HttpOnly`, `SameSite=None; Partitioned` in a frame) for what follows; a url-mode app's ticket
@@ -22,5 +28,7 @@
22
28
  * content without a valid ticket or cookie gets nothing of the content.
23
29
  */
24
30
  export { createServer } from "./server.js";
25
- export { memoryTicketStore } from "./tickets.js";
31
+ export { d1TicketSchema, d1TicketStore, memoryTicketStore, } from "./tickets.js";
32
+ export { FRAME_STATUS_METHOD } from "../isolation/types.js";
33
+ export { isHostLabel, prefixedName } from "./naming.js";
26
34
  export { proxyTo } from "./proxy.js";
@@ -1,4 +1,4 @@
1
- import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
1
+ import { base64url, fromBase64url, fromUtf8, hmacSha256, randomBytes, sha256, timingSafeEqual, utf8, } from "./crypto.js";
2
2
  /**
3
3
  * The server's secrets, derived from the one it is given: one key per use, so a value made for
4
4
  * one (a label) can never stand in for another (a cookie's signature).
@@ -23,7 +23,6 @@ export function base32(bytes) {
23
23
  }
24
24
  return out;
25
25
  }
26
- const base64url = (bytes) => Buffer.from(bytes).toString("base64url");
27
26
  /** JSON with object keys in a fixed order, so equal values always serialize alike. */
28
27
  export function canonicalJson(value) {
29
28
  if (Array.isArray(value)) {
@@ -41,16 +40,16 @@ export function createKeys(secret) {
41
40
  if (secret.byteLength < 32) {
42
41
  throw new TypeError("The server's secret must be at least 32 random bytes");
43
42
  }
44
- const derive = (use) => createHmac("sha256", secret).update(`porthole/${use}`).digest();
43
+ const derive = (use) => hmacSha256(secret, utf8(`porthole/${use}`));
45
44
  const labelKey = derive("label");
46
45
  const instanceKey = derive("instance");
47
46
  const signKey = derive("session");
48
- const mac = (key, text) => createHmac("sha256", key).update(text).digest();
47
+ const mac = (key, text) => hmacSha256(key, utf8(text));
49
48
  return {
50
49
  label: (scope) => base32(mac(labelKey, scope).subarray(0, 15)),
51
50
  instanceKey: (instance) => base32(mac(instanceKey, canonicalJson(instance)).subarray(0, 10)),
52
51
  sign: (value) => {
53
- const payload = Buffer.from(JSON.stringify(value)).toString("base64url");
52
+ const payload = base64url(utf8(JSON.stringify(value)));
54
53
  return `${payload}.${base64url(mac(signKey, payload))}`;
55
54
  },
56
55
  verify: (token) => {
@@ -59,12 +58,16 @@ export function createKeys(secret) {
59
58
  return null;
60
59
  }
61
60
  const expected = mac(signKey, payload);
62
- const given = Buffer.from(signature, "base64url");
63
- if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
61
+ const given = fromBase64url(signature);
62
+ if (given === null || !timingSafeEqual(given, expected)) {
63
+ return null;
64
+ }
65
+ const bytes = fromBase64url(payload);
66
+ if (bytes === null) {
64
67
  return null;
65
68
  }
66
69
  try {
67
- return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
70
+ return JSON.parse(fromUtf8(bytes));
68
71
  }
69
72
  catch {
70
73
  return null;
@@ -75,4 +78,4 @@ export function createKeys(secret) {
75
78
  /** A fresh one-time ticket: 160 random bits. */
76
79
  export const newTicket = () => `pt_${base32(randomBytes(20))}`;
77
80
  /** What a ticket is stored under: its hash, so a leaked store holds no usable ticket. */
78
- export const ticketId = (ticket) => createHash("sha256").update(ticket).digest("base64url");
81
+ export const ticketId = (ticket) => base64url(sha256(utf8(ticket)));
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Names for content origins a host chooses itself (P047): flat labels like `a-<id>` under a
3
+ * shared content domain, next to other servers' namespaces (`<instance>.f.<domain>`).
4
+ */
5
+ /**
6
+ * Whether a label is one a host may name a content origin with: one lowercase DNS label, and not
7
+ * of the form IDNA reserves (`xn--`, any `??--`), so a name never displays as something else.
8
+ */
9
+ export declare function isHostLabel(label: unknown): label is string;
10
+ /**
11
+ * A naming for `content.name` that puts each scope on `<prefix><scope>` (`prefixedName("a-")`:
12
+ * scope `k3x9` is served on `a-k3x9.<domain>`). One-to-one, so no two scopes share an origin, and
13
+ * no name it makes is a reserved one or a keyed default (those have no hyphen). Scopes must
14
+ * already be label-safe (lowercase letters, digits, inner hyphens); anything else throws rather
15
+ * than being folded into another scope's name.
16
+ */
17
+ export declare function prefixedName(prefix: string): (scope: string) => string;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Names for content origins a host chooses itself (P047): flat labels like `a-<id>` under a
3
+ * shared content domain, next to other servers' namespaces (`<instance>.f.<domain>`).
4
+ */
5
+ /** One lowercase DNS label (a-z 0-9 -, at most 63, no hyphen at either end). */
6
+ const DNS_LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/u;
7
+ /**
8
+ * Whether a label is one a host may name a content origin with: one lowercase DNS label, and not
9
+ * of the form IDNA reserves (`xn--`, any `??--`), so a name never displays as something else.
10
+ */
11
+ export function isHostLabel(label) {
12
+ return typeof label === "string" && DNS_LABEL.test(label) && label.slice(2, 4) !== "--";
13
+ }
14
+ const PREFIX = /^[a-z][a-z0-9]{0,15}-$/u;
15
+ const ID = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/u;
16
+ /**
17
+ * A naming for `content.name` that puts each scope on `<prefix><scope>` (`prefixedName("a-")`:
18
+ * scope `k3x9` is served on `a-k3x9.<domain>`). One-to-one, so no two scopes share an origin, and
19
+ * no name it makes is a reserved one or a keyed default (those have no hyphen). Scopes must
20
+ * already be label-safe (lowercase letters, digits, inner hyphens); anything else throws rather
21
+ * than being folded into another scope's name.
22
+ */
23
+ export function prefixedName(prefix) {
24
+ if (!PREFIX.test(prefix)) {
25
+ throw new TypeError("A name prefix is a lowercase letter, then letters or digits, then '-' (a-)");
26
+ }
27
+ return (scope) => {
28
+ const label = `${prefix}${scope}`;
29
+ if (!ID.test(scope) || !isHostLabel(label)) {
30
+ throw new TypeError(`Scope ${JSON.stringify(scope)} can't be named under ${prefix}: lowercase letters, digits and inner hyphens, ${63 - prefix.length} at most`);
31
+ }
32
+ return label;
33
+ };
34
+ }
@@ -6,6 +6,6 @@ export declare const ENTER_SCRIPT_PATH = "/__porthole/enter.js";
6
6
  export declare const SESSION_PATH = "/__porthole/session";
7
7
  /** The pages' scripts, by path: served with the pages' own policy in mind (`script-src 'self'`). */
8
8
  export declare function pageScript(pathname: string): Response | null;
9
- export declare function statusPage(status: number, title: string, detail: string, frameAncestors: string, method?: string, reason?: FrameBlock, hostOrigins?: readonly string[]): Response;
9
+ export declare function statusPage(status: number, title: string, detail: string, frameAncestors: string, method?: string, reason?: FrameBlock, hostOrigins?: readonly string[], code?: string): Response;
10
10
  /** The page a framed app is entered through (its answer also sets the session cookie). */
11
11
  export declare function enterPage(target: string, frameAncestors: string, hostOrigins: readonly string[], cookie: string): Response;