From cad7954981a81e118beced7153193a2c3888ac53 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 16:26:10 +0000 Subject: [PATCH 1/8] Host actions: pin H1, the action manifest and the default export, fail-first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests for Phase 2 step 3 (docs/blocks/PHASE-2-RELEASE-PLAN.md, §7), before the code they test: - H1 at load: an action node needs an id built only from literals and enum props, and a template's possible names must equal its declared actions, or B0 disables the block (actions.test.ts, the ratchet). - Every example and boundary call dispatches only declared names, each with an empty context; auth-form's largest call too, with no field bound to a data model. - The beta's action manifest binds actions by surface and name; forged, misfiled or stale provenance, edited surfaces, undeclared actions and non-empty contexts bind nothing (manifest.test.ts, the runtime tests). - The default Catalog export stays byte-identical to main's (catalog-export test and the composer smoke suite), and the beta's export carries the manifest end to end (composer-blocks e2e). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R6Ax8qvfN3gYUC85oSGnoi --- apps/composer/app/catalog-export.test.ts | 54 ++++ .../experimental/blocks-runtime.test.ts | 105 ++++++- apps/composer/lib/blocks-ratchet.test.mjs | 31 +- e2e/composer-blocks.spec.ts | 57 ++++ e2e/composer-prod-smoke.spec.ts | 27 +- .../composer-core/src/blocks/actions.test.ts | 291 +++++++++++++++++ .../composer-core/src/blocks/manifest.test.ts | 293 ++++++++++++++++++ 7 files changed, 852 insertions(+), 6 deletions(-) create mode 100644 apps/composer/app/catalog-export.test.ts create mode 100644 packages/composer-core/src/blocks/actions.test.ts create mode 100644 packages/composer-core/src/blocks/manifest.test.ts diff --git a/apps/composer/app/catalog-export.test.ts b/apps/composer/app/catalog-export.test.ts new file mode 100644 index 0000000..359c5b6 --- /dev/null +++ b/apps/composer/app/catalog-export.test.ts @@ -0,0 +1,54 @@ +/** + * The Catalog export (Preview's "export catalog"). The beta's experimental + * build adds a per-surface action manifest (docs/blocks/HOST-ACTIONS.md); the + * default build's export must stay byte-identical (docs/blocks/PHASE-2-RELEASE-PLAN.md, + * §12). The oracle below is the export as main wrote it before the action + * manifest (exportBundle in app/views/preview-view.tsx at 3edd589), kept + * verbatim minus the download, and the default export is held to it, byte for + * byte, over real project emits. + */ +import { describe, expect, it } from "vitest"; +import { catalogBundle, catalogJson } from "./catalog-export"; +import { REFERENCES } from "./demo-data"; +import { browserEmit, contractSurfaces } from "./validation"; + +type Emit = { catalog?: Record; surfaces?: Array<{ name: string; messages?: unknown[] }> }; + +/** main's exportBundle at 3edd589, minus the download: the bytes it wrote. */ +function todaysBytes(emit: Emit, exportedAt: string): string { + const bundle = { + a2uiCatalog: emit.catalog, + surfaces: (emit.surfaces ?? []) + .filter((s) => s.messages) + .map((s) => ({ name: s.name, messages: s.messages })), + exportedAt, + }; + return JSON.stringify(bundle, null, 2); +} + +const AT = "2026-10-16T12:00:00.000Z"; + +describe("the default Catalog export is byte-identical to today's", () => { + for (const id of Object.keys(REFERENCES)) { + it(`${id}: the reference's own emit, every surface, exported exactly as before`, () => { + const { contract, profile } = REFERENCES[id]!; + const emit = browserEmit(contract as Record, profile as Record, contractSurfaces(contract as Record)) as Emit; + expect(emit.surfaces?.length).toBeGreaterThan(0); + const bytes = catalogJson(catalogBundle(emit, AT)); + expect(bytes).toBe(todaysBytes(emit, AT)); + expect(Object.keys(JSON.parse(bytes))).toEqual(["a2uiCatalog", "surfaces", "exportedAt"]); + expect(bytes).not.toContain("actionManifest"); + }); + } + + it("a refused surface is left out and a missing catalog is omitted, as before", () => { + const emit: Emit = { surfaces: [{ name: "kept", messages: [{ version: "v0.9" }] }, { name: "refused" }] }; + expect(catalogJson(catalogBundle(emit, AT))).toBe(todaysBytes(emit, AT)); + expect(JSON.parse(catalogJson(catalogBundle(emit, AT)))).toEqual({ surfaces: [{ name: "kept", messages: [{ version: "v0.9" }] }], exportedAt: AT }); + }); + + it("an emit with no surfaces exports an empty list, as before", () => { + const emit: Emit = { catalog: { components: {} } }; + expect(catalogJson(catalogBundle(emit, AT))).toBe(todaysBytes(emit, AT)); + }); +}); diff --git a/apps/composer/experimental/blocks-runtime.test.ts b/apps/composer/experimental/blocks-runtime.test.ts index 3e79c3e..3c922aa 100644 --- a/apps/composer/experimental/blocks-runtime.test.ts +++ b/apps/composer/experimental/blocks-runtime.test.ts @@ -9,8 +9,20 @@ import { createCallRecorder, foldBuildEvents, planDeterministic } from "@dspack- import { REFERENCES } from "../app/demo-data"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import type { BlocksBetaSwitch } from "@dspack-studio/composer-core/blocks"; -import { blockBinding, blockContext, blockRunFor, chooseBlock, planGoalWithBlocks, readBlocksBeta, runHostedBlockBuild, type BlockContext } from "./blocks-runtime"; +import { expandBlock, expansionDigest, type BlocksBetaSwitch } from "@dspack-studio/composer-core/blocks"; +import { catalogBundle } from "../app/catalog-export"; +import { browserEmit, contractSurfaces } from "../app/validation"; +import { + blockBinding, + blockContext, + blockRunFor, + chooseBlock, + planGoalWithBlocks, + readBlocksBeta, + runHostedBlockBuild, + withActionManifest, + type BlockContext, +} from "./blocks-runtime"; type Json = Record; @@ -414,3 +426,92 @@ describe("named calls only (Phase 2 plan, §5)", () => { expect(source).not.toMatch(/positional/i); }); }); + +describe("the beta's Catalog export carries the action manifest (docs/blocks/HOST-ACTIONS.md)", () => { + const AT = "2026-10-16T12:00:00.000Z"; + const ACCEPTED = "ex.chat-1"; + + /** An Astryx project that accepted the auth-form example as a block surface, and its provenance entry. */ + async function acceptedAuthForm() { + const base = (await blockBinding(reference))!; + const block = base.sidecar.blocks.find((b) => b.id === "auth-form")!; + const example = block.examples[0]!; + const expanded = expandBlock(block, example.call, { reference: base.sidecar.reference, intent: example.intent }); + if (!expanded.ok) throw new Error(expanded.message); + const surface = expanded.surface as unknown as Json; + const live = { ...(astryx.contract as Json), examples: [...((astryx.contract as Json).examples as Json[]), { id: ACCEPTED, name: "Sign in", intent: example.intent, prompt: "a sign-in form", surface }] }; + const entry = { + v: 1, + reference: base.sidecar.reference, + baseSha256: base.sidecar.baseSha256, + block: "auth-form", + version: 1, + intent: example.intent, + blockSchemaSha256: "a".repeat(64), + encoding: "named", + call: example.call, + expansionDigest: await expansionDigest(surface), + }; + return { live, entry, surface }; + } + + /** Exactly what Preview exports: the app's own emit, today's bundle, then the manifest. */ + async function exported(live: Json, provenance: Record) { + const emit = browserEmit(live, astryx.profile as Json, contractSurfaces(live)); + const bundle = catalogBundle(emit, AT); + const out = await withActionManifest(bundle, { surfaces: emit.surfaces ?? [], examples: (live.examples as Json[]) ?? [], provenance }); + return { bundle, out: out as Json }; + } + + it("adds the manifest before exportedAt and changes nothing else; the accepted block surface binds, the reference's own examples bind nothing", async () => { + const { live, entry } = await acceptedAuthForm(); + const { bundle, out } = await exported(live, { [ACCEPTED]: entry }); + expect(Object.keys(out)).toEqual(["a2uiCatalog", "surfaces", "actionManifest", "exportedAt"]); + expect(out.a2uiCatalog).toEqual(bundle.a2uiCatalog); + expect(out.surfaces).toEqual(bundle.surfaces); + expect(out.exportedAt).toBe(AT); + + const manifest = out.actionManifest as Json; + expect(manifest.v).toBe(1); + expect(manifest.surfaces.map((s: Json) => s.surface)).toEqual(bundle.surfaces.map((s) => s.name)); + const accepted = manifest.surfaces.find((s: Json) => s.surface === ACCEPTED); + expect(accepted.block).toMatchObject({ id: "auth-form", version: 1, intent: "data-collection", streaming: "disable-actions-until-complete", expansionDigest: entry.expansionDigest }); + expect(accepted.actions).toEqual([ + { name: "auth_provider_github", declared: true }, + { name: "auth_submit", declared: true }, + ]); + for (const other of manifest.surfaces.filter((s: Json) => s.surface !== ACCEPTED)) { + expect(other.block).toBeNull(); + expect(other.actions.every((a: Json) => a.declared === false)).toBe(true); + } + }); + + it("an accepted surface edited afterwards: the manifest is recomputed from its messages, with no binding and the new action undeclared", async () => { + const { live, entry, surface } = await acceptedAuthForm(); + const edited = structuredClone(surface); + edited.root.children.push({ component: "button", id: "auth-delete-account", props: { label: "Delete account", variant: "secondary" } }); + const examples = (live.examples as Json[]).map((e) => (e.id === ACCEPTED ? { ...e, surface: edited } : e)); + const { out } = await exported({ ...live, examples }, { [ACCEPTED]: entry }); + const accepted = (out.actionManifest as Json).surfaces.find((s: Json) => s.surface === ACCEPTED); + expect(accepted.block).toBeNull(); + expect(accepted.actions).toContainEqual({ name: "auth_delete_account", declared: false }); + expect(accepted.actions.every((a: Json) => a.declared === false)).toBe(true); + }); + + it("forged provenance on a reference example grants nothing", async () => { + const { live, entry } = await acceptedAuthForm(); + const target = ((astryx.contract as Json).examples as Json[]).find((e) => e.surface)!.id as string; + const { out } = await exported(live, { [target]: { ...entry, expansionDigest: "f".repeat(64) }, [ACCEPTED]: entry }); + const forged = (out.actionManifest as Json).surfaces.find((s: Json) => s.surface === target); + expect(forged.block).toBeNull(); + expect(forged.actions.every((a: Json) => a.declared === false)).toBe(true); + }); + + it("no provenance at all: every surface is listed, and none binds", async () => { + const { live } = await acceptedAuthForm(); + const { out } = await exported(live, {}); + const surfaces = (out.actionManifest as Json).surfaces as Json[]; + expect(surfaces.length).toBeGreaterThan(1); + for (const s of surfaces) expect(s.block).toBeNull(); + }); +}); diff --git a/apps/composer/lib/blocks-ratchet.test.mjs b/apps/composer/lib/blocks-ratchet.test.mjs index 6523a3f..d69794e 100644 --- a/apps/composer/lib/blocks-ratchet.test.mjs +++ b/apps/composer/lib/blocks-ratchet.test.mjs @@ -8,18 +8,23 @@ * 2. that surface, played through the REAL runPipeline under the reference * profile exactly as hosted-build.ts runs it, passes at attempt 0 with no * representability refusal and every A-gate green on A2UI 0.9.1 and 1.0; - * 3. the emitted action names are a subset of the block's declared actions; + * 3. the emitted action names are a subset of the block's declared actions, + * and every action carries an empty context, so no credential or entered + * value can ride along (docs/blocks/HOST-ACTIONS.md); * 4. boundary calls (minimum and maximum items, optional props omitted and * present) satisfy B1 and B2 and survive the same pipeline; * 5. the sidecar binds to the packaged base (its baseSha256 equals the - * current digest), and a corrupted template or a B0 drift fails closed. + * current digest), and a corrupted template or a B0 drift fails closed; + * 6. H1: the names each template can emit equal its declared actions. */ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { loadProfile } from "@aestheticfunction/dspack-emit"; +import { emitSurface, loadProfile } from "@aestheticfunction/dspack-emit"; import { runPipeline, ScriptedAdapter } from "@aestheticfunction/dspack-gen/browser"; import { + actionComponents, + actionEvents, checkActions, checkConstraints, emitCheck, @@ -28,6 +33,7 @@ import { parseBlocksSidecar, rootScope, specAtPath, + templateActionNames, validateCall, } from "@dspack-studio/composer-core/blocks"; @@ -70,6 +76,18 @@ async function pipelinePasses(surface, contract, profile, intent) { }; } +/** + * Every action the surface dispatches is declared and carries an empty + * context: auth-form's credentials and entered values never enter one. + */ +function expectEmptyContexts(block, surface, contract, profile) { + const events = actionEvents(emitSurface(surface, contract, { profile }).messages); + for (const event of events) { + expect(block.actions).toContain(event.name); + expect(event.context).toEqual({}); + } +} + /** A string at a prop's minimum or maximum length, read from the signature. */ function str(block, path, bound) { const spec = specAtPath(rootScope(block), path, true); @@ -156,6 +174,11 @@ for (const [ref, paths] of Object.entries(REFERENCES)) { expect(loaded.binding).toBe("ok"); expect(loaded.disabled).toEqual([]); expect(loaded.usable.length).toBe(sidecar.blocks.length); + for (const block of sidecar.blocks) { + const proven = templateActionNames(block, actionComponents(profile)); + expect(proven.issues, block.id).toEqual([]); + expect(proven.names, block.id).toEqual([...block.actions].sort()); + } }); for (const block of sidecar.blocks) { @@ -168,6 +191,7 @@ for (const [ref, paths] of Object.entries(REFERENCES)) { const emitted = emitCheck(expanded.surface, contract, profile); if (!emitted.ok) throw new Error(emitted.refusal); expect(checkActions(block, emitted.actions)).toEqual([]); + expectEmptyContexts(block, expanded.surface, contract, profile); const run = await pipelinePasses(expanded.surface, contract, profile, example.intent); expect(run).toEqual({ outcome: "passed", attempts: 1, representability: undefined, versions: ["0.9.1", "1.0"], failedGates: [] }); @@ -186,6 +210,7 @@ for (const [ref, paths] of Object.entries(REFERENCES)) { const emitted = emitCheck(expanded.surface, contract, profile); if (!emitted.ok) throw new Error(emitted.refusal); expect(checkActions(block, emitted.actions)).toEqual([]); + expectEmptyContexts(block, expanded.surface, contract, profile); const run = await pipelinePasses(expanded.surface, contract, profile, intent); expect(run).toEqual({ outcome: "passed", attempts: 1, representability: undefined, versions: ["0.9.1", "1.0"], failedGates: [] }); }); diff --git a/e2e/composer-blocks.spec.ts b/e2e/composer-blocks.spec.ts index bee6343..6ecc98c 100644 --- a/e2e/composer-blocks.spec.ts +++ b/e2e/composer-blocks.spec.ts @@ -118,6 +118,63 @@ test("scripted: a sign-in goal on shadcn expands auth-form at attempt 0, passes, await expect(row).toContainText(String(entry.expansionDigest).slice(0, 12)); }); +test("the beta's Catalog export carries the action manifest: the accepted auth-form surface binds its actions by surface and name, and nothing else binds", async ({ page }) => { + await preferenceOn(page); + await scriptedSwitchOn(page); + await page.goto("/"); + await newProject(page, "Blocks export"); + await page.getByTestId("build-model").selectOption("scripted"); + await page.getByTestId("build-intent").selectOption("structured-input"); + await page.getByTestId("build-prompt").fill("a sign-in form with email and password"); + await page.getByTestId("build-run").click(); + await expect(page.getByTestId("build-block-1")).toContainText("expanded", { timeout: 30_000 }); + await page.getByTestId("build-accept-1").click(); + await expect(page.getByTestId("build-accepted-1")).toContainText(/ex\.chat-\d+/); + const accepted = ((await page.getByTestId("build-accepted-1").textContent()) ?? "").match(/ex\.chat-\d+/)![0]; + // Provenance is written after the accept, off the click's path. + await expect + .poll(() => page.evaluate(() => Object.keys(localStorage).filter((k) => k.startsWith("composer.project.blocks.")).length)) + .toBe(1); + + await page.getByTestId("nav-preview").click(); + const [download] = await Promise.all([page.waitForEvent("download"), page.getByTestId("export-catalog").click()]); + const fs = await import("node:fs/promises"); + const bundle = JSON.parse(await fs.readFile((await download.path())!, "utf8")) as Json; + expect(Object.keys(bundle)).toEqual(["a2uiCatalog", "surfaces", "actionManifest", "exportedAt"]); + + const manifest = bundle.actionManifest as Json; + expect(manifest.v).toBe(1); + expect(manifest.surfaces.map((s: Json) => s.surface)).toEqual(bundle.surfaces.map((s: Json) => s.name)); + const auth = (sidecar.blocks as Json[]).find((b) => b.id === "auth-form")!; + const mine = manifest.surfaces.find((s: Json) => s.surface === accepted); + expect(mine.surfaceId).toBe("structured_input"); + expect(mine.block).toMatchObject({ id: "auth-form", version: 1, intent: "structured-input", declaredActions: auth.actions, streaming: "disable-actions-until-complete" }); + expect(mine.block.expansionDigest).toMatch(/^[0-9a-f]{64}$/); + expect(mine.actions.map((a: Json) => a.name)).toContain("auth_submit"); + for (const action of mine.actions) { + expect(action.declared).toBe(true); + expect(auth.actions).toContain(action.name); + } + // The reference's own surfaces were never block expansions: names alone grant nothing. + for (const other of manifest.surfaces.filter((s: Json) => s.surface !== accepted)) { + expect(other.block).toBeNull(); + for (const action of other.actions) expect(action.declared).toBe(false); + } + // auth-form's actions carry no entered value: every context in what a host renders is empty. + const events: Json[] = []; + const walk = (value: unknown): void => { + if (Array.isArray(value)) value.forEach(walk); + else if (value && typeof value === "object") { + const event = (value as Json).event; + if (event && typeof event === "object") events.push(event as Json); + Object.values(value as Json).forEach(walk); + } + }; + walk(bundle.surfaces.find((s: Json) => s.name === accepted).messages); + expect(events.length).toBeGreaterThan(0); + for (const event of events) expect(event.context).toEqual({}); +}); + test("scripted: a refinement whose prior turn has no trace stays a refinement; it never restarts as a fresh block build", async ({ page }) => { // T1 expands a block. T2 refines with the preference OFF (today's path, no // trace). T3 refines again with the preference back ON: its plan inherits diff --git a/e2e/composer-prod-smoke.spec.ts b/e2e/composer-prod-smoke.spec.ts index 75084e0..98b3082 100644 --- a/e2e/composer-prod-smoke.spec.ts +++ b/e2e/composer-prod-smoke.spec.ts @@ -651,6 +651,30 @@ test("a seeded experimental preference changes nothing in the default build: no expect(planBodies[0]).not.toHaveProperty("maxCalls"); }); +test("the Catalog export is today's: the catalog and each surface's messages, and no action manifest, even with the experimental preference seeded", async ({ page }) => { + // The action manifest is the beta's alone (docs/blocks/HOST-ACTIONS.md); + // app/catalog-export.test.ts holds these bytes to the export before it. + await page.addInitScript(() => { + localStorage.setItem("composer.experimental.v1", JSON.stringify({ blocks: true })); + }); + await page.goto("/"); + await newProject(page, "shadcn", "Catalog export"); + await scriptedBuild(page, "destructive-action", "let people permanently delete their account"); + await page.getByTestId("build-accept-1").click(); + await expect(page.getByTestId("build-accepted-1")).toContainText(/ex\.chat-\d+/); + await page.getByTestId("nav-preview").click(); + const [download] = await Promise.all([page.waitForEvent("download"), page.getByTestId("export-catalog").click()]); + expect(download.suggestedFilename()).toMatch(/-catalog\.json$/); + const fs = await import("node:fs/promises"); + const text = await fs.readFile((await download.path())!, "utf8"); + const bundle = JSON.parse(text) as Record; + expect(Object.keys(bundle)).toEqual(["a2uiCatalog", "surfaces", "exportedAt"]); + expect(bundle.surfaces.length).toBeGreaterThan(1); + for (const surface of bundle.surfaces) expect(Object.keys(surface)).toEqual(["name", "messages"]); + expect(text).not.toContain("actionManifest"); + expect(text).toBe(JSON.stringify(bundle, null, 2)); +}); + test("the default build ships no interface-block code (docs/BLOCKS.md: blocks stay out of default builds)", () => { // Scans the served artifact on disk, so a lazily loaded chunk cannot hide it. const chunks = fileURLToPath(new URL("../apps/composer/out/_next/static/chunks/", import.meta.url)); @@ -665,7 +689,8 @@ test("the default build ships no interface-block code (docs/BLOCKS.md: blocks st expect(files.length).toBeGreaterThan(0); for (const file of files) { const body = readFileSync(file, "utf8"); - for (const marker of ["blocksSidecar", "disable-actions-until-complete", "records-table", "auth-form", "guide-steps", "blocks-runtime", "reference-not-in-beta", "beta-switch-"]) { + // actionManifest: the beta's Catalog export key (docs/blocks/HOST-ACTIONS.md). + for (const marker of ["blocksSidecar", "disable-actions-until-complete", "records-table", "auth-form", "guide-steps", "blocks-runtime", "reference-not-in-beta", "beta-switch-", "actionManifest"]) { expect(body.includes(marker), `${file} contains ${marker}`).toBe(false); } } diff --git a/packages/composer-core/src/blocks/actions.test.ts b/packages/composer-core/src/blocks/actions.test.ts new file mode 100644 index 0000000..cbfc686 --- /dev/null +++ b/packages/composer-core/src/blocks/actions.test.ts @@ -0,0 +1,291 @@ +/** + * Host actions, H1 (docs/blocks/HOST-ACTIONS.md; docs/blocks/PHASE-2-RELEASE-PLAN.md, + * §7): B0 proves the action allowlist at load. Every node the profile gives + * an action carries an id built only from literals and enum props, so the + * model can never coin an action name, and the names a template can emit + * equal the block's declared `actions`, exactly. A block that fails either + * rule is disabled at load, like any other B0 failure. + */ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { emitSurface, loadProfile, type Profile } from "@aestheticfunction/dspack-emit"; +import { + actionComponents, + actionEvents, + b0Issues, + checkConstraints, + emitCheck, + expandBlock, + generationView, + loadBlocks, + parseBlocksSidecar, + templateActionNames, + validateCall, + type BlockDefinition, + type BlocksSidecar, +} from "./index"; + +type Json = Record; +const repo = (rel: string) => fileURLToPath(new URL(`../../../../${rel}`, import.meta.url)); +const json = (rel: string) => JSON.parse(readFileSync(repo(rel), "utf8")) as Json; + +const REFS = { + "shadcn-v3": { + sidecar: "apps/composer/experimental/blocks/shadcn-v3.blocks.json", + contract: "apps/composer/shadcn-v3-project/shadcn-ui.dspack.json", + profile: "apps/composer/shadcn-v3-project/shadcn-v3.profile.json", + }, + astryx: { + sidecar: "apps/composer/experimental/blocks/astryx.blocks.json", + contract: "apps/composer/astryx-project/astryx.dspack.json", + profile: "apps/composer/astryx-project/astryx.profile.json", + }, +} as const; +type Ref = keyof typeof REFS; +const REF_IDS = Object.keys(REFS) as Ref[]; + +const inputs = (ref: Ref) => ({ + sidecar: json(REFS[ref].sidecar), + contract: json(REFS[ref].contract), + profile: loadProfile(json(REFS[ref].profile) as never) as Profile, +}); + +function parsed(raw: Json): BlocksSidecar { + const result = parseBlocksSidecar(raw); + if (!result.ok) throw new Error(JSON.stringify(result.issues)); + return result.sidecar; +} + +const blockOf = (ref: Ref, id: string): BlockDefinition => parsed(inputs(ref).sidecar).blocks.find((b) => b.id === id)!; + +/** The §7 table: the names each block can dispatch, the same on both references. */ +const TABLE: Record = { + "records-table": ["records_create", "records_export", "records_refresh"], + "auth-form": ["auth_forgot", "auth_provider_apple", "auth_provider_github", "auth_provider_google", "auth_provider_microsoft", "auth_submit"], + "guide-steps": ["guide_save", "guide_start"], +}; + +const surfaceOf = (contract: Json, intent: string, root: Json) => ({ dspackSurface: "0.1", system: contract.name, intent, root }); + +describe("actionComponents: the nodes the emitter gives an action, read from the profile", () => { + it("Astryx (profile language v1) and shadcn/ui v3 (v2): buttons and alert dialogs", () => { + expect([...actionComponents(inputs("astryx").profile)].sort()).toEqual(["alert-dialog", "button"]); + expect([...actionComponents(inputs("shadcn-v3").profile)].sort()).toEqual(["alert-dialog", "button"]); + }); + + it("with no profile, the emitter's own default, as emitSurface falls back to it", () => { + expect([...actionComponents()].sort()).toEqual(["alert-dialog", "button"]); + }); + + it("the generation view carries them, so B0 holds every template to them", () => { + for (const ref of REF_IDS) { + const { contract, profile } = inputs(ref); + expect(generationView(contract, profile).actionComponents).toEqual(actionComponents(profile)); + } + }); + + it("the hazard H1 closes: an action node without an id is named from its text, which a model may write", () => { + const { contract, profile } = inputs("shadcn-v3"); + const card = (button: Json) => ({ component: "card", id: "c", children: [{ component: "card-content", id: "cc", children: [button] }] }); + const named = emitCheck(surfaceOf(contract, "structured-input", card({ component: "button", id: "auth-submit", text: "Sign in" })), contract, profile); + const unnamed = emitCheck(surfaceOf(contract, "structured-input", card({ component: "button", text: "Delete everything" })), contract, profile); + expect(named).toEqual({ ok: true, actions: ["auth_submit"] }); + expect(unnamed).toEqual({ ok: true, actions: ["delete_everything"] }); + }); +}); + +describe("H1 at load: an action id is built only from literals and enum props", () => { + const view = (ref: Ref) => generationView(inputs(ref).contract, inputs(ref).profile); + /** auth-form on Astryx with its template replaced and, when given, its actions and props. */ + const auth = (template: Json, overrides: Partial = {}): BlockDefinition => ({ ...blockOf("astryx", "auth-form"), template: template as never, ...overrides }); + const card = (...children: Json[]) => ({ component: "card", id: "auth-card", children }); + const submit = (id: unknown) => ({ component: "button", id, props: { label: "Go", variant: "primary" } }); + const issues = (block: BlockDefinition) => b0Issues(block, view("astryx")).join("\n"); + + it("a button whose id is a string the model fills: the model would coin its action", () => { + expect(issues(auth(card(submit({ $prop: "title" })), { actions: ["auth_submit"] }))).toMatch(/'title' is a string the model fills/); + }); + + it("a button with no id: the emitter would name its action from its text or its component", () => { + expect(issues(auth(card({ component: "button", props: { label: "Go", variant: "primary" } }), { actions: ["auth_submit"] }))).toMatch( + /'button' dispatches an action, so it needs an id/, + ); + }); + + it("a $concat with a part the model fills", () => { + expect(issues(auth(card(submit({ $concat: ["auth-", { $prop: "title" }] })), { actions: ["auth_submit"] }))).toMatch(/'title' is a string the model fills/); + }); + + it("an id from a position ($index): a count, not a literal or an enum", () => { + const template = card({ $each: "fields", as: "field", children: [submit({ $concat: ["auth-", { $index: "field" }] })] }); + expect(issues(auth(template, { actions: ["auth_1"] }))).toMatch(/\$index/); + }); + + it("an id that is not a string", () => { + expect(issues(auth(card(submit(7)), { actions: ["auth_submit"] }))).toMatch(/not a string/); + }); + + it("an optional enum prop with no default: the id could be missing, and the name with it", () => { + const props = [...blockOf("astryx", "auth-form").props, { name: "variant", type: "enum" as const, values: ["submit", "next"] }]; + expect(issues(auth(card(submit({ $concat: ["auth-", { $prop: "variant" }] })), { props, actions: ["auth_next", "auth_submit"] }))).toMatch(/'variant' may be absent/); + // A default makes it total: every call names an action the template declares. + expect(issues(auth(card(submit({ $concat: ["auth-", { $prop: "variant", default: "submit" }] })), { props, actions: ["auth_next", "auth_submit"] }))).toBe(""); + }); + + it("a $case over an enum, onto literal ids, is bounded, and each result is a name", () => { + const id = { $case: "mode", map: { "sign-in": "auth-sign-in", "sign-up": "auth-sign-up", reset: "auth-reset" } }; + expect(issues(auth(card(submit(id)), { actions: ["auth_reset", "auth_sign_in", "auth_sign_up"] }))).toBe(""); + expect(templateActionNames(auth(card(submit(id))), view("astryx").actionComponents).names).toEqual(["auth_reset", "auth_sign_in", "auth_sign_up"]); + }); + + it("the names must equal the declaration: a name the template can emit but does not declare fails at load", () => { + const block = blockOf("astryx", "auth-form"); + expect(issues({ ...block, actions: block.actions.filter((a) => a !== "auth_forgot") })).toMatch(/can emit 'auth_forgot', which is not declared/); + }); + + it("and a declared name that no node can emit fails at load", () => { + const block = blockOf("astryx", "auth-form"); + expect(issues({ ...block, actions: [...block.actions, "auth_logout"] })).toMatch(/'auth_logout' is declared, but no node of the template can emit it/); + }); + + it("a name declared twice", () => { + const block = blockOf("astryx", "auth-form"); + expect(issues({ ...block, actions: [...block.actions, "auth_submit"] })).toMatch(/'auth_submit' is declared more than once/); + }); + + it("an id whose combinations would make an unbounded list of names", () => { + const props = [ + ...blockOf("astryx", "auth-form").props, + ...["a", "b", "c"].map((name) => ({ name, required: true, type: "enum" as const, values: Array.from({ length: 10 }, (_, i) => `${name}${i}`) })), + ]; + // 10 × 10 × 10 ids: past the cap, a declaration could not be read or reviewed. + const id = { $concat: ["auth-", { $prop: "a" }, "-", { $prop: "b" }, "-", { $prop: "c" }] }; + expect(issues(auth(card(submit(id)), { props, actions: ["auth_submit"] }))).toMatch(/more than 256 possible names/); + }); + + it("only action nodes are held to it: a badge numbered by position stays fine", () => { + const template = card(submit("auth-submit"), { $each: "fields", as: "field", children: [{ component: "badge", id: { $concat: ["auth-badge-", { $index: "field" }] }, text: "x" }] }); + expect(issues(auth(template, { actions: ["auth_submit"] }))).toBe(""); + }); + + it("an alert dialog dispatches an action too, and is held to the same rule", () => { + expect(issues(auth(card({ component: "alert-dialog" }), { actions: ["auth_submit"] }))).toMatch(/'alert-dialog' dispatches an action, so it needs an id/); + }); +}); + +describe("H1 on the shipped sidecars: the names each template can emit are the §7 table, and equal its declaration", () => { + for (const ref of REF_IDS) { + it(ref, () => { + const { sidecar, profile } = inputs(ref); + const components = actionComponents(profile); + for (const block of parsed(sidecar).blocks) { + const { names, issues } = templateActionNames(block, components); + expect(issues, block.id).toEqual([]); + expect(names, block.id).toEqual(TABLE[block.id]); + expect([...block.actions].sort(), block.id).toEqual(TABLE[block.id]); + } + }); + } +}); + +describe("loadBlocks: a block whose action names differ from its declaration is disabled at load", () => { + it("one name fewer declared disables that block only, with the H1 reason", async () => { + const { sidecar, contract, profile } = inputs("astryx"); + const changed = structuredClone(sidecar); + const auth = changed.blocks.find((b: Json) => b.id === "auth-form"); + auth.actions = auth.actions.filter((a: string) => a !== "auth_forgot"); + const loaded = await loadBlocks(changed, { baseContract: contract, profile }); + expect(loaded.binding).toBe("ok"); + expect(loaded.usable.map((b) => b.id)).toEqual(["records-table", "guide-steps"]); + expect(loaded.disabled.map((d) => d.block)).toEqual(["auth-form"]); + expect(loaded.disabled[0]!.issues.join(" ")).toMatch(/auth_forgot/); + }); + + it("a template edited so the model fills a button id is disabled at load", async () => { + const { sidecar, contract, profile } = inputs("shadcn-v3"); + const changed = structuredClone(sidecar); + const guide = changed.blocks.find((b: Json) => b.id === "guide-steps"); + const walk = (node: Json): void => { + if (node.component === "button") node.id = { $concat: ["guide-", { $prop: "title" }] }; + for (const child of node.children ?? []) walk(child); + }; + walk(guide.template); + const loaded = await loadBlocks(changed, { baseContract: contract, profile }); + expect(loaded.usable.map((b) => b.id)).toEqual(["records-table", "auth-form"]); + expect(loaded.disabled[0]!.issues.join(" ")).toMatch(/'title' is a string the model fills/); + }); +}); + +/** auth-form's largest call: every field and provider, recovery on. */ +const fullAuthCall = (block: BlockDefinition): Json => ({ + block: block.id, + version: block.version, + mode: "sign-up", + title: "Create your account", + fields: block.props.find((p) => p.name === "fields")!.items!.values!, + intro: "Use your work email.", + providers: block.props.find((p) => p.name === "providers")!.items!.values!, + recovery: true, +}); + +describe("what the emitter dispatches: names within H1's, and an empty context", () => { + for (const ref of REF_IDS) { + it(`${ref}: every example dispatches only names H1 proved, each with an empty context`, () => { + const { sidecar, contract, profile } = inputs(ref); + const loaded = parsed(sidecar); + const components = actionComponents(profile); + for (const block of loaded.blocks) { + const allowed = templateActionNames(block, components).names; + for (const example of block.examples) { + const expanded = expandBlock(block, example.call, { reference: loaded.reference, intent: example.intent }); + if (!expanded.ok) throw new Error(expanded.message); + const events = actionEvents(emitSurface(expanded.surface as never, contract as never, { profile }).messages); + expect(events.length, `${block.id}/${example.id}`).toBeGreaterThan(0); + for (const event of events) { + expect(allowed, `${block.id}/${example.id}`).toContain(event.name); + expect(event.context, `${block.id}/${example.id}`).toEqual({}); + } + } + } + }); + + it(`${ref}: auth-form actions carry an empty context, even for its largest call: no credential or entered value can ride along`, () => { + const { sidecar, contract, profile } = inputs(ref); + const loaded = parsed(sidecar); + const block = loaded.blocks.find((b) => b.id === "auth-form")!; + const call = fullAuthCall(block); + expect(validateCall(block, call)).toEqual([]); + expect(checkConstraints(block, call)).toEqual([]); + const expanded = expandBlock(block, call, { reference: loaded.reference, intent: block.intents[0]! }); + if (!expanded.ok) throw new Error(expanded.message); + const messages = emitSurface(expanded.surface as never, contract as never, { profile }).messages as Json[]; + const events = actionEvents(messages); + expect(events.map((e) => e.name).sort()).toEqual(TABLE["auth-form"]); + for (const event of events) expect(Reflect.ownKeys(event.context as object)).toEqual([]); + // No field is bound to a data model, so an action has nothing to sync: typed text stays in the page. + const bound: unknown[] = []; + const walk = (value: unknown): void => { + if (Array.isArray(value)) value.forEach(walk); + else if (value && typeof value === "object") { + if ("path" in value) bound.push(value); + Object.values(value).forEach(walk); + } + }; + walk(messages); + expect(bound).toEqual([]); + expect(messages.flatMap((m) => Object.keys(m)).sort()).toEqual(["createSurface", "updateComponents", "version", "version"]); + }); + } + + it("actionEvents reads every event, whatever else the messages hold", () => { + const messages = [ + { version: "v0.9", createSurface: { surfaceId: "s" } }, + { version: "v0.9", updateComponents: { surfaceId: "s", components: [{ id: "a", action: { event: { name: "x_one", context: {} } } }, { id: "b", nested: [{ action: { event: { name: "x_two", context: { k: 1 } } } }] }] } }, + ]; + expect(actionEvents(messages)).toEqual([ + { name: "x_one", context: {} }, + { name: "x_two", context: { k: 1 } }, + ]); + }); +}); diff --git a/packages/composer-core/src/blocks/manifest.test.ts b/packages/composer-core/src/blocks/manifest.test.ts new file mode 100644 index 0000000..18c999b --- /dev/null +++ b/packages/composer-core/src/blocks/manifest.test.ts @@ -0,0 +1,293 @@ +/** + * Host actions, H2 (docs/blocks/HOST-ACTIONS.md; docs/blocks/PHASE-2-RELEASE-PLAN.md, + * §7): the per-surface action manifest the beta's Catalog export carries. + * + * It binds every action a surface's messages dispatch by surface and name. A + * surface gets a block binding (block id, version, expansion identity, + * declared actions, streaming policy) only when it verifiably IS its block's + * expansion: its provenance, under an approved sidecar the build bundles, + * re-expands to exactly the stored surface, and emits exactly the exported + * messages, every action declared and every context empty. Anything else, + * forged provenance and edited surfaces included, binds nothing: a surface + * name plus an action name alone grants nothing. + */ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { beforeAll, describe, expect, it } from "vitest"; +import { emitSurface, loadProfile, type Profile } from "@aestheticfunction/dspack-emit"; +import { + ACTION_MANIFEST_VERSION, + actionManifest, + expandBlock, + expansionDigest, + loadBlocks, + parseBlocksSidecar, + type ActionManifest, + type LoadedBlocks, + type ManifestReference, +} from "./index"; + +type Json = Record; +const repo = (rel: string) => fileURLToPath(new URL(`../../../../${rel}`, import.meta.url)); +const json = (rel: string) => JSON.parse(readFileSync(repo(rel), "utf8")) as Json; + +const rawSidecar = json("apps/composer/experimental/blocks/astryx.blocks.json"); +const contract = json("apps/composer/astryx-project/astryx.dspack.json"); +const profile = loadProfile(json("apps/composer/astryx-project/astryx.profile.json") as never) as Profile; +const parsed = parseBlocksSidecar(rawSidecar); +if (!parsed.ok) throw new Error(JSON.stringify(parsed.issues)); +const sidecar = parsed.sidecar; +const authForm = sidecar.blocks.find((b) => b.id === "auth-form")!; +const example = authForm.examples[0]!; +const expanded = expandBlock(authForm, example.call, { reference: sidecar.reference, intent: example.intent }); +if (!expanded.ok) throw new Error(expanded.message); +const surface = expanded.surface as unknown as Json; +const emit = (s: unknown) => emitSurface(s as never, contract as never, { profile }).messages as unknown[]; +const messages = emit(surface); + +/** The surface's name in the export: the accepted example's id. */ +const NAME = "ex.chat-1"; +const PRIMITIVE = "ex.chat-2"; + +/** A hand-built surface (no block) whose button id slugs to a block's action name. */ +const primitive: Json = { + dspackSurface: "0.1", + system: contract.name, + intent: example.intent, + root: { component: "card", id: "auth-card", children: [{ component: "button", id: "auth-submit", props: { label: "Sign in", variant: "primary" } }] }, +}; +const primitiveMessages = emit(primitive); + +let digest: string; +let entry: Json; +let loaded: LoadedBlocks; +let references: ManifestReference[]; + +beforeAll(async () => { + digest = await expansionDigest(surface); + entry = { + v: 1, + reference: sidecar.reference, + baseSha256: sidecar.baseSha256, + block: "auth-form", + version: 1, + intent: example.intent, + blockSchemaSha256: "a".repeat(64), + encoding: "named", + call: example.call, + expansionDigest: digest, + }; + loaded = await loadBlocks(rawSidecar, { baseContract: contract, profile }); + references = [{ loaded, contract, profile }]; +}); + +/** The export of one accepted block surface and one primitive surface, with provenance for the first. */ +const input = (overrides: Json = {}) => ({ + surfaces: [ + { name: NAME, messages }, + { name: PRIMITIVE, messages: primitiveMessages }, + ], + examples: [ + { id: NAME, surface }, + { id: PRIMITIVE, surface: primitive }, + ], + provenance: { [NAME]: entry } as Record, + references, + ...overrides, +}); + +const entryFor = (manifest: ActionManifest, name: string) => manifest.surfaces.find((s) => s.surface === name)!; +const bindsNothing = (manifest: ActionManifest, name: string) => { + const found = entryFor(manifest, name); + expect(found.block).toBeNull(); + expect(found.actions.length).toBeGreaterThan(0); + expect(found.actions.every((a) => a.declared === false)).toBe(true); +}; + +describe("a surface that is its block's expansion", () => { + it("binds each action it dispatches by surface and name, with the block id, version, expansion identity, declared actions and streaming policy", async () => { + const manifest = await actionManifest(input()); + expect(manifest.v).toBe(ACTION_MANIFEST_VERSION); + expect(entryFor(manifest, NAME)).toEqual({ + surface: NAME, + surfaceId: "data_collection", + actions: [ + { name: "auth_provider_github", declared: true }, + { name: "auth_submit", declared: true }, + ], + block: { + id: "auth-form", + version: 1, + reference: sidecar.reference, + baseSha256: sidecar.baseSha256, + intent: example.intent, + expansionDigest: digest, + declaredActions: authForm.actions, + streaming: "disable-actions-until-complete", + }, + }); + }); + + it("names are not capabilities: a primitive surface whose button id slugs to auth_submit binds nothing", async () => { + const manifest = await actionManifest(input()); + expect(entryFor(manifest, PRIMITIVE)).toEqual({ surface: PRIMITIVE, surfaceId: "data_collection", actions: [{ name: "auth_submit", declared: false }], block: null }); + }); + + it("lists every exported surface in the export's order, and none without messages", async () => { + const manifest = await actionManifest(input({ surfaces: [{ name: PRIMITIVE, messages: primitiveMessages }, { name: "ex.refused" }, { name: NAME, messages }] })); + expect(manifest.surfaces.map((s) => s.surface)).toEqual([PRIMITIVE, NAME]); + }); +}); + +describe("forged provenance grants no authority", () => { + const forgeries: Array<[string, () => Json]> = [ + ["a real call filed against another surface's digest", () => ({ ...entry })], + ["a version the sidecar does not have", () => ({ ...entry, version: 2 })], + ["a reference the build does not bundle", () => ({ ...entry, reference: { ...sidecar.reference, version: "9.9.9" } })], + ["a base the sidecar is not bound to", () => ({ ...entry, baseSha256: "0".repeat(64) })], + ["a call with a slot for a password", () => ({ ...entry, call: { ...example.call, password: "hunter2" } })], + ["a call that is not the one that made the surface", () => ({ ...entry, call: { ...example.call, title: "Something else" } })], + ["an intent the block does not serve", () => ({ ...entry, intent: "record-collection" })], + ["an encoding the beta does not write", () => ({ ...entry, encoding: "positional" })], + ["an inert record", () => ({ block: "auth-form", version: 1, status: "inert" })], + ["a record for another block", () => ({ ...entry, block: "guide-steps" })], + ["a recorded digest that is not its call's expansion", () => ({ ...entry, expansionDigest: "f".repeat(64) })], + ]; + + it.each(forgeries)("%s: the primitive surface binds nothing", async (_name, forge) => { + const record = forge(); + const forged = _name.startsWith("a real call") ? { ...record, expansionDigest: await expansionDigest(primitive) } : record; + const manifest = await actionManifest(input({ provenance: { [PRIMITIVE]: forged, [NAME]: entry } })); + bindsNothing(manifest, PRIMITIVE); + }); + + it.each(forgeries.slice(1))("%s: even the real block surface binds nothing", async (_name, forge) => { + const manifest = await actionManifest(input({ provenance: { [NAME]: forge() } })); + bindsNothing(manifest, NAME); + }); + + it("the real entry filed under a primitive surface's name binds nothing", async () => { + const manifest = await actionManifest(input({ provenance: { [PRIMITIVE]: entry } })); + bindsNothing(manifest, PRIMITIVE); + bindsNothing(manifest, NAME); + }); + + it("a record reached only through the prototype is not the surface's", async () => { + const manifest = await actionManifest(input({ provenance: Object.create({ [NAME]: entry }) })); + bindsNothing(manifest, NAME); + }); + + it("an entry the stored surface does not match: provenance is a claim, the expansion is the proof", async () => { + const manifest = await actionManifest(input({ examples: [{ id: NAME, surface: primitive }, { id: PRIMITIVE, surface: primitive }] })); + bindsNothing(manifest, NAME); + }); + + it("a draft sidecar or a draft block binds nothing", async () => { + const draftSidecar = await loadBlocks({ ...structuredClone(rawSidecar), status: "draft" }, { baseContract: contract, profile }); + bindsNothing(await actionManifest(input({ references: [{ loaded: draftSidecar, contract, profile }] })), NAME); + + const draftBlock = structuredClone(rawSidecar); + draftBlock.blocks.find((b: Json) => b.id === "auth-form").status = "draft"; + const loadedDraft = await loadBlocks(draftBlock, { baseContract: contract, profile }); + bindsNothing(await actionManifest(input({ references: [{ loaded: loadedDraft, contract, profile }] })), NAME); + }); + + it("a block B0 disabled at load binds nothing, whatever its provenance says", async () => { + const changed = structuredClone(rawSidecar); + const auth = changed.blocks.find((b: Json) => b.id === "auth-form"); + auth.actions = auth.actions.filter((a: string) => a !== "auth_forgot"); + const disabled = await loadBlocks(changed, { baseContract: contract, profile }); + expect(disabled.disabled.map((d) => d.block)).toEqual(["auth-form"]); + bindsNothing(await actionManifest(input({ references: [{ loaded: disabled, contract, profile }] })), NAME); + }); + + it("a block whose declaration misses a name its template emits binds nothing, even if H1 were bypassed at load", async () => { + const auth = loaded.usable.find((b) => b.id === "auth-form")!; + const bypassed: LoadedBlocks = { ...loaded, usable: loaded.usable.map((b) => (b === auth ? { ...auth, actions: auth.actions.filter((a) => a !== "auth_provider_github") } : b)) }; + const manifest = await actionManifest(input({ references: [{ loaded: bypassed, contract, profile }] })); + bindsNothing(manifest, NAME); + }); + + it("no bundled reference: nothing binds, and nothing throws", async () => { + const manifest = await actionManifest(input({ references: [] })); + bindsNothing(manifest, NAME); + bindsNothing(manifest, PRIMITIVE); + }); + + it("garbage in the provenance map binds nothing, and nothing throws", async () => { + for (const junk of [null, 7, "auth-form", [], { ...entry, call: null }, { ...entry, reference: null }, { ...entry, expansionDigest: 7 }]) { + bindsNothing(await actionManifest(input({ provenance: { [NAME]: junk } })), NAME); + } + }); +}); + +describe("an edited surface recomputes its manifest from its messages", () => { + it("a button added to an accepted block surface: no block binding, and the new action is listed, undeclared", async () => { + const edited = structuredClone(surface); + edited.root.children.push({ component: "button", id: "auth-delete-account", props: { label: "Delete account", variant: "secondary" } }); + const manifest = await actionManifest(input({ surfaces: [{ name: NAME, messages: emit(edited) }], examples: [{ id: NAME, surface: edited }] })); + expect(entryFor(manifest, NAME)).toEqual({ + surface: NAME, + surfaceId: "data_collection", + actions: [ + { name: "auth_delete_account", declared: false }, + { name: "auth_provider_github", declared: false }, + { name: "auth_submit", declared: false }, + ], + block: null, + }); + }); + + it("copy edited alone: the same actions, none of them bound", async () => { + const edited = structuredClone(surface); + edited.root.children[0].text = "Welcome back, admin"; + const manifest = await actionManifest(input({ surfaces: [{ name: NAME, messages: emit(edited) }], examples: [{ id: NAME, surface: edited }] })); + bindsNothing(manifest, NAME); + expect(entryFor(manifest, NAME).actions.map((a) => a.name)).toEqual(["auth_provider_github", "auth_submit"]); + }); + + it("messages that are not the stored surface's own emission bind nothing (a stale or altered export)", async () => { + const manifest = await actionManifest(input({ surfaces: [{ name: NAME, messages: primitiveMessages }] })); + bindsNothing(manifest, NAME); + }); +}); + +describe("undeclared actions and action context fail closed", () => { + /** The verified messages with one component's action replaced. */ + const withAction = (action: Json) => { + const altered = structuredClone(messages) as Json[]; + const components = altered.find((m) => m.updateComponents)!.updateComponents.components as Json[]; + const button = components.find((c) => c.action?.event?.name === "auth_submit")!; + button.action = action; + return altered; + }; + + it("an action the block does not declare binds nothing, and is listed undeclared", async () => { + const manifest = await actionManifest(input({ surfaces: [{ name: NAME, messages: withAction({ event: { name: "auth_delete_everything", context: {} } }) }] })); + bindsNothing(manifest, NAME); + expect(entryFor(manifest, NAME).actions).toContainEqual({ name: "auth_delete_everything", declared: false }); + }); + + it("a block action whose context would carry an entered value binds nothing", async () => { + const manifest = await actionManifest(input({ surfaces: [{ name: NAME, messages: withAction({ event: { name: "auth_submit", context: { password: { path: "/password" } } } }) }] })); + bindsNothing(manifest, NAME); + }); + + it("an action with no name binds nothing", async () => { + const manifest = await actionManifest(input({ surfaces: [{ name: NAME, messages: withAction({ event: { context: {} } }) }] })); + expect(entryFor(manifest, NAME).block).toBeNull(); + }); + + it("two exported surfaces under one name bind nothing: a host could not tell them apart", async () => { + const manifest = await actionManifest(input({ surfaces: [{ name: NAME, messages }, { name: NAME, messages }] })); + expect(manifest.surfaces).toHaveLength(2); + for (const found of manifest.surfaces) expect(found.block).toBeNull(); + }); + + it("messages that name more than one surface report no single surfaceId, and bind nothing", async () => { + const two = [...messages, ...emit({ ...surface, intent: "record-collection" })]; + const manifest = await actionManifest(input({ surfaces: [{ name: NAME, messages: two }] })); + expect(entryFor(manifest, NAME).surfaceId).toBeNull(); + expect(entryFor(manifest, NAME).block).toBeNull(); + }); +}); From 6eb9d720efd36894c982f3043acdfbe62d149862 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 16:26:10 +0000 Subject: [PATCH 2/8] Host actions: H1 at load, the beta export's action manifest, and the host guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 step 3 (docs/blocks/PHASE-2-RELEASE-PLAN.md, §7), limited to H1, H2 and H4. Composer still executes no action. - H1 (composer-core blocks/actions.ts, in B0): every node the profile gives an action has an id built only from literals and enum props, and the names a template can emit equal its declared actions, exactly. Either failure disables the block at load. - H2 (blocks/manifest.ts; the runtime's withActionManifest): in an experimental build, Preview's "export catalog" adds an actionManifest that binds each surface's actions by surface and name. A surface gets a block binding (id, version, expansion identity, declared actions, streaming policy) only when its provenance re-expands, under an approved bundled sidecar, to exactly the stored surface and the exported messages. - The default export: Preview's builder moves to app/catalog-export.ts, unchanged; the default build's export is byte-identical and carries no manifest code. - H4: docs/blocks/HOST-ACTIONS.md, with server-side authorization, idempotency, streaming, untrusted model content and the auth-form rule. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R6Ax8qvfN3gYUC85oSGnoi --- apps/composer/app/catalog-export.ts | 53 +++++ apps/composer/app/state.tsx | 21 +- apps/composer/app/views/preview-view.tsx | 52 +++-- apps/composer/experimental/blocks-runtime.ts | 41 +++- docs/BLOCKS.md | 7 +- docs/blocks/HOST-ACTIONS.md | 138 +++++++++++++ docs/blocks/PHASE-2-RELEASE-PLAN.md | 1 + packages/composer-core/src/blocks/actions.ts | 179 +++++++++++++++++ packages/composer-core/src/blocks/index.ts | 12 ++ packages/composer-core/src/blocks/load.ts | 8 +- packages/composer-core/src/blocks/manifest.ts | 184 ++++++++++++++++++ .../composer-core/src/blocks/provenance.ts | 14 +- packages/composer-core/src/blocks/view.ts | 8 +- 13 files changed, 676 insertions(+), 42 deletions(-) create mode 100644 apps/composer/app/catalog-export.ts create mode 100644 docs/blocks/HOST-ACTIONS.md create mode 100644 packages/composer-core/src/blocks/actions.ts create mode 100644 packages/composer-core/src/blocks/manifest.ts diff --git a/apps/composer/app/catalog-export.ts b/apps/composer/app/catalog-export.ts new file mode 100644 index 0000000..46bb6cb --- /dev/null +++ b/apps/composer/app/catalog-export.ts @@ -0,0 +1,53 @@ +/** + * The Catalog export (Preview's "export catalog"): the emitted A2UI catalog, + * the vocabulary an A2UI/AG-UI client consumes, plus every emitted surface's + * messages (worked AG-UI payloads), as one JSON download. A pure browser + * download, in demo mode and agent mode alike, with no filesystem round + * trip, so a hosted user can take the catalog away and use it. + * + * The beta's experimental build adds a per-surface action manifest to the + * bundle (docs/blocks/HOST-ACTIONS.md). Nothing here knows of it, and the + * default build's export stays byte-identical to the one before it + * (catalog-export.test.ts). + */ + +/** What the export reads from an emit. */ +export interface CatalogEmit { + catalog?: unknown; + surfaces?: ReadonlyArray<{ name: string; messages?: unknown[] }>; +} + +export interface CatalogBundle { + a2uiCatalog: unknown; + surfaces: Array<{ name: string; messages: unknown[] }>; + exportedAt: string; +} + +/** The catalog and every surface that emitted; a refused surface has no messages to take away. */ +export function catalogBundle(emit: CatalogEmit, exportedAt: string): CatalogBundle { + return { + a2uiCatalog: emit.catalog, + surfaces: (emit.surfaces ?? []) + .filter((s) => s.messages) + .map((s) => ({ name: s.name, messages: s.messages as unknown[] })), + exportedAt, + }; +} + +/** The bytes the download holds. */ +export function catalogJson(bundle: object): string { + return JSON.stringify(bundle, null, 2); +} + +/** Download a bundle as `-catalog.json`. */ +export function downloadCatalog(bundle: object, name: string): void { + const blob = new Blob([catalogJson(bundle)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${name.replace(/[^a-z0-9-]+/gi, "-").toLowerCase() || "a2ui"}-catalog.json`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} diff --git a/apps/composer/app/state.tsx b/apps/composer/app/state.tsx index 3cdf036..398707c 100644 --- a/apps/composer/app/state.tsx +++ b/apps/composer/app/state.tsx @@ -242,6 +242,8 @@ export interface ComposerState { importProject: (text: string) => { ok: true; name: string } | { ok: false; error: string }; /** Export a project to a downloadable, portable file. */ exportProject: (id: string) => void; + /** Interface blocks (experimental): a project's block provenance, which its project export and the beta's Catalog export carry. Always empty in default builds. */ + blockProvenanceFor: (projectId: string) => Record; /** Open a packaged reference as a read-only EXAMPLE workspace (ephemeral — * never in "Your projects", pristine on every open, session edits only). */ openExample: (referenceId: string) => void; @@ -640,6 +642,17 @@ export function ComposerProvider({ children }: { children: ReactNode }) { [openProject], ); + /** + * Interface blocks (experimental): a project's block provenance, for its + * project export and the beta's Catalog export (docs/blocks/HOST-ACTIONS.md). + * An Example session keeps its own in memory. Default builds read none. + */ + const blockProvenanceFor = useCallback( + (id: string): Record => + process.env.NEXT_PUBLIC_COMPOSER_EXPERIMENTAL === "1" ? (exampleProject && id === exampleProject.id ? sessionProvenance.current : loadBlockProvenance(id)) : {}, + [exampleProject], + ); + /** * Export a project to a downloadable file — its identity plus its governed * vocabulary, nothing machine-specific. The OPEN project exports exactly @@ -684,13 +697,12 @@ export function ComposerProvider({ children }: { children: ReactNode }) { const projectFlows = id === activeProjectId ? flows : loadFlowsStore(id); // Interface blocks (experimental): provenance is project data and rides // along, additive like flows. Default builds export none. - const blockProvenance = - process.env.NEXT_PUBLIC_COMPOSER_EXPERIMENTAL === "1" ? (exampleProject && id === exampleProject.id ? sessionProvenance.current : loadBlockProvenance(id)) : {}; + const blockProvenance = blockProvenanceFor(id); downloadProjectExport( buildProjectExport({ name: p.name, description: p.description, vocab, exportedAt: new Date().toISOString(), flows: projectFlows, blockProvenance }), ); }, - [activeProjectId, contract, profile, manifest, flows, exampleProject], + [activeProjectId, contract, profile, manifest, flows, blockProvenanceFor], ); /** Return to the hub without loading a project (ends an example session). */ @@ -1668,6 +1680,7 @@ export function ComposerProvider({ children }: { children: ReactNode }) { deleteProject, importProject, exportProject, + blockProvenanceFor, openExample, isExample: exampleProject !== null, duplicateExample, @@ -1703,7 +1716,7 @@ export function ComposerProvider({ children }: { children: ReactNode }) { experimental, setExperimental, }), - [mode, agentUp, projectPath, manifest, contract, profile, ledger, rediscovery, emit, validate, busy, notice, selected, connect, referenceId, loadReference, referenceExampleIds, projects, activeProject, newProject, openProject, closeProject, renameProject, duplicateProject, deleteProject, importProject, exportProject, openExample, exampleProject, duplicateExample, discover, rediscover, saveContract, saveProfile, resolveDeletion, resolveConflict, clearTombstone, acceptFreshFact, runEmit, runValidate, flows, saveFlows, buildTurns, buildBusy, buildModels, selectableModels, activeModel, setActiveModel, providerConfig, storedProviders, openaiKey, configureLocalProvider, readiness, runBuild, acceptBuildTurn, clearBuildThread, flowComposition, experimental, setExperimental], + [mode, agentUp, projectPath, manifest, contract, profile, ledger, rediscovery, emit, validate, busy, notice, selected, connect, referenceId, loadReference, referenceExampleIds, projects, activeProject, newProject, openProject, closeProject, renameProject, duplicateProject, deleteProject, importProject, exportProject, blockProvenanceFor, openExample, exampleProject, duplicateExample, discover, rediscover, saveContract, saveProfile, resolveDeletion, resolveConflict, clearTombstone, acceptFreshFact, runEmit, runValidate, flows, saveFlows, buildTurns, buildBusy, buildModels, selectableModels, activeModel, setActiveModel, providerConfig, storedProviders, openaiKey, configureLocalProvider, readiness, runBuild, acceptBuildTurn, clearBuildThread, flowComposition, experimental, setExperimental], ); return {children}; diff --git a/apps/composer/app/views/preview-view.tsx b/apps/composer/app/views/preview-view.tsx index 0503d2e..35fd1ac 100644 --- a/apps/composer/app/views/preview-view.tsx +++ b/apps/composer/app/views/preview-view.tsx @@ -18,6 +18,7 @@ */ import { useEffect, useMemo, useRef, useState } from "react"; import { A2uiCanvas, type A2uiClientAction, type Registry } from "@dspack-studio/a2ui-ingest"; +import { catalogBundle, downloadCatalog } from "../catalog-export"; import { useComposer } from "../state"; import { ViewHeader } from "../ui"; import { isPendingStep, mintStepId, missingSurfaceMessage, nextFlowId, pendingStepMessage, type Flow, type FlowStep } from "../flows"; @@ -33,32 +34,6 @@ import { type RegistryId = "wireframe" | PreviewRegistryId; -/** - * Export the emitted artifacts as a downloadable JSON bundle: the A2UI - * catalog (the vocabulary an A2UI/AG-UI client consumes) plus every emitted - * surface's messages (worked AG-UI payloads). Pure browser download — works - * in demo mode and agent mode alike, with no filesystem round-trip, so a - * hosted user can actually take the catalog away and use it. - */ -function exportBundle(emit: NonNullable["emit"]>, name: string): void { - const bundle = { - a2uiCatalog: emit.catalog, - surfaces: (emit.surfaces ?? []) - .filter((s) => s.messages) - .map((s) => ({ name: s.name, messages: s.messages })), - exportedAt: new Date().toISOString(), - }; - const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `${name.replace(/[^a-z0-9-]+/gi, "-").toLowerCase() || "a2ui"}-catalog.json`; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); -} - const field = { fontFamily: "var(--mono)", fontSize: 12, @@ -102,7 +77,7 @@ const draftFrom = (flow: Flow): FlowDraft => ({ }); export function PreviewView({ focus }: { focus?: "flows" } = {}) { - const { contract, emit, manifest, referenceExampleIds, isExample, mode, activeProject, projectPath, flows, saveFlows } = useComposer(); + const { contract, emit, manifest, referenceExampleIds, isExample, mode, activeProject, projectPath, flows, saveFlows, blockProvenanceFor } = useComposer(); // null = "no explicit choice yet" — the project's own design system draws // it. An explicit pick survives until the project changes (B5). const [registryId, setRegistryId] = useState(null); @@ -117,6 +92,27 @@ export function PreviewView({ focus }: { focus?: "flows" } = {}) { const catalog = emit?.catalog; const surfaces = (emit?.surfaces ?? []).filter((s) => s.messages); + + /** + * The Catalog export (catalog-export.ts). The beta's experimental build adds + * its per-surface action manifest (docs/blocks/HOST-ACTIONS.md); if that + * cannot be built, today's export goes out instead, which binds nothing. + */ + const exportCatalog = (current: NonNullable, name: string) => { + const bundle = catalogBundle(current, new Date().toISOString()); + if (process.env.NEXT_PUBLIC_COMPOSER_EXPERIMENTAL === "1") { + const provenance = activeProject ? blockProvenanceFor(activeProject.id) : {}; + const examples = (contract?.examples as Array<{ id?: unknown; surface?: unknown }> | undefined) ?? []; + void import("../../experimental/blocks-runtime") + .then((runtime) => runtime.withActionManifest(bundle, { surfaces: current.surfaces ?? [], examples, provenance })) + .then( + (withManifest) => downloadCatalog(withManifest, name), + () => downloadCatalog(bundle, name), + ); + return; + } + downloadCatalog(bundle, name); + }; // A surface a person reads by its TITLE — the goal that produced it, or the // name someone authored — with `ex.chat-1` kept beside it for audit (B7). const entriesById = useMemo(() => surfaceEntriesById(contract?.examples), [contract]); @@ -394,7 +390,7 @@ export function PreviewView({ focus }: { focus?: "flows" } = {}) {