@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
@@ -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
+ }
@@ -1,7 +1,12 @@
1
+ import { FRAME_STATUS_METHOD } from "../isolation/types.ts";
2
+ import type { FrameBlock } from "../host/types.ts";
3
+
1
4
  /**
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
+ * The server's own pages on a content origin: why it shows nothing of the content, and the page a
6
+ * framed app is entered through. Plain, framable only by the host origins, never cached. Each tells
7
+ * the page that frames it how loading went (`porthole/notifications/frame-status`, P040), posted to
8
+ * the host origins only, so a host can show its own fallback ("Open in a new tab"). Their words are
9
+ * the host-agnostic mechanism's; a host that wants its own wording serves its own page in front.
5
10
  */
6
11
 
7
12
  const escape = (text: string): string =>
@@ -10,22 +15,112 @@ const escape = (text: string): string =>
10
15
  const STYLE =
11
16
  ":root{color-scheme:light dark;font-family:system-ui,sans-serif}body{margin:0;min-height:100vh;display:grid;place-items:center;background:Canvas;color:CanvasText}main{max-width:420px;padding:24px}h1{font-size:17px;margin:0 0 8px}p{margin:0;opacity:.75;line-height:1.5}";
12
17
 
18
+ /** Where the pages' scripts are served, on every content origin. */
19
+ export const STATUS_SCRIPT_PATH = "/__porthole/status.js";
20
+ export const ENTER_SCRIPT_PATH = "/__porthole/enter.js";
21
+ /** Answers 204 when the request carries a good session for its origin's app, 401 otherwise. */
22
+ export const SESSION_PATH = "/__porthole/session";
23
+
24
+ const say = (): string =>
25
+ `const say = (params) => { if (window.parent === window) return; for (const origin of hosts) window.parent.postMessage({ jsonrpc: "2.0", method: ${JSON.stringify(FRAME_STATUS_METHOD)}, params }, origin); };`;
26
+
27
+ /** Tells the host the page is blocked, and why (from the page's data attributes). */
28
+ const STATUS_SCRIPT = `(() => {
29
+ const main = document.querySelector("[data-porthole-status]");
30
+ if (main === null) return;
31
+ const hosts = JSON.parse(main.dataset.portholeHosts || "[]");
32
+ ${say()}
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);
36
+ })();`;
37
+
38
+ /**
39
+ * Enters a framed app: says so, checks that the browser kept the session cookie this page's own
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.
42
+ */
43
+ const ENTER_SCRIPT = `(async () => {
44
+ const main = document.querySelector("[data-porthole-enter]");
45
+ if (main === null) return;
46
+ const hosts = JSON.parse(main.dataset.portholeHosts || "[]");
47
+ ${say()}
48
+ say({ state: "entering" });
49
+ let kept = false;
50
+ try {
51
+ kept = (await fetch(${JSON.stringify(SESSION_PATH)}, { credentials: "same-origin", cache: "no-store" })).ok;
52
+ } catch (_) {}
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));
60
+ location.replace(main.dataset.portholeTarget || "/");
61
+ return;
62
+ }
63
+ say({ state: "blocked", reason: "cookies", status: 401 });
64
+ main.querySelector("h1").textContent = "Open this app in a new tab";
65
+ main.querySelector("p").textContent = "This browser keeps no sign-in for it inside a frame.";
66
+ })();`;
67
+
68
+ /** The pages' scripts, by path: served with the pages' own policy in mind (`script-src 'self'`). */
69
+ export function pageScript(pathname: string): Response | null {
70
+ const body =
71
+ pathname === STATUS_SCRIPT_PATH
72
+ ? STATUS_SCRIPT
73
+ : pathname === ENTER_SCRIPT_PATH
74
+ ? ENTER_SCRIPT
75
+ : null;
76
+ return body === null
77
+ ? null
78
+ : new Response(body, {
79
+ headers: {
80
+ "content-type": "text/javascript; charset=utf-8",
81
+ "cache-control": "no-store",
82
+ "x-content-type-options": "nosniff",
83
+ },
84
+ });
85
+ }
86
+
87
+ const policy = (frameAncestors: string, connect: boolean): string =>
88
+ `default-src 'none'; style-src 'unsafe-inline'; script-src 'self'; ${connect ? "connect-src 'self'; " : ""}base-uri 'none'; form-action 'none'; frame-ancestors ${frameAncestors}`;
89
+
90
+ const pageHeaders = (frameAncestors: string, connect: boolean) => ({
91
+ "content-type": "text/html; charset=utf-8",
92
+ "cache-control": "no-store",
93
+ "content-security-policy": policy(frameAncestors, connect),
94
+ "referrer-policy": "no-referrer",
95
+ "x-content-type-options": "nosniff",
96
+ });
97
+
13
98
  export function statusPage(
14
99
  status: number,
15
100
  title: string,
16
101
  detail: string,
17
102
  frameAncestors: string,
18
103
  method = "GET",
104
+ reason: FrameBlock = "error",
105
+ hostOrigins: readonly string[] = [],
106
+ code?: string,
19
107
  ): Response {
20
- 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}"><h1>${escape(title)}</h1><p>${escape(detail)}</p></main></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>`;
21
109
  return new Response(method === "HEAD" ? null : body, {
22
110
  status,
23
- headers: {
24
- "content-type": "text/html; charset=utf-8",
25
- "cache-control": "no-store",
26
- "content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors ${frameAncestors}`,
27
- "referrer-policy": "no-referrer",
28
- "x-content-type-options": "nosniff",
29
- },
111
+ headers: pageHeaders(frameAncestors, false),
30
112
  });
31
113
  }
114
+
115
+ /** The page a framed app is entered through (its answer also sets the session cookie). */
116
+ export function enterPage(
117
+ target: string,
118
+ frameAncestors: string,
119
+ hostOrigins: readonly string[],
120
+ cookie: string,
121
+ ): Response {
122
+ const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Opening the app</title><style>${STYLE}</style></head><body><main data-porthole-enter="" data-porthole-target="${escape(target)}" data-porthole-hosts="${escape(JSON.stringify(hostOrigins))}"><h1>Opening the app…</h1><p></p></main><script src="${ENTER_SCRIPT_PATH}"></script></body></html>`;
123
+ const headers = new Headers(pageHeaders(frameAncestors, true));
124
+ headers.set("set-cookie", cookie);
125
+ return new Response(body, { status: 200, headers });
126
+ }