nixamp 0.61.2 → 0.62.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.
Files changed (33) hide show
  1. package/dist/license-routes.d.ts +17 -0
  2. package/dist/license-routes.js +241 -0
  3. package/dist/music-license.d.ts +120 -0
  4. package/dist/music-license.js +266 -0
  5. package/dist/server.d.ts +3 -0
  6. package/dist/server.js +30 -0
  7. package/package.json +1 -1
  8. package/src/license-routes.ts +258 -0
  9. package/src/music-license.ts +349 -0
  10. package/src/server.ts +29 -0
  11. package/web/dist/assets/{ar-Dgo0iX1D.js → ar-Eigh2EY7.js} +1 -1
  12. package/web/dist/assets/{cs-DDQvQqty.js → cs-BiYj6suH.js} +1 -1
  13. package/web/dist/assets/{da-C1ubh53C.js → da-Bbv8v9LC.js} +1 -1
  14. package/web/dist/assets/{de-DM_PDjWE.js → de-Ct4J4O2-.js} +1 -1
  15. package/web/dist/assets/{es-BGiTH41z.js → es-C1QHkBcK.js} +1 -1
  16. package/web/dist/assets/{fi-BdYHWSPJ.js → fi-BL22uj7L.js} +1 -1
  17. package/web/dist/assets/{fr-C9kqyO2x.js → fr-CbHGddiD.js} +1 -1
  18. package/web/dist/assets/{hi-Abk3EgL1.js → hi-CEnEoHJB.js} +1 -1
  19. package/web/dist/assets/{hu-Ow3FzX6j.js → hu-V6N4YyNP.js} +1 -1
  20. package/web/dist/assets/{id-DNYfCwko.js → id-Dlur-A5Q.js} +1 -1
  21. package/web/dist/assets/{it-0apOyRTj.js → it-CtiClrIo.js} +1 -1
  22. package/web/dist/assets/{main-Do7j8dp8.js → main-CFJDb9Yd.js} +2 -2
  23. package/web/dist/assets/music-gaYfGADg.js +1 -0
  24. package/web/dist/assets/{nl-O0sI0Z6j.js → nl-Crjz8b-j.js} +1 -1
  25. package/web/dist/assets/{ru-C00x-ym1.js → ru-CrwKGInd.js} +1 -1
  26. package/web/dist/assets/{sv-BKcDMn31.js → sv-DG2OJzJH.js} +1 -1
  27. package/web/dist/assets/{uk-B9vZgCcr.js → uk-BteiNgtQ.js} +1 -1
  28. package/web/dist/assets/{vi-DDtOW9Ee.js → vi-CSTApJSE.js} +1 -1
  29. package/web/dist/assets/{zh-Bme07rh0.js → zh-CTag10X-.js} +1 -1
  30. package/web/dist/index.html +1 -1
  31. package/web/dist/music.html +1 -1
  32. package/web/dist/sw.js +20 -20
  33. package/web/dist/assets/music-G79tkjuo.js +0 -1
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Licences to use the AI label's music in somebody's own work: a game, an
3
+ * app, a video, a podcast. A download (music-store.ts) is for listening; a
4
+ * licence is permission to put the music in a project and publish it.
5
+ *
6
+ * Creator a song, $5 online videos, streams, podcasts, social posts, monetized or not
7
+ * Indie a song, $25 all of Creator, plus games, apps, sites, software and films,
8
+ * an album, $100 for a licensee under US$100,000 of revenue a year
9
+ * Commercial a song, $250 any use in the licensee's own products and ads, any size
10
+ * Catalog $29 a year Indie, for every artist the label releases, unlimited projects
11
+ *
12
+ * Every licence is worldwide, non-exclusive and for good (the catalogue pass:
13
+ * for every project published while it is active), and every use credits the
14
+ * artist with a link to their page. The terms are TERMS below (license-routes.ts
15
+ * serves them at /license/terms); each licence records the version it was sold
16
+ * under. Paid through nixamp.com's CoinPay business like the store.
17
+ */
18
+ import { randomBytes, randomUUID } from "node:crypto";
19
+ import { SupportError } from "./support.js";
20
+ /** In cents, by tier and what is licensed; a missing pair is not sold. */
21
+ export const LICENSE_PRICES = {
22
+ creator: { song: 500 },
23
+ indie: { song: 2500, album: 10000 },
24
+ commercial: { song: 25000 },
25
+ catalog: { catalog: 2900 },
26
+ };
27
+ export const TIER_NAMES = { creator: "Creator", indie: "Indie", commercial: "Commercial", catalog: "Catalog pass" };
28
+ /** What each tier lets a licensee do, in a line. */
29
+ export const TIER_USE = {
30
+ creator: "Online videos, livestreams, podcasts and social posts, monetized or not. Not games, apps, ads or broadcast.",
31
+ indie: "Everything in Creator, plus games, apps, websites, software and films, for a licensee earning under US$100,000 a year.",
32
+ commercial: "Any use in your own products, games, apps, films and advertising, broadcast included, at any size.",
33
+ catalog: "The Indie licence for every song by every artist the label releases, for any number of projects published while the pass is active.",
34
+ };
35
+ export const TERMS_VERSION = "1";
36
+ const YEAR_MS = 365 * 24 * 3600_000;
37
+ const EMAIL = /^[^\s@<>"]{1,64}@[^\s@<>"]{1,190}\.[a-z]{2,24}$/i;
38
+ const CODE = /^NXL-[A-Z2-9]{4}-[A-Z2-9]{4}$/;
39
+ /** A licence code: unambiguous letters and digits, for reading out or typing in. */
40
+ function newCode() {
41
+ const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
42
+ const bytes = randomBytes(8);
43
+ const chars = [...bytes].map((one) => alphabet[one % alphabet.length]).join("");
44
+ return `NXL-${chars.slice(0, 4)}-${chars.slice(4)}`;
45
+ }
46
+ function clean(value, max) {
47
+ return typeof value === "string" ? value.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim().slice(0, max) : "";
48
+ }
49
+ function httpsUrl(value) {
50
+ const text = clean(value, 300);
51
+ if (!text)
52
+ return "";
53
+ try {
54
+ const url = new URL(text);
55
+ return url.protocol === "https:" || url.protocol === "http:" ? url.toString() : "";
56
+ }
57
+ catch {
58
+ return "";
59
+ }
60
+ }
61
+ function row(one) {
62
+ const time = (value) => (value ? new Date(String(value)).getTime() : 0);
63
+ return {
64
+ id: String(one["id"]), code: String(one["code"]), token: String(one["token"]), email: String(one["email"]), userId: String(one["user_id"] ?? ""),
65
+ licensee: String(one["licensee"]), project: String(one["project"]), projectUrl: String(one["project_url"] ?? ""),
66
+ tier: String(one["tier"]), kind: String(one["kind"]), server: String(one["server"] ?? ""), artist: String(one["artist"] ?? ""),
67
+ album: String(one["album"] ?? ""), n: Number(one["n"] ?? 0), title: String(one["title"]), artistName: String(one["artist_name"]), creditUrl: String(one["credit_url"]),
68
+ amountCents: Number(one["amount_cents"]), coin: String(one["coin"]), paymentId: String(one["payment_id"] ?? ""),
69
+ status: one["status"] === "paid" ? "paid" : "pending", terms: String(one["terms"]), createdAt: time(one["created_at"]), paidAt: time(one["paid_at"]), expiresAt: time(one["expires_at"]),
70
+ };
71
+ }
72
+ export class MusicLicenses {
73
+ options;
74
+ schema = null;
75
+ constructor(options) {
76
+ this.options = options;
77
+ }
78
+ now() {
79
+ return this.options.now?.() ?? Date.now();
80
+ }
81
+ get holder() {
82
+ return this.options.holder ?? "Profullstack, Inc.";
83
+ }
84
+ ready() {
85
+ this.schema ??= (async () => {
86
+ await this.options.db.query(`CREATE TABLE IF NOT EXISTS music_licenses (
87
+ id TEXT PRIMARY KEY, code TEXT NOT NULL UNIQUE, token TEXT NOT NULL, email TEXT NOT NULL, user_id TEXT NOT NULL DEFAULT '',
88
+ licensee TEXT NOT NULL, project TEXT NOT NULL, project_url TEXT NOT NULL DEFAULT '',
89
+ tier TEXT NOT NULL, kind TEXT NOT NULL, server TEXT NOT NULL DEFAULT '', artist TEXT NOT NULL DEFAULT '', album TEXT NOT NULL DEFAULT '', n INTEGER NOT NULL DEFAULT 0,
90
+ title TEXT NOT NULL, artist_name TEXT NOT NULL, credit_url TEXT NOT NULL, amount_cents INTEGER NOT NULL, coin TEXT NOT NULL,
91
+ payment_id TEXT, status TEXT NOT NULL DEFAULT 'pending', terms TEXT NOT NULL,
92
+ created_at TIMESTAMPTZ NOT NULL, paid_at TIMESTAMPTZ, expires_at TIMESTAMPTZ)`);
93
+ await this.options.db.query("CREATE INDEX IF NOT EXISTS music_licenses_email ON music_licenses (lower(email))");
94
+ await this.options.db.query("CREATE INDEX IF NOT EXISTS music_licenses_user ON music_licenses (user_id)");
95
+ await this.options.db.query("CREATE INDEX IF NOT EXISTS music_licenses_payment ON music_licenses (payment_id)");
96
+ })().catch((error) => { this.schema = null; throw error; });
97
+ return this.schema;
98
+ }
99
+ /** What would be licensed and for how much; refused when the pair is not sold or the music is not there. */
100
+ async quote(input) {
101
+ const tier = String(input["tier"] ?? (input["kind"] === "catalog" ? "catalog" : "indie"));
102
+ const kind = (tier === "catalog" ? "catalog" : String(input["kind"] ?? ""));
103
+ const cents = LICENSE_PRICES[tier]?.[kind];
104
+ if (!cents)
105
+ throw new SupportError("That licence is not sold: Creator and Commercial are per song, Indie per song or album, the Catalog pass covers everything.", 400, "invalid_tier");
106
+ if (kind === "catalog") {
107
+ return { tier, kind, server: "", artist: "", album: "", n: 0, title: `Every artist released by ${this.holder}`, artistName: this.holder, creditUrl: `${this.options.site}/music`, amountCents: cents };
108
+ }
109
+ const item = await this.options.store.describe({ ...input, kind });
110
+ return {
111
+ tier, kind, server: item.server, artist: item.artist, album: item.album, n: item.n, title: item.title, artistName: item.artistName,
112
+ creditUrl: `${this.options.site}/music/artist/${this.options.serverId(item.server)}/${item.artist}`, amountCents: cents,
113
+ };
114
+ }
115
+ /** A licence waiting for its payment, and where to pay. */
116
+ async checkout(input, account) {
117
+ if (input["agree"] !== true)
118
+ throw new SupportError("Agree to the licence terms first.", 400, "terms_required");
119
+ const email = (clean(input["email"], 254) || account?.email || "").toLowerCase();
120
+ if (!EMAIL.test(email))
121
+ throw new SupportError("Give the email the licence should go to.", 400, "invalid_email");
122
+ const licensee = clean(input["licensee"], 120);
123
+ if (licensee.length < 2)
124
+ throw new SupportError("Say who the licence is for: your name or your company's.", 400, "licensee_required");
125
+ const project = clean(input["project"], 160);
126
+ if (project.length < 2)
127
+ throw new SupportError("Name the project the music goes in (the catalogue pass: your studio or channel).", 400, "project_required");
128
+ const quote = await this.quote(input);
129
+ await this.ready();
130
+ const id = randomUUID();
131
+ const token = randomUUID().replace(/-/g, "");
132
+ const code = newCode();
133
+ const checkout = await this.options.support.pay({
134
+ amountUsd: quote.amountCents / 100,
135
+ coin: input["coin"],
136
+ order: `license-${id}`,
137
+ description: `${TIER_NAMES[quote.tier]} licence: ${quote.title}${quote.kind === "catalog" ? ", one year" : ` by ${quote.artistName}`} for ${project}`,
138
+ metadata: { product: "music-license", license: id, tier: quote.tier, kind: quote.kind },
139
+ redirect: this.link(id, token),
140
+ });
141
+ await this.options.db.query(`INSERT INTO music_licenses (id, code, token, email, user_id, licensee, project, project_url, tier, kind, server, artist, album, n,
142
+ title, artist_name, credit_url, amount_cents, coin, payment_id, terms, created_at)
143
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22)`, [id, code, token, email, account?.id ?? "", licensee, project, httpsUrl(input["projectUrl"]), quote.tier, quote.kind, quote.server, quote.artist, quote.album, quote.n,
144
+ quote.title, quote.artistName, quote.creditUrl, quote.amountCents, String(input["coin"]).toUpperCase(), checkout.paymentId, TERMS_VERSION, new Date(this.now())]);
145
+ return { url: checkout.url, id, token, code };
146
+ }
147
+ /** The licensee's own page: the certificate once paid. */
148
+ link(id, token) {
149
+ return `${this.options.site}/licenses/${id}?t=${token}`;
150
+ }
151
+ /** Anybody's check of a licence by its code: a client asking whether a game's music is licensed. */
152
+ verifyUrl(code) {
153
+ return `${this.options.site}/license/${code}`;
154
+ }
155
+ async get(id, token) {
156
+ await this.ready();
157
+ const found = (await this.options.db.query("SELECT * FROM music_licenses WHERE id = $1 AND token = $2", [id, token])).rows[0];
158
+ if (!found)
159
+ return null;
160
+ const license = row(found);
161
+ if (license.status === "pending" && license.paymentId && (await this.options.support.isPaid(license.paymentId)) === true)
162
+ return (await this.paid(license.id)) ?? license;
163
+ return license;
164
+ }
165
+ /** A paid licence by its code, for the public check; a pending one is not anybody's business. */
166
+ async byCode(code) {
167
+ if (!CODE.test(code))
168
+ return null;
169
+ await this.ready();
170
+ const found = (await this.options.db.query("SELECT * FROM music_licenses WHERE code = $1 AND status = 'paid'", [code])).rows[0];
171
+ return found ? row(found) : null;
172
+ }
173
+ async owns(paymentId) {
174
+ await this.ready();
175
+ return (await this.options.db.query("SELECT 1 FROM music_licenses WHERE payment_id = $1", [paymentId])).rows.length > 0;
176
+ }
177
+ /** CoinPay's webhook for one of ours. */
178
+ async settle(paymentId) {
179
+ await this.ready();
180
+ const found = (await this.options.db.query("SELECT id, status FROM music_licenses WHERE payment_id = $1", [paymentId])).rows[0];
181
+ if (!found)
182
+ return { settled: false };
183
+ if (found["status"] === "paid")
184
+ return { settled: true, license: String(found["id"]) };
185
+ if ((await this.options.support.isPaid(paymentId)) !== true)
186
+ return { settled: false, license: String(found["id"]) };
187
+ await this.paid(String(found["id"]));
188
+ return { settled: true, license: String(found["id"]) };
189
+ }
190
+ async forAccount(account) {
191
+ await this.ready();
192
+ const rows = await this.options.db.query("SELECT * FROM music_licenses WHERE status = 'paid' AND (user_id = $1 OR lower(email) = lower($2)) ORDER BY paid_at DESC LIMIT 500", [account.id, account.email]);
193
+ return rows.rows.map(row);
194
+ }
195
+ /** Marks it paid once; the catalogue pass runs a year from then. The first to get here sends the certificate. */
196
+ async paid(id) {
197
+ const now = new Date(this.now());
198
+ const updated = (await this.options.db.query("UPDATE music_licenses SET status = 'paid', paid_at = $2, expires_at = CASE WHEN kind = 'catalog' THEN $3::timestamptz ELSE NULL END WHERE id = $1 AND status = 'pending' RETURNING *", [id, now, new Date(this.now() + YEAR_MS)])).rows[0];
199
+ if (!updated) {
200
+ const again = (await this.options.db.query("SELECT * FROM music_licenses WHERE id = $1", [id])).rows[0];
201
+ return again ? row(again) : null;
202
+ }
203
+ const license = row(updated);
204
+ await this.mail(license.email, `Your music licence ${license.code}: ${license.title}`, [
205
+ `Thank you. ${license.licensee} now holds a ${TIER_NAMES[license.tier]} licence (${license.code}) for "${license.title}"${license.kind === "catalog" ? "" : ` by ${license.artistName}`}, for ${license.project}.`,
206
+ `What it covers: ${TIER_USE[license.tier]}${license.expiresAt ? ` Projects published before ${new Date(license.expiresAt).toISOString().slice(0, 10)} stay licensed for good.` : ""}`,
207
+ `Credit the music where your project has credits or a description, with a link to the artist's page: ${creditLine(license)}`,
208
+ `Your certificate, to keep or print: ${this.link(license.id, license.token)}`,
209
+ `Anybody can check it (a store, a platform, a client): ${this.verifyUrl(license.code)}`,
210
+ `The terms (version ${license.terms}): ${this.options.site}/license/terms`,
211
+ ]);
212
+ return license;
213
+ }
214
+ /** What the licensee's page and the public check show. */
215
+ view(license, full) {
216
+ const now = this.now();
217
+ return {
218
+ code: license.code, licensee: license.licensee, project: license.project, projectUrl: license.projectUrl,
219
+ tier: license.tier, tierName: TIER_NAMES[license.tier], use: TIER_USE[license.tier], kind: license.kind,
220
+ title: license.title, artistName: license.artistName, creditUrl: license.creditUrl, credit: creditLine(license),
221
+ status: license.status, active: license.status === "paid" && (!license.expiresAt || license.expiresAt > now),
222
+ paidAt: license.paidAt, expiresAt: license.expiresAt, terms: license.terms, termsUrl: `${this.options.site}/license/terms`, verify: this.verifyUrl(license.code),
223
+ ...(full ? { id: license.id, email: license.email, amountUsd: license.amountCents / 100, coin: license.coin, paymentId: license.paymentId, link: this.link(license.id, license.token) } : {}),
224
+ };
225
+ }
226
+ async mail(to, subject, lines) {
227
+ if (!this.options.mail) {
228
+ this.options.log?.(` licences: no RESEND_API_KEY, so "${subject}" to ${to} was not sent`);
229
+ return;
230
+ }
231
+ try {
232
+ await (this.options.fetcher ?? fetch)("https://api.resend.com/emails", {
233
+ method: "POST",
234
+ signal: AbortSignal.timeout(15_000),
235
+ headers: { authorization: `Bearer ${this.options.mail.apiKey}`, "content-type": "application/json" },
236
+ body: JSON.stringify({ from: this.options.mail.from, to: [to], subject, text: `${lines.join("\n\n")}\n\nnixamp.com` }),
237
+ });
238
+ }
239
+ catch (error) {
240
+ this.options.log?.(` licences: mail to ${to} failed: ${error.message}`);
241
+ }
242
+ }
243
+ }
244
+ /** The credit a licence asks for, ready to paste. */
245
+ export function creditLine(license) {
246
+ return license.kind === "catalog"
247
+ ? `Music: "<song>" by <artist> (<the artist's nixamp page>), licensed from ${license.artistName} on nixamp (${license.code})`
248
+ : `Music: "${license.title}" by ${license.artistName} (${license.creditUrl}), licensed on nixamp (${license.code})`;
249
+ }
250
+ /** The licence terms, version TERMS_VERSION: plain words, shown at /license/terms and agreed to at checkout. */
251
+ export function licenseTerms(holder, site) {
252
+ return [
253
+ { heading: "Who", text: `These terms are between ${holder} ("we"), which releases the AI label's music on nixamp, and the person or company named on the licence ("you").` },
254
+ { heading: "What you may do", text: "Use the licensed recordings, and their lyrics, in your own project: put them in it, edit, cut, loop and mix them, and publish, sell and distribute the project with the music inside it, worldwide, for as long as the project exists. The licence is non-exclusive: others may license the same music." },
255
+ { heading: "Which uses", text: `Creator: ${TIER_USE.creator} Indie: ${TIER_USE.indie} Commercial: ${TIER_USE.commercial} A song or album licence is for the one project named on it; the Catalog pass covers any number of your projects.` },
256
+ { heading: "The Catalog pass", text: `${TIER_USE.catalog} It runs one year from payment. A project first published while it is active stays licensed for good; a project published after it ends needs a new pass or its own licence.` },
257
+ { heading: "Credit", text: "Credit the music where your project shows credits or a description (end credits, an about screen, a video description, show notes), with a link to the artist's page on nixamp. The certificate gives the line to use. If there is nowhere to credit, put it where people can find it, such as your project's website." },
258
+ { heading: "What you may not do", text: "Sell, give away or publish the music on its own or as music (a track, an album, a sample pack, a stock or production library), or let others take it out of your project to use separately. Register the music as yours, or claim it with a content ID system (YouTube Content ID and the like). Use it as a trademark, jingle or sound logo. Use it to train or evaluate AI models. Use it in anything unlawful, defamatory or hateful." },
259
+ { heading: "Transfer", text: "The licence is yours and is not transferable, except with the project it is in: if you sell the project, the licence goes with it." },
260
+ { heading: "AI and rights", text: `The music and lyrics are made with AI and released by ${holder}. In some countries, including the United States, works made by AI have limited or no copyright protection. This licence is our written permission and our promise not to claim against your licensed use; we do not promise that nobody else can use the same music.` },
261
+ { heading: "No warranty", text: "The music is licensed as it is, without warranty. Our total liability under a licence is what you paid for it." },
262
+ { heading: "Ending", text: "If you break these terms the licence ends for new uses; tell us and fix it within 30 days and it continues." },
263
+ { heading: "Proof", text: `Every licence has a code (NXL-XXXX-XXXX) and a public page at ${site}/license/<code> that says who holds it, for what, and whether it is active.` },
264
+ { heading: "Law", text: "California law governs these terms." },
265
+ ];
266
+ }
package/dist/server.d.ts CHANGED
@@ -27,6 +27,7 @@ import { Importer } from "./handoff.ts";
27
27
  import { AiLabel } from "./ai-label.ts";
28
28
  import { MusicStore } from "./music-store.ts";
29
29
  import { MusicHub } from "./music-hub.ts";
30
+ import { MusicLicenses } from "./music-license.ts";
30
31
  import { AiRadio } from "./ai-radio.ts";
31
32
  import { ListenerShares } from "./listener-shares.ts";
32
33
  import { Support, SupportSubscriptions } from "./support.ts";
@@ -834,6 +835,8 @@ export interface HandlerOptions {
834
835
  coinpayWebhookSecret?: string;
835
836
  /** The AI label's songs for sale, nixamp.com only (music-store.ts). */
836
837
  musicStore?: MusicStore;
838
+ /** Licences to use that music in games, apps and videos, nixamp.com only (music-license.ts). */
839
+ musicLicenses?: MusicLicenses;
837
840
  /** nixamp.com/music: every server's label in one app (music-hub.ts). */
838
841
  music?: {
839
842
  hub: MusicHub;
package/dist/server.js CHANGED
@@ -68,6 +68,8 @@ import { handleMusicRoutes, isMusicPath, MusicHub, musicPageFor, serverId } from
68
68
  import { fileLedger, pgLedger, VeoBudgetError, VeoStudio } from "./veo-studio.js";
69
69
  import { KnownServers } from "./known-servers.js";
70
70
  import { handleStoreRoutes, isStorePath } from "./store-routes.js";
71
+ import { MusicLicenses } from "./music-license.js";
72
+ import { handleLicenseRoutes, isLicensePath } from "./license-routes.js";
71
73
  import { AiRadio } from "./ai-radio.js";
72
74
  import { ListenerShares } from "./listener-shares.js";
73
75
  import { HolderStore, TagReader, factsFor } from "./track-facts.js";
@@ -2179,6 +2181,10 @@ export function createHandler(engine, options) {
2179
2181
  json(response, 200, await options.musicStore.settle(event.paymentId));
2180
2182
  return;
2181
2183
  }
2184
+ if (options.musicLicenses && await options.musicLicenses.owns(event.paymentId)) {
2185
+ json(response, 200, await options.musicLicenses.settle(event.paymentId));
2186
+ return;
2187
+ }
2182
2188
  }
2183
2189
  catch (error) {
2184
2190
  console.error(`nixamp: CoinPay webhook for ${event.paymentId} (music) could not settle: ${error instanceof Error ? error.message : String(error)}`);
@@ -2321,6 +2327,7 @@ export function createHandler(engine, options) {
2321
2327
  !(isPublicLabelPath(path) && Boolean(options.stations?.label)) &&
2322
2328
  // The music store: its pages, checkout, receipts and grants (nixamp.com).
2323
2329
  !(isStorePath(path) && Boolean(options.musicStore)) &&
2330
+ !(isLicensePath(path) && Boolean(options.musicLicenses)) &&
2324
2331
  // nixamp.com/music: the app and its catalogue, for anybody.
2325
2332
  !(isMusicPath(path) && Boolean(options.music)) &&
2326
2333
  !(/^\/radio\/(artist|album)\//.test(path) && Boolean(options.music)) &&
@@ -2918,6 +2925,18 @@ export function createHandler(engine, options) {
2918
2925
  }))
2919
2926
  return;
2920
2927
  }
2928
+ if (options.musicLicenses && isLicensePath(path)) {
2929
+ const accounts = options.accounts;
2930
+ if (await handleLicenseRoutes(request, response, path, url, {
2931
+ licenses: options.musicLicenses,
2932
+ account: async () => (accounts ? await accounts.whoIs(tokenFrom(request.headers)) : null),
2933
+ json,
2934
+ readBody,
2935
+ serverId,
2936
+ site: (process.env["NIXAMP_SITE"] || "https://nixamp.com").replace(/\/+$/, ""),
2937
+ }))
2938
+ return;
2939
+ }
2921
2940
  if (options.stations?.label && (isPublicLabelPath(path) || path === "/api/v1/stations/label" || path.startsWith("/api/v1/stations/label/"))) {
2922
2941
  const control = isPublicLabelPath(path) ? { allowed: false } : await controlOf();
2923
2942
  if (await handleLabelRoutes(request, response, path, {
@@ -7514,6 +7533,16 @@ export async function serve(argv, version = "0.1.0") {
7514
7533
  ...(musicHub ? { resolveServer: (id) => musicHub.resolve(id) } : {}),
7515
7534
  log: (line) => console.log(line),
7516
7535
  }) : undefined;
7536
+ // Licences to use that music in games, apps and videos (music-license.ts), paid the same way.
7537
+ const musicLicenses = musicStore && supportPayments && pool ? new MusicLicenses({
7538
+ db: pool,
7539
+ support: supportPayments,
7540
+ store: musicStore,
7541
+ mail: process.env["RESEND_API_KEY"] ? { apiKey: process.env["RESEND_API_KEY"], from: process.env["NIXAMP_MAIL_FROM"] ?? "nixamp <notifications@nixamp.com>" } : null,
7542
+ site: nixampSite,
7543
+ serverId,
7544
+ log: (line) => console.log(line),
7545
+ }) : undefined;
7517
7546
  /**
7518
7547
  * A server says an album (or song) of its label landed: its artist's
7519
7548
  * followers are told. Only the account that owns a listed server may say
@@ -8151,6 +8180,7 @@ export async function serve(argv, version = "0.1.0") {
8151
8180
  ...(supportPayments ? { support: supportPayments } : {}),
8152
8181
  ...(supportMonthly ? { supportSubscriptions: supportMonthly } : {}),
8153
8182
  ...(musicStore ? { musicStore } : {}),
8183
+ ...(musicLicenses ? { musicLicenses } : {}),
8154
8184
  ...(musicHub ? { music: { hub: musicHub, released: musicReleased, veo: musicVeo } } : {}),
8155
8185
  ...(process.env["COINPAY_WEBHOOK_SECRET"] ? { coinpayWebhookSecret: process.env["COINPAY_WEBHOOK_SECRET"] } : {}),
8156
8186
  ...(sharedTranslations ? { sharedTranslations } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.61.2",
3
+ "version": "0.62.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",
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Music licences on nixamp.com (music-license.ts): the pages and the API.
3
+ *
4
+ * GET /license?server=&kind=song|album&artist=&album=&n= pick a licence for a song or album, then pay
5
+ * GET /license?kind=catalog the Catalog pass, $29 a year
6
+ * GET /license/terms the terms, in plain words
7
+ * GET /license/NXL-XXXX-XXXX anybody's check of a licence
8
+ * GET /licenses/:id?t= the licensee's certificate (printable)
9
+ * POST /api/v1/licenses/checkout {tier, kind, server?, artist?, album?, n?, licensee, project, projectUrl?, email, coin, agree} -> {url}
10
+ * GET /api/v1/licenses/NXL-XXXX-XXXX the check, as JSON
11
+ * GET /api/v1/licenses the signed-in account's licences
12
+ */
13
+ import { createHash } from "node:crypto";
14
+ import type { IncomingMessage, ServerResponse } from "node:http";
15
+ import { LICENSE_PRICES, TERMS_VERSION, TIER_NAMES, TIER_USE, licenseTerms, type License, type MusicLicenses, type Quote, type Tier } from "./music-license.ts";
16
+ import { SupportError } from "./support.ts";
17
+
18
+ const CHECK = /^\/license\/(NXL-[A-Z2-9]{4}-[A-Z2-9]{4})$/;
19
+ const CHECK_JSON = /^\/api\/v1\/licenses\/(NXL-[A-Z2-9]{4}-[A-Z2-9]{4})$/;
20
+ const CERTIFICATE = /^\/licenses\/([0-9a-f-]{36})$/;
21
+
22
+ export function isLicensePath(path: string): boolean {
23
+ return path === "/license" || path === "/license/terms" || CHECK.test(path) || CERTIFICATE.test(path)
24
+ || path === "/api/v1/licenses/checkout" || path === "/api/v1/licenses" || CHECK_JSON.test(path);
25
+ }
26
+
27
+ export interface LicenseRoutesContext {
28
+ licenses: MusicLicenses;
29
+ account: () => Promise<{ id: string; email: string } | null>;
30
+ json: (response: ServerResponse, status: number, body: unknown) => void;
31
+ readBody: (request: IncomingMessage) => Promise<string>;
32
+ /** The opaque id a server goes by in nixamp.com's addresses. */
33
+ serverId: (base: string) => string;
34
+ site: string;
35
+ }
36
+
37
+ const escape = (text: string): string => text.replace(/[&<>"']/g, (one) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[one]!);
38
+ const hash = (text: string): string => `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
39
+ const price = (cents: number): string => `$${(cents / 100).toFixed(cents % 100 ? 2 : 0)}`;
40
+ const day = (ms: number): string => new Date(ms).toISOString().slice(0, 10);
41
+
42
+ const STYLE = `
43
+ :root{--bg:#080c09;--fg:#e8efe9;--muted:#9aa79d;--card:#0e1410;--line:#1f2a22;--accent:#3ddc84}
44
+ *{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif}
45
+ main{max-width:640px;margin:0 auto;padding:40px 16px}
46
+ .card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:24px;margin-bottom:16px}
47
+ h1{margin:0 0 4px;font-size:1.5rem;overflow-wrap:anywhere}h2{font-size:1.05rem;margin:22px 0 6px}.by{color:var(--muted);margin:0 0 16px}
48
+ .cover{border-radius:10px;float:right;margin:0 0 8px 12px;width:120px;height:120px;object-fit:cover;max-width:35%}
49
+ fieldset{border:0;padding:0;margin:16px 0 0}legend{color:var(--muted);font-size:.9rem;margin-bottom:6px}
50
+ .tier{display:grid;grid-template-columns:auto 1fr auto;gap:4px 12px;align-items:start;border:1px solid var(--line);border-radius:12px;padding:12px;margin-bottom:8px;cursor:pointer}
51
+ .tier:has(input:checked){border-color:var(--accent)}.tier strong{display:block}.tier span{color:var(--muted);font-size:.9rem}.tier b{color:var(--accent);white-space:nowrap}
52
+ label.f{display:block;color:var(--muted);font-size:.9rem;margin:12px 0 4px}
53
+ input[type=text],input[type=email],input[type=url],select{width:100%;padding:10px;border-radius:10px;border:1px solid var(--line);background:var(--bg);color:var(--fg);font:inherit}
54
+ .agree{display:flex;gap:8px;align-items:flex-start;margin-top:14px;color:var(--muted);font-size:.9rem}
55
+ button,a.go{display:block;text-align:center;text-decoration:none;margin-top:18px;width:100%;padding:12px;border:0;border-radius:999px;background:var(--accent);color:#06120a;font:inherit;font-weight:800;cursor:pointer}
56
+ button:disabled{opacity:.5;cursor:wait}
57
+ :focus-visible{outline:3px solid var(--accent);outline-offset:2px}
58
+ #status{min-height:1.4em;margin-top:12px;color:var(--muted)}
59
+ small,.muted{color:var(--muted)}a{color:var(--accent)}
60
+ dl{display:grid;grid-template-columns:max-content 1fr;gap:6px 16px;margin:16px 0}dt{color:var(--muted)}dd{margin:0;overflow-wrap:anywhere}
61
+ .ok{display:inline-block;padding:2px 10px;border-radius:999px;background:#14361f;color:var(--accent);font-weight:700;font-size:.85rem}
62
+ .no{display:inline-block;padding:2px 10px;border-radius:999px;background:#3a1717;color:#ff8f8f;font-weight:700;font-size:.85rem}
63
+ code{display:block;white-space:pre-wrap;background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:10px;font-size:.9rem;overflow-wrap:anywhere}
64
+ .print{background:transparent;border:1px solid var(--line);color:var(--fg)}
65
+ @media print{:root{--bg:#fff;--fg:#000;--muted:#444;--card:#fff;--line:#999;--accent:#000}.noprint{display:none}.card{border-color:#000}}
66
+ `;
67
+
68
+ const SCRIPT = `
69
+ (() => {
70
+ const $ = (s) => document.querySelector(s);
71
+ const print = $("#print");
72
+ if (print) print.addEventListener("click", () => window.print());
73
+ const form = $("#license");
74
+ if (!form) return;
75
+ const status = $("#status");
76
+ const pay = $("#pay");
77
+ const label = () => { const one = form.querySelector("input[name=tier]:checked"); pay.textContent = one ? "Pay " + one.dataset.price + " with crypto" : "Pick a licence"; };
78
+ form.addEventListener("change", label);
79
+ label();
80
+ fetch("/api/v1/store/coins").then((r) => r.json()).then((coins) => {
81
+ const cheap = ["USDC_POL", "USDC_SOL", "SOL", "POL", "LTC", "USDC_BASE", "USDC"];
82
+ const list = (coins.coins || []).slice().sort((a, b) => (cheap.indexOf(a) + 1 || 99) - (cheap.indexOf(b) + 1 || 99));
83
+ $("#coin").replaceChildren(...list.map((c) => Object.assign(document.createElement("option"), { value: c, textContent: c })));
84
+ }).catch(() => { status.textContent = "Payments are not available right now."; });
85
+ form.addEventListener("submit", async (event) => {
86
+ event.preventDefault();
87
+ pay.disabled = true;
88
+ status.textContent = "Starting the checkout…";
89
+ const data = new FormData(form);
90
+ try {
91
+ const r = await fetch("/api/v1/licenses/checkout", { method: "POST", headers: { "content-type": "application/json" }, credentials: "same-origin",
92
+ body: JSON.stringify({ ...JSON.parse(document.body.dataset.item), tier: data.get("tier"), licensee: data.get("licensee"), project: data.get("project"),
93
+ projectUrl: data.get("projectUrl"), email: data.get("email"), coin: data.get("coin"), agree: data.get("agree") === "on" }) });
94
+ const body = await r.json();
95
+ if (!r.ok) throw new Error(body.error || "The checkout did not start.");
96
+ status.textContent = "Taking you to CoinPay…";
97
+ location.href = body.url;
98
+ } catch (error) {
99
+ status.textContent = error.message;
100
+ pay.disabled = false;
101
+ }
102
+ });
103
+ })();
104
+ `;
105
+
106
+ export const LICENSE_CSP = ["default-src 'none'", `script-src ${hash(SCRIPT)}`, `style-src ${hash(STYLE)}`, "img-src 'self' https:", "connect-src 'self'", "form-action 'none'", "base-uri 'none'", "frame-ancestors 'none'"].join("; ");
107
+
108
+ function page(title: string, body: string, options: { data?: string; index?: boolean; description?: string } = {}): string {
109
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
110
+ <title>${escape(title)} · nixamp</title>${options.index ? "" : `<meta name="robots" content="noindex">`}${options.description ? `<meta name="description" content="${escape(options.description)}">` : ""}
111
+ <link rel="icon" href="/favicon.png" type="image/png"><style>${STYLE}</style></head>
112
+ <body${options.data ? ` data-item="${escape(options.data)}"` : ""}><main>${body}</main><script>${SCRIPT}</script></body></html>`;
113
+ }
114
+
115
+ function html(response: ServerResponse, status: number, text: string, method: string): void {
116
+ response.writeHead(status, { "content-type": "text/html; charset=utf-8", "content-security-policy": LICENSE_CSP, "cache-control": "no-store", "referrer-policy": "no-referrer" });
117
+ response.end(method === "HEAD" ? undefined : text);
118
+ }
119
+
120
+ /** The tiers sold for what is being licensed, cheapest first. */
121
+ function tiersFor(kind: Quote["kind"]): Tier[] {
122
+ return (Object.keys(LICENSE_PRICES) as Tier[]).filter((tier) => LICENSE_PRICES[tier][kind]).sort((a, b) => LICENSE_PRICES[a][kind]! - LICENSE_PRICES[b][kind]!);
123
+ }
124
+
125
+ function certificate(license: License, ctx: LicenseRoutesContext, full: boolean): string {
126
+ const view = ctx.licenses.view(license, full);
127
+ const active = Boolean(view["active"]);
128
+ return `<div class="card">
129
+ <p>${active ? `<span class="ok">Active licence</span>` : license.status === "paid" ? `<span class="no">Pass ended ${day(license.expiresAt)}</span>` : `<span class="no">Not paid yet</span>`}</p>
130
+ <h1>${escape(TIER_NAMES[license.tier])} licence ${escape(license.code)}</h1>
131
+ <p class="by">${escape(license.title)}${license.kind === "catalog" ? "" : ` · ${escape(license.artistName)}`}</p>
132
+ <dl>
133
+ <dt>Licensee</dt><dd>${escape(license.licensee)}</dd>
134
+ <dt>Project</dt><dd>${escape(license.project)}${license.projectUrl ? ` · <a href="${escape(license.projectUrl)}" rel="nofollow noopener">${escape(license.projectUrl)}</a>` : ""}</dd>
135
+ <dt>Covers</dt><dd>${escape(TIER_USE[license.tier])}</dd>
136
+ <dt>Licensor</dt><dd>${escape(ctx.licenses.holder)}</dd>
137
+ ${license.paidAt ? `<dt>Issued</dt><dd>${day(license.paidAt)}</dd>` : ""}
138
+ ${license.expiresAt ? `<dt>New projects until</dt><dd>${day(license.expiresAt)} (projects published before then stay licensed)</dd>` : `<dt>Term</dt><dd>For good, worldwide, non-exclusive</dd>`}
139
+ <dt>Terms</dt><dd><a href="/license/terms">Version ${escape(license.terms)}</a></dd>
140
+ <dt>Check</dt><dd><a href="/license/${escape(license.code)}">${escape(ctx.site)}/license/${escape(license.code)}</a></dd>
141
+ </dl>
142
+ <h2>Credit line</h2>
143
+ <p class="muted">Required wherever the project shows credits or a description, with the link:</p>
144
+ <code>${escape(String(view["credit"]))}</code>
145
+ ${full ? `<button id="print" class="print noprint" type="button">Print or save as PDF</button>` : ""}
146
+ </div>`;
147
+ }
148
+
149
+ export async function handleLicenseRoutes(request: IncomingMessage, response: ServerResponse, path: string, url: URL, ctx: LicenseRoutesContext): Promise<boolean> {
150
+ if (!isLicensePath(path)) return false;
151
+ const method = request.method ?? "GET";
152
+ const licenses = ctx.licenses;
153
+ try {
154
+ if (path === "/license" && (method === "GET" || method === "HEAD")) {
155
+ const asked = Object.fromEntries(url.searchParams.entries());
156
+ const kind = asked["kind"] === "catalog" ? "catalog" : asked["kind"] === "album" ? "album" : "song";
157
+ let quote: Quote;
158
+ try {
159
+ quote = await licenses.quote({ ...asked, kind, tier: tiersFor(kind)[0] });
160
+ } catch (error) {
161
+ html(response, 404, page("Not licensable", `<div class="card"><h1>Not licensable</h1><p class="by">${escape((error as Error).message)}</p><p><a href="/music">Back to the music</a></p></div>`), method);
162
+ return true;
163
+ }
164
+ const me = await ctx.account().catch(() => null);
165
+ const cover = quote.kind === "catalog" ? "" : `<img class="cover" src="${escape(`/api/v1/music/file/${ctx.serverId(quote.server)}/${quote.artist}/${quote.album}/cover.jpg`)}" alt="">`;
166
+ const tiers = tiersFor(quote.kind);
167
+ const options = tiers.map((tier, at) => `<label class="tier"><input type="radio" name="tier" value="${tier}" data-price="${price(LICENSE_PRICES[tier][quote.kind]!)}"${at === (tiers.length > 1 ? 1 : 0) ? " checked" : ""}>
168
+ <div><strong>${escape(TIER_NAMES[tier])}</strong><span>${escape(TIER_USE[tier])}</span></div><b>${price(LICENSE_PRICES[tier][quote.kind]!)}${tier === "catalog" ? "/year" : ""}</b></label>`).join("");
169
+ const what = quote.kind === "catalog" ? `Every song by every artist ${escape(licenses.holder)} releases on nixamp` : quote.kind === "album" ? `The album "${escape(quote.title)}" by ${escape(quote.artistName)}` : `"${escape(quote.title)}" by ${escape(quote.artistName)}`;
170
+ html(response, 200, page(`License ${quote.title}`, `<div class="card">${cover}
171
+ <h1>License ${quote.kind === "catalog" ? "the whole catalogue" : "this music"}</h1>
172
+ <p class="by">${what}</p>
173
+ <p class="muted">For your game, app, video or podcast. A download is for listening; a licence lets you put the music in your project and publish it. Worldwide, non-exclusive, and every use credits the artist with a link to their page.</p>
174
+ <form id="license">
175
+ <fieldset><legend>Licence</legend>${options}</fieldset>
176
+ <label class="f" for="licensee">Licensed to (your name or company)</label>
177
+ <input id="licensee" name="licensee" type="text" required minlength="2" maxlength="120" autocomplete="organization">
178
+ <label class="f" for="project">${quote.kind === "catalog" ? "Your studio, channel or business" : "Project the music goes in"}</label>
179
+ <input id="project" name="project" type="text" required minlength="2" maxlength="160">
180
+ <label class="f" for="projectUrl">Project link (optional)</label>
181
+ <input id="projectUrl" name="projectUrl" type="url" maxlength="300" placeholder="https://">
182
+ <label class="f" for="email">Email (your certificate goes here)</label>
183
+ <input id="email" name="email" type="email" required autocomplete="email" value="${escape(me?.email ?? "")}">
184
+ <label class="f" for="coin">Pay with</label>
185
+ <select id="coin" name="coin" required></select>
186
+ <label class="agree"><input type="checkbox" name="agree" required> <span>I agree to the <a href="/license/terms" target="_blank" rel="noopener">licence terms</a>, including the credit with a link to the artist's page.</span></label>
187
+ <button id="pay" type="submit">Pay</button>
188
+ </form>
189
+ <p id="status" role="status" aria-live="polite"></p>
190
+ <small>Payments go through <a href="https://coinpayportal.com">CoinPay</a> to nixamp.com. You get a certificate with a licence code that anybody can check at ${escape(ctx.site)}/license/&lt;code&gt;.</small>
191
+ </div>`, { data: JSON.stringify({ kind: quote.kind, ...(quote.kind === "catalog" ? {} : { server: ctx.serverId(quote.server), artist: quote.artist, album: quote.album, n: quote.n }) }) }), method);
192
+ return true;
193
+ }
194
+ if (path === "/license/terms" && (method === "GET" || method === "HEAD")) {
195
+ const sections = licenseTerms(licenses.holder, ctx.site).map((one) => `<h2>${escape(one.heading)}</h2><p>${escape(one.text)}</p>`).join("");
196
+ html(response, 200, page("Music licence terms", `<div class="card"><h1>Music licence terms</h1><p class="by">Version ${TERMS_VERSION} · ${escape(licenses.holder)}</p>${sections}<p><a href="/music">The music</a> · <a href="/license?kind=catalog">Catalog pass</a></p></div>`,
197
+ { index: true, description: `How you may use ${licenses.holder}'s AI music in your games, apps and videos: Creator, Indie, Commercial and the Catalog pass.` }), method);
198
+ return true;
199
+ }
200
+ const check = CHECK.exec(path);
201
+ if (check && (method === "GET" || method === "HEAD")) {
202
+ const license = await licenses.byCode(check[1]!);
203
+ html(response, license ? 200 : 404, page(license ? `Licence ${license.code}` : "No such licence", license
204
+ ? certificate(license, ctx, false)
205
+ : `<div class="card"><h1>No such licence</h1><p class="by">${escape(check[1]!)} is not a paid nixamp music licence.</p></div>`), method);
206
+ return true;
207
+ }
208
+ const mine = CERTIFICATE.exec(path);
209
+ if (mine && (method === "GET" || method === "HEAD")) {
210
+ const license = await licenses.get(mine[1]!, url.searchParams.get("t") ?? "");
211
+ if (!license) {
212
+ html(response, 404, page("No such licence", `<div class="card"><h1>No such licence</h1><p class="by">That link is not one of ours.</p></div>`), method);
213
+ return true;
214
+ }
215
+ const body = license.status === "paid"
216
+ ? certificate(license, ctx, true)
217
+ : `<div class="card"><h1>${escape(TIER_NAMES[license.tier])} licence ${escape(license.code)}</h1><p class="by">${escape(license.title)} · ${price(license.amountCents)} in ${escape(license.coin)}</p>
218
+ <p>Waiting for your payment. Once CoinPay confirms it, your certificate is here and in your email (${escape(license.email)}). This page checks again when you reload it.</p>
219
+ <a class="go" href="https://coinpayportal.com/pay/${escape(license.paymentId)}">Open the payment</a></div>`;
220
+ html(response, 200, page(`Licence ${license.code}`, body), method);
221
+ return true;
222
+ }
223
+ const checkJson = CHECK_JSON.exec(path);
224
+ if (checkJson && method === "GET") {
225
+ const license = await licenses.byCode(checkJson[1]!);
226
+ response.setHeader("access-control-allow-origin", "*");
227
+ ctx.json(response, license ? 200 : 404, license ? { license: licenses.view(license, false) } : { error: "no such licence", code: "not_found" });
228
+ return true;
229
+ }
230
+ if (path === "/api/v1/licenses/checkout" && method === "POST") {
231
+ let input: Record<string, unknown>;
232
+ try {
233
+ input = JSON.parse(await ctx.readBody(request)) as Record<string, unknown>;
234
+ } catch {
235
+ ctx.json(response, 400, { error: "the body is not JSON", code: "bad_json" });
236
+ return true;
237
+ }
238
+ const me = await ctx.account().catch(() => null);
239
+ ctx.json(response, 201, await licenses.checkout(input, me));
240
+ return true;
241
+ }
242
+ if (path === "/api/v1/licenses" && method === "GET") {
243
+ const me = await ctx.account().catch(() => null);
244
+ if (!me) {
245
+ ctx.json(response, 401, { error: "sign in to see your licences", code: "unauthorized" });
246
+ return true;
247
+ }
248
+ ctx.json(response, 200, { licenses: (await licenses.forAccount(me)).map((one) => licenses.view(one, true)) });
249
+ return true;
250
+ }
251
+ ctx.json(response, 405, { error: "not like that", code: "method_not_allowed" });
252
+ return true;
253
+ } catch (error) {
254
+ if (error instanceof SupportError) ctx.json(response, error.status, { error: error.message, code: error.code });
255
+ else ctx.json(response, 500, { error: error instanceof Error ? error.message : String(error), code: "internal" });
256
+ return true;
257
+ }
258
+ }