nixamp 0.27.1 → 0.27.2
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 +14 -4
- package/backtoschool/dist/assets/{index-6vtCMzi1.css → index-OE2xWZUH.css} +1 -1
- package/backtoschool/dist/assets/index-pQ_uH4bZ.js +93 -0
- package/backtoschool/dist/index.html +25 -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/live-events.d.ts +7 -1
- package/dist/live-events.js +88 -12
- package/dist/live-voice.d.ts +1 -1
- package/dist/live-voice.js +7 -7
- package/dist/password-reset-page.d.ts +2 -0
- package/dist/password-reset-page.js +106 -0
- package/dist/password-reset.d.ts +35 -0
- package/dist/password-reset.js +119 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +96 -2
- package/dist/shared-translation.js +25 -17
- 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/live-events.ts +81 -13
- package/src/live-voice.ts +7 -7
- package/src/password-reset-page.ts +108 -0
- package/src/password-reset.ts +134 -0
- package/src/server.ts +77 -2
- package/src/shared-translation.ts +20 -14
- 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/{hls-3VKVEQE3-C1ttVP7K.js → hls-3VKVEQE3-CAL4qBEq.js} +1 -1
- package/web/dist/assets/index-CLeEWlms.js +3 -0
- package/web/dist/assets/{index-BmR7T0zX.css → index-D34dTY18.css} +1 -1
- package/web/dist/assets/{mpegts-CVYPgepU.js → mpegts-H9oC2fAK.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-Dw2p8qGf.js → mpegts-LO6RVLD6-Bkd9Sqr6.js} +1 -1
- package/web/dist/index.html +6 -5
- package/web/dist/sw.js +6 -6
- package/backtoschool/dist/assets/index-D7Oas1m5.js +0 -88
- package/web/dist/assets/index-3WZwGw04.js +0 -3
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";
|
|
@@ -1360,6 +1362,7 @@ export function createHandler(engine, options) {
|
|
|
1360
1362
|
const webSite = options.webSites?.get((request.headers.host ?? "").toLowerCase());
|
|
1361
1363
|
const web = webSite?.web ?? options.web;
|
|
1362
1364
|
const eventSite = webSite?.site ?? options.invites?.site;
|
|
1365
|
+
const accountSite = eventSite ?? options.site ?? "https://nixamp.com";
|
|
1363
1366
|
const key = options.key ?? null;
|
|
1364
1367
|
const behindProxy = options.behindProxy ?? false;
|
|
1365
1368
|
const listenKey = options.listenKey ?? null;
|
|
@@ -1947,6 +1950,20 @@ export function createHandler(engine, options) {
|
|
|
1947
1950
|
json(response, 200, { name: "nixamp", version: options.version, media: options.media });
|
|
1948
1951
|
return;
|
|
1949
1952
|
}
|
|
1953
|
+
if (path === "/reset-password" && options.accounts?.passwordResets) {
|
|
1954
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
1955
|
+
json(response, 405, { error: "GET only" });
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
const nonce = randomBytes(18).toString("base64");
|
|
1959
|
+
response.writeHead(200, {
|
|
1960
|
+
"content-type": "text/html; charset=utf-8", "cache-control": "no-store",
|
|
1961
|
+
"referrer-policy": "no-referrer", "x-robots-tag": "noindex, nofollow",
|
|
1962
|
+
"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'`,
|
|
1963
|
+
});
|
|
1964
|
+
response.end(request.method === "HEAD" ? undefined : passwordResetPage(accountSite, nonce));
|
|
1965
|
+
return;
|
|
1966
|
+
}
|
|
1950
1967
|
// --- accounts ---------------------------------------------------------
|
|
1951
1968
|
//
|
|
1952
1969
|
// Before the share-key check, because signing in is how somebody without a
|
|
@@ -1955,6 +1972,58 @@ export function createHandler(engine, options) {
|
|
|
1955
1972
|
if (path.startsWith("/api/v1/auth/") && options.accounts) {
|
|
1956
1973
|
const accounts = options.accounts;
|
|
1957
1974
|
const secure = options.secureCookies ?? false;
|
|
1975
|
+
if (path === "/api/v1/auth/password-reset/request" || path === "/api/v1/auth/password-reset/confirm") {
|
|
1976
|
+
if (request.method !== "POST") {
|
|
1977
|
+
json(response, 405, { error: "POST only" });
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
const confirming = path.endsWith("/confirm");
|
|
1981
|
+
if (!accounts.passwordResets || (!confirming && !options.resetMail)) {
|
|
1982
|
+
json(response, 503, { error: "Password reset email is temporarily unavailable. Please try again later." });
|
|
1983
|
+
return;
|
|
1984
|
+
}
|
|
1985
|
+
const who = callerOf(request.headers, request.socket.remoteAddress, behindProxy);
|
|
1986
|
+
const limit = guard.check(`password-reset:${confirming ? "confirm" : "request"}:${who}`, { allowed: confirming ? 15 : 10, windowMs: 15 * 60_000 });
|
|
1987
|
+
if (!limit.ok) {
|
|
1988
|
+
response.setHeader("retry-after", String(limit.retryAfter));
|
|
1989
|
+
json(response, 429, { error: "Too many attempts. Please try again in 15 minutes." });
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
try {
|
|
1993
|
+
const input = JSON.parse(await readBody(request, 4096));
|
|
1994
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
1995
|
+
json(response, 400, { error: "Provide a JSON object." });
|
|
1996
|
+
return;
|
|
1997
|
+
}
|
|
1998
|
+
const body = input;
|
|
1999
|
+
if (confirming) {
|
|
2000
|
+
const error = await accounts.passwordResets.confirm(body["token"], body["password"]);
|
|
2001
|
+
if (error) {
|
|
2002
|
+
json(response, error === RESET_INVALID ? 400 : 422, { error });
|
|
2003
|
+
return;
|
|
2004
|
+
}
|
|
2005
|
+
response.setHeader("set-cookie", clearedCookie());
|
|
2006
|
+
json(response, 200, { message: "Your password has been changed. Sign in with your new password." });
|
|
2007
|
+
}
|
|
2008
|
+
else {
|
|
2009
|
+
const email = typeof body["email"] === "string" ? body["email"].trim().toLowerCase() : "";
|
|
2010
|
+
if (email.length > 320 || !/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(email)) {
|
|
2011
|
+
json(response, 422, { error: "Enter a valid email address." });
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
// Count every address, including nonexistent ones, identically.
|
|
2015
|
+
const address = guard.check(`password-reset:email:${email}`, { allowed: 3, windowMs: 15 * 60_000 });
|
|
2016
|
+
if (address.ok)
|
|
2017
|
+
await accounts.passwordResets.request(email, accountSite, options.resetMail);
|
|
2018
|
+
json(response, 200, { message: RESET_MESSAGE });
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
catch (error) {
|
|
2022
|
+
const malformed = error instanceof SyntaxError || error.message === "body too large";
|
|
2023
|
+
json(response, malformed ? 400 : 503, { error: malformed ? "Provide a valid JSON request." : "Password reset is temporarily unavailable. Please try again." });
|
|
2024
|
+
}
|
|
2025
|
+
return;
|
|
2026
|
+
}
|
|
1958
2027
|
if (path === "/api/v1/auth/me") {
|
|
1959
2028
|
const who = await accounts.whoIs(tokenFrom(request.headers));
|
|
1960
2029
|
if (who === null) {
|
|
@@ -2409,9 +2478,25 @@ export function createHandler(engine, options) {
|
|
|
2409
2478
|
return;
|
|
2410
2479
|
}
|
|
2411
2480
|
if (!who)
|
|
2412
|
-
throw new SpeechError("Sign in to
|
|
2481
|
+
throw new SpeechError("Sign in to use translated audio.", 401);
|
|
2413
2482
|
if (!guard.check(`translation-pass-account:${who.id}`, { allowed: 30, windowMs: 60_000 }).ok)
|
|
2414
2483
|
throw new SpeechError("Too many purchase requests.", 429);
|
|
2484
|
+
if (path === "/api/v1/translation-passes/session" && request.method === "POST") {
|
|
2485
|
+
if (!request.headers["content-type"]?.startsWith("application/json"))
|
|
2486
|
+
throw new SpeechError("Send a panel session as JSON.", 415);
|
|
2487
|
+
let body;
|
|
2488
|
+
try {
|
|
2489
|
+
body = JSON.parse(await readBody(request, 1024));
|
|
2490
|
+
}
|
|
2491
|
+
catch {
|
|
2492
|
+
throw new SpeechError("Choose a panel session.", 400);
|
|
2493
|
+
}
|
|
2494
|
+
if (typeof body?.resource !== "string")
|
|
2495
|
+
throw new SpeechError("Choose a panel session.", 400);
|
|
2496
|
+
await service.begin(who.id, body.resource);
|
|
2497
|
+
json(response, 200, await service.access(who.id));
|
|
2498
|
+
return;
|
|
2499
|
+
}
|
|
2415
2500
|
if (path === "/api/v1/translation-passes/checkout" && request.method === "POST") {
|
|
2416
2501
|
if (!request.headers["content-type"]?.startsWith("application/json"))
|
|
2417
2502
|
throw new SpeechError("Send a purchase as JSON.", 415);
|
|
@@ -2492,7 +2577,10 @@ export function createHandler(engine, options) {
|
|
|
2492
2577
|
return;
|
|
2493
2578
|
}
|
|
2494
2579
|
const bytes = await readBytes(request, 484_000);
|
|
2495
|
-
|
|
2580
|
+
const resource = request.headers["x-nixamp-translation-session"];
|
|
2581
|
+
if (resource !== undefined && (typeof resource !== "string" || !/^[\w-]{1,80}$/.test(resource)))
|
|
2582
|
+
throw new SpeechError("Choose a panel session.", 400);
|
|
2583
|
+
json(response, 200, await service.hear(bytes, who.id, controller.signal, undefined, resource));
|
|
2496
2584
|
}
|
|
2497
2585
|
else if (path.endsWith("/grant") && request.method === "POST") {
|
|
2498
2586
|
if (!request.headers["content-type"]?.startsWith("application/json")) {
|
|
@@ -6043,6 +6131,12 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6043
6131
|
...(translations ? { translations } : {}),
|
|
6044
6132
|
...(records ? { records } : {}),
|
|
6045
6133
|
site: nixampSite,
|
|
6134
|
+
...(accounts && process.env["RESEND_API_KEY"] ? {
|
|
6135
|
+
resetMail: resendPasswordReset({
|
|
6136
|
+
apiKey: process.env["RESEND_API_KEY"],
|
|
6137
|
+
from: process.env["NIXAMP_MAIL_FROM"] ?? "nixamp <notifications@nixamp.com>",
|
|
6138
|
+
}),
|
|
6139
|
+
} : {}),
|
|
6046
6140
|
// Other people's OpenProfiles, read for the voice a line is spoken in,
|
|
6047
6141
|
// and the voices to speak in: ElevenLabs when the Telnyx account holds
|
|
6048
6142
|
// the key for it (an integration secret named "elevenlabs"), Kokoro
|
|
@@ -93,23 +93,31 @@ export class SharedTranslations {
|
|
|
93
93
|
const source = sharedSource(raw);
|
|
94
94
|
if (!LIVE_VOICE_LANGUAGES.has(language))
|
|
95
95
|
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
96
|
const id = `dub-${createHash("sha256").update(`${source}|${language}`).digest("hex").slice(0, 40)}`;
|
|
97
|
+
const capacity = () => {
|
|
98
|
+
const connections = [...this.feeds.values()].reduce((total, feed) => total + [...feed.members].filter(member => member.by === by).length, 0);
|
|
99
|
+
if (connections >= 2)
|
|
100
|
+
throw new SpeechError("Translated audio is already open in two players for this account.", 429);
|
|
101
|
+
const feed = this.feeds.get(id);
|
|
102
|
+
if (!feed && this.feeds.size >= 4)
|
|
103
|
+
throw new SpeechError("Live translation is busy. Try again shortly.", 429);
|
|
104
|
+
if (feed && feed.members.size >= 1000)
|
|
105
|
+
throw new SpeechError("This translated stream is full. Try again shortly.", 429);
|
|
106
|
+
};
|
|
107
|
+
capacity();
|
|
108
|
+
if (this.options.billing?.begin)
|
|
109
|
+
await this.options.billing.begin(by, id);
|
|
110
|
+
else
|
|
111
|
+
await this.options.billing?.require(by, id);
|
|
112
|
+
capacity(); // Other joins can finish during the account/database check.
|
|
101
113
|
let feed = this.feeds.get(id);
|
|
102
114
|
if (!feed) {
|
|
103
|
-
if (this.feeds.size >= 4)
|
|
104
|
-
throw new SpeechError("Live translation is busy. Try again shortly.", 429);
|
|
105
115
|
feed = this.make(id, source, language);
|
|
106
116
|
this.feeds.set(id, feed);
|
|
107
117
|
const current = feed;
|
|
108
118
|
current.heartbeat = setInterval(() => void this.checkMembers(current), 10_000);
|
|
109
119
|
current.heartbeat.unref?.();
|
|
110
120
|
}
|
|
111
|
-
if (feed.members.size >= 1000)
|
|
112
|
-
throw new SpeechError("This translated stream is full. Try again shortly.", 429);
|
|
113
121
|
const member = { by, send, close };
|
|
114
122
|
feed.members.add(member);
|
|
115
123
|
this.broadcast(feed, { type: "status", listeners: feed.members.size });
|
|
@@ -135,18 +143,18 @@ export class SharedTranslations {
|
|
|
135
143
|
}
|
|
136
144
|
async checkMembers(feed) {
|
|
137
145
|
const accounts = [...new Set([...feed.members].map(member => member.by))];
|
|
138
|
-
let funded = new Set(accounts), message = "
|
|
146
|
+
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
147
|
try {
|
|
140
148
|
if (this.options.billing?.eligible)
|
|
141
|
-
funded = new Set(await this.options.billing.eligible(accounts));
|
|
149
|
+
funded = new Set(await this.options.billing.eligible(accounts, feed.id));
|
|
142
150
|
else if (this.options.billing) {
|
|
143
|
-
const results = await Promise.allSettled(accounts.map(by => this.options.billing.require(by)));
|
|
151
|
+
const results = await Promise.allSettled(accounts.map(by => this.options.billing.require(by, feed.id)));
|
|
144
152
|
funded = new Set(accounts.filter((_by, index) => results[index]?.status === "fulfilled"));
|
|
145
153
|
}
|
|
146
154
|
}
|
|
147
155
|
catch {
|
|
148
156
|
funded.clear();
|
|
149
|
-
message = "Your translation
|
|
157
|
+
message = "Your translation access could not be checked. Try again shortly.";
|
|
150
158
|
}
|
|
151
159
|
for (const member of [...feed.members])
|
|
152
160
|
if (accounts.includes(member.by) && !funded.has(member.by)) {
|
|
@@ -180,8 +188,8 @@ export class SharedTranslations {
|
|
|
180
188
|
}
|
|
181
189
|
make(id, source, language) {
|
|
182
190
|
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
|
+
// Each account uses free access first, then the same published credit rate.
|
|
192
|
+
// Additional listeners do not trigger recognition or synthesis.
|
|
185
193
|
feed.meter = {
|
|
186
194
|
require: async () => { if (!feed.members.size)
|
|
187
195
|
throw new SpeechError("No listeners.", 410); },
|
|
@@ -191,13 +199,13 @@ export class SharedTranslations {
|
|
|
191
199
|
const accepted = new Set();
|
|
192
200
|
const billing = this.options.billing;
|
|
193
201
|
if (billing?.reserveMany) {
|
|
194
|
-
for (const charged of await billing.reserveMany(accounts, kind, units)) {
|
|
202
|
+
for (const charged of await billing.reserveMany(accounts, kind, units, feed.id)) {
|
|
195
203
|
ids.push(charged.id);
|
|
196
204
|
accepted.add(charged.by);
|
|
197
205
|
}
|
|
198
206
|
}
|
|
199
207
|
else {
|
|
200
|
-
const results = await Promise.allSettled(accounts.map(async (account) => ({ by: account, id: await billing?.reserve(account, kind, units) })));
|
|
208
|
+
const results = await Promise.allSettled(accounts.map(async (account) => ({ by: account, id: await billing?.reserve(account, kind, units, feed.id) })));
|
|
201
209
|
let failure;
|
|
202
210
|
for (const result of results) {
|
|
203
211
|
if (result.status === "fulfilled") {
|
|
@@ -216,7 +224,7 @@ export class SharedTranslations {
|
|
|
216
224
|
for (const member of [...feed.members])
|
|
217
225
|
if (accounts.includes(member.by) && !accepted.has(member.by)) {
|
|
218
226
|
try {
|
|
219
|
-
member.send({ type: "error", error: "
|
|
227
|
+
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
228
|
}
|
|
221
229
|
catch { /* disconnected */ }
|
|
222
230
|
finally {
|
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
|
}[]>;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* each listening account reserves 5x base provider cost for paid access. */
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { SpeechError } from "./speech.js";
|
|
5
|
+
import { UpgradeAllowances } from "./upgrade-allowance.js";
|
|
5
6
|
export const TRANSLATION_MULTIPLIER = 5;
|
|
6
7
|
export const TRANSLATION_PLANS = [
|
|
7
8
|
{ id: "day", name: "Day", days: 1, priceCents: 500 },
|
|
@@ -40,13 +41,16 @@ export class TranslationPasses {
|
|
|
40
41
|
merchantUntil = 0;
|
|
41
42
|
fetcher;
|
|
42
43
|
now;
|
|
44
|
+
free;
|
|
43
45
|
constructor(options) {
|
|
44
46
|
this.options = options;
|
|
45
47
|
this.fetcher = options.fetcher ?? fetch;
|
|
46
48
|
this.now = options.now ?? Date.now;
|
|
49
|
+
this.free = new UpgradeAllowances(options.db, this.now);
|
|
47
50
|
}
|
|
48
51
|
async ensure() {
|
|
49
52
|
this.schema ??= (async () => {
|
|
53
|
+
await this.free.ensure();
|
|
50
54
|
await this.options.db.query(`CREATE TABLE IF NOT EXISTS translation_wallets (
|
|
51
55
|
by_account TEXT PRIMARY KEY, balance_micros BIGINT NOT NULL CHECK (balance_micros >= 0), expires_at TIMESTAMPTZ NOT NULL)`);
|
|
52
56
|
await this.options.db.query(`CREATE TABLE IF NOT EXISTS translation_orders (
|
|
@@ -93,41 +97,48 @@ export class TranslationPasses {
|
|
|
93
97
|
const row = by ? (await this.options.db.query("SELECT balance_micros, expires_at FROM translation_wallets WHERE by_account = $1 AND expires_at > $2", [by, new Date(this.now())])).rows[0] : null;
|
|
94
98
|
const orders = by ? (await this.options.db.query("SELECT id, payment_id, plan FROM translation_orders WHERE by_account = $1 AND status = 'pending' AND payment_id IS NOT NULL ORDER BY created_at DESC LIMIT 5", [by])).rows.map(order => ({ id: String(order["id"]), plan: String(order["plan"]), url: `https://coinpayportal.com/pay/${order["payment_id"]}` })) : [];
|
|
95
99
|
return { required: true, available: config.coins.length > 0, balanceMicros: Number(row?.["balance_micros"] ?? 0),
|
|
96
|
-
expires: row ? new Date(String(row["expires_at"])).toISOString() : null, plans: TRANSLATION_PLANS, coins: config.coins, orders,
|
|
100
|
+
expires: row ? new Date(String(row["expires_at"])).toISOString() : null, plans: TRANSLATION_PLANS, coins: config.coins, orders, free: await this.free.access(by),
|
|
97
101
|
rates: { transcriptionHour: 0.22, voiceThousand: 0.05, multiplier: TRANSLATION_MULTIPLIER } };
|
|
98
102
|
}
|
|
99
|
-
async
|
|
103
|
+
async begin(by, resource) {
|
|
100
104
|
await this.ensure();
|
|
105
|
+
if (await this.free.begin(by, "translation", resource))
|
|
106
|
+
return;
|
|
107
|
+
await this.require(by, resource);
|
|
108
|
+
}
|
|
109
|
+
async require(by, resource = "") {
|
|
110
|
+
await this.ensure();
|
|
111
|
+
if ((await this.free.active([by], "translation", resource, true)).length)
|
|
112
|
+
return;
|
|
101
113
|
const found = await this.options.db.query("SELECT by_account FROM translation_wallets WHERE by_account = $1 AND expires_at > $2 AND balance_micros > 0", [by, new Date(this.now())]);
|
|
102
114
|
if (!found.rows.length)
|
|
103
|
-
throw new SpeechError("Buy a
|
|
115
|
+
throw new SpeechError("Buy a pass or start a remaining free session: this session has no free time or audio credit.", 402);
|
|
104
116
|
}
|
|
105
|
-
async eligible(accounts) {
|
|
117
|
+
async eligible(accounts, resource = "") {
|
|
106
118
|
await this.ensure();
|
|
107
119
|
const result = await this.options.db.query("SELECT by_account FROM translation_wallets WHERE by_account = ANY($1::text[]) AND expires_at > $2 AND balance_micros > 0", [accounts, new Date(this.now())]);
|
|
108
|
-
return result.rows.map(row => String(row["by_account"]));
|
|
120
|
+
return [...new Set([...result.rows.map(row => String(row["by_account"])), ...await this.free.active(accounts, "translation", resource, true)])];
|
|
109
121
|
}
|
|
110
|
-
async reserve(by, kind, units) {
|
|
111
|
-
await this.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
WHERE by_account = $1 AND expires_at > $3 AND balance_micros >= $2 RETURNING by_account)
|
|
116
|
-
INSERT INTO translation_usage (id, by_account, kind, units, cost_micros, charge_micros, created_at)
|
|
117
|
-
SELECT $4, by_account, $5, $6, $7, $2, $3 FROM debit RETURNING id`, [by, charge, new Date(this.now()), id, kind, units, cost]);
|
|
118
|
-
if (!result.rows.length)
|
|
119
|
-
throw new SpeechError("Your translation balance is used up or expired. Buy another pass to continue.", 402);
|
|
120
|
-
return id;
|
|
122
|
+
async reserve(by, kind, units, resource = "") {
|
|
123
|
+
const result = await this.reserveMany([by], kind, units, resource);
|
|
124
|
+
if (!result.length)
|
|
125
|
+
throw new SpeechError("This free session ended and audio credit is used up. Start a remaining free session or buy a pass.", 402);
|
|
126
|
+
return result[0].id;
|
|
121
127
|
}
|
|
122
128
|
/** Batch a shared stream's access charges in one database round trip. */
|
|
123
|
-
async reserveMany(accounts, kind, units) {
|
|
129
|
+
async reserveMany(accounts, kind, units, resource = "") {
|
|
124
130
|
await this.ensure();
|
|
125
131
|
const { cost, charge } = translationCost(kind, units);
|
|
126
|
-
const
|
|
132
|
+
const now = this.now(), key = resource ? this.free.key("translation", resource) : "";
|
|
133
|
+
const result = await this.options.db.query(`WITH free AS (
|
|
134
|
+
SELECT DISTINCT by_account FROM upgrade_daily_uses WHERE by_account = ANY($1::text[])
|
|
135
|
+
AND (sessions->>$7)::bigint > $8), debit AS (
|
|
127
136
|
UPDATE translation_wallets SET balance_micros = balance_micros - $2
|
|
128
|
-
WHERE by_account = ANY($1::text[]) AND expires_at > $3 AND balance_micros >= $2
|
|
137
|
+
WHERE by_account = ANY($1::text[]) AND expires_at > $3 AND balance_micros >= $2
|
|
138
|
+
AND by_account NOT IN (SELECT by_account FROM free) RETURNING by_account), admitted AS (
|
|
139
|
+
SELECT by_account, 0::bigint AS charge FROM free UNION ALL SELECT by_account, $2::bigint FROM debit)
|
|
129
140
|
INSERT INTO translation_usage (id, by_account, kind, units, cost_micros, charge_micros, created_at)
|
|
130
|
-
SELECT gen_random_uuid()::text, by_account, $4, $5, $6,
|
|
141
|
+
SELECT gen_random_uuid()::text, by_account, $4, $5, $6, charge, $3 FROM admitted RETURNING id, by_account`, [[...new Set(accounts)], charge, new Date(now), kind, units, cost, key, now]);
|
|
131
142
|
return result.rows.map(row => ({ by: String(row["by_account"]), id: String(row["id"]) }));
|
|
132
143
|
}
|
|
133
144
|
async commit(id) {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Queryable } from "./follows.ts";
|
|
2
|
+
export declare const FREE_UPGRADE_SESSIONS = 5;
|
|
3
|
+
export declare const FREE_UPGRADE_RECONNECT_SECONDS = 90;
|
|
4
|
+
export interface UpgradeAllowance {
|
|
5
|
+
limit: number;
|
|
6
|
+
remaining: number;
|
|
7
|
+
reconnectSeconds: number;
|
|
8
|
+
resets: string;
|
|
9
|
+
activeUntil: string | null;
|
|
10
|
+
}
|
|
11
|
+
export declare class UpgradeAllowances {
|
|
12
|
+
private readonly db;
|
|
13
|
+
private readonly now;
|
|
14
|
+
private schema;
|
|
15
|
+
constructor(db: Queryable, now?: () => number);
|
|
16
|
+
ensure(): Promise<void>;
|
|
17
|
+
key(product: string, resource: string): string;
|
|
18
|
+
access(by?: string): Promise<UpgradeAllowance>;
|
|
19
|
+
/** One atomic row serializes concurrent starts across tabs, products and replicas.
|
|
20
|
+
* Active use renews a short lease. Reconnecting reuses it; idle leases expire. */
|
|
21
|
+
begin(by: string, product: string, resource: string): Promise<boolean>;
|
|
22
|
+
active(accounts: string[], product: string, resource: string, renew?: boolean): Promise<string[]>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** Daily free access shared by Nixamp's paid panels and upgrades.
|
|
2
|
+
* Claim only on explicit use, never from balance polling or provider chunks. */
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { SpeechError } from "./speech.js";
|
|
5
|
+
export const FREE_UPGRADE_SESSIONS = 5;
|
|
6
|
+
export const FREE_UPGRADE_RECONNECT_SECONDS = 90;
|
|
7
|
+
const DAY = 86_400_000;
|
|
8
|
+
export class UpgradeAllowances {
|
|
9
|
+
db;
|
|
10
|
+
now;
|
|
11
|
+
schema = null;
|
|
12
|
+
constructor(db, now = Date.now) {
|
|
13
|
+
this.db = db;
|
|
14
|
+
this.now = now;
|
|
15
|
+
}
|
|
16
|
+
async ensure() {
|
|
17
|
+
this.schema ??= this.db.query(`CREATE TABLE IF NOT EXISTS upgrade_daily_uses (
|
|
18
|
+
by_account TEXT NOT NULL, day BIGINT NOT NULL,
|
|
19
|
+
uses INTEGER NOT NULL CHECK (uses BETWEEN 1 AND 5),
|
|
20
|
+
sessions JSONB NOT NULL, PRIMARY KEY (by_account, day))`).then(() => undefined)
|
|
21
|
+
.catch(error => { this.schema = null; throw error; });
|
|
22
|
+
await this.schema;
|
|
23
|
+
}
|
|
24
|
+
key(product, resource) {
|
|
25
|
+
if (!/^[a-z][a-z0-9-]{0,63}$/.test(product) || !/^[\w-]{1,80}$/.test(resource))
|
|
26
|
+
throw new SpeechError("Choose a panel session.", 400);
|
|
27
|
+
return createHash("sha256").update(`${product}|${resource}`).digest("hex");
|
|
28
|
+
}
|
|
29
|
+
async access(by) {
|
|
30
|
+
await this.ensure();
|
|
31
|
+
const now = this.now(), day = Math.floor(now / DAY);
|
|
32
|
+
const rows = by ? (await this.db.query("SELECT uses, day, sessions FROM upgrade_daily_uses WHERE by_account = $1", [by])).rows : [];
|
|
33
|
+
const today = rows.find(row => Number(row["day"]) === day);
|
|
34
|
+
const active = Math.max(0, ...rows.flatMap(row => Object.values(row["sessions"]).map(Number)));
|
|
35
|
+
return { limit: FREE_UPGRADE_SESSIONS, remaining: Math.max(0, FREE_UPGRADE_SESSIONS - Number(today?.["uses"] ?? 0)),
|
|
36
|
+
reconnectSeconds: FREE_UPGRADE_RECONNECT_SECONDS, resets: new Date((day + 1) * DAY).toISOString(), activeUntil: active > now ? new Date(active).toISOString() : null };
|
|
37
|
+
}
|
|
38
|
+
/** One atomic row serializes concurrent starts across tabs, products and replicas.
|
|
39
|
+
* Active use renews a short lease. Reconnecting reuses it; idle leases expire. */
|
|
40
|
+
async begin(by, product, resource) {
|
|
41
|
+
if (!by)
|
|
42
|
+
throw new SpeechError("Sign in to use your free sessions.", 401);
|
|
43
|
+
const key = this.key(product, resource);
|
|
44
|
+
if ((await this.active([by], product, resource, true)).length)
|
|
45
|
+
return true;
|
|
46
|
+
await this.ensure();
|
|
47
|
+
const now = this.now(), day = Math.floor(now / DAY), until = now + FREE_UPGRADE_RECONNECT_SECONDS * 1000;
|
|
48
|
+
const result = await this.db.query(`INSERT INTO upgrade_daily_uses AS daily (by_account, day, uses, sessions)
|
|
49
|
+
VALUES ($1, $2, 1, jsonb_build_object($3::text, $5::bigint))
|
|
50
|
+
ON CONFLICT (by_account, day) DO UPDATE SET
|
|
51
|
+
uses = CASE WHEN COALESCE((daily.sessions->>$3)::bigint, 0) > $4 THEN daily.uses ELSE daily.uses + 1 END,
|
|
52
|
+
sessions = daily.sessions || EXCLUDED.sessions
|
|
53
|
+
WHERE COALESCE((daily.sessions->>$3)::bigint, 0) > $4 OR daily.uses < $6
|
|
54
|
+
RETURNING uses`, [by, day, key, now, until, FREE_UPGRADE_SESSIONS]);
|
|
55
|
+
return result.rows.length > 0;
|
|
56
|
+
}
|
|
57
|
+
async active(accounts, product, resource, renew = false) {
|
|
58
|
+
if (!resource || !accounts.length)
|
|
59
|
+
return [];
|
|
60
|
+
const key = this.key(product, resource);
|
|
61
|
+
await this.ensure();
|
|
62
|
+
const now = this.now();
|
|
63
|
+
const result = renew
|
|
64
|
+
? await this.db.query(`UPDATE upgrade_daily_uses SET sessions = sessions || jsonb_build_object($2::text, $4::bigint)
|
|
65
|
+
WHERE by_account = ANY($1::text[]) AND (sessions->>$2)::bigint > $3 RETURNING by_account`, [accounts, key, now, now + FREE_UPGRADE_RECONNECT_SECONDS * 1000])
|
|
66
|
+
: await this.db.query(`SELECT by_account FROM upgrade_daily_uses
|
|
67
|
+
WHERE by_account = ANY($1::text[]) AND (sessions->>$2)::bigint > $3`, [accounts, key, now]);
|
|
68
|
+
return result.rows.map(row => String(row["by_account"]));
|
|
69
|
+
}
|
|
70
|
+
}
|
package/package.json
CHANGED
package/src/accounts.ts
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
* terminal better than either -- OAuth 2.0 reached through the device grant in
|
|
20
20
|
* device.ts -- and what it ends with is a token from tokens.ts.
|
|
21
21
|
*/
|
|
22
|
-
import { createAuthSystem, PostgresAdapter } from "@profullstack/auth-system";
|
|
22
|
+
import { createAuthSystem, createPasswordUtils, PostgresAdapter } from "@profullstack/auth-system";
|
|
23
|
+
import { PasswordResets } from "./password-reset.ts";
|
|
23
24
|
import { Identities, type Identity, type Users } from "./oauth.ts";
|
|
24
25
|
import type { Queryable } from "./follows.ts";
|
|
25
26
|
import { type IssuedToken, looksLikeToken, type TokenKind, type TokenRecord, Tokens } from "./tokens.ts";
|
|
@@ -129,6 +130,7 @@ export class Accounts {
|
|
|
129
130
|
private readonly system: AuthLike;
|
|
130
131
|
/** Null only where a test injected an auth system and no storage. */
|
|
131
132
|
readonly tokens: Tokens | null;
|
|
133
|
+
readonly passwordResets: PasswordResets | null;
|
|
132
134
|
private readonly identities: Identities | null;
|
|
133
135
|
|
|
134
136
|
constructor(options: AccountsOptions) {
|
|
@@ -151,6 +153,8 @@ export class Accounts {
|
|
|
151
153
|
passwordOptions: { ...PASSWORD_RULES },
|
|
152
154
|
}) as AuthLike);
|
|
153
155
|
this.tokens = adapter ? new Tokens(adapter) : null;
|
|
156
|
+
this.passwordResets = adapter && this.tokens
|
|
157
|
+
? new PasswordResets(adapter, this.tokens, createPasswordUtils({ ...PASSWORD_RULES })) : null;
|
|
154
158
|
this.identities = adapter ? new Identities(adapter, adapter) : null;
|
|
155
159
|
}
|
|
156
160
|
|
|
@@ -216,7 +220,9 @@ export class Accounts {
|
|
|
216
220
|
}
|
|
217
221
|
}
|
|
218
222
|
try {
|
|
219
|
-
|
|
223
|
+
const account = readClaims(await this.system.validateToken(token));
|
|
224
|
+
if (account && this.passwordResets && !(await this.passwordResets.acceptsLegacyToken(account.id, token))) return null;
|
|
225
|
+
return account;
|
|
220
226
|
} catch {
|
|
221
227
|
return null;
|
|
222
228
|
}
|
package/src/classroom.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Public classroom metadata, shared by the API and the small school client. */
|
|
2
|
+
export interface ClassroomSettings {
|
|
3
|
+
broadcastUrl?: string;
|
|
4
|
+
hostName?: string;
|
|
5
|
+
homepageUrl?: string;
|
|
6
|
+
avatarUrl?: string;
|
|
7
|
+
recurrence?: "daily" | "weekly";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function publicWebUrl(value: unknown): string | null {
|
|
11
|
+
if (typeof value !== "string" || value.length > 2048) return null;
|
|
12
|
+
try {
|
|
13
|
+
const url = new URL(value);
|
|
14
|
+
if (url.protocol !== "https:" || url.username || url.password) return null;
|
|
15
|
+
return url.href;
|
|
16
|
+
} catch { return null; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function classroomBroadcast(value: unknown): { provider: "pairux" | "nixamp"; url: string; embed: string; join: string } | null {
|
|
20
|
+
const safe = publicWebUrl(value);
|
|
21
|
+
if (!safe) return null;
|
|
22
|
+
const url = new URL(safe);
|
|
23
|
+
if (!url.port && (url.hostname === "pairux.com" || url.hostname === "www.pairux.com")) {
|
|
24
|
+
const code = /^\/(?:l|join|embed)\/([A-Za-z0-9_-]{1,64})\/?$/.exec(url.pathname)?.[1];
|
|
25
|
+
if (!code) return null;
|
|
26
|
+
return { provider: "pairux", url: `https://pairux.com/l/${code}`, embed: `https://pairux.com/embed/${code}`, join: `https://pairux.com/join/${code}` };
|
|
27
|
+
}
|
|
28
|
+
if (!url.port && (url.hostname === "nixamp.com" || url.hostname === "www.nixamp.com")) {
|
|
29
|
+
if (url.pathname !== "/") return null;
|
|
30
|
+
// Never publish an admin credential in a classroom share link.
|
|
31
|
+
const server = url.searchParams.get("url") || "";
|
|
32
|
+
// The server's Share panel supplies a viewer URL without a track selection.
|
|
33
|
+
const play = url.searchParams.get("play") || (server ? "live" : "");
|
|
34
|
+
if (/\/admin\//i.test(server) || /\/admin\//i.test(play) || url.searchParams.has("key")) return null;
|
|
35
|
+
if (server && !publicWebUrl(server)) return null;
|
|
36
|
+
if (!/^(?:live|channel:[^\s]+|track:\d+)$/.test(play) && !publicWebUrl(play)) return null;
|
|
37
|
+
const clean = new URL("https://nixamp.com/");
|
|
38
|
+
if (server) clean.searchParams.set("url", server);
|
|
39
|
+
clean.searchParams.set("play", play);
|
|
40
|
+
const original = clean.href;
|
|
41
|
+
clean.searchParams.set("embed", "1");
|
|
42
|
+
return { provider: "nixamp", url: original, embed: clean.href, join: original };
|
|
43
|
+
}
|
|
44
|
+
// A self-hosted Nixamp viewer link can be played through the shared client.
|
|
45
|
+
if (url.hostname.endsWith(".nixamp.com") && /^\/view\/[A-Za-z0-9_-]+\/?$/.test(url.pathname) && !url.search) {
|
|
46
|
+
const player = new URL("https://nixamp.com/");
|
|
47
|
+
player.searchParams.set("url", url.href);
|
|
48
|
+
player.searchParams.set("play", "live");
|
|
49
|
+
return classroomBroadcast(player.href);
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|