@unifedev/thread-pages 0.3.2 → 1.1.0
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 +11779 -11801
- package/dist/server.meta.json +2 -2
- package/docs/B1-OWN-FILES.md +133 -0
- package/docs/FOR-PAGE-AUTHORS-1.1.md +167 -0
- package/docs/UPGRADING.md +53 -0
- package/package.json +26 -18
- package/server.ts +3 -2175
- package/src/agent/cli.ts +193 -0
- package/src/agent/guide.ts +450 -0
- package/src/agent/instruction.ts +59 -0
- package/src/agent/seed/seed.ts +73 -0
- package/{theme.ts → src/agent/seed/theme-css.ts} +9 -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 +113 -0
- package/src/domain/capabilities/registry.ts +48 -0
- package/src/domain/capabilities/renamed.ts +34 -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 +98 -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/inline.ts +277 -0
- package/src/pages/layout.ts +65 -0
- package/src/pages/page-store.ts +170 -0
- package/src/pages/site.ts +36 -0
- package/src/plugin.ts +81 -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,23 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import { failure } from "../domain/capabilities/protocol.ts";
|
|
3
|
+
import { PageError } from "../domain/errors.ts";
|
|
4
|
+
import { LIMITS } from "../domain/limits.ts";
|
|
5
|
+
import { readJsonBody } from "./action-request.ts";
|
|
6
|
+
import type { createDispatcher } from "./bridge/dispatcher.ts";
|
|
7
|
+
import { jsonResponse } from "./responses.ts";
|
|
8
|
+
|
|
9
|
+
/** `POST /bridge` — one capability invocation. */
|
|
10
|
+
export function bridgeRoute(dispatch: ReturnType<typeof createDispatcher>) {
|
|
11
|
+
return async (context: Context): Promise<Response> => {
|
|
12
|
+
let body: unknown;
|
|
13
|
+
try {
|
|
14
|
+
body = await readJsonBody(context, LIMITS.capabilityPayloadBytes + 8_192);
|
|
15
|
+
} catch (error) {
|
|
16
|
+
const failed = PageError.is(error) ? error : new PageError("invalid_json", "Invalid bridge body");
|
|
17
|
+
const code = failed.code === "request_too_large" ? "request_too_large" : "invalid_json";
|
|
18
|
+
return jsonResponse({ response: failure(undefined, code, failed.message) }, failed.status);
|
|
19
|
+
}
|
|
20
|
+
const outcome = await dispatch(body);
|
|
21
|
+
return jsonResponse(outcome.body, outcome.status);
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { CapabilityRegistry } from "../domain/capabilities/registry.ts";
|
|
2
|
+
import type { RateLimiter } from "../domain/rate-limit.ts";
|
|
3
|
+
import type { OutcomeMemory } from "../domain/submissions/idempotency.ts";
|
|
4
|
+
import type { LiveSettings } from "../config/settings.ts";
|
|
5
|
+
import type { SessionHost } from "../host/contract.ts";
|
|
6
|
+
import type { PageStore } from "../pages/page-store.ts";
|
|
7
|
+
import type { SiteStrategy } from "../pages/site.ts";
|
|
8
|
+
import type { SelectionStore } from "./bridge/selection-store.ts";
|
|
9
|
+
|
|
10
|
+
/** Everything a route or handler may need, assembled once by the composition root. */
|
|
11
|
+
export interface ServingContext {
|
|
12
|
+
readonly host: SessionHost;
|
|
13
|
+
readonly pages: PageStore;
|
|
14
|
+
readonly settings: LiveSettings;
|
|
15
|
+
readonly signingKey: Uint8Array;
|
|
16
|
+
readonly site: SiteStrategy;
|
|
17
|
+
readonly routeBase: string;
|
|
18
|
+
readonly registry: CapabilityRegistry;
|
|
19
|
+
readonly rate: RateLimiter;
|
|
20
|
+
readonly submissions: OutcomeMemory<{ status: number; body: Record<string, unknown> }>;
|
|
21
|
+
readonly replies: OutcomeMemory<{ delivery: "started" | "queued" | "steered" }>;
|
|
22
|
+
readonly selections: SelectionStore;
|
|
23
|
+
/** The host application's URL for a session's conversation, origin-relative. */
|
|
24
|
+
readonly hostSessionUrl: (session: string) => string;
|
|
25
|
+
readonly now: () => number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function pageUrl(routeBase: string, session: string): string {
|
|
29
|
+
return `${routeBase}/page?session=${encodeURIComponent(session)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function homeUrl(routeBase: string): string {
|
|
33
|
+
return `${routeBase}/home`;
|
|
34
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import { injectKernel } from "../domain/html/document.ts";
|
|
3
|
+
import { etagFor, ifNoneMatchMatches } from "../domain/revision.ts";
|
|
4
|
+
import { KERNEL_RUNTIME } from "../generated/kernel-runtime.ts";
|
|
5
|
+
import type { KernelConfig } from "../runtime/shared/protocol.ts";
|
|
6
|
+
import type { ServingContext } from "./context.ts";
|
|
7
|
+
import { baseHeaders, documentCsp, failureResponse } from "./responses.ts";
|
|
8
|
+
import { eligibleSession, sessionIdFrom } from "./session-access.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `GET /document?session=<id>` — the entry document with the kernel injected,
|
|
12
|
+
* and the shell's revision poll: the ETag is the revision, the activity and
|
|
13
|
+
* source state ride along as headers. spec R2.11–R2.13, R2.17, R2.25
|
|
14
|
+
*/
|
|
15
|
+
export function documentRoute(serving: ServingContext) {
|
|
16
|
+
return async (context: Context): Promise<Response> => {
|
|
17
|
+
try {
|
|
18
|
+
const id = sessionIdFrom(context);
|
|
19
|
+
const session = await eligibleSession(serving, id);
|
|
20
|
+
const page = await serving.pages.load(id);
|
|
21
|
+
const headers = baseHeaders("text/html; charset=utf-8");
|
|
22
|
+
headers.set("content-security-policy", documentCsp());
|
|
23
|
+
headers.set("etag", etagFor(page.revision));
|
|
24
|
+
headers.set("x-thread-page-stale", String(page.stale));
|
|
25
|
+
headers.set("x-thread-page-activity", session.state);
|
|
26
|
+
headers.set("x-thread-page-updated-at", String(page.updatedAtMs));
|
|
27
|
+
if (ifNoneMatchMatches(context.req.header("if-none-match"), etagFor(page.revision))) {
|
|
28
|
+
return new Response(null, { status: 304, headers });
|
|
29
|
+
}
|
|
30
|
+
const config: KernelConfig = { pageRevision: page.revision, stale: page.stale };
|
|
31
|
+
const html = injectKernel(page.html, { kernel: KERNEL_RUNTIME, config, baseHref: serving.site.baseHref(id) });
|
|
32
|
+
return new Response(html, { status: 200, headers });
|
|
33
|
+
} catch (error) {
|
|
34
|
+
return failureResponse(error, serving.host.log, "GET /document", true);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import { isSessionId } from "../domain/ids.ts";
|
|
3
|
+
import type { ServingContext } from "./context.ts";
|
|
4
|
+
import { pageUrl } from "./context.ts";
|
|
5
|
+
import { errorPage } from "./responses.ts";
|
|
6
|
+
|
|
7
|
+
/** `GET /home` — a redirect to the designated home page, never a listing. spec R7.9, R7.4 */
|
|
8
|
+
export function homeRoute(serving: ServingContext) {
|
|
9
|
+
return async (_context: Context): Promise<Response> => {
|
|
10
|
+
const home = serving.settings.current().homeSessionId;
|
|
11
|
+
if (!isSessionId(home)) {
|
|
12
|
+
return errorPage(
|
|
13
|
+
"No home page is set yet. A home page is an ordinary page some agent built and designated. To get one, ask any agent: “Set up my Thread Pages home page” — it runs `bb thread-page home` in a session dedicated to it and builds a hub of your sessions there. Or run `bb thread-page home` yourself in the session whose page should be home.",
|
|
14
|
+
404,
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
const session = await serving.host.sessions.get(home).catch(() => null);
|
|
18
|
+
if (!session || session.deleted || session.archived) {
|
|
19
|
+
return errorPage("The home page points at a session that no longer exists. Run `bb thread-page home` in another session, or `bb thread-page home --clear`.", 404);
|
|
20
|
+
}
|
|
21
|
+
return new Response(null, { status: 302, headers: { location: pageUrl(serving.routeBase, home), "cache-control": "no-store, max-age=0" } });
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { PageError, errorText } from "../domain/errors.ts";
|
|
2
|
+
import { escapeHtml } from "../domain/html/escape.ts";
|
|
3
|
+
import type { HostLogger } from "../host/types.ts";
|
|
4
|
+
|
|
5
|
+
/** Response helpers and the two content security policies. spec R2.41–R2.43, 03 */
|
|
6
|
+
|
|
7
|
+
export function baseHeaders(contentType: string): Headers {
|
|
8
|
+
return new Headers({
|
|
9
|
+
"cache-control": "no-store, max-age=0",
|
|
10
|
+
"content-type": contentType,
|
|
11
|
+
"referrer-policy": "no-referrer",
|
|
12
|
+
"x-content-type-options": "nosniff",
|
|
13
|
+
"permissions-policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=()",
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The trusted shell: nothing runs but our nonced script and style. */
|
|
18
|
+
export function shellCsp(nonce: string): string {
|
|
19
|
+
return [
|
|
20
|
+
"default-src 'none'",
|
|
21
|
+
"base-uri 'none'",
|
|
22
|
+
"connect-src 'self'",
|
|
23
|
+
"form-action 'none'",
|
|
24
|
+
"frame-ancestors 'self'",
|
|
25
|
+
"frame-src 'self'",
|
|
26
|
+
`script-src 'nonce-${nonce}'`,
|
|
27
|
+
`style-src 'nonce-${nonce}'`,
|
|
28
|
+
"img-src 'self' data:",
|
|
29
|
+
].join("; ");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The document: open network and arbitrary code (spec R3.4, R3.11), no
|
|
34
|
+
* frames (embedding is deferred), no plugins, same-origin bases only, and
|
|
35
|
+
* the sandbox restated so it holds even if the frame attribute did not.
|
|
36
|
+
*/
|
|
37
|
+
export function documentCsp(): string {
|
|
38
|
+
return [
|
|
39
|
+
"default-src * data: blob: 'unsafe-inline' 'unsafe-eval'",
|
|
40
|
+
"script-src * data: blob: 'unsafe-inline' 'unsafe-eval'",
|
|
41
|
+
"style-src * data: blob: 'unsafe-inline'",
|
|
42
|
+
"img-src * data: blob:",
|
|
43
|
+
"font-src * data: blob:",
|
|
44
|
+
"media-src * data: blob:",
|
|
45
|
+
"connect-src * data: blob:",
|
|
46
|
+
"worker-src * blob: data:",
|
|
47
|
+
"frame-src 'none'",
|
|
48
|
+
"child-src blob:",
|
|
49
|
+
"object-src 'none'",
|
|
50
|
+
"base-uri 'self'",
|
|
51
|
+
"sandbox allow-scripts allow-forms",
|
|
52
|
+
].join("; ");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function jsonResponse(value: unknown, status = 200, extra?: Record<string, string>): Response {
|
|
56
|
+
const headers = baseHeaders("application/json; charset=utf-8");
|
|
57
|
+
for (const [key, entry] of Object.entries(extra ?? {})) headers.set(key, entry);
|
|
58
|
+
return new Response(JSON.stringify(value), { status, headers });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function errorJson(error: PageError): Response {
|
|
62
|
+
return jsonResponse({ ok: false, code: error.code, message: error.message }, error.status);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function errorPage(message: string, status: number): Response {
|
|
66
|
+
const headers = baseHeaders("text/html; charset=utf-8");
|
|
67
|
+
headers.set("content-security-policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox");
|
|
68
|
+
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Thread Page</title><style>body{max-width:42rem;margin:4rem auto;padding:0 1rem;font:16px/1.5 system-ui,sans-serif;color:CanvasText;background:Canvas}h1{font-size:1.4rem}</style></head><body><main><h1>Thread Page</h1><p>${escapeHtml(message)}</p></main></body></html>`;
|
|
69
|
+
return new Response(html, { status, headers });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Turns any failure into a response, logging the cause when it is not a page error. */
|
|
73
|
+
export function failureResponse(error: unknown, log: HostLogger, where: string, asPage: boolean): Response {
|
|
74
|
+
if (PageError.is(error)) {
|
|
75
|
+
if (error.cause !== undefined) log.warn(`${where}: ${error.code}: ${errorText(error.cause)}`);
|
|
76
|
+
return asPage ? errorPage(error.message, error.status) : errorJson(error);
|
|
77
|
+
}
|
|
78
|
+
log.warn(`${where}: ${errorText(error)}`);
|
|
79
|
+
const generic = new PageError("handler_error", "Something went wrong serving this page.");
|
|
80
|
+
return asPage ? errorPage(generic.message, 500) : errorJson(generic);
|
|
81
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { BbPluginApi } from "@get-bb/plugin-sdk";
|
|
2
|
+
import { createDispatcher } from "./bridge/dispatcher.ts";
|
|
3
|
+
import { ALL_HANDLERS } from "./bridge/handlers/index.ts";
|
|
4
|
+
import { bridgeRoute } from "./bridge-route.ts";
|
|
5
|
+
import type { ServingContext } from "./context.ts";
|
|
6
|
+
import { documentRoute } from "./document-route.ts";
|
|
7
|
+
import { homeRoute } from "./home-route.ts";
|
|
8
|
+
import { shellRoute } from "./shell-route.ts";
|
|
9
|
+
import { submitRoute } from "./submit-route.ts";
|
|
10
|
+
import { uploadRoute } from "./upload-route.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The route table. spec 02 §Routes
|
|
14
|
+
*
|
|
15
|
+
* Every route uses the host's "local" auth: the reader's existing session
|
|
16
|
+
* with bb, plus the action token where an effect is possible.
|
|
17
|
+
*/
|
|
18
|
+
export function registerRoutes(bb: BbPluginApi, serving: ServingContext): void {
|
|
19
|
+
const dispatch = createDispatcher(serving, ALL_HANDLERS);
|
|
20
|
+
bb.http.route("GET", "/page", shellRoute(serving), { auth: "local" });
|
|
21
|
+
bb.http.route("GET", "/document", documentRoute(serving), { auth: "local" });
|
|
22
|
+
bb.http.route("GET", "/home", homeRoute(serving), { auth: "local" });
|
|
23
|
+
bb.http.route("POST", "/submit", submitRoute(serving), { auth: "local" });
|
|
24
|
+
bb.http.route("POST", "/upload", uploadRoute(serving), { auth: "local" });
|
|
25
|
+
bb.http.route("POST", "/bridge", bridgeRoute(dispatch), { auth: "local" });
|
|
26
|
+
}
|
|
@@ -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
|
}
|