@botiverse/porthole 0.1.1 → 0.1.3
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.
- package/dist/annotate/types.d.ts +120 -0
- package/dist/annotate/types.js +42 -0
- package/dist/annotate/validate.d.ts +11 -0
- package/dist/annotate/validate.js +156 -0
- package/dist/bridge/bridge.d.ts +4 -0
- package/dist/bridge/bridge.js +12 -1
- package/dist/compile/policy.d.ts +5 -3
- package/dist/compile/policy.js +8 -4
- package/dist/config/capabilities.d.ts +37 -4
- package/dist/config/capabilities.js +20 -0
- package/dist/config/schema.d.ts +8 -0
- package/dist/config/schema.js +5 -0
- package/dist/config/validate.js +15 -1
- package/dist/conformance/clipboard.d.ts +2 -0
- package/dist/conformance/clipboard.js +167 -0
- package/dist/conformance/kit.d.ts +1 -1
- package/dist/conformance/probes.js +2 -0
- package/dist/guide/guide.js +8 -4
- package/dist/host/clipboard.d.ts +11 -0
- package/dist/host/clipboard.js +43 -0
- package/dist/host/create-host.js +141 -0
- package/dist/host/outcome.d.ts +1 -0
- package/dist/host/outcome.js +19 -6
- package/dist/host/types.d.ts +34 -5
- package/dist/index.d.ts +8 -4
- package/dist/index.js +8 -3
- package/dist/mcp/adapter.js +10 -1
- package/dist/presets/presets.d.ts +3 -1
- package/dist/presets/presets.js +1 -0
- package/dist/protocol/mcp-apps.d.ts +5 -0
- package/dist/protocol/mcp-apps.js +5 -0
- package/dist/protocol/shell-adapter.js +6 -2
- package/dist/react/dialog.js +2 -0
- package/dist/react/frame.d.ts +18 -0
- package/dist/react/frame.js +82 -2
- package/dist/react/index.d.ts +2 -1
- package/dist/react/index.js +2 -1
- package/dist/react/toast.d.ts +33 -0
- package/dist/react/toast.js +42 -0
- package/dist/server/crypto.d.ts +19 -0
- package/dist/server/crypto.js +144 -0
- package/dist/server/index.d.ts +12 -4
- package/dist/server/index.js +11 -3
- package/dist/server/keys.js +12 -9
- package/dist/server/naming.d.ts +17 -0
- package/dist/server/naming.js +34 -0
- package/dist/server/pages.d.ts +1 -1
- package/dist/server/pages.js +13 -5
- package/dist/server/server.js +162 -18
- package/dist/server/tickets.d.ts +24 -0
- package/dist/server/tickets.js +46 -0
- package/dist/server/types.d.ts +76 -5
- package/dist/shell/annotate.d.ts +20 -0
- package/dist/shell/annotate.js +669 -0
- package/dist/shell/document.js +4 -1
- package/dist/shell/plan.d.ts +2 -0
- package/dist/shell/plan.js +2 -1
- package/dist/shell/runtime.d.ts +4 -1
- package/dist/shell/runtime.js +38 -2
- package/package.json +2 -1
- package/src/annotate/types.ts +126 -0
- package/src/annotate/validate.ts +186 -0
- package/src/bridge/bridge.ts +13 -0
- package/src/compile/policy.ts +8 -4
- package/src/config/capabilities.ts +33 -3
- package/src/config/schema.ts +10 -0
- package/src/config/validate.ts +16 -1
- package/src/conformance/clipboard.ts +184 -0
- package/src/conformance/kit.ts +1 -1
- package/src/conformance/probes.ts +2 -0
- package/src/guide/guide.ts +19 -4
- package/src/host/clipboard.ts +41 -0
- package/src/host/create-host.ts +155 -0
- package/src/host/outcome.ts +25 -7
- package/src/host/types.ts +39 -5
- package/src/index.ts +31 -1
- package/src/mcp/adapter.ts +10 -1
- package/src/presets/presets.ts +4 -0
- package/src/protocol/mcp-apps.ts +6 -0
- package/src/protocol/shell-adapter.ts +11 -2
- package/src/react/dialog.tsx +2 -0
- package/src/react/frame.tsx +117 -1
- package/src/react/index.ts +9 -1
- package/src/react/toast.tsx +96 -0
- package/src/server/crypto.ts +166 -0
- package/src/server/index.ts +20 -3
- package/src/server/keys.ts +21 -13
- package/src/server/naming.ts +42 -0
- package/src/server/pages.ts +13 -4
- package/src/server/server.ts +190 -20
- package/src/server/tickets.ts +65 -0
- package/src/server/types.ts +84 -7
- package/src/shell/annotate.ts +669 -0
- package/src/shell/document.ts +6 -1
- package/src/shell/plan.ts +4 -1
- package/src/shell/runtime.ts +38 -2
package/dist/server/index.d.ts
CHANGED
|
@@ -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)
|
|
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,
|
|
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";
|
package/dist/server/index.js
CHANGED
|
@@ -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)
|
|
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,
|
|
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";
|
package/dist/server/keys.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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) =>
|
|
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) =>
|
|
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 =
|
|
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 =
|
|
63
|
-
if (given
|
|
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(
|
|
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) =>
|
|
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
|
+
}
|
package/dist/server/pages.d.ts
CHANGED
|
@@ -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;
|
package/dist/server/pages.js
CHANGED
|
@@ -20,12 +20,14 @@ const STATUS_SCRIPT = `(() => {
|
|
|
20
20
|
if (main === null) return;
|
|
21
21
|
const hosts = JSON.parse(main.dataset.portholeHosts || "[]");
|
|
22
22
|
${say()}
|
|
23
|
-
|
|
23
|
+
const blocked = { state: "blocked", reason: main.dataset.portholeReason || "error", status: Number(main.dataset.portholeStatus) };
|
|
24
|
+
if (main.dataset.portholeCode) blocked.code = main.dataset.portholeCode;
|
|
25
|
+
say(blocked);
|
|
24
26
|
})();`;
|
|
25
27
|
/**
|
|
26
28
|
* Enters a framed app: says so, checks that the browser kept the session cookie this page's own
|
|
27
|
-
* answer set (WebKit keeps none in a cross-site frame, P025), then
|
|
28
|
-
* is blocked and why.
|
|
29
|
+
* answer set (WebKit keeps none in a cross-site frame, P025), then, once this page has loaded,
|
|
30
|
+
* goes on to the app, or says it is blocked and why.
|
|
29
31
|
*/
|
|
30
32
|
const ENTER_SCRIPT = `(async () => {
|
|
31
33
|
const main = document.querySelector("[data-porthole-enter]");
|
|
@@ -38,6 +40,12 @@ const ENTER_SCRIPT = `(async () => {
|
|
|
38
40
|
kept = (await fetch(${JSON.stringify(SESSION_PATH)}, { credentials: "same-origin", cache: "no-store" })).ok;
|
|
39
41
|
} catch (_) {}
|
|
40
42
|
if (kept) {
|
|
43
|
+
// The host counts this page's load, then the app's (P040): leave only once this page has
|
|
44
|
+
// loaded, or a fast answer would leave first and its load would never be seen.
|
|
45
|
+
if (document.readyState !== "complete") {
|
|
46
|
+
await new Promise((resolve) => addEventListener("load", resolve, { once: true }));
|
|
47
|
+
}
|
|
48
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
41
49
|
location.replace(main.dataset.portholeTarget || "/");
|
|
42
50
|
return;
|
|
43
51
|
}
|
|
@@ -70,8 +78,8 @@ const pageHeaders = (frameAncestors, connect) => ({
|
|
|
70
78
|
"referrer-policy": "no-referrer",
|
|
71
79
|
"x-content-type-options": "nosniff",
|
|
72
80
|
});
|
|
73
|
-
export function statusPage(status, title, detail, frameAncestors, method = "GET", reason = "error", hostOrigins = []) {
|
|
74
|
-
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>`;
|
|
81
|
+
export function statusPage(status, title, detail, frameAncestors, method = "GET", reason = "error", hostOrigins = [], code) {
|
|
82
|
+
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>`;
|
|
75
83
|
return new Response(method === "HEAD" ? null : body, {
|
|
76
84
|
status,
|
|
77
85
|
headers: pageHeaders(frameAncestors, false),
|
package/dist/server/server.js
CHANGED
|
@@ -2,6 +2,7 @@ import { compile } from "../compile/compile.js";
|
|
|
2
2
|
import { hostOriginProblem, maybeSameSite, originMatchesSource, parseOrigin, } from "../isolation/served.js";
|
|
3
3
|
import { sandboxProxyDocument } from "../mcp/proxy.js";
|
|
4
4
|
import { createKeys, newTicket, ticketId } from "./keys.js";
|
|
5
|
+
import { isHostLabel } from "./naming.js";
|
|
5
6
|
import { enterPage, pageScript, SESSION_PATH, statusPage } from "./pages.js";
|
|
6
7
|
import { memoryTicketStore } from "./tickets.js";
|
|
7
8
|
/**
|
|
@@ -14,6 +15,9 @@ const DOCUMENT_PATH = /^\/__porthole\/d\/([a-z2-7]{16})$/u;
|
|
|
14
15
|
/** Where a url-mode app's ticket is taken, before the app's own landing path. */
|
|
15
16
|
const ENTER_PATH = "/__porthole/enter";
|
|
16
17
|
const LABEL = /^[a-z2-7]{24}$/u;
|
|
18
|
+
/** A host's opaque subject and reason codes. */
|
|
19
|
+
const SUBJECT_MAX = 512;
|
|
20
|
+
const CODE = /^[a-z0-9-]{1,40}$/u;
|
|
17
21
|
const DNS_NAME = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/u;
|
|
18
22
|
const NAME = /^[A-Za-z0-9._:-]{1,200}$/u;
|
|
19
23
|
const DEFAULT_TICKET_SECONDS = 60;
|
|
@@ -144,6 +148,25 @@ export function createServer(options) {
|
|
|
144
148
|
throw new TypeError("content.port must be a port number");
|
|
145
149
|
}
|
|
146
150
|
const keys = createKeys(options.secret);
|
|
151
|
+
const reserved = new Set(content.reserved ?? []);
|
|
152
|
+
for (const label of reserved) {
|
|
153
|
+
if (!isHostLabel(label)) {
|
|
154
|
+
throw new TypeError(`content.reserved: ${JSON.stringify(label)} is not a lowercase DNS label`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** The label a scope is served on: the host's naming, checked, or the keyed default. */
|
|
158
|
+
const nameOf = (scope) => {
|
|
159
|
+
if (content.name === undefined) {
|
|
160
|
+
return keys.label(scope);
|
|
161
|
+
}
|
|
162
|
+
const label = content.name(scope);
|
|
163
|
+
if (!isHostLabel(label) || reserved.has(label)) {
|
|
164
|
+
throw new TypeError(`content.name gave ${JSON.stringify(label)} for a scope: it must be one lowercase DNS label (a-z 0-9 -, at most 63, not xn--), not reserved`);
|
|
165
|
+
}
|
|
166
|
+
return label;
|
|
167
|
+
};
|
|
168
|
+
/** Whether a request's label may be one this server named. */
|
|
169
|
+
const ours = (label) => !reserved.has(label) && (content.name === undefined ? LABEL.test(label) : isHostLabel(label));
|
|
147
170
|
const secure = content.scheme === "https";
|
|
148
171
|
const defaultPort = secure ? 443 : 80;
|
|
149
172
|
const portPart = content.port === undefined || content.port === defaultPort ? "" : `:${content.port}`;
|
|
@@ -151,6 +174,12 @@ export function createServer(options) {
|
|
|
151
174
|
const contentSource = `${content.scheme}://*.${domain}${portPart}`;
|
|
152
175
|
const sampleHost = `${keys.label("")}.${domain}`;
|
|
153
176
|
const sampleOrigin = originOfLabel(keys.label(""));
|
|
177
|
+
const authorize = options.authorize;
|
|
178
|
+
const authorizeTtl = authorize?.ttlSeconds ?? 0;
|
|
179
|
+
if (!Number.isInteger(authorizeTtl) || authorizeTtl < 0 || authorizeTtl > 300) {
|
|
180
|
+
throw new TypeError("authorize.ttlSeconds must be an integer from 0 to 300");
|
|
181
|
+
}
|
|
182
|
+
const answers = new Map();
|
|
154
183
|
if (options.hostOrigins.length === 0) {
|
|
155
184
|
throw new TypeError("hostOrigins must name the origins that embed content");
|
|
156
185
|
}
|
|
@@ -177,7 +206,54 @@ export function createServer(options) {
|
|
|
177
206
|
}
|
|
178
207
|
const tickets = options.tickets ?? memoryTicketStore();
|
|
179
208
|
/** A page saying why there is nothing to show, told to the host too (its reason, P040). */
|
|
180
|
-
const page = (status, title, detail, method, reason = status === 404 ? "not-found" : status === 502 ? "upstream" : "error") => statusPage(status, title, detail, frameAncestors, method, reason, options.hostOrigins);
|
|
209
|
+
const page = (status, title, detail, method, reason = status === 404 ? "not-found" : status === 502 ? "upstream" : "error", code) => statusPage(status, title, detail, frameAncestors, method, reason, options.hostOrigins, code);
|
|
210
|
+
/**
|
|
211
|
+
* The host's check on a request its cookie (or ticket) let in (P050): null to go on, or the
|
|
212
|
+
* page saying it's denied. Answers are reused only for the host's own `ttlSeconds`.
|
|
213
|
+
*/
|
|
214
|
+
async function denied(request, access) {
|
|
215
|
+
if (authorize === undefined) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
const key = JSON.stringify([
|
|
219
|
+
keys.instanceKey(access.instance),
|
|
220
|
+
access.viewer,
|
|
221
|
+
access.subject ?? null,
|
|
222
|
+
access.expiresAt,
|
|
223
|
+
]);
|
|
224
|
+
const at = Date.now();
|
|
225
|
+
let answer = authorizeTtl > 0 ? answers.get(key) : undefined;
|
|
226
|
+
if (answer === undefined || answer.until <= at) {
|
|
227
|
+
let given;
|
|
228
|
+
try {
|
|
229
|
+
given = await authorize.check({
|
|
230
|
+
scope: access.instance.scope,
|
|
231
|
+
instance: access.instance,
|
|
232
|
+
viewer: access.viewer,
|
|
233
|
+
anonymous: access.anonymous === true,
|
|
234
|
+
...(access.subject === undefined ? {} : { subject: access.subject }),
|
|
235
|
+
request,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
given = { allow: false };
|
|
240
|
+
}
|
|
241
|
+
answer = { answer: given, until: at + authorizeTtl * 1_000 };
|
|
242
|
+
if (authorizeTtl > 0) {
|
|
243
|
+
if (answers.size > 10_000) {
|
|
244
|
+
answers.clear();
|
|
245
|
+
}
|
|
246
|
+
answers.set(key, answer);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
// Only an explicit allow lets the request on: anything else the check gives is a deny.
|
|
250
|
+
const given = answer.answer;
|
|
251
|
+
if (given?.allow === true) {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
const code = typeof given?.code === "string" && CODE.test(given.code) ? given.code : undefined;
|
|
255
|
+
return page(403, "You can't open this anymore", "The app that showed it no longer lets you see it here.", request.method.toUpperCase(), "denied", code);
|
|
256
|
+
}
|
|
181
257
|
/** The config a served document is served under, checked to fit it and this server. */
|
|
182
258
|
function configFor(name, kind) {
|
|
183
259
|
const each = compiled.get(name);
|
|
@@ -271,6 +347,8 @@ export function createServer(options) {
|
|
|
271
347
|
i: record.instance,
|
|
272
348
|
w: record.viewer,
|
|
273
349
|
x: Date.now() + sessionSeconds * 1_000,
|
|
350
|
+
...(record.anonymous === true ? { a: 1 } : {}),
|
|
351
|
+
...(record.subject === undefined ? {} : { s: record.subject }),
|
|
274
352
|
};
|
|
275
353
|
const framed = request.headers.get("sec-fetch-dest") !== "document";
|
|
276
354
|
return [
|
|
@@ -294,7 +372,8 @@ export function createServer(options) {
|
|
|
294
372
|
if (!isRecord(session) ||
|
|
295
373
|
typeof session.w !== "string" ||
|
|
296
374
|
typeof session.x !== "number" ||
|
|
297
|
-
session.x <= Date.now()
|
|
375
|
+
session.x <= Date.now() ||
|
|
376
|
+
(session.s !== undefined && typeof session.s !== "string")) {
|
|
298
377
|
continue;
|
|
299
378
|
}
|
|
300
379
|
let instance;
|
|
@@ -304,12 +383,26 @@ export function createServer(options) {
|
|
|
304
383
|
catch {
|
|
305
384
|
continue;
|
|
306
385
|
}
|
|
307
|
-
|
|
386
|
+
let named;
|
|
387
|
+
try {
|
|
388
|
+
named = nameOf(instance.scope);
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (named !== label ||
|
|
308
394
|
(key === null ? instance.document.kind !== "external" : keys.instanceKey(instance) !== key)) {
|
|
309
395
|
continue;
|
|
310
396
|
}
|
|
311
397
|
if (best === null || session.x > best.expiresAt) {
|
|
312
|
-
best = {
|
|
398
|
+
best = {
|
|
399
|
+
instance,
|
|
400
|
+
viewer: session.w,
|
|
401
|
+
origin: originOfLabel(label),
|
|
402
|
+
expiresAt: session.x,
|
|
403
|
+
...(session.a === 1 ? { anonymous: true } : {}),
|
|
404
|
+
...(typeof session.s === "string" ? { subject: session.s } : {}),
|
|
405
|
+
};
|
|
313
406
|
}
|
|
314
407
|
}
|
|
315
408
|
return best;
|
|
@@ -325,7 +418,7 @@ export function createServer(options) {
|
|
|
325
418
|
}
|
|
326
419
|
try {
|
|
327
420
|
const instance = cleanInstance(record.instance);
|
|
328
|
-
return
|
|
421
|
+
return nameOf(instance.scope) === label ? { ...record, instance } : null;
|
|
329
422
|
}
|
|
330
423
|
catch {
|
|
331
424
|
return null;
|
|
@@ -359,12 +452,18 @@ export function createServer(options) {
|
|
|
359
452
|
viewer: record.viewer,
|
|
360
453
|
origin: originOfLabel(label),
|
|
361
454
|
expiresAt: Date.now() + (record.sessionSeconds ?? DEFAULT_SESSION_SECONDS) * 1_000,
|
|
455
|
+
...(record.anonymous === true ? { anonymous: true } : {}),
|
|
456
|
+
...(record.subject === undefined ? {} : { subject: record.subject }),
|
|
362
457
|
};
|
|
363
458
|
}
|
|
364
459
|
access ??= accessOf(request, label, key);
|
|
365
460
|
if (access === null) {
|
|
366
461
|
return page(401, "This link has been used", "Content here opens once per link. Open it again from the app that showed it.", method, "session");
|
|
367
462
|
}
|
|
463
|
+
const refusal = await denied(request, access);
|
|
464
|
+
if (refusal !== null) {
|
|
465
|
+
return refusal;
|
|
466
|
+
}
|
|
368
467
|
const served = await documentOf(access.instance);
|
|
369
468
|
if (served === null) {
|
|
370
469
|
return page(404, "Not found", "This page, or this version of it, doesn't exist.", method);
|
|
@@ -384,6 +483,16 @@ export function createServer(options) {
|
|
|
384
483
|
if (record === null || record.instance.document.kind !== "external") {
|
|
385
484
|
return page(403, "This link has been used", "A link into an app works once, for a short time. Open it again from the app that showed it.", method, "session");
|
|
386
485
|
}
|
|
486
|
+
const refusal = await denied(request, {
|
|
487
|
+
instance: record.instance,
|
|
488
|
+
viewer: record.viewer,
|
|
489
|
+
origin: originOfLabel(label),
|
|
490
|
+
expiresAt: record.expiresAt,
|
|
491
|
+
...(record.subject === undefined ? {} : { subject: record.subject }),
|
|
492
|
+
});
|
|
493
|
+
if (refusal !== null) {
|
|
494
|
+
return refusal;
|
|
495
|
+
}
|
|
387
496
|
const target = new URL(record.instance.document.url);
|
|
388
497
|
const landing = `${target.pathname}${target.search}${target.hash}`;
|
|
389
498
|
// In a frame, a page enters the app: it can tell whether the browser kept the cookie (and tell
|
|
@@ -440,6 +549,10 @@ export function createServer(options) {
|
|
|
440
549
|
if (access === null) {
|
|
441
550
|
return page(401, "Open this from the app", "This address shows an app only to the people the app opens it for.", method, "session");
|
|
442
551
|
}
|
|
552
|
+
const refusal = await denied(request, access);
|
|
553
|
+
if (refusal !== null) {
|
|
554
|
+
return refusal;
|
|
555
|
+
}
|
|
443
556
|
if (options.upstream === undefined) {
|
|
444
557
|
return page(502, "Nothing serves this app", "The server has no upstream for apps.", method);
|
|
445
558
|
}
|
|
@@ -463,34 +576,62 @@ export function createServer(options) {
|
|
|
463
576
|
if (host !== domain && !host.endsWith(`.${domain}`)) {
|
|
464
577
|
return null;
|
|
465
578
|
}
|
|
466
|
-
|
|
579
|
+
const label = host.slice(0, Math.max(0, host.length - domain.length - 1));
|
|
580
|
+
// A reserved label is another server's namespace (`f` for `<x>.f.<domain>`): it and the names
|
|
581
|
+
// under it go to the host's own routes. Any other deeper name is ours to refuse.
|
|
582
|
+
if (reserved.has(label.split(".").at(-1) ?? "")) {
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
585
|
+
return { url, label };
|
|
467
586
|
}
|
|
468
587
|
return {
|
|
469
588
|
contentSource,
|
|
470
|
-
originFor: (scope) => originOfLabel(
|
|
589
|
+
originFor: (scope) => originOfLabel(nameOf(scope)),
|
|
471
590
|
async issue(instance, grant) {
|
|
472
591
|
const clean = cleanInstance(instance);
|
|
473
592
|
check(clean);
|
|
474
|
-
|
|
593
|
+
const anonymous = grant.anonymous === true;
|
|
594
|
+
if (anonymous) {
|
|
595
|
+
if (grant.viewer !== undefined) {
|
|
596
|
+
throw new TypeError("An anonymous grant names no viewer");
|
|
597
|
+
}
|
|
598
|
+
if (clean.document.kind === "external") {
|
|
599
|
+
throw new TypeError("A url-mode app is never opened anonymously (P046)");
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
else if (typeof grant.viewer !== "string" ||
|
|
475
603
|
grant.viewer.length === 0 ||
|
|
476
604
|
grant.viewer.length > 512) {
|
|
477
|
-
throw new TypeError("A grant's viewer is 1 to 512 characters");
|
|
605
|
+
throw new TypeError("A grant's viewer is 1 to 512 characters (or the grant is anonymous)");
|
|
606
|
+
}
|
|
607
|
+
if (grant.subject !== undefined &&
|
|
608
|
+
(typeof grant.subject !== "string" || grant.subject.length > SUBJECT_MAX)) {
|
|
609
|
+
throw new TypeError(`A grant's subject is a string of at most ${SUBJECT_MAX} characters`);
|
|
478
610
|
}
|
|
479
611
|
const ttl = seconds(grant.ttlSeconds, DEFAULT_TICKET_SECONDS, MAX_TICKET_SECONDS, "ttlSeconds");
|
|
480
612
|
const sessionSeconds = seconds(grant.sessionSeconds, DEFAULT_SESSION_SECONDS, MAX_SESSION_SECONDS, "sessionSeconds");
|
|
481
613
|
const key = keys.instanceKey(clean);
|
|
482
|
-
const
|
|
614
|
+
const viewer = anonymous ? "" : grant.viewer;
|
|
615
|
+
const probe = keys.sign({
|
|
616
|
+
i: clean,
|
|
617
|
+
w: viewer,
|
|
618
|
+
x: Number.MAX_SAFE_INTEGER,
|
|
619
|
+
...(anonymous ? { a: 1 } : {}),
|
|
620
|
+
...(grant.subject === undefined ? {} : { s: grant.subject }),
|
|
621
|
+
});
|
|
483
622
|
if (cookieName(key).length + probe.length + 120 > MAX_COOKIE_BYTES) {
|
|
484
623
|
throw new TypeError("This instance is too large to carry in a cookie (fewer origins?)");
|
|
485
624
|
}
|
|
486
625
|
const ticket = newTicket();
|
|
487
626
|
await tickets.put(ticketId(ticket), {
|
|
488
627
|
instance: clean,
|
|
489
|
-
viewer
|
|
628
|
+
viewer,
|
|
490
629
|
expiresAt: Date.now() + ttl * 1_000,
|
|
491
630
|
...(grant.sessionSeconds === undefined ? {} : { sessionSeconds }),
|
|
631
|
+
...(anonymous ? { anonymous: true } : {}),
|
|
632
|
+
...(grant.subject === undefined ? {} : { subject: grant.subject }),
|
|
492
633
|
});
|
|
493
|
-
const origin = originOfLabel(
|
|
634
|
+
const origin = originOfLabel(nameOf(clean.scope));
|
|
494
635
|
const path = clean.document.kind === "external"
|
|
495
636
|
? `${ENTER_PATH}?ticket=${ticket}`
|
|
496
637
|
: `${PREFIX}d/${key}?ticket=${ticket}`;
|
|
@@ -498,7 +639,11 @@ export function createServer(options) {
|
|
|
498
639
|
},
|
|
499
640
|
async access(request) {
|
|
500
641
|
const at = labelOf(request);
|
|
501
|
-
|
|
642
|
+
if (at === null || !ours(at.label)) {
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
const access = accessOf(request, at.label, null);
|
|
646
|
+
return access === null || (await denied(request, access)) !== null ? null : access;
|
|
502
647
|
},
|
|
503
648
|
async handle(request) {
|
|
504
649
|
const at = labelOf(request);
|
|
@@ -508,7 +653,7 @@ export function createServer(options) {
|
|
|
508
653
|
const { url, label } = at;
|
|
509
654
|
const method = request.method.toUpperCase();
|
|
510
655
|
try {
|
|
511
|
-
if (!
|
|
656
|
+
if (!ours(label)) {
|
|
512
657
|
return page(404, "Nothing here", "This address names no content.", method);
|
|
513
658
|
}
|
|
514
659
|
if (url.pathname === ENTER_PATH) {
|
|
@@ -520,10 +665,9 @@ export function createServer(options) {
|
|
|
520
665
|
}
|
|
521
666
|
if (url.pathname === SESSION_PATH) {
|
|
522
667
|
// Whether this request's cookie opens its origin's app: the entry page asks.
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
});
|
|
668
|
+
const access = accessOf(request, label, null);
|
|
669
|
+
const status = access === null ? 401 : (await denied(request, access)) === null ? 204 : 403;
|
|
670
|
+
return new Response(null, { status, headers: { "cache-control": "no-store" } });
|
|
527
671
|
}
|
|
528
672
|
const document = DOCUMENT_PATH.exec(url.pathname);
|
|
529
673
|
if (document?.[1] !== undefined) {
|
package/dist/server/tickets.d.ts
CHANGED
|
@@ -6,3 +6,27 @@ import type { TicketStore } from "./types.ts";
|
|
|
6
6
|
export declare function memoryTicketStore(options?: {
|
|
7
7
|
readonly now?: () => number;
|
|
8
8
|
}): TicketStore;
|
|
9
|
+
/**
|
|
10
|
+
* The part of a Cloudflare D1 database (or any SQLite-like binding with the same shape) the ticket
|
|
11
|
+
* store uses: a prepared statement, bound, run or read once.
|
|
12
|
+
*/
|
|
13
|
+
export interface D1Like {
|
|
14
|
+
prepare(query: string): D1StatementLike;
|
|
15
|
+
}
|
|
16
|
+
export interface D1StatementLike {
|
|
17
|
+
bind(...values: unknown[]): D1StatementLike;
|
|
18
|
+
run(): Promise<unknown>;
|
|
19
|
+
first<T = Record<string, unknown>>(): Promise<T | null>;
|
|
20
|
+
}
|
|
21
|
+
/** The table a D1 ticket store needs (run once, as a migration). */
|
|
22
|
+
export declare function d1TicketSchema(table?: string): string;
|
|
23
|
+
/**
|
|
24
|
+
* Tickets in a D1 database (P045): shared by every Worker isolate, and taken once by an atomic
|
|
25
|
+
* `DELETE … RETURNING` (one statement on D1's single primary, so two requests racing for a ticket
|
|
26
|
+
* can't both get it). Expired tickets are swept as new ones go in. Workers KV is no place for
|
|
27
|
+
* tickets: it is eventually consistent, so a ticket read in two places could be used twice.
|
|
28
|
+
*/
|
|
29
|
+
export declare function d1TicketStore(db: D1Like, options?: {
|
|
30
|
+
readonly table?: string;
|
|
31
|
+
readonly now?: () => number;
|
|
32
|
+
}): TicketStore;
|
package/dist/server/tickets.js
CHANGED
|
@@ -26,3 +26,49 @@ export function memoryTicketStore(options = {}) {
|
|
|
26
26
|
},
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
|
+
const TABLE = /^[a-z_][a-z0-9_]{0,62}$/u;
|
|
30
|
+
/** The table a D1 ticket store needs (run once, as a migration). */
|
|
31
|
+
export function d1TicketSchema(table = "porthole_tickets") {
|
|
32
|
+
if (!TABLE.test(table)) {
|
|
33
|
+
throw new TypeError("A ticket table is named with a-z 0-9 _");
|
|
34
|
+
}
|
|
35
|
+
return `CREATE TABLE IF NOT EXISTS ${table} (id TEXT PRIMARY KEY, record TEXT NOT NULL, expires_at INTEGER NOT NULL);
|
|
36
|
+
CREATE INDEX IF NOT EXISTS ${table}_expires_at ON ${table} (expires_at);`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Tickets in a D1 database (P045): shared by every Worker isolate, and taken once by an atomic
|
|
40
|
+
* `DELETE … RETURNING` (one statement on D1's single primary, so two requests racing for a ticket
|
|
41
|
+
* can't both get it). Expired tickets are swept as new ones go in. Workers KV is no place for
|
|
42
|
+
* tickets: it is eventually consistent, so a ticket read in two places could be used twice.
|
|
43
|
+
*/
|
|
44
|
+
export function d1TicketStore(db, options = {}) {
|
|
45
|
+
const table = options.table ?? "porthole_tickets";
|
|
46
|
+
if (!TABLE.test(table)) {
|
|
47
|
+
throw new TypeError("A ticket table is named with a-z 0-9 _");
|
|
48
|
+
}
|
|
49
|
+
const now = options.now ?? Date.now;
|
|
50
|
+
return {
|
|
51
|
+
async put(id, record) {
|
|
52
|
+
await db.prepare(`DELETE FROM ${table} WHERE expires_at <= ?`).bind(now()).run();
|
|
53
|
+
await db
|
|
54
|
+
.prepare(`INSERT INTO ${table} (id, record, expires_at) VALUES (?, ?, ?)`)
|
|
55
|
+
.bind(id, JSON.stringify(record), record.expiresAt)
|
|
56
|
+
.run();
|
|
57
|
+
},
|
|
58
|
+
async take(id) {
|
|
59
|
+
const row = await db
|
|
60
|
+
.prepare(`DELETE FROM ${table} WHERE id = ? RETURNING record, expires_at`)
|
|
61
|
+
.bind(id)
|
|
62
|
+
.first();
|
|
63
|
+
if (row === null || row.expires_at <= now()) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(row.record);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|