@enbox/auth 0.4.0 → 0.5.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/dist/esm/auth-manager.js +200 -4
- package/dist/esm/auth-manager.js.map +1 -1
- package/dist/esm/flows/dwn-discovery.js +96 -81
- package/dist/esm/flows/dwn-discovery.js.map +1 -1
- package/dist/esm/flows/dwn-registration.js +49 -3
- package/dist/esm/flows/dwn-registration.js.map +1 -1
- package/dist/esm/flows/import-identity.js +2 -0
- package/dist/esm/flows/import-identity.js.map +1 -1
- package/dist/esm/flows/local-connect.js +25 -8
- package/dist/esm/flows/local-connect.js.map +1 -1
- package/dist/esm/flows/session-restore.js +13 -2
- package/dist/esm/flows/session-restore.js.map +1 -1
- package/dist/esm/flows/wallet-connect.js +5 -4
- package/dist/esm/flows/wallet-connect.js.map +1 -1
- package/dist/esm/index.js +5 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/password-provider.js +319 -0
- package/dist/esm/password-provider.js.map +1 -0
- package/dist/esm/types.js +9 -1
- package/dist/esm/types.js.map +1 -1
- package/dist/types/auth-manager.d.ts +67 -2
- package/dist/types/auth-manager.d.ts.map +1 -1
- package/dist/types/flows/dwn-discovery.d.ts +40 -53
- package/dist/types/flows/dwn-discovery.d.ts.map +1 -1
- package/dist/types/flows/dwn-registration.d.ts +20 -1
- package/dist/types/flows/dwn-registration.d.ts.map +1 -1
- package/dist/types/flows/import-identity.d.ts.map +1 -1
- package/dist/types/flows/local-connect.d.ts +2 -0
- package/dist/types/flows/local-connect.d.ts.map +1 -1
- package/dist/types/flows/session-restore.d.ts +2 -0
- package/dist/types/flows/session-restore.d.ts.map +1 -1
- package/dist/types/flows/wallet-connect.d.ts +2 -2
- package/dist/types/flows/wallet-connect.d.ts.map +1 -1
- package/dist/types/index.d.ts +5 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/password-provider.d.ts +194 -0
- package/dist/types/password-provider.d.ts.map +1 -0
- package/dist/types/types.d.ts +86 -1
- package/dist/types/types.d.ts.map +1 -1
- package/package.json +8 -9
- package/src/auth-manager.ts +236 -8
- package/src/flows/dwn-discovery.ts +99 -79
- package/src/flows/dwn-registration.ts +60 -5
- package/src/flows/import-identity.ts +2 -0
- package/src/flows/local-connect.ts +24 -3
- package/src/flows/session-restore.ts +15 -2
- package/src/flows/wallet-connect.ts +5 -4
- package/src/index.ts +10 -1
- package/src/password-provider.ts +383 -0
- package/src/types.ts +93 -1
package/src/auth-manager.ts
CHANGED
|
@@ -12,23 +12,28 @@ import type { BearerIdentity, PortableIdentity } from '@enbox/agent';
|
|
|
12
12
|
import { AuthEventEmitter } from './events.js';
|
|
13
13
|
import { AuthSession } from './identity-session.js';
|
|
14
14
|
import { createDefaultStorage } from './storage/storage.js';
|
|
15
|
+
import { discoverLocalDwn } from './flows/dwn-discovery.js';
|
|
15
16
|
import { localConnect } from './flows/local-connect.js';
|
|
16
17
|
import { restoreSession } from './flows/session-restore.js';
|
|
17
18
|
import { STORAGE_KEYS } from './types.js';
|
|
18
19
|
import { VaultManager } from './vault/vault-manager.js';
|
|
19
20
|
import { walletConnect } from './flows/wallet-connect.js';
|
|
21
|
+
|
|
22
|
+
import type { PasswordProvider } from './password-provider.js';
|
|
20
23
|
import type {
|
|
21
24
|
AuthEvent,
|
|
22
25
|
AuthEventHandler,
|
|
23
26
|
AuthManagerOptions,
|
|
24
27
|
AuthState,
|
|
25
28
|
DisconnectOptions,
|
|
29
|
+
HeadlessConnectOptions,
|
|
26
30
|
IdentityInfo,
|
|
27
31
|
ImportFromPhraseOptions,
|
|
28
32
|
ImportFromPortableOptions,
|
|
29
33
|
LocalConnectOptions,
|
|
30
34
|
RegistrationOptions,
|
|
31
35
|
RestoreSessionOptions,
|
|
36
|
+
ShutdownOptions,
|
|
32
37
|
StorageAdapter,
|
|
33
38
|
SyncOption,
|
|
34
39
|
WalletConnectOptions,
|
|
@@ -76,31 +81,45 @@ export class AuthManager {
|
|
|
76
81
|
private _session: AuthSession | undefined;
|
|
77
82
|
private _state: AuthState = 'uninitialized';
|
|
78
83
|
private _isConnecting = false;
|
|
84
|
+
private _isShutDown = false;
|
|
79
85
|
|
|
80
86
|
// Default options from create()
|
|
81
87
|
private _defaultPassword?: string;
|
|
88
|
+
private _passwordProvider?: PasswordProvider;
|
|
82
89
|
private _defaultSync?: SyncOption;
|
|
83
90
|
private _defaultDwnEndpoints?: string[];
|
|
84
91
|
private _registration?: RegistrationOptions;
|
|
85
92
|
|
|
93
|
+
/**
|
|
94
|
+
* The local DWN server endpoint discovered during `create()`, if any.
|
|
95
|
+
* `undefined` means no local server was found. This is set before any
|
|
96
|
+
* event listeners are attached, so consumers should check this property
|
|
97
|
+
* after `create()` returns rather than relying solely on events.
|
|
98
|
+
*/
|
|
99
|
+
private _localDwnEndpoint?: string;
|
|
100
|
+
|
|
86
101
|
private constructor(params: {
|
|
87
102
|
userAgent: EnboxUserAgent;
|
|
88
103
|
emitter: AuthEventEmitter;
|
|
89
104
|
storage: StorageAdapter;
|
|
90
105
|
vault: VaultManager;
|
|
91
106
|
defaultPassword?: string;
|
|
107
|
+
passwordProvider?: PasswordProvider;
|
|
92
108
|
defaultSync?: SyncOption;
|
|
93
109
|
defaultDwnEndpoints?: string[];
|
|
94
110
|
registration?: RegistrationOptions;
|
|
111
|
+
localDwnEndpoint?: string;
|
|
95
112
|
}) {
|
|
96
113
|
this._userAgent = params.userAgent;
|
|
97
114
|
this._emitter = params.emitter;
|
|
98
115
|
this._storage = params.storage;
|
|
99
116
|
this._vault = params.vault;
|
|
100
117
|
this._defaultPassword = params.defaultPassword;
|
|
118
|
+
this._passwordProvider = params.passwordProvider;
|
|
101
119
|
this._defaultSync = params.defaultSync;
|
|
102
120
|
this._defaultDwnEndpoints = params.defaultDwnEndpoints;
|
|
103
121
|
this._registration = params.registration;
|
|
122
|
+
this._localDwnEndpoint = params.localDwnEndpoint;
|
|
104
123
|
}
|
|
105
124
|
|
|
106
125
|
/**
|
|
@@ -117,11 +136,27 @@ export class AuthManager {
|
|
|
117
136
|
const emitter = new AuthEventEmitter();
|
|
118
137
|
const storage = options.storage ?? createDefaultStorage();
|
|
119
138
|
|
|
139
|
+
// Run local DWN discovery BEFORE creating the agent. Discovery has
|
|
140
|
+
// zero vault/DWN dependencies — it only checks the URL fragment,
|
|
141
|
+
// reads localStorage, and validates via GET /info.
|
|
142
|
+
//
|
|
143
|
+
// When a local DWN server is available, the agent is created in
|
|
144
|
+
// "remote mode": it skips creating an in-process DWN and routes all
|
|
145
|
+
// DWN operations through RPC to the local server.
|
|
146
|
+
let localDwnEndpoint: string | undefined;
|
|
147
|
+
if (!options.agent && options.localDwnStrategy !== 'off') {
|
|
148
|
+
localDwnEndpoint = await discoverLocalDwn(storage);
|
|
149
|
+
// NOTE: We intentionally do NOT emit 'local-dwn-available' here
|
|
150
|
+
// because event listeners aren't attached yet. Consumers should
|
|
151
|
+
// check `authManager.localDwnEndpoint` after create() returns.
|
|
152
|
+
}
|
|
153
|
+
|
|
120
154
|
// Use a pre-built agent or create one with the given options.
|
|
121
155
|
const userAgent = options.agent ?? await EnboxUserAgent.create({
|
|
122
156
|
dataPath : options.dataPath,
|
|
123
157
|
agentVault : options.agentVault,
|
|
124
158
|
localDwnStrategy : options.localDwnStrategy,
|
|
159
|
+
localDwnEndpoint,
|
|
125
160
|
});
|
|
126
161
|
|
|
127
162
|
const vault = new VaultManager(userAgent.vault, emitter);
|
|
@@ -132,9 +167,11 @@ export class AuthManager {
|
|
|
132
167
|
storage,
|
|
133
168
|
vault,
|
|
134
169
|
defaultPassword : options.password,
|
|
170
|
+
passwordProvider : options.passwordProvider,
|
|
135
171
|
defaultSync : options.sync,
|
|
136
172
|
defaultDwnEndpoints : options.dwnEndpoints,
|
|
137
173
|
registration : options.registration,
|
|
174
|
+
localDwnEndpoint,
|
|
138
175
|
});
|
|
139
176
|
|
|
140
177
|
// Determine initial state.
|
|
@@ -170,6 +207,7 @@ export class AuthManager {
|
|
|
170
207
|
emitter : this._emitter,
|
|
171
208
|
storage : this._storage,
|
|
172
209
|
defaultPassword : this._defaultPassword,
|
|
210
|
+
passwordProvider : this._passwordProvider,
|
|
173
211
|
defaultSync : this._defaultSync,
|
|
174
212
|
defaultDwnEndpoints : this._defaultDwnEndpoints,
|
|
175
213
|
registration : this._registration,
|
|
@@ -186,7 +224,7 @@ export class AuthManager {
|
|
|
186
224
|
}
|
|
187
225
|
|
|
188
226
|
/**
|
|
189
|
-
* Connect to an external wallet via the
|
|
227
|
+
* Connect to an external wallet via the Enbox Connect relay protocol.
|
|
190
228
|
*
|
|
191
229
|
* This runs the full WalletConnect flow: generates a URI for QR display,
|
|
192
230
|
* validates the PIN, imports the delegated DID, and processes grants.
|
|
@@ -202,8 +240,22 @@ export class AuthManager {
|
|
|
202
240
|
|
|
203
241
|
try {
|
|
204
242
|
// Ensure the agent is initialized and started before wallet connect.
|
|
205
|
-
const
|
|
206
|
-
|
|
243
|
+
const isFirstLaunch = await this._userAgent.firstLaunch();
|
|
244
|
+
let password = this._defaultPassword;
|
|
245
|
+
|
|
246
|
+
if (!password && this._passwordProvider) {
|
|
247
|
+
try {
|
|
248
|
+
password = await this._passwordProvider.getPassword({
|
|
249
|
+
reason: isFirstLaunch ? 'create' : 'unlock',
|
|
250
|
+
});
|
|
251
|
+
} catch {
|
|
252
|
+
// Provider failed — fall through to insecure default.
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
password ??= 'insecure-static-phrase';
|
|
257
|
+
|
|
258
|
+
if (isFirstLaunch) {
|
|
207
259
|
await this._userAgent.initialize({ password });
|
|
208
260
|
}
|
|
209
261
|
await this._userAgent.start({ password });
|
|
@@ -303,11 +355,12 @@ export class AuthManager {
|
|
|
303
355
|
try {
|
|
304
356
|
const session = await restoreSession(
|
|
305
357
|
{
|
|
306
|
-
userAgent
|
|
307
|
-
emitter
|
|
308
|
-
storage
|
|
309
|
-
defaultPassword
|
|
310
|
-
|
|
358
|
+
userAgent : this._userAgent,
|
|
359
|
+
emitter : this._emitter,
|
|
360
|
+
storage : this._storage,
|
|
361
|
+
defaultPassword : this._defaultPassword,
|
|
362
|
+
passwordProvider : this._passwordProvider,
|
|
363
|
+
defaultSync : this._defaultSync,
|
|
311
364
|
},
|
|
312
365
|
options,
|
|
313
366
|
);
|
|
@@ -322,6 +375,90 @@ export class AuthManager {
|
|
|
322
375
|
}
|
|
323
376
|
}
|
|
324
377
|
|
|
378
|
+
/**
|
|
379
|
+
* Lightweight vault unlock for one-shot utilities and subprocesses.
|
|
380
|
+
*
|
|
381
|
+
* Unlocks the vault and retrieves the active (or first available)
|
|
382
|
+
* identity **without** starting sync, DWN registration, or persisting
|
|
383
|
+
* session markers. This is the recommended replacement for calling
|
|
384
|
+
* `agent.start({ password })` directly.
|
|
385
|
+
*
|
|
386
|
+
* Typical use cases:
|
|
387
|
+
* - Git credential helpers that need to sign a token and exit
|
|
388
|
+
* - CLI utilities that perform a single operation
|
|
389
|
+
* - Any subprocess that shares a data directory with a long-running daemon
|
|
390
|
+
*
|
|
391
|
+
* @param options - Optional password override.
|
|
392
|
+
* @returns An active AuthSession (with sync disabled).
|
|
393
|
+
*
|
|
394
|
+
* @example
|
|
395
|
+
* ```ts
|
|
396
|
+
* const session = await auth.connectHeadless({ password });
|
|
397
|
+
* const did = session.did; // ready to use
|
|
398
|
+
* await auth.shutdown(); // clean exit
|
|
399
|
+
* ```
|
|
400
|
+
*/
|
|
401
|
+
async connectHeadless(options?: HeadlessConnectOptions): Promise<AuthSession> {
|
|
402
|
+
let password = options?.password ?? this._defaultPassword;
|
|
403
|
+
|
|
404
|
+
// Try the password provider if no explicit password.
|
|
405
|
+
if (!password && this._passwordProvider) {
|
|
406
|
+
const isFirstLaunch = await this._userAgent.firstLaunch();
|
|
407
|
+
password = await this._passwordProvider.getPassword({
|
|
408
|
+
reason: isFirstLaunch ? 'create' : 'unlock',
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (!password) {
|
|
413
|
+
throw new Error(
|
|
414
|
+
'[@enbox/auth] connectHeadless() requires a password. ' +
|
|
415
|
+
'Provide one via options.password, a passwordProvider, or the AuthManager default.'
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Unlock the vault (initialise on first launch).
|
|
420
|
+
if (await this._userAgent.firstLaunch()) {
|
|
421
|
+
await this._userAgent.initialize({ password });
|
|
422
|
+
} else {
|
|
423
|
+
await this._userAgent.start({ password });
|
|
424
|
+
}
|
|
425
|
+
this._emitter.emit('vault-unlocked', {});
|
|
426
|
+
|
|
427
|
+
// Find the active identity.
|
|
428
|
+
const identities = await this._userAgent.identity.list();
|
|
429
|
+
if (identities.length === 0) {
|
|
430
|
+
throw new Error('[@enbox/auth] No identities found in vault.');
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Prefer the previously-active identity, fall back to first.
|
|
434
|
+
const savedDid = await this._storage.get(STORAGE_KEYS.ACTIVE_IDENTITY);
|
|
435
|
+
const identity = (savedDid
|
|
436
|
+
? identities.find(id => id.did.uri === savedDid || id.metadata.connectedDid === savedDid)
|
|
437
|
+
: undefined
|
|
438
|
+
) ?? identities[0];
|
|
439
|
+
|
|
440
|
+
const connectedDid = identity.metadata.connectedDid ?? identity.did.uri;
|
|
441
|
+
const delegateDid = identity.metadata.connectedDid ? identity.did.uri : undefined;
|
|
442
|
+
|
|
443
|
+
const identityInfo: IdentityInfo = {
|
|
444
|
+
didUri : connectedDid,
|
|
445
|
+
name : identity.metadata.name,
|
|
446
|
+
connectedDid : identity.metadata.connectedDid,
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
// No sync, no registration, no session persistence markers.
|
|
450
|
+
this._session = new AuthSession({
|
|
451
|
+
agent : this._userAgent,
|
|
452
|
+
did : connectedDid,
|
|
453
|
+
delegateDid,
|
|
454
|
+
identity : identityInfo,
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
this._setState('connected');
|
|
458
|
+
|
|
459
|
+
return this._session;
|
|
460
|
+
}
|
|
461
|
+
|
|
325
462
|
// ─── Session management ────────────────────────────────────────
|
|
326
463
|
|
|
327
464
|
/** The current active session, or `undefined` if not connected. */
|
|
@@ -422,6 +559,86 @@ export class AuthManager {
|
|
|
422
559
|
}
|
|
423
560
|
}
|
|
424
561
|
|
|
562
|
+
/**
|
|
563
|
+
* Gracefully shut down the auth manager, releasing all resources.
|
|
564
|
+
*
|
|
565
|
+
* This goes beyond {@link disconnect} or {@link lock}: it stops sync,
|
|
566
|
+
* clears the active session, locks the vault, and **closes** the
|
|
567
|
+
* underlying storage handles (e.g. LevelDB) so the process can exit
|
|
568
|
+
* without dangling timers or open file descriptors.
|
|
569
|
+
*
|
|
570
|
+
* After calling `shutdown()`, the `AuthManager` instance should not be
|
|
571
|
+
* reused — create a new one via {@link AuthManager.create} if needed.
|
|
572
|
+
*
|
|
573
|
+
* Idempotent: calling `shutdown()` more than once is safe.
|
|
574
|
+
*
|
|
575
|
+
* @param options - Optional shutdown configuration.
|
|
576
|
+
* @param options.timeout - Milliseconds to wait for sync to stop. Default: `2000`.
|
|
577
|
+
*
|
|
578
|
+
* @example
|
|
579
|
+
* ```ts
|
|
580
|
+
* const session = await auth.connectHeadless({ password });
|
|
581
|
+
* // ... perform work ...
|
|
582
|
+
* await auth.shutdown(); // clean exit, no process.exit() needed
|
|
583
|
+
* ```
|
|
584
|
+
*/
|
|
585
|
+
async shutdown(options: ShutdownOptions = {}): Promise<void> {
|
|
586
|
+
if (this._isShutDown) {
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const { timeout = 2000 } = options;
|
|
591
|
+
const did = this._session?.did;
|
|
592
|
+
|
|
593
|
+
// 1. Stop sync.
|
|
594
|
+
if ('sync' in this._userAgent &&
|
|
595
|
+
typeof (this._userAgent as any).sync?.stopSync === 'function') {
|
|
596
|
+
try {
|
|
597
|
+
await (this._userAgent as any).sync.stopSync(timeout);
|
|
598
|
+
} catch {
|
|
599
|
+
// Best-effort — don't block shutdown on sync errors.
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// 2. Clear the active session.
|
|
604
|
+
this._session = undefined;
|
|
605
|
+
|
|
606
|
+
// 3. Lock the vault (emits 'vault-locked').
|
|
607
|
+
try {
|
|
608
|
+
await this._vault.lock();
|
|
609
|
+
} catch {
|
|
610
|
+
// Vault may already be locked or uninitialised — safe to ignore.
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// 4. Close the sync engine (releases LevelDB handles, timers).
|
|
614
|
+
if ('sync' in this._userAgent &&
|
|
615
|
+
typeof (this._userAgent as any).sync?.close === 'function') {
|
|
616
|
+
try {
|
|
617
|
+
await (this._userAgent as any).sync.close();
|
|
618
|
+
} catch {
|
|
619
|
+
// Best-effort.
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// 5. Close the storage adapter (e.g. LevelDB session store).
|
|
624
|
+
if (typeof this._storage.close === 'function') {
|
|
625
|
+
try {
|
|
626
|
+
await this._storage.close();
|
|
627
|
+
} catch {
|
|
628
|
+
// Best-effort.
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// 6. Mark as shut down and transition state.
|
|
633
|
+
this._isShutDown = true;
|
|
634
|
+
this._setState('locked');
|
|
635
|
+
|
|
636
|
+
// 7. Emit session-end if there was an active session.
|
|
637
|
+
if (did) {
|
|
638
|
+
this._emitter.emit('session-end', { did });
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
425
642
|
// ─── Multi-identity ────────────────────────────────────────────
|
|
426
643
|
|
|
427
644
|
/**
|
|
@@ -596,6 +813,17 @@ export class AuthManager {
|
|
|
596
813
|
return this._userAgent;
|
|
597
814
|
}
|
|
598
815
|
|
|
816
|
+
/**
|
|
817
|
+
* The local DWN server endpoint discovered during `create()`, if any.
|
|
818
|
+
*
|
|
819
|
+
* When set, the agent is operating in remote mode (no in-process DWN).
|
|
820
|
+
* This property is available immediately after `create()` returns,
|
|
821
|
+
* before any event listeners are attached.
|
|
822
|
+
*/
|
|
823
|
+
get localDwnEndpoint(): string | undefined {
|
|
824
|
+
return this._localDwnEndpoint;
|
|
825
|
+
}
|
|
826
|
+
|
|
599
827
|
// ─── Private helpers ───────────────────────────────────────────
|
|
600
828
|
|
|
601
829
|
private _setState(state: AuthState): void {
|
|
@@ -7,32 +7,28 @@
|
|
|
7
7
|
*
|
|
8
8
|
* ## Discovery channels (browser, highest to lowest priority)
|
|
9
9
|
*
|
|
10
|
-
* 1. **URL fragment payload** — A `dwn://
|
|
10
|
+
* 1. **URL fragment payload** — A `dwn://connect` redirect just landed
|
|
11
11
|
* on the page with the endpoint in `#`. Highest priority because it's
|
|
12
12
|
* fresh and explicit.
|
|
13
13
|
* 2. **Persisted endpoint** (localStorage) — A previously discovered
|
|
14
14
|
* endpoint restored and re-validated via `GET /info`.
|
|
15
|
-
* 3. **Agent-level discovery** (transparent, runs on every `sendRequest`)
|
|
16
|
-
* — `~/.enbox/dwn.json` discovery file (Node/Bun only; skipped in
|
|
17
|
-
* browsers) and sequential port probing on `127.0.0.1:{3000,55500–55509}`.
|
|
18
|
-
* This channel works even if the browser-specific functions here
|
|
19
|
-
* return `false`.
|
|
20
15
|
*
|
|
21
16
|
* ## Discovery channels (CLI / native, all transparent)
|
|
22
17
|
*
|
|
23
|
-
* In Node/Bun environments,
|
|
18
|
+
* In Node/Bun environments, the agent's `LocalDwnDiscovery` reads the
|
|
19
|
+
* `~/.enbox/dwn.json` discovery file automatically inside
|
|
24
20
|
* `AgentDwnApi.getLocalDwnEndpoint()`. The browser-specific functions
|
|
25
21
|
* in this module (`checkUrlForDwnDiscoveryPayload`, `requestLocalDwnDiscovery`)
|
|
26
|
-
* are not needed
|
|
27
|
-
* on its own.
|
|
22
|
+
* are not needed in those environments.
|
|
28
23
|
*
|
|
29
|
-
* @see https://github.com/enboxorg/enbox/issues/
|
|
24
|
+
* @see https://github.com/enboxorg/enbox/issues/677
|
|
30
25
|
* @module
|
|
31
26
|
*/
|
|
32
27
|
|
|
33
28
|
import type { EnboxUserAgent } from '@enbox/agent';
|
|
34
29
|
|
|
35
|
-
import {
|
|
30
|
+
import { EnboxRpcClient } from '@enbox/dwn-clients';
|
|
31
|
+
import { buildDwnConnectUrl, localDwnServerName, normalizeBaseUrl, readDwnDiscoveryPayloadFromUrl } from '@enbox/agent';
|
|
36
32
|
|
|
37
33
|
import type { AuthEventEmitter } from '../events.js';
|
|
38
34
|
import { STORAGE_KEYS } from '../types.js';
|
|
@@ -42,7 +38,7 @@ import type { StorageAdapter } from '../types.js';
|
|
|
42
38
|
* Check the current page URL for a `DwnDiscoveryPayload` in the fragment.
|
|
43
39
|
*
|
|
44
40
|
* This is called once at the start of a connection flow to detect whether
|
|
45
|
-
* the user was just redirected back from a `dwn://
|
|
41
|
+
* the user was just redirected back from a `dwn://connect` handler. If a
|
|
46
42
|
* valid payload is found, the endpoint is persisted and the fragment is
|
|
47
43
|
* cleared to prevent double-reads.
|
|
48
44
|
*
|
|
@@ -69,6 +65,83 @@ export function checkUrlForDwnDiscoveryPayload(): string | undefined {
|
|
|
69
65
|
return payload.endpoint;
|
|
70
66
|
}
|
|
71
67
|
|
|
68
|
+
// ─── Standalone (pre-agent) discovery ───────────────────────────
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Validate a local DWN endpoint by calling `GET /info` and checking
|
|
72
|
+
* that the server identifies itself as `@enbox/dwn-server`.
|
|
73
|
+
*
|
|
74
|
+
* This function has **zero** agent or vault dependencies — it only uses
|
|
75
|
+
* the network. It is safe to call before the agent exists.
|
|
76
|
+
*
|
|
77
|
+
* @param endpoint - The candidate endpoint URL.
|
|
78
|
+
* @returns The normalised endpoint if valid, `undefined` otherwise.
|
|
79
|
+
*/
|
|
80
|
+
async function validateEndpointStandalone(endpoint: string): Promise<string | undefined> {
|
|
81
|
+
const normalized = normalizeBaseUrl(endpoint);
|
|
82
|
+
try {
|
|
83
|
+
const rpc = new EnboxRpcClient();
|
|
84
|
+
const serverInfo = await rpc.getServerInfo(normalized);
|
|
85
|
+
if (serverInfo.server === localDwnServerName) {
|
|
86
|
+
return normalized;
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// Server not reachable or not ours.
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Run local DWN discovery **before the agent exists**.
|
|
96
|
+
*
|
|
97
|
+
* This is the standalone counterpart of {@link applyLocalDwnDiscovery} and
|
|
98
|
+
* is designed to be called in `AuthManager.create()`, before
|
|
99
|
+
* `EnboxUserAgent.create()`, so the agent creation can decide whether to
|
|
100
|
+
* spin up an in-process DWN or operate in remote mode.
|
|
101
|
+
*
|
|
102
|
+
* Discovery channels (highest → lowest priority):
|
|
103
|
+
* 1. **URL fragment payload** — A `dwn://connect` redirect just landed.
|
|
104
|
+
* 2. **Persisted endpoint** (localStorage) — A previously discovered
|
|
105
|
+
* endpoint, re-validated via `GET /info`.
|
|
106
|
+
*
|
|
107
|
+
* When a valid endpoint is found it is persisted to storage. When a
|
|
108
|
+
* previously-persisted endpoint is stale, it is removed.
|
|
109
|
+
*
|
|
110
|
+
* @param storage - The auth storage adapter (for reading/writing the
|
|
111
|
+
* cached endpoint).
|
|
112
|
+
* @returns The validated endpoint URL, or `undefined` if no local DWN
|
|
113
|
+
* server is available.
|
|
114
|
+
*/
|
|
115
|
+
export async function discoverLocalDwn(
|
|
116
|
+
storage: StorageAdapter,
|
|
117
|
+
): Promise<string | undefined> {
|
|
118
|
+
// Channel 1: Fresh redirect payload in the URL fragment.
|
|
119
|
+
const freshEndpoint = checkUrlForDwnDiscoveryPayload();
|
|
120
|
+
if (freshEndpoint) {
|
|
121
|
+
const validated = await validateEndpointStandalone(freshEndpoint);
|
|
122
|
+
if (validated) {
|
|
123
|
+
await persistLocalDwnEndpoint(storage, validated);
|
|
124
|
+
return validated;
|
|
125
|
+
}
|
|
126
|
+
// Payload was in the URL but the server is unreachable — fall through.
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Channel 2: Persisted endpoint from a previous session.
|
|
130
|
+
const cached = await storage.get(STORAGE_KEYS.LOCAL_DWN_ENDPOINT);
|
|
131
|
+
if (cached) {
|
|
132
|
+
const validated = await validateEndpointStandalone(cached);
|
|
133
|
+
if (validated) {
|
|
134
|
+
return validated;
|
|
135
|
+
}
|
|
136
|
+
// Stale — server no longer running.
|
|
137
|
+
await clearLocalDwnEndpoint(storage);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ─── Storage helpers ────────────────────────────────────────────
|
|
144
|
+
|
|
72
145
|
/**
|
|
73
146
|
* Persist a discovered local DWN endpoint in auth storage.
|
|
74
147
|
*
|
|
@@ -130,25 +203,18 @@ export async function restoreLocalDwnEndpoint(
|
|
|
130
203
|
* Run the full local DWN discovery sequence for a browser connection flow.
|
|
131
204
|
*
|
|
132
205
|
* This function handles the **receiving** side of local DWN discovery in
|
|
133
|
-
* the browser. It does NOT trigger the `dwn://
|
|
206
|
+
* the browser. It does NOT trigger the `dwn://connect` redirect — use
|
|
134
207
|
* {@link requestLocalDwnDiscovery} for that.
|
|
135
208
|
*
|
|
136
209
|
* The discovery channels, from highest to lowest priority:
|
|
137
210
|
*
|
|
138
|
-
* 1. **URL fragment payload** — A `dwn://
|
|
211
|
+
* 1. **URL fragment payload** — A `dwn://connect` redirect just landed on
|
|
139
212
|
* this page with the DWN endpoint in `#`. This is the highest-priority
|
|
140
213
|
* signal because it's fresh and explicit.
|
|
141
214
|
*
|
|
142
215
|
* 2. **Persisted endpoint** (localStorage) — A previously discovered
|
|
143
216
|
* endpoint is restored and re-validated via `GET /info`.
|
|
144
217
|
*
|
|
145
|
-
* 3. **Agent-level discovery** (transparent) — Even if this function
|
|
146
|
-
* returns `false`, the agent's `LocalDwnDiscovery` will independently
|
|
147
|
-
* try the discovery file (`~/.enbox/dwn.json`) and port probing on
|
|
148
|
-
* every `sendRequest()` call. Those channels are not available in
|
|
149
|
-
* browsers (no filesystem access, CORS may block probes), but they
|
|
150
|
-
* work transparently in Node/Bun CLI environments.
|
|
151
|
-
*
|
|
152
218
|
* When an `emitter` is provided, this function emits:
|
|
153
219
|
* - `'local-dwn-available'` with the endpoint when discovery succeeds.
|
|
154
220
|
* - `'local-dwn-unavailable'` when no local DWN could be reached.
|
|
@@ -191,37 +257,31 @@ export async function applyLocalDwnDiscovery(
|
|
|
191
257
|
return restored;
|
|
192
258
|
}
|
|
193
259
|
|
|
194
|
-
// ─── dwn://
|
|
260
|
+
// ─── dwn://connect trigger ──────────────────────────────────────
|
|
195
261
|
|
|
196
262
|
/**
|
|
197
|
-
* Initiate the `dwn://
|
|
263
|
+
* Initiate the `dwn://connect` flow by opening the connect URL.
|
|
198
264
|
*
|
|
199
|
-
* This asks the operating system to route `dwn://
|
|
265
|
+
* This asks the operating system to route `dwn://connect?callback=<url>`
|
|
200
266
|
* to the registered handler (electrobun-dwn), which will redirect the
|
|
201
267
|
* user's browser back to `callbackUrl` with the local DWN endpoint
|
|
202
268
|
* encoded in the URL fragment.
|
|
203
269
|
*
|
|
204
|
-
* **
|
|
205
|
-
*
|
|
206
|
-
* will silently fail or show an OS-level error dialog.
|
|
207
|
-
* {@link probeLocalDwn} first to check if a local DWN is already
|
|
208
|
-
* reachable via port probing — if it is, you can skip the register flow
|
|
209
|
-
* entirely and call {@link applyLocalDwnDiscovery} instead.
|
|
270
|
+
* **Note:** There is no reliable cross-browser API to detect whether a
|
|
271
|
+
* `dwn://` handler is installed. If no handler is registered, this call
|
|
272
|
+
* will silently fail or show an OS-level error dialog.
|
|
210
273
|
*
|
|
211
274
|
* @param callbackUrl - The URL to redirect back to. Defaults to the
|
|
212
275
|
* current page URL (without its fragment) if running in a browser.
|
|
213
|
-
* @returns `true` if the
|
|
276
|
+
* @returns `true` if the connect URL was opened, `false` if no
|
|
214
277
|
* callback URL could be determined (e.g. no `globalThis.location`).
|
|
215
278
|
*
|
|
216
279
|
* @example
|
|
217
280
|
* ```ts
|
|
218
|
-
* //
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
* requestLocalDwnDiscovery();
|
|
223
|
-
* // The page will reload with the endpoint in the URL fragment.
|
|
224
|
-
* }
|
|
281
|
+
* // Trigger the dwn://connect flow to discover a local DWN.
|
|
282
|
+
* requestLocalDwnDiscovery();
|
|
283
|
+
* // The page will reload with the endpoint in the URL fragment.
|
|
284
|
+
* // On the next connect/restore, applyLocalDwnDiscovery() reads it.
|
|
225
285
|
* ```
|
|
226
286
|
*/
|
|
227
287
|
export function requestLocalDwnDiscovery(callbackUrl?: string): boolean {
|
|
@@ -230,7 +290,7 @@ export function requestLocalDwnDiscovery(callbackUrl?: string): boolean {
|
|
|
230
290
|
return false;
|
|
231
291
|
}
|
|
232
292
|
|
|
233
|
-
const registerUrl =
|
|
293
|
+
const registerUrl = buildDwnConnectUrl(resolvedCallback);
|
|
234
294
|
|
|
235
295
|
// Open the dwn:// URL. Use window.open() rather than location.href
|
|
236
296
|
// assignment to avoid navigating away from the current page if the
|
|
@@ -249,46 +309,6 @@ export function requestLocalDwnDiscovery(callbackUrl?: string): boolean {
|
|
|
249
309
|
return false;
|
|
250
310
|
}
|
|
251
311
|
|
|
252
|
-
/**
|
|
253
|
-
* Probe whether a local DWN server is reachable via direct HTTP fetch.
|
|
254
|
-
*
|
|
255
|
-
* Attempts `GET http://127.0.0.1:{port}/info` on the well-known port
|
|
256
|
-
* candidates and returns the endpoint URL of the first server that
|
|
257
|
-
* responds with a valid `@enbox/dwn-server` identity.
|
|
258
|
-
*
|
|
259
|
-
* This is useful in browsers to check if a local DWN is available
|
|
260
|
-
* *before* triggering the `dwn://register` redirect flow — if the
|
|
261
|
-
* server is already reachable (CORS permitting), the redirect is
|
|
262
|
-
* unnecessary.
|
|
263
|
-
*
|
|
264
|
-
* @returns The local DWN endpoint URL, or `undefined` if no server
|
|
265
|
-
* was found. Returns `undefined` (rather than throwing) on CORS
|
|
266
|
-
* errors or network failures.
|
|
267
|
-
*/
|
|
268
|
-
export async function probeLocalDwn(): Promise<string | undefined> {
|
|
269
|
-
// Import port candidates from @enbox/agent. Using a dynamic import
|
|
270
|
-
// here keeps the function self-contained and avoids circular deps.
|
|
271
|
-
const { localDwnPortCandidates, localDwnHostCandidates } = await import('@enbox/agent');
|
|
272
|
-
|
|
273
|
-
for (const port of localDwnPortCandidates) {
|
|
274
|
-
for (const host of localDwnHostCandidates) {
|
|
275
|
-
const endpoint = `http://${host}:${port}`;
|
|
276
|
-
try {
|
|
277
|
-
const response = await fetch(`${endpoint}/info`, { signal: AbortSignal.timeout(2_000) });
|
|
278
|
-
if (!response.ok) { continue; }
|
|
279
|
-
|
|
280
|
-
const serverInfo = await response.json() as { server?: string };
|
|
281
|
-
if (serverInfo?.server === '@enbox/dwn-server') {
|
|
282
|
-
return endpoint;
|
|
283
|
-
}
|
|
284
|
-
} catch {
|
|
285
|
-
// Network error, CORS block, or timeout — try next candidate.
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
return undefined;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
312
|
// ─── Internal helpers ───────────────────────────────────────────
|
|
293
313
|
|
|
294
314
|
/** Return the current page URL without the fragment, or `undefined`. */
|