@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,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which sessions may own a page. spec 01 §Eligibility
|
|
3
|
+
*
|
|
4
|
+
* A session is eligible when it is visible (not an internal helper), a root
|
|
5
|
+
* (not a child, not a fork) and live (not archived, not deleted).
|
|
6
|
+
*/
|
|
7
|
+
export interface EligibilityFacts {
|
|
8
|
+
readonly visibility: "visible" | "hidden";
|
|
9
|
+
readonly parentId: string | null;
|
|
10
|
+
readonly forkOfId: string | null;
|
|
11
|
+
readonly archived: boolean;
|
|
12
|
+
readonly deleted: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type IneligibleReason = "hidden" | "child" | "fork" | "archived" | "deleted";
|
|
16
|
+
|
|
17
|
+
export function ineligibleReason(facts: EligibilityFacts): IneligibleReason | null {
|
|
18
|
+
if (facts.deleted) return "deleted";
|
|
19
|
+
if (facts.archived) return "archived";
|
|
20
|
+
if (facts.visibility !== "visible") return "hidden";
|
|
21
|
+
if (facts.parentId !== null) return "child";
|
|
22
|
+
if (facts.forkOfId !== null) return "fork";
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isEligible(facts: EligibilityFacts): boolean {
|
|
27
|
+
return ineligibleReason(facts) === null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function describeIneligible(reason: IneligibleReason): string {
|
|
31
|
+
switch (reason) {
|
|
32
|
+
case "hidden":
|
|
33
|
+
return "this session is a hidden helper";
|
|
34
|
+
case "child":
|
|
35
|
+
return "this session is a child of another session";
|
|
36
|
+
case "fork":
|
|
37
|
+
return "this session is a fork of another session";
|
|
38
|
+
case "archived":
|
|
39
|
+
return "this session is archived";
|
|
40
|
+
case "deleted":
|
|
41
|
+
return "this session is deleted";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { LIMITS } from "./limits.ts";
|
|
2
|
+
|
|
3
|
+
/** The fixed error set a page may see. spec R5.38 */
|
|
4
|
+
export const BRIDGE_ERROR_CODES = [
|
|
5
|
+
"invalid_json",
|
|
6
|
+
"invalid_request",
|
|
7
|
+
"invalid_params",
|
|
8
|
+
"invalid_response",
|
|
9
|
+
"request_too_large",
|
|
10
|
+
"response_too_large",
|
|
11
|
+
"unsupported_version",
|
|
12
|
+
"unknown_method",
|
|
13
|
+
"stale_page",
|
|
14
|
+
"confirmation_required",
|
|
15
|
+
"confirmation_invalid",
|
|
16
|
+
"cancelled",
|
|
17
|
+
"not_found",
|
|
18
|
+
"conflict",
|
|
19
|
+
"unavailable",
|
|
20
|
+
"rate_limited",
|
|
21
|
+
"handler_error",
|
|
22
|
+
"invalid_result",
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
export type BridgeErrorCode = (typeof BRIDGE_ERROR_CODES)[number];
|
|
26
|
+
|
|
27
|
+
const BRIDGE_ERROR_CODE_SET: ReadonlySet<string> = new Set(BRIDGE_ERROR_CODES);
|
|
28
|
+
|
|
29
|
+
export function isBridgeErrorCode(value: unknown): value is BridgeErrorCode {
|
|
30
|
+
return typeof value === "string" && BRIDGE_ERROR_CODE_SET.has(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Codes that only routes (not the bridge) produce. */
|
|
34
|
+
export type RouteErrorCode =
|
|
35
|
+
| "forbidden"
|
|
36
|
+
| "ineligible"
|
|
37
|
+
| "invalid_session"
|
|
38
|
+
| "no_page"
|
|
39
|
+
| "page_too_large";
|
|
40
|
+
|
|
41
|
+
export type PageErrorCode = BridgeErrorCode | RouteErrorCode;
|
|
42
|
+
|
|
43
|
+
const STATUS_BY_CODE: Record<PageErrorCode, number> = {
|
|
44
|
+
invalid_json: 400,
|
|
45
|
+
invalid_request: 400,
|
|
46
|
+
invalid_params: 400,
|
|
47
|
+
invalid_response: 502,
|
|
48
|
+
request_too_large: 413,
|
|
49
|
+
response_too_large: 500,
|
|
50
|
+
unsupported_version: 400,
|
|
51
|
+
unknown_method: 404,
|
|
52
|
+
stale_page: 409,
|
|
53
|
+
confirmation_required: 401,
|
|
54
|
+
confirmation_invalid: 403,
|
|
55
|
+
cancelled: 400,
|
|
56
|
+
not_found: 404,
|
|
57
|
+
conflict: 409,
|
|
58
|
+
unavailable: 503,
|
|
59
|
+
rate_limited: 429,
|
|
60
|
+
handler_error: 500,
|
|
61
|
+
invalid_result: 500,
|
|
62
|
+
forbidden: 403,
|
|
63
|
+
ineligible: 404,
|
|
64
|
+
invalid_session: 400,
|
|
65
|
+
no_page: 404,
|
|
66
|
+
page_too_large: 413,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One failure: a machine code, a message safe to show a reader or a page,
|
|
71
|
+
* an HTTP status, and (server-side only) the real cause for the log.
|
|
72
|
+
* spec R2.41–R2.43
|
|
73
|
+
*/
|
|
74
|
+
export class PageError extends Error {
|
|
75
|
+
readonly code: PageErrorCode;
|
|
76
|
+
readonly status: number;
|
|
77
|
+
override readonly cause: unknown;
|
|
78
|
+
|
|
79
|
+
constructor(code: PageErrorCode, publicMessage: string, options?: { cause?: unknown; status?: number }) {
|
|
80
|
+
super(boundedMessage(publicMessage));
|
|
81
|
+
this.name = "PageError";
|
|
82
|
+
this.code = code;
|
|
83
|
+
this.status = options?.status ?? STATUS_BY_CODE[code];
|
|
84
|
+
this.cause = options?.cause;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
static is(value: unknown): value is PageError {
|
|
88
|
+
return value instanceof PageError;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function boundedMessage(message: string): string {
|
|
93
|
+
const normalized = message.replace(/\s+/g, " ").trim() || "Request failed";
|
|
94
|
+
return normalized.length <= LIMITS.errorMessageChars
|
|
95
|
+
? normalized
|
|
96
|
+
: `${normalized.slice(0, LIMITS.errorMessageChars - 1)}…`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The text of an unknown error, for logs only (never for a page). */
|
|
100
|
+
export function errorText(error: unknown): string {
|
|
101
|
+
if (error instanceof Error) return error.message;
|
|
102
|
+
return String(error);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const PUBLIC_MESSAGES = Object.freeze({
|
|
106
|
+
noPage: "This session has no page yet. Run `bb thread-page init` in the session first.",
|
|
107
|
+
ineligible: "Only visible root sessions have pages.",
|
|
108
|
+
pageTooLarge: `The page's entry document is larger than ${LIMITS.entryDocumentBytes / (1024 * 1024)} MiB and was not served.`,
|
|
109
|
+
unavailable: "The page's source is unreachable. Reconnect its host and try again.",
|
|
110
|
+
staleCopy: "The source host is offline; this cached page is read-only.",
|
|
111
|
+
stalePage: "This page changed; reload it before responding.",
|
|
112
|
+
handler: "Could not execute the page action.",
|
|
113
|
+
rateLimited: "Too many requests from this page; try again shortly.",
|
|
114
|
+
invalidSession: "A valid session id is required.",
|
|
115
|
+
tokenInvalid: "This page session is invalid or expired; reload the page.",
|
|
116
|
+
});
|
|
@@ -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,98 @@
|
|
|
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
|
+
/**
|
|
66
|
+
* Resolving a page's own files into its entry document (see pages/inline.ts).
|
|
67
|
+
* The per-file cap is generous because a page's stylesheet and data set are
|
|
68
|
+
* the whole point; the total is what keeps one page from becoming a document
|
|
69
|
+
* no phone will load. Base64 costs a third on top of both.
|
|
70
|
+
*/
|
|
71
|
+
inlineFileBytes: 2 * 1024 * 1024,
|
|
72
|
+
inlineTotalBytes: 3 * 1024 * 1024,
|
|
73
|
+
/** How far `url()` inside an inlined stylesheet is followed. */
|
|
74
|
+
inlineCssDepth: 3,
|
|
75
|
+
offlineCopyBytes: 200 * 1024,
|
|
76
|
+
offlineCacheEntries: 32,
|
|
77
|
+
offlineCacheBytes: 8 * 1024 * 1024,
|
|
78
|
+
/** Bridge envelope identifiers. */
|
|
79
|
+
requestIdChars: 96,
|
|
80
|
+
methodNameChars: 96,
|
|
81
|
+
tokenChars: 4_096,
|
|
82
|
+
errorMessageChars: 512,
|
|
83
|
+
summaryChars: 512,
|
|
84
|
+
/** Sizes of lists a capability may return. */
|
|
85
|
+
projectsMax: 200,
|
|
86
|
+
providersMax: 64,
|
|
87
|
+
modelsPerProvider: 64,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
export type Limits = typeof LIMITS;
|
|
91
|
+
|
|
92
|
+
export function mebibytes(bytes: number): string {
|
|
93
|
+
return `${Math.round((bytes / (1024 * 1024)) * 100) / 100} MiB`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function kibibytes(bytes: number): string {
|
|
97
|
+
return `${Math.round(bytes / 1024)} KiB`;
|
|
98
|
+
}
|
|
@@ -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
|
+
}
|