@artblocks/abx-token-api 0.1.0-alpha.4 → 0.1.0-alpha.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/control-plane.d.ts +78 -0
- package/dist/control-plane.d.ts.map +1 -0
- package/dist/control-plane.js +652 -0
- package/dist/control-plane.js.map +1 -0
- package/dist/dashboard.js +5 -3
- package/dist/dashboard.js.map +1 -1
- package/dist/server.d.ts +0 -26
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +59 -357
- package/dist/server.js.map +1 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +125 -2
- package/dist/watcher.js.map +1 -1
- package/package.json +4 -4
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The /v1 control plane + the service descriptor — the resolver's ONE write surface, speaking the
|
|
3
|
+
* provider-neutral interface pinned by specs/self-host-toolkit/remote-services.md. Everything here
|
|
4
|
+
* is index/metadata control (register, list, deregister, reindex, status, effect publishes): 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, normalizeAttributes, renderArtifactKey, resolveChain, CONTROL_PLANE_INTERFACE, EFFECTS_PUBLISH_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 (specs/self-host-toolkit/remote-services.md → Errors). */
|
|
22
|
+
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
|
+
/** The one-line project summary shared by `GET /api/projects` and the register/reindex responses. */
|
|
121
|
+
export function summarize(s) {
|
|
122
|
+
return {
|
|
123
|
+
address: s.address,
|
|
124
|
+
name: s.name,
|
|
125
|
+
symbol: s.symbol,
|
|
126
|
+
owner: s.owner,
|
|
127
|
+
abxVersion: s.abxVersion,
|
|
128
|
+
isCanonical: s.isCanonical,
|
|
129
|
+
extensions: s.extensions.map((e) => e.name),
|
|
130
|
+
eventCount: s.eventCount,
|
|
131
|
+
tokenCount: s.tokens.length,
|
|
132
|
+
mintedCount: s.tokens.filter((t) => t.minted).length,
|
|
133
|
+
reconstructedAt: s.reconstructedAt,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/** Parse a stored content-locators JSON column → `{hash: locator}` (lowercased keys, never throws). */
|
|
137
|
+
export function safeLocators(json) {
|
|
138
|
+
try {
|
|
139
|
+
const obj = JSON.parse(json);
|
|
140
|
+
const out = {};
|
|
141
|
+
for (const [k, v] of Object.entries(obj))
|
|
142
|
+
if (typeof v === 'string')
|
|
143
|
+
out[k.toLowerCase()] = v;
|
|
144
|
+
return Object.keys(out).length ? out : undefined;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** Merge incoming content locators over the stored ones (additive — a re-add can bring new hashes
|
|
151
|
+
* without dropping known ones). Returns a JSON string for the column, or undefined if empty. */
|
|
152
|
+
function mergeLocators(existingJson, incoming) {
|
|
153
|
+
const base = existingJson ? safeLocators(existingJson) ?? {} : {};
|
|
154
|
+
if (incoming && typeof incoming === 'object') {
|
|
155
|
+
for (const [k, v] of Object.entries(incoming)) {
|
|
156
|
+
if (typeof v === 'string' && v)
|
|
157
|
+
base[k.toLowerCase()] = v;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return Object.keys(base).length ? JSON.stringify(base) : undefined;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Decide the scan floor + whether a full replay is needed when registering a project via the
|
|
164
|
+
* control plane. Pure (the discovery/refusal fallback for the null case is the caller's):
|
|
165
|
+
* - explicit `bodyFromBlock` wins; else the `existingFromBlock` already stored.
|
|
166
|
+
* - `null` ⇒ NEITHER supplied nor stored — the caller must derive the deploy block or refuse
|
|
167
|
+
* (never default to genesis: a range-capped RPC would sweep millions of blocks).
|
|
168
|
+
* - `full` is true only when forced, on a first registration (no existing floor), or when the
|
|
169
|
+
* floor actually CHANGED — so re-sending the SAME floor (the CLI now always forwards the deploy
|
|
170
|
+
* block, even on a nudge) stays incremental: registering twice ≠ two full scans.
|
|
171
|
+
*/
|
|
172
|
+
export function planRegistrationFloor(bodyFromBlock, existingFromBlock, forceFull = false) {
|
|
173
|
+
const fromBlock = bodyFromBlock !== undefined ? String(bodyFromBlock) : existingFromBlock;
|
|
174
|
+
if (fromBlock === undefined)
|
|
175
|
+
return null;
|
|
176
|
+
const full = forceFull || existingFromBlock === undefined || fromBlock !== existingFromBlock;
|
|
177
|
+
return { fromBlock, full };
|
|
178
|
+
}
|
|
179
|
+
// ── the service descriptor ──────────────────────────────────────────────────────
|
|
180
|
+
/** This package's own name+version (dev and published resolve the same — npm always ships
|
|
181
|
+
* package.json). Never throws; identity is display-only, not dispatch. */
|
|
182
|
+
function packageIdentity() {
|
|
183
|
+
try {
|
|
184
|
+
const pkgDir = resolve(fileURLToPath(import.meta.url), '..', '..');
|
|
185
|
+
const raw = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'));
|
|
186
|
+
return { name: raw.name ?? '@artblocks/abx-token-api', version: raw.version ?? '0.0.0' };
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return { name: '@artblocks/abx-token-api', version: '0.0.0' };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// The runner /health probe is cached briefly (and keyed by URL, so a config change invalidates):
|
|
193
|
+
// the descriptor must stay cheap to serve, and a dead runner must not slow it down.
|
|
194
|
+
let effectsProbe = null;
|
|
195
|
+
const EFFECTS_PROBE_TTL_MS = 60_000;
|
|
196
|
+
async function probeRunnerEffects(url) {
|
|
197
|
+
if (effectsProbe && effectsProbe.url === url && Date.now() - effectsProbe.at < EFFECTS_PROBE_TTL_MS) {
|
|
198
|
+
return effectsProbe.effects;
|
|
199
|
+
}
|
|
200
|
+
let effects = null;
|
|
201
|
+
try {
|
|
202
|
+
const resp = await fetch(`${url.replace(/\/+$/, '')}/health`, { signal: AbortSignal.timeout(1500) });
|
|
203
|
+
const body = (await resp.json());
|
|
204
|
+
if (Array.isArray(body.effects))
|
|
205
|
+
effects = body.effects;
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
effects = null; // attached-but-unverified — distinct from "attached, zero effects"
|
|
209
|
+
}
|
|
210
|
+
effectsProbe = { at: Date.now(), url, effects };
|
|
211
|
+
return effects;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* `GET /.well-known/abx-service` — what this node supports, agent-readably (public, no auth).
|
|
215
|
+
* The control-plane + effects-publish interfaces (and `auth`) appear iff the bearer token is
|
|
216
|
+
* configured: a token-less self-host node honestly advertises no remote control surface — which
|
|
217
|
+
* is how a client tells "disabled" from "wrong URL". `render` appears when a runner rides behind
|
|
218
|
+
* this resolver (ABX_EFFECTS_URL); its declared effects come from a best-effort `/health` probe.
|
|
219
|
+
* Provider identity (name, signup/docs URLs) is deployment env — no provider ships in this code.
|
|
220
|
+
*/
|
|
221
|
+
export async function serviceDescriptor(ctx) {
|
|
222
|
+
const controlPlane = !!process.env.ABX_RESOLVER_ADMIN_TOKEN;
|
|
223
|
+
const pkg = packageIdentity();
|
|
224
|
+
const descriptor = {
|
|
225
|
+
service: { name: process.env.ABX_SERVICE_NAME ?? pkg.name, version: pkg.version },
|
|
226
|
+
interfaces: controlPlane
|
|
227
|
+
? [TOKEN_API_INTERFACE, CONTROL_PLANE_INTERFACE, EFFECTS_PUBLISH_INTERFACE]
|
|
228
|
+
: [TOKEN_API_INTERFACE],
|
|
229
|
+
chains: [ctx.chainId],
|
|
230
|
+
baseUrl: ctx.baseUrl,
|
|
231
|
+
};
|
|
232
|
+
if (controlPlane) {
|
|
233
|
+
descriptor.auth = {
|
|
234
|
+
scheme: 'bearer',
|
|
235
|
+
...(process.env.ABX_SERVICE_SIGNUP_URL ? { signupUrl: process.env.ABX_SERVICE_SIGNUP_URL } : {}),
|
|
236
|
+
...(process.env.ABX_SERVICE_DOCS_URL ? { docsUrl: process.env.ABX_SERVICE_DOCS_URL } : {}),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
const effectsUrl = process.env.ABX_EFFECTS_URL;
|
|
240
|
+
if (effectsUrl) {
|
|
241
|
+
descriptor.render = { attached: true, effects: await probeRunnerEffects(effectsUrl) };
|
|
242
|
+
}
|
|
243
|
+
return descriptor;
|
|
244
|
+
}
|
|
245
|
+
// ── the /v1 routes ──────────────────────────────────────────────────────────────
|
|
246
|
+
/**
|
|
247
|
+
* Dispatch a `/v1/*` request (`parts` excludes the leading `v1`). Every route is bearer-gated;
|
|
248
|
+
* `chainId` is explicit everywhere (body on collection POSTs, path elsewhere) and validated, so a
|
|
249
|
+
* project can never silently register against the wrong chain. Wrong method on a known path ⇒ 405.
|
|
250
|
+
*/
|
|
251
|
+
export async function routeControlPlane(req, res, ctx, parts) {
|
|
252
|
+
if (!requireBearer(req, res))
|
|
253
|
+
return;
|
|
254
|
+
const method = req.method ?? 'GET';
|
|
255
|
+
if (parts[0] === 'projects') {
|
|
256
|
+
if (parts.length === 1) {
|
|
257
|
+
if (method === 'POST')
|
|
258
|
+
return registerProject(req, res, ctx);
|
|
259
|
+
if (method === 'GET')
|
|
260
|
+
return listProjects(res, ctx);
|
|
261
|
+
return sendError(res, 405, 'invalid_request', 'use POST /v1/projects (register) or GET /v1/projects (list)');
|
|
262
|
+
}
|
|
263
|
+
const [, chainSeg, addrSeg, sub] = parts;
|
|
264
|
+
if (!addrSeg || parts.length > 4) {
|
|
265
|
+
return sendError(res, 404, 'invalid_request', 'unknown control-plane route — projects are /v1/projects/{chainId}/{address}');
|
|
266
|
+
}
|
|
267
|
+
if (!checkChain(res, ctx, chainSeg))
|
|
268
|
+
return;
|
|
269
|
+
if (!ADDRESS_RE.test(addrSeg)) {
|
|
270
|
+
return sendError(res, 400, 'invalid_request', 'address path segment must be a 0x-prefixed 20-byte address');
|
|
271
|
+
}
|
|
272
|
+
const address = addrSeg;
|
|
273
|
+
if (sub === undefined) {
|
|
274
|
+
if (method === 'DELETE')
|
|
275
|
+
return removeProject(res, ctx, address);
|
|
276
|
+
return sendError(res, 405, 'invalid_request', 'use DELETE /v1/projects/{chainId}/{address}');
|
|
277
|
+
}
|
|
278
|
+
if (sub === 'reindex') {
|
|
279
|
+
if (method === 'POST')
|
|
280
|
+
return reindexProject(res, ctx, address);
|
|
281
|
+
return sendError(res, 405, 'invalid_request', 'use POST /v1/projects/{chainId}/{address}/reindex');
|
|
282
|
+
}
|
|
283
|
+
if (sub === 'status') {
|
|
284
|
+
if (method === 'GET')
|
|
285
|
+
return projectStatus(res, ctx, address);
|
|
286
|
+
return sendError(res, 405, 'invalid_request', 'use GET /v1/projects/{chainId}/{address}/status');
|
|
287
|
+
}
|
|
288
|
+
return sendError(res, 404, 'invalid_request', `unknown project action '${sub}' — reindex | status`);
|
|
289
|
+
}
|
|
290
|
+
if (parts[0] === 'effect-artifacts' && parts.length === 1) {
|
|
291
|
+
if (method === 'POST')
|
|
292
|
+
return publishEffectArtifact(req, res, ctx);
|
|
293
|
+
return sendError(res, 405, 'invalid_request', 'use POST /v1/effect-artifacts');
|
|
294
|
+
}
|
|
295
|
+
if (parts[0] === 'effect-status' && parts.length === 1) {
|
|
296
|
+
if (method === 'POST')
|
|
297
|
+
return reportEffectStatus(req, res, ctx);
|
|
298
|
+
return sendError(res, 405, 'invalid_request', 'use POST /v1/effect-status');
|
|
299
|
+
}
|
|
300
|
+
return sendError(res, 404, 'invalid_request', 'unknown control-plane route');
|
|
301
|
+
}
|
|
302
|
+
/** Validate a chainId (path segment or body value) against the chain this node serves. */
|
|
303
|
+
function checkChain(res, ctx, value) {
|
|
304
|
+
const n = typeof value === 'number' ? value : value == null || value === '' ? NaN : Number(value);
|
|
305
|
+
if (!Number.isInteger(n)) {
|
|
306
|
+
sendError(res, 400, 'invalid_request', 'chainId is required (an EIP-155 chain id)');
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
if (n !== ctx.chainId) {
|
|
310
|
+
sendError(res, 400, 'unsupported_chain', `this service serves chain ${ctx.chainId}, not ${n}`, { chains: [ctx.chainId] });
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* `POST /v1/projects {chainId, address, fromBlock?, factory?, label?, description?, externalUrl?,
|
|
317
|
+
* attributes?, tokenAttributes?, contentLocators?, full?}` — attributes are off-chain operator
|
|
318
|
+
* traits; contentLocators bridge `{ "0x<keccak>": "ipfs://<cid>" }` so the image points at IPFS
|
|
319
|
+
* without holding bytes.
|
|
320
|
+
* → register the contract with THIS node and replay it from chain. Idempotent: a re-POST is the
|
|
321
|
+
* post-deploy "nudge" that pulls events that landed since.
|
|
322
|
+
*
|
|
323
|
+
* We deliberately do NOT factory-scope here: if you operate this node and you tell it a contract,
|
|
324
|
+
* it does its best to index it. (Allowlisting by factory is a multi-tenant-provider concern — see
|
|
325
|
+
* docs/10-backlog.md B6 — not a self-hosting one.)
|
|
326
|
+
*/
|
|
327
|
+
async function registerProject(req, res, ctx) {
|
|
328
|
+
const { indexer } = ctx;
|
|
329
|
+
let body;
|
|
330
|
+
try {
|
|
331
|
+
body = await readJsonBody(req);
|
|
332
|
+
}
|
|
333
|
+
catch (err) {
|
|
334
|
+
return sendError(res, 400, 'invalid_request', err.message);
|
|
335
|
+
}
|
|
336
|
+
if (!checkChain(res, ctx, body.chainId))
|
|
337
|
+
return;
|
|
338
|
+
const address = body.address;
|
|
339
|
+
if (!address || !ADDRESS_RE.test(address)) {
|
|
340
|
+
return sendError(res, 400, 'invalid_request', 'body.address must be a 0x-prefixed 20-byte address');
|
|
341
|
+
}
|
|
342
|
+
// A re-POST is the post-deploy nudge: preserve the existing scan floor + metadata
|
|
343
|
+
// (don't reset fromBlock and re-scan), and re-index incrementally. A first add — or one
|
|
344
|
+
// that supplies a *new* fromBlock — replays fully from that floor. (See planRegistrationFloor.)
|
|
345
|
+
const existing = indexer.store.getRegistration(address);
|
|
346
|
+
let plan = planRegistrationFloor(body.fromBlock, existing?.fromBlock, body.full === true);
|
|
347
|
+
if (!plan) {
|
|
348
|
+
// No floor supplied and none stored. NEVER default to genesis — a range-capped RPC would
|
|
349
|
+
// grind millions of blocks (the "resolver won't index" trap). Derive the deploy block from
|
|
350
|
+
// chain; refuse if we can't (archive getCode unavailable) rather than guess a bad floor.
|
|
351
|
+
const discovered = await discoverDeployBlock(indexer.publicClient(ctx.chainKey), address);
|
|
352
|
+
if (discovered === null) {
|
|
353
|
+
return sendError(res, 400, 'invalid_request', 'first registration needs fromBlock (the contract deploy block) — refusing a from-genesis scan. ' +
|
|
354
|
+
'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.');
|
|
355
|
+
}
|
|
356
|
+
plan = { fromBlock: discovered.toString(), full: true };
|
|
357
|
+
}
|
|
358
|
+
const { fromBlock, full } = plan;
|
|
359
|
+
// Attributes / locators arrive as JSON values; store them as text. Normalize attributes so a
|
|
360
|
+
// bad payload can't poison the served traits. Each preserves the existing value when omitted.
|
|
361
|
+
let attributes = existing?.attributes;
|
|
362
|
+
if (body.attributes !== undefined) {
|
|
363
|
+
attributes = body.attributes === null ? undefined : JSON.stringify(normalizeAttributes(body.attributes));
|
|
364
|
+
}
|
|
365
|
+
// Per-token off-chain traits (a Series' editable attributes): a `{ "<tokenId>": attrs }` object,
|
|
366
|
+
// each value normalized. Same preserve-on-omit / clear-on-null semantics as `attributes`.
|
|
367
|
+
let tokenAttributes = existing?.tokenAttributes;
|
|
368
|
+
if (body.tokenAttributes !== undefined) {
|
|
369
|
+
if (body.tokenAttributes === null)
|
|
370
|
+
tokenAttributes = undefined;
|
|
371
|
+
else {
|
|
372
|
+
const norm = {};
|
|
373
|
+
for (const [tokenId, v] of Object.entries(body.tokenAttributes)) {
|
|
374
|
+
const a = normalizeAttributes(v);
|
|
375
|
+
if (a.length)
|
|
376
|
+
norm[tokenId] = a;
|
|
377
|
+
}
|
|
378
|
+
tokenAttributes = Object.keys(norm).length ? JSON.stringify(norm) : undefined;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
let contentLocators = existing?.contentLocators;
|
|
382
|
+
if (body.contentLocators !== undefined) {
|
|
383
|
+
contentLocators = mergeLocators(existing?.contentLocators, body.contentLocators);
|
|
384
|
+
}
|
|
385
|
+
// DURABLE FIRST (normative): the registration is written before any catch-up, so a flaky RPC
|
|
386
|
+
// mid-reconstruct can never lose the add — it becomes a slower backfill, not a failed request.
|
|
387
|
+
indexer.register({
|
|
388
|
+
address: address,
|
|
389
|
+
chainKey: ctx.chainKey,
|
|
390
|
+
fromBlock,
|
|
391
|
+
factory: body.factory ?? existing?.factory ?? process.env.ABX_FACTORY ?? null,
|
|
392
|
+
label: body.label ?? existing?.label,
|
|
393
|
+
description: body.description ?? existing?.description,
|
|
394
|
+
externalUrl: body.externalUrl ?? existing?.externalUrl,
|
|
395
|
+
attributes,
|
|
396
|
+
tokenAttributes,
|
|
397
|
+
contentLocators,
|
|
398
|
+
});
|
|
399
|
+
return catchUpAndRespond(res, ctx, address, { full });
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Start (or join) catch-up and answer in one of the two conformant shapes: `200` with the completed
|
|
403
|
+
* summary when it lands inside the deadline, `202` + the lifecycle state when it doesn't. Shared by
|
|
404
|
+
* register and reindex so both branches behave identically.
|
|
405
|
+
*
|
|
406
|
+
* Single process, no queue: the work continues on the same event loop after the 202 goes out, and
|
|
407
|
+
* `reindexShared` guarantees a second POST joins that run instead of starting a rival reconstruct.
|
|
408
|
+
*/
|
|
409
|
+
async function catchUpAndRespond(res, ctx, address, opts) {
|
|
410
|
+
const { indexer } = ctx;
|
|
411
|
+
const work = indexer.reindexShared(address, opts);
|
|
412
|
+
// Attach terminal handling ONCE, up front: whichever way we answer, a completed catch-up must
|
|
413
|
+
// notify the effects layer and a failed one must not surface as an unhandled rejection (its status
|
|
414
|
+
// is already recorded by reindex()).
|
|
415
|
+
let finished = false;
|
|
416
|
+
const settled = work.then((r) => {
|
|
417
|
+
finished = true;
|
|
418
|
+
notifyEffects(address);
|
|
419
|
+
return r;
|
|
420
|
+
}, (err) => {
|
|
421
|
+
finished = true;
|
|
422
|
+
console.error(`[control-plane] catch-up failed for ${address}: ${err.message}`);
|
|
423
|
+
return undefined;
|
|
424
|
+
});
|
|
425
|
+
const done = await withinDeadline(settled, registerDeadlineMs());
|
|
426
|
+
if (!done && !finished) {
|
|
427
|
+
// About to tell the client "wait": make sure the stored state says so. An INCREMENTAL catch-up
|
|
428
|
+
// deliberately keeps its prior status (so a live project doesn't flicker on every watcher tick) —
|
|
429
|
+
// but a client polling a run we deferred must not read `live` and stop waiting on stale counts.
|
|
430
|
+
// Guarded by `finished` so a run that just completed keeps its own `live`/`failed`.
|
|
431
|
+
const current = indexStatusOf(ctx, address);
|
|
432
|
+
if (current !== 'backfilling' && current !== 'failed') {
|
|
433
|
+
indexer.store.setIndexStatus(address, { status: 'backfilling' });
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
if (done) {
|
|
437
|
+
return sendJson(res, 200, {
|
|
438
|
+
ok: true,
|
|
439
|
+
mode: done.mode,
|
|
440
|
+
elapsedMs: done.elapsedMs,
|
|
441
|
+
project: { ...summarize(done.state), status: indexStatusOf(ctx, address) },
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
// Still working (or it failed and the client should read the class from status, not from a 500 that
|
|
445
|
+
// would imply the registration didn't land).
|
|
446
|
+
return sendJson(res, 202, {
|
|
447
|
+
ok: true,
|
|
448
|
+
accepted: true,
|
|
449
|
+
project: {
|
|
450
|
+
address,
|
|
451
|
+
name: indexer.getProject(address)?.name ?? null,
|
|
452
|
+
...lifecycle(ctx, address),
|
|
453
|
+
},
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
/** `GET /v1/projects` — the projects visible to this token. Single-tenant reference: the token IS
|
|
457
|
+
* the node's one credential, so it sees every registration. */
|
|
458
|
+
function listProjects(res, ctx) {
|
|
459
|
+
const projects = ctx.indexer.store.listRegistrations().map((reg) => {
|
|
460
|
+
const state = ctx.indexer.getProject(reg.address);
|
|
461
|
+
let chainId = ctx.chainId;
|
|
462
|
+
try {
|
|
463
|
+
chainId = resolveChain(reg.chainKey).id;
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
/* unknown stored key — fall back to the node's chain */
|
|
467
|
+
}
|
|
468
|
+
// `status` (+ the error CLASS, never the hint) rides the list so a client renders
|
|
469
|
+
// "3 live, 1 backfilling, 1 failed (rpc_rate_limited)" without a round trip per project.
|
|
470
|
+
const life = lifecycle(ctx, reg.address);
|
|
471
|
+
return {
|
|
472
|
+
chainId,
|
|
473
|
+
address: reg.address,
|
|
474
|
+
label: reg.label ?? null,
|
|
475
|
+
name: state?.name ?? null,
|
|
476
|
+
status: life.status,
|
|
477
|
+
...(life.error ? { error: { class: life.error.class } } : {}),
|
|
478
|
+
eventCount: state?.eventCount ?? 0,
|
|
479
|
+
tokenCount: state?.tokens.length ?? 0,
|
|
480
|
+
mintedCount: state?.tokens.filter((t) => t.minted).length ?? 0,
|
|
481
|
+
reconstructedAt: state?.reconstructedAt ?? null,
|
|
482
|
+
};
|
|
483
|
+
});
|
|
484
|
+
return sendJson(res, 200, { projects });
|
|
485
|
+
}
|
|
486
|
+
/** `DELETE /v1/projects/{chainId}/{address}` — stop indexing it (drops the projection). */
|
|
487
|
+
function removeProject(res, ctx, address) {
|
|
488
|
+
const existed = !!ctx.indexer.store.getRegistration(address);
|
|
489
|
+
if (!existed)
|
|
490
|
+
return sendError(res, 404, 'not_registered', 'not registered');
|
|
491
|
+
ctx.indexer.store.deregister(address);
|
|
492
|
+
return sendJson(res, 200, { ok: true, address });
|
|
493
|
+
}
|
|
494
|
+
/** `POST /v1/projects/{chainId}/{address}/reindex` — full replay from chain. Bearer-gated: a full
|
|
495
|
+
* replay is expensive + mutating, so it must never be a public action (`abx index --remote`). */
|
|
496
|
+
async function reindexProject(res, ctx, address) {
|
|
497
|
+
if (!ctx.indexer.store.getRegistration(address)) {
|
|
498
|
+
return sendError(res, 404, 'not_registered', 'not registered');
|
|
499
|
+
}
|
|
500
|
+
return catchUpAndRespond(res, ctx, address, { full: true });
|
|
501
|
+
}
|
|
502
|
+
/** `GET /v1/projects/{chainId}/{address}/status` — indexing freshness: the registration's floor,
|
|
503
|
+
* the projection's watermarks, and the chain watcher's liveness (all already in the store). */
|
|
504
|
+
async function projectStatus(res, ctx, address) {
|
|
505
|
+
const reg = ctx.indexer.store.getRegistration(address);
|
|
506
|
+
if (!reg)
|
|
507
|
+
return sendError(res, 404, 'not_registered', 'not registered');
|
|
508
|
+
const state = ctx.indexer.getProject(address);
|
|
509
|
+
const pollAt = ctx.indexer.store.getMeta('watch:pollAt');
|
|
510
|
+
// The head the watcher last saw. Top-level (not just under `watcher`) because it's what a client
|
|
511
|
+
// computes lag / "N of M blocks" from, and it must not require knowing this node HAS a watcher.
|
|
512
|
+
const watcherHead = ctx.indexer.store.getMeta(`watch:${reg.chainKey}:head`);
|
|
513
|
+
// No watcher head yet (a fresh node's first backfill — exactly when a client most wants a
|
|
514
|
+
// percentage, and when it's most likely to be polling) ⇒ read head once, cached.
|
|
515
|
+
const head = watcherHead ?? (await cachedHead(ctx, reg.chainKey));
|
|
516
|
+
return sendJson(res, 200, {
|
|
517
|
+
chainId: ctx.chainId,
|
|
518
|
+
address: reg.address,
|
|
519
|
+
...lifecycle(ctx, address),
|
|
520
|
+
fromBlock: reg.fromBlock,
|
|
521
|
+
toBlock: state?.toBlock ?? null,
|
|
522
|
+
headBlock: head,
|
|
523
|
+
eventCount: state?.eventCount ?? 0,
|
|
524
|
+
tokenCount: state?.tokens.length ?? 0,
|
|
525
|
+
mintedCount: state?.tokens.filter((t) => t.minted).length ?? 0,
|
|
526
|
+
reconstructedAt: state?.reconstructedAt ?? null,
|
|
527
|
+
watcher: {
|
|
528
|
+
watching: pollAt !== null,
|
|
529
|
+
pollAt,
|
|
530
|
+
head: watcherHead,
|
|
531
|
+
intervalMs: watchIntervalMs(),
|
|
532
|
+
},
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
// Head is per-chain and moves slowly relative to a client's poll cadence, so one cached read serves
|
|
536
|
+
// a whole wait loop. Best-effort by design: a status read must never fail because the RPC is down —
|
|
537
|
+
// that's precisely when a caller needs the status.
|
|
538
|
+
let headCache = null;
|
|
539
|
+
const HEAD_CACHE_MS = 5_000;
|
|
540
|
+
async function cachedHead(ctx, chainKey) {
|
|
541
|
+
if (headCache && headCache.chainKey === chainKey && Date.now() - headCache.at < HEAD_CACHE_MS)
|
|
542
|
+
return headCache.head;
|
|
543
|
+
try {
|
|
544
|
+
const head = (await ctx.indexer.publicClient(chainKey).getBlockNumber()).toString();
|
|
545
|
+
headCache = { at: Date.now(), chainKey, head };
|
|
546
|
+
return head;
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* `POST /v1/effect-artifacts {chainId, address, tokenId, inputsHash, output?, effectKey?, locator? |
|
|
554
|
+
* bytes_base64?, contentType?}` — a conforming effect runner publishes a render output so a
|
|
555
|
+
* resolver that does NOT share the runner's storage disk can serve it. Two modes:
|
|
556
|
+
* - `locator` (ipfs://<cid> | ar://<txid> | https://…): stored as a pointer; `/image` 302-redirects.
|
|
557
|
+
* - `bytes_base64`: stored in this node's own byte custody (what traits use — they inline into JSON).
|
|
558
|
+
* The artifact key is computed from the runner-supplied `inputsHash` — NOT recomputed from current
|
|
559
|
+
* state — so a render is never re-addressed to a state it doesn't depict (a param change instead makes
|
|
560
|
+
* it unreachable, the correct self-invalidation). Bearer-gated; never signs on-chain.
|
|
561
|
+
*/
|
|
562
|
+
async function publishEffectArtifact(req, res, ctx) {
|
|
563
|
+
const { indexer, storage } = ctx;
|
|
564
|
+
let body;
|
|
565
|
+
try {
|
|
566
|
+
body = await readJsonBody(req, 8 * 1024 * 1024); // a thumbnail pushed as bytes can exceed the 64KB default
|
|
567
|
+
}
|
|
568
|
+
catch (err) {
|
|
569
|
+
return sendError(res, 400, 'invalid_request', err.message);
|
|
570
|
+
}
|
|
571
|
+
if (!checkChain(res, ctx, body.chainId))
|
|
572
|
+
return;
|
|
573
|
+
const address = body.address;
|
|
574
|
+
if (!address || !ADDRESS_RE.test(address)) {
|
|
575
|
+
return sendError(res, 400, 'invalid_request', 'body.address must be a 0x-prefixed 20-byte address');
|
|
576
|
+
}
|
|
577
|
+
if (body.tokenId === undefined || body.tokenId === null) {
|
|
578
|
+
return sendError(res, 400, 'invalid_request', 'body.tokenId required');
|
|
579
|
+
}
|
|
580
|
+
const tokenId = String(body.tokenId);
|
|
581
|
+
const inputsHashHex = body.inputsHash;
|
|
582
|
+
if (!inputsHashHex || !/^0x[0-9a-fA-F]{64}$/.test(inputsHashHex)) {
|
|
583
|
+
return sendError(res, 400, 'invalid_request', 'body.inputsHash must be the 0x 32-byte hash the runner rendered (this node does NOT recompute it)');
|
|
584
|
+
}
|
|
585
|
+
// Any declared output key (the data plane's generality) — 'image'/'traits' are just the
|
|
586
|
+
// reference render effect's two.
|
|
587
|
+
const output = typeof body.output === 'string' && body.output ? body.output : 'image';
|
|
588
|
+
const effectKey = typeof body.effectKey === 'string' && body.effectKey ? body.effectKey : 'render';
|
|
589
|
+
const contentType = body.contentType ?? (output === 'traits' ? 'application/json' : 'image/png');
|
|
590
|
+
const key = renderArtifactKey(ctx.chainId, address, tokenId, inputsHashHex, output, effectKey);
|
|
591
|
+
// BOTH modes register a row — the row is the `artifacts` manifest's enumeration surface;
|
|
592
|
+
// locator NULL on the bytes mode means "the bytes live in this node's custody at the key".
|
|
593
|
+
const row = { key, address, tokenId, effectKey, outputKey: output, inputsHash: inputsHashHex, contentType };
|
|
594
|
+
const locator = typeof body.locator === 'string' && body.locator ? body.locator : undefined;
|
|
595
|
+
if (locator) {
|
|
596
|
+
indexer.store.putEffectArtifact({ ...row, locator });
|
|
597
|
+
return sendJson(res, 200, { ok: true, mode: 'locator', key, output, locator });
|
|
598
|
+
}
|
|
599
|
+
const bytesB64 = typeof body.bytes_base64 === 'string' && body.bytes_base64 ? body.bytes_base64 : undefined;
|
|
600
|
+
if (bytesB64) {
|
|
601
|
+
const bytes = new Uint8Array(Buffer.from(bytesB64, 'base64'));
|
|
602
|
+
await storage.put(key, { bytes, contentType });
|
|
603
|
+
indexer.store.putEffectArtifact({ ...row, locator: null });
|
|
604
|
+
return sendJson(res, 200, { ok: true, mode: 'bytes', key, output, bytes: bytes.length });
|
|
605
|
+
}
|
|
606
|
+
return sendError(res, 400, 'invalid_request', 'provide body.locator (a durable ipfs://ar://https URL) or body.bytes_base64');
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* `POST /v1/effect-status {chainId, key, address, tokenId, effectKey, status, error?, attempts?}` —
|
|
610
|
+
* a runner reports one run's transient state for the artifact `key` it is producing (the runner
|
|
611
|
+
* computes the key; this node never re-derives it, mirroring /v1/effect-artifacts). `status`
|
|
612
|
+
* 'done' clears the row (artifact presence takes over as truth); 'rendering'/'failed' upsert.
|
|
613
|
+
*/
|
|
614
|
+
async function reportEffectStatus(req, res, ctx) {
|
|
615
|
+
const { indexer } = ctx;
|
|
616
|
+
let body;
|
|
617
|
+
try {
|
|
618
|
+
body = await readJsonBody(req);
|
|
619
|
+
}
|
|
620
|
+
catch (err) {
|
|
621
|
+
return sendError(res, 400, 'invalid_request', err.message);
|
|
622
|
+
}
|
|
623
|
+
if (!checkChain(res, ctx, body.chainId))
|
|
624
|
+
return;
|
|
625
|
+
const key = body.key;
|
|
626
|
+
if (!key || !/^0x[0-9a-fA-F]{64}$/.test(key)) {
|
|
627
|
+
return sendError(res, 400, 'invalid_request', 'body.key must be the 0x 32-byte artifact key this run produces');
|
|
628
|
+
}
|
|
629
|
+
const address = body.address;
|
|
630
|
+
if (!address || !ADDRESS_RE.test(address)) {
|
|
631
|
+
return sendError(res, 400, 'invalid_request', 'body.address must be a 0x-prefixed 20-byte address');
|
|
632
|
+
}
|
|
633
|
+
const status = body.status;
|
|
634
|
+
if (status === 'done') {
|
|
635
|
+
indexer.store.clearEffectStatus(key);
|
|
636
|
+
return sendJson(res, 200, { ok: true, cleared: key });
|
|
637
|
+
}
|
|
638
|
+
if (status !== 'rendering' && status !== 'failed') {
|
|
639
|
+
return sendError(res, 400, 'invalid_request', "body.status must be 'rendering' | 'failed' | 'done'");
|
|
640
|
+
}
|
|
641
|
+
indexer.store.putEffectStatus({
|
|
642
|
+
key,
|
|
643
|
+
address,
|
|
644
|
+
tokenId: String(body.tokenId ?? ''),
|
|
645
|
+
effectKey: typeof body.effectKey === 'string' && body.effectKey ? body.effectKey : 'render',
|
|
646
|
+
status,
|
|
647
|
+
error: typeof body.error === 'string' ? body.error.slice(0, 2000) : null,
|
|
648
|
+
attempts: typeof body.attempts === 'number' ? body.attempts : undefined,
|
|
649
|
+
});
|
|
650
|
+
return sendJson(res, 200, { ok: true, key, status });
|
|
651
|
+
}
|
|
652
|
+
//# sourceMappingURL=control-plane.js.map
|