@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,109 @@
|
|
|
1
|
+
import { defaultTreeAdapter, parse as parseHtml, serialize as serializeHtml, type DefaultTreeAdapterTypes } from "parse5";
|
|
2
|
+
import { escapeHtml } from "./escape.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Kernel injection. spec R4.1–R4.4
|
|
6
|
+
*
|
|
7
|
+
* The authored document is parsed the way a browser parses it, and two
|
|
8
|
+
* nodes at most are inserted at the very front of `<head>` (or the first
|
|
9
|
+
* element that can hold them): an optional `<base>` for the site strategy,
|
|
10
|
+
* then the kernel script with its configuration in a data attribute. Nothing
|
|
11
|
+
* else changes — no reformatting, no reordering, no rewritten URLs.
|
|
12
|
+
*/
|
|
13
|
+
export interface InjectionOptions {
|
|
14
|
+
/** The kernel runtime source (an IIFE). */
|
|
15
|
+
readonly kernel: string;
|
|
16
|
+
/** JSON-serialisable kernel configuration, carried in `data-config`. */
|
|
17
|
+
readonly config: unknown;
|
|
18
|
+
/** Same-origin base URL to inject, or null. */
|
|
19
|
+
readonly baseHref: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type HtmlDocument = DefaultTreeAdapterTypes.Document;
|
|
23
|
+
type HtmlElement = DefaultTreeAdapterTypes.Element;
|
|
24
|
+
type HtmlParent = HtmlDocument | HtmlElement;
|
|
25
|
+
|
|
26
|
+
const XHTML = "http://www.w3.org/1999/xhtml";
|
|
27
|
+
|
|
28
|
+
export function injectKernel(source: string, options: InjectionOptions): string {
|
|
29
|
+
const authored = parseAuthored(source);
|
|
30
|
+
if (authored) {
|
|
31
|
+
return injectInto(authored, options);
|
|
32
|
+
}
|
|
33
|
+
// Not a document at all: wrap it so the reader sees something and the
|
|
34
|
+
// kernel still runs. spec R4.4
|
|
35
|
+
return wrapFragment(source, options);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function directChild(parent: HtmlParent, tagName: string): HtmlElement | null {
|
|
39
|
+
for (const child of parent.childNodes) {
|
|
40
|
+
if (defaultTreeAdapter.isElementNode(child) && child.tagName === tagName && child.namespaceURI === XHTML) {
|
|
41
|
+
return child;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface Authored {
|
|
48
|
+
document: HtmlDocument;
|
|
49
|
+
target: HtmlElement;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseAuthored(source: string): Authored | null {
|
|
53
|
+
const text = source.charCodeAt(0) === 0xfeff ? source.slice(1) : source;
|
|
54
|
+
const document = parseHtml(text, { scriptingEnabled: true, sourceCodeLocationInfo: true });
|
|
55
|
+
const html = directChild(document, "html");
|
|
56
|
+
if (!html) return null;
|
|
57
|
+
const head = directChild(html, "head");
|
|
58
|
+
const body = directChild(html, "body");
|
|
59
|
+
const frameset = directChild(html, "frameset");
|
|
60
|
+
const hasDoctype = document.childNodes.some(
|
|
61
|
+
(child) => defaultTreeAdapter.isDocumentTypeNode(child) && child.name.toLowerCase() === "html",
|
|
62
|
+
);
|
|
63
|
+
const hasAuthoredShell = [html, head, body, frameset].some((element) => element?.sourceCodeLocation != null);
|
|
64
|
+
if (!hasDoctype && !hasAuthoredShell) return null;
|
|
65
|
+
return { document, target: head ?? body ?? frameset ?? html };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function kernelElement(namespace: HtmlElement["namespaceURI"], options: InjectionOptions): HtmlElement {
|
|
69
|
+
const script = defaultTreeAdapter.createElement("script", namespace, [
|
|
70
|
+
{ name: "data-thread-page-kernel", value: "" },
|
|
71
|
+
{ name: "data-config", value: JSON.stringify(options.config) },
|
|
72
|
+
]);
|
|
73
|
+
defaultTreeAdapter.insertText(script, options.kernel);
|
|
74
|
+
return script;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function injectInto(authored: Authored, options: InjectionOptions): string {
|
|
78
|
+
const namespace = authored.target.namespaceURI;
|
|
79
|
+
const nodes: HtmlElement[] = [];
|
|
80
|
+
if (options.baseHref) {
|
|
81
|
+
nodes.push(defaultTreeAdapter.createElement("base", namespace, [{ name: "href", value: options.baseHref }]));
|
|
82
|
+
}
|
|
83
|
+
nodes.push(kernelElement(namespace, options));
|
|
84
|
+
const anchor = defaultTreeAdapter.getFirstChild(authored.target);
|
|
85
|
+
for (const node of nodes) {
|
|
86
|
+
if (anchor) defaultTreeAdapter.insertBefore(authored.target, node, anchor);
|
|
87
|
+
else defaultTreeAdapter.appendChild(authored.target, node);
|
|
88
|
+
}
|
|
89
|
+
return serializeHtml(authored.document);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function wrapFragment(source: string, options: InjectionOptions): string {
|
|
93
|
+
const base = options.baseHref ? `<base href="${escapeHtml(options.baseHref)}">\n` : "";
|
|
94
|
+
const config = escapeHtml(JSON.stringify(options.config));
|
|
95
|
+
return `<!doctype html>
|
|
96
|
+
<html lang="en">
|
|
97
|
+
<head>
|
|
98
|
+
<meta charset="utf-8">
|
|
99
|
+
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
100
|
+
<meta name="color-scheme" content="light dark">
|
|
101
|
+
<title>Thread Page</title>
|
|
102
|
+
${base}<script data-thread-page-kernel data-config="${config}">${options.kernel}</script>
|
|
103
|
+
<style>body{max-width:44rem;margin:2rem auto;padding:0 1rem;font:16px/1.55 system-ui,sans-serif;color:CanvasText;background:Canvas}</style>
|
|
104
|
+
</head>
|
|
105
|
+
<body>
|
|
106
|
+
${source}
|
|
107
|
+
</body>
|
|
108
|
+
</html>`;
|
|
109
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function escapeHtml(value: string): string {
|
|
2
|
+
return value
|
|
3
|
+
.replaceAll("&", "&")
|
|
4
|
+
.replaceAll("<", "<")
|
|
5
|
+
.replaceAll(">", ">")
|
|
6
|
+
.replaceAll('"', """)
|
|
7
|
+
.replaceAll("'", "'");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** JSON that is safe inside an inline `<script>`: no `<`, no line separators. */
|
|
11
|
+
export function jsonForScript(value: unknown): string {
|
|
12
|
+
return JSON.stringify(value)
|
|
13
|
+
.replace(/</g, "\\u003c")
|
|
14
|
+
.replace(/\u2028/g, "\\u2028")
|
|
15
|
+
.replace(/\u2029/g, "\\u2029");
|
|
16
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Identifier shapes accepted at every boundary. */
|
|
2
|
+
|
|
3
|
+
const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{2,127}$/;
|
|
4
|
+
const ENTITY_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
5
|
+
const REQUEST_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,95}$/;
|
|
6
|
+
const METHOD_NAME = /^[a-z][a-zA-Z0-9]*(?:\.[a-z][a-zA-Z0-9]*)+$/;
|
|
7
|
+
const REVISION = /^[a-f0-9]{64}$/;
|
|
8
|
+
const OPAQUE_TOKEN = /^[A-Za-z0-9][A-Za-z0-9._~:-]{0,511}$/;
|
|
9
|
+
const STORAGE_KEY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
10
|
+
|
|
11
|
+
export function isSessionId(value: unknown): value is string {
|
|
12
|
+
return typeof value === "string" && SESSION_ID.test(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isEntityId(value: unknown): value is string {
|
|
16
|
+
return typeof value === "string" && ENTITY_ID.test(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isRequestId(value: unknown): value is string {
|
|
20
|
+
return typeof value === "string" && REQUEST_ID.test(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isMethodName(value: unknown): value is string {
|
|
24
|
+
return typeof value === "string" && value.length <= 96 && METHOD_NAME.test(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isRevision(value: unknown): value is string {
|
|
28
|
+
return typeof value === "string" && REVISION.test(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isOpaqueToken(value: unknown): value is string {
|
|
32
|
+
return typeof value === "string" && OPAQUE_TOKEN.test(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isStorageKey(value: unknown): value is string {
|
|
36
|
+
return typeof value === "string" && STORAGE_KEY.test(value);
|
|
37
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { JsonValue } from "./strict-json.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Canonical serialisation: object keys sorted at every level, so reordering
|
|
6
|
+
* keys cannot change a fingerprint. spec R3.20
|
|
7
|
+
*/
|
|
8
|
+
export function canonicalJson(value: JsonValue): string {
|
|
9
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
10
|
+
if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
|
|
11
|
+
return `{${Object.keys(value)
|
|
12
|
+
.sort()
|
|
13
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key] as JsonValue)}`)
|
|
14
|
+
.join(",")}}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function fingerprint(value: JsonValue): string {
|
|
18
|
+
return createHash("sha256").update(canonicalJson(value), "utf8").digest("hex");
|
|
19
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { LIMITS } from "../limits.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A value that survives a JSON round trip without loss, and the checks that
|
|
5
|
+
* establish it: no cycles, accessors, symbols, sparse arrays, unsafe keys,
|
|
6
|
+
* non-finite numbers, and bounded depth, node count and serialised size.
|
|
7
|
+
* spec R5.2
|
|
8
|
+
*/
|
|
9
|
+
export type JsonPrimitive = null | boolean | number | string;
|
|
10
|
+
export type JsonObject = { [key: string]: JsonValue };
|
|
11
|
+
export type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
|
|
12
|
+
|
|
13
|
+
export type IssueCode =
|
|
14
|
+
| "invalid_type"
|
|
15
|
+
| "invalid_value"
|
|
16
|
+
| "missing_key"
|
|
17
|
+
| "unknown_key"
|
|
18
|
+
| "not_json_safe"
|
|
19
|
+
| "too_deep"
|
|
20
|
+
| "too_large";
|
|
21
|
+
|
|
22
|
+
export interface Issue {
|
|
23
|
+
readonly code: IssueCode;
|
|
24
|
+
readonly path: string;
|
|
25
|
+
readonly message: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type Validation<T> =
|
|
29
|
+
| { readonly ok: true; readonly value: T }
|
|
30
|
+
| { readonly ok: false; readonly issues: readonly Issue[] };
|
|
31
|
+
|
|
32
|
+
export function valid<T>(value: T): Validation<T> {
|
|
33
|
+
return { ok: true, value };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function invalid<T = never>(path: string, message: string, code: IssueCode = "invalid_value"): Validation<T> {
|
|
37
|
+
return { ok: false, issues: [{ code, path, message }] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface JsonLimits {
|
|
41
|
+
readonly maxBytes?: number;
|
|
42
|
+
readonly maxDepth?: number;
|
|
43
|
+
readonly maxNodes?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const UNSAFE_KEYS = new Set(["__proto__", "prototype", "constructor"]);
|
|
47
|
+
|
|
48
|
+
export function pathForKey(parent: string, key: string): string {
|
|
49
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? `${parent}.${key}` : `${parent}[${JSON.stringify(key)}]`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function utf8Bytes(value: string): number {
|
|
53
|
+
return new TextEncoder().encode(value).byteLength;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Validates without coercion and returns a detached copy of the value. */
|
|
57
|
+
export function validateJson(input: unknown, limits: JsonLimits = {}): Validation<JsonValue> {
|
|
58
|
+
const maxBytes = limits.maxBytes ?? LIMITS.capabilityPayloadBytes;
|
|
59
|
+
const maxDepth = limits.maxDepth ?? LIMITS.capabilityJsonDepth;
|
|
60
|
+
const maxNodes = limits.maxNodes ?? LIMITS.capabilityJsonNodes;
|
|
61
|
+
const ancestors = new Set<object>();
|
|
62
|
+
let nodes = 0;
|
|
63
|
+
|
|
64
|
+
function visit(value: unknown, path: string, depth: number): Issue | null {
|
|
65
|
+
nodes += 1;
|
|
66
|
+
if (nodes > maxNodes) return { code: "too_large", path, message: `JSON exceeds ${maxNodes} nodes` };
|
|
67
|
+
if (depth > maxDepth) return { code: "too_deep", path, message: `JSON exceeds depth ${maxDepth}` };
|
|
68
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return null;
|
|
69
|
+
if (typeof value === "number") {
|
|
70
|
+
return Number.isFinite(value) ? null : { code: "not_json_safe", path, message: "Numbers must be finite" };
|
|
71
|
+
}
|
|
72
|
+
if (typeof value !== "object") {
|
|
73
|
+
return { code: "not_json_safe", path, message: `Unsupported value type: ${typeof value}` };
|
|
74
|
+
}
|
|
75
|
+
if (ancestors.has(value)) return { code: "not_json_safe", path, message: "Cyclic values are not JSON-safe" };
|
|
76
|
+
ancestors.add(value);
|
|
77
|
+
try {
|
|
78
|
+
if (Array.isArray(value)) {
|
|
79
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
80
|
+
if (typeof key === "symbol") return { code: "not_json_safe", path, message: "Symbol properties are not JSON-safe" };
|
|
81
|
+
if (key !== "length" && !isCanonicalIndex(key, value.length)) {
|
|
82
|
+
return { code: "not_json_safe", path: pathForKey(path, key), message: "Arrays may not carry extra properties" };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
86
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
87
|
+
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
|
|
88
|
+
return { code: "not_json_safe", path: `${path}[${index}]`, message: "Sparse arrays and accessors are not JSON-safe" };
|
|
89
|
+
}
|
|
90
|
+
const issue = visit(descriptor.value, `${path}[${index}]`, depth + 1);
|
|
91
|
+
if (issue) return issue;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const prototype = Object.getPrototypeOf(value);
|
|
96
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
97
|
+
return { code: "not_json_safe", path, message: "Only plain objects are JSON-safe" };
|
|
98
|
+
}
|
|
99
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
100
|
+
if (typeof key === "symbol") return { code: "not_json_safe", path, message: "Symbol properties are not JSON-safe" };
|
|
101
|
+
if (UNSAFE_KEYS.has(key)) return { code: "not_json_safe", path: pathForKey(path, key), message: "Unsafe object key" };
|
|
102
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
103
|
+
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
|
|
104
|
+
return { code: "not_json_safe", path: pathForKey(path, key), message: "Entries must be enumerable data properties" };
|
|
105
|
+
}
|
|
106
|
+
const issue = visit(descriptor.value, pathForKey(path, key), depth + 1);
|
|
107
|
+
if (issue) return issue;
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
} catch {
|
|
111
|
+
return { code: "not_json_safe", path, message: "Value could not be inspected" };
|
|
112
|
+
} finally {
|
|
113
|
+
ancestors.delete(value);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const issue = visit(input, "$", 0);
|
|
118
|
+
if (issue) return { ok: false, issues: [issue] };
|
|
119
|
+
let serialized: string;
|
|
120
|
+
try {
|
|
121
|
+
serialized = JSON.stringify(input);
|
|
122
|
+
} catch {
|
|
123
|
+
return invalid("$", "Value could not be serialised", "not_json_safe");
|
|
124
|
+
}
|
|
125
|
+
if (utf8Bytes(serialized) > maxBytes) {
|
|
126
|
+
return invalid("$", `Serialised JSON exceeds ${maxBytes} bytes`, "too_large");
|
|
127
|
+
}
|
|
128
|
+
return valid(JSON.parse(serialized) as JsonValue);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isCanonicalIndex(key: string, length: number): boolean {
|
|
132
|
+
if (!/^(0|[1-9][0-9]*)$/.test(key)) return false;
|
|
133
|
+
const index = Number(key);
|
|
134
|
+
return Number.isSafeInteger(index) && index >= 0 && index < length;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function isJsonObject(value: JsonValue | undefined): value is JsonObject {
|
|
138
|
+
return value !== undefined && value !== null && typeof value === "object" && !Array.isArray(value);
|
|
139
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every numeric limit the product enforces, in one place.
|
|
3
|
+
*
|
|
4
|
+
* The authoring guide is generated from this object (spec R6.25, R6.26), so a
|
|
5
|
+
* limit that is not here is a limit an agent cannot design against. Values
|
|
6
|
+
* follow spec/09-conformance.md §Limits except where rewrite decisions
|
|
7
|
+
* (RW-8) chose otherwise; each such case is noted.
|
|
8
|
+
*/
|
|
9
|
+
export const LIMITS = Object.freeze({
|
|
10
|
+
/** Entry document (`index.html`); refused above, never truncated. R1.7 */
|
|
11
|
+
entryDocumentBytes: 5 * 1024 * 1024,
|
|
12
|
+
/** One uploaded file. R4.23 */
|
|
13
|
+
uploadFileBytes: 24 * 1024 * 1024,
|
|
14
|
+
/** Files per form submission; extras are ignored visibly. R4.23 */
|
|
15
|
+
uploadsPerForm: 8,
|
|
16
|
+
/** Submission JSON body, excluding uploaded bytes. */
|
|
17
|
+
submissionBodyBytes: 64 * 1024,
|
|
18
|
+
/** Answers per submission, and characters per answer value. */
|
|
19
|
+
answersPerSubmission: 64,
|
|
20
|
+
answerValueChars: 8_000,
|
|
21
|
+
answerListItems: 64,
|
|
22
|
+
/** Capability request and response, serialised. R5.2 */
|
|
23
|
+
capabilityPayloadBytes: 64 * 1024,
|
|
24
|
+
capabilityJsonDepth: 16,
|
|
25
|
+
capabilityJsonNodes: 10_000,
|
|
26
|
+
/** `sessions.start` and `sessions.send` prompts. */
|
|
27
|
+
promptChars: 32 * 1024,
|
|
28
|
+
/** `session.reply` result, serialised. */
|
|
29
|
+
resultTextBytes: 64 * 1024,
|
|
30
|
+
/** Titles, names and labels shown to a reader. */
|
|
31
|
+
titleChars: 240,
|
|
32
|
+
/** One `storage` value, serialised. R5.19 */
|
|
33
|
+
storageValueBytes: 32 * 1024,
|
|
34
|
+
storageKeyChars: 128,
|
|
35
|
+
/** `sessions.snapshot` page size. R5.11 */
|
|
36
|
+
snapshotDefault: 100,
|
|
37
|
+
snapshotMax: 200,
|
|
38
|
+
/** `session.activity` items. */
|
|
39
|
+
activityDefault: 8,
|
|
40
|
+
activityMax: 20,
|
|
41
|
+
/** Action token lifetime. R2.9 */
|
|
42
|
+
actionTokenMs: 2 * 60 * 60 * 1000,
|
|
43
|
+
/** Confirmation challenge lifetime. R3.19 */
|
|
44
|
+
confirmationMs: 2 * 60 * 1000,
|
|
45
|
+
/** Folder-picker selection token lifetime; single use. R5.36 */
|
|
46
|
+
selectionTokenMs: 10 * 60 * 1000,
|
|
47
|
+
selectionTokens: 32,
|
|
48
|
+
/** Submission and reply idempotency records. R2.34 */
|
|
49
|
+
idempotencyRecords: 512,
|
|
50
|
+
idempotencyMs: 5 * 60 * 1000,
|
|
51
|
+
/**
|
|
52
|
+
* Effectful requests per page. RW-8: 120/min and 8 concurrent rather than
|
|
53
|
+
* the spec's 30/4, so a page that lists sessions and refreshes cannot
|
|
54
|
+
* exhaust its own budget (R2.40). The shell's revision poll is not counted.
|
|
55
|
+
*/
|
|
56
|
+
ratePerMinute: 120,
|
|
57
|
+
rateConcurrent: 8,
|
|
58
|
+
/** Shell revision poll while the tab is visible. R2.17 */
|
|
59
|
+
shellPollMs: 10_000,
|
|
60
|
+
/** `watch` interval default and clamp. R4.30 */
|
|
61
|
+
watchDefaultMs: 8_000,
|
|
62
|
+
watchMinMs: 2_000,
|
|
63
|
+
watchMaxMs: 5 * 60 * 1000,
|
|
64
|
+
/** Offline copy of the entry document kept in the host's key-value store. R2.30 */
|
|
65
|
+
offlineCopyBytes: 200 * 1024,
|
|
66
|
+
offlineCacheEntries: 32,
|
|
67
|
+
offlineCacheBytes: 8 * 1024 * 1024,
|
|
68
|
+
/** Bridge envelope identifiers. */
|
|
69
|
+
requestIdChars: 96,
|
|
70
|
+
methodNameChars: 96,
|
|
71
|
+
tokenChars: 4_096,
|
|
72
|
+
errorMessageChars: 512,
|
|
73
|
+
summaryChars: 512,
|
|
74
|
+
/** Sizes of lists a capability may return. */
|
|
75
|
+
projectsMax: 200,
|
|
76
|
+
providersMax: 64,
|
|
77
|
+
modelsPerProvider: 64,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export type Limits = typeof LIMITS;
|
|
81
|
+
|
|
82
|
+
export function mebibytes(bytes: number): string {
|
|
83
|
+
return `${Math.round((bytes / (1024 * 1024)) * 100) / 100} MiB`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function kibibytes(bytes: number): string {
|
|
87
|
+
return `${Math.round(bytes / 1024)} KiB`;
|
|
88
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { LIMITS } from "./limits.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Per-page budget for effectful requests: accepted requests per minute and
|
|
5
|
+
* requests in flight. spec R2.38–R2.40 (numbers: RW-8)
|
|
6
|
+
*/
|
|
7
|
+
export interface RateBudget {
|
|
8
|
+
readonly perMinute: number;
|
|
9
|
+
readonly concurrent: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface Bucket {
|
|
13
|
+
windowStartedAt: number;
|
|
14
|
+
accepted: number;
|
|
15
|
+
inFlight: number;
|
|
16
|
+
touchedAt: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RateLimiter {
|
|
20
|
+
/** Returns a release function, or null when the request is refused. */
|
|
21
|
+
acquire(key: string, now: number): (() => void) | null;
|
|
22
|
+
/** For tests and status. */
|
|
23
|
+
snapshot(key: string): { accepted: number; inFlight: number } | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createRateLimiter(budget: RateBudget = { perMinute: LIMITS.ratePerMinute, concurrent: LIMITS.rateConcurrent }): RateLimiter {
|
|
27
|
+
const buckets = new Map<string, Bucket>();
|
|
28
|
+
|
|
29
|
+
function prune(now: number): void {
|
|
30
|
+
for (const [key, bucket] of buckets) {
|
|
31
|
+
if (bucket.inFlight === 0 && now - bucket.touchedAt > 5 * 60_000) buckets.delete(key);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
acquire(key, now) {
|
|
37
|
+
prune(now);
|
|
38
|
+
const bucket = buckets.get(key) ?? { windowStartedAt: now, accepted: 0, inFlight: 0, touchedAt: now };
|
|
39
|
+
if (now - bucket.windowStartedAt >= 60_000) {
|
|
40
|
+
bucket.windowStartedAt = now;
|
|
41
|
+
bucket.accepted = 0;
|
|
42
|
+
}
|
|
43
|
+
if (bucket.inFlight >= budget.concurrent || bucket.accepted >= budget.perMinute) {
|
|
44
|
+
buckets.set(key, bucket);
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
bucket.accepted += 1;
|
|
48
|
+
bucket.inFlight += 1;
|
|
49
|
+
bucket.touchedAt = now;
|
|
50
|
+
buckets.set(key, bucket);
|
|
51
|
+
let released = false;
|
|
52
|
+
return () => {
|
|
53
|
+
if (released) return;
|
|
54
|
+
released = true;
|
|
55
|
+
bucket.inFlight = Math.max(0, bucket.inFlight - 1);
|
|
56
|
+
bucket.touchedAt = Date.now();
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
snapshot(key) {
|
|
60
|
+
const bucket = buckets.get(key);
|
|
61
|
+
return bucket ? { accepted: bucket.accepted, inFlight: bucket.inFlight } : null;
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/** A page revision is the SHA-256 of the entry document's bytes. spec R2.11 */
|
|
4
|
+
export function revisionOf(content: string | Uint8Array): string {
|
|
5
|
+
const hash = createHash("sha256");
|
|
6
|
+
if (typeof content === "string") hash.update(content, "utf8");
|
|
7
|
+
else hash.update(content);
|
|
8
|
+
return hash.digest("hex");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function sha256Hex(content: string | Uint8Array): string {
|
|
12
|
+
return revisionOf(content);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The revision doubles as the entity tag. spec R2.12 */
|
|
16
|
+
export function etagFor(revision: string): string {
|
|
17
|
+
return `"${revision}"`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Whether an `If-None-Match` header names the given entity tag. */
|
|
21
|
+
export function ifNoneMatchMatches(header: string | undefined | null, etag: string): boolean {
|
|
22
|
+
if (!header) return false;
|
|
23
|
+
return header
|
|
24
|
+
.split(",")
|
|
25
|
+
.map((candidate) => candidate.trim().replace(/^W\//, ""))
|
|
26
|
+
.some((candidate) => candidate === etag || candidate === "*");
|
|
27
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { LIMITS } from "../limits.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Bounded, expiring memory of outcomes keyed by a client id. A repeat with
|
|
5
|
+
* the same fingerprint replays the first outcome; a repeat with a different
|
|
6
|
+
* fingerprint is a conflict. spec R2.33, R2.34, R5.17
|
|
7
|
+
*/
|
|
8
|
+
export type Remembered<T> =
|
|
9
|
+
| { readonly kind: "fresh"; readonly outcome: Promise<T> }
|
|
10
|
+
| { readonly kind: "replay"; readonly outcome: Promise<T> }
|
|
11
|
+
| { readonly kind: "conflict" };
|
|
12
|
+
|
|
13
|
+
interface Record<T> {
|
|
14
|
+
expiresAt: number;
|
|
15
|
+
fingerprint: string;
|
|
16
|
+
outcome: Promise<T>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface OutcomeMemory<T> {
|
|
20
|
+
remember(key: string, fingerprint: string, produce: () => Promise<T>, now: number): Remembered<T>;
|
|
21
|
+
size(): number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createOutcomeMemory<T>(options: { maxRecords?: number; ttlMs?: number } = {}): OutcomeMemory<T> {
|
|
25
|
+
const maxRecords = options.maxRecords ?? LIMITS.idempotencyRecords;
|
|
26
|
+
const ttlMs = options.ttlMs ?? LIMITS.idempotencyMs;
|
|
27
|
+
const records = new Map<string, Record<T>>();
|
|
28
|
+
|
|
29
|
+
function prune(now: number): void {
|
|
30
|
+
for (const [key, record] of records) {
|
|
31
|
+
if (record.expiresAt <= now) records.delete(key);
|
|
32
|
+
}
|
|
33
|
+
while (records.size >= maxRecords) {
|
|
34
|
+
const oldest = records.keys().next().value;
|
|
35
|
+
if (oldest === undefined) break;
|
|
36
|
+
records.delete(oldest);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
remember(key, fingerprint, produce, now) {
|
|
42
|
+
prune(now);
|
|
43
|
+
const existing = records.get(key);
|
|
44
|
+
if (existing) {
|
|
45
|
+
if (existing.fingerprint !== fingerprint) return { kind: "conflict" };
|
|
46
|
+
return { kind: "replay", outcome: existing.outcome };
|
|
47
|
+
}
|
|
48
|
+
const outcome = produce();
|
|
49
|
+
const record: Record<T> = { expiresAt: now + ttlMs, fingerprint, outcome };
|
|
50
|
+
records.set(key, record);
|
|
51
|
+
// A failed attempt must not poison the key: the next try is fresh.
|
|
52
|
+
outcome.catch(() => {
|
|
53
|
+
if (records.get(key) === record) records.delete(key);
|
|
54
|
+
});
|
|
55
|
+
return { kind: "fresh", outcome };
|
|
56
|
+
},
|
|
57
|
+
size: () => records.size,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { JsonValue } from "../json/strict-json.ts";
|
|
2
|
+
import { UPLOAD_DIR } from "../../pages/layout.ts";
|
|
3
|
+
import type { Submission } from "./parse.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The message a form submission becomes. It names itself, names the form,
|
|
7
|
+
* leads with the chosen action, presents each answer under the label the
|
|
8
|
+
* reader saw, and states blanks explicitly. spec R2.35, R2.36
|
|
9
|
+
*/
|
|
10
|
+
export function formatSubmissionMessage(submission: Submission): string {
|
|
11
|
+
const heading = submission.title.trim() || "Thread Page";
|
|
12
|
+
const sections = submission.answers.map((answer) => {
|
|
13
|
+
const label = answer.label.trim() || answer.name;
|
|
14
|
+
return `**${label}**\n${formatValue(answer.value)}`;
|
|
15
|
+
});
|
|
16
|
+
if (submission.files.length > 0) {
|
|
17
|
+
sections.push(
|
|
18
|
+
[
|
|
19
|
+
"**Attached files**",
|
|
20
|
+
...submission.files.map((file) => `- \`$BB_THREAD_STORAGE/${file.path}\` (${file.name}, ${file.sizeBytes} bytes)`),
|
|
21
|
+
`They are in the \`${UPLOAD_DIR}/\` directory of your page root; read them with your normal tools.`,
|
|
22
|
+
].join("\n"),
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return [`The user answered the form on your Thread Page — ${heading}.`, ...sections].join("\n\n");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function formatValue(value: string | string[] | boolean): string {
|
|
29
|
+
if (typeof value === "boolean") return value ? "Yes" : "No";
|
|
30
|
+
if (Array.isArray(value)) return value.length > 0 ? value.join(", ") : "(left blank)";
|
|
31
|
+
return value.length > 0 ? value : "(left blank)";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The message a `session.reply` becomes. spec R5.16 */
|
|
35
|
+
export function formatReplyMessage(title: string | undefined, result: JsonValue): string {
|
|
36
|
+
const heading = title?.trim() || "Interactive response";
|
|
37
|
+
const serialized = JSON.stringify(result, null, 2) ?? "null";
|
|
38
|
+
let longestRun = 0;
|
|
39
|
+
for (const match of serialized.matchAll(/`+/g)) longestRun = Math.max(longestRun, match[0].length);
|
|
40
|
+
const fence = "`".repeat(Math.max(3, longestRun + 1));
|
|
41
|
+
return [`The user sent an interactive response from your Thread Page — ${heading}.`, `**Result**\n\n${fence}json\n${serialized}\n${fence}`].join("\n\n");
|
|
42
|
+
}
|