@unifedev/thread-pages 0.3.2 → 1.0.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/README.md +77 -129
- package/dist/server.js +11426 -11802
- package/dist/server.meta.json +2 -2
- package/package.json +25 -18
- package/server.ts +3 -2175
- package/src/agent/cli.ts +193 -0
- package/src/agent/guide.ts +355 -0
- package/src/agent/instruction.ts +59 -0
- package/src/agent/seed/seed.ts +69 -0
- package/{theme.ts → src/agent/seed/theme-css.ts} +4 -11
- package/src/agent/starter-hub.ts +217 -0
- package/src/bb/activity.ts +59 -0
- package/src/bb/bb-host.ts +280 -0
- package/src/bb/public-origin.ts +45 -0
- package/src/config/settings.ts +82 -0
- package/src/domain/capabilities/contract.ts +48 -0
- package/src/domain/capabilities/index.ts +10 -0
- package/src/domain/capabilities/protocol.ts +112 -0
- package/src/domain/capabilities/registry.ts +48 -0
- package/src/domain/capabilities/schema.ts +198 -0
- package/src/domain/capabilities/specs.ts +479 -0
- package/src/domain/eligibility.ts +43 -0
- package/src/domain/errors.ts +116 -0
- package/src/domain/html/document.ts +109 -0
- package/src/domain/html/escape.ts +16 -0
- package/src/domain/ids.ts +37 -0
- package/src/domain/json/canonical.ts +19 -0
- package/src/domain/json/strict-json.ts +139 -0
- package/src/domain/limits.ts +88 -0
- package/src/domain/rate-limit.ts +64 -0
- package/src/domain/revision.ts +27 -0
- package/src/domain/submissions/idempotency.ts +59 -0
- package/src/domain/submissions/message.ts +42 -0
- package/src/domain/submissions/parse.ts +105 -0
- package/src/domain/tokens/action-token.ts +52 -0
- package/src/domain/tokens/confirmation.ts +99 -0
- package/src/domain/tokens/mac.ts +50 -0
- package/src/generated/kernel-runtime.ts +3 -0
- package/src/generated/shell-runtime.ts +3 -0
- package/src/host/contract.ts +65 -0
- package/src/host/types.ts +89 -0
- package/src/pages/layout.ts +65 -0
- package/src/pages/page-store.ts +136 -0
- package/src/pages/site.ts +36 -0
- package/src/plugin.ts +71 -0
- package/src/runtime/kernel/anchors.ts +45 -0
- package/src/runtime/kernel/api.ts +15 -0
- package/src/runtime/kernel/bridge-client.ts +148 -0
- package/src/runtime/kernel/dirty.ts +51 -0
- package/src/runtime/kernel/forms.ts +114 -0
- package/src/runtime/kernel/install.ts +156 -0
- package/src/runtime/kernel/labels.ts +98 -0
- package/src/runtime/kernel/main.ts +6 -0
- package/src/runtime/kernel/readonly.ts +75 -0
- package/src/runtime/shared/protocol.ts +125 -0
- package/src/runtime/shell/confirm.ts +70 -0
- package/src/runtime/shell/install.ts +79 -0
- package/src/runtime/shell/main.ts +12 -0
- package/src/runtime/shell/navigate.ts +64 -0
- package/src/runtime/shell/poll.ts +125 -0
- package/src/runtime/shell/relay.ts +185 -0
- package/src/serving/action-request.ts +32 -0
- package/src/serving/bridge/dispatcher.ts +112 -0
- package/src/serving/bridge/handler.ts +37 -0
- package/src/serving/bridge/handlers/index.ts +26 -0
- package/src/serving/bridge/handlers/navigation.ts +43 -0
- package/src/serving/bridge/handlers/reads.ts +186 -0
- package/src/serving/bridge/handlers/writes.ts +175 -0
- package/src/serving/bridge/selection-store.ts +58 -0
- package/src/serving/bridge-route.ts +23 -0
- package/src/serving/context.ts +34 -0
- package/src/serving/document-route.ts +37 -0
- package/src/serving/home-route.ts +23 -0
- package/src/serving/responses.ts +81 -0
- package/src/serving/routes.ts +26 -0
- package/src/serving/session-access.ts +22 -0
- package/src/serving/shell-html.ts +77 -0
- package/src/serving/shell-route.ts +51 -0
- package/src/serving/signing-key.ts +25 -0
- package/src/serving/submit-route.ts +47 -0
- package/src/serving/upload-route.ts +46 -0
- package/tsconfig.json +10 -6
- package/ARCHITECTURE.md +0 -230
- package/PLUGIN_OVERVIEW.md +0 -83
- package/authoring.ts +0 -368
- package/bridge.ts +0 -1721
- package/docs/MODEL.md +0 -211
- package/docs/ROADMAP.md +0 -96
- package/home.ts +0 -419
- package/page.ts +0 -782
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigation belongs to the trusted shell. Destinations arrive from the host,
|
|
3
|
+
* already validated; the shell never builds one from page-supplied text.
|
|
4
|
+
* spec R5.29–R5.34
|
|
5
|
+
*/
|
|
6
|
+
export interface Navigator {
|
|
7
|
+
/** Same-origin destinations navigate the reader's view in place. */
|
|
8
|
+
inPlace(url: string): void;
|
|
9
|
+
/** Claims a window during a user gesture so a later navigation is not a blocked popup. */
|
|
10
|
+
reserveWindow(): void;
|
|
11
|
+
/** Sends the reserved window (or, failing that, this view) to an external URL. */
|
|
12
|
+
external(url: string): void;
|
|
13
|
+
/** Releases a reserved window that will not be used. */
|
|
14
|
+
release(): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createNavigator(win: Window): Navigator {
|
|
18
|
+
let reserved: Window | null = null;
|
|
19
|
+
return {
|
|
20
|
+
inPlace(url) {
|
|
21
|
+
win.location.assign(url);
|
|
22
|
+
},
|
|
23
|
+
reserveWindow() {
|
|
24
|
+
try {
|
|
25
|
+
reserved = win.open("", "_blank");
|
|
26
|
+
if (reserved) {
|
|
27
|
+
try {
|
|
28
|
+
reserved.opener = null;
|
|
29
|
+
} catch {
|
|
30
|
+
// Some browsers refuse; the navigation below still uses noreferrer semantics.
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
reserved = null;
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
external(url) {
|
|
38
|
+
const target = reserved;
|
|
39
|
+
reserved = null;
|
|
40
|
+
if (target && !target.closed) {
|
|
41
|
+
try {
|
|
42
|
+
target.location.href = url;
|
|
43
|
+
return;
|
|
44
|
+
} catch {
|
|
45
|
+
try {
|
|
46
|
+
target.close();
|
|
47
|
+
} catch {
|
|
48
|
+
// ignore
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
win.location.assign(url);
|
|
53
|
+
},
|
|
54
|
+
release() {
|
|
55
|
+
const target = reserved;
|
|
56
|
+
reserved = null;
|
|
57
|
+
try {
|
|
58
|
+
target?.close();
|
|
59
|
+
} catch {
|
|
60
|
+
// ignore
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { ShellConfig } from "../shared/protocol.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The revision poll: a conditional GET of the document every few seconds
|
|
5
|
+
* while the tab is visible. It carries the working indicator and the
|
|
6
|
+
* source state, so no second channel exists. spec R2.17–R2.26
|
|
7
|
+
*/
|
|
8
|
+
export interface PollView {
|
|
9
|
+
setStatus(text: string, warn: boolean): void;
|
|
10
|
+
setWorking(working: boolean): void;
|
|
11
|
+
showReload(visible: boolean): void;
|
|
12
|
+
onStaleChanged(stale: boolean): void;
|
|
13
|
+
reloadView(): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Poller {
|
|
17
|
+
start(): void;
|
|
18
|
+
setDirty(dirty: boolean): void;
|
|
19
|
+
/** For tests: run one poll now. */
|
|
20
|
+
pollNow(): Promise<void>;
|
|
21
|
+
isStopped(): boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createPoller(win: Window, config: ShellConfig, view: PollView, fetchImpl: typeof fetch = win.fetch.bind(win)): Poller {
|
|
25
|
+
let etag = `"${config.pageRevision}"`;
|
|
26
|
+
let dirty = false;
|
|
27
|
+
let stopped = false;
|
|
28
|
+
let polling = false;
|
|
29
|
+
let lastStale = config.stale;
|
|
30
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
31
|
+
let controller: AbortController | null = null;
|
|
32
|
+
|
|
33
|
+
function schedule(delay: number): void {
|
|
34
|
+
if (timer !== null) clearTimeout(timer);
|
|
35
|
+
timer = null;
|
|
36
|
+
if (stopped || win.document.visibilityState !== "visible") return;
|
|
37
|
+
timer = setTimeout(() => {
|
|
38
|
+
timer = null;
|
|
39
|
+
void poll();
|
|
40
|
+
}, delay);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function pause(): void {
|
|
44
|
+
if (timer !== null) clearTimeout(timer);
|
|
45
|
+
timer = null;
|
|
46
|
+
controller?.abort();
|
|
47
|
+
controller = null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function newVersion(): void {
|
|
51
|
+
if (dirty) {
|
|
52
|
+
view.setStatus("Page changed — reload when ready", true);
|
|
53
|
+
view.showReload(true);
|
|
54
|
+
} else {
|
|
55
|
+
view.reloadView();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function poll(): Promise<void> {
|
|
60
|
+
if (stopped || polling || win.document.visibilityState !== "visible") return;
|
|
61
|
+
if (Date.now() >= config.expiresAt - 30_000) {
|
|
62
|
+
stopped = true;
|
|
63
|
+
if (dirty) {
|
|
64
|
+
view.setStatus("Session expiring — reload when ready", true);
|
|
65
|
+
view.showReload(true);
|
|
66
|
+
} else {
|
|
67
|
+
view.reloadView();
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
polling = true;
|
|
72
|
+
controller = new AbortController();
|
|
73
|
+
try {
|
|
74
|
+
const response = await fetchImpl(config.documentUrl, {
|
|
75
|
+
method: "GET",
|
|
76
|
+
credentials: "same-origin",
|
|
77
|
+
cache: "no-store",
|
|
78
|
+
headers: { "if-none-match": etag },
|
|
79
|
+
signal: controller.signal,
|
|
80
|
+
});
|
|
81
|
+
if (response.status === 401 || response.status === 403) {
|
|
82
|
+
stopped = true;
|
|
83
|
+
view.setStatus("Session expired — reload this page", true);
|
|
84
|
+
view.showReload(true);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (!response.ok && response.status !== 304) {
|
|
88
|
+
view.setStatus("Page unavailable", true);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const stale = response.headers.get("x-thread-page-stale") === "true";
|
|
92
|
+
view.setWorking(response.headers.get("x-thread-page-activity") === "working");
|
|
93
|
+
if (stale !== lastStale) {
|
|
94
|
+
lastStale = stale;
|
|
95
|
+
view.onStaleChanged(stale);
|
|
96
|
+
}
|
|
97
|
+
view.setStatus(stale ? "Offline copy — read-only" : "", stale);
|
|
98
|
+
const next = response.headers.get("etag");
|
|
99
|
+
if (next && next !== etag) {
|
|
100
|
+
etag = next;
|
|
101
|
+
newVersion();
|
|
102
|
+
}
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (!(error instanceof DOMException && error.name === "AbortError")) view.setStatus("Cannot check for updates", true);
|
|
105
|
+
} finally {
|
|
106
|
+
controller = null;
|
|
107
|
+
polling = false;
|
|
108
|
+
schedule(config.pollMs);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
win.document.addEventListener("visibilitychange", () => {
|
|
113
|
+
if (win.document.visibilityState === "visible") schedule(0);
|
|
114
|
+
else pause();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
start: () => schedule(config.pollMs),
|
|
119
|
+
setDirty: (next) => {
|
|
120
|
+
dirty = next;
|
|
121
|
+
},
|
|
122
|
+
pollNow: () => poll(),
|
|
123
|
+
isStopped: () => stopped,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { isBridgeRequest, isBridgeResponse, isRecord, makeFailure, type BridgeRequestMessage, type ShellConfig, type ShellMessage, type SubmitFile } from "../shared/protocol.ts";
|
|
2
|
+
import type { Confirmer } from "./confirm.ts";
|
|
3
|
+
import type { Navigator } from "./navigate.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The shell's side of the port: it validates every message from the frame,
|
|
7
|
+
* carries bridge calls and submissions to the host with the action token,
|
|
8
|
+
* shows host-authored confirmations, and executes host-validated navigation.
|
|
9
|
+
* spec R3.5–R3.7, R3.17
|
|
10
|
+
*/
|
|
11
|
+
export interface RelayDeps {
|
|
12
|
+
config: ShellConfig;
|
|
13
|
+
confirmer: Confirmer;
|
|
14
|
+
navigator: Navigator;
|
|
15
|
+
onDirty(dirty: boolean): void;
|
|
16
|
+
fetchImpl?: typeof fetch;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Relay {
|
|
20
|
+
handle(port: MessagePort, data: unknown): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type Directive = { kind: "page" | "host" | "external"; url: string };
|
|
24
|
+
|
|
25
|
+
export function createRelay(deps: RelayDeps): Relay {
|
|
26
|
+
const { config, confirmer, navigator } = deps;
|
|
27
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
28
|
+
|
|
29
|
+
function reply(port: MessagePort, message: ShellMessage): void {
|
|
30
|
+
port.postMessage(message);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function postBridge(body: unknown): Promise<unknown> {
|
|
34
|
+
const response = await fetchImpl(config.bridgeUrl, {
|
|
35
|
+
method: "POST",
|
|
36
|
+
credentials: "same-origin",
|
|
37
|
+
cache: "no-store",
|
|
38
|
+
headers: { "content-type": "application/json" },
|
|
39
|
+
body: JSON.stringify(body),
|
|
40
|
+
});
|
|
41
|
+
return response.json().catch(() => null);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function directiveOf(value: unknown): Directive | null {
|
|
45
|
+
if (!isRecord(value)) return null;
|
|
46
|
+
if ((value.kind !== "page" && value.kind !== "host" && value.kind !== "external") || typeof value.url !== "string") return null;
|
|
47
|
+
if (value.kind === "external" && !/^https?:\/\//i.test(value.url)) return null;
|
|
48
|
+
if (value.kind !== "external" && !value.url.startsWith("/")) return null;
|
|
49
|
+
return { kind: value.kind, url: value.url };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function deliver(port: MessagePort, request: BridgeRequestMessage, body: unknown): void {
|
|
53
|
+
if (!isRecord(body) || !isBridgeResponse(body.response, request.id)) {
|
|
54
|
+
reply(port, makeFailure(request.id, "invalid_response", "The Thread Page bridge returned an invalid response"));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const directive = body.navigate === undefined ? null : directiveOf(body.navigate);
|
|
58
|
+
if (body.response.ok && directive) {
|
|
59
|
+
reply(port, body.response);
|
|
60
|
+
if (directive.kind === "external") navigator.external(directive.url);
|
|
61
|
+
else navigator.inPlace(directive.url);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
navigator.release();
|
|
65
|
+
reply(port, body.response);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function relayBridge(port: MessagePort, request: BridgeRequestMessage): Promise<void> {
|
|
69
|
+
try {
|
|
70
|
+
const first = await postBridge({ actionToken: config.actionToken, request });
|
|
71
|
+
if (isRecord(first) && isRecord(first.confirm)) {
|
|
72
|
+
const confirm = first.confirm;
|
|
73
|
+
if (typeof confirm.challenge !== "string" || typeof confirm.summary !== "string" || confirm.requestId !== request.id) {
|
|
74
|
+
reply(port, makeFailure(request.id, "invalid_response", "The Thread Page bridge returned an invalid confirmation"));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const external = request.method === "navigation.openExternal";
|
|
78
|
+
const approved = await confirmer.confirm(confirm.summary, external ? () => navigator.reserveWindow() : undefined);
|
|
79
|
+
if (!approved) {
|
|
80
|
+
reply(port, makeFailure(request.id, "cancelled", "You declined this action"));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const second = await postBridge({ actionToken: config.actionToken, request, confirmation: confirm.challenge });
|
|
84
|
+
deliver(port, request, second);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
deliver(port, request, first);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
navigator.release();
|
|
90
|
+
reply(port, makeFailure(request.id, "unavailable", error instanceof Error ? error.message : "The Thread Page bridge is unavailable"));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function uploadOne(entry: SubmitFile): Promise<{ field: string; name: string; path: string; sizeBytes: number }> {
|
|
95
|
+
const file = entry.file;
|
|
96
|
+
if (!file || typeof file.size !== "number") throw new Error("Attachment is not a file");
|
|
97
|
+
const label = file.name || "file";
|
|
98
|
+
if (file.size <= 0) throw new Error(`Attachment ${label} is empty`);
|
|
99
|
+
if (file.size > config.maxUploadBytes) throw new Error(`Attachment ${label} is larger than ${Math.round(config.maxUploadBytes / (1024 * 1024))} MiB`);
|
|
100
|
+
const content = await encodeBase64(file);
|
|
101
|
+
const response = await fetchImpl(config.uploadUrl, {
|
|
102
|
+
method: "POST",
|
|
103
|
+
credentials: "same-origin",
|
|
104
|
+
cache: "no-store",
|
|
105
|
+
headers: { "content-type": "application/json" },
|
|
106
|
+
body: JSON.stringify({ actionToken: config.actionToken, pageRevision: config.pageRevision, name: label, content }),
|
|
107
|
+
});
|
|
108
|
+
const body = (await response.json().catch(() => null)) as Record<string, unknown> | null;
|
|
109
|
+
if (!response.ok || !body || body.ok !== true || typeof body.name !== "string" || typeof body.path !== "string" || typeof body.sizeBytes !== "number") {
|
|
110
|
+
throw new Error((body && typeof body.message === "string" && body.message) || `Upload failed (${response.status})`);
|
|
111
|
+
}
|
|
112
|
+
return { field: String(entry.field || "file").slice(0, 128), name: body.name, path: body.path, sizeBytes: body.sizeBytes };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function relaySubmit(port: MessagePort, data: Record<string, unknown>): Promise<void> {
|
|
116
|
+
const submissionId = typeof data.submissionId === "string" ? data.submissionId : "";
|
|
117
|
+
try {
|
|
118
|
+
const entries = (Array.isArray(data.files) ? data.files : []).slice(0, config.maxUploads) as SubmitFile[];
|
|
119
|
+
const files = [];
|
|
120
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
121
|
+
reply(port, { kind: "thread-page:submit-progress", submissionId, message: `Uploading ${index + 1} of ${entries.length}…` });
|
|
122
|
+
files.push(await uploadOne(entries[index] as SubmitFile));
|
|
123
|
+
}
|
|
124
|
+
if (files.length > 0) reply(port, { kind: "thread-page:submit-progress", submissionId, message: "Sending…" });
|
|
125
|
+
const response = await fetchImpl(config.submitUrl, {
|
|
126
|
+
method: "POST",
|
|
127
|
+
credentials: "same-origin",
|
|
128
|
+
cache: "no-store",
|
|
129
|
+
headers: { "content-type": "application/json" },
|
|
130
|
+
body: JSON.stringify({
|
|
131
|
+
actionToken: config.actionToken,
|
|
132
|
+
submissionId,
|
|
133
|
+
pageRevision: config.pageRevision,
|
|
134
|
+
title: data.title,
|
|
135
|
+
answers: data.answers,
|
|
136
|
+
files,
|
|
137
|
+
}),
|
|
138
|
+
});
|
|
139
|
+
const body = (await response.json().catch(() => ({ ok: false, message: "Invalid server response" }))) as Record<string, unknown>;
|
|
140
|
+
const ok = response.ok && body.ok === true;
|
|
141
|
+
reply(port, {
|
|
142
|
+
kind: "thread-page:submit-result",
|
|
143
|
+
submissionId,
|
|
144
|
+
ok,
|
|
145
|
+
message: typeof body.delivery === "string" ? `Sent (${body.delivery})` : "Sent",
|
|
146
|
+
error: typeof body.message === "string" ? body.message : `Request failed (${response.status})`,
|
|
147
|
+
});
|
|
148
|
+
} catch (error) {
|
|
149
|
+
reply(port, { kind: "thread-page:submit-result", submissionId, ok: false, error: error instanceof Error ? error.message : "Request failed" });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
handle(port, data) {
|
|
155
|
+
if (!isRecord(data)) return;
|
|
156
|
+
if (data.kind === "thread-page:dirty") {
|
|
157
|
+
deps.onDirty(true);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (data.kind === "thread-page:clean") {
|
|
161
|
+
deps.onDirty(false);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (data.kind === "thread-page:submit") {
|
|
165
|
+
void relaySubmit(port, data);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (!isBridgeRequest(data, config.pageRevision)) {
|
|
169
|
+
reply(port, makeFailure(data.id, "invalid_request", "Invalid Thread Page bridge request"));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
void relayBridge(port, data);
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function encodeBase64(file: Blob): Promise<string> {
|
|
178
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
179
|
+
let binary = "";
|
|
180
|
+
const chunk = 0x8000;
|
|
181
|
+
for (let index = 0; index < bytes.length; index += chunk) {
|
|
182
|
+
binary += String.fromCharCode.apply(null, Array.from(bytes.subarray(index, index + chunk)));
|
|
183
|
+
}
|
|
184
|
+
return btoa(binary);
|
|
185
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import { PageError, PUBLIC_MESSAGES } from "../domain/errors.ts";
|
|
3
|
+
import { verifyActionToken, type ActionToken } from "../domain/tokens/action-token.ts";
|
|
4
|
+
import type { ServingContext } from "./context.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The steps every effectful route shares: a bounded JSON body, a valid action
|
|
8
|
+
* token, and a slot in the page's rate budget. spec R2.6, R2.38
|
|
9
|
+
*/
|
|
10
|
+
export async function readJsonBody(context: Context, maxBytes: number): Promise<unknown> {
|
|
11
|
+
const declared = Number(context.req.header("content-length") ?? "0");
|
|
12
|
+
if (Number.isFinite(declared) && declared > maxBytes) throw new PageError("request_too_large", "Request body is too large");
|
|
13
|
+
const raw = await context.req.text();
|
|
14
|
+
if (Buffer.byteLength(raw, "utf8") > maxBytes) throw new PageError("request_too_large", "Request body is too large");
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(raw) as unknown;
|
|
17
|
+
} catch {
|
|
18
|
+
throw new PageError("invalid_json", "Request body is not valid JSON");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function requireActionToken(serving: ServingContext, token: unknown): ActionToken {
|
|
23
|
+
const verified = typeof token === "string" ? verifyActionToken(token, serving.signingKey, serving.now()) : null;
|
|
24
|
+
if (!verified) throw new PageError("confirmation_invalid", PUBLIC_MESSAGES.tokenInvalid, { status: 401 });
|
|
25
|
+
return verified;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function acquireRate(serving: ServingContext, session: string): () => void {
|
|
29
|
+
const release = serving.rate.acquire(session, serving.now());
|
|
30
|
+
if (!release) throw new PageError("rate_limited", PUBLIC_MESSAGES.rateLimited);
|
|
31
|
+
return release;
|
|
32
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { completeInvocation, decodeBridgeRequest, failure, failureFromError, resolveInvocation, type BridgeTransport } from "../../domain/capabilities/protocol.ts";
|
|
2
|
+
import { PageError, PUBLIC_MESSAGES, errorText, isBridgeErrorCode } from "../../domain/errors.ts";
|
|
3
|
+
import type { JsonValue } from "../../domain/json/strict-json.ts";
|
|
4
|
+
import { LIMITS } from "../../domain/limits.ts";
|
|
5
|
+
import { challengeMatches, mintChallenge, openChallenge } from "../../domain/tokens/confirmation.ts";
|
|
6
|
+
import { acquireRate, requireActionToken } from "../action-request.ts";
|
|
7
|
+
import type { ServingContext } from "../context.ts";
|
|
8
|
+
import { eligibleSession } from "../session-access.ts";
|
|
9
|
+
import type { CapabilityHandler, HandlerContext } from "./handler.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The one path every capability call takes. spec 03 §Confirmed effects, 05
|
|
13
|
+
*
|
|
14
|
+
* envelope → action token → rate budget → resolve (stale, unknown, params)
|
|
15
|
+
* → cheap refusals → confirmation (challenge out, or verify one in)
|
|
16
|
+
* → session still eligible, page still current → handler → projection
|
|
17
|
+
*/
|
|
18
|
+
export interface DispatchResult {
|
|
19
|
+
readonly status: number;
|
|
20
|
+
readonly body: BridgeTransport;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface Envelope {
|
|
24
|
+
readonly actionToken: string;
|
|
25
|
+
readonly request: unknown;
|
|
26
|
+
readonly confirmation: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parseEnvelope(value: unknown): Envelope {
|
|
30
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new PageError("invalid_request", "Invalid bridge envelope");
|
|
31
|
+
const input = value as Record<string, unknown>;
|
|
32
|
+
const keys = Object.keys(input);
|
|
33
|
+
if (!keys.includes("actionToken") || !keys.includes("request") || keys.some((key) => !["actionToken", "request", "confirmation"].includes(key))) {
|
|
34
|
+
throw new PageError("invalid_request", "Invalid bridge envelope");
|
|
35
|
+
}
|
|
36
|
+
if (typeof input.actionToken !== "string" || input.actionToken.length > LIMITS.tokenChars) throw new PageError("invalid_request", "Invalid bridge envelope");
|
|
37
|
+
const confirmation = input.confirmation;
|
|
38
|
+
if (confirmation !== undefined && confirmation !== null && (typeof confirmation !== "string" || confirmation.length > LIMITS.tokenChars)) {
|
|
39
|
+
throw new PageError("invalid_request", "Invalid bridge envelope");
|
|
40
|
+
}
|
|
41
|
+
return { actionToken: input.actionToken, request: input.request, confirmation: typeof confirmation === "string" ? confirmation : null };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createDispatcher(serving: ServingContext, handlers: readonly CapabilityHandler[]) {
|
|
45
|
+
const byMethod = new Map(handlers.map((entry) => [entry.method, entry]));
|
|
46
|
+
for (const spec of serving.registry.list()) {
|
|
47
|
+
if (spec.implemented && !byMethod.has(spec.method)) throw new Error(`No handler for capability ${spec.method}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return async function dispatch(body: unknown): Promise<DispatchResult> {
|
|
51
|
+
let requestId: unknown;
|
|
52
|
+
let release: (() => void) | null = null;
|
|
53
|
+
try {
|
|
54
|
+
const envelope = parseEnvelope(body);
|
|
55
|
+
requestId = (envelope.request as { id?: unknown } | null)?.id;
|
|
56
|
+
const token = requireActionToken(serving, envelope.actionToken);
|
|
57
|
+
release = acquireRate(serving, token.session);
|
|
58
|
+
const request = decodeBridgeRequest(envelope.request);
|
|
59
|
+
requestId = request.id;
|
|
60
|
+
const invocation = resolveInvocation(request, serving.registry, token.revision);
|
|
61
|
+
const entry = byMethod.get(invocation.spec.method);
|
|
62
|
+
if (!entry) throw new PageError("unknown_method", `Unknown capability: ${invocation.spec.method}`);
|
|
63
|
+
|
|
64
|
+
const session = await eligibleSession(serving, token.session).catch((error: unknown) => {
|
|
65
|
+
throw PageError.is(error) && error.code === "ineligible" ? new PageError("conflict", "This session no longer accepts page actions") : error;
|
|
66
|
+
});
|
|
67
|
+
const page = await serving.pages.load(token.session);
|
|
68
|
+
if (page.revision !== token.revision) throw new PageError("stale_page", PUBLIC_MESSAGES.stalePage);
|
|
69
|
+
const context: HandlerContext = { serving, session, page, requestId: request.id };
|
|
70
|
+
|
|
71
|
+
await entry.refuse?.(invocation.params, context);
|
|
72
|
+
|
|
73
|
+
if (invocation.spec.confirmed) {
|
|
74
|
+
const binding = { session: token.session, revision: token.revision, requestId: request.id, method: request.method, params: invocation.params as JsonValue };
|
|
75
|
+
if (envelope.confirmation === null) {
|
|
76
|
+
const summary = (await entry.summarize?.(invocation.params, context)) ?? invocation.spec.description;
|
|
77
|
+
const { challenge, payload } = mintChallenge(binding, summary, serving.now(), serving.signingKey);
|
|
78
|
+
return { status: 401, body: { confirm: { requestId: request.id, summary: payload.summary, challenge } } };
|
|
79
|
+
}
|
|
80
|
+
const challenge = openChallenge(envelope.confirmation, serving.signingKey, serving.now());
|
|
81
|
+
if (!challenge || !challengeMatches(challenge, binding)) {
|
|
82
|
+
throw new PageError("confirmation_invalid", "The confirmation is expired or does not match this request");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (page.stale && invocation.spec.effect !== "read" && invocation.spec.effect !== "navigation") {
|
|
87
|
+
throw new PageError("unavailable", PUBLIC_MESSAGES.staleCopy);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let outcome;
|
|
91
|
+
try {
|
|
92
|
+
outcome = await entry.execute(invocation.params, context);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (PageError.is(error) && isBridgeErrorCode(error.code)) throw error;
|
|
95
|
+
serving.host.log.warn(`bridge ${request.method} for ${token.session}: ${errorText(error)}`);
|
|
96
|
+
throw new PageError("handler_error", PUBLIC_MESSAGES.handler, { cause: error });
|
|
97
|
+
}
|
|
98
|
+
const response = completeInvocation(invocation, outcome.result);
|
|
99
|
+
return { status: response.ok ? 200 : 500, body: outcome.navigate ? { response, navigate: outcome.navigate } : { response } };
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (PageError.is(error)) {
|
|
102
|
+
if (error.cause !== undefined) serving.host.log.warn(`bridge: ${error.code}: ${errorText(error.cause)}`);
|
|
103
|
+
const code = isBridgeErrorCode(error.code) ? error.code : error.code === "ineligible" || error.code === "no_page" ? "not_found" : "handler_error";
|
|
104
|
+
return { status: error.status, body: { response: failure(requestId, code, error.message) } };
|
|
105
|
+
}
|
|
106
|
+
serving.host.log.warn(`bridge: ${errorText(error)}`);
|
|
107
|
+
return { status: 500, body: { response: failureFromError(requestId, error) } };
|
|
108
|
+
} finally {
|
|
109
|
+
release?.();
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { NavigationDirective } from "../../domain/capabilities/protocol.ts";
|
|
2
|
+
import type { SessionRecord } from "../../host/types.ts";
|
|
3
|
+
import type { LoadedPage } from "../../pages/page-store.ts";
|
|
4
|
+
import type { ServingContext } from "../context.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A capability's server-side half. `refuse` runs before any confirmation so
|
|
8
|
+
* cheap refusals (own session, not found) never show a dialog; `summarize`
|
|
9
|
+
* words the confirmation from validated parameters; `execute` acts.
|
|
10
|
+
*/
|
|
11
|
+
export interface HandlerContext {
|
|
12
|
+
readonly serving: ServingContext;
|
|
13
|
+
readonly session: SessionRecord;
|
|
14
|
+
readonly page: LoadedPage;
|
|
15
|
+
readonly requestId: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface HandlerOutcome<R> {
|
|
19
|
+
readonly result: R;
|
|
20
|
+
readonly navigate?: NavigationDirective;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CapabilityHandler<P = unknown, R = unknown> {
|
|
24
|
+
readonly method: string;
|
|
25
|
+
refuse?(params: P, context: HandlerContext): Promise<void>;
|
|
26
|
+
summarize?(params: P, context: HandlerContext): Promise<string>;
|
|
27
|
+
execute(params: P, context: HandlerContext): Promise<HandlerOutcome<R>>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function handler<P, R>(definition: CapabilityHandler<P, R>): CapabilityHandler<P, R> {
|
|
31
|
+
return definition;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function excerpt(text: string, max = 80): string {
|
|
35
|
+
const line = text.replace(/\s+/g, " ").trim();
|
|
36
|
+
return line.length <= max ? line : `${line.slice(0, max - 1)}…`;
|
|
37
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { CapabilityHandler } from "../handler.ts";
|
|
2
|
+
import { navigationOpenExternal, pagesOpen, sessionsOpenHost } from "./navigation.ts";
|
|
3
|
+
import { contextGet, projectsList, providersList, sessionActivity, sessionsSnapshot, storageGet, storageSet } from "./reads.ts";
|
|
4
|
+
import { projectsBrowse, projectsCreate, sessionReply, sessionsArchive, sessionsMarkRead, sessionsSend, sessionsStart, sessionsStop } from "./writes.ts";
|
|
5
|
+
|
|
6
|
+
/** Every implemented capability's handler. The dispatcher checks this list against the registry at load. */
|
|
7
|
+
export const ALL_HANDLERS: readonly CapabilityHandler[] = [
|
|
8
|
+
contextGet,
|
|
9
|
+
sessionActivity,
|
|
10
|
+
sessionsSnapshot,
|
|
11
|
+
projectsList,
|
|
12
|
+
providersList,
|
|
13
|
+
storageGet,
|
|
14
|
+
storageSet,
|
|
15
|
+
sessionReply,
|
|
16
|
+
sessionsSend,
|
|
17
|
+
sessionsStart,
|
|
18
|
+
sessionsStop,
|
|
19
|
+
sessionsArchive,
|
|
20
|
+
sessionsMarkRead,
|
|
21
|
+
projectsBrowse,
|
|
22
|
+
projectsCreate,
|
|
23
|
+
pagesOpen,
|
|
24
|
+
sessionsOpenHost,
|
|
25
|
+
navigationOpenExternal,
|
|
26
|
+
] as CapabilityHandler[];
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { OpenExternalParams } from "../../../domain/capabilities/specs.ts";
|
|
2
|
+
import { ineligibleReason } from "../../../domain/eligibility.ts";
|
|
3
|
+
import { PageError } from "../../../domain/errors.ts";
|
|
4
|
+
import { pageUrl } from "../../context.ts";
|
|
5
|
+
import { excerpt, handler } from "../handler.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Navigation: the host validates the destination and the trusted shell
|
|
9
|
+
* performs it in place (pages, host application) or, after confirmation,
|
|
10
|
+
* to an external origin. spec R5.29–R5.34
|
|
11
|
+
*/
|
|
12
|
+
export const pagesOpen = handler<{ sessionId: string }, unknown>({
|
|
13
|
+
method: "pages.open",
|
|
14
|
+
async refuse(params, { serving }) {
|
|
15
|
+
const target = await serving.host.sessions.get(params.sessionId);
|
|
16
|
+
if (!target || ineligibleReason(target)) throw new PageError("not_found", "That session has no page");
|
|
17
|
+
},
|
|
18
|
+
async execute(params, { serving }) {
|
|
19
|
+
return { result: { opened: true }, navigate: { kind: "page", url: pageUrl(serving.routeBase, params.sessionId) } };
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const sessionsOpenHost = handler<{ sessionId: string }, unknown>({
|
|
24
|
+
method: "sessions.openHost",
|
|
25
|
+
async refuse(params, { serving }) {
|
|
26
|
+
const target = await serving.host.sessions.get(params.sessionId);
|
|
27
|
+
if (!target || target.deleted) throw new PageError("not_found", "That session is not available");
|
|
28
|
+
},
|
|
29
|
+
async execute(params, { serving }) {
|
|
30
|
+
return { result: { opened: true }, navigate: { kind: "host", url: serving.hostSessionUrl(params.sessionId) } };
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const navigationOpenExternal = handler<OpenExternalParams, unknown>({
|
|
35
|
+
method: "navigation.openExternal",
|
|
36
|
+
async summarize(params) {
|
|
37
|
+
const origin = new URL(params.url).origin;
|
|
38
|
+
return params.label ? `Leave this page and open “${excerpt(params.label, 60)}” at ${origin}` : `Leave this page and open ${origin}`;
|
|
39
|
+
},
|
|
40
|
+
async execute(params) {
|
|
41
|
+
return { result: { opened: true }, navigate: { kind: "external", url: new URL(params.url).href } };
|
|
42
|
+
},
|
|
43
|
+
});
|