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]>
190 lines
9.8 KiB
JavaScript
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;
|
|
}
|