@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +155 -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 +4 -1
  34. package/dist/server.d.ts.map +1 -1
  35. package/dist/server.js +257 -70
  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,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), or `null` when the id is genuinely unknown (→ 404).
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
- minted: issued?.minted ?? false,
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 specs/self-host-toolkit/remote-services.md). So:
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, SERVER_CHAIN_ID));
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]) !== SERVER_CHAIN_ID)
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 sendJson(res, 404, { error: 'unknown project' });
256
+ return unknownProject(res);
201
257
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
202
- res.end(renderDashboard(state, baseUrl, SERVER_CHAIN_ID));
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 specs/self-host-toolkit/remote-services.md.
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]) !== SERVER_CHAIN_ID)
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 sendJson(res, 404, { error: 'unknown project' });
289
+ return unknownProject(res);
234
290
  const token = resolveTokenView(state, parts[3]);
235
291
  if (!token)
236
- return sendJson(res, 404, { error: 'unknown token' });
237
- const view = await resolveLiveView(chainClient, state, token);
238
- if (!view)
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]) !== SERVER_CHAIN_ID)
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 sendJson(res, 404, { error: 'unknown project' });
278
- return sendJson(res, 200, await depStatusReport(chainClient, state));
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 sendJson(res, 404, { error: 'unknown project' });
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 sendJson(res, 404, { error: 'unknown project' });
406
+ return unknownProject(res);
305
407
  return sendJson(res, 200, state);
306
408
  }
307
- return sendJson(res, 404, { error: 'unknown api route' });
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]) !== SERVER_CHAIN_ID)
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 sendJson(res, 404, { error: 'unknown project' });
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 sendJson(res, 404, { error: 'unknown token' });
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(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`).
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(chainClient, state, token, baseUrl, SERVER_CHAIN_ID, displayMeta(indexer, address), storage, planeAccess(indexer)));
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]) !== SERVER_CHAIN_ID)
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 sendJson(res, 404, { error: 'unknown project' });
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(chainClient, state, baseUrl, SERVER_CHAIN_ID, displayMeta(indexer, address), storage));
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
- sendJson(res, 404, { error: 'not found' });
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: 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). */
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(chainClient, state, token);
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(chainClient, entry);
603
+ const onChain = await resolveFieldBytes(chainClientLazy(), entry);
447
604
  if (onChain) {
448
605
  res.writeHead(200, {
449
- 'content-type': await fieldMimeType(chainClient, state, entry, field, tokenId, display, storage),
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(chainClient, state.address, token?.tokenId ?? COLLECTION_TOKEN_ID, field, entry);
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
- if (entry.representation === R.url || entry.representation === R.ipfs || entry.representation === R.arweave) {
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
- for (const token of state.tokens.filter((t) => t.minted)) {
579
- const { key, found } = await currentRenderArtifact(chainClient, state, token, storage);
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
- /** 404 for a path whose chainId segment isn't the chain this resolver serves. */
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
- sendJson(res, 404, { error: `this resolver serves chain ${SERVER_CHAIN_ID}, not ${got}` });
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