@farthershore/backend 0.13.0 → 0.14.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 +42 -2
- package/dist/generated/runtime-contract.js +15 -0
- package/dist/index.js +355 -186
- package/dist/testing/index.js +206 -16
- package/dist/types/core/post-stream-usage.d.ts +45 -0
- package/dist/types/core/runtime.d.ts +4 -0
- package/dist/types/core/verifyRequest.d.ts +3 -0
- package/dist/types/generated/runtime-contract.d.ts +15 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/runtime-types.d.ts +10 -0
- package/dist/types/testing/devGateway.d.ts +5 -1
- package/dist/types/testing/usageSink.d.ts +9 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ graceful lifecycle (health + shutdown). Everything else — your product, backen
|
|
|
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.14.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
|
|
|
@@ -142,6 +142,40 @@ Delivery is at-least-once; the event idempotency key keeps ingestion safe.
|
|
|
142
142
|
Background usage is tallied and billed after the cycle, not enforced in
|
|
143
143
|
real time.
|
|
144
144
|
|
|
145
|
+
## Post-stream usage reporting
|
|
146
|
+
|
|
147
|
+
Use `fs.reportUsage()` when a gateway request streams its response and the
|
|
148
|
+
billable total is known only after the stream completes. Declare that route
|
|
149
|
+
with `postStreamBilling: true`, then report from the request-scoped verified
|
|
150
|
+
context so the SDK retains the attested subscription subject:
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
const context = await fs.verifyRequest({ method, path, query, headers, body });
|
|
154
|
+
|
|
155
|
+
await streamResponse(context);
|
|
156
|
+
await context.reportUsage?.({
|
|
157
|
+
meters: { output_tokens: 1280 },
|
|
158
|
+
measureContext: { model: "apsu-1" },
|
|
159
|
+
});
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The callback is HMAC-attested and requires a subscription subject. Core binds it
|
|
163
|
+
to the immutable gateway request row and writes one billable `UsageEvent` with
|
|
164
|
+
the gateway-known request units merged with reported actual units, the served
|
|
165
|
+
plan, served route, and served request time. The gateway evidence row is
|
|
166
|
+
unbilled and omits only response-derived dimensions. This is a billing-only channel: it
|
|
167
|
+
does not settle or mutate Durable Object enforcement windows, so units that are
|
|
168
|
+
unknown at admission cannot be hard-enforced. Knowable request dimensions still
|
|
169
|
+
follow the normal admission path and are billed once on the merged callback.
|
|
170
|
+
|
|
171
|
+
Gateway evidence is published asynchronously. If the callback arrives first,
|
|
172
|
+
Core parks the verified payload and the SDK retries only
|
|
173
|
+
`post_stream_request_not_found` with bounded backoff, reusing the exact signed
|
|
174
|
+
payload and nonce. A maintenance pass binds any remaining parked callback once
|
|
175
|
+
evidence lands and durably alerts if it expires. The SDK method is best-effort and resolves
|
|
176
|
+
`{ ok: false, reason }` instead of rejecting, so handle or log a failed report
|
|
177
|
+
according to your service's delivery policy.
|
|
178
|
+
|
|
145
179
|
## Lifecycle
|
|
146
180
|
|
|
147
181
|
- `fs.health()` returns the current local health report (token present, bootstrap
|
|
@@ -211,6 +245,7 @@ See `templates/3-simulated-authz.test.ts` for the full fail-closed + usage flow.
|
|
|
211
245
|
| `withUsage()` / `createUsage()` | Response-bound usage reporting (no network call). |
|
|
212
246
|
| `computeMeteringHeaders()` | Metering headers as a plain map — never throws. |
|
|
213
247
|
| `fs.meter(meter, qty, opts)` | Async/background usage event. |
|
|
248
|
+
| `fs.reportUsage(input)` | Attested post-stream usage callback. |
|
|
214
249
|
| `fs.health()` / `fs.shutdown()` | Health report and graceful shutdown. |
|
|
215
250
|
| `FartherShoreError`, `MeteringError` | Typed errors. |
|
|
216
251
|
| `@farthershore/backend/testing` | Dev-mode + persona test harness (dev/test only). |
|
|
@@ -220,12 +255,17 @@ types directly if you prefer to wire the middleware yourself.
|
|
|
220
255
|
|
|
221
256
|
## Metering channels
|
|
222
257
|
|
|
223
|
-
|
|
258
|
+
Three usage channels exist and are **not** interchangeable:
|
|
224
259
|
|
|
225
260
|
- **Response-bound** (`withUsage` / `createUsage` / `computeMeteringHeaders`) is
|
|
226
261
|
the attested, request-bound settlement channel: the gateway verifies the HMAC
|
|
227
262
|
and settles the reported units against the request's lease in the same
|
|
228
263
|
lifecycle. Wire recipe (any language): [`docs/response-metering-wire.md`](docs/response-metering-wire.md).
|
|
264
|
+
- **Post-stream** (`fs.reportUsage` or the request-scoped
|
|
265
|
+
`context.reportUsage`) is attested and request-bound for streaming routes
|
|
266
|
+
declared with `postStreamBilling: true`. It writes the sole billable row for
|
|
267
|
+
gateway-known plus reported actual units and never mutates real-time
|
|
268
|
+
enforcement windows.
|
|
229
269
|
- **Background** (`fs.meter`) is a billing-only, unattested, post-cycle tally for
|
|
230
270
|
usage not tied to a gateway response. It never settles a lease.
|
|
231
271
|
|
|
@@ -173,14 +173,29 @@ var RUNTIME_METERING_CONTRACT = {
|
|
|
173
173
|
backend_id: "string",
|
|
174
174
|
route_id: "string?",
|
|
175
175
|
request_id: "string?",
|
|
176
|
+
requestId: "string?",
|
|
177
|
+
subscriptionId: "string",
|
|
178
|
+
nonce: "string?",
|
|
176
179
|
meter: "string",
|
|
177
180
|
qty: "number",
|
|
178
181
|
timestamp: "string"
|
|
179
182
|
},
|
|
183
|
+
postStreamEvent: {
|
|
184
|
+
requestId: "string",
|
|
185
|
+
subscriptionId: "string?",
|
|
186
|
+
nonce: "string",
|
|
187
|
+
meters: "Record<string, number>",
|
|
188
|
+
creditUnitsConsumed: "Record<string, number>?",
|
|
189
|
+
measureContext: "Record<string, unknown>?",
|
|
190
|
+
signature: "string"
|
|
191
|
+
},
|
|
180
192
|
idempotencyKey: "event_id",
|
|
181
193
|
delivery: "at-least-once",
|
|
182
194
|
billingOnly: true,
|
|
183
195
|
realtimeEnforced: false,
|
|
196
|
+
postStreamBillingOnly: true,
|
|
197
|
+
postStreamRealtimeEnforced: false,
|
|
198
|
+
postStreamTrustModel: "HMAC-attested and bound to one served postStreamBilling gateway request. Core writes one billable UsageEvent using the served plan and time. The callback never mutates Durable Object enforcement windows.",
|
|
184
199
|
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."
|
|
185
200
|
};
|
|
186
201
|
var RUNTIME_RESPONSE_METERING_CONTRACT = {
|
package/dist/index.js
CHANGED
|
@@ -899,6 +899,300 @@ function resolveEndpoint(endpoint, coreUrl) {
|
|
|
899
899
|
return `${coreUrl.replace(/\/+$/, "")}${endpoint}`;
|
|
900
900
|
}
|
|
901
901
|
|
|
902
|
+
// src/response-metering.ts
|
|
903
|
+
var RESPONSE_METERING_HEADERS = RUNTIME_RESPONSE_METERING_CONTRACT.headers;
|
|
904
|
+
var RESPONSE_METERING_ERROR_CODES = RUNTIME_RESPONSE_METERING_CONTRACT.errors;
|
|
905
|
+
var devMeteringHooks = null;
|
|
906
|
+
function __setDevMeteringHooks(hooks) {
|
|
907
|
+
devMeteringHooks = hooks;
|
|
908
|
+
}
|
|
909
|
+
var METERING_PAYLOAD_HEADER = RESPONSE_METERING_HEADERS.payload;
|
|
910
|
+
var METERING_SIGNATURE_HEADER = RESPONSE_METERING_HEADERS.signature;
|
|
911
|
+
var METERING_TOKEN_HEADER = RESPONSE_METERING_HEADERS.token;
|
|
912
|
+
var DEFAULT_TOKEN_ENV = RUNTIME_RESPONSE_METERING_CONTRACT.token.environmentVariable;
|
|
913
|
+
var MeteringError = class extends Error {
|
|
914
|
+
code;
|
|
915
|
+
constructor(code, message) {
|
|
916
|
+
super(message);
|
|
917
|
+
this.name = "MeteringError";
|
|
918
|
+
this.code = code;
|
|
919
|
+
}
|
|
920
|
+
};
|
|
921
|
+
function createUsage(request, options = {}) {
|
|
922
|
+
const usage = {};
|
|
923
|
+
const reporter = {
|
|
924
|
+
report(meter, value) {
|
|
925
|
+
usage[assertMeterKey(meter)] = assertMeterValue(meter, value);
|
|
926
|
+
return reporter;
|
|
927
|
+
},
|
|
928
|
+
async wrap(response, wrapOptions = {}) {
|
|
929
|
+
return signResponse(request, response, usage, options, wrapOptions);
|
|
930
|
+
}
|
|
931
|
+
};
|
|
932
|
+
return reporter;
|
|
933
|
+
}
|
|
934
|
+
async function withUsage(request, response, usage, options = {}) {
|
|
935
|
+
const reporter = createUsage(request, options);
|
|
936
|
+
for (const [meter, value] of Object.entries(usage)) {
|
|
937
|
+
reporter.report(meter, value);
|
|
938
|
+
}
|
|
939
|
+
return reporter.wrap(response);
|
|
940
|
+
}
|
|
941
|
+
async function signResponse(request, response, usage, options, wrapOptions) {
|
|
942
|
+
const payload = buildPayload(request, usage, options, wrapOptions);
|
|
943
|
+
const requestId = request.headers.get("x-fs-request-id") ?? void 0;
|
|
944
|
+
const headers = await computeMeteringHeaders(payload, {
|
|
945
|
+
...options.token !== void 0 ? { token: options.token } : {},
|
|
946
|
+
...options.env !== void 0 ? { env: options.env } : {},
|
|
947
|
+
...requestId ? { requestId } : {},
|
|
948
|
+
onSkip: () => {
|
|
949
|
+
}
|
|
950
|
+
});
|
|
951
|
+
if (Object.keys(headers).length === 0) {
|
|
952
|
+
throw new MeteringError(
|
|
953
|
+
RESPONSE_METERING_ERROR_CODES.missingToken,
|
|
954
|
+
`${DEFAULT_TOKEN_ENV} is required to sign Farther Shore metering reports`
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
const merged = new Headers(response.headers);
|
|
958
|
+
for (const [name, value] of Object.entries(headers)) merged.set(name, value);
|
|
959
|
+
return new Response(response.body, {
|
|
960
|
+
status: response.status,
|
|
961
|
+
statusText: response.statusText,
|
|
962
|
+
headers: merged
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
async function computeMeteringHeaders(payload, options = {}) {
|
|
966
|
+
try {
|
|
967
|
+
const token = resolveTokenSoft(options);
|
|
968
|
+
if (!token) {
|
|
969
|
+
skip(`${DEFAULT_TOKEN_ENV} is not set`, options);
|
|
970
|
+
return {};
|
|
971
|
+
}
|
|
972
|
+
const json2 = JSON.stringify(payload);
|
|
973
|
+
const signature = await signPayload(json2, token);
|
|
974
|
+
devMeteringHooks?.record?.(payload, options.requestId);
|
|
975
|
+
return {
|
|
976
|
+
[METERING_PAYLOAD_HEADER]: json2,
|
|
977
|
+
[METERING_SIGNATURE_HEADER]: signature,
|
|
978
|
+
[METERING_TOKEN_HEADER]: token
|
|
979
|
+
};
|
|
980
|
+
} catch (error) {
|
|
981
|
+
skip(error instanceof Error ? error.message : String(error), options);
|
|
982
|
+
return {};
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
function skip(reason, options) {
|
|
986
|
+
if (options.onSkip) {
|
|
987
|
+
options.onSkip(reason);
|
|
988
|
+
} else {
|
|
989
|
+
console.warn(`metering headers skipped: ${reason}`);
|
|
990
|
+
}
|
|
991
|
+
devMeteringHooks?.onSkip?.(reason, options.requestId);
|
|
992
|
+
}
|
|
993
|
+
function resolveTokenSoft(options) {
|
|
994
|
+
return options.token ?? options.env?.[DEFAULT_TOKEN_ENV] ?? processEnv(DEFAULT_TOKEN_ENV) ?? devMeteringHooks?.fallbackToken?.();
|
|
995
|
+
}
|
|
996
|
+
function buildPayload(request, usage, options, wrapOptions) {
|
|
997
|
+
const url = new URL(request.url);
|
|
998
|
+
const measureContext = wrapOptions.measureContext ?? options.measureContext;
|
|
999
|
+
const creditUnitsConsumed = wrapOptions.creditUnitsConsumed ?? options.creditUnitsConsumed;
|
|
1000
|
+
const operationKey = wrapOptions.operationKey ?? options.operationKey;
|
|
1001
|
+
const usagePolicyId = wrapOptions.usagePolicyId ?? options.usagePolicyId;
|
|
1002
|
+
const payload = {
|
|
1003
|
+
method: request.method.toUpperCase(),
|
|
1004
|
+
path: url.pathname,
|
|
1005
|
+
rawDimsUnits: sortUsage(usage),
|
|
1006
|
+
...measureContext ? { measureContext } : {},
|
|
1007
|
+
...creditUnitsConsumed ? {
|
|
1008
|
+
creditUnitsConsumed: sortUsage(
|
|
1009
|
+
validateUsageMap(creditUnitsConsumed, "creditUnitsConsumed")
|
|
1010
|
+
)
|
|
1011
|
+
} : {},
|
|
1012
|
+
...operationKey ? { operationKey: assertIdentifier(operationKey) } : {},
|
|
1013
|
+
...usagePolicyId ? { usagePolicyId: assertIdentifier(usagePolicyId) } : {}
|
|
1014
|
+
};
|
|
1015
|
+
return payload;
|
|
1016
|
+
}
|
|
1017
|
+
function sortUsage(usage) {
|
|
1018
|
+
return Object.fromEntries(
|
|
1019
|
+
Object.entries(usage).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
function validateUsageMap(usage, label) {
|
|
1023
|
+
return Object.fromEntries(
|
|
1024
|
+
Object.entries(usage).map(([meter, value]) => [
|
|
1025
|
+
assertMeterKey(meter),
|
|
1026
|
+
assertMeterValue(`${label}.${meter}`, value)
|
|
1027
|
+
])
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
function assertMeterKey(meter) {
|
|
1031
|
+
if (!/^[a-z0-9_]{1,64}$/.test(meter)) {
|
|
1032
|
+
throw new MeteringError(
|
|
1033
|
+
RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
|
|
1034
|
+
`meter key "${meter}" must be lowercase alphanumeric with underscores`
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
return meter;
|
|
1038
|
+
}
|
|
1039
|
+
function assertMeterValue(meter, value) {
|
|
1040
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
1041
|
+
throw new MeteringError(
|
|
1042
|
+
RESPONSE_METERING_ERROR_CODES.invalidMeterValue,
|
|
1043
|
+
`meter "${meter}" value must be a non-negative finite number`
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
return value;
|
|
1047
|
+
}
|
|
1048
|
+
function assertIdentifier(value) {
|
|
1049
|
+
if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(value)) {
|
|
1050
|
+
throw new MeteringError(
|
|
1051
|
+
RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
|
|
1052
|
+
`operation and usage policy identifiers must be 1-128 URL-safe characters`
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
return value;
|
|
1056
|
+
}
|
|
1057
|
+
function processEnv(key2) {
|
|
1058
|
+
const maybeProcess = globalThis.process;
|
|
1059
|
+
return maybeProcess?.env?.[key2];
|
|
1060
|
+
}
|
|
1061
|
+
async function signPayload(payload, token) {
|
|
1062
|
+
const key2 = await crypto.subtle.importKey(
|
|
1063
|
+
"raw",
|
|
1064
|
+
new TextEncoder().encode(token),
|
|
1065
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
1066
|
+
false,
|
|
1067
|
+
["sign"]
|
|
1068
|
+
);
|
|
1069
|
+
const signature = await crypto.subtle.sign(
|
|
1070
|
+
"HMAC",
|
|
1071
|
+
key2,
|
|
1072
|
+
new TextEncoder().encode(payload)
|
|
1073
|
+
);
|
|
1074
|
+
return base64url(new Uint8Array(signature));
|
|
1075
|
+
}
|
|
1076
|
+
function base64url(bytes) {
|
|
1077
|
+
let binary = "";
|
|
1078
|
+
for (const byte of bytes) {
|
|
1079
|
+
binary += String.fromCharCode(byte);
|
|
1080
|
+
}
|
|
1081
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// src/core/post-stream-usage.ts
|
|
1085
|
+
var METER_KEY_RE2 = /^[a-z0-9_]{1,64}$/;
|
|
1086
|
+
var PostStreamUsageClient = class {
|
|
1087
|
+
config;
|
|
1088
|
+
endpoint;
|
|
1089
|
+
fetchImpl;
|
|
1090
|
+
newNonce;
|
|
1091
|
+
logger;
|
|
1092
|
+
sleep;
|
|
1093
|
+
retryDelaysMs;
|
|
1094
|
+
constructor(options) {
|
|
1095
|
+
this.config = options.config;
|
|
1096
|
+
this.endpoint = resolveEndpoint2(options.config.endpoint, options.coreUrl);
|
|
1097
|
+
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
1098
|
+
this.newNonce = options.newNonce ?? (() => crypto.randomUUID());
|
|
1099
|
+
this.logger = options.logger ?? ((message) => console.warn(message));
|
|
1100
|
+
this.sleep = options.sleep ?? sleep;
|
|
1101
|
+
this.retryDelaysMs = options.retryDelaysMs ?? [100, 250, 500];
|
|
1102
|
+
}
|
|
1103
|
+
async reportUsage(input) {
|
|
1104
|
+
try {
|
|
1105
|
+
if (!this.config.enabled) throw new Error("metering is not enabled");
|
|
1106
|
+
if (!input.requestId) throw new Error("requestId is required");
|
|
1107
|
+
if (!input.subscriptionId) throw new Error("subscriptionId is required");
|
|
1108
|
+
const unsigned = {
|
|
1109
|
+
requestId: input.requestId,
|
|
1110
|
+
subscriptionId: input.subscriptionId,
|
|
1111
|
+
nonce: this.newNonce(),
|
|
1112
|
+
meters: validateAndSortUsage(input.meters, "meters", this.config, true),
|
|
1113
|
+
...input.creditUnitsConsumed ? {
|
|
1114
|
+
creditUnitsConsumed: validateAndSortUsage(
|
|
1115
|
+
input.creditUnitsConsumed,
|
|
1116
|
+
"creditUnitsConsumed",
|
|
1117
|
+
this.config,
|
|
1118
|
+
false
|
|
1119
|
+
)
|
|
1120
|
+
} : {},
|
|
1121
|
+
...input.measureContext ? { measureContext: input.measureContext } : {}
|
|
1122
|
+
};
|
|
1123
|
+
const signature = await signPayload(
|
|
1124
|
+
JSON.stringify(unsigned),
|
|
1125
|
+
this.config.credential
|
|
1126
|
+
);
|
|
1127
|
+
const event = { ...unsigned, signature };
|
|
1128
|
+
const body = JSON.stringify(event);
|
|
1129
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
1130
|
+
const response = await this.fetchImpl(this.endpoint, {
|
|
1131
|
+
method: "POST",
|
|
1132
|
+
headers: {
|
|
1133
|
+
authorization: `Bearer ${this.config.credential}`,
|
|
1134
|
+
"content-type": "application/json",
|
|
1135
|
+
accept: "application/json"
|
|
1136
|
+
},
|
|
1137
|
+
body
|
|
1138
|
+
});
|
|
1139
|
+
if (response.ok) return { ok: true };
|
|
1140
|
+
const requestNotFound = await isPostStreamRequestNotFound(response);
|
|
1141
|
+
const delayMs = this.retryDelaysMs[attempt];
|
|
1142
|
+
if (!requestNotFound || delayMs === void 0) {
|
|
1143
|
+
throw new Error(`metering endpoint returned ${response.status}`);
|
|
1144
|
+
}
|
|
1145
|
+
await this.sleep(delayMs);
|
|
1146
|
+
}
|
|
1147
|
+
} catch (error) {
|
|
1148
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1149
|
+
this.logger(`post-stream usage report skipped: ${reason}`);
|
|
1150
|
+
return { ok: false, reason };
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
};
|
|
1154
|
+
async function isPostStreamRequestNotFound(response) {
|
|
1155
|
+
if (response.status !== 422) return false;
|
|
1156
|
+
try {
|
|
1157
|
+
const body = await response.json();
|
|
1158
|
+
return body.error?.code === "post_stream_request_not_found";
|
|
1159
|
+
} catch {
|
|
1160
|
+
return false;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
function sleep(delayMs) {
|
|
1164
|
+
return new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
1165
|
+
}
|
|
1166
|
+
function validateAndSortUsage(usage, label, config, enforceMeterScope) {
|
|
1167
|
+
const entries = Object.entries(usage).sort(
|
|
1168
|
+
([a], [b]) => a < b ? -1 : a > b ? 1 : 0
|
|
1169
|
+
);
|
|
1170
|
+
for (const [meter, qty] of entries) {
|
|
1171
|
+
if (!METER_KEY_RE2.test(meter)) {
|
|
1172
|
+
throw new Error(
|
|
1173
|
+
`${label} key '${meter}' must be lowercase alphanumeric with underscores`
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1176
|
+
if (!Number.isFinite(qty) || qty < 0) {
|
|
1177
|
+
throw new Error(`${label}.${meter} must be a non-negative finite number`);
|
|
1178
|
+
}
|
|
1179
|
+
if (enforceMeterScope && config.allowedMeters.length > 0 && !config.allowedMeters.includes(meter)) {
|
|
1180
|
+
throw new Error(`meter '${meter}' is not in the token's allowedMeters`);
|
|
1181
|
+
}
|
|
1182
|
+
if (enforceMeterScope && config.perEventMax > 0 && qty > config.perEventMax) {
|
|
1183
|
+
throw new Error(
|
|
1184
|
+
`meter '${meter}' qty ${qty} exceeds the per-event max ${config.perEventMax}`
|
|
1185
|
+
);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
return Object.fromEntries(entries);
|
|
1189
|
+
}
|
|
1190
|
+
function resolveEndpoint2(endpoint, coreUrl) {
|
|
1191
|
+
if (/^https?:\/\//.test(endpoint)) return endpoint;
|
|
1192
|
+
if (!coreUrl) return endpoint;
|
|
1193
|
+
return `${coreUrl.replace(/\/+$/, "")}${endpoint}`;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
902
1196
|
// src/core/nonceCache.ts
|
|
903
1197
|
var DEFAULT_MAX_ENTRIES = 1e5;
|
|
904
1198
|
var DEFAULT_TTL_MS = 6e5;
|
|
@@ -1583,8 +1877,8 @@ function headerGetter(headers) {
|
|
|
1583
1877
|
|
|
1584
1878
|
// src/core/runtime.ts
|
|
1585
1879
|
var DEFAULT_CORE_URL = "https://core.farthershore.com";
|
|
1586
|
-
var SDK_VERSION = "0.
|
|
1587
|
-
var CONTRACTS_FP = "
|
|
1880
|
+
var SDK_VERSION = "0.14.0".length > 0 ? "0.14.0" : "0.0.0-dev";
|
|
1881
|
+
var CONTRACTS_FP = "1feb5a4a80b447ad".length > 0 ? "1feb5a4a80b447ad" : "0000000000000000";
|
|
1588
1882
|
var FartherShore = class {
|
|
1589
1883
|
bootstrapClient;
|
|
1590
1884
|
fetchImpl;
|
|
@@ -1602,6 +1896,7 @@ var FartherShore = class {
|
|
|
1602
1896
|
shutdownManager = new ShutdownManager();
|
|
1603
1897
|
jwks = null;
|
|
1604
1898
|
meteringClient = null;
|
|
1899
|
+
postStreamUsageClient = null;
|
|
1605
1900
|
tunnel = null;
|
|
1606
1901
|
bootstrapped = false;
|
|
1607
1902
|
constructor(options = {}) {
|
|
@@ -1657,6 +1952,11 @@ var FartherShore = class {
|
|
|
1657
1952
|
coreUrl: this.coreUrl,
|
|
1658
1953
|
fetchImpl: this.fetchImpl
|
|
1659
1954
|
});
|
|
1955
|
+
this.postStreamUsageClient = new PostStreamUsageClient({
|
|
1956
|
+
config: config.metering,
|
|
1957
|
+
coreUrl: this.coreUrl,
|
|
1958
|
+
fetchImpl: this.fetchImpl
|
|
1959
|
+
});
|
|
1660
1960
|
}
|
|
1661
1961
|
this.bootstrapped = true;
|
|
1662
1962
|
return config;
|
|
@@ -1728,7 +2028,7 @@ var FartherShore = class {
|
|
|
1728
2028
|
);
|
|
1729
2029
|
}
|
|
1730
2030
|
const knownRouteIds = new Set(config.routes.map((r) => r.id));
|
|
1731
|
-
|
|
2031
|
+
const context = await verifyRequest(input, {
|
|
1732
2032
|
jwks: this.jwks,
|
|
1733
2033
|
nonceCache: this.nonceCache,
|
|
1734
2034
|
productId: config.product.id,
|
|
@@ -1743,6 +2043,23 @@ var FartherShore = class {
|
|
|
1743
2043
|
contextSecrets: this.contextSecrets,
|
|
1744
2044
|
contextVerification: this.contextVerification
|
|
1745
2045
|
});
|
|
2046
|
+
return {
|
|
2047
|
+
...context,
|
|
2048
|
+
reportUsage: (report) => {
|
|
2049
|
+
const subscriptionId = report.subscriptionId ?? context.signedContext?.subscriptionId;
|
|
2050
|
+
if (!subscriptionId) {
|
|
2051
|
+
return Promise.resolve({
|
|
2052
|
+
ok: false,
|
|
2053
|
+
reason: "subscriptionId is required"
|
|
2054
|
+
});
|
|
2055
|
+
}
|
|
2056
|
+
return this.reportUsage({
|
|
2057
|
+
...report,
|
|
2058
|
+
requestId: report.requestId ?? context.requestId,
|
|
2059
|
+
subscriptionId
|
|
2060
|
+
});
|
|
2061
|
+
}
|
|
2062
|
+
};
|
|
1746
2063
|
}
|
|
1747
2064
|
/** Whether verification is required (bootstrap × opt-out). */
|
|
1748
2065
|
async verificationRequired() {
|
|
@@ -1803,6 +2120,20 @@ var FartherShore = class {
|
|
|
1803
2120
|
}
|
|
1804
2121
|
await this.meteringClient.meter(meter, qty, options);
|
|
1805
2122
|
}
|
|
2123
|
+
/** Best-effort attested post-stream usage callback. Never rejects. */
|
|
2124
|
+
async reportUsage(input) {
|
|
2125
|
+
try {
|
|
2126
|
+
await this.ensureBootstrapped();
|
|
2127
|
+
if (!this.meteringEnabledOverride || !this.postStreamUsageClient) {
|
|
2128
|
+
return { ok: false, reason: "metering is not enabled" };
|
|
2129
|
+
}
|
|
2130
|
+
return await this.postStreamUsageClient.reportUsage(input);
|
|
2131
|
+
} catch (error) {
|
|
2132
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
2133
|
+
console.warn(`post-stream usage report skipped: ${reason}`);
|
|
2134
|
+
return { ok: false, reason };
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
1806
2137
|
/** Current local health report. */
|
|
1807
2138
|
health() {
|
|
1808
2139
|
const config = this.bootstrapClient.peek();
|
|
@@ -1903,188 +2234,6 @@ function headerValue(headers, name) {
|
|
|
1903
2234
|
return value;
|
|
1904
2235
|
}
|
|
1905
2236
|
|
|
1906
|
-
// src/response-metering.ts
|
|
1907
|
-
var RESPONSE_METERING_HEADERS = RUNTIME_RESPONSE_METERING_CONTRACT.headers;
|
|
1908
|
-
var RESPONSE_METERING_ERROR_CODES = RUNTIME_RESPONSE_METERING_CONTRACT.errors;
|
|
1909
|
-
var devMeteringHooks = null;
|
|
1910
|
-
function __setDevMeteringHooks(hooks) {
|
|
1911
|
-
devMeteringHooks = hooks;
|
|
1912
|
-
}
|
|
1913
|
-
var METERING_PAYLOAD_HEADER = RESPONSE_METERING_HEADERS.payload;
|
|
1914
|
-
var METERING_SIGNATURE_HEADER = RESPONSE_METERING_HEADERS.signature;
|
|
1915
|
-
var METERING_TOKEN_HEADER = RESPONSE_METERING_HEADERS.token;
|
|
1916
|
-
var DEFAULT_TOKEN_ENV = RUNTIME_RESPONSE_METERING_CONTRACT.token.environmentVariable;
|
|
1917
|
-
var MeteringError = class extends Error {
|
|
1918
|
-
code;
|
|
1919
|
-
constructor(code, message) {
|
|
1920
|
-
super(message);
|
|
1921
|
-
this.name = "MeteringError";
|
|
1922
|
-
this.code = code;
|
|
1923
|
-
}
|
|
1924
|
-
};
|
|
1925
|
-
function createUsage(request, options = {}) {
|
|
1926
|
-
const usage = {};
|
|
1927
|
-
const reporter = {
|
|
1928
|
-
report(meter, value) {
|
|
1929
|
-
usage[assertMeterKey(meter)] = assertMeterValue(meter, value);
|
|
1930
|
-
return reporter;
|
|
1931
|
-
},
|
|
1932
|
-
async wrap(response, wrapOptions = {}) {
|
|
1933
|
-
return signResponse(request, response, usage, options, wrapOptions);
|
|
1934
|
-
}
|
|
1935
|
-
};
|
|
1936
|
-
return reporter;
|
|
1937
|
-
}
|
|
1938
|
-
async function withUsage(request, response, usage, options = {}) {
|
|
1939
|
-
const reporter = createUsage(request, options);
|
|
1940
|
-
for (const [meter, value] of Object.entries(usage)) {
|
|
1941
|
-
reporter.report(meter, value);
|
|
1942
|
-
}
|
|
1943
|
-
return reporter.wrap(response);
|
|
1944
|
-
}
|
|
1945
|
-
async function signResponse(request, response, usage, options, wrapOptions) {
|
|
1946
|
-
const payload = buildPayload(request, usage, options, wrapOptions);
|
|
1947
|
-
const requestId = request.headers.get("x-fs-request-id") ?? void 0;
|
|
1948
|
-
const headers = await computeMeteringHeaders(payload, {
|
|
1949
|
-
...options.token !== void 0 ? { token: options.token } : {},
|
|
1950
|
-
...options.env !== void 0 ? { env: options.env } : {},
|
|
1951
|
-
...requestId ? { requestId } : {},
|
|
1952
|
-
onSkip: () => {
|
|
1953
|
-
}
|
|
1954
|
-
});
|
|
1955
|
-
if (Object.keys(headers).length === 0) {
|
|
1956
|
-
throw new MeteringError(
|
|
1957
|
-
RESPONSE_METERING_ERROR_CODES.missingToken,
|
|
1958
|
-
`${DEFAULT_TOKEN_ENV} is required to sign Farther Shore metering reports`
|
|
1959
|
-
);
|
|
1960
|
-
}
|
|
1961
|
-
const merged = new Headers(response.headers);
|
|
1962
|
-
for (const [name, value] of Object.entries(headers)) merged.set(name, value);
|
|
1963
|
-
return new Response(response.body, {
|
|
1964
|
-
status: response.status,
|
|
1965
|
-
statusText: response.statusText,
|
|
1966
|
-
headers: merged
|
|
1967
|
-
});
|
|
1968
|
-
}
|
|
1969
|
-
async function computeMeteringHeaders(payload, options = {}) {
|
|
1970
|
-
try {
|
|
1971
|
-
const token = resolveTokenSoft(options);
|
|
1972
|
-
if (!token) {
|
|
1973
|
-
skip(`${DEFAULT_TOKEN_ENV} is not set`, options);
|
|
1974
|
-
return {};
|
|
1975
|
-
}
|
|
1976
|
-
const json2 = JSON.stringify(payload);
|
|
1977
|
-
const signature = await signPayload(json2, token);
|
|
1978
|
-
devMeteringHooks?.record?.(payload, options.requestId);
|
|
1979
|
-
return {
|
|
1980
|
-
[METERING_PAYLOAD_HEADER]: json2,
|
|
1981
|
-
[METERING_SIGNATURE_HEADER]: signature,
|
|
1982
|
-
[METERING_TOKEN_HEADER]: token
|
|
1983
|
-
};
|
|
1984
|
-
} catch (error) {
|
|
1985
|
-
skip(error instanceof Error ? error.message : String(error), options);
|
|
1986
|
-
return {};
|
|
1987
|
-
}
|
|
1988
|
-
}
|
|
1989
|
-
function skip(reason, options) {
|
|
1990
|
-
if (options.onSkip) {
|
|
1991
|
-
options.onSkip(reason);
|
|
1992
|
-
} else {
|
|
1993
|
-
console.warn(`metering headers skipped: ${reason}`);
|
|
1994
|
-
}
|
|
1995
|
-
devMeteringHooks?.onSkip?.(reason, options.requestId);
|
|
1996
|
-
}
|
|
1997
|
-
function resolveTokenSoft(options) {
|
|
1998
|
-
return options.token ?? options.env?.[DEFAULT_TOKEN_ENV] ?? processEnv(DEFAULT_TOKEN_ENV) ?? devMeteringHooks?.fallbackToken?.();
|
|
1999
|
-
}
|
|
2000
|
-
function buildPayload(request, usage, options, wrapOptions) {
|
|
2001
|
-
const url = new URL(request.url);
|
|
2002
|
-
const measureContext = wrapOptions.measureContext ?? options.measureContext;
|
|
2003
|
-
const creditUnitsConsumed = wrapOptions.creditUnitsConsumed ?? options.creditUnitsConsumed;
|
|
2004
|
-
const operationKey = wrapOptions.operationKey ?? options.operationKey;
|
|
2005
|
-
const usagePolicyId = wrapOptions.usagePolicyId ?? options.usagePolicyId;
|
|
2006
|
-
const payload = {
|
|
2007
|
-
method: request.method.toUpperCase(),
|
|
2008
|
-
path: url.pathname,
|
|
2009
|
-
rawDimsUnits: sortUsage(usage),
|
|
2010
|
-
...measureContext ? { measureContext } : {},
|
|
2011
|
-
...creditUnitsConsumed ? {
|
|
2012
|
-
creditUnitsConsumed: sortUsage(
|
|
2013
|
-
validateUsageMap(creditUnitsConsumed, "creditUnitsConsumed")
|
|
2014
|
-
)
|
|
2015
|
-
} : {},
|
|
2016
|
-
...operationKey ? { operationKey: assertIdentifier(operationKey) } : {},
|
|
2017
|
-
...usagePolicyId ? { usagePolicyId: assertIdentifier(usagePolicyId) } : {}
|
|
2018
|
-
};
|
|
2019
|
-
return payload;
|
|
2020
|
-
}
|
|
2021
|
-
function sortUsage(usage) {
|
|
2022
|
-
return Object.fromEntries(
|
|
2023
|
-
Object.entries(usage).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
|
|
2024
|
-
);
|
|
2025
|
-
}
|
|
2026
|
-
function validateUsageMap(usage, label) {
|
|
2027
|
-
return Object.fromEntries(
|
|
2028
|
-
Object.entries(usage).map(([meter, value]) => [
|
|
2029
|
-
assertMeterKey(meter),
|
|
2030
|
-
assertMeterValue(`${label}.${meter}`, value)
|
|
2031
|
-
])
|
|
2032
|
-
);
|
|
2033
|
-
}
|
|
2034
|
-
function assertMeterKey(meter) {
|
|
2035
|
-
if (!/^[a-z0-9_]{1,64}$/.test(meter)) {
|
|
2036
|
-
throw new MeteringError(
|
|
2037
|
-
RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
|
|
2038
|
-
`meter key "${meter}" must be lowercase alphanumeric with underscores`
|
|
2039
|
-
);
|
|
2040
|
-
}
|
|
2041
|
-
return meter;
|
|
2042
|
-
}
|
|
2043
|
-
function assertMeterValue(meter, value) {
|
|
2044
|
-
if (!Number.isFinite(value) || value < 0) {
|
|
2045
|
-
throw new MeteringError(
|
|
2046
|
-
RESPONSE_METERING_ERROR_CODES.invalidMeterValue,
|
|
2047
|
-
`meter "${meter}" value must be a non-negative finite number`
|
|
2048
|
-
);
|
|
2049
|
-
}
|
|
2050
|
-
return value;
|
|
2051
|
-
}
|
|
2052
|
-
function assertIdentifier(value) {
|
|
2053
|
-
if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(value)) {
|
|
2054
|
-
throw new MeteringError(
|
|
2055
|
-
RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
|
|
2056
|
-
`operation and usage policy identifiers must be 1-128 URL-safe characters`
|
|
2057
|
-
);
|
|
2058
|
-
}
|
|
2059
|
-
return value;
|
|
2060
|
-
}
|
|
2061
|
-
function processEnv(key2) {
|
|
2062
|
-
const maybeProcess = globalThis.process;
|
|
2063
|
-
return maybeProcess?.env?.[key2];
|
|
2064
|
-
}
|
|
2065
|
-
async function signPayload(payload, token) {
|
|
2066
|
-
const key2 = await crypto.subtle.importKey(
|
|
2067
|
-
"raw",
|
|
2068
|
-
new TextEncoder().encode(token),
|
|
2069
|
-
{ name: "HMAC", hash: "SHA-256" },
|
|
2070
|
-
false,
|
|
2071
|
-
["sign"]
|
|
2072
|
-
);
|
|
2073
|
-
const signature = await crypto.subtle.sign(
|
|
2074
|
-
"HMAC",
|
|
2075
|
-
key2,
|
|
2076
|
-
new TextEncoder().encode(payload)
|
|
2077
|
-
);
|
|
2078
|
-
return base64url(new Uint8Array(signature));
|
|
2079
|
-
}
|
|
2080
|
-
function base64url(bytes) {
|
|
2081
|
-
let binary = "";
|
|
2082
|
-
for (const byte of bytes) {
|
|
2083
|
-
binary += String.fromCharCode(byte);
|
|
2084
|
-
}
|
|
2085
|
-
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
2086
|
-
}
|
|
2087
|
-
|
|
2088
2237
|
// src/testing/signers.ts
|
|
2089
2238
|
import { generateKeyPairSync, randomBytes } from "node:crypto";
|
|
2090
2239
|
var TEST_KID = "fs-runtime-test-2026";
|
|
@@ -2345,6 +2494,7 @@ function createDevGateway(options) {
|
|
|
2345
2494
|
const productId = options.productId ?? "prod_dev";
|
|
2346
2495
|
const backendId = options.backendId ?? "be_dev";
|
|
2347
2496
|
const meterEvents = [];
|
|
2497
|
+
const reportUsageEvents = [];
|
|
2348
2498
|
const bootstrap = {
|
|
2349
2499
|
product: { id: productId, slug: options.productSlug ?? "dev-product" },
|
|
2350
2500
|
backend: {
|
|
@@ -2394,7 +2544,10 @@ function createDevGateway(options) {
|
|
|
2394
2544
|
}
|
|
2395
2545
|
if (url.includes("/v1/metering/events")) {
|
|
2396
2546
|
const event = await readJsonBody(init, input);
|
|
2397
|
-
if (event) {
|
|
2547
|
+
if (event && "meters" in event) {
|
|
2548
|
+
reportUsageEvents.push(event);
|
|
2549
|
+
options.onReportUsage?.(event);
|
|
2550
|
+
} else if (event) {
|
|
2398
2551
|
meterEvents.push(event);
|
|
2399
2552
|
options.onMeterEvent?.(event);
|
|
2400
2553
|
}
|
|
@@ -2412,6 +2565,7 @@ function createDevGateway(options) {
|
|
|
2412
2565
|
fetchImpl,
|
|
2413
2566
|
bootstrap,
|
|
2414
2567
|
meterEvents,
|
|
2568
|
+
reportUsageEvents,
|
|
2415
2569
|
productId,
|
|
2416
2570
|
backendId,
|
|
2417
2571
|
jwksUrl: DEV_JWKS_URL
|
|
@@ -2488,6 +2642,16 @@ var DevUsageSink = class {
|
|
|
2488
2642
|
at: Date.now()
|
|
2489
2643
|
});
|
|
2490
2644
|
}
|
|
2645
|
+
/** Record an attested post-stream report captured by the dev gateway. */
|
|
2646
|
+
recordReportUsage(event) {
|
|
2647
|
+
this.events.push({
|
|
2648
|
+
source: "reportUsage",
|
|
2649
|
+
meters: { ...event.meters },
|
|
2650
|
+
event,
|
|
2651
|
+
requestId: event.requestId,
|
|
2652
|
+
at: Date.now()
|
|
2653
|
+
});
|
|
2654
|
+
}
|
|
2491
2655
|
/** Total quantity per meter key across every recorded event. */
|
|
2492
2656
|
byMeter() {
|
|
2493
2657
|
const out = {};
|
|
@@ -2610,6 +2774,10 @@ function createDevRuntime(options) {
|
|
|
2610
2774
|
onMeterEvent: (event) => {
|
|
2611
2775
|
usage.recordMeterEvent(event);
|
|
2612
2776
|
options.usageJsonl?.(JSON.stringify({ source: "meter", event }));
|
|
2777
|
+
},
|
|
2778
|
+
onReportUsage: (event) => {
|
|
2779
|
+
usage.recordReportUsage(event);
|
|
2780
|
+
options.usageJsonl?.(JSON.stringify({ source: "reportUsage", event }));
|
|
2613
2781
|
}
|
|
2614
2782
|
});
|
|
2615
2783
|
__setDevMeteringHooks({
|
|
@@ -2858,6 +3026,7 @@ export {
|
|
|
2858
3026
|
MeteringClient,
|
|
2859
3027
|
MeteringError,
|
|
2860
3028
|
NonceCache,
|
|
3029
|
+
PostStreamUsageClient,
|
|
2861
3030
|
REDACTED_TOKEN,
|
|
2862
3031
|
RUNTIME_CLOCK_SKEW_SECONDS,
|
|
2863
3032
|
RUNTIME_ERROR_CODES,
|
package/dist/testing/index.js
CHANGED
|
@@ -864,6 +864,7 @@ function createDevGateway(options) {
|
|
|
864
864
|
const productId = options.productId ?? "prod_dev";
|
|
865
865
|
const backendId = options.backendId ?? "be_dev";
|
|
866
866
|
const meterEvents = [];
|
|
867
|
+
const reportUsageEvents = [];
|
|
867
868
|
const bootstrap = {
|
|
868
869
|
product: { id: productId, slug: options.productSlug ?? "dev-product" },
|
|
869
870
|
backend: {
|
|
@@ -913,7 +914,10 @@ function createDevGateway(options) {
|
|
|
913
914
|
}
|
|
914
915
|
if (url.includes("/v1/metering/events")) {
|
|
915
916
|
const event = await readJsonBody(init, input);
|
|
916
|
-
if (event) {
|
|
917
|
+
if (event && "meters" in event) {
|
|
918
|
+
reportUsageEvents.push(event);
|
|
919
|
+
options.onReportUsage?.(event);
|
|
920
|
+
} else if (event) {
|
|
917
921
|
meterEvents.push(event);
|
|
918
922
|
options.onMeterEvent?.(event);
|
|
919
923
|
}
|
|
@@ -931,6 +935,7 @@ function createDevGateway(options) {
|
|
|
931
935
|
fetchImpl,
|
|
932
936
|
bootstrap,
|
|
933
937
|
meterEvents,
|
|
938
|
+
reportUsageEvents,
|
|
934
939
|
productId,
|
|
935
940
|
backendId,
|
|
936
941
|
jwksUrl: DEV_JWKS_URL
|
|
@@ -1269,6 +1274,152 @@ function resolveEndpoint(endpoint, coreUrl) {
|
|
|
1269
1274
|
return `${coreUrl.replace(/\/+$/, "")}${endpoint}`;
|
|
1270
1275
|
}
|
|
1271
1276
|
|
|
1277
|
+
// src/response-metering.ts
|
|
1278
|
+
var RESPONSE_METERING_HEADERS = RUNTIME_RESPONSE_METERING_CONTRACT.headers;
|
|
1279
|
+
var RESPONSE_METERING_ERROR_CODES = RUNTIME_RESPONSE_METERING_CONTRACT.errors;
|
|
1280
|
+
var devMeteringHooks = null;
|
|
1281
|
+
function __setDevMeteringHooks(hooks) {
|
|
1282
|
+
devMeteringHooks = hooks;
|
|
1283
|
+
}
|
|
1284
|
+
var METERING_PAYLOAD_HEADER = RESPONSE_METERING_HEADERS.payload;
|
|
1285
|
+
var METERING_SIGNATURE_HEADER = RESPONSE_METERING_HEADERS.signature;
|
|
1286
|
+
var METERING_TOKEN_HEADER = RESPONSE_METERING_HEADERS.token;
|
|
1287
|
+
var DEFAULT_TOKEN_ENV = RUNTIME_RESPONSE_METERING_CONTRACT.token.environmentVariable;
|
|
1288
|
+
async function signPayload(payload, token) {
|
|
1289
|
+
const key2 = await crypto.subtle.importKey(
|
|
1290
|
+
"raw",
|
|
1291
|
+
new TextEncoder().encode(token),
|
|
1292
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
1293
|
+
false,
|
|
1294
|
+
["sign"]
|
|
1295
|
+
);
|
|
1296
|
+
const signature = await crypto.subtle.sign(
|
|
1297
|
+
"HMAC",
|
|
1298
|
+
key2,
|
|
1299
|
+
new TextEncoder().encode(payload)
|
|
1300
|
+
);
|
|
1301
|
+
return base64url(new Uint8Array(signature));
|
|
1302
|
+
}
|
|
1303
|
+
function base64url(bytes) {
|
|
1304
|
+
let binary = "";
|
|
1305
|
+
for (const byte of bytes) {
|
|
1306
|
+
binary += String.fromCharCode(byte);
|
|
1307
|
+
}
|
|
1308
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// src/core/post-stream-usage.ts
|
|
1312
|
+
var METER_KEY_RE2 = /^[a-z0-9_]{1,64}$/;
|
|
1313
|
+
var PostStreamUsageClient = class {
|
|
1314
|
+
config;
|
|
1315
|
+
endpoint;
|
|
1316
|
+
fetchImpl;
|
|
1317
|
+
newNonce;
|
|
1318
|
+
logger;
|
|
1319
|
+
sleep;
|
|
1320
|
+
retryDelaysMs;
|
|
1321
|
+
constructor(options) {
|
|
1322
|
+
this.config = options.config;
|
|
1323
|
+
this.endpoint = resolveEndpoint2(options.config.endpoint, options.coreUrl);
|
|
1324
|
+
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
1325
|
+
this.newNonce = options.newNonce ?? (() => crypto.randomUUID());
|
|
1326
|
+
this.logger = options.logger ?? ((message) => console.warn(message));
|
|
1327
|
+
this.sleep = options.sleep ?? sleep;
|
|
1328
|
+
this.retryDelaysMs = options.retryDelaysMs ?? [100, 250, 500];
|
|
1329
|
+
}
|
|
1330
|
+
async reportUsage(input) {
|
|
1331
|
+
try {
|
|
1332
|
+
if (!this.config.enabled) throw new Error("metering is not enabled");
|
|
1333
|
+
if (!input.requestId) throw new Error("requestId is required");
|
|
1334
|
+
if (!input.subscriptionId) throw new Error("subscriptionId is required");
|
|
1335
|
+
const unsigned = {
|
|
1336
|
+
requestId: input.requestId,
|
|
1337
|
+
subscriptionId: input.subscriptionId,
|
|
1338
|
+
nonce: this.newNonce(),
|
|
1339
|
+
meters: validateAndSortUsage(input.meters, "meters", this.config, true),
|
|
1340
|
+
...input.creditUnitsConsumed ? {
|
|
1341
|
+
creditUnitsConsumed: validateAndSortUsage(
|
|
1342
|
+
input.creditUnitsConsumed,
|
|
1343
|
+
"creditUnitsConsumed",
|
|
1344
|
+
this.config,
|
|
1345
|
+
false
|
|
1346
|
+
)
|
|
1347
|
+
} : {},
|
|
1348
|
+
...input.measureContext ? { measureContext: input.measureContext } : {}
|
|
1349
|
+
};
|
|
1350
|
+
const signature = await signPayload(
|
|
1351
|
+
JSON.stringify(unsigned),
|
|
1352
|
+
this.config.credential
|
|
1353
|
+
);
|
|
1354
|
+
const event = { ...unsigned, signature };
|
|
1355
|
+
const body = JSON.stringify(event);
|
|
1356
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
1357
|
+
const response = await this.fetchImpl(this.endpoint, {
|
|
1358
|
+
method: "POST",
|
|
1359
|
+
headers: {
|
|
1360
|
+
authorization: `Bearer ${this.config.credential}`,
|
|
1361
|
+
"content-type": "application/json",
|
|
1362
|
+
accept: "application/json"
|
|
1363
|
+
},
|
|
1364
|
+
body
|
|
1365
|
+
});
|
|
1366
|
+
if (response.ok) return { ok: true };
|
|
1367
|
+
const requestNotFound = await isPostStreamRequestNotFound(response);
|
|
1368
|
+
const delayMs = this.retryDelaysMs[attempt];
|
|
1369
|
+
if (!requestNotFound || delayMs === void 0) {
|
|
1370
|
+
throw new Error(`metering endpoint returned ${response.status}`);
|
|
1371
|
+
}
|
|
1372
|
+
await this.sleep(delayMs);
|
|
1373
|
+
}
|
|
1374
|
+
} catch (error) {
|
|
1375
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1376
|
+
this.logger(`post-stream usage report skipped: ${reason}`);
|
|
1377
|
+
return { ok: false, reason };
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
};
|
|
1381
|
+
async function isPostStreamRequestNotFound(response) {
|
|
1382
|
+
if (response.status !== 422) return false;
|
|
1383
|
+
try {
|
|
1384
|
+
const body = await response.json();
|
|
1385
|
+
return body.error?.code === "post_stream_request_not_found";
|
|
1386
|
+
} catch {
|
|
1387
|
+
return false;
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
function sleep(delayMs) {
|
|
1391
|
+
return new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
1392
|
+
}
|
|
1393
|
+
function validateAndSortUsage(usage, label, config, enforceMeterScope) {
|
|
1394
|
+
const entries = Object.entries(usage).sort(
|
|
1395
|
+
([a], [b]) => a < b ? -1 : a > b ? 1 : 0
|
|
1396
|
+
);
|
|
1397
|
+
for (const [meter, qty] of entries) {
|
|
1398
|
+
if (!METER_KEY_RE2.test(meter)) {
|
|
1399
|
+
throw new Error(
|
|
1400
|
+
`${label} key '${meter}' must be lowercase alphanumeric with underscores`
|
|
1401
|
+
);
|
|
1402
|
+
}
|
|
1403
|
+
if (!Number.isFinite(qty) || qty < 0) {
|
|
1404
|
+
throw new Error(`${label}.${meter} must be a non-negative finite number`);
|
|
1405
|
+
}
|
|
1406
|
+
if (enforceMeterScope && config.allowedMeters.length > 0 && !config.allowedMeters.includes(meter)) {
|
|
1407
|
+
throw new Error(`meter '${meter}' is not in the token's allowedMeters`);
|
|
1408
|
+
}
|
|
1409
|
+
if (enforceMeterScope && config.perEventMax > 0 && qty > config.perEventMax) {
|
|
1410
|
+
throw new Error(
|
|
1411
|
+
`meter '${meter}' qty ${qty} exceeds the per-event max ${config.perEventMax}`
|
|
1412
|
+
);
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
return Object.fromEntries(entries);
|
|
1416
|
+
}
|
|
1417
|
+
function resolveEndpoint2(endpoint, coreUrl) {
|
|
1418
|
+
if (/^https?:\/\//.test(endpoint)) return endpoint;
|
|
1419
|
+
if (!coreUrl) return endpoint;
|
|
1420
|
+
return `${coreUrl.replace(/\/+$/, "")}${endpoint}`;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1272
1423
|
// src/core/nonceCache.ts
|
|
1273
1424
|
var DEFAULT_MAX_ENTRIES = 1e5;
|
|
1274
1425
|
var DEFAULT_TTL_MS = 6e5;
|
|
@@ -1947,8 +2098,8 @@ function headerGetter(headers) {
|
|
|
1947
2098
|
|
|
1948
2099
|
// src/core/runtime.ts
|
|
1949
2100
|
var DEFAULT_CORE_URL = "https://core.farthershore.com";
|
|
1950
|
-
var SDK_VERSION = "0.
|
|
1951
|
-
var CONTRACTS_FP = "
|
|
2101
|
+
var SDK_VERSION = "0.14.0".length > 0 ? "0.14.0" : "0.0.0-dev";
|
|
2102
|
+
var CONTRACTS_FP = "1feb5a4a80b447ad".length > 0 ? "1feb5a4a80b447ad" : "0000000000000000";
|
|
1952
2103
|
var FartherShore = class {
|
|
1953
2104
|
bootstrapClient;
|
|
1954
2105
|
fetchImpl;
|
|
@@ -1966,6 +2117,7 @@ var FartherShore = class {
|
|
|
1966
2117
|
shutdownManager = new ShutdownManager();
|
|
1967
2118
|
jwks = null;
|
|
1968
2119
|
meteringClient = null;
|
|
2120
|
+
postStreamUsageClient = null;
|
|
1969
2121
|
tunnel = null;
|
|
1970
2122
|
bootstrapped = false;
|
|
1971
2123
|
constructor(options = {}) {
|
|
@@ -2021,6 +2173,11 @@ var FartherShore = class {
|
|
|
2021
2173
|
coreUrl: this.coreUrl,
|
|
2022
2174
|
fetchImpl: this.fetchImpl
|
|
2023
2175
|
});
|
|
2176
|
+
this.postStreamUsageClient = new PostStreamUsageClient({
|
|
2177
|
+
config: config.metering,
|
|
2178
|
+
coreUrl: this.coreUrl,
|
|
2179
|
+
fetchImpl: this.fetchImpl
|
|
2180
|
+
});
|
|
2024
2181
|
}
|
|
2025
2182
|
this.bootstrapped = true;
|
|
2026
2183
|
return config;
|
|
@@ -2092,7 +2249,7 @@ var FartherShore = class {
|
|
|
2092
2249
|
);
|
|
2093
2250
|
}
|
|
2094
2251
|
const knownRouteIds = new Set(config.routes.map((r) => r.id));
|
|
2095
|
-
|
|
2252
|
+
const context = await verifyRequest(input, {
|
|
2096
2253
|
jwks: this.jwks,
|
|
2097
2254
|
nonceCache: this.nonceCache,
|
|
2098
2255
|
productId: config.product.id,
|
|
@@ -2107,6 +2264,23 @@ var FartherShore = class {
|
|
|
2107
2264
|
contextSecrets: this.contextSecrets,
|
|
2108
2265
|
contextVerification: this.contextVerification
|
|
2109
2266
|
});
|
|
2267
|
+
return {
|
|
2268
|
+
...context,
|
|
2269
|
+
reportUsage: (report) => {
|
|
2270
|
+
const subscriptionId = report.subscriptionId ?? context.signedContext?.subscriptionId;
|
|
2271
|
+
if (!subscriptionId) {
|
|
2272
|
+
return Promise.resolve({
|
|
2273
|
+
ok: false,
|
|
2274
|
+
reason: "subscriptionId is required"
|
|
2275
|
+
});
|
|
2276
|
+
}
|
|
2277
|
+
return this.reportUsage({
|
|
2278
|
+
...report,
|
|
2279
|
+
requestId: report.requestId ?? context.requestId,
|
|
2280
|
+
subscriptionId
|
|
2281
|
+
});
|
|
2282
|
+
}
|
|
2283
|
+
};
|
|
2110
2284
|
}
|
|
2111
2285
|
/** Whether verification is required (bootstrap × opt-out). */
|
|
2112
2286
|
async verificationRequired() {
|
|
@@ -2167,6 +2341,20 @@ var FartherShore = class {
|
|
|
2167
2341
|
}
|
|
2168
2342
|
await this.meteringClient.meter(meter, qty, options);
|
|
2169
2343
|
}
|
|
2344
|
+
/** Best-effort attested post-stream usage callback. Never rejects. */
|
|
2345
|
+
async reportUsage(input) {
|
|
2346
|
+
try {
|
|
2347
|
+
await this.ensureBootstrapped();
|
|
2348
|
+
if (!this.meteringEnabledOverride || !this.postStreamUsageClient) {
|
|
2349
|
+
return { ok: false, reason: "metering is not enabled" };
|
|
2350
|
+
}
|
|
2351
|
+
return await this.postStreamUsageClient.reportUsage(input);
|
|
2352
|
+
} catch (error) {
|
|
2353
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
2354
|
+
console.warn(`post-stream usage report skipped: ${reason}`);
|
|
2355
|
+
return { ok: false, reason };
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2170
2358
|
/** Current local health report. */
|
|
2171
2359
|
health() {
|
|
2172
2360
|
const config = this.bootstrapClient.peek();
|
|
@@ -2267,18 +2455,6 @@ function headerValue(headers, name) {
|
|
|
2267
2455
|
return value;
|
|
2268
2456
|
}
|
|
2269
2457
|
|
|
2270
|
-
// src/response-metering.ts
|
|
2271
|
-
var RESPONSE_METERING_HEADERS = RUNTIME_RESPONSE_METERING_CONTRACT.headers;
|
|
2272
|
-
var RESPONSE_METERING_ERROR_CODES = RUNTIME_RESPONSE_METERING_CONTRACT.errors;
|
|
2273
|
-
var devMeteringHooks = null;
|
|
2274
|
-
function __setDevMeteringHooks(hooks) {
|
|
2275
|
-
devMeteringHooks = hooks;
|
|
2276
|
-
}
|
|
2277
|
-
var METERING_PAYLOAD_HEADER = RESPONSE_METERING_HEADERS.payload;
|
|
2278
|
-
var METERING_SIGNATURE_HEADER = RESPONSE_METERING_HEADERS.signature;
|
|
2279
|
-
var METERING_TOKEN_HEADER = RESPONSE_METERING_HEADERS.token;
|
|
2280
|
-
var DEFAULT_TOKEN_ENV = RUNTIME_RESPONSE_METERING_CONTRACT.token.environmentVariable;
|
|
2281
|
-
|
|
2282
2458
|
// src/testing/prodGuard.ts
|
|
2283
2459
|
function isProductionEnv(env = readProcessEnv2()) {
|
|
2284
2460
|
return (env.NODE_ENV ?? "").trim().toLowerCase() === "production";
|
|
@@ -2326,6 +2502,16 @@ var DevUsageSink = class {
|
|
|
2326
2502
|
at: Date.now()
|
|
2327
2503
|
});
|
|
2328
2504
|
}
|
|
2505
|
+
/** Record an attested post-stream report captured by the dev gateway. */
|
|
2506
|
+
recordReportUsage(event) {
|
|
2507
|
+
this.events.push({
|
|
2508
|
+
source: "reportUsage",
|
|
2509
|
+
meters: { ...event.meters },
|
|
2510
|
+
event,
|
|
2511
|
+
requestId: event.requestId,
|
|
2512
|
+
at: Date.now()
|
|
2513
|
+
});
|
|
2514
|
+
}
|
|
2329
2515
|
/** Total quantity per meter key across every recorded event. */
|
|
2330
2516
|
byMeter() {
|
|
2331
2517
|
const out = {};
|
|
@@ -2470,6 +2656,10 @@ function createDevRuntime(options) {
|
|
|
2470
2656
|
onMeterEvent: (event) => {
|
|
2471
2657
|
usage.recordMeterEvent(event);
|
|
2472
2658
|
options.usageJsonl?.(JSON.stringify({ source: "meter", event }));
|
|
2659
|
+
},
|
|
2660
|
+
onReportUsage: (event) => {
|
|
2661
|
+
usage.recordReportUsage(event);
|
|
2662
|
+
options.usageJsonl?.(JSON.stringify({ source: "reportUsage", event }));
|
|
2473
2663
|
}
|
|
2474
2664
|
});
|
|
2475
2665
|
__setDevMeteringHooks({
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { RuntimeMeteringConfig } from "../runtime-types.js";
|
|
2
|
+
export type ReportUsageInput = {
|
|
3
|
+
requestId: string;
|
|
4
|
+
subscriptionId: string;
|
|
5
|
+
meters: Record<string, number>;
|
|
6
|
+
creditUnitsConsumed?: Record<string, number>;
|
|
7
|
+
measureContext?: Record<string, unknown>;
|
|
8
|
+
};
|
|
9
|
+
export type RequestScopedReportUsageInput = Omit<ReportUsageInput, "requestId" | "subscriptionId"> & {
|
|
10
|
+
requestId?: string;
|
|
11
|
+
subscriptionId?: string;
|
|
12
|
+
};
|
|
13
|
+
export type ReportUsageResult = {
|
|
14
|
+
ok: true;
|
|
15
|
+
} | {
|
|
16
|
+
ok: false;
|
|
17
|
+
reason: string;
|
|
18
|
+
};
|
|
19
|
+
export type PostStreamUsageClientOptions = {
|
|
20
|
+
config: RuntimeMeteringConfig;
|
|
21
|
+
coreUrl?: string;
|
|
22
|
+
fetchImpl?: typeof fetch;
|
|
23
|
+
newNonce?: () => string;
|
|
24
|
+
logger?: (message: string) => void;
|
|
25
|
+
/** Injectable for tests; defaults to a normal timer-backed delay. */
|
|
26
|
+
sleep?: (delayMs: number) => Promise<void>;
|
|
27
|
+
/** Backoff after each request-not-found response. */
|
|
28
|
+
retryDelaysMs?: readonly number[];
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Best-effort, attested post-stream billing reporter. The callback is
|
|
32
|
+
* request-bound in Core but never settles a Durable Object enforcement window.
|
|
33
|
+
* Every failure resolves ok:false.
|
|
34
|
+
*/
|
|
35
|
+
export declare class PostStreamUsageClient {
|
|
36
|
+
private readonly config;
|
|
37
|
+
private readonly endpoint;
|
|
38
|
+
private readonly fetchImpl;
|
|
39
|
+
private readonly newNonce;
|
|
40
|
+
private readonly logger;
|
|
41
|
+
private readonly sleep;
|
|
42
|
+
private readonly retryDelaysMs;
|
|
43
|
+
constructor(options: PostStreamUsageClientOptions);
|
|
44
|
+
reportUsage(input: ReportUsageInput): Promise<ReportUsageResult>;
|
|
45
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type RuntimeBootstrapResponse, type RuntimeHealthReport } from "../runtime-types.js";
|
|
2
2
|
import type { ReconcileResult } from "../reflect/reconcile.js";
|
|
3
3
|
import { type MeterOptions } from "./metering.js";
|
|
4
|
+
import { type ReportUsageInput, type ReportUsageResult } from "./post-stream-usage.js";
|
|
4
5
|
import { type SpawnFn } from "./tunnel.js";
|
|
5
6
|
import { type FartherShoreRequestContext, type VerifyRequestInput } from "./verifyRequest.js";
|
|
6
7
|
/** Advanced opt-in tunnel config. The embedded runner is the default DX. */
|
|
@@ -95,6 +96,7 @@ export declare class FartherShore {
|
|
|
95
96
|
private readonly shutdownManager;
|
|
96
97
|
private jwks;
|
|
97
98
|
private meteringClient;
|
|
99
|
+
private postStreamUsageClient;
|
|
98
100
|
private tunnel;
|
|
99
101
|
private bootstrapped;
|
|
100
102
|
constructor(options?: FartherShoreInitOptions);
|
|
@@ -137,6 +139,8 @@ export declare class FartherShore {
|
|
|
137
139
|
start(): Promise<void>;
|
|
138
140
|
/** Record metering usage (billing-only). */
|
|
139
141
|
meter(meter: string, qty: number, options?: MeterOptions): Promise<void>;
|
|
142
|
+
/** Best-effort attested post-stream usage callback. Never rejects. */
|
|
143
|
+
reportUsage(input: ReportUsageInput): Promise<ReportUsageResult>;
|
|
140
144
|
/** Current local health report. */
|
|
141
145
|
health(): RuntimeHealthReport;
|
|
142
146
|
/** Graceful shutdown: flush metering + send a stopping heartbeat. */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ReportUsageResult, RequestScopedReportUsageInput } from "./post-stream-usage.js";
|
|
1
2
|
import { type FartherShoreSignedContext } from "./verifyContext.js";
|
|
2
3
|
import type { JwksClient } from "./jwks.js";
|
|
3
4
|
import type { NonceCache } from "./nonceCache.js";
|
|
@@ -48,6 +49,8 @@ export type FartherShoreRequestContext = {
|
|
|
48
49
|
signedContext?: FartherShoreSignedContext;
|
|
49
50
|
/** Managed-RBAC role keys the acting user holds (display/audit only). */
|
|
50
51
|
roles?: string[];
|
|
52
|
+
/** Request-bound post-stream reporter, attached by the runtime facade. */
|
|
53
|
+
reportUsage?: (input: RequestScopedReportUsageInput) => Promise<ReportUsageResult>;
|
|
51
54
|
};
|
|
52
55
|
export type VerifyRequestDeps = {
|
|
53
56
|
jwks: JwksClient;
|
|
@@ -149,14 +149,29 @@ export declare const RUNTIME_METERING_CONTRACT: {
|
|
|
149
149
|
readonly backend_id: "string";
|
|
150
150
|
readonly route_id: "string?";
|
|
151
151
|
readonly request_id: "string?";
|
|
152
|
+
readonly requestId: "string?";
|
|
153
|
+
readonly subscriptionId: "string";
|
|
154
|
+
readonly nonce: "string?";
|
|
152
155
|
readonly meter: "string";
|
|
153
156
|
readonly qty: "number";
|
|
154
157
|
readonly timestamp: "string";
|
|
155
158
|
};
|
|
159
|
+
readonly postStreamEvent: {
|
|
160
|
+
readonly requestId: "string";
|
|
161
|
+
readonly subscriptionId: "string?";
|
|
162
|
+
readonly nonce: "string";
|
|
163
|
+
readonly meters: "Record<string, number>";
|
|
164
|
+
readonly creditUnitsConsumed: "Record<string, number>?";
|
|
165
|
+
readonly measureContext: "Record<string, unknown>?";
|
|
166
|
+
readonly signature: "string";
|
|
167
|
+
};
|
|
156
168
|
readonly idempotencyKey: "event_id";
|
|
157
169
|
readonly delivery: "at-least-once";
|
|
158
170
|
readonly billingOnly: true;
|
|
159
171
|
readonly realtimeEnforced: false;
|
|
172
|
+
readonly postStreamBillingOnly: true;
|
|
173
|
+
readonly postStreamRealtimeEnforced: false;
|
|
174
|
+
readonly postStreamTrustModel: "HMAC-attested and bound to one served postStreamBilling gateway request. Core writes one billable UsageEvent using the served plan and time. The callback never mutates Durable Object enforcement windows.";
|
|
160
175
|
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.";
|
|
161
176
|
};
|
|
162
177
|
export declare const RUNTIME_RESPONSE_METERING_CONTRACT: {
|
package/dist/types/index.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ export { JwksClient, type Jwk, type JwksClientOptions } from "./core/jwks.js";
|
|
|
11
11
|
export { NonceCache, type NonceCacheOptions } from "./core/nonceCache.js";
|
|
12
12
|
export { BootstrapClient, type BootstrapClientOptions, } from "./core/bootstrap.js";
|
|
13
13
|
export { MeteringClient, type MeteringClientOptions, type MeterOptions, } from "./core/metering.js";
|
|
14
|
+
export { PostStreamUsageClient, type PostStreamUsageClientOptions, type ReportUsageInput, type RequestScopedReportUsageInput, type ReportUsageResult, } from "./core/post-stream-usage.js";
|
|
14
15
|
export { buildHealthReport, reportHealth, type HealthSnapshot, type HealthStatus, type HeartbeatOptions, } from "./core/health.js";
|
|
15
16
|
export { ShutdownManager, type ShutdownHook } from "./core/shutdown.js";
|
|
16
17
|
export { CloudflaredSupervisor, nodeSpawn, REDACTED_TOKEN, type SpawnFn, type SpawnedTunnelProcess, type CloudflaredSupervisorOptions, type TunnelState, type TunnelStatus, } from "./core/tunnel.js";
|
|
@@ -255,6 +255,16 @@ export type RuntimeMeteringEvent = {
|
|
|
255
255
|
qty: number;
|
|
256
256
|
timestamp: string;
|
|
257
257
|
};
|
|
258
|
+
/** Attested, request-bound billing callback sent after a stream completes. */
|
|
259
|
+
export type RuntimePostStreamUsageEvent = {
|
|
260
|
+
requestId: string;
|
|
261
|
+
subscriptionId: string;
|
|
262
|
+
nonce: string;
|
|
263
|
+
meters: Record<string, number>;
|
|
264
|
+
creditUnitsConsumed?: Record<string, number>;
|
|
265
|
+
measureContext?: Record<string, unknown>;
|
|
266
|
+
signature: string;
|
|
267
|
+
};
|
|
258
268
|
export declare const RUNTIME_READINESS_STATES: readonly ["UNKNOWN", "WAITING", "READY", "DEGRADED", "OFFLINE"];
|
|
259
269
|
export type RuntimeReadinessState = (typeof RUNTIME_READINESS_STATES)[number];
|
|
260
270
|
export type RuntimeHealthReport = {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type RuntimeBootstrapResponse, type RuntimeMeteringEvent } from "../runtime-types.js";
|
|
1
|
+
import { type RuntimeBootstrapResponse, type RuntimeMeteringEvent, type RuntimePostStreamUsageEvent } from "../runtime-types.js";
|
|
2
2
|
import type { DevMode } from "./traceSink.js";
|
|
3
3
|
import type { DevSignerKeys } from "./signers.js";
|
|
4
4
|
export declare const DEV_CORE_URL = "https://dev-gateway.farthershore.local";
|
|
@@ -15,6 +15,8 @@ export type DevGatewayOptions = {
|
|
|
15
15
|
routeIds?: string[];
|
|
16
16
|
/** Called for each captured metering event (at-least-once ACK). */
|
|
17
17
|
onMeterEvent?: (event: RuntimeMeteringEvent) => void;
|
|
18
|
+
/** Called for each captured attested post-stream report. */
|
|
19
|
+
onReportUsage?: (event: RuntimePostStreamUsageEvent) => void;
|
|
18
20
|
};
|
|
19
21
|
export type DevGateway = {
|
|
20
22
|
/** Inject this as `fetchImpl` when constructing the FartherShore runtime. */
|
|
@@ -23,6 +25,8 @@ export type DevGateway = {
|
|
|
23
25
|
bootstrap: RuntimeBootstrapResponse;
|
|
24
26
|
/** Every captured background metering event (a capture == an ACK). */
|
|
25
27
|
meterEvents: RuntimeMeteringEvent[];
|
|
28
|
+
/** Every captured attested post-stream usage report. */
|
|
29
|
+
reportUsageEvents: RuntimePostStreamUsageEvent[];
|
|
26
30
|
productId: string;
|
|
27
31
|
backendId: string;
|
|
28
32
|
jwksUrl: string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RuntimeMeteringEvent } from "../runtime-types.js";
|
|
1
|
+
import type { RuntimeMeteringEvent, RuntimePostStreamUsageEvent } from "../runtime-types.js";
|
|
2
2
|
/** One recorded usage observation. `source` distinguishes the two channels. */
|
|
3
3
|
export type DevUsageEvent = {
|
|
4
4
|
source: "response";
|
|
@@ -16,6 +16,12 @@ export type DevUsageEvent = {
|
|
|
16
16
|
event: RuntimeMeteringEvent;
|
|
17
17
|
requestId?: string;
|
|
18
18
|
at: number;
|
|
19
|
+
} | {
|
|
20
|
+
source: "reportUsage";
|
|
21
|
+
meters: Record<string, number>;
|
|
22
|
+
event: RuntimePostStreamUsageEvent;
|
|
23
|
+
requestId: string;
|
|
24
|
+
at: number;
|
|
19
25
|
};
|
|
20
26
|
/** In-memory, assertable sink for all dev usage. */
|
|
21
27
|
export declare class DevUsageSink {
|
|
@@ -24,6 +30,8 @@ export declare class DevUsageSink {
|
|
|
24
30
|
recordResponse(payload: Record<string, unknown>, requestId?: string): void;
|
|
25
31
|
/** Record a background `fs.meter()` event captured by the dev gateway. */
|
|
26
32
|
recordMeterEvent(event: RuntimeMeteringEvent): void;
|
|
33
|
+
/** Record an attested post-stream report captured by the dev gateway. */
|
|
34
|
+
recordReportUsage(event: RuntimePostStreamUsageEvent): void;
|
|
27
35
|
/** Total quantity per meter key across every recorded event. */
|
|
28
36
|
byMeter(): Record<string, number>;
|
|
29
37
|
/** Clear all recorded usage. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@farthershore/backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
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",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
},
|
|
38
38
|
"optionalDependencies": {
|
|
39
39
|
"@farthershore/cloudflared-linux-x64": "0.0.0",
|
|
40
|
-
"@farthershore/cloudflared-darwin-arm64": "0.0.0",
|
|
41
40
|
"@farthershore/cloudflared-linux-arm64": "0.0.0",
|
|
41
|
+
"@farthershore/cloudflared-darwin-arm64": "0.0.0",
|
|
42
42
|
"@farthershore/cloudflared-darwin-x64": "0.0.0"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|