@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,22 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import { describeIneligible, ineligibleReason } from "../domain/eligibility.ts";
|
|
3
|
+
import { PageError, PUBLIC_MESSAGES } from "../domain/errors.ts";
|
|
4
|
+
import { isSessionId } from "../domain/ids.ts";
|
|
5
|
+
import type { SessionRecord } from "../host/types.ts";
|
|
6
|
+
import type { ServingContext } from "./context.ts";
|
|
7
|
+
|
|
8
|
+
/** Shared first steps of every route: which session, does it exist, may it have a page. */
|
|
9
|
+
export function sessionIdFrom(context: Context): string {
|
|
10
|
+
const url = new URL(context.req.url);
|
|
11
|
+
const candidate = url.searchParams.get("session") ?? url.searchParams.get("threadId");
|
|
12
|
+
if (!isSessionId(candidate)) throw new PageError("invalid_session", PUBLIC_MESSAGES.invalidSession);
|
|
13
|
+
return candidate;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function eligibleSession(serving: ServingContext, id: string): Promise<SessionRecord> {
|
|
17
|
+
const session = await serving.host.sessions.get(id);
|
|
18
|
+
if (!session) throw new PageError("not_found", "That session does not exist.");
|
|
19
|
+
const reason = ineligibleReason(session);
|
|
20
|
+
if (reason) throw new PageError("ineligible", `${PUBLIC_MESSAGES.ineligible} (${describeIneligible(reason)}.)`);
|
|
21
|
+
return session;
|
|
22
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { escapeHtml } from "../domain/html/escape.ts";
|
|
2
|
+
import { SHELL_RUNTIME } from "../generated/shell-runtime.ts";
|
|
3
|
+
import type { ShellConfig } from "../runtime/shared/protocol.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The trusted shell document: title bar, home link, working indicator,
|
|
7
|
+
* status, reload control, the sandboxed frame and the confirmation dialog.
|
|
8
|
+
* Identical wherever the page is read. spec R2.14–R2.16
|
|
9
|
+
*/
|
|
10
|
+
export interface ShellView {
|
|
11
|
+
nonce: string;
|
|
12
|
+
title: string;
|
|
13
|
+
homeUrl: string | null;
|
|
14
|
+
working: boolean;
|
|
15
|
+
config: ShellConfig;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const SHELL_CSS = `
|
|
19
|
+
:root{color-scheme:light dark;font:14px/1.4 ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--bg:#f7f7f5;--surface:#fff;--ink:#17181b;--muted:#676c75;--line:#dfe0e3;--accent:#315fc5;--warn:#a54312}
|
|
20
|
+
@media(prefers-color-scheme:dark){:root{--bg:#111216;--surface:#191b20;--ink:#eeeef0;--muted:#a5a9b1;--line:#30333a;--accent:#91aff1;--warn:#efa879}}
|
|
21
|
+
*{box-sizing:border-box}html,body{height:100%;margin:0;background:var(--bg);color:var(--ink)}
|
|
22
|
+
.shell{display:grid;grid-template-rows:auto 1fr;height:100%;min-height:100dvh}
|
|
23
|
+
.bar{display:flex;align-items:center;gap:.75rem;min-height:2.5rem;padding:.45rem max(.7rem,env(safe-area-inset-right)) .45rem max(.7rem,env(safe-area-inset-left));border-bottom:1px solid var(--line);background:var(--surface)}
|
|
24
|
+
.home{flex:none;color:var(--muted);text-decoration:none;font-weight:600;white-space:nowrap}.home:hover{color:var(--ink)}
|
|
25
|
+
.title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600}
|
|
26
|
+
.status{margin-left:auto;color:var(--muted);text-align:right}.status[data-tone=warn]{color:var(--warn)}
|
|
27
|
+
.work{flex:none;display:none;align-items:center;gap:.4rem;color:var(--muted)}.work[data-visible=true]{display:inline-flex}
|
|
28
|
+
.work .dot{width:.5rem;height:.5rem;border-radius:50%;background:var(--accent)}
|
|
29
|
+
@media(prefers-reduced-motion:no-preference){.work[data-visible=true] .dot{animation:tp-pulse 1.4s ease-in-out infinite}}
|
|
30
|
+
@keyframes tp-pulse{0%,100%{opacity:1}50%{opacity:.25}}
|
|
31
|
+
button.reload{display:none;padding:.25rem .55rem;border:1px solid var(--line);border-radius:.4rem;color:var(--ink);background:var(--bg);cursor:pointer}button.reload[data-visible=true]{display:inline-block}
|
|
32
|
+
iframe{display:block;width:100%;height:100%;border:0;background:var(--bg)}
|
|
33
|
+
dialog{margin:auto;max-width:min(30rem,calc(100vw - 2rem));padding:1.15rem 1.25rem;border:1px solid var(--line);border-radius:.75rem;color:var(--ink);background:var(--surface)}
|
|
34
|
+
dialog::backdrop{background:rgb(0 0 0 / .45)}dialog h2{margin:0 0 .5rem;font-size:1rem}dialog p{margin:0 0 1rem;color:var(--muted);overflow-wrap:anywhere}
|
|
35
|
+
dialog .row{display:flex;gap:.5rem;justify-content:flex-end}dialog button{padding:.4rem .8rem;border:1px solid var(--line);border-radius:.4rem;color:var(--ink);background:var(--bg);cursor:pointer}
|
|
36
|
+
dialog button[value=confirm]{color:#fff;background:var(--accent);border-color:var(--accent)}
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
export function renderShell(view: ShellView): string {
|
|
40
|
+
const title = escapeHtml(view.title);
|
|
41
|
+
const nonce = escapeHtml(view.nonce);
|
|
42
|
+
const config = escapeHtml(JSON.stringify(view.config));
|
|
43
|
+
const working = view.working && view.config.workingLabel ? "true" : "false";
|
|
44
|
+
return `<!doctype html>
|
|
45
|
+
<html lang="en">
|
|
46
|
+
<head>
|
|
47
|
+
<meta charset="utf-8">
|
|
48
|
+
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
49
|
+
<meta name="color-scheme" content="light dark">
|
|
50
|
+
<title>${title}</title>
|
|
51
|
+
<style nonce="${nonce}">${SHELL_CSS}</style>
|
|
52
|
+
</head>
|
|
53
|
+
<body>
|
|
54
|
+
<div class="shell">
|
|
55
|
+
<header class="bar">
|
|
56
|
+
${view.homeUrl ? `<a class="home" href="${escapeHtml(view.homeUrl)}" title="All sessions">← Sessions</a>` : ""}
|
|
57
|
+
<span class="title">${title}</span>
|
|
58
|
+
<span class="work" role="status" data-shell-working data-visible="${working}"><span class="dot" aria-hidden="true"></span><span>${escapeHtml(view.config.workingLabel)}</span></span>
|
|
59
|
+
<span class="status" role="status" data-shell-status${view.config.stale ? ' data-tone="warn"' : ""}>${view.config.stale ? "Offline copy — read-only" : ""}</span>
|
|
60
|
+
<button type="button" class="reload" data-shell-reload aria-label="Reload updated page">Reload</button>
|
|
61
|
+
</header>
|
|
62
|
+
<iframe title="${title}" sandbox="allow-scripts allow-forms" referrerpolicy="no-referrer"></iframe>
|
|
63
|
+
</div>
|
|
64
|
+
<dialog aria-labelledby="tp-confirm-title">
|
|
65
|
+
<form method="dialog">
|
|
66
|
+
<h2 id="tp-confirm-title">Confirm this action</h2>
|
|
67
|
+
<p></p>
|
|
68
|
+
<div class="row">
|
|
69
|
+
<button type="button" value="cancel">Cancel</button>
|
|
70
|
+
<button type="button" value="confirm">Confirm</button>
|
|
71
|
+
</div>
|
|
72
|
+
</form>
|
|
73
|
+
</dialog>
|
|
74
|
+
<script nonce="${nonce}" data-config="${config}">${SHELL_RUNTIME}</script>
|
|
75
|
+
</body>
|
|
76
|
+
</html>`;
|
|
77
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import type { Context } from "hono";
|
|
3
|
+
import { isSessionId } from "../domain/ids.ts";
|
|
4
|
+
import { LIMITS } from "../domain/limits.ts";
|
|
5
|
+
import { mintActionToken } from "../domain/tokens/action-token.ts";
|
|
6
|
+
import type { ServingContext } from "./context.ts";
|
|
7
|
+
import { homeUrl } from "./context.ts";
|
|
8
|
+
import { baseHeaders, failureResponse, shellCsp } from "./responses.ts";
|
|
9
|
+
import { renderShell } from "./shell-html.ts";
|
|
10
|
+
import { eligibleSession, sessionIdFrom } from "./session-access.ts";
|
|
11
|
+
|
|
12
|
+
/** `GET /page?session=<id>` — the shell for one page. spec 02 §The shell */
|
|
13
|
+
export function shellRoute(serving: ServingContext) {
|
|
14
|
+
return async (context: Context): Promise<Response> => {
|
|
15
|
+
try {
|
|
16
|
+
const id = sessionIdFrom(context);
|
|
17
|
+
const session = await eligibleSession(serving, id);
|
|
18
|
+
const page = await serving.pages.load(id);
|
|
19
|
+
const now = serving.now();
|
|
20
|
+
const { token, payload } = mintActionToken({ session: id, revision: page.revision, now }, serving.signingKey);
|
|
21
|
+
const nonce = randomBytes(18).toString("base64url");
|
|
22
|
+
const settings = serving.settings.current();
|
|
23
|
+
const home = isSessionId(settings.homeSessionId) && settings.homeSessionId !== id ? homeUrl(serving.routeBase) : null;
|
|
24
|
+
const html = renderShell({
|
|
25
|
+
nonce,
|
|
26
|
+
title: session.title,
|
|
27
|
+
homeUrl: home,
|
|
28
|
+
working: session.state === "working",
|
|
29
|
+
config: {
|
|
30
|
+
actionToken: token,
|
|
31
|
+
pageRevision: page.revision,
|
|
32
|
+
expiresAt: payload.exp,
|
|
33
|
+
documentUrl: serving.site.documentUrl(id),
|
|
34
|
+
submitUrl: `${serving.routeBase}/submit`,
|
|
35
|
+
uploadUrl: `${serving.routeBase}/upload`,
|
|
36
|
+
bridgeUrl: `${serving.routeBase}/bridge`,
|
|
37
|
+
workingLabel: settings.workingLabel,
|
|
38
|
+
stale: page.stale,
|
|
39
|
+
pollMs: LIMITS.shellPollMs,
|
|
40
|
+
maxUploadBytes: LIMITS.uploadFileBytes,
|
|
41
|
+
maxUploads: LIMITS.uploadsPerForm,
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
const headers = baseHeaders("text/html; charset=utf-8");
|
|
45
|
+
headers.set("content-security-policy", shellCsp(nonce));
|
|
46
|
+
return new Response(html, { status: 200, headers });
|
|
47
|
+
} catch (error) {
|
|
48
|
+
return failureResponse(error, serving.host.log, "GET /page", true);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { errorText } from "../domain/errors.ts";
|
|
3
|
+
import type { SessionHost } from "../host/contract.ts";
|
|
4
|
+
|
|
5
|
+
const KEY = "signing-key:v3";
|
|
6
|
+
|
|
7
|
+
/** The token signing key: 32 random bytes, generated on first use, persisted, never logged. spec R2.8 */
|
|
8
|
+
export async function loadSigningKey(host: SessionHost): Promise<Uint8Array> {
|
|
9
|
+
try {
|
|
10
|
+
const stored = await host.kv.get(KEY);
|
|
11
|
+
if (typeof stored === "string" && /^[A-Za-z0-9_-]{43}$/.test(stored)) {
|
|
12
|
+
const decoded = Buffer.from(stored, "base64url");
|
|
13
|
+
if (decoded.byteLength === 32) return decoded;
|
|
14
|
+
}
|
|
15
|
+
} catch (error) {
|
|
16
|
+
host.log.warn(`signing key: could not read the stored key: ${errorText(error)}`);
|
|
17
|
+
}
|
|
18
|
+
const generated = randomBytes(32);
|
|
19
|
+
try {
|
|
20
|
+
await host.kv.set(KEY, generated.toString("base64url"));
|
|
21
|
+
} catch (error) {
|
|
22
|
+
host.log.warn(`signing key: could not persist; open pages will need a reload after the next plugin reload: ${errorText(error)}`);
|
|
23
|
+
}
|
|
24
|
+
return generated;
|
|
25
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import { PageError, PUBLIC_MESSAGES } from "../domain/errors.ts";
|
|
3
|
+
import { LIMITS } from "../domain/limits.ts";
|
|
4
|
+
import { sha256Hex } from "../domain/revision.ts";
|
|
5
|
+
import { formatSubmissionMessage } from "../domain/submissions/message.ts";
|
|
6
|
+
import { parseSubmission } from "../domain/submissions/parse.ts";
|
|
7
|
+
import { acquireRate, readJsonBody, requireActionToken } from "./action-request.ts";
|
|
8
|
+
import type { ServingContext } from "./context.ts";
|
|
9
|
+
import { failureResponse, jsonResponse } from "./responses.ts";
|
|
10
|
+
import { eligibleSession } from "./session-access.ts";
|
|
11
|
+
|
|
12
|
+
/** `POST /submit` — one form submission becomes one message. spec R2.32–R2.37 */
|
|
13
|
+
export function submitRoute(serving: ServingContext) {
|
|
14
|
+
return async (context: Context): Promise<Response> => {
|
|
15
|
+
let release: (() => void) | null = null;
|
|
16
|
+
try {
|
|
17
|
+
const body = await readJsonBody(context, LIMITS.submissionBodyBytes);
|
|
18
|
+
const submission = parseSubmission(body);
|
|
19
|
+
if (!submission) throw new PageError("invalid_request", "Invalid submission");
|
|
20
|
+
const token = requireActionToken(serving, submission.actionToken);
|
|
21
|
+
if (submission.pageRevision !== token.revision) throw new PageError("stale_page", PUBLIC_MESSAGES.stalePage);
|
|
22
|
+
release = acquireRate(serving, token.session);
|
|
23
|
+
const now = serving.now();
|
|
24
|
+
const fingerprint = sha256Hex(JSON.stringify({ revision: submission.pageRevision, title: submission.title, answers: submission.answers, files: submission.files }));
|
|
25
|
+
const remembered = serving.submissions.remember(
|
|
26
|
+
`${token.session}:${submission.submissionId}`,
|
|
27
|
+
fingerprint,
|
|
28
|
+
async () => {
|
|
29
|
+
await eligibleSession(serving, token.session);
|
|
30
|
+
const page = await serving.pages.load(token.session);
|
|
31
|
+
if (page.stale) throw new PageError("unavailable", PUBLIC_MESSAGES.staleCopy);
|
|
32
|
+
if (page.revision !== token.revision) throw new PageError("stale_page", PUBLIC_MESSAGES.stalePage);
|
|
33
|
+
const sent = await serving.host.sessions.send(token.session, formatSubmissionMessage(submission), "queue");
|
|
34
|
+
return { status: 200, body: { ok: true, delivery: sent.delivery } };
|
|
35
|
+
},
|
|
36
|
+
now,
|
|
37
|
+
);
|
|
38
|
+
if (remembered.kind === "conflict") throw new PageError("conflict", "This submission id was already used with different answers");
|
|
39
|
+
const outcome = await remembered.outcome;
|
|
40
|
+
return jsonResponse(outcome.body, outcome.status);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
return failureResponse(error, serving.host.log, "POST /submit", false);
|
|
43
|
+
} finally {
|
|
44
|
+
release?.();
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import type { Context } from "hono";
|
|
3
|
+
import { PageError, PUBLIC_MESSAGES } from "../domain/errors.ts";
|
|
4
|
+
import { LIMITS, mebibytes } from "../domain/limits.ts";
|
|
5
|
+
import { UPLOAD_DIR, uploadFileName } from "../pages/layout.ts";
|
|
6
|
+
import { acquireRate, readJsonBody, requireActionToken } from "./action-request.ts";
|
|
7
|
+
import type { ServingContext } from "./context.ts";
|
|
8
|
+
import { failureResponse, jsonResponse } from "./responses.ts";
|
|
9
|
+
import { eligibleSession } from "./session-access.ts";
|
|
10
|
+
|
|
11
|
+
const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `POST /upload` — one attached file, base64 in a JSON envelope (the host's
|
|
15
|
+
* "local" auth refuses raw bodies), stored under a host-generated name in the
|
|
16
|
+
* page's uploads directory. spec R4.19–R4.24
|
|
17
|
+
*/
|
|
18
|
+
export function uploadRoute(serving: ServingContext) {
|
|
19
|
+
const maxBody = Math.ceil((LIMITS.uploadFileBytes * 4) / 3) + 8_192;
|
|
20
|
+
return async (context: Context): Promise<Response> => {
|
|
21
|
+
let release: (() => void) | null = null;
|
|
22
|
+
try {
|
|
23
|
+
const body = await readJsonBody(context, maxBody);
|
|
24
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) throw new PageError("invalid_request", "Invalid upload envelope");
|
|
25
|
+
const envelope = body as Record<string, unknown>;
|
|
26
|
+
const token = requireActionToken(serving, envelope.actionToken);
|
|
27
|
+
if (typeof envelope.content !== "string" || !BASE64.test(envelope.content)) throw new PageError("invalid_request", "Attachment content must be base64");
|
|
28
|
+
release = acquireRate(serving, token.session);
|
|
29
|
+
await eligibleSession(serving, token.session);
|
|
30
|
+
const page = await serving.pages.load(token.session);
|
|
31
|
+
if (page.stale) throw new PageError("unavailable", PUBLIC_MESSAGES.staleCopy);
|
|
32
|
+
const bytes = Buffer.from(envelope.content, "base64");
|
|
33
|
+
if (bytes.byteLength === 0) throw new PageError("invalid_request", "The file is empty");
|
|
34
|
+
if (bytes.byteLength > LIMITS.uploadFileBytes) throw new PageError("request_too_large", `Attachments must be at most ${mebibytes(LIMITS.uploadFileBytes)}`);
|
|
35
|
+
const name = uploadFileName(typeof envelope.name === "string" ? envelope.name : "upload", serving.now(), randomBytes(3).toString("hex"));
|
|
36
|
+
const location = await serving.host.sessions.storage(token.session);
|
|
37
|
+
const outcome = await serving.host.files.write(location, `${UPLOAD_DIR}/${name}`, bytes, { onlyIfAbsent: true });
|
|
38
|
+
if (outcome !== "written") throw new PageError("conflict", "The attachment could not be stored under a fresh name; try again");
|
|
39
|
+
return jsonResponse({ ok: true, name, path: `${UPLOAD_DIR}/${name}`, sizeBytes: bytes.byteLength });
|
|
40
|
+
} catch (error) {
|
|
41
|
+
return failureResponse(error, serving.host.log, "POST /upload", false);
|
|
42
|
+
} finally {
|
|
43
|
+
release?.();
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
package/tsconfig.json
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"compilerOptions": {
|
|
3
|
+
"strict": true,
|
|
3
4
|
"target": "ES2022",
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
4
7
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
5
|
-
"
|
|
6
|
-
"moduleResolution": "NodeNext",
|
|
7
|
-
"strict": true,
|
|
8
|
+
"types": ["node"],
|
|
8
9
|
"noEmit": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
9
11
|
"esModuleInterop": true,
|
|
10
12
|
"forceConsistentCasingInFileNames": true,
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
+
"noUncheckedIndexedAccess": true,
|
|
14
|
+
"noImplicitOverride": true,
|
|
15
|
+
"exactOptionalPropertyTypes": true,
|
|
16
|
+
"allowImportingTsExtensions": true
|
|
13
17
|
},
|
|
14
|
-
"include": ["
|
|
18
|
+
"include": ["server.ts", "src", "test", "scripts"]
|
|
15
19
|
}
|
package/ARCHITECTURE.md
DELETED
|
@@ -1,230 +0,0 @@
|
|
|
1
|
-
# Architecture
|
|
2
|
-
|
|
3
|
-
Version 0.3.0 · September 2026
|
|
4
|
-
|
|
5
|
-
This is the design and its reasoning. [docs/MODEL.md](./docs/MODEL.md) is the
|
|
6
|
-
operator's view — where things are stored and how to reach them.
|
|
7
|
-
[docs/ROADMAP.md](./docs/ROADMAP.md) is what is left.
|
|
8
|
-
|
|
9
|
-
## Intent
|
|
10
|
-
|
|
11
|
-
A Thread Page is a small application an agent writes for one task, for one
|
|
12
|
-
person to read and answer from.
|
|
13
|
-
|
|
14
|
-
Chat is a poor medium for the moments that matter: a comparison, a diagram, a set
|
|
15
|
-
of choices, a thing only the user can decide. Those want a page. But a *fixed*
|
|
16
|
-
page — a dashboard with slots — is worse than chat, because the shape of what
|
|
17
|
-
needs saying changes with every task.
|
|
18
|
-
|
|
19
|
-
So the plugin supplies no design. It supplies a secure host for a document the
|
|
20
|
-
agent writes freshly each time, and a way for that document to talk back.
|
|
21
|
-
|
|
22
|
-
Three properties follow, and everything else is downstream of them:
|
|
23
|
-
|
|
24
|
-
1. **The page is a file the agent edits directly.** Not a template it fills, not
|
|
25
|
-
an API it posts to. Saving is publishing.
|
|
26
|
-
2. **The agent's contract stays small.** One command, a short instruction, and
|
|
27
|
-
an optional guide it fetches only when the page needs more than prose.
|
|
28
|
-
3. **Page code is untrusted.** It is generated, so it is sandboxed and given
|
|
29
|
-
narrow named capabilities rather than credentials.
|
|
30
|
-
|
|
31
|
-
## The shape
|
|
32
|
-
|
|
33
|
-
```
|
|
34
|
-
~/.bb/thread-storage/<threadId>/thread-page.html the page
|
|
35
|
-
/thread-page-assets/ what it shows
|
|
36
|
-
/thread-page-uploads/ what the user sent
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
An agent runs `bb thread-page init`, gets that path, and edits the file. The
|
|
40
|
-
plugin serves it at a stable URL on the bb origin the user is already
|
|
41
|
-
authenticated to — so the same link works on a laptop and a phone with no extra
|
|
42
|
-
port, tunnel, or password.
|
|
43
|
-
|
|
44
|
-
Nothing else is stored. Two small values live in bb's existing key-value table: a
|
|
45
|
-
signing key, so open browser sessions survive a plugin reload, and a best-effort
|
|
46
|
-
last-good copy per page, so a page still opens read-only when its machine is
|
|
47
|
-
offline. There is no plugin database and no background service.
|
|
48
|
-
|
|
49
|
-
## Trust boundary
|
|
50
|
-
|
|
51
|
-
The page is generated code, so it is treated as hostile.
|
|
52
|
-
|
|
53
|
-
**Inside the iframe** — `sandbox="allow-scripts allow-forms"`, opaque origin.
|
|
54
|
-
Arbitrary HTML, CSS and JavaScript are allowed *because* the frame has no bb
|
|
55
|
-
cookie, no mutation token, no parent DOM, no `localStorage`, no raw bb API, no
|
|
56
|
-
CLI, and no filesystem access. CSP blocks ordinary `fetch` and subresources.
|
|
57
|
-
Page-authored code can therefore be as creative as the task needs without that
|
|
58
|
-
creativity being a security question.
|
|
59
|
-
|
|
60
|
-
**Outside the iframe** — plugin-authored code on the bb origin. It holds the
|
|
61
|
-
action token, makes the same-origin calls, owns navigation, and renders
|
|
62
|
-
confirmations. The document URL carries render authority only, so page code never
|
|
63
|
-
sees a credential that can change anything.
|
|
64
|
-
|
|
65
|
-
**Between them** — one `MessagePort` and a fixed capability list. Each capability
|
|
66
|
-
has its own validator, size and depth limits, effect class, and output
|
|
67
|
-
projection. There is no generic "call bb" method, no path parameter, and no
|
|
68
|
-
provider passthrough.
|
|
69
|
-
|
|
70
|
-
### Confirmed effects
|
|
71
|
-
|
|
72
|
-
Anything that reaches outside the current thread requires a confirmation the page
|
|
73
|
-
cannot fake or word:
|
|
74
|
-
|
|
75
|
-
1. The page invokes the method.
|
|
76
|
-
2. The server refuses once, returning a signed challenge that carries **its own**
|
|
77
|
-
summary, derived from validated parameters.
|
|
78
|
-
3. The trusted shell shows that summary in a dialog the sandbox cannot draw over.
|
|
79
|
-
4. The server verifies the signature before acting.
|
|
80
|
-
|
|
81
|
-
The challenge is bound to one request id, method, parameter fingerprint, page
|
|
82
|
-
revision and thread, and expires in two minutes. So it cannot be forged, replayed,
|
|
83
|
-
or reused to approve different parameters — a page cannot get "message thread A"
|
|
84
|
-
approved and then quietly reuse it for thread B.
|
|
85
|
-
|
|
86
|
-
### The honest limitation
|
|
87
|
-
|
|
88
|
-
Page JavaScript can navigate its own frame and encode data in the destination
|
|
89
|
-
URL. Browser sandbox flags do not close that channel, and CSP resource directives
|
|
90
|
-
do not either. A page therefore has access to data already inside its own frame.
|
|
91
|
-
|
|
92
|
-
It grants no bb authority. Closing it entirely would mean forbidding authored
|
|
93
|
-
JavaScript and shipping a declarative renderer instead, which would cost the
|
|
94
|
-
open-page model that is the point of the product. This is a stated trade, not an
|
|
95
|
-
oversight.
|
|
96
|
-
|
|
97
|
-
## Capabilities
|
|
98
|
-
|
|
99
|
-
| Method | Effect | Confirmed |
|
|
100
|
-
| --- | --- | --- |
|
|
101
|
-
| `context.get` | read | |
|
|
102
|
-
| `thread.activity` | read | |
|
|
103
|
-
| `threads.snapshot` | read | |
|
|
104
|
-
| `projects.list` | read | |
|
|
105
|
-
| `providers.list` | read | |
|
|
106
|
-
| `storage.get` | read | |
|
|
107
|
-
| `thread.reply` | current-thread write | |
|
|
108
|
-
| `storage.set` | current-thread write | |
|
|
109
|
-
| `threads.openPage` | navigation | |
|
|
110
|
-
| `threads.openBb` | navigation | |
|
|
111
|
-
| `threads.continue` | cross-thread write | yes |
|
|
112
|
-
| `threads.spawn` | cross-thread write | yes |
|
|
113
|
-
| `projects.create` | cross-thread write | yes |
|
|
114
|
-
| `threads.archive` | destructive | yes |
|
|
115
|
-
| `threads.stop` | destructive | yes |
|
|
116
|
-
| `navigation.openExternal` | navigation | yes |
|
|
117
|
-
| `projects.browse` | device | yes |
|
|
118
|
-
| `voice.captureAndTranscribe` | device | contract only |
|
|
119
|
-
|
|
120
|
-
Adding a capability extends this list. It never requires a new page component,
|
|
121
|
-
because no page component is built in.
|
|
122
|
-
|
|
123
|
-
Two deliberate narrowings. `projects.browse` opens the host's native folder
|
|
124
|
-
picker and returns an **opaque single-use token** plus a display string — never a
|
|
125
|
-
filesystem path the page could reuse or leak; `projects.create` redeems it.
|
|
126
|
-
`storage.*` is namespaced per thread, so pages cannot read each other's state
|
|
127
|
-
despite sharing one table.
|
|
128
|
-
|
|
129
|
-
## What the plugin renders, and what it does not
|
|
130
|
-
|
|
131
|
-
The plugin owns exactly three pieces of UI, all of them chrome outside the
|
|
132
|
-
sandbox:
|
|
133
|
-
|
|
134
|
-
- the page title bar;
|
|
135
|
-
- a **Sessions** link back to the home page;
|
|
136
|
-
- a working indicator while the thread is mid-turn.
|
|
137
|
-
|
|
138
|
-
The last two are worth explaining, because both could have been pushed onto
|
|
139
|
-
agents and deliberately were not.
|
|
140
|
-
|
|
141
|
-
**The Sessions link** is chrome so that no agent spends instruction budget on it
|
|
142
|
-
and no page can forget it. The home page it points to is not special: any
|
|
143
|
-
thread's page can be designated home with `bb thread-page home`, and it is an
|
|
144
|
-
ordinary Thread Page afterwards.
|
|
145
|
-
|
|
146
|
-
**The working indicator** answers "is it still writing?". Its state rides on the
|
|
147
|
-
`x-thread-page-activity` header of the revision poll the shell already makes
|
|
148
|
-
every ten seconds — so it costs no extra request, nothing in any page's HTML, and
|
|
149
|
-
nothing in any agent's instructions. Its wording is a setting; blank hides it.
|
|
150
|
-
|
|
151
|
-
Everything else is the page's.
|
|
152
|
-
|
|
153
|
-
## The home page
|
|
154
|
-
|
|
155
|
-
`bb thread-page home` designates a thread and, if that thread has no page yet,
|
|
156
|
-
writes a session hub. The default groups sessions by project and gives each group
|
|
157
|
-
its own look.
|
|
158
|
-
|
|
159
|
-
The design point is that **a group is not a project**. A group is a label, a
|
|
160
|
-
look, and a *set* of project ids, kept in the page's own scoped storage. One
|
|
161
|
-
group per project is only the default; a project may appear in several groups,
|
|
162
|
-
and regrouping is an edit to the page rather than a schema change.
|
|
163
|
-
|
|
164
|
-
Per-group looks need no second document. The stylesheet's `data-world` attribute
|
|
165
|
-
re-resolves every design token for a subtree, so a group can carry a different
|
|
166
|
-
palette, typeface, shape language and button style inside one page. Separate
|
|
167
|
-
linked pages remain possible through `threads.openPage`; they were not necessary.
|
|
168
|
-
|
|
169
|
-
## The design system
|
|
170
|
-
|
|
171
|
-
Five worlds — `paper`, `terminal`, `atrium`, `volume`, `bloom` — each declaring
|
|
172
|
-
both light and dark palettes at once, with one resolver publishing the live half
|
|
173
|
-
onto the tokens the rest of the sheet uses. A page picks one with `data-theme` on
|
|
174
|
-
`<html>`, plus `data-mode` and `data-atmos`.
|
|
175
|
-
|
|
176
|
-
It needs no class names: every rule keys off semantic structure, so plain HTML is
|
|
177
|
-
already styled. Three class names exist for things structure cannot express —
|
|
178
|
-
`.card`, `.needs-you`, `.label`.
|
|
179
|
-
|
|
180
|
-
**The stylesheet travels inside each page** rather than being injected at render
|
|
181
|
-
time. That means an agent can change any rule for one page, and a plugin update
|
|
182
|
-
can never restyle a page the user has already read. The cost is that improvements
|
|
183
|
-
to the design system only reach pages created afterwards; that trade favours the
|
|
184
|
-
reader.
|
|
185
|
-
|
|
186
|
-
A page may add one more `<style>` with two rules: everything inside
|
|
187
|
-
`@scope (main)`, and colour and shape from `var(--token)` only. The second is
|
|
188
|
-
what keeps a bespoke chart correct in all five worlds and in dark mode.
|
|
189
|
-
|
|
190
|
-
## Instructions
|
|
191
|
-
|
|
192
|
-
Three, and only the first is loaded per session.
|
|
193
|
-
|
|
194
|
-
1. **The contract** — why the page matters, how to ask well, what belongs on a
|
|
195
|
-
page. Injected into eligible root threads. It holds only what changes
|
|
196
|
-
behaviour; every mechanical convention is absorbed by the runtime or the seed.
|
|
197
|
-
2. **The seed comment** — the class names, the theme attributes, the escape-hatch
|
|
198
|
-
rule. Free, because the agent is already reading the file.
|
|
199
|
-
3. **The guide** — `bb thread-page guide`. Unbounded, and paid for only by the
|
|
200
|
-
sessions that open it.
|
|
201
|
-
|
|
202
|
-
There is no skill and no agent tool. Both would put the plugin in every session's
|
|
203
|
-
context whether or not the task needs it.
|
|
204
|
-
|
|
205
|
-
## Eligibility
|
|
206
|
-
|
|
207
|
-
Only threads a user started get a page. Children, forks and hidden workers get
|
|
208
|
-
`SKIP` and answer in chat, because they are not the ones being talked to. `init`
|
|
209
|
-
rechecks this at call time rather than trusting the injected instruction.
|
|
210
|
-
|
|
211
|
-
## Testing
|
|
212
|
-
|
|
213
|
-
93 tests over three suites, none needing a browser:
|
|
214
|
-
|
|
215
|
-
- `bridge.test.ts` — the capability contract in isolation: validators, limits,
|
|
216
|
-
effect classes, confirmation binding. No SDK, DOM, or filesystem.
|
|
217
|
-
- `page.test.ts` — HTML parsing against malformed and adversarial documents,
|
|
218
|
-
kernel ordering, sandbox invariants, form and label derivation.
|
|
219
|
-
- `server.test.ts` — routes, tokens, capabilities, uploads, assets, home, and the
|
|
220
|
-
working state, against a fake host.
|
|
221
|
-
|
|
222
|
-
Browser verification is done by hand for the things tests cannot see. Several
|
|
223
|
-
real defects were found only that way — an invalid CSP source that silently
|
|
224
|
-
blocked every relative asset, an auth rule that rejected raw upload bodies, a
|
|
225
|
-
`<select>`'s options leaking into an answer label, and a snapshot flag that
|
|
226
|
-
selected archived threads instead of adding them. Each has a test now.
|
|
227
|
-
|
|
228
|
-
The gap worth naming: there is no hostile-page corpus yet. The confirmation flow
|
|
229
|
-
is tested against forgery, replay and parameter-swapping, but no test plays an
|
|
230
|
-
attacker trying to reach the parent frame or steal a cookie.
|
package/PLUGIN_OVERVIEW.md
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
# Thread Pages
|
|
2
|
-
|
|
3
|
-
Give every bb thread its own web page — written by the agent, for that one task.
|
|
4
|
-
|
|
5
|
-
## What it does
|
|
6
|
-
|
|
7
|
-
An agent working on a task usually has more to tell you than chat can carry: a
|
|
8
|
-
comparison, a diagram, a set of choices, a thing only you can decide. Thread
|
|
9
|
-
Pages gives it a real page to say it on.
|
|
10
|
-
|
|
11
|
-
The agent runs one command, gets an HTML file, and edits it directly. Saving the
|
|
12
|
-
file publishes it. You open one stable link — in bb, in a browser, on your
|
|
13
|
-
phone — read the page, and answer from inside it. Your answer arrives as the
|
|
14
|
-
agent's next message.
|
|
15
|
-
|
|
16
|
-
The page is a complete HTML document the agent writes for the task at hand. Not
|
|
17
|
-
a template with slots. If the task needs a chart, it writes a chart. If it needs
|
|
18
|
-
an eight-screen wizard, a diagram you click, or three separate forms, it writes
|
|
19
|
-
that. The plugin supplies the secure host, never the design.
|
|
20
|
-
|
|
21
|
-
## Why it is built this way
|
|
22
|
-
|
|
23
|
-
**Nothing to learn, nothing to load.** There is no skill in the agent's context,
|
|
24
|
-
no page tool, no publish protocol, no runtime copied into your files. The
|
|
25
|
-
standing instruction is two sentences. Deeper guidance is one optional command
|
|
26
|
-
away, so a simple task never pays for it.
|
|
27
|
-
|
|
28
|
-
**The page is a file.** It lives in the thread's own storage as ordinary HTML.
|
|
29
|
-
The agent reads and writes it with the tools it already has. You can open it,
|
|
30
|
-
diff it, or keep it.
|
|
31
|
-
|
|
32
|
-
**Your bb, your page.** Pages are served through the bb origin you are already
|
|
33
|
-
authenticated to, so they work over bb Connect and on mobile without exposing a
|
|
34
|
-
second port or a public URL.
|
|
35
|
-
|
|
36
|
-
**Safe by construction.** Page code runs in an opaque-origin sandbox with no bb
|
|
37
|
-
cookie, no mutation token, no parent DOM, no raw API, and no general network
|
|
38
|
-
access. Anything the page can ask bb to do goes through one narrow, validated
|
|
39
|
-
capability at a time.
|
|
40
|
-
|
|
41
|
-
## In the page
|
|
42
|
-
|
|
43
|
-
- Any HTML, CSS, and JavaScript the task needs, including Web Components, SVG,
|
|
44
|
-
canvas, and multi-screen state.
|
|
45
|
-
- Forms that reply to the thread with no code at all — blank answers included,
|
|
46
|
-
several forms at once, each with its own state.
|
|
47
|
-
- File attachments, stored beside the thread and handed to the agent by path.
|
|
48
|
-
- Images, stylesheets, fonts, and data from a confined per-thread asset folder.
|
|
49
|
-
- Live thread activity, and a working indicator while the agent is mid-turn.
|
|
50
|
-
- Listing, messaging, starting, stopping and archiving sessions from a page.
|
|
51
|
-
- Update protection, so a page reload never eats what you were typing.
|
|
52
|
-
- A read-only cached copy when the source machine goes offline.
|
|
53
|
-
|
|
54
|
-
## Getting started
|
|
55
|
-
|
|
56
|
-
Install the plugin, then turn on **Agent initialization hint** in its settings
|
|
57
|
-
to have new sessions use their page automatically. Or leave it off and ask any
|
|
58
|
-
agent to run `bb thread-page init`.
|
|
59
|
-
|
|
60
|
-
The page seed and the instruction text are both settings, so you can change what
|
|
61
|
-
every future page starts from without touching any existing page.
|
|
62
|
-
|
|
63
|
-
## The home page
|
|
64
|
-
|
|
65
|
-
`bb thread-page home` writes a session hub grouped by project, each group with
|
|
66
|
-
its own look, and every other page then shows a **← Sessions** link back to it.
|
|
67
|
-
|
|
68
|
-
Home is an ordinary page afterwards — ask the agent that owns it to regroup or
|
|
69
|
-
restyle it. Groups are not tied to projects: a group is a label, a look, and a
|
|
70
|
-
set of projects, so "Work" and "Side projects" are as valid as one-per-project,
|
|
71
|
-
and a project can appear in both.
|
|
72
|
-
|
|
73
|
-
## Current state
|
|
74
|
-
|
|
75
|
-
Everything needed for daily use is implemented and verified in a browser: page
|
|
76
|
-
authoring, forms, attachments, confined assets, live activity, the session hub,
|
|
77
|
-
and the full capability set with trusted confirmations. Voice dictation has a
|
|
78
|
-
defined contract but no handler yet.
|
|
79
|
-
|
|
80
|
-
One limitation worth stating plainly: page JavaScript can navigate its own frame
|
|
81
|
-
and put data in that URL. Browsers cannot prevent this while still allowing page
|
|
82
|
-
scripts. It grants no bb authority, but a page you did not write is still code
|
|
83
|
-
you are choosing to run.
|