@synapsor/runner 0.1.0-alpha.11 → 0.1.0-alpha.14
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 +85 -0
- package/README.md +169 -23
- package/dist/cli.d.ts.map +1 -1
- package/dist/runner.mjs +855 -66
- package/docs/README.md +21 -0
- package/docs/app-owned-executors.md +5 -0
- package/docs/capability-authoring.md +265 -0
- package/docs/doctor.md +98 -0
- package/docs/handler-helper.md +217 -0
- package/docs/local-mode.md +13 -2
- package/docs/release-notes.md +57 -2
- package/docs/release-policy.md +86 -0
- package/docs/result-envelope-v2.md +148 -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/writeback-executors.md +18 -0
- package/examples/app-owned-writeback/README.md +1 -0
- package/examples/mcp-postgres-billing-app-handler/README.md +7 -2
- package/examples/mcp-postgres-billing-app-handler/app-handler.mjs +77 -149
- package/examples/mcp-postgres-billing-app-handler/scripts/run-demo.sh +1 -0
- package/examples/mcp-postgres-billing-app-handler/synapsor-handler.mjs +437 -0
- package/examples/mcp-postgres-billing-app-handler/synapsor.runner.json +1 -0
- package/package.json +3 -1
- 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 +125 -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 +412 -0
- package/schemas/writeback-job.v1.schema.json +121 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
here's the detailed teardown of the OSS synapsor-runner specifically, split into (A) what trips up a serious dev, and (B) how an LLM actually experiences it. Everything below is from things I hit building and live-testing the lab, not theory. I'll keep Cloud-domain stuff out of it (RBAC, central ledger, etc. — those belong in hosted).
|
|
2
|
+
|
|
3
|
+
A. Limitations for a serious dev/user
|
|
4
|
+
|
|
5
|
+
1. Config authoring is the real onboarding cost.
|
|
6
|
+
synapsor.runner.json is powerful but you mostly hand-write it. init --wizard exists, but multi-capability, executor-backed configs are manual, and there's no published JSON schema or one-page field reference — I learned the shape by running init and reading dist/runner.mjs. A serious dev wants: a versioned JSON Schema (so editors autocomplete + validate), and a "capability authoring" doc covering read vs proposal, patch (fixed/from_arg), allowed_columns, numeric_bounds, conflict_guard, executor.
|
|
7
|
+
|
|
8
|
+
2. Env-var sprawl + historical credential ambiguity.
|
|
9
|
+
There are a lot: SYNAPSOR_DATABASE_READ_URL, _WRITE_URL, legacy SYNAPSOR_DATABASE_URL, TENANT_ID, PRINCIPAL, RUNNER_HTTP_TOKEN, plus per-executor *_HANDLER_URL/_TOKEN. The apply-uses-SYNAPSOR_DATABASE_URL-vs-write_url_env thing cost me real debugging on alpha.6 (now documented). doctor is good but only checks presence; it doesn't prove the write path (attempt a rolled-back probe write with the writer cred) or handler reachability.
|
|
10
|
+
|
|
11
|
+
3. Store ↔ server lifecycle is a footgun.
|
|
12
|
+
The SQLite store is shared state, and the running server holds it open. Deleting/resetting the store under a live server gives corrupt/confusing behavior — I hit "database access issue" surfaced to the agent because I reset the store without restarting the server. There's no lock, warning, or coordinated reset. Also: running serve-http + serve-streamable-http on one store simultaneously is asking for contention, with no guardrail.
|
|
13
|
+
|
|
14
|
+
4. The receipt-table permission gotcha.
|
|
15
|
+
Direct writeback does CREATE TABLE IF NOT EXISTS synapsor_writeback_receipts, which a least-privilege writer can't do (PG15+ no CREATE on public). Now documented, but doctor could detect it and print the exact GRANT/DDL (or a flag to pre-create). I had to invent the dedicated-schema trick myself.
|
|
16
|
+
|
|
17
|
+
5. App-owned handlers re-implement security by hand.
|
|
18
|
+
The executor contract is "match the example": you must re-check tenant, parse change_set, enforce the expected_version stale-row guard, and do idempotency yourself. It's easy to write an insecure handler (skip the version check, forget tenant scope). There's no handler SDK/helper and no request signing — the only auth is a bearer token, so the handler trusts the POST body's tenant/version. A @synapsor/handler helper (verify + parse + enforce guards, optional HMAC signature) would remove a whole class of mistakes.
|
|
19
|
+
|
|
20
|
+
6. Versioning discipline.
|
|
21
|
+
@alpha is a moving tag and behavior changed meaningfully across alpha.6→11 (transport, arg types string→number, credential resolution). I had to pin and bump six times. A stable channel + changelog + semver promise is table stakes before serious devs build on it.
|
|
22
|
+
|
|
23
|
+
7. Two serve modes are easy to confuse.
|
|
24
|
+
serve-http (lightweight JSON-RPC, not real MCP) vs serve-streamable-http (spec MCP) on different ports, plus --alias-mode. I pointed the SDK at the wrong one. Consider: make serve-streamable-http the headline, rename serve-http to something like serve-bridge/--legacy-jsonrpc, and have mcp client-config always pair the client with the matching server command (it does for openai-agents — good).
|
|
25
|
+
|
|
26
|
+
8. Observability is CLI-only.
|
|
27
|
+
replay/activity are genuinely nice, but there's no structured event stream/webhook for proposal.created/approved/applied. Even a local webhook would let people build a review UI or Slack-notify a reviewer without polling. (The full ledger is Cloud's job; a local event hook isn't.)
|
|
28
|
+
|
|
29
|
+
B. How easy is it for an LLM to use/understand?
|
|
30
|
+
|
|
31
|
+
This is where you have the most leverage, and I have direct evidence.
|
|
32
|
+
|
|
33
|
+
1. Tool descriptions are the single biggest reliability lever — and they're currently generic.
|
|
34
|
+
On the JSON-RPC path my agent was reliable because my function-tool docstrings were rich ("Read an outage event — window, affected plan, credit policy; use this to decide if a waiver is justified"). On the native streamable path the model stalled and refused to propose, and a big reason was the auto-generated description: "Read public.outage_events through a reviewed Synapsor capability with trusted tenant context and evidence." That tells the model the plumbing, not what the tool is for or what it returns. The model didn't realize the outage tool gives it the policy it needed.
|
|
35
|
+
→ Let capability config carry a model-facing description + per-arg descriptions + an optional "returns/when to use" hint, and surface them in tools/list. This alone would have made my streamable runs reliable. Right now authors can't easily improve what the model sees.
|
|
36
|
+
|
|
37
|
+
2. Inconsistent result envelopes hurt the model (and the code).
|
|
38
|
+
Success = {status:"ok", data:{...}, evidence_bundle_id, trusted_context, ...}. Not-found = {ok:false, code, error}. Two different shapes for the same tool means the model (and my client) must branch on multiple keys. → One envelope always: {ok: true/false, data?, error?, summary}, where summary is a one-line natural-language result the model can echo to the user. The proposal result (proposal_id, diff: {before, proposed}, source_database_changed:false) is the best-designed part — model-legible and unambiguous; mirror that everywhere.
|
|
39
|
+
|
|
40
|
+
3. Leaky/raw errors confuse the model.
|
|
41
|
+
A failed read surfaced connect ECONNREFUSED 127.0.0.1:5433 straight into the agent, which then told the user "database access issue." Raw infra errors are both a small info leak and bad for LLM behavior. → Safe, terse, actionable tool errors ("temporarily unavailable, retry later" / "not found in your tenant"), with details only in the local ledger.
|
|
42
|
+
|
|
43
|
+
4. Dotted names vs aliases.
|
|
44
|
+
alias-mode openai correctly makes names valid (billing__inspect_invoice), but the alias diverges from any example/instruction that uses the canonical dotted name — so prompts that say "call propose_late_fee_waiver" don't match the tool the model sees. I had to make my agent instructions tool-name-agnostic. → Keep openai-safe aliases as the default for the openai-agents config (you do), and put the canonical name + purpose in the description so the model can still reason about it.
|
|
45
|
+
|
|
46
|
+
5. Evidence handles are slightly confusing in-loop.
|
|
47
|
+
The model gets evidence_bundle_id but can't really do anything with it during the turn (it's for replay). Without a hint, a model may try to "use" it. A one-line "this is an audit handle; you don't need to act on it" in the description avoids wasted reasoning.
|
|
48
|
+
|
|
49
|
+
6. No recommended agent-instruction snippet.
|
|
50
|
+
The biggest gap for first-time LLM integrators: the propose-first pattern needs the model to inspect all evidence before proposing and follow referenced IDs. I had to write that system prompt myself (and rewrite it once for the streamable path). → mcp client-config could optionally emit a recommended instruction block ("gather evidence first; follow referenced ids like OUT-…; propose only within policy; you cannot write or approve"). That would make day-1 agent behavior reliable instead of something each integrator rediscovers.
|
|
51
|
+
|
|
52
|
+
Net on LLM-friendliness: the conceptual model is excellent for LLMs — propose-only tools are inherently safe and easy for a model to call. The surface (generic descriptions, mixed envelopes, raw errors, alias/name divergence) is where reliability leaks. These are all fixable in the OSS runner and would noticeably raise the floor on agent reliability.
|
|
53
|
+
|
|
54
|
+
C. If I were prioritizing your OSS updates
|
|
55
|
+
|
|
56
|
+
P0 (adoption + LLM reliability):
|
|
57
|
+
1. Author-supplied rich tool/arg descriptions in config, surfaced in tools/list.
|
|
58
|
+
2. One consistent result envelope + safe terse errors + a summary field.
|
|
59
|
+
3. Published config JSON Schema + a capability/executor authoring page.
|
|
60
|
+
|
|
61
|
+
P1 (trust + safety):
|
|
62
|
+
4. Stable release channel + changelog/semver.
|
|
63
|
+
5. Handler helper/SDK (parse + enforce tenant/version/idempotency, optional HMAC) so app-owned writeback is safe-by-default.
|
|
64
|
+
6. doctor that probes the real write path + handler and emits exact GRANT/DDL for receipts; store/server lifecycle guardrails (warn on store-removed-under-server).
|
|
65
|
+
|
|
66
|
+
P2 (polish):
|
|
67
|
+
7. Optional recommended agent-instruction snippet from mcp client-config.
|
|
68
|
+
8. Local lifecycle webhook/event stream for proposals.
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
---------------
|
|
73
|
+
01-result-envelope-spec.md
|
|
74
|
+
|
|
75
|
+
One envelope for every tool result, so ok is the only thing client code (and the model) must branch on:
|
|
76
|
+
|
|
77
|
+
jsonc
|
|
78
|
+
{ "ok": true, "summary": "<one-line NL the model reads/echoes>", "kind": "read|proposal",
|
|
79
|
+
"data": {…}|null, "proposal": {…}|null, "error": {"code","message","retryable"}|null,
|
|
80
|
+
"evidence": {…}|null, "source_database_changed": false, "_meta": {tenant, principal, canonical_capability} }
|
|
81
|
+
|
|
82
|
+
Key decisions, all grounded in what broke in my live runs:
|
|
83
|
+
- Kills the status vs ok / mixed-shape problem (today success uses status:"ok", errors use ok:false — two branches).
|
|
84
|
+
- Mandatory summary field — the LLM reads it first; fixed my "model parroted infra error" issue.
|
|
85
|
+
- Safe, stable error.code enum (9 codes, table included) — never raw ECONNREFUSED-style strings, which both leak and degrade agent behavior.
|
|
86
|
+
- Author-supplied description / per-arg description / returns_hint surfaced in tools/list — this is the single biggest LLM lever; the streamable stall I hit was the outage tool's generic description not telling the model it returns the policy.
|
|
87
|
+
- Migration path: result_format: 2 flag + dual-emit + old→new mapping table.
|
|
88
|
+
|
|
89
|
+
02-handler-helper-interface.md
|
|
90
|
+
|
|
91
|
+
A first-party helper so app-owned executors are safe by default instead of "match the example."
|
|
92
|
+
- Formalizes (and versions) the request/receipt contract that's currently implicit.
|
|
93
|
+
- createWritebackHandler (TS) + synapsor_handler (Python — since real handlers are app code) where the author writes only the INSERT/UPDATE and returns effects; the helper enforces auth + HMAC signature, tenant scope, expected_version stale-row guard, idempotency, atomicity, safe receipts for them.
|
|
94
|
+
- Calls out request signing (today a handler trusts body-supplied tenant_id behind only a bearer token) and the receipts-table GRANT/DDL gap.
|
|
95
|
+
- Rationale tied to your own thesis: rich writes are the executor's job, but that's only safe if the handler is safe — so make the secure path the easy path.
|
|
96
|
+
|
|
97
|
+
Both are sized to drop into the repo as RFCs. If you want, next I can: turn the error-code enum into a concrete TypeScript type + a v1→v2 adapter shim, or sketch the Job/Tx type definitions for the handler helper so they're ready to implement.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Store Lifecycle
|
|
2
|
+
|
|
3
|
+
Synapsor Runner keeps local evidence, query audit, proposals, receipts, replay,
|
|
4
|
+
and lifecycle events in a SQLite store.
|
|
5
|
+
|
|
6
|
+
Default path:
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
./.synapsor/local.db
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Server leases
|
|
13
|
+
|
|
14
|
+
MCP server modes write a small lease file next to the store:
|
|
15
|
+
|
|
16
|
+
```text
|
|
17
|
+
<store>.lease.json
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The lease records the server pid, mode, transport, and start time. Destructive
|
|
21
|
+
store operations refuse to run while that lease points at a live process.
|
|
22
|
+
|
|
23
|
+
Use `--force` only after you have stopped the server or verified the lease is
|
|
24
|
+
stale.
|
|
25
|
+
|
|
26
|
+
## Prune safely
|
|
27
|
+
|
|
28
|
+
Preview first:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
synapsor-runner store prune --store ./.synapsor/local.db --older-than 30d --dry-run
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Apply after review:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
synapsor-runner store prune --store ./.synapsor/local.db --older-than 30d --yes
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Override an active/stale lease:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
synapsor-runner store prune --store ./.synapsor/local.db --older-than 30d --yes --force
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Reset the local ledger
|
|
47
|
+
|
|
48
|
+
Reset deletes only the local SQLite ledger files:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
synapsor-runner store reset --store ./.synapsor/local.db --yes
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
It removes:
|
|
55
|
+
|
|
56
|
+
```text
|
|
57
|
+
local.db
|
|
58
|
+
local.db-wal
|
|
59
|
+
local.db-shm
|
|
60
|
+
local.db.lease.json
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
It never touches your source Postgres/MySQL database. Like prune, reset refuses
|
|
64
|
+
while an active server lease exists unless you pass `--force` after verifying
|
|
65
|
+
the server is stopped or the lease is stale.
|
|
66
|
+
|
|
67
|
+
## Deleted store under a running server
|
|
68
|
+
|
|
69
|
+
If the store file disappears while a server is still running, model-facing tool
|
|
70
|
+
calls fail safely with `TEMPORARILY_UNAVAILABLE`. Runner does not expose raw
|
|
71
|
+
SQLite paths, corruption text, or filesystem errors to the model.
|
|
72
|
+
|
|
73
|
+
Fix:
|
|
74
|
+
|
|
75
|
+
1. Stop the running MCP server.
|
|
76
|
+
2. Recreate the store by rerunning the demo/setup or restore the previous store.
|
|
77
|
+
3. Restart the MCP server.
|
|
78
|
+
|
|
79
|
+
## Concurrent server modes
|
|
80
|
+
|
|
81
|
+
Running multiple server transports against the same SQLite store can cause
|
|
82
|
+
contention and confusing local state. Runner refuses concurrent server leases by
|
|
83
|
+
default. Use `--allow-concurrent-store` only for controlled local debugging.
|
|
@@ -88,6 +88,7 @@ literal values.
|
|
|
88
88
|
"type": "bearer_env",
|
|
89
89
|
"token_env": "SYNAPSOR_BILLING_HANDLER_TOKEN"
|
|
90
90
|
},
|
|
91
|
+
"signing_secret_env": "SYNAPSOR_BILLING_HANDLER_SIGNING_SECRET",
|
|
91
92
|
"timeout_ms": 5000
|
|
92
93
|
}
|
|
93
94
|
},
|
|
@@ -114,6 +115,17 @@ The handler receives proposal fields, the exact patch, evidence metadata,
|
|
|
114
115
|
guards, and an idempotency key. It does not receive arbitrary model SQL or DB
|
|
115
116
|
credentials from Synapsor Runner.
|
|
116
117
|
|
|
118
|
+
When `signing_secret_env` is set, Runner signs the exact JSON body with HMAC
|
|
119
|
+
SHA-256 and sends:
|
|
120
|
+
|
|
121
|
+
- `X-Synapsor-Signature: sha256=...`
|
|
122
|
+
- `X-Synapsor-Issued-At: ...`
|
|
123
|
+
- `X-Synapsor-Proposal-Id: ...`
|
|
124
|
+
- `Idempotency-Key: ...`
|
|
125
|
+
|
|
126
|
+
Use signing for any handler that is not strictly loopback-only and protected by
|
|
127
|
+
another trusted boundary.
|
|
128
|
+
|
|
117
129
|
Handler responses:
|
|
118
130
|
|
|
119
131
|
```json
|
|
@@ -139,6 +151,12 @@ receipt is stored in replay.
|
|
|
139
151
|
Use your application/API for business logic. Use Synapsor Runner for proposal,
|
|
140
152
|
approval, evidence, policy boundary, and replay.
|
|
141
153
|
|
|
154
|
+
For TypeScript services, use the source-level helper in `packages/handler`.
|
|
155
|
+
It verifies bearer/HMAC auth, parses the request, locks the target row with the
|
|
156
|
+
tenant guard, checks the expected version, handles idempotency, wraps the
|
|
157
|
+
business effect in a transaction, and returns safe receipts without raw driver
|
|
158
|
+
errors. See [Handler Helper](handler-helper.md).
|
|
159
|
+
|
|
142
160
|
This is the recommended path for writes that are richer than the current
|
|
143
161
|
`sql_update` scope, such as:
|
|
144
162
|
|
|
@@ -15,8 +15,12 @@ App-owned rich writeback:
|
|
|
15
15
|
- model-facing MCP only creates a proposal;
|
|
16
16
|
- approval happens outside MCP;
|
|
17
17
|
- Runner calls `billing_app_handler`;
|
|
18
|
-
- the app
|
|
19
|
-
|
|
18
|
+
- the app uses the first-party handler helper from the source workspace, or the
|
|
19
|
+
bundled `synapsor-handler.mjs` shim included in the runner npm package, to
|
|
20
|
+
verify bearer auth, HMAC signature, tenant scope, expected row version,
|
|
21
|
+
idempotency, and transaction/receipt shape;
|
|
22
|
+
- the handler business code inserts an `account_credits` row and updates the
|
|
23
|
+
invoice inside the helper-owned transaction;
|
|
20
24
|
- Runner records the handler receipt and replay.
|
|
21
25
|
|
|
22
26
|
The model never receives `execute_sql`, approval tools, commit/apply tools,
|
|
@@ -46,6 +50,7 @@ export BILLING_APP_READ_URL="postgresql://synapsor_reader:synapsor_reader_passwo
|
|
|
46
50
|
export BILLING_APP_WRITE_URL="postgresql://synapsor_writer:synapsor_writer_password@localhost:55437/synapsor_billing_app_handler"
|
|
47
51
|
export BILLING_APP_HANDLER_URL="http://127.0.0.1:8787/synapsor/writeback"
|
|
48
52
|
export BILLING_APP_HANDLER_TOKEN="dev-handler-token"
|
|
53
|
+
export BILLING_APP_HANDLER_SIGNING_SECRET="dev-handler-signing-secret"
|
|
49
54
|
export SYNAPSOR_TENANT_ID="acme"
|
|
50
55
|
export SYNAPSOR_PRINCIPAL="local_billing_operator"
|
|
51
56
|
|
|
@@ -1,43 +1,44 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import http from "node:http";
|
|
3
|
-
import { createRequire } from "node:module";
|
|
4
3
|
|
|
5
|
-
const {
|
|
4
|
+
const { createWritebackHandler } = await loadHandlerHelper();
|
|
6
5
|
|
|
7
6
|
const port = Number(process.env.BILLING_APP_HANDLER_PORT || "8787");
|
|
8
|
-
const expectedToken = process.env.BILLING_APP_HANDLER_TOKEN || "dev-handler-token";
|
|
9
|
-
const databaseUrl = process.env.BILLING_APP_WRITE_URL;
|
|
10
7
|
|
|
11
|
-
if (!
|
|
8
|
+
if (!process.env.BILLING_APP_WRITE_URL) {
|
|
12
9
|
console.error("BILLING_APP_WRITE_URL is required.");
|
|
13
10
|
process.exit(1);
|
|
14
11
|
}
|
|
15
12
|
|
|
16
|
-
const
|
|
13
|
+
const writebackHandler = createWritebackHandler({
|
|
14
|
+
tokenEnv: "BILLING_APP_HANDLER_TOKEN",
|
|
15
|
+
signingSecretEnv: "BILLING_APP_HANDLER_SIGNING_SECRET",
|
|
16
|
+
source: {
|
|
17
|
+
engine: "postgres",
|
|
18
|
+
writeUrlEnv: "BILLING_APP_WRITE_URL",
|
|
19
|
+
receiptTable: {
|
|
20
|
+
schema: "public",
|
|
21
|
+
table: "synapsor_handler_receipts",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
capabilities: {
|
|
25
|
+
"billing.propose_account_credit": applyAccountCredit,
|
|
26
|
+
},
|
|
27
|
+
});
|
|
17
28
|
|
|
18
29
|
const server = http.createServer(async (request, response) => {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
return writeJson(response, 404, { status: "failed", safe_error_code: "NOT_FOUND", source_database_mutated: false });
|
|
25
|
-
}
|
|
26
|
-
if (request.headers.authorization !== `Bearer ${expectedToken}`) {
|
|
27
|
-
return writeJson(response, 401, { status: "failed", safe_error_code: "UNAUTHORIZED", source_database_mutated: false });
|
|
28
|
-
}
|
|
29
|
-
const body = await readJson(request);
|
|
30
|
-
const receipt = await applyAccountCredit(body);
|
|
31
|
-
return writeJson(response, 200, receipt);
|
|
32
|
-
} catch (error) {
|
|
33
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
34
|
-
return writeJson(response, 500, {
|
|
30
|
+
if (request.method === "GET" && request.url === "/healthz") {
|
|
31
|
+
return writeJson(response, 200, { ok: true });
|
|
32
|
+
}
|
|
33
|
+
if (request.method !== "POST" || request.url !== "/synapsor/writeback") {
|
|
34
|
+
return writeJson(response, 404, {
|
|
35
35
|
status: "failed",
|
|
36
|
-
|
|
36
|
+
rows_affected: 0,
|
|
37
|
+
safe_error_code: "NOT_FOUND",
|
|
37
38
|
source_database_mutated: false,
|
|
38
|
-
details: { message: message.slice(0, 300) },
|
|
39
39
|
});
|
|
40
40
|
}
|
|
41
|
+
await writebackHandler(request, response);
|
|
41
42
|
});
|
|
42
43
|
|
|
43
44
|
server.listen(port, "127.0.0.1", () => {
|
|
@@ -49,136 +50,60 @@ process.once("SIGTERM", shutdown);
|
|
|
49
50
|
|
|
50
51
|
async function shutdown() {
|
|
51
52
|
server.close();
|
|
52
|
-
await pool.end();
|
|
53
53
|
process.exit(0);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
async function applyAccountCredit(
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
safe_error_code: "UNSUPPORTED_ACTION",
|
|
62
|
-
source_database_mutated: false,
|
|
63
|
-
};
|
|
56
|
+
async function applyAccountCredit(job, tx) {
|
|
57
|
+
const amountCents = Number(job.patch.credit_requested_cents);
|
|
58
|
+
const reason = String(job.patch.credit_reason || "approved account credit");
|
|
59
|
+
if (!Number.isInteger(amountCents) || amountCents <= 0) {
|
|
60
|
+
throw new Error("credit amount must be a positive integer");
|
|
64
61
|
}
|
|
65
62
|
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
63
|
+
const creditId = `CR-${job.proposalId.replace(/[^A-Za-z0-9]/g, "").slice(-12) || Date.now()}`;
|
|
64
|
+
await tx.insert("account_credits", {
|
|
65
|
+
id: creditId,
|
|
66
|
+
tenant_id: job.tenantId,
|
|
67
|
+
invoice_id: job.objectId,
|
|
68
|
+
customer_id: String(job.row.customer_id),
|
|
69
|
+
amount_cents: amountCents,
|
|
70
|
+
reason,
|
|
71
|
+
idempotency_key: job.idempotencyKey,
|
|
72
|
+
created_by: job.principal,
|
|
73
|
+
}, { schema: "public" });
|
|
74
|
+
|
|
75
|
+
const newVersion = new Date().toISOString();
|
|
76
|
+
const update = await tx.update("invoices", {
|
|
77
|
+
id: job.objectId,
|
|
78
|
+
tenant_id: job.tenantId,
|
|
79
|
+
}, {
|
|
80
|
+
credit_requested_cents: amountCents,
|
|
81
|
+
credit_reason: reason,
|
|
82
|
+
credited_cents: Number(job.row.credited_cents ?? 0) + amountCents,
|
|
83
|
+
updated_at: newVersion,
|
|
84
|
+
}, {
|
|
85
|
+
schema: "public",
|
|
86
|
+
returning: ["updated_at"],
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (update.rowCount !== 1) {
|
|
90
|
+
throw new Error("invoice update affected an unexpected number of rows");
|
|
81
91
|
}
|
|
82
92
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
await client.query("COMMIT");
|
|
93
|
-
return {
|
|
94
|
-
status: "already_applied",
|
|
95
|
-
rows_affected: 0,
|
|
96
|
-
source_database_mutated: false,
|
|
97
|
-
details: {
|
|
98
|
-
credit_id: duplicate.rows[0].id,
|
|
99
|
-
},
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const invoiceResult = await client.query(
|
|
104
|
-
"SELECT id, tenant_id, customer_id, updated_at FROM public.invoices WHERE id = $1 AND tenant_id = $2 FOR UPDATE",
|
|
105
|
-
[invoiceId, tenantId],
|
|
106
|
-
);
|
|
107
|
-
const invoice = invoiceResult.rows[0];
|
|
108
|
-
if (!invoice) {
|
|
109
|
-
await client.query("ROLLBACK");
|
|
110
|
-
return {
|
|
111
|
-
status: "conflict",
|
|
112
|
-
rows_affected: 0,
|
|
113
|
-
safe_error_code: "INVOICE_NOT_FOUND_OR_WRONG_TENANT",
|
|
114
|
-
source_database_mutated: false,
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const currentVersion = timestampString(invoice.updated_at);
|
|
119
|
-
const expected = timestampString(expectedVersion);
|
|
120
|
-
if (currentVersion !== expected) {
|
|
121
|
-
await client.query("ROLLBACK");
|
|
122
|
-
return {
|
|
123
|
-
status: "conflict",
|
|
124
|
-
rows_affected: 0,
|
|
125
|
-
previous_version: currentVersion,
|
|
126
|
-
safe_error_code: "ROW_CHANGED_AFTER_PROPOSAL",
|
|
127
|
-
source_database_mutated: false,
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const creditId = `CR-${proposalId.replace(/[^A-Za-z0-9]/g, "").slice(-12) || Date.now()}`;
|
|
132
|
-
await client.query(
|
|
133
|
-
`INSERT INTO public.account_credits
|
|
134
|
-
(id, tenant_id, invoice_id, customer_id, amount_cents, reason, idempotency_key, created_by)
|
|
135
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
136
|
-
[creditId, tenantId, invoiceId, invoice.customer_id, amountCents, reason, idempotencyKey, principal],
|
|
137
|
-
);
|
|
138
|
-
const update = await client.query(
|
|
139
|
-
`UPDATE public.invoices
|
|
140
|
-
SET credit_requested_cents = $1,
|
|
141
|
-
credit_reason = $2,
|
|
142
|
-
credited_cents = credited_cents + $1,
|
|
143
|
-
updated_at = now()
|
|
144
|
-
WHERE id = $3 AND tenant_id = $4
|
|
145
|
-
RETURNING updated_at`,
|
|
146
|
-
[amountCents, reason, invoiceId, tenantId],
|
|
147
|
-
);
|
|
148
|
-
await client.query("COMMIT");
|
|
149
|
-
|
|
150
|
-
return {
|
|
151
|
-
status: "applied",
|
|
152
|
-
rows_affected: 2,
|
|
153
|
-
previous_version: currentVersion,
|
|
154
|
-
new_version: timestampString(update.rows[0]?.updated_at),
|
|
155
|
-
source_database_mutated: true,
|
|
156
|
-
details: {
|
|
157
|
-
effects: [
|
|
158
|
-
{ type: "db.insert", table: "account_credits", id: creditId },
|
|
159
|
-
{ type: "db.update", table: "invoices", id: invoiceId },
|
|
160
|
-
{ type: "event", name: "billing.account_credit_created" },
|
|
161
|
-
],
|
|
162
|
-
},
|
|
163
|
-
};
|
|
164
|
-
} catch (error) {
|
|
165
|
-
await client.query("ROLLBACK").catch(() => {});
|
|
166
|
-
throw error;
|
|
167
|
-
} finally {
|
|
168
|
-
client.release();
|
|
169
|
-
}
|
|
93
|
+
return {
|
|
94
|
+
rowsAffected: 2,
|
|
95
|
+
newVersion: timestampString(update.rows[0]?.updated_at ?? newVersion),
|
|
96
|
+
effects: [
|
|
97
|
+
{ type: "db.insert", table: "account_credits", id: creditId },
|
|
98
|
+
{ type: "db.update", table: "invoices", id: job.objectId },
|
|
99
|
+
{ type: "event", name: "billing.account_credit_created" },
|
|
100
|
+
],
|
|
101
|
+
};
|
|
170
102
|
}
|
|
171
103
|
|
|
172
104
|
function timestampString(value) {
|
|
173
|
-
if (value instanceof Date) return value.toISOString()
|
|
174
|
-
return new Date(String(value)).toISOString()
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
async function readJson(request) {
|
|
178
|
-
const chunks = [];
|
|
179
|
-
for await (const chunk of request) chunks.push(Buffer.from(chunk));
|
|
180
|
-
const text = Buffer.concat(chunks).toString("utf8");
|
|
181
|
-
return text ? JSON.parse(text) : {};
|
|
105
|
+
if (value instanceof Date) return value.toISOString();
|
|
106
|
+
return new Date(String(value)).toISOString();
|
|
182
107
|
}
|
|
183
108
|
|
|
184
109
|
function writeJson(response, statusCode, body) {
|
|
@@ -187,11 +112,14 @@ function writeJson(response, statusCode, body) {
|
|
|
187
112
|
response.end(`${JSON.stringify(body, null, 2)}\n`);
|
|
188
113
|
}
|
|
189
114
|
|
|
190
|
-
async function
|
|
115
|
+
async function loadHandlerHelper() {
|
|
191
116
|
try {
|
|
192
|
-
return await import("
|
|
193
|
-
} catch {
|
|
194
|
-
|
|
195
|
-
|
|
117
|
+
return await import("@synapsor/handler");
|
|
118
|
+
} catch (workspaceError) {
|
|
119
|
+
try {
|
|
120
|
+
return await import(new URL("./synapsor-handler.mjs", import.meta.url));
|
|
121
|
+
} catch {
|
|
122
|
+
throw workspaceError;
|
|
123
|
+
}
|
|
196
124
|
}
|
|
197
125
|
}
|
|
@@ -45,6 +45,7 @@ export BILLING_APP_READ_URL="postgresql://synapsor_reader:synapsor_reader_passwo
|
|
|
45
45
|
export BILLING_APP_WRITE_URL="postgresql://synapsor_writer:synapsor_writer_password@localhost:55437/synapsor_billing_app_handler"
|
|
46
46
|
export BILLING_APP_HANDLER_URL="http://127.0.0.1:8787/synapsor/writeback"
|
|
47
47
|
export BILLING_APP_HANDLER_TOKEN="dev-handler-token"
|
|
48
|
+
export BILLING_APP_HANDLER_SIGNING_SECRET="dev-handler-signing-secret"
|
|
48
49
|
export SYNAPSOR_TENANT_ID="acme"
|
|
49
50
|
export SYNAPSOR_PRINCIPAL="local_billing_operator"
|
|
50
51
|
|