@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.
- package/LICENSE +202 -0
- package/NOTICE +16 -0
- package/dist/cjs/.tsbuildinfo +1 -0
- package/dist/cjs/chain/continuity.js +54 -0
- package/dist/cjs/chain/engine.js +34 -0
- package/dist/cjs/chain/recordStore.js +25 -0
- package/dist/cjs/chain/types.js +22 -0
- package/dist/cjs/chain/verify.js +82 -0
- package/dist/cjs/envelope/canonicalJson.js +107 -0
- package/dist/cjs/envelope/recordId.js +60 -0
- package/dist/cjs/envelope/sign.js +75 -0
- package/dist/cjs/envelope/signingInput.js +32 -0
- package/dist/cjs/identity/resolver.js +50 -0
- package/dist/cjs/index.js +71 -0
- package/dist/cjs/node/constants.js +85 -0
- package/dist/cjs/node/didWebResolver.js +126 -0
- package/dist/cjs/node/httpFetch.js +61 -0
- package/dist/cjs/node/index.js +71 -0
- package/dist/cjs/node/nodeApp.js +344 -0
- package/dist/cjs/node/nodeClient.js +204 -0
- package/dist/cjs/node/rateLimit.js +187 -0
- package/dist/cjs/node/verifyRecord.js +51 -0
- package/dist/cjs/platform/crypto.js +186 -0
- package/dist/cjs/platform/crypto.native.js +204 -0
- package/dist/cjs/platform/expoTypes.js +24 -0
- package/dist/cjs/platform/platform.js +33 -0
- package/dist/cjs/secp256k1.js +148 -0
- package/dist/cjs/transparency/checkpoint.js +79 -0
- package/dist/cjs/transparency/tree.js +197 -0
- package/dist/esm/.tsbuildinfo +1 -0
- package/dist/esm/chain/continuity.js +51 -0
- package/dist/esm/chain/engine.js +31 -0
- package/dist/esm/chain/recordStore.js +24 -0
- package/dist/esm/chain/types.js +19 -0
- package/dist/esm/chain/verify.js +78 -0
- package/dist/esm/envelope/canonicalJson.js +104 -0
- package/dist/esm/envelope/recordId.js +56 -0
- package/dist/esm/envelope/sign.js +69 -0
- package/dist/esm/envelope/signingInput.js +29 -0
- package/dist/esm/identity/resolver.js +47 -0
- package/dist/esm/index.js +36 -0
- package/dist/esm/node/constants.js +82 -0
- package/dist/esm/node/didWebResolver.js +122 -0
- package/dist/esm/node/httpFetch.js +55 -0
- package/dist/esm/node/index.js +28 -0
- package/dist/esm/node/nodeApp.js +336 -0
- package/dist/esm/node/nodeClient.js +198 -0
- package/dist/esm/node/rateLimit.js +182 -0
- package/dist/esm/node/verifyRecord.js +48 -0
- package/dist/esm/platform/crypto.js +145 -0
- package/dist/esm/platform/crypto.native.js +196 -0
- package/dist/esm/platform/expoTypes.js +23 -0
- package/dist/esm/platform/platform.js +29 -0
- package/dist/esm/secp256k1.js +137 -0
- package/dist/esm/transparency/checkpoint.js +73 -0
- package/dist/esm/transparency/tree.js +189 -0
- package/dist/types/.tsbuildinfo +1 -0
- package/dist/types/chain/continuity.d.ts +28 -0
- package/dist/types/chain/engine.d.ts +27 -0
- package/dist/types/chain/recordStore.d.ts +85 -0
- package/dist/types/chain/types.d.ts +79 -0
- package/dist/types/chain/verify.d.ts +45 -0
- package/dist/types/envelope/canonicalJson.d.ts +44 -0
- package/dist/types/envelope/recordId.d.ts +30 -0
- package/dist/types/envelope/sign.d.ts +47 -0
- package/dist/types/envelope/signingInput.d.ts +33 -0
- package/dist/types/identity/resolver.d.ts +67 -0
- package/dist/types/index.d.ts +32 -0
- package/dist/types/node/constants.d.ts +80 -0
- package/dist/types/node/didWebResolver.d.ts +47 -0
- package/dist/types/node/httpFetch.d.ts +60 -0
- package/dist/types/node/index.d.ts +28 -0
- package/dist/types/node/nodeApp.d.ts +120 -0
- package/dist/types/node/nodeClient.d.ts +135 -0
- package/dist/types/node/rateLimit.d.ts +95 -0
- package/dist/types/node/verifyRecord.d.ts +41 -0
- package/dist/types/platform/crypto.d.ts +93 -0
- package/dist/types/platform/crypto.native.d.ts +77 -0
- package/dist/types/platform/expoTypes.d.ts +99 -0
- package/dist/types/platform/platform.d.ts +25 -0
- package/dist/types/secp256k1.d.ts +45 -0
- package/dist/types/transparency/checkpoint.d.ts +71 -0
- package/dist/types/transparency/tree.d.ts +135 -0
- package/package.json +157 -0
- package/src/__tests__/canonicalJson.test.ts +116 -0
- package/src/__tests__/chain.test.ts +279 -0
- package/src/__tests__/didWebResolver.test.ts +132 -0
- package/src/__tests__/envelope.test.ts +267 -0
- package/src/__tests__/nodeApp.test.ts +410 -0
- package/src/__tests__/nodeClient.test.ts +177 -0
- package/src/__tests__/nodeHarness.ts +151 -0
- package/src/__tests__/optionalNativePeers.test.ts +233 -0
- package/src/__tests__/rateLimit.test.ts +268 -0
- package/src/__tests__/runnerGuard.test.ts +85 -0
- package/src/__tests__/secp256k1.test.ts +118 -0
- package/src/__tests__/transparency.test.ts +353 -0
- package/src/chain/continuity.ts +59 -0
- package/src/chain/engine.ts +43 -0
- package/src/chain/recordStore.ts +98 -0
- package/src/chain/types.ts +85 -0
- package/src/chain/verify.ts +102 -0
- package/src/envelope/canonicalJson.ts +120 -0
- package/src/envelope/recordId.ts +63 -0
- package/src/envelope/sign.ts +86 -0
- package/src/envelope/signingInput.ts +48 -0
- package/src/identity/resolver.ts +90 -0
- package/src/index.ts +101 -0
- package/src/node/constants.ts +105 -0
- package/src/node/didWebResolver.ts +162 -0
- package/src/node/httpFetch.ts +88 -0
- package/src/node/index.ts +87 -0
- package/src/node/nodeApp.ts +471 -0
- package/src/node/nodeClient.ts +322 -0
- package/src/node/rateLimit.ts +233 -0
- package/src/node/verifyRecord.ts +60 -0
- package/src/platform/crypto.native.ts +251 -0
- package/src/platform/crypto.ts +172 -0
- package/src/platform/expoTypes.ts +99 -0
- package/src/platform/platform.ts +31 -0
- package/src/secp256k1.ts +207 -0
- package/src/transparency/checkpoint.ts +109 -0
- 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;
|