@artblocks/abx-token-api 0.1.0-alpha.5 → 0.1.0-alpha.51
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 +165 -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 +5 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +282 -71
- 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,52 @@ 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` reads as "not available yet" and invites a retry that can
|
|
103
|
+
* never succeed.
|
|
104
|
+
*
|
|
105
|
+
* **There is deliberately no `contractType` check here.** An edition must never `410` — its `uri(id)`
|
|
106
|
+
* has no existence gate and a zero-supply id can mint again — and the way that is guaranteed is that
|
|
107
|
+
* `lifecycle` cannot *be* `'burned'` on that standard (it folds to `'no-live-copies'`). The first
|
|
108
|
+
* version of this function carried an `isEditionState` guard beside the check, which meant the rule
|
|
109
|
+
* lived in two places and held only as long as everyone remembered the second one. It is in the type
|
|
110
|
+
* now: `'burned'` means permanent, on either standard, and this route needs to know nothing else.
|
|
111
|
+
*
|
|
112
|
+
* Returns `true` when it has answered the request.
|
|
113
|
+
*/
|
|
114
|
+
function goneIfBurned(res, token) {
|
|
115
|
+
if (token.lifecycle !== 'burned')
|
|
116
|
+
return false;
|
|
117
|
+
sendError(res, 410, 'burned', `token ${token.tokenId} was burned — it no longer exists on chain`, { burned: true });
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* An unminted token has one public representation: its metadata route returns `{minted: false}`.
|
|
122
|
+
* Every child content route (image, data, and live view) is absent until the mint exists. Keeping
|
|
123
|
+
* this gate at the router prevents placeholder images, synthetic live views, or collection-field
|
|
124
|
+
* fallbacks from making a not-yet-created token look real.
|
|
125
|
+
*
|
|
126
|
+
* Returns `true` when it has answered a content request. The metadata route deliberately handles
|
|
127
|
+
* the same lifecycle separately because its successful, minimal response is part of the public API.
|
|
128
|
+
*/
|
|
129
|
+
function unavailableIfUnminted(res, token) {
|
|
130
|
+
if (token.lifecycle !== 'unminted')
|
|
131
|
+
return false;
|
|
132
|
+
sendError(res, 404, 'not_minted', `token ${token.tokenId} has not been minted`, { minted: false });
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
75
135
|
/**
|
|
76
136
|
* The view to resolve for a requested tokenId. A token's metadata is its token id (no
|
|
77
137
|
* decoupling), so identity and content both come from that token. Returns a synthesized,
|
|
78
|
-
* unminted view for a not-yet-minted id within the cap (
|
|
79
|
-
*
|
|
138
|
+
* unminted view for a not-yet-minted id within the cap (the multi-token analogue of the 1/1's
|
|
139
|
+
* seed-token-0, which the SDK's fold seeds unconditionally — see
|
|
140
|
+
* `reconstruct.ts`'s `assembleState`, so a fresh '1of1'/'1of1-edition' always has an `issued`
|
|
141
|
+
* entry for id 0 despite carrying no `maxInvocations` cap), or `null` when the id is genuinely
|
|
142
|
+
* unknown (→ 404).
|
|
80
143
|
*/
|
|
81
144
|
export function resolveTokenView(state, tokenId) {
|
|
82
145
|
const issued = state.tokens.find((t) => t.tokenId === tokenId);
|
|
@@ -84,12 +147,19 @@ export function resolveTokenView(state, tokenId) {
|
|
|
84
147
|
return null;
|
|
85
148
|
return {
|
|
86
149
|
tokenId,
|
|
87
|
-
|
|
150
|
+
lifecycle: issued?.lifecycle ?? 'unminted',
|
|
88
151
|
owner: issued?.owner ?? null,
|
|
89
152
|
tokenURI: issued?.tokenURI ?? null,
|
|
90
153
|
fields: issued?.fields ?? [],
|
|
91
154
|
lockedFields: issued?.lockedFields ?? [],
|
|
92
155
|
params: issued?.params,
|
|
156
|
+
// ERC-1155 editions only — absent on `issued` (a 721 token, or an edition id the fold never
|
|
157
|
+
// touched) stays absent here too; dropping them was a real bug (found while generalizing this
|
|
158
|
+
// route for editions): every synthesized/passthrough view silently lost supply/cap/holders.
|
|
159
|
+
supply: issued?.supply,
|
|
160
|
+
maxSupply: issued?.maxSupply,
|
|
161
|
+
maxSupplyOverridden: issued?.maxSupplyOverridden,
|
|
162
|
+
holders: issued?.holders,
|
|
93
163
|
};
|
|
94
164
|
}
|
|
95
165
|
export const DEFAULT_PORT = 8787;
|
|
@@ -137,7 +207,7 @@ export function createTokenApiServer(opts) {
|
|
|
137
207
|
// NEVER hand a raw internal error to the client. An unexpected failure here is usually an
|
|
138
208
|
// upstream RPC error, and viem's message embeds the full endpoint URL — which for a keyed
|
|
139
209
|
// 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
|
|
210
|
+
// to any tenant who can trigger a 500 (see site/content/docs/using-abx/remote-services.mdx). So:
|
|
141
211
|
// full detail to the operator's log, a generic + redacted message on the wire.
|
|
142
212
|
const detail = err.message ?? 'unknown error';
|
|
143
213
|
console.error(`[server] ${req.method} ${req.url} failed: ${detail}`);
|
|
@@ -187,19 +257,19 @@ async function route(req, res, indexer, baseUrl, storage) {
|
|
|
187
257
|
// GET / — read-only node index: which contracts this resolver serves (no actions).
|
|
188
258
|
if (parts.length === 0) {
|
|
189
259
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
190
|
-
res.end(renderIndex(indexer.listProjects(), baseUrl,
|
|
260
|
+
res.end(renderIndex(indexer.listProjects(), baseUrl, serverChainId()));
|
|
191
261
|
return;
|
|
192
262
|
}
|
|
193
263
|
// GET /d/:chainId/:addr — the per-contract dashboard (read-only; namespaced so one host can
|
|
194
264
|
// serve many contracts/chains). Actions live behind the admin token, not on this page.
|
|
195
265
|
if (parts[0] === 'd' && parts[1] && parts[2]) {
|
|
196
|
-
if (Number(parts[1]) !==
|
|
266
|
+
if (Number(parts[1]) !== serverChainId())
|
|
197
267
|
return wrongChain(res, parts[1]);
|
|
198
268
|
const state = indexer.getProject(parts[2]);
|
|
199
269
|
if (!state)
|
|
200
|
-
return
|
|
270
|
+
return unknownProject(res);
|
|
201
271
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
202
|
-
res.end(renderDashboard(state, baseUrl,
|
|
272
|
+
res.end(renderDashboard(state, baseUrl, serverChainId()));
|
|
203
273
|
return;
|
|
204
274
|
}
|
|
205
275
|
// GET /health
|
|
@@ -207,7 +277,7 @@ async function route(req, res, indexer, baseUrl, storage) {
|
|
|
207
277
|
return sendJson(res, 200, { ok: true, baseUrl });
|
|
208
278
|
// GET /.well-known/abx-service — the service descriptor (public): what this node supports,
|
|
209
279
|
// agent-readably, so a client can match a project's needs to this service BEFORE trusting it
|
|
210
|
-
// with a registration. See
|
|
280
|
+
// with a registration. See site/content/docs/using-abx/remote-services.mdx.
|
|
211
281
|
if (parts[0] === '.well-known' && parts[1] === 'abx-service') {
|
|
212
282
|
return sendJson(res, 200, await serviceDescriptor(controlPlaneCtx(indexer, storage, baseUrl)));
|
|
213
283
|
}
|
|
@@ -226,17 +296,40 @@ async function route(req, res, indexer, baseUrl, storage) {
|
|
|
226
296
|
// entry-fetch-failure fallback; template mode: the generator document assembled from
|
|
227
297
|
// chain). This is the same document a render node captures — the live view IS the input.
|
|
228
298
|
if (parts[0] === 'a' && parts[1] && parts[2] && parts[3] !== undefined) {
|
|
229
|
-
if (Number(parts[1]) !==
|
|
299
|
+
if (Number(parts[1]) !== serverChainId())
|
|
230
300
|
return wrongChain(res, parts[1]);
|
|
231
301
|
const state = indexer.getProject(parts[2]);
|
|
232
302
|
if (!state)
|
|
233
|
-
return
|
|
303
|
+
return unknownProject(res);
|
|
234
304
|
const token = resolveTokenView(state, parts[3]);
|
|
235
305
|
if (!token)
|
|
236
|
-
return
|
|
237
|
-
|
|
238
|
-
|
|
306
|
+
return sendError(res, 404, 'not_registered', 'unknown token — not minted, and outside this project\'s supply cap');
|
|
307
|
+
if (goneIfBurned(res, token))
|
|
308
|
+
return;
|
|
309
|
+
if (unavailableIfUnminted(res, token))
|
|
310
|
+
return;
|
|
311
|
+
// Both no-code verdicts are answered BEFORE the chain client, since neither needs an RPC (see
|
|
312
|
+
// {liveViewAvailability} for why the two are distinguished at all — collapsing them cost a
|
|
313
|
+
// tester a day). A plain 503 in this route's own `{error}` shape rather than a structured
|
|
314
|
+
// `code`: the versioned control plane's ServiceErrorCode set is pinned by remote-services.md and
|
|
315
|
+
// has no member for "not ready yet", and widening a published interface for one serving-route
|
|
316
|
+
// diagnostic is a decision, not a bug fix. The status code carries the retry semantics.
|
|
317
|
+
const availability = liveViewAvailability(state);
|
|
318
|
+
if (availability === 'indexing') {
|
|
319
|
+
res.writeHead(503, { 'content-type': 'application/json; charset=utf-8', 'retry-after': '5' });
|
|
320
|
+
res.end(JSON.stringify({
|
|
321
|
+
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>.",
|
|
322
|
+
}, null, 2));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (availability === 'not-a-code-project')
|
|
239
326
|
return sendJson(res, 404, { error: 'no live view — not a code project' });
|
|
327
|
+
const view = await resolveLiveView(chainClientLazy(), state, token);
|
|
328
|
+
if (!view) {
|
|
329
|
+
return sendJson(res, 404, {
|
|
330
|
+
error: 'no live view — this project has a `code` field whose locator is not serveable (the `code` field is locators-only: ipfs/arweave/url)',
|
|
331
|
+
});
|
|
332
|
+
}
|
|
240
333
|
if (view.kind === 'redirect') {
|
|
241
334
|
res.writeHead(302, { location: view.location, 'cache-control': 'no-store' });
|
|
242
335
|
res.end();
|
|
@@ -270,12 +363,12 @@ async function route(req, res, indexer, baseUrl, storage) {
|
|
|
270
363
|
// same registry-order resolution the generator document uses, sharing its cache) plus
|
|
271
364
|
// the URL-budget flag for directory projects. Public, like the other status reads.
|
|
272
365
|
if (parts[1] === 'deps' && parts[2] && parts[3]) {
|
|
273
|
-
if (Number(parts[2]) !==
|
|
366
|
+
if (Number(parts[2]) !== serverChainId())
|
|
274
367
|
return wrongChain(res, parts[2]);
|
|
275
368
|
const state = indexer.getProject(parts[3]);
|
|
276
369
|
if (!state)
|
|
277
|
-
return
|
|
278
|
-
return sendJson(res, 200, await depStatusReport(
|
|
370
|
+
return unknownProject(res);
|
|
371
|
+
return sendJson(res, 200, await depStatusReport(chainClientLazy(), state));
|
|
279
372
|
}
|
|
280
373
|
if (parts[1] === 'project' && parts[2]) {
|
|
281
374
|
const address = parts[2];
|
|
@@ -295,31 +388,70 @@ async function route(req, res, indexer, baseUrl, storage) {
|
|
|
295
388
|
if (parts[3] === 'effects') {
|
|
296
389
|
const state = indexer.getProject(address);
|
|
297
390
|
if (!state)
|
|
298
|
-
return
|
|
391
|
+
return unknownProject(res);
|
|
299
392
|
return sendJson(res, 200, await effectStatusReport(indexer, state, storage));
|
|
300
393
|
}
|
|
394
|
+
// GET /api/project/:addr/artifacts?token=<id> — the typed artifact-manifest read behind
|
|
395
|
+
// `abx artifacts --remote`. PUBLIC, like /effects and /api/project/:addr: it reports what this
|
|
396
|
+
// node already serves in a tokenURI document, so bearer-gating it would hide nothing.
|
|
397
|
+
// Per-token by construction — a code project's settled `inputsHash` is that token's own, so
|
|
398
|
+
// there is no coherent all-tokens form of this answer.
|
|
399
|
+
if (parts[3] === 'artifacts') {
|
|
400
|
+
const state = indexer.getProject(address);
|
|
401
|
+
if (!state)
|
|
402
|
+
return unknownProject(res);
|
|
403
|
+
const tokenId = url.searchParams.get('token');
|
|
404
|
+
if (tokenId === null || !/^\d+$/.test(tokenId)) {
|
|
405
|
+
return sendError(res, 400, 'invalid_request', 'artifacts is a per-token read — pass ?token=<decimal id>');
|
|
406
|
+
}
|
|
407
|
+
const token = state.tokens.find((t) => t.tokenId === tokenId);
|
|
408
|
+
// Same code + wording the metadata routes already use for this exact condition, so a client
|
|
409
|
+
// can't tell "unknown token" apart by which route it asked.
|
|
410
|
+
if (!token)
|
|
411
|
+
return sendError(res, 404, 'not_registered', "unknown token — not minted, and outside this project's supply cap");
|
|
412
|
+
// A burned 721 has no artifacts to report, and saying so as an empty manifest would read as
|
|
413
|
+
// "nothing rendered yet". Answer what the contract answers — the same gate the metadata and
|
|
414
|
+
// image routes apply.
|
|
415
|
+
if (goneIfBurned(res, token))
|
|
416
|
+
return;
|
|
417
|
+
return sendJson(res, 200, await tokenArtifacts(chainClientLazy(), state, token, baseUrl, serverChainId(), displayMeta(indexer, address), storage, planeAccess(indexer)));
|
|
418
|
+
}
|
|
301
419
|
// GET /api/project/:addr
|
|
302
420
|
const state = indexer.getProject(address);
|
|
303
421
|
if (!state)
|
|
304
|
-
return
|
|
422
|
+
return unknownProject(res);
|
|
305
423
|
return sendJson(res, 200, state);
|
|
306
424
|
}
|
|
307
|
-
return
|
|
425
|
+
return sendError(res, 404, 'unknown_route', 'unknown api route', {
|
|
426
|
+
routes: ['/api/projects', '/api/watch', '/api/project/:address', '/api/project/:address/artifacts', '/api/project/:address/effects', '/api/project/:address/verify', '/api/deps/:chainId/:address'],
|
|
427
|
+
});
|
|
308
428
|
}
|
|
309
429
|
// GET /t/:chainId/:addr/:id and /t/:chainId/:addr/:id/image
|
|
310
430
|
if (parts[0] === 't' && parts[1] && parts[2] && parts[3] !== undefined) {
|
|
311
|
-
if (Number(parts[1]) !==
|
|
431
|
+
if (Number(parts[1]) !== serverChainId())
|
|
312
432
|
return wrongChain(res, parts[1]);
|
|
313
433
|
const address = parts[2];
|
|
314
434
|
const tokenId = parts[3];
|
|
315
435
|
const state = indexer.getProject(address);
|
|
316
436
|
if (!state)
|
|
317
|
-
return
|
|
437
|
+
return unknownProject(res);
|
|
318
438
|
// Resolve the issuance token → its metadata-id slot (identity vs. content), synthesizing a
|
|
319
439
|
// pre-mint view for a not-yet-issued id within the cap so metadata warms before mint.
|
|
320
440
|
const token = resolveTokenView(state, tokenId);
|
|
321
441
|
if (!token)
|
|
322
|
-
return
|
|
442
|
+
return sendError(res, 404, 'not_registered', 'unknown token — not minted, and outside this project\'s supply cap');
|
|
443
|
+
// Before the image / data / metadata branches below: all three would otherwise compose an answer
|
|
444
|
+
// for an id the contract disowns (and `/image` would warm a placeholder for it forever).
|
|
445
|
+
if (goneIfBurned(res, token))
|
|
446
|
+
return;
|
|
447
|
+
// An in-range token position is a valid metadata URL before mint, but it has no token content.
|
|
448
|
+
// Return only the lifecycle signal; do not publish image/animation URLs that intentionally 404.
|
|
449
|
+
if (token.lifecycle === 'unminted') {
|
|
450
|
+
if (parts.length === 4)
|
|
451
|
+
return sendJson(res, 200, { minted: false });
|
|
452
|
+
unavailableIfUnminted(res, token);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
323
455
|
if (parts[4] === 'image') {
|
|
324
456
|
// the render-effect seam: no explicit image field + a code project ⇒ serve the
|
|
325
457
|
// artifact stored at the CURRENT inputsHash address, when a producer has run.
|
|
@@ -327,13 +459,14 @@ async function route(req, res, indexer, baseUrl, storage) {
|
|
|
327
459
|
state.collectionFields.some((f) => f.field === 'image');
|
|
328
460
|
if (!hasImageField && isCodeProject(state)) {
|
|
329
461
|
try {
|
|
330
|
-
const { key, found } = await currentRenderArtifact(
|
|
331
|
-
//
|
|
332
|
-
// share
|
|
333
|
-
//
|
|
462
|
+
const { key, found } = await currentRenderArtifact(chainClientLazy(), state, token, storage);
|
|
463
|
+
// `image` is a REFERENCED output (`effects.md → Bound vs referenced`): a producer that
|
|
464
|
+
// doesn't share this node's disk registers a locator (ipfs/ar/https) and we 302 straight to
|
|
465
|
+
// it (gateway resolved at serve time), never proxying its bytes. `found` is the co-located
|
|
466
|
+
// case — the producer wrote the artifact into the backend we share.
|
|
334
467
|
const published = indexer.store.getEffectArtifact(key);
|
|
335
468
|
if (published?.locator) {
|
|
336
|
-
res.writeHead(302, { location: resolveLocatorUrl(published.locator), 'cache-control': 'public, max-age=300' });
|
|
469
|
+
res.writeHead(302, { location: resolveLocatorUrl(state, published.locator), 'cache-control': 'public, max-age=300' });
|
|
337
470
|
res.end();
|
|
338
471
|
return;
|
|
339
472
|
}
|
|
@@ -357,7 +490,7 @@ async function route(req, res, indexer, baseUrl, storage) {
|
|
|
357
490
|
// a computed LOCATOR (renderer returning text/uri-list): the bytes ARE a URI — redirect.
|
|
358
491
|
if (content.contentType === 'text/uri-list') {
|
|
359
492
|
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' });
|
|
493
|
+
res.writeHead(302, { location: resolveLocatorUrl(state, target.trim()), 'cache-control': 'public, max-age=300' });
|
|
361
494
|
res.end();
|
|
362
495
|
return;
|
|
363
496
|
}
|
|
@@ -375,22 +508,58 @@ async function route(req, res, indexer, baseUrl, storage) {
|
|
|
375
508
|
}
|
|
376
509
|
return serveFieldArtifact(res, state, token, parts[5], storage, displayMeta(indexer, address));
|
|
377
510
|
}
|
|
378
|
-
return sendJson(res, 200, await buildTokenMetadata(
|
|
511
|
+
return sendJson(res, 200, await buildTokenMetadata(chainClientLazy(), state, token, baseUrl, serverChainId(), displayMeta(indexer, address), storage, planeAccess(indexer)));
|
|
379
512
|
}
|
|
380
513
|
// GET /c/:chainId/:addr and /c/:chainId/:addr/data/{field}
|
|
381
514
|
if (parts[0] === 'c' && parts[1] && parts[2]) {
|
|
382
|
-
if (Number(parts[1]) !==
|
|
515
|
+
if (Number(parts[1]) !== serverChainId())
|
|
383
516
|
return wrongChain(res, parts[1]);
|
|
384
517
|
const address = parts[2];
|
|
385
518
|
const state = indexer.getProject(address);
|
|
386
519
|
if (!state)
|
|
387
|
-
return
|
|
520
|
+
return unknownProject(res);
|
|
388
521
|
if (parts[3] === 'data' && parts[4]) {
|
|
389
522
|
return serveFieldArtifact(res, state, null, parts[4], storage, displayMeta(indexer, address));
|
|
390
523
|
}
|
|
391
|
-
return sendJson(res, 200, await buildContractMetadata(
|
|
524
|
+
return sendJson(res, 200, await buildContractMetadata(chainClientLazy(), state, baseUrl, serverChainId(), displayMeta(indexer, address), storage));
|
|
525
|
+
}
|
|
526
|
+
return sendRouteError(res, parts);
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* The last word of the router: nothing matched. A BARE 404 here is actively misleading — it's the
|
|
530
|
+
* same answer as "this project isn't indexed", so a client that hand-built a URL (dropping the
|
|
531
|
+
* `:id` off `/t/:chainId/:address/:id` and expecting collection metadata is the observed case)
|
|
532
|
+
* reads its own mistake as a service outage and reports a non-bug.
|
|
533
|
+
*
|
|
534
|
+
* So: a KNOWN route prefix with the wrong segment count is a **400 `invalid_request`** naming the
|
|
535
|
+
* correct template, and anything else is a **404 `unknown_route`**. Same `{error, code}` shape the
|
|
536
|
+
* control plane already uses (remote-services.md → Errors), so clients key off `code`, not prose.
|
|
537
|
+
*
|
|
538
|
+
* These are hints, not a discoverable API: the route grammar is fixed by the `abx-token-api/v1`
|
|
539
|
+
* interface and committed on-chain per contract (`tokenURIBase`/`contractURIBase`). The real fix for
|
|
540
|
+
* a client is to read the URL off the contract (`abx tokenuri` / `abx contracturi`) rather than
|
|
541
|
+
* assembling one — so every hint below points at the grammar it should already have, and never
|
|
542
|
+
* invites a client to treat routes as per-node negotiable.
|
|
543
|
+
*/
|
|
544
|
+
const ROUTE_TEMPLATES = {
|
|
545
|
+
t: { template: '/t/:chainId/:address/:id (· /image · /data/:field)', what: 'token metadata (ERC-721 tokenURI / ERC-1155 uri target)' },
|
|
546
|
+
c: { template: '/c/:chainId/:address (· /data/:field)', what: 'ERC-7572 collection metadata' },
|
|
547
|
+
a: { template: '/a/:chainId/:address/:id', what: 'the live view of a code project' },
|
|
548
|
+
d: { template: '/d/:chainId/:address', what: 'the per-contract read-only dashboard' },
|
|
549
|
+
};
|
|
550
|
+
function sendRouteError(res, parts) {
|
|
551
|
+
const known = parts[0] ? ROUTE_TEMPLATES[parts[0]] : undefined;
|
|
552
|
+
if (known) {
|
|
553
|
+
// The single highest-value hint: a `/t/:chainId/:address` with no token id is almost always
|
|
554
|
+
// someone reaching for collection metadata. Name `/c/…` explicitly.
|
|
555
|
+
const missingId = (parts[0] === 't' || parts[0] === 'a') && parts.length === 3;
|
|
556
|
+
return sendError(res, 400, 'invalid_request', `malformed ${known.what} path — use ${known.template}` +
|
|
557
|
+
(missingId ? '. For COLLECTION-level metadata (no token id) use /c/:chainId/:address' : ''), { route: known.template, ...(missingId ? { didYouMean: `/c/${parts[1]}/${parts[2]}` } : {}) });
|
|
392
558
|
}
|
|
393
|
-
|
|
559
|
+
return sendError(res, 404, 'unknown_route', 'this node serves no route at that path', {
|
|
560
|
+
routes: Object.values(ROUTE_TEMPLATES).map((r) => r.template.split(' ')[0]),
|
|
561
|
+
hint: 'a contract commits its own metadata URL on-chain (tokenURIBase/contractURIBase) — read it with `abx tokenuri` / `abx contracturi` instead of building a path',
|
|
562
|
+
});
|
|
394
563
|
}
|
|
395
564
|
/** The manifest's read surface over the effect-artifact registry (metadata.ts stays store-free). */
|
|
396
565
|
function planeAccess(indexer) {
|
|
@@ -399,21 +568,33 @@ function planeAccess(indexer) {
|
|
|
399
568
|
get: (key) => indexer.store.getEffectArtifact(key),
|
|
400
569
|
};
|
|
401
570
|
}
|
|
402
|
-
/** Serve one EFFECT artifact's bytes at the CURRENT settled inputsHash
|
|
403
|
-
*
|
|
404
|
-
*
|
|
571
|
+
/** Serve one EFFECT artifact's bytes at the CURRENT settled inputsHash. Three sources, in order,
|
|
572
|
+
* mirroring `effects.md → Bound vs referenced`:
|
|
573
|
+
* - a registered locator (a REFERENCED output) → **302**, never a proxy: the producer holds those
|
|
574
|
+
* bytes and its egress stays its own;
|
|
575
|
+
* - a BOUND output's content, held with the row (≤64KB, this node stitches it into the JSON too);
|
|
576
|
+
* - this node's own custody at the artifact key — a CO-LOCATED producer sharing the backend.
|
|
577
|
+
* Else 404 (not produced yet, or stale after a param change — self-invalidation, not an error). */
|
|
405
578
|
async function serveEffectArtifact(res, state, token, effectKey, outputKey, storage, indexer) {
|
|
406
579
|
if (!isCodeProject(state))
|
|
407
580
|
return sendJson(res, 404, { error: 'no effect artifacts — not a code project' });
|
|
408
581
|
try {
|
|
409
|
-
const hash = await currentSettledInputsHash(
|
|
582
|
+
const hash = await currentSettledInputsHash(chainClientLazy(), state, token);
|
|
410
583
|
const key = renderArtifactKey(state.chainId, state.address, token.tokenId, hash, outputKey, effectKey);
|
|
411
584
|
const row = indexer.store.getEffectArtifact(key);
|
|
412
585
|
if (row?.locator) {
|
|
413
|
-
res.writeHead(302, { location: resolveLocatorUrl(row.locator), 'cache-control': 'public, max-age=300' });
|
|
586
|
+
res.writeHead(302, { location: resolveLocatorUrl(state, row.locator), 'cache-control': 'public, max-age=300' });
|
|
414
587
|
res.end();
|
|
415
588
|
return;
|
|
416
589
|
}
|
|
590
|
+
if (row?.bytes) {
|
|
591
|
+
res.writeHead(200, {
|
|
592
|
+
'content-type': row.contentType || 'application/octet-stream',
|
|
593
|
+
'cache-control': 'public, max-age=300',
|
|
594
|
+
});
|
|
595
|
+
res.end(row.bytes);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
417
598
|
const stored = await storage.get(key);
|
|
418
599
|
if (stored) {
|
|
419
600
|
res.writeHead(200, {
|
|
@@ -443,21 +624,21 @@ async function serveFieldArtifact(res, state, token, field, storage, display) {
|
|
|
443
624
|
return sendJson(res, 404, { error: `no '${field}' field set` });
|
|
444
625
|
const tokenId = token?.tokenId ?? '0';
|
|
445
626
|
try {
|
|
446
|
-
const onChain = await resolveFieldBytes(
|
|
627
|
+
const onChain = await resolveFieldBytes(chainClientLazy(), entry);
|
|
447
628
|
if (onChain) {
|
|
448
629
|
res.writeHead(200, {
|
|
449
|
-
'content-type': await fieldMimeType(
|
|
630
|
+
'content-type': await fieldMimeType(chainClientLazy(), state, entry, field, tokenId, display, storage),
|
|
450
631
|
'cache-control': 'public, max-age=300',
|
|
451
632
|
});
|
|
452
633
|
res.end(onChain);
|
|
453
634
|
return;
|
|
454
635
|
}
|
|
455
636
|
// computed on-chain at read — the collection surface passes the sentinel id (no token).
|
|
456
|
-
const rendered = await resolveFieldRendered(
|
|
637
|
+
const rendered = await resolveFieldRendered(chainClientLazy(), state.address, token?.tokenId ?? COLLECTION_TOKEN_ID, field, entry);
|
|
457
638
|
if (rendered) {
|
|
458
639
|
if (rendered.contentType === 'text/uri-list') {
|
|
459
640
|
const target = new TextDecoder().decode(rendered.bytes).trim();
|
|
460
|
-
res.writeHead(302, { location: resolveLocatorUrl(target), 'cache-control': 'public, max-age=300' });
|
|
641
|
+
res.writeHead(302, { location: resolveLocatorUrl(state, target), 'cache-control': 'public, max-age=300' });
|
|
461
642
|
res.end();
|
|
462
643
|
return;
|
|
463
644
|
}
|
|
@@ -477,15 +658,15 @@ async function serveFieldArtifact(res, state, token, field, storage, display) {
|
|
|
477
658
|
}
|
|
478
659
|
const bridged = display.contentLocators?.[entry.value.toLowerCase()];
|
|
479
660
|
if (bridged) {
|
|
480
|
-
res.writeHead(302, { location: resolveLocatorUrl(bridged), 'cache-control': 'public, max-age=300' });
|
|
661
|
+
res.writeHead(302, { location: resolveLocatorUrl(state, bridged), 'cache-control': 'public, max-age=300' });
|
|
481
662
|
res.end();
|
|
482
663
|
return;
|
|
483
664
|
}
|
|
484
665
|
return sendJson(res, 404, { error: `'${field}' bytes not in this node's custody (on-chain ${entry.representation} anchor only)` });
|
|
485
666
|
}
|
|
486
|
-
const locator = fieldLocatorUrl(entry, tokenId);
|
|
667
|
+
const locator = fieldLocatorUrl(state, entry, tokenId);
|
|
487
668
|
if (locator) {
|
|
488
|
-
res.writeHead(302, { location: resolveLocatorUrl(locator), 'cache-control': 'public, max-age=300' });
|
|
669
|
+
res.writeHead(302, { location: resolveLocatorUrl(state, locator), 'cache-control': 'public, max-age=300' });
|
|
489
670
|
res.end();
|
|
490
671
|
return;
|
|
491
672
|
}
|
|
@@ -496,8 +677,18 @@ async function serveFieldArtifact(res, state, token, field, storage, display) {
|
|
|
496
677
|
return sendJson(res, 404, { error: `'${field}' (${entry.representation}) is not byte-servable from this node` });
|
|
497
678
|
}
|
|
498
679
|
/** A locator-representation field's URL (`{id}` substituted), or null for non-locator forms. */
|
|
499
|
-
function fieldLocatorUrl(entry, tokenId) {
|
|
500
|
-
|
|
680
|
+
function fieldLocatorUrl(state, entry, tokenId) {
|
|
681
|
+
// Content-addressed values are IDENTITY, not URLs — since v11 a field stores the bare CID/txid,
|
|
682
|
+
// so returning it raw would 302 a browser to `Location: bafy…`. Project it exactly as the
|
|
683
|
+
// metadata document does, through the collection's preferred gateway.
|
|
684
|
+
if (entry.representation === R.ipfs || entry.representation === R.arweave) {
|
|
685
|
+
const network = entry.representation === R.ipfs ? 'ipfs' : 'arweave';
|
|
686
|
+
const text = Buffer.from(entry.value.slice(2), 'hex').toString('utf8').trim();
|
|
687
|
+
if (!text)
|
|
688
|
+
return null;
|
|
689
|
+
return projectGatewayUrl(network, text, projectGatewayPrefix(state, network, gatewayConfigFromEnv()), tokenId);
|
|
690
|
+
}
|
|
691
|
+
if (entry.representation === R.url) {
|
|
501
692
|
const text = Buffer.from(entry.value.slice(2), 'hex').toString('utf8').trim();
|
|
502
693
|
return text || null;
|
|
503
694
|
}
|
|
@@ -575,8 +766,10 @@ async function effectStatusReport(indexer, state, storage) {
|
|
|
575
766
|
const tokens = [];
|
|
576
767
|
const counts = { upToDate: 0, stale: 0, rendering: 0, failed: 0 };
|
|
577
768
|
if (isCodeProject(state)) {
|
|
578
|
-
|
|
579
|
-
|
|
769
|
+
// Live tokens only: a destroyed id has no render to be up-to-date about, and reporting one
|
|
770
|
+
// would keep a producer re-rendering it forever.
|
|
771
|
+
for (const token of state.tokens.filter((t) => t.lifecycle === 'live')) {
|
|
772
|
+
const { key, found } = await currentRenderArtifact(chainClientLazy(), state, token, storage);
|
|
580
773
|
const published = !found && !!indexer.store.getEffectArtifact(key);
|
|
581
774
|
const row = byKey.get(key.toLowerCase());
|
|
582
775
|
let status;
|
|
@@ -635,8 +828,26 @@ function watchStatusReport(indexer) {
|
|
|
635
828
|
chains,
|
|
636
829
|
};
|
|
637
830
|
}
|
|
638
|
-
/**
|
|
831
|
+
/**
|
|
832
|
+
* The path was well-formed and on the right chain — this node just doesn't index that contract.
|
|
833
|
+
* Code `not_registered` (the control plane's own code for the same condition) so a client can tell
|
|
834
|
+
* it apart from a malformed path (400 `invalid_request`) and a nonexistent route (404
|
|
835
|
+
* `unknown_route`). Those three used to be one indistinguishable `{error: '…'}` 404.
|
|
836
|
+
*/
|
|
837
|
+
function unknownProject(res) {
|
|
838
|
+
sendError(res, 404, 'not_registered', 'this node does not index that contract', {
|
|
839
|
+
hint: 'register it with `abx add <address> --remote <name|url>` (bearer-gated control plane), then `abx index <address> --remote`',
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* A path whose chainId segment isn't the chain this resolver serves. **400 `unsupported_chain`**,
|
|
844
|
+
* matching the control plane's `checkChain` exactly (control-plane.ts) — it's a request error, not a
|
|
845
|
+
* missing resource, and answering 404 made it indistinguishable from "that project isn't indexed
|
|
846
|
+
* here", which sent at least one client hunting a phantom outage. `chains` says what IS served.
|
|
847
|
+
*/
|
|
639
848
|
function wrongChain(res, got) {
|
|
640
|
-
|
|
849
|
+
sendError(res, 400, 'unsupported_chain', `this resolver serves chain ${serverChainId()}, not ${got}`, {
|
|
850
|
+
chains: [serverChainId()],
|
|
851
|
+
});
|
|
641
852
|
}
|
|
642
853
|
//# sourceMappingURL=server.js.map
|