@suluk/cost 0.1.1 → 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/README.md +204 -0
- package/package.json +19 -8
- package/src/contract.ts +76 -28
- package/src/event.ts +112 -0
- package/src/index.ts +16 -0
- package/src/settlement.ts +93 -0
- package/src/types.ts +83 -0
- package/test/event.test.ts +59 -0
- package/test/jobs.test.ts +39 -0
- package/test/reconciliation.test.ts +42 -0
- package/test/settlement.test.ts +91 -0
- package/test/trigger.test.ts +80 -0
package/README.md
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://github.com/MahmoodKhalil57/suluk">
|
|
3
|
+
<img src="https://raw.githubusercontent.com/MahmoodKhalil57/suluk/main/branding/export/wordmark.png" alt="Suluk" width="360" />
|
|
4
|
+
</a>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<h1 align="center">@suluk/cost</h1>
|
|
8
|
+
|
|
9
|
+
<p align="center"><b>Cost as a contract facet, plus a runtime meter for what every request actually cost — traced from the frontend action down to each third party.</b></p>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<em>Part of <a href="https://github.com/MahmoodKhalil57/suluk">Suluk</a> — one typed OpenAPI v4 contract projecting into every full-stack layer.</em>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
> **CANDIDATE tooling — not official OpenAPI.** Suluk is a single-contributor candidate for
|
|
18
|
+
> OpenAPI Specification v4.0 ("Moonwalk"), unaffiliated with the OpenAPI Initiative and unable
|
|
19
|
+
> to ratify anything on the SIG's behalf.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
bun add @suluk/cost
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`hono` is an **optional** peer dependency — needed only for the request-time middleware (`costMeter`,
|
|
28
|
+
`recordUsage`). The contract, event, and ledger helpers have no runtime deps beyond `@suluk/core`.
|
|
29
|
+
|
|
30
|
+
## What it does
|
|
31
|
+
|
|
32
|
+
You can't price a user without knowing what they cost you. `@suluk/cost` makes cost a first-class,
|
|
33
|
+
declared part of the contract — and then meters the real thing:
|
|
34
|
+
|
|
35
|
+
- **Declare cost on the contract.** Attach a `CostModel` to each operation as the `x-suluk-cost`
|
|
36
|
+
vendor extension. Like every Suluk facet it bubbles: it survives the 3.1 downgrade (3.1 keeps
|
|
37
|
+
`x-*`), Scalar/Swagger render it, and a coverage **audit** can flag operations that never declared
|
|
38
|
+
a cost (unknown ≠ assumed zero).
|
|
39
|
+
- **Meter the actual cost per request.** A Hono middleware records what each request *did* cost —
|
|
40
|
+
fixed (per-call) components plus metered third-party usage the handler reported — attributed all the
|
|
41
|
+
way down: frontend action → operation → per-source breakdown.
|
|
42
|
+
- **Bill background events too.** A fired webhook / cron tick / queue message has no live caller, so a
|
|
43
|
+
separate Context-free path resolves **who pays** (a runtime attribution expression), dedupes
|
|
44
|
+
at-least-once delivery, and reconciles the declared estimate against the third party's *actual*
|
|
45
|
+
charge read from the event payload.
|
|
46
|
+
- **Read the raw ledger.** Aggregate cost events by principal, operation, action, and source. All money
|
|
47
|
+
is integer **micro-USD** (1 USD = 1,000,000 µ$) — the rawest representation. We display it as-is and
|
|
48
|
+
let you build pricing on top.
|
|
49
|
+
|
|
50
|
+
## When to reach for it
|
|
51
|
+
|
|
52
|
+
- Any metered or usage-priced API — anything where a single user can cost you real money (LLM tokens,
|
|
53
|
+
egress, downstream third-party calls) and you need the per-user picture to price them.
|
|
54
|
+
- When you want cost to be *auditable* in CI: `costAudit` is the coverage gate (every money-moving op
|
|
55
|
+
must declare what it costs).
|
|
56
|
+
- Pairs with **`@suluk/stripe`** for the billing side — `@suluk/cost` produces the raw ledger; Stripe
|
|
57
|
+
turns it into invoices/metered subscriptions. This package never imposes pricing, margins, or limits.
|
|
58
|
+
|
|
59
|
+
## Usage
|
|
60
|
+
|
|
61
|
+
### 1. Declare cost as a contract facet
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { annotateCosts, costAudit, costTable, type CostModel } from "@suluk/cost";
|
|
65
|
+
import { emitV4 } from "@suluk/hono";
|
|
66
|
+
|
|
67
|
+
const ask: CostModel = {
|
|
68
|
+
components: [
|
|
69
|
+
{ source: "compute", basis: "per-call", microUsd: 50 },
|
|
70
|
+
{ source: "openai", basis: "per-1k-tokens", microUsd: 2000, description: "$0.002 / 1k tokens" },
|
|
71
|
+
],
|
|
72
|
+
estimateMicroUsd: 1050, // typical total for display/tests before usage is known
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const { document } = emitV4(/* operations… */);
|
|
76
|
+
|
|
77
|
+
// Set x-suluk-cost on each named operation (returns a new doc; covers paths + webhooks).
|
|
78
|
+
const annotated = annotateCosts(document, { ask });
|
|
79
|
+
|
|
80
|
+
// Coverage audit — which operations never declared a cost (warns), plus background-cost disciplines.
|
|
81
|
+
for (const f of costAudit(annotated)) console.warn(f.code, f.operation, f.message);
|
|
82
|
+
|
|
83
|
+
// The declared costs, raw, for an admin/cockpit table.
|
|
84
|
+
console.table(costTable(annotated)); // [{ operation, path, estimateMicroUsd, sources, trigger }]
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### 2. Meter the actual cost at runtime (Hono)
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { costMeter, recordUsage, MemoryCostSink } from "@suluk/cost";
|
|
91
|
+
import { Hono } from "hono";
|
|
92
|
+
|
|
93
|
+
const sink = new MemoryCostSink(); // swap in D1 / a queue in production
|
|
94
|
+
const app = new Hono<{ Variables: { operation: string; principal: string } }>();
|
|
95
|
+
|
|
96
|
+
app.use("*", costMeter({
|
|
97
|
+
sink,
|
|
98
|
+
costs: { ask }, // operation name → declared CostModel
|
|
99
|
+
operationOf: (c) => c.get("operation"), // resolve the op name for this request
|
|
100
|
+
principalOf: (c) => c.get("principal"), // resolve the user id (optional)
|
|
101
|
+
// actionHeader defaults to "x-suluk-action"; now defaults to () => Date.now()
|
|
102
|
+
}));
|
|
103
|
+
|
|
104
|
+
app.post("/ask", (c) => {
|
|
105
|
+
recordUsage(c, "openai", 2000); // report MEASURED third-party usage for THIS request
|
|
106
|
+
return c.json({ answer: "42" });
|
|
107
|
+
});
|
|
108
|
+
// → records a CostEvent: { operation: "ask", principal, action, breakdown, totalMicroUsd }
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`computeCost(model, usage)` is the pure core the meter uses — call it directly to get the
|
|
112
|
+
`{ breakdown, totalMicroUsd }` for a model + measured usage, e.g. for previews or tests.
|
|
113
|
+
|
|
114
|
+
### 3. Bill a background event (webhook / cron / queue)
|
|
115
|
+
|
|
116
|
+
A fired event has no Hono `Context`, so it gets a Context-free path. The model declares **when** it
|
|
117
|
+
fires (`trigger`), **who pays** (`attribution`), and how it **reconciles** with the actual charge:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import { recordEventCost, type CostModel } from "@suluk/cost";
|
|
121
|
+
|
|
122
|
+
// Stripe fires payment_intent.succeeded → it charged you, attributed to the customer.
|
|
123
|
+
const chargeModel: CostModel = {
|
|
124
|
+
components: [{ source: "stripe", basis: "per-call", microUsd: 2900 }],
|
|
125
|
+
trigger: "webhook-received",
|
|
126
|
+
attribution: { strategy: "event-expression", expression: "{$event.body#/customer}", trust: "verified" },
|
|
127
|
+
idempotencyKey: "{$event.id}", // dedupe at-least-once delivery
|
|
128
|
+
reconciliationBasis: "payload-reconciled",
|
|
129
|
+
amountExpression: "{$event.body#/amount}", // read the ACTUAL charge from the payload…
|
|
130
|
+
amountUnit: "cents", // …in cents (Stripe) → ×10_000 into µ$
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const event = { id: "evt_123", type: "payment_intent.succeeded", body: { customer: "cus_42", amount: 2900 } };
|
|
134
|
+
const seen = new Set<string>(); // an in-memory dedupe store for dev; a durable KV/DO in prod
|
|
135
|
+
|
|
136
|
+
// Resolves the principal, dedupes by idempotencyKey, and records — returns null on a redelivery.
|
|
137
|
+
const recorded = await recordEventCost(
|
|
138
|
+
sink,
|
|
139
|
+
{ operation: "stripeCharge", model: chargeModel, event, at: Date.now() },
|
|
140
|
+
seen,
|
|
141
|
+
);
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
> Security: an `event-expression` off an **unverified** payload is attacker-controllable. Gate it
|
|
145
|
+
> behind a verified webhook signature and set `trust: "verified"` — `costAudit` flags
|
|
146
|
+
> `unverified-attribution` otherwise. A cost that resolves no principal bills to the `UNATTRIBUTED`
|
|
147
|
+
> (`@unattributed`) sentinel — fail loud, never silent.
|
|
148
|
+
|
|
149
|
+
### 4. Read the raw ledger
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
import { summarize, principalCost, formatMicroUsd } from "@suluk/cost";
|
|
153
|
+
|
|
154
|
+
const events = sink.events();
|
|
155
|
+
const totals = summarize(events);
|
|
156
|
+
// { total, count, byPrincipal, byOperation, byAction, bySource } — all in µ$
|
|
157
|
+
|
|
158
|
+
console.log("what did user_42 cost me?", formatMicroUsd(principalCost(events, "user_42").total));
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## API
|
|
162
|
+
|
|
163
|
+
| Export | What it does |
|
|
164
|
+
| --- | --- |
|
|
165
|
+
| `annotateCosts(doc, costs)` | Set `x-suluk-cost` on each named operation; returns a new doc. |
|
|
166
|
+
| `costOf(req)` / `triggerOf(model)` / `isDeferredCost(model)` | Read a request's model; its trigger (default `synchronous`); whether the cost is a background event. |
|
|
167
|
+
| `costAudit(doc)` | Coverage + discipline audit → `CostFinding[]` (no-cost-model, zero-cost, unattributed/unverified background cost, reconciliation-incomplete). |
|
|
168
|
+
| `costTable(doc)` | The declared costs (paths + webhooks + jobs) as `CostRow[]` for display. |
|
|
169
|
+
| `eachOperation(doc)` / `eachJob(doc)` | Walk every cost locus — path requests, webhooks, and C025 jobs. |
|
|
170
|
+
| `computeCost(model, usage)` | Pure: `{ breakdown, totalMicroUsd }` from a model + measured usage. |
|
|
171
|
+
| `costMeter(opts)` | Hono middleware that records a `CostEvent` per request. |
|
|
172
|
+
| `recordUsage(c, source, units)` | A handler reports measured third-party usage for the current request. |
|
|
173
|
+
| `MemoryCostSink` / `CostSink` | In-memory sink for dev/tests; the `record(event)` port you implement for prod. |
|
|
174
|
+
| `resolveEventExpression(expr, event)` | Resolve a `{$event.…}` runtime-expression (top-level key or JSON-Pointer) against a fired event. |
|
|
175
|
+
| `attributePrincipal(model, event, supplied?)` | Resolve who pays for a fired event; `@unattributed` when nothing resolves. |
|
|
176
|
+
| `reconciledAmount(model, event)` | The actual charge (µ$) read from the payload when `payload-reconciled`. |
|
|
177
|
+
| `eventCostEvent(input)` / `recordEventCost(sink, input, seen?)` | Build / record a background `CostEvent` (deduped by `idempotencyKey`). |
|
|
178
|
+
| `summarize(events)` / `principalCost(events, principal)` | Aggregate the ledger; one principal's slice. |
|
|
179
|
+
| `formatMicroUsd(µ$)` | Display µ$ as a `$` string (storage stays integer). |
|
|
180
|
+
| `COST_EXT` / `UNATTRIBUTED` | The `x-suluk-cost` extension key; the no-principal sentinel. |
|
|
181
|
+
|
|
182
|
+
The cost-model vocabulary: `CostBasis` (`per-call`, `per-unit`, `per-token`, `per-1k-tokens`,
|
|
183
|
+
`per-second`, `per-request`, `per-mb`), `CostTrigger` (`synchronous`, `webhook-received`, `scheduled`,
|
|
184
|
+
`queue-consumed`, `callback-completed`), `CostAttribution`, and `ReconciliationBasis` — three
|
|
185
|
+
orthogonal axes: `basis` = HOW it meters, `trigger` = WHEN it fires, `attribution` = WHO pays.
|
|
186
|
+
|
|
187
|
+
## Boundary
|
|
188
|
+
|
|
189
|
+
This package **measures and displays** cost — it does not price, charge, or persist. It stays
|
|
190
|
+
honestly raw on purpose:
|
|
191
|
+
|
|
192
|
+
- **Inject the sink.** `costMeter`/`recordEventCost` write `CostEvent`s to a `CostSink` *you* provide.
|
|
193
|
+
`MemoryCostSink` is for dev/tests; production swaps in D1, a queue, or a Durable Object. The
|
|
194
|
+
package never opens a database.
|
|
195
|
+
- **Inject the inputs.** Wall-clock (`now` / `at`), the principal resolver, the dedupe store (`seen`),
|
|
196
|
+
and the operation matcher are all passed in — so events are reproducible and testable, and nothing
|
|
197
|
+
reads ambient state.
|
|
198
|
+
- **Pricing lives downstream.** Margins, plans, limits, and invoices are the consumer's to build —
|
|
199
|
+
typically via **`@suluk/stripe`**, which turns this ledger into metered billing. `@suluk/cost`
|
|
200
|
+
stops at the raw µ$ picture.
|
|
201
|
+
|
|
202
|
+
## License
|
|
203
|
+
|
|
204
|
+
Apache-2.0
|
package/package.json
CHANGED
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/cost",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Cost as a contract facet: declare per-operation cost (incl. third-party usage), bubble it into the v4 doc/Scalar/tests, and meter the ACTUAL per-user cost at runtime (frontend action -> operation -> third-party). Display as-is. CANDIDATE tooling.",
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"license": "Apache-2.0",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/MahmoodKhalil57/suluk.git",
|
|
12
|
+
"directory": "tooling/ts/packages/cost"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/MahmoodKhalil57/suluk#readme",
|
|
15
|
+
"bugs": "https://github.com/MahmoodKhalil57/suluk/issues",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "src/index.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/index.ts"
|
|
9
20
|
},
|
|
10
21
|
"dependencies": {
|
|
11
|
-
"@suluk/core": "0.1.
|
|
22
|
+
"@suluk/core": "^0.1.13"
|
|
12
23
|
},
|
|
13
24
|
"peerDependencies": {
|
|
14
25
|
"hono": "^4.0.0"
|
|
@@ -21,8 +32,8 @@
|
|
|
21
32
|
"devDependencies": {
|
|
22
33
|
"@types/bun": "latest",
|
|
23
34
|
"hono": "^4.0.0",
|
|
24
|
-
"@suluk/hono": "0.1.
|
|
25
|
-
"@suluk/openapi-compat": "0.1.
|
|
35
|
+
"@suluk/hono": "^0.1.5",
|
|
36
|
+
"@suluk/openapi-compat": "^0.1.3",
|
|
26
37
|
"zod": "^4.4.3"
|
|
27
38
|
},
|
|
28
39
|
"scripts": {
|
package/src/contract.ts
CHANGED
|
@@ -4,19 +4,45 @@
|
|
|
4
4
|
* `x-*`), Scalar/Swagger render it, and a coverage audit can flag operations that never declared a cost.
|
|
5
5
|
* The contract tells you what an operation *should* cost; the runtime meter tells you what it *did*.
|
|
6
6
|
*/
|
|
7
|
-
import type { OpenAPIv4Document, PathItem, Request } from "@suluk/core";
|
|
8
|
-
import type
|
|
7
|
+
import type { OpenAPIv4Document, PathItem, Request, SulukJob } from "@suluk/core";
|
|
8
|
+
import { UNATTRIBUTED, type CostModel, type CostTrigger, type UsageReport } from "./types";
|
|
9
9
|
|
|
10
10
|
export const COST_EXT = "x-suluk-cost";
|
|
11
11
|
|
|
12
|
-
/**
|
|
12
|
+
/** Every background JOB (C025) on the document, as {path, name, job} — non-HTTP cron/queue work. */
|
|
13
|
+
export function eachJob(doc: OpenAPIv4Document): { path: string; name: string; job: SulukJob }[] {
|
|
14
|
+
const jobs = (doc as { ["x-suluk-jobs"]?: Record<string, SulukJob> })["x-suluk-jobs"] ?? {};
|
|
15
|
+
return Object.entries(jobs).map(([name, job]) => ({ path: `jobs/${name}`, name, job }));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Every cost LOCUS in the document — path requests, C018 webhooks, AND C025 jobs — with its declared model. */
|
|
19
|
+
function costLoci(doc: OpenAPIv4Document): { path: string; name: string; model: CostModel | undefined }[] {
|
|
20
|
+
const out = eachOperation(doc).map(({ path, name, req }) => ({ path, name, model: costOf(req) }));
|
|
21
|
+
for (const { path, name, job } of eachJob(doc)) out.push({ path, name, model: (job as unknown as Record<string, unknown>)[COST_EXT] as CostModel | undefined });
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Every named operation in the document — path requests AND C018 webhooks (which are Requests carrying facets) —
|
|
27
|
+
* as {path, name, req}. Background-event cost lives on a webhook op, so every cost reader walks this, not just paths.
|
|
28
|
+
*/
|
|
29
|
+
export function eachOperation(doc: OpenAPIv4Document): { path: string; name: string; req: Request }[] {
|
|
30
|
+
const out: { path: string; name: string; req: Request }[] = [];
|
|
31
|
+
for (const [path, piRaw] of Object.entries(doc.paths ?? {})) {
|
|
32
|
+
for (const [name, req] of Object.entries((piRaw as PathItem).requests ?? {})) out.push({ path, name, req: req as Request });
|
|
33
|
+
}
|
|
34
|
+
for (const [name, req] of Object.entries((doc as { webhooks?: Record<string, Request> }).webhooks ?? {})) {
|
|
35
|
+
out.push({ path: `webhooks/${name}`, name, req: req as Request });
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Annotate a v4 document in place-safe (returns a new doc): set x-suluk-cost on each named operation (incl. webhooks). */
|
|
13
41
|
export function annotateCosts(doc: OpenAPIv4Document, costs: Record<string, CostModel>): OpenAPIv4Document {
|
|
14
42
|
const out: OpenAPIv4Document = structuredClone(doc);
|
|
15
|
-
for (const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (model) (req as Request & Record<string, unknown>)[COST_EXT] = model;
|
|
19
|
-
}
|
|
43
|
+
for (const { name, req } of eachOperation(out)) {
|
|
44
|
+
const model = costs[name];
|
|
45
|
+
if (model) (req as Request & Record<string, unknown>)[COST_EXT] = model;
|
|
20
46
|
}
|
|
21
47
|
return out;
|
|
22
48
|
}
|
|
@@ -26,8 +52,18 @@ export function costOf(req: Request): CostModel | undefined {
|
|
|
26
52
|
return (req as Request & Record<string, unknown>)[COST_EXT] as CostModel | undefined;
|
|
27
53
|
}
|
|
28
54
|
|
|
55
|
+
/** The trigger an operation's cost declares (C024; default "synchronous"). */
|
|
56
|
+
export function triggerOf(model: CostModel | undefined): CostTrigger {
|
|
57
|
+
return model?.trigger ?? "synchronous";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Does this cost accrue on a BACKGROUND event (a non-synchronous trigger) rather than the declaring op's own run? */
|
|
61
|
+
export function isDeferredCost(model: CostModel | undefined): boolean {
|
|
62
|
+
return !!model && triggerOf(model) !== "synchronous";
|
|
63
|
+
}
|
|
64
|
+
|
|
29
65
|
export interface CostFinding {
|
|
30
|
-
code: "no-cost-model" | "zero-cost";
|
|
66
|
+
code: "no-cost-model" | "zero-cost" | "unattributed-background-cost" | "unverified-attribution" | "reconciliation-incomplete";
|
|
31
67
|
severity: "warn" | "info";
|
|
32
68
|
path: string;
|
|
33
69
|
operation: string;
|
|
@@ -35,34 +71,46 @@ export interface CostFinding {
|
|
|
35
71
|
}
|
|
36
72
|
|
|
37
73
|
/**
|
|
38
|
-
* Cost-coverage audit: which operations have NOT declared what they cost
|
|
39
|
-
*
|
|
74
|
+
* Cost-coverage audit: which operations have NOT declared what they cost — plus (C024) the background-cost
|
|
75
|
+
* disciplines: a deferred cost that resolves no principal would bill to @unattributed (fail LOUD, never silent),
|
|
76
|
+
* and an attribution read off an UNVERIFIED event payload is attacker-controllable. Walks paths AND webhooks.
|
|
40
77
|
*/
|
|
41
78
|
export function costAudit(doc: OpenAPIv4Document): CostFinding[] {
|
|
42
79
|
const findings: CostFinding[] = [];
|
|
43
|
-
for (const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
80
|
+
for (const { path, name, model } of costLoci(doc)) {
|
|
81
|
+
if (!model) {
|
|
82
|
+
findings.push({ code: "no-cost-model", severity: "warn", path, operation: name, message: "operation declares no cost — its cost to you is unknown (not assumed zero)" });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (!model.components.length) {
|
|
86
|
+
findings.push({ code: "zero-cost", severity: "info", path, operation: name, message: "operation declares an empty cost model (explicitly free)" });
|
|
87
|
+
}
|
|
88
|
+
if (isDeferredCost(model)) {
|
|
89
|
+
const attr = model.attribution;
|
|
90
|
+
const unattributed = !attr || (attr.strategy === "event-expression" && !attr.expression);
|
|
91
|
+
if (unattributed) {
|
|
92
|
+
findings.push({ code: "unattributed-background-cost", severity: "warn", path, operation: name, message: `${triggerOf(model)} cost declares no resolvable principal — it would bill to ${UNATTRIBUTED}` });
|
|
93
|
+
} else if (attr.strategy === "event-expression" && attr.trust !== "verified") {
|
|
94
|
+
findings.push({ code: "unverified-attribution", severity: "warn", path, operation: name, message: "attribution reads an UNVERIFIED event payload (attacker-controllable) — require a verified signature" });
|
|
50
95
|
}
|
|
51
96
|
}
|
|
97
|
+
// C026: a cost that claims to reconcile against the actual charge but can't read it falls back to the estimate.
|
|
98
|
+
if (model.reconciliationBasis === "payload-reconciled" && !model.amountExpression) {
|
|
99
|
+
findings.push({ code: "reconciliation-incomplete", severity: "warn", path, operation: name, message: "declares payload-reconciled but no amountExpression — the actual charge can't be read; it falls back to the estimate" });
|
|
100
|
+
}
|
|
52
101
|
}
|
|
53
102
|
return findings;
|
|
54
103
|
}
|
|
55
104
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
105
|
+
export interface CostRow { operation: string; path: string; estimateMicroUsd: number; sources: string[]; trigger: CostTrigger }
|
|
106
|
+
|
|
107
|
+
/** The declared costs across the document (paths + webhooks + jobs), for display (the cockpit/admin show this raw). */
|
|
108
|
+
export function costTable(doc: OpenAPIv4Document): CostRow[] {
|
|
109
|
+
const rows: CostRow[] = [];
|
|
110
|
+
for (const { path, name, model } of costLoci(doc)) {
|
|
111
|
+
if (!model) continue;
|
|
112
|
+
const fixed = model.components.filter((c) => c.basis === "per-call").reduce((s, c) => s + c.microUsd, 0);
|
|
113
|
+
rows.push({ operation: name, path, estimateMicroUsd: model.estimateMicroUsd ?? fixed, sources: [...new Set(model.components.map((c) => c.source))], trigger: triggerOf(model) });
|
|
66
114
|
}
|
|
67
115
|
return rows;
|
|
68
116
|
}
|
package/src/event.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background-event cost (C024 runtime counterpart). The request meter (meter.ts) is Hono-Context-bound —
|
|
3
|
+
* `principalOf(c: Context)` reads a live caller. A FIRED event (a Stripe webhook, a cron tick, a queue message)
|
|
4
|
+
* has NO such Context, so this is the separate Context-free path the council flagged: given the fired event payload
|
|
5
|
+
* + the operation's declared cost model (with its `trigger` / `attribution` / `idempotencyKey`), it resolves WHO
|
|
6
|
+
* pays (the runtime attribution expression — never the static matcher) and a DEDUPE key, and builds the CostEvent.
|
|
7
|
+
*
|
|
8
|
+
* Pure + reproducible: `at` is passed in, the event payload is the only input, nothing reads ambient state.
|
|
9
|
+
*/
|
|
10
|
+
import { computeCost } from "./contract";
|
|
11
|
+
import { UNATTRIBUTED, type CostModel, type CostEvent, type UsageReport } from "./types";
|
|
12
|
+
import type { CostSink } from "./meter";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolve a C018 runtime-expression against a fired event. Supports `{$event.id}`, `{$event.<key>}`, and a
|
|
16
|
+
* JSON-Pointer tail `{$event.body#/customer}` / `{$event.body#/data/object/customer}`. Returns the stringified
|
|
17
|
+
* value, or undefined when it doesn't resolve. Pure; never throws (an unresolvable expression is undefined, not an error).
|
|
18
|
+
*/
|
|
19
|
+
export function resolveEventExpression(expression: string, event: Record<string, unknown>): string | undefined {
|
|
20
|
+
const m = /^\{\$event\.([^}]+)\}$/.exec(expression.trim());
|
|
21
|
+
if (!m) return undefined;
|
|
22
|
+
const [base, pointer] = m[1].split("#");
|
|
23
|
+
let node: unknown = base ? (event as Record<string, unknown>)[base] : event;
|
|
24
|
+
if (pointer) {
|
|
25
|
+
for (const raw of pointer.split("/").filter(Boolean)) {
|
|
26
|
+
const seg = raw.replace(/~1/g, "/").replace(/~0/g, "~"); // JSON-Pointer unescape
|
|
27
|
+
if (node && typeof node === "object") node = (node as Record<string, unknown>)[seg];
|
|
28
|
+
else return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return node == null ? undefined : String(node);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the principal charged for a fired event per the model's attribution strategy. Returns the `@unattributed`
|
|
36
|
+
* sentinel (never silent) when nothing resolves: `session`/`job-stamped` use the supplied principal; `event-expression`
|
|
37
|
+
* reads it from the payload. NOTE: an `event-expression` with `trust !== "verified"` is attacker-controllable — the
|
|
38
|
+
* caller MUST gate it behind a verified webhook signature before trusting the result for billing.
|
|
39
|
+
*/
|
|
40
|
+
export function attributePrincipal(model: CostModel, event: Record<string, unknown>, suppliedPrincipal?: string): string {
|
|
41
|
+
const attr = model.attribution;
|
|
42
|
+
if (!attr || attr.strategy === "session" || attr.strategy === "job-stamped") return suppliedPrincipal ?? UNATTRIBUTED;
|
|
43
|
+
if (attr.strategy === "event-expression" && attr.expression) return resolveEventExpression(attr.expression, event) ?? UNATTRIBUTED;
|
|
44
|
+
return UNATTRIBUTED;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface EventCostInput {
|
|
48
|
+
/** the operation name whose cost fired (the webhook/op by-name handle). */
|
|
49
|
+
operation: string;
|
|
50
|
+
/** its declared cost model (carrying trigger / attribution / idempotencyKey). */
|
|
51
|
+
model: CostModel;
|
|
52
|
+
/** the fired event payload. */
|
|
53
|
+
event: Record<string, unknown>;
|
|
54
|
+
/** wall-clock ms (passed in — reproducible). */
|
|
55
|
+
at: number;
|
|
56
|
+
/** any metered third-party usage the handler measured. */
|
|
57
|
+
usage?: UsageReport[];
|
|
58
|
+
/** for `session`/`job-stamped` attribution: the principal the job/session carries. */
|
|
59
|
+
suppliedPrincipal?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the ACTUAL charged amount (in µ$) from the event when the model is `payload-reconciled` (C026), else
|
|
64
|
+
* undefined. Reads the runtime amount-expression (e.g. the Stripe event amount) and converts from its declared unit
|
|
65
|
+
* — so the recorded cost is the third party's real invoice line, not the operator's declared estimate.
|
|
66
|
+
*/
|
|
67
|
+
export function reconciledAmount(model: CostModel, event: Record<string, unknown>): number | undefined {
|
|
68
|
+
if (model.reconciliationBasis !== "payload-reconciled" || !model.amountExpression) return undefined;
|
|
69
|
+
const raw = resolveEventExpression(model.amountExpression, event);
|
|
70
|
+
const n = raw == null ? NaN : Number(raw);
|
|
71
|
+
if (!Number.isFinite(n)) return undefined;
|
|
72
|
+
const unit = model.amountUnit ?? "micro-usd";
|
|
73
|
+
return Math.round(unit === "cents" ? n * 10_000 : unit === "usd" ? n * 1_000_000 : n);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Build the CostEvent for a FIRED background event — pure. Stamps the trigger, resolves principal + dedupeKey,
|
|
77
|
+
* and (C026) uses the payload-reconciled amount as the authoritative total when the model declares one. */
|
|
78
|
+
export function eventCostEvent(input: EventCostInput): CostEvent {
|
|
79
|
+
let { breakdown, totalMicroUsd } = computeCost(input.model, input.usage ?? []);
|
|
80
|
+
const reconciled = reconciledAmount(input.model, input.event);
|
|
81
|
+
if (reconciled !== undefined) {
|
|
82
|
+
const source = input.model.components[0]?.source ?? "reconciled";
|
|
83
|
+
breakdown = [{ source, microUsd: reconciled }]; // the actual charge replaces the estimate
|
|
84
|
+
totalMicroUsd = reconciled;
|
|
85
|
+
}
|
|
86
|
+
const dedupeKey = input.model.idempotencyKey ? resolveEventExpression(input.model.idempotencyKey, input.event) : undefined;
|
|
87
|
+
return {
|
|
88
|
+
at: input.at,
|
|
89
|
+
operation: input.operation,
|
|
90
|
+
principal: attributePrincipal(input.model, input.event, input.suppliedPrincipal),
|
|
91
|
+
trigger: input.model.trigger ?? "synchronous",
|
|
92
|
+
...(dedupeKey ? { dedupeKey } : {}),
|
|
93
|
+
...(reconciled !== undefined ? { reconciled: true } : {}),
|
|
94
|
+
breakdown,
|
|
95
|
+
totalMicroUsd,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Record a fired event's cost into a sink, deduped by its `dedupeKey` against `seen` (so at-least-once delivery
|
|
101
|
+
* can't double-charge). Returns the recorded event, or null when it was a duplicate. `seen` is the app's dedup
|
|
102
|
+
* store (an in-memory Set for dev; a durable KV/DO for prod).
|
|
103
|
+
*/
|
|
104
|
+
export async function recordEventCost(sink: CostSink, input: EventCostInput, seen?: Set<string>): Promise<CostEvent | null> {
|
|
105
|
+
const event = eventCostEvent(input);
|
|
106
|
+
if (event.dedupeKey && seen) {
|
|
107
|
+
if (seen.has(event.dedupeKey)) return null; // already recorded — at-least-once delivery
|
|
108
|
+
seen.add(event.dedupeKey);
|
|
109
|
+
}
|
|
110
|
+
await sink.record(event);
|
|
111
|
+
return event;
|
|
112
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -7,11 +7,27 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export {
|
|
9
9
|
type CostBasis, type CostComponent, type CostModel, type UsageReport, type CostEvent, formatMicroUsd,
|
|
10
|
+
// C024 — background-event cost: WHEN it fires (trigger) + WHO pays (attribution), orthogonal to basis (HOW it meters).
|
|
11
|
+
type CostTrigger, type CostAttribution, UNATTRIBUTED,
|
|
12
|
+
// C026 — reconciliation: declared-estimate vs the third party's actual (payload-reconciled) charge.
|
|
13
|
+
type ReconciliationBasis,
|
|
14
|
+
// C044 — settlement: HOW the cost is recovered (credit | rate-limited | free).
|
|
15
|
+
type SettlementMethod, type CostSettlement,
|
|
10
16
|
} from "./types";
|
|
17
|
+
// C044 — settlement audit (every priced op names a lever) + the errors a request's facets imply.
|
|
18
|
+
export {
|
|
19
|
+
settlementOf, settlementAudit, impliedErrorStatuses, settlementRollup,
|
|
20
|
+
type SettlementFinding, type SettlementSeverity, type SettlementRollup,
|
|
21
|
+
} from "./settlement";
|
|
11
22
|
export {
|
|
12
23
|
COST_EXT, annotateCosts, costOf, costAudit, costTable, computeCost, type CostFinding,
|
|
24
|
+
eachOperation, eachJob, triggerOf, isDeferredCost, type CostRow,
|
|
13
25
|
} from "./contract";
|
|
14
26
|
export {
|
|
15
27
|
costMeter, recordUsage, MemoryCostSink, type CostSink, type CostMeterOptions,
|
|
16
28
|
} from "./meter";
|
|
29
|
+
// C024 — the Context-free background-event cost path (a fired webhook/cron/queue event, no live caller).
|
|
30
|
+
export {
|
|
31
|
+
resolveEventExpression, attributePrincipal, eventCostEvent, recordEventCost, reconciledAmount, type EventCostInput,
|
|
32
|
+
} from "./event";
|
|
17
33
|
export { summarize, principalCost, type CostSummary } from "./ledger";
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cost SETTLEMENT (C044) — how a declared cost is RECOVERED from the user. The fifth orthogonal cost axis. Promotes a
|
|
3
|
+
* real cowpath: toolfactory's governance gate already checks "every cost names a lever — credit | rate-limit | free";
|
|
4
|
+
* this makes that a first-class, Suluk-derived facet. Also derives the HTTP errors a request's facets IMPLY (the
|
|
5
|
+
* generic form of toolfactory's errors-gate). Pure functions of the declared facets — never a request value.
|
|
6
|
+
*/
|
|
7
|
+
import type { OpenAPIv4Document, Request } from "@suluk/core";
|
|
8
|
+
import { eachOperation, costOf } from "./contract";
|
|
9
|
+
import type { CostModel, CostSettlement } from "./types";
|
|
10
|
+
|
|
11
|
+
const ext = (req: Request) => req as Request & Record<string, unknown>;
|
|
12
|
+
|
|
13
|
+
/** The settlement declared on an operation's cost. */
|
|
14
|
+
export function settlementOf(req: Request): CostSettlement | undefined {
|
|
15
|
+
return costOf(req)?.settlement;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const isPriced = (cost: CostModel | undefined): boolean =>
|
|
19
|
+
!!cost && (((cost.estimateMicroUsd ?? 0) > 0) || (cost.components ?? []).some((c) => (c.microUsd ?? 0) > 0));
|
|
20
|
+
|
|
21
|
+
export type SettlementSeverity = "high" | "medium" | "low";
|
|
22
|
+
export interface SettlementFinding {
|
|
23
|
+
rule: string;
|
|
24
|
+
severity: SettlementSeverity;
|
|
25
|
+
operation: string;
|
|
26
|
+
path: string;
|
|
27
|
+
message: string;
|
|
28
|
+
fix: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Audit that every PRICED operation names HOW it is settled, and that the named lever is coherent — the generic form of
|
|
33
|
+
* toolfactory's "cost names a lever" governance check.
|
|
34
|
+
*/
|
|
35
|
+
export function settlementAudit(doc: OpenAPIv4Document): SettlementFinding[] {
|
|
36
|
+
const findings: SettlementFinding[] = [];
|
|
37
|
+
for (const { path, name, req } of eachOperation(doc)) {
|
|
38
|
+
const cost = costOf(req);
|
|
39
|
+
const s = cost?.settlement;
|
|
40
|
+
const add = (rule: string, severity: SettlementSeverity, message: string, fix: string) => findings.push({ rule, severity, operation: name, path, message, fix });
|
|
41
|
+
|
|
42
|
+
if (isPriced(cost) && !s) {
|
|
43
|
+
add("cost-without-settlement", "medium", `priced op '${name}' does not name how its cost is paid`, "add x-suluk-cost.settlement: { method: 'credit' | 'rate-limited' | 'free' }");
|
|
44
|
+
}
|
|
45
|
+
if (s?.method === "rate-limited" && !ext(req)["x-suluk-ratelimit"]) {
|
|
46
|
+
add("rate-limited-without-cap", "high", `op '${name}' is settled by rate-limiting but declares no x-suluk-ratelimit — there is no cap to BE the payment`, "add an x-suluk-ratelimit (the free-tier cap), or change settlement.method");
|
|
47
|
+
}
|
|
48
|
+
if (s?.method === "credit" && s.credits == null && !cost?.estimateMicroUsd) {
|
|
49
|
+
add("credit-without-amount", "medium", `op '${name}' is settled by credit but declares neither settlement.credits nor an estimateMicroUsd to debit`, "set settlement.credits (or x-suluk-cost.estimateMicroUsd) so the runtime knows the debit");
|
|
50
|
+
}
|
|
51
|
+
if (s?.method === "free" && isPriced(cost)) {
|
|
52
|
+
add("free-but-priced", "low", `op '${name}' is settled as free yet declares a positive cost — the operator absorbs it`, "confirm intended, or change settlement.method to credit / rate-limited");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return findings;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The HTTP error statuses a request's FACETS imply (the generic form of toolfactory's errors-gate): a contract should
|
|
60
|
+
* declare these responses. credit→402 · authenticated/admin→401 · owner-scope→403 · rate-limit→429 · an upstream
|
|
61
|
+
* third-party call (a `per-request` cost component)→502. A pure function of the declared facets.
|
|
62
|
+
*/
|
|
63
|
+
export function impliedErrorStatuses(req: Request): number[] {
|
|
64
|
+
const out = new Set<number>();
|
|
65
|
+
const cost = costOf(req);
|
|
66
|
+
const access = ext(req)["x-suluk-access"] as { requires?: string; scope?: string } | undefined;
|
|
67
|
+
if (cost?.settlement?.method === "credit") out.add(402);
|
|
68
|
+
if (access?.requires === "authenticated" || access?.requires === "admin") out.add(401);
|
|
69
|
+
if (access?.scope) out.add(403);
|
|
70
|
+
if (ext(req)["x-suluk-ratelimit"]) out.add(429);
|
|
71
|
+
if ((cost?.components ?? []).some((c) => c.basis === "per-request")) out.add(502);
|
|
72
|
+
return [...out].sort((a, b) => a - b);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface SettlementRollup {
|
|
76
|
+
credit: number;
|
|
77
|
+
["rate-limited"]: number;
|
|
78
|
+
free: number;
|
|
79
|
+
/** priced ops with NO settlement declared (the gap). */
|
|
80
|
+
unsettled: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A quick "how is this API monetized" tally — ops grouped by settlement method (+ priced-but-unsettled). */
|
|
84
|
+
export function settlementRollup(doc: OpenAPIv4Document): SettlementRollup {
|
|
85
|
+
const r: SettlementRollup = { credit: 0, "rate-limited": 0, free: 0, unsettled: 0 };
|
|
86
|
+
for (const { req } of eachOperation(doc)) {
|
|
87
|
+
const cost = costOf(req);
|
|
88
|
+
const m = cost?.settlement?.method;
|
|
89
|
+
if (m) r[m]++;
|
|
90
|
+
else if (isPriced(cost)) r.unsettled++;
|
|
91
|
+
}
|
|
92
|
+
return r;
|
|
93
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -25,12 +25,89 @@ export interface CostComponent {
|
|
|
25
25
|
description?: string;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* WHEN/WHAT fires a cost (C024) — a STATIC, locally-decidable enum (the same KIND as {@link CostBasis}). Default
|
|
30
|
+
* "synchronous" ⇒ every existing declaration is unchanged (zero migration). Strictly DESCRIPTIVE: it names where the
|
|
31
|
+
* cost accrues, asserting NO event-channel / delivery-protocol semantics — the fence that keeps it orthogonal to
|
|
32
|
+
* C018's deliberately-deferred async scope. Three axes stay orthogonal: `basis` = HOW it meters, `trigger` = WHEN it
|
|
33
|
+
* fires, `attribution` = WHO pays.
|
|
34
|
+
*/
|
|
35
|
+
export type CostTrigger =
|
|
36
|
+
| "synchronous" // accrues when this operation's own route runs (default; backwards-compatible)
|
|
37
|
+
| "webhook-received" // accrues when an incoming webhook (C018) fires
|
|
38
|
+
| "scheduled" // accrues when a scheduled / cron job runs
|
|
39
|
+
| "queue-consumed" // accrues when a queue consumer processes a message
|
|
40
|
+
| "callback-completed"; // accrues when an out-of-band callback (C018) completes
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* WHO is charged when a third party fires the event with no live session (C024) — a declared STRATEGY the runtime
|
|
44
|
+
* resolves a concrete principal from, modeled on `SulukRateLimit.key`. The `expression` is RUNTIME-ONLY: a C018
|
|
45
|
+
* runtime-expression that NEVER enters the static matcher (D1-consistent, exactly as C018 walls its callback keys).
|
|
46
|
+
*/
|
|
47
|
+
export interface CostAttribution {
|
|
48
|
+
/** session = the live caller (the existing path); event-expression = read the principal from the event payload at
|
|
49
|
+
* runtime; job-stamped = the job carries its own principal. */
|
|
50
|
+
strategy: "session" | "event-expression" | "job-stamped";
|
|
51
|
+
/** for event-expression: a C018 runtime-expression (e.g. "{$event.body#/customer}"). Runtime-resolved only. */
|
|
52
|
+
expression?: string;
|
|
53
|
+
/** is the attribution input authentic? An event-expression off an UNVERIFIED webhook payload is attacker-controlled
|
|
54
|
+
* — honor it as authoritative only when "verified" (a signature/secret check the runtime performs). */
|
|
55
|
+
trust?: "verified" | "unverified-payload";
|
|
56
|
+
}
|
|
57
|
+
|
|
28
58
|
export interface CostModel {
|
|
29
59
|
components: CostComponent[];
|
|
30
60
|
/** Optional typical total for one call (µ$), for display + tests when usage isn't yet known. */
|
|
31
61
|
estimateMicroUsd?: number;
|
|
62
|
+
/** WHEN/WHAT fires this cost (C024; default "synchronous"). STATIC — decouples accrual-time from the declaring op. */
|
|
63
|
+
trigger?: CostTrigger;
|
|
64
|
+
/** the by-name handle (C009) of the webhook/callback/op whose firing accrues this cost (for a non-sync trigger). */
|
|
65
|
+
triggerRef?: string;
|
|
66
|
+
/** WHO is charged when there is no live session (runtime strategy; the expression never enters the static matcher). */
|
|
67
|
+
attribution?: CostAttribution;
|
|
68
|
+
/** a runtime-expression yielding a stable id to DEDUPE at-least-once delivery (e.g. "{$event.id}") — prevents
|
|
69
|
+
* double-counting a cost charged on both the receipt op and the triggered op. Runtime-only. */
|
|
70
|
+
idempotencyKey?: string;
|
|
71
|
+
/**
|
|
72
|
+
* How the amount RECONCILES with the third party's actual charge (C026; default "declared-estimate"). A declared
|
|
73
|
+
* estimate is a guess; "payload-reconciled" reads the ACTUAL charged amount from the event at runtime (the real
|
|
74
|
+
* invoice line — proration/tax/refund included), so the recorded cost is authoritative, not an approximation.
|
|
75
|
+
*/
|
|
76
|
+
reconciliationBasis?: ReconciliationBasis;
|
|
77
|
+
/** for "payload-reconciled": a runtime-expression yielding the ACTUAL amount (e.g. "{$event.body#/amount}").
|
|
78
|
+
* Runtime-only — never the static matcher. Interpreted in `amountUnit`. */
|
|
79
|
+
amountExpression?: string;
|
|
80
|
+
/** the unit `amountExpression` yields (default "micro-usd"). "cents" (Stripe) → ×10_000; "usd" → ×1_000_000. */
|
|
81
|
+
amountUnit?: "micro-usd" | "cents" | "usd";
|
|
82
|
+
/** HOW the operator RECOVERS this cost (C044). The fifth orthogonal axis — basis=how-meters · trigger=when-fires ·
|
|
83
|
+
* attribution=who-pays · reconciliation=declared-vs-actual · **settlement=how-recovered**. */
|
|
84
|
+
settlement?: CostSettlement;
|
|
32
85
|
}
|
|
33
86
|
|
|
87
|
+
/** Whether a cost's amount is a declared guess or read from the event payload at runtime (C026). */
|
|
88
|
+
export type ReconciliationBasis = "declared-estimate" | "payload-reconciled";
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* HOW a declared cost is RECOVERED from the user (C044). `rate-limited` ⇒ free to the user — the cost is "paid" by
|
|
92
|
+
* CAPPING usage, so the op's `x-suluk-ratelimit` IS the settlement (no money moves). `credit` ⇒ the user pays credits
|
|
93
|
+
* (a balance is debited). `free` ⇒ truly free (the operator absorbs any cost). A purely STATIC fact (an enum + an
|
|
94
|
+
* integer + names) — never a request value, so it rides the x-suluk-cost wall (matcher-invisible since C024).
|
|
95
|
+
*/
|
|
96
|
+
export type SettlementMethod = "credit" | "rate-limited" | "free";
|
|
97
|
+
|
|
98
|
+
export interface CostSettlement {
|
|
99
|
+
method: SettlementMethod;
|
|
100
|
+
/** method:"credit" — the credits debited per call (a non-negative integer). Omitted ⇒ derived from
|
|
101
|
+
* `estimateMicroUsd` × the operator's credit rate (a runtime concern, not declared here). */
|
|
102
|
+
credits?: number;
|
|
103
|
+
/** method:"rate-limited" — what happens when the free cap (`x-suluk-ratelimit`) is exhausted: refuse, or fall back
|
|
104
|
+
* to charging credits. Advisory; the runtime enforces it. */
|
|
105
|
+
overflow?: "deny" | "credit";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The principal sentinel for a background cost that resolved to NO principal — billed to nobody, but never silent. */
|
|
109
|
+
export const UNATTRIBUTED = "@unattributed" as const;
|
|
110
|
+
|
|
34
111
|
/** A measured usage report for one variable component during a request (e.g. {source:"openai", units: 1350}). */
|
|
35
112
|
export interface UsageReport {
|
|
36
113
|
source: string;
|
|
@@ -47,6 +124,12 @@ export interface CostEvent {
|
|
|
47
124
|
operation: string;
|
|
48
125
|
/** The frontend action that triggered it (a button-click id), if the client tagged the request. */
|
|
49
126
|
action?: string;
|
|
127
|
+
/** How this cost fired (C024; default "synchronous"). A non-sync value marks a background charge. */
|
|
128
|
+
trigger?: CostTrigger;
|
|
129
|
+
/** Dedupe id for at-least-once event delivery — two events with the same key are the SAME charge (C024). */
|
|
130
|
+
dedupeKey?: string;
|
|
131
|
+
/** true ⇒ totalMicroUsd is the third party's ACTUAL charge read from the event (C026), not a declared estimate. */
|
|
132
|
+
reconciled?: boolean;
|
|
50
133
|
/** Per-source breakdown (µ$). */
|
|
51
134
|
breakdown: { source: string; microUsd: number }[];
|
|
52
135
|
/** Total µ$ for the request. */
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
resolveEventExpression, attributePrincipal, eventCostEvent, recordEventCost,
|
|
4
|
+
MemoryCostSink, UNATTRIBUTED, type CostModel,
|
|
5
|
+
} from "../src/index";
|
|
6
|
+
|
|
7
|
+
// the motivating case: Stripe fires payment_intent.succeeded → it charges you, attributed to the customer.
|
|
8
|
+
const stripeEvent = { id: "evt_123", type: "payment_intent.succeeded", body: { customer: "cus_42", amount: 2900 } };
|
|
9
|
+
const chargeModel: CostModel = {
|
|
10
|
+
components: [{ source: "stripe", basis: "per-call", microUsd: 2900 }],
|
|
11
|
+
trigger: "webhook-received",
|
|
12
|
+
attribution: { strategy: "event-expression", expression: "{$event.body#/customer}", trust: "verified" },
|
|
13
|
+
idempotencyKey: "{$event.id}",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe("C024 runtime — resolve a C018 runtime-expression against a fired event", () => {
|
|
17
|
+
test("body JSON-pointer, top-level key, and an unresolvable expression", () => {
|
|
18
|
+
expect(resolveEventExpression("{$event.body#/customer}", stripeEvent)).toBe("cus_42");
|
|
19
|
+
expect(resolveEventExpression("{$event.id}", stripeEvent)).toBe("evt_123");
|
|
20
|
+
expect(resolveEventExpression("{$event.body#/missing}", stripeEvent)).toBeUndefined();
|
|
21
|
+
expect(resolveEventExpression("not-an-expression", stripeEvent)).toBeUndefined();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("C024 runtime — attribute the principal (fail to @unattributed, never silent)", () => {
|
|
26
|
+
test("event-expression reads the principal from the payload", () => {
|
|
27
|
+
expect(attributePrincipal(chargeModel, stripeEvent)).toBe("cus_42");
|
|
28
|
+
});
|
|
29
|
+
test("session/job-stamped use the supplied principal; missing → @unattributed", () => {
|
|
30
|
+
expect(attributePrincipal({ components: [], attribution: { strategy: "session" } }, stripeEvent, "u1")).toBe("u1");
|
|
31
|
+
expect(attributePrincipal({ components: [], attribution: { strategy: "session" } }, stripeEvent)).toBe(UNATTRIBUTED);
|
|
32
|
+
expect(attributePrincipal({ components: [], attribution: { strategy: "event-expression" } }, stripeEvent)).toBe(UNATTRIBUTED); // no expression
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("C024 runtime — build + record the background CostEvent", () => {
|
|
37
|
+
test("eventCostEvent stamps the trigger, principal, dedupeKey, and the computed total", () => {
|
|
38
|
+
const e = eventCostEvent({ operation: "stripeCharge", model: chargeModel, event: stripeEvent, at: 1000 });
|
|
39
|
+
expect(e).toMatchObject({ operation: "stripeCharge", principal: "cus_42", trigger: "webhook-received", dedupeKey: "evt_123", totalMicroUsd: 2900 });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("recordEventCost dedupes at-least-once delivery (the same event id records ONCE)", async () => {
|
|
43
|
+
const sink = new MemoryCostSink();
|
|
44
|
+
const seen = new Set<string>();
|
|
45
|
+
const a = await recordEventCost(sink, { operation: "stripeCharge", model: chargeModel, event: stripeEvent, at: 1000 }, seen);
|
|
46
|
+
const b = await recordEventCost(sink, { operation: "stripeCharge", model: chargeModel, event: stripeEvent, at: 1001 }, seen); // redelivery
|
|
47
|
+
expect(a).not.toBeNull();
|
|
48
|
+
expect(b).toBeNull(); // deduped — not double-charged
|
|
49
|
+
expect(sink.events()).toHaveLength(1);
|
|
50
|
+
expect(sink.events()[0].principal).toBe("cus_42");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a cost that can't attribute records to @unattributed (visible, not lost)", async () => {
|
|
54
|
+
const sink = new MemoryCostSink();
|
|
55
|
+
const orphan: CostModel = { components: [{ source: "stripe", basis: "per-call", microUsd: 500 }], trigger: "webhook-received" };
|
|
56
|
+
await recordEventCost(sink, { operation: "x", model: orphan, event: stripeEvent, at: 1 });
|
|
57
|
+
expect(sink.events()[0].principal).toBe(UNATTRIBUTED);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { eachJob, costAudit, costTable } from "../src/index";
|
|
3
|
+
import type { OpenAPIv4Document } from "@suluk/core";
|
|
4
|
+
|
|
5
|
+
// C025: cron + queue jobs have NO inbound Request, so they live in the x-suluk-jobs vendor map (not paths/webhooks).
|
|
6
|
+
const doc = {
|
|
7
|
+
openapi: "4.0.0-candidate",
|
|
8
|
+
info: { title: "t", version: "1" },
|
|
9
|
+
paths: {},
|
|
10
|
+
"x-suluk-jobs": {
|
|
11
|
+
nightlyRollup: {
|
|
12
|
+
trigger: "scheduled", schedule: "0 0 * * *",
|
|
13
|
+
"x-suluk-cost": { components: [{ source: "compute", basis: "per-call", microUsd: 500 }], trigger: "scheduled", attribution: { strategy: "job-stamped" } },
|
|
14
|
+
},
|
|
15
|
+
drainEmailQueue: {
|
|
16
|
+
trigger: "queue-consumed", queue: "emails",
|
|
17
|
+
"x-suluk-cost": { components: [{ source: "resend", basis: "per-call", microUsd: 100 }], trigger: "queue-consumed" }, // no attribution
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
} as unknown as OpenAPIv4Document;
|
|
21
|
+
|
|
22
|
+
describe("C025 — x-suluk-jobs: cron/queue cost gets a first-class home", () => {
|
|
23
|
+
test("eachJob enumerates the jobs map", () => {
|
|
24
|
+
expect(eachJob(doc).map((j) => j.name).sort()).toEqual(["drainEmailQueue", "nightlyRollup"]);
|
|
25
|
+
expect(eachJob(doc).find((j) => j.name === "nightlyRollup")?.job.schedule).toBe("0 0 * * *");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("costTable includes job rows with their non-synchronous trigger", () => {
|
|
29
|
+
const rows = costTable(doc);
|
|
30
|
+
expect(rows.find((r) => r.operation === "nightlyRollup")).toMatchObject({ path: "jobs/nightlyRollup", trigger: "scheduled", estimateMicroUsd: 500 });
|
|
31
|
+
expect(rows.find((r) => r.operation === "drainEmailQueue")?.trigger).toBe("queue-consumed");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("costAudit applies the same fail-loud discipline to jobs: a job-stamped job is clean, an unattributed one is flagged", () => {
|
|
35
|
+
const findings = costAudit(doc);
|
|
36
|
+
expect(findings.find((f) => f.operation === "nightlyRollup" && f.code === "unattributed-background-cost")).toBeUndefined(); // job-stamped declares its principal
|
|
37
|
+
expect(findings.find((f) => f.operation === "drainEmailQueue")?.code).toBe("unattributed-background-cost"); // no attribution → fail loud
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { eventCostEvent, reconciledAmount, costAudit, annotateCosts, type CostModel } from "../src/index";
|
|
3
|
+
import type { OpenAPIv4Document } from "@suluk/core";
|
|
4
|
+
|
|
5
|
+
// Stripe fires payment_intent.succeeded with the REAL amount (cents) — reconcile against it, not the estimate.
|
|
6
|
+
const stripeEvent = { id: "evt_1", body: { customer: "cus_9", amount: 3175 } }; // $31.75 actual (incl. tax/proration)
|
|
7
|
+
const reconciledModel: CostModel = {
|
|
8
|
+
components: [{ source: "stripe", basis: "per-call", microUsd: 29_000_000 }], // declared estimate: $29
|
|
9
|
+
estimateMicroUsd: 29_000_000,
|
|
10
|
+
trigger: "webhook-received",
|
|
11
|
+
reconciliationBasis: "payload-reconciled",
|
|
12
|
+
amountExpression: "{$event.body#/amount}",
|
|
13
|
+
amountUnit: "cents",
|
|
14
|
+
attribution: { strategy: "event-expression", expression: "{$event.body#/customer}", trust: "verified" },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe("C026 — reconciliation: record the third party's ACTUAL charge, not the estimate", () => {
|
|
18
|
+
test("reconciledAmount reads the payload amount and converts cents → µ$", () => {
|
|
19
|
+
expect(reconciledAmount(reconciledModel, stripeEvent)).toBe(31_750_000); // 3175 cents × 10_000
|
|
20
|
+
expect(reconciledAmount({ ...reconciledModel, amountUnit: "usd" }, { body: { amount: 31.75 } } as never)).toBe(31_750_000);
|
|
21
|
+
expect(reconciledAmount({ components: [] }, stripeEvent)).toBeUndefined(); // not payload-reconciled
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("eventCostEvent uses the reconciled amount as the authoritative total + flags reconciled", () => {
|
|
25
|
+
const e = eventCostEvent({ operation: "stripeCharge", model: reconciledModel, event: stripeEvent, at: 1 });
|
|
26
|
+
expect(e.totalMicroUsd).toBe(31_750_000); // the ACTUAL $31.75, not the declared $29
|
|
27
|
+
expect(e.reconciled).toBe(true);
|
|
28
|
+
expect(e.breakdown).toEqual([{ source: "stripe", microUsd: 31_750_000 }]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("a declared-estimate cost is unchanged (the existing path; no reconciliation)", () => {
|
|
32
|
+
const e = eventCostEvent({ operation: "x", model: { components: [{ source: "compute", basis: "per-call", microUsd: 500 }], trigger: "scheduled" }, event: stripeEvent, at: 1 });
|
|
33
|
+
expect(e.totalMicroUsd).toBe(500);
|
|
34
|
+
expect(e.reconciled).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("costAudit flags a payload-reconciled cost that can't read the amount (incomplete)", () => {
|
|
38
|
+
const doc = { openapi: "4.0.0-candidate", info: { title: "t", version: "1" }, paths: { p: { requests: { op: { method: "post", responses: {} } } } } } as unknown as OpenAPIv4Document;
|
|
39
|
+
const annotated = annotateCosts(doc, { op: { components: [{ source: "stripe", basis: "per-call", microUsd: 1 }], reconciliationBasis: "payload-reconciled" } }); // no amountExpression
|
|
40
|
+
expect(costAudit(annotated).find((f) => f.operation === "op" && f.code === "reconciliation-incomplete")).toBeDefined();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { settlementOf, settlementAudit, impliedErrorStatuses, settlementRollup, type CostSettlement } from "../src/index";
|
|
3
|
+
import type { OpenAPIv4Document, Request } from "@suluk/core";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* C044 — cost settlement (HOW a cost is recovered: credit | rate-limited | free). The fifth orthogonal cost axis.
|
|
7
|
+
* settlementAudit is the generic form of toolfactory's "cost names a lever" governance check; impliedErrorStatuses is
|
|
8
|
+
* the generic errors-gate. All pure functions of the declared facets — never a request value (rides the x-suluk-cost wall).
|
|
9
|
+
*/
|
|
10
|
+
const op = (cost: unknown, extra: Record<string, unknown> = {}): Request => ({ method: "post", "x-suluk-cost": cost, ...extra }) as unknown as Request;
|
|
11
|
+
const doc = (requests: Record<string, Request>): OpenAPIv4Document => ({ openapi: "4.0.0-candidate", info: { title: "T" }, paths: { "/x": { requests } } }) as unknown as OpenAPIv4Document;
|
|
12
|
+
|
|
13
|
+
describe("settlementOf", () => {
|
|
14
|
+
test("reads the settlement off the cost facet", () => {
|
|
15
|
+
expect(settlementOf(op({ estimateMicroUsd: 1000, settlement: { method: "credit", credits: 10 } }))).toEqual({ method: "credit", credits: 10 });
|
|
16
|
+
expect(settlementOf(op({ estimateMicroUsd: 1000 }))).toBeUndefined();
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("settlementAudit — every priced op names a coherent lever", () => {
|
|
21
|
+
test("a priced op with no settlement is flagged", () => {
|
|
22
|
+
const f = settlementAudit(doc({ priced: op({ estimateMicroUsd: 200 }) }));
|
|
23
|
+
expect(f.map((x) => x.rule)).toContain("cost-without-settlement");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("rate-limited WITHOUT an x-suluk-ratelimit is a high finding (no cap to be the payment)", () => {
|
|
27
|
+
const f = settlementAudit(doc({ bad: op({ estimateMicroUsd: 500, settlement: { method: "rate-limited" } }) }));
|
|
28
|
+
const hit = f.find((x) => x.rule === "rate-limited-without-cap");
|
|
29
|
+
expect(hit?.severity).toBe("high");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("rate-limited WITH a cap is clean", () => {
|
|
33
|
+
const f = settlementAudit(doc({ ok: op({ estimateMicroUsd: 0, components: [], settlement: { method: "rate-limited" } }, { "x-suluk-ratelimit": { windowMs: 1000, maxRequests: 10, key: "ip" } }) }));
|
|
34
|
+
expect(f.some((x) => x.rule === "rate-limited-without-cap")).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("credit with neither credits nor an estimate is flagged", () => {
|
|
38
|
+
const f = settlementAudit(doc({ c: op({ components: [{ source: "x", basis: "per-call", microUsd: 5 }], settlement: { method: "credit" } }) }));
|
|
39
|
+
expect(f.map((x) => x.rule)).toContain("credit-without-amount");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("free-but-priced is a low finding (operator absorbs it)", () => {
|
|
43
|
+
const f = settlementAudit(doc({ f: op({ estimateMicroUsd: 300, settlement: { method: "free" } }) }));
|
|
44
|
+
const hit = f.find((x) => x.rule === "free-but-priced");
|
|
45
|
+
expect(hit?.severity).toBe("low");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("a well-formed credit op raises nothing", () => {
|
|
49
|
+
const f = settlementAudit(doc({ good: op({ estimateMicroUsd: 1000, settlement: { method: "credit", credits: 10 } }) }));
|
|
50
|
+
expect(f).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("impliedErrorStatuses — errors a request's facets imply", () => {
|
|
55
|
+
test("credit→402, authenticated→401, owner-scope→403, rate-limit→429", () => {
|
|
56
|
+
const r = op({ settlement: { method: "credit", credits: 5 } }, { "x-suluk-access": { requires: "authenticated", scope: "owner-only" }, "x-suluk-ratelimit": { windowMs: 1, maxRequests: 1, key: "ip" } });
|
|
57
|
+
expect(impliedErrorStatuses(r)).toEqual([401, 402, 403, 429]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("an upstream per-request cost component implies 502", () => {
|
|
61
|
+
expect(impliedErrorStatuses(op({ components: [{ source: "openai", basis: "per-request", microUsd: 100 }] }))).toEqual([502]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a public, free op implies no error statuses", () => {
|
|
65
|
+
expect(impliedErrorStatuses(op({ settlement: { method: "free" } }))).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("settlementRollup — how the API is monetized", () => {
|
|
70
|
+
test("tallies ops by method + counts priced-but-unsettled", () => {
|
|
71
|
+
const d = doc({
|
|
72
|
+
a: op({ estimateMicroUsd: 100, settlement: { method: "credit", credits: 1 } }),
|
|
73
|
+
b: op({ components: [], settlement: { method: "rate-limited" } }, { "x-suluk-ratelimit": { windowMs: 1, maxRequests: 1, key: "ip" } }),
|
|
74
|
+
c: op({ estimateMicroUsd: 50 }), // priced, no settlement
|
|
75
|
+
});
|
|
76
|
+
expect(settlementRollup(d)).toEqual({ credit: 1, "rate-limited": 1, free: 0, unsettled: 1 });
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("D1 wall — settlement carries only STATIC facts, never a request-value selector", () => {
|
|
81
|
+
// TYPE-LINKED: every CostSettlement field classifies as an enum or a scalar — none extracts a request VALUE (no
|
|
82
|
+
// expression / pointer). Adding a value-extracting field fails to compile here until classified (the C037 discipline).
|
|
83
|
+
const KIND: Record<keyof CostSettlement, "enum" | "scalar"> = { method: "enum", credits: "scalar", overflow: "enum" };
|
|
84
|
+
test("no settlement field is a runtime value-expression/pointer", () => {
|
|
85
|
+
for (const k of Object.keys(KIND)) expect(["enum", "scalar"]).toContain(KIND[k as keyof CostSettlement]);
|
|
86
|
+
// a populated settlement holds an enum + an integer + an enum — nothing that points into a payload.
|
|
87
|
+
const full: CostSettlement = { method: "rate-limited", credits: 10, overflow: "credit" };
|
|
88
|
+
expect(typeof full.method).toBe("string");
|
|
89
|
+
expect(Number.isInteger(full.credits)).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
annotateCosts, costAudit, costTable, eachOperation, triggerOf, isDeferredCost, costOf, UNATTRIBUTED,
|
|
4
|
+
type CostModel,
|
|
5
|
+
} from "../src/index";
|
|
6
|
+
import type { OpenAPIv4Document } from "@suluk/core";
|
|
7
|
+
|
|
8
|
+
// a doc with a path op + a C018 webhook op
|
|
9
|
+
const doc = {
|
|
10
|
+
openapi: "4.0.0-candidate",
|
|
11
|
+
info: { title: "t", version: "1" },
|
|
12
|
+
paths: { order: { requests: { createOrder: { method: "post", responses: {} } } } },
|
|
13
|
+
webhooks: { stripeCharge: { method: "post", responses: {} } },
|
|
14
|
+
} as unknown as OpenAPIv4Document;
|
|
15
|
+
|
|
16
|
+
describe("C024 — background-event cost: the three orthogonal axes", () => {
|
|
17
|
+
test("triggerOf defaults to synchronous; isDeferredCost flags a non-sync trigger", () => {
|
|
18
|
+
expect(triggerOf(undefined)).toBe("synchronous");
|
|
19
|
+
expect(triggerOf({ components: [] })).toBe("synchronous");
|
|
20
|
+
expect(isDeferredCost({ components: [], trigger: "webhook-received" })).toBe(true);
|
|
21
|
+
expect(isDeferredCost({ components: [] })).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("eachOperation walks paths AND webhooks (the background-cost locus)", () => {
|
|
25
|
+
expect(eachOperation(doc).map((o) => o.name).sort()).toEqual(["createOrder", "stripeCharge"]);
|
|
26
|
+
expect(eachOperation(doc).find((o) => o.name === "stripeCharge")?.path).toBe("webhooks/stripeCharge");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("annotateCosts annotates a webhook op; the cost reads back with its trigger", () => {
|
|
30
|
+
const charge: CostModel = {
|
|
31
|
+
components: [{ source: "stripe", basis: "per-call", microUsd: 2900 }],
|
|
32
|
+
trigger: "webhook-received",
|
|
33
|
+
triggerRef: "stripeCharge",
|
|
34
|
+
attribution: { strategy: "event-expression", expression: "{$event.body#/customer}", trust: "verified" },
|
|
35
|
+
};
|
|
36
|
+
const annotated = annotateCosts(doc, { stripeCharge: charge });
|
|
37
|
+
const webhook = (annotated as unknown as { webhooks: Record<string, unknown> }).webhooks.stripeCharge;
|
|
38
|
+
expect(costOf(webhook as never)?.trigger).toBe("webhook-received");
|
|
39
|
+
// basis is UNCHANGED — the metering axis stays orthogonal to the trigger
|
|
40
|
+
expect(costOf(webhook as never)?.components[0].basis).toBe("per-call");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("C024 — the fail-loud attribution disciplines (audit)", () => {
|
|
45
|
+
test("a deferred cost with NO attribution → unattributed-background-cost (warn, never silent)", () => {
|
|
46
|
+
const annotated = annotateCosts(doc, { stripeCharge: { components: [{ source: "stripe", basis: "per-call", microUsd: 2900 }], trigger: "webhook-received" } });
|
|
47
|
+
const f = costAudit(annotated).find((x) => x.operation === "stripeCharge");
|
|
48
|
+
expect(f?.code).toBe("unattributed-background-cost");
|
|
49
|
+
expect(f?.message).toContain(UNATTRIBUTED);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a deferred cost reading an UNVERIFIED payload → unverified-attribution (attacker-controllable)", () => {
|
|
53
|
+
const annotated = annotateCosts(doc, { stripeCharge: { components: [{ source: "stripe", basis: "per-call", microUsd: 2900 }], trigger: "webhook-received", attribution: { strategy: "event-expression", expression: "{$event.body#/customer}" } } });
|
|
54
|
+
expect(costAudit(annotated).find((x) => x.operation === "stripeCharge")?.code).toBe("unverified-attribution");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("a deferred cost with a VERIFIED event attribution is clean (no background finding)", () => {
|
|
58
|
+
const annotated = annotateCosts(doc, { stripeCharge: { components: [{ source: "stripe", basis: "per-call", microUsd: 2900 }], trigger: "webhook-received", attribution: { strategy: "event-expression", expression: "{$event.body#/customer}", trust: "verified" } } });
|
|
59
|
+
const codes = costAudit(annotated).filter((x) => x.operation === "stripeCharge").map((x) => x.code);
|
|
60
|
+
expect(codes).not.toContain("unattributed-background-cost");
|
|
61
|
+
expect(codes).not.toContain("unverified-attribution");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a SYNCHRONOUS op is never flagged for attribution (the default path is unchanged)", () => {
|
|
65
|
+
const annotated = annotateCosts(doc, { createOrder: { components: [{ source: "compute", basis: "per-call", microUsd: 10 }] } });
|
|
66
|
+
expect(costAudit(annotated).filter((x) => x.operation === "createOrder").map((x) => x.code)).not.toContain("unattributed-background-cost");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("C024 — costTable surfaces the trigger across paths + webhooks", () => {
|
|
71
|
+
test("the table includes the webhook row with its trigger", () => {
|
|
72
|
+
const annotated = annotateCosts(doc, {
|
|
73
|
+
createOrder: { components: [{ source: "compute", basis: "per-call", microUsd: 10 }] },
|
|
74
|
+
stripeCharge: { components: [{ source: "stripe", basis: "per-call", microUsd: 2900 }], trigger: "webhook-received" },
|
|
75
|
+
});
|
|
76
|
+
const rows = costTable(annotated);
|
|
77
|
+
expect(rows.find((r) => r.operation === "createOrder")?.trigger).toBe("synchronous");
|
|
78
|
+
expect(rows.find((r) => r.operation === "stripeCharge")).toMatchObject({ trigger: "webhook-received", estimateMicroUsd: 2900 });
|
|
79
|
+
});
|
|
80
|
+
});
|