@floatonnear/float-adapter 0.0.0-stage → 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,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
+ }
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;
package/dist/gate.js ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The HTTP gate: where to bind, who may POST, and which origin may ask.
3
+ *
4
+ * This layer had no tests, which is why it shipped open. Proven by PoC on
5
+ * 2026-10-29 against the running adapter (see docs/SELF-REVIEW-2026-10-29.md):
6
+ *
7
+ * - `server.listen(port)` with no host bound `0.0.0.0` AND `[::]` — every
8
+ * interface, v4 and v6 — while the startup log claimed `127.0.0.1`.
9
+ * - An OPTIONS preflight carrying `Origin: https://evil.example` returned
10
+ * `204` with `access-control-allow-origin: *`, so a browser proceeds to the
11
+ * real request.
12
+ * - `POST /v1/pay` returned `400`, not `401`: it reached body validation
13
+ * because there was no auth gate to fail at. That route signs with the
14
+ * agent key.
15
+ *
16
+ * Split out from server.ts so each decision is a pure function with a test,
17
+ * rather than three lines buried in a request handler where nothing could see
18
+ * them.
19
+ */
20
+ import { timingSafeEqual } from "node:crypto";
21
+ /**
22
+ * Loopback by default. Exposure is opted into, never inherited.
23
+ *
24
+ * `listen(port)` with no host is the dangerous default — it binds every
25
+ * interface. Passing a host explicitly is the whole fix, and defaulting it to
26
+ * loopback means a deployment has to SAY it wants to be reachable.
27
+ */
28
+ export function resolveHost(host) {
29
+ return host && host.trim() ? host.trim() : "127.0.0.1";
30
+ }
31
+ /**
32
+ * CORS headers, echoing only an exact-match origin.
33
+ *
34
+ * A wildcard plus an approving preflight is what made this drive-by reachable:
35
+ * any page the operator had open could POST JSON here. Returning no
36
+ * `allow-origin` for an unknown origin makes the browser block the response and
37
+ * — for a preflighted `application/json` POST — never send the request at all.
38
+ *
39
+ * The header is omitted entirely when no `Origin` was sent, which is the
40
+ * curl/server-to-server case and needs no CORS at all.
41
+ */
42
+ export function corsHeaders(allowedOrigin, requestOrigin) {
43
+ const base = {
44
+ "access-control-allow-headers": "content-type, authorization",
45
+ "access-control-allow-methods": "GET, POST, OPTIONS",
46
+ vary: "Origin",
47
+ };
48
+ if (requestOrigin && requestOrigin === allowedOrigin) {
49
+ base["access-control-allow-origin"] = requestOrigin;
50
+ }
51
+ return base;
52
+ }
53
+ /**
54
+ * Only mutating requests need the token.
55
+ *
56
+ * GETs are read-only and the bind is loopback, so they are reachable only from
57
+ * the machine itself. OPTIONS must pass or the browser never reaches the real
58
+ * request and the dashboard breaks for a reason nobody can see.
59
+ *
60
+ * A MISSING token is 503, not 401. They are different problems: 401 tells a
61
+ * caller to go and find a credential, and here there is none to find — the
62
+ * operator has not configured one. Conflating them sends people hunting for a
63
+ * secret that does not exist.
64
+ */
65
+ export function authorize({ method, authHeader, token, }) {
66
+ if (method === "GET" || method === "OPTIONS" || method === "HEAD")
67
+ return { ok: true };
68
+ if (!token) {
69
+ return {
70
+ ok: false,
71
+ status: 503,
72
+ error: "this route signs with the agent key and is disabled until FLOAT_API_TOKEN is set. " +
73
+ "Set it in .float/adapter.env and send it as `Authorization: Bearer <token>`.",
74
+ };
75
+ }
76
+ const prefix = "Bearer ";
77
+ if (!authHeader || !authHeader.startsWith(prefix)) {
78
+ return { ok: false, status: 401, error: "missing bearer token" };
79
+ }
80
+ const provided = authHeader.slice(prefix.length);
81
+ // Length check first — timingSafeEqual throws on a length mismatch, and the
82
+ // length of a secret is not the part worth hiding.
83
+ if (provided.length !== token.length) {
84
+ return { ok: false, status: 401, error: "invalid token" };
85
+ }
86
+ const a = Buffer.from(provided);
87
+ const b = Buffer.from(token);
88
+ if (!timingSafeEqual(a, b)) {
89
+ return { ok: false, status: 401, error: "invalid token" };
90
+ }
91
+ return { ok: true };
92
+ }
93
+ /**
94
+ * An error caused by the CALLER, not by this server.
95
+ *
96
+ * The catch-all returned 500 for everything, so `{"amount":"abc"}` came back as
97
+ * `500 invalid amount "abc"` — verified live against the running adapter.
98
+ *
99
+ * That is not a cosmetic status-code quibble on an agent-facing API. 500 means
100
+ * "the server broke, retrying may help"; 400 means "your request is wrong, fix
101
+ * it". An agent reading 500 on a malformed amount retries the same malformed
102
+ * amount forever. SKILL.md teaches agents exactly this distinction for the
103
+ * contract's reason codes, and the HTTP layer was contradicting it.
104
+ */
105
+ export class BadRequest extends Error {
106
+ constructor(message) {
107
+ super(message);
108
+ this.name = "BadRequest";
109
+ }
110
+ }
111
+ /**
112
+ * Map a thrown value to a response.
113
+ *
114
+ * Takes `unknown` rather than `Error` on purpose: `throw "string"` is legal,
115
+ * and a catch-all that assumes `.message` exists turns a small bug into an
116
+ * unhandled one. Only the message is returned — never a stack, which would
117
+ * hand a caller the server's file paths.
118
+ */
119
+ export function classifyError(e) {
120
+ if (e instanceof BadRequest)
121
+ return { status: 400, body: { error: e.message } };
122
+ const message = e instanceof Error ? e.message : "internal error";
123
+ return { status: 500, body: { error: message } };
124
+ }
125
+ /**
126
+ * Run caller-supplied parsing, re-labelling any failure as the caller's.
127
+ *
128
+ * Wraps the validation helpers that throw plain `Error` — `toBaseUnits` and
129
+ * friends — so their failures reach the catch-all already classified, instead
130
+ * of being indistinguishable from an RPC outage.
131
+ */
132
+ export function fromCaller(fn) {
133
+ try {
134
+ return fn();
135
+ }
136
+ catch (e) {
137
+ throw new BadRequest(e instanceof Error ? e.message : String(e));
138
+ }
139
+ }