@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.
Files changed (50) hide show
  1. package/dist/esm/auth-manager.js +200 -4
  2. package/dist/esm/auth-manager.js.map +1 -1
  3. package/dist/esm/flows/dwn-discovery.js +96 -81
  4. package/dist/esm/flows/dwn-discovery.js.map +1 -1
  5. package/dist/esm/flows/dwn-registration.js +49 -3
  6. package/dist/esm/flows/dwn-registration.js.map +1 -1
  7. package/dist/esm/flows/import-identity.js +2 -0
  8. package/dist/esm/flows/import-identity.js.map +1 -1
  9. package/dist/esm/flows/local-connect.js +25 -8
  10. package/dist/esm/flows/local-connect.js.map +1 -1
  11. package/dist/esm/flows/session-restore.js +13 -2
  12. package/dist/esm/flows/session-restore.js.map +1 -1
  13. package/dist/esm/flows/wallet-connect.js +5 -4
  14. package/dist/esm/flows/wallet-connect.js.map +1 -1
  15. package/dist/esm/index.js +5 -1
  16. package/dist/esm/index.js.map +1 -1
  17. package/dist/esm/password-provider.js +319 -0
  18. package/dist/esm/password-provider.js.map +1 -0
  19. package/dist/esm/types.js +9 -1
  20. package/dist/esm/types.js.map +1 -1
  21. package/dist/types/auth-manager.d.ts +67 -2
  22. package/dist/types/auth-manager.d.ts.map +1 -1
  23. package/dist/types/flows/dwn-discovery.d.ts +40 -53
  24. package/dist/types/flows/dwn-discovery.d.ts.map +1 -1
  25. package/dist/types/flows/dwn-registration.d.ts +20 -1
  26. package/dist/types/flows/dwn-registration.d.ts.map +1 -1
  27. package/dist/types/flows/import-identity.d.ts.map +1 -1
  28. package/dist/types/flows/local-connect.d.ts +2 -0
  29. package/dist/types/flows/local-connect.d.ts.map +1 -1
  30. package/dist/types/flows/session-restore.d.ts +2 -0
  31. package/dist/types/flows/session-restore.d.ts.map +1 -1
  32. package/dist/types/flows/wallet-connect.d.ts +2 -2
  33. package/dist/types/flows/wallet-connect.d.ts.map +1 -1
  34. package/dist/types/index.d.ts +5 -2
  35. package/dist/types/index.d.ts.map +1 -1
  36. package/dist/types/password-provider.d.ts +194 -0
  37. package/dist/types/password-provider.d.ts.map +1 -0
  38. package/dist/types/types.d.ts +86 -1
  39. package/dist/types/types.d.ts.map +1 -1
  40. package/package.json +8 -9
  41. package/src/auth-manager.ts +236 -8
  42. package/src/flows/dwn-discovery.ts +99 -79
  43. package/src/flows/dwn-registration.ts +60 -5
  44. package/src/flows/import-identity.ts +2 -0
  45. package/src/flows/local-connect.ts +24 -3
  46. package/src/flows/session-restore.ts +15 -2
  47. package/src/flows/wallet-connect.ts +5 -4
  48. package/src/index.ts +10 -1
  49. package/src/password-provider.ts +383 -0
  50. package/src/types.ts +93 -1
@@ -15,9 +15,12 @@ import type { EnboxUserAgent } from '@enbox/agent';
15
15
 
16
16
  import { DwnRegistrar } from '@enbox/dwn-clients';
17
17
 
18
+ import { STORAGE_KEYS } from '../types.js';
19
+
18
20
  import type {
19
21
  RegistrationOptions,
20
22
  RegistrationTokenData,
23
+ StorageAdapter,
21
24
  } from '../types.js';
22
25
 
23
26
  /** @internal */
@@ -33,6 +36,12 @@ export interface RegistrationContext {
33
36
 
34
37
  /** The connected DID URI (the identity's DID). */
35
38
  connectedDid: string;
39
+
40
+ /**
41
+ * Storage adapter for automatic token persistence.
42
+ * Only used when `registration.persistTokens` is `true`.
43
+ */
44
+ storage?: StorageAdapter;
36
45
  }
37
46
 
38
47
  /**
@@ -51,11 +60,19 @@ export async function registerWithDwnEndpoints(
51
60
  ctx: RegistrationContext,
52
61
  registration: RegistrationOptions,
53
62
  ): Promise<void> {
54
- const { userAgent, dwnEndpoints, agentDid, connectedDid } = ctx;
63
+ const { userAgent, dwnEndpoints, agentDid, connectedDid, storage } = ctx;
64
+
65
+ // Load initial tokens: when persistTokens is enabled, load from storage
66
+ // (ignoring any explicit registrationTokens). Otherwise use the explicit map.
67
+ let seedTokens: Record<string, RegistrationTokenData> = {};
55
68
 
56
- const updatedTokens: Record<string, RegistrationTokenData> = {
57
- ...(registration.registrationTokens ?? {}),
58
- };
69
+ if (registration.persistTokens && storage) {
70
+ seedTokens = await loadTokensFromStorage(storage);
71
+ } else {
72
+ seedTokens = registration.registrationTokens ?? {};
73
+ }
74
+
75
+ const updatedTokens: Record<string, RegistrationTokenData> = { ...seedTokens };
59
76
 
60
77
  try {
61
78
  for (const dwnEndpoint of dwnEndpoints) {
@@ -145,7 +162,12 @@ export async function registerWithDwnEndpoints(
145
162
  }
146
163
  }
147
164
 
148
- // Notify app of updated tokens for persistence.
165
+ // Persist tokens to storage when auto-persistence is enabled.
166
+ if (registration.persistTokens && storage) {
167
+ await saveTokensToStorage(storage, updatedTokens);
168
+ }
169
+
170
+ // Notify app of updated tokens (always, even when auto-persisting).
149
171
  if (registration.onRegistrationTokens) {
150
172
  registration.onRegistrationTokens(updatedTokens);
151
173
  }
@@ -155,3 +177,36 @@ export async function registerWithDwnEndpoints(
155
177
  registration.onFailure(error);
156
178
  }
157
179
  }
180
+
181
+ // ─── Storage helpers ──────────────────────────────────────────────
182
+
183
+ /**
184
+ * Load registration tokens from a `StorageAdapter`.
185
+ *
186
+ * Returns an empty record if no tokens are stored or the stored value
187
+ * is corrupt (best-effort — never throws).
188
+ *
189
+ * @internal
190
+ */
191
+ export async function loadTokensFromStorage(
192
+ storage: StorageAdapter,
193
+ ): Promise<Record<string, RegistrationTokenData>> {
194
+ try {
195
+ const raw = await storage.get(STORAGE_KEYS.REGISTRATION_TOKENS);
196
+ if (!raw) { return {}; }
197
+ return JSON.parse(raw) as Record<string, RegistrationTokenData>;
198
+ } catch {
199
+ return {};
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Save registration tokens to a `StorageAdapter`.
205
+ * @internal
206
+ */
207
+ export async function saveTokensToStorage(
208
+ storage: StorageAdapter,
209
+ tokens: Record<string, RegistrationTokenData>,
210
+ ): Promise<void> {
211
+ await storage.set(STORAGE_KEYS.REGISTRATION_TOKENS, JSON.stringify(tokens));
212
+ }
@@ -105,6 +105,7 @@ export async function importFromPhrase(
105
105
  dwnEndpoints,
106
106
  agentDid : userAgent.agentDid.uri,
107
107
  connectedDid,
108
+ storage : storage,
108
109
  },
109
110
  ctx.registration,
110
111
  );
@@ -174,6 +175,7 @@ export async function importFromPortable(
174
175
  dwnEndpoints,
175
176
  agentDid : userAgent.agentDid.uri,
176
177
  connectedDid,
178
+ storage : storage,
177
179
  },
178
180
  ctx.registration,
179
181
  );
@@ -9,6 +9,7 @@
9
9
  import type { EnboxUserAgent } from '@enbox/agent';
10
10
 
11
11
  import type { AuthEventEmitter } from '../events.js';
12
+ import type { PasswordProvider } from '../password-provider.js';
12
13
  import type { LocalConnectOptions, RegistrationOptions, StorageAdapter, SyncOption } from '../types.js';
13
14
 
14
15
  import { applyLocalDwnDiscovery } from './dwn-discovery.js';
@@ -22,6 +23,7 @@ export interface LocalConnectContext {
22
23
  emitter: AuthEventEmitter;
23
24
  storage: StorageAdapter;
24
25
  defaultPassword?: string;
26
+ passwordProvider?: PasswordProvider;
25
27
  defaultSync?: SyncOption;
26
28
  defaultDwnEndpoints?: string[];
27
29
  registration?: RegistrationOptions;
@@ -39,7 +41,22 @@ export async function localConnect(
39
41
  ): Promise<AuthSession> {
40
42
  const { userAgent, emitter, storage } = ctx;
41
43
 
42
- const password = options.password ?? ctx.defaultPassword ?? INSECURE_DEFAULT_PASSWORD;
44
+ // Resolve password: explicit option provider manager default → insecure fallback.
45
+ const isFirstLaunch = await userAgent.firstLaunch();
46
+ let password = options.password ?? ctx.defaultPassword;
47
+
48
+ if (!password && ctx.passwordProvider) {
49
+ try {
50
+ password = await ctx.passwordProvider.getPassword({
51
+ reason: isFirstLaunch ? 'create' : 'unlock',
52
+ });
53
+ } catch {
54
+ // Provider failed — fall through to insecure default.
55
+ }
56
+ }
57
+
58
+ password ??= INSECURE_DEFAULT_PASSWORD;
59
+
43
60
  const sync = options.sync ?? ctx.defaultSync;
44
61
  const dwnEndpoints = options.dwnEndpoints ?? ctx.defaultDwnEndpoints ?? ['https://enbox-dwn.fly.dev'];
45
62
 
@@ -55,7 +72,7 @@ export async function localConnect(
55
72
  let recoveryPhrase: string | undefined;
56
73
 
57
74
  // Initialize vault on first launch.
58
- if (await userAgent.firstLaunch()) {
75
+ if (isFirstLaunch) {
59
76
  recoveryPhrase = await userAgent.initialize({
60
77
  password,
61
78
  recoveryPhrase: options.recoveryPhrase,
@@ -68,7 +85,10 @@ export async function localConnect(
68
85
  emitter.emit('vault-unlocked', {});
69
86
 
70
87
  // Apply local DWN discovery (browser redirect payload or persisted endpoint).
71
- await applyLocalDwnDiscovery(userAgent, storage, emitter);
88
+ // In remote mode, discovery already ran before agent creation — skip.
89
+ if (!userAgent.dwn.isRemoteMode) {
90
+ await applyLocalDwnDiscovery(userAgent, storage, emitter);
91
+ }
72
92
 
73
93
  // Find or create the user identity.
74
94
  const identities = await userAgent.identity.list();
@@ -117,6 +137,7 @@ export async function localConnect(
117
137
  dwnEndpoints,
118
138
  agentDid : userAgent.agentDid.uri,
119
139
  connectedDid,
140
+ storage : storage,
120
141
  },
121
142
  ctx.registration,
122
143
  );
@@ -9,6 +9,7 @@
9
9
  import type { EnboxUserAgent } from '@enbox/agent';
10
10
 
11
11
  import type { AuthEventEmitter } from '../events.js';
12
+ import type { PasswordProvider } from '../password-provider.js';
12
13
  import type { RestoreSessionOptions, StorageAdapter, SyncOption } from '../types.js';
13
14
 
14
15
  import { applyLocalDwnDiscovery } from './dwn-discovery.js';
@@ -21,6 +22,7 @@ export interface SessionRestoreContext {
21
22
  emitter: AuthEventEmitter;
22
23
  storage: StorageAdapter;
23
24
  defaultPassword?: string;
25
+ passwordProvider?: PasswordProvider;
24
26
  defaultSync?: SyncOption;
25
27
  }
26
28
 
@@ -42,13 +44,21 @@ export async function restoreSession(
42
44
  return undefined;
43
45
  }
44
46
 
45
- // Resolve password: explicit option → callback → manager default → insecure fallback.
47
+ // Resolve password: explicit option → callback → provider → manager default → insecure fallback.
46
48
  let password = options.password ?? ctx.defaultPassword;
47
49
 
48
50
  if (!password && options.onPasswordRequired) {
49
51
  password = await options.onPasswordRequired();
50
52
  }
51
53
 
54
+ if (!password && ctx.passwordProvider) {
55
+ try {
56
+ password = await ctx.passwordProvider.getPassword({ reason: 'unlock' });
57
+ } catch {
58
+ // Provider failed — fall through to insecure default.
59
+ }
60
+ }
61
+
52
62
  password ??= INSECURE_DEFAULT_PASSWORD;
53
63
 
54
64
  // Warn if using insecure default.
@@ -71,7 +81,10 @@ export async function restoreSession(
71
81
  emitter.emit('vault-unlocked', {});
72
82
 
73
83
  // Apply local DWN discovery (browser redirect payload or persisted endpoint).
74
- await applyLocalDwnDiscovery(userAgent, storage, emitter);
84
+ // In remote mode, discovery already ran before agent creation — skip.
85
+ if (!userAgent.dwn.isRemoteMode) {
86
+ await applyLocalDwnDiscovery(userAgent, storage, emitter);
87
+ }
75
88
 
76
89
  // Determine which identity to reconnect.
77
90
  const activeIdentityDid = await storage.get(STORAGE_KEYS.ACTIVE_IDENTITY);
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Wallet connect (OIDC/QR) flow.
2
+ * Wallet connect (Enbox Connect relay) flow.
3
3
  *
4
- * Connects to an external wallet via the WalletConnect relay protocol,
4
+ * Connects to an external wallet via the Enbox Connect relay protocol,
5
5
  * importing a delegated DID with permission grants.
6
6
  * This replaces the "Mode B/C" paths in Enbox.connect().
7
7
  * @module
@@ -99,12 +99,12 @@ export async function walletConnect(
99
99
  );
100
100
  }
101
101
 
102
- // Run the full OIDC wallet connect flow.
102
+ // Run the Enbox Connect relay flow.
103
103
  // permissionRequests are already agent-level ConnectPermissionRequest objects.
104
104
  const result = await WalletConnect.initClient({
105
105
  displayName : options.displayName,
106
106
  connectServerUrl : options.connectServerUrl,
107
- walletUri : options.walletUri ?? 'web5://connect',
107
+ walletUri : options.walletUri ?? 'enbox://connect',
108
108
  permissionRequests : options.permissionRequests,
109
109
  onWalletUriReady : options.onWalletUriReady,
110
110
  validatePin : options.validatePin,
@@ -147,6 +147,7 @@ export async function walletConnect(
147
147
  dwnEndpoints,
148
148
  agentDid : userAgent.agentDid.uri,
149
149
  connectedDid,
150
+ storage : storage,
150
151
  },
151
152
  ctx.registration,
152
153
  );
package/src/index.ts CHANGED
@@ -40,6 +40,10 @@ export { AuthSession } from './identity-session.js';
40
40
  export { VaultManager } from './vault/vault-manager.js';
41
41
  export { AuthEventEmitter } from './events.js';
42
42
 
43
+ // Password providers
44
+ export { PasswordProvider } from './password-provider.js';
45
+ export type { PasswordContext } from './password-provider.js';
46
+
43
47
  // Re-export agent classes so consumers can construct custom agents/vaults
44
48
  // without a direct @enbox/agent dependency.
45
49
  export { EnboxUserAgent, HdIdentityVault } from '@enbox/agent';
@@ -47,13 +51,16 @@ export { EnboxUserAgent, HdIdentityVault } from '@enbox/agent';
47
51
  // Wallet-connect helpers
48
52
  export { processConnectedGrants } from './flows/wallet-connect.js';
49
53
 
54
+ // Registration token storage helpers
55
+ export { loadTokensFromStorage, saveTokensToStorage } from './flows/dwn-registration.js';
56
+
50
57
  // Local DWN discovery (browser dwn:// protocol integration)
51
58
  export {
52
59
  applyLocalDwnDiscovery,
53
60
  checkUrlForDwnDiscoveryPayload,
54
61
  clearLocalDwnEndpoint,
62
+ discoverLocalDwn,
55
63
  persistLocalDwnEndpoint,
56
- probeLocalDwn,
57
64
  requestLocalDwnDiscovery,
58
65
  restoreLocalDwnEndpoint,
59
66
  } from './flows/dwn-discovery.js';
@@ -71,6 +78,7 @@ export type {
71
78
  AuthState,
72
79
  ConnectPermissionRequest,
73
80
  DisconnectOptions,
81
+ HeadlessConnectOptions,
74
82
  IdentityInfo,
75
83
  IdentityVaultBackup,
76
84
  ImportFromPhraseOptions,
@@ -83,6 +91,7 @@ export type {
83
91
  RegistrationOptions,
84
92
  RegistrationTokenData,
85
93
  RestoreSessionOptions,
94
+ ShutdownOptions,
86
95
  StorageAdapter,
87
96
  SyncOption,
88
97
  WalletConnectOptions,
@@ -0,0 +1,383 @@
1
+ /**
2
+ * PasswordProvider — composable password acquisition strategies.
3
+ *
4
+ * Replaces ad-hoc password prompting scattered across CLI consumers
5
+ * (env vars, raw-mode TTY, `/dev/tty` + `stty`, `@clack/prompts`, etc.)
6
+ * with a single, composable abstraction.
7
+ *
8
+ * @example Chained provider (env first, fall back to TTY)
9
+ * ```ts
10
+ * import { PasswordProvider } from '@enbox/auth';
11
+ *
12
+ * const provider = PasswordProvider.chain([
13
+ * PasswordProvider.fromEnv('ENBOX_PASSWORD'),
14
+ * PasswordProvider.fromTty({ prompt: 'Vault password: ' }),
15
+ * ]);
16
+ *
17
+ * const auth = await AuthManager.create({ passwordProvider: provider });
18
+ * ```
19
+ *
20
+ * @module
21
+ */
22
+
23
+ // ─── Types ───────────────────────────────────────────────────────
24
+
25
+ /** Context passed to a password provider explaining why a password is needed. */
26
+ export interface PasswordContext {
27
+ /**
28
+ * Why the password is being requested.
29
+ *
30
+ * - `'create'` — first launch, creating a new vault (prompt may ask
31
+ * for confirmation).
32
+ * - `'unlock'` — unlocking an existing vault.
33
+ */
34
+ reason: 'create' | 'unlock';
35
+ }
36
+
37
+ /**
38
+ * A strategy for obtaining a vault password.
39
+ *
40
+ * Implementations may be interactive (TTY prompts) or non-interactive
41
+ * (environment variables, cached values). Use {@link PasswordProvider.chain}
42
+ * to compose multiple strategies with automatic fallback.
43
+ */
44
+ export interface PasswordProvider {
45
+ /**
46
+ * Obtain a password.
47
+ *
48
+ * @param context - Why the password is needed.
49
+ * @returns The password string.
50
+ * @throws If the provider cannot obtain a password (e.g. env var
51
+ * not set, no TTY available). The error is caught by `chain()`
52
+ * which falls through to the next provider.
53
+ */
54
+ getPassword(context: PasswordContext): Promise<string>;
55
+ }
56
+
57
+ // ─── Internal I/O interfaces (for testing) ───────────────────────
58
+
59
+ /** @internal Minimal interface for an stdin-like readable stream. */
60
+ export interface TtyReadable {
61
+ isTTY?: boolean;
62
+ setRawMode(mode: boolean): void;
63
+ setEncoding(encoding: string): void;
64
+ resume(): void;
65
+ pause(): void;
66
+ on(event: 'data', listener: (chunk: string) => void): void;
67
+ removeListener(event: 'data', listener: (chunk: string) => void): void;
68
+ }
69
+
70
+ /** @internal Minimal interface for an stdout-like writable stream. */
71
+ export interface TtyWritable {
72
+ write(data: string): boolean;
73
+ }
74
+
75
+ // ─── Internal helpers ────────────────────────────────────────────
76
+
77
+ /**
78
+ * Read a password from a raw-mode TTY stream.
79
+ *
80
+ * Reads character-by-character with no echo. Handles Enter (resolve),
81
+ * Ctrl-C (reject), backspace, and printable characters.
82
+ *
83
+ * @internal Exported for testing only.
84
+ */
85
+ export function readPasswordRawMode(
86
+ stdin: TtyReadable,
87
+ stdout: TtyWritable,
88
+ prompt: string,
89
+ ): Promise<string> {
90
+ stdout.write(prompt);
91
+
92
+ return new Promise<string>((resolve, reject) => {
93
+ let buf = '';
94
+ stdin.setRawMode(true);
95
+ stdin.setEncoding('utf8');
96
+ stdin.resume();
97
+
98
+ const onData = (ch: string): void => {
99
+ const code = ch.charCodeAt(0);
100
+
101
+ if (ch === '\r' || ch === '\n') {
102
+ // Enter — done.
103
+ stdin.setRawMode(false);
104
+ stdin.pause();
105
+ stdin.removeListener('data', onData);
106
+ stdout.write('\n');
107
+ resolve(buf);
108
+ } else if (code === 3) {
109
+ // Ctrl-C — abort.
110
+ stdin.setRawMode(false);
111
+ stdin.pause();
112
+ stdin.removeListener('data', onData);
113
+ stdout.write('\n');
114
+ reject(new Error('[@enbox/auth] PasswordProvider.fromTty: cancelled by user.'));
115
+ } else if (code === 127 || code === 8) {
116
+ // Backspace / Delete.
117
+ if (buf.length > 0) {
118
+ buf = buf.slice(0, -1);
119
+ }
120
+ } else if (code >= 32) {
121
+ // Printable character.
122
+ buf += ch;
123
+ }
124
+ };
125
+
126
+ stdin.on('data', onData);
127
+ });
128
+ }
129
+
130
+ /** @internal Injectable I/O for testing `readPasswordDevTty`. */
131
+ export interface DevTtyIo {
132
+ openSync(path: string, flags: string): number;
133
+ readSync(fd: number, buf: Uint8Array, offset: number, length: number, position: null): number;
134
+ writeSync(fd: number, data: string): number;
135
+ closeSync(fd: number): void;
136
+ execSync(cmd: string, opts: { stdio: string }): void;
137
+ }
138
+
139
+ /**
140
+ * Read a password from `/dev/tty` using synchronous I/O.
141
+ *
142
+ * Opens `/dev/tty` directly, uses `stty -echo` to suppress input,
143
+ * reads until newline, then restores echo and closes file descriptors.
144
+ *
145
+ * @param prompt - The prompt string to display.
146
+ * @param io - Injectable I/O functions (defaults to `node:fs` + `node:child_process`).
147
+ * @internal Exported for testing only.
148
+ */
149
+ export async function readPasswordDevTty(
150
+ prompt: string,
151
+ io?: DevTtyIo,
152
+ ): Promise<string> {
153
+ // Use injected I/O or import real modules.
154
+ let fsIo: DevTtyIo;
155
+ if (io) {
156
+ fsIo = io;
157
+ } else {
158
+ const { openSync, readSync, writeSync, closeSync } = await import('node:fs');
159
+ const { execSync } = await import('node:child_process');
160
+ fsIo = {
161
+ openSync,
162
+ readSync,
163
+ writeSync,
164
+ closeSync,
165
+ execSync: (cmd: string, opts: { stdio: string }): void => { execSync(cmd, opts as any); },
166
+ };
167
+ }
168
+
169
+ let readFd: number;
170
+ let writeFd: number;
171
+
172
+ try {
173
+ readFd = fsIo.openSync('/dev/tty', 'r');
174
+ writeFd = fsIo.openSync('/dev/tty', 'w');
175
+ } catch {
176
+ throw new Error(
177
+ '[@enbox/auth] PasswordProvider.fromDevTty: cannot open /dev/tty. ' +
178
+ 'No controlling terminal available.'
179
+ );
180
+ }
181
+
182
+ try {
183
+ // Suppress echo.
184
+ try {
185
+ fsIo.execSync('stty -echo < /dev/tty', { stdio: 'ignore' });
186
+ } catch {
187
+ // Continue — the user sees their password but the flow works.
188
+ }
189
+
190
+ fsIo.writeSync(writeFd, prompt);
191
+
192
+ // Cooked-mode read (line-buffered; terminal handles backspace).
193
+ const readBuf = new Uint8Array(256);
194
+ const decoder = new TextDecoder('utf-8');
195
+ let password = '';
196
+
197
+ while (true) {
198
+ const bytesRead = fsIo.readSync(readFd, readBuf, 0, readBuf.length, null);
199
+ if (bytesRead === 0) { break; }
200
+
201
+ password += decoder.decode(readBuf.subarray(0, bytesRead), { stream: true });
202
+
203
+ const nlIdx = password.indexOf('\n');
204
+ if (nlIdx !== -1) { password = password.slice(0, nlIdx); break; }
205
+
206
+ const crIdx = password.indexOf('\r');
207
+ if (crIdx !== -1) { password = password.slice(0, crIdx); break; }
208
+ }
209
+
210
+ fsIo.writeSync(writeFd, '\n');
211
+ return password;
212
+ } finally {
213
+ // Restore echo.
214
+ try { fsIo.execSync('stty echo < /dev/tty', { stdio: 'ignore' }); } catch { /* best-effort */ }
215
+ fsIo.closeSync(readFd);
216
+ fsIo.closeSync(writeFd);
217
+ }
218
+ }
219
+
220
+ // ─── Factory functions ───────────────────────────────────────────
221
+
222
+
223
+ export namespace PasswordProvider {
224
+
225
+ /**
226
+ * Read the password from an environment variable.
227
+ *
228
+ * Throws if the variable is not set or is empty, allowing `chain()`
229
+ * to fall through to the next provider.
230
+ *
231
+ * @param envVar - Name of the environment variable. Default: `'ENBOX_PASSWORD'`.
232
+ *
233
+ * @example
234
+ * ```ts
235
+ * const provider = PasswordProvider.fromEnv('MY_APP_PASSWORD');
236
+ * ```
237
+ */
238
+ export function fromEnv(envVar = 'ENBOX_PASSWORD'): PasswordProvider {
239
+ return {
240
+ async getPassword(): Promise<string> {
241
+ const value = process.env[envVar];
242
+ if (!value) {
243
+ throw new Error(
244
+ `[@enbox/auth] PasswordProvider.fromEnv: environment variable '${envVar}' is not set.`
245
+ );
246
+ }
247
+ return value;
248
+ },
249
+ };
250
+ }
251
+
252
+ /**
253
+ * Wrap an async callback as a password provider.
254
+ *
255
+ * This is the escape hatch for custom UI (e.g. `@clack/prompts`,
256
+ * Electron dialog, browser modal).
257
+ *
258
+ * @param callback - Called with the password context; must return a password string.
259
+ *
260
+ * @example
261
+ * ```ts
262
+ * const provider = PasswordProvider.fromCallback(async ({ reason }) => {
263
+ * if (reason === 'create') {
264
+ * return await showCreatePasswordDialog();
265
+ * }
266
+ * return await showUnlockDialog();
267
+ * });
268
+ * ```
269
+ */
270
+ export function fromCallback(
271
+ callback: (context: PasswordContext) => Promise<string>,
272
+ ): PasswordProvider {
273
+ return { getPassword: callback };
274
+ }
275
+
276
+ /**
277
+ * Prompt for a password via `process.stdin` in raw mode.
278
+ *
279
+ * Input is read character-by-character with no echo. Handles
280
+ * backspace and Ctrl-C (rejects with an error). Only works when
281
+ * `process.stdin.isTTY` is `true`; throws otherwise so `chain()`
282
+ * can fall through to the next provider.
283
+ *
284
+ * Suitable for main CLI processes that own stdin/stdout.
285
+ *
286
+ * @param options - Optional configuration.
287
+ * @param options.prompt - Text to display before reading. Default: `'Vault password: '`.
288
+ *
289
+ * @example
290
+ * ```ts
291
+ * const provider = PasswordProvider.fromTty({ prompt: 'Password: ' });
292
+ * ```
293
+ */
294
+ export function fromTty(options: { prompt?: string } = {}): PasswordProvider {
295
+ const prompt = options.prompt ?? 'Vault password: ';
296
+
297
+ return {
298
+ async getPassword(): Promise<string> {
299
+ if (!process.stdin.isTTY) {
300
+ throw new Error(
301
+ '[@enbox/auth] PasswordProvider.fromTty: stdin is not a TTY.'
302
+ );
303
+ }
304
+
305
+ return readPasswordRawMode(
306
+ process.stdin as unknown as TtyReadable,
307
+ process.stdout,
308
+ prompt,
309
+ );
310
+ },
311
+ };
312
+ }
313
+
314
+ /**
315
+ * Prompt for a password via `/dev/tty` (Unix only).
316
+ *
317
+ * Opens `/dev/tty` directly, bypassing `process.stdin`. This is
318
+ * essential for subprocesses where stdin is owned by the parent
319
+ * (e.g. Git credential helpers, SSH, GPG). Uses `stty -echo` to
320
+ * suppress input echo.
321
+ *
322
+ * Throws if `/dev/tty` cannot be opened (e.g. non-Unix platform,
323
+ * no controlling terminal), allowing `chain()` to fall through.
324
+ *
325
+ * @param options - Optional configuration.
326
+ * @param options.prompt - Text to display before reading. Default: `'Vault password: '`.
327
+ *
328
+ * @example
329
+ * ```ts
330
+ * // For git credential helpers:
331
+ * const provider = PasswordProvider.fromDevTty();
332
+ * ```
333
+ */
334
+ export function fromDevTty(options: { prompt?: string } = {}): PasswordProvider {
335
+ const prompt = options.prompt ?? 'Vault password: ';
336
+
337
+ return {
338
+ async getPassword(): Promise<string> {
339
+ return readPasswordDevTty(prompt);
340
+ },
341
+ };
342
+ }
343
+
344
+ /**
345
+ * Compose multiple providers with automatic fallback.
346
+ *
347
+ * Tries each provider in order. If a provider throws, the next one
348
+ * is tried. If all providers fail, the last error is rethrown.
349
+ *
350
+ * @param providers - Ordered list of providers to try.
351
+ *
352
+ * @example
353
+ * ```ts
354
+ * // Try env var first, then interactive TTY, then /dev/tty for subprocesses.
355
+ * const provider = PasswordProvider.chain([
356
+ * PasswordProvider.fromEnv('ENBOX_PASSWORD'),
357
+ * PasswordProvider.fromTty(),
358
+ * PasswordProvider.fromDevTty(),
359
+ * ]);
360
+ * ```
361
+ */
362
+ export function chain(providers: PasswordProvider[]): PasswordProvider {
363
+ if (providers.length === 0) {
364
+ throw new Error('[@enbox/auth] PasswordProvider.chain: at least one provider is required.');
365
+ }
366
+
367
+ return {
368
+ async getPassword(context: PasswordContext): Promise<string> {
369
+ let lastError: Error | undefined;
370
+
371
+ for (const provider of providers) {
372
+ try {
373
+ return await provider.getPassword(context);
374
+ } catch (err) {
375
+ lastError = err instanceof Error ? err : new Error(String(err));
376
+ }
377
+ }
378
+
379
+ throw lastError ?? new Error('[@enbox/auth] PasswordProvider.chain: all providers failed.');
380
+ },
381
+ };
382
+ }
383
+ }