nixamp 0.41.2 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.d.ts CHANGED
@@ -26,7 +26,7 @@ import { ListenerStore } from "./library-user.ts";
26
26
  import { Importer } from "./handoff.ts";
27
27
  import { AiRadio } from "./ai-radio.ts";
28
28
  import { ListenerShares } from "./listener-shares.ts";
29
- import { Support } from "./support.ts";
29
+ import { Support, SupportSubscriptions } from "./support.ts";
30
30
  import { PeerGuides } from "./station-guide.ts";
31
31
  import { StationRunner } from "./station-runner.ts";
32
32
  import { StationStore, type Station } from "./stations.ts";
@@ -807,6 +807,8 @@ export interface HandlerOptions {
807
807
  translationPasses?: TranslationPasses;
808
808
  /** Payments to copyright holders nixamp.com collects for (support.ts). */
809
809
  support?: Support;
810
+ /** Monthly support, run by nixamp.com (support.ts). */
811
+ supportSubscriptions?: SupportSubscriptions;
810
812
  /** Signing secret of nixamp's CoinPay business, for /api/v1/webhooks/coinpay. */
811
813
  coinpayWebhookSecret?: string;
812
814
  sharedTranslations?: SharedTranslations;
package/dist/server.js CHANGED
@@ -64,7 +64,7 @@ import { handleAiRoutes, isPublicSharePath } from "./ai-routes.js";
64
64
  import { AiRadio } from "./ai-radio.js";
65
65
  import { ListenerShares } from "./listener-shares.js";
66
66
  import { HolderStore, TagReader, factsFor } from "./track-facts.js";
67
- import { SUPPORT_CSP, SUPPORT_PAGE, Support, SupportError, supportHoldersFrom } from "./support.js";
67
+ import { SUPPORT_CSP, SUPPORT_PAGE, Support, SupportError, SupportSubscriptions, supportHoldersFrom } from "./support.js";
68
68
  import { STATION_APP_CSP, STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.js";
69
69
  import { GUIDE_CSP, guideHosts, guidePeers, peerBase, PeerGuides, renderGuide } from "./station-guide.js";
70
70
  import { StationRunner } from "./station-runner.js";
@@ -2164,7 +2164,7 @@ export function createHandler(engine, options) {
2164
2164
  // What is on, as MCP, for anybody.
2165
2165
  path !== "/mcp/public" &&
2166
2166
  // Supporting a copyright holder is for anybody listening.
2167
- !/^\/support\/[a-z0-9-]{1,40}$/.test(path) && !/^\/api\/v1\/support\/[a-z0-9-]{1,40}$/.test(path) &&
2167
+ !/^\/support\/[a-z0-9-]{1,40}$/.test(path) && path !== "/support/cancel" && !/^\/api\/v1\/support\/[a-z0-9-]{1,40}$/.test(path) &&
2168
2168
  // A station's short link, which sends the browser to wherever it plays.
2169
2169
  !/^\/watch\/[a-z0-9]{10}$/.test(path) &&
2170
2170
  !STATION_PAGE.test(path) &&
@@ -2231,6 +2231,22 @@ export function createHandler(engine, options) {
2231
2231
  * in the directory). It sends the browser to the station's own page, on
2232
2232
  * the server that plays it. Nothing private or unlisted is ever found.
2233
2233
  */
2234
+ /* A monthly support's cancel link, from any of its emails. */
2235
+ if (path === "/support/cancel" && options.supportSubscriptions && (request.method === "GET" || request.method === "HEAD")) {
2236
+ let words;
2237
+ try {
2238
+ const done = await options.supportSubscriptions.cancel(url.searchParams.get("s") ?? "", url.searchParams.get("t") ?? "");
2239
+ const name = options.support?.holder(done.holder)?.name ?? done.holder;
2240
+ words = `Your monthly support for ${name} is cancelled. No more payment links will be sent.`;
2241
+ }
2242
+ catch (error) {
2243
+ words = error instanceof SupportError ? error.message : "That did not work; try the link again.";
2244
+ }
2245
+ const safe = words.replace(/[&<>"']/g, (one) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[one]);
2246
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'", "referrer-policy": "no-referrer" });
2247
+ response.end(request.method === "HEAD" ? undefined : `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Support · nixamp</title><body style="margin:0;background:#080c09;color:#e8efe9;font:16px/1.5 system-ui,sans-serif"><main style="max-width:520px;margin:0 auto;padding:48px 16px"><h1 style="font-size:1.4rem">Monthly support</h1><p>${safe}</p><p><a style="color:#3ddc84" href="https://nixamp.com/guide">Back to the stations</a></p></main></body>`);
2248
+ return;
2249
+ }
2234
2250
  /* Support a copyright holder: the page, the coins, and a CoinPay checkout. */
2235
2251
  const supportPage = /^\/support\/([a-z0-9-]{1,40})$/.exec(path);
2236
2252
  if (supportPage && options.support && (request.method === "GET" || request.method === "HEAD")) {
@@ -2269,7 +2285,14 @@ export function createHandler(engine, options) {
2269
2285
  catch {
2270
2286
  body = {};
2271
2287
  }
2272
- json(response, 200, await support.checkout({ ...body, holder: holder.id }));
2288
+ if (body["monthly"] === true) {
2289
+ if (!options.supportSubscriptions)
2290
+ throw new SupportError("Monthly support is not set up here.", 503, "unavailable");
2291
+ json(response, 200, await options.supportSubscriptions.start({ ...body, holder: holder.id }));
2292
+ }
2293
+ else {
2294
+ json(response, 200, await support.checkout({ ...body, holder: holder.id }));
2295
+ }
2273
2296
  return;
2274
2297
  }
2275
2298
  json(response, 405, { error: "GET or POST", code: "method_not_allowed" });
@@ -6752,6 +6775,15 @@ export async function serve(argv, version = "0.1.0") {
6752
6775
  ? new TranslationPasses({ db: pool, key: coinpayKeyFrom(), site: nixampSite }) : undefined;
6753
6776
  // Payments to the copyright holders nixamp.com collects for, on its own CoinPay business.
6754
6777
  const supportPayments = accounts ? new Support({ key: coinpayKeyFrom(), site: nixampSite, holders: supportHoldersFrom() }) : undefined;
6778
+ // Monthly support: each month a new CoinPay payment and its link by email.
6779
+ const supportMonthly = supportPayments && supportPayments.enabled && pool ? new SupportSubscriptions({
6780
+ db: pool,
6781
+ support: supportPayments,
6782
+ mail: process.env["RESEND_API_KEY"] ? { apiKey: process.env["RESEND_API_KEY"], from: process.env["NIXAMP_MAIL_FROM"] ?? "nixamp <notifications@nixamp.com>" } : null,
6783
+ site: nixampSite,
6784
+ log: (line) => console.log(line),
6785
+ }) : undefined;
6786
+ supportMonthly?.startSchedule();
6755
6787
  if (translationPasses)
6756
6788
  void translationPasses.access().catch(() => console.error("nixamp: translation checkout could not warm; it will retry on demand"));
6757
6789
  const liveVoice = accounts && process.env["NIXAMP_DUBBING"] !== "off" ? new LiveVoice({
@@ -7301,6 +7333,7 @@ export async function serve(argv, version = "0.1.0") {
7301
7333
  ...(liveVoice ? { liveVoice } : {}),
7302
7334
  ...(translationPasses ? { translationPasses } : {}),
7303
7335
  ...(supportPayments ? { support: supportPayments } : {}),
7336
+ ...(supportMonthly ? { supportSubscriptions: supportMonthly } : {}),
7304
7337
  ...(process.env["COINPAY_WEBHOOK_SECRET"] ? { coinpayWebhookSecret: process.env["COINPAY_WEBHOOK_SECRET"] } : {}),
7305
7338
  ...(sharedTranslations ? { sharedTranslations } : {}),
7306
7339
  ...(translator ? { translator } : {}),
package/dist/support.d.ts CHANGED
@@ -20,6 +20,8 @@ export declare class Support {
20
20
  fetcher?: typeof fetch;
21
21
  now?: () => number;
22
22
  });
23
+ /** For a subscription's renewals, which ask CoinPay about their payments. */
24
+ get key(): string;
23
25
  holder(id: string): SupportHolder | undefined;
24
26
  get enabled(): boolean;
25
27
  private provider;
@@ -35,9 +37,72 @@ export declare class Support {
35
37
  coin?: unknown;
36
38
  station?: unknown;
37
39
  track?: unknown;
40
+ metadata?: Record<string, unknown>;
38
41
  }): Promise<{
39
42
  url: string;
43
+ paymentId: string;
40
44
  }>;
41
45
  }
42
46
  export declare const SUPPORT_PAGE: string;
43
47
  export declare const SUPPORT_CSP: string;
48
+ /** Postgres, as nixamp.com's pool offers it. */
49
+ export interface Db {
50
+ query: (text: string, params?: unknown[]) => Promise<{
51
+ rows: Array<Record<string, unknown>>;
52
+ }>;
53
+ }
54
+ /** One month on from a time, on the same day where the month has it. */
55
+ export declare function nextMonth(at: number): number;
56
+ /**
57
+ * Monthly support, run here rather than by CoinPay: CoinPay's only merchant
58
+ * subscriptions are Stripe's, and its recurring invoices refuse a scoped key.
59
+ * A subscription is an email, an amount, a coin and a holder. Month one is
60
+ * paid at once on CoinPay's checkout; each month after, a new payment is made
61
+ * and its link emailed, with a one-click cancel. A month left unpaid ends it
62
+ * (lapsed) rather than emailing forever.
63
+ */
64
+ export declare class SupportSubscriptions {
65
+ private readonly options;
66
+ private schema;
67
+ private timer;
68
+ constructor(options: {
69
+ db: Db;
70
+ support: Support;
71
+ /** Resend; null leaves renewals unsent (and says so in the log). */
72
+ mail: {
73
+ apiKey: string;
74
+ from: string;
75
+ } | null;
76
+ site: string;
77
+ fetcher?: typeof fetch;
78
+ now?: () => number;
79
+ log?: (line: string) => void;
80
+ });
81
+ private now;
82
+ private ready;
83
+ /** Month one: a subscription and its first checkout. */
84
+ start(input: {
85
+ holder?: unknown;
86
+ amountUsd?: unknown;
87
+ coin?: unknown;
88
+ email?: unknown;
89
+ station?: unknown;
90
+ track?: unknown;
91
+ }): Promise<{
92
+ url: string;
93
+ id: string;
94
+ }>;
95
+ cancelLink(id: string, token: string): string;
96
+ cancel(id: string, token: string): Promise<{
97
+ canceled: boolean;
98
+ holder: string;
99
+ }>;
100
+ private paid;
101
+ /** Every subscription now due: a new month's payment and its link, or lapsed when last month went unpaid. */
102
+ renew(): Promise<{
103
+ renewed: number;
104
+ lapsed: number;
105
+ }>;
106
+ startSchedule(everyMs?: number): void;
107
+ private mail;
108
+ }
package/dist/support.js CHANGED
@@ -40,6 +40,10 @@ export class Support {
40
40
  constructor(options) {
41
41
  this.options = options;
42
42
  }
43
+ /** For a subscription's renewals, which ask CoinPay about their payments. */
44
+ get key() {
45
+ return this.options.key;
46
+ }
43
47
  holder(id) {
44
48
  return this.options.holders.find((one) => one.id === id.toLowerCase());
45
49
  }
@@ -100,7 +104,7 @@ export class Support {
100
104
  currency: coin.toLowerCase(),
101
105
  payment_method: "crypto",
102
106
  description: `Support ${holder.name} on nixamp`,
103
- metadata: { app: "nixamp", product: "support", holder: holder.id, order, station: clean(input.station), track: clean(input.track) },
107
+ metadata: { app: "nixamp", product: "support", holder: holder.id, order, station: clean(input.station), track: clean(input.track), ...(input.metadata ?? {}) },
104
108
  redirect_url: `${this.options.site}/support/${holder.id}?thanks=1`,
105
109
  }),
106
110
  });
@@ -108,7 +112,7 @@ export class Support {
108
112
  const id = typeof payment?.id === "string" ? payment.id : "";
109
113
  if (!/^[A-Za-z0-9_-]{6,80}$/.test(id))
110
114
  throw new SupportError("The checkout did not start; nothing was charged. Try again.", 502, "provider");
111
- return { url: `https://coinpayportal.com/pay/${id}` };
115
+ return { url: `https://coinpayportal.com/pay/${id}`, paymentId: id };
112
116
  }
113
117
  }
114
118
  const STYLE = `
@@ -126,6 +130,8 @@ button.go{margin-top:18px;width:100%;padding:12px;border:0;border-radius:999px;b
126
130
  button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
127
131
  #status{min-height:1.4em;margin-top:12px;color:var(--muted)}
128
132
  .thanks{color:var(--accent);font-weight:700}
133
+ .often{display:flex;gap:8px;margin:0 0 14px}.often label{flex:1;margin:0;display:flex;gap:8px;align-items:center;justify-content:center;padding:10px;border:1px solid var(--line);border-radius:10px;color:var(--fg);cursor:pointer}
134
+ .often input{width:auto}
129
135
  small{display:block;margin-top:18px;color:var(--muted)}small a{color:inherit}
130
136
  `;
131
137
  const SCRIPT = `
@@ -141,6 +147,11 @@ const SCRIPT = `
141
147
  for (const o of document.querySelectorAll(".amounts button")) o.setAttribute("aria-pressed", String(o === b));
142
148
  });
143
149
  }
150
+ const monthly = () => document.querySelector("input[name=often]:checked").value === "monthly";
151
+ for (const r of document.querySelectorAll("input[name=often]")) r.addEventListener("change", () => {
152
+ $("#email-box").hidden = !monthly();
153
+ $("#go").textContent = monthly() ? "Pay the first month" : "Continue to payment";
154
+ });
144
155
  if (params.get("thanks")) { $("#status").className = "thanks"; $("#status").textContent = "Thank you. Your payment is on its way."; }
145
156
  try {
146
157
  const info = await (await fetch("/api/v1/support/" + encodeURIComponent(holder), { cache: "no-store" })).json();
@@ -157,7 +168,8 @@ const SCRIPT = `
157
168
  $("#status").textContent = "Starting the checkout…";
158
169
  try {
159
170
  const r = await fetch("/api/v1/support/" + encodeURIComponent(holder), { method: "POST", headers: { "content-type": "application/json" },
160
- body: JSON.stringify({ amountUsd: usd, coin: $("#coin").value, station: params.get("station") || "", track: params.get("track") || "" }) });
171
+ body: JSON.stringify({ amountUsd: usd, coin: $("#coin").value, station: params.get("station") || "", track: params.get("track") || "",
172
+ ...(monthly() ? { monthly: true, email: $("#email").value } : {}) }) });
161
173
  const body = await r.json();
162
174
  if (r.ok && body.url) { location.href = body.url; return; }
163
175
  $("#status").textContent = body.error || "That did not work.";
@@ -180,17 +192,22 @@ export const SUPPORT_PAGE = `<!doctype html>
180
192
  <main>
181
193
  <div class="card">
182
194
  <h1>Support <span id="who">the artist</span></h1>
183
- <p>Pay the copyright holder of the music you are listening to, directly. One payment, in crypto, through CoinPay.</p>
195
+ <p>Pay the copyright holder of the music you are listening to, directly, in crypto through CoinPay. Once, or every month.</p>
184
196
  <form id="form">
197
+ <div class="often"><label><input type="radio" name="often" value="once" checked> One time</label><label><input type="radio" name="often" value="monthly"> Monthly</label></div>
185
198
  <div class="amounts">${AMOUNTS.map((usd, at) => `<button type="button" data-usd="${usd}" aria-pressed="${at === 1}">$${usd}</button>`).join("")}</div>
186
199
  <label for="other">Or another amount (US$)</label>
187
200
  <input id="other" type="number" min="1" max="1000" step="1" inputmode="numeric">
201
+ <div id="email-box" hidden>
202
+ <label for="email">Email, for each month's payment link (and the cancel link)</label>
203
+ <input id="email" type="email" autocomplete="email" maxlength="254">
204
+ </div>
188
205
  <label for="coin">Pay with</label>
189
206
  <select id="coin"></select>
190
207
  <button id="go" class="go" type="submit">Continue to payment</button>
191
208
  <div id="status" role="status" aria-live="polite"></div>
192
209
  </form>
193
- <small>Payments go through <a href="https://coinpayportal.com">CoinPay</a> to nixamp.com, which pays the holder. Nothing recurs.</small>
210
+ <small>Payments go through <a href="https://coinpayportal.com">CoinPay</a> to nixamp.com, which pays the holder. Monthly: each month we email a new payment link; nothing is taken until you pay it, and every email has a one-click cancel.</small>
194
211
  </div>
195
212
  </main>
196
213
  <script>${SCRIPT}</script>
@@ -201,3 +218,168 @@ export const SUPPORT_CSP = [
201
218
  "default-src 'none'", `script-src ${hash(SCRIPT)}`, `style-src ${hash(STYLE)}`, "connect-src 'self'",
202
219
  "img-src 'self' data:", "base-uri 'none'", "form-action 'none'", "frame-ancestors 'none'",
203
220
  ].join("; ");
221
+ /** What CoinPay calls a payment that has been made. */
222
+ const PAID = new Set(["confirmed", "forwarding", "forwarded", "forwarding_failed", "completed"]);
223
+ const EMAIL = /^[^\s@<>"]{1,64}@[^\s@<>"]{1,190}\.[a-z]{2,24}$/i;
224
+ /** One month on from a time, on the same day where the month has it. */
225
+ export function nextMonth(at) {
226
+ const date = new Date(at);
227
+ const day = date.getUTCDate();
228
+ date.setUTCDate(1);
229
+ date.setUTCMonth(date.getUTCMonth() + 1);
230
+ const last = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0)).getUTCDate();
231
+ date.setUTCDate(Math.min(day, last));
232
+ return date.getTime();
233
+ }
234
+ /**
235
+ * Monthly support, run here rather than by CoinPay: CoinPay's only merchant
236
+ * subscriptions are Stripe's, and its recurring invoices refuse a scoped key.
237
+ * A subscription is an email, an amount, a coin and a holder. Month one is
238
+ * paid at once on CoinPay's checkout; each month after, a new payment is made
239
+ * and its link emailed, with a one-click cancel. A month left unpaid ends it
240
+ * (lapsed) rather than emailing forever.
241
+ */
242
+ export class SupportSubscriptions {
243
+ options;
244
+ schema = null;
245
+ timer = null;
246
+ constructor(options) {
247
+ this.options = options;
248
+ }
249
+ now() {
250
+ return this.options.now?.() ?? Date.now();
251
+ }
252
+ ready() {
253
+ this.schema ??= (async () => {
254
+ await this.options.db.query(`CREATE TABLE IF NOT EXISTS support_subscriptions (
255
+ id TEXT PRIMARY KEY, token TEXT NOT NULL, holder TEXT NOT NULL, email TEXT NOT NULL,
256
+ amount_cents INTEGER NOT NULL, coin TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active',
257
+ cycles INTEGER NOT NULL DEFAULT 0, last_payment_id TEXT, next_due TIMESTAMPTZ NOT NULL,
258
+ station TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL, canceled_at TIMESTAMPTZ)`);
259
+ await this.options.db.query("CREATE INDEX IF NOT EXISTS support_subscriptions_due ON support_subscriptions (status, next_due)");
260
+ })().catch((error) => { this.schema = null; throw error; });
261
+ return this.schema;
262
+ }
263
+ /** Month one: a subscription and its first checkout. */
264
+ async start(input) {
265
+ const email = typeof input.email === "string" ? input.email.trim().toLowerCase() : "";
266
+ if (!EMAIL.test(email))
267
+ throw new SupportError("Give an email for the monthly payment links.", 400, "invalid_email");
268
+ const holder = typeof input.holder === "string" ? this.options.support.holder(input.holder) : undefined;
269
+ if (!holder)
270
+ throw new SupportError("Nobody by that name is supported here.", 404, "not_found");
271
+ await this.ready();
272
+ const live = await this.options.db.query("SELECT count(*)::int AS n FROM support_subscriptions WHERE email = $1 AND status = 'active'", [email]);
273
+ if (Number(live.rows[0]?.["n"] ?? 0) >= 10)
274
+ throw new SupportError("That email already has ten monthly supports.", 409, "too_many");
275
+ const id = randomUUID();
276
+ const token = randomUUID().replace(/-/g, "");
277
+ const checkout = await this.options.support.checkout({ ...input, holder: holder.id, metadata: { subscription: id, cycle: 1 } });
278
+ const now = this.now();
279
+ await this.options.db.query(`INSERT INTO support_subscriptions (id, token, holder, email, amount_cents, coin, cycles, last_payment_id, next_due, station, created_at)
280
+ VALUES ($1,$2,$3,$4,$5,$6,1,$7,$8,$9,$10)`, [id, token, holder.id, email, Math.round(Number(input.amountUsd) * 100), String(input.coin).toUpperCase(), checkout.paymentId, new Date(nextMonth(now)), typeof input.station === "string" ? input.station.slice(0, 20) : "", new Date(now)]);
281
+ await this.mail(email, `Your monthly support for ${holder.name}`, [
282
+ `Thank you for supporting ${holder.name} on nixamp, $${Number(input.amountUsd).toFixed(2)} a month in ${String(input.coin).toUpperCase()}.`,
283
+ `This month: ${checkout.url}`,
284
+ `Each month we email you a new payment link like this one. Nothing is taken without you paying it.`,
285
+ `Stop at any time: ${this.cancelLink(id, token)}`,
286
+ ]);
287
+ return { url: checkout.url, id };
288
+ }
289
+ cancelLink(id, token) {
290
+ return `${this.options.site}/support/cancel?s=${encodeURIComponent(id)}&t=${encodeURIComponent(token)}`;
291
+ }
292
+ async cancel(id, token) {
293
+ await this.ready();
294
+ const found = await this.options.db.query("UPDATE support_subscriptions SET status = 'canceled', canceled_at = $3 WHERE id = $1 AND token = $2 AND status <> 'canceled' RETURNING holder", [id, token, new Date(this.now())]);
295
+ const row = found.rows[0];
296
+ if (!row) {
297
+ const known = await this.options.db.query("SELECT holder FROM support_subscriptions WHERE id = $1 AND token = $2", [id, token]);
298
+ if (!known.rows[0])
299
+ throw new SupportError("That cancel link is not one of ours.", 404, "not_found");
300
+ return { canceled: true, holder: String(known.rows[0]["holder"]) };
301
+ }
302
+ return { canceled: true, holder: String(row["holder"]) };
303
+ }
304
+ async paid(paymentId) {
305
+ try {
306
+ const response = await (this.options.fetcher ?? fetch)(`https://coinpayportal.com/api/payments/${encodeURIComponent(paymentId)}`, {
307
+ signal: AbortSignal.timeout(15_000),
308
+ headers: { authorization: `Bearer ${this.options.support.key}` },
309
+ });
310
+ if (!response.ok)
311
+ return null;
312
+ const body = (await response.json());
313
+ return PAID.has(String(body.payment?.status ?? ""));
314
+ }
315
+ catch {
316
+ return null;
317
+ }
318
+ }
319
+ /** Every subscription now due: a new month's payment and its link, or lapsed when last month went unpaid. */
320
+ async renew() {
321
+ await this.ready();
322
+ const due = await this.options.db.query("SELECT * FROM support_subscriptions WHERE status = 'active' AND next_due <= $1 ORDER BY next_due LIMIT 200", [new Date(this.now())]);
323
+ let renewed = 0;
324
+ let lapsed = 0;
325
+ for (const row of due.rows) {
326
+ const id = String(row["id"]);
327
+ const last = String(row["last_payment_id"] ?? "");
328
+ const paid = last ? await this.paid(last) : false;
329
+ if (paid === null)
330
+ continue; // CoinPay did not answer: ask again next time.
331
+ const holder = this.options.support.holder(String(row["holder"]));
332
+ if (!paid || !holder) {
333
+ await this.options.db.query("UPDATE support_subscriptions SET status = 'lapsed' WHERE id = $1 AND status = 'active'", [id]);
334
+ lapsed += 1;
335
+ if (holder)
336
+ await this.mail(String(row["email"]), `Your monthly support for ${holder.name} has stopped`, [
337
+ `Last month's payment was not made, so we have stopped sending payment links. Nothing more will be asked.`,
338
+ `Start again any time: ${this.options.site}/support/${holder.id}`,
339
+ ]);
340
+ continue;
341
+ }
342
+ const amountUsd = Number(row["amount_cents"]) / 100;
343
+ const cycle = Number(row["cycles"]) + 1;
344
+ let checkout;
345
+ try {
346
+ checkout = await this.options.support.checkout({ holder: holder.id, amountUsd, coin: String(row["coin"]), station: String(row["station"] ?? ""), metadata: { subscription: id, cycle } });
347
+ }
348
+ catch (error) {
349
+ this.options.log?.(` support: renewal ${id} could not start a payment: ${error.message}`);
350
+ continue;
351
+ }
352
+ await this.options.db.query("UPDATE support_subscriptions SET cycles = $2, last_payment_id = $3, next_due = $4 WHERE id = $1", [id, cycle, checkout.paymentId, new Date(nextMonth(new Date(String(row["next_due"])).getTime()))]);
353
+ await this.mail(String(row["email"]), `This month's support for ${holder.name}`, [
354
+ `Your monthly support for ${holder.name}: $${amountUsd.toFixed(2)} in ${String(row["coin"])}.`,
355
+ `Pay this month: ${checkout.url}`,
356
+ `Stop at any time: ${this.cancelLink(id, String(row["token"]))}`,
357
+ ]);
358
+ renewed += 1;
359
+ }
360
+ return { renewed, lapsed };
361
+ }
362
+ startSchedule(everyMs = 3600_000) {
363
+ if (this.timer)
364
+ return;
365
+ this.timer = setInterval(() => void this.renew().catch((error) => this.options.log?.(` support: renewals failed: ${error.message}`)), everyMs);
366
+ this.timer.unref?.();
367
+ }
368
+ async mail(to, subject, lines) {
369
+ if (!this.options.mail) {
370
+ this.options.log?.(` support: no RESEND_API_KEY, so "${subject}" to ${to} was not sent`);
371
+ return;
372
+ }
373
+ try {
374
+ await (this.options.fetcher ?? fetch)("https://api.resend.com/emails", {
375
+ method: "POST",
376
+ signal: AbortSignal.timeout(15_000),
377
+ headers: { authorization: `Bearer ${this.options.mail.apiKey}`, "content-type": "application/json" },
378
+ body: JSON.stringify({ from: this.options.mail.from, to: [to], subject, text: `${lines.join("\n\n")}\n\nnixamp.com` }),
379
+ });
380
+ }
381
+ catch (error) {
382
+ this.options.log?.(` support: mail to ${to} failed: ${error.message}`);
383
+ }
384
+ }
385
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.41.2",
3
+ "version": "0.42.0",
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",
package/src/server.ts CHANGED
@@ -81,7 +81,7 @@ import { handleAiRoutes, isPublicSharePath } from "./ai-routes.ts";
81
81
  import { AiRadio } from "./ai-radio.ts";
82
82
  import { ListenerShares } from "./listener-shares.ts";
83
83
  import { HolderStore, TagReader, factsFor } from "./track-facts.ts";
84
- import { SUPPORT_CSP, SUPPORT_PAGE, Support, SupportError, supportHoldersFrom } from "./support.ts";
84
+ import { SUPPORT_CSP, SUPPORT_PAGE, Support, SupportError, SupportSubscriptions, supportHoldersFrom } from "./support.ts";
85
85
  import { STATION_APP_CSP, STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.ts";
86
86
  import { GUIDE_CSP, guideHosts, guidePeers, peerBase, PeerGuides, renderGuide, type GuideEntry } from "./station-guide.ts";
87
87
  import { StationRunner } from "./station-runner.ts";
@@ -1924,6 +1924,8 @@ export interface HandlerOptions {
1924
1924
  translationPasses?: TranslationPasses;
1925
1925
  /** Payments to copyright holders nixamp.com collects for (support.ts). */
1926
1926
  support?: Support;
1927
+ /** Monthly support, run by nixamp.com (support.ts). */
1928
+ supportSubscriptions?: SupportSubscriptions;
1927
1929
  /** Signing secret of nixamp's CoinPay business, for /api/v1/webhooks/coinpay. */
1928
1930
  coinpayWebhookSecret?: string;
1929
1931
  sharedTranslations?: SharedTranslations;
@@ -2698,7 +2700,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2698
2700
  // What is on, as MCP, for anybody.
2699
2701
  path !== "/mcp/public" &&
2700
2702
  // Supporting a copyright holder is for anybody listening.
2701
- !/^\/support\/[a-z0-9-]{1,40}$/.test(path) && !/^\/api\/v1\/support\/[a-z0-9-]{1,40}$/.test(path) &&
2703
+ !/^\/support\/[a-z0-9-]{1,40}$/.test(path) && path !== "/support/cancel" && !/^\/api\/v1\/support\/[a-z0-9-]{1,40}$/.test(path) &&
2702
2704
  // A station's short link, which sends the browser to wherever it plays.
2703
2705
  !/^\/watch\/[a-z0-9]{10}$/.test(path) &&
2704
2706
  !STATION_PAGE.test(path) &&
@@ -2765,6 +2767,22 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2765
2767
  * in the directory). It sends the browser to the station's own page, on
2766
2768
  * the server that plays it. Nothing private or unlisted is ever found.
2767
2769
  */
2770
+ /* A monthly support's cancel link, from any of its emails. */
2771
+ if (path === "/support/cancel" && options.supportSubscriptions && (request.method === "GET" || request.method === "HEAD")) {
2772
+ let words: string;
2773
+ try {
2774
+ const done = await options.supportSubscriptions.cancel(url.searchParams.get("s") ?? "", url.searchParams.get("t") ?? "");
2775
+ const name = options.support?.holder(done.holder)?.name ?? done.holder;
2776
+ words = `Your monthly support for ${name} is cancelled. No more payment links will be sent.`;
2777
+ } catch (error) {
2778
+ words = error instanceof SupportError ? error.message : "That did not work; try the link again.";
2779
+ }
2780
+ const safe = words.replace(/[&<>"']/g, (one) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[one]!);
2781
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'", "referrer-policy": "no-referrer" });
2782
+ response.end(request.method === "HEAD" ? undefined : `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Support · nixamp</title><body style="margin:0;background:#080c09;color:#e8efe9;font:16px/1.5 system-ui,sans-serif"><main style="max-width:520px;margin:0 auto;padding:48px 16px"><h1 style="font-size:1.4rem">Monthly support</h1><p>${safe}</p><p><a style="color:#3ddc84" href="https://nixamp.com/guide">Back to the stations</a></p></main></body>`);
2783
+ return;
2784
+ }
2785
+
2768
2786
  /* Support a copyright holder: the page, the coins, and a CoinPay checkout. */
2769
2787
  const supportPage = /^\/support\/([a-z0-9-]{1,40})$/.exec(path);
2770
2788
  if (supportPage && options.support && (request.method === "GET" || request.method === "HEAD")) {
@@ -2798,7 +2816,12 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2798
2816
  }
2799
2817
  let body: Record<string, unknown> = {};
2800
2818
  try { body = JSON.parse(await readBody(request)) as Record<string, unknown>; } catch { body = {}; }
2801
- json(response, 200, await support.checkout({ ...body, holder: holder.id }));
2819
+ if (body["monthly"] === true) {
2820
+ if (!options.supportSubscriptions) throw new SupportError("Monthly support is not set up here.", 503, "unavailable");
2821
+ json(response, 200, await options.supportSubscriptions.start({ ...body, holder: holder.id }));
2822
+ } else {
2823
+ json(response, 200, await support.checkout({ ...body, holder: holder.id }));
2824
+ }
2802
2825
  return;
2803
2826
  }
2804
2827
  json(response, 405, { error: "GET or POST", code: "method_not_allowed" });
@@ -7234,6 +7257,15 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7234
7257
  ? new TranslationPasses({ db: pool, key: coinpayKeyFrom(), site: nixampSite }) : undefined;
7235
7258
  // Payments to the copyright holders nixamp.com collects for, on its own CoinPay business.
7236
7259
  const supportPayments = accounts ? new Support({ key: coinpayKeyFrom(), site: nixampSite, holders: supportHoldersFrom() }) : undefined;
7260
+ // Monthly support: each month a new CoinPay payment and its link by email.
7261
+ const supportMonthly = supportPayments && supportPayments.enabled && pool ? new SupportSubscriptions({
7262
+ db: pool as unknown as import("./support.ts").Db,
7263
+ support: supportPayments,
7264
+ mail: process.env["RESEND_API_KEY"] ? { apiKey: process.env["RESEND_API_KEY"], from: process.env["NIXAMP_MAIL_FROM"] ?? "nixamp <notifications@nixamp.com>" } : null,
7265
+ site: nixampSite,
7266
+ log: (line) => console.log(line),
7267
+ }) : undefined;
7268
+ supportMonthly?.startSchedule();
7237
7269
  if (translationPasses) void translationPasses.access().catch(() => console.error("nixamp: translation checkout could not warm; it will retry on demand"));
7238
7270
  const liveVoice = accounts && process.env["NIXAMP_DUBBING"] !== "off" ? new LiveVoice({
7239
7271
  ...(pool ? { db: pool } : {}),
@@ -7787,6 +7819,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7787
7819
  ...(liveVoice ? { liveVoice } : {}),
7788
7820
  ...(translationPasses ? { translationPasses } : {}),
7789
7821
  ...(supportPayments ? { support: supportPayments } : {}),
7822
+ ...(supportMonthly ? { supportSubscriptions: supportMonthly } : {}),
7790
7823
  ...(process.env["COINPAY_WEBHOOK_SECRET"] ? { coinpayWebhookSecret: process.env["COINPAY_WEBHOOK_SECRET"] } : {}),
7791
7824
  ...(sharedTranslations ? { sharedTranslations } : {}),
7792
7825
  ...(translator ? { translator } : {}),