From 2cced7395df623392c8692b40cdad791f5a1cf4d Mon Sep 17 00:00:00 2001 From: homen Date: Sun, 19 Jul 2026 20:50:22 -0700 Subject: [PATCH] feat(integrations): add NodeSlide proof across docs, package, scripts, src, and tests --- docs/integrations/NODESLIDE_CONSUMER_PROOF.md | 101 ++++ package.json | 1 + scripts/nodeslide-consumer-proof.ts | 498 ++++++++++++++++++ src/integrations/nodeslide/hostPrincipal.ts | 61 +++ tests/nodeSlideHostPrincipal.test.ts | 51 ++ 5 files changed, 712 insertions(+) create mode 100644 docs/integrations/NODESLIDE_CONSUMER_PROOF.md create mode 100644 scripts/nodeslide-consumer-proof.ts create mode 100644 src/integrations/nodeslide/hostPrincipal.ts create mode 100644 tests/nodeSlideHostPrincipal.test.ts diff --git a/docs/integrations/NODESLIDE_CONSUMER_PROOF.md b/docs/integrations/NODESLIDE_CONSUMER_PROOF.md new file mode 100644 index 00000000..9c6593f7 --- /dev/null +++ b/docs/integrations/NODESLIDE_CONSUMER_PROOF.md @@ -0,0 +1,101 @@ +# NodeSlide consumer contract in NodeRoom + +This is the first cross-repository proof of NodeSlide's injectable repository +boundary. It deliberately does **not** mount a second runtime, replace +NodeRoom's authentication, add Convex tables, or commit an absolute dependency +on an unpublished package. + +The proof loads a built `@nodeslide/testing` entrypoint from either a sibling +NodeSlide checkout or an npm package tarball. It then verifies: + +```text +host-verified NodeRoom actor + -> normalized NodeSlide principal + -> create two unapplied proposals from v1 + -> review both candidates + -> accept one proposal and advance to v2 + -> reject the competing stale base through CAS + -> preserve versions and trace-bound receipts + -> replay acceptance idempotently +``` + +## Repository-root mode + +Install NodeSlide dependencies first. The command builds its package +workspaces, then consumes the built package entrypoint: + +```powershell +$env:NODESLIDE_ROOT = "D:\path\to\NodeSlide" +npm run nodeslide:consumer:proof +``` + +Portable shell/CI form: + +```bash +NODESLIDE_ROOT=../NodeSlide npm run nodeslide:consumer:proof +``` + +Use `-- --skip-build` only when the package artifacts were already built in a +prior CI step. + +## Packed-artifact mode + +NodeRoom can consume a packed artifact without a sibling checkout or a +committed `file:` dependency: + +```powershell +# In NodeSlide +npm run packages:build +npm pack --workspace packages/testing --pack-destination .artifacts + +# In NodeRoom +$env:NODESLIDE_PACKAGE_ARTIFACT = "D:\path\to\.artifacts\nodeslide-testing-0.1.0.tgz" +npm run nodeslide:consumer:proof +``` + +`NODESLIDE_PACKAGE_ARTIFACT` may also name a directory containing exactly one +`nodeslide-testing-*.tgz`. The harness installs the tarball with scripts +disabled in an operating-system temp directory and removes that directory when +the proof finishes. + +Write a machine-readable receipt without committing generated output: + +```bash +npm run nodeslide:consumer:proof -- --root ../NodeSlide --json-out .proofloop/nodeslide-consumer.json +``` + +## What this proves + +- NodeRoom can consume the built package boundary through a backend-neutral + repository port. +- A proposal remains unapplied until review and acceptance. +- Acceptance advances the deck exactly once and produces a trace-bound + receipt. +- A competing proposal pinned to the old base becomes stale instead of + overwriting the accepted change. +- The NodeRoom actor/principal adapter is normalization-only. Existing + `ActorProof` and room-membership verification remain authoritative. +- No NodeRoom NodeAgent, auth, route, artifact, or Convex implementation is + replaced by this proof. + +## What remains before a mounted product integration + +This is not yet a NodeSlide studio mounted in NodeRoom. That requires separate, +versioned deliverables that do not exist in the current package slice: + +1. a controlled/headless React package (canvas, selection, proposal review, + presenter, and accessibility contracts); +2. a production NodeRoom repository adapter mapping NodeSlide deck snapshots, + proposals, versions, and receipts onto NodeRoom's existing artifact and CAS + authority without parallel tables; +3. an explicit translation between NodeRoom's current deck storyboard object + model and NodeSlide `DeckSpec`; +4. server-side authorization that validates the existing NodeRoom + `ActorProof`, membership, and write policy before creating the normalized + principal; +5. a published version or immutable tarball digest for every consumed package; +6. browser proof covering mount, reload, agent proposal, comparison, + acceptance, presenter, PPTX export, and reopen. + +Until those boundaries are available, the repository-port proof is kept +explicit instead of hiding source imports behind a pretend production adapter. diff --git a/package.json b/package.json index 32864ee3..d994bd6f 100644 --- a/package.json +++ b/package.json @@ -161,6 +161,7 @@ "proofloop:proximitty:clips": "node proofloop/adapters/generate-clips.mjs --suite=proximitty-underwriting-pr0 --run=latest", "nodeagent:frame:smoke": "tsx scripts/nodeagent-frame-smoke.ts", "nodeagent:ingestion:smoke": "tsx scripts/nodeagent-ingestion-orchestrator-smoke.ts", + "nodeslide:consumer:proof": "tsx scripts/nodeslide-consumer-proof.ts", "omnigent:nodeagent:smoke": "tsx scripts/omnigent-nodeagent-smoke.ts --json-out docs/eval/omnigent-nodeagent-smoke.json", "nodeagent:api-docs": "tsx scripts/nodeagent-api-docs.ts", "halo:self-improve:smoke": "tsx scripts/halo-self-improvement-smoke.ts --strict", diff --git a/scripts/nodeslide-consumer-proof.ts b/scripts/nodeslide-consumer-proof.ts new file mode 100644 index 00000000..3c86ad41 --- /dev/null +++ b/scripts/nodeslide-consumer-proof.ts @@ -0,0 +1,498 @@ +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { + mkdir, + mkdtemp, + readFile, + readdir, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { parseArgs } from "node:util"; +import { toNodeSlidePrincipalFromVerifiedActor } from "../src/integrations/nodeslide/hostPrincipal"; + +type JsonRecord = Record; + +interface PortableDeckSnapshot { + deck: { id: string; version: number }; + slides: Array<{ id: string; version: number; elementOrder: string[] }>; + elements: Array<{ id: string; version: number; content?: unknown }>; +} + +interface PortablePatchCommand extends JsonRecord { + id: string; + deckId: string; + traceId?: string; +} + +interface PortableReceipt { + id: string; + operation: string; + deckVersion: number; + traceId?: string; +} + +interface PortableProposal { + id: string; + status: string; + operations: unknown[]; +} + +interface PortableResolution { + status: string; + snapshot: PortableDeckSnapshot; + receipt: PortableReceipt; +} + +interface PortableRepository { + getDeck(input: JsonRecord): Promise; + createProposal(input: JsonRecord): Promise; + resolveProposal(input: JsonRecord): Promise; + listVersions(input: JsonRecord): Promise>; + receiptsForDeck?(deckId: string): PortableReceipt[]; +} + +interface PortableConformanceResult { + proposalVersion: number; + acceptedVersion: number; + versionCount: number; + receiptId: string; + resolution: PortableResolution; +} + +interface NodeSlideTestingModule { + MemoryNodeSlideRepository: new (options: { + snapshots: PortableDeckSnapshot[]; + now: () => number; + authorize: ( + principal: { userId: string; permissions: readonly string[] }, + deckId: string, + action: string, + ) => void; + }) => PortableRepository; + createNodeSlideTestSnapshot(deckId?: string, timestamp?: number): PortableDeckSnapshot; + createNodeSlideTextPatch( + snapshot: PortableDeckSnapshot, + text: string, + id?: string, + ): PortablePatchCommand; + runNodeSlideRepositoryConformance(input: { + repository: PortableRepository; + principal: { + userId: string; + roles: readonly string[]; + permissions: readonly string[]; + }; + initialSnapshot: PortableDeckSnapshot; + proposal: PortablePatchCommand; + }): Promise; +} + +interface LoadedTestingModule { + module: NodeSlideTestingModule; + inputKind: "repository-root" | "packed-artifact"; + packageName: string; + packageVersion: string; + cleanup: () => Promise; +} + +interface ProofOptions { + root?: string; + artifact?: string; + jsonOut?: string; + skipBuild: boolean; +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(`NodeSlide consumer proof failed: ${message}`); +} + +function runNpm(args: string[], cwd: string): void { + 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)); + assert( + npmCli, + "npm-cli.js was not found. Run this proof through npm run nodeslide:consumer:proof.", + ); + execFileSync(process.execPath, [npmCli, ...args], { + cwd, + stdio: "inherit", + env: process.env, + }); +} + +async function readPackageIdentity(packageJsonPath: string): Promise<{ + name: string; + version: string; +}> { + const parsed = JSON.parse(await readFile(packageJsonPath, "utf8")) as { + name?: unknown; + version?: unknown; + }; + assert(typeof parsed.name === "string", `${packageJsonPath} has no package name.`); + assert(typeof parsed.version === "string", `${packageJsonPath} has no package version.`); + return { name: parsed.name, version: parsed.version }; +} + +function validateTestingModule(value: unknown): asserts value is NodeSlideTestingModule { + assert(typeof value === "object" && value !== null, "testing entrypoint is not a module."); + const module = value as Record; + for (const exportName of [ + "MemoryNodeSlideRepository", + "createNodeSlideTestSnapshot", + "createNodeSlideTextPatch", + "runNodeSlideRepositoryConformance", + ]) { + assert(typeof module[exportName] === "function", `testing package lacks ${exportName}.`); + } +} + +async function importTestingModule(entrypoint: string): Promise { + const imported: unknown = await import( + `${pathToFileURL(entrypoint).href}?noderoom-consumer=${Date.now()}` + ); + validateTestingModule(imported); + return imported; +} + +async function loadFromRepositoryRoot( + rootInput: string, + skipBuild: boolean, +): Promise { + const root = resolve(rootInput); + const packageJsonPath = join(root, "packages", "testing", "package.json"); + assert(existsSync(packageJsonPath), `${root} is not a NodeSlide package workspace.`); + const identity = await readPackageIdentity(packageJsonPath); + assert(identity.name === "@nodeslide/testing", `${root} does not contain @nodeslide/testing.`); + if (!skipBuild) runNpm(["run", "packages:build"], root); + const entrypoint = join(root, "packages", "testing", "dist", "index.js"); + assert( + existsSync(entrypoint), + `${entrypoint} is missing. Run NodeSlide's packages:build command first.`, + ); + return { + module: await importTestingModule(entrypoint), + inputKind: "repository-root", + packageName: identity.name, + packageVersion: identity.version, + cleanup: async () => undefined, + }; +} + +async function resolvePackedArtifact(input: string): Promise { + const candidate = resolve(input); + const candidateStat = await stat(candidate); + if (candidateStat.isFile()) return candidate; + assert(candidateStat.isDirectory(), `${candidate} is neither a file nor directory.`); + const entries = (await readdir(candidate)) + .filter((entry) => /^nodeslide-testing-.*\.tgz$/u.test(entry)) + .sort(); + assert(entries.length === 1, `${candidate} must contain exactly one nodeslide-testing-*.tgz.`); + return join(candidate, entries[0]); +} + +async function loadFromPackedArtifact(artifactInput: string): Promise { + const artifact = await resolvePackedArtifact(artifactInput); + assert(artifact.endsWith(".tgz"), `${artifact} is not an npm package tarball.`); + const installRoot = await mkdtemp(join(tmpdir(), "noderoom-nodeslide-consumer-")); + try { + runNpm( + [ + "install", + "--prefix", + installRoot, + "--ignore-scripts", + "--no-package-lock", + "--no-audit", + "--no-fund", + artifact, + ], + installRoot, + ); + const packageRoot = join(installRoot, "node_modules", "@nodeslide", "testing"); + const packageJsonPath = join(packageRoot, "package.json"); + const entrypoint = join(packageRoot, "dist", "index.js"); + assert(existsSync(entrypoint), `${artifact} did not install a testing entrypoint.`); + const identity = await readPackageIdentity(packageJsonPath); + assert(identity.name === "@nodeslide/testing", `${artifact} is not @nodeslide/testing.`); + return { + module: await importTestingModule(entrypoint), + inputKind: "packed-artifact", + packageName: identity.name, + packageVersion: identity.version, + cleanup: () => rm(installRoot, { recursive: true, force: true }), + }; + } catch (error) { + await rm(installRoot, { recursive: true, force: true }); + throw error; + } +} + +function parseOptions(): ProofOptions { + const { values } = parseArgs({ + options: { + root: { type: "string" }, + artifact: { type: "string" }, + "json-out": { type: "string" }, + "skip-build": { type: "boolean", default: false }, + }, + strict: true, + }); + const root = values.root ?? process.env.NODESLIDE_ROOT; + const artifact = values.artifact ?? process.env.NODESLIDE_PACKAGE_ARTIFACT; + assert(!(root && artifact), "provide NODESLIDE_ROOT or a packed artifact, not both."); + assert( + root || artifact, + "set NODESLIDE_ROOT or NODESLIDE_PACKAGE_ARTIFACT (or pass --root/--artifact).", + ); + return { + ...(root ? { root } : {}), + ...(artifact ? { artifact } : {}), + ...(values["json-out"] ? { jsonOut: values["json-out"] } : {}), + skipBuild: values["skip-build"] ?? false, + }; +} + +async function runProof(loaded: LoadedTestingModule): Promise { + const runtime = loaded.module; + const principal = toNodeSlidePrincipalFromVerifiedActor({ + actor: { kind: "user", id: "user:noderoom-consumer", name: "NodeRoom consumer" }, + membershipRole: "host", + hostAuthVerified: true, + allowDeckWrites: true, + }); + const authChecks: Array<{ deckId: string; action: string }> = []; + const authorize = ( + candidate: { userId: string; permissions: readonly string[] }, + deckId: string, + action: string, + ) => { + assert(candidate.userId === principal.userId, "repository accepted another principal."); + const requiredPermission = + action === "read" || action === "list_versions" + ? "nodeslide:read" + : action === "create_proposal" + ? "nodeslide:propose" + : "nodeslide:write"; + assert( + candidate.permissions.includes(requiredPermission), + `host principal lacks ${requiredPermission}.`, + ); + authChecks.push({ deckId, action }); + }; + let clock = 1_700_000_001_000; + const now = () => ++clock; + + const conformanceSnapshot = runtime.createNodeSlideTestSnapshot( + "deck:noderoom:conformance", + 1_700_000_000_000, + ); + const conformanceRepository = new runtime.MemoryNodeSlideRepository({ + snapshots: [conformanceSnapshot], + now, + authorize, + }); + const conformancePatch = runtime.createNodeSlideTextPatch( + conformanceSnapshot, + "Accepted through the NodeRoom consumer", + "patch:noderoom:conformance", + ); + conformancePatch.traceId = "trace:noderoom:conformance"; + const conformance = await runtime.runNodeSlideRepositoryConformance({ + repository: conformanceRepository, + principal, + initialSnapshot: conformanceSnapshot, + proposal: conformancePatch, + }); + assert(conformance.proposalVersion === 1, "proposal mutated the authoritative deck."); + assert(conformance.acceptedVersion === 2, "acceptance did not advance the deck to v2."); + assert(conformance.resolution.status === "accepted", "current proposal was not accepted."); + assert( + conformance.resolution.receipt.traceId === conformancePatch.traceId, + "acceptance receipt lost its trace binding.", + ); + + const casSnapshot = runtime.createNodeSlideTestSnapshot( + "deck:noderoom:cas", + 1_700_000_000_100, + ); + const casRepository = new runtime.MemoryNodeSlideRepository({ + snapshots: [casSnapshot], + now, + authorize, + }); + const acceptedCommand = runtime.createNodeSlideTextPatch( + casSnapshot, + "Accepted candidate", + "patch:noderoom:accepted", + ); + acceptedCommand.traceId = "trace:noderoom:accepted"; + const staleCommand = runtime.createNodeSlideTextPatch( + casSnapshot, + "Competing stale candidate", + "patch:noderoom:stale", + ); + staleCommand.traceId = "trace:noderoom:stale"; + + const acceptedProposal = await casRepository.createProposal({ + deckId: casSnapshot.deck.id, + principal, + patch: acceptedCommand, + }); + const staleProposal = await casRepository.createProposal({ + deckId: casSnapshot.deck.id, + principal, + patch: staleCommand, + }); + assert( + acceptedProposal.status === "ready" && staleProposal.status === "ready", + "candidate review state was not ready.", + ); + assert( + acceptedProposal.operations.length > 0 && staleProposal.operations.length > 0, + "candidate review omitted proposed operations.", + ); + const beforeDecision = await casRepository.getDeck({ + deckId: casSnapshot.deck.id, + principal, + }); + assert(beforeDecision?.deck.version === 1, "proposal creation changed canonical state."); + + const accepted = await casRepository.resolveProposal({ + deckId: casSnapshot.deck.id, + principal, + proposalId: acceptedProposal.id, + decision: "accept", + }); + assert(accepted.status === "accepted", "reviewed proposal was not accepted."); + assert(accepted.snapshot.deck.version === 2, "accepted proposal did not create v2."); + assert( + accepted.snapshot.elements[0]?.content === "Accepted candidate", + "accepted text was not applied.", + ); + assert( + accepted.receipt.operation === "proposal.accepted" && + accepted.receipt.traceId === acceptedCommand.traceId, + "accepted proposal receipt is incomplete.", + ); + + const stale = await casRepository.resolveProposal({ + deckId: casSnapshot.deck.id, + principal, + proposalId: staleProposal.id, + decision: "accept", + }); + assert(stale.status === "stale", "competing base-version proposal did not fail CAS."); + assert(stale.snapshot.deck.version === 2, "stale proposal changed canonical deck version."); + assert( + stale.receipt.operation === "proposal.stale" && stale.receipt.traceId === staleCommand.traceId, + "stale proposal receipt is incomplete.", + ); + + const acceptedAgain = await casRepository.resolveProposal({ + deckId: casSnapshot.deck.id, + principal, + proposalId: acceptedProposal.id, + decision: "accept", + }); + assert( + acceptedAgain.receipt.id === accepted.receipt.id, + "idempotent acceptance produced another receipt.", + ); + const finalSnapshot = await casRepository.getDeck({ + deckId: casSnapshot.deck.id, + principal, + }); + const versions = await casRepository.listVersions({ + deckId: casSnapshot.deck.id, + principal, + }); + assert(finalSnapshot?.deck.version === 2, "replayed acceptance advanced the version twice."); + assert( + versions.map((version) => version.version).sort().join(",") === "1,2", + "version history is not exactly v1 -> v2.", + ); + const receipts = casRepository.receiptsForDeck?.(casSnapshot.deck.id) ?? []; + const receiptOperations = receipts.map((receipt) => receipt.operation); + for (const required of ["proposal.created", "proposal.accepted", "proposal.stale"]) { + assert(receiptOperations.includes(required), `receipt ledger lacks ${required}.`); + } + + return { + schemaVersion: "noderoom.nodeslide-consumer-proof/v1", + passed: true, + package: { + name: loaded.packageName, + version: loaded.packageVersion, + inputKind: loaded.inputKind, + entrypoint: + loaded.inputKind === "repository-root" + ? "packages/testing/dist/index.js" + : "@nodeslide/testing/dist/index.js", + }, + host: { + application: "NodeRoom", + runtimeChanged: false, + authAuthority: "NodeRoom ActorProof and room membership (normalization only in this proof)", + principalId: principal.userId, + roles: principal.roles, + permissions: principal.permissions, + authorizationChecks: authChecks.length, + }, + lifecycle: { + proposalVersion: conformance.proposalVersion, + acceptedVersion: conformance.acceptedVersion, + conformanceReceiptId: conformance.receiptId, + reviewedProposalStatus: acceptedProposal.status, + acceptedStatus: accepted.status, + acceptedReceipt: accepted.receipt, + staleStatus: stale.status, + staleReceipt: stale.receipt, + finalVersion: finalSnapshot?.deck.version, + versions: versions.map((version) => version.version).sort(), + receiptOperations, + acceptanceReplayWasIdempotent: acceptedAgain.receipt.id === accepted.receipt.id, + }, + scope: { + repositoryPort: true, + proposalReviewAccept: true, + compareAndSwap: true, + versions: true, + receipts: true, + productionBackend: false, + mountedReactStudio: false, + }, + }; +} + +async function main(): Promise { + const options = parseOptions(); + const loaded = options.root + ? await loadFromRepositoryRoot(options.root, options.skipBuild) + : await loadFromPackedArtifact(options.artifact as string); + try { + const proof = await runProof(loaded); + const serialized = `${JSON.stringify(proof, null, 2)}\n`; + if (options.jsonOut) { + const outputPath = resolve(options.jsonOut); + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, serialized, "utf8"); + } + process.stdout.write(serialized); + } finally { + await loaded.cleanup(); + } +} + +main().catch((error: unknown) => { + const message = error instanceof Error ? error.stack ?? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exitCode = 1; +}); diff --git a/src/integrations/nodeslide/hostPrincipal.ts b/src/integrations/nodeslide/hostPrincipal.ts new file mode 100644 index 00000000..f530e26f --- /dev/null +++ b/src/integrations/nodeslide/hostPrincipal.ts @@ -0,0 +1,61 @@ +import type { Actor } from "../../engine/types"; + +export type NodeRoomNodeSlideMembershipRole = "host" | "member"; + +export type NodeRoomNodeSlidePermission = + | "nodeslide:read" + | "nodeslide:propose" + | "nodeslide:write"; + +/** + * Structural subset of the portable NodeSlide principal contract. + * + * This intentionally does not import an unpublished NodeSlide package. The + * package consumer proof checks the shape at runtime; a versioned package can + * replace this structural seam once it is published or installed from a + * pinned artifact. + */ +export interface NodeRoomNodeSlidePrincipal { + userId: string; + roles: readonly string[]; + permissions: readonly NodeRoomNodeSlidePermission[]; +} + +export interface VerifiedNodeRoomActorForNodeSlide { + actor: Actor; + membershipRole: NodeRoomNodeSlideMembershipRole; + /** + * Must be set only after NodeRoom has verified its existing ActorProof and + * room membership. This adapter is normalization, not an auth replacement. + */ + hostAuthVerified: true; + allowDeckWrites?: boolean; +} + +/** + * Normalize a host-verified NodeRoom actor for a NodeSlide repository port. + * NodeRoom remains the identity and authorization authority. + */ +export function toNodeSlidePrincipalFromVerifiedActor( + input: VerifiedNodeRoomActorForNodeSlide, +): NodeRoomNodeSlidePrincipal { + if (input.hostAuthVerified !== true) { + throw new Error("NodeSlide principal normalization requires verified NodeRoom host auth."); + } + + const actorRole = + input.actor.kind === "agent" + ? `noderoom:agent:${input.actor.scope ?? "public"}` + : `noderoom:${input.membershipRole}`; + const permissions: NodeRoomNodeSlidePermission[] = [ + "nodeslide:read", + "nodeslide:propose", + ]; + if (input.allowDeckWrites === true) permissions.push("nodeslide:write"); + + return { + userId: input.actor.id, + roles: [actorRole], + permissions, + }; +} diff --git a/tests/nodeSlideHostPrincipal.test.ts b/tests/nodeSlideHostPrincipal.test.ts new file mode 100644 index 00000000..0bd486dc --- /dev/null +++ b/tests/nodeSlideHostPrincipal.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { + toNodeSlidePrincipalFromVerifiedActor, + type VerifiedNodeRoomActorForNodeSlide, +} from "../src/integrations/nodeslide/hostPrincipal"; + +describe("NodeRoom NodeSlide host principal adapter", () => { + it("normalizes a verified host without replacing NodeRoom auth", () => { + const principal = toNodeSlidePrincipalFromVerifiedActor({ + actor: { kind: "user", id: "user:host", name: "Host" }, + membershipRole: "host", + hostAuthVerified: true, + allowDeckWrites: true, + }); + + expect(principal).toEqual({ + userId: "user:host", + roles: ["noderoom:host"], + permissions: ["nodeslide:read", "nodeslide:propose", "nodeslide:write"], + }); + }); + + it("keeps write authority opt-in for a verified agent", () => { + const principal = toNodeSlidePrincipalFromVerifiedActor({ + actor: { + kind: "agent", + id: "agent:private-reviewer", + name: "Private reviewer", + scope: "private", + ownerId: "user:host", + }, + membershipRole: "member", + hostAuthVerified: true, + }); + + expect(principal.roles).toEqual(["noderoom:agent:private"]); + expect(principal.permissions).toEqual(["nodeslide:read", "nodeslide:propose"]); + }); + + it("fails closed if callers bypass the verified-host precondition", () => { + const unverified = { + actor: { kind: "user", id: "user:unknown", name: "Unknown" }, + membershipRole: "member", + hostAuthVerified: false, + } as unknown as VerifiedNodeRoomActorForNodeSlide; + + expect(() => toNodeSlidePrincipalFromVerifiedActor(unverified)).toThrow( + "verified NodeRoom host auth", + ); + }); +});