nixamp 0.28.43 → 0.28.44

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 (41) hide show
  1. package/README.md +8 -3
  2. package/dist/accounts.js +9 -1
  3. package/dist/paywall.js +1 -1
  4. package/dist/server.d.ts +16 -0
  5. package/dist/server.js +77 -6
  6. package/dist/tickets.js +1 -1
  7. package/dist/translation-passes.d.ts +29 -0
  8. package/dist/translation-passes.js +59 -1
  9. package/package.json +3 -2
  10. package/src/accounts.ts +9 -1
  11. package/src/paywall.ts +1 -1
  12. package/src/server.ts +57 -6
  13. package/src/tickets.ts +1 -1
  14. package/src/translation-passes.ts +54 -1
  15. package/src/types/auth-system.d.ts +12 -3
  16. package/web/dist/.well-known/openwebring.json +6 -0
  17. package/web/dist/assets/{ar-CR2NEW77.js → ar-CLNm1oA7.js} +1 -1
  18. package/web/dist/assets/{cs-ivXHTYBK.js → cs-Cptn8RbB.js} +1 -1
  19. package/web/dist/assets/{da-DQEGAEdk.js → da-CRbNCFOu.js} +1 -1
  20. package/web/dist/assets/{de-B5VaSv3O.js → de-D4DnC5NQ.js} +1 -1
  21. package/web/dist/assets/{es-DUTtfGnk.js → es-DAHnD9bs.js} +1 -1
  22. package/web/dist/assets/{fi-CzQZtpO5.js → fi-Csbl41Vz.js} +1 -1
  23. package/web/dist/assets/{fr-C2g4DUBr.js → fr-jZYQJed8.js} +1 -1
  24. package/web/dist/assets/{hi-DKB7ZsZ8.js → hi-DjT9u0v_.js} +1 -1
  25. package/web/dist/assets/{hls-3VKVEQE3-Bc75Z5iH.js → hls-3VKVEQE3-BjAVnvGY.js} +1 -1
  26. package/web/dist/assets/{hu--oH7WvAR.js → hu-CVrU8gC4.js} +1 -1
  27. package/web/dist/assets/{id-CjlpBIJc.js → id-B_gN24X5.js} +1 -1
  28. package/web/dist/assets/{index-Bo3utcDw.css → index-BueBHHcv.css} +1 -1
  29. package/web/dist/assets/index-c3uxZS8_.js +3 -0
  30. package/web/dist/assets/{it-Dbctup0F.js → it-BUC1dMpV.js} +1 -1
  31. package/web/dist/assets/{mpegts-D9VECdKk.js → mpegts-CnqmXTu2.js} +1 -1
  32. package/web/dist/assets/{mpegts-LO6RVLD6-CSb7Fz4U.js → mpegts-LO6RVLD6-B4QgG3Xb.js} +1 -1
  33. package/web/dist/assets/{nl-BYw8ZbV_.js → nl-CGi-aIrz.js} +1 -1
  34. package/web/dist/assets/{ru-DFGYCrj-.js → ru-YIbS63cK.js} +1 -1
  35. package/web/dist/assets/{sv-DbO1Sc1v.js → sv-CuJUpOCk.js} +1 -1
  36. package/web/dist/assets/{uk-BTXHwsjV.js → uk-DLfG8YhV.js} +1 -1
  37. package/web/dist/assets/{vi-DJ-vIPLS.js → vi-nv2siY2T.js} +1 -1
  38. package/web/dist/assets/{zh-D3Gju_Y2.js → zh-P5x8w1OW.js} +1 -1
  39. package/web/dist/index.html +8 -2
  40. package/web/dist/sw.js +24 -23
  41. package/web/dist/assets/index-CF04ablL.js +0 -3
package/README.md CHANGED
@@ -619,9 +619,14 @@ multiplied by five. Credentials and balances never travel in checkout URLs.
619
619
  New checkout creation is capped at five per account and fifty per account server
620
620
  per UTC day, plus IP and request throttles; retries reuse the original invoice.
621
621
 
622
- After the ten free sessions, new sessions require purchased credit. Configure `COINPAY_X402_KEY`
623
- with `payments:create` permission and at least one business wallet; the scoped
624
- key supplies the merchant identity. Free sessions and existing credit still work during a
622
+ After the ten free sessions, new sessions require purchased credit. Configure `COINPAY_API_KEY`
623
+ (the older name `COINPAY_X402_KEY` still works; `COINPAY_API_KEY` wins when both are set)
624
+ with `payments:create` and `payments:read` permission and at least one business wallet. The scoped
625
+ key supplies the merchant identity, so it must belong to nixamp's own CoinPay business. Set
626
+ `COINPAY_WEBHOOK_SECRET` to that business's webhook secret and its webhook URL to
627
+ `https://<site>/api/v1/webhooks/coinpay`, and a paid order is credited even if the buyer never
628
+ comes back to the page. The webhook body is only a nudge: the order is re-checked with CoinPay
629
+ before anything is credited, and it is credited once however many times it is delivered. Free sessions and existing credit still work during a
625
630
  checkout outage. A self-hosted operator explicitly sponsoring API usage may set
626
631
  `NIXAMP_TRANSLATION_BILLING=off`.
627
632
 
package/dist/accounts.js CHANGED
@@ -96,11 +96,19 @@ export class Accounts {
96
96
  (options.system
97
97
  ? null
98
98
  : new PostgresAdapter({ connectionString: options.connectionString }));
99
+ // Without a secret the module signs with a public default, and anyone can
100
+ // mint a session for any account. Refuse rather than run that way.
101
+ if (!options.system && !options.secret) {
102
+ throw new Error("NIXAMP_JWT_SECRET is required for accounts");
103
+ }
99
104
  this.system =
100
105
  options.system ??
101
106
  createAuthSystem({
102
107
  adapter,
103
- jwtSecret: options.secret,
108
+ // tokenOptions.secret, not jwtSecret: the module has no top-level
109
+ // jwtSecret, and until 2026-10-09 this passed one, so every JWT was
110
+ // signed with the module's public default secret.
111
+ tokenOptions: { secret: options.secret },
104
112
  // The module defaults to requiring an uppercase and a lowercase
105
113
  // letter. Its rules have to match the ones checked above, or a password
106
114
  // this accepts is refused a layer down in a different sentence.
package/dist/paywall.js CHANGED
@@ -134,7 +134,7 @@ export function paywallFromEnv(env = process.env) {
134
134
  return {
135
135
  enabled: env["NIXAMP_X402"] === "1",
136
136
  payTo: env["NIXAMP_PAY_TO"] ?? "",
137
- coinpayKey: env["COINPAY_X402_KEY"] ?? "",
137
+ coinpayKey: env["COINPAY_API_KEY"] || env["COINPAY_X402_KEY"] || "",
138
138
  priceCents: Number.isFinite(price) && price > 0 ? Math.floor(price) : DEFAULT_PAYWALL.priceCents,
139
139
  passMinutes: Number.isFinite(minutes) && minutes > 0 ? Math.floor(minutes) : DEFAULT_PAYWALL.passMinutes,
140
140
  };
package/dist/server.d.ts CHANGED
@@ -143,6 +143,20 @@ export interface ServeOptions {
143
143
  */
144
144
  rtmp: string[];
145
145
  }
146
+ /**
147
+ * Read DATABASE_URL the way libpq does.
148
+ *
149
+ * node-postgres reads `sslmode=require` as verify-full, so a cluster that
150
+ * signs its own certificate (dev2's shared Postgres) refuses every query with
151
+ * "self signed certificate" while the process itself stays up and healthy:
152
+ * pages serve, accounts and follows and events all fail. libpq, and every
153
+ * other client, read `require` as "encrypt, do not verify", and pg honours
154
+ * that reading when the URL carries `uselibpqcompat=true`. Railway's internal
155
+ * URL had no sslmode at all, which is why this never came up there. Only
156
+ * `require` and `prefer` are rewritten: `no-verify` is pg's own spelling and
157
+ * has no libpq meaning, and `verify-*` asks for exactly what pg does anyway.
158
+ */
159
+ export declare function libpqDatabaseUrl(raw: string | undefined): string | undefined;
146
160
  /**
147
161
  * Flags are parsed by hand: three of them do not justify a dependency, and the
148
162
  * failure mode of a wrong `--port` should be a message rather than NaN.
@@ -723,6 +737,8 @@ export interface HandlerOptions {
723
737
  speech?: Speech;
724
738
  liveVoice?: LiveVoice;
725
739
  translationPasses?: TranslationPasses;
740
+ /** Signing secret of nixamp's CoinPay business, for /api/v1/webhooks/coinpay. */
741
+ coinpayWebhookSecret?: string;
726
742
  sharedTranslations?: SharedTranslations;
727
743
  /** Translation: texts in another language, by a model here. The same optional library. */
728
744
  translator?: Translator;
package/dist/server.js CHANGED
@@ -20,7 +20,7 @@ import { Readable } from "node:stream";
20
20
  import { pipeline as pipeStream } from "node:stream/promises";
21
21
  import { relative } from "node:path";
22
22
  import { SharedTranslations } from "./shared-translation.js";
23
- import { TranslationPasses } from "./translation-passes.js";
23
+ import { TranslationPasses, coinpayKeyFrom, readCoinpayWebhook, verifyCoinpaySignature } from "./translation-passes.js";
24
24
  import { LiveVoice, LIVE_VOICE_LANGUAGES, LIVE_VOICE_MODEL } from "./live-voice.js";
25
25
  import { Connections } from "./connections.js";
26
26
  import { Broadcaster, DEFAULT_ENCODER, PRESETS, redact, } from "./broadcast.js";
@@ -106,6 +106,24 @@ import { emptySnapshot, parseCommand, } from "./protocol.js";
106
106
  const FFT_SIZE = 2048;
107
107
  export const SERVE_BAND_COUNT = 24;
108
108
  export const DEFAULT_PORT = 4321;
109
+ /**
110
+ * Read DATABASE_URL the way libpq does.
111
+ *
112
+ * node-postgres reads `sslmode=require` as verify-full, so a cluster that
113
+ * signs its own certificate (dev2's shared Postgres) refuses every query with
114
+ * "self signed certificate" while the process itself stays up and healthy:
115
+ * pages serve, accounts and follows and events all fail. libpq, and every
116
+ * other client, read `require` as "encrypt, do not verify", and pg honours
117
+ * that reading when the URL carries `uselibpqcompat=true`. Railway's internal
118
+ * URL had no sslmode at all, which is why this never came up there. Only
119
+ * `require` and `prefer` are rewritten: `no-verify` is pg's own spelling and
120
+ * has no libpq meaning, and `verify-*` asks for exactly what pg does anyway.
121
+ */
122
+ export function libpqDatabaseUrl(raw) {
123
+ if (!raw || !/[?&]sslmode=(require|prefer)\b/.test(raw) || /[?&]uselibpqcompat=/.test(raw))
124
+ return raw;
125
+ return `${raw}${raw.includes("?") ? "&" : "?"}uselibpqcompat=true`;
126
+ }
109
127
  /**
110
128
  * Flags are parsed by hand: three of them do not justify a dependency, and the
111
129
  * failure mode of a wrong `--port` should be a message rather than NaN.
@@ -1906,6 +1924,57 @@ export function createHandler(engine, options) {
1906
1924
  json(response, 404, { error: "no such endpoint" });
1907
1925
  return;
1908
1926
  }
1927
+ // --- CoinPay ----------------------------------------------------------
1928
+ //
1929
+ // Ahead of the share-key check because the caller is CoinPay, which signs
1930
+ // the body instead. The body is only a nudge: settle() asks CoinPay for the
1931
+ // payment itself before anything is credited.
1932
+ if (path === "/api/v1/webhooks/coinpay") {
1933
+ if (request.method !== "POST") {
1934
+ json(response, 405, { error: "POST only" });
1935
+ return;
1936
+ }
1937
+ const secret = options.coinpayWebhookSecret ?? "";
1938
+ if (!secret) {
1939
+ json(response, 503, { error: "CoinPay webhooks are not configured here." });
1940
+ return;
1941
+ }
1942
+ let raw;
1943
+ try {
1944
+ raw = await readBody(request);
1945
+ }
1946
+ catch {
1947
+ json(response, 413, { error: "body too large" });
1948
+ return;
1949
+ }
1950
+ const signature = request.headers["x-coinpay-signature"];
1951
+ if (!verifyCoinpaySignature(raw, Array.isArray(signature) ? signature[0] : signature, secret)) {
1952
+ json(response, 401, { error: "invalid signature" });
1953
+ return;
1954
+ }
1955
+ let event;
1956
+ try {
1957
+ event = readCoinpayWebhook(JSON.parse(raw));
1958
+ }
1959
+ catch {
1960
+ json(response, 400, { error: "invalid JSON" });
1961
+ return;
1962
+ }
1963
+ if (!options.translationPasses) {
1964
+ json(response, 200, { settled: false, reason: "billing is off here" });
1965
+ return;
1966
+ }
1967
+ try {
1968
+ json(response, 200, await options.translationPasses.settle(event.paymentId));
1969
+ }
1970
+ catch (error) {
1971
+ // A 5xx makes CoinPay deliver it again, which is what an unreachable
1972
+ // CoinPay or database needs.
1973
+ console.error(`nixamp: CoinPay webhook for ${event.paymentId} could not settle: ${error instanceof Error ? error.message : String(error)}`);
1974
+ json(response, 500, { error: "could not settle; retry" });
1975
+ }
1976
+ return;
1977
+ }
1909
1978
  // --- the party line ---------------------------------------------------
1910
1979
  //
1911
1980
  // Ahead of the share-key check because the caller is a telephone. Telnyx
@@ -5917,8 +5986,9 @@ export async function serve(argv, version = "0.1.0") {
5917
5986
  // pocket can administer it from anywhere by signing in as the same person.
5918
5987
  // Following outlives every stream, so unlike the rest of this it wants a
5919
5988
  // database. Only where there is one: a nixamp on a laptop has no followers.
5920
- const pool = options.directory && process.env["DATABASE_URL"]
5921
- ? new pg.Pool({ connectionString: process.env["DATABASE_URL"] })
5989
+ const databaseUrl = libpqDatabaseUrl(process.env["DATABASE_URL"]);
5990
+ const pool = options.directory && databaseUrl
5991
+ ? new pg.Pool({ connectionString: databaseUrl })
5922
5992
  : undefined;
5923
5993
  const follows = pool ? new Follows(pool) : undefined;
5924
5994
  const favorites = pool ? new Favorites(pool) : undefined;
@@ -5943,9 +6013,9 @@ export async function serve(argv, version = "0.1.0") {
5943
6013
  // the handler options because the authorization server issues its access
5944
6014
  // tokens out of the same table -- an OAuth token IS a nixamp token with a
5945
6015
  // client's name on it, which is why every existing route understands one.
5946
- const accounts = options.directory && process.env["DATABASE_URL"]
6016
+ const accounts = options.directory && databaseUrl
5947
6017
  ? new Accounts({
5948
- connectionString: process.env["DATABASE_URL"],
6018
+ connectionString: databaseUrl,
5949
6019
  secret: process.env["NIXAMP_JWT_SECRET"] ?? "",
5950
6020
  })
5951
6021
  : undefined;
@@ -5956,7 +6026,7 @@ export async function serve(argv, version = "0.1.0") {
5956
6026
  // Account servers meter paid providers by default, including when checkout
5957
6027
  // is temporarily unconfigured. Self-hosters can explicitly sponsor usage.
5958
6028
  const translationPasses = accounts && pool && process.env["NIXAMP_TRANSLATION_BILLING"] !== "off"
5959
- ? new TranslationPasses({ db: pool, key: process.env["COINPAY_X402_KEY"] ?? "", site: nixampSite }) : undefined;
6029
+ ? new TranslationPasses({ db: pool, key: coinpayKeyFrom(), site: nixampSite }) : undefined;
5960
6030
  if (translationPasses)
5961
6031
  void translationPasses.access().catch(() => console.error("nixamp: translation checkout could not warm; it will retry on demand"));
5962
6032
  const liveVoice = accounts && process.env["NIXAMP_DUBBING"] !== "off" ? new LiveVoice({
@@ -6502,6 +6572,7 @@ export async function serve(argv, version = "0.1.0") {
6502
6572
  ...(speech ? { speech } : {}),
6503
6573
  ...(liveVoice ? { liveVoice } : {}),
6504
6574
  ...(translationPasses ? { translationPasses } : {}),
6575
+ ...(process.env["COINPAY_WEBHOOK_SECRET"] ? { coinpayWebhookSecret: process.env["COINPAY_WEBHOOK_SECRET"] } : {}),
6505
6576
  ...(sharedTranslations ? { sharedTranslations } : {}),
6506
6577
  ...(translator ? { translator } : {}),
6507
6578
  ...(transcripts ? { transcripts } : {}),
package/dist/tickets.js CHANGED
@@ -145,7 +145,7 @@ export class Tickets {
145
145
  }
146
146
  /** Read a Tickets configuration out of the environment. */
147
147
  export function ticketsFromEnv(site, env = process.env) {
148
- const coinpayKey = env["COINPAY_X402_KEY"] ?? "";
148
+ const coinpayKey = env["COINPAY_API_KEY"] || env["COINPAY_X402_KEY"] || "";
149
149
  if (!coinpayKey)
150
150
  return null;
151
151
  return {
@@ -54,6 +54,23 @@ export declare function translationCost(kind: TranslationUsage, units: number):
54
54
  charge: number;
55
55
  };
56
56
  export declare function verifyTranslationPayment(payment: Record<string, unknown>, id: string, priceCents: number): boolean;
57
+ /** Which CoinPay key this deployment sells with. `COINPAY_API_KEY` is nixamp's
58
+ * own business; `COINPAY_X402_KEY` is the older name, still honoured so a box
59
+ * that has only it keeps selling. The own key wins, because the fleet-wide
60
+ * x402 key belongs to another site's business and would book nixamp's sales
61
+ * (and send its webhooks) there. */
62
+ export declare function coinpayKeyFrom(env?: NodeJS.ProcessEnv): string;
63
+ /** CoinPay signs `t=<unix>,v1=<hex HMAC-SHA256 of "t.rawBody">` and allows
64
+ * 300 s of skew. Computed over the raw bytes: reserialising changes them. */
65
+ export declare function verifyCoinpaySignature(raw: string, header: string | undefined, secret: string, nowMs?: number): boolean;
66
+ /** CoinPay's webhook nests the payment under `data`, and its top-level `id` is
67
+ * the EVENT (`evt_…`), not the payment. Read `data` first; the flat shape is
68
+ * only the older test sender's. */
69
+ export declare function readCoinpayWebhook(payload: unknown): {
70
+ type: string;
71
+ paymentId: string;
72
+ status: string;
73
+ };
57
74
  export declare class TranslationPasses implements TranslationMeter {
58
75
  private readonly options;
59
76
  private schema;
@@ -90,6 +107,18 @@ export declare class TranslationPasses implements TranslationMeter {
90
107
  id: string;
91
108
  url: string;
92
109
  }>;
110
+ /**
111
+ * A CoinPay webhook only says "look again". The order is settled by asking
112
+ * CoinPay, exactly as a returning buyer's check does, so a replayed or
113
+ * stale body cannot credit anything and a second delivery (confirmed, then
114
+ * forwarded) credits nothing twice. A payment that is not ours is answered,
115
+ * not failed: CoinPay would otherwise retry it forever.
116
+ */
117
+ settle(paymentId: string): Promise<{
118
+ settled: boolean;
119
+ status?: string;
120
+ reason?: string;
121
+ }>;
93
122
  check(by: string, id: string): Promise<{
94
123
  status: string;
95
124
  access: TranslationAccess;
@@ -1,6 +1,6 @@
1
1
  /** Account-bound, prepaid translation credit. Money is integer micro-USD;
2
2
  * each listening account reserves 5x base provider cost for paid access. */
3
- import { randomUUID } from "node:crypto";
3
+ import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
4
4
  import { SpeechError } from "./speech.js";
5
5
  import { UpgradeAllowances } from "./upgrade-allowance.js";
6
6
  export const TRANSLATION_MULTIPLIER = 5;
@@ -33,6 +33,47 @@ export function verifyTranslationPayment(payment, id, priceCents) {
33
33
  return payment["id"] === id && PAID.has(String(payment["status"])) &&
34
34
  String(payment["currency"]).toUpperCase() === "USD" && cents(payment["amount"]) === priceCents;
35
35
  }
36
+ /** Which CoinPay key this deployment sells with. `COINPAY_API_KEY` is nixamp's
37
+ * own business; `COINPAY_X402_KEY` is the older name, still honoured so a box
38
+ * that has only it keeps selling. The own key wins, because the fleet-wide
39
+ * x402 key belongs to another site's business and would book nixamp's sales
40
+ * (and send its webhooks) there. */
41
+ export function coinpayKeyFrom(env = process.env) {
42
+ return env["COINPAY_API_KEY"] || env["COINPAY_X402_KEY"] || "";
43
+ }
44
+ /** CoinPay signs `t=<unix>,v1=<hex HMAC-SHA256 of "t.rawBody">` and allows
45
+ * 300 s of skew. Computed over the raw bytes: reserialising changes them. */
46
+ export function verifyCoinpaySignature(raw, header, secret, nowMs = Date.now()) {
47
+ if (!secret || !header)
48
+ return false;
49
+ let t = "", v1 = "";
50
+ for (const part of header.split(",")) {
51
+ const at = part.indexOf("=");
52
+ const name = part.slice(0, at).trim(), value = part.slice(at + 1).trim();
53
+ if (name === "t")
54
+ t = value;
55
+ else if (name === "v1")
56
+ v1 = value;
57
+ }
58
+ if (!/^\d{1,12}$/.test(t) || !/^[a-f0-9]{64}$/i.test(v1))
59
+ return false;
60
+ if (Math.abs(nowMs / 1000 - Number(t)) > 300)
61
+ return false;
62
+ const expected = createHmac("sha256", secret).update(`${t}.${raw}`).digest();
63
+ return timingSafeEqual(expected, Buffer.from(v1, "hex"));
64
+ }
65
+ /** CoinPay's webhook nests the payment under `data`, and its top-level `id` is
66
+ * the EVENT (`evt_…`), not the payment. Read `data` first; the flat shape is
67
+ * only the older test sender's. */
68
+ export function readCoinpayWebhook(payload) {
69
+ const body = payload && typeof payload === "object" ? payload : {};
70
+ const data = body["data"] && typeof body["data"] === "object" ? body["data"] : body;
71
+ return {
72
+ type: String(body["type"] ?? ""),
73
+ paymentId: String(data["payment_id"] ?? data["id"] ?? ""),
74
+ status: String(data["status"] ?? "").toLowerCase(),
75
+ };
76
+ }
36
77
  export class TranslationPasses {
37
78
  options;
38
79
  schema = null;
@@ -202,6 +243,23 @@ export class TranslationPasses {
202
243
  }
203
244
  return { id: String(order["id"]), url: `https://coinpayportal.com/pay/${paymentId}` };
204
245
  }
246
+ /**
247
+ * A CoinPay webhook only says "look again". The order is settled by asking
248
+ * CoinPay, exactly as a returning buyer's check does, so a replayed or
249
+ * stale body cannot credit anything and a second delivery (confirmed, then
250
+ * forwarded) credits nothing twice. A payment that is not ours is answered,
251
+ * not failed: CoinPay would otherwise retry it forever.
252
+ */
253
+ async settle(paymentId) {
254
+ if (!ID.test(paymentId))
255
+ return { settled: false, reason: "unknown payment" };
256
+ await this.ensure();
257
+ const order = (await this.options.db.query("SELECT id, by_account FROM translation_orders WHERE payment_id = $1", [paymentId])).rows[0];
258
+ if (!order)
259
+ return { settled: false, reason: "unknown payment" };
260
+ const { status } = await this.check(String(order["by_account"]), String(order["id"]));
261
+ return { settled: status === "paid", status };
262
+ }
205
263
  async check(by, id) {
206
264
  if (!ID.test(id))
207
265
  throw new SpeechError("Purchase not found.", 404);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.28.43",
3
+ "version": "0.28.44",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -45,7 +45,7 @@
45
45
  "pack:cli": "bun scripts/pack-cli.ts"
46
46
  },
47
47
  "dependencies": {
48
- "@profullstack/auth-system": "^0.6.0",
48
+ "@profullstack/auth-system": "^0.8.3",
49
49
  "@profullstack/hqtui": "^0.5.0",
50
50
  "@profullstack/notifications": "^0.1.0",
51
51
  "@profullstack/synconfig": "^0.1.2",
@@ -60,6 +60,7 @@
60
60
  "devDependencies": {
61
61
  "@types/node": "^26",
62
62
  "@types/pg": "^8.23.1",
63
+ "jsonwebtoken": "^9.0.2",
63
64
  "typescript": "^7.0.2"
64
65
  },
65
66
  "trustedDependencies": [
package/src/accounts.ts CHANGED
@@ -142,11 +142,19 @@ export class Accounts {
142
142
  (options.system
143
143
  ? null
144
144
  : (new PostgresAdapter({ connectionString: options.connectionString }) as unknown as AdapterLike));
145
+ // Without a secret the module signs with a public default, and anyone can
146
+ // mint a session for any account. Refuse rather than run that way.
147
+ if (!options.system && !options.secret) {
148
+ throw new Error("NIXAMP_JWT_SECRET is required for accounts");
149
+ }
145
150
  this.system =
146
151
  options.system ??
147
152
  (createAuthSystem({
148
153
  adapter,
149
- jwtSecret: options.secret,
154
+ // tokenOptions.secret, not jwtSecret: the module has no top-level
155
+ // jwtSecret, and until 2026-10-09 this passed one, so every JWT was
156
+ // signed with the module's public default secret.
157
+ tokenOptions: { secret: options.secret },
150
158
  // The module defaults to requiring an uppercase and a lowercase
151
159
  // letter. Its rules have to match the ones checked above, or a password
152
160
  // this accepts is refused a layer down in a different sentence.
package/src/paywall.ts CHANGED
@@ -168,7 +168,7 @@ export function paywallFromEnv(env: NodeJS.ProcessEnv = process.env): PaywallCon
168
168
  return {
169
169
  enabled: env["NIXAMP_X402"] === "1",
170
170
  payTo: env["NIXAMP_PAY_TO"] ?? "",
171
- coinpayKey: env["COINPAY_X402_KEY"] ?? "",
171
+ coinpayKey: env["COINPAY_API_KEY"] || env["COINPAY_X402_KEY"] || "",
172
172
  priceCents: Number.isFinite(price) && price > 0 ? Math.floor(price) : DEFAULT_PAYWALL.priceCents,
173
173
  passMinutes:
174
174
  Number.isFinite(minutes) && minutes > 0 ? Math.floor(minutes) : DEFAULT_PAYWALL.passMinutes,
package/src/server.ts CHANGED
@@ -20,7 +20,7 @@ import { Readable } from "node:stream";
20
20
  import { pipeline as pipeStream } from "node:stream/promises";
21
21
  import { relative } from "node:path";
22
22
  import { SharedTranslations, type SharedEvent } from "./shared-translation.ts";
23
- import { TranslationPasses } from "./translation-passes.ts";
23
+ import { TranslationPasses, coinpayKeyFrom, readCoinpayWebhook, verifyCoinpaySignature } from "./translation-passes.ts";
24
24
  import { LiveVoice, LIVE_VOICE_LANGUAGES, LIVE_VOICE_MODEL, type VoiceRequest } from "./live-voice.ts";
25
25
  import { Connections, type Kind } from "./connections.ts";
26
26
  import {
@@ -255,6 +255,24 @@ export interface ServeOptions {
255
255
  rtmp: string[];
256
256
  }
257
257
 
258
+ /**
259
+ * Read DATABASE_URL the way libpq does.
260
+ *
261
+ * node-postgres reads `sslmode=require` as verify-full, so a cluster that
262
+ * signs its own certificate (dev2's shared Postgres) refuses every query with
263
+ * "self signed certificate" while the process itself stays up and healthy:
264
+ * pages serve, accounts and follows and events all fail. libpq, and every
265
+ * other client, read `require` as "encrypt, do not verify", and pg honours
266
+ * that reading when the URL carries `uselibpqcompat=true`. Railway's internal
267
+ * URL had no sslmode at all, which is why this never came up there. Only
268
+ * `require` and `prefer` are rewritten: `no-verify` is pg's own spelling and
269
+ * has no libpq meaning, and `verify-*` asks for exactly what pg does anyway.
270
+ */
271
+ export function libpqDatabaseUrl(raw: string | undefined): string | undefined {
272
+ if (!raw || !/[?&]sslmode=(require|prefer)\b/.test(raw) || /[?&]uselibpqcompat=/.test(raw)) return raw;
273
+ return `${raw}${raw.includes("?") ? "&" : "?"}uselibpqcompat=true`;
274
+ }
275
+
258
276
  /**
259
277
  * Flags are parsed by hand: three of them do not justify a dependency, and the
260
278
  * failure mode of a wrong `--port` should be a message rather than NaN.
@@ -1796,6 +1814,8 @@ export interface HandlerOptions {
1796
1814
  speech?: Speech;
1797
1815
  liveVoice?: LiveVoice;
1798
1816
  translationPasses?: TranslationPasses;
1817
+ /** Signing secret of nixamp's CoinPay business, for /api/v1/webhooks/coinpay. */
1818
+ coinpayWebhookSecret?: string;
1799
1819
  sharedTranslations?: SharedTranslations;
1800
1820
  /** Translation: texts in another language, by a model here. The same optional library. */
1801
1821
  translator?: Translator;
@@ -2417,6 +2437,35 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2417
2437
  return;
2418
2438
  }
2419
2439
 
2440
+ // --- CoinPay ----------------------------------------------------------
2441
+ //
2442
+ // Ahead of the share-key check because the caller is CoinPay, which signs
2443
+ // the body instead. The body is only a nudge: settle() asks CoinPay for the
2444
+ // payment itself before anything is credited.
2445
+ if (path === "/api/v1/webhooks/coinpay") {
2446
+ if (request.method !== "POST") { json(response, 405, { error: "POST only" }); return; }
2447
+ const secret = options.coinpayWebhookSecret ?? "";
2448
+ if (!secret) { json(response, 503, { error: "CoinPay webhooks are not configured here." }); return; }
2449
+ let raw: string;
2450
+ try { raw = await readBody(request); } catch { json(response, 413, { error: "body too large" }); return; }
2451
+ const signature = request.headers["x-coinpay-signature"];
2452
+ if (!verifyCoinpaySignature(raw, Array.isArray(signature) ? signature[0] : signature, secret)) {
2453
+ json(response, 401, { error: "invalid signature" }); return;
2454
+ }
2455
+ let event: ReturnType<typeof readCoinpayWebhook>;
2456
+ try { event = readCoinpayWebhook(JSON.parse(raw)); } catch { json(response, 400, { error: "invalid JSON" }); return; }
2457
+ if (!options.translationPasses) { json(response, 200, { settled: false, reason: "billing is off here" }); return; }
2458
+ try {
2459
+ json(response, 200, await options.translationPasses.settle(event.paymentId));
2460
+ } catch (error) {
2461
+ // A 5xx makes CoinPay deliver it again, which is what an unreachable
2462
+ // CoinPay or database needs.
2463
+ console.error(`nixamp: CoinPay webhook for ${event.paymentId} could not settle: ${error instanceof Error ? error.message : String(error)}`);
2464
+ json(response, 500, { error: "could not settle; retry" });
2465
+ }
2466
+ return;
2467
+ }
2468
+
2420
2469
  // --- the party line ---------------------------------------------------
2421
2470
  //
2422
2471
  // Ahead of the share-key check because the caller is a telephone. Telnyx
@@ -6423,9 +6472,10 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
6423
6472
  // pocket can administer it from anywhere by signing in as the same person.
6424
6473
  // Following outlives every stream, so unlike the rest of this it wants a
6425
6474
  // database. Only where there is one: a nixamp on a laptop has no followers.
6475
+ const databaseUrl = libpqDatabaseUrl(process.env["DATABASE_URL"]);
6426
6476
  const pool =
6427
- options.directory && process.env["DATABASE_URL"]
6428
- ? new pg.Pool({ connectionString: process.env["DATABASE_URL"] })
6477
+ options.directory && databaseUrl
6478
+ ? new pg.Pool({ connectionString: databaseUrl })
6429
6479
  : undefined;
6430
6480
  const follows = pool ? new Follows(pool) : undefined;
6431
6481
  const favorites = pool ? new Favorites(pool) : undefined;
@@ -6452,9 +6502,9 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
6452
6502
  // tokens out of the same table -- an OAuth token IS a nixamp token with a
6453
6503
  // client's name on it, which is why every existing route understands one.
6454
6504
  const accounts =
6455
- options.directory && process.env["DATABASE_URL"]
6505
+ options.directory && databaseUrl
6456
6506
  ? new Accounts({
6457
- connectionString: process.env["DATABASE_URL"],
6507
+ connectionString: databaseUrl,
6458
6508
  secret: process.env["NIXAMP_JWT_SECRET"] ?? "",
6459
6509
  })
6460
6510
  : undefined;
@@ -6465,7 +6515,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
6465
6515
  // Account servers meter paid providers by default, including when checkout
6466
6516
  // is temporarily unconfigured. Self-hosters can explicitly sponsor usage.
6467
6517
  const translationPasses = accounts && pool && process.env["NIXAMP_TRANSLATION_BILLING"] !== "off"
6468
- ? new TranslationPasses({ db: pool, key: process.env["COINPAY_X402_KEY"] ?? "", site: nixampSite }) : undefined;
6518
+ ? new TranslationPasses({ db: pool, key: coinpayKeyFrom(), site: nixampSite }) : undefined;
6469
6519
  if (translationPasses) void translationPasses.access().catch(() => console.error("nixamp: translation checkout could not warm; it will retry on demand"));
6470
6520
  const liveVoice = accounts && process.env["NIXAMP_DUBBING"] !== "off" ? new LiveVoice({
6471
6521
  ...(pool ? { db: pool } : {}),
@@ -7015,6 +7065,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7015
7065
  ...(speech ? { speech } : {}),
7016
7066
  ...(liveVoice ? { liveVoice } : {}),
7017
7067
  ...(translationPasses ? { translationPasses } : {}),
7068
+ ...(process.env["COINPAY_WEBHOOK_SECRET"] ? { coinpayWebhookSecret: process.env["COINPAY_WEBHOOK_SECRET"] } : {}),
7018
7069
  ...(sharedTranslations ? { sharedTranslations } : {}),
7019
7070
  ...(translator ? { translator } : {}),
7020
7071
  ...(transcripts ? { transcripts } : {}),
package/src/tickets.ts CHANGED
@@ -185,7 +185,7 @@ export function ticketsFromEnv(
185
185
  site: string,
186
186
  env: NodeJS.ProcessEnv = process.env,
187
187
  ): TicketOptions | null {
188
- const coinpayKey = env["COINPAY_X402_KEY"] ?? "";
188
+ const coinpayKey = env["COINPAY_API_KEY"] || env["COINPAY_X402_KEY"] || "";
189
189
  if (!coinpayKey) return null;
190
190
  return {
191
191
  coinpayKey,
@@ -1,6 +1,6 @@
1
1
  /** Account-bound, prepaid translation credit. Money is integer micro-USD;
2
2
  * each listening account reserves 5x base provider cost for paid access. */
3
- import { randomUUID } from "node:crypto";
3
+ import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
4
4
  import type { Queryable } from "./follows.ts";
5
5
  import { SpeechError } from "./speech.ts";
6
6
  import { UpgradeAllowances, type UpgradeAllowance } from "./upgrade-allowance.ts";
@@ -53,6 +53,44 @@ export function verifyTranslationPayment(payment: Record<string, unknown>, id: s
53
53
  String(payment["currency"]).toUpperCase() === "USD" && cents(payment["amount"]) === priceCents;
54
54
  }
55
55
 
56
+ /** Which CoinPay key this deployment sells with. `COINPAY_API_KEY` is nixamp's
57
+ * own business; `COINPAY_X402_KEY` is the older name, still honoured so a box
58
+ * that has only it keeps selling. The own key wins, because the fleet-wide
59
+ * x402 key belongs to another site's business and would book nixamp's sales
60
+ * (and send its webhooks) there. */
61
+ export function coinpayKeyFrom(env: NodeJS.ProcessEnv = process.env): string {
62
+ return env["COINPAY_API_KEY"] || env["COINPAY_X402_KEY"] || "";
63
+ }
64
+
65
+ /** CoinPay signs `t=<unix>,v1=<hex HMAC-SHA256 of "t.rawBody">` and allows
66
+ * 300 s of skew. Computed over the raw bytes: reserialising changes them. */
67
+ export function verifyCoinpaySignature(raw: string, header: string | undefined, secret: string, nowMs = Date.now()): boolean {
68
+ if (!secret || !header) return false;
69
+ let t = "", v1 = "";
70
+ for (const part of header.split(",")) {
71
+ const at = part.indexOf("=");
72
+ const name = part.slice(0, at).trim(), value = part.slice(at + 1).trim();
73
+ if (name === "t") t = value; else if (name === "v1") v1 = value;
74
+ }
75
+ if (!/^\d{1,12}$/.test(t) || !/^[a-f0-9]{64}$/i.test(v1)) return false;
76
+ if (Math.abs(nowMs / 1000 - Number(t)) > 300) return false;
77
+ const expected = createHmac("sha256", secret).update(`${t}.${raw}`).digest();
78
+ return timingSafeEqual(expected, Buffer.from(v1, "hex"));
79
+ }
80
+
81
+ /** CoinPay's webhook nests the payment under `data`, and its top-level `id` is
82
+ * the EVENT (`evt_…`), not the payment. Read `data` first; the flat shape is
83
+ * only the older test sender's. */
84
+ export function readCoinpayWebhook(payload: unknown): { type: string; paymentId: string; status: string } {
85
+ const body = payload && typeof payload === "object" ? payload as Record<string, unknown> : {};
86
+ const data = body["data"] && typeof body["data"] === "object" ? body["data"] as Record<string, unknown> : body;
87
+ return {
88
+ type: String(body["type"] ?? ""),
89
+ paymentId: String(data["payment_id"] ?? data["id"] ?? ""),
90
+ status: String(data["status"] ?? "").toLowerCase(),
91
+ };
92
+ }
93
+
56
94
  export class TranslationPasses implements TranslationMeter {
57
95
  private schema: Promise<void> | null = null;
58
96
  private merchant: Promise<{ business: string; coins: string[] }> | null = null;
@@ -209,6 +247,21 @@ export class TranslationPasses implements TranslationMeter {
209
247
  }
210
248
  return { id: String(order["id"]), url: `https://coinpayportal.com/pay/${paymentId}` };
211
249
  }
250
+ /**
251
+ * A CoinPay webhook only says "look again". The order is settled by asking
252
+ * CoinPay, exactly as a returning buyer's check does, so a replayed or
253
+ * stale body cannot credit anything and a second delivery (confirmed, then
254
+ * forwarded) credits nothing twice. A payment that is not ours is answered,
255
+ * not failed: CoinPay would otherwise retry it forever.
256
+ */
257
+ async settle(paymentId: string): Promise<{ settled: boolean; status?: string; reason?: string }> {
258
+ if (!ID.test(paymentId)) return { settled: false, reason: "unknown payment" };
259
+ await this.ensure();
260
+ const order = (await this.options.db.query("SELECT id, by_account FROM translation_orders WHERE payment_id = $1", [paymentId])).rows[0];
261
+ if (!order) return { settled: false, reason: "unknown payment" };
262
+ const { status } = await this.check(String(order["by_account"]), String(order["id"]));
263
+ return { settled: status === "paid", status };
264
+ }
212
265
  async check(by: string, id: string): Promise<{ status: string; access: TranslationAccess }> {
213
266
  if (!ID.test(id)) throw new SpeechError("Purchase not found.", 404);
214
267
  await this.ensure();
@@ -80,9 +80,18 @@ declare module "@profullstack/auth-system" {
80
80
 
81
81
  export function createAuthSystem(options?: {
82
82
  adapter?: unknown;
83
- jwtSecret?: string;
84
- accessTokenExpiry?: string | number;
85
- refreshTokenExpiry?: string | number;
83
+ /**
84
+ * Signing for the module's JWTs. The secret goes HERE: the module has no
85
+ * top-level jwtSecret, and one there is silently ignored, leaving tokens
86
+ * signed with its public default ('default-secret-change-me').
87
+ */
88
+ tokenOptions?: {
89
+ secret?: string;
90
+ /** Seconds. */
91
+ accessTokenExpiry?: number;
92
+ /** Seconds. */
93
+ refreshTokenExpiry?: number;
94
+ };
86
95
  /**
87
96
  * Composition rules. Every "require" defaults to true except
88
97
  * requireSpecialChars, so leaving this out is stricter than passing it.
@@ -0,0 +1,6 @@
1
+ {
2
+ "openwebring": "0.1",
3
+ "site": { "url": "https://nixamp.com/", "name": "nixamp" },
4
+ "made_by": "both",
5
+ "rings": [{ "ring": "https://rssamplifier.com/ring/profullstack", "slug": "nixamp-com" }]
6
+ }