@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,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createDidWebResolver` — a {@link VerificationMethodResolver} for arbitrary
|
|
3
|
+
* `did:web` subjects, backing the chain engine on multi-subject relay/ingest
|
|
4
|
+
* paths (where the signer is NOT a local account but any DID that publishes a
|
|
5
|
+
* DID document).
|
|
6
|
+
*
|
|
7
|
+
* It resolves a `did:web:<host>[:<path>]` subject to its `did.json`, fetched via
|
|
8
|
+
* an INJECTED {@link NodeFetch} — Oxy passes an adapter over `@oxy.so/core/server`
|
|
9
|
+
* `safeFetch` (so the SSRF/transport policy stays in core); a test passes a
|
|
10
|
+
* stub. The subject's current verification keys are read from the DID document's
|
|
11
|
+
* `verificationMethod[].publicKeyHex` (the active assertion methods), and the
|
|
12
|
+
* engine's `isAuthorizedKey` then applies the uniform self-issued rule.
|
|
13
|
+
*
|
|
14
|
+
* `did:web` → URL mapping (W3C did:web):
|
|
15
|
+
* - `did:web:example.com` → `https://example.com/.well-known/did.json`
|
|
16
|
+
* - `did:web:example.com:u:123` → `https://example.com/u/123/did.json`
|
|
17
|
+
* - a `%3A` in the host segment is decoded to `:` (an explicit port).
|
|
18
|
+
*/
|
|
19
|
+
import { didDocumentSchema, } from '@oxy.so/contracts';
|
|
20
|
+
import { readBoundedJson } from './httpFetch.js';
|
|
21
|
+
import { DEFAULT_CLIENT_MAX_REDIRECTS, DEFAULT_CLIENT_TIMEOUT_MS, DEFAULT_DID_DOC_MAX_BYTES, } from './constants.js';
|
|
22
|
+
/**
|
|
23
|
+
* Map a `did:web` DID to its `did.json` URL, or `null` when `did` is not a
|
|
24
|
+
* well-formed `did:web` identifier.
|
|
25
|
+
*/
|
|
26
|
+
export function didWebToUrl(did) {
|
|
27
|
+
const prefix = 'did:web:';
|
|
28
|
+
if (!did.startsWith(prefix)) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const msi = did.slice(prefix.length);
|
|
32
|
+
if (msi.length === 0) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const [domainPart, ...pathParts] = msi.split(':');
|
|
36
|
+
const host = domainPart.replace(/%3A/gi, ':');
|
|
37
|
+
if (host.length === 0 || host.includes('/')) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const base = `https://${host}`;
|
|
41
|
+
if (pathParts.length === 0) {
|
|
42
|
+
return `${base}/.well-known/did.json`;
|
|
43
|
+
}
|
|
44
|
+
if (pathParts.some((part) => part.length === 0)) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
return `${base}/${pathParts.join('/')}/did.json`;
|
|
48
|
+
}
|
|
49
|
+
/** True for a secp256k1 verification method (carries `publicKeyHex`). */
|
|
50
|
+
function isSecp256k1Vm(vm) {
|
|
51
|
+
return vm.type === 'EcdsaSecp256k1VerificationKey2019';
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Collect the subject's current verification keys from its DID document: the
|
|
55
|
+
* `publicKeyHex` of every secp256k1 verification method referenced by
|
|
56
|
+
* `assertionMethod` (the keys that may sign assertions/records), deduped. Falls
|
|
57
|
+
* back to ALL secp256k1 `verificationMethod[]` keys when `assertionMethod`
|
|
58
|
+
* references nothing local. Non-secp256k1 methods (e.g. the atproto `Multikey`,
|
|
59
|
+
* which carries the SAME key in multibase form, not hex) are skipped — record
|
|
60
|
+
* signatures verify against the hex key.
|
|
61
|
+
*/
|
|
62
|
+
function collectCurrentPublicKeys(doc) {
|
|
63
|
+
const byId = new Map(doc.verificationMethod
|
|
64
|
+
.filter(isSecp256k1Vm)
|
|
65
|
+
.map((vm) => [vm.id, vm.publicKeyHex]));
|
|
66
|
+
const keys = [];
|
|
67
|
+
for (const id of doc.assertionMethod) {
|
|
68
|
+
const key = byId.get(id);
|
|
69
|
+
if (key && !keys.includes(key)) {
|
|
70
|
+
keys.push(key);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (keys.length === 0) {
|
|
74
|
+
for (const key of byId.values()) {
|
|
75
|
+
if (!keys.includes(key)) {
|
|
76
|
+
keys.push(key);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return keys;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Build a {@link VerificationMethodResolver} that resolves `did:web` subjects via
|
|
84
|
+
* the injected `fetch`. Returns `null` for any subject that is not a `did:web`
|
|
85
|
+
* DID, whose `did.json` cannot be fetched, or whose document fails schema
|
|
86
|
+
* validation — the engine then treats the signer as unauthorized.
|
|
87
|
+
*/
|
|
88
|
+
export function createDidWebResolver(fetch, options = {}) {
|
|
89
|
+
const headersTimeoutMs = options.headersTimeoutMs ?? DEFAULT_CLIENT_TIMEOUT_MS;
|
|
90
|
+
const maxRedirects = options.maxRedirects ?? DEFAULT_CLIENT_MAX_REDIRECTS;
|
|
91
|
+
const maxBytes = options.maxBytes ?? DEFAULT_DID_DOC_MAX_BYTES;
|
|
92
|
+
return {
|
|
93
|
+
async resolve(subjectDid) {
|
|
94
|
+
const url = didWebToUrl(subjectDid);
|
|
95
|
+
if (!url) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const res = await fetch(url, { method: 'GET', headersTimeoutMs, maxRedirects });
|
|
100
|
+
if (res.status < 200 || res.status >= 300) {
|
|
101
|
+
res.destroy();
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const body = await readBoundedJson(res, maxBytes);
|
|
105
|
+
const parsed = didDocumentSchema.safeParse(body);
|
|
106
|
+
if (!parsed.success) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
// The DID document `id` MUST match the subject we asked for — a document
|
|
110
|
+
// served at the subject's URL but claiming another id is not authoritative.
|
|
111
|
+
if (parsed.data.id !== subjectDid) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
return { currentPublicKeys: collectCurrentPublicKeys(parsed.data) };
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
options.onError?.(err, subjectDid);
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The injected HTTP transport the node {@link ./nodeClient.NodeClient} and the
|
|
3
|
+
* {@link ./didWebResolver} drive — plus the bounded-read helpers that make a
|
|
4
|
+
* response stream safe to consume.
|
|
5
|
+
*
|
|
6
|
+
* The protocol package is app-agnostic and MUST NOT depend on `@oxy.so/core`
|
|
7
|
+
* (core depends on protocol). So instead of importing `@oxy.so/core/server`'s
|
|
8
|
+
* `safeFetch` directly, the node client/resolver accept a {@link NodeFetch} —
|
|
9
|
+
* Oxy supplies an adapter over `safeFetch` (HTTPS-only, DNS-pinned, private-IP
|
|
10
|
+
* denylist, bounded redirects); a test supplies an in-process stub. Either way
|
|
11
|
+
* the SSRF/transport policy stays in the injected implementation, and the
|
|
12
|
+
* bounded body read (the cap that stops a malicious node streaming forever)
|
|
13
|
+
* stays here, close to the parsing.
|
|
14
|
+
*
|
|
15
|
+
* A Node `IncomingMessage` (what `safeFetch` returns) satisfies
|
|
16
|
+
* {@link NodeFetchResponse.body} directly — it is an `AsyncIterable<Buffer>`,
|
|
17
|
+
* and `Buffer` is a `Uint8Array`.
|
|
18
|
+
*/
|
|
19
|
+
/** Thrown when a response body exceeds the caller's byte ceiling. */
|
|
20
|
+
export class ResponseTooLargeError extends Error {
|
|
21
|
+
constructor(maxBytes) {
|
|
22
|
+
super(`response exceeded ${maxBytes} bytes`);
|
|
23
|
+
this.maxBytes = maxBytes;
|
|
24
|
+
this.name = 'ResponseTooLargeError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Read a response body into a single buffer, aborting (and destroying the
|
|
29
|
+
* stream) the moment it exceeds `maxBytes`. The bound is the defence against a
|
|
30
|
+
* node that streams an unbounded body.
|
|
31
|
+
*/
|
|
32
|
+
export async function readBoundedBytes(res, maxBytes) {
|
|
33
|
+
const chunks = [];
|
|
34
|
+
let total = 0;
|
|
35
|
+
try {
|
|
36
|
+
for await (const chunk of res.body) {
|
|
37
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
38
|
+
total += buf.length;
|
|
39
|
+
if (total > maxBytes) {
|
|
40
|
+
throw new ResponseTooLargeError(maxBytes);
|
|
41
|
+
}
|
|
42
|
+
chunks.push(buf);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
res.destroy();
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
return Buffer.concat(chunks);
|
|
50
|
+
}
|
|
51
|
+
/** Read a bounded response body and parse it as JSON. */
|
|
52
|
+
export async function readBoundedJson(res, maxBytes) {
|
|
53
|
+
const bytes = await readBoundedBytes(res, maxBytes);
|
|
54
|
+
return JSON.parse(bytes.toString('utf8'));
|
|
55
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@oxy.so/protocol/node` — the runnable node substrate.
|
|
3
|
+
*
|
|
4
|
+
* The Node-only half of the protocol: the Express app factory that backs any
|
|
5
|
+
* Oxy-protocol data node ({@link createNodeApp}), the HTTP {@link NodeClient}
|
|
6
|
+
* that drives a node's routes, the `did:web` verification-method resolver, the
|
|
7
|
+
* record verifier, and the node-protocol shape constants. A SEPARATE subpath
|
|
8
|
+
* from the package root so this Express/Node-only code never enters React
|
|
9
|
+
* Native / web bundles that import `@oxy.so/protocol`.
|
|
10
|
+
*
|
|
11
|
+
* Reused by `@oxy.so/node` (the runnable node), a future `mention-node` (an
|
|
12
|
+
* env-only deployment of the same base), and oxy-api's node sync (which drives
|
|
13
|
+
* a node via `NodeClient`).
|
|
14
|
+
*/
|
|
15
|
+
// ── Express app factory ───────────────────────────────────────────────────────
|
|
16
|
+
export { createNodeApp, BlobHashMismatchError } from './nodeApp.js';
|
|
17
|
+
// ── Per-IP write rate limiter ──────────────────────────────────────────────────
|
|
18
|
+
export { createRateLimiter, DEFAULT_WRITE_RATE_LIMIT, DEFAULT_MAX_RATE_LIMIT_ENTRIES, } from './rateLimit.js';
|
|
19
|
+
// ── Record verification (signature + v2 + content address) ─────────────────────
|
|
20
|
+
export { verifyNodeRecordEnvelope } from './verifyRecord.js';
|
|
21
|
+
// ── HTTP client ────────────────────────────────────────────────────────────────
|
|
22
|
+
export { NodeClient, NodeClientError, trimTrailingSlashes } from './nodeClient.js';
|
|
23
|
+
// ── Injected transport contract + bounded readers ──────────────────────────────
|
|
24
|
+
export { readBoundedBytes, readBoundedJson, ResponseTooLargeError } from './httpFetch.js';
|
|
25
|
+
// ── did:web verification-method resolver ───────────────────────────────────────
|
|
26
|
+
export { createDidWebResolver, didWebToUrl } from './didWebResolver.js';
|
|
27
|
+
// ── Node-protocol shape constants ──────────────────────────────────────────────
|
|
28
|
+
export { PROTOCOL_VERSION, DEFAULT_WELL_KNOWN_PATH, DEFAULT_SERVICE_TYPE, DEFAULT_APP_NAMESPACE, DEFAULT_PORT, DEFAULT_LOG_LIMIT, MAX_LOG_LIMIT, DEFAULT_MAX_BLOB_BYTES, MAX_SYNC_BATCH, JSON_BODY_LIMIT, OWNER_AUTH_HEADERS, OWNER_AUTH_MAX_AGE_MS, NODE_MODES, OWNER_ACTION_BLOB_PIN, SHA256_HEX, NODE_HEAD_PATH, NODE_LOG_PATH, NODE_RECORDS_PATH, NODE_SYNC_PUSH_PATH, NODE_BLOBS_PATH, DEFAULT_CLIENT_TIMEOUT_MS, DEFAULT_CLIENT_MAX_REDIRECTS, DEFAULT_HEAD_MAX_BYTES, DEFAULT_LOG_MAX_BYTES, DEFAULT_WRITE_RESPONSE_MAX_BYTES, DEFAULT_DID_DOC_MAX_BYTES, } from './constants.js';
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createNodeApp` — the app-agnostic Express factory for an Oxy-protocol data
|
|
3
|
+
* node. A node stores and serves ONE owner's append-only signed-record log
|
|
4
|
+
* (their "personal repo") plus the content-addressed blobs the records point at.
|
|
5
|
+
*
|
|
6
|
+
* This is the engine extracted from `@oxy.so/node` so the SAME code can back many
|
|
7
|
+
* app-node deployments (the Oxy identity node, a future Mention node) that
|
|
8
|
+
* differ only by ENV: the namespace they serve, their well-known manifest path,
|
|
9
|
+
* their advertised protocol id + service-type, and their owner key. Everything
|
|
10
|
+
* app-specific is INJECTED:
|
|
11
|
+
*
|
|
12
|
+
* - `store` — a {@link RecordStore} + {@link BlobStore} (the node's SQLite
|
|
13
|
+
* store, or a test stub). The node holds exactly one subject's
|
|
14
|
+
* repo, so the store keys a single global chain and ignores the
|
|
15
|
+
* subject argument; `createNodeApp` passes the node's own key as
|
|
16
|
+
* a stable sentinel.
|
|
17
|
+
* - `ownerAuth` — the single write authority. Records and blob pins are
|
|
18
|
+
* authorized against the node's configured owner key. This is
|
|
19
|
+
* injected (rather than importing `@oxy.so/core/server`) so the
|
|
20
|
+
* protocol package never depends on core.
|
|
21
|
+
* - `config` — the wire-shape knobs (well-known path, protocol id,
|
|
22
|
+
* service-type, mode, blob ceiling, collection allowlist).
|
|
23
|
+
* - `logger` — structured logging for the terminal error handler.
|
|
24
|
+
*
|
|
25
|
+
* Endpoints:
|
|
26
|
+
* - `GET <wellKnownPath>` — node identity + liveness (a probe target).
|
|
27
|
+
* - `GET /oxy/head` — chain head `{ seq, headRecordId, recordCount }`.
|
|
28
|
+
* - `GET /oxy/log` — ordered envelopes from a cursor (ingest).
|
|
29
|
+
* - `POST /records` — owner writes a single signed envelope.
|
|
30
|
+
* - `POST /sync/push` — owner pushes a batch of signed envelopes.
|
|
31
|
+
* - `GET /blobs/:hash` — serve a content-addressed blob.
|
|
32
|
+
* - `PUT /blobs/:hash` — owner pins a blob (signed-header auth).
|
|
33
|
+
* - `GET /health` — container liveness.
|
|
34
|
+
*/
|
|
35
|
+
import express from 'express';
|
|
36
|
+
import { computeRecordId } from '../envelope/recordId.js';
|
|
37
|
+
import { createRateLimiter, DEFAULT_WRITE_RATE_LIMIT } from './rateLimit.js';
|
|
38
|
+
import { verifyNodeRecordEnvelope } from './verifyRecord.js';
|
|
39
|
+
import { DEFAULT_LOG_LIMIT, JSON_BODY_LIMIT, MAX_LOG_LIMIT, MAX_SYNC_BATCH, NODE_BLOBS_PATH, NODE_HEAD_PATH, NODE_LOG_PATH, NODE_RECORDS_PATH, NODE_SYNC_PUSH_PATH, OWNER_AUTH_HEADERS, SHA256_HEX, } from './constants.js';
|
|
40
|
+
/**
|
|
41
|
+
* Thrown by a {@link BlobStore.putBlob} implementation when bytes do not hash to
|
|
42
|
+
* the supplied address. Defined here (rather than in `@oxy.so/node`) so the node
|
|
43
|
+
* app can map it to `hash_mismatch` without importing the store implementation.
|
|
44
|
+
*/
|
|
45
|
+
export class BlobHashMismatchError extends Error {
|
|
46
|
+
constructor(expected, actual) {
|
|
47
|
+
super(`blob hash mismatch: expected ${expected}, computed ${actual}`);
|
|
48
|
+
this.expected = expected;
|
|
49
|
+
this.actual = actual;
|
|
50
|
+
this.name = 'BlobHashMismatchError';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Coerce a raw Express query value (`string | string[] | ParsedQs | undefined`)
|
|
55
|
+
* to a single string. A repeated/array param (`?since[]=a&since[]=b`) yields a
|
|
56
|
+
* non-string under qs; take its first string element so a tampered array can
|
|
57
|
+
* never reach a `typeof === 'string'` check as a non-string and cause type
|
|
58
|
+
* confusion. Returns `undefined` for anything that is not a string or a
|
|
59
|
+
* string-first array.
|
|
60
|
+
*/
|
|
61
|
+
function firstQueryValue(raw) {
|
|
62
|
+
if (typeof raw === 'string') {
|
|
63
|
+
return raw;
|
|
64
|
+
}
|
|
65
|
+
if (Array.isArray(raw) && raw.length > 0 && typeof raw[0] === 'string') {
|
|
66
|
+
return raw[0];
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
/** Clamp the `limit` query param into `[1, MAX_LOG_LIMIT]`, defaulting when absent/invalid. */
|
|
71
|
+
function parseLimit(raw) {
|
|
72
|
+
const value = firstQueryValue(raw);
|
|
73
|
+
if (value === undefined || value.trim() === '') {
|
|
74
|
+
return DEFAULT_LOG_LIMIT;
|
|
75
|
+
}
|
|
76
|
+
const parsed = Number(value);
|
|
77
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
78
|
+
return DEFAULT_LOG_LIMIT;
|
|
79
|
+
}
|
|
80
|
+
return Math.min(parsed, MAX_LOG_LIMIT);
|
|
81
|
+
}
|
|
82
|
+
/** HTTP status for a chain-append rejection. */
|
|
83
|
+
function appendStatus(reason) {
|
|
84
|
+
return reason === 'chain_conflict' ? 409 : 422;
|
|
85
|
+
}
|
|
86
|
+
/** True when `collection` is writable under the node's allowlist (empty = all). */
|
|
87
|
+
function isCollectionAllowed(config, collection) {
|
|
88
|
+
return config.collections.length === 0 || config.collections.includes(collection);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Resolve the `/oxy/log` `since` query param to an exclusive lower-bound `seq`.
|
|
92
|
+
* Returns `null` when the page should be EMPTY (an unknown `recordId` cursor or
|
|
93
|
+
* an unrecognized cursor shape). Mirrors the legacy in-store cursor resolver,
|
|
94
|
+
* now split across the protocol `RecordStore` (numeric `getLogSince` +
|
|
95
|
+
* `resolveCursorSeq`).
|
|
96
|
+
*/
|
|
97
|
+
async function resolveSinceSeq(store, subject, since) {
|
|
98
|
+
if (since === undefined || since === '') {
|
|
99
|
+
return -1;
|
|
100
|
+
}
|
|
101
|
+
const lower = since.toLowerCase();
|
|
102
|
+
if (SHA256_HEX.test(lower)) {
|
|
103
|
+
return store.resolveCursorSeq(subject, lower);
|
|
104
|
+
}
|
|
105
|
+
if (/^\d+$/.test(since)) {
|
|
106
|
+
const parsed = Number(since);
|
|
107
|
+
return Number.isSafeInteger(parsed) ? parsed : -1;
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
/** Map a stored envelope to the `/oxy/log` wire record. */
|
|
112
|
+
async function toLogWireRecord(env) {
|
|
113
|
+
return {
|
|
114
|
+
seq: env.seq ?? 0,
|
|
115
|
+
recordId: await computeRecordId(env),
|
|
116
|
+
prev: env.prev ?? null,
|
|
117
|
+
issuedAt: env.issuedAt,
|
|
118
|
+
envelope: env,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
export function createNodeApp(deps) {
|
|
122
|
+
const { store, config, ownerAuth, logger } = deps;
|
|
123
|
+
// The node holds one subject's repo; the store keys a single global chain and
|
|
124
|
+
// ignores the subject argument. Pass the node's own key as a stable sentinel.
|
|
125
|
+
const subject = config.nodePublicKey;
|
|
126
|
+
const app = express();
|
|
127
|
+
app.disable('x-powered-by');
|
|
128
|
+
// JSON parser applies only to JSON bodies (it checks Content-Type), so the raw
|
|
129
|
+
// blob upload below is untouched by it.
|
|
130
|
+
const jsonParser = express.json({ limit: JSON_BODY_LIMIT });
|
|
131
|
+
// Per-IP rate limiter on the owner-authorized write routes. Caps request rate
|
|
132
|
+
// BEFORE signature verification so a flood of bogus envelopes can't pin CPU.
|
|
133
|
+
// It owns a background sweep timer; expose its teardown as `app.stop()`.
|
|
134
|
+
const writeRateLimit = createRateLimiter(config.writeRateLimit ?? DEFAULT_WRITE_RATE_LIMIT);
|
|
135
|
+
app.stop = () => writeRateLimit.stop();
|
|
136
|
+
app.get('/health', (_req, res) => {
|
|
137
|
+
res.json({ status: 'ok' });
|
|
138
|
+
});
|
|
139
|
+
// ── Node identity / liveness ────────────────────────────────────────────────
|
|
140
|
+
app.get(config.wellKnownPath, async (_req, res, next) => {
|
|
141
|
+
try {
|
|
142
|
+
const head = await store.getHead(subject);
|
|
143
|
+
res.json({
|
|
144
|
+
nodePublicKey: config.nodePublicKey,
|
|
145
|
+
mode: config.mode,
|
|
146
|
+
version: config.protocolId,
|
|
147
|
+
serviceType: config.serviceType,
|
|
148
|
+
head: head && head.headRecordId !== null ? { seq: head.seq, headRecordId: head.headRecordId } : null,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
next(error);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
// ── Chain head ──────────────────────────────────────────────────────────────
|
|
156
|
+
app.get(NODE_HEAD_PATH, async (_req, res, next) => {
|
|
157
|
+
try {
|
|
158
|
+
const head = await store.getHead(subject);
|
|
159
|
+
if (!head || head.headRecordId === null) {
|
|
160
|
+
res.json({ seq: null, headRecordId: null, recordCount: 0 });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
res.json({ seq: head.seq, headRecordId: head.headRecordId, recordCount: head.recordCount });
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
next(error);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
// ── Ordered log (ingest) ──────────────────────────────────────────────────────
|
|
170
|
+
app.get(NODE_LOG_PATH, async (req, res, next) => {
|
|
171
|
+
try {
|
|
172
|
+
const since = firstQueryValue(req.query.since);
|
|
173
|
+
const limit = parseLimit(req.query.limit);
|
|
174
|
+
const head = await store.getHead(subject);
|
|
175
|
+
const headWire = head && head.headRecordId !== null ? { seq: head.seq, headRecordId: head.headRecordId } : null;
|
|
176
|
+
const sinceSeq = await resolveSinceSeq(store, subject, since);
|
|
177
|
+
if (sinceSeq === null) {
|
|
178
|
+
res.json({ records: [], count: 0, head: headWire });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const envelopes = await store.getLogSince(subject, sinceSeq, limit);
|
|
182
|
+
const records = await Promise.all(envelopes.map(toLogWireRecord));
|
|
183
|
+
res.json({ records, count: records.length, head: headWire });
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
next(error);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
// ── Owner write: a single signed envelope ────────────────────────────────────
|
|
190
|
+
app.post(NODE_RECORDS_PATH, writeRateLimit, jsonParser, async (req, res, next) => {
|
|
191
|
+
try {
|
|
192
|
+
const verification = await verifyNodeRecordEnvelope(req.body);
|
|
193
|
+
if (!verification.ok) {
|
|
194
|
+
res.status(400).json({ error: verification.reason });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (!ownerAuth.isOwnerKey(verification.envelope.publicKey)) {
|
|
198
|
+
res.status(403).json({ error: 'not_owner' });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (!isCollectionAllowed(config, verification.envelope.collection ?? '')) {
|
|
202
|
+
res.status(403).json({ error: 'foreign_collection' });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const outcome = await store.append(subject, verification.envelope, verification.recordId);
|
|
206
|
+
if (!outcome.ok) {
|
|
207
|
+
res.status(appendStatus(outcome.reason)).json({ error: outcome.reason });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
res.status(201).json({ recordId: outcome.recordId, seq: outcome.seq });
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
next(error);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
// ── Owner write: a batch push (verified + appended in order) ──────────────────
|
|
217
|
+
app.post(NODE_SYNC_PUSH_PATH, writeRateLimit, jsonParser, async (req, res, next) => {
|
|
218
|
+
try {
|
|
219
|
+
const body = req.body;
|
|
220
|
+
const items = typeof body === 'object' && body !== null && Array.isArray(body.records)
|
|
221
|
+
? body.records
|
|
222
|
+
: null;
|
|
223
|
+
if (!items) {
|
|
224
|
+
res.status(400).json({ error: 'invalid_batch' });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (items.length > MAX_SYNC_BATCH) {
|
|
228
|
+
res.status(400).json({ error: 'batch_too_large' });
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const results = [];
|
|
232
|
+
for (const item of items) {
|
|
233
|
+
const verification = await verifyNodeRecordEnvelope(item);
|
|
234
|
+
if (!verification.ok) {
|
|
235
|
+
results.push({ ok: false, reason: verification.reason });
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (!ownerAuth.isOwnerKey(verification.envelope.publicKey)) {
|
|
239
|
+
results.push({ ok: false, reason: 'not_owner' });
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (!isCollectionAllowed(config, verification.envelope.collection ?? '')) {
|
|
243
|
+
results.push({ ok: false, reason: 'foreign_collection' });
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const outcome = await store.append(subject, verification.envelope, verification.recordId);
|
|
247
|
+
results.push(outcome.ok
|
|
248
|
+
? { ok: true, recordId: outcome.recordId, seq: outcome.seq }
|
|
249
|
+
: { ok: false, reason: outcome.reason });
|
|
250
|
+
}
|
|
251
|
+
const accepted = results.filter((result) => result.ok).length;
|
|
252
|
+
res.json({ accepted, results });
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
next(error);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
// ── Serve a content-addressed blob ───────────────────────────────────────────
|
|
259
|
+
app.get(`${NODE_BLOBS_PATH}/:hash`, async (req, res, next) => {
|
|
260
|
+
try {
|
|
261
|
+
const hash = req.params.hash.toLowerCase();
|
|
262
|
+
if (!SHA256_HEX.test(hash)) {
|
|
263
|
+
res.status(400).json({ error: 'invalid_hash' });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const bytes = await store.getBlob(hash);
|
|
267
|
+
if (!bytes) {
|
|
268
|
+
res.status(404).json({ error: 'not_found' });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
res.setHeader('Content-Type', 'application/octet-stream');
|
|
272
|
+
// Content-addressed → immutable, safe to cache aggressively.
|
|
273
|
+
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
274
|
+
res.send(Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes));
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
next(error);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
// ── Owner pins a blob (signed-header authorization) ──────────────────────────
|
|
281
|
+
app.put(`${NODE_BLOBS_PATH}/:hash`, writeRateLimit, express.raw({ type: () => true, limit: config.maxBlobBytes }), async (req, res, next) => {
|
|
282
|
+
try {
|
|
283
|
+
const rawHash = req.params.hash;
|
|
284
|
+
if (typeof rawHash !== 'string') {
|
|
285
|
+
res.status(400).json({ error: 'invalid_hash' });
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const hash = rawHash.toLowerCase();
|
|
289
|
+
if (!SHA256_HEX.test(hash)) {
|
|
290
|
+
res.status(400).json({ error: 'invalid_hash' });
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const publicKey = req.header(OWNER_AUTH_HEADERS.publicKey);
|
|
294
|
+
const signature = req.header(OWNER_AUTH_HEADERS.signature);
|
|
295
|
+
const timestampRaw = req.header(OWNER_AUTH_HEADERS.timestamp);
|
|
296
|
+
if (!publicKey || !signature || !timestampRaw) {
|
|
297
|
+
res.status(401).json({ error: 'missing_owner_auth' });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const authorized = await ownerAuth.verifyBlobPin(hash, {
|
|
301
|
+
publicKey,
|
|
302
|
+
signature,
|
|
303
|
+
timestamp: Number(timestampRaw),
|
|
304
|
+
});
|
|
305
|
+
if (!authorized) {
|
|
306
|
+
res.status(403).json({ error: 'unauthorized' });
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const bytes = req.body;
|
|
310
|
+
if (!Buffer.isBuffer(bytes) || bytes.length === 0) {
|
|
311
|
+
res.status(400).json({ error: 'empty_blob' });
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
try {
|
|
315
|
+
await store.putBlob(hash, bytes);
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
if (error instanceof BlobHashMismatchError) {
|
|
319
|
+
res.status(400).json({ error: 'hash_mismatch' });
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
res.status(201).json({ hash, size: bytes.length });
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
next(error);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
// ── Terminal error handler ───────────────────────────────────────────────────
|
|
331
|
+
app.use((error, _req, res, _next) => {
|
|
332
|
+
logger.error({ err: error }, 'unhandled request error');
|
|
333
|
+
res.status(500).json({ error: 'internal_error' });
|
|
334
|
+
});
|
|
335
|
+
return app;
|
|
336
|
+
}
|