// SetSpark record client for the Discord Sage (row 25, Jason's decision // 2026-09-18: record authority moves from the Git vault to NocoDB plus // Outline behind one write service, setspark-api). This module is the // contract-independent half: the `setspark` key of the tools config, the // seat's API key read from a 0600 file on every call, the idempotency key, // and one HTTP core that every verb uses. The verbs themselves (paths, // bodies, codes) are added when stack/api/openapi.json lands on // shared-signals main; nothing here guesses a path. // // The fence: // - one https base url from the binding, no path, query, user or password; // every request goes to `${baseUrl}${path}` with a fixed path per verb // - the key file is checked at load (regular, not a symlink, 0600, // non-empty) and read on each call, so a rotated key takes effect // without a restart; the key is never cached, printed or journaled // - JSON in, JSON out; no redirects; the whole call ends within timeoutMs // - the response is capped; a body over the cap is a refusal // - an error body is `{code, message}` plus `current_revision` and // `changed_fields` on 409; the refusal carries `code` and the fixed // fields, and the server's message is data cut at MESSAGE_MAX_CHARS // - the idempotency key is `::`; the turn // id is the Discord message id from the envelope, the call index the // tool set's counter for that turn; a call outside a turn is refused // // Refusals are SetsparkRefusal with a fixed `reason` from SETSPARK_REFUSAL // and, when the server answered, `status` and `code`. import { lstatSync, readFileSync } from "node:fs"; import { request as httpsRequest } from "node:https"; import { request as httpRequest } from "node:http"; export const SETSPARK_DEFAULTS = Object.freeze({ timeoutMs: 15000, maxResponseBytes: 262144 }); export const USER_AGENT = "mosaic-discord-sage/1 (Mosaic Stack Discord connector; setspark client)"; export const MESSAGE_MAX_CHARS = 400; export const KEY_MAX_BYTES = 4096; export const IDEMPOTENCY_HEADER = "idempotency-key"; export const PRINCIPAL = /^[a-z0-9][a-z0-9._-]{0,63}$/; const SNOWFLAKE = /^[0-9]{17,20}$/; const KEY_SHAPE = /^[!-~]{16,512}$/; // printable ascii, no spaces export const SETSPARK_REFUSAL = Object.freeze({ NO_TURN: "no turn is running, so no idempotency key can be formed", KEY_FILE: "the api key file is missing, not private or empty", KEY_SHAPE: "the api key file does not hold one key", TIMEOUT: "no complete response from the record service within the time limit", NETWORK: "the record service could not be reached", TOO_BIG: "the record service answer is over the size cap", NOT_JSON: "the record service answered with something other than json", CONFLICT: "the record changed since it was read (stale revision)", REPLAY: "the same idempotency key was already used with a different request", REJECTED: "the record service refused the request", UNAUTHORIZED: "the record service did not accept the seat's key", NOT_FOUND: "no such record", SERVER: "the record service failed", BAD_ARGS: "the call's arguments are not valid", }); export class SetsparkRefusal extends Error { constructor(reason, extra = {}) { super(reason); this.reason = reason; Object.assign(this, extra); } } const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v); // The `setspark` key of the tools config. Fixed at pi start like the roots. export function loadSetsparkConfig(raw, where = "setspark") { if (!isObject(raw)) throw new Error(`${where}: not an object`); for (const k of Object.keys(raw)) { if (!["baseUrl", "keyFile", "principal", "timeoutMs"].includes(k)) throw new Error(`${where}: unknown key ${JSON.stringify(k)}`); } if (typeof raw.baseUrl !== "string") throw new Error(`${where}.baseUrl: must be a url string`); let u; try { u = new URL(raw.baseUrl); } catch { throw new Error(`${where}.baseUrl: not a valid url`); } const loopback = u.hostname === "127.0.0.1" || u.hostname === "localhost" || u.hostname === "[::1]"; if (u.username || u.password || u.search || u.hash || (u.pathname !== "/" && u.pathname !== "")) throw new Error(`${where}.baseUrl: must be a bare origin with no path`); if (!(u.protocol === "https:" || (u.protocol === "http:" && loopback))) throw new Error(`${where}.baseUrl: must be https, or http on loopback`); if (typeof raw.keyFile !== "string" || !raw.keyFile.startsWith("/") || raw.keyFile.includes("\0")) throw new Error(`${where}.keyFile: must be an absolute path`); checkPrivateFile(raw.keyFile, `${where}.keyFile`); if (typeof raw.principal !== "string" || !PRINCIPAL.test(raw.principal)) throw new Error(`${where}.principal: must match ${PRINCIPAL}`); const timeoutMs = raw.timeoutMs === undefined ? SETSPARK_DEFAULTS.timeoutMs : raw.timeoutMs; if (!Number.isInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > 60000) throw new Error(`${where}.timeoutMs: must be an integer between 1000 and 60000`); return Object.freeze({ baseUrl: u.origin, keyFile: raw.keyFile, principal: raw.principal, timeoutMs, maxResponseBytes: SETSPARK_DEFAULTS.maxResponseBytes }); } function checkPrivateFile(path, what) { let st; try { st = lstatSync(path); } catch { throw new Error(`${what}: not found: ${path}`); } if (st.isSymbolicLink()) throw new Error(`${what}: must not be a symlink: ${path}`); if (!st.isFile()) throw new Error(`${what}: not a regular file: ${path}`); if ((st.mode & 0o777) !== 0o600) throw new Error(`${what}: must be mode 0600: ${path}`); if (st.size === 0) throw new Error(`${what}: is empty: ${path}`); } // Read the key for one call. The file is re-checked every time, so a key // that stops being private stops being used. The value never leaves this // module except in the Authorization header. export function readKey(config) { try { checkPrivateFile(config.keyFile, "keyFile"); } catch { throw new SetsparkRefusal(SETSPARK_REFUSAL.KEY_FILE); } let st; try { st = lstatSync(config.keyFile); } catch { throw new SetsparkRefusal(SETSPARK_REFUSAL.KEY_FILE); } if (st.size > KEY_MAX_BYTES) throw new SetsparkRefusal(SETSPARK_REFUSAL.KEY_SHAPE); const text = readFileSync(config.keyFile, "utf8"); // Either one bare key line, or the mint's own JSON output // ({"key_id", "key", "note"}) stored as is; only "key" is used. let key = text; if (text.trimStart().startsWith("{")) { try { const obj = JSON.parse(text); key = isObject(obj) && typeof obj.key === "string" ? obj.key : ""; } catch { throw new SetsparkRefusal(SETSPARK_REFUSAL.KEY_SHAPE); } } const lines = key.split("\n").map((l) => l.trim()).filter((l) => l.length > 0); if (lines.length !== 1 || !KEY_SHAPE.test(lines[0])) throw new SetsparkRefusal(SETSPARK_REFUSAL.KEY_SHAPE); return lines[0]; } // `::`. The turn id is the Discord message // id the connector wrote into the envelope; the call index counts this // turn's tool calls from 1. A replay of the same key with the same body // returns the stored result; a different body is refused by the service. export function idempotencyKey(principal, turnId, callIndex) { if (typeof principal !== "string" || !PRINCIPAL.test(principal)) throw new Error("idempotencyKey: bad principal"); if (typeof turnId !== "string" || !SNOWFLAKE.test(turnId)) throw new SetsparkRefusal(SETSPARK_REFUSAL.NO_TURN); if (!Number.isInteger(callIndex) || callIndex < 1) throw new Error("idempotencyKey: call index must be a positive integer"); return `${principal}:${turnId}:${callIndex}`; } // A connector-side key for work that is not a model tool call: a message // id or interaction id and a fixed step name. export function connectorKey(principal, eventId, step) { if (typeof principal !== "string" || !PRINCIPAL.test(principal)) throw new Error("connectorKey: bad principal"); if (typeof eventId !== "string" || !SNOWFLAKE.test(eventId)) throw new Error("connectorKey: bad event id"); if (typeof step !== "string" || !/^[a-z][a-z-]{0,31}$/.test(step)) throw new Error("connectorKey: bad step"); return `${principal}:${eventId}:${step}`; } const defaultDeps = Object.freeze({ httpsRequest, httpRequest }); function cutMessage(v) { return typeof v === "string" ? v.replace(/\s+/g, " ").trim().slice(0, MESSAGE_MAX_CHARS) : ""; } function reasonFor(status, code) { if (status === 401 || status === 403) return SETSPARK_REFUSAL.UNAUTHORIZED; if (status === 404) return SETSPARK_REFUSAL.NOT_FOUND; if (status === 409) return SETSPARK_REFUSAL.CONFLICT; if (status === 422 && code === "idempotency_mismatch") return SETSPARK_REFUSAL.REPLAY; if (status >= 500) return SETSPARK_REFUSAL.SERVER; return SETSPARK_REFUSAL.REJECTED; } // One request. Resolves the parsed JSON body of a 2xx. Rejects with // SetsparkRefusal for everything else (a bad argument is a plain Error: a // bug, not a refusal). The key is read here, per call, and goes into the // header and nowhere else; `body` is sent as JSON. export async function callApi(config, { method, path, body, idempotencyKey: key = null }, deps = defaultDeps) { if (!["GET", "POST", "PATCH", "PUT"].includes(method)) throw new Error(`callApi: bad method ${method}`); if (typeof path !== "string" || !path.startsWith("/") || path.includes("..") || /\s/.test(path)) throw new Error("callApi: bad path"); if (method === "GET" && body !== undefined) throw new Error("callApi: GET takes no body"); if (method !== "GET" && key === null) throw new Error("callApi: a write needs an idempotency key"); const secret = readKey(config); const u = new URL(`${config.baseUrl}${path}`); return new Promise((resolve, reject) => { const mod = u.protocol === "https:" ? deps.httpsRequest : deps.httpRequest; const payload = body === undefined ? null : Buffer.from(JSON.stringify(body), "utf8"); const headers = { host: u.host, "user-agent": USER_AGENT, accept: "application/json", "accept-encoding": "identity", authorization: `Bearer ${secret}`, }; if (key !== null) headers[IDEMPOTENCY_HEADER] = key; if (payload) { headers["content-type"] = "application/json"; headers["content-length"] = String(payload.length); } const opts = { method, hostname: u.hostname.replace(/^\[|\]$/g, ""), port: u.port || (u.protocol === "https:" ? 443 : 80), path: `${u.pathname}${u.search}`, servername: u.protocol === "https:" ? u.hostname.replace(/^\[|\]$/g, "") : undefined, headers, }; let done = false; const finish = (fn, v) => { if (done) return; done = true; clearTimeout(timer); fn(v); }; const req = mod(opts); const timer = setTimeout(() => { req.destroy(); finish(reject, new SetsparkRefusal(SETSPARK_REFUSAL.TIMEOUT)); }, config.timeoutMs); req.on("error", () => finish(reject, new SetsparkRefusal(SETSPARK_REFUSAL.NETWORK))); req.on("response", (res) => { const chunks = []; let size = 0; res.on("data", (c) => { if (done) return; size += c.length; if (size > config.maxResponseBytes) { res.destroy(); req.destroy(); finish(reject, new SetsparkRefusal(SETSPARK_REFUSAL.TOO_BIG, { status: res.statusCode })); return; } chunks.push(c); }); res.on("error", () => finish(reject, new SetsparkRefusal(SETSPARK_REFUSAL.NETWORK))); res.on("end", () => { const text = Buffer.concat(chunks).toString("utf8"); let json = null; if (text.trim().length > 0) { try { json = JSON.parse(text); } catch { json = undefined; } } const status = res.statusCode; if (status >= 200 && status < 300) { if (json === undefined) return finish(reject, new SetsparkRefusal(SETSPARK_REFUSAL.NOT_JSON, { status })); return finish(resolve, { status, body: json }); } const err = isObject(json) ? json : {}; const code = typeof err.code === "string" ? err.code.slice(0, 64) : null; const extra = { status, code, message: cutMessage(err.message) }; if (status === 409) { if (err.current_revision !== undefined) extra.currentRevision = err.current_revision; if (Array.isArray(err.changed_fields)) extra.changedFields = err.changed_fields.filter((f) => typeof f === "string").slice(0, 32); } finish(reject, new SetsparkRefusal(reasonFor(status, code), extra)); }); }); if (payload) req.write(payload); req.end(); }); } // How a refusal reads to the model and in the turn record: the fixed // reason, the code, and on 409 the fields that changed. Never the raw body. export function renderRefusal(err) { let s = `refused: ${err.reason}`; if (err.code) s += ` (code ${err.code})`; if (err.currentRevision !== undefined) s += `; current revision ${err.currentRevision}`; if (Array.isArray(err.changedFields) && err.changedFields.length > 0) s += `; changed: ${err.changedFields.join(", ")}`; if (err.message && err.message !== err.reason) s += `\n${err.message}`; return s; } // --- the verbs (contract: shared-signals stack/api/openapi.json at a5425a2) --- // // Fixed verbs, one HTTP call each, registered by the extension when the // tools config carries a setspark key. Names are prefixed so they cannot be // confused with the file tools. Every write forms its idempotency key from // the running turn and the tool set's call index, and carries the asserted // requester in `context` (recorded by the service next to the verified key, // never used for authorization). Output is rendered to fixed lines and // capped; a record is shown as `key: value` lines. export const SETSPARK_TOOL_NAMES = Object.freeze([ "record_list", "record_get", "record_create", "record_update", "resolve_id", "open_approval_request", "get_approval_request", "create_document", ]); export const RECORD_TYPES = Object.freeze(["business", "project", "work_item", "decision", "reference_note"]); export const LIST_DEFAULT = 20; export const LIST_MAX = 50; export const FILTERS_MAX = 4; export const RECORD_MAX_BYTES = 32768; export const RENDER_MAX_CHARS = 6000; export const DOCUMENT_MAX_CHARS = 20000; export const RECORD_ID = /^[A-Z]{2,5}-[0-9]{1,8}$/; const PROP_NAME = /^[a-z][a-z0-9_]{0,31}$/; const HEX = /^[a-f0-9]{16,128}$/; const HIDDEN_PROPS = new Set(["accepted_snapshot", "props", "import_pending"]); function bad(what) { return new SetsparkRefusal(SETSPARK_REFUSAL.BAD_ARGS, { message: what }); } function needString(params, name, max, re = null) { const v = params[name]; if (typeof v !== "string" || v.length === 0 || v.length > max || (re && !re.test(v))) throw bad(`${name} must be a string${re ? ` matching ${re}` : ""} of at most ${max} characters`); return v; } function needId(params, name = "id") { return needString(params, name, 16, RECORD_ID); } function needType(params) { const t = params.record_type; if (!RECORD_TYPES.includes(t)) throw bad(`record_type must be one of ${RECORD_TYPES.join(", ")}`); return t; } function needObject(params, name) { const v = params[name]; if (!isObject(v)) throw bad(`${name} must be an object`); const size = Buffer.byteLength(JSON.stringify(v), "utf8"); if (size > RECORD_MAX_BYTES) throw bad(`${name} is over ${RECORD_MAX_BYTES} bytes`); for (const k of Object.keys(v)) if (!PROP_NAME.test(k)) throw bad(`${name} has a property name that is not allowed: ${k.slice(0, 32)}`); return v; } function needInt(params, name, min, max) { const v = params[name]; if (!Number.isInteger(v) || v < min || v > max) throw bad(`${name} must be an integer between ${min} and ${max}`); return v; } // The write key and the asserted requester for this call. The turn id and // call index come from the tool set's state; without a running turn the // write is refused before any request is formed. function writeParts(config, state) { const key = idempotencyKey(config.principal, state && state.turnId, state && state.callIndex); const context = { turn_id: state.turnId, client_version: USER_AGENT }; if (state.requester || state.authorId) context.requester = { ...(state.authorId ? { id: state.authorId } : {}), ...(state.requester ? { name: state.requester } : {}) }; return { key, context }; } async function write(config, state, method, path, body, deps) { const { key, context } = writeParts(config, state); const r = await callApi(config, { method, path, body: { ...body, context }, idempotencyKey: key }, deps); return { key, status: r.status, body: r.body }; } function record(body) { return isObject(body) ? body : {}; } export const setsparkVerbs = Object.freeze({ async record_list(config, params, state, deps) { const type = needType(params); const limit = params.limit === undefined ? LIST_DEFAULT : needInt(params, "limit", 1, LIST_MAX); const offset = params.offset === undefined ? 0 : needInt(params, "offset", 0, 100000); const q = new URLSearchParams({ record_type: type, limit: String(limit), offset: String(offset) }); if (params.filters !== undefined) { if (!isObject(params.filters) || Object.keys(params.filters).length > FILTERS_MAX) throw bad(`filters must be an object of at most ${FILTERS_MAX} properties`); for (const [k, v] of Object.entries(params.filters)) { if (!PROP_NAME.test(k) || ["record_type", "type", "limit", "offset"].includes(k)) throw bad(`filters: property name not allowed: ${k.slice(0, 32)}`); if (typeof v !== "string" || v.length === 0 || v.length > 200) throw bad(`filters.${k} must be a short string`); q.set(k, v); } } const r = await callApi(config, { method: "GET", path: `/v1/records?${q}` }, deps); const items = Array.isArray(record(r.body).items) ? record(r.body).items.filter(isObject) : []; return { verb: "record_list", recordType: type, items, limit, offset }; }, async record_get(config, params, state, deps) { const id = needId(params); const r = await callApi(config, { method: "GET", path: `/v1/records/${id}` }, deps); return { verb: "record_get", id, record: record(r.body) }; }, async record_create(config, params, state, deps) { const type = needType(params); const rec = needObject(params, "record"); if (typeof rec.title !== "string" || rec.title.trim().length === 0) throw bad("record.title is required"); const r = await write(config, state, "POST", "/v1/records", { record_type: type, record: rec }, deps); return { verb: "record_create", key: r.key, recordType: type, record: record(r.body) }; }, async record_update(config, params, state, deps) { const id = needId(params); const revision = needInt(params, "revision", 1, 1000000000); const fields = needObject(params, "fields"); if (Object.keys(fields).length === 0) throw bad("fields must name at least one property"); const r = await write(config, state, "PATCH", `/v1/records/${id}`, { revision, fields }, deps); return { verb: "record_update", key: r.key, id, from: revision, record: record(r.body) }; }, async resolve_id(config, params, state, deps) { const query = needString(params, "query", 200).trim(); if (query.length === 0) throw bad("query must not be blank"); const r = await callApi(config, { method: "GET", path: `/v1/resolve?${new URLSearchParams({ q: query })}` }, deps); const matches = Array.isArray(record(r.body).matches) ? record(r.body).matches.filter(isObject).slice(0, LIST_MAX) : []; return { verb: "resolve_id", query, matches }; }, async open_approval_request(config, params, state, deps) { const decisionId = needId(params, "decision_id"); const version = needInt(params, "proposal_version", 1, 1000000); const digest = needString(params, "proposal_digest", 128, HEX); const r = await write(config, state, "POST", "/v1/approval-requests", { decision_id: decisionId, proposal_version: version, proposal_digest: digest }, deps); const b = record(r.body); const approvers = Array.isArray(b.required_approvers) ? b.required_approvers.filter((a) => typeof a === "string") : []; return { verb: "open_approval_request", key: r.key, view: b, // what the connector needs to post the approval message request: { requestId: String(b.request_id), decisionId: String(b.decision_id ?? decisionId), proposalVersion: b.proposal_version ?? version, digest: String(b.proposal_digest ?? digest), approvers }, }; }, async get_approval_request(config, params, state, deps) { const id = needInt(params, "request_id", 1, 1000000000); const r = await callApi(config, { method: "GET", path: `/v1/approval-requests/${id}` }, deps); return { verb: "get_approval_request", requestId: String(id), view: record(r.body) }; }, async create_document(config, params, state, deps) { const collection = needString(params, "collection", 64, /^[A-Za-z0-9][A-Za-z0-9 _-]{0,63}$/); const title = needString(params, "title", 200).trim(); const text = params.text === undefined ? "" : params.text; if (typeof text !== "string" || text.length > DOCUMENT_MAX_CHARS) throw bad(`text must be a string of at most ${DOCUMENT_MAX_CHARS} characters`); const body = { collection, title, text }; if (params.source !== undefined) body.source = needString(params, "source", 500, /^[^\p{Zl}\p{Zp}\p{Cc}]+$/u); const r = await write(config, state, "POST", "/v1/documents", body, deps); return { verb: "create_document", key: r.key, document: record(r.body) }; }, }); // --- rendering --- function scalar(v) { if (v === null || v === undefined) return ""; if (typeof v === "string") return v.replace(/\s+/g, " ").trim(); if (typeof v === "number" || typeof v === "boolean") return String(v); if (Array.isArray(v)) return v.map(scalar).filter((s) => s.length > 0).join(", "); return JSON.stringify(v); } function cap(s) { return s.length > RENDER_MAX_CHARS ? `${s.slice(0, RENDER_MAX_CHARS)}\n… cut at ${RENDER_MAX_CHARS} characters` : s; } // A record as `key: value` lines: id, type and revision first, the title, // then the rest in the service's order, then the body last. The accepted // snapshot and the round-trip props are not shown. export function renderRecord(rec) { const head = `${scalar(rec.id) || "(no id)"} (${scalar(rec.record_type) || "record"}) revision ${scalar(rec.revision) || "?"}`; const lines = [head]; if (rec.title !== undefined) lines.push(`title: ${scalar(rec.title)}`); let body = null; for (const [k, v] of Object.entries(rec)) { if (["id", "record_type", "revision", "title"].includes(k) || HIDDEN_PROPS.has(k)) continue; if (k === "body" || k === "proposal_body") { body = { k, v }; continue; } const s = scalar(v); if (s.length > 0) lines.push(`${k}: ${s.slice(0, 500)}`); } if (body && typeof body.v === "string" && body.v.trim().length > 0) lines.push(`${body.k}:\n${body.v.trim()}`); return cap(lines.join("\n")); } function summary(rec) { const bits = [scalar(rec.title)]; for (const k of ["status", "priority", "kind"]) if (rec[k] !== undefined) bits.push(scalar(rec[k])); return `${scalar(rec.id)}: ${bits.filter((b) => b.length > 0).join(" | ")} (rev ${scalar(rec.revision) || "?"})`; } function renderView(v) { const approvals = Array.isArray(v.approvals) ? v.approvals.filter(isObject) : []; const who = Array.isArray(v.required_approvers) ? v.required_approvers.length : "?"; return `request ${scalar(v.request_id)} for ${scalar(v.decision_id)} version ${scalar(v.proposal_version)}: ${scalar(v.state) || "?"}; ${approvals.length} of ${who} approvals recorded${v.message_id ? "; bound to a Discord message" : "; no message bound yet"}`; } export function renderSetspark(name, out) { if (name === "record_list") { const body = out.items.map(summary).join("\n"); return `${out.items.length} ${out.recordType} record(s) from offset ${out.offset} (limit ${out.limit})\n${body || "(none)"}`; } if (name === "record_get") return renderRecord(out.record); if (name === "record_create") return `created ${scalar(out.record.id)} (${out.recordType}) revision ${scalar(out.record.revision)}; the record is live in SetSpark, no file and no commit`; if (name === "record_update") return `updated ${out.id} from revision ${out.from} to ${scalar(out.record.revision)}; the change is live in SetSpark`; if (name === "resolve_id") { const body = out.matches.map((m) => `${scalar(m.id)} (${scalar(m.record_type)}): ${scalar(m.title)}${m.exact ? " [exact]" : ""}`).join("\n"); return `${out.matches.length} match(es) for ${JSON.stringify(out.query)}\n${body || "(none)"}`; } if (name === "open_approval_request") return `${renderView(out.view)}. The approval message with its Approve button is posted for you after this reply; do not claim any approval yourself.`; if (name === "get_approval_request") return renderView(out.view); if (name === "create_document") return `created document ${scalar(out.document.title || out.document.id)}${out.document.url ? ` at ${scalar(out.document.url)}` : ""}`; throw new Error(`renderSetspark: unknown verb ${name}`); } // What the turn record keeps about a call, beyond the tool set's base. export function setsparkDetails(name, out) { const d = { verb: name }; if (out.key) d.key = out.key; if (out.record && out.record.id !== undefined) d.id = String(out.record.id); if (out.record && out.record.revision !== undefined) d.revision = out.record.revision; if (out.request) d.request = out.request; if (out.view && out.view.request_id !== undefined) d.requestId = String(out.view.request_id); if (name === "record_list") d.count = out.items.length; if (name === "resolve_id") d.count = out.matches.length; return d; } export const SETSPARK_TOOL_DESCRIPTIONS = Object.freeze({ record_list: { label: "List records", description: `List SetSpark records of one type (${RECORD_TYPES.join(", ")}), optionally filtered by exact property values, up to ${LIST_MAX} at a time. Read only.`, snippet: "record_list lists SetSpark records of one type", }, record_get: { label: "Get record", description: "Read one SetSpark record by id, with its revision. Read a record before updating it, and cite the id in your reply.", snippet: "record_get reads one SetSpark record by id", }, record_create: { label: "Create record", description: "Create one SetSpark record; the service allocates the id. Give record_type and the record's properties (title required). Only when the user asked for a record to be created.", snippet: "record_create creates one SetSpark record", }, record_update: { label: "Update record", description: "Change named properties of one SetSpark record. Carry the revision from record_get; a stale revision is refused with what changed, then read again and retry.", snippet: "record_update changes properties of one SetSpark record by revision", }, resolve_id: { label: "Resolve id", description: "Find SetSpark records by exact id or a title substring.", snippet: "resolve_id finds SetSpark records by id or title", }, open_approval_request: { label: "Open approval request", description: "Ask the required approvers to approve a Proposed decision at its current version and digest (from record_get). The approval message and button are posted by the connector after your reply; you never record an approval yourself.", snippet: "open_approval_request opens the approval of a Proposed decision", }, get_approval_request: { label: "Get approval request", description: "Read the state of an approval request: open, approved or closed, and who has approved.", snippet: "get_approval_request reads an approval request's state", }, create_document: { label: "Create document", description: `Create one prose document in an allowed Outline collection with a title and Markdown text (at most ${DOCUMENT_MAX_CHARS} characters). Only when the user asked for a document.`, snippet: "create_document creates one prose document in Outline", }, }); // --- the connector's own client (bind and approvals) --- // Built by the cli from the same config and passed to the connector like // rest. Request ids are strings on the connector side and integers on the // wire. export function createSetsparkApi(config, deps = defaultDeps) { const num = (id) => { const n = Number(id); if (!Number.isSafeInteger(n) || n < 1) throw new Error("setspark api: bad request id"); return n; }; return { async bindApprovalMessage({ requestId, messageId, channelId, idempotencyKey: key }) { const r = await callApi(config, { method: "POST", path: `/v1/approval-requests/${num(requestId)}/message`, body: { message_id: messageId, channel_id: channelId, context: { client_version: USER_AGENT } }, idempotencyKey: key }, deps); return r.body; }, async addApproval({ requestId, kind, authorId, messageId, boundMessageId, sourceUrl, statement, idempotencyKey: key }) { if (kind !== "button" && kind !== "reply") throw new Error("setspark api: kind must be button or reply"); const body = { request_id: num(requestId), kind, author_id: authorId, message_id: messageId, bound_message_id: boundMessageId, source_url: sourceUrl, statement, context: { source_url: sourceUrl, client_version: USER_AGENT } }; const r = await callApi(config, { method: "POST", path: "/v1/approvals", body, idempotencyKey: key }, deps); return r.body; }, async getApprovalRequest(requestId) { const r = await callApi(config, { method: "GET", path: `/v1/approval-requests/${num(requestId)}` }, deps); return r.body; }, }; }