@oxy.so/protocol 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +16 -0
  3. package/dist/cjs/.tsbuildinfo +1 -0
  4. package/dist/cjs/chain/continuity.js +54 -0
  5. package/dist/cjs/chain/engine.js +34 -0
  6. package/dist/cjs/chain/recordStore.js +25 -0
  7. package/dist/cjs/chain/types.js +22 -0
  8. package/dist/cjs/chain/verify.js +82 -0
  9. package/dist/cjs/envelope/canonicalJson.js +107 -0
  10. package/dist/cjs/envelope/recordId.js +60 -0
  11. package/dist/cjs/envelope/sign.js +75 -0
  12. package/dist/cjs/envelope/signingInput.js +32 -0
  13. package/dist/cjs/identity/resolver.js +50 -0
  14. package/dist/cjs/index.js +71 -0
  15. package/dist/cjs/node/constants.js +85 -0
  16. package/dist/cjs/node/didWebResolver.js +126 -0
  17. package/dist/cjs/node/httpFetch.js +61 -0
  18. package/dist/cjs/node/index.js +71 -0
  19. package/dist/cjs/node/nodeApp.js +344 -0
  20. package/dist/cjs/node/nodeClient.js +204 -0
  21. package/dist/cjs/node/rateLimit.js +187 -0
  22. package/dist/cjs/node/verifyRecord.js +51 -0
  23. package/dist/cjs/platform/crypto.js +186 -0
  24. package/dist/cjs/platform/crypto.native.js +204 -0
  25. package/dist/cjs/platform/expoTypes.js +24 -0
  26. package/dist/cjs/platform/platform.js +33 -0
  27. package/dist/cjs/secp256k1.js +148 -0
  28. package/dist/cjs/transparency/checkpoint.js +79 -0
  29. package/dist/cjs/transparency/tree.js +197 -0
  30. package/dist/esm/.tsbuildinfo +1 -0
  31. package/dist/esm/chain/continuity.js +51 -0
  32. package/dist/esm/chain/engine.js +31 -0
  33. package/dist/esm/chain/recordStore.js +24 -0
  34. package/dist/esm/chain/types.js +19 -0
  35. package/dist/esm/chain/verify.js +78 -0
  36. package/dist/esm/envelope/canonicalJson.js +104 -0
  37. package/dist/esm/envelope/recordId.js +56 -0
  38. package/dist/esm/envelope/sign.js +69 -0
  39. package/dist/esm/envelope/signingInput.js +29 -0
  40. package/dist/esm/identity/resolver.js +47 -0
  41. package/dist/esm/index.js +36 -0
  42. package/dist/esm/node/constants.js +82 -0
  43. package/dist/esm/node/didWebResolver.js +122 -0
  44. package/dist/esm/node/httpFetch.js +55 -0
  45. package/dist/esm/node/index.js +28 -0
  46. package/dist/esm/node/nodeApp.js +336 -0
  47. package/dist/esm/node/nodeClient.js +198 -0
  48. package/dist/esm/node/rateLimit.js +182 -0
  49. package/dist/esm/node/verifyRecord.js +48 -0
  50. package/dist/esm/platform/crypto.js +145 -0
  51. package/dist/esm/platform/crypto.native.js +196 -0
  52. package/dist/esm/platform/expoTypes.js +23 -0
  53. package/dist/esm/platform/platform.js +29 -0
  54. package/dist/esm/secp256k1.js +137 -0
  55. package/dist/esm/transparency/checkpoint.js +73 -0
  56. package/dist/esm/transparency/tree.js +189 -0
  57. package/dist/types/.tsbuildinfo +1 -0
  58. package/dist/types/chain/continuity.d.ts +28 -0
  59. package/dist/types/chain/engine.d.ts +27 -0
  60. package/dist/types/chain/recordStore.d.ts +85 -0
  61. package/dist/types/chain/types.d.ts +79 -0
  62. package/dist/types/chain/verify.d.ts +45 -0
  63. package/dist/types/envelope/canonicalJson.d.ts +44 -0
  64. package/dist/types/envelope/recordId.d.ts +30 -0
  65. package/dist/types/envelope/sign.d.ts +47 -0
  66. package/dist/types/envelope/signingInput.d.ts +33 -0
  67. package/dist/types/identity/resolver.d.ts +67 -0
  68. package/dist/types/index.d.ts +32 -0
  69. package/dist/types/node/constants.d.ts +80 -0
  70. package/dist/types/node/didWebResolver.d.ts +47 -0
  71. package/dist/types/node/httpFetch.d.ts +60 -0
  72. package/dist/types/node/index.d.ts +28 -0
  73. package/dist/types/node/nodeApp.d.ts +120 -0
  74. package/dist/types/node/nodeClient.d.ts +135 -0
  75. package/dist/types/node/rateLimit.d.ts +95 -0
  76. package/dist/types/node/verifyRecord.d.ts +41 -0
  77. package/dist/types/platform/crypto.d.ts +93 -0
  78. package/dist/types/platform/crypto.native.d.ts +77 -0
  79. package/dist/types/platform/expoTypes.d.ts +99 -0
  80. package/dist/types/platform/platform.d.ts +25 -0
  81. package/dist/types/secp256k1.d.ts +45 -0
  82. package/dist/types/transparency/checkpoint.d.ts +71 -0
  83. package/dist/types/transparency/tree.d.ts +135 -0
  84. package/package.json +157 -0
  85. package/src/__tests__/canonicalJson.test.ts +116 -0
  86. package/src/__tests__/chain.test.ts +279 -0
  87. package/src/__tests__/didWebResolver.test.ts +132 -0
  88. package/src/__tests__/envelope.test.ts +267 -0
  89. package/src/__tests__/nodeApp.test.ts +410 -0
  90. package/src/__tests__/nodeClient.test.ts +177 -0
  91. package/src/__tests__/nodeHarness.ts +151 -0
  92. package/src/__tests__/optionalNativePeers.test.ts +233 -0
  93. package/src/__tests__/rateLimit.test.ts +268 -0
  94. package/src/__tests__/runnerGuard.test.ts +85 -0
  95. package/src/__tests__/secp256k1.test.ts +118 -0
  96. package/src/__tests__/transparency.test.ts +353 -0
  97. package/src/chain/continuity.ts +59 -0
  98. package/src/chain/engine.ts +43 -0
  99. package/src/chain/recordStore.ts +98 -0
  100. package/src/chain/types.ts +85 -0
  101. package/src/chain/verify.ts +102 -0
  102. package/src/envelope/canonicalJson.ts +120 -0
  103. package/src/envelope/recordId.ts +63 -0
  104. package/src/envelope/sign.ts +86 -0
  105. package/src/envelope/signingInput.ts +48 -0
  106. package/src/identity/resolver.ts +90 -0
  107. package/src/index.ts +101 -0
  108. package/src/node/constants.ts +105 -0
  109. package/src/node/didWebResolver.ts +162 -0
  110. package/src/node/httpFetch.ts +88 -0
  111. package/src/node/index.ts +87 -0
  112. package/src/node/nodeApp.ts +471 -0
  113. package/src/node/nodeClient.ts +322 -0
  114. package/src/node/rateLimit.ts +233 -0
  115. package/src/node/verifyRecord.ts +60 -0
  116. package/src/platform/crypto.native.ts +251 -0
  117. package/src/platform/crypto.ts +172 -0
  118. package/src/platform/expoTypes.ts +99 -0
  119. package/src/platform/platform.ts +31 -0
  120. package/src/secp256k1.ts +207 -0
  121. package/src/transparency/checkpoint.ts +109 -0
  122. package/src/transparency/tree.ts +258 -0
@@ -0,0 +1,344 @@
1
+ "use strict";
2
+ /**
3
+ * `createNodeApp` — the app-agnostic Express factory for an Oxy-protocol data
4
+ * node. A node stores and serves ONE owner's append-only signed-record log
5
+ * (their "personal repo") plus the content-addressed blobs the records point at.
6
+ *
7
+ * This is the engine extracted from `@oxy.so/node` so the SAME code can back many
8
+ * app-node deployments (the Oxy identity node, a future Mention node) that
9
+ * differ only by ENV: the namespace they serve, their well-known manifest path,
10
+ * their advertised protocol id + service-type, and their owner key. Everything
11
+ * app-specific is INJECTED:
12
+ *
13
+ * - `store` — a {@link RecordStore} + {@link BlobStore} (the node's SQLite
14
+ * store, or a test stub). The node holds exactly one subject's
15
+ * repo, so the store keys a single global chain and ignores the
16
+ * subject argument; `createNodeApp` passes the node's own key as
17
+ * a stable sentinel.
18
+ * - `ownerAuth` — the single write authority. Records and blob pins are
19
+ * authorized against the node's configured owner key. This is
20
+ * injected (rather than importing `@oxy.so/core/server`) so the
21
+ * protocol package never depends on core.
22
+ * - `config` — the wire-shape knobs (well-known path, protocol id,
23
+ * service-type, mode, blob ceiling, collection allowlist).
24
+ * - `logger` — structured logging for the terminal error handler.
25
+ *
26
+ * Endpoints:
27
+ * - `GET <wellKnownPath>` — node identity + liveness (a probe target).
28
+ * - `GET /oxy/head` — chain head `{ seq, headRecordId, recordCount }`.
29
+ * - `GET /oxy/log` — ordered envelopes from a cursor (ingest).
30
+ * - `POST /records` — owner writes a single signed envelope.
31
+ * - `POST /sync/push` — owner pushes a batch of signed envelopes.
32
+ * - `GET /blobs/:hash` — serve a content-addressed blob.
33
+ * - `PUT /blobs/:hash` — owner pins a blob (signed-header auth).
34
+ * - `GET /health` — container liveness.
35
+ */
36
+ var __importDefault = (this && this.__importDefault) || function (mod) {
37
+ return (mod && mod.__esModule) ? mod : { "default": mod };
38
+ };
39
+ Object.defineProperty(exports, "__esModule", { value: true });
40
+ exports.BlobHashMismatchError = void 0;
41
+ exports.createNodeApp = createNodeApp;
42
+ const express_1 = __importDefault(require("express"));
43
+ const recordId_1 = require("../envelope/recordId");
44
+ const rateLimit_1 = require("./rateLimit");
45
+ const verifyRecord_1 = require("./verifyRecord");
46
+ const constants_1 = require("./constants");
47
+ /**
48
+ * Thrown by a {@link BlobStore.putBlob} implementation when bytes do not hash to
49
+ * the supplied address. Defined here (rather than in `@oxy.so/node`) so the node
50
+ * app can map it to `hash_mismatch` without importing the store implementation.
51
+ */
52
+ class BlobHashMismatchError extends Error {
53
+ constructor(expected, actual) {
54
+ super(`blob hash mismatch: expected ${expected}, computed ${actual}`);
55
+ this.expected = expected;
56
+ this.actual = actual;
57
+ this.name = 'BlobHashMismatchError';
58
+ }
59
+ }
60
+ exports.BlobHashMismatchError = BlobHashMismatchError;
61
+ /**
62
+ * Coerce a raw Express query value (`string | string[] | ParsedQs | undefined`)
63
+ * to a single string. A repeated/array param (`?since[]=a&since[]=b`) yields a
64
+ * non-string under qs; take its first string element so a tampered array can
65
+ * never reach a `typeof === 'string'` check as a non-string and cause type
66
+ * confusion. Returns `undefined` for anything that is not a string or a
67
+ * string-first array.
68
+ */
69
+ function firstQueryValue(raw) {
70
+ if (typeof raw === 'string') {
71
+ return raw;
72
+ }
73
+ if (Array.isArray(raw) && raw.length > 0 && typeof raw[0] === 'string') {
74
+ return raw[0];
75
+ }
76
+ return undefined;
77
+ }
78
+ /** Clamp the `limit` query param into `[1, MAX_LOG_LIMIT]`, defaulting when absent/invalid. */
79
+ function parseLimit(raw) {
80
+ const value = firstQueryValue(raw);
81
+ if (value === undefined || value.trim() === '') {
82
+ return constants_1.DEFAULT_LOG_LIMIT;
83
+ }
84
+ const parsed = Number(value);
85
+ if (!Number.isInteger(parsed) || parsed <= 0) {
86
+ return constants_1.DEFAULT_LOG_LIMIT;
87
+ }
88
+ return Math.min(parsed, constants_1.MAX_LOG_LIMIT);
89
+ }
90
+ /** HTTP status for a chain-append rejection. */
91
+ function appendStatus(reason) {
92
+ return reason === 'chain_conflict' ? 409 : 422;
93
+ }
94
+ /** True when `collection` is writable under the node's allowlist (empty = all). */
95
+ function isCollectionAllowed(config, collection) {
96
+ return config.collections.length === 0 || config.collections.includes(collection);
97
+ }
98
+ /**
99
+ * Resolve the `/oxy/log` `since` query param to an exclusive lower-bound `seq`.
100
+ * Returns `null` when the page should be EMPTY (an unknown `recordId` cursor or
101
+ * an unrecognized cursor shape). Mirrors the legacy in-store cursor resolver,
102
+ * now split across the protocol `RecordStore` (numeric `getLogSince` +
103
+ * `resolveCursorSeq`).
104
+ */
105
+ async function resolveSinceSeq(store, subject, since) {
106
+ if (since === undefined || since === '') {
107
+ return -1;
108
+ }
109
+ const lower = since.toLowerCase();
110
+ if (constants_1.SHA256_HEX.test(lower)) {
111
+ return store.resolveCursorSeq(subject, lower);
112
+ }
113
+ if (/^\d+$/.test(since)) {
114
+ const parsed = Number(since);
115
+ return Number.isSafeInteger(parsed) ? parsed : -1;
116
+ }
117
+ return null;
118
+ }
119
+ /** Map a stored envelope to the `/oxy/log` wire record. */
120
+ async function toLogWireRecord(env) {
121
+ return {
122
+ seq: env.seq ?? 0,
123
+ recordId: await (0, recordId_1.computeRecordId)(env),
124
+ prev: env.prev ?? null,
125
+ issuedAt: env.issuedAt,
126
+ envelope: env,
127
+ };
128
+ }
129
+ function createNodeApp(deps) {
130
+ const { store, config, ownerAuth, logger } = deps;
131
+ // The node holds one subject's repo; the store keys a single global chain and
132
+ // ignores the subject argument. Pass the node's own key as a stable sentinel.
133
+ const subject = config.nodePublicKey;
134
+ const app = (0, express_1.default)();
135
+ app.disable('x-powered-by');
136
+ // JSON parser applies only to JSON bodies (it checks Content-Type), so the raw
137
+ // blob upload below is untouched by it.
138
+ const jsonParser = express_1.default.json({ limit: constants_1.JSON_BODY_LIMIT });
139
+ // Per-IP rate limiter on the owner-authorized write routes. Caps request rate
140
+ // BEFORE signature verification so a flood of bogus envelopes can't pin CPU.
141
+ // It owns a background sweep timer; expose its teardown as `app.stop()`.
142
+ const writeRateLimit = (0, rateLimit_1.createRateLimiter)(config.writeRateLimit ?? rateLimit_1.DEFAULT_WRITE_RATE_LIMIT);
143
+ app.stop = () => writeRateLimit.stop();
144
+ app.get('/health', (_req, res) => {
145
+ res.json({ status: 'ok' });
146
+ });
147
+ // ── Node identity / liveness ────────────────────────────────────────────────
148
+ app.get(config.wellKnownPath, async (_req, res, next) => {
149
+ try {
150
+ const head = await store.getHead(subject);
151
+ res.json({
152
+ nodePublicKey: config.nodePublicKey,
153
+ mode: config.mode,
154
+ version: config.protocolId,
155
+ serviceType: config.serviceType,
156
+ head: head && head.headRecordId !== null ? { seq: head.seq, headRecordId: head.headRecordId } : null,
157
+ });
158
+ }
159
+ catch (error) {
160
+ next(error);
161
+ }
162
+ });
163
+ // ── Chain head ──────────────────────────────────────────────────────────────
164
+ app.get(constants_1.NODE_HEAD_PATH, async (_req, res, next) => {
165
+ try {
166
+ const head = await store.getHead(subject);
167
+ if (!head || head.headRecordId === null) {
168
+ res.json({ seq: null, headRecordId: null, recordCount: 0 });
169
+ return;
170
+ }
171
+ res.json({ seq: head.seq, headRecordId: head.headRecordId, recordCount: head.recordCount });
172
+ }
173
+ catch (error) {
174
+ next(error);
175
+ }
176
+ });
177
+ // ── Ordered log (ingest) ──────────────────────────────────────────────────────
178
+ app.get(constants_1.NODE_LOG_PATH, async (req, res, next) => {
179
+ try {
180
+ const since = firstQueryValue(req.query.since);
181
+ const limit = parseLimit(req.query.limit);
182
+ const head = await store.getHead(subject);
183
+ const headWire = head && head.headRecordId !== null ? { seq: head.seq, headRecordId: head.headRecordId } : null;
184
+ const sinceSeq = await resolveSinceSeq(store, subject, since);
185
+ if (sinceSeq === null) {
186
+ res.json({ records: [], count: 0, head: headWire });
187
+ return;
188
+ }
189
+ const envelopes = await store.getLogSince(subject, sinceSeq, limit);
190
+ const records = await Promise.all(envelopes.map(toLogWireRecord));
191
+ res.json({ records, count: records.length, head: headWire });
192
+ }
193
+ catch (error) {
194
+ next(error);
195
+ }
196
+ });
197
+ // ── Owner write: a single signed envelope ────────────────────────────────────
198
+ app.post(constants_1.NODE_RECORDS_PATH, writeRateLimit, jsonParser, async (req, res, next) => {
199
+ try {
200
+ const verification = await (0, verifyRecord_1.verifyNodeRecordEnvelope)(req.body);
201
+ if (!verification.ok) {
202
+ res.status(400).json({ error: verification.reason });
203
+ return;
204
+ }
205
+ if (!ownerAuth.isOwnerKey(verification.envelope.publicKey)) {
206
+ res.status(403).json({ error: 'not_owner' });
207
+ return;
208
+ }
209
+ if (!isCollectionAllowed(config, verification.envelope.collection ?? '')) {
210
+ res.status(403).json({ error: 'foreign_collection' });
211
+ return;
212
+ }
213
+ const outcome = await store.append(subject, verification.envelope, verification.recordId);
214
+ if (!outcome.ok) {
215
+ res.status(appendStatus(outcome.reason)).json({ error: outcome.reason });
216
+ return;
217
+ }
218
+ res.status(201).json({ recordId: outcome.recordId, seq: outcome.seq });
219
+ }
220
+ catch (error) {
221
+ next(error);
222
+ }
223
+ });
224
+ // ── Owner write: a batch push (verified + appended in order) ──────────────────
225
+ app.post(constants_1.NODE_SYNC_PUSH_PATH, writeRateLimit, jsonParser, async (req, res, next) => {
226
+ try {
227
+ const body = req.body;
228
+ const items = typeof body === 'object' && body !== null && Array.isArray(body.records)
229
+ ? body.records
230
+ : null;
231
+ if (!items) {
232
+ res.status(400).json({ error: 'invalid_batch' });
233
+ return;
234
+ }
235
+ if (items.length > constants_1.MAX_SYNC_BATCH) {
236
+ res.status(400).json({ error: 'batch_too_large' });
237
+ return;
238
+ }
239
+ const results = [];
240
+ for (const item of items) {
241
+ const verification = await (0, verifyRecord_1.verifyNodeRecordEnvelope)(item);
242
+ if (!verification.ok) {
243
+ results.push({ ok: false, reason: verification.reason });
244
+ continue;
245
+ }
246
+ if (!ownerAuth.isOwnerKey(verification.envelope.publicKey)) {
247
+ results.push({ ok: false, reason: 'not_owner' });
248
+ continue;
249
+ }
250
+ if (!isCollectionAllowed(config, verification.envelope.collection ?? '')) {
251
+ results.push({ ok: false, reason: 'foreign_collection' });
252
+ continue;
253
+ }
254
+ const outcome = await store.append(subject, verification.envelope, verification.recordId);
255
+ results.push(outcome.ok
256
+ ? { ok: true, recordId: outcome.recordId, seq: outcome.seq }
257
+ : { ok: false, reason: outcome.reason });
258
+ }
259
+ const accepted = results.filter((result) => result.ok).length;
260
+ res.json({ accepted, results });
261
+ }
262
+ catch (error) {
263
+ next(error);
264
+ }
265
+ });
266
+ // ── Serve a content-addressed blob ───────────────────────────────────────────
267
+ app.get(`${constants_1.NODE_BLOBS_PATH}/:hash`, async (req, res, next) => {
268
+ try {
269
+ const hash = req.params.hash.toLowerCase();
270
+ if (!constants_1.SHA256_HEX.test(hash)) {
271
+ res.status(400).json({ error: 'invalid_hash' });
272
+ return;
273
+ }
274
+ const bytes = await store.getBlob(hash);
275
+ if (!bytes) {
276
+ res.status(404).json({ error: 'not_found' });
277
+ return;
278
+ }
279
+ res.setHeader('Content-Type', 'application/octet-stream');
280
+ // Content-addressed → immutable, safe to cache aggressively.
281
+ res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
282
+ res.send(Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes));
283
+ }
284
+ catch (error) {
285
+ next(error);
286
+ }
287
+ });
288
+ // ── Owner pins a blob (signed-header authorization) ──────────────────────────
289
+ app.put(`${constants_1.NODE_BLOBS_PATH}/:hash`, writeRateLimit, express_1.default.raw({ type: () => true, limit: config.maxBlobBytes }), async (req, res, next) => {
290
+ try {
291
+ const rawHash = req.params.hash;
292
+ if (typeof rawHash !== 'string') {
293
+ res.status(400).json({ error: 'invalid_hash' });
294
+ return;
295
+ }
296
+ const hash = rawHash.toLowerCase();
297
+ if (!constants_1.SHA256_HEX.test(hash)) {
298
+ res.status(400).json({ error: 'invalid_hash' });
299
+ return;
300
+ }
301
+ const publicKey = req.header(constants_1.OWNER_AUTH_HEADERS.publicKey);
302
+ const signature = req.header(constants_1.OWNER_AUTH_HEADERS.signature);
303
+ const timestampRaw = req.header(constants_1.OWNER_AUTH_HEADERS.timestamp);
304
+ if (!publicKey || !signature || !timestampRaw) {
305
+ res.status(401).json({ error: 'missing_owner_auth' });
306
+ return;
307
+ }
308
+ const authorized = await ownerAuth.verifyBlobPin(hash, {
309
+ publicKey,
310
+ signature,
311
+ timestamp: Number(timestampRaw),
312
+ });
313
+ if (!authorized) {
314
+ res.status(403).json({ error: 'unauthorized' });
315
+ return;
316
+ }
317
+ const bytes = req.body;
318
+ if (!Buffer.isBuffer(bytes) || bytes.length === 0) {
319
+ res.status(400).json({ error: 'empty_blob' });
320
+ return;
321
+ }
322
+ try {
323
+ await store.putBlob(hash, bytes);
324
+ }
325
+ catch (error) {
326
+ if (error instanceof BlobHashMismatchError) {
327
+ res.status(400).json({ error: 'hash_mismatch' });
328
+ return;
329
+ }
330
+ throw error;
331
+ }
332
+ res.status(201).json({ hash, size: bytes.length });
333
+ }
334
+ catch (error) {
335
+ next(error);
336
+ }
337
+ });
338
+ // ── Terminal error handler ───────────────────────────────────────────────────
339
+ app.use((error, _req, res, _next) => {
340
+ logger.error({ err: error }, 'unhandled request error');
341
+ res.status(500).json({ error: 'internal_error' });
342
+ });
343
+ return app;
344
+ }
@@ -0,0 +1,204 @@
1
+ "use strict";
2
+ /**
3
+ * `NodeClient` — the HTTP client that drives an Oxy-protocol data node's
4
+ * routes (head / log / records / blobs). It is the OUTBOUND half of the node
5
+ * protocol: oxy-api uses it to PULL a user's chain back from their node; a
6
+ * future Mention backend (B3) uses it to drive a node + push records/blobs.
7
+ *
8
+ * The client is transport-agnostic — it takes an injected {@link NodeFetch} so
9
+ * the protocol package never depends on `@oxy.so/core`. Oxy supplies an adapter
10
+ * over `@oxy.so/core/server`'s `safeFetch` (HTTPS-only, DNS-pinned, private-IP
11
+ * denylist, bounded redirects); a test supplies an in-process stub. Every
12
+ * response body is read with a hard byte ceiling, so a node cannot stream an
13
+ * unbounded body into the caller.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.NodeClient = exports.NodeClientError = void 0;
17
+ exports.trimTrailingSlashes = trimTrailingSlashes;
18
+ const httpFetch_1 = require("./httpFetch");
19
+ const constants_1 = require("./constants");
20
+ /** A non-2xx node response (or a node that returned a malformed body). */
21
+ class NodeClientError extends Error {
22
+ constructor(message, status, reason) {
23
+ super(message);
24
+ this.status = status;
25
+ this.reason = reason;
26
+ this.name = 'NodeClientError';
27
+ }
28
+ }
29
+ exports.NodeClientError = NodeClientError;
30
+ function readError(body) {
31
+ if (typeof body === 'object' && body !== null && typeof body.error === 'string') {
32
+ return body.error;
33
+ }
34
+ return undefined;
35
+ }
36
+ /**
37
+ * Trim every trailing slash from a base URL in LINEAR time.
38
+ *
39
+ * Replaces an anchored-quantifier regex (`/\/+$/`) whose backtracking is a
40
+ * polynomial-ReDoS sink on a long all-slash input; a single-pass scan is O(n)
41
+ * with no ReDoS surface.
42
+ */
43
+ function trimTrailingSlashes(value) {
44
+ let end = value.length;
45
+ while (end > 0 && value.charCodeAt(end - 1) === 47 /* '/' */) {
46
+ end -= 1;
47
+ }
48
+ return end === value.length ? value : value.slice(0, end);
49
+ }
50
+ class NodeClient {
51
+ constructor(options) {
52
+ this.baseUrl = trimTrailingSlashes(options.baseUrl);
53
+ this.fetch = options.fetch;
54
+ this.headersTimeoutMs = options.headersTimeoutMs ?? constants_1.DEFAULT_CLIENT_TIMEOUT_MS;
55
+ this.maxRedirects = options.maxRedirects ?? constants_1.DEFAULT_CLIENT_MAX_REDIRECTS;
56
+ this.headMaxBytes = options.headMaxBytes ?? constants_1.DEFAULT_HEAD_MAX_BYTES;
57
+ this.logMaxBytes = options.logMaxBytes ?? constants_1.DEFAULT_LOG_MAX_BYTES;
58
+ this.writeResponseMaxBytes = options.writeResponseMaxBytes ?? constants_1.DEFAULT_WRITE_RESPONSE_MAX_BYTES;
59
+ this.blobMaxBytes = options.blobMaxBytes ?? constants_1.DEFAULT_MAX_BLOB_BYTES;
60
+ }
61
+ /** Base request options shared by every call (timeout + redirect budget). */
62
+ init(extra) {
63
+ return {
64
+ headersTimeoutMs: this.headersTimeoutMs,
65
+ maxRedirects: this.maxRedirects,
66
+ ...extra,
67
+ };
68
+ }
69
+ /** The node's current chain head. Throws {@link NodeClientError} on a non-2xx. */
70
+ async head() {
71
+ const res = await this.fetch(`${this.baseUrl}${constants_1.NODE_HEAD_PATH}`, this.init({ method: 'GET' }));
72
+ if (res.status < 200 || res.status >= 300) {
73
+ res.destroy();
74
+ throw new NodeClientError(`node ${constants_1.NODE_HEAD_PATH} responded HTTP ${res.status}`, res.status);
75
+ }
76
+ const body = await (0, httpFetch_1.readBoundedJson)(res, this.headMaxBytes);
77
+ const obj = (typeof body === 'object' && body !== null ? body : {});
78
+ return {
79
+ seq: typeof obj.seq === 'number' ? obj.seq : null,
80
+ headRecordId: typeof obj.headRecordId === 'string' ? obj.headRecordId : null,
81
+ recordCount: typeof obj.recordCount === 'number' ? obj.recordCount : 0,
82
+ };
83
+ }
84
+ /**
85
+ * One ordered page of the node's log strictly after `sinceSeq` (pass `-1` from
86
+ * genesis), capped at `limit`. Throws {@link NodeClientError} on a non-2xx or a
87
+ * response missing the `records` array.
88
+ */
89
+ async log(sinceSeq, limit) {
90
+ // A genesis cursor (`sinceSeq < 0`) is expressed by OMITTING `since` — the
91
+ // node reads an absent cursor as "from genesis". A negative numeric `since`
92
+ // is not a valid cursor on the wire (only an absent one, a non-negative seq,
93
+ // or a recordId), so omitting it is the correct way to request the whole log.
94
+ const sinceParam = sinceSeq >= 0 ? `since=${encodeURIComponent(String(sinceSeq))}&` : '';
95
+ const url = `${this.baseUrl}${constants_1.NODE_LOG_PATH}?${sinceParam}limit=${encodeURIComponent(String(limit))}`;
96
+ const res = await this.fetch(url, this.init({ method: 'GET' }));
97
+ if (res.status < 200 || res.status >= 300) {
98
+ res.destroy();
99
+ throw new NodeClientError(`node ${constants_1.NODE_LOG_PATH} responded HTTP ${res.status}`, res.status);
100
+ }
101
+ const body = await (0, httpFetch_1.readBoundedJson)(res, this.logMaxBytes);
102
+ const records = body.records;
103
+ if (!Array.isArray(records)) {
104
+ throw new NodeClientError(`node ${constants_1.NODE_LOG_PATH} returned no records array`, res.status);
105
+ }
106
+ const headRaw = body.head;
107
+ const head = typeof headRaw === 'object' &&
108
+ headRaw !== null &&
109
+ typeof headRaw.seq === 'number' &&
110
+ typeof headRaw.headRecordId === 'string'
111
+ ? { seq: headRaw.seq, headRecordId: headRaw.headRecordId }
112
+ : null;
113
+ return { records, count: records.length, head };
114
+ }
115
+ /**
116
+ * Write a single owner-signed envelope (`POST /records`). Throws
117
+ * {@link NodeClientError} (carrying the node's `reason`) on any non-2xx — a
118
+ * chain rejection (`chain_gap`/`chain_fork`/`bad_seq`/`chain_conflict`) or an
119
+ * authorization failure.
120
+ */
121
+ async writeRecord(envelope) {
122
+ const res = await this.fetch(`${this.baseUrl}${constants_1.NODE_RECORDS_PATH}`, this.init({
123
+ method: 'POST',
124
+ headers: { 'Content-Type': 'application/json' },
125
+ body: Buffer.from(JSON.stringify(envelope), 'utf8'),
126
+ }));
127
+ const body = await (0, httpFetch_1.readBoundedJson)(res, this.writeResponseMaxBytes);
128
+ if (res.status < 200 || res.status >= 300) {
129
+ const reason = readError(body);
130
+ throw new NodeClientError(`node ${constants_1.NODE_RECORDS_PATH} responded HTTP ${res.status}${reason ? ` (${reason})` : ''}`, res.status, reason);
131
+ }
132
+ const obj = body;
133
+ if (typeof obj.recordId !== 'string' || typeof obj.seq !== 'number') {
134
+ throw new NodeClientError(`node ${constants_1.NODE_RECORDS_PATH} returned a malformed write result`, res.status);
135
+ }
136
+ return { recordId: obj.recordId, seq: obj.seq };
137
+ }
138
+ /**
139
+ * Push a batch of owner-signed envelopes (`POST /sync/push`). Returns the
140
+ * node's per-item results. Throws {@link NodeClientError} only on a non-2xx
141
+ * batch-level failure (`invalid_batch` / `batch_too_large`).
142
+ */
143
+ async pushRecords(envelopes) {
144
+ const res = await this.fetch(`${this.baseUrl}${constants_1.NODE_SYNC_PUSH_PATH}`, this.init({
145
+ method: 'POST',
146
+ headers: { 'Content-Type': 'application/json' },
147
+ body: Buffer.from(JSON.stringify({ records: envelopes }), 'utf8'),
148
+ }));
149
+ const body = await (0, httpFetch_1.readBoundedJson)(res, this.writeResponseMaxBytes);
150
+ if (res.status < 200 || res.status >= 300) {
151
+ const reason = readError(body);
152
+ throw new NodeClientError(`node ${constants_1.NODE_SYNC_PUSH_PATH} responded HTTP ${res.status}${reason ? ` (${reason})` : ''}`, res.status, reason);
153
+ }
154
+ const obj = body;
155
+ return {
156
+ accepted: typeof obj.accepted === 'number' ? obj.accepted : 0,
157
+ results: Array.isArray(obj.results)
158
+ ? obj.results
159
+ : [],
160
+ };
161
+ }
162
+ /** Fetch a content-addressed blob. Returns `null` on a 404; throws on other non-2xx. */
163
+ async getBlob(hash) {
164
+ const res = await this.fetch(`${this.baseUrl}${constants_1.NODE_BLOBS_PATH}/${encodeURIComponent(hash)}`, this.init({ method: 'GET' }));
165
+ if (res.status === 404) {
166
+ res.destroy();
167
+ return null;
168
+ }
169
+ if (res.status < 200 || res.status >= 300) {
170
+ res.destroy();
171
+ throw new NodeClientError(`node ${constants_1.NODE_BLOBS_PATH}/:hash responded HTTP ${res.status}`, res.status);
172
+ }
173
+ return (0, httpFetch_1.readBoundedBytes)(res, this.blobMaxBytes);
174
+ }
175
+ /**
176
+ * Pin a content-addressed blob with an owner-signed authorization
177
+ * (`PUT /blobs/:hash`). The caller signs the pin (it holds the owner key) and
178
+ * passes the resulting `{ publicKey, signature, timestamp }`; the client sets
179
+ * the owner-auth headers. Throws {@link NodeClientError} on a non-2xx.
180
+ */
181
+ async putBlob(hash, bytes, auth) {
182
+ const res = await this.fetch(`${this.baseUrl}${constants_1.NODE_BLOBS_PATH}/${encodeURIComponent(hash)}`, this.init({
183
+ method: 'PUT',
184
+ headers: {
185
+ 'Content-Type': 'application/octet-stream',
186
+ [constants_1.OWNER_AUTH_HEADERS.publicKey]: auth.publicKey,
187
+ [constants_1.OWNER_AUTH_HEADERS.signature]: auth.signature,
188
+ [constants_1.OWNER_AUTH_HEADERS.timestamp]: String(auth.timestamp),
189
+ },
190
+ body: bytes,
191
+ }));
192
+ const body = await (0, httpFetch_1.readBoundedJson)(res, this.writeResponseMaxBytes);
193
+ if (res.status < 200 || res.status >= 300) {
194
+ const reason = readError(body);
195
+ throw new NodeClientError(`node ${constants_1.NODE_BLOBS_PATH}/:hash responded HTTP ${res.status}${reason ? ` (${reason})` : ''}`, res.status, reason);
196
+ }
197
+ const obj = body;
198
+ if (typeof obj.hash !== 'string' || typeof obj.size !== 'number') {
199
+ throw new NodeClientError(`node ${constants_1.NODE_BLOBS_PATH}/:hash returned a malformed pin result`, res.status);
200
+ }
201
+ return { hash: obj.hash, size: obj.size };
202
+ }
203
+ }
204
+ exports.NodeClient = NodeClient;