// Approvals for SetSpark proposals (row 25, plan v3.1 rule from Codex round // 3): the model never asserts an approval. Sage's `open_approval_request` // verb creates a request at the record service and gets back its id and // the Discord ids that may approve it. The connector then posts the // proposal as its own message with an Approve button, records it here, and // binds the message id to the request. An approval is that button, or a // reply to that message whose content is exactly `approve`. The connector // checks the author against the request's approvers and submits request // id, author id and message id; the service verifies against what it // stored. // // The ledger is approvals.jsonl under the binding's journal directory, // appended only, one line per state change, with the same intent/done/ // unknown/refused vocabulary as the outbox so a restart can reconcile: // opened the request message is posted; carries the request, the // message id and channel, the approver ids and the content // bind binding the message to the request at the service // approval one approver's approval, submitted to the service // Ids in this file are private to the binding's data directory (0600), // like every other journal. import { appendFileSync, existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { DiscordError } from "./errors.mjs"; export const APPROVE_WORD = "approve"; export const CUSTOM_ID_PREFIX = "approve:"; export const REQUEST_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; // keeps "approve:" under Discord's 100 char custom id limit const SNOWFLAKE = /^[0-9]{17,20}$/; export const INTERACTION_TYPE = Object.freeze({ PING: 1, MESSAGE_COMPONENT: 3 }); export const COMPONENT_TYPE = Object.freeze({ ACTION_ROW: 1, BUTTON: 2 }); export const BUTTON_STYLE = Object.freeze({ SUCCESS: 3 }); export const CALLBACK_TYPE = Object.freeze({ PONG: 1, CHANNEL_MESSAGE: 4, DEFERRED_UPDATE_MESSAGE: 6, UPDATE_MESSAGE: 7 }); export const EPHEMERAL = 1 << 6; export const APPROVAL_LINES = Object.freeze({ notApprover: "Only a listed approver can approve this request.", already: "You have already approved this request.", closed: "This request is no longer open.", failed: "The approval could not be recorded. The attempt is recorded and someone will look at it.", pressAgain: "The confirmation message could not be posted, so the approval was not recorded. Press Approve again.", }); export const APPROVAL_STATUS = Object.freeze(["intent", "done", "unknown", "refused"]); const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v); // --- the request as posted --- // A request as the model's verb returns it, checked before anything is // posted. Approver ids are Discord user ids; names are the binding's names // for them, looked up by the connector for the rendering. export function validateRequest(raw) { if (!isObject(raw)) throw new DiscordError("approval request: not an object", 1); const { requestId, decisionId, proposalVersion, digest, approvers } = raw; if (typeof requestId !== "string" || !REQUEST_ID.test(requestId)) throw new DiscordError("approval request: bad request id", 1); if (typeof decisionId !== "string" || !REQUEST_ID.test(decisionId)) throw new DiscordError("approval request: bad decision id", 1); if (!Number.isInteger(proposalVersion) || proposalVersion < 1) throw new DiscordError("approval request: bad proposal version", 1); if (typeof digest !== "string" || !/^[a-f0-9]{16,128}$/.test(digest)) throw new DiscordError("approval request: bad digest", 1); if (!Array.isArray(approvers) || approvers.length === 0 || approvers.length > 16) throw new DiscordError("approval request: approvers must be 1..16 ids", 1); for (const a of approvers) if (typeof a !== "string" || !SNOWFLAKE.test(a)) throw new DiscordError("approval request: bad approver id", 1); if (new Set(approvers).size !== approvers.length) throw new DiscordError("approval request: duplicate approver", 1); return Object.freeze({ requestId, decisionId, proposalVersion, digest, approvers: Object.freeze([...approvers]) }); } // The fixed text of the request message. Names, never ids, are shown. export function renderRequest(request, approverNames) { const who = approverNames.length > 0 ? approverNames.join(", ") : "the listed approvers"; return [ `Approval requested for ${request.decisionId}, proposal version ${request.proposalVersion}.`, `Digest ${request.digest.slice(0, 16)}. Request ${request.requestId}.`, `${who} may approve: press Approve, or reply to this message with the single word ${APPROVE_WORD}.`, ].join("\n"); } export function customId(requestId) { return `${CUSTOM_ID_PREFIX}${requestId}`; } export function approveComponents(requestId, { disabled = false } = {}) { return [{ type: COMPONENT_TYPE.ACTION_ROW, components: [{ type: COMPONENT_TYPE.BUTTON, style: BUTTON_STYLE.SUCCESS, label: "Approve", custom_id: customId(requestId), disabled }], }]; } // --- the ledger --- export function approvalsPath(dir) { return join(dir, "approvals.jsonl"); } export function appendApproval(dir, entry) { if (!isObject(entry) || typeof entry.kind !== "string") throw new DiscordError("approvals: entry needs a kind", 1); const line = JSON.stringify(entry); if (line.includes("\n")) throw new DiscordError("approvals: line must not contain a newline", 1); appendFileSync(approvalsPath(dir), line + "\n", { mode: 0o600 }); } export function readApprovals(dir) { const path = approvalsPath(dir); if (!existsSync(path)) return []; const out = []; for (const [i, line] of readFileSync(path, "utf8").split("\n").entries()) { if (line.length === 0) continue; try { out.push(JSON.parse(line)); } catch (err) { throw new DiscordError(`${path}:${i + 1}: not valid JSON (${err.message})`); } } return out; } // Fold the ledger into the open requests, keyed by the posted message id. // Each carries the approvals already recorded as done, the last bind state // and the last state of every approval attempt, so a restart can tell // what still needs sending. export function foldApprovals(entries) { const byMessage = new Map(); const byRequest = new Map(); for (const e of entries) { if (e.kind === "opened") { const rec = { request: { requestId: e.requestId, decisionId: e.decisionId, proposalVersion: e.proposalVersion, digest: e.digest, approvers: e.approvers }, messageId: e.messageId, channelId: e.channelId, content: e.content, openedAt: e.at, bind: null, approvals: new Map(), }; byMessage.set(e.messageId, rec); byRequest.set(e.requestId, rec); continue; } const rec = byRequest.get(e.requestId); if (!rec) continue; if (e.kind === "bind") rec.bind = { status: e.status, at: e.at, error: e.error ?? null }; if (e.kind === "approval") rec.approvals.set(e.authorId, { status: e.status, at: e.at, eventId: e.eventId, messageId: e.messageId, how: e.how, evidenceId: e.evidenceId ?? null, statement: e.statement ?? null, error: e.error ?? null }); } return byMessage; } export function loadOpenRequests(dir) { return foldApprovals(readApprovals(dir)); } // --- resolution --- // Decide what an event means for an open request. Returns // {ok: true, rec, authorId} or {ok: false, reason} with a fixed reason: // not-a-request the event does not point at a request message // not-approve a reply whose text is not exactly the approve word // not-approver the author is not among the request's approvers // already this author's approval is already recorded as done // pending this author's approval is in flight or unknown function decide(rec, authorId) { if (!rec) return { ok: false, reason: "not-a-request" }; if (typeof authorId !== "string" || !rec.request.approvers.includes(authorId)) return { ok: false, reason: "not-approver" }; const prior = rec.approvals.get(authorId); if (prior && prior.status === "done") return { ok: false, reason: "already" }; if (prior && (prior.status === "intent" || prior.status === "unknown")) return { ok: false, reason: "pending" }; return { ok: true, rec, authorId }; } // A MESSAGE_CREATE that replies to a request message. The content must be // exactly the approve word after trimming, case as written. export function resolveReply(message, open) { const ref = message && message.message_reference && typeof message.message_reference.message_id === "string" ? message.message_reference.message_id : null; if (!ref || !open.has(ref)) return { ok: false, reason: "not-a-request" }; if (typeof message.content !== "string" || message.content.trim() !== APPROVE_WORD) return { ok: false, reason: "not-approve" }; return decide(open.get(ref), message.author && message.author.id); } // An INTERACTION_CREATE for the Approve button. The custom id must name // the request the message carries, so a button copied onto another // message cannot approve anything. export function resolveInteraction(interaction, open) { if (!isObject(interaction) || interaction.type !== INTERACTION_TYPE.MESSAGE_COMPONENT) return { ok: false, reason: "not-a-request" }; const data = interaction.data; const msg = interaction.message; if (!isObject(data) || !isObject(msg) || typeof msg.id !== "string" || typeof data.custom_id !== "string") return { ok: false, reason: "not-a-request" }; const rec = open.get(msg.id); if (!rec || data.custom_id !== customId(rec.request.requestId)) return { ok: false, reason: "not-a-request" }; const user = (interaction.member && interaction.member.user) || interaction.user; return decide(rec, user && user.id); } export function interactionAuthorId(interaction) { const user = (interaction && interaction.member && interaction.member.user) || (interaction && interaction.user); return user && typeof user.id === "string" ? user.id : null; }