@artblocks/abx-token-api 0.1.0-alpha.5 → 0.1.0-alpha.50
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 +155 -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 +24 -13
- package/dist/control-plane.d.ts.map +1 -1
- package/dist/control-plane.js +289 -43
- package/dist/control-plane.js.map +1 -1
- package/dist/dashboard.d.ts.map +1 -1
- package/dist/dashboard.js +55 -8
- 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 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +257 -70
- 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,27 +1,45 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
|
-
import { 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';
|
|
3
3
|
import { resolveBackend } from '@artblocks/abx-storage';
|
|
4
|
-
import { fallbackImageSvg, IMAGE_MEDIA_TYPE } from './
|
|
5
|
-
import { buildContractMetadata, buildTokenMetadata, fieldMimeType } from './metadata.js';
|
|
6
|
-
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';
|
|
7
7
|
import { depStatusReport } from './deps.js';
|
|
8
|
-
import { ABX_JS } from './abxjs.js';
|
|
9
8
|
import { resolveFieldBytes, resolveFieldRendered, COLLECTION_TOKEN_ID } from './resolve.js';
|
|
10
9
|
import { renderDashboard, renderIndex } from './dashboard.js';
|
|
11
10
|
import { watchIntervalMs } from './watcher.js';
|
|
12
|
-
import { requireBearer, routeControlPlane, safeLocators, sendJson, serviceDescriptor, summarize, } from './control-plane.js';
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
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
|
+
}
|
|
22
40
|
/** The context the /v1 control plane + descriptor run against (control-plane.ts owns the routes). */
|
|
23
41
|
function controlPlaneCtx(indexer, storage, baseUrl) {
|
|
24
|
-
return { indexer, storage, chainId:
|
|
42
|
+
return { indexer, storage, chainId: serverChainId(), chainKey: serverChainKey(), baseUrl };
|
|
25
43
|
}
|
|
26
44
|
/**
|
|
27
45
|
* Resolve a token's image bytes by dispatching on the `image` field's single active
|
|
@@ -35,14 +53,14 @@ function controlPlaneCtx(indexer, storage, baseUrl) {
|
|
|
35
53
|
*/
|
|
36
54
|
export async function resolveContent(state, token, storage) {
|
|
37
55
|
const image = fieldOf(token.fields, F.image) ?? fieldOf(state.collectionFields, F.image);
|
|
38
|
-
const onChain = await resolveFieldBytes(
|
|
56
|
+
const onChain = await resolveFieldBytes(chainClientLazy(), image); // inline / inline-gzip / reader / reader-gzip
|
|
39
57
|
if (onChain)
|
|
40
58
|
return { contentType: IMAGE_MEDIA_TYPE, body: onChain };
|
|
41
59
|
// computed on-chain at read (`renderer`) — best-effort: a reverting renderer degrades to the
|
|
42
60
|
// placeholder rather than erroring the route (mirrors the on-chain renderer's fallback rule).
|
|
43
61
|
if (image?.representation === R.renderer) {
|
|
44
62
|
try {
|
|
45
|
-
const rendered = await resolveFieldRendered(
|
|
63
|
+
const rendered = await resolveFieldRendered(chainClientLazy(), state.address, token.tokenId, F.image, image);
|
|
46
64
|
if (rendered)
|
|
47
65
|
return { contentType: rendered.contentType, body: rendered.bytes };
|
|
48
66
|
}
|
|
@@ -60,7 +78,11 @@ export async function resolveContent(state, token, storage) {
|
|
|
60
78
|
// so a token with no resolvable image looks the same whether served here or self-resolved.
|
|
61
79
|
return { contentType: IMAGE_MEDIA_TYPE, body: fallbackImageSvg(state.address, token.tokenId) };
|
|
62
80
|
}
|
|
63
|
-
/** 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}. */
|
|
64
86
|
function withinCap(tokenId, maxInvocations) {
|
|
65
87
|
if (maxInvocations == null)
|
|
66
88
|
return false;
|
|
@@ -72,11 +94,38 @@ function withinCap(tokenId, maxInvocations) {
|
|
|
72
94
|
return false;
|
|
73
95
|
}
|
|
74
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
|
+
}
|
|
75
121
|
/**
|
|
76
122
|
* The view to resolve for a requested tokenId. A token's metadata is its token id (no
|
|
77
123
|
* decoupling), so identity and content both come from that token. Returns a synthesized,
|
|
78
124
|
* unminted view for a not-yet-minted id within the cap (pre-mint warming — the multi-token
|
|
79
|
-
* 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).
|
|
80
129
|
*/
|
|
81
130
|
export function resolveTokenView(state, tokenId) {
|
|
82
131
|
const issued = state.tokens.find((t) => t.tokenId === tokenId);
|
|
@@ -84,12 +133,19 @@ export function resolveTokenView(state, tokenId) {
|
|
|
84
133
|
return null;
|
|
85
134
|
return {
|
|
86
135
|
tokenId,
|
|
87
|
-
|
|
136
|
+
lifecycle: issued?.lifecycle ?? 'unminted',
|
|
88
137
|
owner: issued?.owner ?? null,
|
|
89
138
|
tokenURI: issued?.tokenURI ?? null,
|
|
90
139
|
fields: issued?.fields ?? [],
|
|
91
140
|
lockedFields: issued?.lockedFields ?? [],
|
|
92
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,
|
|
93
149
|
};
|
|
94
150
|
}
|
|
95
151
|
export const DEFAULT_PORT = 8787;
|
|
@@ -137,7 +193,7 @@ export function createTokenApiServer(opts) {
|
|
|
137
193
|
// NEVER hand a raw internal error to the client. An unexpected failure here is usually an
|
|
138
194
|
// upstream RPC error, and viem's message embeds the full endpoint URL — which for a keyed
|
|
139
195
|
// endpoint IS a credential. On a multi-tenant provider that would leak the operator's RPC key
|
|
140
|
-
// to any tenant who can trigger a 500 (see
|
|
196
|
+
// to any tenant who can trigger a 500 (see site/content/docs/using-abx/remote-services.mdx). So:
|
|
141
197
|
// full detail to the operator's log, a generic + redacted message on the wire.
|
|
142
198
|
const detail = err.message ?? 'unknown error';
|
|
143
199
|
console.error(`[server] ${req.method} ${req.url} failed: ${detail}`);
|
|
@@ -187,19 +243,19 @@ async function route(req, res, indexer, baseUrl, storage) {
|
|
|
187
243
|
// GET / — read-only node index: which contracts this resolver serves (no actions).
|
|
188
244
|
if (parts.length === 0) {
|
|
189
245
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
190
|
-
res.end(renderIndex(indexer.listProjects(), baseUrl,
|
|
246
|
+
res.end(renderIndex(indexer.listProjects(), baseUrl, serverChainId()));
|
|
191
247
|
return;
|
|
192
248
|
}
|
|
193
249
|
// GET /d/:chainId/:addr — the per-contract dashboard (read-only; namespaced so one host can
|
|
194
250
|
// serve many contracts/chains). Actions live behind the admin token, not on this page.
|
|
195
251
|
if (parts[0] === 'd' && parts[1] && parts[2]) {
|
|
196
|
-
if (Number(parts[1]) !==
|
|
252
|
+
if (Number(parts[1]) !== serverChainId())
|
|
197
253
|
return wrongChain(res, parts[1]);
|
|
198
254
|
const state = indexer.getProject(parts[2]);
|
|
199
255
|
if (!state)
|
|
200
|
-
return
|
|
256
|
+
return unknownProject(res);
|
|
201
257
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
202
|
-
res.end(renderDashboard(state, baseUrl,
|
|
258
|
+
res.end(renderDashboard(state, baseUrl, serverChainId()));
|
|
203
259
|
return;
|
|
204
260
|
}
|
|
205
261
|
// GET /health
|
|
@@ -207,7 +263,7 @@ async function route(req, res, indexer, baseUrl, storage) {
|
|
|
207
263
|
return sendJson(res, 200, { ok: true, baseUrl });
|
|
208
264
|
// GET /.well-known/abx-service — the service descriptor (public): what this node supports,
|
|
209
265
|
// agent-readably, so a client can match a project's needs to this service BEFORE trusting it
|
|
210
|
-
// with a registration. See
|
|
266
|
+
// with a registration. See site/content/docs/using-abx/remote-services.mdx.
|
|
211
267
|
if (parts[0] === '.well-known' && parts[1] === 'abx-service') {
|
|
212
268
|
return sendJson(res, 200, await serviceDescriptor(controlPlaneCtx(indexer, storage, baseUrl)));
|
|
213
269
|
}
|
|
@@ -226,17 +282,38 @@ async function route(req, res, indexer, baseUrl, storage) {
|
|
|
226
282
|
// entry-fetch-failure fallback; template mode: the generator document assembled from
|
|
227
283
|
// chain). This is the same document a render node captures — the live view IS the input.
|
|
228
284
|
if (parts[0] === 'a' && parts[1] && parts[2] && parts[3] !== undefined) {
|
|
229
|
-
if (Number(parts[1]) !==
|
|
285
|
+
if (Number(parts[1]) !== serverChainId())
|
|
230
286
|
return wrongChain(res, parts[1]);
|
|
231
287
|
const state = indexer.getProject(parts[2]);
|
|
232
288
|
if (!state)
|
|
233
|
-
return
|
|
289
|
+
return unknownProject(res);
|
|
234
290
|
const token = resolveTokenView(state, parts[3]);
|
|
235
291
|
if (!token)
|
|
236
|
-
return
|
|
237
|
-
|
|
238
|
-
|
|
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')
|
|
239
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
|
+
}
|
|
240
317
|
if (view.kind === 'redirect') {
|
|
241
318
|
res.writeHead(302, { location: view.location, 'cache-control': 'no-store' });
|
|
242
319
|
res.end();
|
|
@@ -270,12 +347,12 @@ async function route(req, res, indexer, baseUrl, storage) {
|
|
|
270
347
|
// same registry-order resolution the generator document uses, sharing its cache) plus
|
|
271
348
|
// the URL-budget flag for directory projects. Public, like the other status reads.
|
|
272
349
|
if (parts[1] === 'deps' && parts[2] && parts[3]) {
|
|
273
|
-
if (Number(parts[2]) !==
|
|
350
|
+
if (Number(parts[2]) !== serverChainId())
|
|
274
351
|
return wrongChain(res, parts[2]);
|
|
275
352
|
const state = indexer.getProject(parts[3]);
|
|
276
353
|
if (!state)
|
|
277
|
-
return
|
|
278
|
-
return sendJson(res, 200, await depStatusReport(
|
|
354
|
+
return unknownProject(res);
|
|
355
|
+
return sendJson(res, 200, await depStatusReport(chainClientLazy(), state));
|
|
279
356
|
}
|
|
280
357
|
if (parts[1] === 'project' && parts[2]) {
|
|
281
358
|
const address = parts[2];
|
|
@@ -295,31 +372,62 @@ async function route(req, res, indexer, baseUrl, storage) {
|
|
|
295
372
|
if (parts[3] === 'effects') {
|
|
296
373
|
const state = indexer.getProject(address);
|
|
297
374
|
if (!state)
|
|
298
|
-
return
|
|
375
|
+
return unknownProject(res);
|
|
299
376
|
return sendJson(res, 200, await effectStatusReport(indexer, state, storage));
|
|
300
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
|
+
}
|
|
301
403
|
// GET /api/project/:addr
|
|
302
404
|
const state = indexer.getProject(address);
|
|
303
405
|
if (!state)
|
|
304
|
-
return
|
|
406
|
+
return unknownProject(res);
|
|
305
407
|
return sendJson(res, 200, state);
|
|
306
408
|
}
|
|
307
|
-
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
|
+
});
|
|
308
412
|
}
|
|
309
413
|
// GET /t/:chainId/:addr/:id and /t/:chainId/:addr/:id/image
|
|
310
414
|
if (parts[0] === 't' && parts[1] && parts[2] && parts[3] !== undefined) {
|
|
311
|
-
if (Number(parts[1]) !==
|
|
415
|
+
if (Number(parts[1]) !== serverChainId())
|
|
312
416
|
return wrongChain(res, parts[1]);
|
|
313
417
|
const address = parts[2];
|
|
314
418
|
const tokenId = parts[3];
|
|
315
419
|
const state = indexer.getProject(address);
|
|
316
420
|
if (!state)
|
|
317
|
-
return
|
|
421
|
+
return unknownProject(res);
|
|
318
422
|
// Resolve the issuance token → its metadata-id slot (identity vs. content), synthesizing a
|
|
319
423
|
// pre-mint view for a not-yet-issued id within the cap so metadata warms before mint.
|
|
320
424
|
const token = resolveTokenView(state, tokenId);
|
|
321
425
|
if (!token)
|
|
322
|
-
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;
|
|
323
431
|
if (parts[4] === 'image') {
|
|
324
432
|
// the render-effect seam: no explicit image field + a code project ⇒ serve the
|
|
325
433
|
// artifact stored at the CURRENT inputsHash address, when a producer has run.
|
|
@@ -327,13 +435,14 @@ async function route(req, res, indexer, baseUrl, storage) {
|
|
|
327
435
|
state.collectionFields.some((f) => f.field === 'image');
|
|
328
436
|
if (!hasImageField && isCodeProject(state)) {
|
|
329
437
|
try {
|
|
330
|
-
const { key, found } = await currentRenderArtifact(
|
|
331
|
-
//
|
|
332
|
-
// share
|
|
333
|
-
//
|
|
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.
|
|
334
443
|
const published = indexer.store.getEffectArtifact(key);
|
|
335
444
|
if (published?.locator) {
|
|
336
|
-
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' });
|
|
337
446
|
res.end();
|
|
338
447
|
return;
|
|
339
448
|
}
|
|
@@ -357,7 +466,7 @@ async function route(req, res, indexer, baseUrl, storage) {
|
|
|
357
466
|
// a computed LOCATOR (renderer returning text/uri-list): the bytes ARE a URI — redirect.
|
|
358
467
|
if (content.contentType === 'text/uri-list') {
|
|
359
468
|
const target = typeof content.body === 'string' ? content.body : new TextDecoder().decode(content.body);
|
|
360
|
-
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' });
|
|
361
470
|
res.end();
|
|
362
471
|
return;
|
|
363
472
|
}
|
|
@@ -375,22 +484,58 @@ async function route(req, res, indexer, baseUrl, storage) {
|
|
|
375
484
|
}
|
|
376
485
|
return serveFieldArtifact(res, state, token, parts[5], storage, displayMeta(indexer, address));
|
|
377
486
|
}
|
|
378
|
-
return sendJson(res, 200, await buildTokenMetadata(
|
|
487
|
+
return sendJson(res, 200, await buildTokenMetadata(chainClientLazy(), state, token, baseUrl, serverChainId(), displayMeta(indexer, address), storage, planeAccess(indexer)));
|
|
379
488
|
}
|
|
380
489
|
// GET /c/:chainId/:addr and /c/:chainId/:addr/data/{field}
|
|
381
490
|
if (parts[0] === 'c' && parts[1] && parts[2]) {
|
|
382
|
-
if (Number(parts[1]) !==
|
|
491
|
+
if (Number(parts[1]) !== serverChainId())
|
|
383
492
|
return wrongChain(res, parts[1]);
|
|
384
493
|
const address = parts[2];
|
|
385
494
|
const state = indexer.getProject(address);
|
|
386
495
|
if (!state)
|
|
387
|
-
return
|
|
496
|
+
return unknownProject(res);
|
|
388
497
|
if (parts[3] === 'data' && parts[4]) {
|
|
389
498
|
return serveFieldArtifact(res, state, null, parts[4], storage, displayMeta(indexer, address));
|
|
390
499
|
}
|
|
391
|
-
return sendJson(res, 200, await buildContractMetadata(
|
|
500
|
+
return sendJson(res, 200, await buildContractMetadata(chainClientLazy(), state, baseUrl, serverChainId(), displayMeta(indexer, address), storage));
|
|
501
|
+
}
|
|
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]}` } : {}) });
|
|
392
534
|
}
|
|
393
|
-
|
|
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
|
+
});
|
|
394
539
|
}
|
|
395
540
|
/** The manifest's read surface over the effect-artifact registry (metadata.ts stays store-free). */
|
|
396
541
|
function planeAccess(indexer) {
|
|
@@ -399,21 +544,33 @@ function planeAccess(indexer) {
|
|
|
399
544
|
get: (key) => indexer.store.getEffectArtifact(key),
|
|
400
545
|
};
|
|
401
546
|
}
|
|
402
|
-
/** Serve one EFFECT artifact's bytes at the CURRENT settled inputsHash
|
|
403
|
-
*
|
|
404
|
-
*
|
|
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). */
|
|
405
554
|
async function serveEffectArtifact(res, state, token, effectKey, outputKey, storage, indexer) {
|
|
406
555
|
if (!isCodeProject(state))
|
|
407
556
|
return sendJson(res, 404, { error: 'no effect artifacts — not a code project' });
|
|
408
557
|
try {
|
|
409
|
-
const hash = await currentSettledInputsHash(
|
|
558
|
+
const hash = await currentSettledInputsHash(chainClientLazy(), state, token);
|
|
410
559
|
const key = renderArtifactKey(state.chainId, state.address, token.tokenId, hash, outputKey, effectKey);
|
|
411
560
|
const row = indexer.store.getEffectArtifact(key);
|
|
412
561
|
if (row?.locator) {
|
|
413
|
-
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' });
|
|
414
563
|
res.end();
|
|
415
564
|
return;
|
|
416
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
|
+
}
|
|
417
574
|
const stored = await storage.get(key);
|
|
418
575
|
if (stored) {
|
|
419
576
|
res.writeHead(200, {
|
|
@@ -443,21 +600,21 @@ async function serveFieldArtifact(res, state, token, field, storage, display) {
|
|
|
443
600
|
return sendJson(res, 404, { error: `no '${field}' field set` });
|
|
444
601
|
const tokenId = token?.tokenId ?? '0';
|
|
445
602
|
try {
|
|
446
|
-
const onChain = await resolveFieldBytes(
|
|
603
|
+
const onChain = await resolveFieldBytes(chainClientLazy(), entry);
|
|
447
604
|
if (onChain) {
|
|
448
605
|
res.writeHead(200, {
|
|
449
|
-
'content-type': await fieldMimeType(
|
|
606
|
+
'content-type': await fieldMimeType(chainClientLazy(), state, entry, field, tokenId, display, storage),
|
|
450
607
|
'cache-control': 'public, max-age=300',
|
|
451
608
|
});
|
|
452
609
|
res.end(onChain);
|
|
453
610
|
return;
|
|
454
611
|
}
|
|
455
612
|
// computed on-chain at read — the collection surface passes the sentinel id (no token).
|
|
456
|
-
const rendered = await resolveFieldRendered(
|
|
613
|
+
const rendered = await resolveFieldRendered(chainClientLazy(), state.address, token?.tokenId ?? COLLECTION_TOKEN_ID, field, entry);
|
|
457
614
|
if (rendered) {
|
|
458
615
|
if (rendered.contentType === 'text/uri-list') {
|
|
459
616
|
const target = new TextDecoder().decode(rendered.bytes).trim();
|
|
460
|
-
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' });
|
|
461
618
|
res.end();
|
|
462
619
|
return;
|
|
463
620
|
}
|
|
@@ -477,15 +634,15 @@ async function serveFieldArtifact(res, state, token, field, storage, display) {
|
|
|
477
634
|
}
|
|
478
635
|
const bridged = display.contentLocators?.[entry.value.toLowerCase()];
|
|
479
636
|
if (bridged) {
|
|
480
|
-
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' });
|
|
481
638
|
res.end();
|
|
482
639
|
return;
|
|
483
640
|
}
|
|
484
641
|
return sendJson(res, 404, { error: `'${field}' bytes not in this node's custody (on-chain ${entry.representation} anchor only)` });
|
|
485
642
|
}
|
|
486
|
-
const locator = fieldLocatorUrl(entry, tokenId);
|
|
643
|
+
const locator = fieldLocatorUrl(state, entry, tokenId);
|
|
487
644
|
if (locator) {
|
|
488
|
-
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' });
|
|
489
646
|
res.end();
|
|
490
647
|
return;
|
|
491
648
|
}
|
|
@@ -496,8 +653,18 @@ async function serveFieldArtifact(res, state, token, field, storage, display) {
|
|
|
496
653
|
return sendJson(res, 404, { error: `'${field}' (${entry.representation}) is not byte-servable from this node` });
|
|
497
654
|
}
|
|
498
655
|
/** A locator-representation field's URL (`{id}` substituted), or null for non-locator forms. */
|
|
499
|
-
function fieldLocatorUrl(entry, tokenId) {
|
|
500
|
-
|
|
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) {
|
|
501
668
|
const text = Buffer.from(entry.value.slice(2), 'hex').toString('utf8').trim();
|
|
502
669
|
return text || null;
|
|
503
670
|
}
|
|
@@ -575,8 +742,10 @@ async function effectStatusReport(indexer, state, storage) {
|
|
|
575
742
|
const tokens = [];
|
|
576
743
|
const counts = { upToDate: 0, stale: 0, rendering: 0, failed: 0 };
|
|
577
744
|
if (isCodeProject(state)) {
|
|
578
|
-
|
|
579
|
-
|
|
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);
|
|
580
749
|
const published = !found && !!indexer.store.getEffectArtifact(key);
|
|
581
750
|
const row = byKey.get(key.toLowerCase());
|
|
582
751
|
let status;
|
|
@@ -635,8 +804,26 @@ function watchStatusReport(indexer) {
|
|
|
635
804
|
chains,
|
|
636
805
|
};
|
|
637
806
|
}
|
|
638
|
-
/**
|
|
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
|
+
*/
|
|
639
824
|
function wrongChain(res, got) {
|
|
640
|
-
|
|
825
|
+
sendError(res, 400, 'unsupported_chain', `this resolver serves chain ${serverChainId()}, not ${got}`, {
|
|
826
|
+
chains: [serverChainId()],
|
|
827
|
+
});
|
|
641
828
|
}
|
|
642
829
|
//# sourceMappingURL=server.js.map
|