@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
@@ -25,6 +25,7 @@ const BLOCKS: readonly FrameBlock[] = [
25
25
  "cookies",
26
26
  "session",
27
27
  "upstream",
28
+ "denied",
28
29
  "not-found",
29
30
  "error",
30
31
  ];
@@ -33,6 +34,7 @@ interface FrameStatus {
33
34
  readonly state: "entering" | "blocked";
34
35
  readonly reason?: FrameBlock;
35
36
  readonly status?: number;
37
+ readonly code?: string;
36
38
  }
37
39
 
38
40
  /** A frame-status message, or null for anything else. */
@@ -44,7 +46,12 @@ export function parseFrameStatus(data: unknown): FrameStatus | null {
44
46
  if (message.jsonrpc !== "2.0" || message.method !== FRAME_STATUS_METHOD) {
45
47
  return null;
46
48
  }
47
- const params = message.params as { state?: unknown; reason?: unknown; status?: unknown } | null;
49
+ const params = message.params as {
50
+ state?: unknown;
51
+ reason?: unknown;
52
+ status?: unknown;
53
+ code?: unknown;
54
+ } | null;
48
55
  if (params === null || typeof params !== "object") {
49
56
  return null;
50
57
  }
@@ -63,6 +70,9 @@ export function parseFrameStatus(data: unknown): FrameStatus | null {
63
70
  ...(typeof params.status === "number" && Number.isInteger(params.status)
64
71
  ? { status: params.status }
65
72
  : {}),
73
+ ...(typeof params.code === "string" && /^[a-z0-9-]{1,40}$/u.test(params.code)
74
+ ? { code: params.code }
75
+ : {}),
66
76
  };
67
77
  }
68
78
 
@@ -82,7 +92,8 @@ export function watchOutcome(options: {
82
92
  }): OutcomeWatch {
83
93
  const { surface, source, speaks } = options;
84
94
  const reports = source.kind === "url" && source.reports === true;
85
- let decided = false;
95
+ // The first outcome; after a load, only a later block (a status page, access revoked) is told.
96
+ let decided: FrameOutcome["state"] | null = null;
86
97
  let heard = false;
87
98
  let entering = false;
88
99
  let loads = 0;
@@ -94,18 +105,24 @@ export function watchOutcome(options: {
94
105
  }, ms);
95
106
  timers.add(timer);
96
107
  };
97
- const stop = () => {
108
+ const clearTimers = () => {
98
109
  for (const timer of timers) {
99
110
  clearTimeout(timer);
100
111
  }
101
112
  timers.clear();
113
+ };
114
+ const stop = () => {
115
+ clearTimers();
102
116
  unlisten();
103
117
  unload();
104
118
  };
105
119
  const decide = (outcome: FrameOutcome) => {
106
- if (!decided) {
107
- decided = true;
108
- stop();
120
+ if (decided === null) {
121
+ decided = outcome.state;
122
+ clearTimers();
123
+ options.onOutcome(outcome);
124
+ } else if (decided === "loaded" && outcome.state === "blocked") {
125
+ decided = "blocked";
109
126
  options.onOutcome(outcome);
110
127
  }
111
128
  };
@@ -119,6 +136,7 @@ export function watchOutcome(options: {
119
136
  state: "blocked",
120
137
  reason: status.reason ?? "error",
121
138
  ...(status.status === undefined ? {} : { status: status.status }),
139
+ ...(status.code === undefined ? {} : { code: status.code }),
122
140
  });
123
141
  }
124
142
  });
@@ -130,7 +148,7 @@ export function watchOutcome(options: {
130
148
  });
131
149
  const unload = surface.onLoad(() => {
132
150
  loads += 1;
133
- if (decided) {
151
+ if (decided !== null) {
134
152
  return;
135
153
  }
136
154
  if (speaks) {
package/src/host/types.ts CHANGED
@@ -62,18 +62,27 @@ export type FrameBlock =
62
62
  | "session"
63
63
  /** Its app didn't answer. */
64
64
  | "upstream"
65
+ /** The host's own check refused it (revoked, no longer a member): `code` says which. */
66
+ | "denied"
65
67
  | "not-found"
66
68
  | "error";
67
69
 
68
70
  /**
69
- * How a display's first document went, once per run: it loaded (a shell or view finished its
70
- * handshake; a url-mode app's document loaded), it was blocked (and why), or nothing settled in
71
- * `limits.loadTimeoutMs`. A host shows its own fallback ("Open in a new tab") on anything but
72
- * loaded.
71
+ * How a display's document went: it loaded (a shell or view finished its handshake; a url-mode
72
+ * app's document loaded), it was blocked (and why), or nothing settled in
73
+ * `limits.loadTimeoutMs`. Once per run, plus a later `blocked` if the frame shows a status page
74
+ * after it loaded (access revoked mid-session). A host shows its own fallback ("Open in a new
75
+ * tab") on anything but loaded.
73
76
  */
74
77
  export type FrameOutcome =
75
78
  | { readonly state: "loaded" }
76
- | { readonly state: "blocked"; readonly reason: FrameBlock; readonly status?: number }
79
+ | {
80
+ readonly state: "blocked";
81
+ readonly reason: FrameBlock;
82
+ readonly status?: number;
83
+ /** The host's own reason code, for `denied` (`Authorizer`). */
84
+ readonly code?: string;
85
+ }
77
86
  | { readonly state: "timeout" };
78
87
 
79
88
  export interface DisplayEvents {
package/src/index.ts CHANGED
@@ -38,8 +38,14 @@ export {
38
38
  originSourceProblem,
39
39
  validateConfig,
40
40
  } from "./config/validate.ts";
41
- export { URL_SANDBOX_EXTRAS, type UrlSandboxExtra } from "./config/schema.ts";
41
+ export {
42
+ URL_PERMISSIONS,
43
+ URL_SANDBOX_EXTRAS,
44
+ type UrlPermission,
45
+ type UrlSandboxExtra,
46
+ } from "./config/schema.ts";
42
47
  export { hostOriginProblem, isHostOrigin } from "./isolation/served.ts";
48
+ export { FRAME_STATUS_METHOD } from "./isolation/types.ts";
43
49
 
44
50
  // Presets.
45
51
  export {
@@ -121,6 +127,8 @@ export { createHost } from "./host/create-host.ts";
121
127
  export type {
122
128
  DisplayControls,
123
129
  DisplayEvents,
130
+ FrameBlock,
131
+ FrameOutcome,
124
132
  DisplayHandle,
125
133
  HostOptions,
126
134
  MountOptions,
@@ -10,6 +10,7 @@ import type {
10
10
  Limits,
11
11
  PortholeConfig,
12
12
  ThemeValue,
13
+ UrlPermission,
13
14
  UrlSandboxExtra,
14
15
  } from "../config/schema.ts";
15
16
  import { validateConfig } from "../config/validate.ts";
@@ -51,6 +52,8 @@ export interface UrlPresetOptions {
51
52
  readonly contentOrigins: readonly string[];
52
53
  /** Sandbox tokens to add for the app (popups for an OAuth flow, modals, downloads; P037). */
53
54
  readonly sandbox?: readonly UrlSandboxExtra[];
55
+ /** Features to delegate to the app (clipboard writes, full screen; P049). */
56
+ readonly permissions?: readonly UrlPermission[];
54
57
  readonly limits?: Partial<Limits>;
55
58
  readonly layout?: Partial<LayoutConfig>;
56
59
  readonly embedder?: EmbedderConfig;
@@ -211,6 +214,7 @@ export const presets = {
211
214
  contentOrigins: options.contentOrigins,
212
215
  embedder: options.embedder ?? {},
213
216
  ...(options.sandbox === undefined ? {} : { sandbox: options.sandbox }),
217
+ ...(options.permissions === undefined ? {} : { permissions: options.permissions }),
214
218
  }),
215
219
  } as const;
216
220
 
@@ -0,0 +1,166 @@
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
+
9
+ const K = new Uint32Array([
10
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
11
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
12
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
13
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
14
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
15
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
16
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
17
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
18
+ ]);
19
+
20
+ const rotr = (x: number, n: number): number => (x >>> n) | (x << (32 - n));
21
+
22
+ export function sha256(data: Uint8Array): Uint8Array {
23
+ const length = data.length;
24
+ // Padded to a multiple of 64 bytes: a 1 bit, zeros, then the length in bits (64-bit big-endian).
25
+ const padded = new Uint8Array(Math.ceil((length + 9) / 64) * 64);
26
+ padded.set(data);
27
+ padded[length] = 0x80;
28
+ const view = new DataView(padded.buffer);
29
+ view.setUint32(padded.length - 8, Math.floor(length / 0x20000000), false);
30
+ view.setUint32(padded.length - 4, (length * 8) >>> 0, false);
31
+
32
+ const h = new Uint32Array([
33
+ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
34
+ ]);
35
+ const w = new Uint32Array(64);
36
+ for (let offset = 0; offset < padded.length; offset += 64) {
37
+ for (let i = 0; i < 16; i += 1) {
38
+ w[i] = view.getUint32(offset + i * 4, false);
39
+ }
40
+ for (let i = 16; i < 64; i += 1) {
41
+ const a = w[i - 15] as number;
42
+ const b = w[i - 2] as number;
43
+ const s0 = rotr(a, 7) ^ rotr(a, 18) ^ (a >>> 3);
44
+ const s1 = rotr(b, 17) ^ rotr(b, 19) ^ (b >>> 10);
45
+ w[i] = ((w[i - 16] as number) + s0 + (w[i - 7] as number) + s1) >>> 0;
46
+ }
47
+ let [a, b, c, d, e, f, g, hh] = h as unknown as number[] as [
48
+ number,
49
+ number,
50
+ number,
51
+ number,
52
+ number,
53
+ number,
54
+ number,
55
+ number,
56
+ ];
57
+ for (let i = 0; i < 64; i += 1) {
58
+ const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
59
+ const ch = (e & f) ^ (~e & g);
60
+ const t1 = (hh + s1 + ch + (K[i] as number) + (w[i] as number)) >>> 0;
61
+ const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
62
+ const maj = (a & b) ^ (a & c) ^ (b & c);
63
+ const t2 = (s0 + maj) >>> 0;
64
+ hh = g;
65
+ g = f;
66
+ f = e;
67
+ e = (d + t1) >>> 0;
68
+ d = c;
69
+ c = b;
70
+ b = a;
71
+ a = (t1 + t2) >>> 0;
72
+ }
73
+ const next = [a, b, c, d, e, f, g, hh];
74
+ for (let i = 0; i < 8; i += 1) {
75
+ h[i] = ((h[i] as number) + (next[i] as number)) >>> 0;
76
+ }
77
+ }
78
+ const out = new Uint8Array(32);
79
+ const outView = new DataView(out.buffer);
80
+ for (let i = 0; i < 8; i += 1) {
81
+ outView.setUint32(i * 4, h[i] as number, false);
82
+ }
83
+ return out;
84
+ }
85
+
86
+ const BLOCK = 64;
87
+
88
+ export function hmacSha256(key: Uint8Array, message: Uint8Array): Uint8Array {
89
+ const block = new Uint8Array(BLOCK);
90
+ block.set(key.length > BLOCK ? sha256(key) : key);
91
+ const inner = new Uint8Array(BLOCK + message.length);
92
+ const outer = new Uint8Array(BLOCK + 32);
93
+ for (let i = 0; i < BLOCK; i += 1) {
94
+ inner[i] = (block[i] as number) ^ 0x36;
95
+ outer[i] = (block[i] as number) ^ 0x5c;
96
+ }
97
+ inner.set(message, BLOCK);
98
+ outer.set(sha256(inner), BLOCK);
99
+ return sha256(outer);
100
+ }
101
+
102
+ const encoder = new TextEncoder();
103
+ const decoder = new TextDecoder();
104
+
105
+ export const utf8 = (text: string): Uint8Array => encoder.encode(text);
106
+ export const fromUtf8 = (bytes: Uint8Array): string => decoder.decode(bytes);
107
+
108
+ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
109
+
110
+ /** Base64url without padding (RFC 4648 §5). */
111
+ export function base64url(bytes: Uint8Array): string {
112
+ let out = "";
113
+ for (let i = 0; i < bytes.length; i += 3) {
114
+ const n = ((bytes[i] as number) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0);
115
+ out += B64[(n >>> 18) & 63];
116
+ out += B64[(n >>> 12) & 63];
117
+ if (i + 1 < bytes.length) {
118
+ out += B64[(n >>> 6) & 63];
119
+ }
120
+ if (i + 2 < bytes.length) {
121
+ out += B64[n & 63];
122
+ }
123
+ }
124
+ return out;
125
+ }
126
+
127
+ /** Decodes base64url without padding; null for anything else. */
128
+ export function fromBase64url(text: string): Uint8Array | null {
129
+ if (!/^[A-Za-z0-9_-]*$/u.test(text) || text.length % 4 === 1) {
130
+ return null;
131
+ }
132
+ const out = new Uint8Array(Math.floor((text.length * 3) / 4));
133
+ let bits = 0;
134
+ let buffer = 0;
135
+ let index = 0;
136
+ for (const char of text) {
137
+ buffer = (buffer << 6) | B64.indexOf(char);
138
+ bits += 6;
139
+ if (bits >= 8) {
140
+ bits -= 8;
141
+ out[index] = (buffer >>> bits) & 0xff;
142
+ index += 1;
143
+ }
144
+ }
145
+ // The leftover bits of a canonical encoding are zero.
146
+ return (buffer & ((1 << bits) - 1)) === 0 ? out : null;
147
+ }
148
+
149
+ /** Equal bytes, compared in time that depends only on the length. */
150
+ export function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean {
151
+ if (a.length !== b.length) {
152
+ return false;
153
+ }
154
+ let difference = 0;
155
+ for (let i = 0; i < a.length; i += 1) {
156
+ difference |= (a[i] as number) ^ (b[i] as number);
157
+ }
158
+ return difference === 0;
159
+ }
160
+
161
+ /** Random bytes from the runtime's CSPRNG (`crypto.getRandomValues`: Node, Workers, browsers). */
162
+ export function randomBytes(length: number): Uint8Array {
163
+ const bytes = new Uint8Array(length);
164
+ globalThis.crypto.getRandomValues(bytes);
165
+ return bytes;
166
+ }
@@ -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,11 +28,22 @@
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 {
32
+ d1TicketSchema,
33
+ d1TicketStore,
34
+ memoryTicketStore,
35
+ type D1Like,
36
+ type D1StatementLike,
37
+ } from "./tickets.ts";
38
+ export { FRAME_STATUS_METHOD } from "../isolation/types.ts";
39
+ export { isHostLabel, prefixedName } from "./naming.ts";
26
40
  export { proxyTo, type ProxyOptions } from "./proxy.ts";
27
41
  export type {
28
42
  Access,
29
43
  ArtifactStore,
44
+ AuthorizeAnswer,
45
+ AuthorizeRequest,
46
+ Authorizer,
30
47
  ContentDomain,
31
48
  Grant,
32
49
  PortholeServer,
@@ -1,4 +1,13 @@
1
- import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
1
+ import {
2
+ base64url,
3
+ fromBase64url,
4
+ fromUtf8,
5
+ hmacSha256,
6
+ randomBytes,
7
+ sha256,
8
+ timingSafeEqual,
9
+ utf8,
10
+ } from "./crypto.ts";
2
11
 
3
12
  /**
4
13
  * The server's secrets, derived from the one it is given: one key per use, so a value made for
@@ -27,8 +36,6 @@ export function base32(bytes: Uint8Array): string {
27
36
  return out;
28
37
  }
29
38
 
30
- const base64url = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64url");
31
-
32
39
  /** JSON with object keys in a fixed order, so equal values always serialize alike. */
33
40
  export function canonicalJson(value: unknown): string {
34
41
  if (Array.isArray(value)) {
@@ -58,19 +65,17 @@ export function createKeys(secret: Uint8Array): Keys {
58
65
  if (secret.byteLength < 32) {
59
66
  throw new TypeError("The server's secret must be at least 32 random bytes");
60
67
  }
61
- const derive = (use: string): Buffer =>
62
- createHmac("sha256", secret).update(`porthole/${use}`).digest();
68
+ const derive = (use: string): Uint8Array => hmacSha256(secret, utf8(`porthole/${use}`));
63
69
  const labelKey = derive("label");
64
70
  const instanceKey = derive("instance");
65
71
  const signKey = derive("session");
66
- const mac = (key: Buffer, text: string): Buffer =>
67
- createHmac("sha256", key).update(text).digest();
72
+ const mac = (key: Uint8Array, text: string): Uint8Array => hmacSha256(key, utf8(text));
68
73
 
69
74
  return {
70
75
  label: (scope) => base32(mac(labelKey, scope).subarray(0, 15)),
71
76
  instanceKey: (instance) => base32(mac(instanceKey, canonicalJson(instance)).subarray(0, 10)),
72
77
  sign: (value) => {
73
- const payload = Buffer.from(JSON.stringify(value)).toString("base64url");
78
+ const payload = base64url(utf8(JSON.stringify(value)));
74
79
  return `${payload}.${base64url(mac(signKey, payload))}`;
75
80
  },
76
81
  verify: (token) => {
@@ -79,12 +84,16 @@ export function createKeys(secret: Uint8Array): Keys {
79
84
  return null;
80
85
  }
81
86
  const expected = mac(signKey, payload);
82
- const given = Buffer.from(signature, "base64url");
83
- if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
87
+ const given = fromBase64url(signature);
88
+ if (given === null || !timingSafeEqual(given, expected)) {
89
+ return null;
90
+ }
91
+ const bytes = fromBase64url(payload);
92
+ if (bytes === null) {
84
93
  return null;
85
94
  }
86
95
  try {
87
- return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as unknown;
96
+ return JSON.parse(fromUtf8(bytes)) as unknown;
88
97
  } catch {
89
98
  return null;
90
99
  }
@@ -96,5 +105,4 @@ export function createKeys(secret: Uint8Array): Keys {
96
105
  export const newTicket = (): string => `pt_${base32(randomBytes(20))}`;
97
106
 
98
107
  /** What a ticket is stored under: its hash, so a leaked store holds no usable ticket. */
99
- export const ticketId = (ticket: string): string =>
100
- createHash("sha256").update(ticket).digest("base64url");
108
+ export const ticketId = (ticket: string): string => base64url(sha256(utf8(ticket)));
@@ -0,0 +1,42 @@
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
+ /** One lowercase DNS label (a-z 0-9 -, at most 63, no hyphen at either end). */
7
+ const DNS_LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/u;
8
+
9
+ /**
10
+ * Whether a label is one a host may name a content origin with: one lowercase DNS label, and not
11
+ * of the form IDNA reserves (`xn--`, any `??--`), so a name never displays as something else.
12
+ */
13
+ export function isHostLabel(label: unknown): label is string {
14
+ return typeof label === "string" && DNS_LABEL.test(label) && label.slice(2, 4) !== "--";
15
+ }
16
+
17
+ const PREFIX = /^[a-z][a-z0-9]{0,15}-$/u;
18
+ const ID = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/u;
19
+
20
+ /**
21
+ * A naming for `content.name` that puts each scope on `<prefix><scope>` (`prefixedName("a-")`:
22
+ * scope `k3x9` is served on `a-k3x9.<domain>`). One-to-one, so no two scopes share an origin, and
23
+ * no name it makes is a reserved one or a keyed default (those have no hyphen). Scopes must
24
+ * already be label-safe (lowercase letters, digits, inner hyphens); anything else throws rather
25
+ * than being folded into another scope's name.
26
+ */
27
+ export function prefixedName(prefix: string): (scope: string) => string {
28
+ if (!PREFIX.test(prefix)) {
29
+ throw new TypeError(
30
+ "A name prefix is a lowercase letter, then letters or digits, then '-' (a-)",
31
+ );
32
+ }
33
+ return (scope) => {
34
+ const label = `${prefix}${scope}`;
35
+ if (!ID.test(scope) || !isHostLabel(label)) {
36
+ throw new TypeError(
37
+ `Scope ${JSON.stringify(scope)} can't be named under ${prefix}: lowercase letters, digits and inner hyphens, ${63 - prefix.length} at most`,
38
+ );
39
+ }
40
+ return label;
41
+ };
42
+ }
@@ -30,13 +30,15 @@ const STATUS_SCRIPT = `(() => {
30
30
  if (main === null) return;
31
31
  const hosts = JSON.parse(main.dataset.portholeHosts || "[]");
32
32
  ${say()}
33
- say({ state: "blocked", reason: main.dataset.portholeReason || "error", status: Number(main.dataset.portholeStatus) });
33
+ const blocked = { state: "blocked", reason: main.dataset.portholeReason || "error", status: Number(main.dataset.portholeStatus) };
34
+ if (main.dataset.portholeCode) blocked.code = main.dataset.portholeCode;
35
+ say(blocked);
34
36
  })();`;
35
37
 
36
38
  /**
37
39
  * Enters a framed app: says so, checks that the browser kept the session cookie this page's own
38
- * answer set (WebKit keeps none in a cross-site frame, P025), then goes on to the app, or says it
39
- * is blocked and why.
40
+ * answer set (WebKit keeps none in a cross-site frame, P025), then, once this page has loaded,
41
+ * goes on to the app, or says it is blocked and why.
40
42
  */
41
43
  const ENTER_SCRIPT = `(async () => {
42
44
  const main = document.querySelector("[data-porthole-enter]");
@@ -49,6 +51,12 @@ const ENTER_SCRIPT = `(async () => {
49
51
  kept = (await fetch(${JSON.stringify(SESSION_PATH)}, { credentials: "same-origin", cache: "no-store" })).ok;
50
52
  } catch (_) {}
51
53
  if (kept) {
54
+ // The host counts this page's load, then the app's (P040): leave only once this page has
55
+ // loaded, or a fast answer would leave first and its load would never be seen.
56
+ if (document.readyState !== "complete") {
57
+ await new Promise((resolve) => addEventListener("load", resolve, { once: true }));
58
+ }
59
+ await new Promise((resolve) => setTimeout(resolve, 0));
52
60
  location.replace(main.dataset.portholeTarget || "/");
53
61
  return;
54
62
  }
@@ -95,8 +103,9 @@ export function statusPage(
95
103
  method = "GET",
96
104
  reason: FrameBlock = "error",
97
105
  hostOrigins: readonly string[] = [],
106
+ code?: string,
98
107
  ): Response {
99
- const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escape(title)}</title><style>${STYLE}</style></head><body><main data-porthole-status="${status}" data-porthole-reason="${escape(reason)}" data-porthole-hosts="${escape(JSON.stringify(hostOrigins))}"><h1>${escape(title)}</h1><p>${escape(detail)}</p></main><script src="${STATUS_SCRIPT_PATH}"></script></body></html>`;
108
+ const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escape(title)}</title><style>${STYLE}</style></head><body><main data-porthole-status="${status}" data-porthole-reason="${escape(reason)}"${code === undefined ? "" : ` data-porthole-code="${escape(code)}"`} data-porthole-hosts="${escape(JSON.stringify(hostOrigins))}"><h1>${escape(title)}</h1><p>${escape(detail)}</p></main><script src="${STATUS_SCRIPT_PATH}"></script></body></html>`;
100
109
  return new Response(method === "HEAD" ? null : body, {
101
110
  status,
102
111
  headers: pageHeaders(frameAncestors, false),