@farthershore/backend 0.20.0 → 0.21.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/CHANGELOG.md +103 -0
- package/README.md +239 -91
- package/dist/adapters/express.js +68 -12
- package/dist/generated/runtime-contract.js +21 -236
- package/dist/index.js +519 -417
- package/dist/internal/index.js +587 -0
- package/dist/testing/index.js +614 -302
- package/dist/types/adapters/express.d.ts +32 -3
- package/dist/types/core/permissions.d.ts +25 -13
- package/dist/types/core/post-stream-usage.d.ts +19 -4
- package/dist/types/core/report.d.ts +133 -0
- package/dist/types/core/runtime.d.ts +25 -12
- package/dist/types/core/verifyRequest.d.ts +21 -3
- package/dist/types/generated/runtime-contract.d.ts +14 -189
- package/dist/types/index.d.ts +29 -8
- package/dist/types/internal/index.d.ts +2 -0
- package/dist/types/response-metering.d.ts +29 -39
- package/dist/types/runtime-types.d.ts +16 -1
- package/dist/types/testing/devRuntime.d.ts +11 -2
- package/dist/types/testing/index.d.ts +1 -0
- package/dist/types/testing/usageSink.d.ts +1 -1
- package/dist/types/testing/webhooks.d.ts +30 -0
- package/dist/types/webhooks/index.d.ts +247 -0
- package/dist/types/webhooks/types.d.ts +110 -0
- package/dist/webhooks/index.js +498 -0
- package/package.json +12 -3
- package/dist/types/core/metering.d.ts +0 -68
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,109 @@ All notable changes to the runtime backend SDK are documented here. This SDK
|
|
|
4
4
|
versions independently from the frontend and business SDKs. Pre-1.0: minor
|
|
5
5
|
versions may include breaking changes.
|
|
6
6
|
|
|
7
|
+
## [0.21.0] - 2026-09-05
|
|
8
|
+
|
|
9
|
+
### Added — `@farthershore/backend/webhooks`: consuming platform webhooks is first class
|
|
10
|
+
|
|
11
|
+
The platform now signs every builder webhook with the open
|
|
12
|
+
[Standard Webhooks](https://www.standardwebhooks.com) format
|
|
13
|
+
(`webhook-id` / `webhook-timestamp` / `webhook-signature: v1,<base64>` over
|
|
14
|
+
`${id}.${timestamp}.${body}`, ±300 s) and delivers a typed envelope
|
|
15
|
+
`{ id, type, createdAt, businessId, environmentId, data }`. The new subpath
|
|
16
|
+
consumes it:
|
|
17
|
+
|
|
18
|
+
- `createWebhookHandler({ secret | secrets, on, onUnknown?, onRejected?, onDuplicate?, nonceStore? })`
|
|
19
|
+
verifies the signature over the raw body (any `v1,` entry, so the
|
|
20
|
+
platform's 24 h dual-signing after a rotation just works), rejects stale /
|
|
21
|
+
future timestamps, deduplicates on `webhook-id` via a bounded delivery-id
|
|
22
|
+
lease store (claim before the handler, settle after — distinct from the
|
|
23
|
+
request verifier's nonce cache), parses the envelope, and routes to
|
|
24
|
+
typed per-event handlers. Unknown event types and duplicates are
|
|
25
|
+
acknowledged 2xx; a thrown handler is a 500 (the platform retries) and the
|
|
26
|
+
delivery id is released so that retry runs the handler again.
|
|
27
|
+
- `.express()` (mount after `express.raw()`) and `.fetch` (Request → Response)
|
|
28
|
+
adapters; `handle()` is the framework-neutral core.
|
|
29
|
+
- `verifyWebhook({ body, headers, secrets })` — the bare primitive.
|
|
30
|
+
- Typed `WebhookEnvelope<T>` / `WebhookEventData` / `WEBHOOK_EVENT_NAMES`
|
|
31
|
+
(7 subscribable events + the synthetic `webhook.test`).
|
|
32
|
+
- `signWebhookForTesting()` in `@farthershore/backend/testing` builds a
|
|
33
|
+
platform-identical signed delivery for receiver tests. Conformance is
|
|
34
|
+
proven both ways against the reference `standardwebhooks` library.
|
|
35
|
+
|
|
36
|
+
### Removed — BREAKING: eleven unused constants from `@farthershore/backend/runtime`
|
|
37
|
+
|
|
38
|
+
The `runtime` subpath previously re-stated the wire protocol as prose constant
|
|
39
|
+
blobs, emitted by a codegen step from a `fern/runtime-contract.json` mirror.
|
|
40
|
+
That mirror and its codegen are deleted; the constants nothing imported went
|
|
41
|
+
with them, because they duplicated — and had drifted from — types this package
|
|
42
|
+
already declares correctly.
|
|
43
|
+
|
|
44
|
+
Removed: `RUNTIME_CONTRACT_VERSION`, `RUNTIME_TOKEN_ENV`,
|
|
45
|
+
`RUNTIME_TOKEN_CONTRACT`, `RUNTIME_BOOTSTRAP_CONTRACT`,
|
|
46
|
+
`RUNTIME_SIGNING_CONTRACT`, `RUNTIME_CANONICAL_FIELDS`, `RUNTIME_HEADERS`,
|
|
47
|
+
`RUNTIME_REPLAY_CONTRACT`, `RUNTIME_METERING_CONTRACT`,
|
|
48
|
+
`RUNTIME_HEALTH_CONTRACT`, `RUNTIME_TRANSPORT_CONTRACT`.
|
|
49
|
+
|
|
50
|
+
**Replacements — all already exported from the package root (`.`), typed rather
|
|
51
|
+
than stringly-described:**
|
|
52
|
+
|
|
53
|
+
| removed | use instead |
|
|
54
|
+
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
55
|
+
| `RUNTIME_BOOTSTRAP_CONTRACT` | the `RuntimeBootstrapResponse` type |
|
|
56
|
+
| `RUNTIME_TOKEN_ENV` | `FS_RUNTIME_TOKEN_ENV` |
|
|
57
|
+
| `RUNTIME_HEADERS` | `RUNTIME_HEADER_NAMES` |
|
|
58
|
+
| `RUNTIME_REPLAY_CONTRACT` | `RUNTIME_CLOCK_SKEW_SECONDS`, `RUNTIME_REPLAY_WINDOW_SECONDS` |
|
|
59
|
+
| `RUNTIME_CANONICAL_FIELDS` | `buildCanonicalSigningString()` — exported at runtime, with the load-bearing field order baked in; the order is also specified in metering-runtime-spec §3. (`CanonicalSigningInput` types that function's input but is erased at runtime and carries no order, so it is NOT a replacement for callers that read the array.) |
|
|
60
|
+
| `RUNTIME_SIGNING_CONTRACT` | metering-runtime-spec §3 + `docs/superpowers/specs/fixtures/signing-vectors.json` |
|
|
61
|
+
| `RUNTIME_METERING_CONTRACT` | metering-runtime-spec §8; for the post-stream callback, `postStreamUsageSchema` in `apps/core/src/routes/runtime.ts` |
|
|
62
|
+
| `RUNTIME_HEALTH_CONTRACT` | the `RuntimeHealthReport` type |
|
|
63
|
+
| `RUNTIME_TRANSPORT_CONTRACT` | the `TransportMode` type |
|
|
64
|
+
| `RUNTIME_CONTRACT_VERSION` | no replacement — it versioned the deleted mirror, not the wire |
|
|
65
|
+
| `RUNTIME_TOKEN_CONTRACT` | `RUNTIME_TOKEN_PREFIXES`, `RUNTIME_TOKEN_OPERATIONS` |
|
|
66
|
+
|
|
67
|
+
`RUNTIME_ERROR_CODES`, `RuntimeErrorCode`, `RUNTIME_BODY_HASH_CONTRACT` and
|
|
68
|
+
`RUNTIME_RESPONSE_METERING_CONTRACT` are UNCHANGED — they are imported by real
|
|
69
|
+
code, and every field of them with a canonical owner in
|
|
70
|
+
`@farthershore/contracts` is now pinned to it by
|
|
71
|
+
`src/generated/runtime-contract-parity.test.ts`.
|
|
72
|
+
|
|
73
|
+
### Changed — BREAKING: the reporting surface is now ONE verb (FAR-907)
|
|
74
|
+
|
|
75
|
+
- **`ctx.report({ meter, values, dims?, quote? })`** on the verified context is
|
|
76
|
+
the only reporting surface. Backends report **measurements, never money**;
|
|
77
|
+
the platform owns what they cost.
|
|
78
|
+
|
|
79
|
+
- **Transport is an implementation detail.** Before the response is sent the
|
|
80
|
+
measurement rides signed in-band `x-fs-metering` headers (no network call);
|
|
81
|
+
after `res.end()`, or from a background job holding the context, it goes
|
|
82
|
+
over the attested post-stream channel with the SAME served identity. The
|
|
83
|
+
builder never chooses.
|
|
84
|
+
- **No identity ceremony.** Subscription/served identity is read off the
|
|
85
|
+
already-signed context, so the UNBILLED-by-forgotten-`subscriptionId`
|
|
86
|
+
failure mode is structurally unreachable. `report()` on a context the
|
|
87
|
+
runtime did not produce throws an error naming the fix.
|
|
88
|
+
- **`quote`** is the one bounded money channel: a PROPOSED rate input
|
|
89
|
+
(`{ currency, amountNanos }`) for a `backendQuoted` pricing rule. Core
|
|
90
|
+
clamps it to the repo-authored bounds; the SDK rejects only malformed input.
|
|
91
|
+
|
|
92
|
+
- **Removed (hard cut, no shims):** `withUsage`, `createUsage`, `UsageReporter`,
|
|
93
|
+
`MeteringOptions`, `UsageMap`, `MeteringClient` (+ options/`MeterOptions`),
|
|
94
|
+
`fs.meter()`, `fs.reportUsage()`, `ctx.reportUsage()`, and the
|
|
95
|
+
`PostStreamUsageClient` public surface. The post-stream transport survives as
|
|
96
|
+
private machinery behind `report()`. `computeMeteringHeaders()` stays as the
|
|
97
|
+
framework-neutral wire recipe for non-JS backends.
|
|
98
|
+
|
|
99
|
+
- **Wire (additive):** the signed metering payload and the post-stream event
|
|
100
|
+
gain `measurementsVersion: 1`, `measurements: [{ meter, values, dims? }]`, and
|
|
101
|
+
`quote` (non-negative — a quote is a proposed rate, never a credit).
|
|
102
|
+
`rawDimsUnits` / `meters` remain the flat structural projection the existing
|
|
103
|
+
settlement path reads, keyed by the METER id with the sum of the
|
|
104
|
+
measurement's measure values as its quantity (the gateway masks this lane by
|
|
105
|
+
the route's declared meter ids, so measure-keyed entries would be silently
|
|
106
|
+
discarded — UNBILLED). The runtime token's `allowedMeters` scope is enforced
|
|
107
|
+
on BOTH lanes independently: every `meters` key AND every
|
|
108
|
+
`measurements[].meter` must be in scope.
|
|
109
|
+
|
|
7
110
|
## [0.20.0] - 2026-08-07
|
|
8
111
|
|
|
9
112
|
### Changed
|
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ graceful lifecycle (health + shutdown). Everything else — your business, backe
|
|
|
12
12
|
and environment ids, the verification keys, and the metering endpoint — is
|
|
13
13
|
fetched automatically from the token at startup.
|
|
14
14
|
|
|
15
|
-
> **Status: `0.
|
|
15
|
+
> **Status: `0.21.0`.** Pre-1.0: minor releases may include breaking changes, so
|
|
16
16
|
> pin this package to an exact version (or a patch-only range) and upgrade
|
|
17
17
|
> deliberately.
|
|
18
18
|
|
|
@@ -28,7 +28,7 @@ Requires Node 22+. The Express adapter has an optional `express` peer dependency
|
|
|
28
28
|
## Quick start (any Fetch-compatible handler)
|
|
29
29
|
|
|
30
30
|
```ts
|
|
31
|
-
import { fartherShore
|
|
31
|
+
import { fartherShore } from "@farthershore/backend";
|
|
32
32
|
|
|
33
33
|
const fs = fartherShore.initFromEnv(); // derives everything from FS_RUNTIME_TOKEN
|
|
34
34
|
|
|
@@ -38,7 +38,7 @@ export async function POST(request: Request) {
|
|
|
38
38
|
|
|
39
39
|
// Fail-closed: throws a FartherShoreError if the request is not a genuine,
|
|
40
40
|
// unmodified request signed by the gateway.
|
|
41
|
-
await fs.verifyRequest({
|
|
41
|
+
const ctx = await fs.verifyRequest({
|
|
42
42
|
method: request.method,
|
|
43
43
|
path: url.pathname,
|
|
44
44
|
query: url.search,
|
|
@@ -48,10 +48,15 @@ export async function POST(request: Request) {
|
|
|
48
48
|
|
|
49
49
|
const result = await runWorkflow(await request.json());
|
|
50
50
|
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
// ONE reporting verb. No identity argument (the verified context carries the
|
|
52
|
+
// served identity) and no transport argument (the SDK picks one).
|
|
53
|
+
await ctx.report({
|
|
54
|
+
meter: "model_usage",
|
|
55
|
+
values: { tokens_used: result.tokensUsed },
|
|
56
|
+
dims: { model: result.model },
|
|
54
57
|
});
|
|
58
|
+
|
|
59
|
+
return Response.json(result);
|
|
55
60
|
}
|
|
56
61
|
```
|
|
57
62
|
|
|
@@ -66,6 +71,12 @@ app.use(fs.middleware()); // fail-closed verify -> req.fartherShore
|
|
|
66
71
|
|
|
67
72
|
app.post("/v1/runs", async (req, res) => {
|
|
68
73
|
const result = await runWorkflow(req.body);
|
|
74
|
+
|
|
75
|
+
await req.fartherShore.report({
|
|
76
|
+
meter: "model_usage",
|
|
77
|
+
values: { tokens_used: result.tokensUsed },
|
|
78
|
+
});
|
|
79
|
+
|
|
69
80
|
res.json(result);
|
|
70
81
|
});
|
|
71
82
|
|
|
@@ -127,82 +138,218 @@ requests fail **closed** — it never degrades to "not a replay".
|
|
|
127
138
|
`fs.replayProtection()` reports which mode is active (`"shared"` |
|
|
128
139
|
`"single-instance"`) if you want it in your boot logs.
|
|
129
140
|
|
|
130
|
-
##
|
|
141
|
+
## Authorization — the permission grammar & in-handler checks
|
|
142
|
+
|
|
143
|
+
The edge `permission` constraint is the route-level security boundary: the
|
|
144
|
+
gateway resolves the acting user's effective permissions at token mint and
|
|
145
|
+
carries them in the signed `X-Fs-Context` claim. These SDK helpers exist for
|
|
146
|
+
**finer-grained, in-handler** checks the route layer can't express (field- or
|
|
147
|
+
record-level gating).
|
|
148
|
+
|
|
149
|
+
### The grammar
|
|
150
|
+
|
|
151
|
+
A permission is a plain string, checked with three rungs:
|
|
152
|
+
|
|
153
|
+
- `*` — the global wildcard. Grants **every** key (org OWNER, RBAC disabled,
|
|
154
|
+
personal orgs — the gateway stamps an explicit `["*"]`).
|
|
155
|
+
- `<subject>:*` — the subject wildcard, e.g. `widgets:*` grants `widgets:read`,
|
|
156
|
+
`widgets:write`, and any other `widgets:<verb>`. (A literal `*` subject never
|
|
157
|
+
takes this rung — only the bare `*` grant is global.)
|
|
158
|
+
- exact keys — e.g. `widgets:write`. **Custom permission strings work**: any
|
|
159
|
+
`<subject>:<verb>` you invent is checked verbatim; there is no fixed verb
|
|
160
|
+
vocabulary at this layer.
|
|
161
|
+
|
|
162
|
+
Route-shaped keys follow `routePermission(subject, method)` — `<subject>:read`
|
|
163
|
+
for safe verbs (GET / HEAD / OPTIONS, any casing) and `<subject>:write` for
|
|
164
|
+
everything else — the SAME helper the platform uses to derive a route's
|
|
165
|
+
required permission, exported here so you never re-spell the suffix.
|
|
166
|
+
|
|
167
|
+
**Fail-closed at the carrier**: an **absent** permission set
|
|
168
|
+
(`ctx.permissions === undefined`) always **denies** — absence never means
|
|
169
|
+
grant-all, even on a fully verified request. `[]` (authenticated, no grants)
|
|
170
|
+
also denies. A route you don't want gated simply doesn't call a check.
|
|
131
171
|
|
|
132
|
-
|
|
133
|
-
for a request while you are returning the response. These helpers make **no
|
|
134
|
-
network call** — they sign the usage into internal response headers, and the
|
|
135
|
-
gateway verifies, settles, and strips those headers before your subscriber sees
|
|
136
|
-
the response.
|
|
172
|
+
### Checking permissions
|
|
137
173
|
|
|
138
174
|
```ts
|
|
139
|
-
|
|
175
|
+
// Namespace form — dev (`rt.authz`, traced) and prod (`fs.authz`) match:
|
|
176
|
+
app.post(
|
|
177
|
+
"/v1/widgets",
|
|
178
|
+
fs.middleware(),
|
|
179
|
+
fs.handler((ctx, req, res) => {
|
|
180
|
+
fs.authz.requirePermission(ctx, "widgets:write"); // throws 403 permission_denied
|
|
181
|
+
// or: if (fs.authz.hasPermission(ctx, "widgets:publish")) { ... }
|
|
182
|
+
res.json({ ok: true });
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// Declarative form — the handler options overload runs the same fail-closed
|
|
187
|
+
// check BEFORE your callback:
|
|
188
|
+
app.post(
|
|
189
|
+
"/v1/widgets",
|
|
190
|
+
fs.middleware(),
|
|
191
|
+
fs.handler({ permission: "widgets:write" }, (ctx, req, res) => {
|
|
192
|
+
res.json({ ok: true });
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
```
|
|
140
196
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
197
|
+
A failed check responds `403 { "error": "permission_denied" }` (a thrown
|
|
198
|
+
`FartherShorePermissionError` is mapped by `fs.handler`). The standalone
|
|
199
|
+
`hasPermission` / `requirePermission` / `permissionSatisfies` /
|
|
200
|
+
`routePermission` exports are available for non-Express frameworks. In the dev
|
|
201
|
+
runtime, use `rt.authz.*` — the same shape, with every decision recorded into
|
|
202
|
+
the per-request trace (see `templates/3-simulated-authz.test.ts`).
|
|
203
|
+
|
|
204
|
+
## Usage reporting — one verb
|
|
205
|
+
|
|
206
|
+
`ctx.report({ meter, values, dims?, quote? })` on the verified context is the
|
|
207
|
+
ONLY reporting surface. Backends report **measurements, never money**: `values`
|
|
208
|
+
are observed facts (tokens, jobs, rows), `dims` name the catalog tuple they were
|
|
209
|
+
produced under, and the platform owns what they cost.
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
await req.fartherShore.report({
|
|
213
|
+
meter: "model_usage",
|
|
214
|
+
values: { input_tokens: 1200, output_tokens: 850 },
|
|
215
|
+
dims: { model: "acme-4", cache_status: "hit" },
|
|
216
|
+
});
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
**No identity ceremony.** The subscription and served release ride the signed
|
|
220
|
+
context the gateway already sent, so there is no `subscriptionId` to forget —
|
|
221
|
+
the "unbilled because the handler omitted an id" failure mode is unreachable.
|
|
222
|
+
Hand the same `FartherShoreContext` to a background job and it keeps reporting
|
|
223
|
+
against that same served identity:
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
import type { FartherShoreContext } from "@farthershore/backend";
|
|
227
|
+
|
|
228
|
+
export async function runJob(job, fartherShore: FartherShoreContext) {
|
|
229
|
+
const result = await perform(job);
|
|
230
|
+
await fartherShore.report({
|
|
231
|
+
meter: "jobs",
|
|
232
|
+
values: { jobs: 1 },
|
|
233
|
+
dims: { queue: job.queue },
|
|
234
|
+
});
|
|
235
|
+
return result.output;
|
|
152
236
|
}
|
|
153
237
|
```
|
|
154
238
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
239
|
+
**Transport is an implementation detail.** Reported before the response is sent,
|
|
240
|
+
the measurement rides signed `x-fs-metering` response headers — no extra network
|
|
241
|
+
call; the gateway verifies, settles, and strips them before the subscriber sees
|
|
242
|
+
the response. Reported after `res.end()` (a stream) or from a background job, it
|
|
243
|
+
goes over the attested post-stream channel with the same served identity. The
|
|
244
|
+
builder never picks; `report()` resolves `{ ok, transport }` if you want to know.
|
|
245
|
+
|
|
246
|
+
**Multiple meters after the response is sent → ONE batched call.** A served
|
|
247
|
+
request owns exactly ONE post-stream callback identity, so sequential awaited
|
|
248
|
+
single-meter calls after the response cannot all be delivered — the first call
|
|
249
|
+
flushes the callback and every later call resolves `{ ok: false }`. Pass an
|
|
250
|
+
ARRAY to report several meters atomically through that single callback:
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
await fartherShore.report([
|
|
254
|
+
{ meter: "model_usage", values: { output_tokens: 512 } },
|
|
255
|
+
{ meter: "jobs", values: { jobs: 1 } },
|
|
256
|
+
]);
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
All entries of a batch share one quote (supplying two different quotes throws)
|
|
260
|
+
and one `dims` tuple — the request receipt rates under `(route, dims)`, so
|
|
261
|
+
report each dims tuple on its own request. The same one-quote / one-dims rule
|
|
262
|
+
applies to in-band accumulation before the response is sent.
|
|
263
|
+
Before the response is sent this constraint does not exist — sequential in-band
|
|
264
|
+
reports accumulate into the same signed response payload automatically.
|
|
265
|
+
|
|
266
|
+
Malformed input (a bad meter/measure/dimension key, a negative or non-finite
|
|
267
|
+
value, a malformed quote) **throws** — a dropped measurement is unbilled
|
|
268
|
+
revenue. Delivery failures resolve `{ ok: false, reason }` instead of rejecting,
|
|
269
|
+
so a metering hiccup never breaks your endpoint. Calling `report()` on a context
|
|
270
|
+
that did not come from the runtime (the bare `verifyRequest()` primitive) throws
|
|
271
|
+
an error naming the fix.
|
|
272
|
+
|
|
273
|
+
The meter keys you report must match meters declared in your business; the
|
|
274
|
+
gateway validates them against the served release's measurement-emission schema.
|
|
275
|
+
Request-count style limits are enforced by the gateway and need no backend code.
|
|
276
|
+
|
|
277
|
+
### Quotes: the one bounded money channel
|
|
159
278
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
279
|
+
`quote` is the sole exception to "never money": a **proposed rate input** for a
|
|
280
|
+
pricing policy that declared the `backendQuoted` rule with repo-authored
|
|
281
|
+
`{min,max}` bounds (dynamic upstream resale, bespoke jobs).
|
|
163
282
|
|
|
164
|
-
|
|
283
|
+
```ts
|
|
284
|
+
await fartherShore.report({
|
|
285
|
+
meter: "jobs",
|
|
286
|
+
values: { jobs: 1 },
|
|
287
|
+
quote: { currency: "usd", amountNanos: "250000000" }, // $0.25
|
|
288
|
+
});
|
|
289
|
+
```
|
|
165
290
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
Background usage is tallied and billed after the cycle, not enforced in
|
|
171
|
-
real time.
|
|
291
|
+
Core **clamps** it to the declared bounds and flags an out-of-range proposal for
|
|
292
|
+
dispute; contract modifiers and funding still apply on top, and the ledger only
|
|
293
|
+
ever records core-rated charges. The SDK does not validate the bounds (it cannot
|
|
294
|
+
know them) — it rejects only structurally malformed quotes.
|
|
172
295
|
|
|
173
|
-
|
|
296
|
+
### Non-JS backends
|
|
174
297
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
298
|
+
The wire recipe is language-neutral: any backend can stamp the same signed
|
|
299
|
+
headers with a stdlib HMAC. See
|
|
300
|
+
[`docs/response-metering-wire.md`](docs/response-metering-wire.md), or use
|
|
301
|
+
`computeMeteringHeaders()` directly from a non-Express JS host.
|
|
302
|
+
|
|
303
|
+
## Consuming platform webhooks
|
|
304
|
+
|
|
305
|
+
Endpoints are created in the dashboard or CLI (`farthershore webhook create`);
|
|
306
|
+
the SDK consumes what they deliver. `@farthershore/backend/webhooks` is
|
|
307
|
+
standalone — a receiver needs only its `fswh_` signing secret, not a runtime
|
|
308
|
+
token.
|
|
179
309
|
|
|
180
310
|
```ts
|
|
181
|
-
|
|
311
|
+
import { createWebhookHandler } from "@farthershore/backend/webhooks";
|
|
182
312
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
313
|
+
const webhooks = createWebhookHandler({
|
|
314
|
+
secret: process.env.FS_WEBHOOK_SECRET!,
|
|
315
|
+
on: {
|
|
316
|
+
"subscription.created": async (event) => {
|
|
317
|
+
await provision(event.data.subscriptionId, event.businessId);
|
|
318
|
+
},
|
|
319
|
+
"payment.failed": async (event) => {
|
|
320
|
+
await flagAccount(event.data.subscriptionId);
|
|
321
|
+
},
|
|
322
|
+
},
|
|
187
323
|
});
|
|
324
|
+
|
|
325
|
+
// Express — mount after a RAW body parser so the signature can be checked:
|
|
326
|
+
app.post(
|
|
327
|
+
"/webhooks/farthershore",
|
|
328
|
+
express.raw({ type: "*/*" }),
|
|
329
|
+
webhooks.express(),
|
|
330
|
+
);
|
|
331
|
+
// Fetch-style runtimes (Next.js route handlers, Hono, Workers):
|
|
332
|
+
export const POST = webhooks.fetch;
|
|
188
333
|
```
|
|
189
334
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
`{
|
|
205
|
-
|
|
335
|
+
What the handler does for you, in order: verifies the
|
|
336
|
+
[Standard Webhooks](https://www.standardwebhooks.com) signature over the raw
|
|
337
|
+
body (`webhook-id.webhook-timestamp.body`, HMAC-SHA256, any `v1,` entry —
|
|
338
|
+
so a platform-side rotation's dual signature just works, and you can pass
|
|
339
|
+
`secrets: [current, previous]` while you roll your own copy); rejects
|
|
340
|
+
timestamps outside ±5 minutes; deduplicates on `webhook-id` (a retry after a
|
|
341
|
+
lost 2xx is acknowledged without re-running your code; pass a shared
|
|
342
|
+
`nonceStore` on multi-instance receivers); parses the typed envelope
|
|
343
|
+
`{ id, type, createdAt, businessId, environmentId, data }`; acknowledges
|
|
344
|
+
unknown event types with 2xx (`onUnknown` to log them) so a newer platform
|
|
345
|
+
never causes a 500 storm; and turns a thrown handler into a 500 so the
|
|
346
|
+
platform retries (30 s / 5 min / 30 min) — the delivery id is released so
|
|
347
|
+
that retry runs your handler again.
|
|
348
|
+
|
|
349
|
+
`verifyWebhook({ body, headers, secrets })` is the bare primitive if you want
|
|
350
|
+
to wire routing yourself, and `signWebhookForTesting()` from
|
|
351
|
+
`@farthershore/backend/testing` produces a platform-identical signed delivery
|
|
352
|
+
for your receiver tests.
|
|
206
353
|
|
|
207
354
|
## Lifecycle
|
|
208
355
|
|
|
@@ -265,37 +412,38 @@ See `templates/3-simulated-authz.test.ts` for the full fail-closed + usage flow.
|
|
|
265
412
|
|
|
266
413
|
## Key exports
|
|
267
414
|
|
|
268
|
-
| Export | Purpose
|
|
269
|
-
| ------------------------------------ |
|
|
270
|
-
| `fartherShore.initFromEnv()` | Create the runtime instance from `FS_RUNTIME_TOKEN`.
|
|
271
|
-
| `fs.middleware()` | Express fail-closed verify → `req.fartherShore`.
|
|
272
|
-
| `fs.verifyRequest({...})` | Framework-neutral request verification.
|
|
273
|
-
| `
|
|
274
|
-
| `
|
|
275
|
-
| `
|
|
276
|
-
| `
|
|
277
|
-
| `
|
|
278
|
-
| `
|
|
279
|
-
|
|
|
415
|
+
| Export | Purpose |
|
|
416
|
+
| ------------------------------------ | ----------------------------------------------------- |
|
|
417
|
+
| `fartherShore.initFromEnv()` | Create the runtime instance from `FS_RUNTIME_TOKEN`. |
|
|
418
|
+
| `fs.middleware()` | Express fail-closed verify → `req.fartherShore`. |
|
|
419
|
+
| `fs.verifyRequest({...})` | Framework-neutral request verification. |
|
|
420
|
+
| `fs.handler({ permission? }, cb)` | Verified-principal handler (+ declarative gate). |
|
|
421
|
+
| `fs.authz.requirePermission(ctx, k)` | In-handler authz (fail-closed; also `hasPermission`). |
|
|
422
|
+
| `routePermission(subject, method)` | Route-derived permission key (`:read`/`:write`). |
|
|
423
|
+
| `ctx.report({meter, values, …})` | THE reporting verb (SDK picks the transport). |
|
|
424
|
+
| `computeMeteringHeaders()` | Metering headers as a plain map — never throws. |
|
|
425
|
+
| `fs.health()` / `fs.shutdown()` | Health report and graceful shutdown. |
|
|
426
|
+
| `FartherShoreError`, `MeteringError` | Typed errors. |
|
|
427
|
+
| `@farthershore/backend/webhooks` | `createWebhookHandler` / `verifyWebhook` (receivers). |
|
|
428
|
+
| `@farthershore/backend/testing` | Dev-mode + persona test harness (dev/test only). |
|
|
280
429
|
|
|
281
430
|
A subpath export, `@farthershore/backend/express`, exposes the Express adapter
|
|
282
431
|
types directly if you prefer to wire the middleware yourself.
|
|
283
432
|
|
|
284
|
-
## Metering
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
- **
|
|
289
|
-
the attested, request-bound settlement channel
|
|
290
|
-
and settles the reported units against the request's lease
|
|
291
|
-
lifecycle. Wire recipe (any language):
|
|
292
|
-
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
enforcement windows
|
|
297
|
-
|
|
298
|
-
usage not tied to a gateway response. It never settles a lease.
|
|
433
|
+
## Metering transports
|
|
434
|
+
|
|
435
|
+
One verb, two transports — `ctx.report()` chooses; you never do:
|
|
436
|
+
|
|
437
|
+
- **In-band** (signed `x-fs-metering` response headers) while the response is
|
|
438
|
+
still open: the attested, request-bound settlement channel. The gateway
|
|
439
|
+
verifies the HMAC and settles the reported units against the request's lease
|
|
440
|
+
in the same lifecycle, then strips the headers. Wire recipe (any language):
|
|
441
|
+
[`docs/response-metering-wire.md`](docs/response-metering-wire.md).
|
|
442
|
+
- **Post-stream** (the attested `POST /v1/metering/events` callback) once the
|
|
443
|
+
response is on the wire, or from a background job holding the context. It is
|
|
444
|
+
HMAC-attested and carries the same served identity, writes the sole billable
|
|
445
|
+
row for the reported units, and never mutates real-time enforcement windows —
|
|
446
|
+
so units unknown at admission cannot be hard-enforced.
|
|
299
447
|
|
|
300
448
|
## Learn more
|
|
301
449
|
|
package/dist/adapters/express.js
CHANGED
|
@@ -24,6 +24,7 @@ function statusForCode(code) {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
// src/core/permissions.ts
|
|
27
|
+
var WILDCARD = "*";
|
|
27
28
|
var FartherShorePermissionError = class extends Error {
|
|
28
29
|
code = "permission_denied";
|
|
29
30
|
status = 403;
|
|
@@ -35,6 +36,31 @@ var FartherShorePermissionError = class extends Error {
|
|
|
35
36
|
this.requiredPermission = requiredPermission;
|
|
36
37
|
}
|
|
37
38
|
};
|
|
39
|
+
function permissionSatisfies(required, granted) {
|
|
40
|
+
if (granted === void 0) return true;
|
|
41
|
+
if (granted.includes(WILDCARD)) return true;
|
|
42
|
+
if (granted.includes(required)) return true;
|
|
43
|
+
const idx = required.indexOf(":");
|
|
44
|
+
if (idx > 0 && idx < required.length - 1) {
|
|
45
|
+
const subject = required.slice(0, idx);
|
|
46
|
+
if (subject !== WILDCARD && granted.includes(`${subject}:${WILDCARD}`))
|
|
47
|
+
return true;
|
|
48
|
+
if (required.slice(idx + 1) === WILDCARD && subject !== WILDCARD) {
|
|
49
|
+
const prefix = `${subject}:`;
|
|
50
|
+
return granted.some((permission) => permission.startsWith(prefix));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
function hasPermission(ctx, key) {
|
|
56
|
+
if (ctx.permissions === void 0) return false;
|
|
57
|
+
return permissionSatisfies(key, ctx.permissions);
|
|
58
|
+
}
|
|
59
|
+
function requirePermission(ctx, key) {
|
|
60
|
+
if (!hasPermission(ctx, key)) {
|
|
61
|
+
throw new FartherShorePermissionError(key);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
38
64
|
|
|
39
65
|
// src/generated/runtime-contract.ts
|
|
40
66
|
var RUNTIME_BODY_HASH_CONTRACT = {
|
|
@@ -73,14 +99,17 @@ async function runMiddleware(fs, options, req, res, next) {
|
|
|
73
99
|
const contentType = headerValue(req.headers, "content-type");
|
|
74
100
|
const streamingExempt = isStreamingExempt(contentType);
|
|
75
101
|
const body = streamingExempt ? null : extractRawBody(req);
|
|
76
|
-
const ctx = await fs.verifyRequest(
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
102
|
+
const ctx = await fs.verifyRequest(
|
|
103
|
+
{
|
|
104
|
+
method: req.method,
|
|
105
|
+
path,
|
|
106
|
+
query,
|
|
107
|
+
headers: req.headers,
|
|
108
|
+
body,
|
|
109
|
+
streamingExempt
|
|
110
|
+
},
|
|
111
|
+
{ responseSink: expressResponseSink(res) }
|
|
112
|
+
);
|
|
84
113
|
req.fartherShore = ctx;
|
|
85
114
|
stripFartherShoreHeaders(req);
|
|
86
115
|
next();
|
|
@@ -88,6 +117,16 @@ async function runMiddleware(fs, options, req, res, next) {
|
|
|
88
117
|
fail(res, error, options, req);
|
|
89
118
|
}
|
|
90
119
|
}
|
|
120
|
+
function expressResponseSink(res) {
|
|
121
|
+
return {
|
|
122
|
+
canStampHeaders: () => res.headersSent !== true,
|
|
123
|
+
stampHeaders: (headers) => {
|
|
124
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
125
|
+
res.setHeader(name, value);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
91
130
|
function fail(res, error, options, req) {
|
|
92
131
|
const code = error instanceof FartherShoreError ? error.code : "bad_signature";
|
|
93
132
|
const status = error instanceof FartherShoreError ? error.status : 401;
|
|
@@ -128,7 +167,12 @@ function stripFartherShoreHeaders(req) {
|
|
|
128
167
|
withRaw.rawHeaders = cleaned;
|
|
129
168
|
}
|
|
130
169
|
}
|
|
131
|
-
function createExpressHandler(
|
|
170
|
+
function createExpressHandler(optionsOrHandler, maybeHandler) {
|
|
171
|
+
const options = typeof optionsOrHandler === "function" ? {} : optionsOrHandler;
|
|
172
|
+
const handler = typeof optionsOrHandler === "function" ? optionsOrHandler : maybeHandler;
|
|
173
|
+
if (typeof handler !== "function") {
|
|
174
|
+
throw new TypeError("fs.handler(options, cb) requires a handler callback");
|
|
175
|
+
}
|
|
132
176
|
return (req, res, next) => {
|
|
133
177
|
const ctx = req.fartherShore;
|
|
134
178
|
if (!ctx) {
|
|
@@ -139,10 +183,22 @@ function createExpressHandler(handler) {
|
|
|
139
183
|
res.status(401).json({ error: "principal_required" });
|
|
140
184
|
return;
|
|
141
185
|
}
|
|
186
|
+
if (!ctx.signedContext) {
|
|
187
|
+
res.status(401).json({ error: "context_unverified" });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
142
190
|
const verified = ctx;
|
|
143
|
-
void Promise.resolve().then(
|
|
144
|
-
|
|
145
|
-
|
|
191
|
+
void Promise.resolve().then(() => {
|
|
192
|
+
if (options.permission !== void 0) {
|
|
193
|
+
requirePermission(verified, options.permission);
|
|
194
|
+
}
|
|
195
|
+
return handler(
|
|
196
|
+
verified,
|
|
197
|
+
req,
|
|
198
|
+
res,
|
|
199
|
+
next
|
|
200
|
+
);
|
|
201
|
+
}).catch((error) => failHandler(res, next, error));
|
|
146
202
|
};
|
|
147
203
|
}
|
|
148
204
|
function failHandler(res, next, error) {
|