From 82dd058d42ba9bd86118a86284ecfa7c19428285 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 19 Jul 2026 12:00:50 +0100 Subject: [PATCH] Add signed Epiphany operator admission boundary --- .env.local.example | 8 +++++ docs/bridge.md | 4 +++ docs/verse-service-contract.md | 2 ++ .../ProviderAdvertisementTests.cs | 4 +++ tests/epiphany-operator-command.test.mjs | 14 ++++++++ tools/bifrost-signed-identity.mjs | 14 ++++++++ tools/epiphany-operator-command-documents.mjs | 20 +++++++++++ tools/epiphany-operator-commands.mjs | 34 +++++++++++++++++++ tools/provider-advertisement.mjs | 20 +++++++++++ 9 files changed, 120 insertions(+) create mode 100644 tests/epiphany-operator-command.test.mjs create mode 100644 tools/bifrost-signed-identity.mjs create mode 100644 tools/epiphany-operator-command-documents.mjs create mode 100644 tools/epiphany-operator-commands.mjs diff --git a/.env.local.example b/.env.local.example index ab751e7..8f7b01d 100644 --- a/.env.local.example +++ b/.env.local.example @@ -2,3 +2,11 @@ # The default compose.local.yaml is self-contained for local smoke testing. COMPOSE_PROJECT_NAME=bifrost-local + +# Epiphany operator admission. No live transport default is assumed. +BIFROST_EPIPHANY_OPERATOR_REQUEST_STORE=.bifrost/epiphany-operator-requests.cc +BIFROST_EPIPHANY_OPERATOR_PRIVATE_KEY=.bifrost/private/epiphany-operator-ed25519.seed +BIFROST_EPIPHANY_OPERATOR_OWNER_DISCORD_ID= +BIFROST_EPIPHANY_OPERATOR_TARGET_RUNTIME_ID=epiphany-yggdrasil +BIFROST_EPIPHANY_OPERATOR_RESULT_IDENTITY_ID= +BIFROST_EPIPHANY_OPERATOR_CULTMESH_URI= diff --git a/docs/bridge.md b/docs/bridge.md index 5a7fa92..6f8c1a6 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -196,6 +196,10 @@ The command is a dry-run shape probe for public surfaces that are real enough to VoidBot remains the Discord observer and mention scheduler. A Face registry entry may opt into a remote `remotePersonaFeedbackTarget`; only then does VoidBot insert an immutable typed `voidbot.discord.persona_feedback_event.v0` observation into the dedicated `BIFROST_PERSONA_FEEDBACK_OBSERVATION_STORE_PATH`. This is an observation inbox, never an actuator queue. Insert collisions must contain exactly equal content. Its canonical path and physical file identity must differ from the outbound Discord-command provider store and all Bifrost-private state. Local Persona mention handling is committed first; observation export runs independently with three bounded idempotent attempts and cannot suppress the local response path. +Discord operator control is a separate authority. VoidBot may insert only a strict `voidbot.discord.epiphany_operator_request.v0` document containing one of `status`, `sleep`, `wake`, or `directive`, a fixed target runtime, Discord provenance, and a positive expiry of at most 60 seconds. Bifrost independently requires the exact configured Discord owner and the existing guild/actor link with a Heimdall actor reference. It derives the Rust field-order packet tuple, hashes its canonical MessagePack bytes, and signs the exact `BifrostOperatorCommandAdmission` tuple for purpose `bifrost.operator-command.delivery.v0` with a dedicated operator Ed25519 seed. The feedback seed is not reused. + +Bifrost does not mount an Epiphany store and has no argv or shell escape hatch. Operator execution crosses a narrow authenticated transport port. The live CultNet RUDP request/reply adapter is still required; configuration deliberately has no localhost default. Until that adapter exists, admission fails closed. A returned `epiphany.operator_command.result.v0` must arrive under the configured Epiphany transport identity and match command id, packet digest, and target runtime. Bifrost never manufactures success. Ordinary Persona messages remain `feedback_only` and cannot enter this path. + Bifrost admits an observation only when guild, channel, target runtime, canonical repository, Persona, producer, and producer runtime match a preconfigured `bifrost.persona_feedback.target_binding.v0` document in Bifrost's private provider store. That binding also owns source visibility, data classification, and the delivery-store target. Bifrost never infers public visibility from Discord identifiers. It classifies the author using its own `bifrost.discord.actor_link.v0` state: a match yields `linked_governance_feedback`; absence yields `unlinked_social_feedback`. Neither class grants adoption, work, release, or deployment authority. `tools/persona-feedback.mjs enroll-identity` creates the Bifrost service's Ed25519 seed with exclusive creation and mode `0600`, then exports its public MessagePack trust anchor. The private seed belongs under Bifrost's service-private state (default `/var/lib/gamecult/bifrost/persona-feedback/persona-feedback-ed25519.seed`) and is checked for regular-file type, exact Unix mode, and service-user ownership every time it signs. VoidBot publishes pending observations only; it neither pumps admission nor receives the signing path. diff --git a/docs/verse-service-contract.md b/docs/verse-service-contract.md index 6e7d16e..542655f 100644 --- a/docs/verse-service-contract.md +++ b/docs/verse-service-contract.md @@ -55,6 +55,8 @@ Required witness path for the hosted service: Bifrost publishes under these CultMesh namespaces: +The operator bridge additionally advertises `commands/epiphany-operator` and `receipts/epiphany-operator`. The command endpoint consumes strict VoidBot inbox documents and emits dedicated Bifrost-signed admissions; the receipt endpoint exposes only Epiphany-origin, command-bound results. The transport adapter is intentionally unresolved until a live authenticated CultNet RUDP request/reply implementation is installed on Yggdrasil. + - `gamecult.bifrost.service` for service registration, health, build/version, schema catalog, and command capability discovery. - `gamecult.bifrost.governance` for motions, topics, comments, votes, diff --git a/tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs b/tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs index 897069a..9aa1c4f 100644 --- a/tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs +++ b/tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs @@ -22,6 +22,8 @@ public sealed class ProviderAdvertisementTests AssertEndpoint(root, "crossing-receipts", "bifrost.crossing_receipt.v1", "bridge"); AssertEndpoint(root, "patron-support-intake", "bifrost.patron_support_event.v0", "patronage"); AssertEndpoint(root, "github-webhooks", "bifrost.work_item.v0", "github"); + AssertEndpoint(root, "epiphany-operator-requests", "voidbot.discord.epiphany_operator_request.v0", "operator"); + AssertEndpoint(root, "epiphany-operator-results", "epiphany.operator_command.result.v0", "epiphany"); AssertSchemaPurpose(root, "bifrost.crossing_receipt.v1", "canonical Bifrost-owned public crossing receipt"); AssertSchemaPurpose(root, "bifrost.patron_support_event.v0", "current hosted Heimdall-signed patron support fact consumed by Bifrost"); AssertWitness(root, ".bifrost/bridge-receipts.cc", "bifrost.crossing_receipt.v1", "current"); @@ -33,6 +35,8 @@ public sealed class ProviderAdvertisementTests AssertBoundaryForbiddenAuthority(root, "bridge", "surface-specific receipts cannot decide crossing completion"); AssertBoundaryCommand(root, "patron", "consume Heimdall-signed Patreon and PayPal support facts"); AssertBoundaryForbiddenAuthority(root, "patron", "does not store Patreon or PayPal provider tokens"); + AssertBoundaryCommand(root, "epiphany-operator", "sign Rust-compatible expiring operator admissions with a dedicated Ed25519 identity"); + AssertBoundaryForbiddenAuthority(root, "epiphany-operator", "does not synthesize successful Epiphany results"); } [Fact] diff --git a/tests/epiphany-operator-command.test.mjs b/tests/epiphany-operator-command.test.mjs new file mode 100644 index 0000000..2c7c9bc --- /dev/null +++ b/tests/epiphany-operator-command.test.mjs @@ -0,0 +1,14 @@ +import test from "node:test";import assert from "node:assert/strict";import {mkdtemp} from "node:fs/promises";import {tmpdir} from "node:os";import {resolve} from "node:path";import {createRequire} from "node:module"; +import {admitOperatorRequest,enrollOperatorIdentity,ImmutableOperatorLedger,OPERATOR_SIGNING_PURPOSE} from "../tools/epiphany-operator-commands.mjs"; +import {admissionTuple,packetTuple,parseOperatorRequest} from "../tools/epiphany-operator-command-documents.mjs"; +import {verifyPurpose} from "../tools/bifrost-signed-identity.mjs"; +const requireCult=createRequire(resolve("E:/Projects/CultLib/packages/cultnet-ts/package.json")),{encode}=requireCult("@msgpack/msgpack"); +const actorLink={guildId:"guild",discordAuthorId:"owner",bifrostActorId:"actor",heimdallActorRef:"heimdall:actor:1"}; +function request(command={kind:"status"}){return {schemaName:"voidbot.discord.epiphany_operator_request",schemaVersion:"voidbot.discord.epiphany_operator_request.v0",requestId:"interaction-1",commandId:"interaction-1",nonce:"interaction-1",sourceEventId:"interaction-1",sourceActorDiscordId:"owner",discordGuildId:"guild",discordChannelId:"channel",discordMessageId:"interaction-1",targetRuntimeId:"epiphany-yggdrasil",issuedAt:"2026-07-19T10:00:00.000Z",expiresAt:"2026-07-19T10:00:30.000Z",producerId:"voidbot",producerRuntimeId:"voidbot-yggdrasil",authorityClass:"operator_request_only",status:"pending",command};} +function result(admission){return {providerIdentityId:"epiphany-host",result:{schemaVersion:"epiphany.operator_command.result.v0",resultId:"result-1",commandId:admission.packet.commandId,packetSha256:admission.packetSha256,targetRuntimeId:admission.packet.targetRuntimeId,disposition:"observed",consequenceKind:"operator-snapshot",consequenceRef:"snapshot-1",completedAt:"2026-07-19T10:00:01.000Z",privateStateExposed:false,operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",detail:"bounded"}};} +async function identity(){const d=await mkdtemp(resolve(tmpdir(),"bifrost-operator-"));return enrollOperatorIdentity(resolve(d,"operator.seed"));} +test("signs the exact Rust packet and admission tuples",async()=>{const id=await identity(),out=await admitOperatorRequest({request:request(),ownerDiscordId:"owner",actorLink,identity:id,encode,expectedEpiphanyIdentityId:"epiphany-host",now:new Date("2026-07-19T10:00:01Z"),transport:{execute:async a=>result(a)}});assert.equal(out.admission.packetSha256,`sha256-${(await import("node:crypto")).createHash("sha256").update(encode(packetTuple(out.admission.packet))).digest("hex")}`);assert.ok(verifyPurpose(id.publicKey,OPERATOR_SIGNING_PURPOSE,encode(admissionTuple(out.admission)),out.admission.providerSignature));assert.equal(out.result.disposition,"observed");}); +test("requires exact owner and linked Heimdall actor",async()=>{const id=await identity();for(const args of [{ownerDiscordId:"other",actorLink},{ownerDiscordId:"owner",actorLink:null},{ownerDiscordId:"owner",actorLink:{...actorLink,heimdallActorRef:""}}])await assert.rejects(()=>admitOperatorRequest({request:request(),identity:id,encode,expectedEpiphanyIdentityId:"epiphany-host",now:new Date("2026-07-19T10:00:01Z"),transport:{execute:async a=>result(a)},...args}));}); +test("strict request, ttl, command bounds, and expiry fail closed",()=>{assert.throws(()=>parseOperatorRequest({...request(),extra:true}));assert.throws(()=>parseOperatorRequest({...request(),expiresAt:"2026-07-19T10:01:01Z"}));assert.throws(()=>parseOperatorRequest(request({kind:"argv",args:["whoami"]})));assert.throws(()=>parseOperatorRequest(request({kind:"sleep",reason:""})));}); +test("does not synthesize success without an authenticated transport result",async()=>{const id=await identity(),base={request:request(),ownerDiscordId:"owner",actorLink,identity:id,encode,expectedEpiphanyIdentityId:"epiphany-host",now:new Date("2026-07-19T10:00:01Z")};await assert.rejects(()=>admitOperatorRequest(base),/live CultNet RUDP adapter is missing/);await assert.rejects(()=>admitOperatorRequest({...base,transport:{execute:async a=>({...result(a),providerIdentityId:"impostor"})}}),/authenticated Epiphany/);await assert.rejects(()=>admitOperatorRequest({...base,transport:{execute:async a=>({...result(a),result:{...result(a).result,packetSha256:"sha256-wrong"}})}}),/not bound/);}); +test("immutable ledger rejects request and result collisions",()=>{const ledger=new ImmutableOperatorLedger(),r=request(),a={x:1};ledger.admit(r.requestId,r,a);assert.throws(()=>ledger.admit(r.requestId,{...r,nonce:"different"},a),/collision/);ledger.complete(r.requestId,{x:1},"epiphany");assert.throws(()=>ledger.complete(r.requestId,{x:2},"epiphany"),/collision/);}); diff --git a/tools/bifrost-signed-identity.mjs b/tools/bifrost-signed-identity.mjs new file mode 100644 index 0000000..2603003 --- /dev/null +++ b/tools/bifrost-signed-identity.mjs @@ -0,0 +1,14 @@ +import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign as ed25519Sign, verify as ed25519Verify } from "node:crypto"; +import { chmod, mkdir, open, readFile, stat } from "node:fs/promises"; +import { dirname } from "node:path"; + +const ID_DOMAIN=Buffer.from("epiphany.host-incarnation.identity.v0\0","utf8"); +const SIGNATURE_DOMAIN=Buffer.from("epiphany.host-incarnation.signature.v0\0","utf8"); + +export async function enrollSignedIdentity(path,label="Bifrost identity") { await mkdir(dirname(path),{recursive:true}); const {privateKey}=generateKeyPairSync("ed25519"),seed=Buffer.from(privateKey.export({format:"jwk"}).d,"base64url"),handle=await open(path,"wx",0o600); try{await handle.writeFile(seed);await handle.sync();}finally{await handle.close();} await chmod(path,0o600); return loadSignedIdentity(path,label); } +export async function loadSignedIdentity(path,label="Bifrost identity") { const info=await stat(path); if(!info.isFile())throw new Error(`${label} must be a regular file.`); if(process.platform!=="win32"&&(info.mode&0o077)!==0)throw new Error(`${label} private key must have mode 0600.`); if(process.platform!=="win32"&&typeof process.geteuid==="function"&&info.uid!==process.geteuid())throw new Error(`${label} private key must be owned by the service user.`); const seed=await readFile(path);if(seed.length!==32)throw new Error(`${label} seed must be exactly 32 bytes.`); const key=createPrivateKey({key:Buffer.concat([Buffer.from("302e020100300506032b657004220420","hex"),seed]),format:"der",type:"pkcs8"}),publicKey=createPublicKey(key).export({format:"der",type:"spki"}).subarray(-32),identityId=createHash("sha256").update(ID_DOMAIN).update(publicKey).digest("hex"); return {identityId,publicKey,key}; } +export function signPurpose(identity,purpose,payload){return ed25519Sign(null,domainMessage(purpose,payload),identity.key);} +export function verifyPurpose(publicKey,purpose,payload,signature){const key=createPublicKey({key:Buffer.concat([Buffer.from("302a300506032b6570032100","hex"),Buffer.from(publicKey)]),format:"der",type:"spki"});return ed25519Verify(null,domainMessage(purpose,payload),key,signature);} +export function trustAnchor(identity,createdAt){return {schemaVersion:"epiphany.host_identity_trust_anchor.v0",identityId:identity.identityId,publicKey:new Uint8Array(identity.publicKey),assurance:"os_service_file_bound",identityCreatedAt:createdAt,sourceIdentityRecordSha256:`sha256-${createHash("sha256").update(identity.publicKey).digest("hex")}`};} +function domainMessage(purpose,payload){const p=Buffer.from(purpose,"utf8");return Buffer.concat([SIGNATURE_DOMAIN,u64(p.length),p,u64(payload.length),payload]);} +function u64(value){const b=Buffer.alloc(8);b.writeBigUInt64BE(BigInt(value));return b;} diff --git a/tools/epiphany-operator-command-documents.mjs b/tools/epiphany-operator-command-documents.mjs new file mode 100644 index 0000000..1bbebe7 --- /dev/null +++ b/tools/epiphany-operator-command-documents.mjs @@ -0,0 +1,20 @@ +export const operatorRequestType="voidbot.discord.epiphany_operator_request"; +export const operatorRequestSchema="voidbot.discord.epiphany_operator_request.v0"; +export const operatorAdmissionType="bifrost.operator_command.delivery"; +export const operatorAdmissionSchema="bifrost.operator_command.delivery.v0"; +export const operatorResultType="epiphany.operator_command.result"; +export const operatorResultSchema="epiphany.operator_command.result.v0"; + +export function buildOperatorDefinitions(defineDocumentType){const make=(type,schemaId,name,parse)=>defineDocumentType({type,schemaName:type,schemaId,schemaVersion:schemaId,contentHash:schemaId,global:false,name,schema:{parse}});return {request:make(operatorRequestType,operatorRequestSchema,"requestId",parseOperatorRequest),admission:make(operatorAdmissionType,operatorAdmissionSchema,"admissionId",parseOperatorAdmission),result:make(operatorResultType,operatorResultSchema,"resultId",parseOperatorResult)};} +export function parseOperatorRequest(value){const v=exactRecord(value,["schemaName","schemaVersion","requestId","commandId","nonce","sourceEventId","sourceActorDiscordId","discordGuildId","discordChannelId","discordMessageId","targetRuntimeId","issuedAt","expiresAt","producerId","producerRuntimeId","authorityClass","status","command"],"operator request"); exact(v.schemaName,operatorRequestType,"schemaName");exact(v.schemaVersion,operatorRequestSchema,"schemaVersion"); for(const k of ["requestId","commandId","nonce","sourceEventId","sourceActorDiscordId","discordGuildId","discordChannelId","discordMessageId","targetRuntimeId","producerRuntimeId"])text(v[k],k); exact(v.producerId,"voidbot","producerId");exact(v.authorityClass,"operator_request_only","authorityClass");exact(v.status,"pending","status"); const issued=date(v.issuedAt,"issuedAt"),expires=date(v.expiresAt,"expiresAt");if(expires<=issued||expires-issued>60000)throw new Error("operator request TTL must be positive and at most 60 seconds.");parseCommand(v.command);return v;} +export function parseOperatorAdmission(v){v=exactRecord(v,["schemaName","schemaVersion","admissionId","packet","packetSha256","sourceObserverId","sourceObserverRuntimeId","provider","bifrostAdmissionReceiptId","authority","providerIdentityId","providerSignature"],"operator admission");exact(v.schemaName,operatorAdmissionType,"schemaName");exact(v.schemaVersion,operatorAdmissionSchema,"schemaVersion");for(const k of ["admissionId","packetSha256","sourceObserverRuntimeId","bifrostAdmissionReceiptId","providerIdentityId"])text(v[k],k);exact(v.sourceObserverId,"voidbot","sourceObserverId");exact(v.provider,"bifrost","provider");exact(v.authority,"exact_operator_command_only","authority");if(!(v.providerSignature instanceof Uint8Array)||v.providerSignature.length!==64)throw new Error("providerSignature must be 64 bytes.");parsePacket(v.packet);return v;} +export function parseOperatorResult(v){v=exactRecord(v,["schemaVersion","resultId","commandId","packetSha256","targetRuntimeId","disposition","consequenceKind","consequenceRef","completedAt","privateStateExposed","operatorStatus","stateStatus","coordinatorAction","brakeStatus","detail"],"operator result");exact(v.schemaVersion,operatorResultSchema,"schemaVersion");for(const k of ["resultId","commandId","packetSha256","targetRuntimeId","disposition","consequenceKind","completedAt"])text(v[k],k);if(!["observed","applied","refused"].includes(v.disposition))throw new Error("result disposition is invalid.");if(v.privateStateExposed!==false)throw new Error("operator result must not expose private state.");for(const k of ["consequenceRef","operatorStatus","stateStatus","coordinatorAction","brakeStatus","detail"])if(typeof v[k]!=="string")throw new Error(`${k} must be a string.`);date(v.completedAt,"completedAt");return v;} +export function packetTuple(p){return [p.commandId,p.nonce,p.sourceEventId,p.sourceActorId,p.discordGuildId,p.discordChannelId,p.discordMessageId,p.targetRuntimeId,p.issuedAt,p.expiresAt,commandTuple(p.command)];} +export function admissionTuple(a){return [a.schemaVersion,a.admissionId,packetTuple(a.packet),a.packetSha256,a.sourceObserverId,a.sourceObserverRuntimeId,a.provider,a.bifrostAdmissionReceiptId,a.authority,a.providerIdentityId];} +function parsePacket(v){v=exactRecord(v,["commandId","nonce","sourceEventId","sourceActorId","discordGuildId","discordChannelId","discordMessageId","targetRuntimeId","issuedAt","expiresAt","command"],"operator packet");for(const k of ["commandId","nonce","sourceEventId","sourceActorId","discordGuildId","discordChannelId","discordMessageId","targetRuntimeId"])text(v[k],k);date(v.issuedAt,"issuedAt");date(v.expiresAt,"expiresAt");parseCommand(v.command);return v;} +function parseCommand(v){if(!v||typeof v!=="object"||Array.isArray(v))throw new Error("command must be an object.");if(v.kind==="status"||v.kind==="wake")return exactRecord(v,["kind"],"command");if(v.kind==="sleep"){exactRecord(v,["kind","reason"],"command");text(v.reason,"reason");if(v.reason.length>512)throw new Error("sleep reason exceeds 512 characters.");return v;}if(v.kind==="directive"){exactRecord(v,["kind","objective"],"command");text(v.objective,"objective");if(v.objective.length>4096)throw new Error("directive objective exceeds 4096 characters.");return v;}throw new Error("command kind is invalid.");} +function commandTuple(v){if(v.kind==="sleep")return {kind:"sleep",reason:v.reason};if(v.kind==="directive")return {kind:"directive",objective:v.objective};return {kind:v.kind};} +function exactRecord(v,keys,label){if(!v||typeof v!=="object"||Array.isArray(v))throw new Error(`${label} must be an object.`);const actual=Object.keys(v).sort(),expected=[...keys].sort();if(actual.join("\0")!==expected.join("\0"))throw new Error(`${label} has unknown or missing fields.`);return v;} +function text(v,label){if(typeof v!=="string"||!v.trim())throw new Error(`${label} is required.`);return v;} +function exact(v,e,label){if(v!==e)throw new Error(`${label} must be ${e}.`);} +function date(v,label){text(v,label);const n=Date.parse(v);if(!Number.isFinite(n)||!/T/.test(v))throw new Error(`${label} must be RFC3339.`);return n;} diff --git a/tools/epiphany-operator-commands.mjs b/tools/epiphany-operator-commands.mjs new file mode 100644 index 0000000..0f1d3f5 --- /dev/null +++ b/tools/epiphany-operator-commands.mjs @@ -0,0 +1,34 @@ +import { createHash } from "node:crypto"; +import { resolve } from "node:path"; +import { admissionTuple, operatorAdmissionSchema, parseOperatorAdmission, parseOperatorRequest, parseOperatorResult, packetTuple } from "./epiphany-operator-command-documents.mjs"; +import { enrollSignedIdentity, loadSignedIdentity, signPurpose, trustAnchor } from "./bifrost-signed-identity.mjs"; + +export const OPERATOR_SIGNING_PURPOSE="bifrost.operator-command.delivery.v0"; +export const defaultOperatorIdentityPath=()=>process.env.BIFROST_EPIPHANY_OPERATOR_PRIVATE_KEY??(process.platform==="win32"?resolve(".bifrost","private","epiphany-operator-ed25519.seed"):"/var/lib/gamecult/bifrost/epiphany-operator/operator-ed25519.seed"); + +// The transport is deliberately a narrow port. The live CultNet RUDP request/reply +// adapter is not yet present in Bifrost; callers must inject an authenticated port. +export async function admitOperatorRequest({request,ownerDiscordId,actorLink,identity,encode,transport,expectedEpiphanyIdentityId,now=new Date(),ledger=new ImmutableOperatorLedger()}){ + parseOperatorRequest(request); + if(request.sourceActorDiscordId!==ownerDiscordId)throw new Error("Epiphany operator command requires the exact configured Discord owner."); + if(!actorLink||actorLink.guildId!==request.discordGuildId||actorLink.discordAuthorId!==request.sourceActorDiscordId||!actorLink.bifrostActorId?.trim()||!actorLink.heimdallActorRef?.trim())throw new Error("Epiphany operator command requires the exact existing Bifrost actor link and Heimdall actor reference."); + const at=now instanceof Date?now:new Date(now);if(!Number.isFinite(at.getTime())||Date.parse(request.issuedAt)>at.getTime()||Date.parse(request.expiresAt)x instanceof Uint8Array?Buffer.from(x).toString("hex"):x);} diff --git a/tools/provider-advertisement.mjs b/tools/provider-advertisement.mjs index 35d5016..8873f8d 100644 --- a/tools/provider-advertisement.mjs +++ b/tools/provider-advertisement.mjs @@ -165,6 +165,9 @@ const locatedService = `${rootVerse}.${currentMachine}.bifrost`; const plannedLocatedService = `${rootVerse}.yggdrasil.bifrost`; const motionSurfaceCultMeshUri = `cultmesh://${locatedService}/eve/governance/surface`; const motionCommandCultMeshUri = `cultmesh://${locatedService}/commands/motion`; +const epiphanyOperatorRequestSchemaId = "voidbot.discord.epiphany_operator_request.v0"; +const epiphanyOperatorAdmissionSchemaId = "bifrost.operator_command.delivery.v0"; +const epiphanyOperatorResultSchemaId = "epiphany.operator_command.result.v0"; const advertisementDefinition = defineDocumentType({ type: documentType, @@ -330,6 +333,8 @@ function buildAdvertisement(options) { endpoint("github-webhooks", `${locatedService}/github/webhooks`, "bifrost.work_item.v0", ["github", "work-sync", "review-sync"]), endpoint("motion-surface", motionSurfaceCultMeshUri, "gamecult.eve.surface.v1", ["cultmesh", "governance", "motion"]), endpoint("motion-commands", motionCommandCultMeshUri, "bifrost.motion_command.v0", ["command", "cultmesh", "governance", "motion"]), + endpoint("epiphany-operator-requests", `${locatedService}/commands/epiphany-operator`, epiphanyOperatorRequestSchemaId, ["command", "discord", "operator", "cultmesh"]), + endpoint("epiphany-operator-results", `${locatedService}/receipts/epiphany-operator`, epiphanyOperatorResultSchemaId, ["receipt", "epiphany", "operator", "cultmesh"]), ], routes: [ route("cultmesh-store", ".bifrost/provider-store.cc", "local-cultmesh", true), @@ -369,6 +374,9 @@ function buildAdvertisement(options) { schema(discordPostReceiptSchemaId, discordPostReceiptDocumentType, "CultMesh receipt for Bifrost-owned Discord persona/bot posts"), schema("bifrost.patron_support_event.v0", "bifrost.patron-support-event", "current hosted Heimdall-signed patron support fact consumed by Bifrost"), schema("bifrost.member_capability_snapshot.v0", "bifrost.member-capability-snapshot", "planned Heimdall-consumed membership capability witness"), + schema(epiphanyOperatorRequestSchemaId, "voidbot.discord.epiphany_operator_request", "strict owner-authored Discord operator request inbox"), + schema(epiphanyOperatorAdmissionSchemaId, "bifrost.operator_command.delivery", "Bifrost-signed expiring Epiphany operator admission"), + schema(epiphanyOperatorResultSchemaId, "epiphany.operator_command.result", "Epiphany-origin command result returned across the authenticated transport boundary"), ], witnesses: [ witness(".bifrost/provider-store.cc", `${schemaId}; ${surfaceSchemaId}; ${interfaceBindingSchemaId}; ${discordPostCommandSchemaId}; ${discordPostReceiptSchemaId}`, "current", "typed provider advertisement, operator surface, Eve interface binding, and Bifrost Discord command request/receipt store"), @@ -380,6 +388,7 @@ function buildAdvertisement(options) { witness(".bifrost/member-capabilities.cc", "bifrost.member_capability_snapshot.v0", "planned-export", "membership and account capability snapshots consumed by Bifrost"), witness(".bifrost/bridge-receipts.cc", crossingReceiptSchemaId, "current", "canonical governed public crossing receipt history"), witness(".bifrost/eve-surfaces.cc", "gamecult.eve.surface.v1", "planned-export", "future product Eve/CultUI surfaces not yet in the provider store"), + witness(".bifrost/epiphany-operator-requests.cc", epiphanyOperatorRequestSchemaId, "configured", "dedicated VoidBot-writable Bifrost operator request inbox; never an Epiphany store"), ], surfaces: [ surface("bifrost", "Bifrost Operator Dashboard", "gamecult.bifrost.surface.operator", "gamecult.eve.surface_state.v1", ".bifrost/provider-store.cc", "eve", [ @@ -489,6 +498,17 @@ function buildAdvertisement(options) { "surface-specific receipts cannot decide crossing completion", "does not touch secrets in this advertisement", ]), + boundary("epiphany-operator", "Bifrost admission; Epiphany execution", [ + "admit exact owner-only status, sleep, wake, and directive requests", + "require an existing Bifrost actor link and Heimdall actor reference", + "sign Rust-compatible expiring operator admissions with a dedicated Ed25519 identity", + "relay only Epiphany-origin results bound to the admitted command", + ], [ + "does not accept arbitrary argv or shell commands", + "does not mount or mutate Epiphany stores", + "does not synthesize successful Epiphany results", + "ordinary Persona feedback remains feedback_only", + ]), ], styleCapabilities: [ style("density", ["compact TUI grids", "operator scan tables", "member-facing board/list/detail lowerings"]),