@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.
- package/LICENSE +58 -0
- package/README.md +169 -0
- package/dist/anchorStorage.d.ts +58 -0
- package/dist/anchorStorage.d.ts.map +1 -0
- package/dist/anchorStorage.js +72 -0
- package/dist/anchorStorage.js.map +1 -0
- package/dist/attestationAnchor.d.ts +10 -0
- package/dist/attestationAnchor.d.ts.map +1 -0
- package/dist/attestationAnchor.js +10 -0
- package/dist/attestationAnchor.js.map +1 -0
- package/dist/attestationStorage.d.ts +49 -0
- package/dist/attestationStorage.d.ts.map +1 -0
- package/dist/attestationStorage.js +78 -0
- package/dist/attestationStorage.js.map +1 -0
- package/dist/capabilities.d.ts +127 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +210 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/engineStorage.d.ts +106 -0
- package/dist/engineStorage.d.ts.map +1 -0
- package/dist/engineStorage.js +253 -0
- package/dist/engineStorage.js.map +1 -0
- package/dist/evidenceExport.d.ts +189 -0
- package/dist/evidenceExport.d.ts.map +1 -0
- package/dist/evidenceExport.js +517 -0
- package/dist/evidenceExport.js.map +1 -0
- package/dist/history.d.ts +114 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +215 -0
- package/dist/history.js.map +1 -0
- package/dist/index.d.ts +269 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1223 -0
- package/dist/index.js.map +1 -0
- package/dist/lib.d.ts +31 -0
- package/dist/lib.d.ts.map +1 -0
- package/dist/lib.js +31 -0
- package/dist/lib.js.map +1 -0
- package/dist/limits.d.ts +44 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +44 -0
- package/dist/limits.js.map +1 -0
- package/dist/lsAttestation.d.ts +21 -0
- package/dist/lsAttestation.d.ts.map +1 -0
- package/dist/lsAttestation.js +45 -0
- package/dist/lsAttestation.js.map +1 -0
- package/dist/lsDpp.d.ts +53 -0
- package/dist/lsDpp.d.ts.map +1 -0
- package/dist/lsDpp.js +148 -0
- package/dist/lsDpp.js.map +1 -0
- package/dist/lsUoraDpp.d.ts +55 -0
- package/dist/lsUoraDpp.d.ts.map +1 -0
- package/dist/lsUoraDpp.js +152 -0
- package/dist/lsUoraDpp.js.map +1 -0
- package/dist/policyConfig.d.ts +31 -0
- package/dist/policyConfig.d.ts.map +1 -0
- package/dist/policyConfig.js +112 -0
- package/dist/policyConfig.js.map +1 -0
- package/dist/retraction.d.ts +41 -0
- package/dist/retraction.d.ts.map +1 -0
- package/dist/retraction.js +147 -0
- package/dist/retraction.js.map +1 -0
- package/dist/storage.d.ts +117 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +158 -0
- package/dist/storage.js.map +1 -0
- package/dist/sync.d.ts +75 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +118 -0
- package/dist/sync.js.map +1 -0
- package/dist/tmAttestation.d.ts +36 -0
- package/dist/tmAttestation.d.ts.map +1 -0
- package/dist/tmAttestation.js +50 -0
- package/dist/tmAttestation.js.map +1 -0
- package/dist/tmDpp.d.ts +154 -0
- package/dist/tmDpp.d.ts.map +1 -0
- package/dist/tmDpp.js +397 -0
- package/dist/tmDpp.js.map +1 -0
- package/dist/tmUoraDpp.d.ts +73 -0
- package/dist/tmUoraDpp.d.ts.map +1 -0
- package/dist/tmUoraDpp.js +150 -0
- package/dist/tmUoraDpp.js.map +1 -0
- package/dist/uoraAnchor.d.ts +209 -0
- package/dist/uoraAnchor.d.ts.map +1 -0
- package/dist/uoraAnchor.js +433 -0
- package/dist/uoraAnchor.js.map +1 -0
- 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
|