@farthershore/backend 0.19.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 +139 -0
- package/README.md +267 -91
- package/dist/adapters/express.js +68 -12
- package/dist/generated/runtime-contract.js +21 -236
- package/dist/index.js +837 -465
- package/dist/internal/index.js +587 -0
- package/dist/testing/index.js +935 -353
- package/dist/types/adapters/express.d.ts +32 -3
- package/dist/types/core/bootstrap.d.ts +8 -0
- package/dist/types/core/deadline.d.ts +80 -0
- package/dist/types/core/jwks.d.ts +42 -7
- package/dist/types/core/permissions.d.ts +25 -13
- package/dist/types/core/post-stream-usage.d.ts +23 -4
- package/dist/types/core/replay-protection.d.ts +28 -0
- package/dist/types/core/report.d.ts +133 -0
- package/dist/types/core/runtime.d.ts +44 -20
- 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 +30 -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 +21 -12
- package/dist/types/core/metering.d.ts +0 -68
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,145 @@ 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
|
+
|
|
110
|
+
## [0.20.0] - 2026-08-07
|
|
111
|
+
|
|
112
|
+
### Changed
|
|
113
|
+
|
|
114
|
+
- **An injected shared nonce store now fails closed on outage.** If you supply
|
|
115
|
+
`nonceStore`, a throw from it rejects the request instead of falling through
|
|
116
|
+
to "not a replay" — otherwise knocking that store over would switch
|
|
117
|
+
one-time-use enforcement off entirely. No effect on the default in-memory
|
|
118
|
+
cache, which cannot fail this way.
|
|
119
|
+
|
|
120
|
+
`fs.replayProtection()` reports whether enforcement is `"shared"`
|
|
121
|
+
(cross-replica) or `"single-instance"` (this process), for boot logging.
|
|
122
|
+
|
|
123
|
+
Replay protection remains **zero-config**: the signature's ~305s time window
|
|
124
|
+
is the always-on defense and needs nothing from you. A shared store is purely
|
|
125
|
+
an opt-in upgrade for builders who want one-time-use enforced across replicas
|
|
126
|
+
rather than within each one. (FAR-760)
|
|
127
|
+
|
|
128
|
+
### Security
|
|
129
|
+
|
|
130
|
+
- **A stale JWKS key set is no longer trusted forever.** The client held the
|
|
131
|
+
last successful fetch indefinitely, so during a prolonged Core/JWKS outage a
|
|
132
|
+
revoked signing key kept verifying. Stale-while-revalidate now has a ceiling:
|
|
133
|
+
fresh (<5m) → soft-stale (<15m, still served if refresh fails) → hard-stale
|
|
134
|
+
(fail closed). Adds an `onObservation` hook reporting
|
|
135
|
+
`fresh` / `soft_stale` / `hard_stale` / `cold`. (FAR-759)
|
|
136
|
+
|
|
137
|
+
### Fixed
|
|
138
|
+
|
|
139
|
+
- **Every outbound SDK call now has a deadline.** Bootstrap, JWKS refresh,
|
|
140
|
+
metering flush, post-stream usage, the health heartbeat and the drift report
|
|
141
|
+
all awaited without an `AbortSignal`; a half-open connection could wedge
|
|
142
|
+
request verification, boot, readiness, or usage reporting indefinitely.
|
|
143
|
+
Caller cancellation composes with the SDK deadline, timeouts are classifiable,
|
|
144
|
+
and parsed response bodies are byte-bounded. (FAR-785)
|
|
145
|
+
|
|
7
146
|
## [0.19.0]
|
|
8
147
|
|
|
9
148
|
### Added
|
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
|
|
|
@@ -99,82 +110,246 @@ wrong-route / body-hash-mismatch / replayed-nonce / unknown-key /
|
|
|
99
110
|
keys-unavailable) throws a typed `FartherShoreError` that maps to **HTTP 401**
|
|
100
111
|
(413 for oversized bodies). There is no fail-open path.
|
|
101
112
|
|
|
102
|
-
|
|
113
|
+
### Replay protection (nothing to configure)
|
|
114
|
+
|
|
115
|
+
A signed request is one-time-use, and two things enforce that:
|
|
116
|
+
|
|
117
|
+
- **A time window, always on.** The gateway signs a timestamp into the request;
|
|
118
|
+
anything older than ~305s (replay window + clock skew) is rejected outright.
|
|
119
|
+
This holds for every deployment shape and needs nothing from you.
|
|
120
|
+
- **A seen-id list.** Inside that window, the SDK remembers each
|
|
121
|
+
`X-Fs-Request-Id` it has accepted and rejects a second sighting. The default
|
|
122
|
+
list is in-memory and per-process.
|
|
103
123
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
the
|
|
124
|
+
That default is deliberate: running more than one replica means each has its own
|
|
125
|
+
list, so a captured request could be replayed once per replica **within the
|
|
126
|
+
~305s window** — and closing that would mean asking you to provision and operate
|
|
127
|
+
a distributed cache. We would rather keep your setup to one environment variable
|
|
128
|
+
and let the time window bound the exposure.
|
|
129
|
+
|
|
130
|
+
If you do want one-time-use enforced across replicas, inject a shared store:
|
|
109
131
|
|
|
110
132
|
```ts
|
|
111
|
-
|
|
133
|
+
initFromEnv({ nonceStore: myStore }); // checkAndRemember(id) => boolean | Promise<boolean>
|
|
134
|
+
```
|
|
112
135
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
136
|
+
Make it TTL-bound to the signature validity window. If that store goes down,
|
|
137
|
+
requests fail **closed** — it never degrades to "not a replay".
|
|
138
|
+
`fs.replayProtection()` reports which mode is active (`"shared"` |
|
|
139
|
+
`"single-instance"`) if you want it in your boot logs.
|
|
140
|
+
|
|
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.
|
|
171
|
+
|
|
172
|
+
### Checking permissions
|
|
173
|
+
|
|
174
|
+
```ts
|
|
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
|
+
```
|
|
196
|
+
|
|
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;
|
|
124
236
|
}
|
|
125
237
|
```
|
|
126
238
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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.
|
|
131
272
|
|
|
132
|
-
The meter keys you report
|
|
133
|
-
|
|
134
|
-
backend code.
|
|
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.
|
|
135
276
|
|
|
136
|
-
|
|
277
|
+
### Quotes: the one bounded money channel
|
|
137
278
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
Delivery is at-least-once; the event idempotency key keeps ingestion safe.
|
|
142
|
-
Background usage is tallied and billed after the cycle, not enforced in
|
|
143
|
-
real time.
|
|
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).
|
|
144
282
|
|
|
145
|
-
|
|
283
|
+
```ts
|
|
284
|
+
await fartherShore.report({
|
|
285
|
+
meter: "jobs",
|
|
286
|
+
values: { jobs: 1 },
|
|
287
|
+
quote: { currency: "usd", amountNanos: "250000000" }, // $0.25
|
|
288
|
+
});
|
|
289
|
+
```
|
|
290
|
+
|
|
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.
|
|
295
|
+
|
|
296
|
+
### Non-JS backends
|
|
297
|
+
|
|
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
|
|
146
304
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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.
|
|
151
309
|
|
|
152
310
|
```ts
|
|
153
|
-
|
|
311
|
+
import { createWebhookHandler } from "@farthershore/backend/webhooks";
|
|
154
312
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
+
},
|
|
159
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;
|
|
160
333
|
```
|
|
161
334
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
`{
|
|
177
|
-
|
|
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.
|
|
178
353
|
|
|
179
354
|
## Lifecycle
|
|
180
355
|
|
|
@@ -237,37 +412,38 @@ See `templates/3-simulated-authz.test.ts` for the full fail-closed + usage flow.
|
|
|
237
412
|
|
|
238
413
|
## Key exports
|
|
239
414
|
|
|
240
|
-
| Export | Purpose
|
|
241
|
-
| ------------------------------------ |
|
|
242
|
-
| `fartherShore.initFromEnv()` | Create the runtime instance from `FS_RUNTIME_TOKEN`.
|
|
243
|
-
| `fs.middleware()` | Express fail-closed verify → `req.fartherShore`.
|
|
244
|
-
| `fs.verifyRequest({...})` | Framework-neutral request verification.
|
|
245
|
-
| `
|
|
246
|
-
| `
|
|
247
|
-
| `
|
|
248
|
-
| `
|
|
249
|
-
| `
|
|
250
|
-
| `
|
|
251
|
-
|
|
|
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). |
|
|
252
429
|
|
|
253
430
|
A subpath export, `@farthershore/backend/express`, exposes the Express adapter
|
|
254
431
|
types directly if you prefer to wire the middleware yourself.
|
|
255
432
|
|
|
256
|
-
## Metering
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
- **
|
|
261
|
-
the attested, request-bound settlement channel
|
|
262
|
-
and settles the reported units against the request's lease
|
|
263
|
-
lifecycle. Wire recipe (any language):
|
|
264
|
-
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
enforcement windows
|
|
269
|
-
|
|
270
|
-
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.
|
|
271
447
|
|
|
272
448
|
## Learn more
|
|
273
449
|
|