@floatonnear/float-adapter 0.0.0-stage → 0.1.1

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,153 @@
1
+ /**
2
+ * Recovering refused payments from the chain, continuously.
3
+ *
4
+ * A refusal is deliberately NOT stored in contract state — storing one would
5
+ * let an agent inflate the vault's storage bill by spamming rejects, which
6
+ * docs/KNOWN-TRADEOFFS.md §2 names as the real griefing vector. The contract
7
+ * emits a NEP-297 event and keeps nothing. That makes the single most
8
+ * important thing this product does invisible to anything that was not
9
+ * watching at the moment it happened.
10
+ *
11
+ * The events do survive, inside receipt outcomes, so they can be read back.
12
+ * No indexer is involved: NearBlocks returns nothing at all for this testnet
13
+ * account, so everything here is plain RPC and reproducible by anyone.
14
+ *
15
+ * Two things this module is careful about:
16
+ *
17
+ * COST. A naive walk queries every chunk of every block. In practice one
18
+ * chunk in ten carries any transaction, and an empty chunk's `tx_root` is
19
+ * the all-ones hash, so empties are skipped without a second call. Work per
20
+ * tick is also capped, so a long outage cannot turn the catch-up into a
21
+ * thundering scan.
22
+ *
23
+ * HONESTY ABOUT COVERAGE. A public RPC garbage-collects old blocks and
24
+ * occasionally fails a request. Those blocks are HOLES, and a range that
25
+ * contains holes is not covered. Reporting `from..to` while having silently
26
+ * skipped blocks inside it would turn "we did not look there" into "nothing
27
+ * happened" — the exact confusion this feature exists to remove.
28
+ */
29
+ /** A chunk with no transactions carries this as its tx_root. */
30
+ export declare const EMPTY_TX_ROOT = "11111111111111111111111111111111";
31
+ export type RpcFn = (method: string, params: unknown) => Promise<any>;
32
+ /**
33
+ * An RPC caller that paces itself and backs off when throttled.
34
+ *
35
+ * Added after watching the follower shred its own coverage map. Public
36
+ * endpoints rate-limit; without backoff every transient `429` turned into a
37
+ * PERMANENT gap, because a block that could not be read is recorded as
38
+ * unread. One load test left 33 fragmented gaps of three to twenty blocks
39
+ * each — each one technically honest and collectively useless, since nobody
40
+ * is ever going to backfill a five-block hole.
41
+ *
42
+ * Holes should mean "this span is genuinely unavailable", not "the endpoint
43
+ * was briefly busy". Retrying is what keeps that distinction meaningful.
44
+ */
45
+ export declare function createRpc(url: string, opts?: {
46
+ paceMs?: number;
47
+ maxRetries?: number;
48
+ onThrottle?: (waitMs: number) => void;
49
+ }): RpcFn;
50
+ export interface BlockedRecord {
51
+ requestId: string;
52
+ nodeId: number;
53
+ reasonCode: string;
54
+ agent: string;
55
+ txHash: string;
56
+ blockHeight: number;
57
+ blockTimestampNs: string;
58
+ }
59
+ export interface ScanResult {
60
+ records: BlockedRecord[];
61
+ blocksSeen: number;
62
+ chunksFetched: number;
63
+ chunksSkipped: number;
64
+ /** Heights that could not be read. Coverage inside a hole is unknown. */
65
+ holes: number[];
66
+ }
67
+ /**
68
+ * Pull `request_blocked` events out of one transaction's receipt logs.
69
+ * Exported for testing: this parsing is the part that silently breaks if the
70
+ * event format ever changes.
71
+ */
72
+ export declare function extractBlocked(logs: string[], ctx: {
73
+ agent: string;
74
+ txHash: string;
75
+ blockHeight: number;
76
+ blockTimestampNs: string;
77
+ }): BlockedRecord[];
78
+ /** Scan an inclusive block range for refusals by `vault`. */
79
+ export declare function scanRange(rpc: RpcFn, vault: string, from: number, to: number): Promise<ScanResult>;
80
+ /** An inclusive span of blocks that was NOT read. */
81
+ export interface Hole {
82
+ from: number;
83
+ to: number;
84
+ }
85
+ export interface BlockedStore {
86
+ vault: string;
87
+ scannedFrom: number | null;
88
+ scannedTo: number | null;
89
+ /**
90
+ * Spans known to be unread, as RANGES rather than individual heights.
91
+ * An adapter restarted after a day is tens of thousands of blocks behind;
92
+ * listing each one would bloat the file to no purpose. Kept so that
93
+ * coverage is never overstated — `scannedFrom..scannedTo` minus these is
94
+ * what has actually been examined.
95
+ */
96
+ holes: Hole[];
97
+ blocked: Record<string, BlockedRecord>;
98
+ }
99
+ /** Merge overlapping/adjacent spans so the list stays short and readable. */
100
+ export declare function coalesce(spans: Hole[]): Hole[];
101
+ export declare function emptyStore(vault: string): BlockedStore;
102
+ /** Fold a scan result into a store. Pure, so the merge rules are testable. */
103
+ export declare function mergeScan(store: BlockedStore, from: number, to: number, res: ScanResult): {
104
+ store: BlockedStore;
105
+ added: number;
106
+ };
107
+ export interface FollowerOptions {
108
+ rpc: RpcFn;
109
+ vault: string;
110
+ load: () => BlockedStore;
111
+ save: (s: BlockedStore) => void;
112
+ /** Milliseconds between ticks. */
113
+ intervalMs?: number;
114
+ /** Hard cap on blocks examined per tick, so catch-up stays bounded. */
115
+ maxBlocksPerTick?: number;
116
+ /**
117
+ * If the cursor falls further behind the head than this, STOP trying to
118
+ * walk the gap: record it as a hole and resume at the head.
119
+ *
120
+ * Grinding through a day's backlog is tens of thousands of RPC calls and
121
+ * leaves the follower useless for hours. Jumping silently would be worse —
122
+ * it would report the skipped span as covered. Recording the hole keeps the
123
+ * adapter current AND keeps the coverage claim true; the gap can then be
124
+ * filled deliberately with scripts/scan-blocked.mjs.
125
+ */
126
+ maxCatchupBlocks?: number;
127
+ onTick?: (info: {
128
+ from: number;
129
+ to: number;
130
+ added: number;
131
+ holes: number;
132
+ }) => void;
133
+ /** Called when a backlog was too large to walk and became a hole. */
134
+ onSkip?: (span: Hole) => void;
135
+ }
136
+ /**
137
+ * Follows the chain head, recording refusals as they happen.
138
+ *
139
+ * Starts from the stored cursor when there is one, and otherwise from the
140
+ * CURRENT head — never from genesis. A fresh adapter should not spend its
141
+ * first hour replaying history nobody asked for; `scripts/scan-blocked.mjs`
142
+ * exists for deliberate backfill.
143
+ */
144
+ export declare class BlockedFollower {
145
+ private readonly o;
146
+ private timer;
147
+ private running;
148
+ private stopped;
149
+ constructor(o: FollowerOptions);
150
+ tick(): Promise<void>;
151
+ start(): void;
152
+ stop(): void;
153
+ }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Recovering refused payments from the chain, continuously.
3
+ *
4
+ * A refusal is deliberately NOT stored in contract state — storing one would
5
+ * let an agent inflate the vault's storage bill by spamming rejects, which
6
+ * docs/KNOWN-TRADEOFFS.md §2 names as the real griefing vector. The contract
7
+ * emits a NEP-297 event and keeps nothing. That makes the single most
8
+ * important thing this product does invisible to anything that was not
9
+ * watching at the moment it happened.
10
+ *
11
+ * The events do survive, inside receipt outcomes, so they can be read back.
12
+ * No indexer is involved: NearBlocks returns nothing at all for this testnet
13
+ * account, so everything here is plain RPC and reproducible by anyone.
14
+ *
15
+ * Two things this module is careful about:
16
+ *
17
+ * COST. A naive walk queries every chunk of every block. In practice one
18
+ * chunk in ten carries any transaction, and an empty chunk's `tx_root` is
19
+ * the all-ones hash, so empties are skipped without a second call. Work per
20
+ * tick is also capped, so a long outage cannot turn the catch-up into a
21
+ * thundering scan.
22
+ *
23
+ * HONESTY ABOUT COVERAGE. A public RPC garbage-collects old blocks and
24
+ * occasionally fails a request. Those blocks are HOLES, and a range that
25
+ * contains holes is not covered. Reporting `from..to` while having silently
26
+ * skipped blocks inside it would turn "we did not look there" into "nothing
27
+ * happened" — the exact confusion this feature exists to remove.
28
+ */
29
+ /** A chunk with no transactions carries this as its tx_root. */
30
+ export const EMPTY_TX_ROOT = "11111111111111111111111111111111";
31
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
32
+ /**
33
+ * An RPC caller that paces itself and backs off when throttled.
34
+ *
35
+ * Added after watching the follower shred its own coverage map. Public
36
+ * endpoints rate-limit; without backoff every transient `429` turned into a
37
+ * PERMANENT gap, because a block that could not be read is recorded as
38
+ * unread. One load test left 33 fragmented gaps of three to twenty blocks
39
+ * each — each one technically honest and collectively useless, since nobody
40
+ * is ever going to backfill a five-block hole.
41
+ *
42
+ * Holes should mean "this span is genuinely unavailable", not "the endpoint
43
+ * was briefly busy". Retrying is what keeps that distinction meaningful.
44
+ */
45
+ export function createRpc(url, opts = {}) {
46
+ const paceMs = opts.paceMs ?? 25;
47
+ const maxRetries = opts.maxRetries ?? 5;
48
+ return async function rpc(method, params, attempt = 0) {
49
+ if (paceMs)
50
+ await sleep(paceMs);
51
+ let r;
52
+ try {
53
+ r = await fetch(url, {
54
+ method: "POST",
55
+ headers: { "content-type": "application/json" },
56
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
57
+ });
58
+ }
59
+ catch (e) {
60
+ // A dropped connection mid-scan is routine, not exceptional.
61
+ if (attempt >= maxRetries)
62
+ throw e;
63
+ await sleep(Math.min(30_000, 500 * 2 ** attempt));
64
+ return rpc(method, params, attempt + 1);
65
+ }
66
+ if (r.status === 429 || r.status >= 500) {
67
+ if (attempt >= maxRetries)
68
+ throw new Error(`RPC HTTP ${r.status} after ${attempt} retries`);
69
+ const wait = Math.min(30_000, 500 * 2 ** attempt);
70
+ opts.onThrottle?.(wait);
71
+ await sleep(wait);
72
+ return rpc(method, params, attempt + 1);
73
+ }
74
+ if (!r.ok)
75
+ throw new Error(`RPC HTTP ${r.status}`);
76
+ const j = await r.json();
77
+ if (j.error)
78
+ throw new Error(`${method}: ${JSON.stringify(j.error).slice(0, 140)}`);
79
+ return j.result;
80
+ };
81
+ }
82
+ /**
83
+ * Pull `request_blocked` events out of one transaction's receipt logs.
84
+ * Exported for testing: this parsing is the part that silently breaks if the
85
+ * event format ever changes.
86
+ */
87
+ export function extractBlocked(logs, ctx) {
88
+ const out = [];
89
+ for (const line of logs) {
90
+ if (!line.startsWith("EVENT_JSON:") || !line.includes("request_blocked"))
91
+ continue;
92
+ let ev;
93
+ try {
94
+ ev = JSON.parse(line.slice("EVENT_JSON:".length));
95
+ }
96
+ catch {
97
+ // A malformed log line is not a reason to abandon the rest of the
98
+ // transaction, nor to pretend the others do not exist.
99
+ continue;
100
+ }
101
+ if (ev?.event !== "request_blocked")
102
+ continue;
103
+ for (const d of ev.data ?? []) {
104
+ if (typeof d?.request_id !== "string")
105
+ continue;
106
+ out.push({
107
+ requestId: d.request_id,
108
+ nodeId: Number(d.node_id),
109
+ reasonCode: String(d.reason_code ?? "UNKNOWN"),
110
+ ...ctx,
111
+ });
112
+ }
113
+ }
114
+ return out;
115
+ }
116
+ /** Scan an inclusive block range for refusals by `vault`. */
117
+ export async function scanRange(rpc, vault, from, to) {
118
+ const res = {
119
+ records: [],
120
+ blocksSeen: 0,
121
+ chunksFetched: 0,
122
+ chunksSkipped: 0,
123
+ holes: [],
124
+ };
125
+ for (let h = from; h <= to; h++) {
126
+ let blk;
127
+ try {
128
+ blk = await rpc("block", { block_id: h });
129
+ }
130
+ catch {
131
+ res.holes.push(h);
132
+ continue;
133
+ }
134
+ res.blocksSeen++;
135
+ for (const ch of blk.chunks ?? []) {
136
+ if (ch.tx_root === EMPTY_TX_ROOT) {
137
+ res.chunksSkipped++;
138
+ continue;
139
+ }
140
+ let chunk;
141
+ try {
142
+ chunk = await rpc("chunk", { chunk_id: ch.chunk_hash });
143
+ res.chunksFetched++;
144
+ }
145
+ catch {
146
+ // A chunk we could not read means this block is not fully covered.
147
+ res.holes.push(h);
148
+ continue;
149
+ }
150
+ for (const tx of chunk.transactions ?? []) {
151
+ if (tx.receiver_id !== vault)
152
+ continue;
153
+ let st;
154
+ try {
155
+ st = await rpc("EXPERIMENTAL_tx_status", [tx.hash, tx.signer_id]);
156
+ }
157
+ catch {
158
+ res.holes.push(h);
159
+ continue;
160
+ }
161
+ const logs = (st.receipts_outcome ?? []).flatMap((o) => o.outcome?.logs ?? []);
162
+ res.records.push(...extractBlocked(logs, {
163
+ agent: tx.signer_id,
164
+ txHash: tx.hash,
165
+ blockHeight: h,
166
+ blockTimestampNs: String(blk.header?.timestamp ?? "0"),
167
+ }));
168
+ }
169
+ }
170
+ }
171
+ return res;
172
+ }
173
+ /** Merge overlapping/adjacent spans so the list stays short and readable. */
174
+ export function coalesce(spans) {
175
+ if (spans.length < 2)
176
+ return [...spans];
177
+ const sorted = [...spans].sort((a, b) => a.from - b.from);
178
+ const out = [{ ...sorted[0] }];
179
+ for (const s of sorted.slice(1)) {
180
+ const last = out[out.length - 1];
181
+ if (s.from <= last.to + 1)
182
+ last.to = Math.max(last.to, s.to);
183
+ else
184
+ out.push({ ...s });
185
+ }
186
+ return out;
187
+ }
188
+ export function emptyStore(vault) {
189
+ return { vault, scannedFrom: null, scannedTo: null, holes: [], blocked: {} };
190
+ }
191
+ /** Fold a scan result into a store. Pure, so the merge rules are testable. */
192
+ export function mergeScan(store, from, to, res) {
193
+ let added = 0;
194
+ for (const r of res.records) {
195
+ if (store.blocked[r.requestId])
196
+ continue; // same refusal seen twice
197
+ store.blocked[r.requestId] = r;
198
+ added++;
199
+ }
200
+ store.scannedFrom = store.scannedFrom === null ? from : Math.min(store.scannedFrom, from);
201
+ store.scannedTo = Math.max(store.scannedTo ?? 0, to);
202
+ if (res.holes.length) {
203
+ store.holes = coalesce([...store.holes, ...res.holes.map((h) => ({ from: h, to: h }))]);
204
+ }
205
+ return { store, added };
206
+ }
207
+ /**
208
+ * Follows the chain head, recording refusals as they happen.
209
+ *
210
+ * Starts from the stored cursor when there is one, and otherwise from the
211
+ * CURRENT head — never from genesis. A fresh adapter should not spend its
212
+ * first hour replaying history nobody asked for; `scripts/scan-blocked.mjs`
213
+ * exists for deliberate backfill.
214
+ */
215
+ export class BlockedFollower {
216
+ o;
217
+ timer = null;
218
+ running = false;
219
+ stopped = false;
220
+ constructor(o) {
221
+ this.o = o;
222
+ }
223
+ async tick() {
224
+ // Ticks must never overlap. A slow catch-up on a quiet RPC would
225
+ // otherwise stack, and two scans sharing a cursor double the work and
226
+ // race on the store.
227
+ if (this.running || this.stopped)
228
+ return;
229
+ this.running = true;
230
+ try {
231
+ const head = (await this.o.rpc("block", { finality: "final" })).header.height;
232
+ const store = this.o.load();
233
+ const cap = this.o.maxBlocksPerTick ?? 50;
234
+ const maxCatchup = this.o.maxCatchupBlocks ?? 5_000;
235
+ let from = store.scannedTo === null ? head : store.scannedTo + 1;
236
+ if (from > head)
237
+ return;
238
+ if (head - from > maxCatchup) {
239
+ const skipFrom = from;
240
+ const skipTo = head - 1;
241
+ store.holes = coalesce([...store.holes, { from: skipFrom, to: skipTo }]);
242
+ this.o.onSkip?.({ from: skipFrom, to: skipTo });
243
+ from = head;
244
+ }
245
+ const to = Math.min(head, from + cap - 1);
246
+ const res = await scanRange(this.o.rpc, this.o.vault, from, to);
247
+ const { store: next, added } = mergeScan(store, from, to, res);
248
+ next.vault = this.o.vault;
249
+ this.o.save(next);
250
+ this.o.onTick?.({ from, to, added, holes: res.holes.length });
251
+ }
252
+ catch {
253
+ // A failed tick is normal on a public endpoint. Swallow it: the cursor
254
+ // did not advance, so the next tick retries the same range.
255
+ }
256
+ finally {
257
+ this.running = false;
258
+ }
259
+ }
260
+ start() {
261
+ this.stopped = false;
262
+ const ms = this.o.intervalMs ?? 10_000;
263
+ void this.tick();
264
+ this.timer = setInterval(() => void this.tick(), ms);
265
+ // Do not hold the process open on this timer alone.
266
+ this.timer.unref?.();
267
+ }
268
+ stop() {
269
+ this.stopped = true;
270
+ if (this.timer)
271
+ clearInterval(this.timer);
272
+ this.timer = null;
273
+ }
274
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Read an env file without involving a shell.
3
+ *
4
+ * Every doc in this repo tells the operator to run:
5
+ *
6
+ * set -a && . .float/adapter.env && set +a && npx @floatonnear/float-adapter
7
+ *
8
+ * That idiom has already failed on a real box. From this project's memory
9
+ * (prod-env-file-cannot-be-sourced-by-bash): one unquoted value containing a
10
+ * space made `set -a; . .env; set +a` die with `Scan: command not found`, and
11
+ * every script opening with that line failed before it ran.
12
+ *
13
+ * Two reasons that is the wrong mechanism HERE specifically, rather than merely
14
+ * fragile:
15
+ *
16
+ * 1. The file is written by create-float and carries NEAR_VAULT_AGENT_KEY,
17
+ * FLOAT_QUOTE_SIGNER_KEY and FLOAT_API_TOKEN. Sourcing it means the shell
18
+ * EXECUTES its contents: `$(...)` and backticks in a value run as
19
+ * commands. A secrets file should be data.
20
+ * 2. It has to work the same way on Windows and on a VPS. `set -a` is not
21
+ * available in PowerShell at all, which is why this repo's memory carries
22
+ * a bespoke `Get-Content | ForEach-Object { Set-Item Env: }` incantation
23
+ * just to start the adapter locally.
24
+ *
25
+ * One parser, tested, used by both.
26
+ */
27
+ /**
28
+ * Parse `KEY=value` lines into a plain object.
29
+ *
30
+ * Deliberately NOT a dotenv clone: no variable interpolation, no escape
31
+ * sequences, no multi-line values. Each of those is a way for the contents of a
32
+ * secrets file to change meaning, and none is needed by the file create-float
33
+ * writes.
34
+ */
35
+ export declare function parseEnvFile(text: string): Record<string, string>;
36
+ /**
37
+ * Apply a parsed env file WITHOUT overriding anything already set.
38
+ *
39
+ * A real environment variable must outrank the file, so a deploy can point one
40
+ * service at a different port or contract without editing shared secrets. That
41
+ * precedence is the same one the pythscan sandbox relied on to keep a verify run
42
+ * aimed at a database COPY while every credential still came from the
43
+ * production file.
44
+ */
45
+ export declare function applyEnvFile(parsed: Record<string, string>, env?: Record<string, string | undefined>): string[];
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Read an env file without involving a shell.
3
+ *
4
+ * Every doc in this repo tells the operator to run:
5
+ *
6
+ * set -a && . .float/adapter.env && set +a && npx @floatonnear/float-adapter
7
+ *
8
+ * That idiom has already failed on a real box. From this project's memory
9
+ * (prod-env-file-cannot-be-sourced-by-bash): one unquoted value containing a
10
+ * space made `set -a; . .env; set +a` die with `Scan: command not found`, and
11
+ * every script opening with that line failed before it ran.
12
+ *
13
+ * Two reasons that is the wrong mechanism HERE specifically, rather than merely
14
+ * fragile:
15
+ *
16
+ * 1. The file is written by create-float and carries NEAR_VAULT_AGENT_KEY,
17
+ * FLOAT_QUOTE_SIGNER_KEY and FLOAT_API_TOKEN. Sourcing it means the shell
18
+ * EXECUTES its contents: `$(...)` and backticks in a value run as
19
+ * commands. A secrets file should be data.
20
+ * 2. It has to work the same way on Windows and on a VPS. `set -a` is not
21
+ * available in PowerShell at all, which is why this repo's memory carries
22
+ * a bespoke `Get-Content | ForEach-Object { Set-Item Env: }` incantation
23
+ * just to start the adapter locally.
24
+ *
25
+ * One parser, tested, used by both.
26
+ */
27
+ /**
28
+ * Parse `KEY=value` lines into a plain object.
29
+ *
30
+ * Deliberately NOT a dotenv clone: no variable interpolation, no escape
31
+ * sequences, no multi-line values. Each of those is a way for the contents of a
32
+ * secrets file to change meaning, and none is needed by the file create-float
33
+ * writes.
34
+ */
35
+ export function parseEnvFile(text) {
36
+ const out = {};
37
+ // Strip a BOM. PowerShell's Out-File and Set-Content write one by default, so
38
+ // any file the operator has touched from a one-liner has it, and it would
39
+ // otherwise become part of the FIRST key's name — producing a missing-variable
40
+ // failure whose cause is invisible.
41
+ for (const raw of text.replace(/^/, "").split(/\r?\n/)) {
42
+ const line = raw.trim();
43
+ if (!line || line.startsWith("#"))
44
+ continue;
45
+ // Split on the FIRST '=' only. Base64url tokens and ed25519 keys contain
46
+ // '=' as padding, and splitting on each one silently truncates a secret —
47
+ // which surfaces as a 401 that nobody can account for.
48
+ const eq = line.indexOf("=");
49
+ if (eq < 0)
50
+ continue; // not an assignment; not our business
51
+ const key = line.slice(0, eq).trim().replace(/^export\s+/, "");
52
+ if (!key)
53
+ continue;
54
+ let value = line.slice(eq + 1).trim();
55
+ // Strip ONE layer of matching surrounding quotes, so both `A="x y"` and the
56
+ // bare `A=x y` that broke the shell mean the same thing here.
57
+ if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
58
+ value = value.slice(1, -1);
59
+ }
60
+ out[key] = value;
61
+ }
62
+ return out;
63
+ }
64
+ /**
65
+ * Apply a parsed env file WITHOUT overriding anything already set.
66
+ *
67
+ * A real environment variable must outrank the file, so a deploy can point one
68
+ * service at a different port or contract without editing shared secrets. That
69
+ * precedence is the same one the pythscan sandbox relied on to keep a verify run
70
+ * aimed at a database COPY while every credential still came from the
71
+ * production file.
72
+ */
73
+ export function applyEnvFile(parsed, env = process.env) {
74
+ const applied = [];
75
+ for (const [k, v] of Object.entries(parsed)) {
76
+ if (env[k] !== undefined)
77
+ continue;
78
+ env[k] = v;
79
+ applied.push(k);
80
+ }
81
+ return applied;
82
+ }
package/dist/gate.d.ts ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Loopback by default. Exposure is opted into, never inherited.
3
+ *
4
+ * `listen(port)` with no host is the dangerous default — it binds every
5
+ * interface. Passing a host explicitly is the whole fix, and defaulting it to
6
+ * loopback means a deployment has to SAY it wants to be reachable.
7
+ */
8
+ export declare function resolveHost(host: string | undefined): string;
9
+ /**
10
+ * CORS headers, echoing only an exact-match origin.
11
+ *
12
+ * A wildcard plus an approving preflight is what made this drive-by reachable:
13
+ * any page the operator had open could POST JSON here. Returning no
14
+ * `allow-origin` for an unknown origin makes the browser block the response and
15
+ * — for a preflighted `application/json` POST — never send the request at all.
16
+ *
17
+ * The header is omitted entirely when no `Origin` was sent, which is the
18
+ * curl/server-to-server case and needs no CORS at all.
19
+ */
20
+ export declare function corsHeaders(allowedOrigin: string, requestOrigin: string | undefined): Record<string, string>;
21
+ export type AuthVerdict = {
22
+ ok: true;
23
+ } | {
24
+ ok: false;
25
+ status: number;
26
+ error: string;
27
+ };
28
+ /**
29
+ * Only mutating requests need the token.
30
+ *
31
+ * GETs are read-only and the bind is loopback, so they are reachable only from
32
+ * the machine itself. OPTIONS must pass or the browser never reaches the real
33
+ * request and the dashboard breaks for a reason nobody can see.
34
+ *
35
+ * A MISSING token is 503, not 401. They are different problems: 401 tells a
36
+ * caller to go and find a credential, and here there is none to find — the
37
+ * operator has not configured one. Conflating them sends people hunting for a
38
+ * secret that does not exist.
39
+ */
40
+ export declare function authorize({ method, authHeader, token, }: {
41
+ method: string;
42
+ authHeader: string | undefined;
43
+ token: string | undefined;
44
+ }): AuthVerdict;
45
+ /**
46
+ * An error caused by the CALLER, not by this server.
47
+ *
48
+ * The catch-all returned 500 for everything, so `{"amount":"abc"}` came back as
49
+ * `500 invalid amount "abc"` — verified live against the running adapter.
50
+ *
51
+ * That is not a cosmetic status-code quibble on an agent-facing API. 500 means
52
+ * "the server broke, retrying may help"; 400 means "your request is wrong, fix
53
+ * it". An agent reading 500 on a malformed amount retries the same malformed
54
+ * amount forever. SKILL.md teaches agents exactly this distinction for the
55
+ * contract's reason codes, and the HTTP layer was contradicting it.
56
+ */
57
+ export declare class BadRequest extends Error {
58
+ constructor(message: string);
59
+ }
60
+ /**
61
+ * Map a thrown value to a response.
62
+ *
63
+ * Takes `unknown` rather than `Error` on purpose: `throw "string"` is legal,
64
+ * and a catch-all that assumes `.message` exists turns a small bug into an
65
+ * unhandled one. Only the message is returned — never a stack, which would
66
+ * hand a caller the server's file paths.
67
+ */
68
+ export declare function classifyError(e: unknown): {
69
+ status: number;
70
+ body: {
71
+ error: string;
72
+ };
73
+ };
74
+ /**
75
+ * Run caller-supplied parsing, re-labelling any failure as the caller's.
76
+ *
77
+ * Wraps the validation helpers that throw plain `Error` — `toBaseUnits` and
78
+ * friends — so their failures reach the catch-all already classified, instead
79
+ * of being indistinguishable from an RPC outage.
80
+ */
81
+ export declare function fromCaller<T>(fn: () => T): T;