@farthershore/backend 0.2.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/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",
|
|
@@ -117,13 +304,13 @@ async function importEd25519PublicKey(jwk) {
|
|
|
117
304
|
return crypto.subtle.importKey("jwk", { ...jwk, alg: void 0 }, { name: ED25519_ALGORITHM }, false, ["verify"]);
|
|
118
305
|
}
|
|
119
306
|
async function signCanonicalString(canonical, privateJwk) {
|
|
120
|
-
const
|
|
121
|
-
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));
|
|
122
309
|
return base64UrlEncode(new Uint8Array(sig));
|
|
123
310
|
}
|
|
124
311
|
async function verifyCanonicalSignature(canonical, signatureB64Url, publicJwk) {
|
|
125
|
-
const
|
|
126
|
-
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));
|
|
127
314
|
}
|
|
128
315
|
function toHex(bytes) {
|
|
129
316
|
let out = "";
|
|
@@ -345,10 +532,10 @@ var JwksClient = class {
|
|
|
345
532
|
);
|
|
346
533
|
}
|
|
347
534
|
await this.refresh();
|
|
348
|
-
const
|
|
349
|
-
if (
|
|
535
|
+
const key2 = this.keysByKid.get(kid);
|
|
536
|
+
if (key2) {
|
|
350
537
|
this.negativeKids.delete(kid);
|
|
351
|
-
return
|
|
538
|
+
return key2;
|
|
352
539
|
}
|
|
353
540
|
this.rememberMissingKid(kid);
|
|
354
541
|
throw new FartherShoreError(
|
|
@@ -402,8 +589,8 @@ var JwksClient = class {
|
|
|
402
589
|
return;
|
|
403
590
|
}
|
|
404
591
|
const next = /* @__PURE__ */ new Map();
|
|
405
|
-
for (const
|
|
406
|
-
if (typeof
|
|
592
|
+
for (const key2 of doc.keys ?? []) {
|
|
593
|
+
if (typeof key2.kid === "string") next.set(key2.kid, key2);
|
|
407
594
|
}
|
|
408
595
|
this.keysByKid = next;
|
|
409
596
|
this.fetchedAt = this.now();
|
|
@@ -1032,12 +1219,12 @@ function headerGetter(headers) {
|
|
|
1032
1219
|
return (name) => h.get(name) ?? void 0;
|
|
1033
1220
|
}
|
|
1034
1221
|
const lower = /* @__PURE__ */ new Map();
|
|
1035
|
-
for (const [
|
|
1222
|
+
for (const [key2, value] of Object.entries(
|
|
1036
1223
|
headers
|
|
1037
1224
|
)) {
|
|
1038
1225
|
if (value === void 0) continue;
|
|
1039
1226
|
lower.set(
|
|
1040
|
-
|
|
1227
|
+
key2.toLowerCase(),
|
|
1041
1228
|
Array.isArray(value) ? value[0] ?? "" : value
|
|
1042
1229
|
);
|
|
1043
1230
|
}
|
|
@@ -1046,7 +1233,7 @@ function headerGetter(headers) {
|
|
|
1046
1233
|
|
|
1047
1234
|
// src/core/runtime.ts
|
|
1048
1235
|
var DEFAULT_CORE_URL = "https://core.farthershore.com";
|
|
1049
|
-
var SDK_VERSION = "0.
|
|
1236
|
+
var SDK_VERSION = "0.3.0".length > 0 ? "0.3.0" : "0.0.0-dev";
|
|
1050
1237
|
var FartherShore = class {
|
|
1051
1238
|
bootstrapClient;
|
|
1052
1239
|
fetchImpl;
|
|
@@ -1117,6 +1304,60 @@ var FartherShore = class {
|
|
|
1117
1304
|
this.bootstrapped = true;
|
|
1118
1305
|
return config;
|
|
1119
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
|
+
}
|
|
1120
1361
|
/**
|
|
1121
1362
|
* Framework-neutral verification primitive. Fail-closed: throws a typed
|
|
1122
1363
|
* FartherShoreError on any verification failure. Returns the verified context.
|
|
@@ -1471,12 +1712,12 @@ function resolveToken(options) {
|
|
|
1471
1712
|
}
|
|
1472
1713
|
return token;
|
|
1473
1714
|
}
|
|
1474
|
-
function processEnv(
|
|
1715
|
+
function processEnv(key2) {
|
|
1475
1716
|
const maybeProcess = globalThis.process;
|
|
1476
|
-
return maybeProcess?.env?.[
|
|
1717
|
+
return maybeProcess?.env?.[key2];
|
|
1477
1718
|
}
|
|
1478
1719
|
async function signPayload(payload, token) {
|
|
1479
|
-
const
|
|
1720
|
+
const key2 = await crypto.subtle.importKey(
|
|
1480
1721
|
"raw",
|
|
1481
1722
|
new TextEncoder().encode(token),
|
|
1482
1723
|
{ name: "HMAC", hash: "SHA-256" },
|
|
@@ -1485,7 +1726,7 @@ async function signPayload(payload, token) {
|
|
|
1485
1726
|
);
|
|
1486
1727
|
const signature = await crypto.subtle.sign(
|
|
1487
1728
|
"HMAC",
|
|
1488
|
-
|
|
1729
|
+
key2,
|
|
1489
1730
|
new TextEncoder().encode(payload)
|
|
1490
1731
|
);
|
|
1491
1732
|
return base64url(new Uint8Array(signature));
|
|
@@ -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";
|
|
@@ -62,6 +63,23 @@ export declare class FartherShore {
|
|
|
62
63
|
constructor(options?: FartherShoreInitOptions);
|
|
63
64
|
/** Ensure bootstrap config is loaded; build the JWKS + metering clients. */
|
|
64
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;
|
|
65
83
|
/**
|
|
66
84
|
* Framework-neutral verification primitive. Fail-closed: throws a typed
|
|
67
85
|
* FartherShoreError on any verification failure. Returns the verified context.
|
|
@@ -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;
|
|
@@ -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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@farthershore/backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -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"
|