@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.
- package/dist/bridge/bridge.js +20 -6
- package/dist/bridge/types.d.ts +19 -3
- package/dist/compile/policy.d.ts +6 -5
- package/dist/compile/policy.js +10 -12
- package/dist/config/schema.d.ts +29 -0
- package/dist/config/schema.js +16 -1
- package/dist/config/validate.d.ts +3 -1
- package/dist/config/validate.js +55 -10
- package/dist/conformance/behavior.js +50 -0
- package/dist/conformance/index.d.ts +11 -5
- package/dist/conformance/index.js +2 -2
- package/dist/conformance/probes.d.ts +26 -2
- package/dist/conformance/probes.js +51 -40
- package/dist/conformance/served.d.ts +7 -0
- package/dist/conformance/served.js +31 -7
- package/dist/guide/guide.js +6 -2
- package/dist/host/create-host.js +46 -2
- package/dist/host/locate.js +6 -1
- package/dist/host/outcome.d.ts +25 -0
- package/dist/host/outcome.js +147 -0
- package/dist/host/types.d.ts +39 -2
- package/dist/index.d.ts +6 -3
- package/dist/index.js +4 -1
- package/dist/isolation/served.d.ts +3 -0
- package/dist/isolation/served.js +20 -0
- package/dist/isolation/types.d.ts +9 -0
- package/dist/isolation/types.js +2 -1
- package/dist/platform/types.d.ts +7 -0
- package/dist/platform/web.js +15 -2
- package/dist/presets/presets.d.ts +11 -3
- package/dist/presets/presets.js +17 -1
- package/dist/react/frame.d.ts +16 -0
- package/dist/react/frame.js +44 -10
- 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 +11 -6
- package/dist/server/pages.js +86 -12
- package/dist/server/server.js +191 -23
- 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/document.d.ts +3 -2
- package/dist/shell/document.js +4 -3
- package/dist/shell/plan.d.ts +2 -0
- package/dist/shell/plan.js +1 -0
- package/dist/shell/runtime.js +20 -4
- package/package.json +2 -9
- package/src/bridge/bridge.ts +28 -6
- package/src/bridge/types.ts +21 -3
- package/src/compile/policy.ts +10 -12
- package/src/config/schema.ts +38 -0
- package/src/config/validate.ts +63 -14
- package/src/conformance/behavior.ts +55 -0
- package/src/conformance/index.ts +18 -4
- package/src/conformance/probes.ts +88 -52
- package/src/conformance/served.ts +37 -8
- package/src/guide/guide.ts +8 -2
- package/src/host/create-host.ts +47 -2
- package/src/host/locate.ts +6 -1
- package/src/host/outcome.ts +177 -0
- package/src/host/types.ts +42 -2
- package/src/index.ts +19 -1
- package/src/isolation/served.ts +24 -0
- package/src/isolation/types.ts +15 -1
- package/src/platform/types.ts +7 -0
- package/src/platform/web.ts +15 -2
- package/src/presets/presets.ts +31 -3
- package/src/react/frame.tsx +78 -14
- 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 +106 -11
- package/src/server/server.ts +233 -23
- package/src/server/tickets.ts +65 -0
- package/src/server/types.ts +84 -7
- package/src/shell/document.ts +7 -6
- package/src/shell/plan.ts +3 -0
- package/src/shell/runtime.ts +20 -4
package/dist/server/pages.js
CHANGED
|
@@ -1,20 +1,94 @@
|
|
|
1
|
+
import { FRAME_STATUS_METHOD } from "../isolation/types.js";
|
|
1
2
|
/**
|
|
2
|
-
* The server's own pages on a content origin: why it shows nothing of the content
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* The server's own pages on a content origin: why it shows nothing of the content, and the page a
|
|
4
|
+
* framed app is entered through. Plain, framable only by the host origins, never cached. Each tells
|
|
5
|
+
* the page that frames it how loading went (`porthole/notifications/frame-status`, P040), posted to
|
|
6
|
+
* the host origins only, so a host can show its own fallback ("Open in a new tab"). Their words are
|
|
7
|
+
* the host-agnostic mechanism's; a host that wants its own wording serves its own page in front.
|
|
5
8
|
*/
|
|
6
9
|
const escape = (text) => text.replace(/[&<>"']/gu, (char) => `&#${char.charCodeAt(0)};`);
|
|
7
10
|
const STYLE = ":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}";
|
|
8
|
-
|
|
9
|
-
|
|
11
|
+
/** Where the pages' scripts are served, on every content origin. */
|
|
12
|
+
export const STATUS_SCRIPT_PATH = "/__porthole/status.js";
|
|
13
|
+
export const ENTER_SCRIPT_PATH = "/__porthole/enter.js";
|
|
14
|
+
/** Answers 204 when the request carries a good session for its origin's app, 401 otherwise. */
|
|
15
|
+
export const SESSION_PATH = "/__porthole/session";
|
|
16
|
+
const say = () => `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); };`;
|
|
17
|
+
/** Tells the host the page is blocked, and why (from the page's data attributes). */
|
|
18
|
+
const STATUS_SCRIPT = `(() => {
|
|
19
|
+
const main = document.querySelector("[data-porthole-status]");
|
|
20
|
+
if (main === null) return;
|
|
21
|
+
const hosts = JSON.parse(main.dataset.portholeHosts || "[]");
|
|
22
|
+
${say()}
|
|
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);
|
|
26
|
+
})();`;
|
|
27
|
+
/**
|
|
28
|
+
* Enters a framed app: says so, checks that the browser kept the session cookie this page's own
|
|
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.
|
|
31
|
+
*/
|
|
32
|
+
const ENTER_SCRIPT = `(async () => {
|
|
33
|
+
const main = document.querySelector("[data-porthole-enter]");
|
|
34
|
+
if (main === null) return;
|
|
35
|
+
const hosts = JSON.parse(main.dataset.portholeHosts || "[]");
|
|
36
|
+
${say()}
|
|
37
|
+
say({ state: "entering" });
|
|
38
|
+
let kept = false;
|
|
39
|
+
try {
|
|
40
|
+
kept = (await fetch(${JSON.stringify(SESSION_PATH)}, { credentials: "same-origin", cache: "no-store" })).ok;
|
|
41
|
+
} catch (_) {}
|
|
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));
|
|
49
|
+
location.replace(main.dataset.portholeTarget || "/");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
say({ state: "blocked", reason: "cookies", status: 401 });
|
|
53
|
+
main.querySelector("h1").textContent = "Open this app in a new tab";
|
|
54
|
+
main.querySelector("p").textContent = "This browser keeps no sign-in for it inside a frame.";
|
|
55
|
+
})();`;
|
|
56
|
+
/** The pages' scripts, by path: served with the pages' own policy in mind (`script-src 'self'`). */
|
|
57
|
+
export function pageScript(pathname) {
|
|
58
|
+
const body = pathname === STATUS_SCRIPT_PATH
|
|
59
|
+
? STATUS_SCRIPT
|
|
60
|
+
: pathname === ENTER_SCRIPT_PATH
|
|
61
|
+
? ENTER_SCRIPT
|
|
62
|
+
: null;
|
|
63
|
+
return body === null
|
|
64
|
+
? null
|
|
65
|
+
: new Response(body, {
|
|
66
|
+
headers: {
|
|
67
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
68
|
+
"cache-control": "no-store",
|
|
69
|
+
"x-content-type-options": "nosniff",
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
const policy = (frameAncestors, connect) => `default-src 'none'; style-src 'unsafe-inline'; script-src 'self'; ${connect ? "connect-src 'self'; " : ""}base-uri 'none'; form-action 'none'; frame-ancestors ${frameAncestors}`;
|
|
74
|
+
const pageHeaders = (frameAncestors, connect) => ({
|
|
75
|
+
"content-type": "text/html; charset=utf-8",
|
|
76
|
+
"cache-control": "no-store",
|
|
77
|
+
"content-security-policy": policy(frameAncestors, connect),
|
|
78
|
+
"referrer-policy": "no-referrer",
|
|
79
|
+
"x-content-type-options": "nosniff",
|
|
80
|
+
});
|
|
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>`;
|
|
10
83
|
return new Response(method === "HEAD" ? null : body, {
|
|
11
84
|
status,
|
|
12
|
-
headers:
|
|
13
|
-
"content-type": "text/html; charset=utf-8",
|
|
14
|
-
"cache-control": "no-store",
|
|
15
|
-
"content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors ${frameAncestors}`,
|
|
16
|
-
"referrer-policy": "no-referrer",
|
|
17
|
-
"x-content-type-options": "nosniff",
|
|
18
|
-
},
|
|
85
|
+
headers: pageHeaders(frameAncestors, false),
|
|
19
86
|
});
|
|
20
87
|
}
|
|
88
|
+
/** The page a framed app is entered through (its answer also sets the session cookie). */
|
|
89
|
+
export function enterPage(target, frameAncestors, hostOrigins, cookie) {
|
|
90
|
+
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>`;
|
|
91
|
+
const headers = new Headers(pageHeaders(frameAncestors, true));
|
|
92
|
+
headers.set("set-cookie", cookie);
|
|
93
|
+
return new Response(body, { status: 200, headers });
|
|
94
|
+
}
|
package/dist/server/server.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { compile } from "../compile/compile.js";
|
|
2
|
-
import {
|
|
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 {
|
|
5
|
+
import { isHostLabel } from "./naming.js";
|
|
6
|
+
import { enterPage, pageScript, SESSION_PATH, statusPage } from "./pages.js";
|
|
6
7
|
import { memoryTicketStore } from "./tickets.js";
|
|
7
8
|
/**
|
|
8
9
|
* Paths on a content origin. Porthole's own live under `/__porthole/`; on a url-mode app's origin
|
|
@@ -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,13 +174,24 @@ 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
|
}
|
|
157
186
|
for (const origin of options.hostOrigins) {
|
|
158
|
-
const
|
|
187
|
+
const problem = hostOriginProblem(origin);
|
|
188
|
+
if (problem !== null) {
|
|
189
|
+
throw new TypeError(`hostOrigins: ${problem}`);
|
|
190
|
+
}
|
|
191
|
+
// A desktop app's own scheme (ferry://app) is never the same site as web content.
|
|
192
|
+
const parsed = parseOrigin(origin);
|
|
159
193
|
if (parsed === null) {
|
|
160
|
-
|
|
194
|
+
continue;
|
|
161
195
|
}
|
|
162
196
|
if (parsed.host === domain ||
|
|
163
197
|
parsed.host.endsWith(`.${domain}`) ||
|
|
@@ -171,7 +205,55 @@ export function createServer(options) {
|
|
|
171
205
|
compiled.set(name, compile(config));
|
|
172
206
|
}
|
|
173
207
|
const tickets = options.tickets ?? memoryTicketStore();
|
|
174
|
-
|
|
208
|
+
/** A page saying why there is nothing to show, told to the host too (its reason, P040). */
|
|
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
|
+
}
|
|
175
257
|
/** The config a served document is served under, checked to fit it and this server. */
|
|
176
258
|
function configFor(name, kind) {
|
|
177
259
|
const each = compiled.get(name);
|
|
@@ -265,6 +347,8 @@ export function createServer(options) {
|
|
|
265
347
|
i: record.instance,
|
|
266
348
|
w: record.viewer,
|
|
267
349
|
x: Date.now() + sessionSeconds * 1_000,
|
|
350
|
+
...(record.anonymous === true ? { a: 1 } : {}),
|
|
351
|
+
...(record.subject === undefined ? {} : { s: record.subject }),
|
|
268
352
|
};
|
|
269
353
|
const framed = request.headers.get("sec-fetch-dest") !== "document";
|
|
270
354
|
return [
|
|
@@ -288,7 +372,8 @@ export function createServer(options) {
|
|
|
288
372
|
if (!isRecord(session) ||
|
|
289
373
|
typeof session.w !== "string" ||
|
|
290
374
|
typeof session.x !== "number" ||
|
|
291
|
-
session.x <= Date.now()
|
|
375
|
+
session.x <= Date.now() ||
|
|
376
|
+
(session.s !== undefined && typeof session.s !== "string")) {
|
|
292
377
|
continue;
|
|
293
378
|
}
|
|
294
379
|
let instance;
|
|
@@ -298,12 +383,26 @@ export function createServer(options) {
|
|
|
298
383
|
catch {
|
|
299
384
|
continue;
|
|
300
385
|
}
|
|
301
|
-
|
|
386
|
+
let named;
|
|
387
|
+
try {
|
|
388
|
+
named = nameOf(instance.scope);
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (named !== label ||
|
|
302
394
|
(key === null ? instance.document.kind !== "external" : keys.instanceKey(instance) !== key)) {
|
|
303
395
|
continue;
|
|
304
396
|
}
|
|
305
397
|
if (best === null || session.x > best.expiresAt) {
|
|
306
|
-
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
|
+
};
|
|
307
406
|
}
|
|
308
407
|
}
|
|
309
408
|
return best;
|
|
@@ -319,7 +418,7 @@ export function createServer(options) {
|
|
|
319
418
|
}
|
|
320
419
|
try {
|
|
321
420
|
const instance = cleanInstance(record.instance);
|
|
322
|
-
return
|
|
421
|
+
return nameOf(instance.scope) === label ? { ...record, instance } : null;
|
|
323
422
|
}
|
|
324
423
|
catch {
|
|
325
424
|
return null;
|
|
@@ -353,11 +452,17 @@ export function createServer(options) {
|
|
|
353
452
|
viewer: record.viewer,
|
|
354
453
|
origin: originOfLabel(label),
|
|
355
454
|
expiresAt: Date.now() + (record.sessionSeconds ?? DEFAULT_SESSION_SECONDS) * 1_000,
|
|
455
|
+
...(record.anonymous === true ? { anonymous: true } : {}),
|
|
456
|
+
...(record.subject === undefined ? {} : { subject: record.subject }),
|
|
356
457
|
};
|
|
357
458
|
}
|
|
358
459
|
access ??= accessOf(request, label, key);
|
|
359
460
|
if (access === null) {
|
|
360
|
-
return page(401, "This link has been used", "Content here opens once per link. Open it again from the app that showed it.", method);
|
|
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");
|
|
462
|
+
}
|
|
463
|
+
const refusal = await denied(request, access);
|
|
464
|
+
if (refusal !== null) {
|
|
465
|
+
return refusal;
|
|
361
466
|
}
|
|
362
467
|
const served = await documentOf(access.instance);
|
|
363
468
|
if (served === null) {
|
|
@@ -376,13 +481,30 @@ export function createServer(options) {
|
|
|
376
481
|
}
|
|
377
482
|
const record = await take(url.searchParams.get("ticket"), label);
|
|
378
483
|
if (record === null || record.instance.document.kind !== "external") {
|
|
379
|
-
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.");
|
|
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");
|
|
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;
|
|
380
495
|
}
|
|
381
496
|
const target = new URL(record.instance.document.url);
|
|
497
|
+
const landing = `${target.pathname}${target.search}${target.hash}`;
|
|
498
|
+
// In a frame, a page enters the app: it can tell whether the browser kept the cookie (and tell
|
|
499
|
+
// the host), where a redirect would leave a frame without one showing a refusal. On a tab of
|
|
500
|
+
// its own, a redirect.
|
|
501
|
+
if (request.headers.get("sec-fetch-dest") !== "document") {
|
|
502
|
+
return enterPage(landing, frameAncestors, options.hostOrigins, sessionCookie(request, record));
|
|
503
|
+
}
|
|
382
504
|
return new Response(null, {
|
|
383
505
|
status: 303,
|
|
384
506
|
headers: {
|
|
385
|
-
location:
|
|
507
|
+
location: landing,
|
|
386
508
|
"set-cookie": sessionCookie(request, record),
|
|
387
509
|
"cache-control": "no-store",
|
|
388
510
|
"referrer-policy": "no-referrer",
|
|
@@ -425,7 +547,11 @@ export function createServer(options) {
|
|
|
425
547
|
const method = request.method.toUpperCase();
|
|
426
548
|
const access = accessOf(request, label, null);
|
|
427
549
|
if (access === null) {
|
|
428
|
-
return page(401, "Open this from the app", "This address shows an app only to the people the app opens it for.", method);
|
|
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");
|
|
551
|
+
}
|
|
552
|
+
const refusal = await denied(request, access);
|
|
553
|
+
if (refusal !== null) {
|
|
554
|
+
return refusal;
|
|
429
555
|
}
|
|
430
556
|
if (options.upstream === undefined) {
|
|
431
557
|
return page(502, "Nothing serves this app", "The server has no upstream for apps.", method);
|
|
@@ -450,42 +576,74 @@ export function createServer(options) {
|
|
|
450
576
|
if (host !== domain && !host.endsWith(`.${domain}`)) {
|
|
451
577
|
return null;
|
|
452
578
|
}
|
|
453
|
-
|
|
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 };
|
|
454
586
|
}
|
|
455
587
|
return {
|
|
456
588
|
contentSource,
|
|
457
|
-
originFor: (scope) => originOfLabel(
|
|
589
|
+
originFor: (scope) => originOfLabel(nameOf(scope)),
|
|
458
590
|
async issue(instance, grant) {
|
|
459
591
|
const clean = cleanInstance(instance);
|
|
460
592
|
check(clean);
|
|
461
|
-
|
|
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" ||
|
|
462
603
|
grant.viewer.length === 0 ||
|
|
463
604
|
grant.viewer.length > 512) {
|
|
464
|
-
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`);
|
|
465
610
|
}
|
|
466
611
|
const ttl = seconds(grant.ttlSeconds, DEFAULT_TICKET_SECONDS, MAX_TICKET_SECONDS, "ttlSeconds");
|
|
467
612
|
const sessionSeconds = seconds(grant.sessionSeconds, DEFAULT_SESSION_SECONDS, MAX_SESSION_SECONDS, "sessionSeconds");
|
|
468
613
|
const key = keys.instanceKey(clean);
|
|
469
|
-
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
|
+
});
|
|
470
622
|
if (cookieName(key).length + probe.length + 120 > MAX_COOKIE_BYTES) {
|
|
471
623
|
throw new TypeError("This instance is too large to carry in a cookie (fewer origins?)");
|
|
472
624
|
}
|
|
473
625
|
const ticket = newTicket();
|
|
474
626
|
await tickets.put(ticketId(ticket), {
|
|
475
627
|
instance: clean,
|
|
476
|
-
viewer
|
|
628
|
+
viewer,
|
|
477
629
|
expiresAt: Date.now() + ttl * 1_000,
|
|
478
630
|
...(grant.sessionSeconds === undefined ? {} : { sessionSeconds }),
|
|
631
|
+
...(anonymous ? { anonymous: true } : {}),
|
|
632
|
+
...(grant.subject === undefined ? {} : { subject: grant.subject }),
|
|
479
633
|
});
|
|
480
|
-
const origin = originOfLabel(
|
|
634
|
+
const origin = originOfLabel(nameOf(clean.scope));
|
|
481
635
|
const path = clean.document.kind === "external"
|
|
482
636
|
? `${ENTER_PATH}?ticket=${ticket}`
|
|
483
637
|
: `${PREFIX}d/${key}?ticket=${ticket}`;
|
|
484
|
-
return { url: `${origin}${path}`, origin };
|
|
638
|
+
return { url: `${origin}${path}`, origin, reports: true };
|
|
485
639
|
},
|
|
486
640
|
async access(request) {
|
|
487
641
|
const at = labelOf(request);
|
|
488
|
-
|
|
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;
|
|
489
647
|
},
|
|
490
648
|
async handle(request) {
|
|
491
649
|
const at = labelOf(request);
|
|
@@ -495,12 +653,22 @@ export function createServer(options) {
|
|
|
495
653
|
const { url, label } = at;
|
|
496
654
|
const method = request.method.toUpperCase();
|
|
497
655
|
try {
|
|
498
|
-
if (!
|
|
656
|
+
if (!ours(label)) {
|
|
499
657
|
return page(404, "Nothing here", "This address names no content.", method);
|
|
500
658
|
}
|
|
501
659
|
if (url.pathname === ENTER_PATH) {
|
|
502
660
|
return await enter(request, url, label);
|
|
503
661
|
}
|
|
662
|
+
const script = pageScript(url.pathname);
|
|
663
|
+
if (script !== null) {
|
|
664
|
+
return script;
|
|
665
|
+
}
|
|
666
|
+
if (url.pathname === SESSION_PATH) {
|
|
667
|
+
// Whether this request's cookie opens its origin's app: the entry page asks.
|
|
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" } });
|
|
671
|
+
}
|
|
504
672
|
const document = DOCUMENT_PATH.exec(url.pathname);
|
|
505
673
|
if (document?.[1] !== undefined) {
|
|
506
674
|
return await serveDocument(request, url, label, document[1]);
|
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
|
+
}
|