Gate organizational Persona feedback
This commit is contained in:
parent
3bd250f811
commit
c65cbfa527
7 changed files with 309 additions and 0 deletions
|
|
@ -192,6 +192,26 @@ The command is a dry-run shape probe for public surfaces that are real enough to
|
|||
|
||||
## Discord Native Interface Target
|
||||
|
||||
### 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 admits an observation only when guild, channel, target runtime, canonical repository, Persona, producer, and producer runtime match a preconfigured `bifrost.persona_feedback.target_binding.v0` document in Bifrost's private provider store. That binding also owns source visibility, data classification, and the delivery-store target. Bifrost never infers public visibility from Discord identifiers. It classifies the author using its own `bifrost.discord.actor_link.v0` state: a match yields `linked_governance_feedback`; absence yields `unlinked_social_feedback`. Neither class grants adoption, work, release, or deployment authority.
|
||||
|
||||
`tools/persona-feedback.mjs enroll-identity` creates the Bifrost service's Ed25519 seed with exclusive creation and mode `0600`, then exports its public MessagePack trust anchor. The private seed belongs under Bifrost's service-private state (default `/var/lib/gamecult/bifrost/persona-feedback/persona-feedback-ed25519.seed`) and is checked for regular-file type, exact Unix mode, and service-user ownership every time it signs. VoidBot publishes pending observations only; it neither pumps admission nor receives the signing path.
|
||||
|
||||
The public trust-anchor artifact is a Rust-compatible six-element MessagePack tuple in `HostIdentityTrustAnchorEntry` declaration order: schema version, identity id, 32-byte public key, assurance, identity creation time, and source-record digest. It contains no private material.
|
||||
|
||||
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`.
|
||||
|
||||
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.
|
||||
|
||||
The Bifrost service runs `tools/persona-feedback.mjs serve` with separate `--observation-store`, private `--store`, private-key, and public `--delivery-store` paths. This bounded resident pump scans immutable source events in observation order, derives unprocessed state solely from absence of an immutable Bifrost receipt, processes each through the same exact binding/signing primitive, and sleeps for `--interval-ms` (default five seconds). Failed events remain eligible for retry; admitted events are never rewritten, deleted, or re-granted. VoidBot does not invoke this mode and requires no read access to Bifrost's private provider state. `--once true` exists for service probes and fixtures.
|
||||
|
||||
The deploy contract is one ordinary Bifrost sidecar process with Node.js and the built CultLib CultCache/CultMesh/CultNet runtimes. Provision three separately owned paths: a VoidBot-writable/Bifrost-readable observation inbox, a Bifrost-only private provider store, and the Bifrost-writable/Epiphany-readable delivery store at `/srv/bifrost/persona-feedback/deliveries.cc`. Enroll the Bifrost-only seed once, export its six-field public trust-anchor tuple for Epiphany, create exact target bindings (including visibility, data classification, and delivery path), then start `serve`. The service needs no Discord credential and no Eve surface; it transforms typed organizational feedback observations into authenticated cognition pressure.
|
||||
|
||||
`tools/persona-feedback.mjs status` is the non-actuating container readiness probe. It loads the runtime, checks store path and existing-file identity separation, verifies the service-owned key identity, reads bindings without modifying them, and checks each binding's delivery parent/write posture. It emits `bifrost.persona_feedback.readiness.v0` with provider identity, binding count, readiness status, reasons, and `privateStateExposed=false`. It never processes an observation and is not deployment admission.
|
||||
|
||||
Discord should become a native Bifrost interface for Bifrost-scoped work, not just an output pipe.
|
||||
|
||||
The intended shape:
|
||||
|
|
|
|||
76
tests/persona-feedback-cli.test.mjs
Normal file
76
tests/persona-feedback-cli.test.mjs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash, createPublicKey, verify } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
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";
|
||||
|
||||
const root=resolve(import.meta.dirname,".."), cult=resolve(root,"..","CultLib");
|
||||
const cr=createRequire(resolve(cult,"packages","cultcache-ts","package.json"));
|
||||
const mr=createRequire(resolve(cult,"packages","cultmesh-ts","package.json"));
|
||||
const {defineDocumentType}=cr("cultcache-ts"), {CultMesh}=mr("cultmesh-ts"), defs=buildFeedbackDefinitions(defineDocumentType);
|
||||
const nr=createRequire(resolve(cult,"packages","cultnet-ts","package.json")),{decode,encode}=nr("@msgpack/msgpack");
|
||||
|
||||
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");
|
||||
cliNoStore("enroll-identity","--private-key",key,"--trust-anchor-out",anchor);
|
||||
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);
|
||||
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");
|
||||
assert.equal(served.scanned,1);assert.equal(served.results[0].ok,true);
|
||||
assert.deepEqual(await readEvent(observationStore,"one"),sourceBefore);
|
||||
const n=await CultMesh.createNode(store,{documents:[defs.binding,defs.actorLink,defs.receipt,defs.delivery]});await n.cache?.pullAllBackingStores?.();
|
||||
const first={delivery:(await n.get(defs.delivery,"one"))?.value??await n.get(defs.delivery,"one"),receipt:(await n.get(defs.receipt,"one"))?.value??await n.get(defs.receipt,"one")};
|
||||
assert.equal(first.receipt.classification,"unlinked_social_feedback");
|
||||
assert.equal(first.delivery.authority,"feedback_only");
|
||||
assert.equal(first.delivery.packet.privateStateIncluded,false);
|
||||
assert.equal(first.delivery.packet.sourceVisibility,"organization");
|
||||
assert.equal(first.delivery.packet.dataClassification,"organization_feedback");
|
||||
assert.match(first.delivery.packetSha256,/^sha256-[a-f0-9]{64}$/);
|
||||
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");
|
||||
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);
|
||||
const publicKey=createPublicKey({key:Buffer.concat([Buffer.from("302a300506032b6570032100","hex"),Buffer.from(trust[2])]),format:"der",type:"spki"});
|
||||
assert.equal(verify(null,message,publicKey,Buffer.from(signed.providerSignature)),true);
|
||||
assert.equal(first.receipt.grantsWorkAuthority,false);
|
||||
cli("link-actor",store,"--guild-id","g","--discord-author-id","human-two","--bifrost-actor-id","member:42","--heimdall-actor-ref","heimdall:42");
|
||||
await putEvent(observationStore,event("two","human-two"));
|
||||
assert.equal(cli("process",store,"--observation-store",observationStore,"--event-id","two","--private-key",key,"--delivery-store",deliveryStore).receipt.classification,"linked_governance_feedback");
|
||||
const replay=cli("process",store,"--observation-store",observationStore,"--event-id","two","--private-key",key,"--delivery-store",deliveryStore);
|
||||
assert.equal(replay.receiptId,"two");
|
||||
const exported=cli("export",store,"--out",out);
|
||||
assert.ok(exported.documentCount>=4); assert.ok((await stat(out)).size>0);
|
||||
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);
|
||||
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(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 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}));});
|
||||
|
||||
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"};}
|
||||
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"});}
|
||||
function cli(command,store,...args){const r=raw(command,store,...args);assert.equal(r.status,0,r.stderr||r.stdout);return JSON.parse(r.stdout);}
|
||||
function cliNoStore(command,...args){const r=spawnSync(process.execPath,[resolve(root,"tools","persona-feedback.mjs"),command,...args],{cwd:root,encoding:"utf8"});assert.equal(r.status,0,r.stderr||r.stdout);return JSON.parse(r.stdout);}
|
||||
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 u64(v){const b=Buffer.alloc(8);b.writeBigUInt64BE(BigInt(v));return b;}
|
||||
35
tools/bifrost-feedback-identity.mjs
Normal file
35
tools/bifrost-feedback-identity.mjs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign as ed25519Sign } from "node:crypto";
|
||||
import { chmod, mkdir, open, readFile, stat } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const ID_DOMAIN=Buffer.from("epiphany.host-incarnation.identity.v0\0","utf8");
|
||||
const SIGNATURE_DOMAIN=Buffer.from("epiphany.host-incarnation.signature.v0\0","utf8");
|
||||
export const FEEDBACK_SIGNING_PURPOSE="bifrost.persona-feedback.delivery.v0";
|
||||
|
||||
export async function enrollBifrostFeedbackIdentity(privateKeyPath){
|
||||
await mkdir(dirname(privateKeyPath),{recursive:true});
|
||||
const {privateKey}=generateKeyPairSync("ed25519"), jwk=privateKey.export({format:"jwk"});
|
||||
const seed=Buffer.from(jwk.d,"base64url");
|
||||
const handle=await open(privateKeyPath,"wx",0o600);
|
||||
try{await handle.writeFile(seed);await handle.sync();}finally{await handle.close();}
|
||||
await chmod(privateKeyPath,0o600);
|
||||
return loadBifrostFeedbackIdentity(privateKeyPath);
|
||||
}
|
||||
export async function loadBifrostFeedbackIdentity(privateKeyPath){
|
||||
const info=await stat(privateKeyPath);
|
||||
if(!info.isFile())throw new Error("Bifrost feedback identity must be a regular file.");
|
||||
if(process.platform!=="win32"&&(info.mode&0o077)!==0)throw new Error("Bifrost feedback identity private key must have mode 0600.");
|
||||
if(process.platform!=="win32"&&typeof process.geteuid==="function"&&info.uid!==process.geteuid())throw new Error("Bifrost feedback identity private key must be owned by the service user.");
|
||||
const seed=await readFile(privateKeyPath);if(seed.length!==32)throw new Error("Bifrost feedback identity seed must be exactly 32 bytes.");
|
||||
const key=createPrivateKey({key:Buffer.concat([Buffer.from("302e020100300506032b657004220420","hex"),seed]),format:"der",type:"pkcs8"});
|
||||
const publicKey=createPublicKey(key).export({format:"der",type:"spki"}).subarray(-32);
|
||||
const identityId=createHash("sha256").update(ID_DOMAIN).update(publicKey).digest("hex");
|
||||
return {identityId,publicKey,key};
|
||||
}
|
||||
export function signFeedbackAdmission(identity,payload){
|
||||
const purpose=Buffer.from(FEEDBACK_SIGNING_PURPOSE,"utf8"), message=Buffer.concat([SIGNATURE_DOMAIN,u64(purpose.length),purpose,u64(payload.length),payload]);
|
||||
return ed25519Sign(null,message,identity.key);
|
||||
}
|
||||
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")}`};}
|
||||
export function defaultFeedbackIdentityPath(){return process.env.BIFROST_PERSONA_FEEDBACK_PRIVATE_KEY??(process.platform==="win32"?resolve(".bifrost","private","persona-feedback-ed25519.seed"):"/var/lib/gamecult/bifrost/persona-feedback/persona-feedback-ed25519.seed");}
|
||||
function u64(value){const b=Buffer.alloc(8);b.writeBigUInt64BE(BigInt(value));return b;}
|
||||
41
tools/persona-feedback-documents.mjs
Normal file
41
tools/persona-feedback-documents.mjs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
export const feedbackEventType = "voidbot.discord.persona_feedback_event";
|
||||
export const feedbackEventSchema = "voidbot.discord.persona_feedback_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";
|
||||
export const actorLinkSchema = "bifrost.discord.actor_link.v0";
|
||||
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";
|
||||
|
||||
// Kept as explicit constructors because CultCache document metadata is part of the wire contract.
|
||||
export function buildFeedbackDefinitions(defineDocumentType) {
|
||||
const make = (type, schemaId, name, parse) => defineDocumentType({ type, schemaName: type, schemaId, schemaVersion: schemaId, contentHash: schemaId, global: false, name, schema: { parse } });
|
||||
return {
|
||||
event: make(feedbackEventType, feedbackEventSchema, "eventId", parseEvent),
|
||||
binding: make(feedbackBindingType, feedbackBindingSchema, "bindingId", parseBinding),
|
||||
actorLink: make(actorLinkType, actorLinkSchema, "linkId", parseActorLink),
|
||||
receipt: make(feedbackReceiptType, feedbackReceiptSchema, "receiptId", parseReceipt),
|
||||
delivery: make(feedbackDeliveryType, feedbackDeliverySchema, "admissionId", parseDelivery),
|
||||
};
|
||||
}
|
||||
|
||||
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.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.");
|
||||
if (!/^[a-f0-9]{64}$/.test(v.payloadHash)) throw new Error("payloadHash must be sha256 hex.");
|
||||
return v;
|
||||
}
|
||||
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; }
|
||||
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}.`);}
|
||||
38
tools/persona-feedback-idunn-health.mjs
Normal file
38
tools/persona-feedback-idunn-health.mjs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import dgram from "node:dgram";
|
||||
import { createRequire } from "node:module";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const root = resolve(import.meta.dirname, "..");
|
||||
const projects = resolve(root, "..");
|
||||
const cult = resolve(process.env.VOIDBOT_CULTLIB_ROOT || resolve(projects, "CultLib"));
|
||||
const require = createRequire(resolve(cult, "packages", "cultnet-ts", "package.json"));
|
||||
const { encodeCultNetMessageForWire, encodeRudpPacket } = require("cultnet-ts");
|
||||
const { encode } = require("@msgpack/msgpack");
|
||||
const protocol = "cultnet.transport.rudp.v0";
|
||||
|
||||
export async function publishPersonaFeedbackHealth(input) {
|
||||
if (!input.endpoint) throw new Error("Bifrost Persona-feedback Idunn health endpoint is required.");
|
||||
const endpoint = parseEndpoint(input.endpoint);
|
||||
const socket = dgram.createSocket(endpoint.host.includes(":") ? "udp6" : "udp4");
|
||||
try {
|
||||
await new Promise((ok, fail) => { socket.once("error", fail); socket.bind(0, endpoint.host.includes(":") ? "::" : "0.0.0.0", () => { socket.off("error", fail); ok(); }); });
|
||||
await send(socket, endpoint, packet("connect", 1, new Uint8Array(), "control"));
|
||||
await delay(300);
|
||||
const observedAt = input.observedAt || new Date().toISOString();
|
||||
const payload = encode([input.daemonId, input.state, input.detail, observedAt, input.healthContract, "daemon-published", protocol]);
|
||||
const message = {schemaVersion:"cultnet.document_put_raw.v0",messageId:`bifrost-feedback-health:${observedAt.replace(/[:.]/g,"-")}`,document:{schemaId:"idunn.daemon_health",recordKey:input.daemonId,storedAt:observedAt,payloadEncoding:"messagepack",payload,sourceRuntimeId:"bifrost-persona-feedback",sourceRole:"daemon-health-publisher",tags:[protocol]}};
|
||||
await send(socket, endpoint, packet("data", 2, encode(encodeCultNetMessageForWire(message, "cultnet.schema.v0")), "schema"));
|
||||
await delay(1000);
|
||||
} finally { socket.close(); }
|
||||
}
|
||||
|
||||
export function parseEndpoint(value) {
|
||||
const text=String(value||"").trim(), ipv6=text.match(/^\[([^\]]+)\]:(\d+)$/);
|
||||
if(ipv6)return {host:ipv6[1],port:port(ipv6[2])};
|
||||
const split=text.lastIndexOf(":");if(split<=0)throw new Error(`Idunn RUDP endpoint must be host:port, got "${value}".`);
|
||||
return {host:text.slice(0,split),port:port(text.slice(split+1))};
|
||||
}
|
||||
function port(value){const result=Number(value);if(!Number.isInteger(result)||result<=0||result>65535)throw new Error(`Idunn RUDP endpoint port is invalid: ${value}`);return result;}
|
||||
function packet(packetType,sequence,payload,channelId){return {packetType,connectionId:0x1d0d0001,sequence,ack:0,ackMask:0,channelId,reliable:true,ordered:true,sequenced:false,payload};}
|
||||
async function send(socket,endpoint,value){const wire=encodeRudpPacket(value);await new Promise((ok,fail)=>socket.send(wire,endpoint.port,endpoint.host,error=>error?fail(error):ok()));}
|
||||
function delay(ms){return new Promise(ok=>setTimeout(ok,ms));}
|
||||
76
tools/persona-feedback.mjs
Normal file
76
tools/persona-feedback.mjs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
#!/usr/bin/env node
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
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 { defaultFeedbackIdentityPath, enrollBifrostFeedbackIdentity, loadBifrostFeedbackIdentity, signFeedbackAdmission, trustAnchor } from "./bifrost-feedback-identity.mjs";
|
||||
import { publishPersonaFeedbackHealth } from "./persona-feedback-idunn-health.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 deliveryStorePath=resolve(options["delivery-store"]||(process.platform==="win32"?resolve(root,".bifrost","epiphany-persona-feedback.cc"):"/srv/bifrost/persona-feedback/deliveries.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;}
|
||||
|
||||
async function main(){
|
||||
if(command==="bind-target")return bindTarget();
|
||||
if(command==="link-actor")return linkActor();
|
||||
if(command==="process")return processEvent(requireOption("event-id"));
|
||||
if(command==="serve")return serve();
|
||||
if(command==="export")return exportSnapshot();
|
||||
if(command==="enroll-identity")return enrollIdentity();
|
||||
if(command==="status")return status();
|
||||
throw new Error("Usage: persona-feedback.mjs bind-target|link-actor|process|serve|status|export|enroll-identity");
|
||||
}
|
||||
async function privateNode(){return runtime.CultMesh.createNode(privateStorePath,{documents:[defs.binding,defs.actorLink,defs.receipt,defs.delivery]});}
|
||||
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 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){
|
||||
await assertStoreSeparation(deliveryStorePath);
|
||||
const observations=await observationNode(),privateNodeValue=await privateNode();await observations.cache?.pullAllBackingStores?.();await privateNodeValue.cache?.pullAllBackingStores?.();
|
||||
const event=unwrap(await observations.get(defs.event,id));if(!event)throw new Error(`Unknown feedback event ${id}.`);
|
||||
const existing=unwrap(await privateNodeValue.get(defs.receipt,id));if(existing){await republishExisting(privateNodeValue,id);if(emit)print(existing);return;}
|
||||
if(shaText(event.content)!==event.payloadHash)throw new Error("Feedback payload hash mismatch.");
|
||||
const binding=unwrap(await privateNodeValue.get(defs.binding,bindingId({"guild-id":event.guildId,"channel-id":event.channelId,"persona-id":event.targetPersonaId,repo:event.targetRepoName,"runtime-id":event.targetRuntimeId,"producer-id":event.producerId,"producer-runtime-id":event.producerRuntimeId})));
|
||||
if(!binding)throw new Error("No exact Bifrost target binding admits this feedback event.");
|
||||
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()};
|
||||
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});
|
||||
}
|
||||
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.");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"?"healthy":"degraded",detail:readinessState.reasons.length?readinessState.reasons.join("; "):"Bifrost Persona-feedback provider is ready."});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 enrollIdentity(){const keyPath=resolve(options["private-key"]||defaultFeedbackIdentityPath()),identity=await enrollBifrostFeedbackIdentity(keyPath),anchor=trustAnchor(identity,new Date().toISOString()),out=resolve(requireOption("trust-anchor-out"));await mkdir(dirname(out),{recursive:true});await writeFile(out,runtime.encode([anchor.schemaVersion,anchor.identityId,anchor.publicKey,anchor.assurance,anchor.identityCreatedAt,anchor.sourceIdentityRecordSha256]));print({privateKey:keyPath,trustAnchor:out,identityId:identity.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 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 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];}
|
||||
function parseArgs(args){const result={};for(let i=0;i<args.length;i++){const token=args[i];if(!token.startsWith("--"))throw new Error(`Unexpected ${token}`);result[token.slice(2)]=args[++i];}return result;}
|
||||
function requireOption(name){const value=options[name];if(!value)throw new Error(`Missing --${name}`);return value;}
|
||||
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 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;}}}
|
||||
async function canonicalPath(path){try{return await realpath(path);}catch(error){if(!error||typeof error!=="object"||error.code!=="ENOENT")throw error;return resolve(path);}}
|
||||
function print(value){process.stdout.write(`${JSON.stringify(value,null,2)}\n`);}
|
||||
23
tools/verify-persona-feedback-release-authority.mjs
Normal file
23
tools/verify-persona-feedback-release-authority.mjs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { openRepositoryReleaseAuthorityStore } from "./bifrost-repository-release-authority.mjs";
|
||||
|
||||
const storePath = process.argv[2];
|
||||
const authorityId = requiredEnvironment("EXPECTED_AUTHORITY_ID");
|
||||
const commitSha = requiredEnvironment("EXPECTED_COMMIT");
|
||||
if (!storePath) throw new Error("Pass the repository release-authority CultCache path.");
|
||||
|
||||
const store = await openRepositoryReleaseAuthorityStore(storePath);
|
||||
const authority = store.get(authorityId);
|
||||
if (!authority) throw new Error(`Bifrost release authority ${authorityId} is absent.`);
|
||||
if (authority.repositoryFullName !== "GameCult/Bifrost") throw new Error("Release authority does not own GameCult/Bifrost.");
|
||||
if (authority.upstreamRef !== "refs/heads/main") throw new Error("Release authority does not own refs/heads/main.");
|
||||
if (authority.commitSha !== commitSha) throw new Error("Release authority does not bind the frozen Bifrost commit.");
|
||||
if (authority.status !== "authorized" || authority.decision !== "authorize") throw new Error("Release authority is not authorized.");
|
||||
if (authority.expiresAt && Date.parse(authority.expiresAt) <= Date.now()) throw new Error("Release authority has expired.");
|
||||
|
||||
process.stdout.write(`${authority.authorityId}\n`);
|
||||
|
||||
function requiredEnvironment(name) {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) throw new Error(`Missing ${name}.`);
|
||||
return value;
|
||||
}
|
||||
Loading…
Reference in a new issue