@spendctl/proxy 0.0.0-stage → 0.2.0
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/LICENSE +83 -0
- package/README.md +10 -2
- package/THIRD_PARTY_NOTICES +3771 -0
- package/dist/dashboard.d.ts +6 -0
- package/dist/dashboard.d.ts.map +1 -0
- package/dist/dashboard.js +162 -0
- package/dist/dashboard.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/node.d.ts +14 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +112 -0
- package/dist/node.js.map +1 -0
- package/dist/sidecar.d.ts +90 -0
- package/dist/sidecar.d.ts.map +1 -0
- package/dist/sidecar.js +759 -0
- package/dist/sidecar.js.map +1 -0
- package/dist/signers.d.ts +20 -0
- package/dist/signers.d.ts.map +1 -0
- package/dist/signers.js +58 -0
- package/dist/signers.js.map +1 -0
- package/dist/webhooks.d.ts +72 -0
- package/dist/webhooks.d.ts.map +1 -0
- package/dist/webhooks.js +65 -0
- package/dist/webhooks.js.map +1 -0
- package/package.json +31 -3
package/dist/sidecar.js
ADDED
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { hasMppCredential, parseMpp, parseMppReceipt, signMpp, } from "@spendctl/adapter-mpp";
|
|
3
|
+
import { parseX402, parseX402Settlement, signX402, X402_PAYMENT_HEADERS, } from "@spendctl/adapter-x402";
|
|
4
|
+
import { ApprovalBroker, buildTrace, childBaggage, evaluate, FREE_PLAN, formatUsd, hostLabel, isSandbox, monthStartUtc, newId, nextMonthStartUtc, PRICING_URL, planUsage, renderTrace, TIER_LABELS, t, targetFromUrl, toCsv, toExportRow, toJson, traceFromHeaders, WINDOWS, } from "@spendctl/core";
|
|
5
|
+
import { dashboardHtml, reviewHtml } from "./dashboard.js";
|
|
6
|
+
import { sendWebhooks } from "./webhooks.js";
|
|
7
|
+
const HOP_BY_HOP = new Set([
|
|
8
|
+
"connection",
|
|
9
|
+
"keep-alive",
|
|
10
|
+
"proxy-authorization",
|
|
11
|
+
"proxy-authenticate",
|
|
12
|
+
"proxy-connection",
|
|
13
|
+
"te",
|
|
14
|
+
"trailer",
|
|
15
|
+
"transfer-encoding",
|
|
16
|
+
"upgrade",
|
|
17
|
+
"host",
|
|
18
|
+
"content-length",
|
|
19
|
+
]);
|
|
20
|
+
const MAX_BODY_BYTES = 10 * 1024 * 1024;
|
|
21
|
+
function json(body, status = 200, headers = {}) {
|
|
22
|
+
return new Response(`${JSON.stringify(body, (_, v) => (typeof v === "bigint" ? v.toString() : v), 2)}\n`, {
|
|
23
|
+
status,
|
|
24
|
+
headers: { "content-type": "application/json; charset=utf-8", ...headers },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
function safeEqual(a, b) {
|
|
28
|
+
const left = Buffer.from(a);
|
|
29
|
+
const right = Buffer.from(b);
|
|
30
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
31
|
+
}
|
|
32
|
+
function statusForOutcome(decision) {
|
|
33
|
+
if (decision.outcome === "deny")
|
|
34
|
+
return "denied";
|
|
35
|
+
if (decision.outcome === "approve")
|
|
36
|
+
return "pending_approval";
|
|
37
|
+
return "reserved";
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The spendctl sidecar. `handle()` is a Fetch-API handler, so the same logic runs
|
|
41
|
+
* behind node:http (see `listen()`), Bun.serve, or a Workers-style runtime.
|
|
42
|
+
*/
|
|
43
|
+
export class Sidecar {
|
|
44
|
+
options;
|
|
45
|
+
broker;
|
|
46
|
+
circuits = new Map();
|
|
47
|
+
upstreamFetch;
|
|
48
|
+
/** `<month>:80` / `<month>:full` once each, so the log says it once. */
|
|
49
|
+
planWarnings = new Set();
|
|
50
|
+
constructor(options) {
|
|
51
|
+
this.options = options;
|
|
52
|
+
this.upstreamFetch = options.fetch ?? fetch;
|
|
53
|
+
this.broker = new ApprovalBroker(options.ledger, {
|
|
54
|
+
timeoutMs: options.config.approvals.timeoutMs,
|
|
55
|
+
notify: (approval, attempt) => {
|
|
56
|
+
this.emit({ type: "approval", approval, attempt, message: `approval ${approval.id} requested` });
|
|
57
|
+
if (options.config.approvals.webhooks.length === 0)
|
|
58
|
+
return;
|
|
59
|
+
return sendWebhooks(options.config.approvals.webhooks, approval, attempt, {
|
|
60
|
+
review: this.reviewLink(approval.id),
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
get config() {
|
|
66
|
+
return this.options.config;
|
|
67
|
+
}
|
|
68
|
+
get ledger() {
|
|
69
|
+
return this.options.ledger;
|
|
70
|
+
}
|
|
71
|
+
get plan() {
|
|
72
|
+
return this.options.plan ?? FREE_PLAN;
|
|
73
|
+
}
|
|
74
|
+
/** This month's governed-event count against the plan (for the CLI, the dashboard and the admin API). */
|
|
75
|
+
usage(now = Date.now()) {
|
|
76
|
+
return planUsage(this.plan, this.ledger, { now, sandbox: isSandbox(this.config) });
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Free-tier hard stop (docs/PLANS.md): once the month's cap is reached, refuse before policy
|
|
80
|
+
* evaluation. Runs inside the ledger transaction so two requests cannot both pass as the last one.
|
|
81
|
+
* Nothing applies under the mock signer, and dry runs are not gated (they never count either).
|
|
82
|
+
*/
|
|
83
|
+
planGate(now) {
|
|
84
|
+
const limit = this.plan.limits.eventsPerMonth;
|
|
85
|
+
if (limit === null || isSandbox(this.config))
|
|
86
|
+
return undefined;
|
|
87
|
+
const since = monthStartUtc(now);
|
|
88
|
+
const used = this.ledger.countGovernedSince(since);
|
|
89
|
+
this.warnPlan(used, limit, since);
|
|
90
|
+
if (used < limit)
|
|
91
|
+
return undefined;
|
|
92
|
+
const message = t("plan_events_exceeded", {
|
|
93
|
+
tier: TIER_LABELS[this.plan.tier],
|
|
94
|
+
limit: limit.toLocaleString("en-US"),
|
|
95
|
+
used: used.toLocaleString("en-US"),
|
|
96
|
+
since: new Date(since).toISOString().slice(0, 10),
|
|
97
|
+
resets: new Date(nextMonthStartUtc(now)).toISOString().slice(0, 10),
|
|
98
|
+
url: PRICING_URL,
|
|
99
|
+
});
|
|
100
|
+
return { outcome: "deny", reasons: [{ code: "plan_events_exceeded", outcome: "deny", message }] };
|
|
101
|
+
}
|
|
102
|
+
warnPlan(used, limit, since) {
|
|
103
|
+
const level = used >= limit ? "full" : used >= Math.ceil(limit * 0.8) ? "80" : undefined;
|
|
104
|
+
if (!level)
|
|
105
|
+
return;
|
|
106
|
+
const key = `${new Date(since).toISOString().slice(0, 7)}:${level}`;
|
|
107
|
+
if (this.planWarnings.has(key))
|
|
108
|
+
return;
|
|
109
|
+
this.planWarnings.add(key);
|
|
110
|
+
const label = TIER_LABELS[this.plan.tier];
|
|
111
|
+
this.emit({
|
|
112
|
+
type: "plan",
|
|
113
|
+
message: level === "full"
|
|
114
|
+
? `${label} plan: ${limit.toLocaleString("en-US")} governed payments used this month; new payments are refused until the month rolls over (${PRICING_URL})`
|
|
115
|
+
: `${label} plan: ${used.toLocaleString("en-US")} of ${limit.toLocaleString("en-US")} governed payments used this month`,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
emit(event) {
|
|
119
|
+
try {
|
|
120
|
+
this.options.onEvent?.(event);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// logging must never break payments
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
baseUrl() {
|
|
127
|
+
return (this.config.approvals.publicUrl ??
|
|
128
|
+
this.options.baseUrl ??
|
|
129
|
+
`http://${this.config.server.host}:${this.config.server.port}`).replace(/\/$/, "");
|
|
130
|
+
}
|
|
131
|
+
/** One-time-ish review token: HMAC of the approval id under the admin token. */
|
|
132
|
+
approvalToken(id) {
|
|
133
|
+
return createHmac("sha256", this.options.adminToken).update(`approval:${id}`).digest("hex").slice(0, 32);
|
|
134
|
+
}
|
|
135
|
+
reviewLink(id) {
|
|
136
|
+
return `${this.baseUrl()}/_spendctl/review/${id}?t=${this.approvalToken(id)}`;
|
|
137
|
+
}
|
|
138
|
+
// ---------------------------------------------------------------- routing
|
|
139
|
+
async handle(request) {
|
|
140
|
+
const url = new URL(request.url);
|
|
141
|
+
const explicitTarget = request.headers.get("x-spendctl-target");
|
|
142
|
+
if (!explicitTarget && url.pathname.startsWith("/_spendctl/"))
|
|
143
|
+
return this.api(request, url);
|
|
144
|
+
let target;
|
|
145
|
+
try {
|
|
146
|
+
target = new URL(explicitTarget ?? `${url.pathname.slice(1)}${url.search}`);
|
|
147
|
+
if (target.protocol !== "http:" && target.protocol !== "https:")
|
|
148
|
+
throw new Error("bad protocol");
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return json({
|
|
152
|
+
error: "spendctl_bad_target",
|
|
153
|
+
message: "Send requests as http://<sidecar>/<absolute-url>, with an X-Spendctl-Target header, or through HTTP_PROXY.",
|
|
154
|
+
}, 400);
|
|
155
|
+
}
|
|
156
|
+
return this.gateway(request, target);
|
|
157
|
+
}
|
|
158
|
+
identifyAgent(request) {
|
|
159
|
+
let agent = request.headers.get("x-spendctl-agent") ?? undefined;
|
|
160
|
+
let token = request.headers.get("x-spendctl-token") ?? undefined;
|
|
161
|
+
const proxyAuth = request.headers.get("proxy-authorization");
|
|
162
|
+
if (proxyAuth?.toLowerCase().startsWith("basic ")) {
|
|
163
|
+
const decoded = Buffer.from(proxyAuth.slice(6), "base64").toString("utf8");
|
|
164
|
+
const sep = decoded.indexOf(":");
|
|
165
|
+
agent ??= sep === -1 ? decoded : decoded.slice(0, sep);
|
|
166
|
+
token ??= sep === -1 ? undefined : decoded.slice(sep + 1);
|
|
167
|
+
}
|
|
168
|
+
agent ??= this.config.defaultAgent;
|
|
169
|
+
if (!agent)
|
|
170
|
+
return { error: "No agent identity: set the X-Spendctl-Agent header or defaultAgent in spendctl.yaml." };
|
|
171
|
+
if (this.config.agentAuth === "token") {
|
|
172
|
+
const expected = this.options.agentTokens?.[agent];
|
|
173
|
+
if (!expected || !token || !safeEqual(expected, token))
|
|
174
|
+
return { error: `Invalid or missing token for agent "${agent}".` };
|
|
175
|
+
}
|
|
176
|
+
return { agent };
|
|
177
|
+
}
|
|
178
|
+
isAdmin(request) {
|
|
179
|
+
const header = request.headers.get("authorization") ?? "";
|
|
180
|
+
const token = header.toLowerCase().startsWith("bearer ") ? header.slice(7).trim() : "";
|
|
181
|
+
return token.length > 0 && safeEqual(token, this.options.adminToken);
|
|
182
|
+
}
|
|
183
|
+
// ---------------------------------------------------------------- gateway
|
|
184
|
+
async gateway(request, target) {
|
|
185
|
+
const identity = this.identifyAgent(request);
|
|
186
|
+
if (!identity.agent)
|
|
187
|
+
return json({ error: "spendctl_unauthorized", message: identity.error }, 407);
|
|
188
|
+
const agent = identity.agent;
|
|
189
|
+
const suppliedHeader = X402_PAYMENT_HEADERS.find((name) => request.headers.has(name)) ??
|
|
190
|
+
(hasMppCredential(request.headers) ? "Authorization: Payment" : undefined);
|
|
191
|
+
if (suppliedHeader) {
|
|
192
|
+
const reason = {
|
|
193
|
+
code: "agent_supplied_payment",
|
|
194
|
+
outcome: "deny",
|
|
195
|
+
message: t("agent_supplied_payment", { header: suppliedHeader }),
|
|
196
|
+
};
|
|
197
|
+
const attempt = this.recordOnly(agent, request.method, target, traceFromHeaders(request.headers), [reason]);
|
|
198
|
+
return this.refusal(attempt, 403);
|
|
199
|
+
}
|
|
200
|
+
let body;
|
|
201
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
202
|
+
body = await request.arrayBuffer();
|
|
203
|
+
if (body.byteLength > MAX_BODY_BYTES)
|
|
204
|
+
return json({ error: "spendctl_body_too_large" }, 413);
|
|
205
|
+
}
|
|
206
|
+
const trace = traceFromHeaders(request.headers);
|
|
207
|
+
let first;
|
|
208
|
+
try {
|
|
209
|
+
first = await this.forward(request, target, body, {});
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
return json({ error: "spendctl_upstream_error", message: error.message }, 502);
|
|
213
|
+
}
|
|
214
|
+
if (first.status !== 402)
|
|
215
|
+
return this.passthrough(first);
|
|
216
|
+
const finalUrl = first.url ? new URL(first.url) : target;
|
|
217
|
+
const text = await first.text();
|
|
218
|
+
const sources = [];
|
|
219
|
+
const options = [];
|
|
220
|
+
const x402 = parseX402(first.headers, text, this.config.assets);
|
|
221
|
+
if (x402) {
|
|
222
|
+
for (const option of x402.options) {
|
|
223
|
+
if (!this.options.evmSigner && option.signable) {
|
|
224
|
+
option.signable = false;
|
|
225
|
+
option.unsignableReason = "no EVM signer is configured";
|
|
226
|
+
}
|
|
227
|
+
sources.push({ kind: "x402", challenge: x402, local: option.index });
|
|
228
|
+
options.push({ ...option, index: options.length });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const providers = this.options.mppProviders ?? [];
|
|
232
|
+
const mpp = parseMpp(first.headers, providers, this.config.assets);
|
|
233
|
+
if (mpp) {
|
|
234
|
+
for (const option of mpp.options) {
|
|
235
|
+
sources.push({ kind: "mpp", challenge: mpp, local: option.index });
|
|
236
|
+
options.push({ ...option, index: options.length });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (!x402 && !mpp) {
|
|
240
|
+
// Not a payment protocol we know: hand the 402 back untouched.
|
|
241
|
+
return this.passthrough(new Response(text, { status: 402, headers: first.headers }));
|
|
242
|
+
}
|
|
243
|
+
const challengeResponse = new Response(text, { status: 402, headers: first.headers });
|
|
244
|
+
return this.pay({ request, agent, target: finalUrl, body, trace, options, sources, challengeResponse });
|
|
245
|
+
}
|
|
246
|
+
async pay(ctx) {
|
|
247
|
+
const { agent, target, trace, options } = ctx;
|
|
248
|
+
const policy = this.config.agents[agent];
|
|
249
|
+
// An agent may ask for a dry run of a single request (it can only reduce what happens).
|
|
250
|
+
const dryRun = this.options.dryRun || policy?.mode === "dry-run" || ctx.request.headers.get("x-spendctl-dry-run") === "1";
|
|
251
|
+
const tgt = targetFromUrl(target);
|
|
252
|
+
const host = hostLabel(tgt);
|
|
253
|
+
const idempotencyKey = ctx.request.headers.get("idempotency-key") ?? undefined;
|
|
254
|
+
const approvalHeader = ctx.request.headers.get("x-spendctl-approval") ?? undefined;
|
|
255
|
+
const now = Date.now();
|
|
256
|
+
const attemptId = newId("pay");
|
|
257
|
+
let carriedApproval;
|
|
258
|
+
const decided = this.ledger.transaction(() => {
|
|
259
|
+
// Re-using a human approval from an earlier, non-held request. Checked under
|
|
260
|
+
// the lock so that one approval pays for exactly one retry.
|
|
261
|
+
let waived = new Set();
|
|
262
|
+
if (approvalHeader) {
|
|
263
|
+
const approval = this.ledger.getApproval(approvalHeader);
|
|
264
|
+
if (approval &&
|
|
265
|
+
approval.status === "approved" &&
|
|
266
|
+
approval.agent === agent &&
|
|
267
|
+
approval.host === host &&
|
|
268
|
+
approval.path === tgt.path) {
|
|
269
|
+
const original = this.ledger.getAttempt(approval.attemptId);
|
|
270
|
+
if (original && original.status === "expired" && this.ledger.attemptsForApproval(approval.id) <= 1) {
|
|
271
|
+
carriedApproval = approval;
|
|
272
|
+
waived = new Set(approval.reasons.map((reason) => reason.code));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
let decision = (dryRun ? undefined : this.planGate(now)) ??
|
|
277
|
+
evaluate({
|
|
278
|
+
agentName: agent,
|
|
279
|
+
agent: policy,
|
|
280
|
+
target: tgt,
|
|
281
|
+
options,
|
|
282
|
+
trace,
|
|
283
|
+
now,
|
|
284
|
+
stats: this.ledger,
|
|
285
|
+
waived,
|
|
286
|
+
circuit: this.circuit(host),
|
|
287
|
+
});
|
|
288
|
+
if (decision.outcome !== "deny" && idempotencyKey) {
|
|
289
|
+
const previous = this.ledger.findByIdempotencyKey(agent, idempotencyKey, now - this.config.idempotencyWindowMs);
|
|
290
|
+
if (previous && previous.host === host && previous.path === tgt.path) {
|
|
291
|
+
decision = {
|
|
292
|
+
outcome: "deny",
|
|
293
|
+
option: decision.option,
|
|
294
|
+
reasons: [
|
|
295
|
+
{
|
|
296
|
+
code: "duplicate_request",
|
|
297
|
+
outcome: "deny",
|
|
298
|
+
message: t("duplicate_request", { key: idempotencyKey, attempt: previous.id }),
|
|
299
|
+
},
|
|
300
|
+
],
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const status = dryRun ? "dry_run" : statusForOutcome(decision);
|
|
305
|
+
const attempt = this.newAttempt(attemptId, agent, ctx.request.method, tgt, trace, decision, status, idempotencyKey);
|
|
306
|
+
if (carriedApproval)
|
|
307
|
+
attempt.approvalId = carriedApproval.id;
|
|
308
|
+
this.ledger.insertAttempt(attempt);
|
|
309
|
+
return { decision, attempt };
|
|
310
|
+
});
|
|
311
|
+
let { decision, attempt } = decided;
|
|
312
|
+
this.emit({ type: "decision", attempt, message: `${attempt.id} ${decision.outcome}` });
|
|
313
|
+
if (dryRun)
|
|
314
|
+
return this.refusal(attempt, 402, { dryRun: true, wouldBe: decision.outcome });
|
|
315
|
+
if (decision.outcome === "deny")
|
|
316
|
+
return this.refusal(attempt, attempt.reasons.some((r) => r.code === "duplicate_request") ? 409 : 402);
|
|
317
|
+
if (decision.outcome === "approve") {
|
|
318
|
+
const approval = this.broker.create(attempt, decision.reasons.filter((reason) => reason.outcome === "approve"));
|
|
319
|
+
this.ledger.updateAttempt(attempt.id, { approvalId: approval.id });
|
|
320
|
+
attempt.approvalId = approval.id;
|
|
321
|
+
if (!this.config.approvals.hold) {
|
|
322
|
+
this.ledger.updateAttempt(attempt.id, { status: "expired" });
|
|
323
|
+
return this.refusal({ ...attempt, status: "pending_approval" }, 402, {
|
|
324
|
+
approval: approval.id,
|
|
325
|
+
hint: `Ask a human to run \`spendctl approve ${approval.id}\`, then retry with header X-Spendctl-Approval: ${approval.id}.`,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
// Hold on the wire for at most `holdMax`: HTTP clients give up on a silent response
|
|
329
|
+
// (Node's fetch after 300 s), and a payment made for a requester that already left is
|
|
330
|
+
// a payment nobody receives.
|
|
331
|
+
const result = await this.broker.wait(approval.id, Math.min(this.config.approvals.holdMaxMs, this.config.approvals.timeoutMs), ctx.request.signal);
|
|
332
|
+
if (ctx.request.signal.aborted || result.status === "pending") {
|
|
333
|
+
// Same contract as retry mode: this attempt is spent, the approval stays as it is, and
|
|
334
|
+
// the agent retries with X-Spendctl-Approval once a human has approved.
|
|
335
|
+
this.ledger.updateAttempt(attempt.id, { status: "expired" });
|
|
336
|
+
const why = ctx.request.signal.aborted
|
|
337
|
+
? `requester disconnected while waiting; approval ${approval.id} is ${result.status}, nothing paid`
|
|
338
|
+
: `still waiting after ${Math.round(this.config.approvals.holdMaxMs / 1000)}s; approval ${approval.id} stays pending`;
|
|
339
|
+
this.emit({ type: "decision", attempt: { ...attempt, status: "expired" }, message: `${attempt.id} ${why}` });
|
|
340
|
+
return this.refusal({ ...attempt, status: "pending_approval" }, 402, {
|
|
341
|
+
approval: approval.id,
|
|
342
|
+
hint: `Ask a human to run \`spendctl approve ${approval.id}\`, then retry with header X-Spendctl-Approval: ${approval.id}.`,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
if (result.status !== "approved") {
|
|
346
|
+
const code = result.status === "expired" ? "approval_timeout" : "approval_denied";
|
|
347
|
+
const reason = {
|
|
348
|
+
code,
|
|
349
|
+
outcome: "deny",
|
|
350
|
+
message: t(code, {
|
|
351
|
+
timeout: `${Math.round(this.config.approvals.timeoutMs / 1000)}s`,
|
|
352
|
+
note: result.note ? ` (${result.note})` : "",
|
|
353
|
+
}),
|
|
354
|
+
};
|
|
355
|
+
const reasons = [reason, ...attempt.reasons];
|
|
356
|
+
this.ledger.updateAttempt(attempt.id, { status: "denied", outcome: "deny", reasons });
|
|
357
|
+
return this.refusal({ ...attempt, status: "denied", outcome: "deny", reasons }, 402);
|
|
358
|
+
}
|
|
359
|
+
// Re-check under the lock: other agents may have spent in the meantime.
|
|
360
|
+
const approvedCodes = new Set(result.reasons.map((reason) => reason.code));
|
|
361
|
+
const recheck = this.ledger.transaction(() => {
|
|
362
|
+
const again = evaluate({
|
|
363
|
+
agentName: agent,
|
|
364
|
+
agent: policy,
|
|
365
|
+
target: tgt,
|
|
366
|
+
options,
|
|
367
|
+
trace,
|
|
368
|
+
now: Date.now(),
|
|
369
|
+
stats: this.ledger,
|
|
370
|
+
waived: approvedCodes,
|
|
371
|
+
circuit: this.circuit(host),
|
|
372
|
+
});
|
|
373
|
+
const status = again.outcome === "allow" ? "reserved" : "denied";
|
|
374
|
+
this.ledger.updateAttempt(attempt.id, {
|
|
375
|
+
status,
|
|
376
|
+
outcome: again.outcome === "allow" ? "approve" : "deny",
|
|
377
|
+
reasons: again.reasons.length ? again.reasons : attempt.reasons,
|
|
378
|
+
});
|
|
379
|
+
return again;
|
|
380
|
+
});
|
|
381
|
+
if (recheck.outcome !== "allow") {
|
|
382
|
+
const refreshed = this.ledger.getAttempt(attempt.id) ?? attempt;
|
|
383
|
+
return this.refusal(refreshed, 402);
|
|
384
|
+
}
|
|
385
|
+
decision = recheck;
|
|
386
|
+
attempt = this.ledger.getAttempt(attempt.id) ?? attempt;
|
|
387
|
+
}
|
|
388
|
+
return this.signAndSend(ctx, attempt, decision.option);
|
|
389
|
+
}
|
|
390
|
+
async signAndSend(ctx, attempt, option) {
|
|
391
|
+
const source = ctx.sources[option.index];
|
|
392
|
+
if (!source)
|
|
393
|
+
throw new Error(`no source for option ${option.index}`);
|
|
394
|
+
let headers;
|
|
395
|
+
try {
|
|
396
|
+
if (source.kind === "x402") {
|
|
397
|
+
if (!this.options.evmSigner)
|
|
398
|
+
throw new Error("no EVM signer configured");
|
|
399
|
+
const local = source.challenge.options[source.local];
|
|
400
|
+
const signed = await signX402(source.challenge, local, this.options.evmSigner, { paymentId: attempt.id });
|
|
401
|
+
headers = signed.headers;
|
|
402
|
+
if (signed.nonce && this.ledger.nonceUsed(signed.nonce))
|
|
403
|
+
throw new Error("nonce collision; refusing to re-use a signature");
|
|
404
|
+
this.ledger.updateAttempt(attempt.id, { nonce: signed.nonce, payer: signed.payer });
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
const local = source.challenge.options[source.local];
|
|
408
|
+
headers = (await signMpp(source.challenge, local, this.options.mppProviders ?? [], ctx.challengeResponse.clone())).headers;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
const message = error.message;
|
|
413
|
+
this.ledger.updateAttempt(attempt.id, { status: "failed", error: `signing failed: ${message}` });
|
|
414
|
+
this.emit({ type: "error", attempt, message });
|
|
415
|
+
return json({ error: "spendctl_signing_failed", attempt: attempt.id, message }, 502);
|
|
416
|
+
}
|
|
417
|
+
headers.baggage = childBaggage(ctx.request.headers.get("baggage"), ctx.trace, attempt.id);
|
|
418
|
+
let paid;
|
|
419
|
+
try {
|
|
420
|
+
paid = await this.forward(ctx.request, ctx.target, ctx.body, headers);
|
|
421
|
+
}
|
|
422
|
+
catch (error) {
|
|
423
|
+
// The signed payment left this process; we cannot know whether it settled.
|
|
424
|
+
this.finish(attempt, "unknown", { error: `paid request failed: ${error.message}` });
|
|
425
|
+
return json({
|
|
426
|
+
error: "spendctl_upstream_error",
|
|
427
|
+
attempt: attempt.id,
|
|
428
|
+
message: `The paid request failed (${error.message}). spendctl will not retry automatically to avoid paying twice.`,
|
|
429
|
+
}, 502);
|
|
430
|
+
}
|
|
431
|
+
if (source.kind === "x402") {
|
|
432
|
+
const settlement = parseX402Settlement(paid.headers);
|
|
433
|
+
if (paid.ok) {
|
|
434
|
+
this.finish(attempt, "settled", {
|
|
435
|
+
txHash: settlement?.transaction,
|
|
436
|
+
settledAtomic: settlement?.amount,
|
|
437
|
+
receipt: settlement?.raw,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
else if (settlement?.pending) {
|
|
441
|
+
this.finish(attempt, "unknown", {
|
|
442
|
+
txHash: settlement.transaction,
|
|
443
|
+
error: "settlement_pending",
|
|
444
|
+
receipt: settlement.raw,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
else if (paid.status === 402 || (settlement && !settlement.success)) {
|
|
448
|
+
this.finish(attempt, "failed", {
|
|
449
|
+
error: settlement?.errorReason ?? "seller rejected the payment",
|
|
450
|
+
receipt: settlement?.raw,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
else {
|
|
454
|
+
this.finish(attempt, "unknown", { error: `seller answered ${paid.status} after payment` });
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
const receipt = parseMppReceipt(paid);
|
|
459
|
+
if (paid.ok)
|
|
460
|
+
this.finish(attempt, "settled", { txHash: receipt?.reference, receipt: receipt?.raw });
|
|
461
|
+
else if (paid.status === 402)
|
|
462
|
+
this.finish(attempt, "failed", { error: "seller rejected the payment" });
|
|
463
|
+
else
|
|
464
|
+
this.finish(attempt, "unknown", { error: `seller answered ${paid.status} after payment` });
|
|
465
|
+
}
|
|
466
|
+
return this.passthrough(paid, attempt.id);
|
|
467
|
+
}
|
|
468
|
+
finish(attempt, status, patch) {
|
|
469
|
+
this.ledger.updateAttempt(attempt.id, { status, ...patch });
|
|
470
|
+
const updated = this.ledger.getAttempt(attempt.id) ?? { ...attempt, status, ...patch };
|
|
471
|
+
const host = attempt.host;
|
|
472
|
+
if (status === "settled") {
|
|
473
|
+
this.circuits.delete(host);
|
|
474
|
+
this.emit({ type: "settled", attempt: updated, message: `${attempt.id} settled` });
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
const state = this.circuits.get(host) ?? { failures: 0, openUntil: 0 };
|
|
478
|
+
state.failures += 1;
|
|
479
|
+
if (state.failures >= this.config.circuitBreaker.failures)
|
|
480
|
+
state.openUntil = Date.now() + this.config.circuitBreaker.cooldownMs;
|
|
481
|
+
this.circuits.set(host, state);
|
|
482
|
+
this.emit({ type: "failed", attempt: updated, message: `${attempt.id} ${status}: ${patch.error ?? ""}` });
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
circuit(host) {
|
|
486
|
+
const state = this.circuits.get(host);
|
|
487
|
+
if (!state)
|
|
488
|
+
return undefined;
|
|
489
|
+
if (state.openUntil && state.openUntil <= Date.now()) {
|
|
490
|
+
// Half-open: allow one more try; a failure re-opens immediately.
|
|
491
|
+
state.openUntil = 0;
|
|
492
|
+
state.failures = this.config.circuitBreaker.failures - 1;
|
|
493
|
+
}
|
|
494
|
+
return { open: state.openUntil > Date.now(), failures: state.failures, until: state.openUntil };
|
|
495
|
+
}
|
|
496
|
+
newAttempt(id, agent, method, target, trace, decision, status, idempotencyKey) {
|
|
497
|
+
const option = decision.option;
|
|
498
|
+
const now = Date.now();
|
|
499
|
+
return {
|
|
500
|
+
id,
|
|
501
|
+
createdAt: now,
|
|
502
|
+
updatedAt: now,
|
|
503
|
+
agent,
|
|
504
|
+
method,
|
|
505
|
+
host: hostLabel(target),
|
|
506
|
+
path: target.path,
|
|
507
|
+
status,
|
|
508
|
+
outcome: decision.outcome,
|
|
509
|
+
reasons: decision.reasons,
|
|
510
|
+
protocol: option?.protocol,
|
|
511
|
+
protocolVersion: option?.protocolVersion,
|
|
512
|
+
scheme: option?.scheme,
|
|
513
|
+
network: option?.network,
|
|
514
|
+
asset: option?.asset,
|
|
515
|
+
assetSymbol: option?.assetSymbol,
|
|
516
|
+
decimals: option?.decimals,
|
|
517
|
+
amountAtomic: option?.amountAtomic,
|
|
518
|
+
usdMicros: option?.usdMicros,
|
|
519
|
+
payTo: option?.payTo,
|
|
520
|
+
idempotencyKey,
|
|
521
|
+
traceRoot: trace.root,
|
|
522
|
+
traceParent: trace.parent,
|
|
523
|
+
depth: trace.depth,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
recordOnly(agent, method, target, trace, reasons) {
|
|
527
|
+
const attempt = this.newAttempt(newId("pay"), agent, method, targetFromUrl(target), trace, { outcome: "deny", reasons }, "denied");
|
|
528
|
+
this.ledger.insertAttempt(attempt);
|
|
529
|
+
this.emit({ type: "decision", attempt, message: `${attempt.id} deny` });
|
|
530
|
+
return attempt;
|
|
531
|
+
}
|
|
532
|
+
refusal(attempt, status, extra = {}) {
|
|
533
|
+
const primary = attempt.reasons[0];
|
|
534
|
+
const amount = attempt.usdMicros !== undefined ? formatUsd(attempt.usdMicros) : undefined;
|
|
535
|
+
const dry = extra.dryRun === true;
|
|
536
|
+
const message = dry
|
|
537
|
+
? `Dry run: spendctl would ${extra.wouldBe === "allow" ? "pay" : extra.wouldBe === "approve" ? "ask a human to approve" : "refuse"} ${amount ?? ""} to ${attempt.host}${attempt.path}.${primary ? ` ${primary.message}` : ""}`
|
|
538
|
+
: attempt.status === "pending_approval"
|
|
539
|
+
? `spendctl needs a human to approve ${amount ?? "this payment"} to ${attempt.host}${attempt.path}: ${primary?.message ?? ""}`
|
|
540
|
+
: `spendctl refused ${amount ? `to pay ${amount} to` : "a payment to"} ${attempt.host}${attempt.path}: ${primary?.message ?? "denied by policy"}`;
|
|
541
|
+
return json({
|
|
542
|
+
error: dry
|
|
543
|
+
? "spendctl_dry_run"
|
|
544
|
+
: attempt.status === "pending_approval"
|
|
545
|
+
? "spendctl_approval_required"
|
|
546
|
+
: "spendctl_denied",
|
|
547
|
+
message,
|
|
548
|
+
attempt: attempt.id,
|
|
549
|
+
decision: attempt.outcome,
|
|
550
|
+
amount,
|
|
551
|
+
reasons: attempt.reasons.map(({ code, message: text }) => ({ code, message: text })),
|
|
552
|
+
...extra,
|
|
553
|
+
}, status, { "x-spendctl-attempt": attempt.id, "x-spendctl-decision": dry ? "dry-run" : attempt.status });
|
|
554
|
+
}
|
|
555
|
+
async forward(request, target, body, extra) {
|
|
556
|
+
const headers = new Headers();
|
|
557
|
+
request.headers.forEach((value, key) => {
|
|
558
|
+
const lower = key.toLowerCase();
|
|
559
|
+
if (HOP_BY_HOP.has(lower) || lower.startsWith("x-spendctl-"))
|
|
560
|
+
return;
|
|
561
|
+
headers.set(key, value);
|
|
562
|
+
});
|
|
563
|
+
for (const [key, value] of Object.entries(extra))
|
|
564
|
+
headers.set(key, value);
|
|
565
|
+
return this.upstreamFetch(target, {
|
|
566
|
+
method: request.method,
|
|
567
|
+
headers,
|
|
568
|
+
body: body && body.byteLength > 0 ? body : undefined,
|
|
569
|
+
redirect: "follow",
|
|
570
|
+
signal: AbortSignal.timeout(this.config.server.upstreamTimeoutMs),
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
passthrough(response, attemptId) {
|
|
574
|
+
const headers = new Headers(response.headers);
|
|
575
|
+
// fetch already decoded the body; the original framing headers no longer apply.
|
|
576
|
+
headers.delete("content-encoding");
|
|
577
|
+
headers.delete("content-length");
|
|
578
|
+
headers.delete("transfer-encoding");
|
|
579
|
+
if (attemptId) {
|
|
580
|
+
headers.set("x-spendctl-attempt", attemptId);
|
|
581
|
+
const attempt = this.ledger.getAttempt(attemptId);
|
|
582
|
+
if (attempt) {
|
|
583
|
+
headers.set("x-spendctl-decision", attempt.status);
|
|
584
|
+
if (attempt.usdMicros !== undefined)
|
|
585
|
+
headers.set("x-spendctl-amount", formatUsd(attempt.usdMicros));
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
|
589
|
+
}
|
|
590
|
+
// ---------------------------------------------------------------- API
|
|
591
|
+
budget(agent) {
|
|
592
|
+
const policy = this.config.agents[agent];
|
|
593
|
+
if (!policy)
|
|
594
|
+
return undefined;
|
|
595
|
+
const now = Date.now();
|
|
596
|
+
const windows = Object.keys(WINDOWS)
|
|
597
|
+
.filter((window) => policy.budget[window] !== undefined)
|
|
598
|
+
.map((window) => {
|
|
599
|
+
const limit = policy.budget[window];
|
|
600
|
+
const spent = this.ledger.spentSince(agent, now - WINDOWS[window]);
|
|
601
|
+
return {
|
|
602
|
+
window,
|
|
603
|
+
limit: formatUsd(limit),
|
|
604
|
+
spent: formatUsd(spent),
|
|
605
|
+
remaining: formatUsd(limit > spent ? limit - spent : 0n),
|
|
606
|
+
};
|
|
607
|
+
});
|
|
608
|
+
return {
|
|
609
|
+
agent,
|
|
610
|
+
mode: this.options.dryRun ? "dry-run" : policy.mode,
|
|
611
|
+
perRequest: policy.budget.perRequest !== undefined ? formatUsd(policy.budget.perRequest) : null,
|
|
612
|
+
requireApprovalAbove: policy.requireApprovalAbove !== undefined ? formatUsd(policy.requireApprovalAbove) : null,
|
|
613
|
+
onBudgetExceeded: policy.onBudgetExceeded,
|
|
614
|
+
allow: policy.allow ?? ["*"],
|
|
615
|
+
windows,
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
async api(request, url) {
|
|
619
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
620
|
+
if (path === "/_spendctl/health")
|
|
621
|
+
return json({ ok: true, version: "0.2.0", plan: this.plan.tier });
|
|
622
|
+
// Browser review page for approval links (token-bound, POST to act).
|
|
623
|
+
const review = /^\/_spendctl\/review\/([\w-]+)$/.exec(path);
|
|
624
|
+
if (review)
|
|
625
|
+
return this.review(request, url, review[1]);
|
|
626
|
+
if (path.startsWith("/_spendctl/agent/")) {
|
|
627
|
+
const identity = this.identifyAgent(request);
|
|
628
|
+
if (!identity.agent)
|
|
629
|
+
return json({ error: "spendctl_unauthorized", message: identity.error }, 401);
|
|
630
|
+
const agent = identity.agent;
|
|
631
|
+
if (path === "/_spendctl/agent/budget") {
|
|
632
|
+
const budget = this.budget(agent);
|
|
633
|
+
return budget ? json(budget) : json({ error: "unknown_agent", message: t("unknown_agent", { agent }) }, 404);
|
|
634
|
+
}
|
|
635
|
+
if (path === "/_spendctl/agent/receipts") {
|
|
636
|
+
const limit = Math.min(Number(url.searchParams.get("limit") ?? 20) || 20, 200);
|
|
637
|
+
return json({ agent, receipts: this.ledger.listAttempts({ agent, limit }).map(toExportRow) });
|
|
638
|
+
}
|
|
639
|
+
const approvalMatch = /^\/_spendctl\/agent\/approvals\/([\w-]+)$/.exec(path);
|
|
640
|
+
if (approvalMatch) {
|
|
641
|
+
const approval = this.ledger.getApproval(approvalMatch[1]);
|
|
642
|
+
if (!approval || approval.agent !== agent)
|
|
643
|
+
return json({ error: "not_found" }, 404);
|
|
644
|
+
return json({
|
|
645
|
+
id: approval.id,
|
|
646
|
+
status: approval.status,
|
|
647
|
+
expiresAt: new Date(approval.expiresAt).toISOString(),
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
return json({ error: "not_found" }, 404);
|
|
651
|
+
}
|
|
652
|
+
if (path === "/_spendctl/dashboard") {
|
|
653
|
+
return new Response(dashboardHtml(), {
|
|
654
|
+
headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
if (!path.startsWith("/_spendctl/admin/"))
|
|
658
|
+
return json({ error: "not_found" }, 404);
|
|
659
|
+
if (!this.isAdmin(request))
|
|
660
|
+
return json({ error: "spendctl_admin_only", message: "Admin token required." }, 401);
|
|
661
|
+
if (path === "/_spendctl/admin/pending") {
|
|
662
|
+
this.broker.expireStale();
|
|
663
|
+
return json({ pending: this.broker.pending() });
|
|
664
|
+
}
|
|
665
|
+
if (path === "/_spendctl/admin/approvals")
|
|
666
|
+
return json({ approvals: this.ledger.listApprovals(undefined, 200) });
|
|
667
|
+
const decide = /^\/_spendctl\/admin\/approvals\/([\w-]+)\/(approve|deny)$/.exec(path);
|
|
668
|
+
if (decide && request.method === "POST") {
|
|
669
|
+
const body = (await request.json().catch(() => ({})));
|
|
670
|
+
return this.decideApproval(decide[1], decide[2], body.by ?? "admin-api", body.note);
|
|
671
|
+
}
|
|
672
|
+
if (path === "/_spendctl/admin/ledger") {
|
|
673
|
+
const limit = Math.min(Number(url.searchParams.get("limit") ?? 100) || 100, 10_000);
|
|
674
|
+
const agent = url.searchParams.get("agent") ?? undefined;
|
|
675
|
+
return json({ attempts: this.ledger.listAttempts({ agent, limit }).map(toExportRow) });
|
|
676
|
+
}
|
|
677
|
+
if (path === "/_spendctl/admin/export") {
|
|
678
|
+
const format = url.searchParams.get("format") === "json" ? "json" : "csv";
|
|
679
|
+
const since = url.searchParams.get("since");
|
|
680
|
+
const attempts = this.ledger.listAttempts({
|
|
681
|
+
agent: url.searchParams.get("agent") ?? undefined,
|
|
682
|
+
since: since ? Date.parse(since) : undefined,
|
|
683
|
+
});
|
|
684
|
+
const ordered = [...attempts].reverse();
|
|
685
|
+
return new Response(format === "json" ? toJson(ordered) : toCsv(ordered), {
|
|
686
|
+
headers: { "content-type": format === "json" ? "application/json" : "text/csv; charset=utf-8" },
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
const traceMatch = /^\/_spendctl\/admin\/trace\/([\w-]+)$/.exec(path);
|
|
690
|
+
if (traceMatch) {
|
|
691
|
+
const id = traceMatch[1];
|
|
692
|
+
const attempt = this.ledger.getAttempt(id);
|
|
693
|
+
if (!attempt)
|
|
694
|
+
return json({ error: "not_found" }, 404);
|
|
695
|
+
const root = attempt.traceRoot ?? attempt.id;
|
|
696
|
+
const tree = buildTrace(root, this.ledger.listAttempts({ traceRoot: root }));
|
|
697
|
+
return json({ root, text: tree ? renderTrace(tree) : "", nodes: tree });
|
|
698
|
+
}
|
|
699
|
+
if (path === "/_spendctl/admin/summary") {
|
|
700
|
+
return json({
|
|
701
|
+
agents: Object.keys(this.config.agents).map((name) => this.budget(name)),
|
|
702
|
+
pending: this.broker.pending().length,
|
|
703
|
+
recent: this.ledger.listAttempts({ limit: 50 }).map(toExportRow),
|
|
704
|
+
dryRun: Boolean(this.options.dryRun),
|
|
705
|
+
plan: this.usage(),
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
const resolve = /^\/_spendctl\/admin\/ledger\/([\w-]+)\/resolve$/.exec(path);
|
|
709
|
+
if (resolve && request.method === "POST") {
|
|
710
|
+
const body = (await request.json().catch(() => ({})));
|
|
711
|
+
if (body.status !== "settled" && body.status !== "failed")
|
|
712
|
+
return json({ error: "status must be settled or failed" }, 400);
|
|
713
|
+
const attempt = this.ledger.getAttempt(resolve[1]);
|
|
714
|
+
if (!attempt)
|
|
715
|
+
return json({ error: "not_found" }, 404);
|
|
716
|
+
if (attempt.status !== "unknown")
|
|
717
|
+
return json({ error: `attempt is ${attempt.status}, only unknown attempts can be resolved` }, 409);
|
|
718
|
+
this.ledger.updateAttempt(attempt.id, { status: body.status, txHash: body.txHash ?? attempt.txHash });
|
|
719
|
+
return json({ attempt: toExportRow(this.ledger.getAttempt(attempt.id)) });
|
|
720
|
+
}
|
|
721
|
+
return json({ error: "not_found" }, 404);
|
|
722
|
+
}
|
|
723
|
+
decideApproval(id, action, by, note) {
|
|
724
|
+
const existing = this.ledger.getApproval(id);
|
|
725
|
+
if (!existing)
|
|
726
|
+
return json({ error: "not_found", message: `No approval ${id}` }, 404);
|
|
727
|
+
if (existing.status !== "pending")
|
|
728
|
+
return json({ error: "already_decided", approval: existing }, 409);
|
|
729
|
+
if (existing.expiresAt <= Date.now()) {
|
|
730
|
+
const expired = this.broker.decide(id, "expired", "timeout");
|
|
731
|
+
return json({ error: "expired", approval: expired }, 409);
|
|
732
|
+
}
|
|
733
|
+
const approval = this.broker.decide(id, action === "approve" ? "approved" : "denied", by, note);
|
|
734
|
+
return json({ approval });
|
|
735
|
+
}
|
|
736
|
+
async review(request, url, id) {
|
|
737
|
+
const token = url.searchParams.get("t") ?? "";
|
|
738
|
+
if (!safeEqual(token, this.approvalToken(id)))
|
|
739
|
+
return json({ error: "invalid_link" }, 403);
|
|
740
|
+
const approval = this.ledger.getApproval(id);
|
|
741
|
+
if (!approval)
|
|
742
|
+
return json({ error: "not_found" }, 404);
|
|
743
|
+
if (request.method === "POST") {
|
|
744
|
+
const form = await request.formData().catch(() => undefined);
|
|
745
|
+
const action = form?.get("action") === "approve" ? "approve" : "deny";
|
|
746
|
+
const response = this.decideApproval(id, action, "review-link");
|
|
747
|
+
const decided = this.ledger.getApproval(id) ?? approval;
|
|
748
|
+
return new Response(reviewHtml(decided, this.ledger.getAttempt(decided.attemptId), url.toString(), response.status !== 200), {
|
|
749
|
+
status: response.status === 200 ? 200 : 409,
|
|
750
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
// GET never changes state (chat apps prefetch links).
|
|
754
|
+
return new Response(reviewHtml(approval, this.ledger.getAttempt(approval.attemptId), url.toString(), false), {
|
|
755
|
+
headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
//# sourceMappingURL=sidecar.js.map
|