@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 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.20.0`.** Pre-1.0: minor releases may include breaking changes, so
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, withUsage } from "@farthershore/backend";
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
- // Report usage on the way out no extra network call.
52
- return withUsage(request, Response.json(result), {
53
- tokens_used: result.tokensUsed,
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
- ## Response-bound usage reporting
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
- Use `withUsage()` (or the builder-style `createUsage()`) when you know the usage
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
- import { withUsage } from "@farthershore/backend";
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
- export async function POST(request: Request) {
142
- const result = await runWorkflow(await request.json());
143
- return withUsage(
144
- request,
145
- Response.json(result),
146
- { tokens_used: result.tokensUsed },
147
- {
148
- measureContext: { model: result.model },
149
- creditUnitsConsumed: { credits: result.creditsUsed },
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
- - `measureContext` is free-form pricing/analytics context persisted with the
156
- usage event.
157
- - `creditUnitsConsumed` is a numeric map for credit-wallet style businesses; keys
158
- and values are validated locally before signing.
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
- The meter keys you report (e.g. `tokens_used`) must match meters declared in your
161
- business. Request-count style limits are enforced by the gateway and need no
162
- backend code.
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
- ## Async / background usage
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
- Use `fs.meter(meter, qty, { requestId, routeId })` only for usage that is **not**
167
- tied to a gateway response background jobs, deferred billing, batch work. It
168
- enqueues an idempotent event and POSTs it to the platform's metering endpoint.
169
- Delivery is at-least-once; the event idempotency key keeps ingestion safe.
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
- ## Post-stream usage reporting
296
+ ### Non-JS backends
174
297
 
175
- Use `fs.reportUsage()` when a gateway request streams its response and the
176
- billable total is known only after the stream completes. Declare that route
177
- with `postStreamBilling: true`, then report from the request-scoped verified
178
- context so the SDK retains the attested subscription subject:
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
- const context = await fs.verifyRequest({ method, path, query, headers, body });
311
+ import { createWebhookHandler } from "@farthershore/backend/webhooks";
182
312
 
183
- await streamResponse(context);
184
- await context.reportUsage?.({
185
- meters: { output_tokens: 1280 },
186
- measureContext: { model: "apsu-1" },
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
- The callback is HMAC-attested and requires a subscription subject. Core binds it
191
- to the immutable gateway request row and writes one billable `UsageEvent` with
192
- the gateway-known request units merged with reported actual units, the served
193
- plan, served route, and served request time. The gateway evidence row is
194
- unbilled and omits only response-derived dimensions. This is a billing-only channel: it
195
- does not settle or mutate Durable Object enforcement windows, so units that are
196
- unknown at admission cannot be hard-enforced. Knowable request dimensions still
197
- follow the normal admission path and are billed once on the merged callback.
198
-
199
- Gateway evidence is published asynchronously. If the callback arrives first,
200
- Core parks the verified payload and the SDK retries only
201
- `post_stream_request_not_found` with bounded backoff, reusing the exact signed
202
- payload and nonce. A maintenance pass binds any remaining parked callback once
203
- evidence lands and durably alerts if it expires. The SDK method is best-effort and resolves
204
- `{ ok: false, reason }` instead of rejecting, so handle or log a failed report
205
- according to your service's delivery policy.
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
- | `withUsage()` / `createUsage()` | Response-bound usage reporting (no network call). |
274
- | `computeMeteringHeaders()` | Metering headers as a plain map — never throws. |
275
- | `fs.meter(meter, qty, opts)` | Async/background usage event. |
276
- | `fs.reportUsage(input)` | Attested post-stream usage callback. |
277
- | `fs.health()` / `fs.shutdown()` | Health report and graceful shutdown. |
278
- | `FartherShoreError`, `MeteringError` | Typed errors. |
279
- | `@farthershore/backend/testing` | Dev-mode + persona test harness (dev/test only). |
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 channels
285
-
286
- Three usage channels exist and are **not** interchangeable:
287
-
288
- - **Response-bound** (`withUsage` / `createUsage` / `computeMeteringHeaders`) is
289
- the attested, request-bound settlement channel: the gateway verifies the HMAC
290
- and settles the reported units against the request's lease in the same
291
- lifecycle. Wire recipe (any language): [`docs/response-metering-wire.md`](docs/response-metering-wire.md).
292
- - **Post-stream** (`fs.reportUsage` or the request-scoped
293
- `context.reportUsage`) is attested and request-bound for streaming routes
294
- declared with `postStreamBilling: true`. It writes the sole billable row for
295
- gateway-known plus reported actual units and never mutates real-time
296
- enforcement windows.
297
- - **Background** (`fs.meter`) is a billing-only, unattested, post-cycle tally for
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
 
@@ -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
- method: req.method,
78
- path,
79
- query,
80
- headers: req.headers,
81
- body,
82
- streamingExempt
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(handler) {
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
- () => handler(verified, req, res, next)
145
- ).catch((error) => failHandler(res, next, error));
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) {