@botiverse/porthole 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/dist/bridge/bridge.js +20 -6
  2. package/dist/bridge/types.d.ts +19 -3
  3. package/dist/compile/policy.d.ts +6 -5
  4. package/dist/compile/policy.js +10 -12
  5. package/dist/config/schema.d.ts +29 -0
  6. package/dist/config/schema.js +16 -1
  7. package/dist/config/validate.d.ts +3 -1
  8. package/dist/config/validate.js +55 -10
  9. package/dist/conformance/behavior.js +50 -0
  10. package/dist/conformance/index.d.ts +11 -5
  11. package/dist/conformance/index.js +2 -2
  12. package/dist/conformance/probes.d.ts +26 -2
  13. package/dist/conformance/probes.js +51 -40
  14. package/dist/conformance/served.d.ts +7 -0
  15. package/dist/conformance/served.js +31 -7
  16. package/dist/guide/guide.js +6 -2
  17. package/dist/host/create-host.js +46 -2
  18. package/dist/host/locate.js +6 -1
  19. package/dist/host/outcome.d.ts +25 -0
  20. package/dist/host/outcome.js +147 -0
  21. package/dist/host/types.d.ts +39 -2
  22. package/dist/index.d.ts +6 -3
  23. package/dist/index.js +4 -1
  24. package/dist/isolation/served.d.ts +3 -0
  25. package/dist/isolation/served.js +20 -0
  26. package/dist/isolation/types.d.ts +9 -0
  27. package/dist/isolation/types.js +2 -1
  28. package/dist/platform/types.d.ts +7 -0
  29. package/dist/platform/web.js +15 -2
  30. package/dist/presets/presets.d.ts +11 -3
  31. package/dist/presets/presets.js +17 -1
  32. package/dist/react/frame.d.ts +16 -0
  33. package/dist/react/frame.js +44 -10
  34. package/dist/server/crypto.d.ts +19 -0
  35. package/dist/server/crypto.js +144 -0
  36. package/dist/server/index.d.ts +12 -4
  37. package/dist/server/index.js +11 -3
  38. package/dist/server/keys.js +12 -9
  39. package/dist/server/naming.d.ts +17 -0
  40. package/dist/server/naming.js +34 -0
  41. package/dist/server/pages.d.ts +11 -6
  42. package/dist/server/pages.js +86 -12
  43. package/dist/server/server.js +191 -23
  44. package/dist/server/tickets.d.ts +24 -0
  45. package/dist/server/tickets.js +46 -0
  46. package/dist/server/types.d.ts +76 -5
  47. package/dist/shell/document.d.ts +3 -2
  48. package/dist/shell/document.js +4 -3
  49. package/dist/shell/plan.d.ts +2 -0
  50. package/dist/shell/plan.js +1 -0
  51. package/dist/shell/runtime.js +20 -4
  52. package/package.json +2 -9
  53. package/src/bridge/bridge.ts +28 -6
  54. package/src/bridge/types.ts +21 -3
  55. package/src/compile/policy.ts +10 -12
  56. package/src/config/schema.ts +38 -0
  57. package/src/config/validate.ts +63 -14
  58. package/src/conformance/behavior.ts +55 -0
  59. package/src/conformance/index.ts +18 -4
  60. package/src/conformance/probes.ts +88 -52
  61. package/src/conformance/served.ts +37 -8
  62. package/src/guide/guide.ts +8 -2
  63. package/src/host/create-host.ts +47 -2
  64. package/src/host/locate.ts +6 -1
  65. package/src/host/outcome.ts +177 -0
  66. package/src/host/types.ts +42 -2
  67. package/src/index.ts +19 -1
  68. package/src/isolation/served.ts +24 -0
  69. package/src/isolation/types.ts +15 -1
  70. package/src/platform/types.ts +7 -0
  71. package/src/platform/web.ts +15 -2
  72. package/src/presets/presets.ts +31 -3
  73. package/src/react/frame.tsx +78 -14
  74. package/src/server/crypto.ts +166 -0
  75. package/src/server/index.ts +20 -3
  76. package/src/server/keys.ts +21 -13
  77. package/src/server/naming.ts +42 -0
  78. package/src/server/pages.ts +106 -11
  79. package/src/server/server.ts +233 -23
  80. package/src/server/tickets.ts +65 -0
  81. package/src/server/types.ts +84 -7
  82. package/src/shell/document.ts +7 -6
  83. package/src/shell/plan.ts +3 -0
  84. package/src/shell/runtime.ts +20 -4
@@ -62,6 +62,19 @@ const mergeEgress = (base, extra) => {
62
62
  }
63
63
  return merged;
64
64
  };
65
+ /** The default variables, with the host's changes and additions, less those it dropped (null). */
66
+ function themeVariables(changes) {
67
+ const variables = { ...DEFAULT_THEME_VARIABLES };
68
+ for (const [name, value] of Object.entries(changes ?? {})) {
69
+ if (value === null) {
70
+ delete variables[name];
71
+ }
72
+ else {
73
+ variables[name] = value;
74
+ }
75
+ }
76
+ return variables;
77
+ }
65
78
  function build(mode, defaults, options) {
66
79
  const capabilities = {
67
80
  ...BASE_CAPABILITIES,
@@ -80,8 +93,9 @@ function build(mode, defaults, options) {
80
93
  capabilities,
81
94
  egress: mergeEgress({}, options.egress),
82
95
  theme: {
83
- variables: { ...DEFAULT_THEME_VARIABLES, ...options.theme?.variables },
96
+ variables: themeVariables(options.theme?.variables),
84
97
  event: options.theme?.event ?? DEFAULT_THEME_EVENT,
98
+ ...(options.theme?.documents === undefined ? {} : { documents: options.theme.documents }),
85
99
  },
86
100
  limits: { ...BASE_LIMITS, ...defaults.limits, ...options.limits },
87
101
  layout: { ...BASE_LAYOUT, ...options.layout },
@@ -121,5 +135,7 @@ export const presets = {
121
135
  guide: {},
122
136
  contentOrigins: options.contentOrigins,
123
137
  embedder: options.embedder ?? {},
138
+ ...(options.sandbox === undefined ? {} : { sandbox: options.sandbox }),
139
+ ...(options.permissions === undefined ? {} : { permissions: options.permissions }),
124
140
  }),
125
141
  };
@@ -2,6 +2,7 @@ import { type CSSProperties, type ReactElement, type ReactNode } from "react";
2
2
  import type { CapabilityHandlers, Confirm, Refusal } from "../bridge/types.ts";
3
3
  import type { CapabilityName } from "../config/capabilities.ts";
4
4
  import type { ColorScheme, PortholeConfig } from "../config/schema.ts";
5
+ import type { FrameOutcome } from "../host/types.ts";
5
6
  import type { InstanceLocator, InstanceRef } from "../isolation/types.ts";
6
7
  /** The height a display holds before it has reported its own. */
7
8
  export declare const PLACEHOLDER_HEIGHT_PX = 96;
@@ -67,6 +68,21 @@ export interface PortholeFrameProps {
67
68
  readonly onRefused?: (capability: CapabilityName, refusal: Refusal) => void;
68
69
  /** Too many declines: the display stops asking until it runs again. */
69
70
  readonly onMuted?: () => void;
71
+ /**
72
+ * How the frame's first document went: loaded, blocked (and why), or a timeout. A host shows its
73
+ * own fallback (a url-mode app "Open in a new tab") on anything but loaded.
74
+ */
75
+ readonly onOutcome?: (outcome: FrameOutcome) => void;
76
+ /**
77
+ * Other ids this display had (the call's id, before the display's own settled): its remembered
78
+ * height is found under them, for a frame that mounts again under its new id.
79
+ */
80
+ readonly aliases?: readonly string[];
81
+ /**
82
+ * Change it when the host's theme values change without a scheme flip (or after an async
83
+ * restyle): the running frame reads them again.
84
+ */
85
+ readonly themeKey?: string | number;
70
86
  /** Draws the placeholder instead of the default one. */
71
87
  readonly renderPlaceholder?: (props: PlaceholderProps) => ReactNode;
72
88
  readonly className?: string;
@@ -25,9 +25,9 @@ export function PortholeProvider(props) {
25
25
  useLayoutEffect(() => {
26
26
  own.current = props.confirm;
27
27
  });
28
- const confirm = useCallback((request) => own.current !== undefined
29
- ? own.current(request)
30
- : new Promise((resolve) => setQueue((waiting) => [...waiting, { request, resolve }])), []);
28
+ const confirm = useCallback((request, proceed) => own.current !== undefined
29
+ ? own.current(request, proceed)
30
+ : new Promise((resolve) => setQueue((waiting) => [...waiting, { request, resolve, proceed }])), []);
31
31
  const { hostInfo, locator } = props;
32
32
  const value = useMemo(() => ({
33
33
  live,
@@ -38,7 +38,11 @@ export function PortholeProvider(props) {
38
38
  }), [live, heights, confirm, hostInfo, locator]);
39
39
  const current = queue[0] ?? null;
40
40
  return (_jsxs(Page.Provider, { value: value, children: [props.children, props.confirm === undefined ? (_jsx(ConfirmDialog, { request: current?.request ?? null, ...(props.dialogClassName === undefined ? {} : { className: props.dialogClassName }), onAnswer: (accepted) => {
41
- current?.resolve(accepted);
41
+ // A yes runs the effect inside this very click (a window it opens is the person's).
42
+ if (accepted) {
43
+ current?.proceed();
44
+ }
45
+ current?.resolve(accepted ? "accepted" : "declined");
42
46
  setQueue((waiting) => waiting.slice(1));
43
47
  } })) : null] }));
44
48
  }
@@ -56,6 +60,16 @@ const PLACEHOLDER_STYLE = {
56
60
  function DefaultPlaceholder({ state, title, run }) {
57
61
  return (_jsxs("div", { "data-porthole-placeholder": state, style: { ...PLACEHOLDER_STYLE, pointerEvents: state === "stopped" ? "auto" : "none" }, children: [_jsx("span", { children: state === "stopped" ? "Stopped while others run" : title }), state === "stopped" ? (_jsx("button", { type: "button", "data-porthole-run": "", onClick: run, children: "Run again" })) : null] }));
58
62
  }
63
+ /** A display's remembered height: under its id, else under the first of its other ids that has one. */
64
+ function rememberedHeight(heights, displayId, aliases) {
65
+ for (const id of [displayId, ...(aliases ?? [])]) {
66
+ const height = heights.get(id);
67
+ if (height !== undefined) {
68
+ return height;
69
+ }
70
+ }
71
+ return 0;
72
+ }
59
73
  export function PortholeFrame(props) {
60
74
  const page = useContext(Page);
61
75
  const key = useId();
@@ -89,7 +103,17 @@ export function PortholeFrame(props) {
89
103
  page.live.release(key);
90
104
  };
91
105
  }, [page, key, max, eager]);
92
- const height = page.heights.get(props.displayId) ?? 0;
106
+ const height = rememberedHeight(page.heights, props.displayId, props.aliases);
107
+ // A display renamed while it runs (its id settled) keeps its height under its new id.
108
+ const previousId = useRef(props.displayId);
109
+ useLayoutEffect(() => {
110
+ const before = previousId.current;
111
+ previousId.current = props.displayId;
112
+ const kept = page.heights.get(before);
113
+ if (before !== props.displayId && kept !== undefined && !page.heights.has(props.displayId)) {
114
+ page.heights.set(props.displayId, kept);
115
+ }
116
+ }, [page, props.displayId]);
93
117
  const run = () => page.live.admit(key, max);
94
118
  const placeholder = props.renderPlaceholder ?? DefaultPlaceholder;
95
119
  return (_jsx("div", { ref: box, className: props.className, "data-porthole-display": props.displayId, "data-porthole-state": state, style: {
@@ -107,6 +131,7 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
107
131
  // The height this display started at: later reports don't make it start again.
108
132
  const [startHeight] = useState(initialHeightPx);
109
133
  const { config, document: view, instance, content, partial, scheme } = frame;
134
+ const fills = config.mode === "url";
110
135
  // The latest props, read when the display calls back (never during render).
111
136
  const latest = useEffectEvent(() => frame);
112
137
  const notify = useEffectEvent((event, detail) => {
@@ -135,6 +160,9 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
135
160
  case "muted":
136
161
  frame.onMuted?.();
137
162
  return;
163
+ case "outcome":
164
+ frame.onOutcome?.(detail);
165
+ return;
138
166
  }
139
167
  });
140
168
  useEffect(() => {
@@ -162,10 +190,7 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
162
190
  handlers,
163
191
  ...(asks
164
192
  ? {
165
- confirm: (request) => (latest().confirm ?? page.confirm ?? (() => Promise.resolve(false)))({
166
- ...request,
167
- display: { id: latest().displayId, title: latest().title },
168
- }),
193
+ confirm: (request, proceed) => (latest().confirm ?? page.confirm ?? (() => Promise.resolve(false)))({ ...request, display: { id: latest().displayId, title: latest().title } }, proceed),
169
194
  }
170
195
  : {}),
171
196
  theme: (each) => latest().theme?.(each) ?? {},
@@ -192,6 +217,7 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
192
217
  "navigated",
193
218
  "refused",
194
219
  "muted",
220
+ "outcome",
195
221
  ];
196
222
  for (const event of events) {
197
223
  display.on(event, (detail) => notify(event, detail));
@@ -214,8 +240,16 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
214
240
  useEffect(() => {
215
241
  handle.current?.setTheme(scheme);
216
242
  }, [scheme]);
243
+ const { themeKey } = frame;
244
+ useEffect(() => {
245
+ if (themeKey !== undefined) {
246
+ handle.current?.refreshTheme();
247
+ }
248
+ }, [themeKey]);
217
249
  return (_jsxs("div", { "data-porthole-ready": String(ready), style: {
218
250
  position: "relative",
251
+ // A url-mode app fills the frame's box (give PortholeFrame a height); the rest size to content.
252
+ ...(fills ? { height: "100%" } : {}),
219
253
  ...(ready ? {} : { minHeight: Math.max(startHeight, PLACEHOLDER_HEIGHT_PX) }),
220
- }, children: [_jsx("div", { ref: holder, "data-porthole-mount": "", style: { opacity: ready ? 1 : 0 } }), ready ? null : placeholder({ state: "loading", title: frame.title, run })] }));
254
+ }, children: [_jsx("div", { ref: holder, "data-porthole-mount": "", style: { opacity: ready ? 1 : 0, ...(fills ? { height: "100%" } : {}) } }), ready ? null : placeholder({ state: "loading", title: frame.title, run })] }));
221
255
  }
@@ -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
+ }
@@ -1,6 +1,11 @@
1
- /**
2
- * The server's own pages on a content origin: why it shows nothing of the content. Plain, no
3
- * script, framable only by the host origins, never cached. Their words are the host-agnostic
4
- * mechanism's; a host that wants its own wording serves its own page in front.
5
- */
6
- export declare function statusPage(status: number, title: string, detail: string, frameAncestors: string, method?: string): Response;
1
+ import type { FrameBlock } from "../host/types.ts";
2
+ /** Where the pages' scripts are served, on every content origin. */
3
+ export declare const STATUS_SCRIPT_PATH = "/__porthole/status.js";
4
+ export declare const ENTER_SCRIPT_PATH = "/__porthole/enter.js";
5
+ /** Answers 204 when the request carries a good session for its origin's app, 401 otherwise. */
6
+ export declare const SESSION_PATH = "/__porthole/session";
7
+ /** The pages' scripts, by path: served with the pages' own policy in mind (`script-src 'self'`). */
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[], code?: string): Response;
10
+ /** The page a framed app is entered through (its answer also sets the session cookie). */
11
+ export declare function enterPage(target: string, frameAncestors: string, hostOrigins: readonly string[], cookie: string): Response;