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.
- package/README.md +53 -20
- package/backtoschool/dist/assets/{index-6vtCMzi1.css → index-Bot4FEXQ.css} +1 -1
- package/backtoschool/dist/assets/index-Cjai7LgT.js +93 -0
- package/backtoschool/dist/index.html +36 -9
- package/dist/accounts.d.ts +2 -0
- package/dist/accounts.js +9 -2
- package/dist/classroom.d.ts +15 -0
- package/dist/classroom.js +54 -0
- package/dist/event-writer.d.ts +39 -0
- package/dist/event-writer.js +139 -0
- package/dist/live-api.d.ts +2 -0
- package/dist/live-api.js +37 -2
- package/dist/live-events.d.ts +7 -1
- package/dist/live-events.js +88 -12
- package/dist/live-interpreter.d.ts +3 -1
- package/dist/live-interpreter.js +109 -83
- package/dist/live-recovery.d.ts +6 -0
- package/dist/live-recovery.js +11 -0
- package/dist/live-voice.d.ts +1 -1
- package/dist/live-voice.js +7 -7
- package/dist/mail-sender.d.ts +9 -0
- package/dist/mail-sender.js +11 -0
- package/dist/notify.d.ts +3 -3
- package/dist/notify.js +8 -5
- package/dist/password-reset-page.d.ts +2 -0
- package/dist/password-reset-page.js +106 -0
- package/dist/password-reset.d.ts +34 -0
- package/dist/password-reset.js +121 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +109 -3
- package/dist/shared-translation.d.ts +1 -0
- package/dist/shared-translation.js +57 -40
- package/dist/tokens.d.ts +2 -0
- package/dist/tokens.js +2 -0
- package/dist/translation-passes.d.ts +13 -8
- package/dist/translation-passes.js +31 -20
- package/dist/upgrade-allowance.d.ts +23 -0
- package/dist/upgrade-allowance.js +70 -0
- package/package.json +1 -1
- package/src/accounts.ts +8 -2
- package/src/classroom.ts +52 -0
- package/src/event-writer.ts +126 -0
- package/src/live-api.ts +26 -2
- package/src/live-events.ts +81 -13
- package/src/live-interpreter.ts +87 -68
- package/src/live-recovery.ts +10 -0
- package/src/live-voice.ts +7 -7
- package/src/mail-sender.ts +12 -0
- package/src/notify.ts +10 -7
- package/src/password-reset-page.ts +108 -0
- package/src/password-reset.ts +134 -0
- package/src/server.ts +91 -3
- package/src/shared-translation.ts +47 -33
- package/src/tokens.ts +3 -0
- package/src/translation-passes.ts +35 -25
- package/src/types/auth-system.d.ts +10 -0
- package/src/upgrade-allowance.ts +69 -0
- package/web/dist/assets/background.worker-Hz7oaQ8X.js +547 -0
- package/web/dist/assets/{hls-3VKVEQE3-C1ttVP7K.js → hls-3VKVEQE3-_BiJh76x.js} +1 -1
- package/web/dist/assets/index-BgLxkXKe.js +3 -0
- package/web/dist/assets/{index-BmR7T0zX.css → index-D34dTY18.css} +1 -1
- package/web/dist/assets/{mpegts-CVYPgepU.js → mpegts-CV0glck7.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-Dw2p8qGf.js → mpegts-LO6RVLD6-iuTMg-O6.js} +1 -1
- package/web/dist/index.html +6 -5
- package/web/dist/sw.js +7 -7
- package/backtoschool/dist/assets/index-D7Oas1m5.js +0 -88
- package/web/dist/assets/background.worker-DH8ag5Q4.js +0 -547
- 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
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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 = "
|
|
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
|
|
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
|
-
//
|
|
184
|
-
//
|
|
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: "
|
|
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:
|
|
258
|
+
failed: message => this.stop(feed, message),
|
|
250
259
|
lines: lines => {
|
|
251
260
|
feed.queue.push(...lines);
|
|
252
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
322
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
}[]>;
|