@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +165 -0
  2. package/dist/code.d.ts +33 -12
  3. package/dist/code.d.ts.map +1 -1
  4. package/dist/code.js +68 -56
  5. package/dist/code.js.map +1 -1
  6. package/dist/{art.d.ts → content.d.ts} +5 -5
  7. package/dist/content.d.ts.map +1 -0
  8. package/dist/{art.js → content.js} +6 -6
  9. package/dist/content.js.map +1 -0
  10. package/dist/control-plane.d.ts +24 -13
  11. package/dist/control-plane.d.ts.map +1 -1
  12. package/dist/control-plane.js +289 -43
  13. package/dist/control-plane.js.map +1 -1
  14. package/dist/dashboard.d.ts.map +1 -1
  15. package/dist/dashboard.js +55 -8
  16. package/dist/dashboard.js.map +1 -1
  17. package/dist/deps.d.ts +28 -110
  18. package/dist/deps.d.ts.map +1 -1
  19. package/dist/deps.js +34 -184
  20. package/dist/deps.js.map +1 -1
  21. package/dist/index.d.ts +8 -8
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +13 -11
  24. package/dist/index.js.map +1 -1
  25. package/dist/metadata.d.ts +87 -4
  26. package/dist/metadata.d.ts.map +1 -1
  27. package/dist/metadata.js +343 -105
  28. package/dist/metadata.js.map +1 -1
  29. package/dist/resolve.d.ts +0 -2
  30. package/dist/resolve.d.ts.map +1 -1
  31. package/dist/resolve.js +0 -5
  32. package/dist/resolve.js.map +1 -1
  33. package/dist/server.d.ts +5 -2
  34. package/dist/server.d.ts.map +1 -1
  35. package/dist/server.js +282 -71
  36. package/dist/server.js.map +1 -1
  37. package/dist/watcher.d.ts +1 -1
  38. package/dist/watcher.d.ts.map +1 -1
  39. package/dist/watcher.js +137 -8
  40. package/dist/watcher.js.map +1 -1
  41. package/package.json +9 -8
  42. package/dist/abxjs.d.ts +0 -13
  43. package/dist/abxjs.d.ts.map +0 -1
  44. package/dist/abxjs.js +0 -49
  45. package/dist/abxjs.js.map +0 -1
  46. package/dist/art.d.ts.map +0 -1
  47. package/dist/art.js.map +0 -1
  48. package/dist/inline.d.ts +0 -19
  49. package/dist/inline.d.ts.map +0 -1
  50. package/dist/inline.js +0 -23
  51. 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 './art.js';
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
- // A read-only client for resolving on-chain `reader`-represented content (eth_call).
14
- const chainClient = makePublicClient();
15
- // The chain this resolver serves. The path grammar carries the chainId
16
- // (`/t/{chainId}/{address}/{tokenId}`), so a single host can serve many chains and reject
17
- // paths for chains it doesn't index. Today one resolver = one chain; this gates that.
18
- const SERVER_CHAIN_ID = resolveChain(process.env.ABX_CHAIN).id;
19
- // The chain *key* ('sepolia', …) the indexer registers projects under — the string
20
- // form of the same chain SERVER_CHAIN_ID identifies. Used by the control plane.
21
- const SERVER_CHAIN_KEY = process.env.ABX_CHAIN ?? DEFAULT_CHAIN_KEY; // MUST match SERVER_CHAIN_ID's default (base-sepolia) — a stale 'sepolia' desynced the key from the id
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: SERVER_CHAIN_ID, chainKey: SERVER_CHAIN_KEY, baseUrl };
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(chainClient, image); // inline / inline-gzip / reader / reader-gzip
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(chainClient, state.address, token.tokenId, F.image, image);
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 Series' cap (`0 <= id < N`)? */
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 (pre-mint warming — the multi-token
79
- * analogue of the 1/1's seed-token-0), or `null` when the id is genuinely unknown (→ 404).
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
- minted: issued?.minted ?? false,
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 specs/self-host-toolkit/remote-services.md). So:
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, SERVER_CHAIN_ID));
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]) !== SERVER_CHAIN_ID)
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 sendJson(res, 404, { error: 'unknown project' });
270
+ return unknownProject(res);
201
271
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
202
- res.end(renderDashboard(state, baseUrl, SERVER_CHAIN_ID));
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 specs/self-host-toolkit/remote-services.md.
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]) !== SERVER_CHAIN_ID)
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 sendJson(res, 404, { error: 'unknown project' });
303
+ return unknownProject(res);
234
304
  const token = resolveTokenView(state, parts[3]);
235
305
  if (!token)
236
- return sendJson(res, 404, { error: 'unknown token' });
237
- const view = await resolveLiveView(chainClient, state, token);
238
- if (!view)
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]) !== SERVER_CHAIN_ID)
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 sendJson(res, 404, { error: 'unknown project' });
278
- return sendJson(res, 200, await depStatusReport(chainClient, state));
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 sendJson(res, 404, { error: 'unknown project' });
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 sendJson(res, 404, { error: 'unknown project' });
422
+ return unknownProject(res);
305
423
  return sendJson(res, 200, state);
306
424
  }
307
- return sendJson(res, 404, { error: 'unknown api route' });
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]) !== SERVER_CHAIN_ID)
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 sendJson(res, 404, { error: 'unknown project' });
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 sendJson(res, 404, { error: 'unknown token' });
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(chainClient, state, token, storage);
331
- // A runner may have published a durable locator (ipfs/ar/https) for a resolver that doesn't
332
- // share its storage disk — 302 straight to it (gateway resolved at serve time). A bytes-mode
333
- // published row has locator NULL — its bytes landed in this node's storage (`found`).
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(chainClient, state, token, baseUrl, SERVER_CHAIN_ID, displayMeta(indexer, address), storage, planeAccess(indexer)));
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]) !== SERVER_CHAIN_ID)
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 sendJson(res, 404, { error: 'unknown project' });
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(chainClient, state, baseUrl, SERVER_CHAIN_ID, displayMeta(indexer, address), storage));
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
- sendJson(res, 404, { error: 'not found' });
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: 302 to a registered
403
- * durable locator, else the bytes from this node's custody with the DECLARED Content-Type,
404
- * else 404 (not produced yet, or stale after a param change — self-invalidation, not an error). */
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(chainClient, state, token);
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(chainClient, entry);
627
+ const onChain = await resolveFieldBytes(chainClientLazy(), entry);
447
628
  if (onChain) {
448
629
  res.writeHead(200, {
449
- 'content-type': await fieldMimeType(chainClient, state, entry, field, tokenId, display, storage),
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(chainClient, state.address, token?.tokenId ?? COLLECTION_TOKEN_ID, field, entry);
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
- if (entry.representation === R.url || entry.representation === R.ipfs || entry.representation === R.arweave) {
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
- for (const token of state.tokens.filter((t) => t.minted)) {
579
- const { key, found } = await currentRenderArtifact(chainClient, state, token, storage);
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
- /** 404 for a path whose chainId segment isn't the chain this resolver serves. */
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
- sendJson(res, 404, { error: `this resolver serves chain ${SERVER_CHAIN_ID}, not ${got}` });
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