@bounded-sh/observe 0.1.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 +167 -0
- package/dist/context.d.ts +18 -0
- package/dist/context.js +105 -0
- package/dist/counters.d.ts +34 -0
- package/dist/counters.js +141 -0
- package/dist/denylist.d.ts +7 -0
- package/dist/denylist.js +81 -0
- package/dist/envelope.d.ts +71 -0
- package/dist/envelope.js +20 -0
- package/dist/escort.d.ts +92 -0
- package/dist/escort.js +269 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +274 -0
- package/dist/intercept/fetch.d.ts +2 -0
- package/dist/intercept/fetch.js +534 -0
- package/dist/intercept/http.d.ts +2 -0
- package/dist/intercept/http.js +262 -0
- package/dist/llm-prices.d.ts +136 -0
- package/dist/llm-prices.js +240 -0
- package/dist/manifest.d.ts +168 -0
- package/dist/manifest.js +941 -0
- package/dist/pipeline.d.ts +18 -0
- package/dist/pipeline.js +212 -0
- package/dist/recognize.d.ts +38 -0
- package/dist/recognize.js +185 -0
- package/dist/register.d.ts +9 -0
- package/dist/register.js +138 -0
- package/dist/reporter.d.ts +27 -0
- package/dist/reporter.js +210 -0
- package/dist/shapes.d.ts +15 -0
- package/dist/shapes.js +56 -0
- package/dist/state.d.ts +32 -0
- package/dist/state.js +42 -0
- package/dist/template.d.ts +7 -0
- package/dist/template.js +63 -0
- package/dist/types.d.ts +125 -0
- package/dist/types.js +10 -0
- package/package.json +43 -0
package/dist/escort.d.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { CompiledEscort } from "./manifest";
|
|
2
|
+
import type { ActorContext } from "./types";
|
|
3
|
+
export type EscortVerdict = "allowed" | "pending_approval" | "declined";
|
|
4
|
+
export interface VerdictResult {
|
|
5
|
+
verdict: EscortVerdict;
|
|
6
|
+
intentId: string;
|
|
7
|
+
invariant?: string;
|
|
8
|
+
reason?: string;
|
|
9
|
+
approveUrl?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface SettleOutcome {
|
|
12
|
+
outcome: "fired" | "failed" | "fail_open";
|
|
13
|
+
status?: number;
|
|
14
|
+
resultId?: string;
|
|
15
|
+
failOpen?: boolean;
|
|
16
|
+
/** llm-gateway rail ONLY (§4.3): the ACTUAL usage-cost in integer cents,
|
|
17
|
+
* computed from the fired call's response usage. The enforcement worker forwards
|
|
18
|
+
* this as a DECREASE of the estCents reservation (releasing rolling-window
|
|
19
|
+
* headroom). Absent for every other rail. */
|
|
20
|
+
actualCents?: number;
|
|
21
|
+
}
|
|
22
|
+
/** Verdict endpoint unreachable / non-decisive (network, timeout, 5xx, auth).
|
|
23
|
+
* Distinct from a decisive `declined` verdict — the caller applies failMode. */
|
|
24
|
+
export declare class EscortUnreachable extends Error {
|
|
25
|
+
readonly detail: string;
|
|
26
|
+
constructor(detail: string);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A local enforcement error thrown to the app when a call is NOT allowed to
|
|
30
|
+
* fire. Shaped to resemble a Stripe API error (`type`/`code`/`raw.error`) so it
|
|
31
|
+
* flows through rail SDK error handling; also carries Bounded-specific fields.
|
|
32
|
+
* The outbound request never fired.
|
|
33
|
+
*/
|
|
34
|
+
export declare class BoundedEnforcementError extends Error {
|
|
35
|
+
readonly boundedVerdict: "declined" | "pending_approval" | "fail_closed";
|
|
36
|
+
readonly rail: string;
|
|
37
|
+
/** Stripe-compatible error type. */
|
|
38
|
+
readonly type = "invalid_request_error";
|
|
39
|
+
readonly code: string;
|
|
40
|
+
readonly intentId?: string;
|
|
41
|
+
readonly invariant?: string;
|
|
42
|
+
readonly reason?: string;
|
|
43
|
+
readonly approveUrl?: string;
|
|
44
|
+
/** Response-shaped payload mirroring a rail API error body. */
|
|
45
|
+
readonly raw: {
|
|
46
|
+
error: {
|
|
47
|
+
type: string;
|
|
48
|
+
code: string;
|
|
49
|
+
message: string;
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
constructor(opts: {
|
|
53
|
+
boundedVerdict: "declined" | "pending_approval" | "fail_closed";
|
|
54
|
+
rail: string;
|
|
55
|
+
code: string;
|
|
56
|
+
message: string;
|
|
57
|
+
intentId?: string;
|
|
58
|
+
invariant?: string;
|
|
59
|
+
reason?: string;
|
|
60
|
+
approveUrl?: string;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/** Build the rail-shaped local error for a non-fire verdict. */
|
|
64
|
+
export declare function buildRailError(cfg: CompiledEscort, opts: {
|
|
65
|
+
kind: "declined" | "pending_approval" | "fail_closed";
|
|
66
|
+
intentId?: string;
|
|
67
|
+
invariant?: string;
|
|
68
|
+
reason?: string;
|
|
69
|
+
approveUrl?: string;
|
|
70
|
+
}): BoundedEnforcementError;
|
|
71
|
+
/**
|
|
72
|
+
* Extract candidate intent fields from the request body the app already built,
|
|
73
|
+
* via the route's fieldMap (rail field -> intent field). Numbers stay numbers
|
|
74
|
+
* (Stripe amounts in cents); strings are length-capped. PII-shaped mappings are
|
|
75
|
+
* dropped (defense in depth on top of validateManifest).
|
|
76
|
+
*
|
|
77
|
+
* The `llm-gateway` rail (SPEC §4.3) is the ONE COMPUTED case: fieldMap alone
|
|
78
|
+
* cannot express estCents, so instead of copying request-body fields verbatim
|
|
79
|
+
* the shim computes {model, maxTokensReq, estCents} from the model + output cap
|
|
80
|
+
* via the static price table (llm-prices.ts). estCents is an UPPER BOUND at
|
|
81
|
+
* verdict time; the generic verbatim-copy path is UNTOUCHED for every other
|
|
82
|
+
* rail (stripe, etc.).
|
|
83
|
+
*/
|
|
84
|
+
export declare function buildIntentFields(reqBodyText: string | undefined, contentType: string | undefined, cfg: CompiledEscort): Record<string, string | number>;
|
|
85
|
+
/**
|
|
86
|
+
* ONE verdict round-trip to the org's verdict endpoint (sensor-key authed,
|
|
87
|
+
* tight client timeout). Returns a decisive verdict, or throws EscortUnreachable
|
|
88
|
+
* (network/timeout/5xx/auth) so the caller can apply failMode locally.
|
|
89
|
+
*/
|
|
90
|
+
export declare function requestVerdict(cfg: CompiledEscort, intentId: string, intent: Record<string, string | number>, actor: ActorContext | undefined): Promise<VerdictResult>;
|
|
91
|
+
/** Fire-and-forget settle report to the org's settle endpoint. Never throws. */
|
|
92
|
+
export declare function settleAsync(cfg: CompiledEscort, intentId: string, outcome: SettleOutcome): void;
|
package/dist/escort.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// escort.ts -- Mode A escort (enforce) round-trip (SPEC §4.2, T9, U16, U18).
|
|
4
|
+
//
|
|
5
|
+
// When an outbound request matches an escorted route, the interceptor:
|
|
6
|
+
// 1. extracts candidate intent fields from the request the app ALREADY built
|
|
7
|
+
// (never mutates it) via the route's fieldMap,
|
|
8
|
+
// 2. mints an intentId (crypto.randomUUID) and makes ONE verdict round-trip
|
|
9
|
+
// to the org's verdict endpoint (sensor-key authed, tight timeout),
|
|
10
|
+
// 3. on `allowed` lets the call FIRE FROM THE APP'S OWN PROCESS with an
|
|
11
|
+
// Idempotency-Key = intentId, then fire-and-forget settle-reports the
|
|
12
|
+
// outcome; on `declined`/`pending_approval` throws a rail-shaped local
|
|
13
|
+
// error (the call never fires); on verdict-endpoint unreachability applies
|
|
14
|
+
// the route's failMode LOCALLY (closed = clean local error, open =
|
|
15
|
+
// proceed + flag).
|
|
16
|
+
//
|
|
17
|
+
// The payload never transits Bounded (only the mapped intent fields do). This
|
|
18
|
+
// module is deterministic — no LLM anywhere (T3). All network here uses the
|
|
19
|
+
// pre-patch original fetch, so it is never itself captured or escorted.
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.BoundedEnforcementError = exports.EscortUnreachable = void 0;
|
|
23
|
+
exports.buildRailError = buildRailError;
|
|
24
|
+
exports.buildIntentFields = buildIntentFields;
|
|
25
|
+
exports.requestVerdict = requestVerdict;
|
|
26
|
+
exports.settleAsync = settleAsync;
|
|
27
|
+
const denylist_1 = require("./denylist");
|
|
28
|
+
const llm_prices_1 = require("./llm-prices");
|
|
29
|
+
const recognize_1 = require("./recognize");
|
|
30
|
+
const state_1 = require("./state");
|
|
31
|
+
const DEFAULT_VERDICT_TIMEOUT_MS = 3_000;
|
|
32
|
+
/** Verdict endpoint unreachable / non-decisive (network, timeout, 5xx, auth).
|
|
33
|
+
* Distinct from a decisive `declined` verdict — the caller applies failMode. */
|
|
34
|
+
class EscortUnreachable extends Error {
|
|
35
|
+
detail;
|
|
36
|
+
constructor(detail) {
|
|
37
|
+
super(`verdict endpoint unreachable: ${detail}`);
|
|
38
|
+
this.detail = detail;
|
|
39
|
+
this.name = "EscortUnreachable";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
exports.EscortUnreachable = EscortUnreachable;
|
|
43
|
+
/**
|
|
44
|
+
* A local enforcement error thrown to the app when a call is NOT allowed to
|
|
45
|
+
* fire. Shaped to resemble a Stripe API error (`type`/`code`/`raw.error`) so it
|
|
46
|
+
* flows through rail SDK error handling; also carries Bounded-specific fields.
|
|
47
|
+
* The outbound request never fired.
|
|
48
|
+
*/
|
|
49
|
+
class BoundedEnforcementError extends Error {
|
|
50
|
+
boundedVerdict;
|
|
51
|
+
rail;
|
|
52
|
+
/** Stripe-compatible error type. */
|
|
53
|
+
type = "invalid_request_error";
|
|
54
|
+
code;
|
|
55
|
+
intentId;
|
|
56
|
+
invariant;
|
|
57
|
+
reason;
|
|
58
|
+
approveUrl;
|
|
59
|
+
/** Response-shaped payload mirroring a rail API error body. */
|
|
60
|
+
raw;
|
|
61
|
+
constructor(opts) {
|
|
62
|
+
super(opts.message);
|
|
63
|
+
this.name = "BoundedEnforcementError";
|
|
64
|
+
this.boundedVerdict = opts.boundedVerdict;
|
|
65
|
+
this.rail = opts.rail;
|
|
66
|
+
this.code = opts.code;
|
|
67
|
+
this.intentId = opts.intentId;
|
|
68
|
+
this.invariant = opts.invariant;
|
|
69
|
+
this.reason = opts.reason;
|
|
70
|
+
this.approveUrl = opts.approveUrl;
|
|
71
|
+
this.raw = { error: { type: this.type, code: opts.code, message: opts.message } };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
exports.BoundedEnforcementError = BoundedEnforcementError;
|
|
75
|
+
/** Build the rail-shaped local error for a non-fire verdict. */
|
|
76
|
+
function buildRailError(cfg, opts) {
|
|
77
|
+
let code;
|
|
78
|
+
let message;
|
|
79
|
+
if (opts.kind === "declined") {
|
|
80
|
+
code = "bounded_declined";
|
|
81
|
+
message = opts.invariant
|
|
82
|
+
? `Declined by Bounded: invariant "${opts.invariant}" would be violated.`
|
|
83
|
+
: `Declined by Bounded${opts.reason ? ` (${opts.reason})` : ""}.`;
|
|
84
|
+
}
|
|
85
|
+
else if (opts.kind === "pending_approval") {
|
|
86
|
+
code = "bounded_pending_approval";
|
|
87
|
+
message = `Pending approval${opts.approveUrl ? `; approve at ${opts.approveUrl}` : ""}.`;
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
code = "bounded_fail_closed";
|
|
91
|
+
message = "Blocked: Bounded verdict endpoint unreachable and failMode=closed.";
|
|
92
|
+
}
|
|
93
|
+
return new BoundedEnforcementError({
|
|
94
|
+
boundedVerdict: opts.kind,
|
|
95
|
+
rail: cfg.rail,
|
|
96
|
+
code,
|
|
97
|
+
message,
|
|
98
|
+
intentId: opts.intentId,
|
|
99
|
+
invariant: opts.invariant,
|
|
100
|
+
reason: opts.reason,
|
|
101
|
+
approveUrl: opts.approveUrl,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Extract candidate intent fields from the request body the app already built,
|
|
106
|
+
* via the route's fieldMap (rail field -> intent field). Numbers stay numbers
|
|
107
|
+
* (Stripe amounts in cents); strings are length-capped. PII-shaped mappings are
|
|
108
|
+
* dropped (defense in depth on top of validateManifest).
|
|
109
|
+
*
|
|
110
|
+
* The `llm-gateway` rail (SPEC §4.3) is the ONE COMPUTED case: fieldMap alone
|
|
111
|
+
* cannot express estCents, so instead of copying request-body fields verbatim
|
|
112
|
+
* the shim computes {model, maxTokensReq, estCents} from the model + output cap
|
|
113
|
+
* via the static price table (llm-prices.ts). estCents is an UPPER BOUND at
|
|
114
|
+
* verdict time; the generic verbatim-copy path is UNTOUCHED for every other
|
|
115
|
+
* rail (stripe, etc.).
|
|
116
|
+
*/
|
|
117
|
+
function buildIntentFields(reqBodyText, contentType, cfg) {
|
|
118
|
+
const out = {};
|
|
119
|
+
const body = (0, recognize_1.parseBody)(reqBodyText, contentType);
|
|
120
|
+
if (!body)
|
|
121
|
+
return out;
|
|
122
|
+
if (cfg.rail === "llm-gateway") {
|
|
123
|
+
const llm = (0, llm_prices_1.buildLlmIntentFields)(body, cfg.action, reqBodyText);
|
|
124
|
+
if (!llm)
|
|
125
|
+
return out;
|
|
126
|
+
// Only emit intent fields the route's fieldMap targets (server projectFields
|
|
127
|
+
// drops the rest anyway); default carries model + estCents when the promoted
|
|
128
|
+
// fieldMap maps them. maxTokensReq is informational for the reservation.
|
|
129
|
+
for (const [, intentField] of Object.entries(cfg.fieldMap)) {
|
|
130
|
+
if ((0, denylist_1.isDeniedFieldPath)(intentField))
|
|
131
|
+
continue;
|
|
132
|
+
if (intentField === "model")
|
|
133
|
+
out.model = llm.model;
|
|
134
|
+
else if (intentField === "estCents")
|
|
135
|
+
out.estCents = llm.estCents;
|
|
136
|
+
else if (intentField === "maxTokensReq")
|
|
137
|
+
out.maxTokensReq = llm.maxTokensReq;
|
|
138
|
+
}
|
|
139
|
+
// If the fieldMap did not name model/estCents (e.g. a minimal manifest),
|
|
140
|
+
// still carry the invariant field so the reservation is never $0-silent.
|
|
141
|
+
if (out.estCents === undefined)
|
|
142
|
+
out.estCents = llm.estCents;
|
|
143
|
+
if (out.model === undefined)
|
|
144
|
+
out.model = llm.model;
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
for (const [railField, intentField] of Object.entries(cfg.fieldMap)) {
|
|
148
|
+
if ((0, denylist_1.isDeniedFieldPath)(railField) || (0, denylist_1.isDeniedFieldPath)(intentField))
|
|
149
|
+
continue;
|
|
150
|
+
const v = body[railField];
|
|
151
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
152
|
+
out[intentField] = v;
|
|
153
|
+
else if (typeof v === "string" && v.length > 0)
|
|
154
|
+
out[intentField] = v.slice(0, 256);
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* ONE verdict round-trip to the org's verdict endpoint (sensor-key authed,
|
|
160
|
+
* tight client timeout). Returns a decisive verdict, or throws EscortUnreachable
|
|
161
|
+
* (network/timeout/5xx/auth) so the caller can apply failMode locally.
|
|
162
|
+
*/
|
|
163
|
+
async function requestVerdict(cfg, intentId, intent, actor) {
|
|
164
|
+
const doFetch = state_1.state.originalFetch;
|
|
165
|
+
const token = state_1.state.config?.token;
|
|
166
|
+
if (!doFetch || !token)
|
|
167
|
+
throw new EscortUnreachable("shim-not-ready");
|
|
168
|
+
const ac = new AbortController();
|
|
169
|
+
const timer = setTimeout(() => ac.abort(), cfg.timeoutMs ?? DEFAULT_VERDICT_TIMEOUT_MS);
|
|
170
|
+
timer.unref?.();
|
|
171
|
+
let res;
|
|
172
|
+
try {
|
|
173
|
+
res = await doFetch(cfg.verdictUrl, {
|
|
174
|
+
method: "POST",
|
|
175
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
176
|
+
body: JSON.stringify({
|
|
177
|
+
intentId,
|
|
178
|
+
collection: cfg.collection,
|
|
179
|
+
rail: cfg.rail,
|
|
180
|
+
action: cfg.action,
|
|
181
|
+
fields: intent,
|
|
182
|
+
actor: actor?.id ?? "unattributed",
|
|
183
|
+
actorKind: actor?.kind,
|
|
184
|
+
onBehalfOf: actor?.onBehalfOf,
|
|
185
|
+
}),
|
|
186
|
+
signal: ac.signal,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
throw new EscortUnreachable("network"); // timeout / abort / connection error
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
clearTimeout(timer);
|
|
194
|
+
}
|
|
195
|
+
const status = res.status;
|
|
196
|
+
let body = {};
|
|
197
|
+
try {
|
|
198
|
+
body = (await res.json());
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
body = {};
|
|
202
|
+
}
|
|
203
|
+
if (status >= 200 && status < 300) {
|
|
204
|
+
const vb = typeof body.verdict === "string" ? body.verdict : "";
|
|
205
|
+
const approval = body.approval;
|
|
206
|
+
const approveUrl = typeof body.approveUrl === "string" ? body.approveUrl
|
|
207
|
+
: approval && typeof approval.approveUrl === "string" ? approval.approveUrl
|
|
208
|
+
: undefined;
|
|
209
|
+
if (vb === "pending_approval")
|
|
210
|
+
return { verdict: "pending_approval", intentId, approveUrl };
|
|
211
|
+
// A 2xx that is explicitly a decline (defense in depth: the server SHOULD
|
|
212
|
+
// send 403/409, but never FIRE on a body that says "declined").
|
|
213
|
+
if (vb === "declined") {
|
|
214
|
+
return {
|
|
215
|
+
verdict: "declined",
|
|
216
|
+
intentId,
|
|
217
|
+
invariant: typeof body.invariant === "string" ? body.invariant : undefined,
|
|
218
|
+
reason: typeof body.reason === "string" ? body.reason : "rule",
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
// Fire ONLY on an explicit allow. An empty/unknown 2xx verdict is NOT a fire
|
|
222
|
+
// signal — treat it as non-decisive and apply failMode (never fail-open).
|
|
223
|
+
if (vb === "allowed" || vb === "settled")
|
|
224
|
+
return { verdict: "allowed", intentId, approveUrl };
|
|
225
|
+
throw new EscortUnreachable(`ambiguous-verdict-${vb || "empty"}`);
|
|
226
|
+
}
|
|
227
|
+
// Decisive declines: 403 = rule, 409 = invariant (or duplicate intent).
|
|
228
|
+
if (status === 403 || status === 409) {
|
|
229
|
+
return {
|
|
230
|
+
verdict: "declined",
|
|
231
|
+
intentId,
|
|
232
|
+
invariant: typeof body.invariant === "string" ? body.invariant : undefined,
|
|
233
|
+
reason: typeof body.reason === "string" ? body.reason : status === 403 ? "rule" : "invariant",
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
// 401 / 5xx / anything else: not a decisive verdict -> apply failMode.
|
|
237
|
+
throw new EscortUnreachable(`status-${status}`);
|
|
238
|
+
}
|
|
239
|
+
/** Fire-and-forget settle report to the org's settle endpoint. Never throws. */
|
|
240
|
+
function settleAsync(cfg, intentId, outcome) {
|
|
241
|
+
const doFetch = state_1.state.originalFetch;
|
|
242
|
+
const token = state_1.state.config?.token;
|
|
243
|
+
if (!doFetch || !token)
|
|
244
|
+
return;
|
|
245
|
+
try {
|
|
246
|
+
const ac = new AbortController();
|
|
247
|
+
const timer = setTimeout(() => ac.abort(), cfg.timeoutMs ?? DEFAULT_VERDICT_TIMEOUT_MS);
|
|
248
|
+
timer.unref?.();
|
|
249
|
+
void doFetch(cfg.settleUrl, {
|
|
250
|
+
method: "POST",
|
|
251
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
252
|
+
body: JSON.stringify({ intentId, collection: cfg.collection, ...outcome }),
|
|
253
|
+
signal: ac.signal,
|
|
254
|
+
})
|
|
255
|
+
.then((r) => {
|
|
256
|
+
try {
|
|
257
|
+
return r.text();
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
.catch(() => undefined)
|
|
264
|
+
.finally(() => clearTimeout(timer));
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
(0, state_1.noteInternalError)("escort.settle", err);
|
|
268
|
+
}
|
|
269
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { middleware, runAs, currentActor } from "./context";
|
|
2
|
+
import { BUILTIN_MANIFEST, escortedManifest } from "./manifest";
|
|
3
|
+
import type { Diagnostics, ObserveConfig } from "./types";
|
|
4
|
+
export type { ObserveConfig, ActorInput, ActorContext, SessionMapper, WireEvent, Diagnostics, } from "./types";
|
|
5
|
+
export type { ObserveManifest, RecognizerEntry, IdentityFieldConfig, RouteMatcher, EscortConfig, CompiledEscort, EscortPin, } from "./manifest";
|
|
6
|
+
export { escortedManifest };
|
|
7
|
+
export { BoundedEnforcementError } from "./escort";
|
|
8
|
+
export { estimateUpperBoundCents, actualCentsFromUsage, usageFromResponse, buildLlmIntentFields, resolveModelPrice, MODEL_PRICES, PRICE_TABLE_VERSION, } from "./llm-prices";
|
|
9
|
+
export type { ModelPrice, UpperBoundResult, ActualResult, LlmIntentFields } from "./llm-prices";
|
|
10
|
+
export { runAs, middleware, currentActor };
|
|
11
|
+
export { BUILTIN_MANIFEST };
|
|
12
|
+
export interface ObserveHandle {
|
|
13
|
+
runAs: typeof runAs;
|
|
14
|
+
middleware: typeof middleware;
|
|
15
|
+
/** Force-process + report everything pending (partial counter windows included). */
|
|
16
|
+
flush: () => Promise<void>;
|
|
17
|
+
/** Graceful shutdown: final flush, stop timers. Patches stay (inert). */
|
|
18
|
+
shutdown: () => Promise<void>;
|
|
19
|
+
diagnostics: () => Diagnostics;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Initialize the shim. Idempotent: a second call updates config only.
|
|
23
|
+
* With BOUNDED_OBSERVE_DISABLED=1 at startup, nothing is patched and the
|
|
24
|
+
* returned handle is inert (runAs/middleware still work as passthroughs).
|
|
25
|
+
*/
|
|
26
|
+
export declare function init(config: ObserveConfig): ObserveHandle;
|
|
27
|
+
/** Process + report everything pending (forces partial counter windows). */
|
|
28
|
+
export declare function flush(): Promise<void>;
|
|
29
|
+
/** Final flush + stop timers. Patches remain installed but keep working;
|
|
30
|
+
* call this on graceful shutdown so the tail of the queue is reported. */
|
|
31
|
+
export declare function shutdown(): Promise<void>;
|
|
32
|
+
export declare function diagnostics(): Diagnostics;
|
|
33
|
+
/** TEST ONLY: unpatch globals and reset all singleton state. */
|
|
34
|
+
export declare function __resetForTests(): void;
|
|
35
|
+
/** TEST ONLY: run one pipeline/report tick synchronously-ish. */
|
|
36
|
+
export declare function __tickForTests(now?: number): Promise<void>;
|
|
37
|
+
declare const _default: {
|
|
38
|
+
init: typeof init;
|
|
39
|
+
runAs: typeof runAs;
|
|
40
|
+
middleware: typeof middleware;
|
|
41
|
+
currentActor: typeof currentActor;
|
|
42
|
+
flush: typeof flush;
|
|
43
|
+
shutdown: typeof shutdown;
|
|
44
|
+
diagnostics: typeof diagnostics;
|
|
45
|
+
};
|
|
46
|
+
export default _default;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// @bounded-sh/observe -- Node observe shim (SPEC-OBSERVE-ENFORCE-CUSTODY.md
|
|
4
|
+
// §3.1a). Interception: globalThis.fetch (undici) + http/https.request.
|
|
5
|
+
// Actor context: runAs() / middleware() via AsyncLocalStorage.
|
|
6
|
+
// Reporting: async batches (<=500 / 2s) to the observe ingest, fire-and-
|
|
7
|
+
// forget, bounded memory, honest drop counting. Metadata-only by default;
|
|
8
|
+
// capture is manifest-driven (built-in manifest + 60s poll stub).
|
|
9
|
+
//
|
|
10
|
+
// Kill switch: BOUNDED_OBSERVE_DISABLED=1 (checked at init AND re-checked on
|
|
11
|
+
// every tick — flipping it at runtime stops interception within a tick).
|
|
12
|
+
// The shim NEVER breaks the app: every code path is wrapped; original
|
|
13
|
+
// behavior is always preserved; observe is always fail-open.
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.BUILTIN_MANIFEST = exports.currentActor = exports.middleware = exports.runAs = exports.PRICE_TABLE_VERSION = exports.MODEL_PRICES = exports.resolveModelPrice = exports.buildLlmIntentFields = exports.usageFromResponse = exports.actualCentsFromUsage = exports.estimateUpperBoundCents = exports.BoundedEnforcementError = exports.escortedManifest = void 0;
|
|
17
|
+
exports.init = init;
|
|
18
|
+
exports.flush = flush;
|
|
19
|
+
exports.shutdown = shutdown;
|
|
20
|
+
exports.diagnostics = diagnostics;
|
|
21
|
+
exports.__resetForTests = __resetForTests;
|
|
22
|
+
exports.__tickForTests = __tickForTests;
|
|
23
|
+
const context_1 = require("./context");
|
|
24
|
+
Object.defineProperty(exports, "middleware", { enumerable: true, get: function () { return context_1.middleware; } });
|
|
25
|
+
Object.defineProperty(exports, "runAs", { enumerable: true, get: function () { return context_1.runAs; } });
|
|
26
|
+
Object.defineProperty(exports, "currentActor", { enumerable: true, get: function () { return context_1.currentActor; } });
|
|
27
|
+
const fetch_1 = require("./intercept/fetch");
|
|
28
|
+
const http_1 = require("./intercept/http");
|
|
29
|
+
const manifest_1 = require("./manifest");
|
|
30
|
+
Object.defineProperty(exports, "BUILTIN_MANIFEST", { enumerable: true, get: function () { return manifest_1.BUILTIN_MANIFEST; } });
|
|
31
|
+
Object.defineProperty(exports, "escortedManifest", { enumerable: true, get: function () { return manifest_1.escortedManifest; } });
|
|
32
|
+
const pipeline_1 = require("./pipeline");
|
|
33
|
+
const reporter_1 = require("./reporter");
|
|
34
|
+
const state_1 = require("./state");
|
|
35
|
+
var escort_1 = require("./escort");
|
|
36
|
+
Object.defineProperty(exports, "BoundedEnforcementError", { enumerable: true, get: function () { return escort_1.BoundedEnforcementError; } });
|
|
37
|
+
var llm_prices_1 = require("./llm-prices");
|
|
38
|
+
Object.defineProperty(exports, "estimateUpperBoundCents", { enumerable: true, get: function () { return llm_prices_1.estimateUpperBoundCents; } });
|
|
39
|
+
Object.defineProperty(exports, "actualCentsFromUsage", { enumerable: true, get: function () { return llm_prices_1.actualCentsFromUsage; } });
|
|
40
|
+
Object.defineProperty(exports, "usageFromResponse", { enumerable: true, get: function () { return llm_prices_1.usageFromResponse; } });
|
|
41
|
+
Object.defineProperty(exports, "buildLlmIntentFields", { enumerable: true, get: function () { return llm_prices_1.buildLlmIntentFields; } });
|
|
42
|
+
Object.defineProperty(exports, "resolveModelPrice", { enumerable: true, get: function () { return llm_prices_1.resolveModelPrice; } });
|
|
43
|
+
Object.defineProperty(exports, "MODEL_PRICES", { enumerable: true, get: function () { return llm_prices_1.MODEL_PRICES; } });
|
|
44
|
+
Object.defineProperty(exports, "PRICE_TABLE_VERSION", { enumerable: true, get: function () { return llm_prices_1.PRICE_TABLE_VERSION; } });
|
|
45
|
+
const DEFAULT_FLUSH_MS = 2_000;
|
|
46
|
+
const DEFAULT_SEND_TIMEOUT_MS = 5_000;
|
|
47
|
+
const DEFAULT_MAX_QUEUE = 5_000;
|
|
48
|
+
const DEFAULT_PROMOTE_THRESHOLD = 60;
|
|
49
|
+
const DEFAULT_SHAPES_PER_MIN = 30;
|
|
50
|
+
const DEFAULT_MANIFEST_POLL_MS = 60_000;
|
|
51
|
+
function tick() {
|
|
52
|
+
try {
|
|
53
|
+
// Runtime kill switch: honor env flips without a restart.
|
|
54
|
+
if ((0, state_1.killSwitchActive)()) {
|
|
55
|
+
if (state_1.state.enabled)
|
|
56
|
+
(0, state_1.debugLog)(`${state_1.KILL_SWITCH_ENV} set — observation stopped`);
|
|
57
|
+
state_1.state.enabled = false;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (state_1.state.initialized && !state_1.state.enabled) {
|
|
61
|
+
// Env was cleared again: resume (patches are still in place).
|
|
62
|
+
state_1.state.enabled = true;
|
|
63
|
+
(0, state_1.debugLog)("kill switch cleared — observation resumed");
|
|
64
|
+
}
|
|
65
|
+
const processed = state_1.state.pipeline?.process(Date.now(), false);
|
|
66
|
+
if (processed) {
|
|
67
|
+
// Identity hashing in flight — flush after it settles (fire-and-forget).
|
|
68
|
+
void processed.then(() => state_1.state.reporter?.flushOnce());
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
void state_1.state.reporter?.flushOnce();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
(0, state_1.noteInternalError)("tick", err);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async function pollManifest() {
|
|
79
|
+
const cfg = state_1.state.config;
|
|
80
|
+
const doFetch = state_1.state.originalFetch;
|
|
81
|
+
if (!cfg || !doFetch || !state_1.state.enabled)
|
|
82
|
+
return;
|
|
83
|
+
try {
|
|
84
|
+
const org = cfg.org ? `?org=${encodeURIComponent(cfg.org)}` : "";
|
|
85
|
+
const ac = new AbortController();
|
|
86
|
+
const t = setTimeout(() => ac.abort(), 5_000);
|
|
87
|
+
t.unref?.();
|
|
88
|
+
let res;
|
|
89
|
+
try {
|
|
90
|
+
res = await doFetch(`${cfg.ingestBase.replace(/\/$/, "")}/v1/manifest${org}`, {
|
|
91
|
+
signal: ac.signal,
|
|
92
|
+
// Sensor-token authed (same token as /v1/events; the served manifest is
|
|
93
|
+
// org-scoped to the token). No token => 401 => builtin retained.
|
|
94
|
+
headers: cfg.token ? { Authorization: `Bearer ${cfg.token}` } : undefined,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
clearTimeout(t);
|
|
99
|
+
}
|
|
100
|
+
if (!res.ok) {
|
|
101
|
+
// Endpoint doesn't exist yet (404) or is unhappy: keep what we have.
|
|
102
|
+
try {
|
|
103
|
+
await res.text();
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
/* ignore */
|
|
107
|
+
}
|
|
108
|
+
(0, state_1.debugLog)(`manifest poll: ${res.status} — keeping ${state_1.state.manifest.source} manifest`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const body = await res.json();
|
|
112
|
+
// TODO(signed manifests, SPEC §3.1a): verify `signature` before accepting.
|
|
113
|
+
const validated = (0, manifest_1.validateManifest)(body);
|
|
114
|
+
if (!validated) {
|
|
115
|
+
(0, state_1.debugLog)("manifest poll: invalid manifest — keeping current (fail-safe)");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// Escort blocks + field lists INSIDE the fetched manifest already passed
|
|
119
|
+
// cleanEscort / the L2 denylist in validateManifest above. The env-derived
|
|
120
|
+
// escort pin is re-applied over the validated result so a served manifest
|
|
121
|
+
// can never silently drop enforcement (a fetched escort block on the
|
|
122
|
+
// pinned route wins; see escortedManifest).
|
|
123
|
+
const next = (0, manifest_1.withEscortPin)(validated, cfg.escortPin);
|
|
124
|
+
state_1.state.manifest = new manifest_1.CompiledManifest(next, "fetched");
|
|
125
|
+
(0, state_1.debugLog)(`manifest updated to ${next.version}`);
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
(0, state_1.debugLog)("manifest poll failed — keeping current manifest", err);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Initialize the shim. Idempotent: a second call updates config only.
|
|
133
|
+
* With BOUNDED_OBSERVE_DISABLED=1 at startup, nothing is patched and the
|
|
134
|
+
* returned handle is inert (runAs/middleware still work as passthroughs).
|
|
135
|
+
*/
|
|
136
|
+
function init(config) {
|
|
137
|
+
try {
|
|
138
|
+
if (!state_1.state.initialized) {
|
|
139
|
+
state_1.state.config = config;
|
|
140
|
+
try {
|
|
141
|
+
state_1.state.ingestHost = new URL(config.ingestBase).host;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
state_1.state.ingestHost = undefined;
|
|
145
|
+
}
|
|
146
|
+
const reporter = new reporter_1.Reporter(config.maxQueueEvents ?? DEFAULT_MAX_QUEUE, config.sendTimeoutMs ?? DEFAULT_SEND_TIMEOUT_MS);
|
|
147
|
+
state_1.state.reporter = reporter;
|
|
148
|
+
state_1.state.pipeline = new pipeline_1.Pipeline(reporter, config.counterPromoteThreshold ?? DEFAULT_PROMOTE_THRESHOLD, config.shapesPerMinute ?? DEFAULT_SHAPES_PER_MIN);
|
|
149
|
+
// Manifest: override (tests/dev) > built-in; fetched may replace later.
|
|
150
|
+
// The env-derived escort pin (register preload) is applied over the
|
|
151
|
+
// initial manifest here AND over every fetched swap (pollManifest) — a
|
|
152
|
+
// served manifest can never silently drop pinned enforcement.
|
|
153
|
+
if (config.manifest !== undefined) {
|
|
154
|
+
const validated = (0, manifest_1.validateManifest)(config.manifest);
|
|
155
|
+
state_1.state.manifest = validated
|
|
156
|
+
? new manifest_1.CompiledManifest((0, manifest_1.withEscortPin)(validated, config.escortPin), "override")
|
|
157
|
+
: new manifest_1.CompiledManifest((0, manifest_1.withEscortPin)(manifest_1.BUILTIN_MANIFEST, config.escortPin), "builtin"); // fail-safe
|
|
158
|
+
if (!validated)
|
|
159
|
+
(0, state_1.debugLog)("init: invalid manifest override — using built-in (fail-safe)");
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
state_1.state.manifest = new manifest_1.CompiledManifest((0, manifest_1.withEscortPin)(manifest_1.BUILTIN_MANIFEST, config.escortPin), "builtin");
|
|
163
|
+
}
|
|
164
|
+
state_1.state.initialized = true;
|
|
165
|
+
if ((0, state_1.killSwitchActive)()) {
|
|
166
|
+
state_1.state.enabled = false;
|
|
167
|
+
(0, state_1.debugLog)(`${state_1.KILL_SWITCH_ENV} set — shim inert (nothing patched)`);
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
state_1.state.enabled = true;
|
|
171
|
+
(0, fetch_1.patchFetch)();
|
|
172
|
+
(0, http_1.patchHttp)();
|
|
173
|
+
state_1.state.patched = true;
|
|
174
|
+
const flushMs = config.flushIntervalMs ?? DEFAULT_FLUSH_MS;
|
|
175
|
+
state_1.state.tickTimer = setInterval(tick, flushMs);
|
|
176
|
+
state_1.state.tickTimer.unref?.();
|
|
177
|
+
if (!config.disableManifestPoll && config.manifest === undefined) {
|
|
178
|
+
state_1.state.manifestTimer = setInterval(() => void pollManifest(), config.manifestPollMs ?? DEFAULT_MANIFEST_POLL_MS);
|
|
179
|
+
state_1.state.manifestTimer.unref?.();
|
|
180
|
+
}
|
|
181
|
+
(0, state_1.debugLog)("initialized (fetch + http/https patched)");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
state_1.state.config = { ...state_1.state.config, ...config };
|
|
186
|
+
(0, state_1.debugLog)("re-init: config updated");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
// Init must never take the app down.
|
|
191
|
+
(0, state_1.noteInternalError)("init", err);
|
|
192
|
+
}
|
|
193
|
+
return { runAs: context_1.runAs, middleware: context_1.middleware, flush, shutdown, diagnostics };
|
|
194
|
+
}
|
|
195
|
+
/** Process + report everything pending (forces partial counter windows). */
|
|
196
|
+
async function flush() {
|
|
197
|
+
try {
|
|
198
|
+
await state_1.state.pipeline?.process(Date.now(), true);
|
|
199
|
+
await state_1.state.reporter?.drain();
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
(0, state_1.noteInternalError)("flush", err);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/** Final flush + stop timers. Patches remain installed but keep working;
|
|
206
|
+
* call this on graceful shutdown so the tail of the queue is reported. */
|
|
207
|
+
async function shutdown() {
|
|
208
|
+
try {
|
|
209
|
+
if (state_1.state.tickTimer)
|
|
210
|
+
clearInterval(state_1.state.tickTimer);
|
|
211
|
+
if (state_1.state.manifestTimer)
|
|
212
|
+
clearInterval(state_1.state.manifestTimer);
|
|
213
|
+
state_1.state.tickTimer = undefined;
|
|
214
|
+
state_1.state.manifestTimer = undefined;
|
|
215
|
+
await flush();
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
(0, state_1.noteInternalError)("shutdown", err);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function diagnostics() {
|
|
222
|
+
return {
|
|
223
|
+
enabled: state_1.state.enabled,
|
|
224
|
+
patched: state_1.state.patched,
|
|
225
|
+
queueDepth: state_1.state.reporter?.queueDepth ?? 0,
|
|
226
|
+
rawQueueDepth: state_1.state.pipeline?.rawQueueDepth ?? 0,
|
|
227
|
+
pendingDrops: state_1.state.reporter?.pendingDrops ?? 0,
|
|
228
|
+
totalDropped: state_1.state.reporter?.totalDropped ?? 0,
|
|
229
|
+
captured: state_1.state.stats.captured,
|
|
230
|
+
sentEvents: state_1.state.reporter?.sentEvents ?? 0,
|
|
231
|
+
sentBatches: state_1.state.reporter?.sentBatches ?? 0,
|
|
232
|
+
sendFailures: state_1.state.reporter?.sendFailures ?? 0,
|
|
233
|
+
internalErrors: state_1.state.stats.internalErrors,
|
|
234
|
+
manifestVersion: state_1.state.manifest.version,
|
|
235
|
+
manifestSource: state_1.state.manifest.source,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
/** TEST ONLY: unpatch globals and reset all singleton state. */
|
|
239
|
+
function __resetForTests() {
|
|
240
|
+
if (state_1.state.tickTimer)
|
|
241
|
+
clearInterval(state_1.state.tickTimer);
|
|
242
|
+
if (state_1.state.manifestTimer)
|
|
243
|
+
clearInterval(state_1.state.manifestTimer);
|
|
244
|
+
(0, fetch_1.unpatchFetch)();
|
|
245
|
+
(0, http_1.unpatchHttp)();
|
|
246
|
+
state_1.state.initialized = false;
|
|
247
|
+
state_1.state.enabled = false;
|
|
248
|
+
state_1.state.patched = false;
|
|
249
|
+
state_1.state.config = undefined;
|
|
250
|
+
state_1.state.ingestHost = undefined;
|
|
251
|
+
state_1.state.originalFetch = undefined;
|
|
252
|
+
state_1.state.pipeline = undefined;
|
|
253
|
+
state_1.state.reporter = undefined;
|
|
254
|
+
state_1.state.tickTimer = undefined;
|
|
255
|
+
state_1.state.manifestTimer = undefined;
|
|
256
|
+
state_1.state.manifest = new manifest_1.CompiledManifest(manifest_1.BUILTIN_MANIFEST, "builtin");
|
|
257
|
+
state_1.state.stats = { captured: 0, internalErrors: 0 };
|
|
258
|
+
}
|
|
259
|
+
/** TEST ONLY: run one pipeline/report tick synchronously-ish. */
|
|
260
|
+
async function __tickForTests(now) {
|
|
261
|
+
await state_1.state.pipeline?.process(now ?? Date.now(), false);
|
|
262
|
+
await state_1.state.reporter?.flushOnce();
|
|
263
|
+
}
|
|
264
|
+
// Default export mirrors the named surface for CJS ergonomics:
|
|
265
|
+
// const bounded = require("@bounded-sh/observe"); bounded.init({...})
|
|
266
|
+
exports.default = {
|
|
267
|
+
init,
|
|
268
|
+
runAs: context_1.runAs,
|
|
269
|
+
middleware: context_1.middleware,
|
|
270
|
+
currentActor: context_1.currentActor,
|
|
271
|
+
flush,
|
|
272
|
+
shutdown,
|
|
273
|
+
diagnostics,
|
|
274
|
+
};
|