@artblocks/abx-token-api 0.1.0-alpha.4 → 0.1.0-alpha.40

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 +63 -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 +89 -0
  11. package/dist/control-plane.d.ts.map +1 -0
  12. package/dist/control-plane.js +768 -0
  13. package/dist/control-plane.js.map +1 -0
  14. package/dist/dashboard.d.ts.map +1 -1
  15. package/dist/dashboard.js +60 -11
  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 -27
  34. package/dist/server.d.ts.map +1 -1
  35. package/dist/server.js +310 -421
  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
@@ -0,0 +1,768 @@
1
+ /**
2
+ * The /v1 control plane + the service descriptor — the resolver's ONE write surface, speaking the
3
+ * provider-neutral interface pinned by site/content/docs/using-abx/remote-services.mdx. Everything here
4
+ * is index/metadata control (register, list, deregister, reindex, status, artifact registration): the
5
+ * bearer token never signs anything on-chain, so the "no signing key on the host" rule is intact.
6
+ *
7
+ * This module never imports server.ts (one-way dependency), which is why the shared HTTP helpers
8
+ * (sendJson, the bearer guard) live here and the read plane imports them.
9
+ */
10
+ import { timingSafeEqual } from 'node:crypto';
11
+ import { readFileSync } from 'node:fs';
12
+ import { join, resolve } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { discoverDeployBlock, findAnchorGenerationById, findAnchorGenerationForProject, isBoundOutput, locatorRejectionReason, normalizeAttributes, renderArtifactKey, resolveChain, summarizeAnchorGeneration, BOUND_ARTIFACT_MAX_BYTES, CONTROL_PLANE_INTERFACE, TOKEN_API_INTERFACE, } from '@artblocks/abx-sdk';
15
+ import { notifyEffects, watchIntervalMs } from './watcher.js';
16
+ export function sendJson(res, status, body) {
17
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
18
+ res.end(JSON.stringify(body, null, 2));
19
+ }
20
+ /** Every non-2xx control-plane response: `{error: <human>, code: <machine>}` — clients key off
21
+ * `code`, never off prose (site/content/docs/using-abx/remote-services.mdx → Errors). */
22
+ export function sendError(res, status, code, message, extra) {
23
+ sendJson(res, status, { error: message, code, ...extra });
24
+ }
25
+ /**
26
+ * How long a register/reindex may hold the HTTP request open before answering `202 backfilling` and
27
+ * finishing in the background. Registration is durable BEFORE catch-up starts, so the deadline only
28
+ * decides who waits — never whether the add survives.
29
+ *
30
+ * The default keeps the common case synchronous (a fresh deploy is a few-hundred-block window: the
31
+ * client gets real counts, which is the honest answer) and stops the pathological case from holding a
32
+ * socket for minutes (a cold replay of an old contract on a rate-limited RPC — the case where the
33
+ * client used to time out and re-POST, doubling the load on the RPC that was already the problem).
34
+ */
35
+ function registerDeadlineMs() {
36
+ const raw = process.env.ABX_REGISTER_DEADLINE_MS;
37
+ if (raw === undefined || raw === '')
38
+ return 8_000;
39
+ const n = Number(raw);
40
+ return Number.isFinite(n) && n >= 0 ? n : 8_000;
41
+ }
42
+ /** Resolve to the catch-up result if it finishes within the deadline, else `undefined` — the work
43
+ * keeps running either way (the caller must attach its own completion/failure handling). */
44
+ async function withinDeadline(work, ms) {
45
+ let timer;
46
+ const deadline = new Promise((resolve) => {
47
+ timer = setTimeout(() => resolve(undefined), ms);
48
+ });
49
+ try {
50
+ // The rejection case belongs to the caller's handler; here a failure just means "not finished
51
+ // in time with a result", and the caller reports the recorded status instead.
52
+ return await Promise.race([work.catch(() => undefined), deadline]);
53
+ }
54
+ finally {
55
+ clearTimeout(timer);
56
+ }
57
+ }
58
+ /** The lifecycle state to report for a project right now — never a silent nothing (see
59
+ * `SelfHostIndexer.indexStatus` for what an unstamped registration reads as). */
60
+ function indexStatusOf(ctx, address) {
61
+ return ctx.indexer.indexStatus(address).status;
62
+ }
63
+ /** The lifecycle fields shared by the status route and the register/reindex responses. */
64
+ function lifecycle(ctx, address) {
65
+ const row = ctx.indexer.indexStatus(address);
66
+ return {
67
+ status: row.status,
68
+ ...(row.errorClass ? { error: { class: row.errorClass, ...(row.errorMessage ? { message: row.errorMessage } : {}) } } : {}),
69
+ attempts: row.attempts,
70
+ };
71
+ }
72
+ /**
73
+ * The ONE bearer guard for every gated route: 404 code `disabled` when no token is configured on
74
+ * the host (the write surface honestly doesn't exist — the descriptor omits the control-plane
75
+ * interface too), 401 code `unauthorized` on a missing/bad bearer. Returns false if it handled
76
+ * the response (caller must stop), true when the request may proceed. 403 (`forbidden`) is
77
+ * spec-defined for multi-tenant providers; this single-token reference never emits it.
78
+ */
79
+ export function requireBearer(req, res) {
80
+ if (!process.env.ABX_RESOLVER_ADMIN_TOKEN) {
81
+ sendError(res, 404, 'disabled', 'control plane disabled — set ABX_RESOLVER_ADMIN_TOKEN on this node (operate via the abx CLI)');
82
+ return false;
83
+ }
84
+ if (!bearerAuthorized(req)) {
85
+ sendError(res, 401, 'unauthorized', 'unauthorized — send Authorization: Bearer <token>');
86
+ return false;
87
+ }
88
+ return true;
89
+ }
90
+ /** Constant-time bearer check against ABX_RESOLVER_ADMIN_TOKEN (read per request, so rotation
91
+ * needs no restart). */
92
+ function bearerAuthorized(req) {
93
+ const token = process.env.ABX_RESOLVER_ADMIN_TOKEN;
94
+ if (!token)
95
+ return false;
96
+ const header = req.headers['authorization'];
97
+ const raw = (Array.isArray(header) ? header[0] : header) ?? '';
98
+ const m = /^Bearer\s+(.+)$/i.exec(raw.trim());
99
+ if (!m)
100
+ return false;
101
+ const got = Buffer.from(m[1]);
102
+ const want = Buffer.from(token);
103
+ return got.length === want.length && timingSafeEqual(got, want);
104
+ }
105
+ /** Read + JSON-parse a request body, capped so a bad caller can't exhaust memory. */
106
+ async function readJsonBody(req, maxBytes = 64 * 1024) {
107
+ const chunks = [];
108
+ let size = 0;
109
+ for await (const chunk of req) {
110
+ size += chunk.length;
111
+ if (size > maxBytes)
112
+ throw new Error('request body too large');
113
+ chunks.push(chunk);
114
+ }
115
+ if (chunks.length === 0)
116
+ return {};
117
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
118
+ }
119
+ const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
120
+ /** Is this an ERC-1155 edition contract type (any of the three)? Mirrors the SDK's own
121
+ * three-way check (`reconstruct.ts`'s `isEdition`) — kept local rather than exported from the
122
+ * SDK, since this phase's SDK edits are additive-only in `service.ts`. */
123
+ function isEditionContractType(contractType) {
124
+ return contractType === '1of1-edition' || contractType === 'edition' || contractType === 'edition-code';
125
+ }
126
+ /** Sum of every token's current supply — the collector-facing "how many copies exist right now"
127
+ * figure, additive alongside `mintedCount` (which keeps meaning unchanged: `supply > 0`).
128
+ * `undefined` for a non-edition project, so a 721 status response is byte-identical to before. */
129
+ function copiesOf(s) {
130
+ if (!isEditionContractType(s.contractType))
131
+ return undefined;
132
+ let total = 0n;
133
+ for (const t of s.tokens)
134
+ total += BigInt(t.supply ?? '0');
135
+ return total.toString();
136
+ }
137
+ /**
138
+ * How many ids are live, and how many were destroyed.
139
+ *
140
+ * `mintedCount` counts live ids, so it decreases when a burnable token is destroyed.
141
+ * `burnedCount` is omitted when zero, deliberately: a project with no burns serves byte-identical
142
+ * JSON to before, which keeps a consumer's fixtures (and their parity audit) honest about what
143
+ * actually changed. Both figures are the same shape the hosted resolver settled on, so the two
144
+ * implementations agree on the wire rather than each inventing a name.
145
+ */
146
+ function tokenCounts(s) {
147
+ let live = 0;
148
+ let burned = 0;
149
+ for (const t of s.tokens) {
150
+ if (t.lifecycle === 'live')
151
+ live++;
152
+ // `'burned'` is terminal and 721-only, so this counts destroyed tokens and nothing else. An
153
+ // edition's zero-supply ids fold to `'no-live-copies'` and are deliberately not counted here:
154
+ // they may mint again, and `copies` already reports live copies for that lane.
155
+ else if (t.lifecycle === 'burned')
156
+ burned++;
157
+ }
158
+ return { mintedCount: live, ...(burned ? { burnedCount: burned } : {}) };
159
+ }
160
+ /** Explicit adapter/support claim for this service build. Append only after its tests pass. */
161
+ export const SUPPORTED_CONTRACT_GENERATION_IDS = ['abx-core-v3', 'abx-core-v2'];
162
+ const supportedGenerationIds = new Set(SUPPORTED_CONTRACT_GENERATION_IDS);
163
+ function supportedContractGenerations() {
164
+ return SUPPORTED_CONTRACT_GENERATION_IDS.map((id) => {
165
+ const generation = findAnchorGenerationById(id);
166
+ if (!generation)
167
+ throw new Error(`Unknown supported contract generation: ${id}`);
168
+ return summarizeAnchorGeneration(generation);
169
+ });
170
+ }
171
+ function contractGenerationOf(s) {
172
+ const generation = findAnchorGenerationForProject(s);
173
+ return generation && supportedGenerationIds.has(generation.id)
174
+ ? summarizeAnchorGeneration(generation)
175
+ : undefined;
176
+ }
177
+ /** The one-line project summary shared by `GET /api/projects` and the register/reindex responses. */
178
+ export function summarize(s) {
179
+ const copies = copiesOf(s);
180
+ const contractGeneration = contractGenerationOf(s);
181
+ return {
182
+ address: s.address,
183
+ name: s.name,
184
+ symbol: s.symbol,
185
+ owner: s.owner,
186
+ abxVersion: s.abxVersion,
187
+ isCanonical: s.isCanonical,
188
+ ...(contractGeneration ? { contractGeneration } : {}),
189
+ extensions: s.extensions.map((e) => e.name),
190
+ eventCount: s.eventCount,
191
+ tokenCount: s.tokens.length,
192
+ ...tokenCounts(s),
193
+ ...(copies !== undefined ? { copies } : {}),
194
+ reconstructedAt: s.reconstructedAt,
195
+ };
196
+ }
197
+ /** Parse a stored content-locators JSON column → `{hash: locator}` (lowercased keys, never throws). */
198
+ export function safeLocators(json) {
199
+ try {
200
+ const obj = JSON.parse(json);
201
+ const out = {};
202
+ for (const [k, v] of Object.entries(obj))
203
+ if (typeof v === 'string')
204
+ out[k.toLowerCase()] = v;
205
+ return Object.keys(out).length ? out : undefined;
206
+ }
207
+ catch {
208
+ return undefined;
209
+ }
210
+ }
211
+ /** Merge incoming content locators over the stored ones (additive — a re-add can bring new hashes
212
+ * without dropping known ones). Returns a JSON string for the column, or undefined if empty. */
213
+ function mergeLocators(existingJson, incoming) {
214
+ const base = existingJson ? safeLocators(existingJson) ?? {} : {};
215
+ if (incoming && typeof incoming === 'object') {
216
+ for (const [k, v] of Object.entries(incoming)) {
217
+ if (typeof v === 'string' && v)
218
+ base[k.toLowerCase()] = v;
219
+ }
220
+ }
221
+ return Object.keys(base).length ? JSON.stringify(base) : undefined;
222
+ }
223
+ /**
224
+ * Decide the scan floor + whether a full replay is needed when registering a project via the
225
+ * control plane. Pure (the discovery/refusal fallback for the null case is the caller's):
226
+ * - explicit `bodyFromBlock` wins; else the `existingFromBlock` already stored.
227
+ * - `null` ⇒ NEITHER supplied nor stored — the caller must derive the deploy block or refuse
228
+ * (never default to genesis: a range-capped RPC would sweep millions of blocks).
229
+ * - `full` is true only when forced, on a first registration (no existing floor), or when the
230
+ * floor actually CHANGED — so re-sending the SAME floor (the CLI now always forwards the deploy
231
+ * block, even on a nudge) stays incremental: registering twice ≠ two full scans.
232
+ */
233
+ export function planRegistrationFloor(bodyFromBlock, existingFromBlock, forceFull = false) {
234
+ const fromBlock = bodyFromBlock !== undefined ? String(bodyFromBlock) : existingFromBlock;
235
+ if (fromBlock === undefined)
236
+ return null;
237
+ const full = forceFull || existingFromBlock === undefined || fromBlock !== existingFromBlock;
238
+ return { fromBlock, full };
239
+ }
240
+ // ── the service descriptor ──────────────────────────────────────────────────────
241
+ /** This package's own name+version (dev and published resolve the same — npm always ships
242
+ * package.json). Never throws; identity is display-only, not dispatch. */
243
+ function packageIdentity() {
244
+ try {
245
+ const pkgDir = resolve(fileURLToPath(import.meta.url), '..', '..');
246
+ const raw = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'));
247
+ return { name: raw.name ?? '@artblocks/abx-token-api', version: raw.version ?? '0.0.0' };
248
+ }
249
+ catch {
250
+ return { name: '@artblocks/abx-token-api', version: '0.0.0' };
251
+ }
252
+ }
253
+ // The runner /health probe is cached briefly (and keyed by URL, so a config change invalidates):
254
+ // the descriptor must stay cheap to serve, and a dead runner must not slow it down.
255
+ let effectsProbe = null;
256
+ const EFFECTS_PROBE_TTL_MS = 60_000;
257
+ async function probeRunnerEffects(url) {
258
+ if (effectsProbe && effectsProbe.url === url && Date.now() - effectsProbe.at < EFFECTS_PROBE_TTL_MS) {
259
+ return effectsProbe.effects;
260
+ }
261
+ let effects = null;
262
+ try {
263
+ const resp = await fetch(`${url.replace(/\/+$/, '')}/health`, { signal: AbortSignal.timeout(1500) });
264
+ const body = (await resp.json());
265
+ if (Array.isArray(body.effects))
266
+ effects = body.effects;
267
+ }
268
+ catch {
269
+ effects = null; // attached-but-unverified — distinct from "attached, zero effects"
270
+ }
271
+ effectsProbe = { at: Date.now(), url, effects };
272
+ return effects;
273
+ }
274
+ /**
275
+ * `GET /.well-known/abx-service` — what this node supports, agent-readably (public, no auth).
276
+ * The control-plane interface (and `auth`) appears iff the bearer token is configured: a token-less
277
+ * self-host node honestly advertises no remote control surface — which is how a client tells
278
+ * "disabled" from "wrong URL". The artifact-registry routes ride `abx-control-plane/v1` rather than
279
+ * an id of their own: once referenced output is locator-only, accepting a registration is a database
280
+ * insert, so there is no infrastructure a node could lack that would justify a separate capability
281
+ * flag. `render` appears when a runner rides behind this resolver (ABX_EFFECTS_URL); its declared
282
+ * effects come from a best-effort `/health` probe. Provider identity (name, signup/docs URLs) is
283
+ * deployment env — no provider ships in this code.
284
+ */
285
+ export async function serviceDescriptor(ctx) {
286
+ const controlPlane = !!process.env.ABX_RESOLVER_ADMIN_TOKEN;
287
+ const pkg = packageIdentity();
288
+ const descriptor = {
289
+ service: { name: process.env.ABX_SERVICE_NAME ?? pkg.name, version: pkg.version },
290
+ interfaces: controlPlane ? [TOKEN_API_INTERFACE, CONTROL_PLANE_INTERFACE] : [TOKEN_API_INTERFACE],
291
+ chains: [ctx.chainId],
292
+ contractGenerations: supportedContractGenerations(),
293
+ baseUrl: ctx.baseUrl,
294
+ };
295
+ if (controlPlane) {
296
+ descriptor.auth = {
297
+ scheme: 'bearer',
298
+ ...(process.env.ABX_SERVICE_SIGNUP_URL ? { signupUrl: process.env.ABX_SERVICE_SIGNUP_URL } : {}),
299
+ ...(process.env.ABX_SERVICE_DOCS_URL ? { docsUrl: process.env.ABX_SERVICE_DOCS_URL } : {}),
300
+ };
301
+ }
302
+ const effectsUrl = process.env.ABX_EFFECTS_URL;
303
+ if (effectsUrl) {
304
+ descriptor.render = { attached: true, effects: await probeRunnerEffects(effectsUrl) };
305
+ }
306
+ return descriptor;
307
+ }
308
+ // ── the /v1 routes ──────────────────────────────────────────────────────────────
309
+ /**
310
+ * Dispatch a `/v1/*` request (`parts` excludes the leading `v1`). Every route is bearer-gated;
311
+ * `chainId` is explicit everywhere (body on collection POSTs, path elsewhere) and validated, so a
312
+ * project can never silently register against the wrong chain. Wrong method on a known path ⇒ 405.
313
+ */
314
+ export async function routeControlPlane(req, res, ctx, parts) {
315
+ if (!requireBearer(req, res))
316
+ return;
317
+ const method = req.method ?? 'GET';
318
+ if (parts[0] === 'projects') {
319
+ if (parts.length === 1) {
320
+ if (method === 'POST')
321
+ return registerProject(req, res, ctx);
322
+ if (method === 'GET')
323
+ return listProjects(res, ctx);
324
+ return sendError(res, 405, 'invalid_request', 'use POST /v1/projects (register) or GET /v1/projects (list)');
325
+ }
326
+ const [, chainSeg, addrSeg, sub] = parts;
327
+ if (!addrSeg || parts.length > 4) {
328
+ return sendError(res, 404, 'invalid_request', 'unknown control-plane route — projects are /v1/projects/{chainId}/{address}');
329
+ }
330
+ if (!checkChain(res, ctx, chainSeg))
331
+ return;
332
+ if (!ADDRESS_RE.test(addrSeg)) {
333
+ return sendError(res, 400, 'invalid_request', 'address path segment must be a 0x-prefixed 20-byte address');
334
+ }
335
+ const address = addrSeg;
336
+ if (sub === undefined) {
337
+ if (method === 'DELETE')
338
+ return removeProject(res, ctx, address);
339
+ return sendError(res, 405, 'invalid_request', 'use DELETE /v1/projects/{chainId}/{address}');
340
+ }
341
+ if (sub === 'reindex') {
342
+ if (method === 'POST')
343
+ return reindexProject(res, ctx, address);
344
+ return sendError(res, 405, 'invalid_request', 'use POST /v1/projects/{chainId}/{address}/reindex');
345
+ }
346
+ if (sub === 'status') {
347
+ if (method === 'GET')
348
+ return projectStatus(res, ctx, address);
349
+ return sendError(res, 405, 'invalid_request', 'use GET /v1/projects/{chainId}/{address}/status');
350
+ }
351
+ return sendError(res, 404, 'invalid_request', `unknown project action '${sub}' — reindex | status`);
352
+ }
353
+ if (parts[0] === 'effect-artifacts' && parts.length === 1) {
354
+ if (method === 'POST')
355
+ return publishEffectArtifact(req, res, ctx);
356
+ return sendError(res, 405, 'invalid_request', 'use POST /v1/effect-artifacts');
357
+ }
358
+ if (parts[0] === 'effect-status' && parts.length === 1) {
359
+ if (method === 'POST')
360
+ return reportEffectStatus(req, res, ctx);
361
+ return sendError(res, 405, 'invalid_request', 'use POST /v1/effect-status');
362
+ }
363
+ return sendError(res, 404, 'invalid_request', 'unknown control-plane route');
364
+ }
365
+ /** Validate a chainId (path segment or body value) against the chain this node serves. */
366
+ function checkChain(res, ctx, value) {
367
+ const n = typeof value === 'number' ? value : value == null || value === '' ? NaN : Number(value);
368
+ if (!Number.isInteger(n)) {
369
+ sendError(res, 400, 'invalid_request', 'chainId is required (an EIP-155 chain id)');
370
+ return false;
371
+ }
372
+ if (n !== ctx.chainId) {
373
+ sendError(res, 400, 'unsupported_chain', `this service serves chain ${ctx.chainId}, not ${n}`, { chains: [ctx.chainId] });
374
+ return false;
375
+ }
376
+ return true;
377
+ }
378
+ /**
379
+ * `POST /v1/projects {chainId, address, fromBlock?, factory?, label?, description?, externalUrl?,
380
+ * attributes?, tokenAttributes?, contentLocators?, full?}` — attributes are off-chain operator
381
+ * traits; contentLocators bridge `{ "0x<keccak>": "ipfs://<cid>" }` so the image points at IPFS
382
+ * without holding bytes.
383
+ * → register the contract with THIS node and replay it from chain. Idempotent: a re-POST is the
384
+ * post-deploy "nudge" that pulls events that landed since.
385
+ *
386
+ * We deliberately do NOT factory-scope here: if you operate this node and you tell it a contract,
387
+ * it does its best to index it. Factory allowlisting is a multi-tenant-provider policy, not a
388
+ * self-hosting requirement.
389
+ */
390
+ async function registerProject(req, res, ctx) {
391
+ const { indexer } = ctx;
392
+ let body;
393
+ try {
394
+ body = await readJsonBody(req);
395
+ }
396
+ catch (err) {
397
+ return sendError(res, 400, 'invalid_request', err.message);
398
+ }
399
+ if (!checkChain(res, ctx, body.chainId))
400
+ return;
401
+ const address = body.address;
402
+ if (!address || !ADDRESS_RE.test(address)) {
403
+ return sendError(res, 400, 'invalid_request', 'body.address must be a 0x-prefixed 20-byte address');
404
+ }
405
+ // A re-POST is the post-deploy nudge: preserve the existing scan floor + metadata
406
+ // (don't reset fromBlock and re-scan), and re-index incrementally. A first add — or one
407
+ // that supplies a *new* fromBlock — replays fully from that floor. (See planRegistrationFloor.)
408
+ const existing = indexer.store.getRegistration(address);
409
+ let plan = planRegistrationFloor(body.fromBlock, existing?.fromBlock, body.full === true);
410
+ if (!plan) {
411
+ // No floor supplied and none stored. NEVER default to genesis — a range-capped RPC would
412
+ // grind millions of blocks (the "resolver won't index" trap). Derive the deploy block from
413
+ // chain; refuse if we can't (archive getCode unavailable) rather than guess a bad floor.
414
+ const discovered = await discoverDeployBlock(indexer.publicClient(ctx.chainKey), address);
415
+ if (discovered === null) {
416
+ return sendError(res, 400, 'invalid_request', 'first registration needs fromBlock (the contract deploy block) — refusing a from-genesis scan. ' +
417
+ '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.');
418
+ }
419
+ plan = { fromBlock: discovered.toString(), full: true };
420
+ }
421
+ const { fromBlock, full } = plan;
422
+ // Attributes / locators arrive as JSON values; store them as text. Normalize attributes so a
423
+ // bad payload can't poison the served traits. Each preserves the existing value when omitted.
424
+ let attributes = existing?.attributes;
425
+ if (body.attributes !== undefined) {
426
+ attributes = body.attributes === null ? undefined : JSON.stringify(normalizeAttributes(body.attributes));
427
+ }
428
+ // Per-token off-chain traits (a Series' editable attributes): a `{ "<tokenId>": attrs }` object,
429
+ // each value normalized. Same preserve-on-omit / clear-on-null semantics as `attributes`.
430
+ let tokenAttributes = existing?.tokenAttributes;
431
+ if (body.tokenAttributes !== undefined) {
432
+ if (body.tokenAttributes === null)
433
+ tokenAttributes = undefined;
434
+ else {
435
+ const norm = {};
436
+ for (const [tokenId, v] of Object.entries(body.tokenAttributes)) {
437
+ const a = normalizeAttributes(v);
438
+ if (a.length)
439
+ norm[tokenId] = a;
440
+ }
441
+ tokenAttributes = Object.keys(norm).length ? JSON.stringify(norm) : undefined;
442
+ }
443
+ }
444
+ let contentLocators = existing?.contentLocators;
445
+ if (body.contentLocators !== undefined) {
446
+ contentLocators = mergeLocators(existing?.contentLocators, body.contentLocators);
447
+ }
448
+ // DURABLE FIRST (normative): the registration is written before any catch-up, so a flaky RPC
449
+ // mid-reconstruct can never lose the add — it becomes a slower backfill, not a failed request.
450
+ indexer.register({
451
+ address: address,
452
+ chainKey: ctx.chainKey,
453
+ fromBlock,
454
+ factory: body.factory ?? existing?.factory ?? process.env.ABX_FACTORY ?? null,
455
+ label: body.label ?? existing?.label,
456
+ description: body.description ?? existing?.description,
457
+ externalUrl: body.externalUrl ?? existing?.externalUrl,
458
+ attributes,
459
+ tokenAttributes,
460
+ contentLocators,
461
+ });
462
+ return catchUpAndRespond(res, ctx, address, { full });
463
+ }
464
+ /**
465
+ * Start (or join) catch-up and answer in one of the two conformant shapes: `200` with the completed
466
+ * summary when it lands inside the deadline, `202` + the lifecycle state when it doesn't. Shared by
467
+ * register and reindex so both branches behave identically.
468
+ *
469
+ * Single process, no queue: the work continues on the same event loop after the 202 goes out, and
470
+ * `reindexShared` guarantees a second POST joins that run instead of starting a rival reconstruct.
471
+ */
472
+ async function catchUpAndRespond(res, ctx, address, opts) {
473
+ const { indexer } = ctx;
474
+ const work = indexer.reindexShared(address, opts);
475
+ // Attach terminal handling ONCE, up front: whichever way we answer, a completed catch-up must
476
+ // notify the effects layer and a failed one must not surface as an unhandled rejection (its status
477
+ // is already recorded by reindex()).
478
+ let finished = false;
479
+ const settled = work.then((r) => {
480
+ finished = true;
481
+ notifyEffects(address);
482
+ return r;
483
+ }, (err) => {
484
+ finished = true;
485
+ console.error(`[control-plane] catch-up failed for ${address}: ${err.message}`);
486
+ return undefined;
487
+ });
488
+ const done = await withinDeadline(settled, registerDeadlineMs());
489
+ if (!done && !finished) {
490
+ // About to tell the client "wait": make sure the stored state says so. An INCREMENTAL catch-up
491
+ // deliberately keeps its prior status (so a live project doesn't flicker on every watcher tick) —
492
+ // but a client polling a run we deferred must not read `live` and stop waiting on stale counts.
493
+ // Guarded by `finished` so a run that just completed keeps its own `live`/`failed`.
494
+ const current = indexStatusOf(ctx, address);
495
+ if (current !== 'backfilling' && current !== 'failed') {
496
+ indexer.store.setIndexStatus(address, { status: 'backfilling' });
497
+ }
498
+ }
499
+ if (done) {
500
+ return sendJson(res, 200, {
501
+ ok: true,
502
+ mode: done.mode,
503
+ elapsedMs: done.elapsedMs,
504
+ project: { ...summarize(done.state), status: indexStatusOf(ctx, address) },
505
+ });
506
+ }
507
+ // Still working (or it failed and the client should read the class from status, not from a 500 that
508
+ // would imply the registration didn't land).
509
+ return sendJson(res, 202, {
510
+ ok: true,
511
+ accepted: true,
512
+ project: {
513
+ address,
514
+ name: indexer.getProject(address)?.name ?? null,
515
+ ...lifecycle(ctx, address),
516
+ },
517
+ });
518
+ }
519
+ /** `GET /v1/projects` — the projects visible to this token. Single-tenant reference: the token IS
520
+ * the node's one credential, so it sees every registration. */
521
+ function listProjects(res, ctx) {
522
+ const projects = ctx.indexer.store.listRegistrations().map((reg) => {
523
+ const state = ctx.indexer.getProject(reg.address);
524
+ let chainId = ctx.chainId;
525
+ try {
526
+ chainId = resolveChain(reg.chainKey).id;
527
+ }
528
+ catch {
529
+ /* unknown stored key — fall back to the node's chain */
530
+ }
531
+ // `status` (+ the error CLASS, never the hint) rides the list so a client renders
532
+ // "3 live, 1 backfilling, 1 failed (rpc_rate_limited)" without a round trip per project.
533
+ const life = lifecycle(ctx, reg.address);
534
+ const copies = state ? copiesOf(state) : undefined;
535
+ const contractGeneration = state ? contractGenerationOf(state) : undefined;
536
+ return {
537
+ chainId,
538
+ address: reg.address,
539
+ label: reg.label ?? null,
540
+ name: state?.name ?? null,
541
+ status: life.status,
542
+ ...(life.error ? { error: { class: life.error.class } } : {}),
543
+ eventCount: state?.eventCount ?? 0,
544
+ tokenCount: state?.tokens.length ?? 0,
545
+ ...(state ? tokenCounts(state) : { mintedCount: 0 }),
546
+ ...(copies !== undefined ? { copies } : {}),
547
+ ...(contractGeneration ? { contractGeneration } : {}),
548
+ reconstructedAt: state?.reconstructedAt ?? null,
549
+ };
550
+ });
551
+ return sendJson(res, 200, { projects });
552
+ }
553
+ /** `DELETE /v1/projects/{chainId}/{address}` — stop indexing it (drops the projection). */
554
+ function removeProject(res, ctx, address) {
555
+ const existed = !!ctx.indexer.store.getRegistration(address);
556
+ if (!existed)
557
+ return sendError(res, 404, 'not_registered', 'not registered');
558
+ ctx.indexer.store.deregister(address);
559
+ return sendJson(res, 200, { ok: true, address });
560
+ }
561
+ /** `POST /v1/projects/{chainId}/{address}/reindex` — full replay from chain. Bearer-gated: a full
562
+ * replay is expensive + mutating, so it must never be a public action (`abx index --remote`). */
563
+ async function reindexProject(res, ctx, address) {
564
+ if (!ctx.indexer.store.getRegistration(address)) {
565
+ return sendError(res, 404, 'not_registered', 'not registered');
566
+ }
567
+ return catchUpAndRespond(res, ctx, address, { full: true });
568
+ }
569
+ /** `GET /v1/projects/{chainId}/{address}/status` — indexing freshness: the registration's floor,
570
+ * the projection's watermarks, and the chain watcher's liveness (all already in the store). */
571
+ async function projectStatus(res, ctx, address) {
572
+ const reg = ctx.indexer.store.getRegistration(address);
573
+ if (!reg)
574
+ return sendError(res, 404, 'not_registered', 'not registered');
575
+ const state = ctx.indexer.getProject(address);
576
+ const pollAt = ctx.indexer.store.getMeta('watch:pollAt');
577
+ // The head the watcher last saw. Top-level (not just under `watcher`) because it's what a client
578
+ // computes lag / "N of M blocks" from, and it must not require knowing this node HAS a watcher.
579
+ const watcherHead = ctx.indexer.store.getMeta(`watch:${reg.chainKey}:head`);
580
+ // No watcher head yet (a fresh node's first backfill — exactly when a client most wants a
581
+ // percentage, and when it's most likely to be polling) ⇒ read head once, cached.
582
+ const head = watcherHead ?? (await cachedHead(ctx, reg.chainKey));
583
+ const copies = state ? copiesOf(state) : undefined;
584
+ const contractGeneration = state ? contractGenerationOf(state) : undefined;
585
+ return sendJson(res, 200, {
586
+ chainId: ctx.chainId,
587
+ address: reg.address,
588
+ ...lifecycle(ctx, address),
589
+ fromBlock: reg.fromBlock,
590
+ toBlock: state?.toBlock ?? null,
591
+ headBlock: head,
592
+ eventCount: state?.eventCount ?? 0,
593
+ tokenCount: state?.tokens.length ?? 0,
594
+ ...(state ? tokenCounts(state) : { mintedCount: 0 }),
595
+ ...(copies !== undefined ? { copies } : {}),
596
+ ...(contractGeneration ? { contractGeneration } : {}),
597
+ reconstructedAt: state?.reconstructedAt ?? null,
598
+ watcher: {
599
+ watching: pollAt !== null,
600
+ pollAt,
601
+ head: watcherHead,
602
+ intervalMs: watchIntervalMs(),
603
+ },
604
+ });
605
+ }
606
+ // Head is per-chain and moves slowly relative to a client's poll cadence, so one cached read serves
607
+ // a whole wait loop. Best-effort by design: a status read must never fail because the RPC is down —
608
+ // that's precisely when a caller needs the status.
609
+ let headCache = null;
610
+ const HEAD_CACHE_MS = 5_000;
611
+ async function cachedHead(ctx, chainKey) {
612
+ if (headCache && headCache.chainKey === chainKey && Date.now() - headCache.at < HEAD_CACHE_MS)
613
+ return headCache.head;
614
+ try {
615
+ const head = (await ctx.indexer.publicClient(chainKey).getBlockNumber()).toString();
616
+ headCache = { at: Date.now(), chainKey, head };
617
+ return head;
618
+ }
619
+ catch {
620
+ return null;
621
+ }
622
+ }
623
+ /**
624
+ * `POST /v1/effect-artifacts {chainId, address, tokenId, inputsHash, output?, effectKey?, locator? |
625
+ * bytes_base64?, contentType?}` — a conforming producer REGISTERS one render output so a resolver
626
+ * that does NOT share the producer's storage disk can serve it. A pointer registry, not an upload
627
+ * endpoint: which form is legal is decided by the output's BINDING, never by the producer
628
+ * (`site/content/docs/protocol/effects.mdx → Bound vs referenced`):
629
+ * - **referenced** (`render/image`, a video, a model, any output this node can't stitch) → `locator`
630
+ * (ipfs://<cid> | ar://<txid> | https://…), stored as a pointer; the read plane 302-redirects to
631
+ * it and never proxies. `bytes_base64` here is a 400: this node would gain no capability from the
632
+ * bytes (it redirects either way) and would acquire an object store, retention and egress.
633
+ * - **bound** (`render/traits`) → `bytes_base64`, ≤64KB, held next to the row. A locator here is
634
+ * also a 400 — the content stitches into the metadata JSON, so a locator would put a third-party
635
+ * fetch on `tokenURI` (and, historically, was recorded and then silently never stitched).
636
+ * Both refusals are loud on purpose: accept-and-drop leaves a token permanently unrenderable, or
637
+ * serves a confidently wrong answer, with nothing for the producer to act on.
638
+ * This node NEVER fetches a locator while handling the request — that would be byte custody through
639
+ * the back door plus an SSRF surface on an authed route.
640
+ * The artifact key is computed from the producer-supplied `inputsHash` — NOT recomputed from current
641
+ * state — so a render is never re-addressed to a state it doesn't depict (a param change instead makes
642
+ * it unreachable, the correct self-invalidation). Bearer-gated; never signs on-chain.
643
+ */
644
+ async function publishEffectArtifact(req, res, ctx) {
645
+ const { indexer } = ctx;
646
+ let body;
647
+ try {
648
+ // Bound bytes are capped at 64KB; base64 inflates by 4/3, and the rest of the body is small.
649
+ // (This used to allow 8MB so a thumbnail could be pushed as bytes — exactly the custody this
650
+ // route no longer accepts.)
651
+ body = await readJsonBody(req, Math.ceil((BOUND_ARTIFACT_MAX_BYTES * 4) / 3) + 8 * 1024);
652
+ }
653
+ catch (err) {
654
+ return sendError(res, 400, 'invalid_request', err.message);
655
+ }
656
+ if (!checkChain(res, ctx, body.chainId))
657
+ return;
658
+ const address = body.address;
659
+ if (!address || !ADDRESS_RE.test(address)) {
660
+ return sendError(res, 400, 'invalid_request', 'body.address must be a 0x-prefixed 20-byte address');
661
+ }
662
+ if (body.tokenId === undefined || body.tokenId === null) {
663
+ return sendError(res, 400, 'invalid_request', 'body.tokenId required');
664
+ }
665
+ const tokenId = String(body.tokenId);
666
+ const inputsHashHex = body.inputsHash;
667
+ if (!inputsHashHex || !/^0x[0-9a-fA-F]{64}$/.test(inputsHashHex)) {
668
+ return sendError(res, 400, 'invalid_request', 'body.inputsHash must be the 0x 32-byte hash the runner rendered (this node does NOT recompute it)');
669
+ }
670
+ // Any declared output key (the data plane's generality) — 'image'/'traits' are just the
671
+ // reference render effect's two.
672
+ const output = typeof body.output === 'string' && body.output ? body.output : 'image';
673
+ const effectKey = typeof body.effectKey === 'string' && body.effectKey ? body.effectKey : 'render';
674
+ const contentType = body.contentType ?? (output === 'traits' ? 'application/json' : 'image/png');
675
+ const key = renderArtifactKey(ctx.chainId, address, tokenId, inputsHashHex, output, effectKey);
676
+ // BOTH classes register a row — the row is the `artifacts` manifest's enumeration surface. What
677
+ // differs is what rides with it: a locator (referenced) or the content itself (bound).
678
+ const row = { key, address, tokenId, effectKey, outputKey: output, inputsHash: inputsHashHex, contentType };
679
+ const bound = isBoundOutput(effectKey, output);
680
+ const locator = typeof body.locator === 'string' && body.locator ? body.locator : undefined;
681
+ const bytesB64 = typeof body.bytes_base64 === 'string' && body.bytes_base64 ? body.bytes_base64 : undefined;
682
+ if (bound) {
683
+ // Bound: content only. A locator can never work here — the bytes stitch into the metadata JSON.
684
+ if (locator) {
685
+ return sendError(res, 400, 'invalid_request', `'${effectKey}/${output}' is a BOUND output — its content stitches into the token JSON, so it must be published as body.bytes_base64, not a locator. ` +
686
+ `A locator would be recorded here and then never stitch (a wrong answer served confidently), and it would put a third-party fetch on tokenURI.`);
687
+ }
688
+ if (!bytesB64) {
689
+ return sendError(res, 400, 'invalid_request', `'${effectKey}/${output}' is a BOUND output — provide body.bytes_base64 (≤${BOUND_ARTIFACT_MAX_BYTES} bytes)`);
690
+ }
691
+ const bytes = new Uint8Array(Buffer.from(bytesB64, 'base64'));
692
+ if (bytes.length > BOUND_ARTIFACT_MAX_BYTES) {
693
+ return sendError(res, 400, 'invalid_request', `bound output '${effectKey}/${output}' is ${bytes.length} bytes — the cap is ${BOUND_ARTIFACT_MAX_BYTES}. ` +
694
+ `An output this size belongs in the producer's own storage, registered as a locator.`);
695
+ }
696
+ indexer.store.putEffectArtifact({ ...row, locator: null, bytes });
697
+ // Retention is this node's POLICY, not a conformance rule (the normative half is that superseded
698
+ // content is never served or stitched — so nothing may read it). We drop eagerly: rows at older
699
+ // hashes stay as provenance, their content goes. Otherwise every param change would add another
700
+ // copy and held bytes would grow without bound instead of being capped by supply.
701
+ indexer.store.pruneBoundArtifactBytes(address, tokenId, effectKey, output, inputsHashHex);
702
+ return sendJson(res, 200, { ok: true, mode: 'bytes', key, output, bytes: bytes.length });
703
+ }
704
+ // Referenced: locator only. We store the pointer and never fetch it — the read plane 302s.
705
+ if (bytesB64) {
706
+ return sendError(res, 400, 'invalid_request', `'${effectKey}/${output}' is a REFERENCED output — publish it as body.locator (an https://, ipfs:// or ar:// URI reachable without your credentials). ` +
707
+ `This node serves referenced output by redirect, so holding its bytes would gain it nothing and cost it object storage. ` +
708
+ `A producer that can't expose a locator needs a storage backend that can (S3/R2, IPFS, Arweave, or its own public base), or to run co-located with the resolver.`);
709
+ }
710
+ if (!locator) {
711
+ return sendError(res, 400, 'invalid_request', `'${effectKey}/${output}' is a REFERENCED output — provide body.locator (an https://, ipfs:// or ar:// URI)`);
712
+ }
713
+ // A locator is a REACHABILITY claim (any scheme; no durability preference — a gateway URL and an
714
+ // ipfs:// are peers here). We can't prove reachability without fetching, and fetching a
715
+ // third-party URL while handling an authed write is exactly the custody/SSRF surface this route
716
+ // refuses — so we reject what's decidable from the string: private hosts, and presigned URLs that
717
+ // would pass today and rot later.
718
+ const bad = locatorRejectionReason(locator);
719
+ if (bad)
720
+ return sendError(res, 400, 'invalid_request', `body.locator rejected: ${bad}`);
721
+ indexer.store.putEffectArtifact({ ...row, locator });
722
+ return sendJson(res, 200, { ok: true, mode: 'locator', key, output, locator });
723
+ }
724
+ /**
725
+ * `POST /v1/effect-status {chainId, key, address, tokenId, effectKey, status, error?, attempts?}` —
726
+ * a runner reports one run's transient state for the artifact `key` it is producing (the runner
727
+ * computes the key; this node never re-derives it, mirroring /v1/effect-artifacts). `status`
728
+ * 'done' clears the row (artifact presence takes over as truth); 'rendering'/'failed' upsert.
729
+ */
730
+ async function reportEffectStatus(req, res, ctx) {
731
+ const { indexer } = ctx;
732
+ let body;
733
+ try {
734
+ body = await readJsonBody(req);
735
+ }
736
+ catch (err) {
737
+ return sendError(res, 400, 'invalid_request', err.message);
738
+ }
739
+ if (!checkChain(res, ctx, body.chainId))
740
+ return;
741
+ const key = body.key;
742
+ if (!key || !/^0x[0-9a-fA-F]{64}$/.test(key)) {
743
+ return sendError(res, 400, 'invalid_request', 'body.key must be the 0x 32-byte artifact key this run produces');
744
+ }
745
+ const address = body.address;
746
+ if (!address || !ADDRESS_RE.test(address)) {
747
+ return sendError(res, 400, 'invalid_request', 'body.address must be a 0x-prefixed 20-byte address');
748
+ }
749
+ const status = body.status;
750
+ if (status === 'done') {
751
+ indexer.store.clearEffectStatus(key);
752
+ return sendJson(res, 200, { ok: true, cleared: key });
753
+ }
754
+ if (status !== 'rendering' && status !== 'failed') {
755
+ return sendError(res, 400, 'invalid_request', "body.status must be 'rendering' | 'failed' | 'done'");
756
+ }
757
+ indexer.store.putEffectStatus({
758
+ key,
759
+ address,
760
+ tokenId: String(body.tokenId ?? ''),
761
+ effectKey: typeof body.effectKey === 'string' && body.effectKey ? body.effectKey : 'render',
762
+ status,
763
+ error: typeof body.error === 'string' ? body.error.slice(0, 2000) : null,
764
+ attempts: typeof body.attempts === 'number' ? body.attempts : undefined,
765
+ });
766
+ return sendJson(res, 200, { ok: true, key, status });
767
+ }
768
+ //# sourceMappingURL=control-plane.js.map