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.
- package/README.md +8 -3
- package/dist/accounts.js +9 -1
- package/dist/paywall.js +1 -1
- package/dist/server.d.ts +16 -0
- package/dist/server.js +77 -6
- package/dist/tickets.js +1 -1
- package/dist/translation-passes.d.ts +29 -0
- package/dist/translation-passes.js +59 -1
- package/package.json +3 -2
- package/src/accounts.ts +9 -1
- package/src/paywall.ts +1 -1
- package/src/server.ts +57 -6
- package/src/tickets.ts +1 -1
- package/src/translation-passes.ts +54 -1
- package/src/types/auth-system.d.ts +12 -3
- package/web/dist/.well-known/openwebring.json +6 -0
- package/web/dist/assets/{ar-CR2NEW77.js → ar-CLNm1oA7.js} +1 -1
- package/web/dist/assets/{cs-ivXHTYBK.js → cs-Cptn8RbB.js} +1 -1
- package/web/dist/assets/{da-DQEGAEdk.js → da-CRbNCFOu.js} +1 -1
- package/web/dist/assets/{de-B5VaSv3O.js → de-D4DnC5NQ.js} +1 -1
- package/web/dist/assets/{es-DUTtfGnk.js → es-DAHnD9bs.js} +1 -1
- package/web/dist/assets/{fi-CzQZtpO5.js → fi-Csbl41Vz.js} +1 -1
- package/web/dist/assets/{fr-C2g4DUBr.js → fr-jZYQJed8.js} +1 -1
- package/web/dist/assets/{hi-DKB7ZsZ8.js → hi-DjT9u0v_.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-Bc75Z5iH.js → hls-3VKVEQE3-BjAVnvGY.js} +1 -1
- package/web/dist/assets/{hu--oH7WvAR.js → hu-CVrU8gC4.js} +1 -1
- package/web/dist/assets/{id-CjlpBIJc.js → id-B_gN24X5.js} +1 -1
- package/web/dist/assets/{index-Bo3utcDw.css → index-BueBHHcv.css} +1 -1
- package/web/dist/assets/index-c3uxZS8_.js +3 -0
- package/web/dist/assets/{it-Dbctup0F.js → it-BUC1dMpV.js} +1 -1
- package/web/dist/assets/{mpegts-D9VECdKk.js → mpegts-CnqmXTu2.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CSb7Fz4U.js → mpegts-LO6RVLD6-B4QgG3Xb.js} +1 -1
- package/web/dist/assets/{nl-BYw8ZbV_.js → nl-CGi-aIrz.js} +1 -1
- package/web/dist/assets/{ru-DFGYCrj-.js → ru-YIbS63cK.js} +1 -1
- package/web/dist/assets/{sv-DbO1Sc1v.js → sv-CuJUpOCk.js} +1 -1
- package/web/dist/assets/{uk-BTXHwsjV.js → uk-DLfG8YhV.js} +1 -1
- package/web/dist/assets/{vi-DJ-vIPLS.js → vi-nv2siY2T.js} +1 -1
- package/web/dist/assets/{zh-D3Gju_Y2.js → zh-P5x8w1OW.js} +1 -1
- package/web/dist/index.html +8 -2
- package/web/dist/sw.js +24 -23
- 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 `
|
|
623
|
-
|
|
624
|
-
|
|
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
|
-
|
|
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
|
|
5921
|
-
|
|
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 &&
|
|
6016
|
+
const accounts = options.directory && databaseUrl
|
|
5947
6017
|
? new Accounts({
|
|
5948
|
-
connectionString:
|
|
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:
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 &&
|
|
6428
|
-
? new pg.Pool({ connectionString:
|
|
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 &&
|
|
6505
|
+
options.directory && databaseUrl
|
|
6456
6506
|
? new Accounts({
|
|
6457
|
-
connectionString:
|
|
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:
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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.
|