@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/src/server/index.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,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 {
|
|
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,
|
package/src/server/keys.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import {
|
|
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):
|
|
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:
|
|
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 =
|
|
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 =
|
|
83
|
-
if (given
|
|
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(
|
|
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
|
+
}
|
package/src/server/pages.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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),
|
package/src/server/server.ts
CHANGED
|
@@ -9,10 +9,18 @@ import {
|
|
|
9
9
|
import type { FrameLocation, InstanceRef, ServedDocument } from "../isolation/types.ts";
|
|
10
10
|
import { sandboxProxyDocument } from "../mcp/proxy.ts";
|
|
11
11
|
import { createKeys, newTicket, ticketId } from "./keys.ts";
|
|
12
|
+
import { isHostLabel } from "./naming.ts";
|
|
12
13
|
import { enterPage, pageScript, SESSION_PATH, statusPage } from "./pages.ts";
|
|
13
14
|
import type { FrameBlock } from "../host/types.ts";
|
|
14
15
|
import { memoryTicketStore } from "./tickets.ts";
|
|
15
|
-
import type {
|
|
16
|
+
import type {
|
|
17
|
+
Access,
|
|
18
|
+
AuthorizeAnswer,
|
|
19
|
+
PortholeServer,
|
|
20
|
+
ServerOptions,
|
|
21
|
+
TicketRecord,
|
|
22
|
+
TicketStore,
|
|
23
|
+
} from "./types.ts";
|
|
16
24
|
|
|
17
25
|
/**
|
|
18
26
|
* Paths on a content origin. Porthole's own live under `/__porthole/`; on a url-mode app's origin
|
|
@@ -25,6 +33,9 @@ const DOCUMENT_PATH = /^\/__porthole\/d\/([a-z2-7]{16})$/u;
|
|
|
25
33
|
const ENTER_PATH = "/__porthole/enter";
|
|
26
34
|
|
|
27
35
|
const LABEL = /^[a-z2-7]{24}$/u;
|
|
36
|
+
/** A host's opaque subject and reason codes. */
|
|
37
|
+
const SUBJECT_MAX = 512;
|
|
38
|
+
const CODE = /^[a-z0-9-]{1,40}$/u;
|
|
28
39
|
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;
|
|
29
40
|
const NAME = /^[A-Za-z0-9._:-]{1,200}$/u;
|
|
30
41
|
|
|
@@ -35,11 +46,16 @@ const MAX_SESSION_SECONDS = 30 * 24 * 3_600;
|
|
|
35
46
|
/** Browsers keep a cookie of up to 4 KB, name and attributes included. */
|
|
36
47
|
const MAX_COOKIE_BYTES = 3_800;
|
|
37
48
|
|
|
38
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* What a session cookie carries, signed: the instance, its viewer (or anonymous), the host's
|
|
51
|
+
* opaque subject, and when it stops.
|
|
52
|
+
*/
|
|
39
53
|
interface Session {
|
|
40
54
|
readonly i: InstanceRef;
|
|
41
55
|
readonly w: string;
|
|
42
56
|
readonly x: number;
|
|
57
|
+
readonly a?: 1;
|
|
58
|
+
readonly s?: string;
|
|
43
59
|
}
|
|
44
60
|
|
|
45
61
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
@@ -174,6 +190,30 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
174
190
|
throw new TypeError("content.port must be a port number");
|
|
175
191
|
}
|
|
176
192
|
const keys = createKeys(options.secret);
|
|
193
|
+
const reserved = new Set(content.reserved ?? []);
|
|
194
|
+
for (const label of reserved) {
|
|
195
|
+
if (!isHostLabel(label)) {
|
|
196
|
+
throw new TypeError(
|
|
197
|
+
`content.reserved: ${JSON.stringify(label)} is not a lowercase DNS label`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/** The label a scope is served on: the host's naming, checked, or the keyed default. */
|
|
202
|
+
const nameOf = (scope: string): string => {
|
|
203
|
+
if (content.name === undefined) {
|
|
204
|
+
return keys.label(scope);
|
|
205
|
+
}
|
|
206
|
+
const label = content.name(scope);
|
|
207
|
+
if (!isHostLabel(label) || reserved.has(label)) {
|
|
208
|
+
throw new TypeError(
|
|
209
|
+
`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`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return label;
|
|
213
|
+
};
|
|
214
|
+
/** Whether a request's label may be one this server named. */
|
|
215
|
+
const ours = (label: string): boolean =>
|
|
216
|
+
!reserved.has(label) && (content.name === undefined ? LABEL.test(label) : isHostLabel(label));
|
|
177
217
|
const secure = content.scheme === "https";
|
|
178
218
|
const defaultPort = secure ? 443 : 80;
|
|
179
219
|
const portPart =
|
|
@@ -183,6 +223,12 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
183
223
|
const contentSource = `${content.scheme}://*.${domain}${portPart}`;
|
|
184
224
|
const sampleHost = `${keys.label("")}.${domain}`;
|
|
185
225
|
const sampleOrigin = originOfLabel(keys.label(""));
|
|
226
|
+
const authorize = options.authorize;
|
|
227
|
+
const authorizeTtl = authorize?.ttlSeconds ?? 0;
|
|
228
|
+
if (!Number.isInteger(authorizeTtl) || authorizeTtl < 0 || authorizeTtl > 300) {
|
|
229
|
+
throw new TypeError("authorize.ttlSeconds must be an integer from 0 to 300");
|
|
230
|
+
}
|
|
231
|
+
const answers = new Map<string, { readonly answer: AuthorizeAnswer; readonly until: number }>();
|
|
186
232
|
|
|
187
233
|
if (options.hostOrigins.length === 0) {
|
|
188
234
|
throw new TypeError("hostOrigins must name the origins that embed content");
|
|
@@ -222,8 +268,63 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
222
268
|
detail: string,
|
|
223
269
|
method?: string,
|
|
224
270
|
reason: FrameBlock = status === 404 ? "not-found" : status === 502 ? "upstream" : "error",
|
|
271
|
+
code?: string,
|
|
225
272
|
): Response =>
|
|
226
|
-
statusPage(status, title, detail, frameAncestors, method, reason, options.hostOrigins);
|
|
273
|
+
statusPage(status, title, detail, frameAncestors, method, reason, options.hostOrigins, code);
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The host's check on a request its cookie (or ticket) let in (P050): null to go on, or the
|
|
277
|
+
* page saying it's denied. Answers are reused only for the host's own `ttlSeconds`.
|
|
278
|
+
*/
|
|
279
|
+
async function denied(request: Request, access: Access): Promise<Response | null> {
|
|
280
|
+
if (authorize === undefined) {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
const key = JSON.stringify([
|
|
284
|
+
keys.instanceKey(access.instance),
|
|
285
|
+
access.viewer,
|
|
286
|
+
access.subject ?? null,
|
|
287
|
+
access.expiresAt,
|
|
288
|
+
]);
|
|
289
|
+
const at = Date.now();
|
|
290
|
+
let answer = authorizeTtl > 0 ? answers.get(key) : undefined;
|
|
291
|
+
if (answer === undefined || answer.until <= at) {
|
|
292
|
+
let given: AuthorizeAnswer;
|
|
293
|
+
try {
|
|
294
|
+
given = await authorize.check({
|
|
295
|
+
scope: access.instance.scope,
|
|
296
|
+
instance: access.instance,
|
|
297
|
+
viewer: access.viewer,
|
|
298
|
+
anonymous: access.anonymous === true,
|
|
299
|
+
...(access.subject === undefined ? {} : { subject: access.subject }),
|
|
300
|
+
request,
|
|
301
|
+
});
|
|
302
|
+
} catch {
|
|
303
|
+
given = { allow: false };
|
|
304
|
+
}
|
|
305
|
+
answer = { answer: given, until: at + authorizeTtl * 1_000 };
|
|
306
|
+
if (authorizeTtl > 0) {
|
|
307
|
+
if (answers.size > 10_000) {
|
|
308
|
+
answers.clear();
|
|
309
|
+
}
|
|
310
|
+
answers.set(key, answer);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// Only an explicit allow lets the request on: anything else the check gives is a deny.
|
|
314
|
+
const given = answer.answer as Partial<{ allow: unknown; code: unknown }> | null | undefined;
|
|
315
|
+
if (given?.allow === true) {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
const code = typeof given?.code === "string" && CODE.test(given.code) ? given.code : undefined;
|
|
319
|
+
return page(
|
|
320
|
+
403,
|
|
321
|
+
"You can't open this anymore",
|
|
322
|
+
"The app that showed it no longer lets you see it here.",
|
|
323
|
+
request.method.toUpperCase(),
|
|
324
|
+
"denied",
|
|
325
|
+
code,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
227
328
|
|
|
228
329
|
/** The config a served document is served under, checked to fit it and this server. */
|
|
229
330
|
function configFor(name: string, kind: keyof typeof MODES): CompiledConfig {
|
|
@@ -331,6 +432,8 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
331
432
|
i: record.instance,
|
|
332
433
|
w: record.viewer,
|
|
333
434
|
x: Date.now() + sessionSeconds * 1_000,
|
|
435
|
+
...(record.anonymous === true ? { a: 1 as const } : {}),
|
|
436
|
+
...(record.subject === undefined ? {} : { s: record.subject }),
|
|
334
437
|
};
|
|
335
438
|
const framed = request.headers.get("sec-fetch-dest") !== "document";
|
|
336
439
|
return [
|
|
@@ -356,7 +459,8 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
356
459
|
!isRecord(session) ||
|
|
357
460
|
typeof session.w !== "string" ||
|
|
358
461
|
typeof session.x !== "number" ||
|
|
359
|
-
session.x <= Date.now()
|
|
462
|
+
session.x <= Date.now() ||
|
|
463
|
+
(session.s !== undefined && typeof session.s !== "string")
|
|
360
464
|
) {
|
|
361
465
|
continue;
|
|
362
466
|
}
|
|
@@ -366,14 +470,27 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
366
470
|
} catch {
|
|
367
471
|
continue;
|
|
368
472
|
}
|
|
473
|
+
let named: string;
|
|
474
|
+
try {
|
|
475
|
+
named = nameOf(instance.scope);
|
|
476
|
+
} catch {
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
369
479
|
if (
|
|
370
|
-
|
|
480
|
+
named !== label ||
|
|
371
481
|
(key === null ? instance.document.kind !== "external" : keys.instanceKey(instance) !== key)
|
|
372
482
|
) {
|
|
373
483
|
continue;
|
|
374
484
|
}
|
|
375
485
|
if (best === null || session.x > best.expiresAt) {
|
|
376
|
-
best = {
|
|
486
|
+
best = {
|
|
487
|
+
instance,
|
|
488
|
+
viewer: session.w,
|
|
489
|
+
origin: originOfLabel(label),
|
|
490
|
+
expiresAt: session.x,
|
|
491
|
+
...(session.a === 1 ? { anonymous: true } : {}),
|
|
492
|
+
...(typeof session.s === "string" ? { subject: session.s } : {}),
|
|
493
|
+
};
|
|
377
494
|
}
|
|
378
495
|
}
|
|
379
496
|
return best;
|
|
@@ -390,7 +507,7 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
390
507
|
}
|
|
391
508
|
try {
|
|
392
509
|
const instance = cleanInstance(record.instance);
|
|
393
|
-
return
|
|
510
|
+
return nameOf(instance.scope) === label ? { ...record, instance } : null;
|
|
394
511
|
} catch {
|
|
395
512
|
return null;
|
|
396
513
|
}
|
|
@@ -432,6 +549,8 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
432
549
|
viewer: record.viewer,
|
|
433
550
|
origin: originOfLabel(label),
|
|
434
551
|
expiresAt: Date.now() + (record.sessionSeconds ?? DEFAULT_SESSION_SECONDS) * 1_000,
|
|
552
|
+
...(record.anonymous === true ? { anonymous: true } : {}),
|
|
553
|
+
...(record.subject === undefined ? {} : { subject: record.subject }),
|
|
435
554
|
};
|
|
436
555
|
}
|
|
437
556
|
access ??= accessOf(request, label, key);
|
|
@@ -444,6 +563,10 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
444
563
|
"session",
|
|
445
564
|
);
|
|
446
565
|
}
|
|
566
|
+
const refusal = await denied(request, access);
|
|
567
|
+
if (refusal !== null) {
|
|
568
|
+
return refusal;
|
|
569
|
+
}
|
|
447
570
|
const served = await documentOf(access.instance);
|
|
448
571
|
if (served === null) {
|
|
449
572
|
return page(404, "Not found", "This page, or this version of it, doesn't exist.", method);
|
|
@@ -470,6 +593,16 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
470
593
|
"session",
|
|
471
594
|
);
|
|
472
595
|
}
|
|
596
|
+
const refusal = await denied(request, {
|
|
597
|
+
instance: record.instance,
|
|
598
|
+
viewer: record.viewer,
|
|
599
|
+
origin: originOfLabel(label),
|
|
600
|
+
expiresAt: record.expiresAt,
|
|
601
|
+
...(record.subject === undefined ? {} : { subject: record.subject }),
|
|
602
|
+
});
|
|
603
|
+
if (refusal !== null) {
|
|
604
|
+
return refusal;
|
|
605
|
+
}
|
|
473
606
|
const target = new URL(record.instance.document.url);
|
|
474
607
|
const landing = `${target.pathname}${target.search}${target.hash}`;
|
|
475
608
|
// In a frame, a page enters the app: it can tell whether the browser kept the cookie (and tell
|
|
@@ -539,6 +672,10 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
539
672
|
"session",
|
|
540
673
|
);
|
|
541
674
|
}
|
|
675
|
+
const refusal = await denied(request, access);
|
|
676
|
+
if (refusal !== null) {
|
|
677
|
+
return refusal;
|
|
678
|
+
}
|
|
542
679
|
if (options.upstream === undefined) {
|
|
543
680
|
return page(502, "Nothing serves this app", "The server has no upstream for apps.", method);
|
|
544
681
|
}
|
|
@@ -561,23 +698,43 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
561
698
|
if (host !== domain && !host.endsWith(`.${domain}`)) {
|
|
562
699
|
return null;
|
|
563
700
|
}
|
|
564
|
-
|
|
701
|
+
const label = host.slice(0, Math.max(0, host.length - domain.length - 1));
|
|
702
|
+
// A reserved label is another server's namespace (`f` for `<x>.f.<domain>`): it and the names
|
|
703
|
+
// under it go to the host's own routes. Any other deeper name is ours to refuse.
|
|
704
|
+
if (reserved.has(label.split(".").at(-1) ?? "")) {
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
return { url, label };
|
|
565
708
|
}
|
|
566
709
|
|
|
567
710
|
return {
|
|
568
711
|
contentSource,
|
|
569
712
|
|
|
570
|
-
originFor: (scope) => originOfLabel(
|
|
713
|
+
originFor: (scope) => originOfLabel(nameOf(scope)),
|
|
571
714
|
|
|
572
715
|
async issue(instance, grant): Promise<FrameLocation> {
|
|
573
716
|
const clean = cleanInstance(instance);
|
|
574
717
|
check(clean);
|
|
575
|
-
|
|
718
|
+
const anonymous = grant.anonymous === true;
|
|
719
|
+
if (anonymous) {
|
|
720
|
+
if (grant.viewer !== undefined) {
|
|
721
|
+
throw new TypeError("An anonymous grant names no viewer");
|
|
722
|
+
}
|
|
723
|
+
if (clean.document.kind === "external") {
|
|
724
|
+
throw new TypeError("A url-mode app is never opened anonymously (P046)");
|
|
725
|
+
}
|
|
726
|
+
} else if (
|
|
576
727
|
typeof grant.viewer !== "string" ||
|
|
577
728
|
grant.viewer.length === 0 ||
|
|
578
729
|
grant.viewer.length > 512
|
|
579
730
|
) {
|
|
580
|
-
throw new TypeError("A grant's viewer is 1 to 512 characters");
|
|
731
|
+
throw new TypeError("A grant's viewer is 1 to 512 characters (or the grant is anonymous)");
|
|
732
|
+
}
|
|
733
|
+
if (
|
|
734
|
+
grant.subject !== undefined &&
|
|
735
|
+
(typeof grant.subject !== "string" || grant.subject.length > SUBJECT_MAX)
|
|
736
|
+
) {
|
|
737
|
+
throw new TypeError(`A grant's subject is a string of at most ${SUBJECT_MAX} characters`);
|
|
581
738
|
}
|
|
582
739
|
const ttl = seconds(
|
|
583
740
|
grant.ttlSeconds,
|
|
@@ -592,18 +749,27 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
592
749
|
"sessionSeconds",
|
|
593
750
|
);
|
|
594
751
|
const key = keys.instanceKey(clean);
|
|
595
|
-
const
|
|
752
|
+
const viewer = anonymous ? "" : grant.viewer;
|
|
753
|
+
const probe = keys.sign({
|
|
754
|
+
i: clean,
|
|
755
|
+
w: viewer,
|
|
756
|
+
x: Number.MAX_SAFE_INTEGER,
|
|
757
|
+
...(anonymous ? { a: 1 } : {}),
|
|
758
|
+
...(grant.subject === undefined ? {} : { s: grant.subject }),
|
|
759
|
+
});
|
|
596
760
|
if (cookieName(key).length + probe.length + 120 > MAX_COOKIE_BYTES) {
|
|
597
761
|
throw new TypeError("This instance is too large to carry in a cookie (fewer origins?)");
|
|
598
762
|
}
|
|
599
763
|
const ticket = newTicket();
|
|
600
764
|
await tickets.put(ticketId(ticket), {
|
|
601
765
|
instance: clean,
|
|
602
|
-
viewer
|
|
766
|
+
viewer,
|
|
603
767
|
expiresAt: Date.now() + ttl * 1_000,
|
|
604
768
|
...(grant.sessionSeconds === undefined ? {} : { sessionSeconds }),
|
|
769
|
+
...(anonymous ? { anonymous: true } : {}),
|
|
770
|
+
...(grant.subject === undefined ? {} : { subject: grant.subject }),
|
|
605
771
|
});
|
|
606
|
-
const origin = originOfLabel(
|
|
772
|
+
const origin = originOfLabel(nameOf(clean.scope));
|
|
607
773
|
const path =
|
|
608
774
|
clean.document.kind === "external"
|
|
609
775
|
? `${ENTER_PATH}?ticket=${ticket}`
|
|
@@ -613,7 +779,11 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
613
779
|
|
|
614
780
|
async access(request) {
|
|
615
781
|
const at = labelOf(request);
|
|
616
|
-
|
|
782
|
+
if (at === null || !ours(at.label)) {
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
const access = accessOf(request, at.label, null);
|
|
786
|
+
return access === null || (await denied(request, access)) !== null ? null : access;
|
|
617
787
|
},
|
|
618
788
|
|
|
619
789
|
async handle(request) {
|
|
@@ -624,7 +794,7 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
624
794
|
const { url, label } = at;
|
|
625
795
|
const method = request.method.toUpperCase();
|
|
626
796
|
try {
|
|
627
|
-
if (!
|
|
797
|
+
if (!ours(label)) {
|
|
628
798
|
return page(404, "Nothing here", "This address names no content.", method);
|
|
629
799
|
}
|
|
630
800
|
if (url.pathname === ENTER_PATH) {
|
|
@@ -636,10 +806,10 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
636
806
|
}
|
|
637
807
|
if (url.pathname === SESSION_PATH) {
|
|
638
808
|
// Whether this request's cookie opens its origin's app: the entry page asks.
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
});
|
|
809
|
+
const access = accessOf(request, label, null);
|
|
810
|
+
const status =
|
|
811
|
+
access === null ? 401 : (await denied(request, access)) === null ? 204 : 403;
|
|
812
|
+
return new Response(null, { status, headers: { "cache-control": "no-store" } });
|
|
643
813
|
}
|
|
644
814
|
const document = DOCUMENT_PATH.exec(url.pathname);
|
|
645
815
|
if (document?.[1] !== undefined) {
|
package/src/server/tickets.ts
CHANGED
|
@@ -28,3 +28,68 @@ export function memoryTicketStore(options: { readonly now?: () => number } = {})
|
|
|
28
28
|
},
|
|
29
29
|
};
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The part of a Cloudflare D1 database (or any SQLite-like binding with the same shape) the ticket
|
|
34
|
+
* store uses: a prepared statement, bound, run or read once.
|
|
35
|
+
*/
|
|
36
|
+
export interface D1Like {
|
|
37
|
+
prepare(query: string): D1StatementLike;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface D1StatementLike {
|
|
41
|
+
bind(...values: unknown[]): D1StatementLike;
|
|
42
|
+
run(): Promise<unknown>;
|
|
43
|
+
first<T = Record<string, unknown>>(): Promise<T | null>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const TABLE = /^[a-z_][a-z0-9_]{0,62}$/u;
|
|
47
|
+
|
|
48
|
+
/** The table a D1 ticket store needs (run once, as a migration). */
|
|
49
|
+
export function d1TicketSchema(table = "porthole_tickets"): string {
|
|
50
|
+
if (!TABLE.test(table)) {
|
|
51
|
+
throw new TypeError("A ticket table is named with a-z 0-9 _");
|
|
52
|
+
}
|
|
53
|
+
return `CREATE TABLE IF NOT EXISTS ${table} (id TEXT PRIMARY KEY, record TEXT NOT NULL, expires_at INTEGER NOT NULL);
|
|
54
|
+
CREATE INDEX IF NOT EXISTS ${table}_expires_at ON ${table} (expires_at);`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Tickets in a D1 database (P045): shared by every Worker isolate, and taken once by an atomic
|
|
59
|
+
* `DELETE … RETURNING` (one statement on D1's single primary, so two requests racing for a ticket
|
|
60
|
+
* can't both get it). Expired tickets are swept as new ones go in. Workers KV is no place for
|
|
61
|
+
* tickets: it is eventually consistent, so a ticket read in two places could be used twice.
|
|
62
|
+
*/
|
|
63
|
+
export function d1TicketStore(
|
|
64
|
+
db: D1Like,
|
|
65
|
+
options: { readonly table?: string; readonly now?: () => number } = {},
|
|
66
|
+
): TicketStore {
|
|
67
|
+
const table = options.table ?? "porthole_tickets";
|
|
68
|
+
if (!TABLE.test(table)) {
|
|
69
|
+
throw new TypeError("A ticket table is named with a-z 0-9 _");
|
|
70
|
+
}
|
|
71
|
+
const now = options.now ?? Date.now;
|
|
72
|
+
return {
|
|
73
|
+
async put(id, record) {
|
|
74
|
+
await db.prepare(`DELETE FROM ${table} WHERE expires_at <= ?`).bind(now()).run();
|
|
75
|
+
await db
|
|
76
|
+
.prepare(`INSERT INTO ${table} (id, record, expires_at) VALUES (?, ?, ?)`)
|
|
77
|
+
.bind(id, JSON.stringify(record), record.expiresAt)
|
|
78
|
+
.run();
|
|
79
|
+
},
|
|
80
|
+
async take(id) {
|
|
81
|
+
const row = await db
|
|
82
|
+
.prepare(`DELETE FROM ${table} WHERE id = ? RETURNING record, expires_at`)
|
|
83
|
+
.bind(id)
|
|
84
|
+
.first<{ record: string; expires_at: number }>();
|
|
85
|
+
if (row === null || row.expires_at <= now()) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(row.record) as TicketRecord;
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|