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,108 @@
1
+ function escape(value: string): string {
2
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll('"', "&quot;");
3
+ }
4
+
5
+ /** A small recovery page shared by every branded client on this account server. */
6
+ export function passwordResetPage(site: string, nonce: string): string {
7
+ const host = new URL(site).hostname;
8
+ const school = host === "backtoschool.help" || host === "www.backtoschool.help";
9
+ const name = school ? "BackToSchool.help" : host;
10
+ const home = school ? "/#live" : "/";
11
+ return `<!doctype html><html lang="en"><head>
12
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
13
+ <meta name="referrer" content="no-referrer"><meta name="robots" content="noindex, nofollow">
14
+ <title>Reset your password — ${escape(name)}</title>
15
+ <style>
16
+ :root{font-family:system-ui,sans-serif;color:#34246d;background:#f7f3e9;color-scheme:light}
17
+ *{box-sizing:border-box}body{margin:0}header{padding:1.25rem max(1rem,calc((100% - 960px)/2));border-bottom:1px solid #ddd5e6}
18
+ a{color:#34246d;text-underline-offset:3px}header a{font-weight:800;text-decoration:none}
19
+ main{max-width:480px;margin:clamp(2rem,8vh,5rem) auto;padding:0 1rem 3rem}
20
+ .card{background:#fffdf8;border:1px solid #ddd5e6;border-radius:20px;padding:clamp(1.25rem,5vw,2rem)}
21
+ h1{font-family:Georgia,serif;font-size:2.25rem;line-height:1.1;margin:0 0 1rem}p{line-height:1.55}
22
+ label{display:block;font-weight:650;margin:1.25rem 0 .5rem}input{width:100%;font:inherit;padding:.85rem;border:1px solid #8a829f;border-radius:10px;background:white;color:#211747}
23
+ button{width:100%;font:inherit;font-weight:700;border:0;border-radius:30px;background:#34246d;color:white;padding:.9rem 1.1rem;margin-top:1.25rem;cursor:pointer}
24
+ button[aria-disabled=true]{opacity:.65;cursor:wait}a:focus-visible,input:focus-visible,button:focus-visible{outline:3px solid #b14b29;outline-offset:3px}
25
+ .help{font-size:.9rem;color:#514961}.message:empty{display:none}.error{color:#9b202a}.success{color:#215d3d}
26
+ .back{display:inline-block;margin-top:1.5rem}[hidden]{display:none!important}
27
+ @media(forced-colors:active){.card,button{border:1px solid CanvasText}}
28
+ </style></head><body>
29
+ <header><a href="${home}">${escape(name)}</a></header>
30
+ <main><div class="card">
31
+ <h1 id="title">Forgot your password?</h1>
32
+ <p id="intro">Enter your account’s email address and we’ll send you a link to choose a new password.</p>
33
+ <form id="request-form">
34
+ <label for="email">Email</label><input id="email" name="email" type="email" autocomplete="email" maxlength="320" required>
35
+ <button type="submit">Send reset link</button>
36
+ </form>
37
+ <form id="reset-form" hidden>
38
+ <label for="password">New password</label><input id="password" name="password" type="password" autocomplete="new-password" minlength="8" maxlength="200" aria-describedby="password-help" required>
39
+ <p class="help" id="password-help">Use at least 8 characters, including a number.</p>
40
+ <label for="confirm">Confirm new password</label><input id="confirm" name="confirm" type="password" autocomplete="new-password" minlength="8" maxlength="200" required>
41
+ <button type="submit">Save new password</button>
42
+ </form>
43
+ <p id="status" class="message success" role="status" aria-live="polite"></p>
44
+ <p id="error" class="message error" role="alert"></p>
45
+ <p id="another" hidden><a href="/reset-password">Request a new reset link</a></p>
46
+ <a class="back" id="back" href="${home}">Back to sign in</a>
47
+ <noscript><p>Enable JavaScript to reset your password.</p></noscript>
48
+ </div></main>
49
+ <script nonce="${nonce}">${SCRIPT}</script></body></html>`;
50
+ }
51
+
52
+ const SCRIPT = String.raw`
53
+ (() => {
54
+ const requestForm = document.querySelector('#request-form');
55
+ const resetForm = document.querySelector('#reset-form');
56
+ const status = document.querySelector('#status');
57
+ const error = document.querySelector('#error');
58
+ let token = ''; let pending = false; let done = false;
59
+ function readLink() {
60
+ const incoming = new URLSearchParams(location.hash.slice(1)).get('token');
61
+ if (location.hash) history.replaceState(null, '', location.pathname);
62
+ if (!incoming) return;
63
+ token = incoming; done = false;
64
+ status.textContent = ''; error.textContent = '';
65
+ document.querySelector('#another').hidden = true;
66
+ for (const field of resetForm.querySelectorAll('input')) {field.value = ''; field.readOnly = false;}
67
+ const button = resetForm.querySelector('button');
68
+ button.textContent = 'Save new password'; button.removeAttribute('aria-disabled');
69
+ requestForm.hidden = true; resetForm.hidden = false;
70
+ document.querySelector('#title').textContent = 'Choose a new password';
71
+ document.querySelector('#intro').textContent = 'Your new password works here and on NixAmp. Existing sessions will be signed out.';
72
+ }
73
+ readLink(); window.addEventListener('hashchange', readLink);
74
+ async function submit(form, path, input) {
75
+ if (pending || done) return;
76
+ pending = true; error.textContent = ''; status.textContent = '';
77
+ const button = form.querySelector('button');
78
+ button.setAttribute('aria-disabled', 'true'); form.setAttribute('aria-busy', 'true');
79
+ try {
80
+ const response = await fetch(path, {method:'POST', credentials:'same-origin', headers:{'content-type':'application/json'}, body:JSON.stringify(input)});
81
+ const result = await response.json();
82
+ if (!response.ok) {
83
+ if (form === resetForm && response.status === 400) document.querySelector('#another').hidden = false;
84
+ throw new Error(result.error || 'That did not work. Please try again.');
85
+ }
86
+ status.textContent = result.message;
87
+ if (form === resetForm) {
88
+ done = true; token = '';
89
+ for (const field of form.querySelectorAll('input')) {field.value = ''; field.readOnly = true;}
90
+ button.textContent = 'Password saved';
91
+ document.querySelector('#back').textContent = 'Sign in with your new password';
92
+ }
93
+ } catch (problem) {error.textContent = problem instanceof TypeError ? 'Could not connect. Please try again.' : problem.message;}
94
+ finally {pending = false; form.removeAttribute('aria-busy'); if (!done) button.removeAttribute('aria-disabled');}
95
+ }
96
+ requestForm.addEventListener('submit', event => {
97
+ event.preventDefault();
98
+ void submit(requestForm, '/api/v1/auth/password-reset/request', {email:requestForm.elements.email.value.trim()});
99
+ });
100
+ resetForm.addEventListener('submit', event => {
101
+ event.preventDefault();
102
+ if (pending || done) return;
103
+ const password = resetForm.elements.password.value;
104
+ if (password !== resetForm.elements.confirm.value) {error.textContent = 'The passwords do not match.'; return;}
105
+ if (!/[0-9]/.test(password)) {error.textContent = 'Include at least one number in your password.'; return;}
106
+ void submit(resetForm, '/api/v1/auth/password-reset/confirm', {token, password});
107
+ });
108
+ })();`;
@@ -0,0 +1,134 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import type { Queryable } from "./follows.ts";
3
+ import type { Tokens } from "./tokens.ts";
4
+ import { mailSender, type BrandedMailSender } from "./mail-sender.ts";
5
+
6
+ export const RESET_MESSAGE = "If an account exists for that email, you’ll receive a password reset link shortly.";
7
+ export const RESET_INVALID = "This reset link is invalid, expired, or already used. Request a new link.";
8
+ const LIFETIME_MS = 30 * 60_000;
9
+ const SCHEMA = `
10
+ CREATE TABLE IF NOT EXISTS nixamp_password_resets (
11
+ user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
12
+ token_hash TEXT UNIQUE,
13
+ password_hash TEXT,
14
+ expires_at TIMESTAMPTZ,
15
+ reset_at TIMESTAMPTZ
16
+ );
17
+ `;
18
+
19
+ interface Passwords {
20
+ hashPassword(password: string): Promise<string>;
21
+ validatePassword(password: string): { valid: boolean; message: string };
22
+ }
23
+
24
+ export type ResetMail = (email: string, link: string) => Promise<boolean>;
25
+
26
+ function digest(token: string): string {
27
+ return createHash("sha256").update(token).digest("hex");
28
+ }
29
+
30
+ export class PasswordResets {
31
+ private ready: Promise<void> | null = null;
32
+
33
+ constructor(
34
+ private readonly db: Queryable & { initialize?(): Promise<void> },
35
+ private readonly tokens: Tokens,
36
+ private readonly passwords: Passwords,
37
+ ) {}
38
+
39
+ private async ensure(): Promise<void> {
40
+ this.ready ??= (async () => {
41
+ await this.db.initialize?.();
42
+ await this.tokens.initialize();
43
+ await this.db.query(SCHEMA);
44
+ })().catch((error) => { this.ready = null; throw error; });
45
+ await this.ready;
46
+ }
47
+
48
+ /** The public response is identical for registered and unknown addresses. */
49
+ async request(email: string, site: string, deliver: ResetMail): Promise<void> {
50
+ await this.ensure();
51
+ const token = randomBytes(32).toString("base64url");
52
+ const { rows } = await this.db.query(
53
+ `INSERT INTO nixamp_password_resets (user_id, token_hash, password_hash, expires_at)
54
+ SELECT id, $2, password, now() + interval '30 minutes' FROM users WHERE lower(email) = $1
55
+ ON CONFLICT (user_id) DO UPDATE SET token_hash = EXCLUDED.token_hash,
56
+ password_hash = EXCLUDED.password_hash, expires_at = EXCLUDED.expires_at
57
+ RETURNING user_id`,
58
+ [email.trim().toLowerCase(), digest(token)],
59
+ );
60
+ if (!rows.length) return;
61
+ // Fragments never reach HTTP logs or referrers. Email delivery also never
62
+ // changes the HTTP response (or its timing) according to account existence.
63
+ const link = `${site.replace(/\/+$/, "")}/reset-password#token=${token}`;
64
+ void Promise.resolve().then(() => deliver(email.trim().toLowerCase(), link)).then((sent) => {
65
+ if (!sent) console.error("nixamp: password reset email delivery failed");
66
+ }).catch(() => console.error("nixamp: password reset email delivery failed"));
67
+ }
68
+
69
+ /** Claim the link, change the password, and revoke sessions in one statement. */
70
+ async confirm(token: unknown, password: unknown): Promise<string | null> {
71
+ if (typeof token !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(token)) return RESET_INVALID;
72
+ if (typeof password !== "string" || password.length > 200) return "Enter a password between 8 and 200 characters.";
73
+ const validation = this.passwords.validatePassword(password);
74
+ if (!validation.valid) return validation.message;
75
+ await this.ensure();
76
+ const tokenHash = digest(token);
77
+ // Reject bad tokens before doing the deliberately expensive password hash.
78
+ const candidate = await this.db.query(
79
+ `SELECT user_id FROM nixamp_password_resets WHERE token_hash = $1 AND expires_at > now()`, [tokenHash],
80
+ );
81
+ if (!candidate.rows.length) return RESET_INVALID;
82
+ const hashed = await this.passwords.hashPassword(password);
83
+ const { rows } = await this.db.query(
84
+ `WITH consumed AS (
85
+ UPDATE nixamp_password_resets r SET token_hash = NULL, expires_at = NULL,
86
+ password_hash = NULL, reset_at = clock_timestamp()
87
+ FROM users u WHERE r.token_hash = $1 AND r.expires_at > now()
88
+ AND u.id = r.user_id AND r.password_hash IS NOT DISTINCT FROM u.password
89
+ RETURNING r.user_id
90
+ ), changed AS (
91
+ UPDATE users u SET password = $2, updated_at = now()
92
+ FROM consumed c WHERE u.id = c.user_id RETURNING u.id
93
+ ), revoked AS (
94
+ DELETE FROM nixamp_tokens t USING changed c WHERE t.user_id = c.id RETURNING t.id
95
+ ) SELECT id FROM changed`,
96
+ [tokenHash, hashed],
97
+ );
98
+ return rows.length ? null : RESET_INVALID;
99
+ }
100
+
101
+ /** Older JWT sessions also stop working after recovery, across restarts. */
102
+ async acceptsLegacyToken(userId: string, token: string): Promise<boolean> {
103
+ await this.ensure();
104
+ const { rows } = await this.db.query("SELECT reset_at FROM nixamp_password_resets WHERE user_id = $1", [userId]);
105
+ if (!rows[0]?.["reset_at"]) return true;
106
+ try {
107
+ // Called only after the auth library has verified the JWT signature.
108
+ const claims = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
109
+ const issued = claims.iat;
110
+ const reset = rows[0]["reset_at"];
111
+ const resetAt = reset instanceof Date ? reset.getTime() : new Date(String(reset)).getTime();
112
+ return typeof issued === "number" && Number.isFinite(issued)
113
+ && issued * 1000 >= resetAt;
114
+ } catch { return false; }
115
+ }
116
+ }
117
+
118
+ /** Transactional recovery mail has no follower footer and never logs a token. */
119
+ export function resendPasswordReset(options: BrandedMailSender & { fetch?: typeof globalThis.fetch }): ResetMail {
120
+ return async (email, link) => {
121
+ const host = new URL(link).hostname;
122
+ const sender = mailSender(options, link);
123
+ const response = await (options.fetch ?? globalThis.fetch)("https://api.resend.com/emails", {
124
+ method: "POST",
125
+ headers: { authorization: `Bearer ${sender.apiKey}`, "content-type": "application/json" },
126
+ signal: AbortSignal.timeout(15_000),
127
+ body: JSON.stringify({
128
+ from: sender.from, to: [email], subject: `Reset your ${host} password`,
129
+ 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.`,
130
+ }),
131
+ });
132
+ return response.ok;
133
+ };
134
+ }
package/src/server.ts CHANGED
@@ -37,6 +37,8 @@ import {
37
37
  } from "./channels.ts";
38
38
  import { RtmpListeners } from "./rtmp-in.ts";
39
39
  import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.ts";
40
+ import { RESET_INVALID, RESET_MESSAGE, resendPasswordReset, type ResetMail } from "./password-reset.ts";
41
+ import { passwordResetPage } from "./password-reset-page.ts";
40
42
  import { anonymousHandle, Handles } from "./handles.ts";
41
43
  import { OpenDirs } from "./opendirs.ts";
42
44
  import { Servers } from "./servers.ts";
@@ -85,6 +87,7 @@ import { createThrottle, presentedCredential, type Throttle } from "@profullstac
85
87
  import { Durable } from "./durable.ts";
86
88
  import { notifyAll, resendEmail, webPush, type Notification } from "./notify.ts";
87
89
  import { inviteSubject, inviteText, isEmail, isPhone, watchLink } from "./invite.ts";
90
+ import { EventWriter } from "./event-writer.ts";
88
91
  import { handleLiveApi } from "./live-api.ts";
89
92
  import { LiveEvents, eventStructuredData, isTicketed, type LiveEvent } from "./live-events.ts";
90
93
  import { Tickets, needsTicket, ticketFrom, ticketsFromEnv } from "./tickets.ts";
@@ -1717,6 +1720,8 @@ export interface HandlerOptions {
1717
1720
  publishUrls?: () => { id: string; url: string }[];
1718
1721
  /** Accounts, on the instance that keeps them. Only nixamp.com passes this. */
1719
1722
  accounts?: Accounts;
1723
+ /** Transactional password recovery mail, separate from follow notifications. */
1724
+ resetMail?: ResetMail;
1720
1725
  /** Providers to sign in with, and the terminals waiting to be connected. */
1721
1726
  signIn?: SignIn;
1722
1727
  /** The servers each account runs, on the instance that keeps accounts. */
@@ -1755,6 +1760,7 @@ export interface HandlerOptions {
1755
1760
  settingsSync?: SettingsSync;
1756
1761
  /** Scheduled and live sessions, kept by NixAmp and shared by branded clients. */
1757
1762
  events?: LiveEvents;
1763
+ eventWriter?: EventWriter;
1758
1764
  /** Versioned panel layouts, including event and user overrides. */
1759
1765
  layouts?: Layouts;
1760
1766
  /** Persistent participation state that must not be coupled to live audio. */
@@ -1852,6 +1858,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
1852
1858
  const webSite = options.webSites?.get((request.headers.host ?? "").toLowerCase());
1853
1859
  const web = webSite?.web ?? options.web;
1854
1860
  const eventSite = webSite?.site ?? options.invites?.site;
1861
+ const accountSite = eventSite ?? options.site ?? "https://nixamp.com";
1855
1862
  const key = options.key ?? null;
1856
1863
  const behindProxy = options.behindProxy ?? false;
1857
1864
  const listenKey = options.listenKey ?? null;
@@ -1939,6 +1946,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
1939
1946
 
1940
1947
  if (options.events && await handleLiveApi(request, response, url, {
1941
1948
  events: options.events,
1949
+ ...(options.eventWriter ? {eventWriter: options.eventWriter} : {}),
1942
1950
  ...(options.accounts ? { accounts: options.accounts } : {}),
1943
1951
  ...(options.layouts ? { layouts: options.layouts } : {}),
1944
1952
  ...(options.rooms ? { rooms: options.rooms } : {}),
@@ -2460,6 +2468,20 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2460
2468
  return;
2461
2469
  }
2462
2470
 
2471
+ if (path === "/reset-password" && options.accounts?.passwordResets) {
2472
+ if (request.method !== "GET" && request.method !== "HEAD") {
2473
+ json(response, 405, { error: "GET only" }); return;
2474
+ }
2475
+ const nonce = randomBytes(18).toString("base64");
2476
+ response.writeHead(200, {
2477
+ "content-type": "text/html; charset=utf-8", "cache-control": "no-store",
2478
+ "referrer-policy": "no-referrer", "x-robots-tag": "noindex, nofollow",
2479
+ "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'`,
2480
+ });
2481
+ response.end(request.method === "HEAD" ? undefined : passwordResetPage(accountSite, nonce));
2482
+ return;
2483
+ }
2484
+
2463
2485
  // --- accounts ---------------------------------------------------------
2464
2486
  //
2465
2487
  // Before the share-key check, because signing in is how somebody without a
@@ -2469,6 +2491,46 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2469
2491
  const accounts = options.accounts;
2470
2492
  const secure = options.secureCookies ?? false;
2471
2493
 
2494
+ if (path === "/api/v1/auth/password-reset/request" || path === "/api/v1/auth/password-reset/confirm") {
2495
+ if (request.method !== "POST") { json(response, 405, { error: "POST only" }); return; }
2496
+ const confirming = path.endsWith("/confirm");
2497
+ if (!accounts.passwordResets || (!confirming && !options.resetMail)) {
2498
+ json(response, 503, { error: "Password reset email is temporarily unavailable. Please try again later." }); return;
2499
+ }
2500
+ const who = callerOf(request.headers, request.socket.remoteAddress, behindProxy);
2501
+ const limit = guard.check(`password-reset:${confirming ? "confirm" : "request"}:${who}`, { allowed: confirming ? 15 : 10, windowMs: 15 * 60_000 });
2502
+ if (!limit.ok) {
2503
+ response.setHeader("retry-after", String(limit.retryAfter));
2504
+ json(response, 429, { error: "Too many attempts. Please try again in 15 minutes." }); return;
2505
+ }
2506
+ try {
2507
+ const input: unknown = JSON.parse(await readBody(request, 4096));
2508
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
2509
+ json(response, 400, { error: "Provide a JSON object." }); return;
2510
+ }
2511
+ const body = input as Record<string, unknown>;
2512
+ if (confirming) {
2513
+ const error = await accounts.passwordResets.confirm(body["token"], body["password"]);
2514
+ if (error) { json(response, error === RESET_INVALID ? 400 : 422, { error }); return; }
2515
+ response.setHeader("set-cookie", clearedCookie());
2516
+ json(response, 200, { message: "Your password has been changed. Sign in with your new password." });
2517
+ } else {
2518
+ const email = typeof body["email"] === "string" ? body["email"].trim().toLowerCase() : "";
2519
+ if (email.length > 320 || !/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(email)) {
2520
+ json(response, 422, { error: "Enter a valid email address." }); return;
2521
+ }
2522
+ // Count every address, including nonexistent ones, identically.
2523
+ const address = guard.check(`password-reset:email:${email}`, { allowed: 3, windowMs: 15 * 60_000 });
2524
+ if (address.ok) await accounts.passwordResets.request(email, accountSite, options.resetMail!);
2525
+ json(response, 200, { message: RESET_MESSAGE });
2526
+ }
2527
+ } catch (error) {
2528
+ const malformed = error instanceof SyntaxError || (error as Error).message === "body too large";
2529
+ json(response, malformed ? 400 : 503, { error: malformed ? "Provide a valid JSON request." : "Password reset is temporarily unavailable. Please try again." });
2530
+ }
2531
+ return;
2532
+ }
2533
+
2472
2534
  if (path === "/api/v1/auth/me") {
2473
2535
  const who = await accounts.whoIs(tokenFrom(request.headers));
2474
2536
  if (who === null) {
@@ -2909,8 +2971,16 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2909
2971
  if (path === "/api/v1/translation-passes" && request.method === "GET") {
2910
2972
  json(response, 200, await service.access(who?.id)); return;
2911
2973
  }
2912
- if (!who) throw new SpeechError("Sign in to buy translated audio.", 401);
2974
+ if (!who) throw new SpeechError("Sign in to use translated audio.", 401);
2913
2975
  if (!guard.check(`translation-pass-account:${who.id}`, { allowed: 30, windowMs: 60_000 }).ok) throw new SpeechError("Too many purchase requests.", 429);
2976
+ if (path === "/api/v1/translation-passes/session" && request.method === "POST") {
2977
+ if (!request.headers["content-type"]?.startsWith("application/json")) throw new SpeechError("Send a panel session as JSON.", 415);
2978
+ let body: { resource?: unknown };
2979
+ try { body = JSON.parse(await readBody(request, 1024)); } catch { throw new SpeechError("Choose a panel session.", 400); }
2980
+ if (typeof body?.resource !== "string") throw new SpeechError("Choose a panel session.", 400);
2981
+ await service.begin(who.id, body.resource);
2982
+ json(response, 200, await service.access(who.id)); return;
2983
+ }
2914
2984
  if (path === "/api/v1/translation-passes/checkout" && request.method === "POST") {
2915
2985
  if (!request.headers["content-type"]?.startsWith("application/json")) throw new SpeechError("Send a purchase as JSON.", 415);
2916
2986
  if (!guard.check(`translation-checkout-ip:${caller}`, { allowed: 10, windowMs: 3600_000 }).ok) throw new SpeechError("Too many new checkouts. Resume your pending purchase.", 429);
@@ -2959,7 +3029,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2959
3029
  } else if (path.endsWith("/speakers") && request.method === "POST") {
2960
3030
  if (!request.headers["content-type"]?.startsWith("audio/wav")) { json(response, 415, { error: "send a mono 16 kHz WAV" }); return; }
2961
3031
  const bytes = await readBytes(request, 484_000);
2962
- json(response, 200, await service.hear(bytes, who.id, controller.signal));
3032
+ const resource = request.headers["x-nixamp-translation-session"];
3033
+ if (resource !== undefined && (typeof resource !== "string" || !/^[\w-]{1,80}$/.test(resource))) throw new SpeechError("Choose a panel session.", 400);
3034
+ json(response, 200, await service.hear(bytes, who.id, controller.signal, undefined, resource));
2963
3035
  } else if (path.endsWith("/grant") && request.method === "POST") {
2964
3036
  if (!request.headers["content-type"]?.startsWith("application/json")) { json(response, 415, { error: "send JSON" }); return; }
2965
3037
  let body: { channel?: unknown };
@@ -6054,6 +6126,10 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
6054
6126
  const favorites = pool ? new Favorites(pool) : undefined;
6055
6127
  const settingsSync = pool ? new SettingsSync(pool) : undefined;
6056
6128
  const nixampSite = (process.env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY).replace(/\/+$/, "");
6129
+ const schoolMailKey = process.env["BACKTOSCHOOL_RESEND_API_KEY"] ?? process.env["RESEND_API_KEY"];
6130
+ const schoolMail = schoolMailKey && process.env["BACKTOSCHOOL_MAIL_FROM"] ? {
6131
+ apiKey: schoolMailKey, from: process.env["BACKTOSCHOOL_MAIL_FROM"],
6132
+ } : undefined;
6057
6133
  const events = pool ? new LiveEvents(pool) : undefined;
6058
6134
  const layouts = pool ? new Layouts(pool) : undefined;
6059
6135
  const rooms = pool ? new Rooms(pool) : undefined;
@@ -6582,7 +6658,10 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
6582
6658
  ...(follows ? { follows, vapidPublicKey } : {}),
6583
6659
  ...(favorites ? { favorites } : {}),
6584
6660
  ...(settingsSync ? { settingsSync } : {}),
6585
- ...(events ? { events } : {}),
6661
+ ...(events ? { events, eventWriter: new EventWriter({
6662
+ openaiKey: process.env["OPENAI_API_KEY"], anthropicKey: process.env["ANTHROPIC_API_KEY"],
6663
+ openaiModel: process.env["NIXAMP_WRITER_OPENAI_MODEL"], claudeModel: process.env["NIXAMP_WRITER_CLAUDE_MODEL"],
6664
+ }) } : {}),
6586
6665
  ...(layouts ? { layouts } : {}),
6587
6666
  ...(rooms ? { rooms } : {}),
6588
6667
  ...(trollbox ? { trollbox } : {}),
@@ -6595,6 +6674,13 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
6595
6674
  ...(translations ? { translations } : {}),
6596
6675
  ...(records ? { records } : {}),
6597
6676
  site: nixampSite,
6677
+ ...(accounts && process.env["RESEND_API_KEY"] ? {
6678
+ resetMail: resendPasswordReset({
6679
+ apiKey: process.env["RESEND_API_KEY"],
6680
+ from: process.env["NIXAMP_MAIL_FROM"] ?? "nixamp <notifications@nixamp.com>",
6681
+ backtoschool: schoolMail,
6682
+ }),
6683
+ } : {}),
6598
6684
  // Other people's OpenProfiles, read for the voice a line is spoken in,
6599
6685
  // and the voices to speak in: ElevenLabs when the Telnyx account holds
6600
6686
  // the key for it (an integration secret named "elevenlabs"), Kokoro
@@ -6617,6 +6703,8 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
6617
6703
  email: resendEmail({
6618
6704
  apiKey: process.env["RESEND_API_KEY"],
6619
6705
  from: process.env["NIXAMP_MAIL_FROM"] ?? "nixamp <notifications@nixamp.com>",
6706
+ backtoschool: schoolMail,
6707
+ reason: "You received this email because a host invited you to join a live session.",
6620
6708
  onEvent: (message) => console.log(message),
6621
6709
  }),
6622
6710
  }
@@ -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.ts";
3
4
  import { createHash, randomUUID } from "node:crypto";
4
5
  import { lookup } from "node:dns/promises";
5
6
  import { BlockList, isIP } from "node:net";
@@ -58,9 +59,9 @@ export function liveInput(ffmpeg: string[] = ["ffmpeg"]): OpenLiveInput {
58
59
  };
59
60
  }
60
61
 
61
- export type SharedEvent = { type: "status"; listeners: number } | { type: "line"; id: string; line: Caption } | { type: "audio"; id: string; data: string } | { type: "end"; id: string } | { type: "error"; error: string };
62
+ export type SharedEvent = { type: "status"; listeners: number } | { type: "line"; id: string; line: Caption } | { type: "audio"; id: string; data: string } | { type: "end"; id: string } | { type: "error"; error: string; retryable?: boolean };
62
63
  type Member = { by: string; send: (event: SharedEvent) => void; close: () => void };
63
- interface Feed { id: string; source: URL; language: string; members: Set<Member>; controller: AbortController; input?: LiveInput; interpreter: Interpreter; meter: TranslationMeter; reservations: Map<string, string[]>; queue: Caption[]; speaking: boolean; previous: Buffer; pending: Buffer; until: number; heartbeat?: ReturnType<typeof setInterval>; }
64
+ interface Feed { id: string; source: URL; language: string; members: Set<Member>; controller: AbortController; input?: LiveInput; interpreter: Interpreter; meter: TranslationMeter; reservations: Map<string, string[]>; queue: Caption[]; speaking: boolean; voiceFailures: number; previous: Buffer; pending: Buffer; until: number; heartbeat?: ReturnType<typeof setInterval>; }
64
65
 
65
66
  export class SharedTranslations {
66
67
  private readonly feeds = new Map<string, Feed>();
@@ -71,18 +72,24 @@ export class SharedTranslations {
71
72
  if (!this.options.voice.available()) throw new SpeechError("Translated audio is unavailable.", 503);
72
73
  const source = sharedSource(raw);
73
74
  if (!LIVE_VOICE_LANGUAGES.has(language)) throw new SpeechError("Choose a supported audio language.", 400);
74
- await this.options.billing?.require(by);
75
- const connections = [...this.feeds.values()].reduce((total, feed) => total + [...feed.members].filter(member => member.by === by).length, 0);
76
- if (connections >= 2) throw new SpeechError("Translated audio is already open in two players for this account.", 429);
77
75
  const id = `dub-${createHash("sha256").update(`${source}|${language}`).digest("hex").slice(0, 40)}`;
76
+ const capacity = (): void => {
77
+ const connections = [...this.feeds.values()].reduce((total, feed) => total + [...feed.members].filter(member => member.by === by).length, 0);
78
+ if (connections >= 2) throw new SpeechError("Translated audio is already open in two players for this account.", 429);
79
+ const feed = this.feeds.get(id);
80
+ if (!feed && this.feeds.size >= 4) throw new SpeechError("Live translation is busy. Try again shortly.", 429);
81
+ if (feed && feed.members.size >= 1000) throw new SpeechError("This translated stream is full. Try again shortly.", 429);
82
+ };
83
+ capacity();
84
+ if (this.options.billing?.begin) await this.options.billing.begin(by, id);
85
+ else await this.options.billing?.require(by, id);
86
+ capacity(); // Other joins can finish during the account/database check.
78
87
  let feed = this.feeds.get(id);
79
88
  if (!feed) {
80
- if (this.feeds.size >= 4) throw new SpeechError("Live translation is busy. Try again shortly.", 429);
81
89
  feed = this.make(id, source, language); this.feeds.set(id, feed);
82
90
  const current = feed;
83
91
  current.heartbeat = setInterval(() => void this.checkMembers(current), 10_000); current.heartbeat.unref?.();
84
92
  }
85
- if (feed.members.size >= 1000) throw new SpeechError("This translated stream is full. Try again shortly.", 429);
86
93
  const member = { by, send, close }; feed.members.add(member);
87
94
  this.broadcast(feed, { type: "status", listeners: feed.members.size });
88
95
  if (feed.members.size === 1 && !feed.input) void this.start(feed);
@@ -99,14 +106,14 @@ export class SharedTranslations {
99
106
  }
100
107
  private async checkMembers(feed: Feed): Promise<void> {
101
108
  const accounts = [...new Set([...feed.members].map(member => member.by))];
102
- let funded = new Set(accounts), message = "Your translation balance is used up or expired. Buy another pass to continue.";
109
+ 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.";
103
110
  try {
104
- if (this.options.billing?.eligible) funded = new Set(await this.options.billing.eligible(accounts));
111
+ if (this.options.billing?.eligible) funded = new Set(await this.options.billing.eligible(accounts, feed.id));
105
112
  else if (this.options.billing) {
106
- const results = await Promise.allSettled(accounts.map(by => this.options.billing!.require(by)));
113
+ const results = await Promise.allSettled(accounts.map(by => this.options.billing!.require(by, feed.id)));
107
114
  funded = new Set(accounts.filter((_by, index) => results[index]?.status === "fulfilled"));
108
115
  }
109
- } catch { funded.clear(); message = "Your translation balance could not be checked. Try again shortly."; }
116
+ } catch { funded.clear(); message = "Your translation access could not be checked. Try again shortly."; }
110
117
  for (const member of [...feed.members]) if (accounts.includes(member.by) && !funded.has(member.by)) {
111
118
  try { member.send({ type: "error", error: message }); } catch { /* disconnected */ }
112
119
  finally { this.leave(feed, member); member.close(); }
@@ -117,14 +124,14 @@ export class SharedTranslations {
117
124
  try { member.send(event); } catch { this.leave(feed, member); member.close(); }
118
125
  }
119
126
  }
120
- private stop(feed: Feed, error: string): void {
121
- this.broadcast(feed, { type: "error", error });
127
+ private stop(feed: Feed, error: string, retryable = false): void {
128
+ this.broadcast(feed, { type: "error", error, retryable });
122
129
  for (const member of [...feed.members]) { this.leave(feed, member); member.close(); }
123
130
  }
124
131
  private make(id: string, source: URL, language: string): Feed {
125
- const feed = { id, source, language, members: new Set<Member>(), controller: new AbortController(), reservations: new Map<string, string[]>(), queue: [], speaking: false, previous: Buffer.alloc(0), pending: Buffer.alloc(0), until: Date.now() } as unknown as Feed;
126
- // Every active account pays the same published access rate. API work is
127
- // done once; additional listeners do not trigger recognition or synthesis.
132
+ const feed = { id, source, language, members: new Set<Member>(), controller: new AbortController(), reservations: new Map<string, string[]>(), queue: [], speaking: false, voiceFailures: 0, previous: Buffer.alloc(0), pending: Buffer.alloc(0), until: Date.now() } as unknown as Feed;
133
+ // Each account uses free access first, then the same published credit rate.
134
+ // Additional listeners do not trigger recognition or synthesis.
128
135
  feed.meter = {
129
136
  require: async () => { if (!feed.members.size) throw new SpeechError("No listeners.", 410); },
130
137
  reserve: async (_by: string, kind: TranslationUsage, units: number) => {
@@ -133,9 +140,9 @@ export class SharedTranslations {
133
140
  const accepted = new Set<string>();
134
141
  const billing = this.options.billing;
135
142
  if (billing?.reserveMany) {
136
- for (const charged of await billing.reserveMany(accounts, kind, units)) { ids.push(charged.id); accepted.add(charged.by); }
143
+ for (const charged of await billing.reserveMany(accounts, kind, units, feed.id)) { ids.push(charged.id); accepted.add(charged.by); }
137
144
  } else {
138
- const results = await Promise.allSettled(accounts.map(async account => ({ by: account, id: await billing?.reserve(account, kind, units) })));
145
+ const results = await Promise.allSettled(accounts.map(async account => ({ by: account, id: await billing?.reserve(account, kind, units, feed.id) })));
139
146
  let failure: unknown;
140
147
  for (const result of results) {
141
148
  if (result.status === "fulfilled") { accepted.add(result.value.by); if (result.value.id) ids.push(result.value.id); }
@@ -144,7 +151,7 @@ export class SharedTranslations {
144
151
  if (failure) { await Promise.all(ids.map(id => billing!.refund(id))); throw failure; }
145
152
  }
146
153
  for (const member of [...feed.members]) if (accounts.includes(member.by) && !accepted.has(member.by)) {
147
- try { member.send({ type: "error", error: "Your translation balance is used up or expired. Buy another pass to continue." }); }
154
+ try { member.send({ type: "error", error: "This free session ended and audio credit is used up. Start a remaining free session or buy a pass." }); }
148
155
  catch { /* disconnected */ } finally { this.leave(feed, member); member.close(); }
149
156
  }
150
157
  if (!feed.members.size) { await Promise.all(ids.map(id => this.options.billing!.refund(id))); throw new SpeechError("No funded listeners.", 402); }
@@ -157,10 +164,10 @@ export class SharedTranslations {
157
164
  feed.interpreter = new Interpreter({
158
165
  language: () => language, speakers: () => true, voices: () => voices, channel: () => id,
159
166
  status: text => { if (!feed.controller.signal.aborted && !feed.members.size) this.stop(feed, text); },
160
- failed: () => this.stop(feed, "Live translation stopped. Enable it again to retry."),
167
+ failed: message => this.stop(feed, message),
161
168
  lines: lines => {
162
169
  feed.queue.push(...lines);
163
- if (feed.queue.length > 24) { this.stop(feed, "Live translation fell behind. Enable it again to retry."); return; }
170
+ feed.queue = feed.queue.filter(line => Date.now() - line.until <= 12_000).slice(-24);
164
171
  if (!feed.speaking) void this.speak(feed);
165
172
  },
166
173
  fetcher: (async (url, init) => {
@@ -190,7 +197,7 @@ export class SharedTranslations {
190
197
  for (let index = 0; index < samples.length; index++) samples[index] = pcm.readInt16LE(index * 2) / 32768;
191
198
  feed.interpreter.push({ samples, at: feed.until - samples.length / 16, until: feed.until, freshAt: feed.until - 2000 });
192
199
  }
193
- }, () => this.stop(feed, "The live source stopped. Enable translated audio again when playback resumes."), feed.controller.signal);
200
+ }, () => this.stop(feed, "The live source disconnected.", true), feed.controller.signal);
194
201
  if (feed.controller.signal.aborted) input.stop(); else feed.input = input;
195
202
  } catch (error) { this.stop(feed, error instanceof SpeechError ? error.message : "The live source could not be opened."); }
196
203
  }
@@ -199,18 +206,25 @@ export class SharedTranslations {
199
206
  try {
200
207
  while (feed.queue.length && !feed.controller.signal.aborted) {
201
208
  const line = feed.queue.shift()!;
202
- if (Date.now() - line.until > 12_000) throw new SpeechError("Live translation fell behind. Enable it again to retry.", 503);
203
- 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);
204
- const id = randomUUID();
205
- this.broadcast(feed, { type: "line", id, line });
206
- const reader = response.body!.getReader();
209
+ if (Date.now() - line.until > 12_000) continue;
207
210
  try {
208
- while (true) {
209
- const { done, value } = await reader.read(); if (done) break;
210
- for (let at = 0; at < value.length; at += 16_384) this.broadcast(feed, { type: "audio", id, data: Buffer.from(value.subarray(at, at + 16_384)).toString("base64") });
211
- }
212
- } finally { reader.releaseLock(); }
213
- this.broadcast(feed, { type: "end", id });
211
+ 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);
212
+ const id = randomUUID();
213
+ this.broadcast(feed, { type: "line", id, line });
214
+ const reader = response.body!.getReader();
215
+ try {
216
+ while (true) {
217
+ const { done, value } = await reader.read(); if (done) break;
218
+ for (let at = 0; at < value.length; at += 16_384) this.broadcast(feed, { type: "audio", id, data: Buffer.from(value.subarray(at, at + 16_384)).toString("base64") });
219
+ }
220
+ } finally { reader.releaseLock(); this.broadcast(feed, { type: "end", id }); }
221
+ feed.voiceFailures = 0;
222
+ } catch (error) {
223
+ if (feed.controller.signal.aborted) return;
224
+ if (!transientAudioError(error) || ++feed.voiceFailures >= 3) throw error;
225
+ // A provider failure skips this phrase; the source and other listeners
226
+ // remain connected. Never replay partial speech or retry a charge.
227
+ }
214
228
  }
215
229
  } catch (error) { if (!feed.controller.signal.aborted) this.stop(feed, error instanceof SpeechError ? error.message : "Translated audio was interrupted."); }
216
230
  finally { feed.speaking = false; }
package/src/tokens.ts CHANGED
@@ -152,6 +152,9 @@ export class Tokens {
152
152
  await this.ready;
153
153
  }
154
154
 
155
+ /** Recovery revokes this table in the same transaction as the password update. */
156
+ async initialize(): Promise<void> { await this.ensure(); }
157
+
155
158
  async issue(options: IssueOptions): Promise<IssuedToken> {
156
159
  await this.ensure();
157
160
  const { id, secret, token } = mintToken();