@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
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { RawCapture } from "./types";
|
|
2
|
+
import type { Reporter } from "./reporter";
|
|
3
|
+
export declare class Pipeline {
|
|
4
|
+
private reporter;
|
|
5
|
+
private rawQueue;
|
|
6
|
+
private counters;
|
|
7
|
+
private shapes;
|
|
8
|
+
constructor(reporter: Reporter, promoteThreshold: number, shapesPerMinute: number);
|
|
9
|
+
get rawQueueDepth(): number;
|
|
10
|
+
/** HOT PATH: O(1) push; drop + count on overflow. Never throws. */
|
|
11
|
+
enqueueRaw(raw: RawCapture): void;
|
|
12
|
+
/** Tick work: classify pending raws, roll counter windows. Never throws.
|
|
13
|
+
* Returns a promise ONLY when identity hashing is in flight (wanted-identity
|
|
14
|
+
* routes) so flush()/tests can await full delivery; the common path stays
|
|
15
|
+
* fully synchronous. */
|
|
16
|
+
process(now: number, force?: boolean): void | Promise<void>;
|
|
17
|
+
private classify;
|
|
18
|
+
}
|
package/dist/pipeline.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// pipeline.ts -- raw capture -> event classes (SPEC §3.2).
|
|
4
|
+
//
|
|
5
|
+
// The interceptors' hot path only does enqueueRaw() (bounded array push).
|
|
6
|
+
// Everything heavy — templating, recognition, body parsing, shape dedupe,
|
|
7
|
+
// counter aggregation, wire-event construction — happens here, on the shared
|
|
8
|
+
// tick, off the request path.
|
|
9
|
+
//
|
|
10
|
+
// Event classes:
|
|
11
|
+
// recognized + 2xx -> action (rec{rail, action, registryVersion})
|
|
12
|
+
// recognized + non-2xx/0 -> error (rec present)
|
|
13
|
+
// manifest counter match
|
|
14
|
+
// or promoted route -> exact per-minute counter aggregation
|
|
15
|
+
// unrecognized -> shape sample (deduped, rate-capped, names/types
|
|
16
|
+
// only) + provisional counter tally for GETs
|
|
17
|
+
// (promotes at >threshold calls/min)
|
|
18
|
+
// manifest mute match -> nothing
|
|
19
|
+
//
|
|
20
|
+
// Wire events are constructed as EXACT envelope-v2 objects (whitelist by
|
|
21
|
+
// construction): org/sensor omitted (server-stamped), `scrubbed` never sent,
|
|
22
|
+
// no unknown TOP-LEVEL fields. Recognized safe-field values ride the envelope
|
|
23
|
+
// under `rec.fields` (v2) AND stay on InternalEvent.fields for the debug hook.
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
+
exports.Pipeline = void 0;
|
|
27
|
+
const envelope_1 = require("./envelope");
|
|
28
|
+
const template_1 = require("./template");
|
|
29
|
+
const recognize_1 = require("./recognize");
|
|
30
|
+
const counters_1 = require("./counters");
|
|
31
|
+
const shapes_1 = require("./shapes");
|
|
32
|
+
const state_1 = require("./state");
|
|
33
|
+
const MAX_RAW_QUEUE = 10_000;
|
|
34
|
+
function actorOf(raw) {
|
|
35
|
+
if (!raw.actor)
|
|
36
|
+
return undefined;
|
|
37
|
+
return { id: raw.actor.id, kind: raw.actor.kind, grade: "asserted" };
|
|
38
|
+
}
|
|
39
|
+
class Pipeline {
|
|
40
|
+
reporter;
|
|
41
|
+
rawQueue = [];
|
|
42
|
+
counters;
|
|
43
|
+
shapes;
|
|
44
|
+
constructor(reporter, promoteThreshold, shapesPerMinute) {
|
|
45
|
+
this.reporter = reporter;
|
|
46
|
+
this.counters = new counters_1.CounterAggregator(promoteThreshold);
|
|
47
|
+
this.shapes = new shapes_1.ShapeDeduper(shapesPerMinute);
|
|
48
|
+
}
|
|
49
|
+
get rawQueueDepth() {
|
|
50
|
+
return this.rawQueue.length;
|
|
51
|
+
}
|
|
52
|
+
/** HOT PATH: O(1) push; drop + count on overflow. Never throws. */
|
|
53
|
+
enqueueRaw(raw) {
|
|
54
|
+
if (this.rawQueue.length >= MAX_RAW_QUEUE) {
|
|
55
|
+
this.reporter.countDrop(1);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
state_1.state.stats.captured++;
|
|
59
|
+
this.rawQueue.push(raw);
|
|
60
|
+
}
|
|
61
|
+
/** Tick work: classify pending raws, roll counter windows. Never throws.
|
|
62
|
+
* Returns a promise ONLY when identity hashing is in flight (wanted-identity
|
|
63
|
+
* routes) so flush()/tests can await full delivery; the common path stays
|
|
64
|
+
* fully synchronous. */
|
|
65
|
+
process(now, force = false) {
|
|
66
|
+
try {
|
|
67
|
+
const pending = this.rawQueue;
|
|
68
|
+
let hashing;
|
|
69
|
+
if (pending.length > 0) {
|
|
70
|
+
this.rawQueue = [];
|
|
71
|
+
for (const raw of pending) {
|
|
72
|
+
try {
|
|
73
|
+
const p = this.classify(raw);
|
|
74
|
+
if (p)
|
|
75
|
+
(hashing ??= []).push(p.catch((err) => (0, state_1.noteInternalError)("pipeline.classify", err)));
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
(0, state_1.noteInternalError)("pipeline.classify", err);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const c of this.counters.flush(now, force)) {
|
|
83
|
+
this.reporter.push({ wire: counterEvent(c) });
|
|
84
|
+
}
|
|
85
|
+
if (hashing)
|
|
86
|
+
return Promise.all(hashing).then(() => undefined);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
(0, state_1.noteInternalError)("pipeline.process", err);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
classify(raw) {
|
|
93
|
+
const manifest = state_1.state.manifest;
|
|
94
|
+
const pathTemplate = (0, template_1.templatePath)(raw.path);
|
|
95
|
+
const { host, method } = raw;
|
|
96
|
+
if (manifest.isMuted(host, method, pathTemplate))
|
|
97
|
+
return;
|
|
98
|
+
const route = { host, method, pathTemplate };
|
|
99
|
+
// Explicit counter policy (manifest) or an already-promoted hot route.
|
|
100
|
+
if (manifest.isCounterRoute(host, method, pathTemplate) || this.counters.isPromoted(route)) {
|
|
101
|
+
this.counters.add(route, raw.ts, raw.status, raw.durMs, raw.bytesIn, raw.bytesOut, false);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
// Parse the request body at most once, reused across JSON-RPC method
|
|
105
|
+
// matching, safe-field extraction, and shape sampling (the shim already
|
|
106
|
+
// retained the body; this is the same parse it does today — nothing extra
|
|
107
|
+
// on the request hot path, this runs on the shared tick).
|
|
108
|
+
let reqBody;
|
|
109
|
+
let reqParsed = false;
|
|
110
|
+
const reqBodyOnce = () => {
|
|
111
|
+
if (!reqParsed) {
|
|
112
|
+
reqBody = (0, recognize_1.parseBody)(raw.reqBodyText, raw.reqContentType);
|
|
113
|
+
reqParsed = true;
|
|
114
|
+
}
|
|
115
|
+
return reqBody;
|
|
116
|
+
};
|
|
117
|
+
let rec = manifest.recognize(host, method, pathTemplate);
|
|
118
|
+
// Crypto JSON-RPC: the operation is in the body's `method`, not the path
|
|
119
|
+
// (every RPC call POSTs to "/"). When no exact route matched and the host
|
|
120
|
+
// carries rpc recognizers, match on the parsed JSON-RPC method instead.
|
|
121
|
+
if (!rec && method === "POST" && manifest.hasRpcHost(host)) {
|
|
122
|
+
const rpcMethod = (0, recognize_1.jsonRpcMethod)(reqBodyOnce());
|
|
123
|
+
if (rpcMethod)
|
|
124
|
+
rec = manifest.recognizeRpc(host, rpcMethod);
|
|
125
|
+
}
|
|
126
|
+
if (rec) {
|
|
127
|
+
const body = reqBodyOnce();
|
|
128
|
+
const fields = (0, recognize_1.extractRecognizedFields)(rec, body, raw.respJson);
|
|
129
|
+
const cls = raw.status >= 200 && raw.status < 300 ? "action" : "error";
|
|
130
|
+
const wire = {
|
|
131
|
+
v: envelope_1.EVENT_SCHEMA_VERSION,
|
|
132
|
+
ts: raw.ts,
|
|
133
|
+
class: cls,
|
|
134
|
+
dest: { host, pathTemplate, method },
|
|
135
|
+
status: raw.status,
|
|
136
|
+
dur_ms: round1(raw.durMs),
|
|
137
|
+
bytes: { i: raw.bytesIn, o: raw.bytesOut },
|
|
138
|
+
rec: { rail: rec.rail, action: rec.action, registryVersion: manifest.version },
|
|
139
|
+
};
|
|
140
|
+
const actor = actorOf(raw);
|
|
141
|
+
if (actor)
|
|
142
|
+
wire.actor = actor;
|
|
143
|
+
if (raw.actor?.onBehalfOf)
|
|
144
|
+
wire.onBehalfOf = raw.actor.onBehalfOf;
|
|
145
|
+
const policyVersion = manifest.manifest.policyVersion;
|
|
146
|
+
if (policyVersion)
|
|
147
|
+
wire.policyVersion = policyVersion;
|
|
148
|
+
// v2: carry recognizer-extracted SAFE values on the wire (rec.fields).
|
|
149
|
+
// Already L1/L2-filtered + primitive-capped at extraction; ingest
|
|
150
|
+
// re-checks the KEYS (L2) and scrubs the VALUES (L4) server-side.
|
|
151
|
+
if (fields && wire.rec)
|
|
152
|
+
wire.rec.fields = fields;
|
|
153
|
+
// Wanted identity → the ATTRIBUTION channel: the manifest routed a
|
|
154
|
+
// request field into onBehalfOf (route config wins over the ALS
|
|
155
|
+
// context). Hashed IN-PROCESS by default — the raw value never leaves;
|
|
156
|
+
// raw rides ONLY on the owner's explicit hash:false opt-in. Never in
|
|
157
|
+
// rec.fields (extraction skips the source; validateManifest rejects
|
|
158
|
+
// entries that list it for capture).
|
|
159
|
+
const identity = rec.identityField;
|
|
160
|
+
const idValue = identity ? (0, recognize_1.extractIdentityValue)(identity, body) : undefined;
|
|
161
|
+
if (idValue !== undefined && identity.hash !== false) {
|
|
162
|
+
return (0, recognize_1.hashIdentityValue)(idValue).then((principal) => {
|
|
163
|
+
wire.onBehalfOf = principal;
|
|
164
|
+
this.reporter.push({ wire, fields });
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
if (idValue !== undefined)
|
|
168
|
+
wire.onBehalfOf = idValue;
|
|
169
|
+
this.reporter.push({ wire, fields });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
// Unrecognized: heuristic counter tally for GETs (exact from call #1).
|
|
173
|
+
if (method === "GET") {
|
|
174
|
+
this.counters.add(route, raw.ts, raw.status, raw.durMs, raw.bytesIn, raw.bytesOut, true);
|
|
175
|
+
}
|
|
176
|
+
// Shape sample: once per route, rate-capped, names/types only (L5).
|
|
177
|
+
if (this.shapes.shouldEmit(host, method, pathTemplate, raw.ts)) {
|
|
178
|
+
const fields = (0, recognize_1.extractShapeFields)(reqBodyOnce(), raw.queryNames);
|
|
179
|
+
const wire = {
|
|
180
|
+
v: envelope_1.EVENT_SCHEMA_VERSION,
|
|
181
|
+
ts: raw.ts,
|
|
182
|
+
class: "shape",
|
|
183
|
+
dest: { host, pathTemplate, method },
|
|
184
|
+
status: raw.status,
|
|
185
|
+
dur_ms: round1(raw.durMs),
|
|
186
|
+
bytes: { i: raw.bytesIn, o: raw.bytesOut },
|
|
187
|
+
shape: { fields },
|
|
188
|
+
};
|
|
189
|
+
const actor = actorOf(raw);
|
|
190
|
+
if (actor)
|
|
191
|
+
wire.actor = actor;
|
|
192
|
+
this.reporter.push({ wire });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
exports.Pipeline = Pipeline;
|
|
197
|
+
function round1(n) {
|
|
198
|
+
return n < 0 ? 0 : Math.round(n * 10) / 10;
|
|
199
|
+
}
|
|
200
|
+
function counterEvent(c) {
|
|
201
|
+
return {
|
|
202
|
+
v: envelope_1.EVENT_SCHEMA_VERSION,
|
|
203
|
+
ts: c.ts,
|
|
204
|
+
class: "counter",
|
|
205
|
+
dest: { host: c.host, pathTemplate: c.pathTemplate, method: c.method },
|
|
206
|
+
status: c.status,
|
|
207
|
+
dur_ms: round1(c.durMsSum),
|
|
208
|
+
bytes: { i: c.bytesInSum, o: c.bytesOutSum },
|
|
209
|
+
count: c.count,
|
|
210
|
+
window: { start: c.windowStart, seconds: c.windowSeconds },
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { RecFields, ShapeField } from "./envelope";
|
|
2
|
+
import type { IdentityFieldConfig, RecognizerEntry } from "./manifest";
|
|
3
|
+
/** Parse a request body by content type. Returns undefined on any failure. */
|
|
4
|
+
export declare function parseBody(text: string | undefined, contentType: string | undefined): Record<string, unknown> | undefined;
|
|
5
|
+
/**
|
|
6
|
+
* Read the JSON-RPC operation name from an ALREADY-PARSED request body (crypto
|
|
7
|
+
* rails, SPEC §3.2). A JSON-RPC 2.0 request is {jsonrpc:"2.0", method, params};
|
|
8
|
+
* we return `method` for rpcMethod matching. The `params` VALUES (raw signed
|
|
9
|
+
* transactions, calldata, signatures) are NEVER read here — only the operation
|
|
10
|
+
* name. Returns undefined for anything that is not a single JSON-RPC call
|
|
11
|
+
* (batch arrays parse to non-objects and never reach here).
|
|
12
|
+
*/
|
|
13
|
+
export declare function jsonRpcMethod(body: Record<string, unknown> | undefined): string | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* Extract the recognizer's SAFE fields from parsed request/response bodies.
|
|
16
|
+
* Returns undefined when nothing was extracted. Response fields are prefixed
|
|
17
|
+
* "resp." when the same name exists on the request side.
|
|
18
|
+
*/
|
|
19
|
+
export declare function extractRecognizedFields(entry: RecognizerEntry, reqBody: Record<string, unknown> | undefined, respBody: unknown): RecFields | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* Extract the wanted-identity source value for the ATTRIBUTION channel
|
|
22
|
+
* (event.onBehalfOf) — never rec.fields. Primitives only; capped at the
|
|
23
|
+
* envelope's onBehalfOf bound. Returns undefined when absent/non-primitive.
|
|
24
|
+
*/
|
|
25
|
+
export declare function extractIdentityValue(cfg: IdentityFieldConfig, reqBody: Record<string, unknown> | undefined): string | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Hash an identity value IN-PROCESS (webcrypto sha256) into the deterministic
|
|
28
|
+
* attribution principal "sha256:<hex>". The raw value never leaves the shim
|
|
29
|
+
* on this path.
|
|
30
|
+
*/
|
|
31
|
+
export declare function hashIdentityValue(value: string): Promise<string>;
|
|
32
|
+
/**
|
|
33
|
+
* Shape sampling (L5): field NAMES + JSON types only, from the request body's
|
|
34
|
+
* top level plus query parameter names. Values never leave this function.
|
|
35
|
+
* Even the NAMES of PII-denied fields are dropped (nothing PII-shaped rides
|
|
36
|
+
* shape samples either).
|
|
37
|
+
*/
|
|
38
|
+
export declare function extractShapeFields(reqBody: Record<string, unknown> | undefined, queryNames: string[] | undefined): ShapeField[];
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// recognize.ts -- safe-field extraction for recognized routes (SPEC §3.2/§3.3)
|
|
4
|
+
// and name/type extraction for shape samples (§3.2, L5).
|
|
5
|
+
//
|
|
6
|
+
// L1: only manifest-listed fields are ever extracted. L2: the hard PII
|
|
7
|
+
// denylist is applied AGAIN here (defense in depth — even if a bad manifest
|
|
8
|
+
// slipped past validateManifest, PII-named fields never leave this function).
|
|
9
|
+
// Values captured are primitives only: numbers (amounts in cents, token
|
|
10
|
+
// counts), booleans (delivery acks, flags) and short strings (opaque ids,
|
|
11
|
+
// model names, enum-ish statuses).
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.parseBody = parseBody;
|
|
15
|
+
exports.jsonRpcMethod = jsonRpcMethod;
|
|
16
|
+
exports.extractRecognizedFields = extractRecognizedFields;
|
|
17
|
+
exports.extractIdentityValue = extractIdentityValue;
|
|
18
|
+
exports.hashIdentityValue = hashIdentityValue;
|
|
19
|
+
exports.extractShapeFields = extractShapeFields;
|
|
20
|
+
const node_crypto_1 = require("node:crypto");
|
|
21
|
+
const denylist_1 = require("./denylist");
|
|
22
|
+
const MAX_STRING_FIELD = 128;
|
|
23
|
+
/** onBehalfOf is capped at 256 by the ingest envelope (and context.ts). */
|
|
24
|
+
const MAX_IDENTITY_VALUE = 256;
|
|
25
|
+
/** Parse a request body by content type. Returns undefined on any failure. */
|
|
26
|
+
function parseBody(text, contentType) {
|
|
27
|
+
if (!text)
|
|
28
|
+
return undefined;
|
|
29
|
+
const ct = (contentType ?? "").toLowerCase();
|
|
30
|
+
try {
|
|
31
|
+
if (ct.includes("application/x-www-form-urlencoded")) {
|
|
32
|
+
const params = new URLSearchParams(text);
|
|
33
|
+
const out = {};
|
|
34
|
+
for (const [k, v] of params) {
|
|
35
|
+
// Numeric form values (Stripe amounts) become numbers.
|
|
36
|
+
out[k] = v !== "" && /^\d+$/.test(v) ? Number(v) : v;
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
// Default to JSON (covers application/json and JSON without a header).
|
|
41
|
+
const parsed = JSON.parse(text);
|
|
42
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
43
|
+
return parsed;
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Read the JSON-RPC operation name from an ALREADY-PARSED request body (crypto
|
|
53
|
+
* rails, SPEC §3.2). A JSON-RPC 2.0 request is {jsonrpc:"2.0", method, params};
|
|
54
|
+
* we return `method` for rpcMethod matching. The `params` VALUES (raw signed
|
|
55
|
+
* transactions, calldata, signatures) are NEVER read here — only the operation
|
|
56
|
+
* name. Returns undefined for anything that is not a single JSON-RPC call
|
|
57
|
+
* (batch arrays parse to non-objects and never reach here).
|
|
58
|
+
*/
|
|
59
|
+
function jsonRpcMethod(body) {
|
|
60
|
+
if (!body)
|
|
61
|
+
return undefined;
|
|
62
|
+
if (typeof body.jsonrpc !== "string")
|
|
63
|
+
return undefined; // JSON-RPC 2.0 marker required
|
|
64
|
+
const method = body.method;
|
|
65
|
+
if (typeof method !== "string" || method.length === 0 || method.length > 128)
|
|
66
|
+
return undefined;
|
|
67
|
+
return method;
|
|
68
|
+
}
|
|
69
|
+
function getPath(obj, dotPath) {
|
|
70
|
+
let cur = obj;
|
|
71
|
+
for (const seg of dotPath.split(".")) {
|
|
72
|
+
if (typeof cur !== "object" || cur === null)
|
|
73
|
+
return undefined;
|
|
74
|
+
cur = cur[seg];
|
|
75
|
+
}
|
|
76
|
+
return cur;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Extract the recognizer's SAFE fields from parsed request/response bodies.
|
|
80
|
+
* Returns undefined when nothing was extracted. Response fields are prefixed
|
|
81
|
+
* "resp." when the same name exists on the request side.
|
|
82
|
+
*/
|
|
83
|
+
function extractRecognizedFields(entry, reqBody, respBody) {
|
|
84
|
+
const out = {};
|
|
85
|
+
let any = false;
|
|
86
|
+
const identitySource = entry.identityField?.source;
|
|
87
|
+
const take = (source, paths, prefixOnClash) => {
|
|
88
|
+
if (!source || !paths)
|
|
89
|
+
return;
|
|
90
|
+
for (const p of paths) {
|
|
91
|
+
if ((0, denylist_1.isDeniedFieldPath)(p))
|
|
92
|
+
continue; // L2 hard deny, re-checked
|
|
93
|
+
if (p === identitySource)
|
|
94
|
+
continue; // wanted identity NEVER rides capture
|
|
95
|
+
const v = getPath(source, p);
|
|
96
|
+
let val;
|
|
97
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
98
|
+
val = v;
|
|
99
|
+
else if (typeof v === "boolean")
|
|
100
|
+
val = v;
|
|
101
|
+
else if (typeof v === "string" && v.length > 0)
|
|
102
|
+
val = v.slice(0, MAX_STRING_FIELD);
|
|
103
|
+
if (val === undefined)
|
|
104
|
+
continue;
|
|
105
|
+
let key = p;
|
|
106
|
+
if (prefixOnClash && key in out)
|
|
107
|
+
key = `resp.${p}`;
|
|
108
|
+
out[key] = val;
|
|
109
|
+
any = true;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
take(reqBody, entry.requestFields, false);
|
|
113
|
+
take(respBody, entry.responseFields, true);
|
|
114
|
+
return any ? out : undefined;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Extract the wanted-identity source value for the ATTRIBUTION channel
|
|
118
|
+
* (event.onBehalfOf) — never rec.fields. Primitives only; capped at the
|
|
119
|
+
* envelope's onBehalfOf bound. Returns undefined when absent/non-primitive.
|
|
120
|
+
*/
|
|
121
|
+
function extractIdentityValue(cfg, reqBody) {
|
|
122
|
+
if (!reqBody)
|
|
123
|
+
return undefined;
|
|
124
|
+
const v = getPath(reqBody, cfg.source);
|
|
125
|
+
if (typeof v === "string" && v.length > 0)
|
|
126
|
+
return v.slice(0, MAX_IDENTITY_VALUE);
|
|
127
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
128
|
+
return String(v);
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Hash an identity value IN-PROCESS (webcrypto sha256) into the deterministic
|
|
133
|
+
* attribution principal "sha256:<hex>". The raw value never leaves the shim
|
|
134
|
+
* on this path.
|
|
135
|
+
*/
|
|
136
|
+
async function hashIdentityValue(value) {
|
|
137
|
+
const digest = await node_crypto_1.webcrypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
138
|
+
let hex = "";
|
|
139
|
+
for (const b of new Uint8Array(digest))
|
|
140
|
+
hex += b.toString(16).padStart(2, "0");
|
|
141
|
+
return `sha256:${hex}`;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Shape sampling (L5): field NAMES + JSON types only, from the request body's
|
|
145
|
+
* top level plus query parameter names. Values never leave this function.
|
|
146
|
+
* Even the NAMES of PII-denied fields are dropped (nothing PII-shaped rides
|
|
147
|
+
* shape samples either).
|
|
148
|
+
*/
|
|
149
|
+
function extractShapeFields(reqBody, queryNames) {
|
|
150
|
+
const fields = [];
|
|
151
|
+
const seen = new Set();
|
|
152
|
+
const push = (name, type) => {
|
|
153
|
+
if (fields.length >= 64)
|
|
154
|
+
return;
|
|
155
|
+
if (name.length === 0 || name.length > 128)
|
|
156
|
+
return;
|
|
157
|
+
if (seen.has(name))
|
|
158
|
+
return;
|
|
159
|
+
if ((0, denylist_1.isDeniedSegment)(name))
|
|
160
|
+
return;
|
|
161
|
+
seen.add(name);
|
|
162
|
+
fields.push({ name, type: type });
|
|
163
|
+
};
|
|
164
|
+
if (reqBody) {
|
|
165
|
+
for (const [k, v] of Object.entries(reqBody)) {
|
|
166
|
+
const t = v === null
|
|
167
|
+
? "null"
|
|
168
|
+
: Array.isArray(v)
|
|
169
|
+
? "array"
|
|
170
|
+
: typeof v === "object"
|
|
171
|
+
? "object"
|
|
172
|
+
: typeof v === "number"
|
|
173
|
+
? "number"
|
|
174
|
+
: typeof v === "boolean"
|
|
175
|
+
? "boolean"
|
|
176
|
+
: "string";
|
|
177
|
+
push(k, t);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (queryNames) {
|
|
181
|
+
for (const q of queryNames)
|
|
182
|
+
push(q, "string");
|
|
183
|
+
}
|
|
184
|
+
return fields;
|
|
185
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ObserveHandle } from "./index";
|
|
2
|
+
export { escortedManifest } from "./manifest";
|
|
3
|
+
type Env = Record<string, string | undefined>;
|
|
4
|
+
/** Install undici's env-proxy dispatcher so global fetch honors HTTPS_PROXY.
|
|
5
|
+
* No-op when no proxy is configured or undici is not resolvable (fail-open). */
|
|
6
|
+
export declare function installProxyDispatcher(env?: Env): void;
|
|
7
|
+
/** Arm the shim from BOUNDED_* env. Returns the ObserveHandle from init(), or
|
|
8
|
+
* undefined if @bounded-sh/observe could not initialize (never throws). */
|
|
9
|
+
export declare function bootstrapFromEnv(env?: Env): ObserveHandle | undefined;
|
package/dist/register.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// register.ts -- the env-driven --require/--import PRELOAD entrypoint for the
|
|
4
|
+
// Bounded escort (SPEC §3.1e, T9). Arms the shim from BOUNDED_* env with ZERO
|
|
5
|
+
// changes to the customer app:
|
|
6
|
+
//
|
|
7
|
+
// node --require @bounded-sh/observe/register app.js (CJS)
|
|
8
|
+
// node --import @bounded-sh/observe/register app.js (ESM interop)
|
|
9
|
+
//
|
|
10
|
+
// This is a SIDE-EFFECT module: importing/requiring it runs bootstrapFromEnv()
|
|
11
|
+
// once (unless BOUNDED_REGISTER_AUTOSTART=0, which the unit tests set so they can
|
|
12
|
+
// drive bootstrapFromEnv explicitly). It does two things, in order:
|
|
13
|
+
//
|
|
14
|
+
// 1. PROXY-AWARENESS. Node's global fetch (undici) does NOT honor HTTPS_PROXY
|
|
15
|
+
// by default. Behind an egress sidecar we install undici's
|
|
16
|
+
// EnvHttpProxyAgent so every fetch (the app's rail calls AND the shim's
|
|
17
|
+
// verdict/settle/ingest calls) tunnels through the sidecar (CONNECT, no
|
|
18
|
+
// MITM). The escort still fires the call from THIS process (T9).
|
|
19
|
+
// 2. ESCORT ARMING. init() with an env-derived escort PIN (escortPin) for
|
|
20
|
+
// the built-in Stripe-refund recognizer (mirrors the reference mapping).
|
|
21
|
+
// The pin is applied over the built-in manifest at init AND re-applied
|
|
22
|
+
// over every fetched manifest swap (60s poll stays LIVE), so a served
|
|
23
|
+
// manifest can never silently drop enforcement; a fetched escort block
|
|
24
|
+
// on the pinned route wins over the pin. Without BOUNDED_VERDICT_URL we
|
|
25
|
+
// init OBSERVE-only (report egress, escort nothing).
|
|
26
|
+
//
|
|
27
|
+
// The shim NEVER breaks the app: every failure here is logged and swallowed.
|
|
28
|
+
// This is the canonical source; packages/observe-container/bootstrap/register.cjs
|
|
29
|
+
// is a thin delegator that just requires @bounded-sh/observe/register.
|
|
30
|
+
//
|
|
31
|
+
// Env (all optional except a sensor token to authenticate):
|
|
32
|
+
// BOUNDED_VERDICT_URL https://<org>.bounded.sh/api/verdict (arms escort)
|
|
33
|
+
// BOUNDED_SENSOR_TOKEN obs1.<keyId>.<sig> (verdict auth + report auth)
|
|
34
|
+
// BOUNDED_ORG org slug (manifest polling only)
|
|
35
|
+
// BOUNDED_INGEST_BASE default prod observe-ingest
|
|
36
|
+
// BOUNDED_COLLECTION escort collection (default stripeRefunds)
|
|
37
|
+
// BOUNDED_FAIL_MODE closed|open (default closed; SPEC U18) [alias BOUNDED_FAILMODE]
|
|
38
|
+
// BOUNDED_FLUSH_MS report flush cadence, ms (default 2000)
|
|
39
|
+
// BOUNDED_VERDICT_TIMEOUT_MS verdict round-trip timeout, ms (capped 30000)
|
|
40
|
+
// BOUNDED_DEBUG=1 shim debug logging
|
|
41
|
+
// BOUNDED_OBSERVE_DISABLED=1 kill switch (shim honors it directly)
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
44
|
+
exports.escortedManifest = void 0;
|
|
45
|
+
exports.installProxyDispatcher = installProxyDispatcher;
|
|
46
|
+
exports.bootstrapFromEnv = bootstrapFromEnv;
|
|
47
|
+
const index_1 = require("./index");
|
|
48
|
+
// Re-exported for compatibility: the pin transformation lives in manifest.ts
|
|
49
|
+
// so the shim core can re-apply it over fetched manifests without importing
|
|
50
|
+
// this side-effect module.
|
|
51
|
+
var manifest_1 = require("./manifest");
|
|
52
|
+
Object.defineProperty(exports, "escortedManifest", { enumerable: true, get: function () { return manifest_1.escortedManifest; } });
|
|
53
|
+
const TAG = "[bounded-observe]";
|
|
54
|
+
function log(msg) {
|
|
55
|
+
try {
|
|
56
|
+
process.stderr.write(`${TAG} ${msg}\n`);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
/* ignore */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const DEFAULT_INGEST = "https://observe-ingest.buildwithtarobase.workers.dev";
|
|
63
|
+
function redactProxy(p) {
|
|
64
|
+
// Proxy URLs rarely hold secrets, but strip any userinfo defensively.
|
|
65
|
+
try {
|
|
66
|
+
const u = new URL(p);
|
|
67
|
+
u.username = "";
|
|
68
|
+
u.password = "";
|
|
69
|
+
return u.toString();
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return "set";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Install undici's env-proxy dispatcher so global fetch honors HTTPS_PROXY.
|
|
76
|
+
* No-op when no proxy is configured or undici is not resolvable (fail-open). */
|
|
77
|
+
function installProxyDispatcher(env = process.env) {
|
|
78
|
+
const proxy = env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy;
|
|
79
|
+
if (!proxy)
|
|
80
|
+
return;
|
|
81
|
+
try {
|
|
82
|
+
// Dynamic require: undici is a Node runtime module, not a package dependency.
|
|
83
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
84
|
+
const undici = require("undici");
|
|
85
|
+
undici.setGlobalDispatcher(new undici.EnvHttpProxyAgent());
|
|
86
|
+
log(`proxy dispatcher enabled (fetch honors HTTPS_PROXY=${redactProxy(proxy)})`);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
// Non-fatal: without a dispatcher, fetch ignores the proxy and (behind a
|
|
90
|
+
// default-deny egress network) simply fails closed — never a silent bypass.
|
|
91
|
+
log(`WARN could not enable proxy dispatcher: ${err?.message}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Arm the shim from BOUNDED_* env. Returns the ObserveHandle from init(), or
|
|
95
|
+
* undefined if @bounded-sh/observe could not initialize (never throws). */
|
|
96
|
+
function bootstrapFromEnv(env = process.env) {
|
|
97
|
+
try {
|
|
98
|
+
installProxyDispatcher(env);
|
|
99
|
+
const verdictUrl = env.BOUNDED_VERDICT_URL || "";
|
|
100
|
+
const collection = env.BOUNDED_COLLECTION || "stripeRefunds";
|
|
101
|
+
const failMode = String(env.BOUNDED_FAIL_MODE || env.BOUNDED_FAILMODE || "closed").toLowerCase() === "open"
|
|
102
|
+
? "open"
|
|
103
|
+
: "closed";
|
|
104
|
+
const flushMsRaw = Number(env.BOUNDED_FLUSH_MS);
|
|
105
|
+
const flushIntervalMs = Number.isFinite(flushMsRaw) && flushMsRaw > 0 ? flushMsRaw : undefined;
|
|
106
|
+
const timeoutRaw = Number(env.BOUNDED_VERDICT_TIMEOUT_MS);
|
|
107
|
+
const timeoutMs = Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? Math.min(timeoutRaw, 30_000) : undefined;
|
|
108
|
+
const cfg = {
|
|
109
|
+
ingestBase: env.BOUNDED_INGEST_BASE || DEFAULT_INGEST,
|
|
110
|
+
token: env.BOUNDED_SENSOR_TOKEN || "obs1.unset.unset",
|
|
111
|
+
org: env.BOUNDED_ORG || undefined,
|
|
112
|
+
debug: env.BOUNDED_DEBUG === "1",
|
|
113
|
+
...(flushIntervalMs ? { flushIntervalMs } : {}),
|
|
114
|
+
};
|
|
115
|
+
if (verdictUrl) {
|
|
116
|
+
// Escort PIN (not a manifest override): applied over the built-in at
|
|
117
|
+
// init and re-applied over every fetched swap, with the 60s manifest
|
|
118
|
+
// poll left LIVE so broader/multi-rail escort (incl. llm-gateway) can
|
|
119
|
+
// still arrive server-side via the promoted manifest.
|
|
120
|
+
cfg.escortPin = { verdictUrl, collection, failMode, ...(timeoutMs ? { timeoutMs } : {}) };
|
|
121
|
+
}
|
|
122
|
+
const handle = (0, index_1.init)(cfg);
|
|
123
|
+
log(verdictUrl
|
|
124
|
+
? `escort ARMED -> collection=${collection} failMode=${failMode} verdict=${verdictUrl}`
|
|
125
|
+
: "observe-only (no BOUNDED_VERDICT_URL) — reporting egress, escorting nothing");
|
|
126
|
+
return handle;
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
log(`WARN observe init failed (app continues): ${err?.message}`);
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Auto-arm on load (the --require/--import entrypoint). Guarded so unit tests can
|
|
134
|
+
// import the exported helpers WITHOUT the global-patch side effect
|
|
135
|
+
// (BOUNDED_REGISTER_AUTOSTART=0). Real preloads never set that flag.
|
|
136
|
+
if (process.env.BOUNDED_REGISTER_AUTOSTART !== "0") {
|
|
137
|
+
bootstrapFromEnv();
|
|
138
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { InternalEvent } from "./types";
|
|
2
|
+
export declare class Reporter {
|
|
3
|
+
private readonly maxQueue;
|
|
4
|
+
private readonly sendTimeoutMs;
|
|
5
|
+
private queue;
|
|
6
|
+
private retry;
|
|
7
|
+
/** In-flight send (single-flight). drain() AWAITS it — never abandons it. */
|
|
8
|
+
private inFlight;
|
|
9
|
+
private immediateScheduled;
|
|
10
|
+
/** Drops awaiting report on the next batch's first event. */
|
|
11
|
+
pendingDrops: number;
|
|
12
|
+
totalDropped: number;
|
|
13
|
+
sentEvents: number;
|
|
14
|
+
sentBatches: number;
|
|
15
|
+
sendFailures: number;
|
|
16
|
+
constructor(maxQueue: number, sendTimeoutMs: number);
|
|
17
|
+
get queueDepth(): number;
|
|
18
|
+
countDrop(n?: number): void;
|
|
19
|
+
push(ev: InternalEvent): void;
|
|
20
|
+
/** Send at most one batch. Single-flight; never throws. */
|
|
21
|
+
flushOnce(): Promise<void>;
|
|
22
|
+
/** Drain everything (graceful shutdown / tests). WAITS for any in-flight
|
|
23
|
+
* send first — a shutdown never abandons a batch mid-air. Never throws. */
|
|
24
|
+
drain(): Promise<void>;
|
|
25
|
+
private takeBatch;
|
|
26
|
+
private sendNext;
|
|
27
|
+
}
|