@totemsdk/omnia-host 0.10.0 → 0.11.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 -21
- package/NOTICE +60 -0
- package/README.md +44 -0
- package/dist/api/jsonrpc.d.ts +12 -0
- package/dist/api/jsonrpc.js +100 -1
- package/dist/api/methods.d.ts +101 -3
- package/dist/api/methods.js +1664 -23
- package/dist/chain/totem-node-adapter.js +1 -1
- package/dist/cli.js +65 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.js +33 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +20 -1
- package/dist/lifecycle.d.ts +15 -0
- package/dist/lifecycle.js +171 -0
- package/dist/observe/bus.d.ts +13 -0
- package/dist/observe/bus.js +29 -0
- package/dist/observe/emitter.d.ts +61 -0
- package/dist/observe/emitter.js +133 -0
- package/dist/observe/index.d.ts +5 -0
- package/dist/observe/index.js +21 -0
- package/dist/observe/snapshot.d.ts +38 -0
- package/dist/observe/snapshot.js +101 -0
- package/dist/observe/store.d.ts +31 -0
- package/dist/observe/store.js +82 -0
- package/dist/observe/types.d.ts +106 -0
- package/dist/observe/types.js +14 -0
- package/dist/pairing.d.ts +109 -0
- package/dist/pairing.js +231 -0
- package/dist/signer-port.d.ts +27 -0
- package/dist/signer-port.js +79 -0
- package/dist/stores/operations.d.ts +3 -2
- package/dist/stores/operations.js +96 -83
- package/dist/stores/sqlite-store.d.ts +8 -5
- package/dist/stores/sqlite-store.js +45 -42
- package/package.json +11 -9
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC-062 — local node/extension pairing MVP.
|
|
3
|
+
*
|
|
4
|
+
* A loopback HTTP service issues one-time pairing codes, verifies the extension
|
|
5
|
+
* origin, binds a device key and returns a scoped bearer token plus the node
|
|
6
|
+
* identity/fingerprint and advertised capabilities (chain RPC, P2P hub, lease).
|
|
7
|
+
*/
|
|
8
|
+
import type { AddressInfo } from 'node:net';
|
|
9
|
+
export interface PairingCapabilities {
|
|
10
|
+
chain?: {
|
|
11
|
+
rpcUrl: string;
|
|
12
|
+
network: string;
|
|
13
|
+
genesisHash?: string;
|
|
14
|
+
};
|
|
15
|
+
lease?: {
|
|
16
|
+
modes: Array<'local' | 'hybrid'>;
|
|
17
|
+
onchainWatermarkUrl?: string;
|
|
18
|
+
};
|
|
19
|
+
omnia?: {
|
|
20
|
+
relayUrl?: string;
|
|
21
|
+
hostControlUrl?: string;
|
|
22
|
+
transportPubkey?: string;
|
|
23
|
+
};
|
|
24
|
+
p2p?: {
|
|
25
|
+
hubUrl: string;
|
|
26
|
+
token?: string;
|
|
27
|
+
scopes: Array<'p2p.pubsub'>;
|
|
28
|
+
topicPrefixes?: string[];
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export interface LinkedClient {
|
|
32
|
+
clientId: string;
|
|
33
|
+
origin: string;
|
|
34
|
+
devicePublicKey: string;
|
|
35
|
+
scopes: string[];
|
|
36
|
+
tokenHash: string;
|
|
37
|
+
createdAt: number;
|
|
38
|
+
lastUsedAt: number;
|
|
39
|
+
revokedAt?: number;
|
|
40
|
+
}
|
|
41
|
+
export interface PairingInvitation {
|
|
42
|
+
endpoint: string;
|
|
43
|
+
code: string;
|
|
44
|
+
nodeId: string;
|
|
45
|
+
fingerprint: string;
|
|
46
|
+
network: string;
|
|
47
|
+
scopes: string[];
|
|
48
|
+
capabilities: PairingCapabilities;
|
|
49
|
+
expiresAt: number;
|
|
50
|
+
}
|
|
51
|
+
export interface PairingServiceOptions {
|
|
52
|
+
storePath: string;
|
|
53
|
+
endpoint?: string;
|
|
54
|
+
network?: string;
|
|
55
|
+
ttlMs?: number;
|
|
56
|
+
allowedOrigins?: string[];
|
|
57
|
+
scopes?: string[];
|
|
58
|
+
capabilities?: PairingCapabilities;
|
|
59
|
+
now?: () => number;
|
|
60
|
+
}
|
|
61
|
+
export interface PairingService {
|
|
62
|
+
readonly nodeId: string;
|
|
63
|
+
readonly fingerprint: string;
|
|
64
|
+
readonly network: string;
|
|
65
|
+
readonly scopes: string[];
|
|
66
|
+
readonly capabilities: PairingCapabilities;
|
|
67
|
+
createInvitation(): PairingInvitation;
|
|
68
|
+
info(code: string): Omit<PairingInvitation, 'code' | 'endpoint'>;
|
|
69
|
+
complete(input: {
|
|
70
|
+
code: string;
|
|
71
|
+
devicePublicKey: string;
|
|
72
|
+
origin: string;
|
|
73
|
+
scopes?: string[];
|
|
74
|
+
}): {
|
|
75
|
+
token: string;
|
|
76
|
+
clientId: string;
|
|
77
|
+
node: {
|
|
78
|
+
nodeId: string;
|
|
79
|
+
fingerprint: string;
|
|
80
|
+
network: string;
|
|
81
|
+
};
|
|
82
|
+
capabilities: PairingCapabilities;
|
|
83
|
+
};
|
|
84
|
+
identity(): {
|
|
85
|
+
nodeId: string;
|
|
86
|
+
fingerprint: string;
|
|
87
|
+
publicKey: string;
|
|
88
|
+
network: string;
|
|
89
|
+
capabilities: PairingCapabilities;
|
|
90
|
+
};
|
|
91
|
+
listClients(): LinkedClient[];
|
|
92
|
+
revoke(clientId: string): boolean;
|
|
93
|
+
allowsOrigin(origin?: string): boolean;
|
|
94
|
+
}
|
|
95
|
+
export declare function createPairingService(options: PairingServiceOptions): PairingService;
|
|
96
|
+
export interface PairingHttpServerOptions {
|
|
97
|
+
host?: string;
|
|
98
|
+
port: number;
|
|
99
|
+
maxBodyBytes?: number;
|
|
100
|
+
}
|
|
101
|
+
export interface PairingHttpServer {
|
|
102
|
+
listen(): Promise<{
|
|
103
|
+
host: string;
|
|
104
|
+
port: number;
|
|
105
|
+
}>;
|
|
106
|
+
close(): Promise<void>;
|
|
107
|
+
readonly address?: AddressInfo;
|
|
108
|
+
}
|
|
109
|
+
export declare function createPairingHttpServer(service: PairingService, options: PairingHttpServerOptions): PairingHttpServer;
|
package/dist/pairing.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* RFC-062 — local node/extension pairing MVP.
|
|
4
|
+
*
|
|
5
|
+
* A loopback HTTP service issues one-time pairing codes, verifies the extension
|
|
6
|
+
* origin, binds a device key and returns a scoped bearer token plus the node
|
|
7
|
+
* identity/fingerprint and advertised capabilities (chain RPC, P2P hub, lease).
|
|
8
|
+
*/
|
|
9
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
10
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
11
|
+
};
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.createPairingService = createPairingService;
|
|
14
|
+
exports.createPairingHttpServer = createPairingHttpServer;
|
|
15
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
16
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
17
|
+
const node_http_1 = __importDefault(require("node:http"));
|
|
18
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
19
|
+
function sha256(value) {
|
|
20
|
+
return node_crypto_1.default.createHash('sha256').update(value).digest('hex');
|
|
21
|
+
}
|
|
22
|
+
function loadStore(storePath) {
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(node_fs_1.default.readFileSync(storePath, 'utf8'));
|
|
25
|
+
if (parsed.identity?.publicKey && parsed.identity?.privateKey) {
|
|
26
|
+
return { identity: parsed.identity, clients: parsed.clients ?? {} };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Create a new identity below.
|
|
31
|
+
}
|
|
32
|
+
const pair = node_crypto_1.default.generateKeyPairSync('ed25519');
|
|
33
|
+
const identity = {
|
|
34
|
+
publicKey: pair.publicKey.export({ type: 'spki', format: 'der' }).toString('base64'),
|
|
35
|
+
privateKey: pair.privateKey.export({ type: 'pkcs8', format: 'der' }).toString('base64'),
|
|
36
|
+
createdAt: Date.now(),
|
|
37
|
+
};
|
|
38
|
+
const store = { identity, clients: {} };
|
|
39
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(storePath), { recursive: true });
|
|
40
|
+
node_fs_1.default.writeFileSync(storePath, JSON.stringify(store, null, 2), { mode: 0o600 });
|
|
41
|
+
return store;
|
|
42
|
+
}
|
|
43
|
+
function createPairingService(options) {
|
|
44
|
+
const now = options.now ?? (() => Date.now());
|
|
45
|
+
const ttlMs = options.ttlMs ?? 5 * 60_000;
|
|
46
|
+
const endpoint = options.endpoint ?? 'http://127.0.0.1:7789';
|
|
47
|
+
const network = options.network ?? 'minima';
|
|
48
|
+
const scopes = options.scopes ?? ['chain.read', 'chain.submit', 'p2p.pubsub'];
|
|
49
|
+
const capabilities = options.capabilities ?? {};
|
|
50
|
+
const store = loadStore(options.storePath);
|
|
51
|
+
const sessions = new Map();
|
|
52
|
+
const publicKeyBytes = Buffer.from(store.identity.publicKey, 'base64');
|
|
53
|
+
const fingerprint = sha256(publicKeyBytes);
|
|
54
|
+
const nodeId = fingerprint.slice(0, 16);
|
|
55
|
+
const persist = () => {
|
|
56
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(options.storePath), { recursive: true });
|
|
57
|
+
node_fs_1.default.writeFileSync(options.storePath, JSON.stringify(store, null, 2), { mode: 0o600 });
|
|
58
|
+
};
|
|
59
|
+
const allowsOrigin = (origin) => {
|
|
60
|
+
if (!origin)
|
|
61
|
+
return true;
|
|
62
|
+
if (!options.allowedOrigins || options.allowedOrigins.length === 0)
|
|
63
|
+
return true;
|
|
64
|
+
return options.allowedOrigins.includes(origin);
|
|
65
|
+
};
|
|
66
|
+
const baseInfo = () => ({
|
|
67
|
+
nodeId,
|
|
68
|
+
fingerprint,
|
|
69
|
+
network,
|
|
70
|
+
scopes,
|
|
71
|
+
capabilities,
|
|
72
|
+
});
|
|
73
|
+
return {
|
|
74
|
+
nodeId,
|
|
75
|
+
fingerprint,
|
|
76
|
+
network,
|
|
77
|
+
scopes,
|
|
78
|
+
capabilities,
|
|
79
|
+
createInvitation() {
|
|
80
|
+
const code = node_crypto_1.default.randomBytes(18).toString('base64url');
|
|
81
|
+
const hash = sha256(code);
|
|
82
|
+
const expiresAt = now() + ttlMs;
|
|
83
|
+
sessions.set(hash, { hash, expiresAt, attempts: 0 });
|
|
84
|
+
// Opportunistic cleanup; expired sessions cannot be completed.
|
|
85
|
+
for (const [key, session] of sessions) {
|
|
86
|
+
if (session.expiresAt <= now())
|
|
87
|
+
sessions.delete(key);
|
|
88
|
+
}
|
|
89
|
+
return { endpoint, code, ...baseInfo(), expiresAt };
|
|
90
|
+
},
|
|
91
|
+
info(code) {
|
|
92
|
+
const session = sessions.get(sha256(code));
|
|
93
|
+
if (!session || session.expiresAt <= now())
|
|
94
|
+
throw new Error('Invalid or expired pairing code');
|
|
95
|
+
return { ...baseInfo(), expiresAt: session.expiresAt };
|
|
96
|
+
},
|
|
97
|
+
complete({ code, devicePublicKey, origin, scopes: requested }) {
|
|
98
|
+
const hash = sha256(code);
|
|
99
|
+
const session = sessions.get(hash);
|
|
100
|
+
if (!session || session.expiresAt <= now())
|
|
101
|
+
throw new Error('Invalid or expired pairing code');
|
|
102
|
+
if (!allowsOrigin(origin))
|
|
103
|
+
throw new Error('Origin not allowed for pairing');
|
|
104
|
+
const granted = requested ?? scopes;
|
|
105
|
+
if (granted.some((scope) => !scopes.includes(scope)))
|
|
106
|
+
throw new Error('Requested scope is not offered');
|
|
107
|
+
const token = node_crypto_1.default.randomBytes(32).toString('base64url');
|
|
108
|
+
const clientId = `pair-${node_crypto_1.default.randomBytes(8).toString('hex')}`;
|
|
109
|
+
store.clients[clientId] = {
|
|
110
|
+
clientId,
|
|
111
|
+
origin,
|
|
112
|
+
devicePublicKey,
|
|
113
|
+
scopes: granted,
|
|
114
|
+
tokenHash: sha256(token),
|
|
115
|
+
createdAt: now(),
|
|
116
|
+
lastUsedAt: now(),
|
|
117
|
+
};
|
|
118
|
+
persist();
|
|
119
|
+
sessions.delete(hash);
|
|
120
|
+
return {
|
|
121
|
+
token,
|
|
122
|
+
clientId,
|
|
123
|
+
node: { nodeId, fingerprint, network },
|
|
124
|
+
capabilities,
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
identity() {
|
|
128
|
+
return {
|
|
129
|
+
nodeId,
|
|
130
|
+
fingerprint,
|
|
131
|
+
publicKey: store.identity.publicKey,
|
|
132
|
+
network,
|
|
133
|
+
capabilities,
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
listClients() {
|
|
137
|
+
return Object.values(store.clients);
|
|
138
|
+
},
|
|
139
|
+
revoke(clientId) {
|
|
140
|
+
const client = store.clients[clientId];
|
|
141
|
+
if (!client)
|
|
142
|
+
return false;
|
|
143
|
+
client.revokedAt = now();
|
|
144
|
+
persist();
|
|
145
|
+
return true;
|
|
146
|
+
},
|
|
147
|
+
allowsOrigin,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function createPairingHttpServer(service, options) {
|
|
151
|
+
const maxBodyBytes = options.maxBodyBytes ?? 16 * 1024;
|
|
152
|
+
const server = node_http_1.default.createServer(async (req, res) => {
|
|
153
|
+
const origin = typeof req.headers.origin === 'string' ? req.headers.origin : undefined;
|
|
154
|
+
const cors = () => {
|
|
155
|
+
if (origin && service.allowsOrigin(origin)) {
|
|
156
|
+
res.setHeader('access-control-allow-origin', origin);
|
|
157
|
+
res.setHeader('vary', 'Origin');
|
|
158
|
+
}
|
|
159
|
+
res.setHeader('access-control-allow-methods', 'GET,POST,OPTIONS');
|
|
160
|
+
res.setHeader('access-control-allow-headers', 'content-type');
|
|
161
|
+
};
|
|
162
|
+
cors();
|
|
163
|
+
if (req.method === 'OPTIONS') {
|
|
164
|
+
res.writeHead(204).end();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const send = (status, body) => {
|
|
168
|
+
const payload = JSON.stringify(body);
|
|
169
|
+
res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) });
|
|
170
|
+
res.end(payload);
|
|
171
|
+
};
|
|
172
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
173
|
+
try {
|
|
174
|
+
if (req.method === 'GET' && url.pathname === '/healthz')
|
|
175
|
+
return send(200, { ok: true });
|
|
176
|
+
if (req.method === 'GET' && url.pathname === '/node/v1/identity')
|
|
177
|
+
return send(200, service.identity());
|
|
178
|
+
if (req.method === 'GET' && url.pathname === '/pair/v1/info') {
|
|
179
|
+
const code = url.searchParams.get('code');
|
|
180
|
+
if (!code)
|
|
181
|
+
return send(400, { error: 'code is required' });
|
|
182
|
+
if (origin && !service.allowsOrigin(origin))
|
|
183
|
+
return send(403, { error: 'Origin not allowed' });
|
|
184
|
+
return send(200, service.info(code));
|
|
185
|
+
}
|
|
186
|
+
if (req.method === 'POST' && url.pathname === '/pair/v1/complete') {
|
|
187
|
+
const body = await readBody(req, maxBodyBytes);
|
|
188
|
+
const parsed = JSON.parse(body);
|
|
189
|
+
if (!parsed.code || !parsed.devicePublicKey)
|
|
190
|
+
return send(400, { error: 'code and devicePublicKey are required' });
|
|
191
|
+
return send(200, service.complete({
|
|
192
|
+
code: parsed.code,
|
|
193
|
+
devicePublicKey: parsed.devicePublicKey,
|
|
194
|
+
origin: parsed.origin ?? origin ?? '',
|
|
195
|
+
scopes: parsed.scopes,
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
send(404, { error: 'Not found' });
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
send(400, { error: error instanceof Error ? error.message : String(error) });
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
let address;
|
|
205
|
+
return {
|
|
206
|
+
get address() { return address; },
|
|
207
|
+
listen: () => new Promise((resolve, reject) => {
|
|
208
|
+
server.once('error', reject);
|
|
209
|
+
server.listen(options.port, options.host ?? '127.0.0.1', () => {
|
|
210
|
+
server.off('error', reject);
|
|
211
|
+
address = server.address();
|
|
212
|
+
resolve({ host: address.address, port: address.port });
|
|
213
|
+
});
|
|
214
|
+
}),
|
|
215
|
+
close: () => new Promise((resolve, reject) => {
|
|
216
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
217
|
+
}),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
async function readBody(req, limit) {
|
|
221
|
+
const chunks = [];
|
|
222
|
+
let total = 0;
|
|
223
|
+
for await (const chunk of req) {
|
|
224
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
225
|
+
total += buffer.length;
|
|
226
|
+
if (total > limit)
|
|
227
|
+
throw new Error('Request body too large');
|
|
228
|
+
chunks.push(buffer);
|
|
229
|
+
}
|
|
230
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
231
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local signer port (RFC-058 P1).
|
|
3
|
+
*
|
|
4
|
+
* Adapts the host's unified TreeKey signer + WOTS lease to the SDK `SignerPort`
|
|
5
|
+
* contract. A hosted deployment supplies its own `SignerPort` (KMS/HSM/MPC +
|
|
6
|
+
* policy) instead; the host code is agnostic.
|
|
7
|
+
*/
|
|
8
|
+
import { type CustodyMode, type SignDomain, type SignRequest, type SignerPort } from '@totemsdk/omnia';
|
|
9
|
+
import type { HostSigning } from './signing.js';
|
|
10
|
+
export declare function createLocalSignerPort(signing: HostSigning): SignerPort;
|
|
11
|
+
/** Hosted/KMS policy checks applied before delegating to the underlying port. */
|
|
12
|
+
export interface SignerPolicy {
|
|
13
|
+
allowedDomains?: SignDomain[];
|
|
14
|
+
allowedModes?: CustodyMode[];
|
|
15
|
+
/** Reject requests whose declared amount exceeds this value (base units). */
|
|
16
|
+
maxAmount?: bigint;
|
|
17
|
+
/** Require a disclosure hash/version on the custody context. */
|
|
18
|
+
requireDisclosure?: boolean;
|
|
19
|
+
/** Additional operator approval hook (KMS/HSM/MPC or quorum). */
|
|
20
|
+
authorize?: (request: SignRequest) => void | Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Wrap a signer port with policy checks. The underlying port may be a local
|
|
24
|
+
* signer, KMS/HSM/MPC adapter, or hosted policy engine; this wrapper only
|
|
25
|
+
* enforces the deployment policy and disclosure requirements.
|
|
26
|
+
*/
|
|
27
|
+
export declare function createPolicySignerPort(base: SignerPort, policy?: SignerPolicy): SignerPort;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Local signer port (RFC-058 P1).
|
|
4
|
+
*
|
|
5
|
+
* Adapts the host's unified TreeKey signer + WOTS lease to the SDK `SignerPort`
|
|
6
|
+
* contract. A hosted deployment supplies its own `SignerPort` (KMS/HSM/MPC +
|
|
7
|
+
* policy) instead; the host code is agnostic.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.createLocalSignerPort = createLocalSignerPort;
|
|
11
|
+
exports.createPolicySignerPort = createPolicySignerPort;
|
|
12
|
+
const core_1 = require("@totemsdk/core");
|
|
13
|
+
const omnia_1 = require("@totemsdk/omnia");
|
|
14
|
+
function createLocalSignerPort(signing) {
|
|
15
|
+
return {
|
|
16
|
+
publicKeyDigest: signing.publicKeyDigest,
|
|
17
|
+
async sign(request) {
|
|
18
|
+
(0, omnia_1.assertSignRequest)(request);
|
|
19
|
+
if (!request.signers.includes(signing.publicKeyDigest)) {
|
|
20
|
+
throw new Error(`local signer ${signing.publicKeyDigest} is not a required signer for ${request.requestId}`);
|
|
21
|
+
}
|
|
22
|
+
const payload = (0, omnia_1.signRequestPayload)(request);
|
|
23
|
+
if (request.signingIndices) {
|
|
24
|
+
// Caller owns the lease (channel/factory tx signing); sign the exact leaf.
|
|
25
|
+
return signing.signer.sign(payload, request.signingIndices);
|
|
26
|
+
}
|
|
27
|
+
const reservation = await signing.leaseProvider.reserveKeyUse({
|
|
28
|
+
treeId: 'omnia-sign-request',
|
|
29
|
+
purpose: `sign-request:${request.domain}`,
|
|
30
|
+
payloadHash: (0, core_1.bytesToHex)(payload),
|
|
31
|
+
});
|
|
32
|
+
try {
|
|
33
|
+
const signature = await signing.signer.sign(payload, reservation.indices);
|
|
34
|
+
await signing.leaseProvider.commitKeyUse(reservation.reservationId, `sign-request:${request.domain}`);
|
|
35
|
+
return signature;
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
await signing.leaseProvider
|
|
39
|
+
.burnReservation(reservation.reservationId, 'sign-request-failed')
|
|
40
|
+
.catch(() => { });
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Wrap a signer port with policy checks. The underlying port may be a local
|
|
48
|
+
* signer, KMS/HSM/MPC adapter, or hosted policy engine; this wrapper only
|
|
49
|
+
* enforces the deployment policy and disclosure requirements.
|
|
50
|
+
*/
|
|
51
|
+
function createPolicySignerPort(base, policy = {}) {
|
|
52
|
+
return {
|
|
53
|
+
publicKeyDigest: base.publicKeyDigest,
|
|
54
|
+
async sign(request) {
|
|
55
|
+
(0, omnia_1.assertSignRequest)(request);
|
|
56
|
+
if (!request.signers.includes(base.publicKeyDigest)) {
|
|
57
|
+
throw new Error(`signer ${base.publicKeyDigest} is not a required signer for ${request.requestId}`);
|
|
58
|
+
}
|
|
59
|
+
if (policy.allowedDomains && !policy.allowedDomains.includes(request.domain)) {
|
|
60
|
+
throw new Error(`domain ${request.domain} is not allowed by policy`);
|
|
61
|
+
}
|
|
62
|
+
if (policy.allowedModes && !policy.allowedModes.includes(request.custody.mode)) {
|
|
63
|
+
throw new Error(`custody mode ${request.custody.mode} is not allowed by policy`);
|
|
64
|
+
}
|
|
65
|
+
if (policy.requireDisclosure && (!request.custody.disclosureHash || !request.custody.disclosureVersion)) {
|
|
66
|
+
throw new Error('policy requires a disclosure hash and version');
|
|
67
|
+
}
|
|
68
|
+
const amount = request.context.amount;
|
|
69
|
+
if (policy.maxAmount !== undefined && amount !== undefined) {
|
|
70
|
+
const parsed = BigInt(amount);
|
|
71
|
+
if (parsed > policy.maxAmount) {
|
|
72
|
+
throw new Error(`request amount ${parsed} exceeds policy limit ${policy.maxAmount}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
await policy.authorize?.(request);
|
|
76
|
+
return base.sign(request);
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -9,10 +9,11 @@ export interface OperationRecord {
|
|
|
9
9
|
result?: unknown;
|
|
10
10
|
error?: string;
|
|
11
11
|
}
|
|
12
|
-
/** Durable operation journal with atomic status transitions. */
|
|
12
|
+
/** Durable operation journal with atomic status transitions, via storage SDK. */
|
|
13
13
|
export declare class OperationStore {
|
|
14
|
-
private readonly
|
|
14
|
+
private readonly kv;
|
|
15
15
|
constructor(dbPath: string);
|
|
16
|
+
private migrateLegacy;
|
|
16
17
|
get(operationId: string): OperationRecord | undefined;
|
|
17
18
|
create(operationId: string, request?: unknown, now?: number): OperationRecord;
|
|
18
19
|
verifyRequest(operationId: string, request: unknown): boolean;
|
|
@@ -1,120 +1,133 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
-
};
|
|
5
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
3
|
exports.OperationStore = void 0;
|
|
7
|
-
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
|
|
8
4
|
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const sqlite_sync_1 = require("@totemsdk/storage/sqlite-sync");
|
|
6
|
+
const PREFIX = 'omnia:operation:';
|
|
7
|
+
const FORMAT_KEY = 'omnia:store:operation:format';
|
|
8
|
+
const STORE_FORMAT = 1;
|
|
9
9
|
/**
|
|
10
|
-
* CAN-001: operation requests/results can carry `bigint` amounts
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* primitive it cannot persist.
|
|
10
|
+
* CAN-001: operation requests/results can carry `bigint` amounts. The storage
|
|
11
|
+
* codec tags bigints natively, so the record is persisted as-is. Legacy
|
|
12
|
+
* `omnia_operations` rows (JSON with `{$bigint}` tags) are migrated on open.
|
|
14
13
|
*/
|
|
15
|
-
function
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
function encode(value) {
|
|
28
|
-
return value === undefined ? null : JSON.stringify(value, bigintReplacer);
|
|
29
|
-
}
|
|
30
|
-
function decode(value) {
|
|
31
|
-
return value === null ? undefined : JSON.parse(value, bigintReviver);
|
|
32
|
-
}
|
|
33
|
-
function toRecord(row) {
|
|
34
|
-
return {
|
|
35
|
-
operationId: row.operation_id,
|
|
36
|
-
status: row.status,
|
|
37
|
-
createdAt: row.created_at,
|
|
38
|
-
updatedAt: row.updated_at,
|
|
39
|
-
request: decode(row.request_json),
|
|
40
|
-
requestDigest: row.request_digest ?? undefined,
|
|
41
|
-
result: decode(row.result_json),
|
|
42
|
-
error: row.error ?? undefined,
|
|
43
|
-
};
|
|
14
|
+
function decodeLegacy(value) {
|
|
15
|
+
if (value === null)
|
|
16
|
+
return undefined;
|
|
17
|
+
return JSON.parse(value, (_key, nested) => {
|
|
18
|
+
if (nested !== null &&
|
|
19
|
+
typeof nested === 'object' &&
|
|
20
|
+
typeof nested.$bigint === 'string' &&
|
|
21
|
+
Object.keys(nested).length === 1) {
|
|
22
|
+
return BigInt(nested.$bigint);
|
|
23
|
+
}
|
|
24
|
+
return nested;
|
|
25
|
+
});
|
|
44
26
|
}
|
|
45
|
-
/** Durable operation journal with atomic status transitions. */
|
|
27
|
+
/** Durable operation journal with atomic status transitions, via storage SDK. */
|
|
46
28
|
class OperationStore {
|
|
47
|
-
|
|
29
|
+
kv;
|
|
48
30
|
constructor(dbPath) {
|
|
49
|
-
this.
|
|
50
|
-
this.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
status TEXT NOT NULL,
|
|
55
|
-
created_at INTEGER NOT NULL,
|
|
56
|
-
updated_at INTEGER NOT NULL,
|
|
57
|
-
request_json TEXT,
|
|
58
|
-
request_digest TEXT,
|
|
59
|
-
result_json TEXT,
|
|
60
|
-
error TEXT
|
|
61
|
-
)
|
|
62
|
-
`);
|
|
63
|
-
const columns = this.db.prepare('PRAGMA table_info(omnia_operations)').all();
|
|
64
|
-
if (!columns.some((column) => column.name === 'request_digest')) {
|
|
65
|
-
this.db.exec('ALTER TABLE omnia_operations ADD COLUMN request_digest TEXT');
|
|
31
|
+
this.kv = new sqlite_sync_1.SyncSqliteStore(dbPath);
|
|
32
|
+
const version = this.kv.get(FORMAT_KEY);
|
|
33
|
+
if (version !== null && version !== STORE_FORMAT) {
|
|
34
|
+
this.kv.close();
|
|
35
|
+
throw new Error(`Unsupported OperationStore format ${version} (this build supports ${STORE_FORMAT}) — refusing to open`);
|
|
66
36
|
}
|
|
37
|
+
if (version === null)
|
|
38
|
+
this.migrateLegacy();
|
|
39
|
+
}
|
|
40
|
+
migrateLegacy() {
|
|
41
|
+
// One-time migration from the pre-storage direct-SQLite table.
|
|
42
|
+
this.kv.migrateLegacyTable('omnia_operations', (row) => {
|
|
43
|
+
const request = decodeLegacy(row['request_json'] ?? null);
|
|
44
|
+
const result = decodeLegacy(row['result_json'] ?? null);
|
|
45
|
+
const digestValue = row['request_digest'] ? String(row['request_digest']) : undefined;
|
|
46
|
+
const error = row['error'] ? String(row['error']) : undefined;
|
|
47
|
+
const record = {
|
|
48
|
+
operationId: String(row['operation_id']),
|
|
49
|
+
status: row['status'] ?? 'unknown',
|
|
50
|
+
createdAt: Number(row['created_at']),
|
|
51
|
+
updatedAt: Number(row['updated_at']),
|
|
52
|
+
...(request !== undefined ? { request } : {}),
|
|
53
|
+
...(digestValue ? { requestDigest: digestValue } : {}),
|
|
54
|
+
...(result !== undefined ? { result } : {}),
|
|
55
|
+
...(error ? { error } : {}),
|
|
56
|
+
};
|
|
57
|
+
return { key: PREFIX + record.operationId, value: record };
|
|
58
|
+
});
|
|
59
|
+
this.kv.set(FORMAT_KEY, STORE_FORMAT);
|
|
67
60
|
}
|
|
68
61
|
get(operationId) {
|
|
69
|
-
|
|
70
|
-
return row ? toRecord(row) : undefined;
|
|
62
|
+
return this.kv.get(PREFIX + operationId) ?? undefined;
|
|
71
63
|
}
|
|
72
64
|
create(operationId, request, now = Date.now()) {
|
|
73
|
-
this.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
65
|
+
return this.kv.transaction(() => {
|
|
66
|
+
const existing = this.get(operationId);
|
|
67
|
+
if (existing)
|
|
68
|
+
return existing;
|
|
69
|
+
const record = {
|
|
70
|
+
operationId,
|
|
71
|
+
status: 'pending',
|
|
72
|
+
createdAt: now,
|
|
73
|
+
updatedAt: now,
|
|
74
|
+
...(request !== undefined ? { request } : {}),
|
|
75
|
+
requestDigest: digest(request),
|
|
76
|
+
};
|
|
77
|
+
this.kv.set(PREFIX + operationId, record);
|
|
78
|
+
return record;
|
|
79
|
+
});
|
|
82
80
|
}
|
|
83
81
|
verifyRequest(operationId, request) {
|
|
84
82
|
const record = this.get(operationId);
|
|
85
83
|
return !record?.requestDigest || record.requestDigest === digest(request);
|
|
86
84
|
}
|
|
87
85
|
transition(operationId, from, to, patch = {}, now = Date.now()) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
WHERE operation_id = ? AND status = ?
|
|
92
|
-
`).run(to, now, encode(patch.result), patch.error ?? null, operationId, from);
|
|
93
|
-
if (result.changes !== 1) {
|
|
94
|
-
const current = this.get(operationId);
|
|
95
|
-
if (!current)
|
|
86
|
+
return this.kv.transaction(() => {
|
|
87
|
+
const record = this.get(operationId);
|
|
88
|
+
if (!record)
|
|
96
89
|
throw new Error(`Unknown operation ${operationId}`);
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
90
|
+
if (record.status !== from) {
|
|
91
|
+
throw new Error(`Operation ${operationId} expected status ${from} but is ${record.status}`);
|
|
92
|
+
}
|
|
93
|
+
const next = {
|
|
94
|
+
...record,
|
|
95
|
+
status: to,
|
|
96
|
+
updatedAt: now,
|
|
97
|
+
...(patch.error !== undefined ? { error: patch.error } : {}),
|
|
98
|
+
...(patch.result !== undefined ? { result: patch.result } : {}),
|
|
99
|
+
};
|
|
100
|
+
this.kv.set(PREFIX + operationId, next);
|
|
101
|
+
return next;
|
|
102
|
+
});
|
|
100
103
|
}
|
|
101
104
|
listByStatus(status) {
|
|
102
|
-
|
|
103
|
-
|
|
105
|
+
return this.kv
|
|
106
|
+
.keys()
|
|
107
|
+
.filter((key) => key.startsWith(PREFIX))
|
|
108
|
+
.map((key) => this.kv.get(key))
|
|
109
|
+
.filter((record) => record !== null)
|
|
110
|
+
.filter((record) => record.status === status)
|
|
111
|
+
.sort((a, b) => a.createdAt - b.createdAt);
|
|
104
112
|
}
|
|
105
113
|
close() {
|
|
106
|
-
this.
|
|
114
|
+
this.kv.close();
|
|
107
115
|
}
|
|
108
116
|
}
|
|
109
117
|
exports.OperationStore = OperationStore;
|
|
110
118
|
function canonical(value) {
|
|
119
|
+
if (value === undefined)
|
|
120
|
+
return 'undefined';
|
|
111
121
|
if (typeof value === 'bigint')
|
|
112
122
|
return `{"$bigint":${JSON.stringify(value.toString())}}`;
|
|
113
123
|
if (value === null || typeof value !== 'object')
|
|
114
|
-
return JSON.stringify(value);
|
|
124
|
+
return JSON.stringify(value) ?? 'null';
|
|
115
125
|
if (Array.isArray(value))
|
|
116
126
|
return `[${value.map(canonical).join(',')}]`;
|
|
117
|
-
return `{${Object.keys(value)
|
|
127
|
+
return `{${Object.keys(value)
|
|
128
|
+
.sort()
|
|
129
|
+
.map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`)
|
|
130
|
+
.join(',')}}`;
|
|
118
131
|
}
|
|
119
132
|
function digest(value) {
|
|
120
133
|
return (0, node_crypto_1.createHash)('sha256').update(canonical(value)).digest('hex');
|