epistery 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.
@@ -0,0 +1,334 @@
1
+ import { ethers, Wallet } from 'ethers';
2
+ import { Config } from './Config';
3
+ import { DomainConfig } from './types';
4
+ import fs from 'fs';
5
+ import { join } from 'path';
6
+
7
+ /**
8
+ * CliWallet - Manages wallet operations for CLI/bot contexts
9
+ *
10
+ * Uses Epistery's domain configuration system:
11
+ * - Domain configs stored in ~/.epistery/{domain}/config.ini
12
+ * - Each domain has its own wallet (like server-side)
13
+ * - Default domain configurable in ~/.epistery/config.ini [cli] section
14
+ * - Automatic wallet creation on initialize
15
+ *
16
+ * This matches the server-side model where each domain has a wallet,
17
+ * making CLI usage consistent with server architecture.
18
+ */
19
+
20
+ export interface KeyExchangeRequest {
21
+ clientAddress: string;
22
+ clientPublicKey: string;
23
+ challenge: string;
24
+ message: string;
25
+ signature: string;
26
+ walletSource: string;
27
+ }
28
+
29
+ export interface KeyExchangeResponse {
30
+ serverAddress: string;
31
+ serverPublicKey: string;
32
+ services: string[];
33
+ challenge: string;
34
+ signature: string;
35
+ identified: boolean;
36
+ authenticated?: boolean;
37
+ profile?: any;
38
+ }
39
+
40
+ export interface SessionInfo {
41
+ domain: string;
42
+ cookie: string;
43
+ authenticated: boolean;
44
+ timestamp: string;
45
+ }
46
+
47
+ export class CliWallet {
48
+ private config: Config;
49
+ private domainName: string;
50
+ private domainConfig: DomainConfig;
51
+ private wallet: Wallet;
52
+ public address: string;
53
+ public publicKey: string;
54
+
55
+ private constructor(config: Config, domainName: string, domainConfig: DomainConfig, wallet: Wallet) {
56
+ this.config = config;
57
+ this.domainName = domainName.toLowerCase();
58
+ this.domainConfig = domainConfig;
59
+ this.wallet = wallet;
60
+ this.address = wallet.address;
61
+ this.publicKey = wallet.publicKey;
62
+ }
63
+
64
+ /**
65
+ * Get the default domain from config.ini [cli] section
66
+ */
67
+ static getDefaultDomain(): string {
68
+ const config = new Config();
69
+ return (config.data as any).cli?.default_domain || 'localhost';
70
+ }
71
+
72
+ /**
73
+ * Set the default domain in config.ini [cli] section
74
+ */
75
+ static setDefaultDomain(domain: string): void {
76
+ const config = new Config();
77
+ if (!(config.data as any).cli) {
78
+ (config.data as any).cli = {};
79
+ }
80
+ (config.data as any).cli.default_domain = domain;
81
+ config.save();
82
+ }
83
+
84
+ /**
85
+ * Initialize a new domain with wallet
86
+ * Creates ~/.epistery/{domain}/config.ini with new wallet
87
+ */
88
+ static initialize(domain: string, provider?: { name: string, chainId: number, rpc: string }): CliWallet {
89
+ const config = new Config();
90
+
91
+ // Check if domain already exists
92
+ config.setPath(`/${domain}`);
93
+ config.load();
94
+ if (config.data.wallet) {
95
+ throw new Error(`Domain '${domain}' already initialized. Use load() to access it.`);
96
+ }
97
+
98
+ // Create new wallet
99
+ const ethersWallet = ethers.Wallet.createRandom();
100
+
101
+ // Get provider from root default, argument, or fallback default
102
+ config.setPath('/');
103
+ config.load();
104
+ const providerConfig = provider || config.data.default?.provider || {
105
+ chainId: 420420422,
106
+ name: 'polkadot-hub-testnet',
107
+ rpc: 'https://testnet-passet-hub-eth-rpc.polkadot.io'
108
+ };
109
+
110
+ // Create domain config
111
+ config.setPath(`/${domain}`);
112
+ config.data = {
113
+ domain: domain,
114
+ wallet: {
115
+ address: ethersWallet.address,
116
+ mnemonic: ethersWallet.mnemonic?.phrase || '',
117
+ publicKey: ethersWallet.publicKey,
118
+ privateKey: ethersWallet.privateKey
119
+ },
120
+ provider: providerConfig
121
+ };
122
+ config.save();
123
+
124
+ console.log(`Initialized domain: ${domain}`);
125
+ console.log(`Address: ${ethersWallet.address}`);
126
+ console.log(`Provider: ${providerConfig.name}`);
127
+
128
+ return new CliWallet(config, domain, config.data, ethersWallet);
129
+ }
130
+
131
+ /**
132
+ * Load domain wallet from config
133
+ * Throws if domain doesn't exist - use initialize() first
134
+ */
135
+ static load(domain?: string): CliWallet {
136
+ const config = new Config();
137
+ const domainName = domain || CliWallet.getDefaultDomain();
138
+
139
+ config.setPath(`/${domainName}`);
140
+ config.load();
141
+
142
+ if (!config.data.wallet) {
143
+ throw new Error(
144
+ `Domain '${domainName}' not found or has no wallet. ` +
145
+ `Initialize with: epistery initialize ${domainName}`
146
+ );
147
+ }
148
+
149
+ // Reconstruct wallet from config
150
+ let ethersWallet: Wallet;
151
+ if (config.data.wallet.mnemonic) {
152
+ ethersWallet = ethers.Wallet.fromMnemonic(config.data.wallet.mnemonic);
153
+ } else if (config.data.wallet.privateKey) {
154
+ ethersWallet = new ethers.Wallet(config.data.wallet.privateKey);
155
+ } else {
156
+ throw new Error(`Domain '${domainName}' wallet has no mnemonic or privateKey`);
157
+ }
158
+
159
+ return new CliWallet(config, domainName, config.data, ethersWallet);
160
+ }
161
+
162
+ /**
163
+ * Get domain name
164
+ */
165
+ getDomain(): string {
166
+ return this.domainName;
167
+ }
168
+
169
+ /**
170
+ * Get provider info
171
+ */
172
+ getProvider() {
173
+ return this.domainConfig.provider;
174
+ }
175
+
176
+ /**
177
+ * Sign a message
178
+ */
179
+ async sign(message: string): Promise<string> {
180
+ return await this.wallet.signMessage(message);
181
+ }
182
+
183
+ /**
184
+ * Perform key exchange with an Epistery server
185
+ * Automatically saves session cookie to domain config
186
+ */
187
+ async performKeyExchange(serverUrl: string): Promise<KeyExchangeResponse> {
188
+ // Ensure server URL is properly formatted
189
+ const baseUrl = serverUrl.replace(/\/$/, '');
190
+ const connectUrl = `${baseUrl}/.well-known/epistery/connect`;
191
+
192
+ // Generate challenge for key exchange
193
+ const challenge = ethers.utils.hexlify(ethers.utils.randomBytes(32));
194
+ const message = `Epistery Key Exchange - ${this.address} - ${challenge}`;
195
+
196
+ // Sign the message
197
+ const signature = await this.sign(message);
198
+
199
+ // Prepare key exchange request
200
+ const requestData: KeyExchangeRequest = {
201
+ clientAddress: this.address,
202
+ clientPublicKey: this.publicKey,
203
+ challenge: challenge,
204
+ message: message,
205
+ signature: signature,
206
+ walletSource: 'server'
207
+ };
208
+
209
+ // Perform key exchange
210
+ const response = await fetch(connectUrl, {
211
+ method: 'POST',
212
+ headers: {
213
+ 'Content-Type': 'application/json'
214
+ },
215
+ body: JSON.stringify(requestData)
216
+ });
217
+
218
+ if (!response.ok) {
219
+ const errorText = await response.text();
220
+ throw new Error(`Key exchange failed: ${response.status} - ${errorText}`);
221
+ }
222
+
223
+ const serverResponse = await response.json() as KeyExchangeResponse;
224
+
225
+ // Verify server's identity
226
+ const expectedMessage = `Epistery Server Response - ${serverResponse.serverAddress} - ${serverResponse.challenge}`;
227
+ const recoveredAddress = ethers.utils.verifyMessage(expectedMessage, serverResponse.signature);
228
+
229
+ if (recoveredAddress.toLowerCase() !== serverResponse.serverAddress.toLowerCase()) {
230
+ throw new Error('Server identity verification failed');
231
+ }
232
+
233
+ // Extract and save session cookie if present
234
+ const cookies = response.headers.get('set-cookie');
235
+ if (cookies) {
236
+ const sessionMatch = cookies.match(/_rhonda_session=([^;]+)/);
237
+ if (sessionMatch) {
238
+ const sessionToken = sessionMatch[1];
239
+
240
+ // Save session to domain config
241
+ this.saveSession({
242
+ domain: serverUrl,
243
+ cookie: sessionToken,
244
+ authenticated: serverResponse.authenticated || false,
245
+ timestamp: new Date().toISOString()
246
+ });
247
+ }
248
+ }
249
+
250
+ return serverResponse;
251
+ }
252
+
253
+ /**
254
+ * Get saved session for a specific server URL
255
+ */
256
+ getSession(serverUrl: string): SessionInfo | null {
257
+ const sessionFile = this.getSessionFilePath(serverUrl);
258
+ if (!fs.existsSync(sessionFile)) {
259
+ return null;
260
+ }
261
+
262
+ try {
263
+ const data = fs.readFileSync(sessionFile, 'utf8');
264
+ return JSON.parse(data) as SessionInfo;
265
+ } catch (error) {
266
+ return null;
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Save session info to domain directory, keyed by server URL
272
+ */
273
+ private saveSession(session: SessionInfo): void {
274
+ const sessionFile = this.getSessionFilePath(session.domain);
275
+ fs.writeFileSync(sessionFile, JSON.stringify(session, null, 2), { mode: 0o600 });
276
+ }
277
+
278
+ /**
279
+ * Clear saved session for a server URL
280
+ */
281
+ clearSession(serverUrl: string): void {
282
+ const sessionFile = this.getSessionFilePath(serverUrl);
283
+ if (fs.existsSync(sessionFile)) {
284
+ fs.unlinkSync(sessionFile);
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Get session file path for a server URL
290
+ * Hashes the server URL to create a safe filename
291
+ */
292
+ private getSessionFilePath(serverUrl: string): string {
293
+ // Create a safe filename from the server URL
294
+ const crypto = require('crypto');
295
+ const hash = crypto.createHash('md5').update(serverUrl).digest('hex');
296
+ const sessionsDir = join(this.config.configDir, this.domainName, 'sessions');
297
+
298
+ // Ensure sessions directory exists
299
+ if (!fs.existsSync(sessionsDir)) {
300
+ fs.mkdirSync(sessionsDir, { mode: 0o700, recursive: true });
301
+ }
302
+
303
+ return join(sessionsDir, `${hash}.json`);
304
+ }
305
+
306
+ /**
307
+ * Create bot authentication header
308
+ * Format: Authorization: Bot <base64-json>
309
+ */
310
+ async createBotAuthHeader(): Promise<string> {
311
+ const message = `Rhonda Bot Authentication - ${new Date().toISOString()}`;
312
+ const signature = await this.sign(message);
313
+
314
+ const payload = {
315
+ address: this.address,
316
+ signature,
317
+ message
318
+ };
319
+
320
+ return `Bot ${Buffer.from(JSON.stringify(payload)).toString('base64')}`;
321
+ }
322
+
323
+ /**
324
+ * Export wallet data (for migration or backup)
325
+ */
326
+ toJSON() {
327
+ return {
328
+ domain: this.domainName,
329
+ address: this.wallet.address,
330
+ publicKey: this.wallet.publicKey,
331
+ provider: this.domainConfig.provider
332
+ };
333
+ }
334
+ }
@@ -0,0 +1,196 @@
1
+ import fs from 'fs';
2
+ import { join } from 'path';
3
+ import ini from 'ini';
4
+
5
+ /**
6
+ * Epistery Config - Path-based configuration system
7
+ *
8
+ * Provides unified, filesystem-like config management:
9
+ * - setPath('/') → ~/.epistery/config.ini
10
+ * - setPath('/domain') → ~/.epistery/domain/config.ini
11
+ * - setPath('/.ssl/domain') → ~/.epistery/.ssl/domain/config.ini
12
+ *
13
+ * Usage:
14
+ * const config = new Config('epistery');
15
+ * config.setPath('/wiki.rootz.global');
16
+ * config.load();
17
+ * config.data.verified = true;
18
+ * config.save();
19
+ */
20
+ export class Config {
21
+ public readonly rootName: string;
22
+ public readonly homeDir: string;
23
+ public readonly configDir: string;
24
+
25
+ private currentPath: string = '/';
26
+ private currentDir: string;
27
+ private currentFile: string;
28
+
29
+ public data: any = {};
30
+
31
+ constructor(rootName: string = 'epistery') {
32
+ this.rootName = rootName;
33
+ this.homeDir = (process.platform === 'win32' ? process.env.USERPROFILE : process.env.HOME) || '';
34
+ this.configDir = join(this.homeDir, '.' + this.rootName);
35
+
36
+ this.currentDir = this.configDir;
37
+ this.currentFile = join(this.configDir, 'config.ini');
38
+
39
+ // Initialize root config if it doesn't exist
40
+ if (!fs.existsSync(this.currentFile)) {
41
+ this.initialize();
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Set current working path (like cd)
47
+ * Examples: '/', '/domain', '/.ssl/domain'
48
+ */
49
+ public setPath(path: string): void {
50
+ // Normalize path: ensure leading slash, remove trailing slash, lowercase
51
+ path = path.trim();
52
+ if (!path.startsWith('/')) path = '/' + path;
53
+ if (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1);
54
+ path = path.toLowerCase();
55
+
56
+ this.currentPath = path;
57
+
58
+ // Calculate directory and file paths
59
+ if (path === '/') {
60
+ this.currentDir = this.configDir;
61
+ this.currentFile = join(this.configDir, 'config.ini');
62
+ } else {
63
+ this.currentDir = join(this.configDir, path.slice(1)); // Remove leading /
64
+ this.currentFile = join(this.currentDir, 'config.ini');
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Get current path
70
+ */
71
+ public getPath(): string {
72
+ return this.currentPath;
73
+ }
74
+
75
+ /**
76
+ * Initialize config at current path
77
+ */
78
+ private initialize(): void {
79
+ if (!fs.existsSync(this.currentDir)) {
80
+ fs.mkdirSync(this.currentDir, { recursive: true });
81
+ }
82
+
83
+ // Write default config for root, empty for paths
84
+ const defaultContent = this.currentPath === '/' ? defaultIni : '';
85
+ fs.writeFileSync(this.currentFile, defaultContent);
86
+ this.data = ini.decode(defaultContent);
87
+ }
88
+
89
+ /**
90
+ * Load config from current path
91
+ */
92
+ public load(): void {
93
+ if (!fs.existsSync(this.currentFile)) {
94
+ this.data = {};
95
+ return;
96
+ }
97
+
98
+ const fileData = fs.readFileSync(this.currentFile, 'utf8');
99
+ this.data = ini.decode(fileData);
100
+ }
101
+
102
+ /**
103
+ * Read config from arbitrary path without changing current path
104
+ * @param path Path to read from (e.g., '/', '/domain')
105
+ * @returns Parsed config data from that path
106
+ */
107
+ public read(path: string): any {
108
+ // Normalize path
109
+ path = path.trim();
110
+ if (!path.startsWith('/')) path = '/' + path;
111
+ if (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1);
112
+ path = path.toLowerCase();
113
+
114
+ // Calculate file location
115
+ let configFile: string;
116
+ if (path === '/') {
117
+ configFile = join(this.configDir, 'config.ini');
118
+ } else {
119
+ configFile = join(this.configDir, path.slice(1), 'config.ini');
120
+ }
121
+
122
+ // Read and parse
123
+ if (!fs.existsSync(configFile)) {
124
+ return {};
125
+ }
126
+
127
+ const fileData = fs.readFileSync(configFile, 'utf8');
128
+ return ini.decode(fileData);
129
+ }
130
+
131
+ /**
132
+ * Save config to current path
133
+ */
134
+ public save(): void {
135
+ if (!fs.existsSync(this.currentDir)) {
136
+ fs.mkdirSync(this.currentDir, { recursive: true });
137
+ }
138
+
139
+ const text = ini.stringify(this.data);
140
+ fs.writeFileSync(this.currentFile, text);
141
+ }
142
+
143
+ /**
144
+ * Read file from current path directory
145
+ */
146
+ public readFile(filename: string): Buffer {
147
+ return fs.readFileSync(join(this.currentDir, filename));
148
+ }
149
+
150
+ /**
151
+ * Write file to current path directory
152
+ */
153
+ public writeFile(filename: string, data: string | Buffer): void {
154
+ if (!fs.existsSync(this.currentDir)) {
155
+ fs.mkdirSync(this.currentDir, { recursive: true });
156
+ }
157
+ fs.writeFileSync(join(this.currentDir, filename), data);
158
+ }
159
+
160
+ /**
161
+ * Check if config exists at current path
162
+ */
163
+ public exists(): boolean {
164
+ return fs.existsSync(this.currentFile);
165
+ }
166
+
167
+ /**
168
+ * List all subdirectories at current path
169
+ */
170
+ public listPaths(): string[] {
171
+ if (!fs.existsSync(this.currentDir)) {
172
+ return [];
173
+ }
174
+
175
+ return fs.readdirSync(this.currentDir, { withFileTypes: true })
176
+ .filter(dirent => dirent.isDirectory())
177
+ .map(dirent => dirent.name);
178
+ }
179
+ }
180
+
181
+ const defaultIni =
182
+ `[profile]
183
+ name=
184
+ email=
185
+
186
+ [ipfs]
187
+ url=https://rootz.digital/api/v0
188
+
189
+ [default.provider]
190
+ chainId=1,
191
+ name=Ethereum Mainnet
192
+ rpc=https://eth.llamarpc.com
193
+ nativeCurrencyName=Ether
194
+ nativeCurrencySymbol=ETH
195
+ nativeCurrencyDecimals=18
196
+ `