nostr-bray 2.4.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,50 +1,101 @@
1
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
2
- import { dirname } from 'node:path';
3
- import { getConversationKey, encrypt, decrypt } from 'nostr-tools/nip44';
4
- import { finalizeEvent } from 'nostr-tools/pure';
1
+ import { chmodSync, renameSync, unlinkSync, writeFileSync, mkdirSync, existsSync, } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { basename, dirname, isAbsolute, join } from 'node:path';
4
+ import { NwcClient, NwcError, inspectNwcConnection } from '@forgesworn/nwc-kit';
5
+ import { NwcTransactionHistoryClient } from '@forgesworn/nwc-kit/extensions/05';
6
+ import { tryDecodeBolt11, verifyPreimage } from 'farrier-kit';
7
+ import { readPrivateRegularFile, readNwcUriFile, } from './nwc-file.js';
8
+ const PUBKEY_HEX = /^[0-9a-f]{64}$/i;
9
+ const MAX_WALLETS_FILE_BYTES = 1_048_576;
10
+ const MAX_WALLETS = 128;
11
+ const MAX_PATH_CHARS = 4096;
12
+ function isRecord(value) {
13
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
14
+ }
15
+ function validateWalletFiles(value) {
16
+ if (!isRecord(value) || Object.keys(value).length > MAX_WALLETS) {
17
+ throw new Error(`Wallet registry must contain at most ${MAX_WALLETS} entries`);
18
+ }
19
+ const walletFiles = {};
20
+ for (const [pubkey, filePath] of Object.entries(value)) {
21
+ if (!PUBKEY_HEX.test(pubkey) ||
22
+ typeof filePath !== 'string' ||
23
+ filePath.length === 0 ||
24
+ filePath.length > MAX_PATH_CHARS ||
25
+ !isAbsolute(filePath)) {
26
+ throw new Error('Wallet registry contains an invalid public key or secret-file path');
27
+ }
28
+ walletFiles[pubkey.toLowerCase()] = filePath;
29
+ }
30
+ return walletFiles;
31
+ }
5
32
  /**
6
33
  * Load the wallets map from the JSON file. Returns empty map if file missing.
7
34
  *
8
35
  * @param walletsFile - Absolute path to the JSON wallets store.
9
- * @returns A map of public key hex strings to NWC URIs.
36
+ * @returns A map of public key hex strings to absolute NWC secret-file paths.
10
37
  *
11
38
  * @example
12
39
  * const wallets = loadWallets('/home/user/.bray/wallets.json')
13
- * // { 'abc123...': 'nostr+walletconnect://...' }
40
+ * // { 'abc123...': '/run/secrets/alice-nwc' }
14
41
  */
15
42
  export function loadWallets(walletsFile) {
16
43
  if (!walletsFile || !existsSync(walletsFile))
17
44
  return {};
45
+ const bytes = readPrivateRegularFile(walletsFile, MAX_WALLETS_FILE_BYTES, 'Wallet registry');
46
+ let parsed;
18
47
  try {
19
- const data = JSON.parse(readFileSync(walletsFile, 'utf-8'));
20
- return data.wallets ?? {};
48
+ parsed = JSON.parse(bytes.toString('utf-8'));
21
49
  }
22
50
  catch {
23
- return {};
51
+ throw new Error('Wallet registry is not valid JSON');
24
52
  }
53
+ finally {
54
+ bytes.fill(0);
55
+ }
56
+ if (!isRecord(parsed) ||
57
+ parsed.version !== 1 ||
58
+ !('walletFiles' in parsed) ||
59
+ Object.keys(parsed).some((key) => key !== 'version' && key !== 'walletFiles')) {
60
+ throw new Error('Wallet registry has an unsupported or unsafe format');
61
+ }
62
+ return validateWalletFiles(parsed.walletFiles);
25
63
  }
26
64
  /**
27
65
  * Save the wallets map to the JSON file. Creates parent dirs and sets 0600.
28
66
  *
29
67
  * @param walletsFile - Absolute path to the JSON wallets store.
30
- * @param wallets - Map of public key hex strings to NWC URIs to persist.
68
+ * @param wallets - Map of public key hex strings to absolute NWC secret-file paths.
31
69
  * @returns void
32
70
  *
33
71
  * @example
34
72
  * saveWallets('/home/user/.bray/wallets.json', {
35
- * 'abc123...': 'nostr+walletconnect://pubkey?relay=wss://relay.example&secret=deadbeef',
73
+ * 'abc123...': '/run/secrets/alice-nwc',
36
74
  * })
37
75
  */
38
76
  export function saveWallets(walletsFile, wallets) {
77
+ if (!walletsFile)
78
+ throw new Error('Wallet registry path is not configured');
79
+ const validated = validateWalletFiles(wallets);
39
80
  const dir = dirname(walletsFile);
40
81
  if (!existsSync(dir))
41
82
  mkdirSync(dir, { recursive: true, mode: 0o700 });
42
- const data = { wallets };
43
- writeFileSync(walletsFile, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
83
+ const data = { version: 1, walletFiles: validated };
84
+ const temporary = join(dir, `.${basename(walletsFile)}.${process.pid}.${randomUUID()}.tmp`);
85
+ try {
86
+ writeFileSync(temporary, JSON.stringify(data, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
87
+ chmodSync(temporary, 0o600);
88
+ renameSync(temporary, walletsFile);
89
+ chmodSync(walletsFile, 0o600);
90
+ }
91
+ finally {
92
+ if (existsSync(temporary))
93
+ unlinkSync(temporary);
94
+ }
44
95
  }
45
96
  /**
46
97
  * Resolve the NWC URI for the active identity.
47
- * 1. Per-identity wallet from wallets file
98
+ * 1. Per-identity NWC secret file referenced by the wallet registry
48
99
  * 2. Global NWC URI (fallback)
49
100
  * 3. undefined (no wallet configured)
50
101
  *
@@ -53,71 +104,36 @@ export function saveWallets(walletsFile, wallets) {
53
104
  * @returns The resolved NWC URI, or `undefined` if no wallet is configured.
54
105
  *
55
106
  * @example
56
- * const uri = resolveNwcUri(ctx, '/home/user/.bray/wallets.json', process.env.NWC_URI)
107
+ * const uri = resolveNwcUri(ctx, '/home/user/.bray/wallets.json', configuredNwcUri)
57
108
  * if (!uri) throw new Error('No wallet configured')
58
109
  */
59
110
  export function resolveNwcUri(ctx, walletsFile, globalNwcUri) {
60
111
  const pubkey = ctx.activePublicKeyHex;
61
112
  const wallets = loadWallets(walletsFile);
62
- return wallets[pubkey] ?? globalNwcUri;
113
+ const walletFile = wallets[pubkey.toLowerCase()];
114
+ return walletFile ? readNwcUriFile(walletFile) : globalNwcUri;
63
115
  }
64
116
  /**
65
117
  * Parse a nostr+walletconnect:// URI.
66
118
  *
67
119
  * @param uri - A `nostr+walletconnect://` URI as specified in NIP-47.
68
- * @returns A parsed {@link NwcConnection} containing the wallet pubkey, relay URL, and connection secret.
69
- * @throws {Error} If `pubkey`, `relay`, or `secret` are missing from the URI.
120
+ * @returns Public connection metadata. The connection secret is never returned.
121
+ * @throws {Error} If the URI is malformed or insecure.
70
122
  *
71
123
  * @example
72
124
  * const conn = parseNwcUri(
73
125
  * 'nostr+walletconnect://abc123?relay=wss%3A%2F%2Frelay.example&secret=deadbeef',
74
126
  * )
75
- * // { pubkey: 'abc123', relay: 'wss://relay.example', secret: 'deadbeef' }
127
+ * // { pubkey: 'abc123', relay: 'wss://relay.example/', relays: ['wss://relay.example/'] }
76
128
  */
77
129
  export function parseNwcUri(uri) {
78
- const url = new URL(uri);
79
- const pubkey = url.hostname || url.pathname.replace('//', '');
80
- const relay = url.searchParams.get('relay');
81
- const secret = url.searchParams.get('secret');
82
- if (!pubkey || !relay || !secret) {
83
- throw new Error('Invalid NWC URI: missing pubkey, relay, or secret');
84
- }
85
- return { pubkey, relay, secret };
86
- }
87
- /** Build an encrypted NIP-47 request event (kind 23194) */
88
- function buildNwcRequest(conn, method, params) {
89
- const secretBytes = Buffer.from(conn.secret, 'hex');
90
- try {
91
- const conversationKey = getConversationKey(secretBytes, conn.pubkey);
92
- const content = encrypt(JSON.stringify({ method, params }), conversationKey);
93
- const event = finalizeEvent({
94
- kind: 23194,
95
- created_at: Math.floor(Date.now() / 1000),
96
- tags: [['p', conn.pubkey]],
97
- content,
98
- }, secretBytes);
99
- return event;
100
- }
101
- finally {
102
- secretBytes.fill(0);
103
- }
104
- }
105
- /** Decrypt a NIP-47 response event (kind 23195) */
106
- function decryptNwcResponse(conn, event) {
107
- const secretBytes = Buffer.from(conn.secret, 'hex');
108
- try {
109
- const conversationKey = getConversationKey(secretBytes, conn.pubkey);
110
- const plaintext = decrypt(event.content, conversationKey);
111
- try {
112
- return JSON.parse(plaintext);
113
- }
114
- catch {
115
- throw new Error('NWC response is not valid JSON');
116
- }
117
- }
118
- finally {
119
- secretBytes.fill(0);
120
- }
130
+ const connection = inspectNwcConnection(uri);
131
+ return {
132
+ pubkey: connection.walletPubkey,
133
+ relay: connection.relays[0],
134
+ relays: connection.relays,
135
+ ...(connection.lud16 ? { lud16: connection.lud16 } : {}),
136
+ };
121
137
  }
122
138
  /**
123
139
  * Parse zap receipts (kind 9735) for the active identity.
@@ -167,184 +183,122 @@ function parseZapReceipt(event) {
167
183
  * Decode basic bolt11 invoice fields.
168
184
  *
169
185
  * @param bolt11 - A BOLT-11 Lightning invoice string (e.g. `lnbc...`).
170
- * @returns An object with the decoded fields. `amountMsats` is present when the
171
- * amount is encoded in the invoice prefix; `description` and `expiry` are not
172
- * yet decoded and will be absent in the current implementation.
186
+ * @returns The bounded amount, description and expiry fields committed by the
187
+ * invoice. `amountMsats` is absent for an amountless invoice.
173
188
  *
174
189
  * @example
175
190
  * const { amountMsats } = handleZapDecode('lnbc1000n1...')
176
191
  * console.log(`Invoice is for ${amountMsats} msats`)
177
192
  */
178
193
  export function handleZapDecode(bolt11) {
179
- const result = {};
180
- const match = bolt11.match(/^ln(?:bc|tb|tbs)(\d+)([munp])?/);
181
- if (match) {
182
- const num = parseInt(match[1], 10);
183
- const multiplier = match[2];
184
- const multipliers = {
185
- 'm': 100_000_000,
186
- 'u': 100_000,
187
- 'n': 100,
188
- 'p': 0.1,
189
- };
190
- if (multiplier && multipliers[multiplier]) {
191
- result.amountMsats = Math.round(num * multipliers[multiplier]);
192
- }
194
+ const decoded = tryDecodeBolt11(bolt11);
195
+ if (!decoded)
196
+ return {};
197
+ if (decoded.amountMsats !== null && decoded.amountMsats > BigInt(Number.MAX_SAFE_INTEGER)) {
198
+ throw new Error('BOLT-11 amount exceeds JavaScript safe integer range');
193
199
  }
194
- return result;
200
+ return {
201
+ ...(decoded.amountMsats !== null ? { amountMsats: Number(decoded.amountMsats) } : {}),
202
+ ...(decoded.description !== null ? { description: decoded.description } : {}),
203
+ expiry: decoded.expirySeconds,
204
+ };
195
205
  }
196
- // --- NWC Operations ---
197
206
  /**
198
- * Pay a Lightning invoice via NWC.
199
- *
200
- * @param args - Payment arguments.
201
- * @param args.invoice - BOLT-11 Lightning invoice to pay.
202
- * @param args.nwcUri - NWC URI identifying the wallet to use.
203
- * @returns The signed NIP-47 request event and relay publish result.
204
- * @throws {Error} If `nwcUri` is not provided.
205
- *
206
- * @example
207
- * const { event, publish } = await handleZapSend(ctx, pool, {
208
- * invoice: 'lnbc500n1...',
209
- * nwcUri: 'nostr+walletconnect://abc123?relay=wss%3A%2F%2Frelay.example&secret=deadbeef',
210
- * })
207
+ * The wallet may have submitted a payment but Bray cannot prove settlement.
208
+ * Callers must reconcile `paymentHash` before attempting the invoice again.
211
209
  */
212
- export async function handleZapSend(ctx, pool, args) {
213
- if (!args.nwcUri) {
214
- throw new Error('Wallet not configured. Set NWC_URI or NWC_URI_FILE to enable zap sending.');
210
+ export class ZapPaymentOutcomeUnknownError extends Error {
211
+ paymentHash;
212
+ code = 'PAYMENT_OUTCOME_UNKNOWN';
213
+ constructor(paymentHash) {
214
+ super(`Payment outcome is unknown for ${paymentHash}. Reconcile the original invoice before retrying.`);
215
+ this.paymentHash = paymentHash;
216
+ this.name = 'ZapPaymentOutcomeUnknownError';
215
217
  }
216
- const conn = parseNwcUri(args.nwcUri);
217
- const event = buildNwcRequest(conn, 'pay_invoice', { invoice: args.invoice });
218
- const publish = await pool.publish(ctx.activeNpub, event);
219
- return { event, publish };
220
218
  }
221
- /**
222
- * Request wallet balance via NWC.
223
- *
224
- * @param args - Balance request arguments.
225
- * @param args.nwcUri - NWC URI identifying the wallet to query.
226
- * @returns The signed NIP-47 `get_balance` request event and relay publish result.
227
- * @throws {Error} If `nwcUri` is not provided.
228
- *
229
- * @example
230
- * const { event, publish } = await handleZapBalance(ctx, pool, {
231
- * nwcUri: 'nostr+walletconnect://abc123?relay=wss%3A%2F%2Frelay.example&secret=deadbeef',
232
- * })
233
- */
234
- export async function handleZapBalance(ctx, pool, args) {
235
- if (!args.nwcUri) {
236
- throw new Error('Wallet not configured. Set NWC_URI or NWC_URI_FILE to check balance.');
219
+ function requireWallet(nwcUri, action) {
220
+ if (!nwcUri) {
221
+ throw new Error(`Wallet not configured. Set NWC_URI_FILE or connect a persona wallet file to ${action}.`);
237
222
  }
238
- const conn = parseNwcUri(args.nwcUri);
239
- const event = buildNwcRequest(conn, 'get_balance', {});
240
- const publish = await pool.publish(ctx.activeNpub, event);
241
- return { event, publish };
223
+ return nwcUri;
242
224
  }
243
- /**
244
- * Generate a Lightning invoice via NWC.
245
- *
246
- * @param args - Invoice creation arguments.
247
- * @param args.amountMsats - Invoice amount in millisatoshis.
248
- * @param args.description - Optional human-readable memo attached to the invoice.
249
- * @param args.nwcUri - NWC URI identifying the wallet to use.
250
- * @returns The signed NIP-47 `make_invoice` request event and relay publish result.
251
- * @throws {Error} If `nwcUri` is not provided.
252
- *
253
- * @example
254
- * const { event, publish } = await handleZapMakeInvoice(ctx, pool, {
255
- * amountMsats: 21_000,
256
- * description: 'Coffee ☕',
257
- * nwcUri: 'nostr+walletconnect://abc123?relay=wss%3A%2F%2Frelay.example&secret=deadbeef',
258
- * })
259
- */
260
- export async function handleZapMakeInvoice(ctx, pool, args) {
261
- if (!args.nwcUri) {
262
- throw new Error('Wallet not configured. Set NWC_URI or NWC_URI_FILE to create invoices.');
263
- }
264
- const conn = parseNwcUri(args.nwcUri);
265
- const event = buildNwcRequest(conn, 'make_invoice', {
266
- amount: args.amountMsats,
267
- description: args.description,
268
- });
269
- const publish = await pool.publish(ctx.activeNpub, event);
270
- return { event, publish };
225
+ function requestOptions(args) {
226
+ return {
227
+ ...(args.signal ? { signal: args.signal } : {}),
228
+ ...(args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}),
229
+ };
271
230
  }
272
- /**
273
- * Look up an invoice via NWC.
274
- *
275
- * @param args - Lookup arguments. Provide at least one of `paymentHash` or `invoice`.
276
- * @param args.paymentHash - Hex-encoded payment hash of the invoice to look up.
277
- * @param args.invoice - BOLT-11 invoice string to look up.
278
- * @param args.nwcUri - NWC URI identifying the wallet to query.
279
- * @returns The signed NIP-47 `lookup_invoice` request event and relay publish result.
280
- * @throws {Error} If `nwcUri` is not provided.
281
- *
282
- * @example
283
- * const { event, publish } = await handleZapLookupInvoice(ctx, pool, {
284
- * paymentHash: 'a1b2c3...',
285
- * nwcUri: 'nostr+walletconnect://abc123?relay=wss%3A%2F%2Frelay.example&secret=deadbeef',
286
- * })
287
- */
288
- export async function handleZapLookupInvoice(ctx, pool, args) {
289
- if (!args.nwcUri) {
290
- throw new Error('Wallet not configured.');
231
+ async function withClient(nwcUri, transport, operation) {
232
+ const client = new NwcClient(nwcUri, { ...(transport ? { transport } : {}) });
233
+ try {
234
+ return await operation(client);
235
+ }
236
+ finally {
237
+ client.close();
291
238
  }
292
- const conn = parseNwcUri(args.nwcUri);
293
- const params = {};
294
- if (args.paymentHash)
295
- params.payment_hash = args.paymentHash;
296
- if (args.invoice)
297
- params.invoice = args.invoice;
298
- const event = buildNwcRequest(conn, 'lookup_invoice', params);
299
- const publish = await pool.publish(ctx.activeNpub, event);
300
- return { event, publish };
301
239
  }
302
- /**
303
- * List recent transactions via NWC.
304
- *
305
- * @param args - Pagination arguments.
306
- * @param args.limit - Maximum number of transactions to return (default 10).
307
- * @param args.offset - Number of transactions to skip for pagination (default 0).
308
- * @param args.nwcUri - NWC URI identifying the wallet to query.
309
- * @returns The signed NIP-47 `list_transactions` request event and relay publish result.
310
- * @throws {Error} If `nwcUri` is not provided.
311
- *
312
- * @example
313
- * const { event, publish } = await handleZapListTransactions(ctx, pool, {
314
- * limit: 5,
315
- * offset: 0,
316
- * nwcUri: 'nostr+walletconnect://abc123?relay=wss%3A%2F%2Frelay.example&secret=deadbeef',
317
- * })
318
- */
319
- export async function handleZapListTransactions(ctx, pool, args) {
320
- if (!args.nwcUri) {
321
- throw new Error('Wallet not configured.');
240
+ /** Pay a BOLT-11 invoice and return only after the wallet proves settlement. */
241
+ export async function handleZapSend(_ctx, _pool, args) {
242
+ const uri = requireWallet(args.nwcUri, 'enable payments');
243
+ const decoded = tryDecodeBolt11(args.invoice);
244
+ if (!decoded)
245
+ throw new Error('Invalid BOLT-11 invoice');
246
+ if (decoded.amountMsats === null) {
247
+ throw new Error('Amountless BOLT-11 invoices are refused because no explicit payment amount was supplied');
322
248
  }
323
- const conn = parseNwcUri(args.nwcUri);
324
- const event = buildNwcRequest(conn, 'list_transactions', {
325
- limit: args.limit ?? 10,
326
- offset: args.offset ?? 0,
327
- });
328
- const publish = await pool.publish(ctx.activeNpub, event);
329
- return { event, publish };
249
+ if (decoded.amountMsats > BigInt(Number.MAX_SAFE_INTEGER)) {
250
+ throw new Error('BOLT-11 amount exceeds JavaScript safe integer range');
251
+ }
252
+ let paid;
253
+ try {
254
+ paid = await withClient(uri, args.transport, (client) => client.payInvoice({ invoice: args.invoice }, requestOptions(args)));
255
+ }
256
+ catch (error) {
257
+ // An authenticated wallet rejection is definitive. Transport, timeout,
258
+ // abort, and malformed-response failures can happen after submission.
259
+ if (error instanceof NwcError && error.code === 'WALLET_ERROR')
260
+ throw error;
261
+ throw new ZapPaymentOutcomeUnknownError(decoded.paymentHashHex);
262
+ }
263
+ if (!verifyPreimage(paid.preimage, decoded.paymentHashHex)) {
264
+ throw new ZapPaymentOutcomeUnknownError(decoded.paymentHashHex);
265
+ }
266
+ return { ...paid, paymentHash: decoded.paymentHashHex, verified: true };
330
267
  }
331
- /**
332
- * Parse a NWC wallet response event.
333
- *
334
- * @param nwcUri - The NWC URI whose secret is used to decrypt the response.
335
- * @param event - A kind 23195 NIP-47 response event from the wallet service.
336
- * @returns The decrypted response payload containing `result_type`, an optional
337
- * `result` object, and an optional `error` with `code` and `message`.
338
- *
339
- * @example
340
- * const response = handleZapParseResponse(
341
- * 'nostr+walletconnect://abc123?relay=wss%3A%2F%2Frelay.example&secret=deadbeef',
342
- * walletResponseEvent,
343
- * )
344
- * if (response.error) console.error(response.error.message)
345
- * else console.log(response.result)
346
- */
347
- export function handleZapParseResponse(nwcUri, event) {
348
- const conn = parseNwcUri(nwcUri);
349
- return decryptNwcResponse(conn, event);
268
+ /** Return the wallet's confirmed balance in millisatoshis. */
269
+ export async function handleZapBalance(_ctx, _pool, args) {
270
+ const uri = requireWallet(args.nwcUri, 'check the balance');
271
+ return withClient(uri, args.transport, (client) => client.getBalance(requestOptions(args)));
272
+ }
273
+ /** Generate a Lightning invoice through the configured wallet. */
274
+ export async function handleZapMakeInvoice(_ctx, _pool, args) {
275
+ const uri = requireWallet(args.nwcUri, 'create invoices');
276
+ return withClient(uri, args.transport, (client) => client.makeInvoice({
277
+ amount: args.amountMsats,
278
+ ...(args.description !== undefined ? { description: args.description } : {}),
279
+ }, requestOptions(args)));
280
+ }
281
+ /** Look up an invoice and return the wallet's authenticated result. */
282
+ export async function handleZapLookupInvoice(_ctx, _pool, args) {
283
+ const uri = requireWallet(args.nwcUri, 'look up invoices');
284
+ return withClient(uri, args.transport, (client) => client.lookupInvoice({
285
+ ...(args.paymentHash !== undefined ? { payment_hash: args.paymentHash } : {}),
286
+ ...(args.invoice !== undefined ? { invoice: args.invoice } : {}),
287
+ }, requestOptions(args)));
288
+ }
289
+ /** List recent transactions through the isolated NIP-47 extension 05 client. */
290
+ export async function handleZapListTransactions(_ctx, _pool, args) {
291
+ const uri = requireWallet(args.nwcUri, 'list transactions');
292
+ const client = new NwcTransactionHistoryClient(uri, {
293
+ ...(args.transport ? { transport: args.transport } : {}),
294
+ });
295
+ try {
296
+ return await client.listTransactions({
297
+ limit: args.limit ?? 10,
298
+ offset: args.offset ?? 0,
299
+ }, requestOptions(args));
300
+ }
301
+ finally {
302
+ client.close();
303
+ }
350
304
  }
@@ -1 +1,2 @@
1
1
  export * from './handlers.js';
2
+ export * from './nwc-file.js';
package/dist/zap/index.js CHANGED
@@ -1 +1,2 @@
1
1
  export * from './handlers.js';
2
+ export * from './nwc-file.js';
@@ -0,0 +1,5 @@
1
+ export declare function readPrivateRegularFile(filePath: string, maximumBytes: number, label: string): Buffer;
2
+ /** Read and validate an NWC connection from a private local secret file. */
3
+ export declare function readNwcUriFile(filePath: string): string;
4
+ /** Resolve and validate a secret-file path before adding it to the registry. */
5
+ export declare function normaliseNwcUriFile(filePath: string): string;
@@ -0,0 +1,65 @@
1
+ import { closeSync, fstatSync, openSync, readSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { inspectNwcConnection } from '@forgesworn/nwc-kit';
4
+ const MAX_NWC_URI_BYTES = 8192;
5
+ const MAX_PATH_CHARS = 4096;
6
+ export function readPrivateRegularFile(filePath, maximumBytes, label) {
7
+ const descriptor = openSync(filePath, 'r');
8
+ let bytes;
9
+ try {
10
+ const stats = fstatSync(descriptor);
11
+ if (!stats.isFile() || stats.size === 0 || stats.size > maximumBytes) {
12
+ throw new Error(`${label} must be a non-empty regular file no larger than ${maximumBytes} bytes`);
13
+ }
14
+ if (process.platform !== 'win32' && (stats.mode & 0o077) !== 0) {
15
+ throw new Error(`${label} permissions are too broad; run chmod 600 on the file`);
16
+ }
17
+ bytes = Buffer.allocUnsafe(stats.size);
18
+ let offset = 0;
19
+ while (offset < bytes.length) {
20
+ const count = readSync(descriptor, bytes, offset, bytes.length - offset, null);
21
+ if (count === 0)
22
+ throw new Error(`${label} changed while it was being read`);
23
+ offset += count;
24
+ }
25
+ const extra = Buffer.allocUnsafe(1);
26
+ try {
27
+ if (readSync(descriptor, extra, 0, 1, null) !== 0) {
28
+ throw new Error(`${label} changed while it was being read`);
29
+ }
30
+ }
31
+ finally {
32
+ extra.fill(0);
33
+ }
34
+ return bytes;
35
+ }
36
+ catch (error) {
37
+ bytes?.fill(0);
38
+ throw error;
39
+ }
40
+ finally {
41
+ closeSync(descriptor);
42
+ }
43
+ }
44
+ /** Read and validate an NWC connection from a private local secret file. */
45
+ export function readNwcUriFile(filePath) {
46
+ if (typeof filePath !== 'string' || filePath.length === 0 || filePath.length > MAX_PATH_CHARS) {
47
+ throw new Error('NWC URI file path is missing or too long');
48
+ }
49
+ const absolutePath = resolve(filePath);
50
+ const bytes = readPrivateRegularFile(absolutePath, MAX_NWC_URI_BYTES, 'NWC URI file');
51
+ try {
52
+ const uri = bytes.toString('utf-8').trim();
53
+ inspectNwcConnection(uri);
54
+ return uri;
55
+ }
56
+ finally {
57
+ bytes.fill(0);
58
+ }
59
+ }
60
+ /** Resolve and validate a secret-file path before adding it to the registry. */
61
+ export function normaliseNwcUriFile(filePath) {
62
+ const absolutePath = resolve(filePath);
63
+ readNwcUriFile(absolutePath);
64
+ return absolutePath;
65
+ }