epistery 2.0.3 → 2.1.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 (47) hide show
  1. package/.test.env +25 -0
  2. package/cli/epistery.mjs +9 -10
  3. package/client/wallet.js +30 -7
  4. package/client/witness.js +29 -12
  5. package/dist/chains/registry.d.ts +2 -2
  6. package/dist/chains/registry.d.ts.map +1 -1
  7. package/dist/chains/registry.js +4 -4
  8. package/dist/chains/registry.js.map +1 -1
  9. package/dist/epistery.d.ts +1 -1
  10. package/dist/epistery.d.ts.map +1 -1
  11. package/dist/epistery.js +4 -4
  12. package/dist/epistery.js.map +1 -1
  13. package/dist/utils/CliWallet.d.ts +9 -5
  14. package/dist/utils/CliWallet.d.ts.map +1 -1
  15. package/dist/utils/CliWallet.js +18 -13
  16. package/dist/utils/CliWallet.js.map +1 -1
  17. package/dist/utils/Config.d.ts +101 -48
  18. package/dist/utils/Config.d.ts.map +1 -1
  19. package/dist/utils/Config.js +257 -116
  20. package/dist/utils/Config.js.map +1 -1
  21. package/dist/utils/Utils.d.ts +10 -2
  22. package/dist/utils/Utils.d.ts.map +1 -1
  23. package/dist/utils/Utils.js +18 -9
  24. package/dist/utils/Utils.js.map +1 -1
  25. package/docs/RivetSignerConfigAuthority.md +219 -0
  26. package/index.mjs +10 -9
  27. package/package.json +1 -1
  28. package/routes/auth.mjs +6 -6
  29. package/routes/domain.mjs +2 -2
  30. package/routes/fido.mjs +4 -4
  31. package/src/chains/registry.ts +4 -4
  32. package/src/epistery.ts +4 -4
  33. package/src/utils/CliWallet.ts +18 -13
  34. package/src/utils/Config.ts +289 -106
  35. package/src/utils/Utils.ts +19 -9
  36. package/test/config/.epistery/127.0.0.1/config.ini +15 -0
  37. package/test/config/.epistery/config.ini +14 -0
  38. package/test/config/.epistery/localhost/config.ini +16 -0
  39. package/test/config/.epistery/no-pending-claim.local/config.ini +15 -0
  40. package/test/config/.epistery/test-claim-1782325887560.local/config.ini +20 -0
  41. package/test/config/.epistery/test-idempotent-1782325887610.local/config.ini +20 -0
  42. package/test/config/.epistery/test-init-1782325888110.local/config.ini +16 -0
  43. package/test/routes/auth.test.ts +7 -3
  44. package/test/routes/identity.test.ts +0 -41
  45. package/test/routes/status.test.ts +5 -39
  46. package/test/setup.ts +9 -9
  47. 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,291 @@ 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;
131
179
 
132
- const fileData = fs.readFileSync(configFile, 'utf8');
133
- return ini.decode(fileData);
180
+ constructor(
181
+ private readonly baseUrl: string,
182
+ private readonly machineAddress: string,
183
+ private readonly signChallenge: (message: string) => Promise<string>,
184
+ ) {
185
+ this.baseUrl = baseUrl.replace(/\/+$/, '');
134
186
  }
135
187
 
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 });
188
+ public getPath(): string {
189
+ return this.currentPath;
190
+ }
191
+
192
+ private async authenticate(): Promise<void> {
193
+ const fetch = (globalThis as any).fetch;
194
+ const cRes = await fetch(`${this.baseUrl}/auth/challenge`, {
195
+ method: 'POST',
196
+ headers: { 'content-type': 'application/json' },
197
+ body: JSON.stringify({ machineAddress: this.machineAddress }),
198
+ });
199
+ if (!cRes.ok) throw new Error(`authority challenge failed: ${cRes.status}`);
200
+ const { challenge } = await cRes.json();
201
+
202
+ const message = `Epistery Key Exchange - ${this.machineAddress} - ${challenge}`;
203
+ const signature = await this.signChallenge(message);
204
+
205
+ const vRes = await fetch(`${this.baseUrl}/auth/verify`, {
206
+ method: 'POST',
207
+ headers: { 'content-type': 'application/json' },
208
+ body: JSON.stringify({ machineAddress: this.machineAddress, message, signature }),
209
+ });
210
+ if (!vRes.ok) throw new Error(`authority verify failed: ${vRes.status}`);
211
+ this.token = (await vRes.json()).token;
212
+ }
213
+
214
+ private async authedFetch(pathAndQuery: string, init: any = {}): Promise<any> {
215
+ const fetch = (globalThis as any).fetch;
216
+ if (!this.token) await this.authenticate();
217
+ const withAuth = () => ({ ...init, headers: { ...(init.headers || {}), authorization: `Bearer ${this.token}` } });
218
+ let res = await fetch(this.baseUrl + pathAndQuery, withAuth());
219
+ if (res.status === 401) {
220
+ this.token = null; // expired — re-auth once
221
+ await this.authenticate();
222
+ res = await fetch(this.baseUrl + pathAndQuery, withAuth());
142
223
  }
224
+ return res;
225
+ }
143
226
 
144
- const text = ini.stringify(this.data);
145
- fs.writeFileSync(this.currentFile, text);
227
+ public async setPath(path: string): Promise<void> {
228
+ this.currentPath = normalizePath(path);
229
+ await this.load();
146
230
  }
147
231
 
148
- /**
149
- * Read file from current path directory
150
- */
151
- public readFile(filename: string): Buffer {
152
- return fs.readFileSync(join(this.currentDir, filename));
232
+ public async load(): Promise<void> {
233
+ this.data = await this.read(this.currentPath);
153
234
  }
154
235
 
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 });
236
+ public async read(path: string): Promise<any> {
237
+ path = normalizePath(path);
238
+ const res = await this.authedFetch(`/config${path === '/' ? '/' : path}`);
239
+ if (!res.ok) return {};
240
+ return (await res.json()).data || {};
241
+ }
242
+
243
+ public async save(): Promise<void> {
244
+ const path = this.currentPath;
245
+ const res = await this.authedFetch(`/config${path === '/' ? '/' : path}`, {
246
+ method: 'PUT',
247
+ headers: { 'content-type': 'application/json' },
248
+ body: JSON.stringify({ data: this.data }),
249
+ });
250
+ if (!res.ok) throw new Error(`authority save failed: ${res.status}`);
251
+ }
252
+
253
+ private filePrefix(): string {
254
+ return this.currentPath === '/' ? '' : this.currentPath;
255
+ }
256
+
257
+ public async readFile(filename: string): Promise<Buffer> {
258
+ const res = await this.authedFetch(`/file${this.filePrefix()}/${filename}`);
259
+ if (!res.ok) throw new Error(`authority file read failed: ${res.status}`);
260
+ return Buffer.from(await res.arrayBuffer());
261
+ }
262
+
263
+ public async writeFile(filename: string, data: string | Buffer): Promise<void> {
264
+ const res = await this.authedFetch(`/file${this.filePrefix()}/${filename}`, {
265
+ method: 'PUT',
266
+ headers: { 'content-type': 'application/octet-stream' },
267
+ body: data,
268
+ });
269
+ if (!res.ok) throw new Error(`authority file write failed: ${res.status}`);
270
+ }
271
+
272
+ public async exists(): Promise<boolean> {
273
+ const data = await this.read(this.currentPath);
274
+ return !!data && Object.keys(data).length > 0;
275
+ }
276
+
277
+ public async listPaths(): Promise<string[]> {
278
+ const res = await this.authedFetch(`/paths${this.currentPath === '/' ? '/' : this.currentPath}`);
279
+ if (!res.ok) return [];
280
+ return (await res.json()).paths || [];
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Config — the public facade. `new Config()` selects the backend synchronously
286
+ * (reading only the local bootstrap), then delegates every async operation.
287
+ * Existing call sites change only by adding `await`.
288
+ */
289
+ export class Config implements ConfigStore {
290
+ private readonly backend: ConfigStore;
291
+ private readonly local: LocalConfig;
292
+
293
+ constructor(rootName: string = 'epistery') {
294
+ this.local = new LocalConfig(rootName);
295
+ const authorityUrl = Config.resolveAuthorityUrl(rootName);
296
+ if (authorityUrl) {
297
+ const { machineAddress, sign } = Config.machineSigner(rootName);
298
+ this.backend = new RemoteConfig(authorityUrl, machineAddress, sign);
299
+ } else {
300
+ this.backend = this.local;
161
301
  }
162
- fs.writeFileSync(join(this.currentDir, filename), data);
163
302
  }
164
303
 
165
- /**
166
- * Check if config exists at current path
167
- */
168
- public exists(): boolean {
169
- return fs.existsSync(this.currentFile);
304
+ /** Read the local bootstrap config.ini synchronously (authority selection only). */
305
+ private static readBootstrap(rootName: string): any {
306
+ const homeDir = (process.platform === 'win32' ? process.env.USERPROFILE : process.env.HOME) || '';
307
+ const file = join(homeDir, '.' + rootName, 'config.ini');
308
+ try {
309
+ return ini.decode(fs.readFileSync(file, 'utf8'));
310
+ } catch {
311
+ return {};
312
+ }
313
+ }
314
+
315
+ private static resolveAuthorityUrl(rootName: string): string | null {
316
+ if (process.env.EPISTERY_CONFIG_URL) return process.env.EPISTERY_CONFIG_URL;
317
+ const boot = Config.readBootstrap(rootName);
318
+ return boot?.authority?.url || null;
170
319
  }
171
320
 
172
321
  /**
173
- * List all subdirectories at current path
322
+ * Build the machine signer used to authenticate to the authority. The machine
323
+ * rivet key lives in the local bootstrap [authority] section (Phase 1). A
324
+ * future TPM-backed key replaces this without changing the call site.
174
325
  */
175
- public listPaths(): string[] {
176
- if (!fs.existsSync(this.currentDir)) {
177
- return [];
326
+ private static machineSigner(rootName: string): { machineAddress: string; sign: (m: string) => Promise<string> } {
327
+ const boot = Config.readBootstrap(rootName);
328
+ const a = boot?.authority || {};
329
+ let wallet: ethers.Wallet;
330
+ if (a.machineMnemonic) {
331
+ wallet = ethers.Wallet.fromMnemonic(a.machineMnemonic);
332
+ } else if (a.machineKey) {
333
+ wallet = new ethers.Wallet(a.machineKey);
334
+ } else {
335
+ throw new Error(
336
+ 'Config: [authority] url is set but no machine credential ([authority] machineMnemonic or machineKey) is configured to authenticate to it.',
337
+ );
178
338
  }
179
-
180
- return fs.readdirSync(this.currentDir, { withFileTypes: true })
181
- .filter(dirent => dirent.isDirectory())
182
- .map(dirent => dirent.name);
339
+ return { machineAddress: wallet.address, sign: (m: string) => wallet.signMessage(m) };
183
340
  }
341
+
342
+ // Local-machine facts (always the bootstrap LocalConfig, even in remote mode):
343
+ // these are filesystem paths some callers need (e.g. CliWallet session dir).
344
+ public get rootName(): string { return this.local.rootName; }
345
+ public get homeDir(): string { return this.local.homeDir; }
346
+ public get configDir(): string { return this.local.configDir; }
347
+
348
+ public get data(): any { return this.backend.data; }
349
+ public set data(v: any) { this.backend.data = v; }
350
+
351
+ public getPath(): string { return this.backend.getPath(); }
352
+ public setPath(path: string): Promise<void> { return this.backend.setPath(path); }
353
+ public load(): Promise<void> { return this.backend.load(); }
354
+ public read(path: string): Promise<any> { return this.backend.read(path); }
355
+ public save(): Promise<void> { return this.backend.save(); }
356
+ public readFile(filename: string): Promise<Buffer> { return this.backend.readFile(filename); }
357
+ public writeFile(filename: string, data: string | Buffer): Promise<void> { return this.backend.writeFile(filename, data); }
358
+ public exists(): Promise<boolean> { return this.backend.exists(); }
359
+ public listPaths(): Promise<string[]> { return this.backend.listPaths(); }
184
360
  }
185
361
 
186
362
  const defaultIni =
@@ -224,4 +400,11 @@ nativeCurrencyDecimals=18
224
400
  ; minPriorityFeeGwei=25 ; Polygon RPC floor (don't lower)
225
401
  ; [default.rpc.81.policy]
226
402
  ; maxGasPriceGwei=1000 ; legacy-chain analogue (JOC)
227
- `
403
+
404
+ ; Shared Configuration Authority (optional). When set, Config reads/writes
405
+ ; through the epistery-authority server instead of the local filesystem, so
406
+ ; this host becomes a stateless pool member. EPISTERY_CONFIG_URL overrides url.
407
+ ; [authority]
408
+ ; url=https://epistery-authority-1.internal:4500
409
+ ; machineMnemonic=... ; this machine's rivet (or machineKey=0x…)
410
+ `;
@@ -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
@@ -0,0 +1,20 @@
1
+ domain=test-idempotent-1782325887610.local
2
+ pending=true
3
+ challenge_token=4a6b22e1c5fbcc409a6a7b2818cc59adcdc209c9f17a830f6597a154112a393d
4
+ challenge_address=0xdcb8454e5ce6b911dba93c11ad530c61d2520d8e
5
+ challenge_created=2026-06-24T18:31:27.654Z
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=0x3088095FfBcfB4c5c41bF2013aaC445925D97053
18
+ mnemonic=jump youth pigeon erupt pigeon decorate pattern evil cage piece mean dawn
19
+ publicKey=0x04a6c3b50b9c644e9165c694b2aa999cf68dcb2c84d0d25944e77dbbce02efab7415726e7d73117df9923431238d1b2ba9041c1f0dc623102fd57d3bf80ec662ba
20
+ privateKey=0x759580a4cf6b6d0fe1903b59071186e2c6ecc6224d5c2852e76a031ea1218acf