fix(discord): row 25 approvers are user names, never Discord ids in tool text (#1509)
Jason's live check after the 20:58Z restart posted no Approve button. The
Discord Sage wrote DEC-009's required_approvers as names; the SetSpark
service stores approvers as discord:<id> and accepted the names, and the
connector correctly refused the approval request ("bad approver id").
- binding.mjs derives setspark.approvers from the binding's users (name to
id); a binding-set approvers key and duplicate names are refused. With
setspark set, a user id or name change refuses the reload (pi's approvers
are fixed at start).
- setspark.mjs: record_create/record_update map required_approvers names to
discord:<id> and refuse unknown names, ids, duplicates and non-lists
before any request, without echoing the value. hideIds turns mentions,
discord: values and standalone 17-20 digit runs into the user's name or
"unknown user" in every verb's text and refusal, including the service
message and code before they are cut. The connector's approval request
keeps the bare ids.
- tests: boundary test over nested, keyed, numeric, mention and cut ids;
a local contract fixture from create through validateRequest, with the
old name-stored shape still refused.
Rocko: R1 revise, R2 revise, R3 approve (81379830..., report da75219f...).
Suites on an index export: 24/90/43/17/14/15/63/18; Discord node tests 173/173.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -9,10 +9,11 @@ import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
SETSPARK_REFUSAL, SetsparkRefusal, IDEMPOTENCY_HEADER, MESSAGE_MAX_CHARS, USER_AGENT, SETSPARK_TOOL_NAMES, LIST_MAX,
|
||||
loadSetsparkConfig, readKey, idempotencyKey, connectorKey, callApi, renderRefusal, createSetsparkApi, renderRecord,
|
||||
loadSetsparkConfig, readKey, idempotencyKey, connectorKey, callApi, renderRefusal, createSetsparkApi, renderRecord, hideIds,
|
||||
} from "../src/setspark.mjs";
|
||||
import { loadToolsConfig, createToolSet, enabledToolNames } from "../src/tools.mjs";
|
||||
import { validateBinding, resolveToolRoots } from "../src/binding.mjs";
|
||||
import { validateBinding, resolveToolRoots, reloadDiff } from "../src/binding.mjs";
|
||||
import { validateRequest } from "../src/approvals.mjs";
|
||||
import { makeRoot, rawBinding } from "./helpers.mjs";
|
||||
|
||||
const KEY_A = "ssk_" + "a".repeat(40);
|
||||
@@ -46,6 +47,15 @@ const server = createServer((req, res) => {
|
||||
if (path === "/v1/records" && req.method === "GET") return json(200, { record_type: "work_item", items: [{ id: "WI-7", record_type: "work_item", revision: 1, title: "Ship it", status: "active" }, { id: "WI-8", record_type: "work_item", revision: 4, title: "Later", status: "active", priority: "low" }], limit: 20, offset: 0 });
|
||||
if (path === "/v1/records/WI-7" && req.method === "GET") return json(200, { id: "WI-7", record_type: "work_item", revision: 3, title: "Ship it", status: "active", owner: "Jason", tags: ["a", "b"], accepted_snapshot: { hidden: true }, body: "Two lines.\nOf body." });
|
||||
if (path === "/v1/records/WI-7" && req.method === "PATCH") return json(200, { id: "WI-7", record_type: "work_item", revision: parsed.revision + 1, title: "Ship it", ...parsed.fields });
|
||||
if (path === "/v1/records/DEC-9" && req.method === "GET") return json(200, { id: "DEC-9", record_type: "decision", revision: 2, title: "Pick one", status: "Proposed", required_approvers: ["discord:100000000000000100", "discord:199999999999999999"], approvals: [{ approver: "100000000000000100", at: "t" }] });
|
||||
if (path === "/v1/records/DEC-9" && req.method === "PATCH") return json(200, { id: "DEC-9", record_type: "decision", revision: parsed.revision + 1, title: "Pick one", ...parsed.fields });
|
||||
// Discord ids where the model could read them (the id-hiding boundary)
|
||||
if (path === "/v1/records/DEC-10" && req.method === "GET") return json(200, { id: "DEC-10", record_type: "decision", revision: 1, title: "Ask 100000000000000100", required_approvers: ["discord:100000000000000100", "100000000000000101"], proposal_digest: "12345678901234567890abcdef0123456789abcdef0123456789abcdef012345", nested: { text: "approver discord:100000000000000100", "199999999999999999": "keyed" }, owner_id: 100000000000000100, note: `${"x".repeat(485)} 100000000000000101`, body: "Ask <@100000000000000100> and <@!199999999999999999>." });
|
||||
if (path === "/v1/records/DEC-10" && req.method === "PATCH") return json(409, { code: "stale_revision", message: "approver discord:100000000000000101 changed", current_revision: 2, changed_fields: ["required_approvers", "100000000000000100"] });
|
||||
if (path === "/v1/records/DEC-12" && req.method === "PATCH") return json(400, { code: `${"x".repeat(49)} 100000000000000100`, message: "no" });
|
||||
if (path === "/v1/records/DEC-11" && req.method === "PATCH") return json(422, { code: "validation", message: `required_approvers: 199999999999999999 is not a user ${"z".repeat(332)} 100000000000000100` });
|
||||
if (path === "/v1/resolve" && new URL(req.url, "http://x").searchParams.get("q") === "leak") return json(200, { query: "leak", matches: [{ id: "DEC-10", record_type: "decision", title: "Ask <@100000000000000101>", exact: false }] });
|
||||
if (path === "/v1/documents" && req.method === "POST" && parsed.collection === "leak") return json(201, { id: "doc-2", title: "Notes for 100000000000000100", url: "https://outline.example.test/doc/199999999999999999" });
|
||||
if (path === "/v1/records/WI-9" && req.method === "PATCH") return json(409, { code: "stale_revision", message: "behind", current_revision: 5, changed_fields: ["status"] });
|
||||
if (path === "/v1/resolve") return json(200, { query: "ship", matches: [{ id: "WI-7", record_type: "work_item", title: "Ship it", exact: false }] });
|
||||
if (path === "/v1/approval-requests" && req.method === "POST") return json(201, view);
|
||||
@@ -83,7 +93,7 @@ test("setspark config: a bare https or loopback origin, a private key file, a pr
|
||||
const root = makeRoot();
|
||||
const kf = keyFile(root);
|
||||
const c = loadSetsparkConfig({ baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" });
|
||||
assert.deepEqual(c, { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage", timeoutMs: 15000, maxResponseBytes: 262144 });
|
||||
assert.deepEqual(c, { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage", timeoutMs: 15000, maxResponseBytes: 262144, approvers: {} });
|
||||
assert.equal(loadSetsparkConfig({ baseUrl: "https://api.setspark.io/", keyFile: kf, principal: "sage" }).baseUrl, "https://api.setspark.io");
|
||||
assert.throws(() => loadSetsparkConfig(null), /not an object/);
|
||||
assert.throws(() => loadSetsparkConfig({ baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage", extra: 1 }), /unknown key/);
|
||||
@@ -129,7 +139,7 @@ test("setspark config: the binding's key survives resolveToolRoots and the engin
|
||||
const kf = keyFile(root);
|
||||
const b = validateBinding(rawBinding({ tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage", timeoutMs: 5000 } } }));
|
||||
const resolved = resolveToolRoots(b, { dataRoot });
|
||||
assert.deepEqual(resolved.setspark, { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage", timeoutMs: 5000 });
|
||||
assert.deepEqual(resolved.setspark, { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage", timeoutMs: 5000, approvers: { owner: "100000000000000100" } });
|
||||
for (const name of SETSPARK_TOOL_NAMES) assert.ok(enabledToolNames(resolved).includes(name), `${name} reaches pi's --tools list`);
|
||||
const ext = loadToolsConfig(JSON.parse(JSON.stringify(resolved)));
|
||||
assert.deepEqual(ext.setspark, b.tools.setspark, "the extension rebuilds the same config, response cap included");
|
||||
@@ -138,6 +148,155 @@ test("setspark config: the binding's key survives resolveToolRoots and the engin
|
||||
assert.ok(!enabledToolNames(plain).some((n) => SETSPARK_TOOL_NAMES.includes(n)));
|
||||
});
|
||||
|
||||
test("setspark config: approvers come from the binding's users, never from the binding's setspark key", () => {
|
||||
const root = makeRoot();
|
||||
const docs = join(root, "docs");
|
||||
mkdirSync(docs);
|
||||
const kf = keyFile(root);
|
||||
const users = [{ id: "100000000000000100", name: "Jason" }, { id: "100000000000000101", name: "carmen" }];
|
||||
const b = validateBinding(rawBinding({ users, tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } }));
|
||||
assert.deepEqual(b.tools.setspark.approvers, { jason: "100000000000000100", carmen: "100000000000000101" });
|
||||
assert.throws(() => validateBinding(rawBinding({ users, tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage", approvers: { mallory: "100000000000000199" } } } })), /approvers come from users/);
|
||||
assert.throws(() => validateBinding(rawBinding({ users: [{ id: "100000000000000100", name: "Jason" }, { id: "100000000000000101", name: "jason" }], tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } })), /two users named jason/);
|
||||
const withCarmenOut = validateBinding(rawBinding({ users: [users[0]], tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } }));
|
||||
assert.throws(() => reloadDiff(b, withCarmenOut), /tools cannot change/, "pi's approvers are fixed at start, so a user change needs a restart");
|
||||
const channelsOnly = validateBinding(rawBinding({ users: [{ ...users[0], channels: [rawBinding().channels[0].id] }, users[1]], tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } }));
|
||||
assert.doesNotThrow(() => reloadDiff(b, channelsOnly), "a user's channels still reload");
|
||||
assert.throws(() => loadSetsparkConfig({ baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage", approvers: { jason: "not-an-id" } }), /approvers/);
|
||||
assert.throws(() => loadSetsparkConfig({ baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage", approvers: { jason: "100000000000000100", jay: "100000000000000100" } }), /approvers/);
|
||||
});
|
||||
|
||||
test("setspark verbs: required_approvers go out as discord ids from names and come back as names", async () => {
|
||||
const root = makeRoot();
|
||||
const named = createToolSet(loadToolsConfig({ roots: [{ name: "docs", path: root }], maxCallsPerTurn: 12, setspark: { baseUrl: base, keyFile: keyFile(root), principal: "sage", timeoutMs: 1000, approvers: { jason: "100000000000000100", carmen: "100000000000000101" } } }));
|
||||
named.setTurn(TURN);
|
||||
seen.length = 0;
|
||||
const created = await named.call("record_create", { record_type: "decision", record: { title: "Pick one", status: "Proposed", work_item: "SS-1", required_approvers: ["Jason", "@carmen"] } });
|
||||
assert.equal(created.ok, true, created.text);
|
||||
assert.deepEqual(JSON.parse(seen[0].body).record.required_approvers, ["discord:100000000000000100", "discord:100000000000000101"]);
|
||||
const unknown = await named.call("record_create", { record_type: "decision", record: { title: "Pick one", required_approvers: ["Jason", "Mallory"] } });
|
||||
assert.equal(unknown.ok, false);
|
||||
assert.match(unknown.text, /entry 2 is not a known user name; use names from: jason, carmen/);
|
||||
const raw = await named.call("record_create", { record_type: "decision", record: { title: "Pick one", required_approvers: ["discord:100000000000000100"] } });
|
||||
assert.equal(raw.ok, false, "an id is not a name");
|
||||
assert.doesNotMatch(raw.text, /100000000000000100/, "a refused id is not echoed back");
|
||||
const twice = await named.call("record_create", { record_type: "decision", record: { title: "Pick one", required_approvers: ["jason", "Jason"] } });
|
||||
assert.match(twice.text, /the same person twice/);
|
||||
const notList = await named.call("record_update", { id: "DEC-9", revision: 2, fields: { required_approvers: "jason" } });
|
||||
assert.equal(notList.ok, false);
|
||||
assert.equal(seen.length, 1, "refused calls send nothing");
|
||||
const updated = await named.call("record_update", { id: "DEC-9", revision: 2, fields: { required_approvers: ["carmen"] } });
|
||||
assert.equal(updated.ok, true, updated.text);
|
||||
assert.deepEqual(JSON.parse(seen[1].body).fields.required_approvers, ["discord:100000000000000101"]);
|
||||
const got = await named.call("record_get", { id: "DEC-9" });
|
||||
assert.match(got.text, /required_approvers: jason, unknown user/);
|
||||
assert.doesNotMatch(got.text, /1000000000000001|1999999999/, "no discord id reaches the model");
|
||||
const none = createToolSet(loadToolsConfig({ roots: [{ name: "docs", path: root }], setspark: { baseUrl: base, keyFile: keyFile(root), principal: "sage", timeoutMs: 1000 } }));
|
||||
none.setTurn(TURN);
|
||||
const noUsers = await none.call("record_create", { record_type: "decision", record: { title: "Pick one", required_approvers: ["jason"] } });
|
||||
assert.match(noUsers.text, /use names from: \(none\)/);
|
||||
});
|
||||
|
||||
test("setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in", async () => {
|
||||
const root = makeRoot();
|
||||
const ids = ["100000000000000100", "100000000000000101", "199999999999999999"];
|
||||
const t = createToolSet(loadToolsConfig({ roots: [{ name: "docs", path: root }], maxCallsPerTurn: 12, setspark: { baseUrl: base, keyFile: keyFile(root), principal: "sage", timeoutMs: 1000, approvers: { jason: "100000000000000100", carmen: "100000000000000101" } } }));
|
||||
t.setTurn(TURN);
|
||||
const texts = [];
|
||||
const got = await t.call("record_get", { id: "DEC-10" });
|
||||
assert.equal(got.ok, true, got.text);
|
||||
texts.push(got.text);
|
||||
assert.match(got.text, /title: Ask jason/);
|
||||
assert.match(got.text, /required_approvers: jason, carmen/);
|
||||
assert.match(got.text, /nested: \{"text":"approver jason","unknown user":"keyed"\}/);
|
||||
assert.match(got.text, /Ask jason and unknown user\./);
|
||||
assert.match(got.text, /proposal_digest: 12345678901234567890abcdef/, "a digit run inside a digest is not an id");
|
||||
const stale = await t.call("record_update", { id: "DEC-10", revision: 1, fields: { status: "Proposed" } });
|
||||
assert.equal(stale.ok, false);
|
||||
assert.match(stale.text, /changed: required_approvers, jason/);
|
||||
assert.match(stale.text, /approver carmen changed/);
|
||||
texts.push(stale.text);
|
||||
const rejected = await t.call("record_update", { id: "DEC-11", revision: 1, fields: { status: "Proposed" } });
|
||||
assert.equal(rejected.ok, false);
|
||||
assert.match(rejected.text, /unknown user is not a user/);
|
||||
texts.push(rejected.text);
|
||||
// a field capped before it is shown: the service's code, a bad property
|
||||
// name, a bad filter name
|
||||
const code = await t.call("record_update", { id: "DEC-12", revision: 1, fields: { status: "Proposed" } });
|
||||
assert.equal(code.ok, false);
|
||||
texts.push(code.text);
|
||||
const prop = await t.call("record_create", { record_type: "decision", record: { title: "x", [`${"x".repeat(17)} 100000000000000101`]: "v" } });
|
||||
assert.match(prop.text, /property name that is not allowed/);
|
||||
texts.push(prop.text);
|
||||
const filter = await t.call("record_list", { record_type: "decision", filters: { [`${"x".repeat(17)} 199999999999999999`]: "v" } });
|
||||
assert.match(filter.text, /property name not allowed/);
|
||||
texts.push(filter.text);
|
||||
const found = await t.call("resolve_id", { query: "leak" });
|
||||
assert.match(found.text, /Ask carmen/);
|
||||
texts.push(found.text);
|
||||
const doc = await t.call("create_document", { collection: "leak", title: "Notes", text: "x" });
|
||||
assert.equal(doc.ok, true, doc.text);
|
||||
texts.push(doc.text);
|
||||
const opened = await t.call("open_approval_request", { decision_id: "DEC-012", proposal_version: 2, proposal_digest: "0123456789abcdef0123456789abcdef" });
|
||||
texts.push(opened.text);
|
||||
for (const text of texts) for (const id of ids) assert.equal(text.includes(id), false, `id ${id} in: ${text}`);
|
||||
for (const text of texts) assert.doesNotMatch(text, /(?<![0-9A-Za-z])[0-9]{12,16}(?![0-9A-Za-z])/, `no part of an id survives a cut: ${text}`);
|
||||
// the connector's request keeps the service's bare ids; only text is hidden
|
||||
assert.deepEqual([...opened.details.request.approvers], ["100000000000000002", "100000000000000004"]);
|
||||
assert.equal(hideIds(null, "<@100000000000000100> discord:100000000000000100 SS-027 v2"), "unknown user unknown user SS-027 v2");
|
||||
});
|
||||
|
||||
test("setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse", async () => {
|
||||
// A local stand-in for the service's contract: records keep
|
||||
// required_approvers as written (discord:<id>), and the approval-request
|
||||
// view returns them as bare Discord ids, as shared-signals documents.
|
||||
const store = new Map();
|
||||
let next = 1;
|
||||
const svc = createServer((req, res) => {
|
||||
const chunks = [];
|
||||
req.on("data", (c) => chunks.push(c));
|
||||
req.on("end", () => {
|
||||
const parsed = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : null;
|
||||
const json = (status, obj) => {
|
||||
res.writeHead(status, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(obj));
|
||||
};
|
||||
if (req.url === "/v1/records" && req.method === "POST") {
|
||||
const id = `DEC-${next++}`;
|
||||
store.set(id, { id, record_type: parsed.record_type, revision: 1, ...parsed.record });
|
||||
return json(201, store.get(id));
|
||||
}
|
||||
if (req.url === "/v1/approval-requests" && req.method === "POST") {
|
||||
const d = store.get(parsed.decision_id);
|
||||
if (!d) return json(404, { code: "not_found", message: "no decision" });
|
||||
const bare = d.required_approvers.map((a) => (typeof a === "string" && a.startsWith("discord:") ? a.slice(8) : a));
|
||||
return json(201, { request_id: next++, decision_id: d.id, state: "open", proposal_version: parsed.proposal_version, proposal_digest: parsed.proposal_digest, required_approvers: bare, approvals: [], message_id: null });
|
||||
}
|
||||
return json(404, { code: "not_found", message: "no route" });
|
||||
});
|
||||
});
|
||||
svc.listen(0, "127.0.0.1");
|
||||
await once(svc, "listening");
|
||||
try {
|
||||
const root = makeRoot();
|
||||
const t = createToolSet(loadToolsConfig({ roots: [{ name: "docs", path: root }], maxCallsPerTurn: 12, setspark: { baseUrl: `http://127.0.0.1:${svc.address().port}`, keyFile: keyFile(root), principal: "sage", timeoutMs: 1000, approvers: { jason: "100000000000000100", carmen: "100000000000000101" } } }));
|
||||
t.setTurn(TURN);
|
||||
const made = await t.call("record_create", { record_type: "decision", record: { title: "Pick one", status: "Proposed", required_approvers: ["jason", "carmen"] } });
|
||||
assert.equal(made.ok, true, made.text);
|
||||
const opened = await t.call("open_approval_request", { decision_id: "DEC-1", proposal_version: 1, proposal_digest: "0123456789abcdef" });
|
||||
assert.equal(opened.ok, true, opened.text);
|
||||
const request = validateRequest(opened.details.request);
|
||||
assert.deepEqual([...request.approvers], ["100000000000000100", "100000000000000101"]);
|
||||
// DEC-009's shape: names written before this fix. The connector refuses.
|
||||
store.set("DEC-9", { id: "DEC-9", record_type: "decision", revision: 1, title: "Old", required_approvers: ["Jason", "Carmen"] });
|
||||
const old = await t.call("open_approval_request", { decision_id: "DEC-9", proposal_version: 1, proposal_digest: "0123456789abcdef" });
|
||||
assert.equal(old.ok, true, old.text);
|
||||
assert.throws(() => validateRequest(old.details.request), /bad approver id/);
|
||||
} finally {
|
||||
svc.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("setspark keys: read per call, one printable token per file, rotation without a restart", async () => {
|
||||
const root = makeRoot();
|
||||
const c = config(root);
|
||||
|
||||
Reference in New Issue
Block a user