@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,82 @@
|
|
|
1
|
+
import type { BbPluginApi } from "@get-bb/plugin-sdk";
|
|
2
|
+
import { DEFAULT_AGENT_INSTRUCTION } from "../agent/instruction.ts";
|
|
3
|
+
import { DEFAULT_PAGE_SEED } from "../agent/seed/seed.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The five settings and nothing more. spec 07
|
|
7
|
+
*
|
|
8
|
+
* Read live: a change applies to the next request without a restart (R7.5).
|
|
9
|
+
* Neither the seed nor the instruction ever touches an existing page (R7.1).
|
|
10
|
+
*/
|
|
11
|
+
export interface Settings {
|
|
12
|
+
readonly agentInstructions: boolean;
|
|
13
|
+
readonly agentInstructionText: string;
|
|
14
|
+
readonly pageSeedHtml: string;
|
|
15
|
+
readonly workingLabel: string;
|
|
16
|
+
readonly homeSessionId: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_WORKING_LABEL = "Working — this is the last saved version";
|
|
20
|
+
|
|
21
|
+
export interface LiveSettings {
|
|
22
|
+
current(): Settings;
|
|
23
|
+
set(values: Partial<{ [K in keyof Settings]: Settings[K] | null }>): Promise<Settings>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function defineSettings(bb: BbPluginApi): Promise<LiveSettings> {
|
|
27
|
+
const handle = bb.settings.define({
|
|
28
|
+
agentInstructions: {
|
|
29
|
+
type: "boolean",
|
|
30
|
+
label: "Agent instructions",
|
|
31
|
+
description: "Inject the standing Thread Pages instruction into every eligible new session.",
|
|
32
|
+
default: false,
|
|
33
|
+
},
|
|
34
|
+
agentInstructionText: {
|
|
35
|
+
type: "string",
|
|
36
|
+
label: "Agent instruction text",
|
|
37
|
+
description: "What eligible new sessions receive when Agent instructions is on. Changing it affects future sessions only.",
|
|
38
|
+
experimental_multiline: true,
|
|
39
|
+
default: DEFAULT_AGENT_INSTRUCTION,
|
|
40
|
+
},
|
|
41
|
+
pageSeedHtml: {
|
|
42
|
+
type: "string",
|
|
43
|
+
label: "New-page seed",
|
|
44
|
+
description: "The complete HTML a new page starts from. {{TITLE}} is replaced, escaped. Existing pages are never rewritten.",
|
|
45
|
+
experimental_multiline: true,
|
|
46
|
+
default: DEFAULT_PAGE_SEED,
|
|
47
|
+
},
|
|
48
|
+
workingLabel: {
|
|
49
|
+
type: "string",
|
|
50
|
+
label: "Working indicator text",
|
|
51
|
+
description: "Shown in the page header while the owning session is mid-turn. Blank hides the indicator.",
|
|
52
|
+
default: DEFAULT_WORKING_LABEL,
|
|
53
|
+
},
|
|
54
|
+
homeSessionId: {
|
|
55
|
+
type: "string",
|
|
56
|
+
label: "Home page session",
|
|
57
|
+
description: "The session whose page is home; every other page links back to it. Set with `bb thread-page home`.",
|
|
58
|
+
default: "",
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
let current = normalize(await handle.get());
|
|
62
|
+
handle.onChange((next) => {
|
|
63
|
+
current = normalize(next);
|
|
64
|
+
});
|
|
65
|
+
return {
|
|
66
|
+
current: () => current,
|
|
67
|
+
async set(values) {
|
|
68
|
+
current = normalize(await handle.experimental_set(values));
|
|
69
|
+
return current;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalize(values: { agentInstructions: boolean; agentInstructionText: string; pageSeedHtml: string; workingLabel: string; homeSessionId: string }): Settings {
|
|
75
|
+
return {
|
|
76
|
+
agentInstructions: values.agentInstructions === true,
|
|
77
|
+
agentInstructionText: values.agentInstructionText,
|
|
78
|
+
pageSeedHtml: values.pageSeedHtml,
|
|
79
|
+
workingLabel: values.workingLabel.trim(),
|
|
80
|
+
homeSessionId: values.homeSessionId.trim(),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { JsonValue, Validation } from "../json/strict-json.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What every capability declares. spec R5.1
|
|
5
|
+
*
|
|
6
|
+
* A spec is pure: it names the capability, classes its effect, and validates
|
|
7
|
+
* its parameters and its result. Execution and confirmation wording live in
|
|
8
|
+
* the server-side handler, which is the only place with host access.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* `reader-state` changes only what the reader sees about a session — its
|
|
12
|
+
* read mark — never the session's work; it is not confirmed. spec R5.7a
|
|
13
|
+
*/
|
|
14
|
+
export const EFFECT_CLASSES = ["read", "own-session-write", "cross-session-write", "destructive", "navigation", "device", "reader-state"] as const;
|
|
15
|
+
export type EffectClass = (typeof EFFECT_CLASSES)[number];
|
|
16
|
+
|
|
17
|
+
/** Effects that must be confirmed in trusted chrome. spec R5.7 */
|
|
18
|
+
export const CONFIRMED_EFFECTS: ReadonlySet<EffectClass> = new Set(["cross-session-write", "destructive", "device"]);
|
|
19
|
+
|
|
20
|
+
export interface CapabilityDoc {
|
|
21
|
+
/** One paragraph on the parameters, for the guide. */
|
|
22
|
+
readonly params: string;
|
|
23
|
+
/** One paragraph on the result, for the guide. */
|
|
24
|
+
readonly result: string;
|
|
25
|
+
/** Anything an author must know: refusals, defaults, confirmation wording. */
|
|
26
|
+
readonly notes?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CapabilitySpec<Params = unknown, Result = unknown> {
|
|
30
|
+
readonly method: string;
|
|
31
|
+
readonly description: string;
|
|
32
|
+
readonly effect: EffectClass;
|
|
33
|
+
readonly confirmed: boolean;
|
|
34
|
+
/** `false` means the contract exists but the host does not implement it: `unknown_method`. spec R5.6 */
|
|
35
|
+
readonly implemented: boolean;
|
|
36
|
+
readonly validateParams: (value: JsonValue | undefined) => Validation<Params>;
|
|
37
|
+
readonly validateResult: (value: unknown) => Validation<Result>;
|
|
38
|
+
readonly doc: CapabilityDoc;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type AnyCapabilitySpec = CapabilitySpec<any, any>;
|
|
42
|
+
|
|
43
|
+
/** What `context.get` reports about one capability. spec R5.9 */
|
|
44
|
+
export interface CapabilityDescriptor {
|
|
45
|
+
readonly method: string;
|
|
46
|
+
readonly effect: EffectClass;
|
|
47
|
+
readonly confirmation: "none" | "required";
|
|
48
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createRegistry } from "./registry.ts";
|
|
2
|
+
import { ALL_CAPABILITIES } from "./specs.ts";
|
|
3
|
+
|
|
4
|
+
export * from "./contract.ts";
|
|
5
|
+
export * from "./protocol.ts";
|
|
6
|
+
export * from "./registry.ts";
|
|
7
|
+
export * from "./specs.ts";
|
|
8
|
+
|
|
9
|
+
/** The registry the host serves: every spec, with `implemented` deciding the roster. */
|
|
10
|
+
export const capabilityRegistry = createRegistry(ALL_CAPABILITIES);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { boundedMessage, isBridgeErrorCode, PageError, type BridgeErrorCode } from "../errors.ts";
|
|
2
|
+
import { isMethodName, isRequestId, isRevision } from "../ids.ts";
|
|
3
|
+
import { isJsonObject, validateJson, type JsonValue } from "../json/strict-json.ts";
|
|
4
|
+
import { LIMITS } from "../limits.ts";
|
|
5
|
+
import type { CapabilityRegistry } from "./registry.ts";
|
|
6
|
+
import type { AnyCapabilitySpec } from "./contract.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The bridge protocol: what a page sends over the port and what it gets back.
|
|
10
|
+
* Version 1. spec R3.9, R4.28, R5.38
|
|
11
|
+
*/
|
|
12
|
+
export const BRIDGE_PROTOCOL_VERSION = 1 as const;
|
|
13
|
+
|
|
14
|
+
export interface BridgeRequest {
|
|
15
|
+
readonly v: 1;
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly method: string;
|
|
18
|
+
readonly params: JsonValue;
|
|
19
|
+
readonly pageRevision: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface BridgeSuccess {
|
|
23
|
+
readonly v: 1;
|
|
24
|
+
readonly id: string;
|
|
25
|
+
readonly ok: true;
|
|
26
|
+
readonly result: JsonValue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface BridgeFailure {
|
|
30
|
+
readonly v: 1;
|
|
31
|
+
readonly id: string;
|
|
32
|
+
readonly ok: false;
|
|
33
|
+
readonly error: { readonly code: BridgeErrorCode; readonly message: string };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type BridgeResponse = BridgeSuccess | BridgeFailure;
|
|
37
|
+
|
|
38
|
+
/** What the shell receives from `POST /bridge`. */
|
|
39
|
+
export type BridgeTransport =
|
|
40
|
+
| { readonly response: BridgeResponse; readonly navigate?: NavigationDirective }
|
|
41
|
+
| { readonly confirm: { readonly requestId: string; readonly summary: string; readonly challenge: string } };
|
|
42
|
+
|
|
43
|
+
/** A host-validated destination the trusted shell navigates to. spec R5.29–R5.34 */
|
|
44
|
+
export type NavigationDirective =
|
|
45
|
+
| { readonly kind: "page"; readonly url: string }
|
|
46
|
+
| { readonly kind: "host"; readonly url: string }
|
|
47
|
+
| { readonly kind: "external"; readonly url: string };
|
|
48
|
+
|
|
49
|
+
export function decodeBridgeRequest(input: unknown): BridgeRequest {
|
|
50
|
+
const checked = validateJson(input);
|
|
51
|
+
if (!checked.ok) {
|
|
52
|
+
const tooLarge = checked.issues.some((issue) => issue.code === "too_large");
|
|
53
|
+
throw new PageError(tooLarge ? "request_too_large" : "invalid_request", tooLarge ? "Bridge request is too large" : "Bridge request is not strict JSON");
|
|
54
|
+
}
|
|
55
|
+
const value = checked.value;
|
|
56
|
+
if (!isJsonObject(value)) throw new PageError("invalid_request", "Bridge request must be an object");
|
|
57
|
+
const keys = Object.keys(value).sort().join(",");
|
|
58
|
+
if (keys !== "id,method,pageRevision,params,v") throw new PageError("invalid_request", "Bridge request has the wrong shape");
|
|
59
|
+
if (value.v !== BRIDGE_PROTOCOL_VERSION) throw new PageError("unsupported_version", "Unsupported bridge protocol version");
|
|
60
|
+
if (!isRequestId(value.id)) throw new PageError("invalid_request", "Invalid request id");
|
|
61
|
+
if (!isMethodName(value.method)) throw new PageError("invalid_request", "Invalid method name");
|
|
62
|
+
if (!isRevision(value.pageRevision)) throw new PageError("invalid_request", "Invalid page revision");
|
|
63
|
+
return { v: 1, id: value.id, method: value.method, params: value.params as JsonValue, pageRevision: value.pageRevision };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function safeRequestId(value: unknown): string {
|
|
67
|
+
return isRequestId(value) ? value : "invalid";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function failure(id: unknown, code: BridgeErrorCode, message: string): BridgeFailure {
|
|
71
|
+
return { v: 1, id: safeRequestId(id), ok: false, error: { code, message: boundedMessage(message) } };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function failureFromError(id: unknown, error: unknown): BridgeFailure {
|
|
75
|
+
if (PageError.is(error) && isBridgeErrorCode(error.code)) return failure(id, error.code, error.message);
|
|
76
|
+
return failure(id, "handler_error", "Could not execute the page action.");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ResolvedInvocation<Params = unknown> {
|
|
80
|
+
readonly request: BridgeRequest;
|
|
81
|
+
readonly spec: AnyCapabilitySpec;
|
|
82
|
+
readonly params: Params;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Looks a request up in the registry and validates its parameters. Stale
|
|
87
|
+
* revisions are refused before the method is even looked at. spec R2.13
|
|
88
|
+
*/
|
|
89
|
+
export function resolveInvocation(request: BridgeRequest, registry: CapabilityRegistry, currentRevision: string): ResolvedInvocation {
|
|
90
|
+
if (request.pageRevision !== currentRevision) throw new PageError("stale_page", "This page changed; reload it before responding.");
|
|
91
|
+
const spec = registry.get(request.method);
|
|
92
|
+
if (!spec || !spec.implemented) throw new PageError("unknown_method", `Unknown capability: ${request.method}`);
|
|
93
|
+
const params = spec.validateParams(request.params);
|
|
94
|
+
if (!params.ok) {
|
|
95
|
+
const first = params.issues[0];
|
|
96
|
+
throw new PageError("invalid_params", `Invalid parameters for ${spec.method}${first ? ` at ${first.path}: ${first.message}` : ""}`);
|
|
97
|
+
}
|
|
98
|
+
return { request, spec, params: params.value };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Projects a handler result through the spec's validator into a response. spec R5.3 */
|
|
102
|
+
export function completeInvocation(invocation: ResolvedInvocation, result: unknown): BridgeResponse {
|
|
103
|
+
const projected = invocation.spec.validateResult(result);
|
|
104
|
+
if (!projected.ok) return failure(invocation.request.id, "invalid_result", `Invalid result for ${invocation.spec.method}`);
|
|
105
|
+
const json = validateJson(projected.value);
|
|
106
|
+
if (!json.ok) return failure(invocation.request.id, "invalid_result", `Result for ${invocation.spec.method} is not strict JSON`);
|
|
107
|
+
const response: BridgeSuccess = { v: 1, id: invocation.request.id, ok: true, result: json.value };
|
|
108
|
+
if (Buffer.byteLength(JSON.stringify(response), "utf8") > LIMITS.capabilityPayloadBytes) {
|
|
109
|
+
return failure(invocation.request.id, "response_too_large", "Bridge response is too large");
|
|
110
|
+
}
|
|
111
|
+
return response;
|
|
112
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { isMethodName } from "../ids.ts";
|
|
2
|
+
import { CONFIRMED_EFFECTS, EFFECT_CLASSES, type AnyCapabilitySpec, type CapabilityDescriptor } from "./contract.ts";
|
|
3
|
+
|
|
4
|
+
export interface CapabilityRegistry {
|
|
5
|
+
get(method: string): AnyCapabilitySpec | undefined;
|
|
6
|
+
list(): readonly AnyCapabilitySpec[];
|
|
7
|
+
/** The roster a page may discover: implemented capabilities only. spec R5.9 */
|
|
8
|
+
descriptors(): readonly CapabilityDescriptor[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Builds a registry and enforces the invariants no capability may break:
|
|
13
|
+
* unique, well-formed names; a known effect class; confirmation exactly where
|
|
14
|
+
* the effect class demands it (navigation may go either way). spec R5.7, R5.8
|
|
15
|
+
*/
|
|
16
|
+
export function createRegistry(specs: readonly AnyCapabilitySpec[]): CapabilityRegistry {
|
|
17
|
+
const byMethod = new Map<string, AnyCapabilitySpec>();
|
|
18
|
+
for (const spec of specs) {
|
|
19
|
+
if (!isMethodName(spec.method)) throw new TypeError(`Invalid capability name: ${spec.method}`);
|
|
20
|
+
if (byMethod.has(spec.method)) throw new TypeError(`Duplicate capability: ${spec.method}`);
|
|
21
|
+
if (!EFFECT_CLASSES.includes(spec.effect)) throw new TypeError(`Invalid effect for ${spec.method}`);
|
|
22
|
+
if (CONFIRMED_EFFECTS.has(spec.effect) && !spec.confirmed) {
|
|
23
|
+
throw new TypeError(`${spec.method} has a ${spec.effect} effect and must be confirmed`);
|
|
24
|
+
}
|
|
25
|
+
if ((spec.effect === "read" || spec.effect === "own-session-write" || spec.effect === "reader-state") && spec.confirmed) {
|
|
26
|
+
throw new TypeError(`${spec.method} is a ${spec.effect} and must not be confirmed`);
|
|
27
|
+
}
|
|
28
|
+
if (typeof spec.description !== "string" || spec.description.trim().length === 0 || spec.description.length > 240) {
|
|
29
|
+
throw new TypeError(`Invalid description for ${spec.method}`);
|
|
30
|
+
}
|
|
31
|
+
byMethod.set(spec.method, Object.freeze({ ...spec }));
|
|
32
|
+
}
|
|
33
|
+
const list = Object.freeze([...byMethod.values()]);
|
|
34
|
+
const descriptors = Object.freeze(
|
|
35
|
+
list
|
|
36
|
+
.filter((spec) => spec.implemented)
|
|
37
|
+
.map((spec): CapabilityDescriptor => ({
|
|
38
|
+
method: spec.method,
|
|
39
|
+
effect: spec.effect,
|
|
40
|
+
confirmation: spec.confirmed ? "required" : "none",
|
|
41
|
+
})),
|
|
42
|
+
);
|
|
43
|
+
return Object.freeze({
|
|
44
|
+
get: (method: string) => byMethod.get(method),
|
|
45
|
+
list: () => list,
|
|
46
|
+
descriptors: () => descriptors,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { invalid, isJsonObject, pathForKey, valid, validateJson, type Issue, type JsonLimits, type JsonValue, type Validation } from "../json/strict-json.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A tiny typed schema language for capability parameters and results.
|
|
5
|
+
*
|
|
6
|
+
* Every object is exact: unknown keys are rejected, required keys must be
|
|
7
|
+
* present, optional keys may be absent (never `undefined`). Strings are
|
|
8
|
+
* bounded; numbers are integers in a range; JSON blobs carry their own
|
|
9
|
+
* limits. spec R5.2, R5.3
|
|
10
|
+
*/
|
|
11
|
+
export interface Schema<T> {
|
|
12
|
+
parse(value: JsonValue | undefined, path: string): Validation<T>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface OptionalSchema<T> extends Schema<T> {
|
|
16
|
+
readonly isOptional: true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Optional on the wire, always present after parsing. */
|
|
20
|
+
export interface DefaultedSchema<T> extends OptionalSchema<T> {
|
|
21
|
+
readonly hasDefault: true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type Infer<S> = S extends Schema<infer T> ? T : never;
|
|
25
|
+
|
|
26
|
+
type Shape = Record<string, Schema<unknown>>;
|
|
27
|
+
type OptionalKeys<S extends Shape> = {
|
|
28
|
+
[K in keyof S]: S[K] extends DefaultedSchema<unknown> ? never : S[K] extends OptionalSchema<unknown> ? K : never;
|
|
29
|
+
}[keyof S];
|
|
30
|
+
type RequiredKeys<S extends Shape> = Exclude<keyof S, OptionalKeys<S>>;
|
|
31
|
+
export type InferShape<S extends Shape> = { [K in RequiredKeys<S>]: Infer<S[K]> } & { [K in OptionalKeys<S>]?: Infer<S[K]> };
|
|
32
|
+
|
|
33
|
+
function issues<T>(list: readonly Issue[]): Validation<T> {
|
|
34
|
+
return { ok: false, issues: list };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function string(options: { min?: number; max: number; pattern?: RegExp; label?: string }): Schema<string> {
|
|
38
|
+
const label = options.label ?? "String";
|
|
39
|
+
return {
|
|
40
|
+
parse(value, path) {
|
|
41
|
+
if (typeof value !== "string") return invalid(path, `${label}: expected a string`, "invalid_type");
|
|
42
|
+
const min = options.min ?? 0;
|
|
43
|
+
if (value.length < min || value.length > options.max) {
|
|
44
|
+
return invalid(path, `${label}: length must be ${min}–${options.max}`, "too_large");
|
|
45
|
+
}
|
|
46
|
+
if (options.pattern && !options.pattern.test(value)) return invalid(path, `${label}: invalid format`);
|
|
47
|
+
return valid(value);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function integer(min: number, max: number, label = "Integer"): Schema<number> {
|
|
53
|
+
return {
|
|
54
|
+
parse(value, path) {
|
|
55
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min || value > max) {
|
|
56
|
+
return invalid(path, `${label}: expected an integer from ${min} to ${max}`);
|
|
57
|
+
}
|
|
58
|
+
return valid(value);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function boolean(label = "Boolean"): Schema<boolean> {
|
|
64
|
+
return {
|
|
65
|
+
parse(value, path) {
|
|
66
|
+
return typeof value === "boolean" ? valid(value) : invalid(path, `${label}: expected a boolean`, "invalid_type");
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function literal<const T extends readonly (string | number | boolean | null)[]>(values: T, label = "Value"): Schema<T[number]> {
|
|
72
|
+
return {
|
|
73
|
+
parse(value, path) {
|
|
74
|
+
return (values as readonly unknown[]).includes(value)
|
|
75
|
+
? valid(value as T[number])
|
|
76
|
+
: invalid(path, `${label}: expected one of ${values.map((item) => JSON.stringify(item)).join(", ")}`);
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function nullable<T>(schema: Schema<T>): Schema<T | null> {
|
|
82
|
+
return {
|
|
83
|
+
parse(value, path) {
|
|
84
|
+
return value === null ? valid(null) : schema.parse(value, path);
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function optional<T>(schema: Schema<T>): OptionalSchema<T> {
|
|
90
|
+
return { isOptional: true, parse: (value, path) => schema.parse(value, path) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Optional with a default applied when the key is absent. */
|
|
94
|
+
export function withDefault<T>(schema: Schema<T>, fallback: T): DefaultedSchema<T> {
|
|
95
|
+
return {
|
|
96
|
+
isOptional: true,
|
|
97
|
+
hasDefault: true,
|
|
98
|
+
parse(value, path) {
|
|
99
|
+
return value === undefined ? valid(fallback) : schema.parse(value, path);
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function array<T>(item: Schema<T>, max: number, label = "List"): Schema<T[]> {
|
|
105
|
+
return {
|
|
106
|
+
parse(value, path) {
|
|
107
|
+
if (!Array.isArray(value)) return invalid(path, `${label}: expected a list`, "invalid_type");
|
|
108
|
+
if (value.length > max) return invalid(path, `${label}: at most ${max} items`, "too_large");
|
|
109
|
+
const out: T[] = [];
|
|
110
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
111
|
+
const parsed = item.parse(value[index], `${path}[${index}]`);
|
|
112
|
+
if (!parsed.ok) return issues(parsed.issues);
|
|
113
|
+
out.push(parsed.value);
|
|
114
|
+
}
|
|
115
|
+
return valid(out);
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function object<S extends Shape>(shape: S, label = "Object"): Schema<InferShape<S>> {
|
|
121
|
+
const keys = Object.keys(shape);
|
|
122
|
+
const known = new Set(keys);
|
|
123
|
+
return {
|
|
124
|
+
parse(value, path) {
|
|
125
|
+
if (!isJsonObject(value)) return invalid(path, `${label}: expected an object`, "invalid_type");
|
|
126
|
+
for (const key of Object.keys(value)) {
|
|
127
|
+
if (!known.has(key)) return invalid(pathForKey(path, key), "Unknown key", "unknown_key");
|
|
128
|
+
}
|
|
129
|
+
const out: Record<string, unknown> = {};
|
|
130
|
+
for (const key of keys) {
|
|
131
|
+
const schema = shape[key] as Schema<unknown> & { isOptional?: boolean };
|
|
132
|
+
const present = Object.prototype.hasOwnProperty.call(value, key);
|
|
133
|
+
if (!present) {
|
|
134
|
+
if (schema.isOptional) {
|
|
135
|
+
// withDefault schemas yield a value for an absent key; plain optional ones do not.
|
|
136
|
+
const parsed = schema.parse(undefined, pathForKey(path, key));
|
|
137
|
+
if (parsed.ok && parsed.value !== undefined) out[key] = parsed.value;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
return invalid(pathForKey(path, key), "Missing required key", "missing_key");
|
|
141
|
+
}
|
|
142
|
+
const parsed = schema.parse(value[key], pathForKey(path, key));
|
|
143
|
+
if (!parsed.ok) return issues(parsed.issues);
|
|
144
|
+
out[key] = parsed.value;
|
|
145
|
+
}
|
|
146
|
+
return valid(out as InferShape<S>);
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** `null`, absent, or `{}` all mean "no parameters". */
|
|
152
|
+
export function noParams(): Schema<null> {
|
|
153
|
+
return {
|
|
154
|
+
parse(value, path) {
|
|
155
|
+
if (value === undefined || value === null) return valid(null);
|
|
156
|
+
if (isJsonObject(value) && Object.keys(value).length === 0) return valid(null);
|
|
157
|
+
return invalid(path, "This capability takes no parameters", "unknown_key");
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Any strict JSON value within the given limits. */
|
|
163
|
+
export function json(limits: JsonLimits = {}, label = "Value"): Schema<JsonValue> {
|
|
164
|
+
return {
|
|
165
|
+
parse(value, path) {
|
|
166
|
+
if (value === undefined) return invalid(path, `${label}: missing`, "missing_key");
|
|
167
|
+
const checked = validateJson(value, limits);
|
|
168
|
+
if (!checked.ok) {
|
|
169
|
+
const first = checked.issues[0];
|
|
170
|
+
return first ? invalid(path === "$" ? first.path : `${path}${first.path.slice(1)}`, first.message, first.code) : checked;
|
|
171
|
+
}
|
|
172
|
+
return checked;
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function union<A, B>(first: Schema<A>, second: Schema<B>, label = "Value"): Schema<A | B> {
|
|
178
|
+
return {
|
|
179
|
+
parse(value, path) {
|
|
180
|
+
const a = first.parse(value, path);
|
|
181
|
+
if (a.ok) return a;
|
|
182
|
+
const b = second.parse(value, path);
|
|
183
|
+
if (b.ok) return b;
|
|
184
|
+
return invalid(path, `${label}: did not match any accepted shape`);
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function refine<T>(schema: Schema<T>, check: (value: T) => string | null): Schema<T> {
|
|
190
|
+
return {
|
|
191
|
+
parse(value, path) {
|
|
192
|
+
const parsed = schema.parse(value, path);
|
|
193
|
+
if (!parsed.ok) return parsed;
|
|
194
|
+
const problem = check(parsed.value);
|
|
195
|
+
return problem ? invalid(path, problem) : parsed;
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|