@farthershore/backend 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -18
- package/dist/generated/runtime-contract.js +39 -1
- package/dist/index.js +352 -53
- package/dist/types/core/bootstrap.d.ts +1 -1
- package/dist/types/core/runtime.d.ts +20 -1
- package/dist/types/generated/runtime-contract.d.ts +38 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/reflect/index.d.ts +16 -0
- package/dist/types/reflect/reconcile.d.ts +42 -0
- package/dist/types/reflect/route-canon.d.ts +20 -0
- package/dist/types/response-metering.d.ts +32 -0
- package/dist/types/runtime-types.d.ts +12 -1
- package/package.json +6 -2
- package/dist/types/generated/metering-contract.d.ts +0 -36
- package/dist/types/legacy/metering.d.ts +0 -20
package/README.md
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
# @farthershore/backend
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
(`FS_RUNTIME_TOKEN`), and Farther Shore
|
|
5
|
-
request
|
|
6
|
-
your backend.
|
|
3
|
+
Runtime metering and gateway-verification SDK for builder upstreams. Install one
|
|
4
|
+
package, set one token (`FS_RUNTIME_TOKEN`), and Farther Shore handles signed
|
|
5
|
+
gateway-to-upstream request verification plus response-bound usage reporting.
|
|
7
6
|
|
|
8
7
|
## Install
|
|
9
8
|
|
|
@@ -11,20 +10,43 @@ your backend.
|
|
|
11
10
|
npm install @farthershore/backend
|
|
12
11
|
```
|
|
13
12
|
|
|
14
|
-
## Quick start (
|
|
13
|
+
## Quick start (Fetch-compatible handlers)
|
|
15
14
|
|
|
16
15
|
```ts
|
|
17
|
-
import { fartherShore } from "@farthershore/backend";
|
|
16
|
+
import { fartherShore, withUsage } from "@farthershore/backend";
|
|
18
17
|
|
|
19
18
|
const fs = fartherShore.initFromEnv(); // derives everything from FS_RUNTIME_TOKEN
|
|
20
19
|
|
|
21
|
-
|
|
20
|
+
export async function POST(request: Request) {
|
|
21
|
+
const url = new URL(request.url);
|
|
22
|
+
const body = new Uint8Array(await request.clone().arrayBuffer());
|
|
23
|
+
|
|
24
|
+
await fs.verifyRequest({
|
|
25
|
+
method: request.method,
|
|
26
|
+
path: url.pathname,
|
|
27
|
+
query: url.search,
|
|
28
|
+
headers: request.headers,
|
|
29
|
+
body,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const result = await runWorkflow(await request.json());
|
|
33
|
+
return withUsage(request, Response.json(result), {
|
|
34
|
+
tokens_used: result.tokensUsed,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Express verification
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { fartherShore } from "@farthershore/backend";
|
|
43
|
+
|
|
44
|
+
const fs = fartherShore.initFromEnv();
|
|
45
|
+
|
|
46
|
+
app.use(fs.middleware()); // fail-closed verify -> req.fartherShore
|
|
22
47
|
|
|
23
48
|
app.post("/v1/runs", async (req, res) => {
|
|
24
49
|
const result = await runWorkflow(req.body);
|
|
25
|
-
await fs.meter("tokens", result.tokensUsed, {
|
|
26
|
-
requestId: req.fartherShore.requestId,
|
|
27
|
-
});
|
|
28
50
|
res.json(result);
|
|
29
51
|
});
|
|
30
52
|
|
|
@@ -35,7 +57,7 @@ process.on("SIGTERM", () => void fs.shutdown());
|
|
|
35
57
|
## What `initFromEnv()` derives
|
|
36
58
|
|
|
37
59
|
The builder configures exactly one thing: `FS_RUNTIME_TOKEN`. Everything else —
|
|
38
|
-
product/
|
|
60
|
+
product/upstream/environment ids, the JWKS url, the metering endpoint and
|
|
39
61
|
credential, verification config, transport — is fetched from
|
|
40
62
|
`POST /v1/runtime/bootstrap` and cached in memory (refreshed lazily).
|
|
41
63
|
|
|
@@ -43,7 +65,7 @@ credential, verification config, transport — is fetched from
|
|
|
43
65
|
|
|
44
66
|
`fs.middleware()` (Express) and the framework-neutral
|
|
45
67
|
`fs.verifyRequest({ method, path, query, headers, body })` recompute the
|
|
46
|
-
[canonical signing string](../../docs/superpowers/specs/
|
|
68
|
+
[canonical signing string](../../docs/superpowers/specs/metering-runtime-spec.md)
|
|
47
69
|
from the actual request and verify the gateway's Ed25519 signature against a
|
|
48
70
|
JWKS-resolved public key. The plaintext `X-FS-*` headers are **untrusted** —
|
|
49
71
|
identity comes only from a signature whose claims match the real request.
|
|
@@ -53,18 +75,50 @@ wrong-route / body-hash-mismatch / replayed-nonce / unknown-kid /
|
|
|
53
75
|
jwks-unavailable) returns a typed `FartherShoreError` → **HTTP 401** (413 for
|
|
54
76
|
oversized bodies). There is no fail-open branch.
|
|
55
77
|
|
|
56
|
-
##
|
|
78
|
+
## Response-bound metering
|
|
79
|
+
|
|
80
|
+
Use `withUsage()` or `createUsage()` when usage is known while returning a
|
|
81
|
+
gateway-handled response. These helpers make no network call. They sign dynamic
|
|
82
|
+
usage into internal response headers, and the gateway verifies, settles, and
|
|
83
|
+
strips those headers before the subscriber receives the response.
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { withUsage } from "@farthershore/backend";
|
|
87
|
+
|
|
88
|
+
export async function POST(request: Request) {
|
|
89
|
+
const result = await runWorkflow(await request.json());
|
|
90
|
+
return withUsage(
|
|
91
|
+
request,
|
|
92
|
+
Response.json(result),
|
|
93
|
+
{ tokens_used: result.tokensUsed },
|
|
94
|
+
{
|
|
95
|
+
measureContext: { model: result.model },
|
|
96
|
+
creditUnitsConsumed: { credits: result.creditsUsed },
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`measureContext` is free-form pricing/analytics context persisted with the usage
|
|
103
|
+
event. `creditUnitsConsumed` is a numeric map for credit-wallet style products;
|
|
104
|
+
keys and values are validated locally before signing. The gateway accepts actual
|
|
105
|
+
request-bound usage only from the `x-fs-metering` response-header contract signed
|
|
106
|
+
with `FS_RUNTIME_TOKEN`; retired `x-fs-usage` actual-report headers are stripped
|
|
107
|
+
and ignored.
|
|
108
|
+
|
|
109
|
+
## Async/background metering
|
|
57
110
|
|
|
58
|
-
`fs.meter(meter, qty, { requestId, routeId })`
|
|
111
|
+
Use `fs.meter(meter, qty, { requestId, routeId })` only for async/background
|
|
112
|
+
usage that is not tied to a gateway response. It enqueues an idempotent event and
|
|
59
113
|
POSTs it to `/v1/metering/events` with a reusable bearer credential. Delivery is
|
|
60
114
|
at-least-once; the `event_id` idempotency key keeps core's ingest safe. Values
|
|
61
115
|
are tallied/billed post-cycle, not real-time enforced.
|
|
62
116
|
|
|
63
|
-
|
|
117
|
+
The meter key is not hardcoded by the SDK. It must match a dynamic meter declared
|
|
118
|
+
in the product contract, such as `tokens_used` in the Product SDK examples.
|
|
64
119
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
prefer `fs.meter("tokens", n)` over the response-header signer.
|
|
120
|
+
Product route defaults such as request counts remain platform-managed and
|
|
121
|
+
require no upstream code.
|
|
68
122
|
|
|
69
123
|
The signing primitives and contract constants are re-exported from
|
|
70
124
|
`@farthershore/contracts/runtime`, the language-neutral source of truth every
|
|
@@ -180,7 +180,44 @@ var RUNTIME_METERING_CONTRACT = {
|
|
|
180
180
|
delivery: "at-least-once",
|
|
181
181
|
billingOnly: true,
|
|
182
182
|
realtimeEnforced: false,
|
|
183
|
-
trustModel: "
|
|
183
|
+
trustModel: "upstream-reported values are NOT cryptographically attested; a buggy or compromised upstream can self-report arbitrary values for its OWN product only. Core enforces allowedMeters/allowedRoutes from the authoritative token record at ingest, applies a per-event sanity max (perEventMax), and raises an implausible-volume alert."
|
|
184
|
+
};
|
|
185
|
+
var RUNTIME_RESPONSE_METERING_CONTRACT = {
|
|
186
|
+
headers: {
|
|
187
|
+
payload: "x-fs-metering",
|
|
188
|
+
signature: "x-fs-metering-sig",
|
|
189
|
+
token: "x-fs-metering-token"
|
|
190
|
+
},
|
|
191
|
+
token: {
|
|
192
|
+
environmentVariable: "FS_RUNTIME_TOKEN",
|
|
193
|
+
presentation: "x-fs-metering-token",
|
|
194
|
+
storage: "sha256-hash-only"
|
|
195
|
+
},
|
|
196
|
+
signature: {
|
|
197
|
+
algorithm: "HMAC-SHA256",
|
|
198
|
+
encoding: "base64url",
|
|
199
|
+
input: "payload-json",
|
|
200
|
+
secret: "presented-runtime-token"
|
|
201
|
+
},
|
|
202
|
+
payload: {
|
|
203
|
+
method: "string",
|
|
204
|
+
path: "string",
|
|
205
|
+
rawDimsUnits: "Record<string, number>",
|
|
206
|
+
measureContext: "Record<string, unknown>?",
|
|
207
|
+
creditUnitsConsumed: "Record<string, number>?"
|
|
208
|
+
},
|
|
209
|
+
errors: {
|
|
210
|
+
missingToken: "missing_token",
|
|
211
|
+
invalidMeterKey: "invalid_meter_key",
|
|
212
|
+
invalidMeterValue: "invalid_meter_value"
|
|
213
|
+
},
|
|
214
|
+
httpAdapter: {
|
|
215
|
+
input: "Request",
|
|
216
|
+
output: "Response",
|
|
217
|
+
networkCalls: false,
|
|
218
|
+
preserves: ["body", "headers", "status", "statusText"],
|
|
219
|
+
gatewayStripsInternalHeaders: true
|
|
220
|
+
}
|
|
184
221
|
};
|
|
185
222
|
var RUNTIME_HEALTH_CONTRACT = {
|
|
186
223
|
endpoint: "/v1/runtime/health",
|
|
@@ -232,6 +269,7 @@ export {
|
|
|
232
269
|
RUNTIME_HEALTH_CONTRACT,
|
|
233
270
|
RUNTIME_METERING_CONTRACT,
|
|
234
271
|
RUNTIME_REPLAY_CONTRACT,
|
|
272
|
+
RUNTIME_RESPONSE_METERING_CONTRACT,
|
|
235
273
|
RUNTIME_SIGNING_CONTRACT,
|
|
236
274
|
RUNTIME_TOKEN_CONTRACT,
|
|
237
275
|
RUNTIME_TOKEN_ENV,
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,188 @@
|
|
|
1
1
|
import { createRequire as __createRequire } from "node:module";const require=__createRequire(import.meta.url);
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res) => function __init() {
|
|
5
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
6
|
+
};
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// src/reflect/route-canon.ts
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
function normalizeSegment(seg) {
|
|
15
|
+
if (seg === "**") return "{rest}";
|
|
16
|
+
if (seg === "*") return "{splat}";
|
|
17
|
+
if (seg.startsWith(":")) return `{${seg.slice(1)}}`;
|
|
18
|
+
if (seg.startsWith("[") && seg.endsWith("]")) return `{${seg.slice(1, -1)}}`;
|
|
19
|
+
return seg;
|
|
20
|
+
}
|
|
21
|
+
function normalizePath(path) {
|
|
22
|
+
if (path === "/" || path === "") return "/";
|
|
23
|
+
const lead = path.startsWith("/");
|
|
24
|
+
const out = path.split("/").filter((s) => s.length > 0).map(normalizeSegment).join("/");
|
|
25
|
+
return lead ? `/${out}` : out;
|
|
26
|
+
}
|
|
27
|
+
function pathSpecificity(path) {
|
|
28
|
+
return path.split("/").filter(
|
|
29
|
+
(s) => s.length > 0 && !s.startsWith("{") && s !== "*" && s !== "**"
|
|
30
|
+
).length;
|
|
31
|
+
}
|
|
32
|
+
function canonicalSort(routes) {
|
|
33
|
+
return [...routes].sort(
|
|
34
|
+
(a, b) => pathSpecificity(b.path) - pathSpecificity(a.path) || a.method.localeCompare(b.method) || a.path.localeCompare(b.path)
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
function surfaceHash(routes) {
|
|
38
|
+
const canonical = canonicalSort(routes).map(
|
|
39
|
+
(r) => `${r.method.toUpperCase()} ${r.path}`
|
|
40
|
+
);
|
|
41
|
+
const h = createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
|
|
42
|
+
return `sha256:${h}`;
|
|
43
|
+
}
|
|
44
|
+
var init_route_canon = __esm({
|
|
45
|
+
"src/reflect/route-canon.ts"() {
|
|
46
|
+
"use strict";
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// src/reflect/index.ts
|
|
51
|
+
var reflect_exports = {};
|
|
52
|
+
__export(reflect_exports, {
|
|
53
|
+
reflectRoutes: () => reflectRoutes,
|
|
54
|
+
reflectRoutesDetailed: () => reflectRoutesDetailed
|
|
55
|
+
});
|
|
56
|
+
function methodsOf(route) {
|
|
57
|
+
const out = /* @__PURE__ */ new Set();
|
|
58
|
+
if (route.methods) {
|
|
59
|
+
for (const [m, on] of Object.entries(route.methods))
|
|
60
|
+
if (on) out.add(m.toUpperCase());
|
|
61
|
+
}
|
|
62
|
+
for (const s of route.stack ?? []) {
|
|
63
|
+
if (typeof s.method === "string") out.add(s.method.toUpperCase());
|
|
64
|
+
}
|
|
65
|
+
return [...out].filter((m) => VALID_METHODS.has(m));
|
|
66
|
+
}
|
|
67
|
+
function rootStack(app) {
|
|
68
|
+
const a = app;
|
|
69
|
+
return a?.router?.stack ?? a?._router?.stack;
|
|
70
|
+
}
|
|
71
|
+
function prefixFromRegexp(re) {
|
|
72
|
+
if (!re) return void 0;
|
|
73
|
+
let s = re.source;
|
|
74
|
+
if (s === "^\\/?$" || s === "^\\/") return "";
|
|
75
|
+
s = s.replace(/^\^/, "").replace(/\\\/\?\(\?=\\\/\|\$\)$/, "").replace(/\(\?=\\\/\|\$\)$/, "").replace(/\\\/\?$/, "").replace(/\$$/, "");
|
|
76
|
+
s = s.replace(/\\\//g, "/");
|
|
77
|
+
if (/[()?:*+\[\]|]/.test(s)) return void 0;
|
|
78
|
+
return s.startsWith("/") ? s : `/${s}`;
|
|
79
|
+
}
|
|
80
|
+
function walk(stack, prefix, out, counters) {
|
|
81
|
+
for (const layer of stack) {
|
|
82
|
+
if (layer.route && typeof layer.route.path === "string") {
|
|
83
|
+
const path = normalizePath(`${prefix}${layer.route.path}`);
|
|
84
|
+
for (const method of methodsOf(layer.route)) {
|
|
85
|
+
out.push({ method, path });
|
|
86
|
+
}
|
|
87
|
+
} else if (layer.handle?.stack && Array.isArray(layer.handle.stack)) {
|
|
88
|
+
const sub = prefixFromRegexp(layer.regexp);
|
|
89
|
+
if (sub === void 0 && layer.name === "router") {
|
|
90
|
+
counters.unreflectable += layer.handle.stack.filter(
|
|
91
|
+
(l) => l.route
|
|
92
|
+
).length;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
walk(layer.handle.stack, `${prefix}${sub ?? ""}`, out, counters);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function reflectRoutesDetailed(app, _opts) {
|
|
100
|
+
const stack = rootStack(app);
|
|
101
|
+
if (!stack) return { routes: [], unreflectable: 0 };
|
|
102
|
+
const raw = [];
|
|
103
|
+
const counters = { unreflectable: 0 };
|
|
104
|
+
walk(stack, "", raw, counters);
|
|
105
|
+
const seen = /* @__PURE__ */ new Set();
|
|
106
|
+
const routes = [];
|
|
107
|
+
for (const r of raw) {
|
|
108
|
+
if (r.method === "HEAD") continue;
|
|
109
|
+
const key2 = `${r.method} ${r.path}`;
|
|
110
|
+
if (seen.has(key2)) continue;
|
|
111
|
+
seen.add(key2);
|
|
112
|
+
routes.push(r);
|
|
113
|
+
}
|
|
114
|
+
return { routes, unreflectable: counters.unreflectable };
|
|
115
|
+
}
|
|
116
|
+
function reflectRoutes(app, opts) {
|
|
117
|
+
return reflectRoutesDetailed(app, opts).routes;
|
|
118
|
+
}
|
|
119
|
+
var VALID_METHODS;
|
|
120
|
+
var init_reflect = __esm({
|
|
121
|
+
"src/reflect/index.ts"() {
|
|
122
|
+
"use strict";
|
|
123
|
+
init_route_canon();
|
|
124
|
+
VALID_METHODS = /* @__PURE__ */ new Set([
|
|
125
|
+
"GET",
|
|
126
|
+
"POST",
|
|
127
|
+
"PUT",
|
|
128
|
+
"PATCH",
|
|
129
|
+
"DELETE",
|
|
130
|
+
"OPTIONS"
|
|
131
|
+
]);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// src/reflect/reconcile.ts
|
|
136
|
+
var reconcile_exports = {};
|
|
137
|
+
__export(reconcile_exports, {
|
|
138
|
+
reconcileOnStartup: () => reconcileOnStartup
|
|
139
|
+
});
|
|
140
|
+
function key(r) {
|
|
141
|
+
return `${r.method.toUpperCase()} ${r.path}`;
|
|
142
|
+
}
|
|
143
|
+
async function reconcileOnStartup(args) {
|
|
144
|
+
const { reflected, bootstrap, report } = args;
|
|
145
|
+
const reflectedHash = surfaceHash(reflected);
|
|
146
|
+
const lockVersion = bootstrap.lock?.lockVersion ?? 0;
|
|
147
|
+
const inSync = bootstrap.lock?.surfaceHash === reflectedHash;
|
|
148
|
+
const result = {
|
|
149
|
+
inSync,
|
|
150
|
+
reportedDrift: false,
|
|
151
|
+
confirmedRouteIds: []
|
|
152
|
+
};
|
|
153
|
+
const servedKeys = new Set(reflected.map(key));
|
|
154
|
+
const confirmedRouteIds = (bootstrap.routes ?? []).filter(
|
|
155
|
+
(r) => r.pending === true && servedKeys.has(key(r)) && typeof r.id === "string"
|
|
156
|
+
).map((r) => r.id);
|
|
157
|
+
try {
|
|
158
|
+
if (confirmedRouteIds.length > 0) {
|
|
159
|
+
await report.confirmServed({
|
|
160
|
+
backendId: bootstrap.backendId,
|
|
161
|
+
lockVersion,
|
|
162
|
+
servedRouteIds: confirmedRouteIds
|
|
163
|
+
});
|
|
164
|
+
result.confirmedRouteIds = confirmedRouteIds;
|
|
165
|
+
}
|
|
166
|
+
if (!inSync) {
|
|
167
|
+
await report.reportDrift({
|
|
168
|
+
backendId: bootstrap.backendId,
|
|
169
|
+
lockVersion,
|
|
170
|
+
reflectedSurfaceHash: reflectedHash,
|
|
171
|
+
routes: reflected.map((r) => ({ method: r.method, path: r.path }))
|
|
172
|
+
});
|
|
173
|
+
result.reportedDrift = true;
|
|
174
|
+
}
|
|
175
|
+
} catch (err) {
|
|
176
|
+
result.reportError = err instanceof Error ? err.message : String(err);
|
|
177
|
+
}
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
var init_reconcile = __esm({
|
|
181
|
+
"src/reflect/reconcile.ts"() {
|
|
182
|
+
"use strict";
|
|
183
|
+
init_route_canon();
|
|
184
|
+
}
|
|
185
|
+
});
|
|
2
186
|
|
|
3
187
|
// src/runtime-types.ts
|
|
4
188
|
var FS_RUNTIME_TOKEN_ENV = "FS_RUNTIME_TOKEN";
|
|
@@ -10,7 +194,10 @@ var RUNTIME_TOKEN_CAPABILITIES = [
|
|
|
10
194
|
"gateway_verification",
|
|
11
195
|
"metering",
|
|
12
196
|
"health",
|
|
13
|
-
"tunnel"
|
|
197
|
+
"tunnel",
|
|
198
|
+
// Mirror of @farthershore/contracts RUNTIME_TOKEN_CAPABILITIES — kept in
|
|
199
|
+
// lockstep (golden-vector test). Opt-in capability for reporting route drift.
|
|
200
|
+
"drift_report"
|
|
14
201
|
];
|
|
15
202
|
var RUNTIME_HEADER_NAMES = {
|
|
16
203
|
signature: "x-fs-signature",
|
|
@@ -30,6 +217,7 @@ var STREAMING_EXEMPT_BODY_HASH = "STREAM";
|
|
|
30
217
|
var MAX_BODY_BYTES = 10 * 1024 * 1024;
|
|
31
218
|
|
|
32
219
|
// ../contracts/dist/runtime.js
|
|
220
|
+
var FS_RUNTIME_TOKEN_ENV2 = "FS_RUNTIME_TOKEN";
|
|
33
221
|
var RUNTIME_TOKEN_PREFIXES2 = {
|
|
34
222
|
live: "fsrt_live_",
|
|
35
223
|
test: "fsrt_test_"
|
|
@@ -41,6 +229,16 @@ function runtimeTokenKind(token) {
|
|
|
41
229
|
return "test";
|
|
42
230
|
return null;
|
|
43
231
|
}
|
|
232
|
+
var RESPONSE_METERING_HEADER_NAMES = {
|
|
233
|
+
payload: "x-fs-metering",
|
|
234
|
+
signature: "x-fs-metering-sig",
|
|
235
|
+
token: "x-fs-metering-token"
|
|
236
|
+
};
|
|
237
|
+
var RESPONSE_METERING_TOKEN_CONTRACT = {
|
|
238
|
+
environmentVariable: FS_RUNTIME_TOKEN_ENV2,
|
|
239
|
+
presentation: RESPONSE_METERING_HEADER_NAMES.token,
|
|
240
|
+
storage: "sha256-hash-only"
|
|
241
|
+
};
|
|
44
242
|
var MAX_BODY_BYTES2 = 10 * 1024 * 1024;
|
|
45
243
|
async function hashBody(body) {
|
|
46
244
|
const bytes = new Uint8Array(body);
|
|
@@ -106,13 +304,13 @@ async function importEd25519PublicKey(jwk) {
|
|
|
106
304
|
return crypto.subtle.importKey("jwk", { ...jwk, alg: void 0 }, { name: ED25519_ALGORITHM }, false, ["verify"]);
|
|
107
305
|
}
|
|
108
306
|
async function signCanonicalString(canonical, privateJwk) {
|
|
109
|
-
const
|
|
110
|
-
const sig = await crypto.subtle.sign(ED25519_ALGORITHM,
|
|
307
|
+
const key2 = await importEd25519PrivateKey(privateJwk);
|
|
308
|
+
const sig = await crypto.subtle.sign(ED25519_ALGORITHM, key2, new TextEncoder().encode(canonical));
|
|
111
309
|
return base64UrlEncode(new Uint8Array(sig));
|
|
112
310
|
}
|
|
113
311
|
async function verifyCanonicalSignature(canonical, signatureB64Url, publicJwk) {
|
|
114
|
-
const
|
|
115
|
-
return crypto.subtle.verify(ED25519_ALGORITHM,
|
|
312
|
+
const key2 = await importEd25519PublicKey(publicJwk);
|
|
313
|
+
return crypto.subtle.verify(ED25519_ALGORITHM, key2, base64UrlDecode(signatureB64Url), new TextEncoder().encode(canonical));
|
|
116
314
|
}
|
|
117
315
|
function toHex(bytes) {
|
|
118
316
|
let out = "";
|
|
@@ -334,10 +532,10 @@ var JwksClient = class {
|
|
|
334
532
|
);
|
|
335
533
|
}
|
|
336
534
|
await this.refresh();
|
|
337
|
-
const
|
|
338
|
-
if (
|
|
535
|
+
const key2 = this.keysByKid.get(kid);
|
|
536
|
+
if (key2) {
|
|
339
537
|
this.negativeKids.delete(kid);
|
|
340
|
-
return
|
|
538
|
+
return key2;
|
|
341
539
|
}
|
|
342
540
|
this.rememberMissingKid(kid);
|
|
343
541
|
throw new FartherShoreError(
|
|
@@ -391,8 +589,8 @@ var JwksClient = class {
|
|
|
391
589
|
return;
|
|
392
590
|
}
|
|
393
591
|
const next = /* @__PURE__ */ new Map();
|
|
394
|
-
for (const
|
|
395
|
-
if (typeof
|
|
592
|
+
for (const key2 of doc.keys ?? []) {
|
|
593
|
+
if (typeof key2.kid === "string") next.set(key2.kid, key2);
|
|
396
594
|
}
|
|
397
595
|
this.keysByKid = next;
|
|
398
596
|
this.fetchedAt = this.now();
|
|
@@ -469,11 +667,19 @@ var MeteringClient = class {
|
|
|
469
667
|
`meter '${meter}' is not in the token's allowedMeters`
|
|
470
668
|
);
|
|
471
669
|
}
|
|
472
|
-
if (
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
670
|
+
if (this.config.allowedRoutes.length > 0) {
|
|
671
|
+
if (!options.routeId) {
|
|
672
|
+
throw new FartherShoreError(
|
|
673
|
+
"invalid_token",
|
|
674
|
+
"routeId is required because this runtime token is route-scoped"
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
if (!this.config.allowedRoutes.includes(options.routeId)) {
|
|
678
|
+
throw new FartherShoreError(
|
|
679
|
+
"invalid_token",
|
|
680
|
+
`route '${options.routeId}' is not in the token's allowedRoutes`
|
|
681
|
+
);
|
|
682
|
+
}
|
|
477
683
|
}
|
|
478
684
|
if (this.config.perEventMax > 0 && qty > this.config.perEventMax) {
|
|
479
685
|
throw new FartherShoreError(
|
|
@@ -521,9 +727,6 @@ var MeteringClient = class {
|
|
|
521
727
|
body: JSON.stringify(event)
|
|
522
728
|
});
|
|
523
729
|
if (response.ok) return true;
|
|
524
|
-
if (response.status >= 400 && response.status < 500 && response.status !== 429) {
|
|
525
|
-
return true;
|
|
526
|
-
}
|
|
527
730
|
} catch {
|
|
528
731
|
}
|
|
529
732
|
}
|
|
@@ -1016,12 +1219,12 @@ function headerGetter(headers) {
|
|
|
1016
1219
|
return (name) => h.get(name) ?? void 0;
|
|
1017
1220
|
}
|
|
1018
1221
|
const lower = /* @__PURE__ */ new Map();
|
|
1019
|
-
for (const [
|
|
1222
|
+
for (const [key2, value] of Object.entries(
|
|
1020
1223
|
headers
|
|
1021
1224
|
)) {
|
|
1022
1225
|
if (value === void 0) continue;
|
|
1023
1226
|
lower.set(
|
|
1024
|
-
|
|
1227
|
+
key2.toLowerCase(),
|
|
1025
1228
|
Array.isArray(value) ? value[0] ?? "" : value
|
|
1026
1229
|
);
|
|
1027
1230
|
}
|
|
@@ -1029,8 +1232,8 @@ function headerGetter(headers) {
|
|
|
1029
1232
|
}
|
|
1030
1233
|
|
|
1031
1234
|
// src/core/runtime.ts
|
|
1032
|
-
var DEFAULT_CORE_URL = "https://
|
|
1033
|
-
var SDK_VERSION = "0.
|
|
1235
|
+
var DEFAULT_CORE_URL = "https://core.farthershore.com";
|
|
1236
|
+
var SDK_VERSION = "0.3.0".length > 0 ? "0.3.0" : "0.0.0-dev";
|
|
1034
1237
|
var FartherShore = class {
|
|
1035
1238
|
bootstrapClient;
|
|
1036
1239
|
fetchImpl;
|
|
@@ -1101,6 +1304,60 @@ var FartherShore = class {
|
|
|
1101
1304
|
this.bootstrapped = true;
|
|
1102
1305
|
return config;
|
|
1103
1306
|
}
|
|
1307
|
+
/**
|
|
1308
|
+
* Boot-time route reconciliation (call once, before `listen()`). Reflects the
|
|
1309
|
+
* app's real route surface, diffs it against the declared lock from bootstrap,
|
|
1310
|
+
* REPORTS drift to the platform, and CONFIRMS the declared `pending` routes
|
|
1311
|
+
* this replica serves. Fail-OPEN: never throws / never blocks boot.
|
|
1312
|
+
*
|
|
1313
|
+
* The reflection code is dynamically imported so it stays OFF the per-request
|
|
1314
|
+
* verification hot path (the runtime stays route-unaware there). The report is
|
|
1315
|
+
* an OUTBOUND backend→core call (same channel as bootstrap/metering), so it
|
|
1316
|
+
* works for every transport (public_origin / mTLS / cloudflare_tunnel).
|
|
1317
|
+
*
|
|
1318
|
+
* Returns the reconcile result (or null if reflection is unavailable / boot
|
|
1319
|
+
* reporting failed). v1 reflects Express; `app` omitted → no-op.
|
|
1320
|
+
*/
|
|
1321
|
+
async ready(app) {
|
|
1322
|
+
try {
|
|
1323
|
+
const config = await this.ensureBootstrapped();
|
|
1324
|
+
if (app === void 0) return null;
|
|
1325
|
+
const [{ reflectRoutes: reflectRoutes2 }, { reconcileOnStartup: reconcileOnStartup2 }] = await Promise.all([
|
|
1326
|
+
Promise.resolve().then(() => (init_reflect(), reflect_exports)),
|
|
1327
|
+
Promise.resolve().then(() => (init_reconcile(), reconcile_exports))
|
|
1328
|
+
]);
|
|
1329
|
+
return await reconcileOnStartup2({
|
|
1330
|
+
reflected: reflectRoutes2(app),
|
|
1331
|
+
bootstrap: {
|
|
1332
|
+
backendId: config.backend.id,
|
|
1333
|
+
lock: config.lock,
|
|
1334
|
+
routes: config.routes
|
|
1335
|
+
},
|
|
1336
|
+
report: this.buildReportSink()
|
|
1337
|
+
});
|
|
1338
|
+
} catch {
|
|
1339
|
+
return null;
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
/** Outbound report sink for `ready()` — runtime-token-authed POSTs to core. */
|
|
1343
|
+
buildReportSink() {
|
|
1344
|
+
const post = async (path, body) => {
|
|
1345
|
+
const base = this.coreUrl.replace(/\/$/, "");
|
|
1346
|
+
const res = await this.fetchImpl(`${base}${path}`, {
|
|
1347
|
+
method: "POST",
|
|
1348
|
+
headers: {
|
|
1349
|
+
"content-type": "application/json",
|
|
1350
|
+
authorization: `Bearer ${this.runtimeToken}`
|
|
1351
|
+
},
|
|
1352
|
+
body: JSON.stringify(body)
|
|
1353
|
+
});
|
|
1354
|
+
if (!res.ok) throw new Error(`runtime report ${path} -> ${res.status}`);
|
|
1355
|
+
};
|
|
1356
|
+
return {
|
|
1357
|
+
reportDrift: (report) => post("/v1/runtime/drift", report),
|
|
1358
|
+
confirmServed: (confirm) => post("/v1/runtime/health", confirm)
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1104
1361
|
/**
|
|
1105
1362
|
* Framework-neutral verification primitive. Fail-closed: throws a typed
|
|
1106
1363
|
* FartherShoreError on any verification failure. Returns the verified context.
|
|
@@ -1244,6 +1501,43 @@ var RUNTIME_ERROR_CODES = {
|
|
|
1244
1501
|
missingToken: "missing_token",
|
|
1245
1502
|
invalidToken: "invalid_token"
|
|
1246
1503
|
};
|
|
1504
|
+
var RUNTIME_RESPONSE_METERING_CONTRACT = {
|
|
1505
|
+
headers: {
|
|
1506
|
+
payload: "x-fs-metering",
|
|
1507
|
+
signature: "x-fs-metering-sig",
|
|
1508
|
+
token: "x-fs-metering-token"
|
|
1509
|
+
},
|
|
1510
|
+
token: {
|
|
1511
|
+
environmentVariable: "FS_RUNTIME_TOKEN",
|
|
1512
|
+
presentation: "x-fs-metering-token",
|
|
1513
|
+
storage: "sha256-hash-only"
|
|
1514
|
+
},
|
|
1515
|
+
signature: {
|
|
1516
|
+
algorithm: "HMAC-SHA256",
|
|
1517
|
+
encoding: "base64url",
|
|
1518
|
+
input: "payload-json",
|
|
1519
|
+
secret: "presented-runtime-token"
|
|
1520
|
+
},
|
|
1521
|
+
payload: {
|
|
1522
|
+
method: "string",
|
|
1523
|
+
path: "string",
|
|
1524
|
+
rawDimsUnits: "Record<string, number>",
|
|
1525
|
+
measureContext: "Record<string, unknown>?",
|
|
1526
|
+
creditUnitsConsumed: "Record<string, number>?"
|
|
1527
|
+
},
|
|
1528
|
+
errors: {
|
|
1529
|
+
missingToken: "missing_token",
|
|
1530
|
+
invalidMeterKey: "invalid_meter_key",
|
|
1531
|
+
invalidMeterValue: "invalid_meter_value"
|
|
1532
|
+
},
|
|
1533
|
+
httpAdapter: {
|
|
1534
|
+
input: "Request",
|
|
1535
|
+
output: "Response",
|
|
1536
|
+
networkCalls: false,
|
|
1537
|
+
preserves: ["body", "headers", "status", "statusText"],
|
|
1538
|
+
gatewayStripsInternalHeaders: true
|
|
1539
|
+
}
|
|
1540
|
+
};
|
|
1247
1541
|
|
|
1248
1542
|
// src/adapters/express.ts
|
|
1249
1543
|
var STREAMING_CONTENT_TYPES = new Set(
|
|
@@ -1311,24 +1605,13 @@ function headerValue(headers, name) {
|
|
|
1311
1605
|
return value;
|
|
1312
1606
|
}
|
|
1313
1607
|
|
|
1314
|
-
// src/
|
|
1315
|
-
var
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
var
|
|
1321
|
-
var METERING_ERROR_CODES = {
|
|
1322
|
-
missingToken: "missing_token",
|
|
1323
|
-
invalidMeterKey: "invalid_meter_key",
|
|
1324
|
-
invalidMeterValue: "invalid_meter_value"
|
|
1325
|
-
};
|
|
1326
|
-
|
|
1327
|
-
// src/legacy/metering.ts
|
|
1328
|
-
var METERING_PAYLOAD_HEADER = METERING_HEADERS.payload;
|
|
1329
|
-
var METERING_SIGNATURE_HEADER = METERING_HEADERS.signature;
|
|
1330
|
-
var METERING_TOKEN_HEADER = METERING_HEADERS.token;
|
|
1331
|
-
var DEFAULT_TOKEN_ENV = METERING_TOKEN_ENV;
|
|
1608
|
+
// src/response-metering.ts
|
|
1609
|
+
var RESPONSE_METERING_HEADERS = RUNTIME_RESPONSE_METERING_CONTRACT.headers;
|
|
1610
|
+
var RESPONSE_METERING_ERROR_CODES = RUNTIME_RESPONSE_METERING_CONTRACT.errors;
|
|
1611
|
+
var METERING_PAYLOAD_HEADER = RESPONSE_METERING_HEADERS.payload;
|
|
1612
|
+
var METERING_SIGNATURE_HEADER = RESPONSE_METERING_HEADERS.signature;
|
|
1613
|
+
var METERING_TOKEN_HEADER = RESPONSE_METERING_HEADERS.token;
|
|
1614
|
+
var DEFAULT_TOKEN_ENV = RUNTIME_RESPONSE_METERING_CONTRACT.token.environmentVariable;
|
|
1332
1615
|
var MeteringError = class extends Error {
|
|
1333
1616
|
code;
|
|
1334
1617
|
constructor(code, message) {
|
|
@@ -1344,8 +1627,8 @@ function createUsage(request, options = {}) {
|
|
|
1344
1627
|
usage[assertMeterKey(meter)] = assertMeterValue(meter, value);
|
|
1345
1628
|
return reporter;
|
|
1346
1629
|
},
|
|
1347
|
-
async wrap(response) {
|
|
1348
|
-
return signResponse(request, response, usage, options);
|
|
1630
|
+
async wrap(response, wrapOptions = {}) {
|
|
1631
|
+
return signResponse(request, response, usage, options, wrapOptions);
|
|
1349
1632
|
}
|
|
1350
1633
|
};
|
|
1351
1634
|
return reporter;
|
|
@@ -1357,9 +1640,9 @@ async function withUsage(request, response, usage, options = {}) {
|
|
|
1357
1640
|
}
|
|
1358
1641
|
return reporter.wrap(response);
|
|
1359
1642
|
}
|
|
1360
|
-
async function signResponse(request, response, usage, options) {
|
|
1643
|
+
async function signResponse(request, response, usage, options, wrapOptions) {
|
|
1361
1644
|
const token = resolveToken(options);
|
|
1362
|
-
const payload = buildPayload(request, usage);
|
|
1645
|
+
const payload = buildPayload(request, usage, options, wrapOptions);
|
|
1363
1646
|
const signature = await signPayload(payload, token);
|
|
1364
1647
|
const headers = new Headers(response.headers);
|
|
1365
1648
|
headers.set(METERING_PAYLOAD_HEADER, payload);
|
|
@@ -1371,12 +1654,20 @@ async function signResponse(request, response, usage, options) {
|
|
|
1371
1654
|
headers
|
|
1372
1655
|
});
|
|
1373
1656
|
}
|
|
1374
|
-
function buildPayload(request, usage) {
|
|
1657
|
+
function buildPayload(request, usage, options, wrapOptions) {
|
|
1375
1658
|
const url = new URL(request.url);
|
|
1659
|
+
const measureContext = wrapOptions.measureContext ?? options.measureContext;
|
|
1660
|
+
const creditUnitsConsumed = wrapOptions.creditUnitsConsumed ?? options.creditUnitsConsumed;
|
|
1376
1661
|
const payload = {
|
|
1377
1662
|
method: request.method.toUpperCase(),
|
|
1378
1663
|
path: url.pathname,
|
|
1379
|
-
rawDimsUnits: sortUsage(usage)
|
|
1664
|
+
rawDimsUnits: sortUsage(usage),
|
|
1665
|
+
...measureContext ? { measureContext } : {},
|
|
1666
|
+
...creditUnitsConsumed ? {
|
|
1667
|
+
creditUnitsConsumed: sortUsage(
|
|
1668
|
+
validateUsageMap(creditUnitsConsumed, "creditUnitsConsumed")
|
|
1669
|
+
)
|
|
1670
|
+
} : {}
|
|
1380
1671
|
};
|
|
1381
1672
|
return JSON.stringify(payload);
|
|
1382
1673
|
}
|
|
@@ -1385,10 +1676,18 @@ function sortUsage(usage) {
|
|
|
1385
1676
|
Object.entries(usage).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
|
|
1386
1677
|
);
|
|
1387
1678
|
}
|
|
1679
|
+
function validateUsageMap(usage, label) {
|
|
1680
|
+
return Object.fromEntries(
|
|
1681
|
+
Object.entries(usage).map(([meter, value]) => [
|
|
1682
|
+
assertMeterKey(meter),
|
|
1683
|
+
assertMeterValue(`${label}.${meter}`, value)
|
|
1684
|
+
])
|
|
1685
|
+
);
|
|
1686
|
+
}
|
|
1388
1687
|
function assertMeterKey(meter) {
|
|
1389
1688
|
if (!/^[a-z0-9_]{1,64}$/.test(meter)) {
|
|
1390
1689
|
throw new MeteringError(
|
|
1391
|
-
|
|
1690
|
+
RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
|
|
1392
1691
|
`meter key "${meter}" must be lowercase alphanumeric with underscores`
|
|
1393
1692
|
);
|
|
1394
1693
|
}
|
|
@@ -1397,7 +1696,7 @@ function assertMeterKey(meter) {
|
|
|
1397
1696
|
function assertMeterValue(meter, value) {
|
|
1398
1697
|
if (!Number.isFinite(value) || value < 0) {
|
|
1399
1698
|
throw new MeteringError(
|
|
1400
|
-
|
|
1699
|
+
RESPONSE_METERING_ERROR_CODES.invalidMeterValue,
|
|
1401
1700
|
`meter "${meter}" value must be a non-negative finite number`
|
|
1402
1701
|
);
|
|
1403
1702
|
}
|
|
@@ -1407,18 +1706,18 @@ function resolveToken(options) {
|
|
|
1407
1706
|
const token = options.token ?? options.env?.[DEFAULT_TOKEN_ENV] ?? processEnv(DEFAULT_TOKEN_ENV);
|
|
1408
1707
|
if (!token) {
|
|
1409
1708
|
throw new MeteringError(
|
|
1410
|
-
|
|
1709
|
+
RESPONSE_METERING_ERROR_CODES.missingToken,
|
|
1411
1710
|
`${DEFAULT_TOKEN_ENV} is required to sign Farther Shore metering reports`
|
|
1412
1711
|
);
|
|
1413
1712
|
}
|
|
1414
1713
|
return token;
|
|
1415
1714
|
}
|
|
1416
|
-
function processEnv(
|
|
1715
|
+
function processEnv(key2) {
|
|
1417
1716
|
const maybeProcess = globalThis.process;
|
|
1418
|
-
return maybeProcess?.env?.[
|
|
1717
|
+
return maybeProcess?.env?.[key2];
|
|
1419
1718
|
}
|
|
1420
1719
|
async function signPayload(payload, token) {
|
|
1421
|
-
const
|
|
1720
|
+
const key2 = await crypto.subtle.importKey(
|
|
1422
1721
|
"raw",
|
|
1423
1722
|
new TextEncoder().encode(token),
|
|
1424
1723
|
{ name: "HMAC", hash: "SHA-256" },
|
|
@@ -1427,7 +1726,7 @@ async function signPayload(payload, token) {
|
|
|
1427
1726
|
);
|
|
1428
1727
|
const signature = await crypto.subtle.sign(
|
|
1429
1728
|
"HMAC",
|
|
1430
|
-
|
|
1729
|
+
key2,
|
|
1431
1730
|
new TextEncoder().encode(payload)
|
|
1432
1731
|
);
|
|
1433
1732
|
return base64url(new Uint8Array(signature));
|
|
@@ -2,7 +2,7 @@ import { type RuntimeBootstrapRequest, type RuntimeBootstrapResponse } from "../
|
|
|
2
2
|
/** Where to reach core's bootstrap endpoint. Derived from the token's coreUrl. */
|
|
3
3
|
export type BootstrapClientOptions = {
|
|
4
4
|
runtimeToken: string;
|
|
5
|
-
/** Core base URL, e.g. https://
|
|
5
|
+
/** Core base URL, e.g. https://core.farthershore.com. */
|
|
6
6
|
coreUrl: string;
|
|
7
7
|
/** Optional bootstrap request metadata. */
|
|
8
8
|
request?: RuntimeBootstrapRequest;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type RuntimeBootstrapResponse, type RuntimeHealthReport } from "../runtime-types.js";
|
|
2
|
+
import type { ReconcileResult } from "../reflect/reconcile.js";
|
|
2
3
|
import { type MeterOptions } from "./metering.js";
|
|
3
4
|
import { type SpawnFn } from "./tunnel.js";
|
|
4
5
|
import { type FartherShoreRequestContext, type VerifyRequestInput } from "./verifyRequest.js";
|
|
@@ -20,7 +21,7 @@ export type FartherShoreInitOptions = {
|
|
|
20
21
|
runtimeToken?: string;
|
|
21
22
|
/**
|
|
22
23
|
* Core base URL. Defaults to FS_CORE_URL / FARTHERSHORE_CORE_URL or
|
|
23
|
-
* https://
|
|
24
|
+
* https://core.farthershore.com.
|
|
24
25
|
*/
|
|
25
26
|
coreUrl?: string;
|
|
26
27
|
/** Env map (tests). Defaults to process.env. */
|
|
@@ -39,6 +40,7 @@ export type FartherShoreInitOptions = {
|
|
|
39
40
|
/** SDK metadata forwarded to bootstrap. */
|
|
40
41
|
instanceId?: string;
|
|
41
42
|
};
|
|
43
|
+
export declare const SDK_VERSION: string;
|
|
42
44
|
/**
|
|
43
45
|
* The runtime instance. Lazily bootstraps; holds the JWKS client, nonce cache,
|
|
44
46
|
* metering buffer, and shutdown hooks.
|
|
@@ -61,6 +63,23 @@ export declare class FartherShore {
|
|
|
61
63
|
constructor(options?: FartherShoreInitOptions);
|
|
62
64
|
/** Ensure bootstrap config is loaded; build the JWKS + metering clients. */
|
|
63
65
|
ensureBootstrapped(): Promise<RuntimeBootstrapResponse>;
|
|
66
|
+
/**
|
|
67
|
+
* Boot-time route reconciliation (call once, before `listen()`). Reflects the
|
|
68
|
+
* app's real route surface, diffs it against the declared lock from bootstrap,
|
|
69
|
+
* REPORTS drift to the platform, and CONFIRMS the declared `pending` routes
|
|
70
|
+
* this replica serves. Fail-OPEN: never throws / never blocks boot.
|
|
71
|
+
*
|
|
72
|
+
* The reflection code is dynamically imported so it stays OFF the per-request
|
|
73
|
+
* verification hot path (the runtime stays route-unaware there). The report is
|
|
74
|
+
* an OUTBOUND backend→core call (same channel as bootstrap/metering), so it
|
|
75
|
+
* works for every transport (public_origin / mTLS / cloudflare_tunnel).
|
|
76
|
+
*
|
|
77
|
+
* Returns the reconcile result (or null if reflection is unavailable / boot
|
|
78
|
+
* reporting failed). v1 reflects Express; `app` omitted → no-op.
|
|
79
|
+
*/
|
|
80
|
+
ready(app?: unknown): Promise<ReconcileResult | null>;
|
|
81
|
+
/** Outbound report sink for `ready()` — runtime-token-authed POSTs to core. */
|
|
82
|
+
private buildReportSink;
|
|
64
83
|
/**
|
|
65
84
|
* Framework-neutral verification primitive. Fail-closed: throws a typed
|
|
66
85
|
* FartherShoreError on any verification failure. Returns the verified context.
|
|
@@ -156,7 +156,44 @@ export declare const RUNTIME_METERING_CONTRACT: {
|
|
|
156
156
|
readonly delivery: "at-least-once";
|
|
157
157
|
readonly billingOnly: true;
|
|
158
158
|
readonly realtimeEnforced: false;
|
|
159
|
-
readonly trustModel: "
|
|
159
|
+
readonly trustModel: "upstream-reported values are NOT cryptographically attested; a buggy or compromised upstream can self-report arbitrary values for its OWN product only. Core enforces allowedMeters/allowedRoutes from the authoritative token record at ingest, applies a per-event sanity max (perEventMax), and raises an implausible-volume alert.";
|
|
160
|
+
};
|
|
161
|
+
export declare const RUNTIME_RESPONSE_METERING_CONTRACT: {
|
|
162
|
+
readonly headers: {
|
|
163
|
+
readonly payload: "x-fs-metering";
|
|
164
|
+
readonly signature: "x-fs-metering-sig";
|
|
165
|
+
readonly token: "x-fs-metering-token";
|
|
166
|
+
};
|
|
167
|
+
readonly token: {
|
|
168
|
+
readonly environmentVariable: "FS_RUNTIME_TOKEN";
|
|
169
|
+
readonly presentation: "x-fs-metering-token";
|
|
170
|
+
readonly storage: "sha256-hash-only";
|
|
171
|
+
};
|
|
172
|
+
readonly signature: {
|
|
173
|
+
readonly algorithm: "HMAC-SHA256";
|
|
174
|
+
readonly encoding: "base64url";
|
|
175
|
+
readonly input: "payload-json";
|
|
176
|
+
readonly secret: "presented-runtime-token";
|
|
177
|
+
};
|
|
178
|
+
readonly payload: {
|
|
179
|
+
readonly method: "string";
|
|
180
|
+
readonly path: "string";
|
|
181
|
+
readonly rawDimsUnits: "Record<string, number>";
|
|
182
|
+
readonly measureContext: "Record<string, unknown>?";
|
|
183
|
+
readonly creditUnitsConsumed: "Record<string, number>?";
|
|
184
|
+
};
|
|
185
|
+
readonly errors: {
|
|
186
|
+
readonly missingToken: "missing_token";
|
|
187
|
+
readonly invalidMeterKey: "invalid_meter_key";
|
|
188
|
+
readonly invalidMeterValue: "invalid_meter_value";
|
|
189
|
+
};
|
|
190
|
+
readonly httpAdapter: {
|
|
191
|
+
readonly input: "Request";
|
|
192
|
+
readonly output: "Response";
|
|
193
|
+
readonly networkCalls: false;
|
|
194
|
+
readonly preserves: readonly ["body", "headers", "status", "statusText"];
|
|
195
|
+
readonly gatewayStripsInternalHeaders: true;
|
|
196
|
+
};
|
|
160
197
|
};
|
|
161
198
|
export declare const RUNTIME_HEALTH_CONTRACT: {
|
|
162
199
|
readonly endpoint: "/v1/runtime/health";
|
package/dist/types/index.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export { createExpressMiddleware, type ExpressMiddleware, type ExpressRequestLik
|
|
|
16
16
|
export { FS_RUNTIME_TOKEN_ENV, RUNTIME_TOKEN_PREFIXES, RUNTIME_TOKEN_CAPABILITIES, RUNTIME_HEADER_NAMES, RUNTIME_CLOCK_SKEW_SECONDS, RUNTIME_REPLAY_WINDOW_SECONDS, EMPTY_BODY_SHA256, STREAMING_EXEMPT_BODY_HASH, MAX_BODY_BYTES, type RuntimeErrorCode, type RuntimeTokenCapability, type CanonicalSigningInput, type RuntimeBootstrapResponse, type RuntimeMeteringEvent, type RuntimeHealthReport, type TransportMode, } from "./runtime-types.js";
|
|
17
17
|
export { RUNTIME_ERROR_CODES } from "./generated/runtime-contract.js";
|
|
18
18
|
export { hashBody, buildCanonicalSigningString, canonicalizeQuery, signCanonicalString, verifyCanonicalSignature, runtimeTokenKind, } from "./runtime-signing.js";
|
|
19
|
-
export { createUsage, withUsage, MeteringError, METERING_PAYLOAD_HEADER, METERING_SIGNATURE_HEADER, METERING_TOKEN_HEADER, DEFAULT_TOKEN_ENV, type UsageMap, type UsageReporter, type MeteringOptions, } from "./
|
|
19
|
+
export { createUsage, withUsage, MeteringError, METERING_PAYLOAD_HEADER, METERING_SIGNATURE_HEADER, METERING_TOKEN_HEADER, DEFAULT_TOKEN_ENV, type UsageMap, type UsageReporter, type MeteringOptions, } from "./response-metering.js";
|
|
20
20
|
/**
|
|
21
21
|
* The conceptual public entrypoint. `fartherShore.initFromEnv()` mirrors the
|
|
22
22
|
* language-neutral spec. The returned instance is augmented with `middleware()`
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ReflectedRoute } from "./route-canon.js";
|
|
2
|
+
export interface ReflectResult {
|
|
3
|
+
routes: ReflectedRoute[];
|
|
4
|
+
/** Count of sub-mounted routes that couldn't be reflected (Express-5 mount). */
|
|
5
|
+
unreflectable: number;
|
|
6
|
+
}
|
|
7
|
+
export interface ReflectOptions {
|
|
8
|
+
framework?: "express";
|
|
9
|
+
}
|
|
10
|
+
/** Reflect with diagnostics. Use `reflectRoutes` for just the canonical list. */
|
|
11
|
+
export declare function reflectRoutesDetailed(app: unknown, _opts?: ReflectOptions): ReflectResult;
|
|
12
|
+
/**
|
|
13
|
+
* Reflect the routes an Express app serves into the canonical `ReflectedRoute[]`.
|
|
14
|
+
* Deduped, HEAD-suppressed, canonical paths (`:id`→`{id}`).
|
|
15
|
+
*/
|
|
16
|
+
export declare function reflectRoutes(app: unknown, opts?: ReflectOptions): ReflectedRoute[];
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type ReflectedRoute, type DriftReport, type PendingConfirm } from "./route-canon.js";
|
|
2
|
+
/** The slice of the bootstrap response reconciliation needs. */
|
|
3
|
+
export interface StartupBootstrap {
|
|
4
|
+
backendId: string;
|
|
5
|
+
lock?: {
|
|
6
|
+
surfaceHash: string;
|
|
7
|
+
lockVersion: number;
|
|
8
|
+
};
|
|
9
|
+
routes?: Array<{
|
|
10
|
+
id?: string;
|
|
11
|
+
method: string;
|
|
12
|
+
path: string;
|
|
13
|
+
pending?: boolean;
|
|
14
|
+
}>;
|
|
15
|
+
}
|
|
16
|
+
/** Where reconciliation sends its outbound reports (DI for tests + transport). */
|
|
17
|
+
export interface ReportSink {
|
|
18
|
+
/** PR-opening drift report → `POST /v1/runtime/drift`. */
|
|
19
|
+
reportDrift(report: DriftReport): Promise<void> | void;
|
|
20
|
+
/** Confirm served pending routes → rides `/v1/runtime/health`. */
|
|
21
|
+
confirmServed(confirm: PendingConfirm): Promise<void> | void;
|
|
22
|
+
}
|
|
23
|
+
export interface ReconcileResult {
|
|
24
|
+
/** Reflected surface matched the declared lock. */
|
|
25
|
+
inSync: boolean;
|
|
26
|
+
/** A drift report was sent. */
|
|
27
|
+
reportedDrift: boolean;
|
|
28
|
+
/** Route ids confirmed served (pending → clearable). */
|
|
29
|
+
confirmedRouteIds: string[];
|
|
30
|
+
/** A report sink call failed (swallowed — boot never blocks on it). */
|
|
31
|
+
reportError?: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Reconcile the reflected surface against the declared lock at startup.
|
|
35
|
+
* Idempotent + multi-replica-safe (all replicas compute the same hash; confirm
|
|
36
|
+
* carries `lockVersion` for the server's optimistic-concurrency check).
|
|
37
|
+
*/
|
|
38
|
+
export declare function reconcileOnStartup(args: {
|
|
39
|
+
reflected: readonly ReflectedRoute[];
|
|
40
|
+
bootstrap: StartupBootstrap;
|
|
41
|
+
report: ReportSink;
|
|
42
|
+
}): Promise<ReconcileResult>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** A reflected route — method + canonical path. Mirrors contracts ReflectedRoute. */
|
|
2
|
+
export interface ReflectedRoute {
|
|
3
|
+
method: string;
|
|
4
|
+
path: string;
|
|
5
|
+
}
|
|
6
|
+
/** Backend → core drift report. Mirrors contracts DriftReport. */
|
|
7
|
+
export interface DriftReport {
|
|
8
|
+
backendId: string;
|
|
9
|
+
lockVersion: number;
|
|
10
|
+
reflectedSurfaceHash: string;
|
|
11
|
+
routes: ReflectedRoute[];
|
|
12
|
+
}
|
|
13
|
+
/** Backend → core pending confirmation. Mirrors contracts PendingConfirm. */
|
|
14
|
+
export interface PendingConfirm {
|
|
15
|
+
backendId: string;
|
|
16
|
+
lockVersion: number;
|
|
17
|
+
servedRouteIds: string[];
|
|
18
|
+
}
|
|
19
|
+
export declare function normalizePath(path: string): string;
|
|
20
|
+
export declare function surfaceHash(routes: readonly ReflectedRoute[]): string;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
declare const RESPONSE_METERING_ERROR_CODES: {
|
|
2
|
+
readonly missingToken: "missing_token";
|
|
3
|
+
readonly invalidMeterKey: "invalid_meter_key";
|
|
4
|
+
readonly invalidMeterValue: "invalid_meter_value";
|
|
5
|
+
};
|
|
6
|
+
type ResponseMeteringErrorCode = (typeof RESPONSE_METERING_ERROR_CODES)[keyof typeof RESPONSE_METERING_ERROR_CODES];
|
|
7
|
+
export declare const METERING_PAYLOAD_HEADER: "x-fs-metering";
|
|
8
|
+
export declare const METERING_SIGNATURE_HEADER: "x-fs-metering-sig";
|
|
9
|
+
export declare const METERING_TOKEN_HEADER: "x-fs-metering-token";
|
|
10
|
+
export declare const DEFAULT_TOKEN_ENV: "FS_RUNTIME_TOKEN";
|
|
11
|
+
export type UsageMap = Record<string, number>;
|
|
12
|
+
export type MeteringOptions = {
|
|
13
|
+
token?: string;
|
|
14
|
+
env?: Record<string, string | undefined>;
|
|
15
|
+
measureContext?: Record<string, unknown>;
|
|
16
|
+
creditUnitsConsumed?: UsageMap;
|
|
17
|
+
};
|
|
18
|
+
export type UsageWrapOptions = {
|
|
19
|
+
measureContext?: Record<string, unknown>;
|
|
20
|
+
creditUnitsConsumed?: UsageMap;
|
|
21
|
+
};
|
|
22
|
+
export type UsageReporter = {
|
|
23
|
+
report(meter: string, value: number): UsageReporter;
|
|
24
|
+
wrap(response: Response, options?: UsageWrapOptions): Promise<Response>;
|
|
25
|
+
};
|
|
26
|
+
export declare class MeteringError extends Error {
|
|
27
|
+
readonly code: ResponseMeteringErrorCode;
|
|
28
|
+
constructor(code: ResponseMeteringErrorCode, message: string);
|
|
29
|
+
}
|
|
30
|
+
export declare function createUsage(request: Request, options?: MeteringOptions): UsageReporter;
|
|
31
|
+
export declare function withUsage(request: Request, response: Response, usage: UsageMap, options?: MeteringOptions): Promise<Response>;
|
|
32
|
+
export {};
|
|
@@ -5,7 +5,7 @@ export declare const RUNTIME_TOKEN_PREFIXES: {
|
|
|
5
5
|
readonly test: "fsrt_test_";
|
|
6
6
|
};
|
|
7
7
|
export type RuntimeTokenKind = keyof typeof RUNTIME_TOKEN_PREFIXES;
|
|
8
|
-
export declare const RUNTIME_TOKEN_CAPABILITIES: readonly ["gateway_verification", "metering", "health", "tunnel"];
|
|
8
|
+
export declare const RUNTIME_TOKEN_CAPABILITIES: readonly ["gateway_verification", "metering", "health", "tunnel", "drift_report"];
|
|
9
9
|
export type RuntimeTokenCapability = (typeof RUNTIME_TOKEN_CAPABILITIES)[number];
|
|
10
10
|
export declare const RUNTIME_HEADER_NAMES: {
|
|
11
11
|
readonly signature: "x-fs-signature";
|
|
@@ -93,6 +93,14 @@ export type RuntimeRouteDescriptor = {
|
|
|
93
93
|
method: string;
|
|
94
94
|
path: string;
|
|
95
95
|
backendId: string;
|
|
96
|
+
/** Declared-but-not-yet-confirmed-served route. `fs.ready()` confirms the ones
|
|
97
|
+
* this replica actually serves so core can clear the flag and publish. */
|
|
98
|
+
pending?: boolean;
|
|
99
|
+
};
|
|
100
|
+
/** The declared route-surface lock the backend reconciles against at startup. */
|
|
101
|
+
export type RuntimeLockDescriptor = {
|
|
102
|
+
surfaceHash: string;
|
|
103
|
+
lockVersion: number;
|
|
96
104
|
};
|
|
97
105
|
export type RuntimeBootstrapResponse = {
|
|
98
106
|
product: {
|
|
@@ -113,6 +121,9 @@ export type RuntimeBootstrapResponse = {
|
|
|
113
121
|
metering: RuntimeMeteringConfig;
|
|
114
122
|
transport: RuntimeTransportConfig;
|
|
115
123
|
routes: RuntimeRouteDescriptor[];
|
|
124
|
+
/** Reflected route-surface lock (surfaceHash + version). Present once core
|
|
125
|
+
* serves it; `fs.ready()` diffs the reflected surface against it. */
|
|
126
|
+
lock?: RuntimeLockDescriptor;
|
|
116
127
|
policyVersion: string;
|
|
117
128
|
refreshAfterSeconds: number;
|
|
118
129
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@farthershore/backend",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Farther Shore backend SDK:
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Farther Shore backend SDK for builder upstreams: signed response usage, fail-closed gateway request verification, health, and lifecycle from FS_RUNTIME_TOKEN",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"module": "./dist/index.js",
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
"types": "./dist/types/adapters/express.d.ts",
|
|
16
16
|
"import": "./dist/adapters/express.js"
|
|
17
17
|
},
|
|
18
|
+
"./reflect": {
|
|
19
|
+
"types": "./dist/types/reflect/index.d.ts",
|
|
20
|
+
"import": "./dist/reflect/index.js"
|
|
21
|
+
},
|
|
18
22
|
"./runtime": {
|
|
19
23
|
"types": "./dist/types/generated/runtime-contract.d.ts",
|
|
20
24
|
"import": "./dist/generated/runtime-contract.js"
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
export declare const METERING_CONTRACT_VERSION: 1;
|
|
2
|
-
export declare const METERING_HEADERS: {
|
|
3
|
-
readonly payload: "x-fs-metering";
|
|
4
|
-
readonly signature: "x-fs-metering-sig";
|
|
5
|
-
readonly token: "x-fs-metering-token";
|
|
6
|
-
};
|
|
7
|
-
export declare const METERING_TOKEN_ENV: "FARTHERSHORE_METERING_TOKEN";
|
|
8
|
-
export declare const METERING_TOKEN_CONTRACT: {
|
|
9
|
-
readonly environmentVariable: "FARTHERSHORE_METERING_TOKEN";
|
|
10
|
-
readonly presentation: "x-fs-metering-token";
|
|
11
|
-
readonly storage: "sha256-hash-only";
|
|
12
|
-
};
|
|
13
|
-
export declare const METERING_SIGNATURE_CONTRACT: {
|
|
14
|
-
readonly algorithm: "HMAC-SHA256";
|
|
15
|
-
readonly encoding: "base64url";
|
|
16
|
-
readonly input: "payload-json";
|
|
17
|
-
readonly secret: "presented-metering-token";
|
|
18
|
-
};
|
|
19
|
-
export declare const METERING_ERROR_CODES: {
|
|
20
|
-
readonly missingToken: "missing_token";
|
|
21
|
-
readonly invalidMeterKey: "invalid_meter_key";
|
|
22
|
-
readonly invalidMeterValue: "invalid_meter_value";
|
|
23
|
-
};
|
|
24
|
-
export type MeteringErrorCode = (typeof METERING_ERROR_CODES)[keyof typeof METERING_ERROR_CODES];
|
|
25
|
-
export declare const METERING_HTTP_ADAPTER_CONTRACT: {
|
|
26
|
-
readonly input: "Request";
|
|
27
|
-
readonly output: "Response";
|
|
28
|
-
readonly networkCalls: false;
|
|
29
|
-
readonly preserves: readonly ["body", "headers", "status", "statusText"];
|
|
30
|
-
readonly gatewayStripsInternalHeaders: true;
|
|
31
|
-
};
|
|
32
|
-
export type MeteringUsagePayload = {
|
|
33
|
-
method: string;
|
|
34
|
-
path: string;
|
|
35
|
-
rawDimsUnits: Record<string, number>;
|
|
36
|
-
};
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import { type MeteringErrorCode } from "../generated/metering-contract.js";
|
|
2
|
-
export declare const METERING_PAYLOAD_HEADER: "x-fs-metering";
|
|
3
|
-
export declare const METERING_SIGNATURE_HEADER: "x-fs-metering-sig";
|
|
4
|
-
export declare const METERING_TOKEN_HEADER: "x-fs-metering-token";
|
|
5
|
-
export declare const DEFAULT_TOKEN_ENV: "FARTHERSHORE_METERING_TOKEN";
|
|
6
|
-
export type UsageMap = Record<string, number>;
|
|
7
|
-
export type MeteringOptions = {
|
|
8
|
-
token?: string;
|
|
9
|
-
env?: Record<string, string | undefined>;
|
|
10
|
-
};
|
|
11
|
-
export type UsageReporter = {
|
|
12
|
-
report(meter: string, value: number): UsageReporter;
|
|
13
|
-
wrap(response: Response): Promise<Response>;
|
|
14
|
-
};
|
|
15
|
-
export declare class MeteringError extends Error {
|
|
16
|
-
readonly code: MeteringErrorCode;
|
|
17
|
-
constructor(code: MeteringErrorCode, message: string);
|
|
18
|
-
}
|
|
19
|
-
export declare function createUsage(request: Request, options?: MeteringOptions): UsageReporter;
|
|
20
|
-
export declare function withUsage(request: Request, response: Response, usage: UsageMap, options?: MeteringOptions): Promise<Response>;
|