@floatonnear/float-adapter 0.2.0 → 0.3.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.
@@ -47,6 +47,51 @@ export declare function createRpc(url: string, opts?: {
47
47
  maxRetries?: number;
48
48
  onThrottle?: (waitMs: number) => void;
49
49
  }): RpcFn;
50
+ /** An RPC failure that carries the JSON-RPC error object it came from. */
51
+ export interface RpcError extends Error {
52
+ rpcError?: {
53
+ name?: string;
54
+ cause?: {
55
+ name?: string;
56
+ };
57
+ data?: unknown;
58
+ };
59
+ }
60
+ /**
61
+ * Did this failure mean "no block was ever produced at that height"?
62
+ *
63
+ * NEAR gives every slot a height, and when a producer misses its slot that
64
+ * height never gets a block. Measured on testnet: 8 of the 1,604 heights in
65
+ * one real scan, about 0.5%. Those heights are not gaps in coverage — there
66
+ * is nothing at them to read, by anyone, ever.
67
+ *
68
+ * The scanner used to record them as unread, so every scan of every vault
69
+ * reported holes that could never be filled and the dashboard said "refusal
70
+ * history is incomplete" permanently, for a reason that was not real.
71
+ *
72
+ * `UNKNOWN_BLOCK` ALONE IS NOT ENOUGH, and this is the trap. A non-archival
73
+ * node returns the same `UNKNOWN_BLOCK` for a block that genuinely exists
74
+ * but has been garbage-collected — measured against the default testnet RPC,
75
+ * which returned it for every height in that same scan, including ones the
76
+ * scanner had read successfully. Treating that as "nothing there" would
77
+ * claim coverage of a range never read, which is the exact failure this
78
+ * subsystem exists to prevent.
79
+ *
80
+ * The two are distinguishable by what the node could resolve:
81
+ *
82
+ * never produced data: "DB Not Found Error: BLOCK HEIGHT: 276175670"
83
+ * node lacks it data: "DB Not Found Error: BLOCK: DN5cUfsq…"
84
+ *
85
+ * A HEIGHT that resolves to nothing means no block exists. A HASH means the
86
+ * node resolved the height to a block and only lacks the body.
87
+ *
88
+ * FAILS SAFE. This matches a nearcore error string, which is not a stable
89
+ * API. If that wording changes, the match fails and the height is recorded
90
+ * as a hole — degrading to the old behaviour, which overstates gaps. The
91
+ * opposite error, overstating coverage, is the one that matters, and no
92
+ * change to this string can produce it.
93
+ */
94
+ export declare function isAbsentHeight(e: unknown): boolean;
50
95
  export interface BlockedRecord {
51
96
  requestId: string;
52
97
  nodeId: number;
@@ -63,6 +108,14 @@ export interface ScanResult {
63
108
  chunksSkipped: number;
64
109
  /** Heights that could not be read. Coverage inside a hole is unknown. */
65
110
  holes: number[];
111
+ /**
112
+ * Heights where NEAR never produced a block, so there was nothing to read.
113
+ *
114
+ * NOT holes. Counted separately so a scan can still SAY how many it met —
115
+ * silently dropping them would make "1,604 blocks, 1,596 seen" look like
116
+ * an arithmetic error with no explanation on screen.
117
+ */
118
+ absentHeights: number[];
66
119
  }
67
120
  /**
68
121
  * Pull `request_blocked` events out of one transaction's receipt logs.
@@ -99,6 +152,31 @@ export interface BlockedStore {
99
152
  /** Merge overlapping/adjacent spans so the list stays short and readable. */
100
153
  export declare function coalesce(spans: Hole[]): Hole[];
101
154
  export declare function emptyStore(vault: string): BlockedStore;
155
+ /**
156
+ * Decide whether a persisted log belongs to the vault being served.
157
+ *
158
+ * CAUGHT ON A LIVE DASHBOARD. The adapter was pointed at a vault with exactly
159
+ * ONE node, and the activity page showed six refusals attributed to nodes 5
160
+ * through 20, five days old, by agents named `s*.v3.floatvault-12329.testnet`
161
+ * — real refusals from a DIFFERENT vault, left in `.demo/blocked-log.json` by
162
+ * an earlier swarm test. The loader read `parsed.vault ?? env.vault` and never
163
+ * compared the two, so any vault's log loaded into any other vault's
164
+ * dashboard.
165
+ *
166
+ * Not merely cosmetic. The store also carries `scannedFrom`/`scannedTo`, so
167
+ * adopting a foreign log makes the follower believe it has COVERAGE of block
168
+ * ranges it never scanned for this vault — collapsing "we looked and found
169
+ * nothing" back into "we never looked here", which is the one distinction this
170
+ * module exists to preserve.
171
+ *
172
+ * Refusing a payment is this product's headline behaviour, so showing another
173
+ * vault's refusals as yours is worse than showing none.
174
+ *
175
+ * A log with NO vault recorded predates the field. That one is adopted — it is
176
+ * almost certainly ours, written before the field existed — but adopted under
177
+ * THIS vault's name, so the next load can check it.
178
+ */
179
+ export declare function adoptBlockedStore(parsed: BlockedStore, vault: string): BlockedStore;
102
180
  /** Fold a scan result into a store. Pure, so the merge rules are testable. */
103
181
  export declare function mergeScan(store: BlockedStore, from: number, to: number, res: ScanResult): {
104
182
  store: BlockedStore;
@@ -71,14 +71,88 @@ export function createRpc(url, opts = {}) {
71
71
  await sleep(wait);
72
72
  return rpc(method, params, attempt + 1);
73
73
  }
74
+ /**
75
+ * READ THE BODY BEFORE JUDGING THE STATUS.
76
+ *
77
+ * NEAR answers a handler error with HTTP 422 AND a complete JSON-RPC
78
+ * error body. The previous order — `if (!r.ok) throw` first — threw a
79
+ * bare `RPC HTTP 422` and never looked, so every caller saw a failure
80
+ * with no idea what kind.
81
+ *
82
+ * That silently defeated the first version of `isAbsentHeight`: its
83
+ * unit tests passed, because they threw errors with the right shape by
84
+ * hand, and against the real chain it never matched once. Correct and
85
+ * unreachable. Measured: block 276175670 on fastnear's archival
86
+ * endpoint returns 422 with `cause.name = "UNKNOWN_BLOCK"`.
87
+ *
88
+ * `text()` then parse, not `json()`: a proxy or gateway error is HTML,
89
+ * and `json()` would throw over the status this is trying to report.
90
+ */
91
+ const raw = await r.text();
92
+ let j = null;
93
+ try {
94
+ j = JSON.parse(raw);
95
+ }
96
+ catch {
97
+ // Not JSON. The status is all there is to say.
98
+ }
99
+ if (j?.error) {
100
+ // THE ERROR TRAVELS AS DATA, not only as a message. A caller that
101
+ // needs to tell one failure from another should not be parsing a
102
+ // string that was built for a human and truncated to 140 characters.
103
+ // `scanRange` needs exactly that distinction — see `isAbsentHeight`.
104
+ const err = new Error(`${method}: ${JSON.stringify(j.error).slice(0, 140)}`);
105
+ err.rpcError = j.error;
106
+ throw err;
107
+ }
74
108
  if (!r.ok)
75
109
  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;
110
+ return j?.result;
80
111
  };
81
112
  }
113
+ /**
114
+ * Did this failure mean "no block was ever produced at that height"?
115
+ *
116
+ * NEAR gives every slot a height, and when a producer misses its slot that
117
+ * height never gets a block. Measured on testnet: 8 of the 1,604 heights in
118
+ * one real scan, about 0.5%. Those heights are not gaps in coverage — there
119
+ * is nothing at them to read, by anyone, ever.
120
+ *
121
+ * The scanner used to record them as unread, so every scan of every vault
122
+ * reported holes that could never be filled and the dashboard said "refusal
123
+ * history is incomplete" permanently, for a reason that was not real.
124
+ *
125
+ * `UNKNOWN_BLOCK` ALONE IS NOT ENOUGH, and this is the trap. A non-archival
126
+ * node returns the same `UNKNOWN_BLOCK` for a block that genuinely exists
127
+ * but has been garbage-collected — measured against the default testnet RPC,
128
+ * which returned it for every height in that same scan, including ones the
129
+ * scanner had read successfully. Treating that as "nothing there" would
130
+ * claim coverage of a range never read, which is the exact failure this
131
+ * subsystem exists to prevent.
132
+ *
133
+ * The two are distinguishable by what the node could resolve:
134
+ *
135
+ * never produced data: "DB Not Found Error: BLOCK HEIGHT: 276175670"
136
+ * node lacks it data: "DB Not Found Error: BLOCK: DN5cUfsq…"
137
+ *
138
+ * A HEIGHT that resolves to nothing means no block exists. A HASH means the
139
+ * node resolved the height to a block and only lacks the body.
140
+ *
141
+ * FAILS SAFE. This matches a nearcore error string, which is not a stable
142
+ * API. If that wording changes, the match fails and the height is recorded
143
+ * as a hole — degrading to the old behaviour, which overstates gaps. The
144
+ * opposite error, overstating coverage, is the one that matters, and no
145
+ * change to this string can produce it.
146
+ */
147
+ export function isAbsentHeight(e) {
148
+ const err = e;
149
+ const rpc = err?.rpcError;
150
+ if (!rpc)
151
+ return false;
152
+ if (rpc.cause?.name !== "UNKNOWN_BLOCK")
153
+ return false;
154
+ return typeof rpc.data === "string" && /BLOCK HEIGHT:/i.test(rpc.data);
155
+ }
82
156
  /**
83
157
  * Pull `request_blocked` events out of one transaction's receipt logs.
84
158
  * Exported for testing: this parsing is the part that silently breaks if the
@@ -121,14 +195,22 @@ export async function scanRange(rpc, vault, from, to) {
121
195
  chunksFetched: 0,
122
196
  chunksSkipped: 0,
123
197
  holes: [],
198
+ absentHeights: [],
124
199
  };
125
200
  for (let h = from; h <= to; h++) {
126
201
  let blk;
127
202
  try {
128
203
  blk = await rpc("block", { block_id: h });
129
204
  }
130
- catch {
131
- res.holes.push(h);
205
+ catch (e) {
206
+ // A height NEAR never produced is not a gap in coverage: there is
207
+ // nothing at it to read. Anything else is a genuine failure and stays
208
+ // a hole. See `isAbsentHeight` for why UNKNOWN_BLOCK alone is not
209
+ // enough to tell those apart.
210
+ if (isAbsentHeight(e))
211
+ res.absentHeights.push(h);
212
+ else
213
+ res.holes.push(h);
132
214
  continue;
133
215
  }
134
216
  res.blocksSeen++;
@@ -188,6 +270,41 @@ export function coalesce(spans) {
188
270
  export function emptyStore(vault) {
189
271
  return { vault, scannedFrom: null, scannedTo: null, holes: [], blocked: {} };
190
272
  }
273
+ /**
274
+ * Decide whether a persisted log belongs to the vault being served.
275
+ *
276
+ * CAUGHT ON A LIVE DASHBOARD. The adapter was pointed at a vault with exactly
277
+ * ONE node, and the activity page showed six refusals attributed to nodes 5
278
+ * through 20, five days old, by agents named `s*.v3.floatvault-12329.testnet`
279
+ * — real refusals from a DIFFERENT vault, left in `.demo/blocked-log.json` by
280
+ * an earlier swarm test. The loader read `parsed.vault ?? env.vault` and never
281
+ * compared the two, so any vault's log loaded into any other vault's
282
+ * dashboard.
283
+ *
284
+ * Not merely cosmetic. The store also carries `scannedFrom`/`scannedTo`, so
285
+ * adopting a foreign log makes the follower believe it has COVERAGE of block
286
+ * ranges it never scanned for this vault — collapsing "we looked and found
287
+ * nothing" back into "we never looked here", which is the one distinction this
288
+ * module exists to preserve.
289
+ *
290
+ * Refusing a payment is this product's headline behaviour, so showing another
291
+ * vault's refusals as yours is worse than showing none.
292
+ *
293
+ * A log with NO vault recorded predates the field. That one is adopted — it is
294
+ * almost certainly ours, written before the field existed — but adopted under
295
+ * THIS vault's name, so the next load can check it.
296
+ */
297
+ export function adoptBlockedStore(parsed, vault) {
298
+ if (parsed.vault && parsed.vault !== vault)
299
+ return emptyStore(vault);
300
+ return {
301
+ vault,
302
+ scannedFrom: parsed.scannedFrom ?? null,
303
+ scannedTo: parsed.scannedTo ?? null,
304
+ holes: Array.isArray(parsed.holes) ? parsed.holes : [],
305
+ blocked: parsed.blocked ?? {},
306
+ };
307
+ }
191
308
  /** Fold a scan result into a store. Pure, so the merge rules are testable. */
192
309
  export function mergeScan(store, from, to, res) {
193
310
  let added = 0;
@@ -197,10 +314,47 @@ export function mergeScan(store, from, to, res) {
197
314
  store.blocked[r.requestId] = r;
198
315
  added++;
199
316
  }
317
+ // THE GAP BETWEEN DISJOINT SCANS IS A HOLE, and nothing but this merge can
318
+ // see it. A single `scanRange` reports the blocks IT could not read; it has
319
+ // no idea another range was scanned 54,000 blocks away.
320
+ //
321
+ // Without this, min/max over the two ranges claims the whole envelope as
322
+ // covered. Observed live: 1,500 blocks scanned, then 160 blocks scanned
323
+ // 54,300 later, and the store reported `scannedFrom 275882255, scannedTo
324
+ // 275938214, holes: []` — 55,960 blocks claimed, 1,660 actually read. The
325
+ // dashboard caveat written to prevent precisely this confusion would then
326
+ // have rendered the fabrication as fact.
327
+ const newHoles = [];
328
+ if (store.scannedFrom !== null && store.scannedTo !== null) {
329
+ // Scanning forward, away from the existing range.
330
+ if (from > store.scannedTo + 1)
331
+ newHoles.push({ from: store.scannedTo + 1, to: from - 1 });
332
+ // Scanning backward, leaving the gap above the new range.
333
+ if (to < store.scannedFrom - 1)
334
+ newHoles.push({ from: to + 1, to: store.scannedFrom - 1 });
335
+ }
200
336
  store.scannedFrom = store.scannedFrom === null ? from : Math.min(store.scannedFrom, from);
201
337
  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 }))]);
338
+ // CLEAR FIRST, THEN ADD. Order matters and getting it wrong is silent.
339
+ //
340
+ // Scanning a span that was previously a hole must REMOVE it, or a backfill
341
+ // that genuinely read those blocks leaves coverage understated forever.
342
+ // But this subtraction has to run BEFORE this scan's own holes are added —
343
+ // a scanner hole is by definition INSIDE the range just scanned, so doing it
344
+ // the other way round deletes the holes this very call just reported. The
345
+ // pre-existing "carries holes forward" test caught exactly that.
346
+ if (store.holes.length) {
347
+ store.holes = coalesce(store.holes.flatMap((h) => {
348
+ if (to < h.from || from > h.to)
349
+ return [h]; // untouched by this scan
350
+ const left = h.from < from ? [{ from: h.from, to: Math.min(h.to, from - 1) }] : [];
351
+ const right = h.to > to ? [{ from: Math.max(h.from, to + 1), to: h.to }] : [];
352
+ return [...left, ...right];
353
+ }));
354
+ }
355
+ const scannerHoles = res.holes.map((h) => ({ from: h, to: h }));
356
+ if (newHoles.length || scannerHoles.length) {
357
+ store.holes = coalesce([...store.holes, ...newHoles, ...scannerHoles]);
204
358
  }
205
359
  return { store, added };
206
360
  }
@@ -23,6 +23,22 @@
23
23
  * just to start the adapter locally.
24
24
  *
25
25
  * One parser, tested, used by both.
26
+ *
27
+ * ---- WHERE THIS ARGUMENT STOPS
28
+ *
29
+ * Everything above is about how a secrets file is LOADED: as data, never as
30
+ * something a shell executes. It says nothing about whether the secret should
31
+ * be sitting in an env value in the first place — and it should not. Reading
32
+ * this file to check which vault is configured is routine, and it prints a live
33
+ * private key into a terminal scrollback and into the transcript of whatever
34
+ * agent is driving.
35
+ *
36
+ * So the keys named above have a second, preferred form: NEAR_VAULT_AGENT_KEY
37
+ * and FLOAT_QUOTE_SIGNER_KEY each accept a `*_FILE` variant holding a PATH.
38
+ * The reasoning, the accepted file shapes, and why a broken path must never
39
+ * fall back to the inline value all live in ONE place rather than being
40
+ * restated here — `@floatonnear/mcp`'s `key-file.ts`. The two variables still
41
+ * travel through this parser; it is only their VALUES that stop being secrets.
26
42
  */
27
43
  /**
28
44
  * Parse `KEY=value` lines into a plain object.
package/dist/env-file.js CHANGED
@@ -23,6 +23,22 @@
23
23
  * just to start the adapter locally.
24
24
  *
25
25
  * One parser, tested, used by both.
26
+ *
27
+ * ---- WHERE THIS ARGUMENT STOPS
28
+ *
29
+ * Everything above is about how a secrets file is LOADED: as data, never as
30
+ * something a shell executes. It says nothing about whether the secret should
31
+ * be sitting in an env value in the first place — and it should not. Reading
32
+ * this file to check which vault is configured is routine, and it prints a live
33
+ * private key into a terminal scrollback and into the transcript of whatever
34
+ * agent is driving.
35
+ *
36
+ * So the keys named above have a second, preferred form: NEAR_VAULT_AGENT_KEY
37
+ * and FLOAT_QUOTE_SIGNER_KEY each accept a `*_FILE` variant holding a PATH.
38
+ * The reasoning, the accepted file shapes, and why a broken path must never
39
+ * fall back to the inline value all live in ONE place rather than being
40
+ * restated here — `@floatonnear/mcp`'s `key-file.ts`. The two variables still
41
+ * travel through this parser; it is only their VALUES that stop being secrets.
26
42
  */
27
43
  /**
28
44
  * Parse `KEY=value` lines into a plain object.
package/dist/gate.d.ts CHANGED
@@ -26,19 +26,23 @@ export type AuthVerdict = {
26
26
  error: string;
27
27
  };
28
28
  /**
29
- * Only mutating requests need the token.
29
+ * Reads are open; mutating routes need the token unless they sign nothing.
30
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.
31
+ * OPTIONS must pass or the browser never reaches the real request and the
32
+ * dashboard breaks for a reason nobody can see.
34
33
  *
35
34
  * A MISSING token is 503, not 401. They are different problems: 401 tells a
36
35
  * caller to go and find a credential, and here there is none to find — the
37
36
  * operator has not configured one. Conflating them sends people hunting for a
38
37
  * secret that does not exist.
38
+ *
39
+ * `path` is OPTIONAL and an absent one is GATED, never open. A caller that
40
+ * forgets to pass it gets the safe answer; the alternative defaults to
41
+ * exposure, which is how the `0.0.0.0` bind above happened.
39
42
  */
40
- export declare function authorize({ method, authHeader, token, }: {
43
+ export declare function authorize({ method, path, authHeader, token, }: {
41
44
  method: string;
45
+ path?: string | undefined;
42
46
  authHeader: string | undefined;
43
47
  token: string | undefined;
44
48
  }): AuthVerdict;
package/dist/gate.js CHANGED
@@ -51,20 +51,47 @@ export function corsHeaders(allowedOrigin, requestOrigin) {
51
51
  return base;
52
52
  }
53
53
  /**
54
- * Only mutating requests need the token.
54
+ * Mutating routes that sign NOTHING, and may therefore work without a token.
55
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.
56
+ * AN ALLOWLIST, AND THAT DIRECTION IS THE SAFETY ARGUMENT. Gating used to be
57
+ * by METHOD, so every non-GET needed the token — which cannot exist on a
58
+ * public box, because `apps/dashboard/app/api/float/[...path]/route.ts`
59
+ * attaches it to any mutating request WITHOUT authenticating the caller. A
60
+ * hosted dashboard with a token set is an open `/v1/pay` for the internet,
61
+ * bounded only by the vault's caps.
62
+ *
63
+ * `/v1/owner-plan` qualifies: it returns an UNSIGNED payload and holds no key.
64
+ * The owner approving it in their own wallet IS the authority, so a token adds
65
+ * nothing an attacker could not already do by building the payload themselves.
66
+ *
67
+ * A DENYLIST OF `/v1/pay` WOULD BE WRONG. The next money-moving route would be
68
+ * born public and nobody would notice until it was exploited. Named-open means
69
+ * a new route is born shut and someone has to argue it open in a visible diff.
70
+ */
71
+ const SIGNS_NOTHING = new Set(["/v1/owner-plan"]);
72
+ /**
73
+ * Reads are open; mutating routes need the token unless they sign nothing.
74
+ *
75
+ * OPTIONS must pass or the browser never reaches the real request and the
76
+ * dashboard breaks for a reason nobody can see.
59
77
  *
60
78
  * A MISSING token is 503, not 401. They are different problems: 401 tells a
61
79
  * caller to go and find a credential, and here there is none to find — the
62
80
  * operator has not configured one. Conflating them sends people hunting for a
63
81
  * secret that does not exist.
82
+ *
83
+ * `path` is OPTIONAL and an absent one is GATED, never open. A caller that
84
+ * forgets to pass it gets the safe answer; the alternative defaults to
85
+ * exposure, which is how the `0.0.0.0` bind above happened.
64
86
  */
65
- export function authorize({ method, authHeader, token, }) {
87
+ export function authorize({ method, path, authHeader, token, }) {
66
88
  if (method === "GET" || method === "OPTIONS" || method === "HEAD")
67
89
  return { ok: true };
90
+ // EXACT match only. A prefix or case variant must not inherit the exemption:
91
+ // `/v1/owner-plan/extra` is a different route and may not exist yet, and
92
+ // NEAR paths are not case-folded anywhere else in this server.
93
+ if (path !== undefined && SIGNS_NOTHING.has(path))
94
+ return { ok: true };
68
95
  if (!token) {
69
96
  return {
70
97
  ok: false,