diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd10a2cb..843ac8fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,7 @@ jobs: - name: Checkout NodeRoom uses: actions/checkout@v4 with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} path: noderoom - name: Resolve immutable NodeSlide producer @@ -115,12 +116,15 @@ jobs: NODESLIDE_PACKAGE_ARTIFACT: ${{ runner.temp }}/nodeslide-packages run: npm run nodeslide:consumer:proof - - name: Verify immutable mounted release and exact producer checkout + - name: Verify immutable mounted release, exact producer, and full mounted journey working-directory: noderoom env: NODESLIDE_ROOT: ${{ github.workspace }}/nodeslide - run: npm run nodeslide:mounted:release:proof + run: npm run nodeslide:mounted:release:proof -- --json-out nodeslide-mounted-release-proof.json - - name: Run smallest mounted Memory and Convex journeys - working-directory: noderoom - run: npm test -- --run tests/nodeSlideMountedMemoryJourney.test.ts tests/nodeSlideMountedConvexJourney.test.ts tests/nodeSlideStudioMount.test.tsx + - name: Upload mounted release receipt + uses: actions/upload-artifact@v4 + with: + name: nodeslide-mounted-release-${{ github.run_id }} + path: noderoom/nodeslide-mounted-release-proof.json + if-no-files-found: error diff --git a/artifacts/nodeslide-mounted-ui-proof-20260720/README.md b/artifacts/nodeslide-mounted-ui-proof-20260720/README.md new file mode 100644 index 00000000..047f4917 --- /dev/null +++ b/artifacts/nodeslide-mounted-ui-proof-20260720/README.md @@ -0,0 +1,9 @@ +# Mounted NodeSlide UI proof — 2026-07-20 PDT + +This folder is the literal browser-camera companion to the deterministic mounted-component journey. It records a real NodeRoom memory sample because the live Convex deployment correctly rejected the disposable unauthenticated sample with `production_identity_required`; no identity check was bypassed. + +- `mounted-artifact-command-surface-clean.jpg` shows the persisted `Q3 diligence readout` after editing `Capture Notebook — mounted v0.2.2`, clicking **Make live**, reopening the artifact, and observing the mounted NodeSlide command controls inside the real NodeRoom Artifacts surface. +- `memory-write-fails-closed-clean.jpg` shows the same mounted surface after **Apply title through NodeSlide** correctly refused a write without a live verified room session. +- `receipt.json` records the a11y/DOM assertions, camera classification, console result, and SHA-256 digests. + +The camera proves the literal mounted React surface and its fail-closed memory behavior. ActorProof, membership, request-digest binding, exact-byte acceptance, grant replay rejection, durable proposal acceptance, reload, presenter/PPTX, and credential-free receipts are proved separately by the deterministic Convex and repository journeys named in `receipt.json`. diff --git a/artifacts/nodeslide-mounted-ui-proof-20260720/memory-write-fails-closed-clean.jpg b/artifacts/nodeslide-mounted-ui-proof-20260720/memory-write-fails-closed-clean.jpg new file mode 100644 index 00000000..b5acec02 Binary files /dev/null and b/artifacts/nodeslide-mounted-ui-proof-20260720/memory-write-fails-closed-clean.jpg differ diff --git a/artifacts/nodeslide-mounted-ui-proof-20260720/mounted-artifact-command-surface-clean.jpg b/artifacts/nodeslide-mounted-ui-proof-20260720/mounted-artifact-command-surface-clean.jpg new file mode 100644 index 00000000..a4ff3aa7 Binary files /dev/null and b/artifacts/nodeslide-mounted-ui-proof-20260720/mounted-artifact-command-surface-clean.jpg differ diff --git a/artifacts/nodeslide-mounted-ui-proof-20260720/receipt.json b/artifacts/nodeslide-mounted-ui-proof-20260720/receipt.json new file mode 100644 index 00000000..fa87cb4a --- /dev/null +++ b/artifacts/nodeslide-mounted-ui-proof-20260720/receipt.json @@ -0,0 +1,74 @@ +{ + "schema": "noderoom.nodeslide-mounted-ui-proof/v1", + "capturedAt": "2026-07-21T02:16:14.6459902Z", + "capturedOnLocalDate": "2026-07-20", + "route": "http://127.0.0.1:5287/?demo=1&name=CodexUI&surface=desktop", + "acceptanceClass": "literal-browser-a11y", + "camera": { + "authorityMode": "memory", + "authenticatedConvexCamera": false, + "authenticatedConvexDeterministicProof": true, + "reason": "The live Convex deployment correctly rejected the disposable unauthenticated sample with production_identity_required; no identity check was bypassed." + }, + "journey": [ + "Open the real NodeRoom Artifacts surface.", + "Open the generated Q3 diligence readout deck.", + "Edit the first slide title to Capture Notebook — mounted v0.2.2.", + "Persist it with Make live.", + "Reopen the persisted deck artifact.", + "Observe the literal mounted NodeSlide region, package boundary, and command controls.", + "Attempt the mounted title command without a live identity and observe the fail-closed alert." + ], + "accessibleTree": { + "region": "NodeSlide studio mounted in NodeRoom", + "workbench": "Deck storyboard workbench", + "deckHeading": "Q3 diligence readout", + "persistedSlideHeading": "Capture Notebook — mounted v0.2.2", + "controls": [ + "NodeSlide title command", + "Apply title through NodeSlide", + "NodeSlide purpose proposal", + "Propose purpose for review" + ], + "failClosedAlert": "The mounted NodeSlide write path requires a live, verified room session." + }, + "domAttributes": { + "aria-label": "NodeSlide studio mounted in NodeRoom", + "data-nodeslide-package-version": "0.2.2", + "data-nodeslide-authority": "noderoom-artifact-cas", + "data-nodeslide-surface": "noderoom-deck-storyboard" + }, + "browserConsole": { + "localApplicationErrors": 0, + "localApplicationWarnings": 0, + "excludedExtensionEntries": 10, + "observation": "A fresh tab completed the full camera journey and remained stable for eight seconds after reopen. Extension-origin entries were excluded from the local application count." + }, + "screenshots": [ + { + "file": "mounted-artifact-command-surface-clean.jpg", + "mediaType": "image/jpeg", + "width": 2560, + "height": 1431, + "bytes": 279084, + "sha256": "0a0022343073ff4a6edf506ba0268f97591118bb41214bdcea87974058b79ac7" + }, + { + "file": "memory-write-fails-closed-clean.jpg", + "mediaType": "image/jpeg", + "width": 2560, + "height": 1431, + "bytes": 275492, + "sha256": "6fc76fec4ffa1920baeeb3a0d56afe93e0ed016174865ac7e4a7ea05274ec409" + } + ], + "deterministicProof": { + "script": "scripts/nodeslide-mounted-release-proof.ts", + "isolatedComponentJourney": "tests/nodeSlideMountedIsolatedComponentJourney.test.ts", + "memoryJourney": "tests/nodeSlideMountedMemoryJourney.test.ts", + "convexJourney": "tests/nodeSlideMountedConvexJourney.test.ts", + "reactMountJourney": "tests/nodeSlideStudioMount.test.tsx", + "producerReleaseRun": "https://github.com/HomenShum/NodeSlide/actions/runs/29787121559", + "producerReleaseTag": "https://github.com/HomenShum/NodeSlide/releases/tag/v0.2.2" + } +} diff --git a/convex/nodeslideHost.ts b/convex/nodeslideHost.ts index ce1ced9e..1d2d2abf 100644 --- a/convex/nodeslideHost.ts +++ b/convex/nodeslideHost.ts @@ -1,6 +1,13 @@ import { v } from "convex/values"; +import { + NODESLIDE_COMPONENT_GRANT_VERSION, + nodeSlideComponentPatchDigest, + type NodeSlideComponentGrant, +} from "@nodeslide/convex/component"; +import type { FunctionReference } from "convex/server"; import type { MutationCtx, QueryCtx } from "./_generated/server"; -import { internalQuery, mutation, query } from "./_generated/server"; +import { internalMutation, internalQuery, mutation, query } from "./_generated/server"; +import { components } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; import { applyCellEditCore, resolveProposalCore } from "./artifacts"; import { actorProofV, requireActorProof, requireArtifactInRoom, sha256Hex, type ActorValue } from "./lib"; @@ -34,6 +41,23 @@ const mountedDeckArgs = { requester: actorProofV, }; +// The packaged component carries its exact repository declarations, but the +// current Convex app codegen narrows this mounted property to `{}`. Keep the +// runtime reference from generated `components` and spell the three consumed +// functions structurally until the upstream codegen preserves that shape. +const nodeSlideComponentRepository = (components.nodeslide as unknown as { + repository: { + initializeDeck: FunctionReference<"mutation", "public", { + snapshot: unknown; + grant: NodeSlideComponentGrant; + }, unknown, "nodeslide">; + getDeck: FunctionReference<"query", "public", { + deckId: string; + grant: NodeSlideComponentGrant; + }, unknown, "nodeslide">; + }; +}).repository; + export const getMountedDeck = query({ args: mountedDeckArgs, handler: async (ctx, args) => { @@ -315,6 +339,62 @@ export const storeMountedReceipt = mutation({ }, }); +/** + * Server-only bridge into the package-owned isolated component namespace. + * NodeRoom authenticates ActorProof and room membership before minting the + * one-time component grant; the component never receives the ActorProof. + */ +export const initializeMountedComponent = internalMutation({ + args: mountedDeckArgs, + handler: async (ctx, args) => { + const deck = await loadDeckArtifact(ctx, args.roomId, args.artifactId); + const translated = translateNodeRoomArtifactToNodeSlide(deck); + const grant = await componentGrantForAuthorizedRequest(ctx, { + ...args, + action: "deck.initialize", + resource: { kind: "deck", id: String(args.artifactId) }, + recordEvidence: true, + }); + const snapshot = await ctx.runMutation(nodeSlideComponentRepository.initializeDeck, { + snapshot: translated.snapshot, + grant, + }); + return { + snapshot, + translationReceipt: translated.receipt, + authorization: componentAuthorizationReceipt(grant), + }; + }, +}); + +export const issueMountedComponentPatchGrant = internalQuery({ + args: { ...mountedDeckArgs, patch: v.any() }, + handler: async (ctx, args) => { + const patch = parsePatchCommand(args.patch); + return componentGrantForAuthorizedRequest(ctx, { + ...args, + action: "patch.apply", + resource: { kind: "patch", id: patch.id }, + patch, + }); + }, +}); + +export const getMountedComponentDeck = internalQuery({ + args: mountedDeckArgs, + handler: async (ctx, args) => { + const grant = await componentGrantForAuthorizedRequest(ctx, { + ...args, + action: "deck.read", + resource: { kind: "deck", id: String(args.artifactId) }, + }); + return ctx.runQuery(nodeSlideComponentRepository.getDeck, { + deckId: String(args.artifactId), + grant, + }); + }, +}); + async function authorizeOperation( ctx: DbCtx, args: { @@ -367,6 +447,72 @@ async function authorizeOperation( }; } +async function componentGrantForAuthorizedRequest( + ctx: DbCtx, + args: { + roomId: Id<"rooms">; + artifactId: Id<"artifacts">; + requester: Requester; + action: "deck.initialize" | "deck.read" | "patch.apply"; + resource: NodeSlideComponentGrant["resource"]; + patch?: NodeRoomNodeSlidePatchCommand; + recordEvidence?: boolean; + }, +): Promise { + if (args.action === "patch.apply" && !args.patch) { + throw new Error("nodeslide_component_patch_required"); + } + const authorization = await authorizeOperation(ctx, { + roomId: args.roomId, + artifactId: args.artifactId, + requester: args.requester, + // Component initialization is a host-only write. Reuse the same strongest + // NodeRoom write-policy decision without pretending it is a repository + // receipt action understood by older NodeSlide consumers. + action: args.action === "deck.read" ? "deck.read" : "patch.apply", + resourceId: args.resource.id, + recordEvidence: args.recordEvidence, + }); + const requestDigest = args.patch + ? await nodeSlideComponentPatchDigest(args.patch) + : undefined; + const authorizedAt = Date.now(); + const grantFingerprint = await sha256Hex([ + authorization.principal.userId, + String(args.artifactId), + args.action, + args.resource.kind, + args.resource.id, + requestDigest ?? "no-request-digest", + authorization.evidence.evidenceId ?? "no-evidence-id", + String(authorizedAt), + ].join("\n")); + return { + schemaVersion: NODESLIDE_COMPONENT_GRANT_VERSION, + id: `grant:noderoom:${grantFingerprint}`, + principalId: authorization.principal.userId, + deckId: String(args.artifactId), + action: args.action, + resource: args.resource, + ...(requestDigest ? { requestDigest } : {}), + authorizedAt, + evidence: authorization.evidence, + }; +} + +function componentAuthorizationReceipt(grant: NodeSlideComponentGrant) { + return { + schemaVersion: "nodeslide.authorization/v1" as const, + id: grant.id, + principalId: grant.principalId, + deckId: grant.deckId, + action: grant.action, + resource: grant.resource, + authorizedAt: grant.authorizedAt, + evidence: grant.evidence, + }; +} + async function loadDeckArtifact( ctx: DbCtx, roomId: Id<"rooms">, diff --git a/docs/NEXT_SESSION.md b/docs/NEXT_SESSION.md index 83cd24c1..635cb5aa 100644 --- a/docs/NEXT_SESSION.md +++ b/docs/NEXT_SESSION.md @@ -8,6 +8,8 @@ producer is NodeSlide main commit [`v0.2.2`](https://github.com/HomenShum/NodeSlide/releases/tag/v0.2.2). The public clean-install, immutable-upgrade, exact-rebuild, tamper, and mixed-release proof passed in [run 29787121559](https://github.com/HomenShum/NodeSlide/actions/runs/29787121559). +The release is bound to annotated tag object +`ec4870300e1ad7ddd74209aada3a47a26779b4bb`. ## Shipped integration @@ -23,13 +25,25 @@ proof passed in [run 29787121559](https://github.com/HomenShum/NodeSlide/actions - `NodeRoomNodeSlideStudioMount.tsx` mounts the packed `@nodeslide/react` controlled shell inside the real deck workbench. Members can propose; only hosts can patch or decide. +- `artifacts/nodeslide-mounted-ui-proof-20260720/` contains the literal browser + camera and a11y receipt for the real Artifacts surface. It proves edit, + persistence via **Make live**, reopen, the 0.2.2 mounted DOM boundary, command + controls, and fail-closed memory behavior with zero local application console + errors or warnings on the final fresh-tab pass. - The memory journey runs a real NodeAgent tool call, host review, competing stale proposal, repository reconstruction, presenter/PPTX generation, archive reopen, snapshot revalidation, and credential-free receipt checks. - The Convex journey proves the same authorization, CAS, proposal, reload, version-history, room-activity, and receipt behavior on durable tables. +- The isolated-component journey starts from real NodeRoom ActorProof and room + membership, initializes the package namespace, computes the canonical patch + digest, rejects substituted bytes without consuming the grant, accepts the + exact command, rejects replay, and proves `requestDigest` never enters a + NodeRoom receipt. It asserts semantic parity because NodeRoom intentionally + re-derives brief/source timestamps and owns a separate slide-object clock. - `vendor/nodeslide/release-lock.json` binds the complete 11-package v0.2.2 set, - the 0.1.0 to 0.2.2 upgrade receipt, and all package/manifest digests to exact + the public 0.1.0 to 0.2.2 upgrade receipt, and all package/manifest digests to + exact NodeSlide main. `package-lock.json` integrity-pins the six runtime packages. - CI reads that lock, checks out the exact NodeSlide commit, runs the legacy portable consumer proof, verifies the complete immutable release, and runs @@ -42,7 +56,7 @@ npm run typecheck npm run design:audit npm run nodeagent:frame:smoke npm run omnigent:nodeagent:smoke -npm test -- --run tests/nodeSlideMountedMemoryJourney.test.ts tests/nodeSlideMountedConvexJourney.test.ts tests/nodeSlideStudioMount.test.tsx +npm test -- --run tests/nodeSlideMountedMemoryJourney.test.ts tests/nodeSlideMountedConvexJourney.test.ts tests/nodeSlideMountedIsolatedComponentJourney.test.ts tests/nodeSlideStudioMount.test.tsx $env:NODESLIDE_ROOT = "D:\path\to\NodeSlide-at-a88fb57f" npm run nodeslide:mounted:release:proof npm run floor @@ -51,16 +65,22 @@ npm run prod:gate The mounted release proof is fail-closed on manifest/proof digests, every tarball digest, lockstep versions, NodeRoom's package-lock integrities, package -exports, component governance/grant exports, and the exact producer checkout. +exports, component governance/grant exports, fresh isolated-component +digest/replay behavior, the full mounted NodeRoom journey, and the exact +producer checkout. CI uploads the JSON receipt for the exact tested SHA. ## Honest boundary This closes the NodeSlide I4 host-authorizer work and the repository, runtime, -package, and CI portions of I7/I8. The tests prove the mounted React boundary and -the product workbench's command wiring; they are not a recorded real-browser -accessibility/camera acceptance. If that stronger evidence is requested, capture -it as a separate browser proof without weakening or relabeling the deterministic -journeys above. +package, isolated-component, literal mounted-UI, and CI portions of I7/I8. +`v0.2.0` (pre-final review) and `v0.2.1` (cross-platform byte-rebuild mismatch) +remain immutable, superseded history and must not be rebound. The camera proof +is deliberately classified as a memory-room browser pass: the live Convex +deployment correctly rejected a disposable unauthenticated sample with +`production_identity_required`, and no identity check was bypassed. Do not call +the screenshots an authenticated production journey; use the deterministic +Convex and isolated-component tests for ActorProof, authorization, digest, +replay, durability, and receipt claims. Leave unrelated `.qa/` and `.proofloop/` state uncommitted. Before changing NodeAgent, continue to run both required smokes from `AGENTS.md`. diff --git a/docs/integrations/NODESLIDE_CONSUMER_PROOF.md b/docs/integrations/NODESLIDE_CONSUMER_PROOF.md index 40485d6e..3d890c42 100644 --- a/docs/integrations/NODESLIDE_CONSUMER_PROOF.md +++ b/docs/integrations/NODESLIDE_CONSUMER_PROOF.md @@ -31,20 +31,33 @@ The product implementation is separate from that fixture proof: controlled React shell into `DeckStoryboardWorkbench`. - `tests/nodeSlideMountedMemoryJourney.test.ts` and `tests/nodeSlideMountedConvexJourney.test.ts` prove the two authorities. +- `tests/nodeSlideMountedIsolatedComponentJourney.test.ts` mounts the packaged + component under real NodeRoom ActorProof/membership authorization. It proves + the one-time request digest rejects substituted command bytes before grant + consumption, accepts the exact command, rejects replay, and keeps + `requestDigest` out of NodeRoom receipts. - `tests/nodeSlideStudioMount.test.tsx` proves host/member command gating and CAS clocks at the React mount. +- `artifacts/nodeslide-mounted-ui-proof-20260720/` records a literal browser and + accessibility-tree pass through NodeRoom's real Artifacts surface: edit, + **Make live**, reopen, mounted package/authority DOM attributes, command + controls, fail-closed memory write, and a clean local application console. ## Immutable package release -NodeSlide releases -[`v0.1.0`](https://github.com/HomenShum/NodeSlide/releases/tag/v0.1.0) and -[`v0.2.2`](https://github.com/HomenShum/NodeSlide/releases/tag/v0.2.2) are -GitHub-immutable and attested. v0.2.2 is bound to exact producer commit -`a88fb57f111db82e9334d68fa7611a51ed54c3c1` and passed the public -[`v0.1.0 → v0.2.2` install/upgrade proof](https://github.com/HomenShum/NodeSlide/actions/runs/29787121559). +NodeRoom consumes the GitHub-immutable +[`v0.2.2`](https://github.com/HomenShum/NodeSlide/releases/tag/v0.2.2), bound to +annotated tag object `ec4870300e1ad7ddd74209aada3a47a26779b4bb` and exact +producer commit `a88fb57f111db82e9334d68fa7611a51ed54c3c1`. +[`v0.2.0`](https://github.com/HomenShum/NodeSlide/releases/tag/v0.2.0) remains +immutable but was published before the final review gate. `v0.2.1` remains +immutable but its public Windows assets did not reproduce byte-for-byte on +Linux. Both are preserved as superseded history and are not NodeRoom inputs. -The complete v0.2.2 artifact set and upgrade receipt are mirrored under -`vendor/nodeslide/`; `release-lock.json` binds their digests. Run: +The complete v0.2.2 artifact set and public workflow receipt from +[run 29787121559](https://github.com/HomenShum/NodeSlide/actions/runs/29787121559) +are mirrored under `vendor/nodeslide/`; `release-lock.json` binds their +digests. Run: ```powershell $env:NODESLIDE_ROOT = "D:\path\to\NodeSlide-at-a88fb57f" @@ -53,15 +66,23 @@ npm run nodeslide:mounted:release:proof The proof rejects a mismatched producer checkout, changed manifest or upgrade receipt, changed package bytes, mixed versions, package-lock integrity drift, -or missing controlled/component exports. +or missing controlled/component exports. The same command fresh-installs the +runtime packages plus the proof-only testing package, mounts the isolated +component, exercises digest substitution/exact/replay, and runs the real +Memory/Convex/React NodeRoom journey suite. ## CI -The NodeSlide consumer job reads the immutable producer SHA from the committed -release lock instead of following a moving producer branch. It then runs the -portable package proof, the complete mounted release proof, and the smallest -Memory/Convex/React mounted journeys. The main `prod:gate` continues to run the +The NodeSlide consumer job checks out the exact NodeRoom event SHA, reads the +immutable producer SHA from the committed release lock instead of following a +moving producer branch, and checks out that exact NodeSlide commit. It runs the +portable package proof and the complete mounted release/journey proof, then +uploads the machine-readable receipt. The main `prod:gate` continues to run the full NodeRoom corpus. -Recorded browser/a11y media remains a separate acceptance class; deterministic -component tests must not be presented as camera proof. +The recorded browser/a11y media remains a separate acceptance class from the +deterministic component proof. Its camera uses the memory sample because the +live Convex deployment correctly required production identity; it does not +claim an authenticated production camera. The mounted ActorProof, authorization, +digest, replay, durability, reload, and receipt claims come from the separate +Convex/repository/component journeys and must not be inferred from screenshots. diff --git a/nodekit.yaml b/nodekit.yaml index 989e68e1..c471a455 100644 --- a/nodekit.yaml +++ b/nodekit.yaml @@ -13,8 +13,11 @@ consumes: - nodeplatform.environment - nodeagent.agent-run - nodeagent.policy-context + - nodeagent.provider-pi - nodeagent.proposal-review - nodeagent.trace-workpaper + - nodeslide.deck + - nodeslide.deck-patch - nodetrace.trace-ui-store - nodemem.memory - proofloop.certification @@ -42,6 +45,11 @@ proof: receiptSchema: null contractDeclarations: + - concept: nodeagent.provider-pi + signature: agent-model-adapter + path: src/nodeagent/models/piAiAdapter.ts + mode: adapter + origin: nodeagent.provider-pi - concept: nodeagent.agent-run signature: agent-run-result path: src/eval/proofloopAgentAdapters.ts diff --git a/scripts/nodeslide-mounted-release-proof.ts b/scripts/nodeslide-mounted-release-proof.ts index c823a9d4..1f516688 100644 --- a/scripts/nodeslide-mounted-release-proof.ts +++ b/scripts/nodeslide-mounted-release-proof.ts @@ -6,6 +6,8 @@ import { tmpdir } from "node:os"; import { basename, dirname, join, relative, resolve, sep } from "node:path"; import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; +import { convexTest } from "convex-test"; +import { componentsGeneric } from "convex/server"; const execFileAsync = promisify(execFile); const LOCK_SCHEMA = "noderoom.nodeslide-release-lock/v1" as const; @@ -13,8 +15,12 @@ const MANIFEST_SCHEMA = "nodeslide.artifacts/v1" as const; type ReleaseLock = { schemaVersion: typeof LOCK_SCHEMA; + releaseTag: string; + releaseTagObject: string; nodeSlideCommit: string; releaseVersion: string; + immutable: true; + publicProofRunId: number; manifestFile: string; manifestSha256: `sha256:${string}`; installUpgradeProofFile: string; @@ -79,6 +85,8 @@ const runtimeEntries = lock.runtimePackages.map((name) => { if (!entry) fail(`runtime package ${name} is absent from the artifact manifest`); return entry; }); +const testingEntry = byName.get("@nodeslide/testing"); +if (!testingEntry) fail("@nodeslide/testing is absent from the artifact manifest"); const nodeRoomPackageLock = record(await readJson(resolve("package-lock.json")), "NodeRoom package lock"); const packageRows = record(nodeRoomPackageLock.packages, "NodeRoom package lock packages"); @@ -95,14 +103,10 @@ for (const entry of runtimeEntries) { const temporaryRoot = await mkdtemp(join(tmpdir(), "noderoom-nodeslide-release-")); let installedPackages: Array<{ name: string; version: string }> = []; +let isolatedComponentProof: Awaited> | null = null; +const npmCli = resolveNpmCli(); try { await writeFile(join(temporaryRoot, "package.json"), JSON.stringify({ private: true, type: "module" })); - const npmCliCandidates = [ - process.env.npm_execpath, - join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"), - ].filter((candidate): candidate is string => Boolean(candidate)); - const npmCli = npmCliCandidates.find((candidate) => existsSync(candidate)); - if (!npmCli) fail("npm-cli.js was not found; run this proof through npm"); await execFileAsync(process.execPath, [npmCli, "install", "--ignore-scripts", @@ -111,6 +115,7 @@ try { "--no-save", "--package-lock=false", ...runtimeEntries.map((entry) => resolve(artifactRoot, entry.file)), + resolve(artifactRoot, testingEntry.file), ], { cwd: temporaryRoot, maxBuffer: 16 * 1024 * 1024 }); installedPackages = await Promise.all(lock.runtimePackages.map(async (name) => { @@ -134,9 +139,13 @@ try { if (componentPackage.NODESLIDE_COMPONENT_GRANT_VERSION !== "nodeslide.component-grant/v1") { fail("@nodeslide/convex component grant protocol is missing"); } + isolatedComponentProof = await runFreshIsolatedComponentProof(temporaryRoot); } finally { await rm(temporaryRoot, { recursive: true, force: true }); } +if (!isolatedComponentProof) fail("fresh isolated component proof did not complete"); + +const nodeRoomJourneyProof = await runNodeRoomJourneyTests(npmCli); const nodeRoomCommit = await gitCommit(process.cwd()); const nodeRoomDirty = (await execFileAsync("git", ["status", "--porcelain"], { cwd: process.cwd() })).stdout.trim().length > 0; @@ -152,6 +161,10 @@ const receipt = { nodeSlideCommit: lock.nodeSlideCommit, checkedNodeSlideCommit, releaseVersion: lock.releaseVersion, + releaseTag: lock.releaseTag, + releaseTagObject: lock.releaseTagObject, + immutable: lock.immutable, + publicProofRunId: lock.publicProofRunId, releaseId: manifest.releaseId, manifestSha256: lock.manifestSha256, installUpgradeProofSha256: lock.installUpgradeProofSha256, @@ -166,10 +179,14 @@ const receipt = { controlledReactExportVerified: true, convexGovernanceExportVerified: true, componentGrantProtocolVerified: true, + isolatedComponent: isolatedComponentProof, + nodeRoomJourney: nodeRoomJourneyProof, nodeRoomPackageLockPinsVerified: true, producerInstallUpgradeReceiptVerified: true, producerTamperRejectionVerified: upgradeProof.tamperedArtifactRejected, producerMixedReleaseRejectionVerified: upgradeProof.mixedReleaseRejected, + producerCandidateRebuildMatchesPublicAssets: + upgradeProof.candidateRebuildMatchesPublicAssets, exactNodeSlideCheckoutVerified: checkedNodeSlideCommit !== null, exactNodeRoomCommitRecorded: nodeRoomCommit !== null, }, @@ -179,6 +196,213 @@ const output = `${JSON.stringify(receipt, null, 2)}\n`; if (jsonOut) await writeFile(resolve(jsonOut), output); process.stdout.write(output); +async function runFreshIsolatedComponentProof(temporaryRoot: string) { + const packageRoot = join(temporaryRoot, "node_modules", "@nodeslide"); + const componentRoot = join(packageRoot, "convex", "dist", "component"); + const testing = await import( + pathToFileURL(join(packageRoot, "testing", "dist", "index.js")).href + ) as { + createNodeSlideTestSnapshot(deckId: string): { + deck: { id: string; updatedAt: number; version: number }; + elements: Array<{ content?: unknown }>; + }; + createNodeSlideTextPatch( + snapshot: unknown, + text: string, + id?: string, + ): { id: string; deckId: string; summary: string }; + }; + const componentProtocol = await import( + pathToFileURL(join(packageRoot, "convex", "dist", "component.js")).href + ) as { + nodeSlideComponentPatchDigest(patch: unknown): Promise; + }; + const componentSchemaModule = await import( + pathToFileURL(join(packageRoot, "convex", "dist", "componentSchema.js")).href + ) as { default: Parameters["registerComponent"]>[1] }; + + const mounted = convexTest({ + modules: { "./_generated/server.js": async () => ({}) }, + }); + mounted.registerComponent("nodeslide", componentSchemaModule.default, { + "./_generated/server.js": () => import( + pathToFileURL(join(componentRoot, "_generated", "server.js")).href + ), + "./repository.js": () => import( + pathToFileURL(join(componentRoot, "repository.js")).href + ), + }); + const repository = componentsGeneric().nodeslide.repository; + const snapshot = testing.createNodeSlideTestSnapshot( + "deck:noderoom:fresh-mounted-release", + ); + const grant = ( + id: string, + action: "deck.initialize" | "deck.read" | "patch.apply", + resourceKind: "deck" | "patch", + resourceId: string, + authorizedAt: number, + requestDigest?: string, + ) => ({ + schemaVersion: "nodeslide.component-grant/v1" as const, + id: `grant:noderoom:release:${id}`, + principalId: "user:noderoom:release-proof", + deckId: snapshot.deck.id, + action, + resource: { kind: resourceKind, id: resourceId }, + ...(requestDigest ? { requestDigest } : {}), + authorizedAt, + evidence: { + issuer: "noderoom", + policyId: "noderoom.nodeslide.artifact-authority", + policyVersion: "1", + evidenceId: `evidence:${id}`, + }, + }); + + await mounted.mutation(repository.initializeDeck, { + snapshot, + grant: grant( + "initialize", + "deck.initialize", + "deck", + snapshot.deck.id, + snapshot.deck.updatedAt, + ), + }); + const patch = testing.createNodeSlideTextPatch( + snapshot, + "Fresh installed component accepted exact command", + "patch:noderoom:fresh-mounted-release", + ); + const patchDigest = await componentProtocol.nodeSlideComponentPatchDigest(patch); + if (!/^sha256:[0-9a-f]{64}$/u.test(patchDigest)) { + fail("fresh component returned a non-canonical patch digest"); + } + const boundGrant = grant( + "exact-patch", + "patch.apply", + "patch", + patch.id, + snapshot.deck.updatedAt + 1, + patchDigest, + ); + await requireRejected( + () => mounted.mutation(repository.applyPatch, { + deckId: snapshot.deck.id, + patch: { ...patch, summary: "Substituted after authorization" }, + grant: boundGrant, + }), + /not bound/u, + "substituted component command", + ); + const applied = record(await mounted.mutation(repository.applyPatch, { + deckId: snapshot.deck.id, + patch, + grant: boundGrant, + }), "fresh component apply result"); + await requireRejected( + () => mounted.mutation(repository.applyPatch, { + deckId: snapshot.deck.id, + patch, + grant: boundGrant, + }), + /already consumed/u, + "replayed component grant", + ); + const reread = record(await mounted.query(repository.getDeck, { + deckId: snapshot.deck.id, + grant: grant( + "read", + "deck.read", + "deck", + snapshot.deck.id, + snapshot.deck.updatedAt + 2, + ), + }), "fresh component reread"); + const rereadElements = reread.elements; + if ( + !Array.isArray(rereadElements) || + !rereadElements.some((element) => + element && typeof element === "object" && + (element as Record).content === + "Fresh installed component accepted exact command") + ) { + fail("fresh component exact command did not survive reread"); + } + const componentReceipt = record(applied.receipt, "fresh component receipt"); + if (/requestDigest|requester|token|actorProof/iu.test(JSON.stringify(componentReceipt))) { + fail("fresh component receipt leaked a grant digest or NodeRoom credential field"); + } + + return { + mountedFromFreshInstall: true, + initialized: true, + patchDigestVerified: true, + substitutedCommandRejectedBeforeConsumption: true, + exactCommandAccepted: true, + grantReplayRejected: true, + rereadPreservedAcceptedEdit: true, + receiptExcludedRequestDigest: true, + actorProofNeverEnteredComponent: true, + }; +} + +async function runNodeRoomJourneyTests(npmCli: string) { + const testFiles = [ + "tests/nodeSlideMountedMemoryJourney.test.ts", + "tests/nodeSlideMountedConvexJourney.test.ts", + "tests/nodeSlideMountedIsolatedComponentJourney.test.ts", + "tests/nodeSlideStudioMount.test.tsx", + ]; + await execFileAsync(process.execPath, [ + npmCli, + "test", + "--", + "--run", + ...testFiles, + ], { + cwd: process.cwd(), + maxBuffer: 32 * 1024 * 1024, + }); + return { + passed: true, + testFiles, + actorProofAndMembershipAuthorization: true, + createAndManualEdit: true, + existingNodeAgentReviewStayedUnappliedUntilAccept: true, + activityAndCredentialFreeReceipts: true, + reloadAndVersionHistory: true, + presenterPptxReopenAndRevalidation: true, + memoryAndConvexSemanticParity: true, + }; +} + +async function requireRejected( + operation: () => Promise, + expected: RegExp, + label: string, +): Promise { + try { + await operation(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (expected.test(message)) return; + fail(`${label} rejected for the wrong reason: ${message}`); + } + fail(`${label} did not reject`); +} + +function resolveNpmCli(): string { + const candidates = [ + process.env.npm_execpath, + join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"), + ].filter((candidate): candidate is string => Boolean(candidate)); + const npmCli = candidates.find((candidate) => existsSync(candidate)); + if (!npmCli) fail("npm-cli.js was not found; run this proof through npm"); + return npmCli; +} + function argument(name: string): string | undefined { const index = args.indexOf(name); if (index < 0) return undefined; @@ -194,8 +418,15 @@ async function readJson(path: string): Promise { function parseLock(value: unknown): ReleaseLock { const lock = record(value, "release lock"); requireEqual(lock.schemaVersion, LOCK_SCHEMA, "release lock schema"); + requireString(lock.releaseTag, "releaseTag", /^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u); + requireString(lock.releaseTagObject, "releaseTagObject", /^[0-9a-f]{40}$/u); requireString(lock.nodeSlideCommit, "nodeSlideCommit", /^[0-9a-f]{40}$/u); requireString(lock.releaseVersion, "releaseVersion", /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u); + requireEqual(lock.releaseTag, `v${lock.releaseVersion}`, "release tag/version binding"); + requireEqual(lock.immutable, true, "immutable release flag"); + if (!Number.isSafeInteger(lock.publicProofRunId) || Number(lock.publicProofRunId) < 1) { + fail("publicProofRunId is invalid"); + } requireString(lock.manifestFile, "manifestFile", /^[0-9A-Za-z._-]+\.json$/u); requireString(lock.manifestSha256, "manifestSha256", /^sha256:[0-9a-f]{64}$/u); requireString(lock.installUpgradeProofFile, "installUpgradeProofFile", /^[0-9A-Za-z._-]+\.json$/u); @@ -214,6 +445,7 @@ function parseUpgradeProof(value: unknown): { to: { manifestSha256: string; releaseId: string; releaseVersion: string }; tamperedArtifactRejected: true; mixedReleaseRejected: true; + candidateRebuildMatchesPublicAssets: true; } { const proof = record(value, "install-upgrade proof"); requireEqual(proof.schemaVersion, "nodeslide.immutable-install-upgrade-proof/v1", "install-upgrade proof schema"); @@ -231,6 +463,7 @@ function parseUpgradeProof(value: unknown): { "upgradeReceiptAdvanced", "tamperedArtifactRejected", "mixedReleaseRejected", + "candidateRebuildMatchesPublicAssets", ] as const) { requireEqual(proof[claim], true, `install-upgrade ${claim}`); } @@ -243,6 +476,7 @@ function parseUpgradeProof(value: unknown): { }, tamperedArtifactRejected: true, mixedReleaseRejected: true, + candidateRebuildMatchesPublicAssets: true, }; } diff --git a/src/integrations/nodeslide/NodeRoomNodeSlideStudioMount.tsx b/src/integrations/nodeslide/NodeRoomNodeSlideStudioMount.tsx index ccaf4437..400eb312 100644 --- a/src/integrations/nodeslide/NodeRoomNodeSlideStudioMount.tsx +++ b/src/integrations/nodeslide/NodeRoomNodeSlideStudioMount.tsx @@ -3,7 +3,7 @@ import type { NodeSlidePatchCommand, NodeSlideProposalDecision } from "@nodeslid import type { DeckPatch, DeckSnapshot } from "@nodeslide/contracts"; import type { ReactNode } from "react"; -export const NODEROOM_NODESLIDE_PACKAGE_VERSION = "0.2.0" as const; +export const NODEROOM_NODESLIDE_PACKAGE_VERSION = "0.2.2" as const; export interface NodeRoomNodeSlideStudioMountProps { snapshot: DeckSnapshot; diff --git a/src/ui/workArtifacts/DeckStoryboardWorkbench.tsx b/src/ui/workArtifacts/DeckStoryboardWorkbench.tsx index da7ae62e..59ddc946 100644 --- a/src/ui/workArtifacts/DeckStoryboardWorkbench.tsx +++ b/src/ui/workArtifacts/DeckStoryboardWorkbench.tsx @@ -470,7 +470,7 @@ export function DeckStoryboardWorkbench({