@artblocks/abx-token-api 0.1.0-alpha.2 → 0.1.0-alpha.20

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