Own native Persona Discord transport
This commit is contained in:
parent
0010f7da29
commit
548dd9397f
11 changed files with 430 additions and 24 deletions
|
|
@ -194,7 +194,17 @@ The command is a dry-run shape probe for public surfaces that are real enough to
|
|||
|
||||
### Organizational Persona Feedback Ingress
|
||||
|
||||
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.
|
||||
Bifrost's native Discord gateway is the conversation observer. A direct mention of the Bifrost bot, or a reply to one of its messages, becomes an immutable typed `bifrost.discord.persona_conversation_event.v0` only when the guild and channel have one exact Persona target binding. Bot-authored, ambient, empty, over-1200-character, unbound, and ambiguously bound messages do not enter the conversation path. The 1200-character cap is the shared signed alpha boundary with Epiphany. The observation store is Bifrost-private working state, never an operator-command or actuator queue, and insert collisions must contain exactly equal content. VoidBot does not observe, schedule, or answer for the target Persona.
|
||||
|
||||
Persona replies cross back through two explicit CultMesh surfaces. Epiphany is
|
||||
the sole writer of `epiphany.persona_discord_delivery_request.v0`; Bifrost
|
||||
reads that surface, authenticates the dedicated Persona-mouth signer, journals
|
||||
external execution before touching Discord, and is the sole writer of
|
||||
`bifrost.persona_discord_delivery_receipt.v0`. Epiphany reads receipts but
|
||||
never receives the Discord token or write access to Bifrost private state.
|
||||
`unknown` is terminal for automatic transport retry: an external post may have
|
||||
happened, so restart recovery must not manufacture a duplicate. Failed and
|
||||
unknown outcomes remain Persona pressure for explicit resolution.
|
||||
|
||||
Discord operator control is a separate authority. VoidBot may insert only a strict `voidbot.discord.epiphany_operator_request.v1` document containing one of `status`, `sleep`, `wake`, `directive`, `reviews`, or `review`, 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 exact persisted guild/actor mapping from its private actor-link store. The actor link is the admitted mapping authority; its Heimdall reference remains provenance until a signed live Heimdall claim surface exists and is not represented as authorization. Bifrost derives the Rust field-order packet tuple, hashes its canonical MessagePack bytes, and signs the exact `BifrostOperatorCommandAdmission` tuple for purpose `bifrost.operator-command.delivery.v1` with a dedicated operator Ed25519 seed. The feedback seed is not reused.
|
||||
|
||||
|
|
@ -208,7 +218,7 @@ The public trust-anchor artifact is a Rust-compatible six-element MessagePack tu
|
|||
|
||||
The same seed also exposes a separately domain-derived `gamecult.provider_health_identity.trust_anchor.v1` through `export-provider-health-anchor` (or `--provider-health-trust-anchor-out` during first enrollment). This works with the already-enrolled service key and creates the public artifact without replacing an existing file. Persona-feedback health uses that provider-health identity, not the feedback host identity. One `serve` process owns one UUID incarnation and a strictly increasing sequence. Each pulse is the exact 17-field `idunn.signed_daemon_health.v1` tuple, signed over its empty-signature canonical MessagePack form with the `gamecult.provider-health.signature.v1` domain and `idunn.signed_daemon_health.v1` purpose. It carries only the closed provider state and a bounded generic reason. The temporary `idunn.daemon_health` dual publication is explicitly tagged and sourced as an unsigned diagnostic; it cannot substitute for signed admission and can be removed after the deployed Idunn trust binding is live.
|
||||
|
||||
The signed `bifrost.persona_feedback.delivery.v0` document matches Epiphany's `BifrostPersonaFeedbackAdmission`: its packet digest is `sha256-` plus SHA-256 of the Rust field-order MessagePack packet tuple, and Ed25519 signs the Epiphany host-identity domain message for purpose `bifrost.persona-feedback.delivery.v0` over the exact admission tuple. The observation provenance is `sourceObserverId=voidbot`; the admission provider is separately and explicitly `provider=bifrost`. Authority is `feedback_only`, and the canonical Epiphany repository target is `GameCult/Epiphany`.
|
||||
The signed `bifrost.persona_feedback.delivery.v0` document matches Epiphany's `BifrostPersonaFeedbackAdmission`: its packet digest is `sha256-` plus SHA-256 of the Rust field-order MessagePack packet tuple, and Ed25519 signs the Epiphany host-identity domain message for purpose `bifrost.persona-feedback.delivery.v0` over the exact admission tuple. The observation provenance is `sourceObserverId=bifrost-discord`; the admission provider is separately and explicitly `provider=bifrost`. Authority is `feedback_only`, and the canonical Epiphany repository target is `GameCult/Epiphany`.
|
||||
|
||||
Each binding selects a dedicated provider-owned CultCache store containing only signed delivery documents. The canonical Epiphany path is `/srv/bifrost/persona-feedback/deliveries.cc`; Windows development defaults to `.bifrost/epiphany-persona-feedback.cc`. Observation inbox, Bifrost-private provider state, and delivery store must have distinct canonical paths and distinct existing file identities. Publication is crash-replayable, not cross-store transactional: the signed delivery is inserted idempotently first, then Bifrost records its private delivery copy and immutable receipt. A crash between those writes leaves the source observation unchanged and eligible for replay. Epiphany may consume the delivery store read-only without gaining access to bindings, actor links, receipts, or signing state. CultNet snapshot export remains available.
|
||||
|
||||
|
|
|
|||
25
tests/persona-discord-crossing-rust-smoke.test.mjs
Normal file
25
tests/persona-discord-crossing-rust-smoke.test.mjs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {spawnSync} from "node:child_process";
|
||||
import {chmod,mkdtemp,writeFile} from "node:fs/promises";
|
||||
import {tmpdir} from "node:os";
|
||||
import {resolve} from "node:path";
|
||||
import {createRequire} from "node:module";
|
||||
import {enrollDomainIdentity} from "../tools/bifrost-signed-identity.mjs";
|
||||
import {receiptIdentityDomain} from "../tools/persona-discord-delivery-documents.mjs";
|
||||
import {processPersonaDiscordDeliveries} from "../tools/persona-discord-delivery.mjs";
|
||||
|
||||
const epiphany=resolve(import.meta.dirname,"..","..","EpiphanyAgent"),cult=resolve(import.meta.dirname,"..","..","CultLib");
|
||||
function runtime(){const cr=createRequire(resolve(cult,"packages","cultcache-ts","package.json")),nr=createRequire(resolve(cult,"packages","cultnet-ts","package.json")),cc=cr("cultcache-ts"),mp=nr("@msgpack/msgpack");return {CultCache:cc.CultCache,SingleFileMessagePackBackingStore:cc.SingleFileMessagePackBackingStore,defineDocumentType:cc.defineDocumentType,encode:mp.encode,decode:mp.decode};}
|
||||
|
||||
test("Bifrost consumes a Rust-authored request store through its read-only crossing directory",async()=>{
|
||||
const root=await mkdtemp(resolve(tmpdir(),"rust-bifrost-crossing-")),requestDir=resolve(root,"request"),receiptDir=resolve(root,"receipt"),requestStore=resolve(requestDir,"requests.cc"),anchor=resolve(root,"mouth-anchor.msgpack"),identity=resolve(root,"mouth.cc"),deliveryKey=resolve(root,"delivery.seed"),bridge=resolve(root,"bridge.mjs");
|
||||
const cargo=spawnSync("cargo",["run","--quiet","--manifest-path",resolve(epiphany,"epiphany-core","Cargo.toml"),"--bin","epiphany-persona-discord-crossing-fixture","--","--request-store",requestStore,"--identity-store",identity,"--request-anchor",anchor],{cwd:epiphany,encoding:"utf8",timeout:180000,windowsHide:true});
|
||||
assert.equal(cargo.status,0,cargo.stderr||cargo.stdout);
|
||||
await chmod(requestStore,0o444);
|
||||
await enrollDomainIdentity(deliveryKey,"delivery",receiptIdentityDomain);
|
||||
await writeFile(bridge,`console.log(JSON.stringify({action:"discord-post",ok:true,channelId:"123",messageId:"rust-smoke-message",transport:"smoke",crossingReceiptId:"rust-smoke-receipt",url:"https://discord.invalid/rust-smoke"}))`);
|
||||
const r=runtime(),results=await processPersonaDiscordDeliveries({runtime:r,requestStorePath:requestStore,receiptStorePath:resolve(receiptDir,"receipts.cc"),executionStorePath:resolve(root,"private","executions.cc"),epiphanyTrustAnchorPath:anchor,privateKeyPath:deliveryKey,targetAdmitted:()=>true,requestPermit:async({requestPayloadSha256})=>({permitId:`permit:${requestPayloadSha256}`,expiresAt:new Date(Date.now()+5000).toISOString()}),bridgeCliPath:bridge,bifrostRoot:root});
|
||||
assert.equal(results[0]?.status,"completed");
|
||||
assert.equal(results[0]?.requestId,"rust-crossing-smoke-1");
|
||||
});
|
||||
32
tests/persona-discord-delivery.test.mjs
Normal file
32
tests/persona-discord-delivery.test.mjs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {createHash} from "node:crypto";
|
||||
import {createRequire} from "node:module";
|
||||
import {mkdtemp,writeFile} from "node:fs/promises";
|
||||
import {tmpdir} from "node:os";
|
||||
import {resolve} from "node:path";
|
||||
import {buildPersonaDiscordDeliveryDefinitions,executionSchema,parseReceipt,parseRequest,receiptIdentityDomain,receiptSignatureDomain,receiptSigningPurpose,receiptSigningTuple,requestIdentityDomain,requestSignatureDomain,requestSigningPurpose,requestSigningTuple} from "../tools/persona-discord-delivery-documents.mjs";
|
||||
import {processPersonaDiscordDeliveries} from "../tools/persona-discord-delivery.mjs";
|
||||
import {enrollDomainIdentity,signDomainPurpose,verifyDomainPurpose} from "../tools/bifrost-signed-identity.mjs";
|
||||
|
||||
const signature=()=>new Uint8Array(64);
|
||||
const request=()=>({schemaVersion:"epiphany.persona_discord_delivery_request.v0",requestId:"request-1",effectDocumentId:"effect-1",targetRuntimeId:"epiphany-yggdrasil",personaAgentId:"epiphany.Persona",channelId:"123",replyToMessageId:"456",content:"hello",contentSha256:`sha256-${"a".repeat(64)}`,issuedAt:"2026-07-21T12:00:00Z",expiresAt:"2026-07-21T12:01:00Z",privateStateExposed:false,signerIdentityId:"signer",signerSignature:signature()});
|
||||
|
||||
test("Persona delivery request has one exact signature tuple",()=>{const value=parseRequest(request());assert.deepEqual(requestSigningTuple(value),["epiphany.persona_discord_delivery_request.v0","request-1","effect-1","epiphany-yggdrasil","epiphany.Persona","123","456","hello",`sha256-${"a".repeat(64)}`,"2026-07-21T12:00:00Z","2026-07-21T12:01:00Z",false,"signer"]);});
|
||||
|
||||
test("Persona delivery receipt terminal states cannot counterfeit delivery",()=>{const base={schemaVersion:"bifrost.persona_discord_delivery_receipt.v0",receiptId:"request-1",requestId:"request-1",requestPayloadSha256:`sha256-${"b".repeat(64)}`,status:"unknown",channelId:"123",replyToMessageId:"456",messageId:"",transport:"",crossingReceiptId:"",receiptUrl:"",completedAt:"2026-07-21T12:00:02Z",providerIdentityId:"bifrost",privateStateExposed:false,providerSignature:signature()};assert.equal(receiptSigningTuple(parseReceipt(base)).length,14);assert.throws(()=>parseReceipt({...base,messageId:"counterfeit"}),/may not claim delivery evidence/);assert.doesNotThrow(()=>parseReceipt({...base,status:"failed"}));assert.doesNotThrow(()=>parseReceipt({...base,status:"completed",messageId:"789",transport:"bifrost.discord-post",crossingReceiptId:"crossing-1",receiptUrl:"https://discord.com/channels/g/123/789"}));});
|
||||
|
||||
test("Persona delivery documents refuse privacy and identity substitutions",()=>{assert.throws(()=>parseRequest({...request(),privateStateExposed:true}),/must be false/);const receipt={schemaVersion:"bifrost.persona_discord_delivery_receipt.v0",receiptId:"receipt-2",requestId:"request-1",requestPayloadSha256:`sha256-${"b".repeat(64)}`,status:"failed",channelId:"123",replyToMessageId:"",messageId:"",transport:"",crossingReceiptId:"",receiptUrl:"",completedAt:"2026-07-21T12:00:02Z",providerIdentityId:"bifrost",privateStateExposed:false,providerSignature:signature()};assert.throws(()=>parseReceipt(receipt),/must equal requestId/);});
|
||||
|
||||
test("Bifrost authenticates one request, publishes one signed receipt, and replays without reposting",async()=>{const root=await mkdtemp(resolve(tmpdir(),"persona-mouth-")),runtime=loadRuntime(),defs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType),requestStore=resolve(root,"requests.cc"),receiptStore=resolve(root,"receipts.cc"),executionStore=resolve(root,"private","executions.cc"),requestKey=resolve(root,"request.seed"),deliveryKey=resolve(root,"delivery.seed"),anchorPath=resolve(root,"request-anchor.msgpack"),counterPath=resolve(root,"post-count"),bridgePath=resolve(root,"fake-bridge.mjs"),requestIdentity=await enrollDomainIdentity(requestKey,"request identity",requestIdentityDomain),deliveryIdentity=await enrollDomainIdentity(deliveryKey,"delivery identity",receiptIdentityDomain);await writeFile(anchorPath,runtime.encode(requestAnchor(requestIdentity)));await writeFile(bridgePath,`import{readFileSync,writeFileSync}from'node:fs';const p=${JSON.stringify(counterPath)};let n=0;try{n=Number(readFileSync(p,'utf8'))}catch{}writeFileSync(p,String(n+1));console.log(JSON.stringify({action:'discord-post',ok:true,channelId:'123',messageId:'789',transport:'bifrost.discord-post',crossingReceiptId:'crossing-1',url:'https://discord.com/channels/g/123/789'}));`);const now=Date.now(),value={...request(),issuedAt:new Date(now-1000).toISOString(),expiresAt:new Date(now+60000).toISOString(),signerIdentityId:requestIdentity.identityId};value.contentSha256=`sha256-${createHash("sha256").update(value.content).digest("hex")}`;value.signerSignature=new Uint8Array(signDomainPurpose(requestIdentity,requestSignatureDomain,requestSigningPurpose,runtime.encode(requestSigningTuple(value))));const requests=await open(runtime,defs.request,requestStore);await requests.put(defs.request,value.requestId,value);const options={runtime,requestStorePath:requestStore,receiptStorePath:receiptStore,executionStorePath:executionStore,epiphanyTrustAnchorPath:anchorPath,privateKeyPath:deliveryKey,targetAdmitted:()=>true,bridgeCliPath:bridgePath,bifrostRoot:root,requestPermit:async()=>({permitId:"permit-1"})};let results=await processPersonaDiscordDeliveries(options);assert.equal(results[0].status,"completed");results=await processPersonaDiscordDeliveries(options);assert.equal(results.length,0);const receipts=await open(runtime,defs.receipt,receiptStore),receipt=receipts.get(defs.receipt,value.requestId);assert.equal(receipt.messageId,"789");assert.equal(verifyDomainPurpose(deliveryIdentity.publicKey,receiptSignatureDomain,receiptSigningPurpose,runtime.encode(receiptSigningTuple(receipt)),receipt.providerSignature),true);assert.equal(await import("node:fs/promises").then(fs=>fs.readFile(counterPath,"utf8")),"1");});
|
||||
|
||||
test("running journal recovers as terminal unknown without a second Discord post",async()=>{const root=await mkdtemp(resolve(tmpdir(),"persona-mouth-crash-")),runtime=loadRuntime(),defs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType),requestStore=resolve(root,"requests.cc"),receiptStore=resolve(root,"receipts.cc"),executionStore=resolve(root,"private","executions.cc"),requestIdentity=await enrollDomainIdentity(resolve(root,"request.seed"),"request identity",requestIdentityDomain),deliveryKey=resolve(root,"delivery.seed"),anchorPath=resolve(root,"anchor.msgpack");await enrollDomainIdentity(deliveryKey,"delivery identity",receiptIdentityDomain);await writeFile(anchorPath,runtime.encode(requestAnchor(requestIdentity)));const now=Date.now(),value={...request(),issuedAt:new Date(now-1000).toISOString(),expiresAt:new Date(now+60000).toISOString(),signerIdentityId:requestIdentity.identityId};value.contentSha256=`sha256-${createHash("sha256").update(value.content).digest("hex")}`;value.signerSignature=new Uint8Array(signDomainPurpose(requestIdentity,requestSignatureDomain,requestSigningPurpose,runtime.encode(requestSigningTuple(value))));const requests=await open(runtime,defs.request,requestStore);await requests.put(defs.request,value.requestId,value);const digest=`sha256-${createHash("sha256").update(runtime.encode(requestSigningTuple(value))).digest("hex")}`,executions=await open(runtime,defs.execution,executionStore);await executions.put(defs.execution,value.requestId,{schemaName:"bifrost.persona_discord_delivery_execution",schemaVersion:executionSchema,requestId:value.requestId,requestPayloadSha256:digest,status:"running",recordedAt:new Date().toISOString(),detail:"crash fixture"});const results=await processPersonaDiscordDeliveries({runtime,requestStorePath:requestStore,receiptStorePath:receiptStore,executionStorePath:executionStore,epiphanyTrustAnchorPath:anchorPath,privateKeyPath:deliveryKey,targetAdmitted:()=>true,bridgeCliPath:resolve(root,"must-not-run.mjs"),bifrostRoot:root});assert.equal(results[0].status,"unknown");assert.equal(results[0].messageId,"");});
|
||||
|
||||
test("permit intent survives network failure and restart reuses its exact request tuple",async()=>{const f=await deliveryFixture("permit-retry-");const attempts=[];let results=await processPersonaDiscordDeliveries({...f.options,requestPermit:async value=>{attempts.push({...value});throw new Error("permit network unavailable");}});assert.equal(results[0].status,"permit_pending");const executions=await open(f.runtime,f.defs.execution,f.executionStore),pending=executions.get(f.defs.execution,f.value.requestId);assert.equal(pending.status,"permit_pending");assert.equal(pending.permitNonce,attempts[0].nonce);assert.equal(pending.permitIssuedAt,attempts[0].issuedAt);assert.equal(pending.permitExpiresAt,attempts[0].expiresAt);results=await processPersonaDiscordDeliveries({...f.options,requestPermit:async value=>{attempts.push({...value});return {permitId:"permit-after-restart"};}});assert.equal(results[0].status,"completed");assert.equal(attempts.length,2);for(const key of ["requestId","requestPayloadSha256","targetRuntimeId","nonce","issuedAt","expiresAt"])assert.equal(attempts[1][key],attempts[0][key]);assert.equal(await import("node:fs/promises").then(fs=>fs.readFile(f.counterPath,"utf8")),"1");});
|
||||
|
||||
test("expired permit intent restarts as terminal unknown without requesting or posting",async()=>{const f=await deliveryFixture("permit-expired-"),digest=`sha256-${createHash("sha256").update(f.runtime.encode(requestSigningTuple(f.value))).digest("hex")}`,executions=await open(f.runtime,f.defs.execution,f.executionStore),issuedAt=new Date(Date.now()-6000).toISOString(),expiresAt=new Date(Date.now()-1000).toISOString();await executions.put(f.defs.execution,f.value.requestId,{schemaName:"bifrost.persona_discord_delivery_execution",schemaVersion:executionSchema,requestId:f.value.requestId,requestPayloadSha256:digest,status:"permit_pending",recordedAt:issuedAt,permitNonce:"persisted-nonce",permitIssuedAt:issuedAt,permitExpiresAt:expiresAt,detail:"restart fixture"});let permitCalls=0;const results=await processPersonaDiscordDeliveries({...f.options,requestPermit:async()=>{permitCalls++;throw new Error("must not request");},bridgeCliPath:resolve(f.root,"must-not-post.mjs")});assert.equal(results[0].status,"unknown");assert.equal(results[0].messageId,"");assert.equal(permitCalls,0);await assert.rejects(import("node:fs/promises").then(fs=>fs.readFile(f.counterPath,"utf8")));});
|
||||
|
||||
function loadRuntime(){const cult=resolve(import.meta.dirname,"..","..","CultLib"),cr=createRequire(resolve(cult,"packages","cultcache-ts","package.json")),nr=createRequire(resolve(cult,"packages","cultnet-ts","package.json")),cc=cr("cultcache-ts"),mp=nr("@msgpack/msgpack");return {CultCache:cc.CultCache,SingleFileMessagePackBackingStore:cc.SingleFileMessagePackBackingStore,defineDocumentType:cc.defineDocumentType,encode:mp.encode,decode:mp.decode};}
|
||||
async function open(runtime,definition,path){const cache=runtime.CultCache.builder().withDocumentType(definition).withGenericStore(new runtime.SingleFileMessagePackBackingStore(path)).build();await cache.pullAllBackingStores();return cache;}
|
||||
function requestAnchor(identity){return ["gamecult.service_trust_anchor.v1","epiphany-persona-mouth:epiphany-yggdrasil:v0","epiphany-persona-mouth","epiphany-yggdrasil",identity.identityId,new Uint8Array(identity.publicKey),"ed25519",requestSigningPurpose,"epiphany.persona_discord_delivery_request.v0","root",Date.now(),null,false];}
|
||||
async function deliveryFixture(prefix){const root=await mkdtemp(resolve(tmpdir(),prefix)),runtime=loadRuntime(),defs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType),requestStore=resolve(root,"requests.cc"),receiptStore=resolve(root,"receipts.cc"),executionStore=resolve(root,"private","executions.cc"),deliveryKey=resolve(root,"delivery.seed"),anchorPath=resolve(root,"request-anchor.msgpack"),counterPath=resolve(root,"post-count"),bridgeCliPath=resolve(root,"fake-bridge.mjs"),requestIdentity=await enrollDomainIdentity(resolve(root,"request.seed"),"request identity",requestIdentityDomain);await enrollDomainIdentity(deliveryKey,"delivery identity",receiptIdentityDomain);await writeFile(anchorPath,runtime.encode(requestAnchor(requestIdentity)));await writeFile(bridgeCliPath,`import{readFileSync,writeFileSync}from'node:fs';const p=${JSON.stringify(counterPath)};let n=0;try{n=Number(readFileSync(p,'utf8'))}catch{}writeFileSync(p,String(n+1));console.log(JSON.stringify({action:'discord-post',ok:true,channelId:'123',messageId:'789',transport:'bifrost.discord-post',crossingReceiptId:'crossing-1',url:'https://discord.com/channels/g/123/789'}));`);const now=Date.now(),value={...request(),issuedAt:new Date(now-1000).toISOString(),expiresAt:new Date(now+60000).toISOString(),signerIdentityId:requestIdentity.identityId};value.contentSha256=`sha256-${createHash("sha256").update(value.content).digest("hex")}`;value.signerSignature=new Uint8Array(signDomainPurpose(requestIdentity,requestSignatureDomain,requestSigningPurpose,runtime.encode(requestSigningTuple(value))));const requests=await open(runtime,defs.request,requestStore);await requests.put(defs.request,value.requestId,value);return {root,runtime,defs,value,executionStore,counterPath,options:{runtime,requestStorePath:requestStore,receiptStorePath:receiptStore,executionStorePath:executionStore,epiphanyTrustAnchorPath:anchorPath,privateKeyPath:deliveryKey,targetAdmitted:()=>true,bridgeCliPath,bifrostRoot:root}};}
|
||||
|
|
@ -7,6 +7,7 @@ import { mkdtemp, stat, readFile } from "node:fs/promises";
|
|||
import { tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { buildFeedbackDefinitions } from "../tools/persona-feedback-documents.mjs";
|
||||
import { conversationFromDiscordMessage, discordGatewayReady } from "../tools/bifrost-discord-persona-ingress.mjs";
|
||||
|
||||
const root=resolve(import.meta.dirname,".."), cult=resolve(root,"..","CultLib");
|
||||
const cr=createRequire(resolve(cult,"packages","cultcache-ts","package.json"));
|
||||
|
|
@ -18,7 +19,7 @@ test("Bifrost admits bound feedback as pressure only and exports CultNet",async(
|
|||
const dir=await mkdtemp(resolve(tmpdir(),"bifrost-feedback-")), store=resolve(dir,"provider.cc"), observationStore=resolve(dir,"observations.cc"), deliveryStore=resolve(dir,"delivery.cc"), unusedDeliveryDefault=resolve(dir,"unused-delivery.cc"), out=resolve(dir,"feedback.msgpack"), key=resolve(dir,"identity.seed"), anchor=resolve(dir,"anchor.msgpack"),providerAnchor=resolve(dir,"provider-health-anchor.msgpack");
|
||||
cliNoStore("enroll-identity","--private-key",key,"--trust-anchor-out",anchor);
|
||||
cliNoStore("export-provider-health-anchor","--private-key",key,"--trust-anchor-out",providerAnchor);
|
||||
cli("bind-target",store,"--guild-id","g","--channel-id","c","--persona-id","epiphany","--repo","GameCult/Epiphany","--runtime-id","epiphany-yggdrasil","--producer-runtime-id","voidbot-yggdrasil","--source-visibility","organization","--data-classification","organization_feedback","--delivery-store",deliveryStore);
|
||||
cli("bind-target",store,"--guild-id","g","--channel-id","c","--persona-id","epiphany","--repo","GameCult/Epiphany","--runtime-id","epiphany-yggdrasil","--producer-runtime-id","bifrost-discord-yggdrasil","--source-visibility","organization","--data-classification","organization_feedback","--delivery-store",deliveryStore);
|
||||
await putEvent(observationStore,event("one","human-one"));
|
||||
const sourceBefore=await readEvent(observationStore,"one");
|
||||
const served=cli("serve",store,"--observation-store",observationStore,"--once","true","--private-key",key,"--delivery-store",unusedDeliveryDefault,"--interval-ms","1");
|
||||
|
|
@ -35,7 +36,7 @@ test("Bifrost admits bound feedback as pressure only and exports CultNet",async(
|
|||
assert.equal(Object.keys(first.delivery.providerSignature).length,64);
|
||||
const signed=(await n.get(defs.delivery,"one"))?.value??await n.get(defs.delivery,"one"), trust=decode(await readFile(anchor));
|
||||
assert.equal(signed.packetSha256,`sha256-${createHash("sha256").update(encode(packetTuple(signed.packet))).digest("hex")}`);
|
||||
assert.equal(signed.sourceObserverId,"voidbot");assert.equal(signed.provider,"bifrost");
|
||||
assert.equal(signed.sourceObserverId,"bifrost-discord");assert.equal(signed.provider,"bifrost");
|
||||
const payload=encode([signed.schemaVersion,signed.admissionId,packetTuple(signed.packet),signed.packetSha256,signed.sourceObserverId,signed.sourceObserverRuntimeId,signed.provider,signed.bifrostAdmissionReceiptId,signed.authority,signed.providerIdentityId]);
|
||||
const purpose=Buffer.from("bifrost.persona-feedback.delivery.v0"),domain=Buffer.from("epiphany.host-incarnation.signature.v0\0"),message=Buffer.concat([domain,u64(purpose.length),purpose,u64(payload.length),payload]);
|
||||
assert.equal(trust.length,6); assert.equal(trust[0],"epiphany.host_identity_trust_anchor.v0"); assert.equal(trust[1],signed.providerIdentityId);
|
||||
|
|
@ -54,22 +55,71 @@ test("Bifrost admits bound feedback as pressure only and exports CultNet",async(
|
|||
const deliveryCache=cr("cultcache-ts").CultCache.builder().withDocumentType(defs.delivery).withGenericStore(new (cr("cultcache-ts").SingleFileMessagePackBackingStore)(deliveryStore)).build();await deliveryCache.pullAllBackingStores();assert.equal(deliveryCache.getAll(defs.delivery).length,2);
|
||||
const beforeStatus=await Promise.all([store,observationStore,deliveryStore,key].map(path=>readFile(path)));
|
||||
const readiness=cli("status",store,"--observation-store",observationStore,"--private-key",key);
|
||||
assert.equal(readiness.schemaVersion,"bifrost.persona_feedback.readiness.v0");assert.equal(readiness.status,"ready");assert.equal(readiness.bindingCount,1);assert.equal(readiness.privateStateExposed,false);
|
||||
assert.equal(readiness.schemaVersion,"bifrost.persona_feedback.readiness.v0");assert.equal(readiness.status,"ready");assert.equal(readiness.bindingCount,1);assert.equal(readiness.pendingFailedCount,0);assert.equal(readiness.privateStateExposed,false);
|
||||
const staged=cli("status",store,"--observation-store",observationStore,"--private-key",key,"--epiphany-persona-mouth-trust-anchor",resolve(dir,"missing-mouth-anchor.msgpack"),"--epiphany-persona-permit-trust-anchor",resolve(dir,"missing-permit-anchor.msgpack"),"--epiphany-persona-permit-rudp","rudp://127.0.0.1:9","--persona-delivery-private-key",resolve(dir,"missing-delivery.seed"));
|
||||
assert.equal(staged.ingressReady,true);assert.deepEqual(staged.ingressReasons,[]);assert.equal(staged.personaDelivery.ready,false);assert.equal(staged.status,"degraded");
|
||||
const afterStatus=await Promise.all([store,observationStore,deliveryStore,key].map(path=>readFile(path)));assert.deepEqual(afterStatus,beforeStatus);
|
||||
const Store=cr("cultcache-ts").SingleFileMessagePackBackingStore;
|
||||
assert.deepEqual([...new Set((await new Store(observationStore).pullAll()).map(entry=>entry.type))],["voidbot.discord.persona_feedback_event"]);
|
||||
assert.equal((await new Store(store).pullAll()).some(entry=>entry.type==="voidbot.discord.persona_feedback_event"),false);
|
||||
assert.deepEqual([...new Set((await new Store(observationStore).pullAll()).map(entry=>entry.type))],["bifrost.discord.persona_conversation_event"]);
|
||||
assert.equal((await new Store(store).pullAll()).some(entry=>entry.type==="bifrost.discord.persona_conversation_event"),false);
|
||||
assert.deepEqual([...new Set((await new Store(deliveryStore).pullAll()).map(entry=>entry.type))],["bifrost.persona_feedback.delivery"]);
|
||||
await assert.rejects(stat(unusedDeliveryDefault));
|
||||
});
|
||||
|
||||
test("Bifrost rejects an event without exact target binding",async()=>{const dir=await mkdtemp(resolve(tmpdir(),"bifrost-feedback-")),store=resolve(dir,"provider.cc"),observationStore=resolve(dir,"observations.cc");await putEvent(observationStore,event("wrong","human","other-runtime"));const r=raw("process",store,"--observation-store",observationStore,"--event-id","wrong");assert.notEqual(r.status,0);assert.match(r.stderr,/No exact Bifrost target binding/);assert.equal((await readEvent(observationStore,"wrong")).status,"pending");});
|
||||
|
||||
test("Bifrost rejects aliased observation, private, and delivery stores",async()=>{const dir=await mkdtemp(resolve(tmpdir(),"bifrost-feedback-")),store=resolve(dir,"provider.cc"),observations=resolve(dir,"observations.cc");await putEvent(observations,event("alias","human"));const r=raw("bind-target",store,"--observation-store",observations,"--guild-id","g","--channel-id","c","--persona-id","epiphany","--repo","GameCult/Epiphany","--runtime-id","epiphany-yggdrasil","--producer-runtime-id","voidbot-yggdrasil","--source-visibility","organization","--data-classification","organization_feedback","--delivery-store",store);assert.notEqual(r.status,0);assert.match(r.stderr,/distinct canonical paths/);});
|
||||
test("Bifrost rejects aliased observation, private, and delivery stores",async()=>{const dir=await mkdtemp(resolve(tmpdir(),"bifrost-feedback-")),store=resolve(dir,"provider.cc"),observations=resolve(dir,"observations.cc");await putEvent(observations,event("alias","human"));const r=raw("bind-target",store,"--observation-store",observations,"--guild-id","g","--channel-id","c","--persona-id","epiphany","--repo","GameCult/Epiphany","--runtime-id","epiphany-yggdrasil","--producer-runtime-id","bifrost-discord-yggdrasil","--source-visibility","organization","--data-classification","organization_feedback","--delivery-store",store);assert.notEqual(r.status,0);assert.match(r.stderr,/distinct canonical paths/);});
|
||||
|
||||
test("Bifrost rejects mismatched classification and malformed observations",async()=>{const dir=await mkdtemp(resolve(tmpdir(),"bifrost-feedback-")),store=resolve(dir,"provider.cc"),observations=resolve(dir,"observations.cc"),delivery=resolve(dir,"delivery.cc");const mismatch=raw("bind-target",store,"--observation-store",observations,"--guild-id","g","--channel-id","c","--persona-id","epiphany","--repo","GameCult/Epiphany","--runtime-id","epiphany-yggdrasil","--producer-runtime-id","voidbot-yggdrasil","--source-visibility","private","--data-classification","public_feedback","--delivery-store",delivery);assert.notEqual(mismatch.status,0);assert.match(mismatch.stderr,/exact admitted pair/);for(const malformed of [{observedAt:"yesterday"},{addressingMode:"ambient"},{authorityClass:"work"},{status:"admitted"},{payloadHash:"not-a-digest"}])await assert.rejects(putEvent(observations,{...event(`bad-${Object.keys(malformed)[0]}`,"human"),...malformed}));});
|
||||
test("Bifrost rejects mismatched classification and malformed observations",async()=>{const dir=await mkdtemp(resolve(tmpdir(),"bifrost-feedback-")),store=resolve(dir,"provider.cc"),observations=resolve(dir,"observations.cc"),delivery=resolve(dir,"delivery.cc");const mismatch=raw("bind-target",store,"--observation-store",observations,"--guild-id","g","--channel-id","c","--persona-id","epiphany","--repo","GameCult/Epiphany","--runtime-id","epiphany-yggdrasil","--producer-runtime-id","bifrost-discord-yggdrasil","--source-visibility","private","--data-classification","public_feedback","--delivery-store",delivery);assert.notEqual(mismatch.status,0);assert.match(mismatch.stderr,/exact admitted pair/);for(const malformed of [{observedAt:"yesterday"},{addressingMode:"ambient"},{authorityClass:"work"},{status:"admitted"},{payloadHash:"not-a-digest"},{producerId:"voidbot"}])await assert.rejects(putEvent(observations,{...event(`bad-${Object.keys(malformed)[0]}`,"human"),...malformed}));});
|
||||
|
||||
function event(id,author,targetRuntimeId="epiphany-yggdrasil"){const content=`feedback ${id}`;return {schemaName:"voidbot.discord.persona_feedback_event",schemaVersion:"voidbot.discord.persona_feedback_event.v0",eventId:id,observedAt:new Date().toISOString(),guildId:"g",channelId:"c",messageId:`m-${id}`,authorId:author,authorName:author,addressingMode:"role",targetPersonaId:"epiphany",targetRepoName:"GameCult/Epiphany",targetRuntimeId,content,payloadHash:createHash("sha256").update(content).digest("hex"),producerId:"voidbot",producerRuntimeId:"voidbot-yggdrasil",authorityClass:"feedback_only",status:"pending"};}
|
||||
test("native Discord ingress admits only direct human address",()=>{
|
||||
const base={id:"m1",guild_id:"g",channel_id:"c",timestamp:"2026-07-21T20:00:00.000Z",author:{id:"human",username:"Human",bot:false},content:"<@42> hello Epiphany",mentions:[{id:"42"}]};
|
||||
const admitted=conversationFromDiscordMessage(base,"42");assert.equal(admitted.content,"hello Epiphany");assert.equal(admitted.addressingMode,"text");assert.match(admitted.payloadHash,/^[a-f0-9]{64}$/);
|
||||
assert.equal(conversationFromDiscordMessage({...base,author:{id:"bot",bot:true}},"42"),null);
|
||||
assert.equal(conversationFromDiscordMessage({...base,mentions:[],content:"ambient chatter"},"42"),null);
|
||||
assert.equal(conversationFromDiscordMessage({...base,mentions:[{id:"42"}],content:"<@42> "},"42"),null);
|
||||
assert.equal(conversationFromDiscordMessage({...base,content:`<@42> ${"x".repeat(1201)}`},"42"),null);
|
||||
assert.equal(conversationFromDiscordMessage({...base,content:`<@42> ${"x".repeat(1200)}`},"42").content.length,1200);
|
||||
assert.equal(Buffer.byteLength(conversationFromDiscordMessage({...base,content:`<@42> ${"😀".repeat(300)}`},"42").content,"utf8"),1200);
|
||||
assert.equal(conversationFromDiscordMessage({...base,content:`<@42> ${"😀".repeat(301)}`},"42"),null);
|
||||
assert.equal(conversationFromDiscordMessage({...base,timestamp:"not-a-time"},"42"),null);
|
||||
const reply=conversationFromDiscordMessage({...base,mentions:[],content:"following up",referenced_message:{author:{id:"42"}}},"42");assert.equal(reply.addressingMode,"reply");
|
||||
const commandShaped=conversationFromDiscordMessage({...base,content:"<@42> /epiphany wake"},"42");assert.equal(commandShaped.content,"/epiphany wake");assert.equal(commandShaped.addressingMode,"text");
|
||||
});
|
||||
|
||||
test("Discord gateway health fails closed while disconnected or heartbeat-stale",()=>{
|
||||
const now=1_000_000;
|
||||
assert.equal(discordGatewayReady({connected:false,sessionId:"s",lastHeartbeatAckAtMillis:now},now),false);
|
||||
assert.equal(discordGatewayReady({connected:true,sessionId:"",lastHeartbeatAckAtMillis:now},now),false);
|
||||
assert.equal(discordGatewayReady({connected:true,sessionId:"s",lastHeartbeatAckAtMillis:0},now),false);
|
||||
assert.equal(discordGatewayReady({connected:true,sessionId:"s",lastHeartbeatAckAtMillis:now-120001},now),false);
|
||||
assert.equal(discordGatewayReady({connected:true,sessionId:"s",lastHeartbeatAckAtMillis:now-120000},now),true);
|
||||
});
|
||||
|
||||
test("serve drains a persisted observation left by a failed prior process",async()=>{
|
||||
const dir=await mkdtemp(resolve(tmpdir(),"bifrost-feedback-recovery-")),store=resolve(dir,"provider.cc"),observations=resolve(dir,"observations.cc"),delivery=resolve(dir,"delivery.cc"),key=resolve(dir,"identity.seed"),anchor=resolve(dir,"anchor.msgpack");
|
||||
cli("bind-target",store,"--guild-id","g","--channel-id","c","--persona-id","epiphany","--repo","GameCult/Epiphany","--runtime-id","epiphany-yggdrasil","--producer-runtime-id","bifrost-discord-yggdrasil","--source-visibility","organization","--data-classification","organization_feedback","--delivery-store",delivery);
|
||||
await putEvent(observations,event("crash-recovery","human"));
|
||||
const failed=raw("process",store,"--observation-store",observations,"--event-id","crash-recovery","--private-key",key,"--delivery-store",delivery);assert.notEqual(failed.status,0);
|
||||
cliNoStore("enroll-identity","--private-key",key,"--trust-anchor-out",anchor);
|
||||
const recovered=cli("serve",store,"--observation-store",observations,"--once","true","--private-key",key,"--delivery-store",delivery,"--interval-ms","1");assert.equal(recovered.scanned,1);assert.equal(recovered.results[0].ok,true);
|
||||
const n=await CultMesh.createNode(store,{documents:[defs.binding,defs.actorLink,defs.receipt,defs.delivery]});await n.cache?.pullAllBackingStores?.();assert.ok((await n.get(defs.receipt,"crash-recovery")));
|
||||
});
|
||||
|
||||
test("serve durably accounts for failed pending observations and clears them after admission",async()=>{
|
||||
const dir=await mkdtemp(resolve(tmpdir(),"bifrost-feedback-failure-")),store=resolve(dir,"provider.cc"),observations=resolve(dir,"observations.cc"),delivery=resolve(dir,"delivery.cc"),key=resolve(dir,"identity.seed"),anchor=resolve(dir,"anchor.msgpack");
|
||||
cli("bind-target",store,"--guild-id","g","--channel-id","c","--persona-id","epiphany","--repo","GameCult/Epiphany","--runtime-id","epiphany-yggdrasil","--producer-runtime-id","bifrost-discord-yggdrasil","--source-visibility","organization","--data-classification","organization_feedback","--delivery-store",delivery);
|
||||
await putEvent(observations,event("retry-evidence","human"));
|
||||
const failed=cli("serve",store,"--observation-store",observations,"--once","true","--private-key",key,"--delivery-store",delivery,"--interval-ms","1");
|
||||
assert.equal(failed.results[0].ok,false);assert.equal(failed.readiness.status,"degraded");assert.equal(failed.readiness.pendingFailedCount,1);assert.match(failed.readiness.reasons[0],/identity|pendingFailures/);
|
||||
let n=await CultMesh.createNode(store,{documents:[defs.binding,defs.actorLink,defs.receipt,defs.delivery,defs.pendingFailure]});await n.cache?.pullAllBackingStores?.();
|
||||
const evidence=(await n.get(defs.pendingFailure,"retry-evidence"))?.value??await n.get(defs.pendingFailure,"retry-evidence");assert.equal(evidence.attemptCount,1);assert.equal(evidence.privateStateExposed,false);assert.ok(Buffer.byteLength(evidence.lastError,"utf8")<=512);
|
||||
cliNoStore("enroll-identity","--private-key",key,"--trust-anchor-out",anchor);
|
||||
const recovered=cli("serve",store,"--observation-store",observations,"--once","true","--private-key",key,"--delivery-store",delivery,"--interval-ms","1");assert.equal(recovered.results[0].ok,true);assert.equal(recovered.readiness.pendingFailedCount,0);assert.equal(recovered.readiness.status,"ready");
|
||||
n=await CultMesh.createNode(store,{documents:[defs.binding,defs.actorLink,defs.receipt,defs.delivery,defs.pendingFailure]});await n.cache?.pullAllBackingStores?.();assert.equal(await n.get(defs.pendingFailure,"retry-evidence"),undefined);
|
||||
});
|
||||
|
||||
function event(id,author,targetRuntimeId="epiphany-yggdrasil"){const content=`feedback ${id}`;return {schemaName:"bifrost.discord.persona_conversation_event",schemaVersion:"bifrost.discord.persona_conversation_event.v0",eventId:id,observedAt:new Date().toISOString(),guildId:"g",channelId:"c",messageId:`m-${id}`,authorId:author,authorName:author,addressingMode:"role",targetPersonaId:"epiphany",targetRepoName:"GameCult/Epiphany",targetRuntimeId,content,payloadHash:createHash("sha256").update(content).digest("hex"),producerId:"bifrost-discord",producerRuntimeId:"bifrost-discord-yggdrasil",authorityClass:"feedback_only",status:"pending"};}
|
||||
async function putEvent(store,value){const n=await CultMesh.createNode(store,{documents:[defs.event]});await n.put(defs.event,value.eventId,value);await n.flush?.();}
|
||||
async function readEvent(store,id){const n=await CultMesh.createNode(store,{documents:[defs.event]});await n.cache?.pullAllBackingStores?.();return (await n.get(defs.event,id))?.value??await n.get(defs.event,id);}
|
||||
function raw(command,store,...args){return spawnSync(process.execPath,[resolve(root,"tools","persona-feedback.mjs"),command,"--store",store,...args],{cwd:root,encoding:"utf8"});}
|
||||
|
|
|
|||
107
tools/bifrost-discord-persona-ingress.mjs
Normal file
107
tools/bifrost-discord-persona-ingress.mjs
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { createHash } from "node:crypto";
|
||||
|
||||
const GATEWAY_VERSION = 10;
|
||||
const GATEWAY_INTENTS = (1 << 0) | (1 << 9) | (1 << 15);
|
||||
|
||||
export function conversationFromDiscordMessage(message, botUserId) {
|
||||
if (!message || typeof message !== "object" || message.author?.bot === true) return null;
|
||||
const guildId = text(message.guild_id);
|
||||
const channelId = text(message.channel_id);
|
||||
const messageId = text(message.id);
|
||||
const authorId = text(message.author?.id);
|
||||
const observedAt = Date.parse(String(message.timestamp ?? ""));
|
||||
if (!guildId || !channelId || !messageId || !authorId || !botUserId || !Number.isFinite(observedAt)) return null;
|
||||
const mentioned = Array.isArray(message.mentions)
|
||||
&& message.mentions.some((mention) => String(mention?.id ?? "") === botUserId);
|
||||
const replied = String(message.referenced_message?.author?.id ?? "") === botUserId;
|
||||
if (!mentioned && !replied) return null;
|
||||
const content = String(message.content ?? "")
|
||||
.replace(new RegExp(`<@!?${escapeRegex(botUserId)}>`, "g"), " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (!content || Buffer.byteLength(content, "utf8") > 1200) return null;
|
||||
return {
|
||||
guildId,
|
||||
channelId,
|
||||
messageId,
|
||||
authorId,
|
||||
authorName: text(message.member?.nick) || text(message.author?.global_name) || text(message.author?.username) || authorId,
|
||||
content,
|
||||
addressingMode: mentioned ? "text" : "reply",
|
||||
observedAt: new Date(observedAt).toISOString(),
|
||||
payloadHash: createHash("sha256").update(content, "utf8").digest("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
export function discordGatewayReady(state, nowMillis = Date.now(), maxAckAgeMillis = 120000) {
|
||||
return state?.connected === true
|
||||
&& text(state.sessionId) !== ""
|
||||
&& Number.isFinite(state.lastHeartbeatAckAtMillis)
|
||||
&& state.lastHeartbeatAckAtMillis > 0
|
||||
&& nowMillis >= state.lastHeartbeatAckAtMillis
|
||||
&& nowMillis - state.lastHeartbeatAckAtMillis <= maxAckAgeMillis;
|
||||
}
|
||||
|
||||
export async function serveDiscordPersonaIngress({ token, runtimeId, onConversation, onReady = () => {}, onDisconnect = () => {}, onState = () => {} }) {
|
||||
if (!text(token)) throw new Error("Bifrost Discord Persona ingress requires a bot token.");
|
||||
if (!text(runtimeId)) throw new Error("Bifrost Discord Persona ingress requires a runtime id.");
|
||||
if (typeof onConversation !== "function") throw new Error("Bifrost Discord Persona ingress requires a conversation sink.");
|
||||
onState({connected:false,sessionId:"",lastHeartbeatAckAtMillis:0,phase:"discovering"});
|
||||
const gateway = await discordJson("https://discord.com/api/v10/gateway/bot", token);
|
||||
if (!text(gateway?.url)) throw new Error("Discord gateway discovery returned no URL.");
|
||||
onState({connected:false,sessionId:"",lastHeartbeatAckAtMillis:0,phase:"discovered"});
|
||||
let sequence = null, sessionId = "", resumeUrl = gateway.url, botUserId = "", stopped = false;
|
||||
while (!stopped) {
|
||||
try {
|
||||
const result = await gatewaySession({ token, runtimeId, url: resumeUrl, sequence, sessionId, botUserId, onConversation, onReady, onState });
|
||||
sequence = result.sequence; sessionId = result.sessionId; resumeUrl = result.resumeUrl; botUserId = result.botUserId;
|
||||
stopped = result.stopped;
|
||||
} catch (error) {
|
||||
if (error?.fatal === true) throw error;
|
||||
onState({connected:false,sessionId,botUserId,lastHeartbeatAckAtMillis:0,phase:"disconnected"});
|
||||
onDisconnect(error);
|
||||
await delay(2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function gatewaySession(state) {
|
||||
const socket = new WebSocket(`${state.url}/?v=${GATEWAY_VERSION}&encoding=json`);
|
||||
let sequence=state.sequence,sessionId=state.sessionId,resumeUrl=state.url,botUserId=state.botUserId,heartbeatTimer=null,heartbeatAck=true,stopped=false;
|
||||
await new Promise((resolve, reject) => {
|
||||
const cleanup=()=>{if(heartbeatTimer)clearInterval(heartbeatTimer);};
|
||||
socket.addEventListener("error",()=>{cleanup();reject(new Error("Discord gateway socket failed."));},{once:true});
|
||||
socket.addEventListener("close",(event)=>{cleanup();state.onState({connected:false,sessionId,botUserId,lastHeartbeatAckAtMillis:0,phase:"disconnected"});if([4004,4013,4014].includes(event.code)){stopped=true;const error=new Error(`Discord permanently rejected the Bifrost gateway session (close ${event.code}).`);error.fatal=true;reject(error);return;}resolve();},{once:true});
|
||||
socket.addEventListener("message",async event=>{
|
||||
try {
|
||||
const frame=JSON.parse(String(event.data));
|
||||
if(Number.isInteger(frame.s))sequence=frame.s;
|
||||
if(frame.op===10){
|
||||
const interval=Number(frame.d?.heartbeat_interval);if(!Number.isFinite(interval)||interval<1000)throw new Error("Discord gateway supplied an invalid heartbeat interval.");
|
||||
const heartbeat=()=>{if(!heartbeatAck){socket.close(4000,"heartbeat timeout");return;}heartbeatAck=false;socket.send(JSON.stringify({op:1,d:sequence}));};
|
||||
heartbeatTimer=setInterval(heartbeat,interval);heartbeat();
|
||||
if(sessionId&&sequence!==null)socket.send(JSON.stringify({op:6,d:{token:state.token,session_id:sessionId,seq:sequence}}));
|
||||
else socket.send(JSON.stringify({op:2,d:{token:state.token,intents:GATEWAY_INTENTS,properties:{os:process.platform,browser:"bifrost",device:"bifrost"}}}));
|
||||
} else if(frame.op===11){heartbeatAck=true;state.onState({connected:true,sessionId,botUserId,lastHeartbeatAckAtMillis:Date.now(),phase:"heartbeat-acknowledged"});}
|
||||
else if(frame.op===7)socket.close(4000,"server reconnect");
|
||||
else if(frame.op===9){sessionId="";sequence=null;socket.close(4000,"invalid session");}
|
||||
else if(frame.op===0&&frame.t==="READY"){
|
||||
sessionId=text(frame.d?.session_id);resumeUrl=text(frame.d?.resume_gateway_url)||state.url;botUserId=text(frame.d?.user?.id);if(!sessionId||!botUserId)throw new Error("Discord READY omitted session or bot identity.");state.onState({connected:true,sessionId,botUserId,lastHeartbeatAckAtMillis:0,phase:"ready-awaiting-heartbeat-ack"});await state.onReady({runtimeId:state.runtimeId,botUserId,sessionId});
|
||||
} else if(frame.op===0&&frame.t==="RESUMED"){state.onState({connected:true,sessionId,botUserId,lastHeartbeatAckAtMillis:0,phase:"resumed-awaiting-heartbeat-ack"});await state.onReady({runtimeId:state.runtimeId,botUserId,sessionId});}
|
||||
else if(frame.op===0&&frame.t==="MESSAGE_CREATE"){
|
||||
const conversation=conversationFromDiscordMessage(frame.d,botUserId);if(conversation)await state.onConversation(conversation);
|
||||
}
|
||||
} catch(error){socket.close(4000,"ingress failure");reject(error);}
|
||||
});
|
||||
});
|
||||
return {sequence,sessionId,resumeUrl,botUserId,stopped};
|
||||
}
|
||||
|
||||
async function discordJson(url, token) {
|
||||
const response=await fetch(url,{headers:{authorization:`Bot ${token}`},redirect:"error",signal:AbortSignal.timeout(15000)});
|
||||
if(!response.ok)throw new Error(`Discord gateway discovery failed with HTTP ${response.status}.`);
|
||||
return response.json();
|
||||
}
|
||||
function text(value){return typeof value==="string"?value.trim():"";}
|
||||
function escapeRegex(value){return value.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");}
|
||||
function delay(ms){return new Promise(resolve=>setTimeout(resolve,ms));}
|
||||
|
|
@ -7,8 +7,13 @@ const SIGNATURE_DOMAIN=Buffer.from("epiphany.host-incarnation.signature.v0\0","u
|
|||
|
||||
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 async function enrollDomainIdentity(path,label,identityDomain){await enrollSignedIdentity(path,label);return loadDomainIdentity(path,label,identityDomain);}
|
||||
export async function loadDomainIdentity(path,label,identityDomain){const identity=await loadSignedIdentity(path,label);return {...identity,identityId:createHash("sha256").update(Buffer.from(identityDomain,"utf8")).update(identity.publicKey).digest("hex")};}
|
||||
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 signDomainPurpose(identity,signatureDomain,purpose,payload){return ed25519Sign(null,domainMessageWith(signatureDomain,purpose,payload),identity.key);}
|
||||
export function verifyDomainPurpose(publicKey,signatureDomain,purpose,payload,signature){const key=createPublicKey({key:Buffer.concat([Buffer.from("302a300506032b6570032100","hex"),Buffer.from(publicKey)]),format:"der",type:"spki"});return ed25519Verify(null,domainMessageWith(signatureDomain,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 domainMessageWith(signatureDomain,purpose,payload){const p=Buffer.from(purpose,"utf8");return Buffer.concat([Buffer.from(signatureDomain,"utf8"),u64(p.length),p,u64(payload.length),payload]);}
|
||||
function u64(value){const b=Buffer.alloc(8);b.writeBigUInt64BE(BigInt(value));return b;}
|
||||
|
|
|
|||
42
tools/persona-discord-delivery-documents.mjs
Normal file
42
tools/persona-discord-delivery-documents.mjs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
export const requestType = "epiphany.persona_discord_delivery_request";
|
||||
export const requestSchema = "epiphany.persona_discord_delivery_request.v0";
|
||||
export const requestSigningPurpose = "epiphany.persona-discord.delivery-request.v0";
|
||||
export const requestIdentityDomain = "epiphany.persona-discord-delivery-request.identity.v0\0";
|
||||
export const requestSignatureDomain = "epiphany.persona-discord-delivery-request.signature.v0\0";
|
||||
export const receiptType = "bifrost.persona_discord_delivery_receipt";
|
||||
export const receiptSchema = "bifrost.persona_discord_delivery_receipt.v0";
|
||||
export const receiptSigningPurpose = "bifrost.persona-discord.delivery-receipt.v0";
|
||||
export const receiptIdentityDomain = "bifrost.persona-discord-delivery-receipt.identity.v0\0";
|
||||
export const receiptSignatureDomain = "bifrost.persona-discord-delivery-receipt.signature.v0\0";
|
||||
export const executionType = "bifrost.persona_discord_delivery_execution";
|
||||
export const executionSchema = "bifrost.persona_discord_delivery_execution.v0";
|
||||
|
||||
export function buildPersonaDiscordDeliveryDefinitions(defineDocumentType) {
|
||||
const make=(type,schema,name,parse,members)=>defineDocumentType({type,schemaName:type,schemaId:schema,schemaVersion:schema,contentHash:schema,global:false,name,schema:{parse},members});
|
||||
return {
|
||||
request:make(requestType,requestSchema,"requestId",parseRequest,requestMembers),
|
||||
receipt:make(receiptType,receiptSchema,"receiptId",parseReceipt,receiptMembers),
|
||||
execution:make(executionType,executionSchema,"requestId",parseExecution),
|
||||
};
|
||||
}
|
||||
|
||||
const requestMembers = ["schemaVersion","requestId","effectDocumentId","targetRuntimeId","personaAgentId","channelId","replyToMessageId","content","contentSha256","issuedAt","expiresAt","privateStateExposed","signerIdentityId","signerSignature"].map((memberName,slot)=>({slot,memberName,typeName:memberName==="privateStateExposed"?"boolean":memberName==="signerSignature"?"byte[]":"string",isName:memberName==="requestId"}));
|
||||
const receiptMembers = ["schemaVersion","receiptId","requestId","requestPayloadSha256","status","channelId","replyToMessageId","messageId","transport","crossingReceiptId","receiptUrl","completedAt","providerIdentityId","privateStateExposed","providerSignature"].map((memberName,slot)=>({slot,memberName,typeName:memberName==="privateStateExposed"?"boolean":memberName==="providerSignature"?"byte[]":"string",isName:memberName==="receiptId"}));
|
||||
|
||||
export function requestSigningTuple(v){return [v.schemaVersion,v.requestId,v.effectDocumentId,v.targetRuntimeId,v.personaAgentId,v.channelId,v.replyToMessageId,v.content,v.contentSha256,v.issuedAt,v.expiresAt,v.privateStateExposed,v.signerIdentityId];}
|
||||
export function receiptSigningTuple(v){return [v.schemaVersion,v.receiptId,v.requestId,v.requestPayloadSha256,v.status,v.channelId,v.replyToMessageId,v.messageId,v.transport,v.crossingReceiptId,v.receiptUrl,v.completedAt,v.providerIdentityId,v.privateStateExposed];}
|
||||
|
||||
export function parseRequest(value){const v=typed(value,requestMembers,"Persona Discord delivery request");closed(v,["schemaName","schemaVersion","requestId","effectDocumentId","targetRuntimeId","personaAgentId","channelId","replyToMessageId","content","contentSha256","issuedAt","expiresAt","privateStateExposed","signerIdentityId","signerSignature"],"request");exact(v.schemaVersion,requestSchema,"request schema");for(const key of ["requestId","effectDocumentId","targetRuntimeId","personaAgentId","channelId","contentSha256","issuedAt","expiresAt","signerIdentityId"])text(v[key],key);content(v.content);optionalText(v.replyToMessageId,"replyToMessageId");if(v.privateStateExposed!==false)throw new Error("request privateStateExposed must be false.");bytes64(v.signerSignature,"signerSignature");sha(v.contentSha256,"contentSha256");const issued=date(v.issuedAt,"issuedAt"),expires=date(v.expiresAt,"expiresAt");if(expires<=issued||expires-issued>120000)throw new Error("request lifetime must be positive and at most 120 seconds.");return v;}
|
||||
export function parseReceipt(value){const v=record(value,"Persona Discord delivery receipt");exact(v.schemaVersion,receiptSchema,"receipt schema");for(const key of ["receiptId","requestId","requestPayloadSha256","status","channelId","completedAt","providerIdentityId"])text(v[key],key);for(const key of ["replyToMessageId","messageId","transport","crossingReceiptId","receiptUrl"])optionalText(v[key],key);if(v.receiptId!==v.requestId)throw new Error("receiptId must equal requestId.");if(!["completed","failed","unknown"].includes(v.status))throw new Error("receipt status is invalid.");if(v.status==="completed"&&[v.messageId,v.transport,v.crossingReceiptId,v.receiptUrl].some(x=>!x))throw new Error("completed receipt lacks exact delivery evidence.");if(v.status!=="completed"&&[v.messageId,v.transport,v.crossingReceiptId,v.receiptUrl].some(Boolean))throw new Error("non-completed receipt may not claim delivery evidence.");if(v.privateStateExposed!==false)throw new Error("receipt privateStateExposed must be false.");bytes64(v.providerSignature,"providerSignature");sha(v.requestPayloadSha256,"requestPayloadSha256");date(v.completedAt,"completedAt");return v;}
|
||||
export function parseExecution(value){const v=record(value,"Persona Discord execution journal");exact(v.schemaVersion,executionSchema,"execution schema");for(const key of ["requestId","requestPayloadSha256","status","recordedAt"])text(v[key],key);if(!["permit_pending","running","completed","failed","unknown"].includes(v.status))throw new Error("execution status is invalid.");if(v.status==="permit_pending"){text(v.permitNonce,"permitNonce");date(v.permitIssuedAt,"permitIssuedAt");date(v.permitExpiresAt,"permitExpiresAt");if(Date.parse(v.permitExpiresAt)<=Date.parse(v.permitIssuedAt)||Date.parse(v.permitExpiresAt)-Date.parse(v.permitIssuedAt)>5000)throw new Error("permit intent lifetime is invalid");}return v;}
|
||||
function record(v,label){if(!v||typeof v!=="object"||Array.isArray(v))throw new Error(`${label} must be an object.`);return v;}
|
||||
function typed(v,members,label){if(Array.isArray(v)){if(v.length!==members.length)throw new Error(`${label} tuple has the wrong arity.`);return Object.fromEntries(members.map(member=>[member.memberName,member.typeName==="byte[]"&&Array.isArray(v[member.slot])?Uint8Array.from(v[member.slot]):v[member.slot]]));}return record(v,label);}
|
||||
function text(v,label){bounded(v,label,256);}
|
||||
function bounded(v,label,maxBytes){if(typeof v!=="string"||!v.trim()||Buffer.byteLength(v,"utf8")>maxBytes||/\p{Cc}/u.test(v))throw new Error(`${label} is empty, oversized, or contains control characters.`);}
|
||||
function content(v){if(typeof v!=="string"||!v.trim()||Buffer.byteLength(v,"utf8")>1900||v.includes("\0"))throw new Error("content is empty, exceeds 1900 UTF-8 bytes, or contains NUL.");}
|
||||
function optionalText(v,label){if(typeof v!=="string")throw new Error(`${label} must be a string.`);}
|
||||
function exact(v,w,label){if(v!==w)throw new Error(`${label} must be ${w}.`);}
|
||||
function bytes64(v,label){if(!(v instanceof Uint8Array)||v.length!==64)throw new Error(`${label} must be 64 bytes.`);}
|
||||
function sha(v,label){if(!/^sha256-[0-9a-f]{64}$/.test(v))throw new Error(`${label} must be lowercase sha256.`);}
|
||||
function date(v,label){const parsed=Date.parse(v);if(Number.isNaN(parsed))throw new Error(`${label} must be RFC3339.`);return parsed;}
|
||||
function closed(v,keys,label){const allowed=new Set(keys);for(const key of Object.keys(v))if(!allowed.has(key))throw new Error(`${label} contains unknown field ${key}.`);}
|
||||
51
tools/persona-discord-delivery.mjs
Normal file
51
tools/persona-discord-delivery.mjs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import {createHash,randomBytes} from "node:crypto";
|
||||
import {spawnSync} from "node:child_process";
|
||||
import {access,readFile,writeFile,mkdir} from "node:fs/promises";
|
||||
import {constants} from "node:fs";
|
||||
import {dirname,resolve} from "node:path";
|
||||
import {enrollDomainIdentity,loadDomainIdentity,signDomainPurpose,verifyDomainPurpose} from "./bifrost-signed-identity.mjs";
|
||||
import {buildPersonaDiscordDeliveryDefinitions,executionSchema,receiptIdentityDomain,receiptSchema,receiptSignatureDomain,receiptSigningPurpose,receiptSigningTuple,requestSignatureDomain,requestSigningPurpose,requestSigningTuple} from "./persona-discord-delivery-documents.mjs";
|
||||
import {requestPersonaDiscordPermit} from "./persona-discord-permit.mjs";
|
||||
import {permitRequestIdentityDomain,permitRequestPurpose,permitRequestSchema} from "./persona-discord-permit.mjs";
|
||||
|
||||
export async function enrollPersonaDiscordDeliveryIdentity({runtime,privateKeyPath,trustAnchorOut}){const identity=await enrollDomainIdentity(privateKeyPath,"Bifrost Persona Discord delivery identity",receiptIdentityDomain),anchor=["gamecult.service_trust_anchor.v1","bifrost-persona-discord-delivery:bifrost-discord-yggdrasil:v0","bifrost-persona-discord-delivery","bifrost-discord-yggdrasil",identity.identityId,new Uint8Array(identity.publicKey),"ed25519",receiptSigningPurpose,"bifrost.persona_discord_delivery_receipt.v0","root",Date.now(),null,false];await mkdir(dirname(trustAnchorOut),{recursive:true});await writeFile(trustAnchorOut,runtime.encode(anchor),{flag:"wx",mode:0o640});return {identityId:identity.identityId,privateKeyPath,trustAnchorOut};}
|
||||
export async function exportPersonaDiscordPermitRequestAnchor({runtime,privateKeyPath,trustAnchorOut}){const identity=await loadDomainIdentity(privateKeyPath,"Bifrost Persona Discord permit requester identity",permitRequestIdentityDomain),anchor=["gamecult.service_trust_anchor.v1","bifrost-persona-discord-delivery-permit-request:bifrost-discord-yggdrasil:v0","bifrost-persona-discord-delivery","bifrost-discord-yggdrasil",identity.identityId,new Uint8Array(identity.publicKey),"ed25519",permitRequestPurpose,permitRequestSchema,"root",Date.now(),null,false];await mkdir(dirname(trustAnchorOut),{recursive:true});await writeFile(trustAnchorOut,runtime.encode(anchor),{flag:"wx",mode:0o640});return {identityId:identity.identityId,privateKeyPath,trustAnchorOut};}
|
||||
|
||||
export async function processPersonaDiscordDeliveries(options){
|
||||
const {runtime,requestStorePath,receiptStorePath,executionStorePath,epiphanyTrustAnchorPath,privateKeyPath,targetAdmitted}=options,defs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType);
|
||||
requireSeparate([requestStorePath,receiptStorePath,executionStorePath]);
|
||||
const requests=await open(runtime,defs.request,requestStorePath),receipts=await open(runtime,defs.receipt,receiptStorePath),executions=await open(runtime,defs.execution,executionStorePath),anchor=await readAnchor(runtime,epiphanyTrustAnchorPath),identity=await loadDomainIdentity(privateKeyPath,"Bifrost Persona Discord delivery identity",receiptIdentityDomain);
|
||||
const results=[];
|
||||
for(const request of requests.cache.getAll(defs.request).sort((a,b)=>String(a.issuedAt).localeCompare(String(b.issuedAt)))){
|
||||
const unsigned=runtime.encode(requestSigningTuple(request)),digest=`sha256-${sha(unsigned)}`,existingReceipt=unwrap(receipts.cache.get(defs.receipt,request.requestId)),existingExecution=unwrap(executions.cache.get(defs.execution,request.requestId));
|
||||
if(existingReceipt){if(existingReceipt.requestPayloadSha256!==digest)throw new Error(`Persona Discord request ${request.requestId} conflicts with its terminal receipt.`);continue;}
|
||||
if(existingExecution){if(existingExecution.requestPayloadSha256!==digest)throw new Error(`Persona Discord request ${request.requestId} conflicts with its execution journal.`);if(existingExecution.status==="running"){results.push(await terminal("unknown",request,digest,"delivery outcome was indeterminate after service interruption",{runtime,defs,receipts,executions,identity}));continue;}if(existingExecution.status!=="permit_pending")continue;}
|
||||
let failure="";
|
||||
try{validateRequest(request,unsigned,anchor,targetAdmitted);}catch(error){failure=error instanceof Error?error.message:String(error);}
|
||||
if(failure){results.push(await terminal("failed",request,digest,failure,{runtime,defs,receipts,executions,identity}));continue;}
|
||||
if(existingExecution&&Date.parse(existingExecution.permitExpiresAt)<Date.now()){results.push(await terminal("unknown",request,digest,"permit issuance outcome was indeterminate after its durable request window expired",{runtime,defs,receipts,executions,identity}));continue;}
|
||||
const permitIssuedAt=existingExecution?.permitIssuedAt??new Date().toISOString(),permitExpiresAt=existingExecution?.permitExpiresAt??new Date(Date.parse(permitIssuedAt)+5000).toISOString(),nonce=existingExecution?.permitNonce??randomBytes(16).toString("hex");
|
||||
if(!existingExecution)await executions.cache.put(defs.execution,request.requestId,{schemaName:"bifrost.persona_discord_delivery_execution",schemaVersion:executionSchema,requestId:request.requestId,requestPayloadSha256:digest,status:"permit_pending",recordedAt:permitIssuedAt,permitNonce:nonce,permitIssuedAt,permitExpiresAt,detail:"permit request intent persisted before network"});
|
||||
let permit;
|
||||
try{permit=await (options.requestPermit??requestPersonaDiscordPermit)({endpoint:options.epiphanyPermitEndpoint,requestId:request.requestId,requestPayloadSha256:digest,targetRuntimeId:request.targetRuntimeId,nonce,issuedAt:permitIssuedAt,expiresAt:permitExpiresAt,privateKeyPath,epiphanyPermitAnchorPath:options.epiphanyPermitAnchorPath,runtime:options.permitRuntime,timeoutMs:options.permitTimeoutMs});}
|
||||
catch(error){results.push({requestId:request.requestId,status:"permit_pending",detail:error instanceof Error?error.message:String(error)});continue;}
|
||||
await executions.cache.put(defs.execution,request.requestId,{schemaName:"bifrost.persona_discord_delivery_execution",schemaVersion:executionSchema,requestId:request.requestId,requestPayloadSha256:digest,status:"running",recordedAt:new Date().toISOString(),detail:`permit ${permit.permitId} durably consumed before external delivery`});
|
||||
if(Date.parse(permit.expiresAt)<=Date.now()){results.push(await terminal("failed",request,digest,"Epiphany brake permit expired after durable running journal and before Discord actuation",{runtime,defs,receipts,executions,identity}));continue;}
|
||||
try{
|
||||
const posted=post(request,options);
|
||||
results.push(await terminal("completed",request,digest,"",{runtime,defs,receipts,executions,identity,posted}));
|
||||
}catch(error){results.push(await terminal("unknown",request,digest,error instanceof Error?error.message:String(error),{runtime,defs,receipts,executions,identity}));}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function inspectPersonaDiscordDeliveryReadiness(options){try{requireSeparate([options.requestStorePath,options.receiptStorePath,options.executionStorePath]);const anchor=await readAnchor(options.runtime,options.epiphanyTrustAnchorPath),identity=await loadDomainIdentity(options.privateKeyPath,"Bifrost Persona Discord delivery identity",receiptIdentityDomain);if(!options.epiphanyPermitEndpoint)throw new Error("Epiphany permit endpoint is absent");await access(options.epiphanyPermitAnchorPath,constants.R_OK);await access(options.requestStorePath,constants.R_OK);await access(options.receiptStorePath,constants.R_OK|constants.W_OK);return {ready:true,requestSignerIdentityId:anchor.identityId,receiptSignerIdentityId:identity.identityId,reasons:[]};}catch(error){return {ready:false,requestSignerIdentityId:"",receiptSignerIdentityId:"",reasons:[error instanceof Error?error.message:String(error)]};}}
|
||||
|
||||
function validateRequest(request,unsigned,anchor,targetAdmitted){if(request.signerIdentityId!==anchor.identityId)throw new Error("request signer is not the pinned Epiphany Persona mouth identity");if(!verifyDomainPurpose(anchor.publicKey,requestSignatureDomain,requestSigningPurpose,unsigned,request.signerSignature))throw new Error("request signature is invalid");if(`sha256-${sha(Buffer.from(request.content,"utf8"))}`!==request.contentSha256)throw new Error("request content digest is invalid");const now=Date.now(),issued=Date.parse(request.issuedAt),expires=Date.parse(request.expiresAt);if(issued>now+30_000)throw new Error("request issuance is in the future");if(expires<=issued||expires<now)throw new Error("request is expired");if(expires-issued>120_000)throw new Error("request lifetime exceeds 120 seconds");if(!targetAdmitted(request))throw new Error("request target is not an exact configured Bifrost Persona binding");}
|
||||
function post(request,options){const args=[options.bridgeCliPath,"discord-post","--channel-id",request.channelId,"--content",request.content,"--identity",request.personaAgentId,"--source-kind","epiphany_persona_speech","--source-id",request.effectDocumentId,"--authority-ref","epiphany.persona_discord_delivery_request.v0","--epiphany-lane-id","Persona","--epiphany-agent-identity",request.personaAgentId,"--receipt-store",options.crossingReceiptStorePath??resolve(dirname(options.executionStorePath),"persona-discord-crossing-receipts.cc")];if(request.replyToMessageId)args.push("--reply-to-message-id",request.replyToMessageId);const capability=process.env.HEIMDALL_CAPABILITY_REF||process.env.BIFROST_HEIMDALL_CAPABILITY_REF;if(capability)args.push("--heimdall-capability-ref",capability);const result=spawnSync(process.execPath,args,{cwd:options.bifrostRoot,encoding:"utf8",windowsHide:true,env:process.env});if(result.status!==0||result.error)throw new Error(`Bifrost Discord transport returned indeterminate outcome: ${result.error?.message||result.stderr||result.stdout}`);const value=JSON.parse(result.stdout);if(value.action!=="discord-post"||value.ok!==true||value.channelId!==request.channelId||!value.messageId||!value.crossingReceiptId||!value.url)throw new Error("Bifrost Discord transport did not return exact delivery evidence");return value;}
|
||||
async function terminal(status,request,digest,detail,c){const now=new Date().toISOString(),posted=c.posted??{},receipt={schemaName:"bifrost.persona_discord_delivery_receipt",schemaVersion:receiptSchema,receiptId:request.requestId,requestId:request.requestId,requestPayloadSha256:digest,status,channelId:request.channelId,replyToMessageId:request.replyToMessageId,messageId:status==="completed"?posted.messageId:"",transport:status==="completed"?posted.transport:"",crossingReceiptId:status==="completed"?posted.crossingReceiptId:"",receiptUrl:status==="completed"?posted.url:"",completedAt:now,providerIdentityId:c.identity.identityId,privateStateExposed:false,providerSignature:new Uint8Array()};receipt.providerSignature=new Uint8Array(signDomainPurpose(c.identity,receiptSignatureDomain,receiptSigningPurpose,c.runtime.encode(receiptSigningTuple(receipt))));await c.receipts.cache.put(c.defs.receipt,receipt.receiptId,receipt);await c.executions.cache.put(c.defs.execution,request.requestId,{schemaName:"bifrost.persona_discord_delivery_execution",schemaVersion:executionSchema,requestId:request.requestId,requestPayloadSha256:digest,status,recordedAt:now,detail});return receipt;}
|
||||
async function open(runtime,definition,path){await mkdir(dirname(path),{recursive:true});const cache=runtime.CultCache.builder().withDocumentType(definition).withGenericStore(new runtime.SingleFileMessagePackBackingStore(path)).build();await cache.pullAllBackingStores();return {cache};}
|
||||
async function readAnchor(runtime,path){const value=runtime.decode(await readFile(path));if(!Array.isArray(value)||value.length!==13)throw new Error("Epiphany Persona mouth trust anchor must be the exact service-contract tuple");const [schemaVersion,trustAnchorId,serviceId,runtimeId,identityId,rawPublicKey,algorithm,purpose,signedSchema,bindingAuthority,boundAt,expiresAt,privateStateExposed]=value,publicKey=rawPublicKey instanceof Uint8Array?rawPublicKey:Array.isArray(rawPublicKey)?Uint8Array.from(rawPublicKey):null,now=Date.now();if(schemaVersion!=="gamecult.service_trust_anchor.v1"||typeof trustAnchorId!=="string"||serviceId!=="epiphany-persona-mouth"||runtimeId!=="epiphany-yggdrasil"||typeof identityId!=="string"||!publicKey||publicKey.length!==32||algorithm!=="ed25519"||purpose!==requestSigningPurpose||signedSchema!=="epiphany.persona_discord_delivery_request.v0"||bindingAuthority!=="root"||!Number.isSafeInteger(boundAt)||boundAt<=0||boundAt>now||(expiresAt!==null&&(!Number.isSafeInteger(expiresAt)||expiresAt<=boundAt||expiresAt<=now))||privateStateExposed!==false)throw new Error("Epiphany Persona mouth service trust anchor is invalid");return {identityId,publicKey};}
|
||||
function requireSeparate(paths){const keys=paths.map(p=>resolve(p).toLowerCase());if(new Set(keys).size!==keys.length)throw new Error("Persona Discord crossing and private execution stores must be distinct.");}
|
||||
function sha(value){return createHash("sha256").update(value).digest("hex");}
|
||||
function unwrap(value){return Array.isArray(value)?value[0]:value?.value??value;}
|
||||
36
tools/persona-discord-permit.mjs
Normal file
36
tools/persona-discord-permit.mjs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import {createHash,randomBytes} from "node:crypto";
|
||||
import {createSocket} from "node:dgram";
|
||||
import {createRequire} from "node:module";
|
||||
import {existsSync} from "node:fs";
|
||||
import {readFile} from "node:fs/promises";
|
||||
import {resolve} from "node:path";
|
||||
import {loadDomainIdentity,signDomainPurpose,verifyDomainPurpose} from "./bifrost-signed-identity.mjs";
|
||||
|
||||
export const permitRequestSchema="bifrost.persona_discord_delivery_permit_request.v0";
|
||||
export const permitSchema="epiphany.persona_discord_delivery_permit.v0";
|
||||
export const permitRequestPurpose="bifrost.persona-discord.delivery-permit-request.v0";
|
||||
export const permitPurpose="epiphany.persona-discord.delivery-permit.v0";
|
||||
export const permitRequestIdentityDomain="bifrost.persona-discord-delivery-permit-request.identity.v0\0";
|
||||
export const permitRequestSignatureDomain="bifrost.persona-discord-delivery-permit-request.signature.v0\0";
|
||||
export const permitIdentityDomain="epiphany.persona-discord-delivery-permit.identity.v0\0";
|
||||
export const permitSignatureDomain="epiphany.persona-discord-delivery-permit.signature.v0\0";
|
||||
const connectionId=0xe91f0002;
|
||||
|
||||
export const permitRequestTuple=v=>[v.schemaVersion,v.requestId,v.requestPayloadSha256,v.targetRuntimeId,v.nonce,v.issuedAt,v.expiresAt,v.requesterIdentityId,v.privateStateExposed];
|
||||
export const permitTuple=v=>[v.schemaVersion,v.permitId,v.requestId,v.requestPayloadSha256,v.targetRuntimeId,v.nonce,v.requesterIdentityId,v.brakeStateDocumentId,v.brakeStateDocumentSha256,v.brakeObservedAt,v.issuedAt,v.expiresAt,v.providerIdentityId,v.privateStateExposed];
|
||||
|
||||
export async function requestPersonaDiscordPermit({endpoint,requestId,requestPayloadSha256,targetRuntimeId,nonce,issuedAt,expiresAt,privateKeyPath,epiphanyPermitAnchorPath,timeoutMs=5000,runtime:provided,socketFactory=createSocket,connectionFactory}){
|
||||
const runtime=provided??loadRuntime(),identity=await loadDomainIdentity(privateKeyPath,"Bifrost Persona Discord permit requester identity",permitRequestIdentityDomain),anchor=await readAnchor(runtime,epiphanyPermitAnchorPath),now=Date.now();
|
||||
const request={schemaVersion:permitRequestSchema,requestId,requestPayloadSha256,targetRuntimeId,nonce:nonce??randomBytes(16).toString("hex"),issuedAt:issuedAt??new Date(now).toISOString(),expiresAt:expiresAt??new Date(now+5000).toISOString(),requesterIdentityId:identity.identityId,privateStateExposed:false,requesterSignature:new Uint8Array()};
|
||||
request.requesterSignature=new Uint8Array(signDomainPurpose(identity,permitRequestSignatureDomain,permitRequestPurpose,runtime.msgpack.encode(permitRequestTuple(request))));
|
||||
const {host,port}=parseEndpoint(endpoint),socket=socketFactory("udp4");await bind(socket);const connection=(connectionFactory??(o=>new runtime.cultnet.CultNetRudpSocketTransportConnection(o)))({runtimeId:"bifrost-discord-yggdrasil",socket,mode:"client",remoteHost:host,remotePort:port,connectionId,resendDelayMs:50,resendPollMs:10});
|
||||
try{connection.connect();await connected(connection,timeoutMs);const response=wait(connection,runtime.msgpack,timeoutMs,request);const message={schemaVersion:"cultnet.document_put_raw.v0",messageId:`permit-request-${requestId}`,document:{schemaId:permitRequestSchema,recordKey:requestId,storedAt:request.issuedAt,payloadEncoding:"messagepack",payload:runtime.msgpack.encode(request),sourceRuntimeId:"bifrost-discord-yggdrasil",sourceAgentId:identity.identityId,sourceRole:"bifrost-persona-discord-delivery",tags:["cultnet.transport.rudp.v0"]}};connection.send("schema",runtime.msgpack.encode(runtime.cultnet.encodeCultNetMessageForWire(message,"cultnet.schema.v0")));const permit=await response;validatePermit(permit,request,anchor,runtime.msgpack.encode);return permit;}finally{connection.close();}
|
||||
}
|
||||
|
||||
export function validatePermit(v,request,anchor,encode){if(!v||v.schemaVersion!==permitSchema||v.requestId!==request.requestId||v.requestPayloadSha256!==request.requestPayloadSha256||v.targetRuntimeId!==request.targetRuntimeId||v.nonce!==request.nonce||v.requesterIdentityId!==request.requesterIdentityId||v.providerIdentityId!==anchor.identityId||v.privateStateExposed!==false||!/^sha256-[0-9a-f]{64}$/.test(v.brakeStateDocumentSha256)||!(v.providerSignature instanceof Uint8Array)||v.providerSignature.length!==64)throw new Error("Epiphany permit substituted delivery authority");const issued=Date.parse(v.issuedAt),expires=Date.parse(v.expiresAt),observed=Date.parse(v.brakeObservedAt),now=Date.now();if(!Number.isFinite(issued)||!Number.isFinite(expires)||!Number.isFinite(observed)||expires<=issued||expires-issued>5000||expires<now||observed>issued||issued>now+1000)throw new Error("Epiphany permit is stale or has an invalid lifetime");if(!verifyDomainPurpose(anchor.publicKey,permitSignatureDomain,permitPurpose,encode(permitTuple(v)),v.providerSignature))throw new Error("Epiphany permit signature is invalid");return v;}
|
||||
async function readAnchor(runtime,path){const a=runtime.msgpack.decode(await readFile(path));if(!Array.isArray(a)||a.length!==13)throw new Error("Epiphany permit anchor must be an exact service anchor");const [schemaVersion,,serviceId,runtimeId,identityId,publicKey,algorithm,purpose,signedSchema,authority,boundAt,expiresAt,privateStateExposed]=a,now=Date.now();if(schemaVersion!=="gamecult.service_trust_anchor.v1"||serviceId!=="epiphany-persona-discord-permit"||runtimeId!=="epiphany-yggdrasil"||!(publicKey instanceof Uint8Array)||publicKey.length!==32||algorithm!=="ed25519"||purpose!==permitPurpose||signedSchema!==permitSchema||authority!=="root"||boundAt>now||(expiresAt!==null&&expiresAt<=now)||privateStateExposed!==false)throw new Error("Epiphany permit anchor is invalid");return {identityId,publicKey};}
|
||||
function wait(c,m,t,r){return new Promise((ok,no)=>{const done=(f,v)=>{clearTimeout(timer);c.off?.("frame",frame);c.off?.("error",error);f(v)},error=e=>done(no,e),frame=x=>{try{if(x.channelId!=="schema")throw new Error("permit arrived on foreign channel");const w=m.decode(x.payload),d=w?.document,p=m.decode(d?.payload);if(w?.schemaVersion!=="cultnet.document_put_raw.v0"||d?.schemaId!==permitSchema||d?.recordKey!==`permit:${r.requestPayloadSha256}`||d?.payloadEncoding!=="messagepack"||d?.sourceRuntimeId!==r.targetRuntimeId||d?.sourceRole!=="epiphany-persona-discord-permit"||d?.sourceAgentId!==p?.providerIdentityId)throw new Error("permit envelope substituted authority");done(ok,p)}catch(e){done(no,e)}};const timer=setTimeout(()=>done(no,new Error("Timed out waiting for Epiphany Persona Discord permit")),t);c.on("frame",frame);c.on("error",error)})}
|
||||
async function connected(c,t){const s=Date.now();while(!c.connected){if(Date.now()-s>=t)throw new Error("Timed out establishing permit RUDP session");await new Promise(r=>setTimeout(r,5));}}
|
||||
function bind(s){return new Promise((ok,no)=>{s.once("error",no);s.once("listening",ok);s.bind(0,"0.0.0.0")})}
|
||||
function parseEndpoint(v){const u=new URL(v);if(u.protocol!=="rudp:"||!u.hostname||!u.port)throw new Error("permit endpoint must be rudp://host:port");return {host:u.hostname,port:Number(u.port)}}
|
||||
function loadRuntime(){const root=resolve(import.meta.dirname,".."),cult=resolve(process.env.VOIDBOT_CULTLIB_ROOT||resolve(root,"..","CultLib")),pkg=resolve(cult,"packages","cultnet-ts","package.json");if(!existsSync(pkg))throw new Error("CultNet runtime unavailable");const req=createRequire(pkg);return {cultnet:req("cultnet-ts"),msgpack:req("@msgpack/msgpack")};}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
export const feedbackEventType = "voidbot.discord.persona_feedback_event";
|
||||
export const feedbackEventSchema = "voidbot.discord.persona_feedback_event.v0";
|
||||
export const feedbackEventType = "bifrost.discord.persona_conversation_event";
|
||||
export const feedbackEventSchema = "bifrost.discord.persona_conversation_event.v0";
|
||||
export const feedbackBindingType = "bifrost.persona_feedback.target_binding";
|
||||
export const feedbackBindingSchema = "bifrost.persona_feedback.target_binding.v0";
|
||||
export const actorLinkType = "bifrost.discord.actor_link";
|
||||
|
|
@ -8,6 +8,8 @@ export const feedbackReceiptType = "bifrost.persona_feedback.admission_receipt";
|
|||
export const feedbackReceiptSchema = "bifrost.persona_feedback.admission_receipt.v0";
|
||||
export const feedbackDeliveryType = "bifrost.persona_feedback.delivery";
|
||||
export const feedbackDeliverySchema = "bifrost.persona_feedback.delivery.v0";
|
||||
export const feedbackPendingFailureType = "bifrost.persona_feedback.pending_failure";
|
||||
export const feedbackPendingFailureSchema = "bifrost.persona_feedback.pending_failure.v0";
|
||||
|
||||
// Kept as explicit constructors because CultCache document metadata is part of the wire contract.
|
||||
export function buildFeedbackDefinitions(defineDocumentType) {
|
||||
|
|
@ -18,6 +20,7 @@ export function buildFeedbackDefinitions(defineDocumentType) {
|
|||
actorLink: make(actorLinkType, actorLinkSchema, "linkId", parseActorLink),
|
||||
receipt: make(feedbackReceiptType, feedbackReceiptSchema, "receiptId", parseReceipt),
|
||||
delivery: make(feedbackDeliveryType, feedbackDeliverySchema, "admissionId", parseDelivery),
|
||||
pendingFailure: make(feedbackPendingFailureType, feedbackPendingFailureSchema, "failureId", parsePendingFailure),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -25,7 +28,7 @@ export function parseEvent(value) {
|
|||
const v = record(value, "feedback event");
|
||||
exact(v.schemaVersion, feedbackEventSchema, "event schema");
|
||||
for (const key of ["eventId","guildId","channelId","messageId","authorId","addressingMode","targetPersonaId","targetRepoName","targetRuntimeId","content","payloadHash","producerId","producerRuntimeId"]) text(v[key], key);
|
||||
exact(v.producerId, "voidbot", "producer"); exact(v.authorityClass, "feedback_only", "authorityClass");
|
||||
exact(v.producerId, "bifrost-discord", "producer"); exact(v.authorityClass, "feedback_only", "authorityClass");
|
||||
exact(v.status, "pending", "status");
|
||||
if(!["role","text","reply","broadcast"].includes(v.addressingMode))throw new Error("addressingMode is invalid.");
|
||||
if(!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(v.observedAt)||Number.isNaN(Date.parse(v.observedAt)))throw new Error("observedAt must be RFC3339.");
|
||||
|
|
@ -35,7 +38,8 @@ export function parseEvent(value) {
|
|||
export function parseBinding(value) { const v = record(value,"target binding"); exact(v.schemaVersion,feedbackBindingSchema,"binding schema"); for (const k of ["bindingId","guildId","channelId","targetPersonaId","targetRepoName","targetRuntimeId","producerId","producerRuntimeId","sourceVisibility","dataClassification","deliveryStorePath"]) text(v[k],k); const expected={public:"public_feedback",organization:"organization_feedback",private:"private_feedback"}[v.sourceVisibility];if(!expected||v.dataClassification!==expected)throw new Error("sourceVisibility and dataClassification must be the exact admitted pair."); return v; }
|
||||
export function parseActorLink(value) { const v=record(value,"actor link"); exact(v.schemaVersion,actorLinkSchema,"actor link schema"); for(const k of ["linkId","guildId","discordAuthorId","bifrostActorId","heimdallActorRef"]) text(v[k],k); return v; }
|
||||
export function parseReceipt(value) { const v=record(value,"receipt"); exact(v.schemaVersion,feedbackReceiptSchema,"receipt schema"); for(const k of ["receiptId","eventId","status","classification","payloadHash","bindingId","recordedAt"]) text(v[k],k); return v; }
|
||||
export function parseDelivery(value) { const v=record(value,"delivery"); exact(v.schemaVersion,feedbackDeliverySchema,"delivery schema"); for(const k of ["admissionId","packetSha256","sourceObserverId","sourceObserverRuntimeId","provider","bifrostAdmissionReceiptId","authority","providerIdentityId"]) text(v[k],k); exact(v.sourceObserverId,"voidbot","sourceObserverId"); exact(v.provider,"bifrost","provider"); exact(v.authority,"feedback_only","authority"); if(!(v.providerSignature instanceof Uint8Array)||v.providerSignature.length!==64)throw new Error("providerSignature must be 64 bytes."); const p=record(v.packet,"packet"); for(const k of ["feedbackId","sourceEventId","sourceActorId","actorClassification","discordGuildId","discordChannelId","discordMessageId","targetRuntimeId","targetRepository","targetPersonaId","sourceRoomId","feedbackText","contentSha256","sourceVisibility","dataClassification"])text(p[k],k);if(!Array.isArray(p.sourceDiscussionRefs)||p.sourceDiscussionRefs.length===0)throw new Error("sourceDiscussionRefs are required.");if(p.privateStateIncluded!==false)throw new Error("privateStateIncluded describes embedded private machine state and must be false.");return v; }
|
||||
export function parseDelivery(value) { const v=record(value,"delivery"); exact(v.schemaVersion,feedbackDeliverySchema,"delivery schema"); for(const k of ["admissionId","packetSha256","sourceObserverId","sourceObserverRuntimeId","provider","bifrostAdmissionReceiptId","authority","providerIdentityId"]) text(v[k],k); exact(v.sourceObserverId,"bifrost-discord","sourceObserverId"); exact(v.provider,"bifrost","provider"); exact(v.authority,"feedback_only","authority"); if(!(v.providerSignature instanceof Uint8Array)||v.providerSignature.length!==64)throw new Error("providerSignature must be 64 bytes."); const p=record(v.packet,"packet"); for(const k of ["feedbackId","sourceEventId","sourceActorId","actorClassification","discordGuildId","discordChannelId","discordMessageId","targetRuntimeId","targetRepository","targetPersonaId","sourceRoomId","feedbackText","contentSha256","sourceVisibility","dataClassification"])text(p[k],k);if(!Array.isArray(p.sourceDiscussionRefs)||p.sourceDiscussionRefs.length===0)throw new Error("sourceDiscussionRefs are required.");if(p.privateStateIncluded!==false)throw new Error("privateStateIncluded describes embedded private machine state and must be false.");return v; }
|
||||
export function parsePendingFailure(value) { const v=record(value,"pending failure"); exact(v.schemaVersion,feedbackPendingFailureSchema,"pending failure schema"); for(const k of ["failureId","eventId","lastError","lastAttemptAt"])text(v[k],k); if(v.failureId!==v.eventId)throw new Error("failureId must equal eventId."); if(!Number.isSafeInteger(v.attemptCount)||v.attemptCount<1||v.attemptCount>1_000_000)throw new Error("attemptCount is invalid."); if(Buffer.byteLength(v.lastError,"utf8")>512)throw new Error("lastError exceeds 512 UTF-8 bytes."); if(!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(v.lastAttemptAt)||Number.isNaN(Date.parse(v.lastAttemptAt)))throw new Error("lastAttemptAt must be RFC3339."); if(v.privateStateExposed!==false)throw new Error("privateStateExposed must be false."); return v; }
|
||||
function record(value,label){if(!value||typeof value!=="object"||Array.isArray(value))throw new Error(`${label} must be an object.`);return value;}
|
||||
function text(value,label){if(typeof value!=="string"||!value.trim())throw new Error(`${label} is required.`);return value;}
|
||||
function exact(value,expected,label){if(value!==expected)throw new Error(`${label} must be ${expected}.`);}
|
||||
|
|
|
|||
|
|
@ -4,15 +4,20 @@ import { createRequire } from "node:module";
|
|||
import { constants } from "node:fs";
|
||||
import { access, mkdir, realpath, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { buildFeedbackDefinitions, actorLinkSchema, feedbackBindingSchema, feedbackDeliverySchema, feedbackReceiptSchema } from "./persona-feedback-documents.mjs";
|
||||
import { buildFeedbackDefinitions, actorLinkSchema, feedbackBindingSchema, feedbackDeliverySchema, feedbackReceiptSchema, feedbackPendingFailureSchema } from "./persona-feedback-documents.mjs";
|
||||
import { defaultFeedbackIdentityPath, enrollBifrostFeedbackIdentity, loadBifrostFeedbackIdentity, providerHealthTrustAnchor, signFeedbackAdmission, trustAnchor } from "./bifrost-feedback-identity.mjs";
|
||||
import { createPersonaFeedbackHealthPublisher, publishPersonaFeedbackHealth } from "./persona-feedback-idunn-health.mjs";
|
||||
import { discordGatewayReady, serveDiscordPersonaIngress } from "./bifrost-discord-persona-ingress.mjs";
|
||||
import { enrollPersonaDiscordDeliveryIdentity, exportPersonaDiscordPermitRequestAnchor, inspectPersonaDiscordDeliveryReadiness, processPersonaDiscordDeliveries } from "./persona-discord-delivery.mjs";
|
||||
|
||||
const root=resolve(import.meta.dirname,".."),projects=resolve(root,".."),cult=resolve(process.env.VOIDBOT_CULTLIB_ROOT||resolve(projects,"CultLib"));
|
||||
const [command,...rawArgs]=process.argv.slice(2),options=parseArgs(rawArgs);
|
||||
const privateStorePath=resolve(options.store||(process.platform==="win32"?resolve(root,".bifrost","provider-store.cc"):"/var/lib/gamecult/bifrost/persona-feedback/provider.cc"));
|
||||
const observationStorePath=resolve(options["observation-store"]||(process.platform==="win32"?resolve(root,".bifrost","persona-feedback-observations.cc"):"/srv/bifrost/persona-feedback/observations.cc"));
|
||||
const observationStorePath=resolve(options["observation-store"]||(process.platform==="win32"?resolve(root,".bifrost","persona-conversation-observations.cc"):"/srv/bifrost/persona-feedback/observations.cc"));
|
||||
const deliveryStorePath=resolve(options["delivery-store"]||(process.platform==="win32"?resolve(root,".bifrost","epiphany-persona-feedback.cc"):"/srv/bifrost/persona-feedback/deliveries.cc"));
|
||||
const personaDeliveryRequestStorePath=resolve(options["persona-delivery-request-store"]||(process.platform==="win32"?resolve(root,".bifrost","persona-discord-requests.cc"):"/srv/bifrost/persona-discord-crossing/requests.cc"));
|
||||
const personaDeliveryReceiptStorePath=resolve(options["persona-delivery-receipt-store"]||(process.platform==="win32"?resolve(root,".bifrost","persona-discord-receipts.cc"):"/srv/bifrost/persona-discord-crossing/receipts.cc"));
|
||||
const personaDeliveryExecutionStorePath=resolve(options["persona-delivery-execution-store"]||(process.platform==="win32"?resolve(root,".bifrost","private","persona-discord-executions.cc"):"/var/lib/gamecult/bifrost/persona-feedback/persona-discord-executions.cc"));
|
||||
const runtime=loadRuntime(),defs=buildFeedbackDefinitions(runtime.defineDocumentType);
|
||||
|
||||
try{await main();}catch(error){console.error(error instanceof Error?error.message:String(error));process.exitCode=1;}
|
||||
|
|
@ -22,16 +27,19 @@ async function main(){
|
|||
if(command==="link-actor")return linkActor();
|
||||
if(command==="process")return processEvent(requireOption("event-id"));
|
||||
if(command==="serve")return serve();
|
||||
if(command==="serve-discord")return serveDiscord();
|
||||
if(command==="export")return exportSnapshot();
|
||||
if(command==="enroll-identity")return enrollIdentity();
|
||||
if(command==="enroll-persona-delivery-identity")return enrollPersonaDeliveryIdentity();
|
||||
if(command==="export-persona-permit-request-anchor")return exportPersonaPermitRequestAnchor();
|
||||
if(command==="export-provider-health-anchor")return exportProviderHealthAnchor();
|
||||
if(command==="status")return status();
|
||||
throw new Error("Usage: persona-feedback.mjs bind-target|link-actor|process|serve|status|export|enroll-identity|export-provider-health-anchor");
|
||||
throw new Error("Usage: persona-feedback.mjs bind-target|link-actor|process|serve|serve-discord|status|export|enroll-identity|enroll-persona-delivery-identity|export-provider-health-anchor");
|
||||
}
|
||||
async function privateNode(){return runtime.CultMesh.createNode(privateStorePath,{documents:[defs.binding,defs.actorLink,defs.receipt,defs.delivery]});}
|
||||
async function privateNode(){return runtime.CultMesh.createNode(privateStorePath,{documents:[defs.binding,defs.actorLink,defs.receipt,defs.delivery,defs.pendingFailure]});}
|
||||
async function observationNode(){return runtime.CultMesh.createNode(observationStorePath,{documents:[defs.event]});}
|
||||
|
||||
async function bindTarget(){const n=await privateNode(),binding={schemaName:"bifrost.persona_feedback.target_binding",schemaVersion:feedbackBindingSchema,bindingId:bindingId(options),guildId:requireOption("guild-id"),channelId:requireOption("channel-id"),targetPersonaId:requireOption("persona-id"),targetRepoName:requireOption("repo"),targetRuntimeId:requireOption("runtime-id"),producerId:options["producer-id"]||"voidbot",producerRuntimeId:requireOption("producer-runtime-id"),sourceVisibility:requireOption("source-visibility"),dataClassification:requireOption("data-classification"),deliveryStorePath:resolve(options["delivery-store"]||deliveryStorePath),createdAt:new Date().toISOString()};await assertStoreSeparation(binding.deliveryStorePath);await n.put(defs.binding,binding.bindingId,binding);await n.flush?.();print(binding);}
|
||||
async function bindTarget(){const n=await privateNode(),binding={schemaName:"bifrost.persona_feedback.target_binding",schemaVersion:feedbackBindingSchema,bindingId:bindingId(options),guildId:requireOption("guild-id"),channelId:requireOption("channel-id"),targetPersonaId:requireOption("persona-id"),targetRepoName:requireOption("repo"),targetRuntimeId:requireOption("runtime-id"),producerId:options["producer-id"]||"bifrost-discord",producerRuntimeId:requireOption("producer-runtime-id"),sourceVisibility:requireOption("source-visibility"),dataClassification:requireOption("data-classification"),deliveryStorePath:resolve(options["delivery-store"]||deliveryStorePath),createdAt:new Date().toISOString()};await assertStoreSeparation(binding.deliveryStorePath);await n.put(defs.binding,binding.bindingId,binding);await n.flush?.();print(binding);}
|
||||
async function linkActor(){const n=await privateNode(),link={schemaName:"bifrost.discord.actor_link",schemaVersion:actorLinkSchema,linkId:`${requireOption("guild-id")}:${requireOption("discord-author-id")}`,guildId:requireOption("guild-id"),discordAuthorId:requireOption("discord-author-id"),bifrostActorId:requireOption("bifrost-actor-id"),heimdallActorRef:requireOption("heimdall-actor-ref"),linkedAt:new Date().toISOString()};await n.put(defs.actorLink,link.linkId,link);await n.flush?.();print(link);}
|
||||
|
||||
async function processEvent(id,emit=true){
|
||||
|
|
@ -45,7 +53,7 @@ async function processEvent(id,emit=true){
|
|||
const link=unwrap(await privateNodeValue.get(defs.actorLink,`${event.guildId}:${event.authorId}`)),classification=link?"linked_governance_feedback":"unlinked_social_feedback",sourceActorId=link?.bifrostActorId||`discord:${event.guildId}:${event.authorId}`;
|
||||
const packet={feedbackId:id,sourceEventId:id,sourceActorId,actorClassification:classification,actorLinkRefs:link?[link.heimdallActorRef]:[],discordGuildId:event.guildId,discordChannelId:event.channelId,discordMessageId:event.messageId,targetRuntimeId:event.targetRuntimeId,targetRepository:event.targetRepoName,targetPersonaId:event.targetPersonaId,sourceRoomId:`discord:${event.guildId}:${event.channelId}`,feedbackText:event.content,contentSha256:`sha256-${event.payloadHash}`,sourceDiscussionRefs:[`discord:${event.guildId}:${event.channelId}:${event.messageId}`],sourceVisibility:binding.sourceVisibility,dataClassification:binding.dataClassification,privateStateIncluded:false};
|
||||
const identity=await loadBifrostFeedbackIdentity(resolve(options["private-key"]||defaultFeedbackIdentityPath()));
|
||||
const admission={schemaName:"bifrost.persona_feedback.delivery",schemaVersion:feedbackDeliverySchema,admissionId:id,packet,packetSha256:`sha256-${shaBytes(runtime.encode(packetTuple(packet)))}`,sourceObserverId:"voidbot",sourceObserverRuntimeId:event.producerRuntimeId,provider:"bifrost",bifrostAdmissionReceiptId:id,authority:"feedback_only",providerIdentityId:identity.identityId,providerSignature:new Uint8Array()};
|
||||
const admission={schemaName:"bifrost.persona_feedback.delivery",schemaVersion:feedbackDeliverySchema,admissionId:id,packet,packetSha256:`sha256-${shaBytes(runtime.encode(packetTuple(packet)))}`,sourceObserverId:"bifrost-discord",sourceObserverRuntimeId:event.producerRuntimeId,provider:"bifrost",bifrostAdmissionReceiptId:id,authority:"feedback_only",providerIdentityId:identity.identityId,providerSignature:new Uint8Array()};
|
||||
admission.providerSignature=new Uint8Array(signFeedbackAdmission(identity,runtime.encode(admissionSigningTuple(admission))));
|
||||
const receipt={schemaName:"bifrost.persona_feedback.admission_receipt",schemaVersion:feedbackReceiptSchema,receiptId:id,eventId:id,status:"admitted",classification,payloadHash:event.payloadHash,bindingId:binding.bindingId,deliveryId:id,recordedAt:new Date().toISOString(),immutable:true,grantsWorkAuthority:false,grantsAdoptionAuthority:false};
|
||||
await publishAdmission(admission,binding.deliveryStorePath);await privateNodeValue.put(defs.delivery,id,admission);await privateNodeValue.put(defs.receipt,id,receipt);await privateNodeValue.flush?.();if(emit)print({receipt,delivery:admission,deliveryStore:binding.deliveryStorePath});
|
||||
|
|
@ -53,16 +61,51 @@ async function processEvent(id,emit=true){
|
|||
async function republishExisting(n,id){const admission=unwrap(await n.get(defs.delivery,id));if(!admission)throw new Error(`Feedback receipt ${id} has no signed admission.`);const binding=unwrap(await n.get(defs.binding,bindingIdFromAdmission(admission)));if(!binding)throw new Error(`Feedback admission ${id} has no target binding.`);await publishAdmission(admission,binding.deliveryStorePath);}
|
||||
async function publishAdmission(admission,path){await assertStoreSeparation(path);await mkdir(dirname(path),{recursive:true});const cache=runtime.CultCache.builder().withDocumentType(defs.delivery).withGenericStore(new runtime.SingleFileMessagePackBackingStore(path)).build();await cache.pullAllBackingStores();const current=cache.get(defs.delivery,admission.admissionId);if(current&&stable(current)!==stable(admission))throw new Error(`Signed admission ${admission.admissionId} already exists with different immutable content.`);if(!current)await cache.put(defs.delivery,admission.admissionId,admission);}
|
||||
|
||||
async function serve(){const interval=positiveInteger(options["interval-ms"]||"5000","interval-ms"),once=options.once==="true",healthEndpoint=options["idunn-rudp-health"]||process.env.BIFROST_IDUNN_RUDP_HEALTH;if(!healthEndpoint&&!once)throw new Error("Persona-feedback serve requires --idunn-rudp-health or BIFROST_IDUNN_RUDP_HEALTH; no localhost default is assumed.");const healthPublisher=healthEndpoint?createPersonaFeedbackHealthPublisher(await loadBifrostFeedbackIdentity(resolve(options["private-key"]||defaultFeedbackIdentityPath()))):null;do{const observations=await observationNode(),privateNodeValue=await privateNode();await observations.cache?.pullAllBackingStores?.();await privateNodeValue.cache?.pullAllBackingStores?.();const admitted=new Set(privateNodeValue.cache.getAll(defs.receipt).map(value=>value.eventId));const pending=observations.cache.getAll(defs.event).filter(event=>!admitted.has(event.eventId)).sort((a,b)=>String(a.observedAt).localeCompare(String(b.observedAt)));const results=[];for(const event of pending){try{await processEvent(event.eventId,false);results.push({eventId:event.eventId,ok:true,error:""});}catch(error){results.push({eventId:event.eventId,ok:false,error:error instanceof Error?error.message:String(error)});}}const readinessState=await readiness();if(healthEndpoint)await publishPersonaFeedbackHealth({endpoint:healthEndpoint,daemonId:"yggdrasil-bifrost-persona-feedback",healthContract:"bifrost.cultnet-rudp-persona-feedback-health",state:readinessState.status==="ready"?"active":"degraded",detail:readinessState.status==="ready"?"ready":"readiness-degraded",publisher:healthPublisher,publishUnsignedDiagnostic:true});if(once){print({ok:results.every(value=>value.ok)&&readinessState.status==="ready",scanned:pending.length,results,readiness:readinessState});return;}await new Promise(done=>setTimeout(done,interval));}while(true);}
|
||||
async function serve(){const interval=positiveInteger(options["interval-ms"]||"5000","interval-ms"),once=options.once==="true",healthEndpoint=options["idunn-rudp-health"]||process.env.BIFROST_IDUNN_RUDP_HEALTH;if(!healthEndpoint&&!once)throw new Error("Persona-feedback serve requires --idunn-rudp-health or BIFROST_IDUNN_RUDP_HEALTH; no localhost default is assumed.");const healthPublisher=healthEndpoint?createPersonaFeedbackHealthPublisher(await loadBifrostFeedbackIdentity(resolve(options["private-key"]||defaultFeedbackIdentityPath()))):null;do{const results=await drainPendingObservations(),readinessState=await readiness();if(healthEndpoint)await publishPersonaFeedbackHealth({endpoint:healthEndpoint,daemonId:"yggdrasil-bifrost-persona-feedback",healthContract:"bifrost.cultnet-rudp-persona-feedback-health",state:readinessState.status==="ready"?"active":"degraded",detail:readinessState.status==="ready"?"ready":"readiness-degraded",publisher:healthPublisher,publishUnsignedDiagnostic:true});if(once){print({ok:results.every(value=>value.ok)&&readinessState.status==="ready",scanned:results.length,results,readiness:readinessState});return;}await new Promise(done=>setTimeout(done,interval));}while(true);}
|
||||
|
||||
async function drainPendingObservations(){const observations=await observationNode(),privateNodeValue=await privateNode();await observations.cache?.pullAllBackingStores?.();await privateNodeValue.cache?.pullAllBackingStores?.();const admitted=new Set(privateNodeValue.cache.getAll(defs.receipt).map(value=>value.eventId));const pending=observations.cache.getAll(defs.event).filter(event=>!admitted.has(event.eventId)).sort((a,b)=>String(a.observedAt).localeCompare(String(b.observedAt)));const results=[];for(const event of pending){try{await processEvent(event.eventId,false);await privateNodeValue.cache.pullAllBackingStores();await privateNodeValue.cache.delete(defs.pendingFailure,event.eventId);results.push({eventId:event.eventId,ok:true,error:""});}catch(error){const message=boundedError(error),previous=unwrap(await privateNodeValue.get(defs.pendingFailure,event.eventId)),failure={schemaName:"bifrost.persona_feedback.pending_failure",schemaVersion:feedbackPendingFailureSchema,failureId:event.eventId,eventId:event.eventId,attemptCount:Math.min((previous?.attemptCount??0)+1,1_000_000),lastError:message,lastAttemptAt:new Date().toISOString(),privateStateExposed:false};await privateNodeValue.put(defs.pendingFailure,event.eventId,failure);results.push({eventId:event.eventId,ok:false,error:message});}}await privateNodeValue.flush?.();return results;}
|
||||
|
||||
async function serveDiscord(){
|
||||
const token=process.env.BIFROST_DISCORD_BOT_TOKEN;
|
||||
const producerRuntimeId=options["producer-runtime-id"]||process.env.BIFROST_DISCORD_RUNTIME_ID||"bifrost-discord-yggdrasil";
|
||||
const healthEndpoint=options["idunn-rudp-health"]||process.env.BIFROST_IDUNN_RUDP_HEALTH;if(!healthEndpoint)throw new Error("Bifrost Discord Persona ingress requires --idunn-rudp-health or BIFROST_IDUNN_RUDP_HEALTH.");
|
||||
const healthPublisher=createPersonaFeedbackHealthPublisher(await loadBifrostFeedbackIdentity(resolve(options["private-key"]||defaultFeedbackIdentityPath())));
|
||||
let gatewayState={connected:false,sessionId:"",lastHeartbeatAckAtMillis:0,phase:"starting"};
|
||||
const publishHealth=async()=>{const value=await readiness(),gatewayReady=discordGatewayReady(gatewayState),active=value.status==="ready"&&gatewayReady;await publishPersonaFeedbackHealth({endpoint:healthEndpoint,daemonId:"yggdrasil-bifrost-persona-feedback",healthContract:"bifrost.cultnet-rudp-persona-feedback-health",state:active?"active":"degraded",detail:active?"ready":`discord-${gatewayState.phase||"disconnected"}`,publisher:healthPublisher,publishUnsignedDiagnostic:true});return {...value,gatewayReady,gatewayState};};
|
||||
const initial=await readiness();if(!initial.ingressReady)throw new Error(`Bifrost Discord Persona ingress is not ready: ${initial.ingressReasons.join("; ")}`);
|
||||
const recovered=await drainPendingObservations();if(recovered.some(value=>!value.ok))console.error(`Bifrost Persona ingress startup recovery retained ${recovered.filter(value=>!value.ok).length} failed observation(s) for reconnect retry.`);
|
||||
await drainPersonaDeliveries().catch(error=>console.error(`Bifrost Persona delivery is staged but not yet ready: ${error instanceof Error?error.message:String(error)}`));
|
||||
await publishHealth();
|
||||
const healthTimer=setInterval(()=>publishHealth().catch(error=>console.error(`Bifrost Persona-feedback health publication failed: ${error instanceof Error?error.message:String(error)}`)),10000);
|
||||
let observationBusy=false;const observationTimer=setInterval(async()=>{if(observationBusy)return;observationBusy=true;try{const retried=await drainPendingObservations();if(retried.some(value=>!value.ok))console.error(`Bifrost Persona ingress retained ${retried.filter(value=>!value.ok).length} failed pending observation(s).`);}catch(error){console.error(`Bifrost Persona observation reconciliation failed: ${error instanceof Error?error.message:String(error)}`);}finally{observationBusy=false;}},positiveInteger(options["observation-retry-interval-ms"]||"5000","observation-retry-interval-ms"));
|
||||
let deliveryBusy=false;const deliveryTimer=setInterval(async()=>{if(deliveryBusy)return;deliveryBusy=true;try{await drainPersonaDeliveries();}catch(error){console.error(`Bifrost Persona delivery poll failed: ${error instanceof Error?error.message:String(error)}`);}finally{deliveryBusy=false;}},positiveInteger(options["persona-delivery-interval-ms"]||"500","persona-delivery-interval-ms"));
|
||||
try{await serveDiscordPersonaIngress({token,runtimeId:producerRuntimeId,onConversation:conversation=>admitDiscordConversation(conversation,producerRuntimeId),onReady:async state=>{const drained=await drainPendingObservations();print({schemaVersion:"bifrost.discord.persona_ingress_status.v0",status:drained.every(value=>value.ok)?"ready":"degraded",runtimeId:state.runtimeId,botUserId:state.botUserId,recoveredObservationCount:drained.length,privateStateExposed:false});},onState:state=>{gatewayState=state;},onDisconnect:error=>console.error(`Bifrost Discord Persona ingress reconnecting: ${error instanceof Error?error.message:String(error)}`)});}finally{gatewayState={connected:false,sessionId:"",lastHeartbeatAckAtMillis:0,phase:"stopped"};await publishHealth().catch(()=>{});clearInterval(healthTimer);clearInterval(observationTimer);clearInterval(deliveryTimer);}
|
||||
}
|
||||
|
||||
async function drainPersonaDeliveries(){const n=await privateNode();await n.cache?.pullAllBackingStores?.();const bindings=n.cache.getAll(defs.binding);return processPersonaDiscordDeliveries({runtime,permitRuntime:{cultnet:runtime.cultnet,msgpack:runtime.msgpack},requestStorePath:personaDeliveryRequestStorePath,receiptStorePath:personaDeliveryReceiptStorePath,executionStorePath:personaDeliveryExecutionStorePath,epiphanyTrustAnchorPath:resolve(requireOption("epiphany-persona-mouth-trust-anchor")),epiphanyPermitAnchorPath:resolve(requireOption("epiphany-persona-permit-trust-anchor")),epiphanyPermitEndpoint:requireOption("epiphany-persona-permit-rudp"),privateKeyPath:resolve(requireOption("persona-delivery-private-key")),bridgeCliPath:resolve(root,"tools","bifrost-bridge.mjs"),bifrostRoot:root,targetAdmitted:request=>request.personaAgentId==="epiphany.Persona"&&bindings.some(binding=>binding.targetRuntimeId===request.targetRuntimeId&&binding.targetPersonaId==="epiphany"&&binding.channelId===request.channelId)});}
|
||||
|
||||
async function admitDiscordConversation(conversation,producerRuntimeId){
|
||||
const privateNodeValue=await privateNode();await privateNodeValue.cache?.pullAllBackingStores?.();
|
||||
const bindings=privateNodeValue.cache.getAll(defs.binding).filter(binding=>binding.guildId===conversation.guildId&&binding.channelId===conversation.channelId&&binding.producerId==="bifrost-discord"&&binding.producerRuntimeId===producerRuntimeId);
|
||||
if(bindings.length===0)return;
|
||||
if(bindings.length!==1)throw new Error("Discord channel has multiple Bifrost Persona targets; addressed conversation is ambiguous.");
|
||||
const binding=bindings[0],eventId=`discord:${conversation.guildId}:${conversation.channelId}:${conversation.messageId}`,event={schemaName:"bifrost.discord.persona_conversation_event",schemaVersion:"bifrost.discord.persona_conversation_event.v0",eventId,observedAt:conversation.observedAt,guildId:conversation.guildId,channelId:conversation.channelId,messageId:conversation.messageId,authorId:conversation.authorId,authorName:conversation.authorName,addressingMode:conversation.addressingMode,targetPersonaId:binding.targetPersonaId,targetRepoName:binding.targetRepoName,targetRuntimeId:binding.targetRuntimeId,content:conversation.content,payloadHash:conversation.payloadHash,producerId:"bifrost-discord",producerRuntimeId,authorityClass:"feedback_only",status:"pending"};
|
||||
const observations=await observationNode();await observations.cache?.pullAllBackingStores?.();const existing=unwrap(await observations.get(defs.event,eventId));
|
||||
if(existing&&stable(existing)!==stable(event))throw new Error("Discord conversation event identity collision.");
|
||||
if(!existing){await observations.put(defs.event,eventId,event);await observations.flush?.();}
|
||||
await processEvent(eventId,false);
|
||||
}
|
||||
|
||||
async function enrollIdentity(){const keyPath=resolve(options["private-key"]||defaultFeedbackIdentityPath()),identity=await enrollBifrostFeedbackIdentity(keyPath),createdAt=new Date().toISOString(),anchor=trustAnchor(identity,createdAt),out=resolve(requireOption("trust-anchor-out")),providerOut=options["provider-health-trust-anchor-out"]?resolve(options["provider-health-trust-anchor-out"]):null;await mkdir(dirname(out),{recursive:true});await writeFile(out,runtime.encode(anchorTuple(anchor)));if(providerOut){if(providerOut===out)throw new Error("Feedback and provider-health trust anchors must use distinct paths.");const providerAnchor=providerHealthTrustAnchor(identity,createdAt);await mkdir(dirname(providerOut),{recursive:true});await writeFile(providerOut,runtime.encode(anchorTuple(providerAnchor)));}print({privateKey:keyPath,trustAnchor:out,identityId:identity.identityId,providerHealthTrustAnchor:providerOut??""});}
|
||||
async function enrollPersonaDeliveryIdentity(){print(await enrollPersonaDiscordDeliveryIdentity({runtime,privateKeyPath:resolve(requireOption("private-key")),trustAnchorOut:resolve(requireOption("trust-anchor-out"))}));}
|
||||
async function exportPersonaPermitRequestAnchor(){print(await exportPersonaDiscordPermitRequestAnchor({runtime,privateKeyPath:resolve(requireOption("private-key")),trustAnchorOut:resolve(requireOption("trust-anchor-out"))}));}
|
||||
async function exportProviderHealthAnchor(){const keyPath=resolve(options["private-key"]||defaultFeedbackIdentityPath()),identity=await loadBifrostFeedbackIdentity(keyPath),out=resolve(requireOption("trust-anchor-out")),anchor=providerHealthTrustAnchor(identity,new Date().toISOString());await mkdir(dirname(out),{recursive:true});await writeFile(out,runtime.encode(anchorTuple(anchor)),{flag:"wx",mode:0o640});print({privateKey:keyPath,trustAnchor:out,identityId:anchor.identityId});}
|
||||
async function exportSnapshot(){const n=await privateNode();await n.cache?.pullAllBackingStores?.();const out=resolve(requireOption("out")),scratch=`${out}.scratch.cc`,cache=runtime.CultCache.builder().withDocumentType(defs.delivery).withDocumentType(defs.receipt).withGenericStore(new runtime.SingleFileMessagePackBackingStore(scratch)).build();for(const value of n.cache.getAll(defs.delivery))await cache.put(defs.delivery,value.admissionId,value);for(const value of n.cache.getAll(defs.receipt))await cache.put(defs.receipt,value.receiptId,value);const registry=new runtime.CultNetDocumentRegistry([runtime.defineCultNetDocumentBinding({definition:defs.delivery,payloadSchemaVersion:feedbackDeliverySchema}),runtime.defineCultNetDocumentBinding({definition:defs.receipt,payloadSchemaVersion:feedbackReceiptSchema})]),message=registry.createRawSnapshotResponse(cache,options["message-id"]||`persona-feedback-${randomUUID()}`);await mkdir(dirname(out),{recursive:true});await writeFile(out,runtime.encode(message));await rm(scratch,{force:true});print({schemaVersion:message.schemaVersion,messageId:message.messageId,documentCount:message.documents.length,out});}
|
||||
async function readiness(){const reasons=[];let identity,bindingCount=0;try{identity=await loadBifrostFeedbackIdentity(resolve(options["private-key"]||defaultFeedbackIdentityPath()));}catch(error){reasons.push(`identity:${error instanceof Error?error.message:String(error)}`);}let bindings=[];try{const info=await stat(privateStorePath);if(!info.isFile())throw new Error("private provider store is not a regular file");const cache=runtime.CultCache.builder().withDocumentType(defs.binding).withDocumentType(defs.actorLink).withDocumentType(defs.receipt).withDocumentType(defs.delivery).withGenericStore(new runtime.SingleFileMessagePackBackingStore(privateStorePath)).build();await cache.pullAllBackingStores();bindings=cache.getAll(defs.binding);bindingCount=bindings.length;if(bindingCount===0)reasons.push("bindings:none");}catch(error){reasons.push(`privateStore:${error instanceof Error?error.message:String(error)}`);}for(const binding of bindings){try{await assertStoreSeparation(binding.deliveryStorePath);try{const info=await stat(binding.deliveryStorePath);if(!info.isFile())throw new Error("delivery path is not a regular file");await access(binding.deliveryStorePath,constants.W_OK);}catch(error){if(!error||typeof error!=="object"||error.code!=="ENOENT")throw error;await access(dirname(binding.deliveryStorePath),constants.W_OK);}}catch(error){reasons.push(`binding:${binding.bindingId}:${error instanceof Error?error.message:String(error)}`);}}return {schemaVersion:"bifrost.persona_feedback.readiness.v0",providerIdentityId:identity?.identityId??"",bindingCount,status:reasons.length===0?"ready":"degraded",reasons,privateStateExposed:false};}
|
||||
async function readiness(){const ingressReasons=[];let identity,bindingCount=0,pendingFailedCount=0;try{identity=await loadBifrostFeedbackIdentity(resolve(options["private-key"]||defaultFeedbackIdentityPath()));}catch(error){ingressReasons.push(`identity:${error instanceof Error?error.message:String(error)}`);}let bindings=[];try{const info=await stat(privateStorePath);if(!info.isFile())throw new Error("private provider store is not a regular file");const cache=runtime.CultCache.builder().withDocumentType(defs.binding).withDocumentType(defs.actorLink).withDocumentType(defs.receipt).withDocumentType(defs.delivery).withDocumentType(defs.pendingFailure).withGenericStore(new runtime.SingleFileMessagePackBackingStore(privateStorePath)).build();await cache.pullAllBackingStores();bindings=cache.getAll(defs.binding);bindingCount=bindings.length;const admitted=new Set(cache.getAll(defs.receipt).map(value=>value.eventId));pendingFailedCount=cache.getAll(defs.pendingFailure).filter(value=>!admitted.has(value.eventId)).length;if(pendingFailedCount>0)ingressReasons.push(`pendingFailures:${pendingFailedCount}`);if(bindingCount===0)ingressReasons.push("bindings:none");}catch(error){ingressReasons.push(`privateStore:${error instanceof Error?error.message:String(error)}`);}for(const binding of bindings){try{await assertStoreSeparation(binding.deliveryStorePath);try{const info=await stat(binding.deliveryStorePath);if(!info.isFile())throw new Error("delivery path is not a regular file");await access(binding.deliveryStorePath,constants.W_OK);}catch(error){if(!error||typeof error!=="object"||error.code!=="ENOENT")throw error;await access(dirname(binding.deliveryStorePath),constants.W_OK);}}catch(error){ingressReasons.push(`binding:${binding.bindingId}:${error instanceof Error?error.message:String(error)}`);}}const personaConfigured=command==="serve-discord"||Boolean(options["epiphany-persona-mouth-trust-anchor"]);let personaDelivery={ready:false,requestSignerIdentityId:"",receiptSignerIdentityId:"",reasons:["not configured"]};if(personaConfigured){personaDelivery=await inspectPersonaDiscordDeliveryReadiness({runtime,requestStorePath:personaDeliveryRequestStorePath,receiptStorePath:personaDeliveryReceiptStorePath,executionStorePath:personaDeliveryExecutionStorePath,epiphanyTrustAnchorPath:resolve(requireOption("epiphany-persona-mouth-trust-anchor")),epiphanyPermitAnchorPath:resolve(requireOption("epiphany-persona-permit-trust-anchor")),epiphanyPermitEndpoint:requireOption("epiphany-persona-permit-rudp"),privateKeyPath:resolve(requireOption("persona-delivery-private-key"))});}const reasons=[...ingressReasons,...(personaConfigured?personaDelivery.reasons.map(reason=>`personaDelivery:${reason}`):[])],ingressReady=ingressReasons.length===0;return {schemaVersion:"bifrost.persona_feedback.readiness.v0",providerIdentityId:identity?.identityId??"",bindingCount,pendingFailedCount,ingressReady,ingressReasons,personaDelivery,status:ingressReady&&(!personaConfigured||personaDelivery.ready)?"ready":"degraded",reasons,privateStateExposed:false};}
|
||||
async function status(){print(await readiness());}
|
||||
|
||||
function loadRuntime(){const cr=createRequire(resolve(cult,"packages","cultcache-ts","package.json")),mr=createRequire(resolve(cult,"packages","cultmesh-ts","package.json")),nr=createRequire(resolve(cult,"packages","cultnet-ts","package.json")),cc=cr("cultcache-ts"),cn=nr("cultnet-ts");return {CultMesh:mr("cultmesh-ts").CultMesh,CultCache:cc.CultCache,SingleFileMessagePackBackingStore:cc.SingleFileMessagePackBackingStore,defineDocumentType:cc.defineDocumentType,CultNetDocumentRegistry:cn.CultNetDocumentRegistry,defineCultNetDocumentBinding:cn.defineCultNetDocumentBinding,encode:nr("@msgpack/msgpack").encode};}
|
||||
function bindingId(o){return `feedback:${o["guild-id"]}:${o["channel-id"]}:${o["runtime-id"]}:${o.repo}:${o["persona-id"]}:${o["producer-id"]||"voidbot"}:${o["producer-runtime-id"]}`;}
|
||||
function loadRuntime(){const cr=createRequire(resolve(cult,"packages","cultcache-ts","package.json")),mr=createRequire(resolve(cult,"packages","cultmesh-ts","package.json")),nr=createRequire(resolve(cult,"packages","cultnet-ts","package.json")),cc=cr("cultcache-ts"),cn=nr("cultnet-ts"),mp=nr("@msgpack/msgpack");return {CultMesh:mr("cultmesh-ts").CultMesh,CultCache:cc.CultCache,SingleFileMessagePackBackingStore:cc.SingleFileMessagePackBackingStore,defineDocumentType:cc.defineDocumentType,CultNetDocumentRegistry:cn.CultNetDocumentRegistry,defineCultNetDocumentBinding:cn.defineCultNetDocumentBinding,cultnet:cn,msgpack:mp,encode:mp.encode,decode:mp.decode};}
|
||||
function bindingId(o){return `feedback:${o["guild-id"]}:${o["channel-id"]}:${o["runtime-id"]}:${o.repo}:${o["persona-id"]}:${o["producer-id"]||"bifrost-discord"}:${o["producer-runtime-id"]}`;}
|
||||
function bindingIdFromAdmission(admission){const p=admission.packet;return bindingId({"guild-id":p.discordGuildId,"channel-id":p.discordChannelId,"runtime-id":p.targetRuntimeId,repo:p.targetRepository,"persona-id":p.targetPersonaId,"producer-id":admission.sourceObserverId,"producer-runtime-id":admission.sourceObserverRuntimeId});}
|
||||
function packetTuple(p){return [p.feedbackId,p.sourceEventId,p.sourceActorId,p.actorClassification,p.actorLinkRefs,p.discordGuildId,p.discordChannelId,p.discordMessageId,p.targetRuntimeId,p.targetRepository,p.targetPersonaId,p.sourceRoomId,p.feedbackText,p.contentSha256,p.sourceDiscussionRefs,p.sourceVisibility,p.dataClassification,p.privateStateIncluded];}
|
||||
function admissionSigningTuple(a){return [a.schemaVersion,a.admissionId,packetTuple(a.packet),a.packetSha256,a.sourceObserverId,a.sourceObserverRuntimeId,a.provider,a.bifrostAdmissionReceiptId,a.authority,a.providerIdentityId];}
|
||||
|
|
@ -72,6 +115,7 @@ function requireOption(name){const value=options[name];if(!value)throw new Error
|
|||
function unwrap(value){return Array.isArray(value)?value[0]:value?.value??value;}
|
||||
function shaText(value){return createHash("sha256").update(value,"utf8").digest("hex");}
|
||||
function shaBytes(value){return createHash("sha256").update(value).digest("hex");}
|
||||
function boundedError(error){const value=error instanceof Error?error.message:String(error),bytes=Buffer.from(value,"utf8");if(bytes.length<=512)return value;let end=512;while(end>0&&(bytes[end]&0xc0)===0x80)end--;return bytes.subarray(0,end).toString("utf8");}
|
||||
function stable(value){return JSON.stringify(value,(_key,current)=>current instanceof Uint8Array?Buffer.from(current).toString("hex"):current);}
|
||||
function positiveInteger(value,label){const parsed=Number(value);if(!Number.isSafeInteger(parsed)||parsed<=0)throw new Error(`${label} must be a positive integer.`);return parsed;}
|
||||
async function assertStoreSeparation(targetDeliveryStore){const paths=[observationStorePath,privateStorePath,resolve(targetDeliveryStore)],seenPaths=new Set(),seenFiles=new Set();for(const path of paths){const canonical=await canonicalPath(path);const key=process.platform==="win32"?canonical.toLowerCase():canonical;if(seenPaths.has(key))throw new Error("Persona feedback observation inbox, Bifrost private state, and delivery export must use distinct canonical paths.");seenPaths.add(key);try{const info=await stat(path),fileKey=`${info.dev}:${info.ino}`;if(seenFiles.has(fileKey))throw new Error("Persona feedback stores must not alias the same physical file.");seenFiles.add(fileKey);}catch(error){if(!error||typeof error!=="object"||error.code!=="ENOENT")throw error;}}}
|
||||
|
|
|
|||
Loading…
Reference in a new issue