epistery 2.0.4 → 2.2.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.
Files changed (45) hide show
  1. package/.test.env +25 -0
  2. package/cli/epistery.mjs +9 -10
  3. package/dist/chains/registry.d.ts +2 -2
  4. package/dist/chains/registry.d.ts.map +1 -1
  5. package/dist/chains/registry.js +4 -4
  6. package/dist/chains/registry.js.map +1 -1
  7. package/dist/epistery.d.ts +1 -1
  8. package/dist/epistery.d.ts.map +1 -1
  9. package/dist/epistery.js +4 -4
  10. package/dist/epistery.js.map +1 -1
  11. package/dist/utils/CliWallet.d.ts +9 -5
  12. package/dist/utils/CliWallet.d.ts.map +1 -1
  13. package/dist/utils/CliWallet.js +18 -13
  14. package/dist/utils/CliWallet.js.map +1 -1
  15. package/dist/utils/Config.d.ts +104 -48
  16. package/dist/utils/Config.d.ts.map +1 -1
  17. package/dist/utils/Config.js +273 -116
  18. package/dist/utils/Config.js.map +1 -1
  19. package/dist/utils/Utils.d.ts +10 -2
  20. package/dist/utils/Utils.d.ts.map +1 -1
  21. package/dist/utils/Utils.js +18 -9
  22. package/dist/utils/Utils.js.map +1 -1
  23. package/docs/RivetSignerConfigAuthority.md +219 -0
  24. package/index.mjs +10 -9
  25. package/package.json +1 -1
  26. package/routes/auth.mjs +6 -6
  27. package/routes/domain.mjs +2 -2
  28. package/routes/fido.mjs +4 -4
  29. package/src/chains/registry.ts +4 -4
  30. package/src/epistery.ts +4 -4
  31. package/src/utils/CliWallet.ts +18 -13
  32. package/src/utils/Config.ts +313 -106
  33. package/src/utils/Utils.ts +19 -9
  34. package/test/config/.epistery/127.0.0.1/config.ini +15 -0
  35. package/test/config/.epistery/config.ini +14 -0
  36. package/test/config/.epistery/localhost/config.ini +16 -0
  37. package/test/config/.epistery/no-pending-claim.local/config.ini +15 -0
  38. package/test/config/.epistery/test-claim-1782325887560.local/config.ini +20 -0
  39. package/test/config/.epistery/test-idempotent-1782325887610.local/config.ini +20 -0
  40. package/test/config/.epistery/test-init-1782325888110.local/config.ini +16 -0
  41. package/test/routes/auth.test.ts +7 -3
  42. package/test/routes/identity.test.ts +0 -41
  43. package/test/routes/status.test.ts +5 -39
  44. package/test/setup.ts +9 -9
  45. package/test/utils.ts +9 -3
@@ -1,23 +1,59 @@
1
1
  import fs from 'fs';
2
+ import fsp from 'fs/promises';
2
3
  import { join } from 'path';
3
4
  import ini from 'ini';
5
+ import { ethers } from 'ethers';
4
6
 
5
7
  /**
6
- * Epistery Config - Path-based configuration system
8
+ * Epistery Config async path-based configuration store.
7
9
  *
8
- * Provides unified, filesystem-like config management:
10
+ * Filesystem-like config management, now async so the same interface can be
11
+ * served by a remote authority:
9
12
  * - setPath('/') → ~/.epistery/config.ini
10
13
  * - setPath('/domain') → ~/.epistery/domain/config.ini
11
14
  * - setPath('/.ssl/domain') → ~/.epistery/.ssl/domain/config.ini
12
15
  *
16
+ * `Config` is a thin facade that picks a backend at construction:
17
+ * - if the local bootstrap ~/.epistery/config.ini has [authority] url=…,
18
+ * or EPISTERY_CONFIG_URL is set → RemoteConfig (talks to epistery-authority)
19
+ * - otherwise → LocalConfig (the filesystem store; unchanged semantics)
20
+ *
21
+ * Every IO method is async because a remote/HSM custodian cannot answer
22
+ * synchronously. `data` holds the snapshot from the last awaited load()/setPath().
23
+ *
13
24
  * Usage:
14
25
  * const config = new Config('epistery');
15
- * config.setPath('/wiki.rootz.global');
16
- * config.load();
26
+ * await config.setPath('/wiki.rootz.global'); // loads
17
27
  * config.data.verified = true;
18
- * config.save();
28
+ * await config.save();
29
+ */
30
+ export interface ConfigStore {
31
+ data: any;
32
+ getPath(): string;
33
+ setPath(path: string): Promise<void>;
34
+ load(): Promise<void>;
35
+ read(path: string): Promise<any>;
36
+ save(): Promise<void>;
37
+ readFile(filename: string): Promise<Buffer>;
38
+ writeFile(filename: string, data: string | Buffer): Promise<void>;
39
+ exists(): Promise<boolean>;
40
+ listPaths(): Promise<string[]>;
41
+ }
42
+
43
+ /** Normalize a path: leading slash, no trailing slash, lowercase. */
44
+ function normalizePath(path: string): string {
45
+ path = path.trim();
46
+ if (!path.startsWith('/')) path = '/' + path;
47
+ if (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1);
48
+ return path.toLowerCase();
49
+ }
50
+
51
+ /**
52
+ * LocalConfig — the filesystem backend under ~/.epistery. This is the original
53
+ * Config behavior, with all IO made async. It is also what the epistery-authority
54
+ * server mounts as its own storage backend (one implementation, not two).
19
55
  */
20
- export class Config {
56
+ export class LocalConfig implements ConfigStore {
21
57
  public readonly rootName: string;
22
58
  public readonly homeDir: string;
23
59
  public readonly configDir: string;
@@ -36,151 +72,313 @@ export class Config {
36
72
  this.currentDir = this.configDir;
37
73
  this.currentFile = join(this.configDir, 'config.ini');
38
74
 
39
- // Initialize root config if it doesn't exist
75
+ // Bootstrap is synchronous: seed the root config so the very first run has
76
+ // somewhere to read the authority URL from. Only this seed stays sync; all
77
+ // subsequent IO is async.
40
78
  if (!fs.existsSync(this.currentFile)) {
41
- this.initialize();
79
+ this.initializeSync();
42
80
  }
43
81
  }
44
82
 
45
- /**
46
- * Set current working path and load config (like cd)
47
- * Examples: '/', 'domain', '/domain', '/.ssl/domain'
48
- * Leading slash is optional and will be added if not present
49
- * Automatically loads the config at the specified path
50
- */
51
- public setPath(path: string): void {
52
- // Normalize path: ensure leading slash, remove trailing slash, lowercase
53
- path = path.trim();
54
- if (!path.startsWith('/')) path = '/' + path;
55
- if (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1);
56
- path = path.toLowerCase();
83
+ private initializeSync(): void {
84
+ if (!fs.existsSync(this.currentDir)) {
85
+ fs.mkdirSync(this.currentDir, { recursive: true });
86
+ }
87
+ const defaultContent = this.currentPath === '/' ? defaultIni : '';
88
+ fs.writeFileSync(this.currentFile, defaultContent);
89
+ this.data = ini.decode(defaultContent);
90
+ }
91
+
92
+ public getPath(): string {
93
+ return this.currentPath;
94
+ }
57
95
 
96
+ public async setPath(path: string): Promise<void> {
97
+ path = normalizePath(path);
58
98
  this.currentPath = path;
59
99
 
60
- // Calculate directory and file paths
61
100
  if (path === '/') {
62
101
  this.currentDir = this.configDir;
63
102
  this.currentFile = join(this.configDir, 'config.ini');
64
103
  } else {
65
- this.currentDir = join(this.configDir, path.slice(1)); // Remove leading /
104
+ this.currentDir = join(this.configDir, path.slice(1));
66
105
  this.currentFile = join(this.currentDir, 'config.ini');
67
106
  }
68
107
 
69
- // Automatically load the config at this path
70
- this.load();
108
+ await this.load();
71
109
  }
72
110
 
73
- /**
74
- * Get current path
75
- */
76
- public getPath(): string {
77
- return this.currentPath;
111
+ public async load(): Promise<void> {
112
+ try {
113
+ const fileData = await fsp.readFile(this.currentFile, 'utf8');
114
+ this.data = ini.decode(fileData);
115
+ } catch {
116
+ this.data = {};
117
+ }
78
118
  }
79
119
 
80
- /**
81
- * Initialize config at current path
82
- */
83
- private initialize(): void {
84
- if (!fs.existsSync(this.currentDir)) {
85
- fs.mkdirSync(this.currentDir, { recursive: true });
120
+ public async read(path: string): Promise<any> {
121
+ path = normalizePath(path);
122
+ const configFile = path === '/'
123
+ ? join(this.configDir, 'config.ini')
124
+ : join(this.configDir, path.slice(1), 'config.ini');
125
+ try {
126
+ const fileData = await fsp.readFile(configFile, 'utf8');
127
+ return ini.decode(fileData);
128
+ } catch {
129
+ return {};
86
130
  }
131
+ }
87
132
 
88
- // Write default config for root, empty for paths
89
- const defaultContent = this.currentPath === '/' ? defaultIni : '';
90
- fs.writeFileSync(this.currentFile, defaultContent);
91
- this.data = ini.decode(defaultContent);
133
+ public async save(): Promise<void> {
134
+ await fsp.mkdir(this.currentDir, { recursive: true });
135
+ await fsp.writeFile(this.currentFile, ini.stringify(this.data));
92
136
  }
93
137
 
94
- /**
95
- * Load config from current path
96
- */
97
- public load(): void {
98
- if (!fs.existsSync(this.currentFile)) {
99
- this.data = {};
100
- return;
101
- }
138
+ public async readFile(filename: string): Promise<Buffer> {
139
+ return fsp.readFile(join(this.currentDir, filename));
140
+ }
102
141
 
103
- const fileData = fs.readFileSync(this.currentFile, 'utf8');
104
- this.data = ini.decode(fileData);
142
+ public async writeFile(filename: string, data: string | Buffer): Promise<void> {
143
+ await fsp.mkdir(this.currentDir, { recursive: true });
144
+ await fsp.writeFile(join(this.currentDir, filename), data);
105
145
  }
106
146
 
107
- /**
108
- * Read config from arbitrary path without changing current path
109
- * @param path Path to read from (e.g., '/', '/domain')
110
- * @returns Parsed config data from that path
111
- */
112
- public read(path: string): any {
113
- // Normalize path
114
- path = path.trim();
115
- if (!path.startsWith('/')) path = '/' + path;
116
- if (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1);
117
- path = path.toLowerCase();
118
-
119
- // Calculate file location
120
- let configFile: string;
121
- if (path === '/') {
122
- configFile = join(this.configDir, 'config.ini');
123
- } else {
124
- configFile = join(this.configDir, path.slice(1), 'config.ini');
147
+ public async exists(): Promise<boolean> {
148
+ try {
149
+ await fsp.access(this.currentFile);
150
+ return true;
151
+ } catch {
152
+ return false;
125
153
  }
154
+ }
126
155
 
127
- // Read and parse
128
- if (!fs.existsSync(configFile)) {
129
- return {};
156
+ public async listPaths(): Promise<string[]> {
157
+ try {
158
+ const entries = await fsp.readdir(this.currentDir, { withFileTypes: true });
159
+ return entries.filter(e => e.isDirectory()).map(e => e.name);
160
+ } catch {
161
+ return [];
130
162
  }
163
+ }
164
+ }
165
+
166
+ /**
167
+ * RemoteConfig — HTTP client to an epistery-authority server. Implements the
168
+ * same ConfigStore interface; the authority mounts a LocalConfig behind it.
169
+ *
170
+ * Auth is the rivet key-exchange: the machine signs a challenge with its
171
+ * device key, and the authority issues a bearer token (see epistery-authority
172
+ * lib/auth.mjs). In Phase 1 config data (including the wallet mnemonic) is
173
+ * still served; Phase 2 splits public from secret and adds /sign/*.
174
+ */
175
+ export class RemoteConfig implements ConfigStore {
176
+ public data: any = {};
177
+ private currentPath: string = '/';
178
+ private token: string | null = null;
179
+
180
+ // Optional per-role subtree on the authority. When set (e.g. '/relay'), every
181
+ // path the client uses is remapped under it: the client's '/' → authority
182
+ // '/relay', '/relay.epistery.com' → '/relay/relay.epistery.com'. This lets a
183
+ // role (and a pool of its instances) share one config + cert subtree without
184
+ // colliding with other roles on the authority's root. Empty = no remap.
185
+ private readonly basePath: string;
131
186
 
132
- const fileData = fs.readFileSync(configFile, 'utf8');
133
- return ini.decode(fileData);
187
+ constructor(
188
+ private readonly baseUrl: string,
189
+ private readonly machineAddress: string,
190
+ private readonly signChallenge: (message: string) => Promise<string>,
191
+ basePath: string = '',
192
+ ) {
193
+ this.baseUrl = baseUrl.replace(/\/+$/, '');
194
+ this.basePath = (!basePath || basePath === '/') ? '' : normalizePath(basePath);
134
195
  }
135
196
 
136
- /**
137
- * Save config to current path
138
- */
139
- public save(): void {
140
- if (!fs.existsSync(this.currentDir)) {
141
- fs.mkdirSync(this.currentDir, { recursive: true });
197
+ public getPath(): string {
198
+ return this.currentPath; // the client's logical path (unprefixed)
199
+ }
200
+
201
+ /** Map a client path into the authority's namespace under basePath. */
202
+ private prefixed(path: string): string {
203
+ const p = normalizePath(path);
204
+ if (!this.basePath) return p;
205
+ return p === '/' ? this.basePath : this.basePath + p;
206
+ }
207
+
208
+ private async authenticate(): Promise<void> {
209
+ const fetch = (globalThis as any).fetch;
210
+ const cRes = await fetch(`${this.baseUrl}/auth/challenge`, {
211
+ method: 'POST',
212
+ headers: { 'content-type': 'application/json' },
213
+ body: JSON.stringify({ machineAddress: this.machineAddress }),
214
+ });
215
+ if (!cRes.ok) throw new Error(`authority challenge failed: ${cRes.status}`);
216
+ const { challenge } = await cRes.json();
217
+
218
+ const message = `Epistery Key Exchange - ${this.machineAddress} - ${challenge}`;
219
+ const signature = await this.signChallenge(message);
220
+
221
+ const vRes = await fetch(`${this.baseUrl}/auth/verify`, {
222
+ method: 'POST',
223
+ headers: { 'content-type': 'application/json' },
224
+ body: JSON.stringify({ machineAddress: this.machineAddress, message, signature }),
225
+ });
226
+ if (!vRes.ok) throw new Error(`authority verify failed: ${vRes.status}`);
227
+ this.token = (await vRes.json()).token;
228
+ }
229
+
230
+ private async authedFetch(pathAndQuery: string, init: any = {}): Promise<any> {
231
+ const fetch = (globalThis as any).fetch;
232
+ if (!this.token) await this.authenticate();
233
+ const withAuth = () => ({ ...init, headers: { ...(init.headers || {}), authorization: `Bearer ${this.token}` } });
234
+ let res = await fetch(this.baseUrl + pathAndQuery, withAuth());
235
+ if (res.status === 401) {
236
+ this.token = null; // expired — re-auth once
237
+ await this.authenticate();
238
+ res = await fetch(this.baseUrl + pathAndQuery, withAuth());
142
239
  }
240
+ return res;
241
+ }
143
242
 
144
- const text = ini.stringify(this.data);
145
- fs.writeFileSync(this.currentFile, text);
243
+ public async setPath(path: string): Promise<void> {
244
+ this.currentPath = normalizePath(path);
245
+ await this.load();
146
246
  }
147
247
 
148
- /**
149
- * Read file from current path directory
150
- */
151
- public readFile(filename: string): Buffer {
152
- return fs.readFileSync(join(this.currentDir, filename));
248
+ public async load(): Promise<void> {
249
+ this.data = await this.read(this.currentPath);
153
250
  }
154
251
 
155
- /**
156
- * Write file to current path directory
157
- */
158
- public writeFile(filename: string, data: string | Buffer): void {
159
- if (!fs.existsSync(this.currentDir)) {
160
- fs.mkdirSync(this.currentDir, { recursive: true });
252
+ public async read(path: string): Promise<any> {
253
+ const p = this.prefixed(path);
254
+ const res = await this.authedFetch(`/config${p === '/' ? '/' : p}`);
255
+ if (!res.ok) return {};
256
+ return (await res.json()).data || {};
257
+ }
258
+
259
+ public async save(): Promise<void> {
260
+ const p = this.prefixed(this.currentPath);
261
+ const res = await this.authedFetch(`/config${p === '/' ? '/' : p}`, {
262
+ method: 'PUT',
263
+ headers: { 'content-type': 'application/json' },
264
+ body: JSON.stringify({ data: this.data }),
265
+ });
266
+ if (!res.ok) throw new Error(`authority save failed: ${res.status}`);
267
+ }
268
+
269
+ private filePrefix(): string {
270
+ const p = this.prefixed(this.currentPath);
271
+ return p === '/' ? '' : p;
272
+ }
273
+
274
+ public async readFile(filename: string): Promise<Buffer> {
275
+ const res = await this.authedFetch(`/file${this.filePrefix()}/${filename}`);
276
+ if (!res.ok) throw new Error(`authority file read failed: ${res.status}`);
277
+ return Buffer.from(await res.arrayBuffer());
278
+ }
279
+
280
+ public async writeFile(filename: string, data: string | Buffer): Promise<void> {
281
+ const res = await this.authedFetch(`/file${this.filePrefix()}/${filename}`, {
282
+ method: 'PUT',
283
+ headers: { 'content-type': 'application/octet-stream' },
284
+ body: data,
285
+ });
286
+ if (!res.ok) throw new Error(`authority file write failed: ${res.status}`);
287
+ }
288
+
289
+ public async exists(): Promise<boolean> {
290
+ const data = await this.read(this.currentPath);
291
+ return !!data && Object.keys(data).length > 0;
292
+ }
293
+
294
+ public async listPaths(): Promise<string[]> {
295
+ const p = this.prefixed(this.currentPath);
296
+ const res = await this.authedFetch(`/paths${p === '/' ? '/' : p}`);
297
+ if (!res.ok) return [];
298
+ return (await res.json()).paths || [];
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Config — the public facade. `new Config()` selects the backend synchronously
304
+ * (reading only the local bootstrap), then delegates every async operation.
305
+ * Existing call sites change only by adding `await`.
306
+ */
307
+ export class Config implements ConfigStore {
308
+ private readonly backend: ConfigStore;
309
+ private readonly local: LocalConfig;
310
+
311
+ constructor(rootName: string = 'epistery') {
312
+ this.local = new LocalConfig(rootName);
313
+ const authorityUrl = Config.resolveAuthorityUrl(rootName);
314
+ if (authorityUrl) {
315
+ const { machineAddress, sign } = Config.machineSigner(rootName);
316
+ // Per-role subtree: this host's '/' maps under it on the authority, so a
317
+ // pool of like role instances shares one config + cert subtree.
318
+ const root = process.env.EPISTERY_CONFIG_ROOT
319
+ || Config.readBootstrap(rootName)?.authority?.root || '';
320
+ this.backend = new RemoteConfig(authorityUrl, machineAddress, sign, root);
321
+ } else {
322
+ this.backend = this.local;
161
323
  }
162
- fs.writeFileSync(join(this.currentDir, filename), data);
163
324
  }
164
325
 
165
- /**
166
- * Check if config exists at current path
167
- */
168
- public exists(): boolean {
169
- return fs.existsSync(this.currentFile);
326
+ /** Read the local bootstrap config.ini synchronously (authority selection only). */
327
+ private static readBootstrap(rootName: string): any {
328
+ const homeDir = (process.platform === 'win32' ? process.env.USERPROFILE : process.env.HOME) || '';
329
+ const file = join(homeDir, '.' + rootName, 'config.ini');
330
+ try {
331
+ return ini.decode(fs.readFileSync(file, 'utf8'));
332
+ } catch {
333
+ return {};
334
+ }
335
+ }
336
+
337
+ private static resolveAuthorityUrl(rootName: string): string | null {
338
+ if (process.env.EPISTERY_CONFIG_URL) return process.env.EPISTERY_CONFIG_URL;
339
+ const boot = Config.readBootstrap(rootName);
340
+ return boot?.authority?.url || null;
170
341
  }
171
342
 
172
343
  /**
173
- * List all subdirectories at current path
344
+ * Build the machine signer used to authenticate to the authority. The machine
345
+ * rivet key lives in the local bootstrap [authority] section (Phase 1). A
346
+ * future TPM-backed key replaces this without changing the call site.
174
347
  */
175
- public listPaths(): string[] {
176
- if (!fs.existsSync(this.currentDir)) {
177
- return [];
348
+ private static machineSigner(rootName: string): { machineAddress: string; sign: (m: string) => Promise<string> } {
349
+ const boot = Config.readBootstrap(rootName);
350
+ const a = boot?.authority || {};
351
+ let wallet: ethers.Wallet;
352
+ if (a.machineMnemonic) {
353
+ wallet = ethers.Wallet.fromMnemonic(a.machineMnemonic);
354
+ } else if (a.machineKey) {
355
+ wallet = new ethers.Wallet(a.machineKey);
356
+ } else {
357
+ throw new Error(
358
+ 'Config: [authority] url is set but no machine credential ([authority] machineMnemonic or machineKey) is configured to authenticate to it.',
359
+ );
178
360
  }
179
-
180
- return fs.readdirSync(this.currentDir, { withFileTypes: true })
181
- .filter(dirent => dirent.isDirectory())
182
- .map(dirent => dirent.name);
361
+ return { machineAddress: wallet.address, sign: (m: string) => wallet.signMessage(m) };
183
362
  }
363
+
364
+ // Local-machine facts (always the bootstrap LocalConfig, even in remote mode):
365
+ // these are filesystem paths some callers need (e.g. CliWallet session dir).
366
+ public get rootName(): string { return this.local.rootName; }
367
+ public get homeDir(): string { return this.local.homeDir; }
368
+ public get configDir(): string { return this.local.configDir; }
369
+
370
+ public get data(): any { return this.backend.data; }
371
+ public set data(v: any) { this.backend.data = v; }
372
+
373
+ public getPath(): string { return this.backend.getPath(); }
374
+ public setPath(path: string): Promise<void> { return this.backend.setPath(path); }
375
+ public load(): Promise<void> { return this.backend.load(); }
376
+ public read(path: string): Promise<any> { return this.backend.read(path); }
377
+ public save(): Promise<void> { return this.backend.save(); }
378
+ public readFile(filename: string): Promise<Buffer> { return this.backend.readFile(filename); }
379
+ public writeFile(filename: string, data: string | Buffer): Promise<void> { return this.backend.writeFile(filename, data); }
380
+ public exists(): Promise<boolean> { return this.backend.exists(); }
381
+ public listPaths(): Promise<string[]> { return this.backend.listPaths(); }
184
382
  }
185
383
 
186
384
  const defaultIni =
@@ -224,4 +422,13 @@ nativeCurrencyDecimals=18
224
422
  ; minPriorityFeeGwei=25 ; Polygon RPC floor (don't lower)
225
423
  ; [default.rpc.81.policy]
226
424
  ; maxGasPriceGwei=1000 ; legacy-chain analogue (JOC)
227
- `
425
+
426
+ ; Shared Configuration Authority (optional). When set, Config reads/writes
427
+ ; through the epistery-authority server instead of the local filesystem, so
428
+ ; this host becomes a stateless pool member. EPISTERY_CONFIG_URL overrides url.
429
+ ; [authority]
430
+ ; url=https://epistery-authority-1.internal:4500
431
+ ; machineMnemonic=... ; this machine's rivet (or machineKey=0x…)
432
+ ; root=/relay ; optional: this role's subtree on the authority
433
+ ; ; (a pool of like instances shares it). EPISTERY_CONFIG_ROOT overrides.
434
+ `;
@@ -11,7 +11,7 @@ export class Utils {
11
11
  // across concurrent requests for different domains on a multi-tenant host.
12
12
  private static walletCache: Map<string, ethers.Wallet> = new Map();
13
13
 
14
- public static InitServerWallet(domain: string = 'localhost'): ethers.Wallet | null {
14
+ public static async InitServerWallet(domain: string = 'localhost'): Promise<ethers.Wallet | null> {
15
15
  // Fast path: return cached wallet for this domain. Avoids both the
16
16
  // wallet-rebuild cost and the static config setPath mutation.
17
17
  const cached = this.walletCache.get(domain);
@@ -25,7 +25,7 @@ export class Utils {
25
25
  }
26
26
 
27
27
  // Load domain config
28
- this.config.setPath(domain);
28
+ await this.config.setPath(domain);
29
29
 
30
30
  const domainConfig = this.config.data.domain ? this.config.data : {domain: domain};
31
31
 
@@ -41,10 +41,10 @@ export class Utils {
41
41
  // "provider=undefined", and which on reload becomes a truthy
42
42
  // string that bypasses the `||` fallback below at chainFor).
43
43
  if (!domainConfig.provider) {
44
- this.config.setPath('/');
44
+ await this.config.setPath('/');
45
45
  domainConfig.provider =
46
46
  this.config.data.default?.provider ?? this.config.data.provider;
47
- this.config.setPath(domain); // Switch back to domain
47
+ await this.config.setPath(domain); // Switch back to domain
48
48
  }
49
49
 
50
50
  if (!domainConfig.wallet) {
@@ -69,7 +69,7 @@ export class Utils {
69
69
  }
70
70
 
71
71
  this.config.data = domainConfig;
72
- this.config.save();
72
+ await this.config.save();
73
73
 
74
74
  console.log(`[debug] Created new wallet for domain: ${domain}`);
75
75
  console.log(`[debug] Wallet address: ${wallet.address}`);
@@ -98,6 +98,17 @@ export class Utils {
98
98
  return this.serverWallet;
99
99
  }
100
100
 
101
+ /**
102
+ * Synchronous accessor for a domain's already-initialized wallet, read from
103
+ * the per-domain cache. Returns null if InitServerWallet(domain) has not been
104
+ * awaited yet. This lets the synchronous `get signer()` getter survive the
105
+ * async migration: setDomain() awaits InitServerWallet to warm the cache, and
106
+ * the getter reads it here without re-entering async config IO.
107
+ */
108
+ public static GetServerWalletFor(domain: string): ethers.Wallet | null {
109
+ return this.walletCache.get(domain) || null;
110
+ }
111
+
101
112
  public static GetConfig(): Config {
102
113
  if (!this.config) {
103
114
  this.config = new Config();
@@ -105,13 +116,12 @@ export class Utils {
105
116
  return this.config;
106
117
  }
107
118
 
108
- public static GetDomainInfo(domain: string = 'localhost'): DomainConfig {
119
+ public static async GetDomainInfo(domain: string = 'localhost'): Promise<DomainConfig> {
109
120
  if (!this.config) {
110
121
  this.config = new Config();
111
122
  }
112
123
 
113
- this.config.setPath(`/${domain}`);
114
- this.config.load();
124
+ await this.config.setPath(`/${domain}`);
115
125
 
116
126
  if (!this.config.data.domain)
117
127
  return {domain:domain};
@@ -126,7 +136,7 @@ export class Utils {
126
136
  // read epistery.domain.provider.rpc (e.g. connect's on-chain contract
127
137
  // verification) get no RPC. read('/') doesn't move the current path.
128
138
  if (!domainConfig.provider) {
129
- const rootData = this.config.read('/');
139
+ const rootData = await this.config.read('/');
130
140
  domainConfig.provider = rootData.default?.provider ?? rootData.provider;
131
141
  }
132
142
 
@@ -0,0 +1,15 @@
1
+ domain=127.0.0.1
2
+
3
+ [provider]
4
+ chainId=80002
5
+ name=Polygon Amoy Testnet
6
+ rpc=https://rpc-amoy.polygon.technology
7
+ nativeCurrencyName=POL
8
+ nativeCurrencySymbol=POL
9
+ nativeCurrencyDecimals=18
10
+
11
+ [wallet]
12
+ address=0x94C8407888499483CB6795cd5155801B9e767515
13
+ mnemonic=meadow chimney grain search thrive away visual grace wear powder giant profit
14
+ publicKey=0x04098c8c61f007b937dc9f5ec63ec41857b4b50ece763d8c1501876f3ae33b8758d7cdf0e4cb6cf44f715b7e8395b9ee81b0ff258816a1b6ce9ea76e91626962eb
15
+ privateKey=0x4f2bd152b2c3b08472ed4e61297058d8df367bd96e66ee76fed6ad778de92aaa
@@ -0,0 +1,14 @@
1
+ [profile]
2
+ name=Test Profile
3
+ email=test@epistery.test
4
+
5
+ [default.provider]
6
+ chainId=80002
7
+ name=Polygon Amoy Testnet
8
+ rpc=https://rpc-amoy.polygon.technology
9
+ nativeCurrencyName=POL
10
+ nativeCurrencySymbol=POL
11
+ nativeCurrencyDecimals=18
12
+
13
+ [cli]
14
+ default_domain=localhost
@@ -0,0 +1,16 @@
1
+ domain=localhost
2
+ pending=true
3
+
4
+ [wallet]
5
+ address=0x5277286b5821D1bF5c2395F38c3250A5B0F14da3
6
+ mnemonic=attack sweet hint arctic skull vessel refuse mountain tiny kind feature amazing
7
+ publicKey=0x041de8ef7ae49c387ed8f8c138f33901acefdd63e03300dbd205bd407efba3b95002b5337ce4437924b52c9581cf7bda21eb6df5f0d3e17fbd99ccddfefc63f0d8
8
+ privateKey=0x91806b336879df60435ab8f95673bbdd7b24d47dec5ac35c10098934f214049d
9
+
10
+ [provider]
11
+ name=Polygon Amoy Testnet
12
+ chainId=80002
13
+ rpc=https://rpc-amoy.polygon.technology
14
+ nativeCurrencySymbol=POL
15
+ nativeCurrencyName=POL
16
+ nativeCurrencyDecimals=18
@@ -0,0 +1,15 @@
1
+ domain=no-pending-claim.local
2
+
3
+ [provider]
4
+ chainId=80002
5
+ name=Polygon Amoy Testnet
6
+ rpc=https://rpc-amoy.polygon.technology
7
+ nativeCurrencyName=POL
8
+ nativeCurrencySymbol=POL
9
+ nativeCurrencyDecimals=18
10
+
11
+ [wallet]
12
+ address=0xaDBD5bBE73B80430E047A3496d277B05191c45CA
13
+ mnemonic=devote link lens finger february close soda harbor urge chunk zoo knife
14
+ publicKey=0x042c874d737d4c4620bc03a255ebf7733f369c603e8bdf8cbcddbdd0042d257e06f684374b1d4502d2db202ced4dcaa7139c9ec92c93deafa2ca718265a9ad8580
15
+ privateKey=0x5b02e2b6f91e742d52d4d75b91be1cea416561fb67a4873c2842ba0b2a023dfd
@@ -0,0 +1,20 @@
1
+ domain=test-claim-1782325887560.local
2
+ pending=true
3
+ challenge_token=3027fd6c76df13ea5c5a22c8e89b496cf541e6a55484fb67dc20df4c1b033ed6
4
+ challenge_address=0xdcb8454e5ce6b911dba93c11ad530c61d2520d8e
5
+ challenge_created=2026-06-24T18:31:27.608Z
6
+ challenge_requester_ip=::ffff:127.0.0.1
7
+
8
+ [provider]
9
+ name=Polygon Amoy Testnet
10
+ chainId=80002
11
+ rpc=https://rpc-amoy.polygon.technology
12
+ nativeCurrencySymbol=POL
13
+ nativeCurrencyName=POL
14
+ nativeCurrencyDecimals=18
15
+
16
+ [wallet]
17
+ address=0xcB676926b57659261Cb4C8bA765E895E9A61363B
18
+ mnemonic=clinic tragic regular leave sunset expect manage rally street derive swap rapid
19
+ publicKey=0x047389a40eb30c1da9f0fe88d4e05df15f4940022f5c1b3928b4de08f205274ba220d19811d2d8ab5dac45e0c0ff2caf6dad1db8e834354df65da2973e306fdbc8
20
+ privateKey=0xa26ab628421a96202f49516d236c15144cc9825c636146ddb3091f649e51501b