@totemsdk/omnia-host 0.2.1 → 0.2.2
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/dist/api/jsonrpc.d.ts +12 -0
- package/dist/api/jsonrpc.js +85 -4
- package/dist/config.d.ts +10 -0
- package/dist/config.js +9 -0
- package/dist/lifecycle.js +2 -0
- package/dist/signing.d.ts +6 -4
- package/dist/signing.js +26 -41
- package/package.json +13 -12
package/dist/api/jsonrpc.d.ts
CHANGED
|
@@ -24,6 +24,18 @@ export interface ControlServerOptions {
|
|
|
24
24
|
isReady?: () => boolean;
|
|
25
25
|
methods?: Map<string, JsonRpcHandler>;
|
|
26
26
|
onConnection?: (socket: WebSocket) => void;
|
|
27
|
+
/**
|
|
28
|
+
* Browser Origin allowlist (AUD-009). When set, browser requests whose
|
|
29
|
+
* `Origin` header is not listed are rejected. Requests with no Origin
|
|
30
|
+
* (native clients, curl) are always allowed.
|
|
31
|
+
*/
|
|
32
|
+
allowedOrigins?: string[];
|
|
33
|
+
/**
|
|
34
|
+
* When set, control-plane requests must present this bearer token (AUD-009):
|
|
35
|
+
* `Authorization: Bearer <token>` over HTTP, or a query param / subprotocol
|
|
36
|
+
* over the WebSocket handshake.
|
|
37
|
+
*/
|
|
38
|
+
authToken?: string;
|
|
27
39
|
}
|
|
28
40
|
export interface ControlServer {
|
|
29
41
|
listen(): Promise<void>;
|
package/dist/api/jsonrpc.js
CHANGED
|
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.createControlServer = createControlServer;
|
|
7
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
7
8
|
const node_http_1 = __importDefault(require("node:http"));
|
|
8
9
|
const ws_1 = require("ws");
|
|
9
10
|
function response(id, result) {
|
|
@@ -44,23 +45,95 @@ function sendJson(res, status, body) {
|
|
|
44
45
|
res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) });
|
|
45
46
|
res.end(payload);
|
|
46
47
|
}
|
|
48
|
+
function pathname(url) {
|
|
49
|
+
if (!url)
|
|
50
|
+
return '/';
|
|
51
|
+
const query = url.indexOf('?');
|
|
52
|
+
return query === -1 ? url : url.slice(0, query);
|
|
53
|
+
}
|
|
54
|
+
function isOriginAllowed(req, allowedOrigins) {
|
|
55
|
+
const origin = req.headers.origin;
|
|
56
|
+
// Native clients (no browser Origin header) are always allowed.
|
|
57
|
+
if (!origin)
|
|
58
|
+
return true;
|
|
59
|
+
// RFC-020 H1: browser requests require an explicit allowlist (default deny).
|
|
60
|
+
if (!allowedOrigins || allowedOrigins.length === 0)
|
|
61
|
+
return false;
|
|
62
|
+
return allowedOrigins.includes(origin);
|
|
63
|
+
}
|
|
64
|
+
function bearerToken(req) {
|
|
65
|
+
const header = req.headers.authorization;
|
|
66
|
+
if (typeof header === 'string' && header.startsWith('Bearer '))
|
|
67
|
+
return header.slice('Bearer '.length);
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
function handshakeToken(req) {
|
|
71
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
72
|
+
const queryToken = url.searchParams.get('token');
|
|
73
|
+
if (queryToken)
|
|
74
|
+
return queryToken;
|
|
75
|
+
const header = req.headers['sec-websocket-protocol'];
|
|
76
|
+
const raw = Array.isArray(header) ? header.join(',') : header;
|
|
77
|
+
if (typeof raw === 'string') {
|
|
78
|
+
for (const part of raw.split(',')) {
|
|
79
|
+
const protocol = part.trim();
|
|
80
|
+
if (protocol.startsWith('bearer.'))
|
|
81
|
+
return protocol.slice('bearer.'.length);
|
|
82
|
+
if (protocol)
|
|
83
|
+
return protocol;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
function safeTokenEqual(provided, expected) {
|
|
89
|
+
if (provided === undefined)
|
|
90
|
+
return false;
|
|
91
|
+
const a = Buffer.from(provided);
|
|
92
|
+
const b = Buffer.from(expected);
|
|
93
|
+
if (a.length !== b.length)
|
|
94
|
+
return false;
|
|
95
|
+
return node_crypto_1.default.timingSafeEqual(a, b);
|
|
96
|
+
}
|
|
47
97
|
function createControlServer(options) {
|
|
98
|
+
// RFC-020 H1: a non-loopback bind must be authenticated. Refuse to start a
|
|
99
|
+
// network-exposed control plane without a token.
|
|
100
|
+
const isLoopback = options.host === '127.0.0.1' || options.host === '::1' || options.host === 'localhost';
|
|
101
|
+
if (!isLoopback && (options.authToken === undefined || options.authToken.length === 0)) {
|
|
102
|
+
throw new Error(`Refusing to bind the control plane to ${options.host} without a control token (OMNIA_CONTROL_TOKEN)`);
|
|
103
|
+
}
|
|
48
104
|
const methods = options.methods ?? new Map();
|
|
49
105
|
const wsPath = options.wsPath ?? '/rpc';
|
|
106
|
+
const allowedOrigins = options.allowedOrigins;
|
|
107
|
+
const authToken = options.authToken;
|
|
50
108
|
const server = node_http_1.default.createServer(async (req, res) => {
|
|
51
|
-
if (req.method === 'GET' && req.url === '/healthz') {
|
|
109
|
+
if (req.method === 'GET' && pathname(req.url) === '/healthz') {
|
|
52
110
|
sendJson(res, 200, { ok: true });
|
|
53
111
|
return;
|
|
54
112
|
}
|
|
55
|
-
if (req.method === 'GET' && req.url === '/readyz') {
|
|
113
|
+
if (req.method === 'GET' && pathname(req.url) === '/readyz') {
|
|
56
114
|
const ready = options.isReady?.() ?? false;
|
|
57
115
|
sendJson(res, ready ? 200 : 503, { ok: ready });
|
|
58
116
|
return;
|
|
59
117
|
}
|
|
60
|
-
if (req.method !== 'POST' || req.url !== wsPath) {
|
|
118
|
+
if (req.method !== 'POST' || pathname(req.url) !== wsPath) {
|
|
61
119
|
sendJson(res, 404, { error: 'Not found' });
|
|
62
120
|
return;
|
|
63
121
|
}
|
|
122
|
+
if (!isOriginAllowed(req, allowedOrigins)) {
|
|
123
|
+
sendJson(res, 403, { error: 'Forbidden' });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
// RFC-020 H1: require application/json — a browser cross-origin `text/plain`
|
|
127
|
+
// POST (a classic CSRF vector that skips CORS preflight) must be rejected.
|
|
128
|
+
const contentType = String(req.headers['content-type'] ?? '').toLowerCase();
|
|
129
|
+
if (!contentType.startsWith('application/json')) {
|
|
130
|
+
sendJson(res, 415, { error: 'Unsupported Media Type: application/json required' });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (authToken !== undefined && !safeTokenEqual(bearerToken(req), authToken)) {
|
|
134
|
+
sendJson(res, 403, { error: 'Forbidden' });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
64
137
|
try {
|
|
65
138
|
const request = JSON.parse(await readBody(req));
|
|
66
139
|
sendJson(res, 200, await dispatch(request, methods));
|
|
@@ -72,7 +145,15 @@ function createControlServer(options) {
|
|
|
72
145
|
});
|
|
73
146
|
const webSockets = new ws_1.WebSocketServer({ noServer: true });
|
|
74
147
|
server.on('upgrade', (req, socket, head) => {
|
|
75
|
-
if (req.url !== wsPath) {
|
|
148
|
+
if (pathname(req.url) !== wsPath) {
|
|
149
|
+
socket.destroy();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (!isOriginAllowed(req, allowedOrigins)) {
|
|
153
|
+
socket.destroy();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (authToken !== undefined && !safeTokenEqual(handshakeToken(req), authToken)) {
|
|
76
157
|
socket.destroy();
|
|
77
158
|
return;
|
|
78
159
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -26,6 +26,16 @@ export interface OmniaHostConfig {
|
|
|
26
26
|
identityFile?: string;
|
|
27
27
|
/** EdgeServiceManifest serviceType (default "omnia-router"). */
|
|
28
28
|
serviceType: string;
|
|
29
|
+
/**
|
|
30
|
+
* Browser Origin allowlist for the control plane (OMNIA_ALLOWED_ORIGINS,
|
|
31
|
+
* comma-separated). Undefined/empty allows any Origin.
|
|
32
|
+
*/
|
|
33
|
+
allowedOrigins?: string[];
|
|
34
|
+
/**
|
|
35
|
+
* Bearer token required on control-plane requests (OMNIA_CONTROL_TOKEN).
|
|
36
|
+
* Undefined disables token auth.
|
|
37
|
+
*/
|
|
38
|
+
controlToken?: string;
|
|
29
39
|
}
|
|
30
40
|
/** Load the daemon configuration from the documented OMNIA_* environment. */
|
|
31
41
|
export declare function loadConfigFromEnv(env?: NodeJS.ProcessEnv): OmniaHostConfig;
|
package/dist/config.js
CHANGED
|
@@ -28,6 +28,13 @@ function optionalInteger(value) {
|
|
|
28
28
|
throw new Error(`OMNIA_LOCAL_ADDRESS_INDEX must be a non-negative integer; received ${value}`);
|
|
29
29
|
return parsed;
|
|
30
30
|
}
|
|
31
|
+
function parseOrigins(value) {
|
|
32
|
+
const parts = value
|
|
33
|
+
?.split(',')
|
|
34
|
+
.map((part) => part.trim())
|
|
35
|
+
.filter((part) => part.length > 0);
|
|
36
|
+
return parts && parts.length > 0 ? parts : undefined;
|
|
37
|
+
}
|
|
31
38
|
function parseSeed(value) {
|
|
32
39
|
const seed = optional(value);
|
|
33
40
|
if (!seed)
|
|
@@ -76,5 +83,7 @@ function loadConfigFromEnv(env = process.env) {
|
|
|
76
83
|
readOnly: env.OMNIA_HOST_READ_ONLY?.trim() === '1',
|
|
77
84
|
identityFile: optional(env.OMNIA_HOST_IDENTITY_FILE),
|
|
78
85
|
serviceType: optional(env.OMNIA_HOST_SERVICE_TYPE) ?? 'omnia-router',
|
|
86
|
+
allowedOrigins: parseOrigins(env.OMNIA_ALLOWED_ORIGINS),
|
|
87
|
+
controlToken: optional(env.OMNIA_CONTROL_TOKEN),
|
|
79
88
|
};
|
|
80
89
|
}
|
package/dist/lifecycle.js
CHANGED
|
@@ -86,6 +86,8 @@ function createOmniaHost(config, dependencies = {}) {
|
|
|
86
86
|
port: config.port,
|
|
87
87
|
wsPath: config.wsPath,
|
|
88
88
|
isReady: () => started,
|
|
89
|
+
allowedOrigins: config.allowedOrigins,
|
|
90
|
+
authToken: config.controlToken,
|
|
89
91
|
methods: dependencies.methods ?? (0, methods_js_1.createHostMethods)({
|
|
90
92
|
channels: channelStore,
|
|
91
93
|
routing: routingProvider,
|
package/dist/signing.d.ts
CHANGED
|
@@ -43,13 +43,15 @@ interface KeyfilePayload {
|
|
|
43
43
|
export declare function encryptSeedForKeyfile(seed: Uint8Array, passphrase: string): KeyfilePayload;
|
|
44
44
|
/**
|
|
45
45
|
* JSON-file-backed StorageAdapter rooted at a directory next to the channel DB.
|
|
46
|
-
*
|
|
46
|
+
*
|
|
47
|
+
* Delegates to the hardened shared `FileStore` from `@totemsdk/storage/fs`
|
|
48
|
+
* (RFC-007): one versioned codec record per key written temp+rename with an
|
|
49
|
+
* fsync; corrupt records surface as `StorageError` (`corrupt`) rather than
|
|
50
|
+
* silently returning JSON `null`. The directory is created on first write.
|
|
47
51
|
*/
|
|
48
52
|
export declare class JsonFileStorageAdapter implements StorageAdapter {
|
|
49
|
-
private readonly
|
|
53
|
+
private readonly store;
|
|
50
54
|
constructor(dir: string);
|
|
51
|
-
private fileFor;
|
|
52
|
-
private ensureDir;
|
|
53
55
|
get<T>(key: string): Promise<T | null>;
|
|
54
56
|
set<T>(key: string, value: T): Promise<void>;
|
|
55
57
|
remove(key: string): Promise<boolean>;
|
package/dist/signing.js
CHANGED
|
@@ -30,6 +30,7 @@ const node_fs_1 = __importDefault(require("node:fs"));
|
|
|
30
30
|
const node_path_1 = __importDefault(require("node:path"));
|
|
31
31
|
const node_crypto_1 = require("node:crypto");
|
|
32
32
|
const core_1 = require("@totemsdk/core");
|
|
33
|
+
const fs_1 = require("@totemsdk/storage/fs");
|
|
33
34
|
const wots_lease_1 = require("@totemsdk/wots-lease");
|
|
34
35
|
const wots_lease_2 = require("@totemsdk/wots-lease");
|
|
35
36
|
const KEYFILE_SCRYPT_N = 16384;
|
|
@@ -115,58 +116,34 @@ function resolveBaseSeed(config) {
|
|
|
115
116
|
}
|
|
116
117
|
/**
|
|
117
118
|
* JSON-file-backed StorageAdapter rooted at a directory next to the channel DB.
|
|
118
|
-
*
|
|
119
|
+
*
|
|
120
|
+
* Delegates to the hardened shared `FileStore` from `@totemsdk/storage/fs`
|
|
121
|
+
* (RFC-007): one versioned codec record per key written temp+rename with an
|
|
122
|
+
* fsync; corrupt records surface as `StorageError` (`corrupt`) rather than
|
|
123
|
+
* silently returning JSON `null`. The directory is created on first write.
|
|
119
124
|
*/
|
|
120
125
|
class JsonFileStorageAdapter {
|
|
121
|
-
|
|
126
|
+
store;
|
|
122
127
|
constructor(dir) {
|
|
123
|
-
this.
|
|
124
|
-
}
|
|
125
|
-
fileFor(key) {
|
|
126
|
-
const safe = key.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
127
|
-
return node_path_1.default.join(this.dir, `${safe}.json`);
|
|
128
|
-
}
|
|
129
|
-
ensureDir() {
|
|
130
|
-
node_fs_1.default.mkdirSync(this.dir, { recursive: true });
|
|
128
|
+
this.store = new fs_1.FileStore(dir);
|
|
131
129
|
}
|
|
132
130
|
async get(key) {
|
|
133
|
-
|
|
134
|
-
if (!node_fs_1.default.existsSync(file))
|
|
135
|
-
return null;
|
|
136
|
-
try {
|
|
137
|
-
return JSON.parse(node_fs_1.default.readFileSync(file, 'utf8'));
|
|
138
|
-
}
|
|
139
|
-
catch {
|
|
140
|
-
return null;
|
|
141
|
-
}
|
|
131
|
+
return this.store.get(key);
|
|
142
132
|
}
|
|
143
133
|
async set(key, value) {
|
|
144
|
-
this.
|
|
145
|
-
node_fs_1.default.writeFileSync(this.fileFor(key), JSON.stringify(value));
|
|
134
|
+
return this.store.set(key, value);
|
|
146
135
|
}
|
|
147
136
|
async remove(key) {
|
|
148
|
-
|
|
149
|
-
if (!node_fs_1.default.existsSync(file))
|
|
150
|
-
return false;
|
|
151
|
-
node_fs_1.default.unlinkSync(file);
|
|
152
|
-
return true;
|
|
137
|
+
return this.store.remove(key);
|
|
153
138
|
}
|
|
154
139
|
async clear() {
|
|
155
|
-
|
|
156
|
-
return;
|
|
157
|
-
for (const entry of node_fs_1.default.readdirSync(this.dir)) {
|
|
158
|
-
node_fs_1.default.unlinkSync(node_path_1.default.join(this.dir, entry));
|
|
159
|
-
}
|
|
140
|
+
return this.store.clear();
|
|
160
141
|
}
|
|
161
142
|
async keys() {
|
|
162
|
-
|
|
163
|
-
return [];
|
|
164
|
-
return node_fs_1.default.readdirSync(this.dir)
|
|
165
|
-
.filter((name) => name.endsWith('.json'))
|
|
166
|
-
.map((name) => name.slice(0, -'.json'.length));
|
|
143
|
+
return this.store.keys();
|
|
167
144
|
}
|
|
168
145
|
async has(key) {
|
|
169
|
-
return
|
|
146
|
+
return this.store.has(key);
|
|
170
147
|
}
|
|
171
148
|
}
|
|
172
149
|
exports.JsonFileStorageAdapter = JsonFileStorageAdapter;
|
|
@@ -200,14 +177,22 @@ function createHostSigning(config) {
|
|
|
200
177
|
const kp = (0, core_1.wotsKeypairFromSeed)(perAddressSeed, 0);
|
|
201
178
|
const publicKeyDigest = `0x${(0, core_1.bytesToHex)(kp.pk)}`;
|
|
202
179
|
const address = (0, core_1.wotsAddressFromKeypair)(kp);
|
|
180
|
+
const storage = new JsonFileStorageAdapter(leaseStorageDir(config.dbPath));
|
|
181
|
+
const leaseProvider = new wots_lease_1.LocalLeaseProvider(storage, undefined, deviceId);
|
|
203
182
|
const signer = {
|
|
204
183
|
publicKeyDigest,
|
|
205
|
-
async sign(payload,
|
|
206
|
-
|
|
184
|
+
async sign(payload, indices) {
|
|
185
|
+
// RFC-020 C1: honor the reserved leaf instead of hardcoding index 0. The
|
|
186
|
+
// reservation must belong to this signer's address; the leaf within the
|
|
187
|
+
// per-address seed is (l1*64 + l2). Callers must supply the pk digest for
|
|
188
|
+
// the leaf they reserved (the default reservation is leaf (0,0)).
|
|
189
|
+
if (indices.addressIndex !== addressIndex) {
|
|
190
|
+
throw new Error(`omnia-host signer address ${addressIndex} cannot sign reserved address ${indices.addressIndex}`);
|
|
191
|
+
}
|
|
192
|
+
const leafIndex = indices.l1 * 64 + indices.l2;
|
|
193
|
+
return (0, core_1.wotsSign)(perAddressSeed, leafIndex, payload);
|
|
207
194
|
},
|
|
208
195
|
};
|
|
209
|
-
const storage = new JsonFileStorageAdapter(leaseStorageDir(config.dbPath));
|
|
210
|
-
const leaseProvider = new wots_lease_1.LocalLeaseProvider(storage, undefined, deviceId);
|
|
211
196
|
return {
|
|
212
197
|
signer,
|
|
213
198
|
leaseProvider,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@totemsdk/omnia-host",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Durable Omnia node daemon for channel lifecycle, routing, and control APIs",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -22,17 +22,18 @@
|
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"better-sqlite3": "^12.2.0",
|
|
24
24
|
"ws": "^8.21.1",
|
|
25
|
-
"@totemsdk/
|
|
26
|
-
"@totemsdk/
|
|
25
|
+
"@totemsdk/chain-provider": "^0.2.1",
|
|
26
|
+
"@totemsdk/core": "^1.2.11",
|
|
27
|
+
"@totemsdk/identity": "^1.0.1",
|
|
27
28
|
"@totemsdk/manifest": "^1.0.0",
|
|
28
|
-
"@totemsdk/
|
|
29
|
-
"@totemsdk/omnia": "^
|
|
30
|
-
"@totemsdk/omnia-
|
|
31
|
-
"@totemsdk/omnia-
|
|
32
|
-
"@totemsdk/
|
|
33
|
-
"@totemsdk/
|
|
34
|
-
"@totemsdk/
|
|
35
|
-
"@totemsdk/
|
|
29
|
+
"@totemsdk/omnia": "^1.0.2",
|
|
30
|
+
"@totemsdk/omnia-factory": "^0.2.1",
|
|
31
|
+
"@totemsdk/omnia-splice": "^0.2.1",
|
|
32
|
+
"@totemsdk/omnia-vtxo": "^0.2.1",
|
|
33
|
+
"@totemsdk/minima-rpc": "^0.2.2",
|
|
34
|
+
"@totemsdk/storage": "^0.1.0",
|
|
35
|
+
"@totemsdk/wots-lease": "^1.0.1",
|
|
36
|
+
"@totemsdk/omnia-router": "^0.2.1"
|
|
36
37
|
},
|
|
37
38
|
"devDependencies": {
|
|
38
39
|
"@types/jest": "^30.0.0",
|
|
@@ -42,7 +43,7 @@
|
|
|
42
43
|
"jest": "^30.4.2",
|
|
43
44
|
"ts-jest": "^29.4.11",
|
|
44
45
|
"typescript": "5.9.3",
|
|
45
|
-
"@totemsdk/txpow": "^1.0.
|
|
46
|
+
"@totemsdk/txpow": "^1.0.2"
|
|
46
47
|
},
|
|
47
48
|
"engines": {
|
|
48
49
|
"node": ">=20.0.0"
|