@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.
@@ -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
+ }
@@ -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;
@@ -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
+ }