@artblocks/abx-token-api 0.1.0-alpha.4 → 0.1.0-alpha.40
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/CHANGELOG.md +63 -0
- package/dist/code.d.ts +33 -12
- package/dist/code.d.ts.map +1 -1
- package/dist/code.js +68 -56
- package/dist/code.js.map +1 -1
- package/dist/{art.d.ts → content.d.ts} +5 -5
- package/dist/content.d.ts.map +1 -0
- package/dist/{art.js → content.js} +6 -6
- package/dist/content.js.map +1 -0
- package/dist/control-plane.d.ts +89 -0
- package/dist/control-plane.d.ts.map +1 -0
- package/dist/control-plane.js +768 -0
- package/dist/control-plane.js.map +1 -0
- package/dist/dashboard.d.ts.map +1 -1
- package/dist/dashboard.js +60 -11
- package/dist/dashboard.js.map +1 -1
- package/dist/deps.d.ts +28 -110
- package/dist/deps.d.ts.map +1 -1
- package/dist/deps.js +34 -184
- package/dist/deps.js.map +1 -1
- package/dist/index.d.ts +8 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -11
- package/dist/index.js.map +1 -1
- package/dist/metadata.d.ts +87 -4
- package/dist/metadata.d.ts.map +1 -1
- package/dist/metadata.js +343 -105
- package/dist/metadata.js.map +1 -1
- package/dist/resolve.d.ts +0 -2
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +0 -5
- package/dist/resolve.js.map +1 -1
- package/dist/server.d.ts +4 -27
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +310 -421
- package/dist/server.js.map +1 -1
- package/dist/watcher.d.ts +1 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +137 -8
- package/dist/watcher.js.map +1 -1
- package/package.json +9 -8
- package/dist/abxjs.d.ts +0 -13
- package/dist/abxjs.d.ts.map +0 -1
- package/dist/abxjs.js +0 -49
- package/dist/abxjs.js.map +0 -1
- package/dist/art.d.ts.map +0 -1
- package/dist/art.js.map +0 -1
- package/dist/inline.d.ts +0 -19
- package/dist/inline.d.ts.map +0 -1
- package/dist/inline.js +0 -23
- package/dist/inline.js.map +0 -1
package/dist/server.js
CHANGED
|
@@ -1,24 +1,46 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
|
-
import {
|
|
3
|
-
import { discoverDeployBlock, fieldOf, makePublicClient, normalizeAttributes, renderArtifactKey, resolveChain, DEFAULT_CHAIN_KEY, verifyAgainstHash, METADATA_FIELD as F, METADATA_REPRESENTATION as R, } from '@artblocks/abx-sdk';
|
|
2
|
+
import { ABX_JS, fieldOf, makePublicClient, normalizeAttributes, renderArtifactKey, resolveChain, DEFAULT_CHAIN_KEY, verifyAgainstHash, METADATA_FIELD as F, METADATA_REPRESENTATION as R, gatewayConfigFromEnv, projectGatewayPrefix, projectGatewayUrl, } from '@artblocks/abx-sdk';
|
|
4
3
|
import { resolveBackend } from '@artblocks/abx-storage';
|
|
5
|
-
import { fallbackImageSvg, IMAGE_MEDIA_TYPE } from './
|
|
6
|
-
import { buildContractMetadata, buildTokenMetadata, fieldMimeType } from './metadata.js';
|
|
7
|
-
import { currentRenderArtifact, currentSettledInputsHash, isCodeProject, resolveLiveView, resolveLocatorUrl } from './code.js';
|
|
4
|
+
import { fallbackImageSvg, IMAGE_MEDIA_TYPE } from './content.js';
|
|
5
|
+
import { buildContractMetadata, buildTokenMetadata, fieldMimeType, tokenArtifacts } from './metadata.js';
|
|
6
|
+
import { currentRenderArtifact, currentSettledInputsHash, isCodeProject, liveViewAvailability, resolveLiveView, resolveLocatorUrl } from './code.js';
|
|
8
7
|
import { depStatusReport } from './deps.js';
|
|
9
|
-
import { ABX_JS } from './abxjs.js';
|
|
10
8
|
import { resolveFieldBytes, resolveFieldRendered, COLLECTION_TOKEN_ID } from './resolve.js';
|
|
11
9
|
import { renderDashboard, renderIndex } from './dashboard.js';
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
10
|
+
import { watchIntervalMs } from './watcher.js';
|
|
11
|
+
import { requireBearer, routeControlPlane, safeLocators, sendError, sendJson, serviceDescriptor, summarize, } from './control-plane.js';
|
|
12
|
+
// EVERYTHING chain-derived in this module is resolved LAZILY, on first use.
|
|
13
|
+
//
|
|
14
|
+
// It used to be three module-scope consts, and that made an invalid `ABX_CHAIN` catastrophic in a
|
|
15
|
+
// way wildly out of proportion to the mistake: the CLI imports this package (for the generator
|
|
16
|
+
// runtime), so the throw happened during module evaluation — before `main()` existed to catch it.
|
|
17
|
+
// `ABX_CHAIN=mainnet abx doctor` printed a raw Node stack trace with an internal source path and
|
|
18
|
+
// exited 1, as did commands that should diagnose the environment. Deferring the work to first use
|
|
19
|
+
// keeps a bad value an ordinary, catchable error.
|
|
20
|
+
let _chainClient = null;
|
|
21
|
+
/** A read-only client for resolving on-chain `reader`-represented content (eth_call). */
|
|
22
|
+
function chainClientLazy() {
|
|
23
|
+
return (_chainClient ??= makePublicClient());
|
|
24
|
+
}
|
|
25
|
+
let _serverChainId = null;
|
|
26
|
+
/**
|
|
27
|
+
* The chain this resolver serves. The path grammar carries the chainId
|
|
28
|
+
* (`/t/{chainId}/{address}/{tokenId}`), so a single host can serve many chains and reject paths for
|
|
29
|
+
* chains it doesn't index. Today one resolver = one chain; this gates that.
|
|
30
|
+
*/
|
|
31
|
+
function serverChainId() {
|
|
32
|
+
return (_serverChainId ??= resolveChain(process.env.ABX_CHAIN).id);
|
|
33
|
+
}
|
|
34
|
+
/** The chain *key* ('sepolia', …) the indexer registers projects under — the string form of the same
|
|
35
|
+
* chain {@link serverChainId} identifies. MUST match its default (base-sepolia): a stale 'sepolia'
|
|
36
|
+
* once desynced the key from the id. */
|
|
37
|
+
function serverChainKey() {
|
|
38
|
+
return process.env.ABX_CHAIN ?? DEFAULT_CHAIN_KEY;
|
|
39
|
+
}
|
|
40
|
+
/** The context the /v1 control plane + descriptor run against (control-plane.ts owns the routes). */
|
|
41
|
+
function controlPlaneCtx(indexer, storage, baseUrl) {
|
|
42
|
+
return { indexer, storage, chainId: serverChainId(), chainKey: serverChainKey(), baseUrl };
|
|
43
|
+
}
|
|
22
44
|
/**
|
|
23
45
|
* Resolve a token's image bytes by dispatching on the `image` field's single active
|
|
24
46
|
* representation (token scope first, else the collection-wide field — the same fallback the
|
|
@@ -31,14 +53,14 @@ const SERVER_CHAIN_KEY = process.env.ABX_CHAIN ?? DEFAULT_CHAIN_KEY; // MUST mat
|
|
|
31
53
|
*/
|
|
32
54
|
export async function resolveContent(state, token, storage) {
|
|
33
55
|
const image = fieldOf(token.fields, F.image) ?? fieldOf(state.collectionFields, F.image);
|
|
34
|
-
const onChain = await resolveFieldBytes(
|
|
56
|
+
const onChain = await resolveFieldBytes(chainClientLazy(), image); // inline / inline-gzip / reader / reader-gzip
|
|
35
57
|
if (onChain)
|
|
36
58
|
return { contentType: IMAGE_MEDIA_TYPE, body: onChain };
|
|
37
59
|
// computed on-chain at read (`renderer`) — best-effort: a reverting renderer degrades to the
|
|
38
60
|
// placeholder rather than erroring the route (mirrors the on-chain renderer's fallback rule).
|
|
39
61
|
if (image?.representation === R.renderer) {
|
|
40
62
|
try {
|
|
41
|
-
const rendered = await resolveFieldRendered(
|
|
63
|
+
const rendered = await resolveFieldRendered(chainClientLazy(), state.address, token.tokenId, F.image, image);
|
|
42
64
|
if (rendered)
|
|
43
65
|
return { contentType: rendered.contentType, body: rendered.bytes };
|
|
44
66
|
}
|
|
@@ -56,7 +78,11 @@ export async function resolveContent(state, token, storage) {
|
|
|
56
78
|
// so a token with no resolvable image looks the same whether served here or self-resolved.
|
|
57
79
|
return { contentType: IMAGE_MEDIA_TYPE, body: fallbackImageSvg(state.address, token.tokenId) };
|
|
58
80
|
}
|
|
59
|
-
/** Is `tokenId` a valid, not-yet-minted position within a
|
|
81
|
+
/** Is `tokenId` a valid, not-yet-minted position within a multi-token contract's id-space cap
|
|
82
|
+
* (`0 <= id < N`)? `maxInvocations` caps the id space the same way for a Series and its edition
|
|
83
|
+
* twins (EditionImage/EditionCode) — "number of distinct works" is unchanged by copies-per-id
|
|
84
|
+
* — so this needs no contractType branch. A '1of1'/'1of1-edition' has no cap at all (id space
|
|
85
|
+
* fixed to {0}); that token gets its pre-mint view a different way — see {@link resolveTokenView}. */
|
|
60
86
|
function withinCap(tokenId, maxInvocations) {
|
|
61
87
|
if (maxInvocations == null)
|
|
62
88
|
return false;
|
|
@@ -68,11 +94,38 @@ function withinCap(tokenId, maxInvocations) {
|
|
|
68
94
|
return false;
|
|
69
95
|
}
|
|
70
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* A destroyed token gets `410 Gone`, and every token route answers it before doing any other work.
|
|
99
|
+
*
|
|
100
|
+
* The rule is the contract's, not ours: a burned ERC-721's `tokenURI` reverts `NonexistentToken`
|
|
101
|
+
* (`TokenURI.sol`), so serving a metadata document for that id would put this node in contradiction
|
|
102
|
+
* with the contract it speaks for. `404` would be wrong twice over — it reads as "wrong URL / not
|
|
103
|
+
* indexed yet", inviting a retry that can never succeed, and on `/image` an in-cap unknown id gets
|
|
104
|
+
* the *warming placeholder*, so a destroyed token would say "still loading" forever.
|
|
105
|
+
*
|
|
106
|
+
* **There is deliberately no `contractType` check here.** An edition must never `410` — its `uri(id)`
|
|
107
|
+
* has no existence gate and a zero-supply id can mint again — and the way that is guaranteed is that
|
|
108
|
+
* `lifecycle` cannot *be* `'burned'` on that standard (it folds to `'no-live-copies'`). The first
|
|
109
|
+
* version of this function carried an `isEditionState` guard beside the check, which meant the rule
|
|
110
|
+
* lived in two places and held only as long as everyone remembered the second one. It is in the type
|
|
111
|
+
* now: `'burned'` means permanent, on either standard, and this route needs to know nothing else.
|
|
112
|
+
*
|
|
113
|
+
* Returns `true` when it has answered the request.
|
|
114
|
+
*/
|
|
115
|
+
function goneIfBurned(res, token) {
|
|
116
|
+
if (token.lifecycle !== 'burned')
|
|
117
|
+
return false;
|
|
118
|
+
sendError(res, 410, 'burned', `token ${token.tokenId} was burned — it no longer exists on chain`, { burned: true });
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
71
121
|
/**
|
|
72
122
|
* The view to resolve for a requested tokenId. A token's metadata is its token id (no
|
|
73
123
|
* decoupling), so identity and content both come from that token. Returns a synthesized,
|
|
74
124
|
* unminted view for a not-yet-minted id within the cap (pre-mint warming — the multi-token
|
|
75
|
-
* analogue of the 1/1's seed-token-0
|
|
125
|
+
* analogue of the 1/1's seed-token-0, which the SDK's fold seeds unconditionally — see
|
|
126
|
+
* `reconstruct.ts`'s `assembleState`, so a fresh '1of1'/'1of1-edition' always has an `issued`
|
|
127
|
+
* entry for id 0 despite carrying no `maxInvocations` cap), or `null` when the id is genuinely
|
|
128
|
+
* unknown (→ 404).
|
|
76
129
|
*/
|
|
77
130
|
export function resolveTokenView(state, tokenId) {
|
|
78
131
|
const issued = state.tokens.find((t) => t.tokenId === tokenId);
|
|
@@ -80,12 +133,19 @@ export function resolveTokenView(state, tokenId) {
|
|
|
80
133
|
return null;
|
|
81
134
|
return {
|
|
82
135
|
tokenId,
|
|
83
|
-
|
|
136
|
+
lifecycle: issued?.lifecycle ?? 'unminted',
|
|
84
137
|
owner: issued?.owner ?? null,
|
|
85
138
|
tokenURI: issued?.tokenURI ?? null,
|
|
86
139
|
fields: issued?.fields ?? [],
|
|
87
140
|
lockedFields: issued?.lockedFields ?? [],
|
|
88
141
|
params: issued?.params,
|
|
142
|
+
// ERC-1155 editions only — absent on `issued` (a 721 token, or an edition id the fold never
|
|
143
|
+
// touched) stays absent here too; dropping them was a real bug (found while generalizing this
|
|
144
|
+
// route for editions): every synthesized/passthrough view silently lost supply/cap/holders.
|
|
145
|
+
supply: issued?.supply,
|
|
146
|
+
maxSupply: issued?.maxSupply,
|
|
147
|
+
maxSupplyOverridden: issued?.maxSupplyOverridden,
|
|
148
|
+
holders: issued?.holders,
|
|
89
149
|
};
|
|
90
150
|
}
|
|
91
151
|
export const DEFAULT_PORT = 8787;
|
|
@@ -130,10 +190,32 @@ export function createTokenApiServer(opts) {
|
|
|
130
190
|
await route(req, res, indexer, baseUrl, storage);
|
|
131
191
|
}
|
|
132
192
|
catch (err) {
|
|
133
|
-
|
|
193
|
+
// NEVER hand a raw internal error to the client. An unexpected failure here is usually an
|
|
194
|
+
// upstream RPC error, and viem's message embeds the full endpoint URL — which for a keyed
|
|
195
|
+
// endpoint IS a credential. On a multi-tenant provider that would leak the operator's RPC key
|
|
196
|
+
// to any tenant who can trigger a 500 (see site/content/docs/using-abx/remote-services.mdx). So:
|
|
197
|
+
// full detail to the operator's log, a generic + redacted message on the wire.
|
|
198
|
+
const detail = err.message ?? 'unknown error';
|
|
199
|
+
console.error(`[server] ${req.method} ${req.url} failed: ${detail}`);
|
|
200
|
+
sendJson(res, 500, {
|
|
201
|
+
error: `internal error serving ${req.url ?? '/'} — see the node's logs for detail${upstreamHint(detail)}`,
|
|
202
|
+
code: 'internal_error',
|
|
203
|
+
});
|
|
134
204
|
}
|
|
135
205
|
});
|
|
136
206
|
}
|
|
207
|
+
/** A safe, credential-free hint about WHAT class of thing broke, so a caller isn't left blind by the
|
|
208
|
+
* generic 500. Recognizes the common upstream-RPC failures by status text only — never echoes the
|
|
209
|
+
* message (which can embed a keyed endpoint URL). */
|
|
210
|
+
function upstreamHint(detail) {
|
|
211
|
+
if (/\b429\b|Too Many Requests|rate limit/i.test(detail))
|
|
212
|
+
return ' (upstream RPC rate-limited this node — its operator needs a higher-capacity endpoint)';
|
|
213
|
+
if (/\b(401|403)\b|Unauthorized|Forbidden/i.test(detail))
|
|
214
|
+
return " (this node's upstream RPC rejected its credentials)";
|
|
215
|
+
if (/\b5\d\d\b|ECONNREFUSED|ETIMEDOUT|fetch failed/i.test(detail))
|
|
216
|
+
return " (this node's upstream RPC is unreachable or erroring)";
|
|
217
|
+
return '';
|
|
218
|
+
}
|
|
137
219
|
export function startTokenApiServer(opts) {
|
|
138
220
|
const port = opts.port ?? Number(process.env.ABX_PORT ?? DEFAULT_PORT);
|
|
139
221
|
const baseUrl = opts.baseUrl ?? resolveBaseUrl(port);
|
|
@@ -147,27 +229,44 @@ async function route(req, res, indexer, baseUrl, storage) {
|
|
|
147
229
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
148
230
|
const parts = url.pathname.split('/').filter(Boolean);
|
|
149
231
|
const method = req.method ?? 'GET';
|
|
232
|
+
// CORS preflight — without this, a browser client can never send Authorization to the
|
|
233
|
+
// control plane (the wildcard origin above covers simple GETs only).
|
|
234
|
+
if (method === 'OPTIONS') {
|
|
235
|
+
res.writeHead(204, {
|
|
236
|
+
'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS',
|
|
237
|
+
'access-control-allow-headers': 'authorization,content-type',
|
|
238
|
+
'access-control-max-age': '86400',
|
|
239
|
+
});
|
|
240
|
+
res.end();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
150
243
|
// GET / — read-only node index: which contracts this resolver serves (no actions).
|
|
151
244
|
if (parts.length === 0) {
|
|
152
245
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
153
|
-
res.end(renderIndex(indexer.listProjects(), baseUrl,
|
|
246
|
+
res.end(renderIndex(indexer.listProjects(), baseUrl, serverChainId()));
|
|
154
247
|
return;
|
|
155
248
|
}
|
|
156
249
|
// GET /d/:chainId/:addr — the per-contract dashboard (read-only; namespaced so one host can
|
|
157
250
|
// serve many contracts/chains). Actions live behind the admin token, not on this page.
|
|
158
251
|
if (parts[0] === 'd' && parts[1] && parts[2]) {
|
|
159
|
-
if (Number(parts[1]) !==
|
|
252
|
+
if (Number(parts[1]) !== serverChainId())
|
|
160
253
|
return wrongChain(res, parts[1]);
|
|
161
254
|
const state = indexer.getProject(parts[2]);
|
|
162
255
|
if (!state)
|
|
163
|
-
return
|
|
256
|
+
return unknownProject(res);
|
|
164
257
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
165
|
-
res.end(renderDashboard(state, baseUrl,
|
|
258
|
+
res.end(renderDashboard(state, baseUrl, serverChainId()));
|
|
166
259
|
return;
|
|
167
260
|
}
|
|
168
261
|
// GET /health
|
|
169
262
|
if (parts[0] === 'health')
|
|
170
263
|
return sendJson(res, 200, { ok: true, baseUrl });
|
|
264
|
+
// GET /.well-known/abx-service — the service descriptor (public): what this node supports,
|
|
265
|
+
// agent-readably, so a client can match a project's needs to this service BEFORE trusting it
|
|
266
|
+
// with a registration. See site/content/docs/using-abx/remote-services.mdx.
|
|
267
|
+
if (parts[0] === '.well-known' && parts[1] === 'abx-service') {
|
|
268
|
+
return sendJson(res, 200, await serviceDescriptor(controlPlaneCtx(indexer, storage, baseUrl)));
|
|
269
|
+
}
|
|
171
270
|
// GET /abx.js — the runtime companion (directory builds include it; the generator inlines it).
|
|
172
271
|
if (parts[0] === 'abx.js') {
|
|
173
272
|
res.writeHead(200, {
|
|
@@ -183,17 +282,38 @@ async function route(req, res, indexer, baseUrl, storage) {
|
|
|
183
282
|
// entry-fetch-failure fallback; template mode: the generator document assembled from
|
|
184
283
|
// chain). This is the same document a render node captures — the live view IS the input.
|
|
185
284
|
if (parts[0] === 'a' && parts[1] && parts[2] && parts[3] !== undefined) {
|
|
186
|
-
if (Number(parts[1]) !==
|
|
285
|
+
if (Number(parts[1]) !== serverChainId())
|
|
187
286
|
return wrongChain(res, parts[1]);
|
|
188
287
|
const state = indexer.getProject(parts[2]);
|
|
189
288
|
if (!state)
|
|
190
|
-
return
|
|
289
|
+
return unknownProject(res);
|
|
191
290
|
const token = resolveTokenView(state, parts[3]);
|
|
192
291
|
if (!token)
|
|
193
|
-
return
|
|
194
|
-
|
|
195
|
-
|
|
292
|
+
return sendError(res, 404, 'not_registered', 'unknown token — not minted, and outside this project\'s supply cap');
|
|
293
|
+
if (goneIfBurned(res, token))
|
|
294
|
+
return;
|
|
295
|
+
// Both no-code verdicts are answered BEFORE the chain client, since neither needs an RPC (see
|
|
296
|
+
// {liveViewAvailability} for why the two are distinguished at all — collapsing them cost a
|
|
297
|
+
// tester a day). A plain 503 in this route's own `{error}` shape rather than a structured
|
|
298
|
+
// `code`: the versioned control plane's ServiceErrorCode set is pinned by remote-services.md and
|
|
299
|
+
// has no member for "not ready yet", and widening a published interface for one serving-route
|
|
300
|
+
// diagnostic is a decision, not a bug fix. The status code carries the retry semantics.
|
|
301
|
+
const availability = liveViewAvailability(state);
|
|
302
|
+
if (availability === 'indexing') {
|
|
303
|
+
res.writeHead(503, { 'content-type': 'application/json; charset=utf-8', 'retry-after': '5' });
|
|
304
|
+
res.end(JSON.stringify({
|
|
305
|
+
error: "this IS a code project, but its on-chain code has not been folded into the projection yet — retry shortly. If it persists, this node's scan floor is above the deploy block: re-add with --from-block <deployBlock>.",
|
|
306
|
+
}, null, 2));
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (availability === 'not-a-code-project')
|
|
196
310
|
return sendJson(res, 404, { error: 'no live view — not a code project' });
|
|
311
|
+
const view = await resolveLiveView(chainClientLazy(), state, token);
|
|
312
|
+
if (!view) {
|
|
313
|
+
return sendJson(res, 404, {
|
|
314
|
+
error: 'no live view — this project has a `code` field whose locator is not serveable (the `code` field is locators-only: ipfs/arweave/url)',
|
|
315
|
+
});
|
|
316
|
+
}
|
|
197
317
|
if (view.kind === 'redirect') {
|
|
198
318
|
res.writeHead(302, { location: view.location, 'cache-control': 'no-store' });
|
|
199
319
|
res.end();
|
|
@@ -203,25 +323,14 @@ async function route(req, res, indexer, baseUrl, storage) {
|
|
|
203
323
|
res.end(view.html);
|
|
204
324
|
return;
|
|
205
325
|
}
|
|
206
|
-
// /
|
|
207
|
-
// it's a *remote `abx add`*: it
|
|
208
|
-
// any local one) which contracts to index.
|
|
209
|
-
// "no signing key on the host" rule is intact; the
|
|
210
|
-
// control only. Disabled unless ABX_RESOLVER_ADMIN_TOKEN is
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
// POST /admin/effect-artifacts — a conforming effect runner publishes a render output (a durable
|
|
215
|
-
// locator, or raw bytes for small must-inline outputs like traits) so a resolver that does NOT share
|
|
216
|
-
// the runner's storage disk can still serve it. Same bearer as the control plane; never signs on-chain.
|
|
217
|
-
if (parts[0] === 'admin' && parts[1] === 'effect-artifacts') {
|
|
218
|
-
return adminRenderArtifacts(req, res, indexer, storage);
|
|
219
|
-
}
|
|
220
|
-
// POST /admin/effect-status — a runner reports a run's transient state ('rendering' /
|
|
221
|
-
// 'failed{error}' / 'done'). Pure observability: correctness stays artifact-presence at the
|
|
222
|
-
// settled inputsHash; these rows are rebuildable and 'done' simply clears one. Admin-gated.
|
|
223
|
-
if (parts[0] === 'admin' && parts[1] === 'effect-status') {
|
|
224
|
-
return adminEffectStatus(req, res, indexer);
|
|
326
|
+
// /v1/* — the control plane (register/list/deregister/reindex/status + the effect-publish
|
|
327
|
+
// lane). This is the ONE write surface on the resolver, and it's a *remote `abx add`*: it
|
|
328
|
+
// tells THIS node (a different projection store from any local one) which contracts to index.
|
|
329
|
+
// It never signs anything on-chain — the "no signing key on the host" rule is intact; the
|
|
330
|
+
// bearer token authorizes indexing control only. Disabled unless ABX_RESOLVER_ADMIN_TOKEN is
|
|
331
|
+
// set on the resolver. Routes + auth live in control-plane.ts.
|
|
332
|
+
if (parts[0] === 'v1') {
|
|
333
|
+
return routeControlPlane(req, res, controlPlaneCtx(indexer, storage, baseUrl), parts.slice(1));
|
|
225
334
|
}
|
|
226
335
|
// /api/*
|
|
227
336
|
if (parts[0] === 'api') {
|
|
@@ -238,30 +347,21 @@ async function route(req, res, indexer, baseUrl, storage) {
|
|
|
238
347
|
// same registry-order resolution the generator document uses, sharing its cache) plus
|
|
239
348
|
// the URL-budget flag for directory projects. Public, like the other status reads.
|
|
240
349
|
if (parts[1] === 'deps' && parts[2] && parts[3]) {
|
|
241
|
-
if (Number(parts[2]) !==
|
|
350
|
+
if (Number(parts[2]) !== serverChainId())
|
|
242
351
|
return wrongChain(res, parts[2]);
|
|
243
352
|
const state = indexer.getProject(parts[3]);
|
|
244
353
|
if (!state)
|
|
245
|
-
return
|
|
246
|
-
return sendJson(res, 200, await depStatusReport(
|
|
354
|
+
return unknownProject(res);
|
|
355
|
+
return sendJson(res, 200, await depStatusReport(chainClientLazy(), state));
|
|
247
356
|
}
|
|
248
357
|
if (parts[1] === 'project' && parts[2]) {
|
|
249
358
|
const address = parts[2];
|
|
250
|
-
// POST|GET /api/project/:addr/reindex — full replay from chain. ADMIN-ONLY: a full replay
|
|
251
|
-
// is expensive + mutating, so it must never be a public action (it's `abx index --remote`
|
|
252
|
-
// from the CLI). Gated by the same bearer as the control plane.
|
|
253
|
-
if (parts[3] === 'reindex') {
|
|
254
|
-
if (!requireAdmin(req, res))
|
|
255
|
-
return;
|
|
256
|
-
const { state, elapsedMs } = await indexer.reindex(address, { full: true });
|
|
257
|
-
notifyEffects(address);
|
|
258
|
-
return sendJson(res, 200, { elapsedMs, state });
|
|
259
|
-
}
|
|
260
359
|
// GET /api/project/:addr/verify — re-hashes served bytes against the on-chain anchor.
|
|
261
|
-
//
|
|
360
|
+
// BEARER-ONLY: it triggers outbound chain + gateway fetches, so it isn't a public endpoint
|
|
262
361
|
// (anyone can still verify independently via `abx verify` — no need for this node to do it).
|
|
362
|
+
// Reindex moved to the control plane: POST /v1/projects/{chainId}/{address}/reindex.
|
|
263
363
|
if (parts[3] === 'verify') {
|
|
264
|
-
if (!
|
|
364
|
+
if (!requireBearer(req, res))
|
|
265
365
|
return;
|
|
266
366
|
return sendJson(res, 200, await verifyProject(indexer.getProject(address), storage));
|
|
267
367
|
}
|
|
@@ -272,31 +372,62 @@ async function route(req, res, indexer, baseUrl, storage) {
|
|
|
272
372
|
if (parts[3] === 'effects') {
|
|
273
373
|
const state = indexer.getProject(address);
|
|
274
374
|
if (!state)
|
|
275
|
-
return
|
|
375
|
+
return unknownProject(res);
|
|
276
376
|
return sendJson(res, 200, await effectStatusReport(indexer, state, storage));
|
|
277
377
|
}
|
|
378
|
+
// GET /api/project/:addr/artifacts?token=<id> — the typed artifact-manifest read behind
|
|
379
|
+
// `abx artifacts --remote`. PUBLIC, like /effects and /api/project/:addr: it reports what this
|
|
380
|
+
// node already serves in a tokenURI document, so bearer-gating it would hide nothing.
|
|
381
|
+
// Per-token by construction — a code project's settled `inputsHash` is that token's own, so
|
|
382
|
+
// there is no coherent all-tokens form of this answer.
|
|
383
|
+
if (parts[3] === 'artifacts') {
|
|
384
|
+
const state = indexer.getProject(address);
|
|
385
|
+
if (!state)
|
|
386
|
+
return unknownProject(res);
|
|
387
|
+
const tokenId = url.searchParams.get('token');
|
|
388
|
+
if (tokenId === null || !/^\d+$/.test(tokenId)) {
|
|
389
|
+
return sendError(res, 400, 'invalid_request', 'artifacts is a per-token read — pass ?token=<decimal id>');
|
|
390
|
+
}
|
|
391
|
+
const token = state.tokens.find((t) => t.tokenId === tokenId);
|
|
392
|
+
// Same code + wording the metadata routes already use for this exact condition, so a client
|
|
393
|
+
// can't tell "unknown token" apart by which route it asked.
|
|
394
|
+
if (!token)
|
|
395
|
+
return sendError(res, 404, 'not_registered', "unknown token — not minted, and outside this project's supply cap");
|
|
396
|
+
// A burned 721 has no artifacts to report, and saying so as an empty manifest would read as
|
|
397
|
+
// "nothing rendered yet". Answer what the contract answers — the same gate the metadata and
|
|
398
|
+
// image routes apply.
|
|
399
|
+
if (goneIfBurned(res, token))
|
|
400
|
+
return;
|
|
401
|
+
return sendJson(res, 200, await tokenArtifacts(chainClientLazy(), state, token, baseUrl, serverChainId(), displayMeta(indexer, address), storage, planeAccess(indexer)));
|
|
402
|
+
}
|
|
278
403
|
// GET /api/project/:addr
|
|
279
404
|
const state = indexer.getProject(address);
|
|
280
405
|
if (!state)
|
|
281
|
-
return
|
|
406
|
+
return unknownProject(res);
|
|
282
407
|
return sendJson(res, 200, state);
|
|
283
408
|
}
|
|
284
|
-
return
|
|
409
|
+
return sendError(res, 404, 'unknown_route', 'unknown api route', {
|
|
410
|
+
routes: ['/api/projects', '/api/watch', '/api/project/:address', '/api/project/:address/artifacts', '/api/project/:address/effects', '/api/project/:address/verify', '/api/deps/:chainId/:address'],
|
|
411
|
+
});
|
|
285
412
|
}
|
|
286
413
|
// GET /t/:chainId/:addr/:id and /t/:chainId/:addr/:id/image
|
|
287
414
|
if (parts[0] === 't' && parts[1] && parts[2] && parts[3] !== undefined) {
|
|
288
|
-
if (Number(parts[1]) !==
|
|
415
|
+
if (Number(parts[1]) !== serverChainId())
|
|
289
416
|
return wrongChain(res, parts[1]);
|
|
290
417
|
const address = parts[2];
|
|
291
418
|
const tokenId = parts[3];
|
|
292
419
|
const state = indexer.getProject(address);
|
|
293
420
|
if (!state)
|
|
294
|
-
return
|
|
421
|
+
return unknownProject(res);
|
|
295
422
|
// Resolve the issuance token → its metadata-id slot (identity vs. content), synthesizing a
|
|
296
423
|
// pre-mint view for a not-yet-issued id within the cap so metadata warms before mint.
|
|
297
424
|
const token = resolveTokenView(state, tokenId);
|
|
298
425
|
if (!token)
|
|
299
|
-
return
|
|
426
|
+
return sendError(res, 404, 'not_registered', 'unknown token — not minted, and outside this project\'s supply cap');
|
|
427
|
+
// Before the image / data / metadata branches below: all three would otherwise compose an answer
|
|
428
|
+
// for an id the contract disowns (and `/image` would warm a placeholder for it forever).
|
|
429
|
+
if (goneIfBurned(res, token))
|
|
430
|
+
return;
|
|
300
431
|
if (parts[4] === 'image') {
|
|
301
432
|
// the render-effect seam: no explicit image field + a code project ⇒ serve the
|
|
302
433
|
// artifact stored at the CURRENT inputsHash address, when a producer has run.
|
|
@@ -304,13 +435,14 @@ async function route(req, res, indexer, baseUrl, storage) {
|
|
|
304
435
|
state.collectionFields.some((f) => f.field === 'image');
|
|
305
436
|
if (!hasImageField && isCodeProject(state)) {
|
|
306
437
|
try {
|
|
307
|
-
const { key, found } = await currentRenderArtifact(
|
|
308
|
-
//
|
|
309
|
-
// share
|
|
310
|
-
//
|
|
438
|
+
const { key, found } = await currentRenderArtifact(chainClientLazy(), state, token, storage);
|
|
439
|
+
// `image` is a REFERENCED output (`effects.md → Bound vs referenced`): a producer that
|
|
440
|
+
// doesn't share this node's disk registers a locator (ipfs/ar/https) and we 302 straight to
|
|
441
|
+
// it (gateway resolved at serve time), never proxying its bytes. `found` is the co-located
|
|
442
|
+
// case — the producer wrote the artifact into the backend we share.
|
|
311
443
|
const published = indexer.store.getEffectArtifact(key);
|
|
312
444
|
if (published?.locator) {
|
|
313
|
-
res.writeHead(302, { location: resolveLocatorUrl(published.locator), 'cache-control': 'public, max-age=300' });
|
|
445
|
+
res.writeHead(302, { location: resolveLocatorUrl(state, published.locator), 'cache-control': 'public, max-age=300' });
|
|
314
446
|
res.end();
|
|
315
447
|
return;
|
|
316
448
|
}
|
|
@@ -334,7 +466,7 @@ async function route(req, res, indexer, baseUrl, storage) {
|
|
|
334
466
|
// a computed LOCATOR (renderer returning text/uri-list): the bytes ARE a URI — redirect.
|
|
335
467
|
if (content.contentType === 'text/uri-list') {
|
|
336
468
|
const target = typeof content.body === 'string' ? content.body : new TextDecoder().decode(content.body);
|
|
337
|
-
res.writeHead(302, { location: resolveLocatorUrl(target.trim()), 'cache-control': 'public, max-age=300' });
|
|
469
|
+
res.writeHead(302, { location: resolveLocatorUrl(state, target.trim()), 'cache-control': 'public, max-age=300' });
|
|
338
470
|
res.end();
|
|
339
471
|
return;
|
|
340
472
|
}
|
|
@@ -352,22 +484,58 @@ async function route(req, res, indexer, baseUrl, storage) {
|
|
|
352
484
|
}
|
|
353
485
|
return serveFieldArtifact(res, state, token, parts[5], storage, displayMeta(indexer, address));
|
|
354
486
|
}
|
|
355
|
-
return sendJson(res, 200, await buildTokenMetadata(
|
|
487
|
+
return sendJson(res, 200, await buildTokenMetadata(chainClientLazy(), state, token, baseUrl, serverChainId(), displayMeta(indexer, address), storage, planeAccess(indexer)));
|
|
356
488
|
}
|
|
357
489
|
// GET /c/:chainId/:addr and /c/:chainId/:addr/data/{field}
|
|
358
490
|
if (parts[0] === 'c' && parts[1] && parts[2]) {
|
|
359
|
-
if (Number(parts[1]) !==
|
|
491
|
+
if (Number(parts[1]) !== serverChainId())
|
|
360
492
|
return wrongChain(res, parts[1]);
|
|
361
493
|
const address = parts[2];
|
|
362
494
|
const state = indexer.getProject(address);
|
|
363
495
|
if (!state)
|
|
364
|
-
return
|
|
496
|
+
return unknownProject(res);
|
|
365
497
|
if (parts[3] === 'data' && parts[4]) {
|
|
366
498
|
return serveFieldArtifact(res, state, null, parts[4], storage, displayMeta(indexer, address));
|
|
367
499
|
}
|
|
368
|
-
return sendJson(res, 200, await buildContractMetadata(
|
|
500
|
+
return sendJson(res, 200, await buildContractMetadata(chainClientLazy(), state, baseUrl, serverChainId(), displayMeta(indexer, address), storage));
|
|
369
501
|
}
|
|
370
|
-
|
|
502
|
+
return sendRouteError(res, parts);
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* The last word of the router: nothing matched. A BARE 404 here is actively misleading — it's the
|
|
506
|
+
* same answer as "this project isn't indexed", so a client that hand-built a URL (dropping the
|
|
507
|
+
* `:id` off `/t/:chainId/:address/:id` and expecting collection metadata is the observed case)
|
|
508
|
+
* reads its own mistake as a service outage and reports a non-bug.
|
|
509
|
+
*
|
|
510
|
+
* So: a KNOWN route prefix with the wrong segment count is a **400 `invalid_request`** naming the
|
|
511
|
+
* correct template, and anything else is a **404 `unknown_route`**. Same `{error, code}` shape the
|
|
512
|
+
* control plane already uses (remote-services.md → Errors), so clients key off `code`, not prose.
|
|
513
|
+
*
|
|
514
|
+
* These are hints, not a discoverable API: the route grammar is fixed by the `abx-token-api/v1`
|
|
515
|
+
* interface and committed on-chain per contract (`tokenURIBase`/`contractURIBase`). The real fix for
|
|
516
|
+
* a client is to read the URL off the contract (`abx tokenuri` / `abx contracturi`) rather than
|
|
517
|
+
* assembling one — so every hint below points at the grammar it should already have, and never
|
|
518
|
+
* invites a client to treat routes as per-node negotiable.
|
|
519
|
+
*/
|
|
520
|
+
const ROUTE_TEMPLATES = {
|
|
521
|
+
t: { template: '/t/:chainId/:address/:id (· /image · /data/:field)', what: 'token metadata (ERC-721 tokenURI / ERC-1155 uri target)' },
|
|
522
|
+
c: { template: '/c/:chainId/:address (· /data/:field)', what: 'ERC-7572 collection metadata' },
|
|
523
|
+
a: { template: '/a/:chainId/:address/:id', what: 'the live view of a code project' },
|
|
524
|
+
d: { template: '/d/:chainId/:address', what: 'the per-contract read-only dashboard' },
|
|
525
|
+
};
|
|
526
|
+
function sendRouteError(res, parts) {
|
|
527
|
+
const known = parts[0] ? ROUTE_TEMPLATES[parts[0]] : undefined;
|
|
528
|
+
if (known) {
|
|
529
|
+
// The single highest-value hint: a `/t/:chainId/:address` with no token id is almost always
|
|
530
|
+
// someone reaching for collection metadata. Name `/c/…` explicitly.
|
|
531
|
+
const missingId = (parts[0] === 't' || parts[0] === 'a') && parts.length === 3;
|
|
532
|
+
return sendError(res, 400, 'invalid_request', `malformed ${known.what} path — use ${known.template}` +
|
|
533
|
+
(missingId ? '. For COLLECTION-level metadata (no token id) use /c/:chainId/:address' : ''), { route: known.template, ...(missingId ? { didYouMean: `/c/${parts[1]}/${parts[2]}` } : {}) });
|
|
534
|
+
}
|
|
535
|
+
return sendError(res, 404, 'unknown_route', 'this node serves no route at that path', {
|
|
536
|
+
routes: Object.values(ROUTE_TEMPLATES).map((r) => r.template.split(' ')[0]),
|
|
537
|
+
hint: 'a contract commits its own metadata URL on-chain (tokenURIBase/contractURIBase) — read it with `abx tokenuri` / `abx contracturi` instead of building a path',
|
|
538
|
+
});
|
|
371
539
|
}
|
|
372
540
|
/** The manifest's read surface over the effect-artifact registry (metadata.ts stays store-free). */
|
|
373
541
|
function planeAccess(indexer) {
|
|
@@ -376,21 +544,33 @@ function planeAccess(indexer) {
|
|
|
376
544
|
get: (key) => indexer.store.getEffectArtifact(key),
|
|
377
545
|
};
|
|
378
546
|
}
|
|
379
|
-
/** Serve one EFFECT artifact's bytes at the CURRENT settled inputsHash
|
|
380
|
-
*
|
|
381
|
-
*
|
|
547
|
+
/** Serve one EFFECT artifact's bytes at the CURRENT settled inputsHash. Three sources, in order,
|
|
548
|
+
* mirroring `effects.md → Bound vs referenced`:
|
|
549
|
+
* - a registered locator (a REFERENCED output) → **302**, never a proxy: the producer holds those
|
|
550
|
+
* bytes and its egress stays its own;
|
|
551
|
+
* - a BOUND output's content, held with the row (≤64KB, this node stitches it into the JSON too);
|
|
552
|
+
* - this node's own custody at the artifact key — a CO-LOCATED producer sharing the backend.
|
|
553
|
+
* Else 404 (not produced yet, or stale after a param change — self-invalidation, not an error). */
|
|
382
554
|
async function serveEffectArtifact(res, state, token, effectKey, outputKey, storage, indexer) {
|
|
383
555
|
if (!isCodeProject(state))
|
|
384
556
|
return sendJson(res, 404, { error: 'no effect artifacts — not a code project' });
|
|
385
557
|
try {
|
|
386
|
-
const hash = await currentSettledInputsHash(
|
|
558
|
+
const hash = await currentSettledInputsHash(chainClientLazy(), state, token);
|
|
387
559
|
const key = renderArtifactKey(state.chainId, state.address, token.tokenId, hash, outputKey, effectKey);
|
|
388
560
|
const row = indexer.store.getEffectArtifact(key);
|
|
389
561
|
if (row?.locator) {
|
|
390
|
-
res.writeHead(302, { location: resolveLocatorUrl(row.locator), 'cache-control': 'public, max-age=300' });
|
|
562
|
+
res.writeHead(302, { location: resolveLocatorUrl(state, row.locator), 'cache-control': 'public, max-age=300' });
|
|
391
563
|
res.end();
|
|
392
564
|
return;
|
|
393
565
|
}
|
|
566
|
+
if (row?.bytes) {
|
|
567
|
+
res.writeHead(200, {
|
|
568
|
+
'content-type': row.contentType || 'application/octet-stream',
|
|
569
|
+
'cache-control': 'public, max-age=300',
|
|
570
|
+
});
|
|
571
|
+
res.end(row.bytes);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
394
574
|
const stored = await storage.get(key);
|
|
395
575
|
if (stored) {
|
|
396
576
|
res.writeHead(200, {
|
|
@@ -420,21 +600,21 @@ async function serveFieldArtifact(res, state, token, field, storage, display) {
|
|
|
420
600
|
return sendJson(res, 404, { error: `no '${field}' field set` });
|
|
421
601
|
const tokenId = token?.tokenId ?? '0';
|
|
422
602
|
try {
|
|
423
|
-
const onChain = await resolveFieldBytes(
|
|
603
|
+
const onChain = await resolveFieldBytes(chainClientLazy(), entry);
|
|
424
604
|
if (onChain) {
|
|
425
605
|
res.writeHead(200, {
|
|
426
|
-
'content-type': await fieldMimeType(
|
|
606
|
+
'content-type': await fieldMimeType(chainClientLazy(), state, entry, field, tokenId, display, storage),
|
|
427
607
|
'cache-control': 'public, max-age=300',
|
|
428
608
|
});
|
|
429
609
|
res.end(onChain);
|
|
430
610
|
return;
|
|
431
611
|
}
|
|
432
612
|
// computed on-chain at read — the collection surface passes the sentinel id (no token).
|
|
433
|
-
const rendered = await resolveFieldRendered(
|
|
613
|
+
const rendered = await resolveFieldRendered(chainClientLazy(), state.address, token?.tokenId ?? COLLECTION_TOKEN_ID, field, entry);
|
|
434
614
|
if (rendered) {
|
|
435
615
|
if (rendered.contentType === 'text/uri-list') {
|
|
436
616
|
const target = new TextDecoder().decode(rendered.bytes).trim();
|
|
437
|
-
res.writeHead(302, { location: resolveLocatorUrl(target), 'cache-control': 'public, max-age=300' });
|
|
617
|
+
res.writeHead(302, { location: resolveLocatorUrl(state, target), 'cache-control': 'public, max-age=300' });
|
|
438
618
|
res.end();
|
|
439
619
|
return;
|
|
440
620
|
}
|
|
@@ -454,15 +634,15 @@ async function serveFieldArtifact(res, state, token, field, storage, display) {
|
|
|
454
634
|
}
|
|
455
635
|
const bridged = display.contentLocators?.[entry.value.toLowerCase()];
|
|
456
636
|
if (bridged) {
|
|
457
|
-
res.writeHead(302, { location: resolveLocatorUrl(bridged), 'cache-control': 'public, max-age=300' });
|
|
637
|
+
res.writeHead(302, { location: resolveLocatorUrl(state, bridged), 'cache-control': 'public, max-age=300' });
|
|
458
638
|
res.end();
|
|
459
639
|
return;
|
|
460
640
|
}
|
|
461
641
|
return sendJson(res, 404, { error: `'${field}' bytes not in this node's custody (on-chain ${entry.representation} anchor only)` });
|
|
462
642
|
}
|
|
463
|
-
const locator = fieldLocatorUrl(entry, tokenId);
|
|
643
|
+
const locator = fieldLocatorUrl(state, entry, tokenId);
|
|
464
644
|
if (locator) {
|
|
465
|
-
res.writeHead(302, { location: resolveLocatorUrl(locator), 'cache-control': 'public, max-age=300' });
|
|
645
|
+
res.writeHead(302, { location: resolveLocatorUrl(state, locator), 'cache-control': 'public, max-age=300' });
|
|
466
646
|
res.end();
|
|
467
647
|
return;
|
|
468
648
|
}
|
|
@@ -473,8 +653,18 @@ async function serveFieldArtifact(res, state, token, field, storage, display) {
|
|
|
473
653
|
return sendJson(res, 404, { error: `'${field}' (${entry.representation}) is not byte-servable from this node` });
|
|
474
654
|
}
|
|
475
655
|
/** A locator-representation field's URL (`{id}` substituted), or null for non-locator forms. */
|
|
476
|
-
function fieldLocatorUrl(entry, tokenId) {
|
|
477
|
-
|
|
656
|
+
function fieldLocatorUrl(state, entry, tokenId) {
|
|
657
|
+
// Content-addressed values are IDENTITY, not URLs — since v11 a field stores the bare CID/txid,
|
|
658
|
+
// so returning it raw would 302 a browser to `Location: bafy…`. Project it exactly as the
|
|
659
|
+
// metadata document does, through the collection's preferred gateway.
|
|
660
|
+
if (entry.representation === R.ipfs || entry.representation === R.arweave) {
|
|
661
|
+
const network = entry.representation === R.ipfs ? 'ipfs' : 'arweave';
|
|
662
|
+
const text = Buffer.from(entry.value.slice(2), 'hex').toString('utf8').trim();
|
|
663
|
+
if (!text)
|
|
664
|
+
return null;
|
|
665
|
+
return projectGatewayUrl(network, text, projectGatewayPrefix(state, network, gatewayConfigFromEnv()), tokenId);
|
|
666
|
+
}
|
|
667
|
+
if (entry.representation === R.url) {
|
|
478
668
|
const text = Buffer.from(entry.value.slice(2), 'hex').toString('utf8').trim();
|
|
479
669
|
return text || null;
|
|
480
670
|
}
|
|
@@ -523,32 +713,6 @@ function safeTokenAttributes(json) {
|
|
|
523
713
|
return undefined;
|
|
524
714
|
}
|
|
525
715
|
}
|
|
526
|
-
/** Parse a stored content-locators JSON column → `{hash: locator}` (lowercased keys, never throws). */
|
|
527
|
-
function safeLocators(json) {
|
|
528
|
-
try {
|
|
529
|
-
const obj = JSON.parse(json);
|
|
530
|
-
const out = {};
|
|
531
|
-
for (const [k, v] of Object.entries(obj))
|
|
532
|
-
if (typeof v === 'string')
|
|
533
|
-
out[k.toLowerCase()] = v;
|
|
534
|
-
return Object.keys(out).length ? out : undefined;
|
|
535
|
-
}
|
|
536
|
-
catch {
|
|
537
|
-
return undefined;
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
/** Merge incoming content locators over the stored ones (additive — a re-add can bring new hashes
|
|
541
|
-
* without dropping known ones). Returns a JSON string for the column, or undefined if empty. */
|
|
542
|
-
function mergeLocators(existingJson, incoming) {
|
|
543
|
-
const base = existingJson ? safeLocators(existingJson) ?? {} : {};
|
|
544
|
-
if (incoming && typeof incoming === 'object') {
|
|
545
|
-
for (const [k, v] of Object.entries(incoming)) {
|
|
546
|
-
if (typeof v === 'string' && v)
|
|
547
|
-
base[k.toLowerCase()] = v;
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
return Object.keys(base).length ? JSON.stringify(base) : undefined;
|
|
551
|
-
}
|
|
552
716
|
export async function verifyProject(state, storage) {
|
|
553
717
|
if (!state)
|
|
554
718
|
return { error: 'unknown project' };
|
|
@@ -564,301 +728,6 @@ export async function verifyProject(state, storage) {
|
|
|
564
728
|
}));
|
|
565
729
|
return { address: state.address, tokens };
|
|
566
730
|
}
|
|
567
|
-
function summarize(s) {
|
|
568
|
-
return {
|
|
569
|
-
address: s.address,
|
|
570
|
-
name: s.name,
|
|
571
|
-
symbol: s.symbol,
|
|
572
|
-
owner: s.owner,
|
|
573
|
-
abxVersion: s.abxVersion,
|
|
574
|
-
isCanonical: s.isCanonical,
|
|
575
|
-
extensions: s.extensions.map((e) => e.name),
|
|
576
|
-
eventCount: s.eventCount,
|
|
577
|
-
tokenCount: s.tokens.length,
|
|
578
|
-
mintedCount: s.tokens.filter((t) => t.minted).length,
|
|
579
|
-
reconstructedAt: s.reconstructedAt,
|
|
580
|
-
};
|
|
581
|
-
}
|
|
582
|
-
function sendJson(res, status, body) {
|
|
583
|
-
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
584
|
-
res.end(JSON.stringify(body, null, 2));
|
|
585
|
-
}
|
|
586
|
-
// ── admin control plane (remote `abx add`) ───────────────────────────────────--
|
|
587
|
-
/** Guard an admin-only API action (reindex/verify): 404 when no admin token is configured on the
|
|
588
|
-
* host (the action surface is disabled), 401 on a missing/bad bearer. Returns false if it handled
|
|
589
|
-
* the response (caller must stop), true when the request is authorized to proceed. */
|
|
590
|
-
function requireAdmin(req, res) {
|
|
591
|
-
if (!process.env.ABX_RESOLVER_ADMIN_TOKEN) {
|
|
592
|
-
sendJson(res, 404, { error: 'admin action disabled — set ABX_RESOLVER_ADMIN_TOKEN on the resolver (operate via the abx CLI)' });
|
|
593
|
-
return false;
|
|
594
|
-
}
|
|
595
|
-
if (!adminAuthorized(req)) {
|
|
596
|
-
sendJson(res, 401, { error: 'unauthorized — this action needs Authorization: Bearer <ABX_RESOLVER_ADMIN_TOKEN>' });
|
|
597
|
-
return false;
|
|
598
|
-
}
|
|
599
|
-
return true;
|
|
600
|
-
}
|
|
601
|
-
/** Constant-time bearer check against ABX_RESOLVER_ADMIN_TOKEN. */
|
|
602
|
-
function adminAuthorized(req) {
|
|
603
|
-
const token = process.env.ABX_RESOLVER_ADMIN_TOKEN;
|
|
604
|
-
if (!token)
|
|
605
|
-
return false;
|
|
606
|
-
const header = req.headers['authorization'];
|
|
607
|
-
const raw = (Array.isArray(header) ? header[0] : header) ?? '';
|
|
608
|
-
const m = /^Bearer\s+(.+)$/i.exec(raw.trim());
|
|
609
|
-
if (!m)
|
|
610
|
-
return false;
|
|
611
|
-
const got = Buffer.from(m[1]);
|
|
612
|
-
const want = Buffer.from(token);
|
|
613
|
-
return got.length === want.length && timingSafeEqual(got, want);
|
|
614
|
-
}
|
|
615
|
-
/** Read + JSON-parse a request body, capped so a bad caller can't exhaust memory. */
|
|
616
|
-
async function readJsonBody(req, maxBytes = 64 * 1024) {
|
|
617
|
-
const chunks = [];
|
|
618
|
-
let size = 0;
|
|
619
|
-
for await (const chunk of req) {
|
|
620
|
-
size += chunk.length;
|
|
621
|
-
if (size > maxBytes)
|
|
622
|
-
throw new Error('request body too large');
|
|
623
|
-
chunks.push(chunk);
|
|
624
|
-
}
|
|
625
|
-
if (chunks.length === 0)
|
|
626
|
-
return {};
|
|
627
|
-
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
628
|
-
}
|
|
629
|
-
const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
630
|
-
/**
|
|
631
|
-
* `POST /admin/projects {address, fromBlock?, factory?, label?, description?, externalUrl?,
|
|
632
|
-
* attributes?, contentLocators?}` — attributes are off-chain operator traits; contentLocators
|
|
633
|
-
* bridge `{ "0x<keccak>": "ipfs://<cid>" }` so the image points at IPFS without holding bytes.
|
|
634
|
-
* → register the contract with THIS node and replay it from chain. Idempotent: a
|
|
635
|
-
* re-POST is the post-deploy "nudge" that pulls events that landed since.
|
|
636
|
-
* `DELETE /admin/projects/:address` → stop indexing it (drops the projection).
|
|
637
|
-
*
|
|
638
|
-
* We deliberately do NOT factory-scope here: if you own this resolver and you tell it
|
|
639
|
-
* a contract, it does its best to index it. (Allowlisting by factory is a platform
|
|
640
|
-
* concern — see docs/10-backlog.md B6 — not a self-hosting one.)
|
|
641
|
-
*/
|
|
642
|
-
/**
|
|
643
|
-
* Decide the scan floor + whether a full replay is needed when registering a project via the
|
|
644
|
-
* admin control plane. Pure (the discovery/refusal fallback for the null case is the caller's):
|
|
645
|
-
* - explicit `bodyFromBlock` wins; else the `existingFromBlock` already stored.
|
|
646
|
-
* - `null` ⇒ NEITHER supplied nor stored — the caller must derive the deploy block or refuse
|
|
647
|
-
* (never default to genesis: a range-capped RPC would sweep millions of blocks).
|
|
648
|
-
* - `full` is true only when forced, on a first registration (no existing floor), or when the
|
|
649
|
-
* floor actually CHANGED — so re-sending the SAME floor (the CLI now always forwards the deploy
|
|
650
|
-
* block, even on a nudge) stays incremental: `abx add --remote` twice ≠ two full scans.
|
|
651
|
-
*/
|
|
652
|
-
export function planRegistrationFloor(bodyFromBlock, existingFromBlock, forceFull = false) {
|
|
653
|
-
const fromBlock = bodyFromBlock !== undefined ? String(bodyFromBlock) : existingFromBlock;
|
|
654
|
-
if (fromBlock === undefined)
|
|
655
|
-
return null;
|
|
656
|
-
const full = forceFull || existingFromBlock === undefined || fromBlock !== existingFromBlock;
|
|
657
|
-
return { fromBlock, full };
|
|
658
|
-
}
|
|
659
|
-
async function adminProjects(req, res, indexer, addrSeg) {
|
|
660
|
-
if (!process.env.ABX_RESOLVER_ADMIN_TOKEN) {
|
|
661
|
-
return sendJson(res, 404, {
|
|
662
|
-
error: 'admin API disabled — set ABX_RESOLVER_ADMIN_TOKEN on the resolver to enable remote add/remove',
|
|
663
|
-
});
|
|
664
|
-
}
|
|
665
|
-
if (!adminAuthorized(req)) {
|
|
666
|
-
return sendJson(res, 401, { error: 'unauthorized — send Authorization: Bearer <ABX_RESOLVER_ADMIN_TOKEN>' });
|
|
667
|
-
}
|
|
668
|
-
const method = req.method ?? 'GET';
|
|
669
|
-
if (method === 'POST' && addrSeg === undefined) {
|
|
670
|
-
let body;
|
|
671
|
-
try {
|
|
672
|
-
body = await readJsonBody(req);
|
|
673
|
-
}
|
|
674
|
-
catch (err) {
|
|
675
|
-
return sendJson(res, 400, { error: err.message });
|
|
676
|
-
}
|
|
677
|
-
const address = body.address;
|
|
678
|
-
if (!address || !ADDRESS_RE.test(address)) {
|
|
679
|
-
return sendJson(res, 400, { error: 'body.address must be a 0x-prefixed 20-byte address' });
|
|
680
|
-
}
|
|
681
|
-
// A re-POST is the post-deploy nudge: preserve the existing scan floor + metadata
|
|
682
|
-
// (don't reset fromBlock and re-scan), and re-index incrementally. A first add — or one
|
|
683
|
-
// that supplies a *new* fromBlock — replays fully from that floor. (See planRegistrationFloor.)
|
|
684
|
-
const existing = indexer.store.getRegistration(address);
|
|
685
|
-
let plan = planRegistrationFloor(body.fromBlock, existing?.fromBlock, body.full === true);
|
|
686
|
-
if (!plan) {
|
|
687
|
-
// No floor supplied and none stored. NEVER default to genesis — a range-capped RPC would
|
|
688
|
-
// grind millions of blocks (the "resolver won't index" trap). Derive the deploy block from
|
|
689
|
-
// chain; refuse if we can't (archive getCode unavailable) rather than guess a bad floor.
|
|
690
|
-
const discovered = await discoverDeployBlock(indexer.publicClient(SERVER_CHAIN_KEY), address);
|
|
691
|
-
if (discovered === null) {
|
|
692
|
-
return sendJson(res, 400, {
|
|
693
|
-
error: 'first registration needs fromBlock (the contract deploy block) — refusing a from-genesis scan. ' +
|
|
694
|
-
'The abx CLI derives it automatically; if calling the API directly, pass fromBlock, or point the resolver at an archive RPC that serves historical eth_getCode.',
|
|
695
|
-
});
|
|
696
|
-
}
|
|
697
|
-
plan = { fromBlock: discovered.toString(), full: true };
|
|
698
|
-
}
|
|
699
|
-
const { fromBlock, full } = plan;
|
|
700
|
-
// Attributes / locators arrive as JSON values; store them as text. Normalize attributes so a
|
|
701
|
-
// bad payload can't poison the served traits. Each preserves the existing value when omitted.
|
|
702
|
-
let attributes = existing?.attributes;
|
|
703
|
-
if (body.attributes !== undefined) {
|
|
704
|
-
attributes = body.attributes === null ? undefined : JSON.stringify(normalizeAttributes(body.attributes));
|
|
705
|
-
}
|
|
706
|
-
// Per-token off-chain traits (a Series' editable attributes): a `{ "<tokenId>": attrs }` object,
|
|
707
|
-
// each value normalized. Same preserve-on-omit / clear-on-null semantics as `attributes`.
|
|
708
|
-
let tokenAttributes = existing?.tokenAttributes;
|
|
709
|
-
if (body.tokenAttributes !== undefined) {
|
|
710
|
-
if (body.tokenAttributes === null)
|
|
711
|
-
tokenAttributes = undefined;
|
|
712
|
-
else {
|
|
713
|
-
const norm = {};
|
|
714
|
-
for (const [tokenId, v] of Object.entries(body.tokenAttributes)) {
|
|
715
|
-
const a = normalizeAttributes(v);
|
|
716
|
-
if (a.length)
|
|
717
|
-
norm[tokenId] = a;
|
|
718
|
-
}
|
|
719
|
-
tokenAttributes = Object.keys(norm).length ? JSON.stringify(norm) : undefined;
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
let contentLocators = existing?.contentLocators;
|
|
723
|
-
if (body.contentLocators !== undefined) {
|
|
724
|
-
contentLocators = mergeLocators(existing?.contentLocators, body.contentLocators);
|
|
725
|
-
}
|
|
726
|
-
indexer.register({
|
|
727
|
-
address: address,
|
|
728
|
-
chainKey: SERVER_CHAIN_KEY,
|
|
729
|
-
fromBlock,
|
|
730
|
-
factory: body.factory ?? existing?.factory ?? process.env.ABX_FACTORY ?? null,
|
|
731
|
-
label: body.label ?? existing?.label,
|
|
732
|
-
description: body.description ?? existing?.description,
|
|
733
|
-
externalUrl: body.externalUrl ?? existing?.externalUrl,
|
|
734
|
-
attributes,
|
|
735
|
-
tokenAttributes,
|
|
736
|
-
contentLocators,
|
|
737
|
-
});
|
|
738
|
-
const { state, elapsedMs, mode } = await indexer.reindex(address, { full });
|
|
739
|
-
notifyEffects(address);
|
|
740
|
-
return sendJson(res, 200, { ok: true, mode, elapsedMs, project: summarize(state) });
|
|
741
|
-
}
|
|
742
|
-
if (method === 'DELETE' && addrSeg) {
|
|
743
|
-
if (!ADDRESS_RE.test(addrSeg))
|
|
744
|
-
return sendJson(res, 400, { error: 'address path segment must be a 0x address' });
|
|
745
|
-
const existed = !!indexer.store.getRegistration(addrSeg);
|
|
746
|
-
indexer.store.deregister(addrSeg);
|
|
747
|
-
return sendJson(res, existed ? 200 : 404, existed ? { ok: true, address: addrSeg } : { error: 'not registered' });
|
|
748
|
-
}
|
|
749
|
-
return sendJson(res, 405, { error: 'use POST /admin/projects or DELETE /admin/projects/<address>' });
|
|
750
|
-
}
|
|
751
|
-
/**
|
|
752
|
-
* `POST /admin/effect-artifacts {address, tokenId, inputsHash, output?, effectKey?, locator? |
|
|
753
|
-
* bytes_base64?, contentType?}` — a conforming effect runner publishes a render output so a
|
|
754
|
-
* resolver that does NOT share the runner's storage disk can serve it. Two modes:
|
|
755
|
-
* - `locator` (ipfs://<cid> | ar://<txid> | https://…): stored as a pointer; `/image` 302-redirects.
|
|
756
|
-
* - `bytes_base64`: stored in this node's own byte custody (what traits use — they inline into JSON).
|
|
757
|
-
* The artifact key is computed from the runner-supplied `inputsHash` — NOT recomputed from current
|
|
758
|
-
* state — so a render is never re-addressed to a state it doesn't depict (a param change instead makes
|
|
759
|
-
* it unreachable, the correct self-invalidation). Admin-token gated; never signs on-chain.
|
|
760
|
-
*/
|
|
761
|
-
async function adminRenderArtifacts(req, res, indexer, storage) {
|
|
762
|
-
if (!process.env.ABX_RESOLVER_ADMIN_TOKEN) {
|
|
763
|
-
return sendJson(res, 404, { error: 'admin API disabled — set ABX_RESOLVER_ADMIN_TOKEN on the resolver' });
|
|
764
|
-
}
|
|
765
|
-
if (!adminAuthorized(req)) {
|
|
766
|
-
return sendJson(res, 401, { error: 'unauthorized — send Authorization: Bearer <ABX_RESOLVER_ADMIN_TOKEN>' });
|
|
767
|
-
}
|
|
768
|
-
if ((req.method ?? 'GET') !== 'POST') {
|
|
769
|
-
return sendJson(res, 405, { error: 'use POST /admin/effect-artifacts' });
|
|
770
|
-
}
|
|
771
|
-
let body;
|
|
772
|
-
try {
|
|
773
|
-
body = await readJsonBody(req, 8 * 1024 * 1024); // a thumbnail pushed as bytes can exceed the 64KB default
|
|
774
|
-
}
|
|
775
|
-
catch (err) {
|
|
776
|
-
return sendJson(res, 400, { error: err.message });
|
|
777
|
-
}
|
|
778
|
-
const address = body.address;
|
|
779
|
-
if (!address || !ADDRESS_RE.test(address)) {
|
|
780
|
-
return sendJson(res, 400, { error: 'body.address must be a 0x-prefixed 20-byte address' });
|
|
781
|
-
}
|
|
782
|
-
if (body.tokenId === undefined || body.tokenId === null) {
|
|
783
|
-
return sendJson(res, 400, { error: 'body.tokenId required' });
|
|
784
|
-
}
|
|
785
|
-
const tokenId = String(body.tokenId);
|
|
786
|
-
const inputsHashHex = body.inputsHash;
|
|
787
|
-
if (!inputsHashHex || !/^0x[0-9a-fA-F]{64}$/.test(inputsHashHex)) {
|
|
788
|
-
return sendJson(res, 400, { error: 'body.inputsHash must be the 0x 32-byte hash the runner rendered (this node does NOT recompute it)' });
|
|
789
|
-
}
|
|
790
|
-
// Any declared output key (the data plane's generality) — 'image'/'traits' are just the
|
|
791
|
-
// reference render effect's two.
|
|
792
|
-
const output = typeof body.output === 'string' && body.output ? body.output : 'image';
|
|
793
|
-
const effectKey = typeof body.effectKey === 'string' && body.effectKey ? body.effectKey : 'render';
|
|
794
|
-
const contentType = body.contentType ?? (output === 'traits' ? 'application/json' : 'image/png');
|
|
795
|
-
const key = renderArtifactKey(SERVER_CHAIN_ID, address, tokenId, inputsHashHex, output, effectKey);
|
|
796
|
-
// BOTH modes register a row — the row is the `artifacts` manifest's enumeration surface;
|
|
797
|
-
// locator NULL on the bytes mode means "the bytes live in this node's custody at the key".
|
|
798
|
-
const row = { key, address, tokenId, effectKey, outputKey: output, inputsHash: inputsHashHex, contentType };
|
|
799
|
-
const locator = typeof body.locator === 'string' && body.locator ? body.locator : undefined;
|
|
800
|
-
if (locator) {
|
|
801
|
-
indexer.store.putEffectArtifact({ ...row, locator });
|
|
802
|
-
return sendJson(res, 200, { ok: true, mode: 'locator', key, output, locator });
|
|
803
|
-
}
|
|
804
|
-
const bytesB64 = typeof body.bytes_base64 === 'string' && body.bytes_base64 ? body.bytes_base64 : undefined;
|
|
805
|
-
if (bytesB64) {
|
|
806
|
-
const bytes = new Uint8Array(Buffer.from(bytesB64, 'base64'));
|
|
807
|
-
await storage.put(key, { bytes, contentType });
|
|
808
|
-
indexer.store.putEffectArtifact({ ...row, locator: null });
|
|
809
|
-
return sendJson(res, 200, { ok: true, mode: 'bytes', key, output, bytes: bytes.length });
|
|
810
|
-
}
|
|
811
|
-
return sendJson(res, 400, { error: 'provide body.locator (a durable ipfs://ar://https URL) or body.bytes_base64' });
|
|
812
|
-
}
|
|
813
|
-
/**
|
|
814
|
-
* `POST /admin/effect-status {key, address, tokenId, effectKey, status, error?, attempts?}` — a
|
|
815
|
-
* runner reports one run's transient state for the artifact `key` it is producing (the runner
|
|
816
|
-
* computes the key; this node never re-derives it, mirroring /admin/effect-artifacts). `status`
|
|
817
|
-
* 'done' clears the row (artifact presence takes over as truth); 'rendering'/'failed' upsert.
|
|
818
|
-
*/
|
|
819
|
-
async function adminEffectStatus(req, res, indexer) {
|
|
820
|
-
if (!process.env.ABX_RESOLVER_ADMIN_TOKEN) {
|
|
821
|
-
return sendJson(res, 404, { error: 'admin API disabled — set ABX_RESOLVER_ADMIN_TOKEN on the resolver' });
|
|
822
|
-
}
|
|
823
|
-
if (!adminAuthorized(req)) {
|
|
824
|
-
return sendJson(res, 401, { error: 'unauthorized — send Authorization: Bearer <ABX_RESOLVER_ADMIN_TOKEN>' });
|
|
825
|
-
}
|
|
826
|
-
if ((req.method ?? 'GET') !== 'POST')
|
|
827
|
-
return sendJson(res, 405, { error: 'use POST /admin/effect-status' });
|
|
828
|
-
let body;
|
|
829
|
-
try {
|
|
830
|
-
body = await readJsonBody(req);
|
|
831
|
-
}
|
|
832
|
-
catch (err) {
|
|
833
|
-
return sendJson(res, 400, { error: err.message });
|
|
834
|
-
}
|
|
835
|
-
const key = body.key;
|
|
836
|
-
if (!key || !/^0x[0-9a-fA-F]{64}$/.test(key)) {
|
|
837
|
-
return sendJson(res, 400, { error: 'body.key must be the 0x 32-byte artifact key this run produces' });
|
|
838
|
-
}
|
|
839
|
-
const address = body.address;
|
|
840
|
-
if (!address || !ADDRESS_RE.test(address)) {
|
|
841
|
-
return sendJson(res, 400, { error: 'body.address must be a 0x-prefixed 20-byte address' });
|
|
842
|
-
}
|
|
843
|
-
const status = body.status;
|
|
844
|
-
if (status === 'done') {
|
|
845
|
-
indexer.store.clearEffectStatus(key);
|
|
846
|
-
return sendJson(res, 200, { ok: true, cleared: key });
|
|
847
|
-
}
|
|
848
|
-
if (status !== 'rendering' && status !== 'failed') {
|
|
849
|
-
return sendJson(res, 400, { error: "body.status must be 'rendering' | 'failed' | 'done'" });
|
|
850
|
-
}
|
|
851
|
-
indexer.store.putEffectStatus({
|
|
852
|
-
key,
|
|
853
|
-
address,
|
|
854
|
-
tokenId: String(body.tokenId ?? ''),
|
|
855
|
-
effectKey: typeof body.effectKey === 'string' && body.effectKey ? body.effectKey : 'render',
|
|
856
|
-
status,
|
|
857
|
-
error: typeof body.error === 'string' ? body.error.slice(0, 2000) : null,
|
|
858
|
-
attempts: typeof body.attempts === 'number' ? body.attempts : undefined,
|
|
859
|
-
});
|
|
860
|
-
return sendJson(res, 200, { ok: true, key, status });
|
|
861
|
-
}
|
|
862
731
|
/**
|
|
863
732
|
* Per-token effect status for a project — the "did every thumbnail land?" surface behind
|
|
864
733
|
* `GET /api/project/:addr/effects` and `abx verify`. Status is DERIVED, in precedence order:
|
|
@@ -873,8 +742,10 @@ async function effectStatusReport(indexer, state, storage) {
|
|
|
873
742
|
const tokens = [];
|
|
874
743
|
const counts = { upToDate: 0, stale: 0, rendering: 0, failed: 0 };
|
|
875
744
|
if (isCodeProject(state)) {
|
|
876
|
-
|
|
877
|
-
|
|
745
|
+
// Live tokens only: a destroyed id has no render to be up-to-date about, and reporting one
|
|
746
|
+
// would keep a producer re-rendering it forever.
|
|
747
|
+
for (const token of state.tokens.filter((t) => t.lifecycle === 'live')) {
|
|
748
|
+
const { key, found } = await currentRenderArtifact(chainClientLazy(), state, token, storage);
|
|
878
749
|
const published = !found && !!indexer.store.getEffectArtifact(key);
|
|
879
750
|
const row = byKey.get(key.toLowerCase());
|
|
880
751
|
let status;
|
|
@@ -933,8 +804,26 @@ function watchStatusReport(indexer) {
|
|
|
933
804
|
chains,
|
|
934
805
|
};
|
|
935
806
|
}
|
|
936
|
-
/**
|
|
807
|
+
/**
|
|
808
|
+
* The path was well-formed and on the right chain — this node just doesn't index that contract.
|
|
809
|
+
* Code `not_registered` (the control plane's own code for the same condition) so a client can tell
|
|
810
|
+
* it apart from a malformed path (400 `invalid_request`) and a nonexistent route (404
|
|
811
|
+
* `unknown_route`). Those three used to be one indistinguishable `{error: '…'}` 404.
|
|
812
|
+
*/
|
|
813
|
+
function unknownProject(res) {
|
|
814
|
+
sendError(res, 404, 'not_registered', 'this node does not index that contract', {
|
|
815
|
+
hint: 'register it with `abx add <address> --remote <name|url>` (bearer-gated control plane), then `abx index <address> --remote`',
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* A path whose chainId segment isn't the chain this resolver serves. **400 `unsupported_chain`**,
|
|
820
|
+
* matching the control plane's `checkChain` exactly (control-plane.ts) — it's a request error, not a
|
|
821
|
+
* missing resource, and answering 404 made it indistinguishable from "that project isn't indexed
|
|
822
|
+
* here", which sent at least one client hunting a phantom outage. `chains` says what IS served.
|
|
823
|
+
*/
|
|
937
824
|
function wrongChain(res, got) {
|
|
938
|
-
|
|
825
|
+
sendError(res, 400, 'unsupported_chain', `this resolver serves chain ${serverChainId()}, not ${got}`, {
|
|
826
|
+
chains: [serverChainId()],
|
|
827
|
+
});
|
|
939
828
|
}
|
|
940
829
|
//# sourceMappingURL=server.js.map
|