@floatonnear/float-adapter 0.2.1 → 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.
- package/dist/blocked-scan.d.ts +78 -0
- package/dist/blocked-scan.js +162 -8
- package/dist/env-file.d.ts +16 -0
- package/dist/env-file.js +16 -0
- package/dist/gate.d.ts +9 -5
- package/dist/gate.js +32 -5
- package/dist/handlers.d.ts +176 -3
- package/dist/handlers.js +262 -29
- package/dist/keyring.d.ts +81 -2
- package/dist/keyring.js +80 -10
- package/dist/map.d.ts +56 -11
- package/dist/map.js +43 -21
- package/dist/owner-plan.d.ts +112 -0
- package/dist/owner-plan.js +112 -0
- package/dist/server.d.ts +10 -1
- package/dist/server.js +461 -53
- package/dist/unavailable.d.ts +31 -0
- package/dist/unavailable.js +90 -0
- package/package.json +2 -2
package/dist/blocked-scan.d.ts
CHANGED
|
@@ -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;
|
package/dist/blocked-scan.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
}
|
package/dist/env-file.d.ts
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/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
|
-
*
|
|
29
|
+
* Reads are open; mutating routes need the token unless they sign nothing.
|
|
30
30
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
-
*
|
|
54
|
+
* Mutating routes that sign NOTHING, and may therefore work without a token.
|
|
55
55
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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,
|