Files
stack/packages/discord/src/approvals.mjs
T
jason.woltjeandClaude Fable 5.1 43d7574d6a feat(discord): SetSpark record client for the Discord Sage, fixed verbs against setspark-api, connector-verified approvals (#1509)
Row 25, parts 2a and 2b, against the shared-signals contract a5425a2.

Model side: eight fixed verbs in the pi extension (record_list, record_get,
record_create, record_update, resolve_id, open_approval_request,
get_approval_request, create_document), each one HTTP call with arguments
checked before any request. Writes carry an idempotency key
<principal>:<message id>:<call index> and an audit context. The seat key is
read from a 0600 file on every call and never cached, printed or journaled.

Connector side: append-only approval ledger, Approve button and exact
"approve" reply resolved by the connector against the required approvers,
confirmation message posted as button evidence, bind and add_approval through
the service under connector keys, retry of unknown entries on start.

Evidence: node tests 162 pass, scripts/test-discord.sh 63/63. Review by
rev-code-02, round 1 approved (#1509 comment 26467, tree 7872d8c5).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-22 12:59:39 -05:00

190 lines
9.8 KiB
JavaScript

// 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:<id>" 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;
}