@artblocks/abx-token-api 0.1.0-alpha.0
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/LICENSE +21 -0
- package/dist/abxjs.d.ts +13 -0
- package/dist/abxjs.d.ts.map +1 -0
- package/dist/abxjs.js +49 -0
- package/dist/abxjs.js.map +1 -0
- package/dist/art.d.ts +26 -0
- package/dist/art.d.ts.map +1 -0
- package/dist/art.js +85 -0
- package/dist/art.js.map +1 -0
- package/dist/code.d.ts +73 -0
- package/dist/code.d.ts.map +1 -0
- package/dist/code.js +216 -0
- package/dist/code.js.map +1 -0
- package/dist/dashboard.d.ts +12 -0
- package/dist/dashboard.d.ts.map +1 -0
- package/dist/dashboard.js +231 -0
- package/dist/dashboard.js.map +1 -0
- package/dist/deps.d.ts +146 -0
- package/dist/deps.d.ts.map +1 -0
- package/dist/deps.js +297 -0
- package/dist/deps.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/inline.d.ts +19 -0
- package/dist/inline.d.ts.map +1 -0
- package/dist/inline.js +23 -0
- package/dist/inline.js.map +1 -0
- package/dist/metadata.d.ts +89 -0
- package/dist/metadata.d.ts.map +1 -0
- package/dist/metadata.js +673 -0
- package/dist/metadata.js.map +1 -0
- package/dist/resolve.d.ts +74 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +107 -0
- package/dist/resolve.js.map +1 -0
- package/dist/server.d.ts +91 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +940 -0
- package/dist/server.js.map +1 -0
- package/dist/watcher.d.ts +56 -0
- package/dist/watcher.d.ts.map +1 -0
- package/dist/watcher.js +179 -0
- package/dist/watcher.js.map +1 -0
- package/package.json +50 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,940 @@
|
|
|
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';
|
|
4
|
+
import { resolveBackend } from '@artblocks/abx-storage';
|
|
5
|
+
import { fallbackImageSvg, IMAGE_MEDIA_TYPE } from './art.js';
|
|
6
|
+
import { buildContractMetadata, buildTokenMetadata, fieldMimeType } from './metadata.js';
|
|
7
|
+
import { currentRenderArtifact, currentSettledInputsHash, isCodeProject, resolveLiveView, resolveLocatorUrl } from './code.js';
|
|
8
|
+
import { depStatusReport } from './deps.js';
|
|
9
|
+
import { ABX_JS } from './abxjs.js';
|
|
10
|
+
import { resolveFieldBytes, resolveFieldRendered, COLLECTION_TOKEN_ID } from './resolve.js';
|
|
11
|
+
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
|
|
22
|
+
/**
|
|
23
|
+
* Resolve a token's image bytes by dispatching on the `image` field's single active
|
|
24
|
+
* representation (token scope first, else the collection-wide field — the same fallback the
|
|
25
|
+
* JSON assembly uses): on-chain content — `inline` / `inline-gzip` / `reader` / `reader-gzip`,
|
|
26
|
+
* all decoded by the shared {@link resolveFieldBytes} (which calls `read(pointer)` for a
|
|
27
|
+
* reader and gunzips the gzip variants) → computed content (`renderer` — eth_call, typed by
|
|
28
|
+
* the returned contentType; `text/uri-list` means the bytes are a locator the route redirects
|
|
29
|
+
* to) → off-chain custody located by the on-chain `keccak256`/`sha256` hash → graceful
|
|
30
|
+
* placeholder. The node never errors on missing bytes.
|
|
31
|
+
*/
|
|
32
|
+
export async function resolveContent(state, token, storage) {
|
|
33
|
+
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
|
|
35
|
+
if (onChain)
|
|
36
|
+
return { contentType: IMAGE_MEDIA_TYPE, body: onChain };
|
|
37
|
+
// computed on-chain at read (`renderer`) — best-effort: a reverting renderer degrades to the
|
|
38
|
+
// placeholder rather than erroring the route (mirrors the on-chain renderer's fallback rule).
|
|
39
|
+
if (image?.representation === R.renderer) {
|
|
40
|
+
try {
|
|
41
|
+
const rendered = await resolveFieldRendered(chainClient, state.address, token.tokenId, F.image, image);
|
|
42
|
+
if (rendered)
|
|
43
|
+
return { contentType: rendered.contentType, body: rendered.bytes };
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// fall through to the placeholder
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// off-chain custody, located by the on-chain hash of the image
|
|
50
|
+
if (image && (image.representation === R.keccak256 || image.representation === R.sha256)) {
|
|
51
|
+
const stored = await storage.get(image.value);
|
|
52
|
+
if (stored)
|
|
53
|
+
return { contentType: stored.contentType, body: stored.bytes };
|
|
54
|
+
}
|
|
55
|
+
// graceful placeholder — the deterministic fallback the on-chain renderer also uses,
|
|
56
|
+
// so a token with no resolvable image looks the same whether served here or self-resolved.
|
|
57
|
+
return { contentType: IMAGE_MEDIA_TYPE, body: fallbackImageSvg(state.address, token.tokenId) };
|
|
58
|
+
}
|
|
59
|
+
/** Is `tokenId` a valid, not-yet-minted position within a Series' cap (`0 <= id < N`)? */
|
|
60
|
+
function withinCap(tokenId, maxInvocations) {
|
|
61
|
+
if (maxInvocations == null)
|
|
62
|
+
return false;
|
|
63
|
+
try {
|
|
64
|
+
const id = BigInt(tokenId);
|
|
65
|
+
return id >= 0n && id < BigInt(maxInvocations);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The view to resolve for a requested tokenId. A token's metadata is its token id (no
|
|
73
|
+
* decoupling), so identity and content both come from that token. Returns a synthesized,
|
|
74
|
+
* 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).
|
|
76
|
+
*/
|
|
77
|
+
export function resolveTokenView(state, tokenId) {
|
|
78
|
+
const issued = state.tokens.find((t) => t.tokenId === tokenId);
|
|
79
|
+
if (!issued && !withinCap(tokenId, state.maxInvocations))
|
|
80
|
+
return null;
|
|
81
|
+
return {
|
|
82
|
+
tokenId,
|
|
83
|
+
minted: issued?.minted ?? false,
|
|
84
|
+
owner: issued?.owner ?? null,
|
|
85
|
+
tokenURI: issued?.tokenURI ?? null,
|
|
86
|
+
fields: issued?.fields ?? [],
|
|
87
|
+
lockedFields: issued?.lockedFields ?? [],
|
|
88
|
+
params: issued?.params,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
export const DEFAULT_PORT = 8787;
|
|
92
|
+
/** The base URL this node is reachable at — what gets baked into on-chain URIs. */
|
|
93
|
+
export function resolveBaseUrl(port = DEFAULT_PORT) {
|
|
94
|
+
return process.env.ABX_PUBLIC_BASE_URL ?? `http://localhost:${port}`;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Guard the base URL a hosted resolver serves its own metadata URLs from. `.example` is RFC-2606
|
|
98
|
+
* reserved — it can only be a leftover placeholder, so refuse it anywhere. A localhost base is fine
|
|
99
|
+
* for a local `abx serve` (dev), but in a hosted image (ABX_HOSTED=1) it means the public URL was
|
|
100
|
+
* never set — refuse so the deploy fails loudly instead of silently serving dead localhost links.
|
|
101
|
+
* The scaffold now always bakes a real host, so this only fires on a stripped env or an old artifact.
|
|
102
|
+
*/
|
|
103
|
+
export function assertServableBaseUrl(baseUrl) {
|
|
104
|
+
let host;
|
|
105
|
+
try {
|
|
106
|
+
host = new URL(baseUrl).hostname;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
throw new Error(`ABX_PUBLIC_BASE_URL is not a valid URL: ${JSON.stringify(baseUrl)}`);
|
|
110
|
+
}
|
|
111
|
+
if (host === 'example' || host.endsWith('.example')) {
|
|
112
|
+
throw new Error(`ABX_PUBLIC_BASE_URL is a placeholder host (${host}). Set it to the resolver's real public URL ` +
|
|
113
|
+
`(e.g. https://<app>.fly.dev, or your custom domain) — the metadata this node serves points ` +
|
|
114
|
+
`image/animation URLs at this base, so a placeholder serves dead links.`);
|
|
115
|
+
}
|
|
116
|
+
const isLocal = host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '0.0.0.0';
|
|
117
|
+
if (isLocal && process.env.ABX_HOSTED === '1') {
|
|
118
|
+
throw new Error(`hosted resolver has no public ABX_PUBLIC_BASE_URL (resolved to ${baseUrl}). Set it to this host's ` +
|
|
119
|
+
`URL (e.g. https://<app>.fly.dev) — otherwise it serves localhost links no client can reach.`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export function createTokenApiServer(opts) {
|
|
123
|
+
const { indexer } = opts;
|
|
124
|
+
const port = opts.port ?? Number(process.env.ABX_PORT ?? DEFAULT_PORT);
|
|
125
|
+
const baseUrl = opts.baseUrl ?? resolveBaseUrl(port);
|
|
126
|
+
assertServableBaseUrl(baseUrl);
|
|
127
|
+
const storage = opts.storage ?? resolveBackend();
|
|
128
|
+
return createServer(async (req, res) => {
|
|
129
|
+
try {
|
|
130
|
+
await route(req, res, indexer, baseUrl, storage);
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
sendJson(res, 500, { error: err.message });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
export function startTokenApiServer(opts) {
|
|
138
|
+
const port = opts.port ?? Number(process.env.ABX_PORT ?? DEFAULT_PORT);
|
|
139
|
+
const baseUrl = opts.baseUrl ?? resolveBaseUrl(port);
|
|
140
|
+
const server = createTokenApiServer({ ...opts, port, baseUrl });
|
|
141
|
+
return new Promise((resolve) => {
|
|
142
|
+
server.listen(port, () => resolve({ server, url: baseUrl }));
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
async function route(req, res, indexer, baseUrl, storage) {
|
|
146
|
+
res.setHeader('access-control-allow-origin', '*');
|
|
147
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
148
|
+
const parts = url.pathname.split('/').filter(Boolean);
|
|
149
|
+
const method = req.method ?? 'GET';
|
|
150
|
+
// GET / — read-only node index: which contracts this resolver serves (no actions).
|
|
151
|
+
if (parts.length === 0) {
|
|
152
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
153
|
+
res.end(renderIndex(indexer.listProjects(), baseUrl, SERVER_CHAIN_ID));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
// GET /d/:chainId/:addr — the per-contract dashboard (read-only; namespaced so one host can
|
|
157
|
+
// serve many contracts/chains). Actions live behind the admin token, not on this page.
|
|
158
|
+
if (parts[0] === 'd' && parts[1] && parts[2]) {
|
|
159
|
+
if (Number(parts[1]) !== SERVER_CHAIN_ID)
|
|
160
|
+
return wrongChain(res, parts[1]);
|
|
161
|
+
const state = indexer.getProject(parts[2]);
|
|
162
|
+
if (!state)
|
|
163
|
+
return sendJson(res, 404, { error: 'unknown project' });
|
|
164
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
165
|
+
res.end(renderDashboard(state, baseUrl, SERVER_CHAIN_ID));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// GET /health
|
|
169
|
+
if (parts[0] === 'health')
|
|
170
|
+
return sendJson(res, 200, { ok: true, baseUrl });
|
|
171
|
+
// GET /abx.js — the runtime companion (directory builds include it; the generator inlines it).
|
|
172
|
+
if (parts[0] === 'abx.js') {
|
|
173
|
+
res.writeHead(200, {
|
|
174
|
+
'content-type': 'application/javascript; charset=utf-8',
|
|
175
|
+
'cache-control': 'public, max-age=3600',
|
|
176
|
+
});
|
|
177
|
+
res.end(ABX_JS);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
// GET /a/:chainId/:addr/:id — the live view: the code project's document with canonical
|
|
181
|
+
// tokenData delivered per the spec (directory mode: the build's entry document served
|
|
182
|
+
// from here with window.abxTokenData injected — the ?abx= 302 redirect is the
|
|
183
|
+
// entry-fetch-failure fallback; template mode: the generator document assembled from
|
|
184
|
+
// chain). This is the same document a render node captures — the live view IS the input.
|
|
185
|
+
if (parts[0] === 'a' && parts[1] && parts[2] && parts[3] !== undefined) {
|
|
186
|
+
if (Number(parts[1]) !== SERVER_CHAIN_ID)
|
|
187
|
+
return wrongChain(res, parts[1]);
|
|
188
|
+
const state = indexer.getProject(parts[2]);
|
|
189
|
+
if (!state)
|
|
190
|
+
return sendJson(res, 404, { error: 'unknown project' });
|
|
191
|
+
const token = resolveTokenView(state, parts[3]);
|
|
192
|
+
if (!token)
|
|
193
|
+
return sendJson(res, 404, { error: 'unknown token' });
|
|
194
|
+
const view = await resolveLiveView(chainClient, state, token);
|
|
195
|
+
if (!view)
|
|
196
|
+
return sendJson(res, 404, { error: 'no live view — not a code project' });
|
|
197
|
+
if (view.kind === 'redirect') {
|
|
198
|
+
res.writeHead(302, { location: view.location, 'cache-control': 'no-store' });
|
|
199
|
+
res.end();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
203
|
+
res.end(view.html);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
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);
|
|
225
|
+
}
|
|
226
|
+
// /api/*
|
|
227
|
+
if (parts[0] === 'api') {
|
|
228
|
+
if (parts[1] === 'projects') {
|
|
229
|
+
return sendJson(res, 200, indexer.listProjects().map(summarize));
|
|
230
|
+
}
|
|
231
|
+
// GET /api/watch — chain-watcher liveness. Read from the `meta` table the watcher writes each
|
|
232
|
+
// tick (this resolver's watcher shares the indexer store), so a HOSTED node — where you can't
|
|
233
|
+
// tail the log — can still PROVE it's watching: `pollAt` advances, `head` tracks the chain.
|
|
234
|
+
if (parts[1] === 'watch') {
|
|
235
|
+
return sendJson(res, 200, watchStatusReport(indexer));
|
|
236
|
+
}
|
|
237
|
+
// GET /api/deps/:chainId/:addr — per-dep resolution status (a read-only dry run of the
|
|
238
|
+
// same registry-order resolution the generator document uses, sharing its cache) plus
|
|
239
|
+
// the URL-budget flag for directory projects. Public, like the other status reads.
|
|
240
|
+
if (parts[1] === 'deps' && parts[2] && parts[3]) {
|
|
241
|
+
if (Number(parts[2]) !== SERVER_CHAIN_ID)
|
|
242
|
+
return wrongChain(res, parts[2]);
|
|
243
|
+
const state = indexer.getProject(parts[3]);
|
|
244
|
+
if (!state)
|
|
245
|
+
return sendJson(res, 404, { error: 'unknown project' });
|
|
246
|
+
return sendJson(res, 200, await depStatusReport(chainClient, state));
|
|
247
|
+
}
|
|
248
|
+
if (parts[1] === 'project' && parts[2]) {
|
|
249
|
+
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
|
+
// 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
|
|
262
|
+
// (anyone can still verify independently via `abx verify` — no need for this node to do it).
|
|
263
|
+
if (parts[3] === 'verify') {
|
|
264
|
+
if (!requireAdmin(req, res))
|
|
265
|
+
return;
|
|
266
|
+
return sendJson(res, 200, await verifyProject(indexer.getProject(address), storage));
|
|
267
|
+
}
|
|
268
|
+
// GET /api/project/:addr/effects — per-token effect status. Derived, never stored:
|
|
269
|
+
// `up-to-date` = artifact present at the CURRENT settled inputsHash (this node's store or a
|
|
270
|
+
// published locator); `rendering`/`failed` = a runner-reported transient row at that key;
|
|
271
|
+
// else `stale`. Rows for effects this resolver doesn't consume are reported verbatim.
|
|
272
|
+
if (parts[3] === 'effects') {
|
|
273
|
+
const state = indexer.getProject(address);
|
|
274
|
+
if (!state)
|
|
275
|
+
return sendJson(res, 404, { error: 'unknown project' });
|
|
276
|
+
return sendJson(res, 200, await effectStatusReport(indexer, state, storage));
|
|
277
|
+
}
|
|
278
|
+
// GET /api/project/:addr
|
|
279
|
+
const state = indexer.getProject(address);
|
|
280
|
+
if (!state)
|
|
281
|
+
return sendJson(res, 404, { error: 'unknown project' });
|
|
282
|
+
return sendJson(res, 200, state);
|
|
283
|
+
}
|
|
284
|
+
return sendJson(res, 404, { error: 'unknown api route' });
|
|
285
|
+
}
|
|
286
|
+
// GET /t/:chainId/:addr/:id and /t/:chainId/:addr/:id/image
|
|
287
|
+
if (parts[0] === 't' && parts[1] && parts[2] && parts[3] !== undefined) {
|
|
288
|
+
if (Number(parts[1]) !== SERVER_CHAIN_ID)
|
|
289
|
+
return wrongChain(res, parts[1]);
|
|
290
|
+
const address = parts[2];
|
|
291
|
+
const tokenId = parts[3];
|
|
292
|
+
const state = indexer.getProject(address);
|
|
293
|
+
if (!state)
|
|
294
|
+
return sendJson(res, 404, { error: 'unknown project' });
|
|
295
|
+
// Resolve the issuance token → its metadata-id slot (identity vs. content), synthesizing a
|
|
296
|
+
// pre-mint view for a not-yet-issued id within the cap so metadata warms before mint.
|
|
297
|
+
const token = resolveTokenView(state, tokenId);
|
|
298
|
+
if (!token)
|
|
299
|
+
return sendJson(res, 404, { error: 'unknown token' });
|
|
300
|
+
if (parts[4] === 'image') {
|
|
301
|
+
// the render-effect seam: no explicit image field + a code project ⇒ serve the
|
|
302
|
+
// artifact stored at the CURRENT inputsHash address, when a producer has run.
|
|
303
|
+
const hasImageField = token.fields.some((f) => f.field === 'image') ||
|
|
304
|
+
state.collectionFields.some((f) => f.field === 'image');
|
|
305
|
+
if (!hasImageField && isCodeProject(state)) {
|
|
306
|
+
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`).
|
|
311
|
+
const published = indexer.store.getEffectArtifact(key);
|
|
312
|
+
if (published?.locator) {
|
|
313
|
+
res.writeHead(302, { location: resolveLocatorUrl(published.locator), 'cache-control': 'public, max-age=300' });
|
|
314
|
+
res.end();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (found) {
|
|
318
|
+
const artifact = await storage.get(key);
|
|
319
|
+
if (artifact) {
|
|
320
|
+
res.writeHead(200, {
|
|
321
|
+
'content-type': artifact.contentType || 'image/png',
|
|
322
|
+
'cache-control': 'public, max-age=300',
|
|
323
|
+
});
|
|
324
|
+
res.end(artifact.bytes);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
// fall through to the standard resolution (placeholder) — the seam is best-effort
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const content = await resolveContent(state, token, storage);
|
|
334
|
+
// a computed LOCATOR (renderer returning text/uri-list): the bytes ARE a URI — redirect.
|
|
335
|
+
if (content.contentType === 'text/uri-list') {
|
|
336
|
+
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' });
|
|
338
|
+
res.end();
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
res.writeHead(200, { 'content-type': content.contentType, 'cache-control': 'public, max-age=300' });
|
|
342
|
+
res.end(content.body);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
// GET /t/:chainId/:addr/:id/data/{field} and /t/:chainId/:addr/:id/data/{effectKey}/{outputKey}
|
|
346
|
+
// — the plane's byte-serving route (data-plane.md → Serving): declared Content-Type, 302 to a
|
|
347
|
+
// durable locator when one is registered. Effect keys are dot-namespaced, never slashed, so
|
|
348
|
+
// segment count disambiguates a field artifact (1) from an effect artifact (2).
|
|
349
|
+
if (parts[4] === 'data' && parts[5]) {
|
|
350
|
+
if (parts[6] !== undefined) {
|
|
351
|
+
return serveEffectArtifact(res, state, token, parts[5], parts[6], storage, indexer);
|
|
352
|
+
}
|
|
353
|
+
return serveFieldArtifact(res, state, token, parts[5], storage, displayMeta(indexer, address));
|
|
354
|
+
}
|
|
355
|
+
return sendJson(res, 200, await buildTokenMetadata(chainClient, state, token, baseUrl, SERVER_CHAIN_ID, displayMeta(indexer, address), storage, planeAccess(indexer)));
|
|
356
|
+
}
|
|
357
|
+
// GET /c/:chainId/:addr and /c/:chainId/:addr/data/{field}
|
|
358
|
+
if (parts[0] === 'c' && parts[1] && parts[2]) {
|
|
359
|
+
if (Number(parts[1]) !== SERVER_CHAIN_ID)
|
|
360
|
+
return wrongChain(res, parts[1]);
|
|
361
|
+
const address = parts[2];
|
|
362
|
+
const state = indexer.getProject(address);
|
|
363
|
+
if (!state)
|
|
364
|
+
return sendJson(res, 404, { error: 'unknown project' });
|
|
365
|
+
if (parts[3] === 'data' && parts[4]) {
|
|
366
|
+
return serveFieldArtifact(res, state, null, parts[4], storage, displayMeta(indexer, address));
|
|
367
|
+
}
|
|
368
|
+
return sendJson(res, 200, await buildContractMetadata(chainClient, state, baseUrl, SERVER_CHAIN_ID, displayMeta(indexer, address), storage));
|
|
369
|
+
}
|
|
370
|
+
sendJson(res, 404, { error: 'not found' });
|
|
371
|
+
}
|
|
372
|
+
/** The manifest's read surface over the effect-artifact registry (metadata.ts stays store-free). */
|
|
373
|
+
function planeAccess(indexer) {
|
|
374
|
+
return {
|
|
375
|
+
list: (address, tokenId) => indexer.store.listEffectArtifacts(address, tokenId),
|
|
376
|
+
get: (key) => indexer.store.getEffectArtifact(key),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
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). */
|
|
382
|
+
async function serveEffectArtifact(res, state, token, effectKey, outputKey, storage, indexer) {
|
|
383
|
+
if (!isCodeProject(state))
|
|
384
|
+
return sendJson(res, 404, { error: 'no effect artifacts — not a code project' });
|
|
385
|
+
try {
|
|
386
|
+
const hash = await currentSettledInputsHash(chainClient, state, token);
|
|
387
|
+
const key = renderArtifactKey(state.chainId, state.address, token.tokenId, hash, outputKey, effectKey);
|
|
388
|
+
const row = indexer.store.getEffectArtifact(key);
|
|
389
|
+
if (row?.locator) {
|
|
390
|
+
res.writeHead(302, { location: resolveLocatorUrl(row.locator), 'cache-control': 'public, max-age=300' });
|
|
391
|
+
res.end();
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const stored = await storage.get(key);
|
|
395
|
+
if (stored) {
|
|
396
|
+
res.writeHead(200, {
|
|
397
|
+
'content-type': row?.contentType || stored.contentType || 'application/octet-stream',
|
|
398
|
+
'cache-control': 'public, max-age=300',
|
|
399
|
+
});
|
|
400
|
+
res.end(stored.bytes);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
// fall through to the 404 — the route is best-effort, like the image seam
|
|
406
|
+
}
|
|
407
|
+
return sendJson(res, 404, {
|
|
408
|
+
error: `no ${effectKey}/${outputKey} artifact at the current inputsHash — not produced yet, or re-addressed by a param change (re-runs on the next sweep)`,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
/** Serve one FIELD artifact's bytes (token scope with collection fallback; `token === null` =
|
|
412
|
+
* collection scope): on-chain content decoded (inline/reader ±gzip), computed content
|
|
413
|
+
* (`renderer` — eth_call, typed by the returned contentType), custody bytes by hash,
|
|
414
|
+
* 302 for locator forms — with the declared-type ladder's Content-Type. */
|
|
415
|
+
async function serveFieldArtifact(res, state, token, field, storage, display) {
|
|
416
|
+
if (field === 'code')
|
|
417
|
+
return sendJson(res, 404, { error: '`code` is never served verbatim — the live view serves the program' });
|
|
418
|
+
const entry = (token ? fieldOf(token.fields, field) : null) ?? fieldOf(state.collectionFields, field);
|
|
419
|
+
if (!entry)
|
|
420
|
+
return sendJson(res, 404, { error: `no '${field}' field set` });
|
|
421
|
+
const tokenId = token?.tokenId ?? '0';
|
|
422
|
+
try {
|
|
423
|
+
const onChain = await resolveFieldBytes(chainClient, entry);
|
|
424
|
+
if (onChain) {
|
|
425
|
+
res.writeHead(200, {
|
|
426
|
+
'content-type': await fieldMimeType(chainClient, state, entry, field, tokenId, display, storage),
|
|
427
|
+
'cache-control': 'public, max-age=300',
|
|
428
|
+
});
|
|
429
|
+
res.end(onChain);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
// 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);
|
|
434
|
+
if (rendered) {
|
|
435
|
+
if (rendered.contentType === 'text/uri-list') {
|
|
436
|
+
const target = new TextDecoder().decode(rendered.bytes).trim();
|
|
437
|
+
res.writeHead(302, { location: resolveLocatorUrl(target), 'cache-control': 'public, max-age=300' });
|
|
438
|
+
res.end();
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
res.writeHead(200, { 'content-type': rendered.contentType, 'cache-control': 'public, max-age=300' });
|
|
442
|
+
res.end(rendered.bytes);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
if (entry.representation === R.keccak256 || entry.representation === R.sha256) {
|
|
446
|
+
const stored = await storage.get(entry.value);
|
|
447
|
+
if (stored) {
|
|
448
|
+
res.writeHead(200, {
|
|
449
|
+
'content-type': stored.contentType || 'application/octet-stream',
|
|
450
|
+
'cache-control': 'public, max-age=300',
|
|
451
|
+
});
|
|
452
|
+
res.end(stored.bytes);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
const bridged = display.contentLocators?.[entry.value.toLowerCase()];
|
|
456
|
+
if (bridged) {
|
|
457
|
+
res.writeHead(302, { location: resolveLocatorUrl(bridged), 'cache-control': 'public, max-age=300' });
|
|
458
|
+
res.end();
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
return sendJson(res, 404, { error: `'${field}' bytes not in this node's custody (on-chain ${entry.representation} anchor only)` });
|
|
462
|
+
}
|
|
463
|
+
const locator = fieldLocatorUrl(entry, tokenId);
|
|
464
|
+
if (locator) {
|
|
465
|
+
res.writeHead(302, { location: resolveLocatorUrl(locator), 'cache-control': 'public, max-age=300' });
|
|
466
|
+
res.end();
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
catch (err) {
|
|
471
|
+
return sendJson(res, 502, { error: `field '${field}' failed to resolve: ${err.message}` });
|
|
472
|
+
}
|
|
473
|
+
return sendJson(res, 404, { error: `'${field}' (${entry.representation}) is not byte-servable from this node` });
|
|
474
|
+
}
|
|
475
|
+
/** 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) {
|
|
478
|
+
const text = Buffer.from(entry.value.slice(2), 'hex').toString('utf8').trim();
|
|
479
|
+
return text || null;
|
|
480
|
+
}
|
|
481
|
+
if (entry.representation === R.urlTemplate) {
|
|
482
|
+
const text = Buffer.from(entry.value.slice(2), 'hex').toString('utf8');
|
|
483
|
+
return text.split('{id}').join(tokenId);
|
|
484
|
+
}
|
|
485
|
+
return null; // renderer is dispatched above; anything else has no locator form
|
|
486
|
+
}
|
|
487
|
+
/** Operator display metadata (creator's description/external_url/traits + bridged locators) from the
|
|
488
|
+
* registration — survives re-index. The off-chain attributes + content locators are JSON columns. */
|
|
489
|
+
function displayMeta(indexer, address) {
|
|
490
|
+
const reg = indexer.store.getRegistration(address);
|
|
491
|
+
return {
|
|
492
|
+
description: reg?.description,
|
|
493
|
+
externalUrl: reg?.externalUrl,
|
|
494
|
+
attributes: reg?.attributes ? safeAttributes(reg.attributes) : undefined,
|
|
495
|
+
tokenAttributes: reg?.tokenAttributes ? safeTokenAttributes(reg.tokenAttributes) : undefined,
|
|
496
|
+
contentLocators: reg?.contentLocators ? safeLocators(reg.contentLocators) : undefined,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
/** Parse a stored off-chain attributes JSON column → normalized OpenSea traits (never throws). */
|
|
500
|
+
function safeAttributes(json) {
|
|
501
|
+
try {
|
|
502
|
+
return normalizeAttributes(JSON.parse(json));
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
return undefined;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
/** Parse the per-token off-chain attributes column (`{ "<tokenId>": attrs }`) → normalized per token. */
|
|
509
|
+
function safeTokenAttributes(json) {
|
|
510
|
+
try {
|
|
511
|
+
const obj = JSON.parse(json);
|
|
512
|
+
if (!obj || typeof obj !== 'object')
|
|
513
|
+
return undefined;
|
|
514
|
+
const out = {};
|
|
515
|
+
for (const [tokenId, v] of Object.entries(obj)) {
|
|
516
|
+
const attrs = normalizeAttributes(v);
|
|
517
|
+
if (attrs.length)
|
|
518
|
+
out[tokenId] = attrs;
|
|
519
|
+
}
|
|
520
|
+
return Object.keys(out).length ? out : undefined;
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
return undefined;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
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
|
+
export async function verifyProject(state, storage) {
|
|
553
|
+
if (!state)
|
|
554
|
+
return { error: 'unknown project' };
|
|
555
|
+
const tokens = await Promise.all(state.tokens.map(async (t) => {
|
|
556
|
+
const { body } = await resolveContent(state, t, storage);
|
|
557
|
+
// verify the served image bytes against the on-chain `image` field when it carries a hash
|
|
558
|
+
const image = fieldOf(t.fields, F.image);
|
|
559
|
+
const isHash = image && (image.representation === R.keccak256 || image.representation === R.sha256);
|
|
560
|
+
const checks = isHash
|
|
561
|
+
? [{ kind: image.representation, committed: image.value, verified: verifyAgainstHash(body, image) }]
|
|
562
|
+
: [];
|
|
563
|
+
return { tokenId: t.tokenId, checks };
|
|
564
|
+
}));
|
|
565
|
+
return { address: state.address, tokens };
|
|
566
|
+
}
|
|
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
|
+
/**
|
|
863
|
+
* Per-token effect status for a project — the "did every thumbnail land?" surface behind
|
|
864
|
+
* `GET /api/project/:addr/effects` and `abx verify`. Status is DERIVED, in precedence order:
|
|
865
|
+
* artifact present at the current settled inputsHash (own store or published locator) →
|
|
866
|
+
* `up-to-date`; a runner-reported transient row at that key → `rendering` | `failed`; else
|
|
867
|
+
* `stale` (work the effects layer hasn't landed yet). Non-render effects the resolver doesn't
|
|
868
|
+
* consume are included verbatim from their reported rows — effect-agnostic by construction.
|
|
869
|
+
*/
|
|
870
|
+
async function effectStatusReport(indexer, state, storage) {
|
|
871
|
+
const reported = indexer.store.listEffectStatuses(state.address);
|
|
872
|
+
const byKey = new Map(reported.map((r) => [r.key.toLowerCase(), r]));
|
|
873
|
+
const tokens = [];
|
|
874
|
+
const counts = { upToDate: 0, stale: 0, rendering: 0, failed: 0 };
|
|
875
|
+
if (isCodeProject(state)) {
|
|
876
|
+
for (const token of state.tokens.filter((t) => t.minted)) {
|
|
877
|
+
const { key, found } = await currentRenderArtifact(chainClient, state, token, storage);
|
|
878
|
+
const published = !found && !!indexer.store.getEffectArtifact(key);
|
|
879
|
+
const row = byKey.get(key.toLowerCase());
|
|
880
|
+
let status;
|
|
881
|
+
if (found || published)
|
|
882
|
+
status = 'up-to-date';
|
|
883
|
+
else if (row)
|
|
884
|
+
status = row.status;
|
|
885
|
+
else
|
|
886
|
+
status = 'stale';
|
|
887
|
+
counts[status === 'up-to-date' ? 'upToDate' : status] += 1;
|
|
888
|
+
byKey.delete(key.toLowerCase());
|
|
889
|
+
tokens.push({
|
|
890
|
+
tokenId: token.tokenId,
|
|
891
|
+
effectKey: 'render',
|
|
892
|
+
status,
|
|
893
|
+
key,
|
|
894
|
+
...(row?.error ? { error: row.error } : {}),
|
|
895
|
+
...(row?.attempts ? { attempts: row.attempts } : {}),
|
|
896
|
+
...(row?.updatedAt ? { updatedAt: row.updatedAt } : {}),
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
// Any remaining reported rows belong to effects this resolver doesn't consume — pass through.
|
|
901
|
+
const other = [...byKey.values()].map((r) => ({
|
|
902
|
+
tokenId: r.tokenId,
|
|
903
|
+
effectKey: r.effectKey,
|
|
904
|
+
status: r.status,
|
|
905
|
+
key: r.key,
|
|
906
|
+
...(r.error ? { error: r.error } : {}),
|
|
907
|
+
...(r.attempts ? { attempts: r.attempts } : {}),
|
|
908
|
+
...(r.updatedAt ? { updatedAt: r.updatedAt } : {}),
|
|
909
|
+
}));
|
|
910
|
+
return { address: state.address, counts, tokens: [...tokens, ...other] };
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Chain-watcher liveness behind `GET /api/watch`. Pure read of the `meta` k/v the watcher stamps
|
|
914
|
+
* each tick (`watch:pollAt`, `watch:<chainKey>:head`, `watch:<chainKey>` = watched-through block,
|
|
915
|
+
* `watch:lastDeltaAt`). `watching:false` when nothing has been recorded (watcher off / never ran).
|
|
916
|
+
* The reader judges freshness from `pollAt` vs `intervalMs` — a stale `pollAt` means it stopped.
|
|
917
|
+
*/
|
|
918
|
+
function watchStatusReport(indexer) {
|
|
919
|
+
const pollAt = indexer.store.getMeta('watch:pollAt');
|
|
920
|
+
const chainKeys = [...new Set(indexer.store.listRegistrations().map((r) => r.chainKey))];
|
|
921
|
+
const chains = {};
|
|
922
|
+
for (const ck of chainKeys) {
|
|
923
|
+
chains[ck] = {
|
|
924
|
+
head: indexer.store.getMeta(`watch:${ck}:head`),
|
|
925
|
+
watchedThrough: indexer.store.getMeta(`watch:${ck}`),
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
return {
|
|
929
|
+
watching: pollAt !== null,
|
|
930
|
+
intervalMs: watchIntervalMs(),
|
|
931
|
+
pollAt,
|
|
932
|
+
lastDeltaAt: indexer.store.getMeta('watch:lastDeltaAt'),
|
|
933
|
+
chains,
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
/** 404 for a path whose chainId segment isn't the chain this resolver serves. */
|
|
937
|
+
function wrongChain(res, got) {
|
|
938
|
+
sendJson(res, 404, { error: `this resolver serves chain ${SERVER_CHAIN_ID}, not ${got}` });
|
|
939
|
+
}
|
|
940
|
+
//# sourceMappingURL=server.js.map
|