nixamp 0.27.1 → 0.27.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +53 -20
  2. package/backtoschool/dist/assets/{index-6vtCMzi1.css → index-Bot4FEXQ.css} +1 -1
  3. package/backtoschool/dist/assets/index-Cjai7LgT.js +93 -0
  4. package/backtoschool/dist/index.html +36 -9
  5. package/dist/accounts.d.ts +2 -0
  6. package/dist/accounts.js +9 -2
  7. package/dist/classroom.d.ts +15 -0
  8. package/dist/classroom.js +54 -0
  9. package/dist/event-writer.d.ts +39 -0
  10. package/dist/event-writer.js +139 -0
  11. package/dist/live-api.d.ts +2 -0
  12. package/dist/live-api.js +37 -2
  13. package/dist/live-events.d.ts +7 -1
  14. package/dist/live-events.js +88 -12
  15. package/dist/live-interpreter.d.ts +3 -1
  16. package/dist/live-interpreter.js +109 -83
  17. package/dist/live-recovery.d.ts +6 -0
  18. package/dist/live-recovery.js +11 -0
  19. package/dist/live-voice.d.ts +1 -1
  20. package/dist/live-voice.js +7 -7
  21. package/dist/mail-sender.d.ts +9 -0
  22. package/dist/mail-sender.js +11 -0
  23. package/dist/notify.d.ts +3 -3
  24. package/dist/notify.js +8 -5
  25. package/dist/password-reset-page.d.ts +2 -0
  26. package/dist/password-reset-page.js +106 -0
  27. package/dist/password-reset.d.ts +34 -0
  28. package/dist/password-reset.js +121 -0
  29. package/dist/server.d.ts +5 -0
  30. package/dist/server.js +109 -3
  31. package/dist/shared-translation.d.ts +1 -0
  32. package/dist/shared-translation.js +57 -40
  33. package/dist/tokens.d.ts +2 -0
  34. package/dist/tokens.js +2 -0
  35. package/dist/translation-passes.d.ts +13 -8
  36. package/dist/translation-passes.js +31 -20
  37. package/dist/upgrade-allowance.d.ts +23 -0
  38. package/dist/upgrade-allowance.js +70 -0
  39. package/package.json +1 -1
  40. package/src/accounts.ts +8 -2
  41. package/src/classroom.ts +52 -0
  42. package/src/event-writer.ts +126 -0
  43. package/src/live-api.ts +26 -2
  44. package/src/live-events.ts +81 -13
  45. package/src/live-interpreter.ts +87 -68
  46. package/src/live-recovery.ts +10 -0
  47. package/src/live-voice.ts +7 -7
  48. package/src/mail-sender.ts +12 -0
  49. package/src/notify.ts +10 -7
  50. package/src/password-reset-page.ts +108 -0
  51. package/src/password-reset.ts +134 -0
  52. package/src/server.ts +91 -3
  53. package/src/shared-translation.ts +47 -33
  54. package/src/tokens.ts +3 -0
  55. package/src/translation-passes.ts +35 -25
  56. package/src/types/auth-system.d.ts +10 -0
  57. package/src/upgrade-allowance.ts +69 -0
  58. package/web/dist/assets/background.worker-Hz7oaQ8X.js +547 -0
  59. package/web/dist/assets/{hls-3VKVEQE3-C1ttVP7K.js → hls-3VKVEQE3-_BiJh76x.js} +1 -1
  60. package/web/dist/assets/index-BgLxkXKe.js +3 -0
  61. package/web/dist/assets/{index-BmR7T0zX.css → index-D34dTY18.css} +1 -1
  62. package/web/dist/assets/{mpegts-CVYPgepU.js → mpegts-CV0glck7.js} +1 -1
  63. package/web/dist/assets/{mpegts-LO6RVLD6-Dw2p8qGf.js → mpegts-LO6RVLD6-iuTMg-O6.js} +1 -1
  64. package/web/dist/index.html +6 -5
  65. package/web/dist/sw.js +7 -7
  66. package/backtoschool/dist/assets/index-D7Oas1m5.js +0 -88
  67. package/web/dist/assets/background.worker-DH8ag5Q4.js +0 -547
  68. package/web/dist/assets/index-3WZwGw04.js +0 -3
@@ -0,0 +1,121 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { mailSender } from "./mail-sender.js";
3
+ export const RESET_MESSAGE = "If an account exists for that email, you’ll receive a password reset link shortly.";
4
+ export const RESET_INVALID = "This reset link is invalid, expired, or already used. Request a new link.";
5
+ const LIFETIME_MS = 30 * 60_000;
6
+ const SCHEMA = `
7
+ CREATE TABLE IF NOT EXISTS nixamp_password_resets (
8
+ user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
9
+ token_hash TEXT UNIQUE,
10
+ password_hash TEXT,
11
+ expires_at TIMESTAMPTZ,
12
+ reset_at TIMESTAMPTZ
13
+ );
14
+ `;
15
+ function digest(token) {
16
+ return createHash("sha256").update(token).digest("hex");
17
+ }
18
+ export class PasswordResets {
19
+ db;
20
+ tokens;
21
+ passwords;
22
+ ready = null;
23
+ constructor(db, tokens, passwords) {
24
+ this.db = db;
25
+ this.tokens = tokens;
26
+ this.passwords = passwords;
27
+ }
28
+ async ensure() {
29
+ this.ready ??= (async () => {
30
+ await this.db.initialize?.();
31
+ await this.tokens.initialize();
32
+ await this.db.query(SCHEMA);
33
+ })().catch((error) => { this.ready = null; throw error; });
34
+ await this.ready;
35
+ }
36
+ /** The public response is identical for registered and unknown addresses. */
37
+ async request(email, site, deliver) {
38
+ await this.ensure();
39
+ const token = randomBytes(32).toString("base64url");
40
+ const { rows } = await this.db.query(`INSERT INTO nixamp_password_resets (user_id, token_hash, password_hash, expires_at)
41
+ SELECT id, $2, password, now() + interval '30 minutes' FROM users WHERE lower(email) = $1
42
+ ON CONFLICT (user_id) DO UPDATE SET token_hash = EXCLUDED.token_hash,
43
+ password_hash = EXCLUDED.password_hash, expires_at = EXCLUDED.expires_at
44
+ RETURNING user_id`, [email.trim().toLowerCase(), digest(token)]);
45
+ if (!rows.length)
46
+ return;
47
+ // Fragments never reach HTTP logs or referrers. Email delivery also never
48
+ // changes the HTTP response (or its timing) according to account existence.
49
+ const link = `${site.replace(/\/+$/, "")}/reset-password#token=${token}`;
50
+ void Promise.resolve().then(() => deliver(email.trim().toLowerCase(), link)).then((sent) => {
51
+ if (!sent)
52
+ console.error("nixamp: password reset email delivery failed");
53
+ }).catch(() => console.error("nixamp: password reset email delivery failed"));
54
+ }
55
+ /** Claim the link, change the password, and revoke sessions in one statement. */
56
+ async confirm(token, password) {
57
+ if (typeof token !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(token))
58
+ return RESET_INVALID;
59
+ if (typeof password !== "string" || password.length > 200)
60
+ return "Enter a password between 8 and 200 characters.";
61
+ const validation = this.passwords.validatePassword(password);
62
+ if (!validation.valid)
63
+ return validation.message;
64
+ await this.ensure();
65
+ const tokenHash = digest(token);
66
+ // Reject bad tokens before doing the deliberately expensive password hash.
67
+ const candidate = await this.db.query(`SELECT user_id FROM nixamp_password_resets WHERE token_hash = $1 AND expires_at > now()`, [tokenHash]);
68
+ if (!candidate.rows.length)
69
+ return RESET_INVALID;
70
+ const hashed = await this.passwords.hashPassword(password);
71
+ const { rows } = await this.db.query(`WITH consumed AS (
72
+ UPDATE nixamp_password_resets r SET token_hash = NULL, expires_at = NULL,
73
+ password_hash = NULL, reset_at = clock_timestamp()
74
+ FROM users u WHERE r.token_hash = $1 AND r.expires_at > now()
75
+ AND u.id = r.user_id AND r.password_hash IS NOT DISTINCT FROM u.password
76
+ RETURNING r.user_id
77
+ ), changed AS (
78
+ UPDATE users u SET password = $2, updated_at = now()
79
+ FROM consumed c WHERE u.id = c.user_id RETURNING u.id
80
+ ), revoked AS (
81
+ DELETE FROM nixamp_tokens t USING changed c WHERE t.user_id = c.id RETURNING t.id
82
+ ) SELECT id FROM changed`, [tokenHash, hashed]);
83
+ return rows.length ? null : RESET_INVALID;
84
+ }
85
+ /** Older JWT sessions also stop working after recovery, across restarts. */
86
+ async acceptsLegacyToken(userId, token) {
87
+ await this.ensure();
88
+ const { rows } = await this.db.query("SELECT reset_at FROM nixamp_password_resets WHERE user_id = $1", [userId]);
89
+ if (!rows[0]?.["reset_at"])
90
+ return true;
91
+ try {
92
+ // Called only after the auth library has verified the JWT signature.
93
+ const claims = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
94
+ const issued = claims.iat;
95
+ const reset = rows[0]["reset_at"];
96
+ const resetAt = reset instanceof Date ? reset.getTime() : new Date(String(reset)).getTime();
97
+ return typeof issued === "number" && Number.isFinite(issued)
98
+ && issued * 1000 >= resetAt;
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ }
104
+ }
105
+ /** Transactional recovery mail has no follower footer and never logs a token. */
106
+ export function resendPasswordReset(options) {
107
+ return async (email, link) => {
108
+ const host = new URL(link).hostname;
109
+ const sender = mailSender(options, link);
110
+ const response = await (options.fetch ?? globalThis.fetch)("https://api.resend.com/emails", {
111
+ method: "POST",
112
+ headers: { authorization: `Bearer ${sender.apiKey}`, "content-type": "application/json" },
113
+ signal: AbortSignal.timeout(15_000),
114
+ body: JSON.stringify({
115
+ from: sender.from, to: [email], subject: `Reset your ${host} password`,
116
+ text: `Someone requested a password reset for your account on ${host}.\n\nChoose a new password using this link:\n${link}\n\nThis link expires in ${LIFETIME_MS / 60_000} minutes and works once. Resetting your password signs out existing sessions.\n\nIf you did not request this, you can ignore this email. Your password will stay the same.`,
117
+ }),
118
+ });
119
+ return response.ok;
120
+ };
121
+ }
package/dist/server.d.ts CHANGED
@@ -7,6 +7,7 @@ import { Broadcaster, type Destination, type EncoderSettings } from "./broadcast
7
7
  import { Ingest } from "./ingest.ts";
8
8
  import { Channels, type Channel, type ChannelInfo, type RememberedChannel } from "./channels.ts";
9
9
  import { Accounts } from "./accounts.ts";
10
+ import { type ResetMail } from "./password-reset.ts";
10
11
  import { Handles } from "./handles.ts";
11
12
  import { OpenDirs } from "./opendirs.ts";
12
13
  import { Servers } from "./servers.ts";
@@ -28,6 +29,7 @@ import { Names } from "./names.ts";
28
29
  import { Certs } from "./certs.ts";
29
30
  import { type Throttle } from "@profullstack/throttle";
30
31
  import { type Notification } from "./notify.ts";
32
+ import { EventWriter } from "./event-writer.ts";
31
33
  import { LiveEvents } from "./live-events.ts";
32
34
  import { Tickets } from "./tickets.ts";
33
35
  import { Layouts } from "./layouts.ts";
@@ -659,6 +661,8 @@ export interface HandlerOptions {
659
661
  }[];
660
662
  /** Accounts, on the instance that keeps them. Only nixamp.com passes this. */
661
663
  accounts?: Accounts;
664
+ /** Transactional password recovery mail, separate from follow notifications. */
665
+ resetMail?: ResetMail;
662
666
  /** Providers to sign in with, and the terminals waiting to be connected. */
663
667
  signIn?: SignIn;
664
668
  /** The servers each account runs, on the instance that keeps accounts. */
@@ -700,6 +704,7 @@ export interface HandlerOptions {
700
704
  settingsSync?: SettingsSync;
701
705
  /** Scheduled and live sessions, kept by NixAmp and shared by branded clients. */
702
706
  events?: LiveEvents;
707
+ eventWriter?: EventWriter;
703
708
  /** Versioned panel layouts, including event and user overrides. */
704
709
  layouts?: Layouts;
705
710
  /** Persistent participation state that must not be coupled to live audio. */
package/dist/server.js CHANGED
@@ -27,6 +27,8 @@ import { Ingest, normaliseFormat } from "./ingest.js";
27
27
  import { BACKLOG_SECONDS, Channels, cleanId, generatedId, rememberChannels, rememberedChannels, rememberedNow, } from "./channels.js";
28
28
  import { RtmpListeners } from "./rtmp-in.js";
29
29
  import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.js";
30
+ import { RESET_INVALID, RESET_MESSAGE, resendPasswordReset } from "./password-reset.js";
31
+ import { passwordResetPage } from "./password-reset-page.js";
30
32
  import { anonymousHandle, Handles } from "./handles.js";
31
33
  import { OpenDirs } from "./opendirs.js";
32
34
  import { Servers } from "./servers.js";
@@ -64,6 +66,7 @@ import { createThrottle, presentedCredential } from "@profullstack/throttle";
64
66
  import { Durable } from "./durable.js";
65
67
  import { notifyAll, resendEmail, webPush } from "./notify.js";
66
68
  import { inviteSubject, inviteText, isEmail, isPhone, watchLink } from "./invite.js";
69
+ import { EventWriter } from "./event-writer.js";
67
70
  import { handleLiveApi } from "./live-api.js";
68
71
  import { LiveEvents, eventStructuredData, isTicketed } from "./live-events.js";
69
72
  import { Tickets, needsTicket, ticketFrom, ticketsFromEnv } from "./tickets.js";
@@ -1360,6 +1363,7 @@ export function createHandler(engine, options) {
1360
1363
  const webSite = options.webSites?.get((request.headers.host ?? "").toLowerCase());
1361
1364
  const web = webSite?.web ?? options.web;
1362
1365
  const eventSite = webSite?.site ?? options.invites?.site;
1366
+ const accountSite = eventSite ?? options.site ?? "https://nixamp.com";
1363
1367
  const key = options.key ?? null;
1364
1368
  const behindProxy = options.behindProxy ?? false;
1365
1369
  const listenKey = options.listenKey ?? null;
@@ -1445,6 +1449,7 @@ export function createHandler(engine, options) {
1445
1449
  return;
1446
1450
  if (options.events && await handleLiveApi(request, response, url, {
1447
1451
  events: options.events,
1452
+ ...(options.eventWriter ? { eventWriter: options.eventWriter } : {}),
1448
1453
  ...(options.accounts ? { accounts: options.accounts } : {}),
1449
1454
  ...(options.layouts ? { layouts: options.layouts } : {}),
1450
1455
  ...(options.rooms ? { rooms: options.rooms } : {}),
@@ -1947,6 +1952,20 @@ export function createHandler(engine, options) {
1947
1952
  json(response, 200, { name: "nixamp", version: options.version, media: options.media });
1948
1953
  return;
1949
1954
  }
1955
+ if (path === "/reset-password" && options.accounts?.passwordResets) {
1956
+ if (request.method !== "GET" && request.method !== "HEAD") {
1957
+ json(response, 405, { error: "GET only" });
1958
+ return;
1959
+ }
1960
+ const nonce = randomBytes(18).toString("base64");
1961
+ response.writeHead(200, {
1962
+ "content-type": "text/html; charset=utf-8", "cache-control": "no-store",
1963
+ "referrer-policy": "no-referrer", "x-robots-tag": "noindex, nofollow",
1964
+ "content-security-policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'`,
1965
+ });
1966
+ response.end(request.method === "HEAD" ? undefined : passwordResetPage(accountSite, nonce));
1967
+ return;
1968
+ }
1950
1969
  // --- accounts ---------------------------------------------------------
1951
1970
  //
1952
1971
  // Before the share-key check, because signing in is how somebody without a
@@ -1955,6 +1974,58 @@ export function createHandler(engine, options) {
1955
1974
  if (path.startsWith("/api/v1/auth/") && options.accounts) {
1956
1975
  const accounts = options.accounts;
1957
1976
  const secure = options.secureCookies ?? false;
1977
+ if (path === "/api/v1/auth/password-reset/request" || path === "/api/v1/auth/password-reset/confirm") {
1978
+ if (request.method !== "POST") {
1979
+ json(response, 405, { error: "POST only" });
1980
+ return;
1981
+ }
1982
+ const confirming = path.endsWith("/confirm");
1983
+ if (!accounts.passwordResets || (!confirming && !options.resetMail)) {
1984
+ json(response, 503, { error: "Password reset email is temporarily unavailable. Please try again later." });
1985
+ return;
1986
+ }
1987
+ const who = callerOf(request.headers, request.socket.remoteAddress, behindProxy);
1988
+ const limit = guard.check(`password-reset:${confirming ? "confirm" : "request"}:${who}`, { allowed: confirming ? 15 : 10, windowMs: 15 * 60_000 });
1989
+ if (!limit.ok) {
1990
+ response.setHeader("retry-after", String(limit.retryAfter));
1991
+ json(response, 429, { error: "Too many attempts. Please try again in 15 minutes." });
1992
+ return;
1993
+ }
1994
+ try {
1995
+ const input = JSON.parse(await readBody(request, 4096));
1996
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
1997
+ json(response, 400, { error: "Provide a JSON object." });
1998
+ return;
1999
+ }
2000
+ const body = input;
2001
+ if (confirming) {
2002
+ const error = await accounts.passwordResets.confirm(body["token"], body["password"]);
2003
+ if (error) {
2004
+ json(response, error === RESET_INVALID ? 400 : 422, { error });
2005
+ return;
2006
+ }
2007
+ response.setHeader("set-cookie", clearedCookie());
2008
+ json(response, 200, { message: "Your password has been changed. Sign in with your new password." });
2009
+ }
2010
+ else {
2011
+ const email = typeof body["email"] === "string" ? body["email"].trim().toLowerCase() : "";
2012
+ if (email.length > 320 || !/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(email)) {
2013
+ json(response, 422, { error: "Enter a valid email address." });
2014
+ return;
2015
+ }
2016
+ // Count every address, including nonexistent ones, identically.
2017
+ const address = guard.check(`password-reset:email:${email}`, { allowed: 3, windowMs: 15 * 60_000 });
2018
+ if (address.ok)
2019
+ await accounts.passwordResets.request(email, accountSite, options.resetMail);
2020
+ json(response, 200, { message: RESET_MESSAGE });
2021
+ }
2022
+ }
2023
+ catch (error) {
2024
+ const malformed = error instanceof SyntaxError || error.message === "body too large";
2025
+ json(response, malformed ? 400 : 503, { error: malformed ? "Provide a valid JSON request." : "Password reset is temporarily unavailable. Please try again." });
2026
+ }
2027
+ return;
2028
+ }
1958
2029
  if (path === "/api/v1/auth/me") {
1959
2030
  const who = await accounts.whoIs(tokenFrom(request.headers));
1960
2031
  if (who === null) {
@@ -2409,9 +2480,25 @@ export function createHandler(engine, options) {
2409
2480
  return;
2410
2481
  }
2411
2482
  if (!who)
2412
- throw new SpeechError("Sign in to buy translated audio.", 401);
2483
+ throw new SpeechError("Sign in to use translated audio.", 401);
2413
2484
  if (!guard.check(`translation-pass-account:${who.id}`, { allowed: 30, windowMs: 60_000 }).ok)
2414
2485
  throw new SpeechError("Too many purchase requests.", 429);
2486
+ if (path === "/api/v1/translation-passes/session" && request.method === "POST") {
2487
+ if (!request.headers["content-type"]?.startsWith("application/json"))
2488
+ throw new SpeechError("Send a panel session as JSON.", 415);
2489
+ let body;
2490
+ try {
2491
+ body = JSON.parse(await readBody(request, 1024));
2492
+ }
2493
+ catch {
2494
+ throw new SpeechError("Choose a panel session.", 400);
2495
+ }
2496
+ if (typeof body?.resource !== "string")
2497
+ throw new SpeechError("Choose a panel session.", 400);
2498
+ await service.begin(who.id, body.resource);
2499
+ json(response, 200, await service.access(who.id));
2500
+ return;
2501
+ }
2415
2502
  if (path === "/api/v1/translation-passes/checkout" && request.method === "POST") {
2416
2503
  if (!request.headers["content-type"]?.startsWith("application/json"))
2417
2504
  throw new SpeechError("Send a purchase as JSON.", 415);
@@ -2492,7 +2579,10 @@ export function createHandler(engine, options) {
2492
2579
  return;
2493
2580
  }
2494
2581
  const bytes = await readBytes(request, 484_000);
2495
- json(response, 200, await service.hear(bytes, who.id, controller.signal));
2582
+ const resource = request.headers["x-nixamp-translation-session"];
2583
+ if (resource !== undefined && (typeof resource !== "string" || !/^[\w-]{1,80}$/.test(resource)))
2584
+ throw new SpeechError("Choose a panel session.", 400);
2585
+ json(response, 200, await service.hear(bytes, who.id, controller.signal, undefined, resource));
2496
2586
  }
2497
2587
  else if (path.endsWith("/grant") && request.method === "POST") {
2498
2588
  if (!request.headers["content-type"]?.startsWith("application/json")) {
@@ -5511,6 +5601,10 @@ export async function serve(argv, version = "0.1.0") {
5511
5601
  const favorites = pool ? new Favorites(pool) : undefined;
5512
5602
  const settingsSync = pool ? new SettingsSync(pool) : undefined;
5513
5603
  const nixampSite = (process.env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY).replace(/\/+$/, "");
5604
+ const schoolMailKey = process.env["BACKTOSCHOOL_RESEND_API_KEY"] ?? process.env["RESEND_API_KEY"];
5605
+ const schoolMail = schoolMailKey && process.env["BACKTOSCHOOL_MAIL_FROM"] ? {
5606
+ apiKey: schoolMailKey, from: process.env["BACKTOSCHOOL_MAIL_FROM"],
5607
+ } : undefined;
5514
5608
  const events = pool ? new LiveEvents(pool) : undefined;
5515
5609
  const layouts = pool ? new Layouts(pool) : undefined;
5516
5610
  const rooms = pool ? new Rooms(pool) : undefined;
@@ -6030,7 +6124,10 @@ export async function serve(argv, version = "0.1.0") {
6030
6124
  ...(follows ? { follows, vapidPublicKey } : {}),
6031
6125
  ...(favorites ? { favorites } : {}),
6032
6126
  ...(settingsSync ? { settingsSync } : {}),
6033
- ...(events ? { events } : {}),
6127
+ ...(events ? { events, eventWriter: new EventWriter({
6128
+ openaiKey: process.env["OPENAI_API_KEY"], anthropicKey: process.env["ANTHROPIC_API_KEY"],
6129
+ openaiModel: process.env["NIXAMP_WRITER_OPENAI_MODEL"], claudeModel: process.env["NIXAMP_WRITER_CLAUDE_MODEL"],
6130
+ }) } : {}),
6034
6131
  ...(layouts ? { layouts } : {}),
6035
6132
  ...(rooms ? { rooms } : {}),
6036
6133
  ...(trollbox ? { trollbox } : {}),
@@ -6043,6 +6140,13 @@ export async function serve(argv, version = "0.1.0") {
6043
6140
  ...(translations ? { translations } : {}),
6044
6141
  ...(records ? { records } : {}),
6045
6142
  site: nixampSite,
6143
+ ...(accounts && process.env["RESEND_API_KEY"] ? {
6144
+ resetMail: resendPasswordReset({
6145
+ apiKey: process.env["RESEND_API_KEY"],
6146
+ from: process.env["NIXAMP_MAIL_FROM"] ?? "nixamp <notifications@nixamp.com>",
6147
+ backtoschool: schoolMail,
6148
+ }),
6149
+ } : {}),
6046
6150
  // Other people's OpenProfiles, read for the voice a line is spoken in,
6047
6151
  // and the voices to speak in: ElevenLabs when the Telnyx account holds
6048
6152
  // the key for it (an integration secret named "elevenlabs"), Kokoro
@@ -6065,6 +6169,8 @@ export async function serve(argv, version = "0.1.0") {
6065
6169
  email: resendEmail({
6066
6170
  apiKey: process.env["RESEND_API_KEY"],
6067
6171
  from: process.env["NIXAMP_MAIL_FROM"] ?? "nixamp <notifications@nixamp.com>",
6172
+ backtoschool: schoolMail,
6173
+ reason: "You received this email because a host invited you to join a live session.",
6068
6174
  onEvent: (message) => console.log(message),
6069
6175
  }),
6070
6176
  }
@@ -27,6 +27,7 @@ export type SharedEvent = {
27
27
  } | {
28
28
  type: "error";
29
29
  error: string;
30
+ retryable?: boolean;
30
31
  };
31
32
  type Member = {
32
33
  by: string;
@@ -1,5 +1,6 @@
1
1
  /** One live source/language pipeline, paid access for every listening account.
2
2
  * Browsers receive the same PCM, never upload a replacement for a public feed. */
3
+ import { transientAudioError } from "./live-recovery.js";
3
4
  import { createHash, randomUUID } from "node:crypto";
4
5
  import { lookup } from "node:dns/promises";
5
6
  import { BlockList, isIP } from "node:net";
@@ -93,23 +94,31 @@ export class SharedTranslations {
93
94
  const source = sharedSource(raw);
94
95
  if (!LIVE_VOICE_LANGUAGES.has(language))
95
96
  throw new SpeechError("Choose a supported audio language.", 400);
96
- await this.options.billing?.require(by);
97
- const connections = [...this.feeds.values()].reduce((total, feed) => total + [...feed.members].filter(member => member.by === by).length, 0);
98
- if (connections >= 2)
99
- throw new SpeechError("Translated audio is already open in two players for this account.", 429);
100
97
  const id = `dub-${createHash("sha256").update(`${source}|${language}`).digest("hex").slice(0, 40)}`;
98
+ const capacity = () => {
99
+ const connections = [...this.feeds.values()].reduce((total, feed) => total + [...feed.members].filter(member => member.by === by).length, 0);
100
+ if (connections >= 2)
101
+ throw new SpeechError("Translated audio is already open in two players for this account.", 429);
102
+ const feed = this.feeds.get(id);
103
+ if (!feed && this.feeds.size >= 4)
104
+ throw new SpeechError("Live translation is busy. Try again shortly.", 429);
105
+ if (feed && feed.members.size >= 1000)
106
+ throw new SpeechError("This translated stream is full. Try again shortly.", 429);
107
+ };
108
+ capacity();
109
+ if (this.options.billing?.begin)
110
+ await this.options.billing.begin(by, id);
111
+ else
112
+ await this.options.billing?.require(by, id);
113
+ capacity(); // Other joins can finish during the account/database check.
101
114
  let feed = this.feeds.get(id);
102
115
  if (!feed) {
103
- if (this.feeds.size >= 4)
104
- throw new SpeechError("Live translation is busy. Try again shortly.", 429);
105
116
  feed = this.make(id, source, language);
106
117
  this.feeds.set(id, feed);
107
118
  const current = feed;
108
119
  current.heartbeat = setInterval(() => void this.checkMembers(current), 10_000);
109
120
  current.heartbeat.unref?.();
110
121
  }
111
- if (feed.members.size >= 1000)
112
- throw new SpeechError("This translated stream is full. Try again shortly.", 429);
113
122
  const member = { by, send, close };
114
123
  feed.members.add(member);
115
124
  this.broadcast(feed, { type: "status", listeners: feed.members.size });
@@ -135,18 +144,18 @@ export class SharedTranslations {
135
144
  }
136
145
  async checkMembers(feed) {
137
146
  const accounts = [...new Set([...feed.members].map(member => member.by))];
138
- let funded = new Set(accounts), message = "Your translation balance is used up or expired. Buy another pass to continue.";
147
+ let funded = new Set(accounts), message = "This free session ended and audio credit is used up. Start a remaining free session or buy a pass.";
139
148
  try {
140
149
  if (this.options.billing?.eligible)
141
- funded = new Set(await this.options.billing.eligible(accounts));
150
+ funded = new Set(await this.options.billing.eligible(accounts, feed.id));
142
151
  else if (this.options.billing) {
143
- const results = await Promise.allSettled(accounts.map(by => this.options.billing.require(by)));
152
+ const results = await Promise.allSettled(accounts.map(by => this.options.billing.require(by, feed.id)));
144
153
  funded = new Set(accounts.filter((_by, index) => results[index]?.status === "fulfilled"));
145
154
  }
146
155
  }
147
156
  catch {
148
157
  funded.clear();
149
- message = "Your translation balance could not be checked. Try again shortly.";
158
+ message = "Your translation access could not be checked. Try again shortly.";
150
159
  }
151
160
  for (const member of [...feed.members])
152
161
  if (accounts.includes(member.by) && !funded.has(member.by)) {
@@ -171,17 +180,17 @@ export class SharedTranslations {
171
180
  }
172
181
  }
173
182
  }
174
- stop(feed, error) {
175
- this.broadcast(feed, { type: "error", error });
183
+ stop(feed, error, retryable = false) {
184
+ this.broadcast(feed, { type: "error", error, retryable });
176
185
  for (const member of [...feed.members]) {
177
186
  this.leave(feed, member);
178
187
  member.close();
179
188
  }
180
189
  }
181
190
  make(id, source, language) {
182
- const feed = { id, source, language, members: new Set(), controller: new AbortController(), reservations: new Map(), queue: [], speaking: false, previous: Buffer.alloc(0), pending: Buffer.alloc(0), until: Date.now() };
183
- // Every active account pays the same published access rate. API work is
184
- // done once; additional listeners do not trigger recognition or synthesis.
191
+ const feed = { id, source, language, members: new Set(), controller: new AbortController(), reservations: new Map(), queue: [], speaking: false, voiceFailures: 0, previous: Buffer.alloc(0), pending: Buffer.alloc(0), until: Date.now() };
192
+ // Each account uses free access first, then the same published credit rate.
193
+ // Additional listeners do not trigger recognition or synthesis.
185
194
  feed.meter = {
186
195
  require: async () => { if (!feed.members.size)
187
196
  throw new SpeechError("No listeners.", 410); },
@@ -191,13 +200,13 @@ export class SharedTranslations {
191
200
  const accepted = new Set();
192
201
  const billing = this.options.billing;
193
202
  if (billing?.reserveMany) {
194
- for (const charged of await billing.reserveMany(accounts, kind, units)) {
203
+ for (const charged of await billing.reserveMany(accounts, kind, units, feed.id)) {
195
204
  ids.push(charged.id);
196
205
  accepted.add(charged.by);
197
206
  }
198
207
  }
199
208
  else {
200
- const results = await Promise.allSettled(accounts.map(async (account) => ({ by: account, id: await billing?.reserve(account, kind, units) })));
209
+ const results = await Promise.allSettled(accounts.map(async (account) => ({ by: account, id: await billing?.reserve(account, kind, units, feed.id) })));
201
210
  let failure;
202
211
  for (const result of results) {
203
212
  if (result.status === "fulfilled") {
@@ -216,7 +225,7 @@ export class SharedTranslations {
216
225
  for (const member of [...feed.members])
217
226
  if (accounts.includes(member.by) && !accepted.has(member.by)) {
218
227
  try {
219
- member.send({ type: "error", error: "Your translation balance is used up or expired. Buy another pass to continue." });
228
+ member.send({ type: "error", error: "This free session ended and audio credit is used up. Start a remaining free session or buy a pass." });
220
229
  }
221
230
  catch { /* disconnected */ }
222
231
  finally {
@@ -246,13 +255,10 @@ export class SharedTranslations {
246
255
  language: () => language, speakers: () => true, voices: () => voices, channel: () => id,
247
256
  status: text => { if (!feed.controller.signal.aborted && !feed.members.size)
248
257
  this.stop(feed, text); },
249
- failed: () => this.stop(feed, "Live translation stopped. Enable it again to retry."),
258
+ failed: message => this.stop(feed, message),
250
259
  lines: lines => {
251
260
  feed.queue.push(...lines);
252
- if (feed.queue.length > 24) {
253
- this.stop(feed, "Live translation fell behind. Enable it again to retry.");
254
- return;
255
- }
261
+ feed.queue = feed.queue.filter(line => Date.now() - line.until <= 12_000).slice(-24);
256
262
  if (!feed.speaking)
257
263
  void this.speak(feed);
258
264
  },
@@ -288,7 +294,7 @@ export class SharedTranslations {
288
294
  samples[index] = pcm.readInt16LE(index * 2) / 32768;
289
295
  feed.interpreter.push({ samples, at: feed.until - samples.length / 16, until: feed.until, freshAt: feed.until - 2000 });
290
296
  }
291
- }, () => this.stop(feed, "The live source stopped. Enable translated audio again when playback resumes."), feed.controller.signal);
297
+ }, () => this.stop(feed, "The live source disconnected.", true), feed.controller.signal);
292
298
  if (feed.controller.signal.aborted)
293
299
  input.stop();
294
300
  else
@@ -304,24 +310,35 @@ export class SharedTranslations {
304
310
  while (feed.queue.length && !feed.controller.signal.aborted) {
305
311
  const line = feed.queue.shift();
306
312
  if (Date.now() - line.until > 12_000)
307
- throw new SpeechError("Live translation fell behind. Enable it again to retry.", 503);
308
- const response = await this.options.voice.stream({ text: line.text, language: feed.language, channel: feed.id, speaker: line.speaker, voice: feed.interpreter.tracker.speakers.get(line.speaker ?? "")?.voice }, feed.id, feed.controller.signal, feed.meter);
309
- const id = randomUUID();
310
- this.broadcast(feed, { type: "line", id, line });
311
- const reader = response.body.getReader();
313
+ continue;
312
314
  try {
313
- while (true) {
314
- const { done, value } = await reader.read();
315
- if (done)
316
- break;
317
- for (let at = 0; at < value.length; at += 16_384)
318
- this.broadcast(feed, { type: "audio", id, data: Buffer.from(value.subarray(at, at + 16_384)).toString("base64") });
315
+ const response = await this.options.voice.stream({ text: line.text, language: feed.language, channel: feed.id, speaker: line.speaker, voice: feed.interpreter.tracker.speakers.get(line.speaker ?? "")?.voice }, feed.id, feed.controller.signal, feed.meter);
316
+ const id = randomUUID();
317
+ this.broadcast(feed, { type: "line", id, line });
318
+ const reader = response.body.getReader();
319
+ try {
320
+ while (true) {
321
+ const { done, value } = await reader.read();
322
+ if (done)
323
+ break;
324
+ for (let at = 0; at < value.length; at += 16_384)
325
+ this.broadcast(feed, { type: "audio", id, data: Buffer.from(value.subarray(at, at + 16_384)).toString("base64") });
326
+ }
319
327
  }
328
+ finally {
329
+ reader.releaseLock();
330
+ this.broadcast(feed, { type: "end", id });
331
+ }
332
+ feed.voiceFailures = 0;
320
333
  }
321
- finally {
322
- reader.releaseLock();
334
+ catch (error) {
335
+ if (feed.controller.signal.aborted)
336
+ return;
337
+ if (!transientAudioError(error) || ++feed.voiceFailures >= 3)
338
+ throw error;
339
+ // A provider failure skips this phrase; the source and other listeners
340
+ // remain connected. Never replay partial speech or retry a charge.
323
341
  }
324
- this.broadcast(feed, { type: "end", id });
325
342
  }
326
343
  }
327
344
  catch (error) {
package/dist/tokens.d.ts CHANGED
@@ -56,6 +56,8 @@ export declare class Tokens {
56
56
  * sign in is a setup step too many.
57
57
  */
58
58
  private ensure;
59
+ /** Recovery revokes this table in the same transaction as the password update. */
60
+ initialize(): Promise<void>;
59
61
  issue(options: IssueOptions): Promise<IssuedToken>;
60
62
  /** The account a token belongs to, or null for one this server will not accept. */
61
63
  verify(value: string): Promise<Account | null>;
package/dist/tokens.js CHANGED
@@ -111,6 +111,8 @@ export class Tokens {
111
111
  this.ready ??= this.db.query(SCHEMA).then(() => undefined);
112
112
  await this.ready;
113
113
  }
114
+ /** Recovery revokes this table in the same transaction as the password update. */
115
+ async initialize() { await this.ensure(); }
114
116
  async issue(options) {
115
117
  await this.ensure();
116
118
  const { id, secret, token } = mintToken();
@@ -1,4 +1,5 @@
1
1
  import type { Queryable } from "./follows.ts";
2
+ import { UpgradeAllowances, type UpgradeAllowance } from "./upgrade-allowance.ts";
2
3
  export declare const TRANSLATION_MULTIPLIER = 5;
3
4
  export declare const TRANSLATION_PLANS: readonly [{
4
5
  readonly id: "day";
@@ -19,10 +20,11 @@ export declare const TRANSLATION_PLANS: readonly [{
19
20
  export type TranslationPlan = typeof TRANSLATION_PLANS[number];
20
21
  export type TranslationUsage = "transcription" | "voice";
21
22
  export interface TranslationMeter {
22
- require(by: string): Promise<void>;
23
- eligible?(accounts: string[]): Promise<string[]>;
24
- reserve(by: string, kind: TranslationUsage, units: number): Promise<string>;
25
- reserveMany?(accounts: string[], kind: TranslationUsage, units: number): Promise<{
23
+ begin?(by: string, resource: string): Promise<void>;
24
+ require(by: string, resource?: string): Promise<void>;
25
+ eligible?(accounts: string[], resource?: string): Promise<string[]>;
26
+ reserve(by: string, kind: TranslationUsage, units: number, resource?: string): Promise<string>;
27
+ reserveMany?(accounts: string[], kind: TranslationUsage, units: number, resource?: string): Promise<{
26
28
  by: string;
27
29
  id: string;
28
30
  }[]>;
@@ -48,6 +50,7 @@ export interface TranslationAccess {
48
50
  voiceThousand: number;
49
51
  multiplier: number;
50
52
  };
53
+ free: UpgradeAllowance;
51
54
  }
52
55
  /** At the published rates: Scribe $0.22/submitted hour, Flash $0.05/1k chars.
53
56
  * Transcription units are 16 kHz samples; overlap is charged exactly as sent. */
@@ -63,6 +66,7 @@ export declare class TranslationPasses implements TranslationMeter {
63
66
  private merchantUntil;
64
67
  private readonly fetcher;
65
68
  private readonly now;
69
+ readonly free: UpgradeAllowances;
66
70
  constructor(options: {
67
71
  db: Queryable;
68
72
  key: string;
@@ -74,11 +78,12 @@ export declare class TranslationPasses implements TranslationMeter {
74
78
  private provider;
75
79
  private settings;
76
80
  access(by?: string): Promise<TranslationAccess>;
77
- require(by: string): Promise<void>;
78
- eligible(accounts: string[]): Promise<string[]>;
79
- reserve(by: string, kind: TranslationUsage, units: number): Promise<string>;
81
+ begin(by: string, resource: string): Promise<void>;
82
+ require(by: string, resource?: string): Promise<void>;
83
+ eligible(accounts: string[], resource?: string): Promise<string[]>;
84
+ reserve(by: string, kind: TranslationUsage, units: number, resource?: string): Promise<string>;
80
85
  /** Batch a shared stream's access charges in one database round trip. */
81
- reserveMany(accounts: string[], kind: TranslationUsage, units: number): Promise<{
86
+ reserveMany(accounts: string[], kind: TranslationUsage, units: number, resource?: string): Promise<{
82
87
  by: string;
83
88
  id: string;
84
89
  }[]>;