Skip to content

Commit ec338f6

Browse files
committed
Fix capability isolation and Unicode validation
1 parent d5dde93 commit ec338f6

8 files changed

Lines changed: 132 additions & 17 deletions

File tree

.changeset/capability-graph.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ The capability graph: define a typed application operation once and project it t
1010

1111
**Capability core.** The new `@pracht/capabilities` package provides `defineCapability()`: a protocol-neutral operation with a dependency-free JSON Schema subset validator (unsupported keywords are rejected at definition time so they can never silently widen an exposed contract), effect classes (`read`/`write`/`destructive`), named middleware, and explicit exposure. Capabilities register in the app manifest via `defineApp({ capabilities: { ... } })` and are private by default. The package is also the single home of the wire protocol — `capabilityHttpPath()`, the confirmation and transport header names, the `CapabilityErrorCode` union, the envelope types, the schema→TypeScript printer, and the shared static extractor (`@pracht/capabilities/static`) — consumed by the framework, the Vite plugin, and the CLI so the contract cannot drift between packages.
1212

13-
Capability validation also enforces the JSON data model at every boundary, including unconstrained/additional properties and schema `const`, `default`, and `enum` values, so multipart files, prototype-named fields, and other JavaScript-only values cannot bypass validation or destructive-call confirmation bindings.
13+
Capability validation also enforces the JSON data model at every boundary, including unconstrained/additional properties and schema `const`, `default`, and `enum` values, and applies JSON Schema string lengths by Unicode code point, so multipart files, prototype-named fields, astral Unicode characters, and other JavaScript-only values cannot bypass or distort validation and destructive-call confirmation bindings.
1414

15-
The shared static extractor used by browser codegen and `pracht verify` ignores comments, string contents, and regex literals when locating capability definitions and registrations, analyzes the module's default-exported capability, and scopes manifest extraction to the exported `defineApp()` configuration — so examples, commented-out code, or a helper capability defined earlier in the file cannot change the generated capability surface.
15+
The shared static extractor used by browser codegen and `pracht verify` ignores comments, string contents, and regex literals when locating capability definitions and registrations, parses both fixed-width and code-point Unicode escapes in inline literals, analyzes the module's default-exported capability, and scopes manifest extraction to the exported `defineApp()` configuration — so examples, commented-out code, or a helper capability defined earlier in the file cannot change the generated capability surface.
1616

17-
**Projections.** `@pracht/core` resolves the registry and runs one dispatch pipeline (input validation → named middleware → `run()` → output validation) behind every surface: `invokeCapability()` for direct server use (loaders, API routes, middleware), `POST /api/capabilities/<name>` with a typed `{ ok, data | error }` envelope, CSRF protection, and production redaction (custom HTTP paths that URL parsing could reinterpret as cross-origin or as a different pathname are rejected), and — via `@pracht/vite-plugin` — the generated `virtual:pracht/capabilities` browser client (`callCapability()`, with `confirm` sugar for confirmation tokens) and `virtual:pracht/webmcp`, a feature-detected WebMCP page-tool shim (`document.modelContext.registerTool`, Chrome origin trial). Both virtual modules cost zero bytes when unused.
17+
**Projections.** `@pracht/core` resolves the registry and runs one dispatch pipeline (input validation → named middleware → `run()` → output validation) behind every surface: request-scoped `invokeCapability()` for direct server use (loaders, API routes, middleware), `POST /api/capabilities/<name>` with a typed `{ ok, data | error }` envelope, CSRF protection, and production redaction (custom HTTP paths that URL parsing could reinterpret as cross-origin or as a different pathname are rejected), and — via `@pracht/vite-plugin` — the generated `virtual:pracht/capabilities` browser client (`callCapability()`, with `confirm` sugar for confirmation tokens) and `virtual:pracht/webmcp`, a feature-detected WebMCP page-tool shim (`document.modelContext.registerTool`, Chrome origin trial). Direct invocation hosts are bound to their incoming `Request`, so overlapping apps or dev-server generations cannot route a call through another registry. Both virtual modules cost zero bytes when unused.
1818

1919
**One contract for humans and agents.** `<Form capability="notes.create">` posts the framework's form component straight to the capability endpoint agents call: fields are coerced onto the input schema server-side, `onCapabilityResult` receives the typed envelope, and without JavaScript the endpoint accepts the form-encoded post and answers a successful document submission with a 303 back to the same-origin referring page. Enhanced submissions honor a clicked submitter's `formaction` and follow middleware redirects to their final browser URL, matching that no-JavaScript behavior: a redirect is handed back to the same-origin fetch as a readable target and the browser navigates itself, so an external OAuth/SSO destination is never fetched through CORS and never submitted twice, and a cross-origin form target falls back to a native document submission (after client-side schema validation, if any). Effect classes drive the client cache: after any successful non-`read` browser call (`callCapability()` or `<Form capability>`) the active route's loader data revalidates automatically — a full reload under islands hydration — and `revalidate: false` opts out per call.
2020

packages/capabilities/src/schema.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -260,10 +260,14 @@ function validateJsonAgainstSchema(
260260
}
261261

262262
if (typeof value === "string") {
263-
if (typeof schema.minLength === "number" && value.length < schema.minLength) {
263+
// JSON Schema measures string length in Unicode code points, while
264+
// JavaScript's String#length counts UTF-16 code units. Count code points so
265+
// astral characters such as emoji contribute one character, not two.
266+
const length = Array.from(value).length;
267+
if (typeof schema.minLength === "number" && length < schema.minLength) {
264268
issues.push({ path, message: `must be at least ${schema.minLength} character(s) long` });
265269
}
266-
if (typeof schema.maxLength === "number" && value.length > schema.maxLength) {
270+
if (typeof schema.maxLength === "number" && length > schema.maxLength) {
267271
issues.push({ path, message: `must be at most ${schema.maxLength} character(s) long` });
268272
}
269273
}

packages/capabilities/src/static.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -714,6 +714,17 @@ function parseStringLiteral(source: string, start: number): ParsedLiteral | null
714714
break;
715715
}
716716
case "u": {
717+
if (body[index + 1] === "{") {
718+
const close = body.indexOf("}", index + 2);
719+
if (close === -1) return null;
720+
const hex = body.slice(index + 2, close);
721+
if (!/^[0-9a-fA-F]{1,6}$/.test(hex)) return null;
722+
const codePoint = Number.parseInt(hex, 16);
723+
if (codePoint > 0x10ffff) return null;
724+
value += String.fromCodePoint(codePoint);
725+
index = close;
726+
break;
727+
}
717728
const hex = body.slice(index + 1, index + 5);
718729
if (!/^[0-9a-fA-F]{4}$/.test(hex)) return null;
719730
value += String.fromCharCode(Number.parseInt(hex, 16));

packages/capabilities/test/schema.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,13 @@ describe("validateAgainstSchema", () => {
104104
]);
105105
});
106106

107+
it("counts Unicode code points for minLength and maxLength", () => {
108+
expect(validateAgainstSchema({ type: "string", minLength: 2 }, "😀")).toEqual([
109+
{ path: "", message: "must be at least 2 character(s) long" },
110+
]);
111+
expect(validateAgainstSchema({ type: "string", maxLength: 1 }, "😀")).toEqual([]);
112+
});
113+
107114
it("rejects unknown properties when additionalProperties is false", () => {
108115
expect(validateAgainstSchema(schema, { query: "x", bogus: 1 })).toEqual([
109116
{ path: "/bogus", message: "is not an allowed property" },

packages/capabilities/test/static.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { describe, expect, it } from "vitest";
22

3-
import { extractCapabilityRegistrations, extractDefineCapabilityArgs } from "../src/static.ts";
3+
import {
4+
evaluateLiteral,
5+
extractCapabilityRegistrations,
6+
extractDefineCapabilityArgs,
7+
scanTopLevelProperties,
8+
} from "../src/static.ts";
49

510
describe("capability static extraction", () => {
611
it("ignores defineCapability examples in comments and strings", () => {
@@ -140,6 +145,25 @@ describe("capability static extraction", () => {
140145
expect(extractDefineCapabilityArgs(source)).toContain('effect: "read"');
141146
});
142147

148+
it("parses Unicode code-point escapes in inline schemas", () => {
149+
const source = String.raw`
150+
export default defineCapability({
151+
title: "Emoji",
152+
description: "Accept one emoji.",
153+
input: { type: "string", enum: ["\u{1F600}"] },
154+
output: { type: "string" },
155+
effect: "read",
156+
expose: { http: true, webmcp: true },
157+
run() {},
158+
});
159+
`;
160+
161+
const args = extractDefineCapabilityArgs(source);
162+
expect(args).not.toBeNull();
163+
const input = scanTopLevelProperties(args!).get("input");
164+
expect(evaluateLiteral(input!)).toEqual({ type: "string", enum: ["😀"] });
165+
});
166+
143167
it("ignores commented-out manifest registrations", () => {
144168
const source = `
145169
export const app = defineApp({

packages/framework/src/runtime-capabilities.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -734,13 +734,19 @@ export interface CapabilityHost {
734734
registry: ModuleRegistry;
735735
}
736736

737-
// The app manifest and module registry are process-level constants, so a
738-
// single slot is safe under concurrent requests. `handlePrachtRequest`
739-
// refreshes it on every request (dev re-evaluates the server module).
740-
let activeCapabilityHost: CapabilityHost | null = null;
741-
742-
export function setActiveCapabilityHost(app: CapabilityHostApp, registry: ModuleRegistry): void {
743-
activeCapabilityHost = { app, registry };
737+
// Bind each host to the incoming Request rather than a process-global slot.
738+
// Custom servers may serve multiple Pracht apps from one process, and dev HMR
739+
// can replace a registry while an older request is still awaiting its loader.
740+
// A WeakMap keeps those overlapping invocations isolated on every Web runtime
741+
// without retaining completed requests.
742+
const activeCapabilityHosts = new WeakMap<Request, CapabilityHost>();
743+
744+
export function setActiveCapabilityHost(
745+
request: Request,
746+
app: CapabilityHostApp,
747+
registry: ModuleRegistry,
748+
): void {
749+
activeCapabilityHosts.set(request, { app, registry });
744750
}
745751

746752
export interface InvokeCapabilityContext<TContext = unknown> {
@@ -777,10 +783,10 @@ export async function invokeCapability<T = unknown>(
777783
input: unknown,
778784
ctx: InvokeCapabilityContext,
779785
): Promise<CapabilityEnvelope<T>> {
780-
const host = activeCapabilityHost;
786+
const host = activeCapabilityHosts.get(ctx.request);
781787
if (!host) {
782788
throw new Error(
783-
"invokeCapability() has no capability host yet. It is only available while " +
789+
"invokeCapability() has no capability host for this request. It is only available while " +
784790
"handlePrachtRequest() is serving requests (loaders, API routes, middleware). " +
785791
"In tests, build a standalone host with createCapabilityTestHost() instead.",
786792
);
@@ -790,7 +796,7 @@ export async function invokeCapability<T = unknown>(
790796

791797
/**
792798
* Run one capability through the full dispatch pipeline against an explicit
793-
* host. Shared by `invokeCapability()` (the process-level host installed by
799+
* host. Shared by `invokeCapability()` (the request-bound host installed by
794800
* `handlePrachtRequest`) and `createCapabilityTestHost()` (a synthetic host
795801
* for tests).
796802
*/

packages/framework/src/runtime.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ export async function handlePrachtRequest<TContext>(
232232
// Register the capability host so `invokeCapability()` works from loaders,
233233
// API routes, and middleware during this request. One assignment — free
234234
// for apps without capabilities.
235-
setActiveCapabilityHost(options.app, registry);
235+
setActiveCapabilityHost(options.request, options.app, registry);
236236

237237
// Web Bot Auth: verify the agent signature once per request when the app
238238
// opted in via `defineApp({ agents: { webBotAuth } })`. The result (identity

packages/framework/test/capabilities.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,69 @@ describe("invokeCapability", () => {
578578
});
579579
});
580580

581+
it("keeps each capability host scoped to its originating request", async () => {
582+
const createNamedCapability = (name: string) =>
583+
createSearchCapability({
584+
async run() {
585+
return { notes: [name] };
586+
},
587+
});
588+
const appOptions = {
589+
routes: [route("/notes", "./routes/notes.tsx", { id: "notes" })],
590+
};
591+
const first = createApp(createNamedCapability("first"), appOptions);
592+
const second = createApp(createNamedCapability("second"), appOptions);
593+
594+
let releaseFirst!: () => void;
595+
const firstMayContinue = new Promise<void>((resolve) => {
596+
releaseFirst = resolve;
597+
});
598+
let markFirstStarted!: () => void;
599+
const firstStarted = new Promise<void>((resolve) => {
600+
markFirstStarted = resolve;
601+
});
602+
603+
first.registry.routeModules = {
604+
"./routes/notes.tsx": async () => ({
605+
loader: async ({ request, context, signal }: LoaderArgs) => {
606+
markFirstStarted();
607+
await firstMayContinue;
608+
return invokeCapability("notes.search", { query: "first" }, { request, context, signal });
609+
},
610+
Component: () => null,
611+
}),
612+
};
613+
second.registry.routeModules = {
614+
"./routes/notes.tsx": async () => ({
615+
loader: async ({ request, context, signal }: LoaderArgs) =>
616+
invokeCapability("notes.search", { query: "second" }, { request, context, signal }),
617+
Component: () => null,
618+
}),
619+
};
620+
621+
const firstResponsePromise = handlePrachtRequest({
622+
app: first.app,
623+
registry: first.registry,
624+
request: new Request("http://first.local/notes?_data=1"),
625+
});
626+
await firstStarted;
627+
628+
const secondResponse = await handlePrachtRequest({
629+
app: second.app,
630+
registry: second.registry,
631+
request: new Request("http://second.local/notes?_data=1"),
632+
});
633+
releaseFirst();
634+
const firstResponse = await firstResponsePromise;
635+
636+
expect(await firstResponse.json()).toEqual({
637+
data: { ok: true, data: { notes: ["first"] } },
638+
});
639+
expect(await secondResponse.json()).toEqual({
640+
data: { ok: true, data: { notes: ["second"] } },
641+
});
642+
});
643+
581644
it("works for private capabilities and returns validation envelopes", async () => {
582645
const { app, registry } = createApp(createSearchCapability({ expose: undefined }), {
583646
routes: [route("/notes", "./routes/notes.tsx", { id: "notes" })],

0 commit comments

Comments
 (0)