@synapsor/runner 0.1.0-alpha.9 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +189 -0
- package/README.md +949 -164
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/runner.mjs +2982 -238
- package/docs/README.md +90 -15
- package/docs/app-owned-executors.md +38 -0
- package/docs/capability-authoring.md +265 -0
- package/docs/cloud-mode.md +24 -0
- package/docs/current-scope.md +29 -0
- package/docs/dependency-license-inventory.md +35 -0
- package/docs/doctor.md +98 -0
- package/docs/getting-started-own-database.md +131 -46
- package/docs/handler-helper.md +228 -0
- package/docs/http-mcp.md +85 -17
- package/docs/licensing.md +36 -0
- package/docs/local-mode.md +44 -25
- package/docs/mcp-audit.md +8 -8
- package/docs/mcp-client-setup.md +59 -21
- package/docs/openai-agents-sdk.md +57 -0
- package/docs/recipes.md +6 -6
- package/docs/release-notes.md +348 -0
- package/docs/release-policy.md +125 -0
- package/docs/result-envelope-v2.md +151 -0
- package/docs/rfcs/001-result-envelope-v2.md +143 -0
- package/docs/rfcs/002-app-owned-handler-helper.md +161 -0
- package/docs/rfcs/003-integrator-feedback-teardown.md +97 -0
- package/docs/store-lifecycle.md +83 -0
- package/docs/troubleshooting-first-run.md +6 -6
- package/docs/use-your-own-database.md +18 -0
- package/docs/writeback-executors.md +92 -1
- package/examples/app-owned-writeback/README.md +128 -0
- package/examples/app-owned-writeback/business-actions.md +221 -0
- package/examples/app-owned-writeback/command-handler.mjs +55 -0
- package/examples/app-owned-writeback/node-fastify-handler.mjs +64 -0
- package/examples/app-owned-writeback/python-fastapi-handler.py +66 -0
- package/examples/claude-desktop-postgres/Makefile +6 -0
- package/examples/claude-desktop-postgres/README.md +40 -0
- package/examples/cursor-postgres/Makefile +6 -0
- package/examples/cursor-postgres/README.md +30 -0
- package/examples/mcp-postgres-billing-app-handler/README.md +94 -0
- package/examples/mcp-postgres-billing-app-handler/app-handler.mjs +123 -0
- package/examples/mcp-postgres-billing-app-handler/docker-compose.yml +13 -0
- package/examples/mcp-postgres-billing-app-handler/schema.sql +59 -0
- package/examples/mcp-postgres-billing-app-handler/scripts/run-demo.sh +100 -0
- package/examples/mcp-postgres-billing-app-handler/seed.sql +39 -0
- package/examples/mcp-postgres-billing-app-handler/synapsor-handler.mjs +437 -0
- package/examples/mcp-postgres-billing-app-handler/synapsor.runner.json +158 -0
- package/examples/mysql-refund-agent/Makefile +4 -0
- package/examples/mysql-refund-agent/README.md +36 -0
- package/examples/openai-agents-http/Makefile +6 -0
- package/examples/openai-agents-http/README.md +33 -12
- package/examples/openai-agents-http/agent.py +29 -65
- package/examples/openai-agents-stdio/Makefile +6 -0
- package/examples/openai-agents-stdio/README.md +24 -6
- package/examples/openai-agents-stdio/agent.py +4 -2
- package/examples/raw-sql-vs-synapsor/Makefile +11 -0
- package/examples/raw-sql-vs-synapsor/README.md +41 -0
- package/examples/reference-support-billing-app/README.md +16 -16
- package/examples/reference-support-billing-app/mcp-client.generic.json +1 -1
- package/examples/support-billing-agent/Makefile +19 -0
- package/examples/support-billing-agent/README.md +89 -0
- package/examples/support-billing-agent/app/README.md +13 -0
- package/examples/support-billing-agent/db/schema.sql +91 -0
- package/examples/support-billing-agent/db/seed.sql +43 -0
- package/examples/support-billing-agent/docker-compose.yml +13 -0
- package/examples/support-billing-agent/scripts/run-demo.sh +15 -0
- package/examples/support-billing-agent/synapsor.runner.json +233 -0
- package/fixtures/benchmark/mcp-efficiency.json +53 -0
- package/fixtures/benchmark/mcp-efficiency.txt +25 -0
- package/fixtures/protocol/MANIFEST.json +54 -0
- package/fixtures/protocol/change-set.late-fee-waiver.v1.json +72 -0
- package/fixtures/protocol/execution-receipt.applied.v1.json +14 -0
- package/fixtures/protocol/execution-receipt.conflict.v1.json +15 -0
- package/fixtures/protocol/runner-registration.v1.json +22 -0
- package/fixtures/protocol/writeback-job.late-fee-waiver.v1.json +44 -0
- package/package.json +27 -4
- package/schemas/change-set.v1.schema.json +140 -0
- package/schemas/execution-receipt.v1.schema.json +34 -0
- package/schemas/onboarding-selection.v1.schema.json +132 -0
- package/schemas/runner-registration.v1.schema.json +48 -0
- package/schemas/synapsor.app-handler-receipt.v1.json +39 -0
- package/schemas/synapsor.app-handler-request.v1.json +119 -0
- package/schemas/synapsor.runner.schema.json +415 -0
- package/schemas/writeback-job.v1.schema.json +121 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { Pool } from "pg";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
const safeIdentifier = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
5
|
+
const scalarSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
|
|
6
|
+
export const appHandlerRequestV1Schema = z.object({
|
|
7
|
+
protocol_version: z.string().optional(),
|
|
8
|
+
schema_version: z.string().optional(),
|
|
9
|
+
proposal_id: z.string().min(1),
|
|
10
|
+
idempotency_key: z.string().min(1),
|
|
11
|
+
issued_at: z.string().optional(),
|
|
12
|
+
signature: z.string().optional(),
|
|
13
|
+
}).passthrough();
|
|
14
|
+
export const appHandlerReceiptV1Schema = z.object({
|
|
15
|
+
status: z.enum(["applied", "already_applied", "conflict", "failed"]),
|
|
16
|
+
rows_affected: z.number().int().nonnegative().default(0),
|
|
17
|
+
source_database_mutated: z.boolean().default(false),
|
|
18
|
+
previous_version: scalarSchema.optional(),
|
|
19
|
+
new_version: scalarSchema.optional(),
|
|
20
|
+
safe_error_code: z.string().nullable().optional(),
|
|
21
|
+
details: z.record(z.unknown()).optional(),
|
|
22
|
+
});
|
|
23
|
+
export function createWritebackHandler(options) {
|
|
24
|
+
const database = options.database ?? createConfiguredDatabase(options);
|
|
25
|
+
return async (request, response) => {
|
|
26
|
+
const result = await handleWritebackHttpRequest(request, options, database);
|
|
27
|
+
response.statusCode = result.statusCode;
|
|
28
|
+
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
29
|
+
response.end(`${JSON.stringify(result.receipt, null, 2)}\n`);
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export async function handleWritebackHttpRequest(request, options, database = options.database ?? createConfiguredDatabase(options)) {
|
|
33
|
+
try {
|
|
34
|
+
if (request.method && request.method !== "POST") {
|
|
35
|
+
return { statusCode: 405, receipt: failedReceipt("METHOD_NOT_ALLOWED") };
|
|
36
|
+
}
|
|
37
|
+
const rawBody = await readRawBody(request);
|
|
38
|
+
const auth = verifyRequestAuth({
|
|
39
|
+
headers: request.headers,
|
|
40
|
+
rawBody,
|
|
41
|
+
options,
|
|
42
|
+
});
|
|
43
|
+
if (!auth.ok)
|
|
44
|
+
return auth;
|
|
45
|
+
let body;
|
|
46
|
+
try {
|
|
47
|
+
body = JSON.parse(rawBody || "{}");
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return { statusCode: 400, receipt: failedReceipt("BAD_JSON") };
|
|
51
|
+
}
|
|
52
|
+
const parsed = appHandlerRequestV1Schema.safeParse(body);
|
|
53
|
+
if (!parsed.success) {
|
|
54
|
+
return { statusCode: 400, receipt: failedReceipt("BAD_WRITEBACK_REQUEST") };
|
|
55
|
+
}
|
|
56
|
+
let baseJob;
|
|
57
|
+
try {
|
|
58
|
+
baseJob = normalizeHandlerJob(parsed.data);
|
|
59
|
+
if (!supportedProtocolVersion(baseJob.protocolVersion)) {
|
|
60
|
+
return { statusCode: 400, receipt: failedReceipt("UNSUPPORTED_PROTOCOL_VERSION") };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return { statusCode: 400, receipt: failedReceipt("BAD_WRITEBACK_REQUEST") };
|
|
65
|
+
}
|
|
66
|
+
const apply = options.capabilities[baseJob.action];
|
|
67
|
+
if (!apply) {
|
|
68
|
+
return { statusCode: 400, receipt: failedReceipt("UNSUPPORTED_ACTION") };
|
|
69
|
+
}
|
|
70
|
+
const receipt = await database.withTransaction(async (tx) => {
|
|
71
|
+
const duplicate = await tx.findReceipt(baseJob.idempotencyKey);
|
|
72
|
+
if (duplicate?.status === "applied" || duplicate?.status === "already_applied") {
|
|
73
|
+
return {
|
|
74
|
+
...duplicate,
|
|
75
|
+
status: "already_applied",
|
|
76
|
+
rows_affected: 0,
|
|
77
|
+
source_database_mutated: false,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const row = await tx.lockTarget(baseJob);
|
|
81
|
+
if (!row) {
|
|
82
|
+
const conflict = conflictReceipt("ROW_NOT_FOUND_OR_WRONG_TENANT");
|
|
83
|
+
await tx.recordReceipt(baseJob, conflict);
|
|
84
|
+
return conflict;
|
|
85
|
+
}
|
|
86
|
+
const currentVersion = row[baseJob.expectedVersion.column];
|
|
87
|
+
if (!versionValuesMatch(currentVersion, baseJob.expectedVersion.value)) {
|
|
88
|
+
const conflict = conflictReceipt("ROW_CHANGED_AFTER_PROPOSAL", scalarOrNull(currentVersion));
|
|
89
|
+
await tx.recordReceipt(baseJob, conflict);
|
|
90
|
+
return conflict;
|
|
91
|
+
}
|
|
92
|
+
const job = { ...baseJob, row };
|
|
93
|
+
const effects = await apply(job, tx).catch(() => {
|
|
94
|
+
throw new SafeHandlerError("HANDLER_BUSINESS_ERROR");
|
|
95
|
+
});
|
|
96
|
+
const receipt = appliedReceipt(job, effects);
|
|
97
|
+
await tx.recordReceipt(job, receipt);
|
|
98
|
+
return receipt;
|
|
99
|
+
});
|
|
100
|
+
return { statusCode: receipt.status === "failed" ? 500 : 200, receipt };
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
return {
|
|
104
|
+
statusCode: 500,
|
|
105
|
+
receipt: failedReceipt(error instanceof SafeHandlerError ? error.code : "HANDLER_EXCEPTION"),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
class SafeHandlerError extends Error {
|
|
110
|
+
code;
|
|
111
|
+
constructor(code) {
|
|
112
|
+
super(code);
|
|
113
|
+
this.code = code;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function supportedProtocolVersion(value) {
|
|
117
|
+
return value === "1.0" || value === "synapsor.handler-writeback.v1";
|
|
118
|
+
}
|
|
119
|
+
function createConfiguredDatabase(options) {
|
|
120
|
+
if (!options.source)
|
|
121
|
+
throw new Error("createWritebackHandler requires source or database");
|
|
122
|
+
if (options.source.engine !== "postgres")
|
|
123
|
+
throw new Error("Only postgres app-owned handler helper source is implemented in this alpha");
|
|
124
|
+
const env = options.env ?? process.env;
|
|
125
|
+
const writeUrl = env[options.source.writeUrlEnv];
|
|
126
|
+
if (!writeUrl)
|
|
127
|
+
throw new Error(`${options.source.writeUrlEnv} is not set`);
|
|
128
|
+
return new PostgresWritebackHandlerDatabase(writeUrl, options.source.receiptTable);
|
|
129
|
+
}
|
|
130
|
+
export class PostgresWritebackHandlerDatabase {
|
|
131
|
+
pool;
|
|
132
|
+
receiptSchema;
|
|
133
|
+
receiptTable;
|
|
134
|
+
constructor(connectionString, receiptTable) {
|
|
135
|
+
this.pool = new Pool({ connectionString });
|
|
136
|
+
this.receiptSchema = receiptTable?.schema ?? "public";
|
|
137
|
+
this.receiptTable = receiptTable?.table ?? "synapsor_handler_receipts";
|
|
138
|
+
}
|
|
139
|
+
async withTransaction(fn) {
|
|
140
|
+
const client = await this.pool.connect();
|
|
141
|
+
try {
|
|
142
|
+
await client.query("BEGIN");
|
|
143
|
+
const tx = new PostgresWritebackHandlerTransaction(client, this.receiptSchema, this.receiptTable);
|
|
144
|
+
await tx.ensureReceiptTable();
|
|
145
|
+
const result = await fn(tx);
|
|
146
|
+
await client.query("COMMIT");
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
await client.query("ROLLBACK").catch(() => undefined);
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
client.release();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async close() {
|
|
158
|
+
await this.pool.end();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
class PostgresWritebackHandlerTransaction {
|
|
162
|
+
client;
|
|
163
|
+
receiptSchema;
|
|
164
|
+
receiptTable;
|
|
165
|
+
constructor(client, receiptSchema, receiptTable) {
|
|
166
|
+
this.client = client;
|
|
167
|
+
this.receiptSchema = receiptSchema;
|
|
168
|
+
this.receiptTable = receiptTable;
|
|
169
|
+
}
|
|
170
|
+
async ensureReceiptTable() {
|
|
171
|
+
await this.client.query(`
|
|
172
|
+
CREATE TABLE IF NOT EXISTS ${this.receiptTableName()} (
|
|
173
|
+
idempotency_key text PRIMARY KEY,
|
|
174
|
+
proposal_id text NOT NULL,
|
|
175
|
+
action text NOT NULL,
|
|
176
|
+
status text NOT NULL,
|
|
177
|
+
receipt_json jsonb NOT NULL,
|
|
178
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
179
|
+
completed_at timestamptz
|
|
180
|
+
)
|
|
181
|
+
`);
|
|
182
|
+
}
|
|
183
|
+
async query(sql, values = []) {
|
|
184
|
+
const result = await this.client.query(sql, values);
|
|
185
|
+
return { rows: result.rows, rowCount: result.rowCount ?? 0 };
|
|
186
|
+
}
|
|
187
|
+
async insert(table, values, options = {}) {
|
|
188
|
+
const entries = Object.entries(values);
|
|
189
|
+
if (!entries.length)
|
|
190
|
+
throw new Error("insert values must not be empty");
|
|
191
|
+
const columns = entries.map(([column]) => quoteIdentifier(column));
|
|
192
|
+
const params = entries.map((_, index) => `$${index + 1}`);
|
|
193
|
+
const returning = options.returning?.length ? ` RETURNING ${options.returning.map(quoteIdentifier).join(", ")}` : "";
|
|
194
|
+
const result = await this.client.query(`INSERT INTO ${qualifiedName(options.schema ?? "public", table)} (${columns.join(", ")}) VALUES (${params.join(", ")})${returning}`, entries.map(([, value]) => value));
|
|
195
|
+
return { rows: result.rows, rowCount: result.rowCount ?? 0 };
|
|
196
|
+
}
|
|
197
|
+
async update(table, where, values, options = {}) {
|
|
198
|
+
const valueEntries = Object.entries(values);
|
|
199
|
+
const whereEntries = Object.entries(where);
|
|
200
|
+
if (!valueEntries.length)
|
|
201
|
+
throw new Error("update values must not be empty");
|
|
202
|
+
if (!whereEntries.length)
|
|
203
|
+
throw new Error("update where must not be empty");
|
|
204
|
+
const params = [];
|
|
205
|
+
const set = valueEntries.map(([column, value]) => {
|
|
206
|
+
params.push(value);
|
|
207
|
+
return `${quoteIdentifier(column)} = $${params.length}`;
|
|
208
|
+
});
|
|
209
|
+
const clauses = whereEntries.map(([column, value]) => {
|
|
210
|
+
params.push(value);
|
|
211
|
+
return `${quoteIdentifier(column)} = $${params.length}`;
|
|
212
|
+
});
|
|
213
|
+
const returning = options.returning?.length ? ` RETURNING ${options.returning.map(quoteIdentifier).join(", ")}` : "";
|
|
214
|
+
const result = await this.client.query(`UPDATE ${qualifiedName(options.schema ?? "public", table)} SET ${set.join(", ")} WHERE ${clauses.join(" AND ")}${returning}`, params);
|
|
215
|
+
return { rows: result.rows, rowCount: result.rowCount ?? 0 };
|
|
216
|
+
}
|
|
217
|
+
async findReceipt(idempotencyKey) {
|
|
218
|
+
const result = await this.client.query(`SELECT receipt_json FROM ${this.receiptTableName()} WHERE idempotency_key = $1 FOR UPDATE`, [idempotencyKey]);
|
|
219
|
+
const raw = result.rows[0]?.receipt_json;
|
|
220
|
+
return isRecord(raw) ? coerceReceipt(raw) : undefined;
|
|
221
|
+
}
|
|
222
|
+
async lockTarget(job) {
|
|
223
|
+
const result = await this.client.query(`SELECT * FROM ${qualifiedName(job.target.schema, job.target.table)}
|
|
224
|
+
WHERE ${quoteIdentifier(job.target.primaryKey.column)} = $1
|
|
225
|
+
AND ${quoteIdentifier(job.tenantGuard.column)} = $2
|
|
226
|
+
FOR UPDATE`, [job.target.primaryKey.value, job.tenantGuard.value]);
|
|
227
|
+
return result.rows[0];
|
|
228
|
+
}
|
|
229
|
+
async recordReceipt(job, receipt) {
|
|
230
|
+
await this.client.query(`INSERT INTO ${this.receiptTableName()} (idempotency_key, proposal_id, action, status, receipt_json, completed_at)
|
|
231
|
+
VALUES ($1, $2, $3, $4, $5::jsonb, now())
|
|
232
|
+
ON CONFLICT (idempotency_key)
|
|
233
|
+
DO UPDATE SET status = EXCLUDED.status, receipt_json = EXCLUDED.receipt_json, completed_at = EXCLUDED.completed_at`, [job.idempotencyKey, job.proposalId, job.action, receipt.status, JSON.stringify(receipt)]);
|
|
234
|
+
}
|
|
235
|
+
receiptTableName() {
|
|
236
|
+
return qualifiedName(this.receiptSchema, this.receiptTable);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function normalizeHandlerJob(request) {
|
|
240
|
+
const changeSet = isRecord(request.change_set) ? request.change_set : request;
|
|
241
|
+
const source = isRecord(changeSet.source) ? changeSet.source : {};
|
|
242
|
+
const target = isRecord(changeSet.target) ? changeSet.target : source;
|
|
243
|
+
const guards = isRecord(changeSet.guards) ? changeSet.guards : {};
|
|
244
|
+
const scope = isRecord(changeSet.scope) ? changeSet.scope : {};
|
|
245
|
+
const principal = isRecord(changeSet.principal) ? changeSet.principal : {};
|
|
246
|
+
const primaryKey = isRecord(target.primary_key) ? target.primary_key : isRecord(source.primary_key) ? source.primary_key : {};
|
|
247
|
+
const tenantGuard = isRecord(guards.tenant) ? guards.tenant : isRecord(changeSet.tenant_guard) ? changeSet.tenant_guard : {};
|
|
248
|
+
const expectedVersion = isRecord(guards.expected_version) ? guards.expected_version : {};
|
|
249
|
+
const patch = scalarRecord(changeSet.patch);
|
|
250
|
+
const action = stringValue(changeSet.action ?? request.action, "action");
|
|
251
|
+
const schema = safeIdentifierValue(target.schema ?? source.schema, "target.schema");
|
|
252
|
+
const table = safeIdentifierValue(target.table ?? source.table, "target.table");
|
|
253
|
+
const primaryKeyColumn = safeIdentifierValue(primaryKey.column, "target.primary_key.column");
|
|
254
|
+
const primaryKeyValue = scalarValue(primaryKey.value ?? scope.object_id, "target.primary_key.value");
|
|
255
|
+
const tenantColumn = safeIdentifierValue(tenantGuard.column, "guards.tenant.column");
|
|
256
|
+
const tenantValue = scalarValue(tenantGuard.value ?? scope.tenant_id, "guards.tenant.value");
|
|
257
|
+
const expectedColumn = safeIdentifierValue(expectedVersion.column, "guards.expected_version.column");
|
|
258
|
+
const expectedValue = scalarValue(expectedVersion.value, "guards.expected_version.value");
|
|
259
|
+
return {
|
|
260
|
+
protocolVersion: String(request.protocol_version ?? request.schema_version ?? "1.0"),
|
|
261
|
+
proposalId: stringValue(request.proposal_id, "proposal_id"),
|
|
262
|
+
idempotencyKey: stringValue(request.idempotency_key, "idempotency_key"),
|
|
263
|
+
issuedAt: typeof request.issued_at === "string" ? request.issued_at : undefined,
|
|
264
|
+
action,
|
|
265
|
+
tenantId: String(tenantValue),
|
|
266
|
+
objectId: String(primaryKeyValue),
|
|
267
|
+
principal: String(principal.id ?? changeSet.runner_hint ?? "approved_operator"),
|
|
268
|
+
target: {
|
|
269
|
+
schema,
|
|
270
|
+
table,
|
|
271
|
+
primaryKey: { column: primaryKeyColumn, value: primaryKeyValue },
|
|
272
|
+
},
|
|
273
|
+
tenantGuard: { column: tenantColumn, value: tenantValue },
|
|
274
|
+
expectedVersion: { column: expectedColumn, value: expectedValue },
|
|
275
|
+
patch,
|
|
276
|
+
raw: request,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
function verifyRequestAuth(input) {
|
|
280
|
+
const env = input.options.env ?? process.env;
|
|
281
|
+
const tokenEnv = input.options.tokenEnv;
|
|
282
|
+
if (tokenEnv) {
|
|
283
|
+
const expected = env[tokenEnv];
|
|
284
|
+
if (!expected)
|
|
285
|
+
return { ok: false, statusCode: 500, receipt: failedReceipt("HANDLER_TOKEN_MISSING") };
|
|
286
|
+
if (!validBearer(input.headers.authorization, expected)) {
|
|
287
|
+
return { ok: false, statusCode: 401, receipt: failedReceipt("UNAUTHORIZED") };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const signingSecretEnv = input.options.signingSecretEnv;
|
|
291
|
+
if (signingSecretEnv) {
|
|
292
|
+
const secret = env[signingSecretEnv];
|
|
293
|
+
if (!secret)
|
|
294
|
+
return { ok: false, statusCode: 500, receipt: failedReceipt("HANDLER_SIGNING_SECRET_MISSING") };
|
|
295
|
+
const signature = headerValue(input.headers["x-synapsor-signature"]);
|
|
296
|
+
const issuedAt = headerValue(input.headers["x-synapsor-issued-at"]);
|
|
297
|
+
if (!signature || !validSignature(input.rawBody, secret, signature)) {
|
|
298
|
+
return { ok: false, statusCode: 401, receipt: failedReceipt("INVALID_SIGNATURE") };
|
|
299
|
+
}
|
|
300
|
+
if (!issuedAt || !issuedAtWithinSkew(issuedAt, input.options.issuedAtSkewMs ?? 5 * 60 * 1000)) {
|
|
301
|
+
return { ok: false, statusCode: 401, receipt: failedReceipt("INVALID_ISSUED_AT") };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return { ok: true };
|
|
305
|
+
}
|
|
306
|
+
function appliedReceipt(job, effects) {
|
|
307
|
+
const details = {
|
|
308
|
+
...(effects?.details ?? {}),
|
|
309
|
+
...(effects?.effects ? { effects: effects.effects } : {}),
|
|
310
|
+
};
|
|
311
|
+
return {
|
|
312
|
+
status: "applied",
|
|
313
|
+
rows_affected: Math.max(1, Number(effects?.rowsAffected ?? effects?.effects?.length ?? 1)),
|
|
314
|
+
previous_version: scalarOrNull(job.row[job.expectedVersion.column]),
|
|
315
|
+
new_version: effects?.newVersion ?? scalarOrNull(job.row[job.expectedVersion.column]),
|
|
316
|
+
source_database_mutated: true,
|
|
317
|
+
safe_error_code: null,
|
|
318
|
+
...(Object.keys(details).length ? { details } : {}),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
function conflictReceipt(code, previousVersion) {
|
|
322
|
+
return {
|
|
323
|
+
status: "conflict",
|
|
324
|
+
rows_affected: 0,
|
|
325
|
+
source_database_mutated: false,
|
|
326
|
+
safe_error_code: code,
|
|
327
|
+
...(previousVersion !== undefined ? { previous_version: previousVersion } : {}),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
function failedReceipt(code) {
|
|
331
|
+
return {
|
|
332
|
+
status: "failed",
|
|
333
|
+
rows_affected: 0,
|
|
334
|
+
source_database_mutated: false,
|
|
335
|
+
safe_error_code: code,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
function coerceReceipt(value) {
|
|
339
|
+
const parsed = appHandlerReceiptV1Schema.safeParse(value);
|
|
340
|
+
return parsed.success ? parsed.data : undefined;
|
|
341
|
+
}
|
|
342
|
+
function validBearer(header, expected) {
|
|
343
|
+
const value = headerValue(header);
|
|
344
|
+
if (!value?.startsWith("Bearer "))
|
|
345
|
+
return false;
|
|
346
|
+
const actual = Buffer.from(value.slice("Bearer ".length));
|
|
347
|
+
const wanted = Buffer.from(expected);
|
|
348
|
+
return actual.length === wanted.length && crypto.timingSafeEqual(actual, wanted);
|
|
349
|
+
}
|
|
350
|
+
export function signHandlerRequest(rawBody, secret) {
|
|
351
|
+
return `sha256=${crypto.createHmac("sha256", secret).update(rawBody).digest("hex")}`;
|
|
352
|
+
}
|
|
353
|
+
function validSignature(rawBody, secret, signature) {
|
|
354
|
+
const expected = Buffer.from(signHandlerRequest(rawBody, secret));
|
|
355
|
+
const actual = Buffer.from(signature);
|
|
356
|
+
return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
|
|
357
|
+
}
|
|
358
|
+
function issuedAtWithinSkew(value, skewMs) {
|
|
359
|
+
const time = new Date(value).getTime();
|
|
360
|
+
if (Number.isNaN(time))
|
|
361
|
+
return false;
|
|
362
|
+
return Math.abs(Date.now() - time) <= skewMs;
|
|
363
|
+
}
|
|
364
|
+
function headerValue(value) {
|
|
365
|
+
return Array.isArray(value) ? value[0] : value;
|
|
366
|
+
}
|
|
367
|
+
async function readRawBody(request) {
|
|
368
|
+
const chunks = [];
|
|
369
|
+
let bytes = 0;
|
|
370
|
+
for await (const chunk of request) {
|
|
371
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
372
|
+
bytes += buffer.length;
|
|
373
|
+
if (bytes > 1024 * 1024)
|
|
374
|
+
throw new Error("handler body exceeds 1 MiB");
|
|
375
|
+
chunks.push(buffer);
|
|
376
|
+
}
|
|
377
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
378
|
+
}
|
|
379
|
+
function scalarRecord(input) {
|
|
380
|
+
if (!isRecord(input))
|
|
381
|
+
throw new Error("patch must be an object");
|
|
382
|
+
const result = {};
|
|
383
|
+
for (const [key, value] of Object.entries(input)) {
|
|
384
|
+
if (!safeIdentifier.test(key))
|
|
385
|
+
throw new Error(`unsafe patch column: ${key}`);
|
|
386
|
+
result[key] = scalarValue(value, `patch.${key}`);
|
|
387
|
+
}
|
|
388
|
+
if (!Object.keys(result).length)
|
|
389
|
+
throw new Error("patch must not be empty");
|
|
390
|
+
return result;
|
|
391
|
+
}
|
|
392
|
+
function scalarValue(value, name) {
|
|
393
|
+
if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean")
|
|
394
|
+
return value;
|
|
395
|
+
throw new Error(`${name} must be a scalar`);
|
|
396
|
+
}
|
|
397
|
+
function scalarOrNull(value) {
|
|
398
|
+
if (value instanceof Date)
|
|
399
|
+
return value.toISOString();
|
|
400
|
+
if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean")
|
|
401
|
+
return value;
|
|
402
|
+
if (value === undefined)
|
|
403
|
+
return null;
|
|
404
|
+
return String(value);
|
|
405
|
+
}
|
|
406
|
+
function stringValue(value, name) {
|
|
407
|
+
if (typeof value === "string" && value.length > 0)
|
|
408
|
+
return value;
|
|
409
|
+
throw new Error(`${name} must be a non-empty string`);
|
|
410
|
+
}
|
|
411
|
+
function safeIdentifierValue(value, name) {
|
|
412
|
+
const text = stringValue(value, name);
|
|
413
|
+
if (!safeIdentifier.test(text))
|
|
414
|
+
throw new Error(`${name} must be a safe identifier`);
|
|
415
|
+
return text;
|
|
416
|
+
}
|
|
417
|
+
function versionValuesMatch(actual, expected) {
|
|
418
|
+
if (actual instanceof Date)
|
|
419
|
+
return versionValuesMatch(actual.toISOString(), expected);
|
|
420
|
+
const actualDate = new Date(String(actual));
|
|
421
|
+
const expectedDate = new Date(String(expected));
|
|
422
|
+
if (!Number.isNaN(actualDate.getTime()) && !Number.isNaN(expectedDate.getTime())) {
|
|
423
|
+
return actualDate.getTime() === expectedDate.getTime();
|
|
424
|
+
}
|
|
425
|
+
return String(actual) === String(expected);
|
|
426
|
+
}
|
|
427
|
+
function quoteIdentifier(identifier) {
|
|
428
|
+
if (!safeIdentifier.test(identifier))
|
|
429
|
+
throw new Error(`unsafe postgres identifier: ${identifier}`);
|
|
430
|
+
return `"${identifier}"`;
|
|
431
|
+
}
|
|
432
|
+
function qualifiedName(schema, table) {
|
|
433
|
+
return `${quoteIdentifier(schema)}.${quoteIdentifier(table)}`;
|
|
434
|
+
}
|
|
435
|
+
function isRecord(value) {
|
|
436
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
437
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"mode": "review",
|
|
4
|
+
"storage": {
|
|
5
|
+
"sqlite_path": "./tmp/billing-app-handler/local.db"
|
|
6
|
+
},
|
|
7
|
+
"sources": {
|
|
8
|
+
"billing_postgres": {
|
|
9
|
+
"engine": "postgres",
|
|
10
|
+
"read_url_env": "BILLING_APP_READ_URL",
|
|
11
|
+
"write_url_env": "BILLING_APP_WRITE_URL",
|
|
12
|
+
"statement_timeout_ms": 3000
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"trusted_context": {
|
|
16
|
+
"provider": "environment",
|
|
17
|
+
"values": {
|
|
18
|
+
"tenant_id_env": "SYNAPSOR_TENANT_ID",
|
|
19
|
+
"principal_env": "SYNAPSOR_PRINCIPAL"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"contexts": {
|
|
23
|
+
"local_operator": {
|
|
24
|
+
"provider": "environment",
|
|
25
|
+
"values": {
|
|
26
|
+
"tenant_id_env": "SYNAPSOR_TENANT_ID",
|
|
27
|
+
"principal_env": "SYNAPSOR_PRINCIPAL"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"executors": {
|
|
32
|
+
"billing_app_handler": {
|
|
33
|
+
"type": "http_handler",
|
|
34
|
+
"url_env": "BILLING_APP_HANDLER_URL",
|
|
35
|
+
"method": "POST",
|
|
36
|
+
"auth": {
|
|
37
|
+
"type": "bearer_env",
|
|
38
|
+
"token_env": "BILLING_APP_HANDLER_TOKEN"
|
|
39
|
+
},
|
|
40
|
+
"signing_secret_env": "BILLING_APP_HANDLER_SIGNING_SECRET",
|
|
41
|
+
"timeout_ms": 5000
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"capabilities": [
|
|
45
|
+
{
|
|
46
|
+
"name": "billing.inspect_invoice",
|
|
47
|
+
"kind": "read",
|
|
48
|
+
"source": "billing_postgres",
|
|
49
|
+
"context": "local_operator",
|
|
50
|
+
"target": {
|
|
51
|
+
"schema": "public",
|
|
52
|
+
"table": "invoices",
|
|
53
|
+
"primary_key": "id",
|
|
54
|
+
"tenant_key": "tenant_id"
|
|
55
|
+
},
|
|
56
|
+
"args": {
|
|
57
|
+
"invoice_id": { "type": "string", "required": true, "max_length": 128 }
|
|
58
|
+
},
|
|
59
|
+
"lookup": { "id_from_arg": "invoice_id" },
|
|
60
|
+
"visible_columns": [
|
|
61
|
+
"id",
|
|
62
|
+
"tenant_id",
|
|
63
|
+
"customer_id",
|
|
64
|
+
"status",
|
|
65
|
+
"balance_cents",
|
|
66
|
+
"late_fee_cents",
|
|
67
|
+
"waiver_reason",
|
|
68
|
+
"credit_requested_cents",
|
|
69
|
+
"credit_reason",
|
|
70
|
+
"credited_cents",
|
|
71
|
+
"updated_at"
|
|
72
|
+
],
|
|
73
|
+
"evidence": "required",
|
|
74
|
+
"max_rows": 1
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"name": "billing.propose_late_fee_waiver",
|
|
78
|
+
"kind": "proposal",
|
|
79
|
+
"source": "billing_postgres",
|
|
80
|
+
"context": "local_operator",
|
|
81
|
+
"target": {
|
|
82
|
+
"schema": "public",
|
|
83
|
+
"table": "invoices",
|
|
84
|
+
"primary_key": "id",
|
|
85
|
+
"tenant_key": "tenant_id"
|
|
86
|
+
},
|
|
87
|
+
"args": {
|
|
88
|
+
"invoice_id": { "type": "string", "required": true, "max_length": 128 },
|
|
89
|
+
"reason": { "type": "string", "required": true, "max_length": 500 }
|
|
90
|
+
},
|
|
91
|
+
"lookup": { "id_from_arg": "invoice_id" },
|
|
92
|
+
"visible_columns": [
|
|
93
|
+
"id",
|
|
94
|
+
"tenant_id",
|
|
95
|
+
"customer_id",
|
|
96
|
+
"status",
|
|
97
|
+
"balance_cents",
|
|
98
|
+
"late_fee_cents",
|
|
99
|
+
"waiver_reason",
|
|
100
|
+
"updated_at"
|
|
101
|
+
],
|
|
102
|
+
"evidence": "required",
|
|
103
|
+
"max_rows": 1,
|
|
104
|
+
"patch": {
|
|
105
|
+
"late_fee_cents": { "fixed": 0 },
|
|
106
|
+
"waiver_reason": { "from_arg": "reason" }
|
|
107
|
+
},
|
|
108
|
+
"allowed_columns": ["late_fee_cents", "waiver_reason"],
|
|
109
|
+
"numeric_bounds": {
|
|
110
|
+
"late_fee_cents": { "minimum": 0, "maximum": 10000 }
|
|
111
|
+
},
|
|
112
|
+
"conflict_guard": { "column": "updated_at" },
|
|
113
|
+
"approval": { "mode": "human", "required_role": "billing_lead" }
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
"name": "billing.propose_account_credit",
|
|
117
|
+
"kind": "proposal",
|
|
118
|
+
"source": "billing_postgres",
|
|
119
|
+
"context": "local_operator",
|
|
120
|
+
"executor": "billing_app_handler",
|
|
121
|
+
"target": {
|
|
122
|
+
"schema": "public",
|
|
123
|
+
"table": "invoices",
|
|
124
|
+
"primary_key": "id",
|
|
125
|
+
"tenant_key": "tenant_id"
|
|
126
|
+
},
|
|
127
|
+
"args": {
|
|
128
|
+
"invoice_id": { "type": "string", "required": true, "max_length": 128 },
|
|
129
|
+
"amount_cents": { "type": "number", "required": true, "minimum": 1, "maximum": 10000 },
|
|
130
|
+
"reason": { "type": "string", "required": true, "max_length": 500 }
|
|
131
|
+
},
|
|
132
|
+
"lookup": { "id_from_arg": "invoice_id" },
|
|
133
|
+
"visible_columns": [
|
|
134
|
+
"id",
|
|
135
|
+
"tenant_id",
|
|
136
|
+
"customer_id",
|
|
137
|
+
"status",
|
|
138
|
+
"balance_cents",
|
|
139
|
+
"credited_cents",
|
|
140
|
+
"credit_requested_cents",
|
|
141
|
+
"credit_reason",
|
|
142
|
+
"updated_at"
|
|
143
|
+
],
|
|
144
|
+
"evidence": "required",
|
|
145
|
+
"max_rows": 1,
|
|
146
|
+
"patch": {
|
|
147
|
+
"credit_requested_cents": { "from_arg": "amount_cents" },
|
|
148
|
+
"credit_reason": { "from_arg": "reason" }
|
|
149
|
+
},
|
|
150
|
+
"allowed_columns": ["credit_requested_cents", "credit_reason"],
|
|
151
|
+
"numeric_bounds": {
|
|
152
|
+
"credit_requested_cents": { "minimum": 1, "maximum": 10000 }
|
|
153
|
+
},
|
|
154
|
+
"conflict_guard": { "column": "updated_at" },
|
|
155
|
+
"approval": { "mode": "human", "required_role": "billing_lead" }
|
|
156
|
+
}
|
|
157
|
+
]
|
|
158
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# MySQL Refund Agent
|
|
2
|
+
|
|
3
|
+
This example points at the MySQL order/refund fixture and proves Synapsor
|
|
4
|
+
Runner is not Postgres-only.
|
|
5
|
+
|
|
6
|
+
The reviewed tools are:
|
|
7
|
+
|
|
8
|
+
- `orders.inspect_order`
|
|
9
|
+
- `orders.propose_refund_review`
|
|
10
|
+
- `orders.propose_status_change`
|
|
11
|
+
|
|
12
|
+
The refund proposal updates only review fields on one existing order. It does
|
|
13
|
+
not issue money movement, call a payment provider, or expose a generic SQL
|
|
14
|
+
tool.
|
|
15
|
+
|
|
16
|
+
## Run
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
make demo
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Expected output includes the shared local MCP smoke passing for the MySQL orders
|
|
23
|
+
scenario:
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
MySQL orders
|
|
27
|
+
ACCEPT execute_sql approval and commit tools absent
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The source database remains unchanged until a proposal is approved outside MCP
|
|
31
|
+
and applied through guarded writeback.
|
|
32
|
+
|
|
33
|
+
## Underlying Fixture
|
|
34
|
+
|
|
35
|
+
This folder wraps `../mcp-mysql-orders/`, which contains the Docker compose
|
|
36
|
+
file, seed SQL, and `synapsor.runner.json` contract.
|