@openreceive/node 0.3.1 → 0.3.3

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/cli.d.ts CHANGED
@@ -48,6 +48,15 @@ declare function runScaffoldPayments(input: RunScaffoldPaymentsInput): Promise<n
48
48
  interface CliIo {
49
49
  write(message: string): void;
50
50
  }
51
+ /** The two doctor probe methods a wallet client must answer. */
52
+ interface DoctorWalletClient {
53
+ preflight(): Promise<{
54
+ readonly methods: readonly string[];
55
+ readonly spendCapabilityAdvertised: boolean;
56
+ readonly warnings: readonly string[];
57
+ }>;
58
+ close(): Promise<void>;
59
+ }
51
60
  interface CliOptions {
52
61
  readonly argv: readonly string[];
53
62
  readonly env?: NodeJS.ProcessEnv;
@@ -57,7 +66,16 @@ interface CliOptions {
57
66
  readonly stdin?: NodeJS.ReadableStream;
58
67
  readonly isTTY?: boolean;
59
68
  readonly prompt?: (question: string) => Promise<string>;
69
+ /**
70
+ * Testing seam for `doctor`: builds the wallet client the relay probe uses.
71
+ * Defaults to the real NWC client.
72
+ */
73
+ readonly walletClientFactory?: (options: {
74
+ connectionString: string;
75
+ allowSpendCapableWallet: boolean;
76
+ spendCapabilityWarning: (message: string) => void;
77
+ }) => DoctorWalletClient;
60
78
  }
61
79
  declare function runCli(options: CliOptions): Promise<number>;
62
80
 
63
- export { type CliIo, type CliOptions, finalizeScaffoldOptions, parseScaffoldPaymentsArgv, renderScaffoldPaymentsFiles, runCli, runScaffoldPayments };
81
+ export { type CliIo, type CliOptions, type DoctorWalletClient, finalizeScaffoldOptions, parseScaffoldPaymentsArgv, renderScaffoldPaymentsFiles, runCli, runScaffoldPayments };
package/dist/cli.js CHANGED
@@ -1,10 +1,20 @@
1
1
  import {
2
+ createNwcReceiveClient,
2
3
  readLscConnectionsFromEnvironment,
3
4
  redactSecrets
4
- } from "./chunk-6HALOJYF.js";
5
+ } from "./chunk-ZYS6QECW.js";
5
6
 
6
7
  // src/cli.ts
7
- import { formatInvalidNwcMessage, NwcUriParseError, parseNwcUri } from "@openreceive/core";
8
+ import { existsSync } from "fs";
9
+ import { createRequire } from "module";
10
+ import path2 from "path";
11
+ import { pathToFileURL } from "url";
12
+ import {
13
+ formatInvalidNwcMessage,
14
+ NwcUriParseError,
15
+ OPENRECEIVE_NWC_CODE_HELP_URL,
16
+ parseNwcUri
17
+ } from "@openreceive/core";
8
18
 
9
19
  // src/scaffold/index.ts
10
20
  import { createInterface } from "readline/promises";
@@ -912,13 +922,26 @@ var HELP = `
912
922
  Usage: openreceive <command> [options]
913
923
 
914
924
  Commands:
915
- doctor Validate server configuration (Node, NWC_URI, swap providers).
925
+ doctor Validate server configuration: Node, NWC_URI, swap
926
+ providers, and a receive-only wallet probe over the relay.
927
+ --db and --url extend the checks; exits 1 on problems.
916
928
  debug-report Print the same diagnostics as a redacted support report
917
929
  (alias of doctor; always exits 0).
918
930
  scaffold payments Emit the openreceive_payments + openreceive_meta migration and wiring guide for your ORM.
919
931
 
920
932
  Options:
921
933
  -h, --help Show this help.
934
+
935
+ Doctor options:
936
+ --db <target> Also check the payment tables exist: a SQLite file
937
+ path, or a postgres:// / mysql:// URL (the matching
938
+ driver is loaded from this project's node_modules).
939
+ --url <base-url> Also check the OpenReceive routes answer on a
940
+ running app, e.g. --url http://localhost:3000.
941
+ --prefix <path> Route prefix for --url (default /openreceive).
942
+ --table-name <name> Payments table for --db (default openreceive_payments).
943
+ --meta-table-name <name> Reconcile-gate table for --db (default openreceive_meta).
944
+ --offline Skip the wallet relay probe.
922
945
  `.trim();
923
946
  async function runCli(options) {
924
947
  const stdout = options.stdout ?? process.stdout;
@@ -933,8 +956,14 @@ async function runCli(options) {
933
956
  return 0;
934
957
  }
935
958
  if (command === "doctor" || command === "debug-report") {
936
- if (args.length > 0) throw new Error(`Unexpected option: ${args[0]}`);
937
- return runDiagnostics({ command, env, cwd, stdout });
959
+ return await runDiagnostics({
960
+ command,
961
+ flags: parseDiagnosticsArgv(args),
962
+ env,
963
+ cwd,
964
+ stdout,
965
+ walletClientFactory: options.walletClientFactory
966
+ });
938
967
  }
939
968
  if (command === "scaffold") {
940
969
  const [target = "help", ...scaffoldArgs] = args;
@@ -967,8 +996,42 @@ ${HELP}
967
996
  return 1;
968
997
  }
969
998
  }
970
- function runDiagnostics(input) {
971
- const nwc = input.env.NWC_URI?.trim();
999
+ function parseDiagnosticsArgv(args) {
1000
+ const flags = {
1001
+ db: void 0,
1002
+ url: void 0,
1003
+ prefix: "/openreceive",
1004
+ tableName: "openreceive_payments",
1005
+ metaTableName: "openreceive_meta",
1006
+ offline: false
1007
+ };
1008
+ for (let index = 0; index < args.length; index += 1) {
1009
+ const arg = args[index];
1010
+ const valueFor = (name) => {
1011
+ const value = args[index + 1];
1012
+ if (value === void 0 || value.startsWith("--")) {
1013
+ throw new Error(`${name} needs a value. See \`openreceive --help\`.`);
1014
+ }
1015
+ index += 1;
1016
+ return value;
1017
+ };
1018
+ if (arg === "--db") flags.db = valueFor("--db");
1019
+ else if (arg === "--url") flags.url = valueFor("--url");
1020
+ else if (arg === "--prefix") flags.prefix = valueFor("--prefix");
1021
+ else if (arg === "--table-name") flags.tableName = valueFor("--table-name");
1022
+ else if (arg === "--meta-table-name") flags.metaTableName = valueFor("--meta-table-name");
1023
+ else if (arg === "--offline") flags.offline = true;
1024
+ else throw new Error(`Unexpected option: ${arg}. See \`openreceive --help\`.`);
1025
+ }
1026
+ if (!flags.prefix.startsWith("/")) flags.prefix = `/${flags.prefix}`;
1027
+ return flags;
1028
+ }
1029
+ var WALLET_PROBE_TIMEOUT_MS = 1e4;
1030
+ var HTTP_PROBE_TIMEOUT_MS = 5e3;
1031
+ var SPEND_OVERRIDE_TRUE = /* @__PURE__ */ new Set(["1", "true", "yes"]);
1032
+ async function runDiagnostics(input) {
1033
+ const { flags, env } = input;
1034
+ const nwc = env.NWC_URI?.trim();
972
1035
  let nwcError;
973
1036
  try {
974
1037
  if (nwc) parseNwcUri(nwc);
@@ -978,7 +1041,7 @@ function runDiagnostics(input) {
978
1041
  let lscConnections = 0;
979
1042
  let lscError;
980
1043
  try {
981
- lscConnections = readLscConnectionsFromEnvironment(input.env).length;
1044
+ lscConnections = readLscConnectionsFromEnvironment(env).length;
982
1045
  } catch (error) {
983
1046
  lscError = error;
984
1047
  }
@@ -990,10 +1053,211 @@ function runDiagnostics(input) {
990
1053
  `NWC_URI: ${nwcError === void 0 ? nwc ? "present-redacted" : "missing" : safeErrorMessage(nwcError)}`,
991
1054
  `LSC_URI connections: ${lscError === void 0 ? lscConnections : safeErrorMessage(lscError)}`
992
1055
  ];
1056
+ let failed = nwcError !== void 0 || !nwc || lscError !== void 0;
1057
+ if (nwc && nwcError === void 0 && !flags.offline) {
1058
+ const wallet = await probeWallet({
1059
+ nwc,
1060
+ env,
1061
+ walletClientFactory: input.walletClientFactory
1062
+ });
1063
+ failed = failed || !wallet.ok;
1064
+ lines.push(...wallet.lines);
1065
+ } else if (flags.offline) {
1066
+ lines.push("wallet: probe skipped (--offline)");
1067
+ } else {
1068
+ lines.push("wallet: probe skipped (no parseable NWC_URI to probe)");
1069
+ }
1070
+ if (flags.db === void 0) {
1071
+ lines.push(
1072
+ "database: skipped \u2014 pass --db <sqlite file | postgres:// | mysql:// URL> to check the payment tables exist"
1073
+ );
1074
+ } else {
1075
+ const database = await checkDatabaseMigrated({ ...flags, db: flags.db, cwd: input.cwd });
1076
+ failed = failed || !database.ok;
1077
+ lines.push(...database.lines);
1078
+ }
1079
+ if (flags.url === void 0) {
1080
+ lines.push(
1081
+ "routes: skipped \u2014 pass --url http://localhost:3000 to check the OpenReceive routes answer"
1082
+ );
1083
+ } else {
1084
+ const routes = await checkRoutesMounted({ url: flags.url, prefix: flags.prefix });
1085
+ failed = failed || !routes.ok;
1086
+ lines.push(...routes.lines);
1087
+ }
993
1088
  input.stdout.write(`${lines.join("\n")}
994
1089
  `);
995
1090
  if (input.command === "debug-report") return 0;
996
- return nwcError !== void 0 || !nwc || lscError !== void 0 ? 1 : 0;
1091
+ return failed ? 1 : 0;
1092
+ }
1093
+ async function probeWallet(input) {
1094
+ const allowSpendCapableWallet = SPEND_OVERRIDE_TRUE.has(
1095
+ input.env.OPENRECEIVE_ALLOW_SPEND_CAPABLE_NWC?.trim().toLowerCase() ?? ""
1096
+ );
1097
+ const overrideWarnings = [];
1098
+ const factory = input.walletClientFactory ?? ((options) => createNwcReceiveClient(options));
1099
+ const client = factory({
1100
+ connectionString: input.nwc,
1101
+ allowSpendCapableWallet,
1102
+ spendCapabilityWarning: (message) => overrideWarnings.push(message)
1103
+ });
1104
+ let timer;
1105
+ try {
1106
+ const summary = await Promise.race([
1107
+ client.preflight(),
1108
+ new Promise((_, reject) => {
1109
+ timer = setTimeout(() => {
1110
+ reject(
1111
+ new Error(
1112
+ `no relay answered within ${WALLET_PROBE_TIMEOUT_MS / 1e3}s. Check the network and the wss relay in the NWC code, or rerun with --offline to skip the probe.`
1113
+ )
1114
+ );
1115
+ }, WALLET_PROBE_TIMEOUT_MS);
1116
+ timer.unref?.();
1117
+ })
1118
+ ]);
1119
+ if (summary.spendCapabilityAdvertised) {
1120
+ return {
1121
+ ok: false,
1122
+ lines: [
1123
+ `wallet: reachable but SPEND-CAPABLE (override active). A leaked code can drain this wallet \u2014 mint a receive-only code: ${OPENRECEIVE_NWC_CODE_HELP_URL}`,
1124
+ ...summary.warnings.map((warning) => `wallet: ${warning}`)
1125
+ ]
1126
+ };
1127
+ }
1128
+ return {
1129
+ ok: true,
1130
+ lines: [
1131
+ `wallet: reachable, receive-only (${summary.methods.join(", ")})`,
1132
+ ...summary.warnings.map((warning) => `wallet: ${warning}`)
1133
+ ]
1134
+ };
1135
+ } catch (error) {
1136
+ return { ok: false, lines: [`wallet: ${safeErrorMessage(error)}`] };
1137
+ } finally {
1138
+ if (timer !== void 0) clearTimeout(timer);
1139
+ try {
1140
+ await client.close();
1141
+ } catch {
1142
+ }
1143
+ }
1144
+ }
1145
+ async function checkDatabaseMigrated(input) {
1146
+ const fix = `run \`npx openreceive scaffold payments --orm <your orm>\` and apply the emitted migration through your normal workflow \u2014 https://openreceive.org/guides/storage.md`;
1147
+ try {
1148
+ const present = await listPresentTables(input);
1149
+ const missing = [input.tableName, input.metaTableName].filter(
1150
+ (table) => !present.includes(table)
1151
+ );
1152
+ if (missing.length === 0) {
1153
+ return {
1154
+ ok: true,
1155
+ lines: [`database: ${input.tableName} and ${input.metaTableName} present`]
1156
+ };
1157
+ }
1158
+ return {
1159
+ ok: false,
1160
+ lines: [`database: ${missing.join(" and ")} missing \u2014 the migration has not run; ${fix}`]
1161
+ };
1162
+ } catch (error) {
1163
+ return { ok: false, lines: [`database: ${safeErrorMessage(error)}`] };
1164
+ }
1165
+ }
1166
+ async function listPresentTables(input) {
1167
+ const tables = [input.tableName, input.metaTableName];
1168
+ if (/^postgres(ql)?:/.test(input.db)) {
1169
+ const pg = await importFromHostProject("pg", input.cwd);
1170
+ const client = new pg.Client({
1171
+ connectionString: input.db,
1172
+ connectionTimeoutMillis: HTTP_PROBE_TIMEOUT_MS
1173
+ });
1174
+ await client.connect();
1175
+ try {
1176
+ const result = await client.query(
1177
+ "SELECT to_regclass($1) AS a, to_regclass($2) AS b",
1178
+ tables
1179
+ );
1180
+ const row = result.rows[0] ?? {};
1181
+ return tables.filter((_, index) => row[index === 0 ? "a" : "b"] !== null);
1182
+ } finally {
1183
+ await client.end();
1184
+ }
1185
+ }
1186
+ if (/^mysql:/.test(input.db)) {
1187
+ const mysql = await importFromHostProject("mysql2/promise", input.cwd);
1188
+ const connection = await mysql.createConnection(input.db);
1189
+ try {
1190
+ const [rows] = await connection.query(
1191
+ "SELECT table_name AS name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name IN (?, ?)",
1192
+ tables
1193
+ );
1194
+ return rows.map((row) => String(row.name));
1195
+ } finally {
1196
+ await connection.end();
1197
+ }
1198
+ }
1199
+ const sqlitePath = path2.isAbsolute(input.db) ? input.db : path2.join(input.cwd, input.db);
1200
+ if (!existsSync(sqlitePath)) {
1201
+ throw new Error(
1202
+ `no SQLite database at ${sqlitePath}. Pass the file your app opens, or a postgres:// / mysql:// URL.`
1203
+ );
1204
+ }
1205
+ const { DatabaseSync } = await import("sqlite");
1206
+ const database = new DatabaseSync(sqlitePath, { readOnly: true });
1207
+ try {
1208
+ const rows = database.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (?, ?)").all(...tables);
1209
+ return rows.map((row) => row.name);
1210
+ } finally {
1211
+ database.close();
1212
+ }
1213
+ }
1214
+ async function importFromHostProject(specifier, cwd) {
1215
+ let resolved;
1216
+ try {
1217
+ resolved = createRequire(path2.join(cwd, "package.json")).resolve(specifier);
1218
+ } catch {
1219
+ throw new Error(
1220
+ `this database needs the ${JSON.stringify(specifier.split("/")[0])} driver, which is not installed here. Install it (\`npm install ${specifier.split("/")[0]}\`) or point --db at the SQLite file directly.`
1221
+ );
1222
+ }
1223
+ const mod = await import(pathToFileURL(resolved).href);
1224
+ return mod.default ?? mod;
1225
+ }
1226
+ async function checkRoutesMounted(input) {
1227
+ const base = input.url.replace(/\/+$/, "");
1228
+ const prefix = input.prefix.replace(/\/+$/, "");
1229
+ const probe = `${base}${prefix}/__doctor-probe__`;
1230
+ const fix = `mount the router (\`app.use(openreceive)\` \u2014 https://openreceive.org/guides/quickstart-node.md), or pass --prefix if it is mounted somewhere other than ${prefix}`;
1231
+ let response;
1232
+ try {
1233
+ response = await fetch(probe, {
1234
+ headers: { accept: "application/json" },
1235
+ signal: AbortSignal.timeout(HTTP_PROBE_TIMEOUT_MS)
1236
+ });
1237
+ } catch (error) {
1238
+ return {
1239
+ ok: false,
1240
+ lines: [
1241
+ `routes: could not reach ${base} (${safeErrorMessage(error)}) \u2014 is the app running at that URL?`
1242
+ ]
1243
+ };
1244
+ }
1245
+ let body;
1246
+ try {
1247
+ body = JSON.parse(await response.text());
1248
+ } catch {
1249
+ body = void 0;
1250
+ }
1251
+ const message = typeof body === "object" && body !== null && "message" in body ? String(body.message) : "";
1252
+ if (message.startsWith("No OpenReceive route matched")) {
1253
+ return { ok: true, lines: [`routes: OpenReceive router answering at ${base}${prefix}`] };
1254
+ }
1255
+ return {
1256
+ ok: false,
1257
+ lines: [
1258
+ `routes: nothing OpenReceive answered at ${base}${prefix} (HTTP ${response.status}) \u2014 ${fix}`
1259
+ ]
1260
+ };
997
1261
  }
998
1262
  function safeErrorMessage(error) {
999
1263
  if (error instanceof Error) return redactSecrets(error.message);
package/dist/index.d.ts CHANGED
@@ -180,10 +180,12 @@ type SwapAvailabilityReason = "provider_unconfigured" | "amount_too_small" | "am
180
180
  type SwapAttentionReason = "provider_completed_without_wallet_settlement" | "provider_order_creation_stale" | "provider_order_creation_failed" | "provider_order_creation_needs_reconcile" | "provider_reported_emergency" | "provider_status_unrecognized" | "provider_order_expires_after_shadow_invoice";
181
181
  /**
182
182
  * Why a swap attempt entered the refund path (`refund_required` → `refunded`).
183
- * Mapped from FixedFloat `emergency.status` (LESS / EXPIRED). Overpay (MORE/OVER)
184
- * goes to `attention`, not here.
183
+ * Mapped from FixedFloat `emergency.status` (LESS / MORE / EXPIRED). An overpay
184
+ * refunds the WHOLE deposit like every other emergency: the payout is a
185
+ * fixed-amount bolt11, so there is nothing to exchange the surplus into and
186
+ * `choice=EXCHANGE` is not a path this client takes.
185
187
  */
186
- type SwapRefundReason = "underpaid" | "late_deposit" | "underpaid_and_late";
188
+ type SwapRefundReason = "underpaid" | "overpaid" | "late_deposit" | "underpaid_and_late" | "overpaid_and_late";
187
189
  interface SwapQuote {
188
190
  readonly pay_amount?: string;
189
191
  readonly minimum_pay_amount?: string;
@@ -239,7 +241,7 @@ interface SwapOrder {
239
241
  readonly attention_reason?: SwapAttentionReason;
240
242
  /**
241
243
  * Why a refund is needed, when the attempt is on the refund path. Mapped from
242
- * FixedFloat `emergency.status` (LESS / EXPIRED).
244
+ * FixedFloat `emergency.status` (LESS / MORE / EXPIRED).
243
245
  */
244
246
  readonly refund_reason?: SwapRefundReason;
245
247
  /**