@bsv/dpp-overlay-topics 0.4.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/LICENSE +58 -0
  2. package/README.md +169 -0
  3. package/dist/anchorStorage.d.ts +58 -0
  4. package/dist/anchorStorage.d.ts.map +1 -0
  5. package/dist/anchorStorage.js +72 -0
  6. package/dist/anchorStorage.js.map +1 -0
  7. package/dist/attestationAnchor.d.ts +10 -0
  8. package/dist/attestationAnchor.d.ts.map +1 -0
  9. package/dist/attestationAnchor.js +10 -0
  10. package/dist/attestationAnchor.js.map +1 -0
  11. package/dist/attestationStorage.d.ts +49 -0
  12. package/dist/attestationStorage.d.ts.map +1 -0
  13. package/dist/attestationStorage.js +78 -0
  14. package/dist/attestationStorage.js.map +1 -0
  15. package/dist/capabilities.d.ts +127 -0
  16. package/dist/capabilities.d.ts.map +1 -0
  17. package/dist/capabilities.js +210 -0
  18. package/dist/capabilities.js.map +1 -0
  19. package/dist/engineStorage.d.ts +106 -0
  20. package/dist/engineStorage.d.ts.map +1 -0
  21. package/dist/engineStorage.js +253 -0
  22. package/dist/engineStorage.js.map +1 -0
  23. package/dist/evidenceExport.d.ts +189 -0
  24. package/dist/evidenceExport.d.ts.map +1 -0
  25. package/dist/evidenceExport.js +517 -0
  26. package/dist/evidenceExport.js.map +1 -0
  27. package/dist/history.d.ts +114 -0
  28. package/dist/history.d.ts.map +1 -0
  29. package/dist/history.js +215 -0
  30. package/dist/history.js.map +1 -0
  31. package/dist/index.d.ts +269 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +1223 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/lib.d.ts +31 -0
  36. package/dist/lib.d.ts.map +1 -0
  37. package/dist/lib.js +31 -0
  38. package/dist/lib.js.map +1 -0
  39. package/dist/limits.d.ts +44 -0
  40. package/dist/limits.d.ts.map +1 -0
  41. package/dist/limits.js +44 -0
  42. package/dist/limits.js.map +1 -0
  43. package/dist/lsAttestation.d.ts +21 -0
  44. package/dist/lsAttestation.d.ts.map +1 -0
  45. package/dist/lsAttestation.js +45 -0
  46. package/dist/lsAttestation.js.map +1 -0
  47. package/dist/lsDpp.d.ts +53 -0
  48. package/dist/lsDpp.d.ts.map +1 -0
  49. package/dist/lsDpp.js +148 -0
  50. package/dist/lsDpp.js.map +1 -0
  51. package/dist/lsUoraDpp.d.ts +55 -0
  52. package/dist/lsUoraDpp.d.ts.map +1 -0
  53. package/dist/lsUoraDpp.js +152 -0
  54. package/dist/lsUoraDpp.js.map +1 -0
  55. package/dist/policyConfig.d.ts +31 -0
  56. package/dist/policyConfig.d.ts.map +1 -0
  57. package/dist/policyConfig.js +112 -0
  58. package/dist/policyConfig.js.map +1 -0
  59. package/dist/retraction.d.ts +41 -0
  60. package/dist/retraction.d.ts.map +1 -0
  61. package/dist/retraction.js +147 -0
  62. package/dist/retraction.js.map +1 -0
  63. package/dist/storage.d.ts +117 -0
  64. package/dist/storage.d.ts.map +1 -0
  65. package/dist/storage.js +158 -0
  66. package/dist/storage.js.map +1 -0
  67. package/dist/sync.d.ts +75 -0
  68. package/dist/sync.d.ts.map +1 -0
  69. package/dist/sync.js +118 -0
  70. package/dist/sync.js.map +1 -0
  71. package/dist/tmAttestation.d.ts +36 -0
  72. package/dist/tmAttestation.d.ts.map +1 -0
  73. package/dist/tmAttestation.js +50 -0
  74. package/dist/tmAttestation.js.map +1 -0
  75. package/dist/tmDpp.d.ts +154 -0
  76. package/dist/tmDpp.d.ts.map +1 -0
  77. package/dist/tmDpp.js +397 -0
  78. package/dist/tmDpp.js.map +1 -0
  79. package/dist/tmUoraDpp.d.ts +73 -0
  80. package/dist/tmUoraDpp.d.ts.map +1 -0
  81. package/dist/tmUoraDpp.js +150 -0
  82. package/dist/tmUoraDpp.js.map +1 -0
  83. package/dist/uoraAnchor.d.ts +209 -0
  84. package/dist/uoraAnchor.d.ts.map +1 -0
  85. package/dist/uoraAnchor.js +433 -0
  86. package/dist/uoraAnchor.js.map +1 -0
  87. package/package.json +51 -0
package/dist/index.js ADDED
@@ -0,0 +1,1223 @@
1
+ /**
2
+ * The DPP overlay node: an HTTP host for two rails, wrapping the
3
+ * `@bsv/overlay` Engine.
4
+ *
5
+ * the passport `tm_dpp` admits states, `ls_dpp` resolves them
6
+ * the anchors `tm_attestation` admits generic anchors, `ls_attestation`
7
+ * looks them up; `tm_uora_dpp` and `ls_uora_dpp` carry the
8
+ * historical UORA anchors
9
+ *
10
+ * The wire contract is the ecosystem's standard one (BRC-22 submit, BRC-24
11
+ * lookup), pinned as OpenAPI in `contracts/overlay.yaml`:
12
+ *
13
+ * POST /submit application/octet-stream body = BEEF bytes
14
+ * X-Topics: ["tm_dpp"] -> STEAK as JSON, plus an
15
+ * X-Admission answer header (topic=admitted|duplicate|none)
16
+ * Authorization: Bearer <token> when SUBMIT_TOKEN is set
17
+ * POST /lookup {"service":"ls_dpp","query":{"passportId"|"uid": "..."}}
18
+ * {"service":"ls_uora_dpp","query":{"issuer":"did:key:z..."}}
19
+ * -> {type:"output-list",
20
+ * outputs:[{beef,outputIndex}]}
21
+ * POST /arc-ingest {"txid","merklePath","blockHeight"}
22
+ * -> the proof, checked and
23
+ * applied to the held state (spec/services.md section 2);
24
+ * X-Callback-Token or bearer when ARC_CALLBACK_TOKEN is set
25
+ * GET /health -> {status:"ok",...}
26
+ *
27
+ * And the extensions this reference deployment serves beside them, each in
28
+ * the shape a contract in `contracts/` fixes:
29
+ *
30
+ * GET /capabilities -> the capability document
31
+ * (contracts/capabilities.schema.json), built from constants
32
+ * and configuration, never typed by hand
33
+ * GET /history?passportId=|uid=&limit=&cursor= -> one page of a stable
34
+ * snapshot (contracts/paginated-history.schema.json), the
35
+ * export beside the bounded lookup
36
+ * GET /evidence-package?passportId= -> dpp-evidence-package@1
37
+ * as {manifest, files} (contracts/evidence-package.schema.json),
38
+ * signed with EXPORT_SIGNING_KEY; 503 when it is unset;
39
+ * bounded to the newest 500 states, the rest declared absent
40
+ * GET /evidence-export?passportId=&cursor= -> one part of the complete
41
+ * export (contracts/evidence-export.schema.json): a package
42
+ * over a contiguous sequence range of one snapshot, oldest
43
+ * first, bounded by states and bytes, resumed by cursor;
44
+ * Authorization: Bearer <token> when EXPORT_TOKEN is set
45
+ * POST /retract {"txid","outputIndex","reason"} -> removes a state the
46
+ * network refused, behind the /submit bearer; 409 when the
47
+ * state is proven, spent or known to the chain tracker
48
+ * POST /requestSyncResponse, POST /requestForeignGASPNode
49
+ * -> the upstream GASP
50
+ * routes a peer synchronises from, exactly as the overlay
51
+ * protocol defines them
52
+ *
53
+ * Everything is environment; an unset variable switches its feature off or
54
+ * falls back, and only a missing identity key (or a policy file that does not
55
+ * verify) fails the boot. See the Configuration table in this package's
56
+ * README.
57
+ *
58
+ * This file is both the entry point and a library: it boots only when node
59
+ * runs it directly, so tests can drive `createRequestHandler` and
60
+ * `startOverlayService` without a container.
61
+ *
62
+ * Deliberately absent: a Broadcaster and an Advertiser. The app's wallet has
63
+ * already broadcast the transaction by the time it announces it here, so a
64
+ * second broadcast would only add a failure mode; and SHIP/SLAP advertising
65
+ * would need this service to hold a funded wallet of its own. Neither is
66
+ * needed for the demo, and both can be added later without changing the wire.
67
+ * Peer synchronisation is present but static: SYNC_PEERS names the operators
68
+ * this node pulls from through the SDK's GASP (sync.ts), and a state a peer
69
+ * offers passes the same topic managers as one a writer announces.
70
+ * Because this host does not broadcast, no broadcaster's callback reaches it
71
+ * on its own: merkle proofs arrive through POST /arc-ingest, pushed by the
72
+ * writer once its wallet has them or by a broadcaster whose callback URL a
73
+ * writer pointed here (spec/writing.md section 7). Re-announcing a mined state
74
+ * would not do it: the engine skips a txid it already holds.
75
+ */
76
+ import { createServer } from 'node:http';
77
+ import { timingSafeEqual } from 'node:crypto';
78
+ import { realpathSync } from 'node:fs';
79
+ import { pathToFileURL } from 'node:url';
80
+ import { MongoClient } from 'mongodb';
81
+ import { MerklePath, PrivateKey, Transaction, Utils, WhatsOnChain, } from '@bsv/sdk';
82
+ import { Engine } from '@bsv/overlay';
83
+ import { DppTopicManager, DPP_TOPIC } from './tmDpp.js';
84
+ import { DppLookupService, DPP_SERVICE } from './lsDpp.js';
85
+ import { InMemoryDppStorage, MongoDppStorage } from './storage.js';
86
+ import { InMemoryOverlayStorage, MongoOverlayStorage } from './engineStorage.js';
87
+ import { UoraAnchorTopicManager } from './tmUoraDpp.js';
88
+ import { UoraAnchorLookupService, UORA_SERVICE, UORA_TOPIC } from './lsUoraDpp.js';
89
+ import { AttestationTopicManager, ATTESTATION_TOPIC } from './tmAttestation.js';
90
+ import { AttestationLookupService, ATTESTATION_SERVICE } from './lsAttestation.js';
91
+ import { InMemoryAttestationStorage, MongoAttestationStorage, validateAttestationQuery } from './attestationStorage.js';
92
+ import { InMemoryUoraAnchorStorage, MongoUoraAnchorStorage, } from './anchorStorage.js';
93
+ import { MAX_BODY_BYTES, MAX_PAGE_SIZE } from './limits.js';
94
+ import { buildCapabilities, implementationIdentity } from './capabilities.js';
95
+ import { HistoryError, HistoryPaginator } from './history.js';
96
+ import { buildEvidenceExportPart, buildEvidencePackage } from './evidenceExport.js';
97
+ import { RetractionRefused, retractOutput } from './retraction.js';
98
+ import { policyKeysFor, publisherPolicyFromEnvironment } from './policyConfig.js';
99
+ import { startPeerSynchronisation, syncConfigurationFor, syncSettingsFromEnvironment } from './sync.js';
100
+ export const TOPIC = DPP_TOPIC;
101
+ export const SERVICE = DPP_SERVICE;
102
+ export { UORA_SERVICE, UORA_TOPIC };
103
+ export { ATTESTATION_SERVICE, ATTESTATION_TOPIC };
104
+ /** How long a stop waits for in-flight requests before it gives up. */
105
+ const SHUTDOWN_GRACE_MS = 10_000;
106
+ /** An error that answers with something other than the default 400. */
107
+ class HttpError extends Error {
108
+ status;
109
+ constructor(status, message) {
110
+ super(message);
111
+ this.status = status;
112
+ }
113
+ }
114
+ /** A refusal the wire names, so a client branches on `error` and reads `description` as prose. */
115
+ class NamedError extends HttpError {
116
+ code;
117
+ hint;
118
+ constructor(status, code, message, hint) {
119
+ super(status, message);
120
+ this.code = code;
121
+ this.hint = hint;
122
+ }
123
+ }
124
+ function json(response, status, body, headers = {}) {
125
+ const payload = JSON.stringify(body);
126
+ response.writeHead(status, {
127
+ 'Content-Type': 'application/json',
128
+ 'Content-Length': Buffer.byteLength(payload),
129
+ 'Access-Control-Allow-Origin': '*',
130
+ ...headers,
131
+ });
132
+ response.end(payload);
133
+ }
134
+ async function readBody(request) {
135
+ const chunks = [];
136
+ let size = 0;
137
+ for await (const chunk of request) {
138
+ const buffer = chunk;
139
+ size += buffer.length;
140
+ if (size > MAX_BODY_BYTES)
141
+ throw new HttpError(413, 'request body too large');
142
+ chunks.push(buffer);
143
+ }
144
+ return Buffer.concat(chunks);
145
+ }
146
+ function topicsFrom(request) {
147
+ const header = request.headers['x-topics'];
148
+ const raw = Array.isArray(header) ? header[0] : header;
149
+ if (raw == null || raw === '')
150
+ return [TOPIC];
151
+ let parsed;
152
+ try {
153
+ parsed = JSON.parse(raw);
154
+ }
155
+ catch {
156
+ throw new HttpError(400, 'X-Topics must be a JSON array of strings');
157
+ }
158
+ if (!Array.isArray(parsed) || parsed.some((t) => typeof t !== 'string')) {
159
+ throw new HttpError(400, 'X-Topics must be a JSON array of strings');
160
+ }
161
+ return parsed;
162
+ }
163
+ /**
164
+ * BRC-22 allows off-chain values to ride along with the BEEF, length-prefixed.
165
+ * The app never sends them; supporting the header keeps a standard client
166
+ * (the SDK's SHIPBroadcaster) from being silently mis-parsed.
167
+ */
168
+ function splitBody(request, body) {
169
+ const flag = request.headers['x-includes-off-chain-values'];
170
+ if (flag !== 'true')
171
+ return { beef: [...body] };
172
+ const reader = new Utils.Reader([...body]);
173
+ const length = reader.readVarIntNum();
174
+ return { beef: reader.read(length), offChainValues: reader.read() };
175
+ }
176
+ /**
177
+ * Constant-time bearer comparison. The token is a shared secret rather than a
178
+ * per-caller credential, so the only thing worth denying an attacker is a
179
+ * byte-at-a-time oracle.
180
+ */
181
+ function bearerAccepted(request, expected) {
182
+ const header = request.headers.authorization;
183
+ const raw = Array.isArray(header) ? header[0] : header;
184
+ if (raw == null)
185
+ return false;
186
+ // The scheme is checked by fixed-width prefix and the token is what follows
187
+ // it, trimmed: no regex over the caller-controlled value, so a header of
188
+ // many spaces costs linear time.
189
+ const trimmed = raw.trim();
190
+ if (trimmed.length < 7 || trimmed.slice(0, 6).toLowerCase() !== 'bearer' || !/\s/.test(trimmed[6]))
191
+ return false;
192
+ const token = trimmed.slice(7).trim();
193
+ if (token === '')
194
+ return false;
195
+ return secretsEqual(token, expected);
196
+ }
197
+ function secretsEqual(given, expected) {
198
+ const a = Buffer.from(given, 'utf8');
199
+ const b = Buffer.from(expected, 'utf8');
200
+ if (a.length !== b.length)
201
+ return false;
202
+ return timingSafeEqual(a, b);
203
+ }
204
+ /**
205
+ * The callback token as an ARC-compatible broadcaster presents it: the token
206
+ * given at submission comes back as `Authorization: Bearer`, and the overlay
207
+ * host upstream also reads `X-Callback-Token`, so both are accepted.
208
+ */
209
+ function callbackTokenAccepted(request, expected) {
210
+ if (bearerAccepted(request, expected))
211
+ return true;
212
+ const header = request.headers['x-callback-token'];
213
+ const raw = Array.isArray(header) ? header[0] : header;
214
+ if (raw == null)
215
+ return false;
216
+ return secretsEqual(raw.trim(), expected);
217
+ }
218
+ /**
219
+ * The body of a proof callback, as the stack's broadcast contract defines it
220
+ * (`ArcMerkleCallback`): the txid, the BRC-74 BUMP in hex, and the block
221
+ * height, which the BUMP also carries. A proof that does not contain the
222
+ * txid, or a body that disagrees with itself about the height, is refused
223
+ * rather than trusted on either value. No merkle path means a status-only
224
+ * callback, which the caller acknowledges rather than refuses.
225
+ */
226
+ function parseProofCallback(body) {
227
+ let parsed;
228
+ try {
229
+ parsed = JSON.parse(body.toString('utf8'));
230
+ }
231
+ catch {
232
+ throw new HttpError(400, 'body must be JSON');
233
+ }
234
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
235
+ throw new HttpError(400, 'body must be a JSON object with txid and merklePath');
236
+ }
237
+ const { txid, merklePath, blockHeight } = parsed;
238
+ if (typeof txid !== 'string' || !/^[0-9a-f]{64}$/.test(txid)) {
239
+ throw new HttpError(400, 'txid must be 64 lower-case hex characters');
240
+ }
241
+ if (merklePath == null || merklePath === '')
242
+ return { txid };
243
+ if (typeof merklePath !== 'string')
244
+ throw new HttpError(400, 'merklePath must be a BUMP in hex');
245
+ let proof;
246
+ try {
247
+ proof = MerklePath.fromHex(merklePath);
248
+ }
249
+ catch {
250
+ throw new HttpError(400, 'merklePath is not a BUMP');
251
+ }
252
+ try {
253
+ proof.computeRoot(txid);
254
+ }
255
+ catch {
256
+ throw new HttpError(400, 'merklePath does not contain txid');
257
+ }
258
+ if (blockHeight != null && blockHeight !== proof.blockHeight) {
259
+ throw new HttpError(400, 'blockHeight does not match the merkle path');
260
+ }
261
+ return { txid, merklePath: proof };
262
+ }
263
+ /** A JSON object body, or a 400 naming what it should have carried. */
264
+ function parseJsonObject(body, expected) {
265
+ let parsed;
266
+ try {
267
+ parsed = JSON.parse(body.toString('utf8'));
268
+ }
269
+ catch {
270
+ throw new HttpError(400, 'body must be JSON');
271
+ }
272
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
273
+ throw new HttpError(400, `body must be a JSON object with ${expected}`);
274
+ }
275
+ return parsed;
276
+ }
277
+ /**
278
+ * The body of a retraction: the outpoint and the writer's reason, which is
279
+ * logged and echoed and never interpreted. Bounded so a log line stays a log
280
+ * line.
281
+ */
282
+ function parseRetraction(body) {
283
+ let parsed;
284
+ try {
285
+ parsed = JSON.parse(body.toString('utf8'));
286
+ }
287
+ catch {
288
+ throw new HttpError(400, 'body must be JSON');
289
+ }
290
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
291
+ throw new HttpError(400, 'body must be a JSON object with txid, outputIndex and reason');
292
+ }
293
+ const { txid, outputIndex, reason } = parsed;
294
+ if (typeof txid !== 'string' || !/^[0-9a-f]{64}$/.test(txid)) {
295
+ throw new HttpError(400, 'txid must be 64 lower-case hex characters');
296
+ }
297
+ if (typeof outputIndex !== 'number' || !Number.isSafeInteger(outputIndex) || outputIndex < 0) {
298
+ throw new HttpError(400, 'outputIndex must be a non-negative integer');
299
+ }
300
+ if (typeof reason !== 'string' || reason.trim() === '' || reason.length > 1000) {
301
+ throw new HttpError(400, 'reason must be a non-empty string of at most 1000 characters');
302
+ }
303
+ return { txid, outputIndex, reason: reason.trim() };
304
+ }
305
+ /**
306
+ * The lookup query, checked before it reaches any engine. The Mongo stores
307
+ * build filters from these fields, so a value that is not a plain string is a
308
+ * query operator waiting to run: `{"uid":{"$ne":""}}` would otherwise match
309
+ * every document. `limit` alone may be a number; `ls_uora_dpp` documents it as
310
+ * the answer's page size and the stores bound it themselves.
311
+ */
312
+ function checkedQuery(query, service) {
313
+ if (service === ATTESTATION_SERVICE) {
314
+ try {
315
+ return { ...validateAttestationQuery(query) };
316
+ }
317
+ catch (error) {
318
+ throw new HttpError(400, error instanceof Error ? error.message : 'invalid attestation query');
319
+ }
320
+ }
321
+ if (typeof query !== 'object' || query === null || Array.isArray(query)) {
322
+ throw new HttpError(400, 'query must be an object of string fields');
323
+ }
324
+ for (const [key, value] of Object.entries(query)) {
325
+ if (key === 'limit') {
326
+ if (typeof value === 'number' && Number.isFinite(value))
327
+ continue;
328
+ throw new HttpError(400, 'query.limit must be a finite number');
329
+ }
330
+ if (typeof value !== 'string') {
331
+ throw new HttpError(400, `query.${key} must be a string`);
332
+ }
333
+ }
334
+ return query;
335
+ }
336
+ /**
337
+ * What one topic's STEAK entry actually says. The discriminator is pinned
338
+ * `@bsv/overlay` 2.0.3 behaviour: a duplicate skips the mutation phase, so its
339
+ * entry never gains `coinsRemoved`, while a refused non-duplicate passes
340
+ * through it and gets `coinsRemoved` set (to `[]` when nothing was removed).
341
+ * A topic whose validation threw inside the engine also reads as `duplicate`
342
+ * here; the engine logs its own error line for that path.
343
+ */
344
+ function admissionOutcome(instructions) {
345
+ const removed = instructions.coinsRemoved;
346
+ if (instructions.outputsToAdmit.length > 0 || (removed?.length ?? 0) > 0)
347
+ return 'admitted';
348
+ if (removed === undefined)
349
+ return 'duplicate';
350
+ return 'none';
351
+ }
352
+ /** Only wanted when a warning fires, so junk bytes on a happy path cost nothing. */
353
+ function txidOf(beef) {
354
+ try {
355
+ return Transaction.fromBEEF(beef).id('hex');
356
+ }
357
+ catch {
358
+ return 'an unparseable BEEF';
359
+ }
360
+ }
361
+ /**
362
+ * A "duplicate" the engine holds nothing for was refused earlier: the Engine
363
+ * records every transaction it validates as applied to the topic, admitted or
364
+ * not, and skips a txid it has applied, so a state refused once for a reason
365
+ * that has since gone (its predecessor announced after it, a policy version
366
+ * not yet loaded, a stranger announcing a successor before the writer could
367
+ * announce its predecessor) would read as a duplicate for ever and the writer
368
+ * would record success. When this host's storage shows no admitted output of
369
+ * the txid for a topic that read as a duplicate, it clears the applied record
370
+ * and submits the transaction again for those topics, so the answer is what
371
+ * the topic managers say today: admitted, or none. Returns the topics
372
+ * re-evaluated; `steak` is updated in place for them. Without the storage (a
373
+ * stand-in engine) nothing changes.
374
+ */
375
+ async function reconsiderRefusedEarlier(engine, options, beef, offChainValues, steak, outcomes) {
376
+ const storage = options.components?.engineStorage;
377
+ if (storage == null || storage.deleteAppliedTransaction == null)
378
+ return [];
379
+ const duplicates = outcomes.filter(([, outcome]) => outcome === 'duplicate').map(([topic]) => topic);
380
+ if (duplicates.length === 0)
381
+ return [];
382
+ let txid;
383
+ try {
384
+ txid = Transaction.fromBEEF(beef).id('hex');
385
+ }
386
+ catch {
387
+ return [];
388
+ }
389
+ const held = await storage.findOutputsForTransaction(txid);
390
+ const refusedEarlier = duplicates.filter((topic) => !held.some((output) => output.topic === topic));
391
+ if (refusedEarlier.length === 0)
392
+ return [];
393
+ for (const topic of refusedEarlier)
394
+ await storage.deleteAppliedTransaction(txid, topic);
395
+ const again = await engine.submit({ beef, topics: refusedEarlier, offChainValues });
396
+ for (const topic of refusedEarlier)
397
+ if (again[topic] != null)
398
+ steak[topic] = again[topic];
399
+ return refusedEarlier;
400
+ }
401
+ /**
402
+ * The whole HTTP surface, as a plain node request listener. Separated from the
403
+ * server so a test can exercise the wire contract against a stand-in engine.
404
+ */
405
+ export function createRequestHandler(engine, options = {}) {
406
+ const network = options.network ?? 'main';
407
+ const startedAt = options.startedAt ?? new Date().toISOString();
408
+ const submitToken = options.submitToken != null && options.submitToken !== '' ? options.submitToken : undefined;
409
+ const proofToken = options.proofToken != null && options.proofToken !== '' ? options.proofToken : undefined;
410
+ const chainTracker = options.chainTracker;
411
+ const now = options.now ?? (() => new Date());
412
+ const components = options.components;
413
+ // The paginator holds this process's cursor secret, so it lives as long as
414
+ // the handler: one per node, drawn at boot.
415
+ const paginator = components == null ? undefined : new HistoryPaginator(components.records, { now });
416
+ const exportSigner = options.exportSigningKey != null && options.exportSigningKey !== ''
417
+ ? PrivateKey.fromHex(options.exportSigningKey)
418
+ : undefined;
419
+ const exportToken = options.exportToken != null && options.exportToken !== '' ? options.exportToken : undefined;
420
+ const identity = implementationIdentity();
421
+ const resolved = {
422
+ submitToken,
423
+ proofToken,
424
+ chainTracker,
425
+ network,
426
+ startedAt,
427
+ now,
428
+ components,
429
+ paginator,
430
+ exportSigner,
431
+ exportToken,
432
+ knownOnChain: options.knownOnChain,
433
+ software: `${identity.name}@${identity.version}`,
434
+ };
435
+ return (request, response) => {
436
+ void handle(engine, request, response, resolved);
437
+ };
438
+ }
439
+ async function handle(engine, request, response, options) {
440
+ const url = new URL(request.url ?? '/', 'http://localhost');
441
+ const route = `${request.method ?? 'GET'} ${url.pathname}`;
442
+ if (request.method === 'OPTIONS') {
443
+ response.writeHead(204, {
444
+ 'Access-Control-Allow-Origin': '*',
445
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
446
+ 'Access-Control-Allow-Headers': 'Authorization, Content-Type, X-Topics, X-Includes-Off-Chain-Values, X-Callback-Token',
447
+ });
448
+ response.end();
449
+ return;
450
+ }
451
+ try {
452
+ if (route === 'GET /health') {
453
+ json(response, 200, {
454
+ status: 'ok',
455
+ // `topic` and `service` name the passport rail and stay singular:
456
+ // deployed health checks read them, and a second rail is not a reason
457
+ // to break a probe. The full set is beside them.
458
+ topic: TOPIC,
459
+ service: SERVICE,
460
+ topics: [TOPIC, ATTESTATION_TOPIC, UORA_TOPIC],
461
+ services: [SERVICE, ATTESTATION_SERVICE, UORA_SERVICE],
462
+ legacyTopics: [UORA_TOPIC],
463
+ legacyServices: [UORA_SERVICE],
464
+ network: options.network,
465
+ startedAt: options.startedAt,
466
+ });
467
+ return;
468
+ }
469
+ if (route === 'GET /capabilities') {
470
+ const components = options.components;
471
+ json(response, 200, buildCapabilities({
472
+ publisherPolicy: components?.publisherPolicy,
473
+ serviceIdentityKey: components?.serviceIdentityKey,
474
+ anchorServiceKeys: components?.anchorServiceKeys,
475
+ ownerConsent: components?.ownerConsent,
476
+ controlAuthorities: components?.controlAuthorities,
477
+ managedAcceptance: components?.managedAcceptance,
478
+ exportAvailable: options.exportSigner != null,
479
+ completeExportBearer: options.exportToken != null,
480
+ networkOracleConfigured: options.knownOnChain != null,
481
+ at: options.now(),
482
+ syncPeers: components?.sync?.peers,
483
+ syncIntervalMs: components?.sync?.intervalMs,
484
+ }));
485
+ return;
486
+ }
487
+ if (route === 'GET /listTopicManagers') {
488
+ json(response, 200, await engine.listTopicManagers());
489
+ return;
490
+ }
491
+ if (route === 'GET /listLookupServiceProviders') {
492
+ json(response, 200, await engine.listLookupServiceProviders());
493
+ return;
494
+ }
495
+ if (route === 'GET /getDocumentationForTopicManager' || route === 'GET /getDocumentationForLookupServiceProvider') {
496
+ const params = new URL(request.url ?? '/', 'http://localhost').searchParams;
497
+ const topic = route === 'GET /getDocumentationForTopicManager';
498
+ const parameter = topic ? 'manager' : 'lookupService';
499
+ const name = params.get(parameter);
500
+ const available = topic ? await engine.listTopicManagers() : await engine.listLookupServiceProviders();
501
+ if (!name || params.getAll(parameter).length !== 1 || !Object.hasOwn(available, name)) {
502
+ json(response, 400, { status: 'error', description: `unknown or missing ${parameter}` });
503
+ return;
504
+ }
505
+ const documentation = topic ? await engine.getDocumentationForTopicManager(name) : await engine.getDocumentationForLookupServiceProvider(name);
506
+ response.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8', 'Access-Control-Allow-Origin': '*' });
507
+ response.end(documentation);
508
+ return;
509
+ }
510
+ if (route === 'POST /submit') {
511
+ // Checked before the body is buffered: an unauthorised caller should not
512
+ // get to spend memory here, let alone the header quota that
513
+ // Engine.submit's SPV check spends downstream.
514
+ if (options.submitToken != null && !bearerAccepted(request, options.submitToken)) {
515
+ request.resume();
516
+ json(response, 401, { status: 'error', description: 'POST /submit requires a bearer token' });
517
+ return;
518
+ }
519
+ const body = await readBody(request);
520
+ if (body.length === 0) {
521
+ json(response, 400, { status: 'error', description: 'empty body' });
522
+ return;
523
+ }
524
+ const topics = topicsFrom(request);
525
+ const { beef, offChainValues } = splitBody(request, body);
526
+ const steak = await engine.submit({ beef, topics, offChainValues });
527
+ let outcomes = topics
528
+ .filter((topic) => steak[topic] != null)
529
+ .map((topic) => [topic, admissionOutcome(steak[topic])]);
530
+ const refusedEarlier = await reconsiderRefusedEarlier(engine, options, beef, offChainValues, steak, outcomes);
531
+ if (refusedEarlier.length > 0) {
532
+ outcomes = topics
533
+ .filter((topic) => steak[topic] != null)
534
+ .map((topic) => [topic, admissionOutcome(steak[topic])]);
535
+ }
536
+ const refused = outcomes.filter(([, outcome]) => outcome === 'none').map(([topic]) => topic);
537
+ if (refused.length > 0) {
538
+ const again = refused.filter((topic) => refusedEarlier.includes(topic));
539
+ console.warn(`POST /submit admitted nothing on ${refused.join(', ')} for ${txidOf(beef)}: ` +
540
+ 'not a duplicate, so the submission was refused (wrong identity key, a ' +
541
+ 'predecessor this instance never admitted, or, when OWNER_CONSENT is set, a ' +
542
+ 'TRANSFER whose actor did not prove they are the previous owner). Re-announce after repair, ' +
543
+ 'oldest state first: an index that never admitted a predecessor ' +
544
+ 'refuses every later state.' +
545
+ (again.length === 0 ? '' : ` This transaction was announced and refused before on ${again.join(', ')} and was re-evaluated now, not skipped.`));
546
+ }
547
+ // The STEAK body stays exactly what the engine returned, because that is
548
+ // the protocol's shape; the header is where the outcome is allowed to be
549
+ // plainer than the body.
550
+ json(response, 200, steak, {
551
+ 'X-Admission': outcomes.map(([topic, outcome]) => `${topic}=${outcome}`).join(', '),
552
+ 'Access-Control-Expose-Headers': 'X-Admission',
553
+ });
554
+ return;
555
+ }
556
+ if (route === 'POST /arc-ingest') {
557
+ // A merkle proof for a state this index holds, in the shape an
558
+ // ARC-compatible broadcaster's callback carries, so a writer offering a
559
+ // proof and a broadcaster whose callback URL points here send the same
560
+ // body (spec/writing.md section 7). The token is checked before the body
561
+ // is read, as on /submit.
562
+ if (options.proofToken != null && !callbackTokenAccepted(request, options.proofToken)) {
563
+ request.resume();
564
+ json(response, 401, {
565
+ status: 'error',
566
+ description: 'POST /arc-ingest requires the callback token',
567
+ });
568
+ return;
569
+ }
570
+ const callback = parseProofCallback(await readBody(request));
571
+ if (callback.merklePath === undefined) {
572
+ // A status-only callback (seen, rejected) carries no proof and is not
573
+ // an error: answering 200 keeps a broadcaster from retrying it forever.
574
+ json(response, 200, { status: 'ignored', txid: callback.txid, reason: 'no merkle path' });
575
+ return;
576
+ }
577
+ const { txid, merklePath } = callback;
578
+ // spec/services.md section 2: a pushed proof is admission of a kind, so
579
+ // it is validated against the header source before anything is stored.
580
+ // No answer from the source is neither agreement nor refutation; the
581
+ // proof is not applied and the caller is told to try again.
582
+ const tracker = options.chainTracker;
583
+ if (tracker != null && tracker !== 'scripts only') {
584
+ let proven;
585
+ try {
586
+ proven = await merklePath.verify(txid, tracker);
587
+ }
588
+ catch (cause) {
589
+ console.warn(`POST /arc-ingest could not evaluate the proof for ${txid}:`, cause);
590
+ throw new HttpError(503, 'header source unavailable; the proof was not applied, try again');
591
+ }
592
+ if (!proven)
593
+ throw new HttpError(400, 'merklePath does not validate against block headers');
594
+ }
595
+ try {
596
+ await engine.handleNewMerkleProof(txid, merklePath, merklePath.blockHeight);
597
+ }
598
+ catch (cause) {
599
+ if (cause instanceof Error && /Could not find matching transaction outputs/.test(cause.message)) {
600
+ throw new HttpError(404, 'this index holds no output of that transaction');
601
+ }
602
+ throw cause;
603
+ }
604
+ console.log(`POST /arc-ingest applied the proof for ${txid} at height ${merklePath.blockHeight}`);
605
+ json(response, 200, { status: 'applied', txid, blockHeight: merklePath.blockHeight });
606
+ return;
607
+ }
608
+ if (route === 'POST /requestSyncResponse') {
609
+ // The first half of a peer's synchronisation (GASP, as the upstream
610
+ // overlay protocol defines it): which outputs this index holds for the
611
+ // topic named in X-BSV-Topic since the peer's checkpoint. Read-only, and
612
+ // open like /lookup: what it lists is findable either way.
613
+ const header = request.headers['x-bsv-topic'];
614
+ const topic = Array.isArray(header) ? header[0] : header;
615
+ if (topic == null || topic === '')
616
+ throw new HttpError(400, 'X-BSV-Topic header is required');
617
+ if (!Object.hasOwn(await engine.listTopicManagers(), topic))
618
+ throw new HttpError(400, `unknown topic ${topic}`);
619
+ const body = parseJsonObject(await readBody(request), 'version and since');
620
+ const { version, since, limit } = body;
621
+ if (typeof version !== 'number' || !Number.isFinite(version))
622
+ throw new HttpError(400, 'version must be a number');
623
+ if (typeof since !== 'number' || !Number.isSafeInteger(since) || since < 0)
624
+ throw new HttpError(400, 'since must be a non-negative integer');
625
+ if (limit !== undefined && (typeof limit !== 'number' || !Number.isSafeInteger(limit) || limit < 1)) {
626
+ throw new HttpError(400, 'limit must be a positive integer');
627
+ }
628
+ // The engine's storage applies no cap of its own, so an absent or a
629
+ // large limit would hand an anonymous peer every unspent output of the
630
+ // topic in one answer. The page is bounded as every other answer is;
631
+ // a peer holding more continues from its checkpoint next round.
632
+ const page = Math.min(typeof limit === 'number' ? limit : MAX_PAGE_SIZE, MAX_PAGE_SIZE);
633
+ json(response, 200, await engine.provideForeignSyncResponse({ version, since, limit: page }, topic));
634
+ return;
635
+ }
636
+ if (route === 'POST /requestForeignGASPNode') {
637
+ // The second half: one transaction of a graph the peer is assembling,
638
+ // with its merkle path when this index holds one. The graph identifier
639
+ // is the outpoint at the tip of the graph, txid.outputIndex.
640
+ const body = parseJsonObject(await readBody(request), 'graphID, txid and outputIndex');
641
+ const { graphID, txid, outputIndex } = body;
642
+ if (typeof graphID !== 'string' || !/^[0-9a-f]{64}\.\d+$/.test(graphID))
643
+ throw new HttpError(400, 'graphID must be txid.outputIndex');
644
+ if (typeof txid !== 'string' || !/^[0-9a-f]{64}$/.test(txid))
645
+ throw new HttpError(400, 'txid must be 64 lower-case hex characters');
646
+ if (typeof outputIndex !== 'number' || !Number.isSafeInteger(outputIndex) || outputIndex < 0) {
647
+ throw new HttpError(400, 'outputIndex must be a non-negative integer');
648
+ }
649
+ /*
650
+ * Answered from this index's own storage first, by the transaction the
651
+ * peer asked for. The engine's provider walks the graph from its root
652
+ * through hydrated inputs, and since `@bsv/overlay` 2.3.1 the engine
653
+ * stores a proven state as the compact atomic BEEF of that state alone,
654
+ * so a walk from a proven tip meets an unhydrated input at the first
655
+ * step and a peer starting from nothing could never fetch a lineage
656
+ * whose tip is proven. Every retained state is held here with its own
657
+ * proof, so the node the peer names is served directly; the engine's
658
+ * walk remains the answer for anything this index does not hold by
659
+ * outpoint.
660
+ */
661
+ const held = options.components?.engineStorage == null ? undefined : await options.components.engineStorage.findOutput(txid, outputIndex, undefined, undefined, true);
662
+ if (held?.beef != null) {
663
+ try {
664
+ const tx = Transaction.fromBEEF(held.beef);
665
+ if (tx.id('hex') === txid) {
666
+ json(response, 200, { rawTx: tx.toHex(), graphID, outputIndex, ...(tx.merklePath == null ? {} : { proof: tx.merklePath.toHex() }) });
667
+ return;
668
+ }
669
+ }
670
+ catch {
671
+ // A stored BEEF this reader cannot parse falls through to the engine.
672
+ }
673
+ }
674
+ try {
675
+ json(response, 200, await engine.provideForeignGASPNode(graphID, txid, outputIndex));
676
+ }
677
+ catch (cause) {
678
+ if (cause instanceof Error && /No matching output found|Unable to find output/.test(cause.message)) {
679
+ throw new HttpError(404, 'this index holds no such output in that graph');
680
+ }
681
+ throw cause;
682
+ }
683
+ return;
684
+ }
685
+ if (route === 'POST /lookup') {
686
+ const body = await readBody(request);
687
+ let question;
688
+ try {
689
+ question = JSON.parse(body.toString('utf8'));
690
+ }
691
+ catch {
692
+ throw new HttpError(400, 'body must be JSON');
693
+ }
694
+ if (typeof question.service !== 'string') {
695
+ json(response, 400, { status: 'error', description: 'service is required' });
696
+ return;
697
+ }
698
+ const answer = await engine.lookup({
699
+ service: question.service,
700
+ query: checkedQuery(question.query, question.service),
701
+ });
702
+ json(response, 200, answer);
703
+ return;
704
+ }
705
+ if (route === 'GET /history') {
706
+ // The export beside the bounded lookup (spec/portable-evidence.md
707
+ // section 1): pages of one snapshot, in the record store's own order.
708
+ if (options.paginator == null) {
709
+ throw new NamedError(503, 'history-unavailable', 'this node was started without its record store; GET /history is not served');
710
+ }
711
+ const params = url.searchParams;
712
+ const page = await options.paginator.page({
713
+ selector: { passportId: params.get('passportId') ?? undefined, uid: params.get('uid') ?? undefined },
714
+ limit: params.get('limit'),
715
+ cursor: params.get('cursor'),
716
+ });
717
+ json(response, 200, page);
718
+ return;
719
+ }
720
+ if (route === 'GET /evidence-package') {
721
+ // spec/portable-evidence.md section 2. The signer is the switch: without
722
+ // one there is no manifest anyone could hold this node to, so the route
723
+ // says so rather than serving an unsigned bundle.
724
+ if (options.exportSigner == null) {
725
+ throw new NamedError(503, 'export-unavailable', 'EXPORT_SIGNING_KEY is unset, so this node signs no evidence package; GET /history and POST /lookup remain');
726
+ }
727
+ if (options.components == null || options.paginator == null) {
728
+ throw new NamedError(503, 'export-unavailable', 'this node was started without its stores; GET /evidence-package is not served');
729
+ }
730
+ const passportId = url.searchParams.get('passportId');
731
+ if (passportId == null || passportId === '') {
732
+ throw new NamedError(400, 'query-invalid', 'passportId is required');
733
+ }
734
+ const envelope = await buildEvidencePackage({
735
+ passportId,
736
+ paginator: options.paginator,
737
+ engineStorage: options.components.engineStorage,
738
+ signingKey: options.exportSigner,
739
+ publisherPolicy: options.components.publisherPolicy,
740
+ serviceIdentityKey: options.components.serviceIdentityKey,
741
+ ownerConsent: options.components.ownerConsent,
742
+ controlAuthorities: options.components.controlAuthorities,
743
+ managedAcceptance: options.components.managedAcceptance,
744
+ chainTracker: options.chainTracker,
745
+ now: options.now(),
746
+ software: options.software,
747
+ });
748
+ if (envelope == null) {
749
+ throw new NamedError(404, 'passport-unknown', `this index holds no state of ${passportId}`);
750
+ }
751
+ json(response, 200, envelope);
752
+ return;
753
+ }
754
+ if (route === 'GET /evidence-export') {
755
+ // The complete export (spec/portable-evidence.md section 2): parts over
756
+ // one snapshot, oldest first, each bounded, resumed by cursor. The same
757
+ // signer switch as the bounded package, plus the bearer when the
758
+ // deployment sets one, checked before any store is read.
759
+ if (options.exportToken != null && !bearerAccepted(request, options.exportToken)) {
760
+ throw new NamedError(401, 'export-unauthorised', 'GET /evidence-export requires the EXPORT_TOKEN bearer; the bounded GET /evidence-package and GET /history remain open');
761
+ }
762
+ if (options.exportSigner == null) {
763
+ throw new NamedError(503, 'export-unavailable', 'EXPORT_SIGNING_KEY is unset, so this node signs no evidence package; GET /history and POST /lookup remain');
764
+ }
765
+ if (options.components == null || options.paginator == null) {
766
+ throw new NamedError(503, 'export-unavailable', 'this node was started without its stores; GET /evidence-export is not served');
767
+ }
768
+ const passportId = url.searchParams.get('passportId');
769
+ if (passportId == null || passportId === '') {
770
+ throw new NamedError(400, 'query-invalid', 'passportId is required');
771
+ }
772
+ const part = await buildEvidenceExportPart({
773
+ passportId,
774
+ cursor: url.searchParams.get('cursor'),
775
+ paginator: options.paginator,
776
+ engineStorage: options.components.engineStorage,
777
+ signingKey: options.exportSigner,
778
+ publisherPolicy: options.components.publisherPolicy,
779
+ serviceIdentityKey: options.components.serviceIdentityKey,
780
+ now: options.now(),
781
+ software: options.software,
782
+ });
783
+ if (part == null) {
784
+ throw new NamedError(404, 'passport-unknown', `this index holds no state of ${passportId}`);
785
+ }
786
+ json(response, 200, part);
787
+ return;
788
+ }
789
+ if (route === 'POST /retract') {
790
+ // A writer announced before sending, as the writing rules allow, and the
791
+ // network refused the transaction; the index holds a phantom tip. The
792
+ // same bearer as /submit: whoever may add a state may withdraw one.
793
+ if (options.submitToken != null && !bearerAccepted(request, options.submitToken)) {
794
+ request.resume();
795
+ json(response, 401, { status: 'error', description: 'POST /retract requires the /submit bearer token' });
796
+ return;
797
+ }
798
+ if (options.components == null) {
799
+ request.resume();
800
+ throw new NamedError(503, 'retraction-unavailable', 'this node was started without its stores; POST /retract is not served');
801
+ }
802
+ const { txid, outputIndex, reason } = parseRetraction(await readBody(request));
803
+ const retraction = await retractOutput({
804
+ storage: options.components.engineStorage,
805
+ lookupServices: options.components.lookupServices,
806
+ records: options.components.records,
807
+ topic: TOPIC,
808
+ txid,
809
+ outputIndex,
810
+ reason,
811
+ knownOnChain: options.knownOnChain,
812
+ });
813
+ console.warn(`POST /retract removed ${TOPIC} output ${txid}:${outputIndex} (${reason})` +
814
+ (retraction.restoredTip == null ? '' : `; ${retraction.restoredTip.txid}:${retraction.restoredTip.outputIndex} is the tip again`) +
815
+ (retraction.networkChecked ? '' : '; the network was not asked, no header source is configured'));
816
+ json(response, 200, {
817
+ status: 'retracted',
818
+ ...retraction,
819
+ ...(retraction.networkChecked
820
+ ? {}
821
+ : { note: 'the network was not asked whether it knows this transaction: no header source is configured (CHAIN_TRACKER=scripts-only), so only the local merkle-path check ran' }),
822
+ });
823
+ return;
824
+ }
825
+ json(response, 404, { status: 'error', description: `no route for ${route}` });
826
+ }
827
+ catch (cause) {
828
+ const description = cause instanceof Error ? cause.message : 'unknown error';
829
+ if (cause instanceof NamedError || cause instanceof HistoryError || cause instanceof RetractionRefused) {
830
+ // Named refusals are the client's to branch on and are not failures of
831
+ // this node, so they are logged as a line rather than a stack.
832
+ console.warn(`${route} refused (${cause.code}): ${description}`);
833
+ json(response, cause.status, {
834
+ status: 'error',
835
+ error: cause.code,
836
+ description,
837
+ ...('hint' in cause && cause.hint != null ? { hint: cause.hint } : {}),
838
+ });
839
+ return;
840
+ }
841
+ const status = cause instanceof HttpError ? cause.status : 400;
842
+ console.error(`${route} failed:`, cause);
843
+ json(response, status, { status: 'error', description });
844
+ }
845
+ }
846
+ /** Bind the HTTP surface. Resolves once the socket is listening. */
847
+ export async function startOverlayService(engine, options = {}) {
848
+ const server = createServer(createRequestHandler(engine, options));
849
+ await new Promise((resolve, reject) => {
850
+ server.once('error', reject);
851
+ // `listen(port, host)` when a host is given, `listen(port)` otherwise: the
852
+ // wildcard stays the default so a container is unchanged.
853
+ const listening = () => {
854
+ server.removeListener('error', reject);
855
+ resolve();
856
+ };
857
+ if (options.host != null && options.host !== '') {
858
+ server.listen(options.port ?? 8080, options.host, listening);
859
+ return;
860
+ }
861
+ server.listen(options.port ?? 8080, () => {
862
+ server.removeListener('error', reject);
863
+ resolve();
864
+ });
865
+ });
866
+ const address = server.address();
867
+ const port = typeof address === 'object' && address != null ? address.port : (options.port ?? 0);
868
+ return {
869
+ server,
870
+ port,
871
+ close: async () => {
872
+ await new Promise((resolve) => {
873
+ // Idle keep-alive sockets would otherwise hold the close open until a
874
+ // platform's kill timer beat it.
875
+ server.closeIdleConnections();
876
+ server.close(() => resolve());
877
+ setTimeout(resolve, SHUTDOWN_GRACE_MS).unref();
878
+ });
879
+ },
880
+ };
881
+ }
882
+ /**
883
+ * The key `server_signature` is checked against (`spec/record-model.md` §5). Only the
884
+ * public half is needed, so the deployment should set SERVICE_IDENTITY_KEY
885
+ * and this service never holds the treasury secret. SERVER_PRIVATE_KEY is
886
+ * accepted as a fallback because it is what every other component is
887
+ * configured with, but it grants this container more than it needs.
888
+ */
889
+ export function serviceIdentityKey() {
890
+ const declared = optionalServiceIdentityKey();
891
+ if (declared != null)
892
+ return declared;
893
+ throw new Error('Set SERVICE_IDENTITY_KEY (or SERVER_PRIVATE_KEY) so admission can be checked');
894
+ }
895
+ /** The same key when the environment names one, or undefined: under a publisher policy the key is optional. */
896
+ function optionalServiceIdentityKey() {
897
+ const declared = process.env.SERVICE_IDENTITY_KEY;
898
+ if (declared != null && declared !== '')
899
+ return declared;
900
+ const priv = process.env.SERVER_PRIVATE_KEY;
901
+ if (priv != null && priv !== '') {
902
+ console.warn('SERVICE_IDENTITY_KEY is unset; deriving it from SERVER_PRIVATE_KEY. ' +
903
+ 'Set the public key instead so this service holds no secret.');
904
+ return PrivateKey.fromHex(priv).toPublicKey().toString();
905
+ }
906
+ return undefined;
907
+ }
908
+ /**
909
+ * Identity keys of the anchoring services whose UORA anchors this instance
910
+ * admits, comma-separated in ANCHOR_SERVICE_KEYS.
911
+ *
912
+ * A list rather than one key, because the shared overlay instances this topic
913
+ * is meant for may serve more than one anchoring deployment, and because the
914
+ * resolver already established that this programme has anchored under two
915
+ * treasuries without being able to tell which.
916
+ *
917
+ * Public keys only. Unlike `serviceIdentityKey` there is no private-key
918
+ * fallback: the anchoring treasury is a different custody boundary from this
919
+ * container, and inviting an operator to paste its secret here to save a step
920
+ * is exactly the convenience that should not exist.
921
+ */
922
+ export function anchorServiceKeys() {
923
+ const declared = process.env.ANCHOR_SERVICE_KEYS;
924
+ if (declared == null || declared.trim() === '')
925
+ return [];
926
+ return declared
927
+ .split(',')
928
+ .map((key) => key.trim())
929
+ .filter((key) => key !== '');
930
+ }
931
+ /**
932
+ * The owner-signed transfer (`spec/custody.md` §4) is a profile's choice, and
933
+ * a deployment selects it with OWNER_CONSENT=required. TRANSFER_AUTHORITIES
934
+ * then names, comma-separated, the identity keys that may move ownership
935
+ * without proving consent (recovery); public keys only, as for
936
+ * ANCHOR_SERVICE_KEYS, and validated when the topic manager is built. Any
937
+ * other value of OWNER_CONSENT fails the boot rather than being read as a
938
+ * guess, and authorities set without the policy are a misconfiguration worth a
939
+ * warning, because they would silently do nothing.
940
+ */
941
+ export function ownerConsentPolicy() {
942
+ const declared = (process.env.OWNER_CONSENT ?? '').trim();
943
+ const authorities = (process.env.TRANSFER_AUTHORITIES ?? '')
944
+ .split(',')
945
+ .map((key) => key.trim())
946
+ .filter((key) => key !== '');
947
+ if (declared === '') {
948
+ if (authorities.length > 0) {
949
+ console.warn('TRANSFER_AUTHORITIES is set but OWNER_CONSENT is not: the authorities are ignored, ' +
950
+ 'because there is no consent rule for them to be exempt from');
951
+ }
952
+ return false;
953
+ }
954
+ if (declared !== 'required') {
955
+ throw new Error(`OWNER_CONSENT must be "required" or unset, got "${declared}"`);
956
+ }
957
+ return authorities.length === 0 ? true : { authorities };
958
+ }
959
+ /**
960
+ * The version 2 admission options (`spec/record-model-v2.md` §6,
961
+ * `spec/managed-custody.md`). ACCEPTANCE_COMMITMENT=required selects the
962
+ * managed-custody profile: a version 2 TRANSFER is admitted only with its
963
+ * acceptance commitment. CONTROL_AUTHORITIES names, comma-separated, the
964
+ * identity keys whose version 2 UPDATE, TRANSFER or RETIRE is admitted without
965
+ * a control proof; absent, the TRANSFER_AUTHORITIES serve both versions.
966
+ * Public keys only, validated when the topic manager is built; any other value
967
+ * of ACCEPTANCE_COMMITMENT fails the boot.
968
+ */
969
+ export function versionTwoAdmission() {
970
+ const declared = (process.env.ACCEPTANCE_COMMITMENT ?? '').trim();
971
+ if (declared !== '' && declared !== 'required') {
972
+ throw new Error(`ACCEPTANCE_COMMITMENT must be "required" or unset, got "${declared}"`);
973
+ }
974
+ const authorities = (process.env.CONTROL_AUTHORITIES ?? '')
975
+ .split(',')
976
+ .map((key) => key.trim())
977
+ .filter((key) => key !== '');
978
+ return { managedAcceptance: declared === 'required', ...(authorities.length === 0 ? {} : { controlAuthorities: authorities }) };
979
+ }
980
+ /**
981
+ * EXPORT_SIGNING_KEY: the private key that signs evidence-package manifests
982
+ * (`spec/portable-evidence.md` section 2). Optional, because a node that
983
+ * exports nothing needs no key; malformed, it fails the boot, because a key
984
+ * that cannot sign is a promise the capability document would break.
985
+ */
986
+ export function exportSigningKey() {
987
+ const declared = (process.env.EXPORT_SIGNING_KEY ?? '').trim();
988
+ if (declared === '') {
989
+ console.warn('EXPORT_SIGNING_KEY is unset: GET /evidence-package answers 503 export-unavailable');
990
+ return undefined;
991
+ }
992
+ try {
993
+ PrivateKey.fromHex(declared);
994
+ }
995
+ catch {
996
+ throw new Error('EXPORT_SIGNING_KEY is not a private key in hex');
997
+ }
998
+ return declared;
999
+ }
1000
+ /**
1001
+ * 'scripts only' skips header verification, which is a local-development
1002
+ * convenience and never a hosted setting: it would admit a transaction whose
1003
+ * ancestry is not proved.
1004
+ */
1005
+ function chainTracker(network) {
1006
+ if (process.env.CHAIN_TRACKER === 'scripts-only') {
1007
+ console.warn('CHAIN_TRACKER=scripts-only: submissions are NOT verified against block headers');
1008
+ return 'scripts only';
1009
+ }
1010
+ const apiKey = process.env.WOC_API_KEY;
1011
+ return new WhatsOnChain(network, apiKey != null && apiKey !== '' ? { apiKey } : undefined);
1012
+ }
1013
+ /**
1014
+ * Whether the network knows a transaction, asked of the same explorer the
1015
+ * default header source reads, for POST /retract: a transaction it answers is
1016
+ * one the network took, and an index does not retract what a block holds. A
1017
+ * 404 is the one negative; any other failure is neither and is reported as
1018
+ * the source being unavailable.
1019
+ */
1020
+ export function whatsOnChainKnows(network, apiKey) {
1021
+ const base = `https://api.whatsonchain.com/v1/bsv/${network}/tx/hash/`;
1022
+ const headers = { Accept: 'application/json' };
1023
+ if (apiKey != null && apiKey.trim() !== '')
1024
+ headers.Authorization = apiKey;
1025
+ return async (txid) => {
1026
+ const response = await fetch(base + txid, { headers });
1027
+ if (response.status === 404)
1028
+ return false;
1029
+ if (response.ok)
1030
+ return true;
1031
+ throw new Error(`the explorer answered ${response.status} for ${txid}`);
1032
+ };
1033
+ }
1034
+ /** The engine as the environment describes it, plus what the routes work on and whatever it has to close. */
1035
+ async function engineFromEnvironment(network, tracker) {
1036
+ // The policy first: with a chain, the single identity key is optional and,
1037
+ // where the chain covers tm_dpp, not consulted at all.
1038
+ const publisherPolicy = publisherPolicyFromEnvironment();
1039
+ const identityKey = publisherPolicy == null ? serviceIdentityKey() : optionalServiceIdentityKey();
1040
+ if (publisherPolicy != null) {
1041
+ console.log(`${TOPIC} admits under publisher policy versions ${publisherPolicy.versions.join(', ')} from ${publisherPolicy.source} ` +
1042
+ `(${publisherPolicy.chain[publisherPolicy.chain.length - 1].scope.operatorProfile}); ` +
1043
+ (identityKey == null ? 'no SERVICE_IDENTITY_KEY is set' : 'SERVICE_IDENTITY_KEY applies only where the policy does not cover a topic'));
1044
+ }
1045
+ const mongoUrl = process.env.MONGO_URL;
1046
+ let records;
1047
+ let anchors;
1048
+ let attestations;
1049
+ let engineStorage;
1050
+ let close = async () => { };
1051
+ if (mongoUrl != null && mongoUrl !== '') {
1052
+ const client = new MongoClient(mongoUrl);
1053
+ await client.connect();
1054
+ const db = client.db(process.env.MONGO_DB);
1055
+ const mongoRecords = new MongoDppStorage(db);
1056
+ // Indexes and the sequence numbering of rows older than the field, before
1057
+ // a request can ask for a page (storage.ts).
1058
+ await mongoRecords.ensureReady();
1059
+ records = mongoRecords;
1060
+ anchors = new MongoUoraAnchorStorage(db);
1061
+ attestations = new MongoAttestationStorage(db);
1062
+ const storage = new MongoOverlayStorage(db);
1063
+ await storage.ensureIndexes();
1064
+ engineStorage = storage;
1065
+ close = async () => {
1066
+ await client.close();
1067
+ };
1068
+ console.log('overlay state persists to MongoDB');
1069
+ }
1070
+ else {
1071
+ records = new InMemoryDppStorage();
1072
+ anchors = new InMemoryUoraAnchorStorage();
1073
+ attestations = new InMemoryAttestationStorage();
1074
+ engineStorage = new InMemoryOverlayStorage();
1075
+ console.warn('MONGO_URL is unset: overlay state is in memory and is lost on restart. ' +
1076
+ 'Fine locally, never hosted.');
1077
+ }
1078
+ const acceptedAnchorServices = anchorServiceKeys();
1079
+ // A policy restricts the anchor rail only where its scope covers the topic;
1080
+ // elsewhere the static list is in charge, and an empty static list admits
1081
+ // anchors from anyone. The capability document says the same under
1082
+ // unsupported (anchoring-service-restriction), so the warning and the
1083
+ // document never disagree about what an empty list means.
1084
+ const anchorsRestrictedByPolicy = publisherPolicy != null && policyKeysFor(publisherPolicy.chain, new Date(), 'anchor-publisher', ATTESTATION_TOPIC) != null;
1085
+ if (acceptedAnchorServices.length === 0 && !anchorsRestrictedByPolicy) {
1086
+ console.warn(`ANCHOR_SERVICE_KEYS is unset${publisherPolicy == null ? '' : ' and the publisher policy does not cover the anchor topic'}: ` +
1087
+ `${ATTESTATION_TOPIC} admits any well-formed anchor, whoever wrote it. Name the anchoring services on any deployment a stranger can reach.`);
1088
+ }
1089
+ const ownerConsent = ownerConsentPolicy();
1090
+ const versionTwo = versionTwoAdmission();
1091
+ console.log(versionTwo.managedAcceptance
1092
+ ? `${TOPIC} admits a version 2 TRANSFER only with its acceptance commitment (ACCEPTANCE_COMMITMENT=required, the managed-custody profile)`
1093
+ : `${TOPIC} admits a version 2 TRANSFER with or without an acceptance commitment (ACCEPTANCE_COMMITMENT is unset)`);
1094
+ if (versionTwo.controlAuthorities != null) {
1095
+ console.log(`${TOPIC} admits version 2 states by the control authorities ${versionTwo.controlAuthorities.join(', ')} without a control proof`);
1096
+ }
1097
+ if (ownerConsent === false) {
1098
+ console.log(`${TOPIC} admits any signed TRANSFER that spends the tip (OWNER_CONSENT is unset)`);
1099
+ }
1100
+ else {
1101
+ console.log(`${TOPIC} requires the owner-signed transfer on every TRANSFER` +
1102
+ (typeof ownerConsent === 'object'
1103
+ ? `, except by the transfer authorities ${ownerConsent.authorities.join(', ')}`
1104
+ : ', with no transfer authorities'));
1105
+ }
1106
+ const sync = syncSettingsFromEnvironment();
1107
+ if (sync.peers.length > 0) {
1108
+ console.log(`synchronising ${TOPIC} and ${ATTESTATION_TOPIC}${sync.legacy ? ` and ${UORA_TOPIC}` : ''} from ${sync.peers.join(', ')}` +
1109
+ (sync.intervalMs > 0 ? ` every ${sync.intervalMs} ms` : ' once, at startup'));
1110
+ }
1111
+ const lookupServices = {
1112
+ [SERVICE]: new DppLookupService(records),
1113
+ [ATTESTATION_SERVICE]: new AttestationLookupService(attestations),
1114
+ [UORA_SERVICE]: new UoraAnchorLookupService(anchors),
1115
+ };
1116
+ const engine = new Engine({
1117
+ // The engine's storage is handed to tm_dpp so a state whose BEEF omits
1118
+ // its admitted predecessor (a proven state announced alone, or one a
1119
+ // peer offers) is judged against the predecessor this index holds.
1120
+ [TOPIC]: new DppTopicManager(identityKey ?? '', { ownerConsent, ...versionTwo, publisherPolicy: publisherPolicy?.chain, admittedOutputs: engineStorage }),
1121
+ [ATTESTATION_TOPIC]: new AttestationTopicManager(acceptedAnchorServices, { publisherPolicy: publisherPolicy?.chain }),
1122
+ [UORA_TOPIC]: new UoraAnchorTopicManager(acceptedAnchorServices),
1123
+ }, lookupServices, engineStorage, tracker, process.env.PUBLIC_URL, undefined, // shipTrackers: no peer discovery
1124
+ undefined, // slapTrackers
1125
+ undefined, // broadcaster: the app's wallet already broadcast
1126
+ undefined, // advertiser: SHIP/SLAP would need a funded wallet here
1127
+ // Static peers per topic when SYNC_PEERS names them, false otherwise: no
1128
+ // GASP with anyone the operator did not name.
1129
+ syncConfigurationFor(sync, { passport: TOPIC, attestation: ATTESTATION_TOPIC, legacy: UORA_TOPIC }));
1130
+ const components = {
1131
+ records,
1132
+ engineStorage,
1133
+ lookupServices,
1134
+ publisherPolicy,
1135
+ serviceIdentityKey: identityKey,
1136
+ anchorServiceKeys: acceptedAnchorServices,
1137
+ ownerConsent,
1138
+ managedAcceptance: versionTwo.managedAcceptance,
1139
+ ...(versionTwo.controlAuthorities == null ? {} : { controlAuthorities: versionTwo.controlAuthorities }),
1140
+ sync: { peers: sync.peers, intervalMs: sync.intervalMs },
1141
+ };
1142
+ return { engine, components, close };
1143
+ }
1144
+ async function main() {
1145
+ const port = Number(process.env.PORT ?? 8080);
1146
+ const network = (process.env.NETWORK ?? 'main');
1147
+ const submitToken = process.env.SUBMIT_TOKEN;
1148
+ if (submitToken == null || submitToken === '') {
1149
+ console.warn('SUBMIT_TOKEN is unset: POST /submit and POST /retract accept requests from anyone. ' +
1150
+ 'Set it on any deployment a stranger can reach.');
1151
+ }
1152
+ const proofToken = process.env.ARC_CALLBACK_TOKEN;
1153
+ if (proofToken == null || proofToken === '') {
1154
+ console.warn('ARC_CALLBACK_TOKEN is unset: POST /arc-ingest accepts proofs from anyone. Each is still ' +
1155
+ 'verified against block headers before it is stored, so an open route costs header quota, ' +
1156
+ 'not truth; set it on any deployment a stranger can reach.');
1157
+ }
1158
+ const tracker = chainTracker(network);
1159
+ const knownOnChain = tracker === 'scripts only'
1160
+ ? undefined
1161
+ : whatsOnChainKnows(network, process.env.WOC_API_KEY);
1162
+ if (knownOnChain == null) {
1163
+ console.warn('CHAIN_TRACKER=scripts-only: POST /retract cannot ask the network about a transaction; its answers say so');
1164
+ }
1165
+ const signingKey = exportSigningKey();
1166
+ const exportToken = process.env.EXPORT_TOKEN;
1167
+ if (signingKey != null && (exportToken == null || exportToken === '')) {
1168
+ console.warn('EXPORT_TOKEN is unset: GET /evidence-export serves the complete export to anyone, one bounded part per request. ' +
1169
+ 'Set it on any deployment a stranger can reach; the bounded GET /evidence-package stays open either way.');
1170
+ }
1171
+ const { engine, components, close } = await engineFromEnvironment(network, tracker);
1172
+ const service = await startOverlayService(engine, {
1173
+ port,
1174
+ submitToken,
1175
+ proofToken,
1176
+ chainTracker: tracker,
1177
+ network,
1178
+ components,
1179
+ exportSigningKey: signingKey,
1180
+ exportToken,
1181
+ knownOnChain,
1182
+ });
1183
+ console.log(`dpp overlay listening on http://localhost:${service.port}`);
1184
+ console.log(`topics ${TOPIC}, ${ATTESTATION_TOPIC}; services ${SERVICE}, ${ATTESTATION_SERVICE}; legacy ${UORA_TOPIC}, ${UORA_SERVICE}`);
1185
+ console.log(`network ${network}`);
1186
+ // The first round after the socket is listening, so a peer that is also a
1187
+ // peer of ours can answer us; later rounds on the interval. Nothing awaits
1188
+ // a round: a peer that is down is a log line, not a stalled node.
1189
+ const synchronisation = components.sync != null && components.sync.peers.length > 0
1190
+ ? startPeerSynchronisation(engine, { intervalMs: components.sync.intervalMs, peers: components.sync.peers })
1191
+ : undefined;
1192
+ if (synchronisation != null)
1193
+ void synchronisation.runOnce();
1194
+ let stopping = false;
1195
+ for (const signal of ['SIGINT', 'SIGTERM']) {
1196
+ process.on(signal, () => {
1197
+ if (stopping)
1198
+ return;
1199
+ stopping = true;
1200
+ console.log(`${signal} received, draining`);
1201
+ synchronisation?.stop();
1202
+ void service
1203
+ .close()
1204
+ .then(close)
1205
+ .finally(() => process.exit(0));
1206
+ });
1207
+ }
1208
+ }
1209
+ /** Booted only when node runs this file; imported, it is a library. */
1210
+ function runningAsEntryPoint() {
1211
+ const entry = process.argv[1];
1212
+ if (entry == null)
1213
+ return false;
1214
+ try {
1215
+ return import.meta.url === pathToFileURL(realpathSync(entry)).href;
1216
+ }
1217
+ catch {
1218
+ return false;
1219
+ }
1220
+ }
1221
+ if (runningAsEntryPoint())
1222
+ await main();
1223
+ //# sourceMappingURL=index.js.map