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/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";
|
|
@@ -1717,6 +1719,8 @@ export interface HandlerOptions {
|
|
|
1717
1719
|
publishUrls?: () => { id: string; url: string }[];
|
|
1718
1720
|
/** Accounts, on the instance that keeps them. Only nixamp.com passes this. */
|
|
1719
1721
|
accounts?: Accounts;
|
|
1722
|
+
/** Transactional password recovery mail, separate from follow notifications. */
|
|
1723
|
+
resetMail?: ResetMail;
|
|
1720
1724
|
/** Providers to sign in with, and the terminals waiting to be connected. */
|
|
1721
1725
|
signIn?: SignIn;
|
|
1722
1726
|
/** The servers each account runs, on the instance that keeps accounts. */
|
|
@@ -1852,6 +1856,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
1852
1856
|
const webSite = options.webSites?.get((request.headers.host ?? "").toLowerCase());
|
|
1853
1857
|
const web = webSite?.web ?? options.web;
|
|
1854
1858
|
const eventSite = webSite?.site ?? options.invites?.site;
|
|
1859
|
+
const accountSite = eventSite ?? options.site ?? "https://nixamp.com";
|
|
1855
1860
|
const key = options.key ?? null;
|
|
1856
1861
|
const behindProxy = options.behindProxy ?? false;
|
|
1857
1862
|
const listenKey = options.listenKey ?? null;
|
|
@@ -2460,6 +2465,20 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2460
2465
|
return;
|
|
2461
2466
|
}
|
|
2462
2467
|
|
|
2468
|
+
if (path === "/reset-password" && options.accounts?.passwordResets) {
|
|
2469
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
2470
|
+
json(response, 405, { error: "GET only" }); return;
|
|
2471
|
+
}
|
|
2472
|
+
const nonce = randomBytes(18).toString("base64");
|
|
2473
|
+
response.writeHead(200, {
|
|
2474
|
+
"content-type": "text/html; charset=utf-8", "cache-control": "no-store",
|
|
2475
|
+
"referrer-policy": "no-referrer", "x-robots-tag": "noindex, nofollow",
|
|
2476
|
+
"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'`,
|
|
2477
|
+
});
|
|
2478
|
+
response.end(request.method === "HEAD" ? undefined : passwordResetPage(accountSite, nonce));
|
|
2479
|
+
return;
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2463
2482
|
// --- accounts ---------------------------------------------------------
|
|
2464
2483
|
//
|
|
2465
2484
|
// Before the share-key check, because signing in is how somebody without a
|
|
@@ -2469,6 +2488,46 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2469
2488
|
const accounts = options.accounts;
|
|
2470
2489
|
const secure = options.secureCookies ?? false;
|
|
2471
2490
|
|
|
2491
|
+
if (path === "/api/v1/auth/password-reset/request" || path === "/api/v1/auth/password-reset/confirm") {
|
|
2492
|
+
if (request.method !== "POST") { json(response, 405, { error: "POST only" }); return; }
|
|
2493
|
+
const confirming = path.endsWith("/confirm");
|
|
2494
|
+
if (!accounts.passwordResets || (!confirming && !options.resetMail)) {
|
|
2495
|
+
json(response, 503, { error: "Password reset email is temporarily unavailable. Please try again later." }); return;
|
|
2496
|
+
}
|
|
2497
|
+
const who = callerOf(request.headers, request.socket.remoteAddress, behindProxy);
|
|
2498
|
+
const limit = guard.check(`password-reset:${confirming ? "confirm" : "request"}:${who}`, { allowed: confirming ? 15 : 10, windowMs: 15 * 60_000 });
|
|
2499
|
+
if (!limit.ok) {
|
|
2500
|
+
response.setHeader("retry-after", String(limit.retryAfter));
|
|
2501
|
+
json(response, 429, { error: "Too many attempts. Please try again in 15 minutes." }); return;
|
|
2502
|
+
}
|
|
2503
|
+
try {
|
|
2504
|
+
const input: unknown = JSON.parse(await readBody(request, 4096));
|
|
2505
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
2506
|
+
json(response, 400, { error: "Provide a JSON object." }); return;
|
|
2507
|
+
}
|
|
2508
|
+
const body = input as Record<string, unknown>;
|
|
2509
|
+
if (confirming) {
|
|
2510
|
+
const error = await accounts.passwordResets.confirm(body["token"], body["password"]);
|
|
2511
|
+
if (error) { json(response, error === RESET_INVALID ? 400 : 422, { error }); return; }
|
|
2512
|
+
response.setHeader("set-cookie", clearedCookie());
|
|
2513
|
+
json(response, 200, { message: "Your password has been changed. Sign in with your new password." });
|
|
2514
|
+
} else {
|
|
2515
|
+
const email = typeof body["email"] === "string" ? body["email"].trim().toLowerCase() : "";
|
|
2516
|
+
if (email.length > 320 || !/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(email)) {
|
|
2517
|
+
json(response, 422, { error: "Enter a valid email address." }); return;
|
|
2518
|
+
}
|
|
2519
|
+
// Count every address, including nonexistent ones, identically.
|
|
2520
|
+
const address = guard.check(`password-reset:email:${email}`, { allowed: 3, windowMs: 15 * 60_000 });
|
|
2521
|
+
if (address.ok) await accounts.passwordResets.request(email, accountSite, options.resetMail!);
|
|
2522
|
+
json(response, 200, { message: RESET_MESSAGE });
|
|
2523
|
+
}
|
|
2524
|
+
} catch (error) {
|
|
2525
|
+
const malformed = error instanceof SyntaxError || (error as Error).message === "body too large";
|
|
2526
|
+
json(response, malformed ? 400 : 503, { error: malformed ? "Provide a valid JSON request." : "Password reset is temporarily unavailable. Please try again." });
|
|
2527
|
+
}
|
|
2528
|
+
return;
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2472
2531
|
if (path === "/api/v1/auth/me") {
|
|
2473
2532
|
const who = await accounts.whoIs(tokenFrom(request.headers));
|
|
2474
2533
|
if (who === null) {
|
|
@@ -2909,8 +2968,16 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2909
2968
|
if (path === "/api/v1/translation-passes" && request.method === "GET") {
|
|
2910
2969
|
json(response, 200, await service.access(who?.id)); return;
|
|
2911
2970
|
}
|
|
2912
|
-
if (!who) throw new SpeechError("Sign in to
|
|
2971
|
+
if (!who) throw new SpeechError("Sign in to use translated audio.", 401);
|
|
2913
2972
|
if (!guard.check(`translation-pass-account:${who.id}`, { allowed: 30, windowMs: 60_000 }).ok) throw new SpeechError("Too many purchase requests.", 429);
|
|
2973
|
+
if (path === "/api/v1/translation-passes/session" && request.method === "POST") {
|
|
2974
|
+
if (!request.headers["content-type"]?.startsWith("application/json")) throw new SpeechError("Send a panel session as JSON.", 415);
|
|
2975
|
+
let body: { resource?: unknown };
|
|
2976
|
+
try { body = JSON.parse(await readBody(request, 1024)); } catch { throw new SpeechError("Choose a panel session.", 400); }
|
|
2977
|
+
if (typeof body?.resource !== "string") throw new SpeechError("Choose a panel session.", 400);
|
|
2978
|
+
await service.begin(who.id, body.resource);
|
|
2979
|
+
json(response, 200, await service.access(who.id)); return;
|
|
2980
|
+
}
|
|
2914
2981
|
if (path === "/api/v1/translation-passes/checkout" && request.method === "POST") {
|
|
2915
2982
|
if (!request.headers["content-type"]?.startsWith("application/json")) throw new SpeechError("Send a purchase as JSON.", 415);
|
|
2916
2983
|
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 +3026,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2959
3026
|
} else if (path.endsWith("/speakers") && request.method === "POST") {
|
|
2960
3027
|
if (!request.headers["content-type"]?.startsWith("audio/wav")) { json(response, 415, { error: "send a mono 16 kHz WAV" }); return; }
|
|
2961
3028
|
const bytes = await readBytes(request, 484_000);
|
|
2962
|
-
|
|
3029
|
+
const resource = request.headers["x-nixamp-translation-session"];
|
|
3030
|
+
if (resource !== undefined && (typeof resource !== "string" || !/^[\w-]{1,80}$/.test(resource))) throw new SpeechError("Choose a panel session.", 400);
|
|
3031
|
+
json(response, 200, await service.hear(bytes, who.id, controller.signal, undefined, resource));
|
|
2963
3032
|
} else if (path.endsWith("/grant") && request.method === "POST") {
|
|
2964
3033
|
if (!request.headers["content-type"]?.startsWith("application/json")) { json(response, 415, { error: "send JSON" }); return; }
|
|
2965
3034
|
let body: { channel?: unknown };
|
|
@@ -6595,6 +6664,12 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
6595
6664
|
...(translations ? { translations } : {}),
|
|
6596
6665
|
...(records ? { records } : {}),
|
|
6597
6666
|
site: nixampSite,
|
|
6667
|
+
...(accounts && process.env["RESEND_API_KEY"] ? {
|
|
6668
|
+
resetMail: resendPasswordReset({
|
|
6669
|
+
apiKey: process.env["RESEND_API_KEY"],
|
|
6670
|
+
from: process.env["NIXAMP_MAIL_FROM"] ?? "nixamp <notifications@nixamp.com>",
|
|
6671
|
+
}),
|
|
6672
|
+
} : {}),
|
|
6598
6673
|
// Other people's OpenProfiles, read for the voice a line is spoken in,
|
|
6599
6674
|
// and the voices to speak in: ElevenLabs when the Telnyx account holds
|
|
6600
6675
|
// the key for it (an integration secret named "elevenlabs"), Kokoro
|
|
@@ -71,18 +71,24 @@ export class SharedTranslations {
|
|
|
71
71
|
if (!this.options.voice.available()) throw new SpeechError("Translated audio is unavailable.", 503);
|
|
72
72
|
const source = sharedSource(raw);
|
|
73
73
|
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
74
|
const id = `dub-${createHash("sha256").update(`${source}|${language}`).digest("hex").slice(0, 40)}`;
|
|
75
|
+
const capacity = (): void => {
|
|
76
|
+
const connections = [...this.feeds.values()].reduce((total, feed) => total + [...feed.members].filter(member => member.by === by).length, 0);
|
|
77
|
+
if (connections >= 2) throw new SpeechError("Translated audio is already open in two players for this account.", 429);
|
|
78
|
+
const feed = this.feeds.get(id);
|
|
79
|
+
if (!feed && this.feeds.size >= 4) throw new SpeechError("Live translation is busy. Try again shortly.", 429);
|
|
80
|
+
if (feed && feed.members.size >= 1000) throw new SpeechError("This translated stream is full. Try again shortly.", 429);
|
|
81
|
+
};
|
|
82
|
+
capacity();
|
|
83
|
+
if (this.options.billing?.begin) await this.options.billing.begin(by, id);
|
|
84
|
+
else await this.options.billing?.require(by, id);
|
|
85
|
+
capacity(); // Other joins can finish during the account/database check.
|
|
78
86
|
let feed = this.feeds.get(id);
|
|
79
87
|
if (!feed) {
|
|
80
|
-
if (this.feeds.size >= 4) throw new SpeechError("Live translation is busy. Try again shortly.", 429);
|
|
81
88
|
feed = this.make(id, source, language); this.feeds.set(id, feed);
|
|
82
89
|
const current = feed;
|
|
83
90
|
current.heartbeat = setInterval(() => void this.checkMembers(current), 10_000); current.heartbeat.unref?.();
|
|
84
91
|
}
|
|
85
|
-
if (feed.members.size >= 1000) throw new SpeechError("This translated stream is full. Try again shortly.", 429);
|
|
86
92
|
const member = { by, send, close }; feed.members.add(member);
|
|
87
93
|
this.broadcast(feed, { type: "status", listeners: feed.members.size });
|
|
88
94
|
if (feed.members.size === 1 && !feed.input) void this.start(feed);
|
|
@@ -99,14 +105,14 @@ export class SharedTranslations {
|
|
|
99
105
|
}
|
|
100
106
|
private async checkMembers(feed: Feed): Promise<void> {
|
|
101
107
|
const accounts = [...new Set([...feed.members].map(member => member.by))];
|
|
102
|
-
let funded = new Set(accounts), message = "
|
|
108
|
+
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
109
|
try {
|
|
104
|
-
if (this.options.billing?.eligible) funded = new Set(await this.options.billing.eligible(accounts));
|
|
110
|
+
if (this.options.billing?.eligible) funded = new Set(await this.options.billing.eligible(accounts, feed.id));
|
|
105
111
|
else if (this.options.billing) {
|
|
106
|
-
const results = await Promise.allSettled(accounts.map(by => this.options.billing!.require(by)));
|
|
112
|
+
const results = await Promise.allSettled(accounts.map(by => this.options.billing!.require(by, feed.id)));
|
|
107
113
|
funded = new Set(accounts.filter((_by, index) => results[index]?.status === "fulfilled"));
|
|
108
114
|
}
|
|
109
|
-
} catch { funded.clear(); message = "Your translation
|
|
115
|
+
} catch { funded.clear(); message = "Your translation access could not be checked. Try again shortly."; }
|
|
110
116
|
for (const member of [...feed.members]) if (accounts.includes(member.by) && !funded.has(member.by)) {
|
|
111
117
|
try { member.send({ type: "error", error: message }); } catch { /* disconnected */ }
|
|
112
118
|
finally { this.leave(feed, member); member.close(); }
|
|
@@ -123,8 +129,8 @@ export class SharedTranslations {
|
|
|
123
129
|
}
|
|
124
130
|
private make(id: string, source: URL, language: string): Feed {
|
|
125
131
|
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
|
-
//
|
|
127
|
-
//
|
|
132
|
+
// Each account uses free access first, then the same published credit rate.
|
|
133
|
+
// Additional listeners do not trigger recognition or synthesis.
|
|
128
134
|
feed.meter = {
|
|
129
135
|
require: async () => { if (!feed.members.size) throw new SpeechError("No listeners.", 410); },
|
|
130
136
|
reserve: async (_by: string, kind: TranslationUsage, units: number) => {
|
|
@@ -133,9 +139,9 @@ export class SharedTranslations {
|
|
|
133
139
|
const accepted = new Set<string>();
|
|
134
140
|
const billing = this.options.billing;
|
|
135
141
|
if (billing?.reserveMany) {
|
|
136
|
-
for (const charged of await billing.reserveMany(accounts, kind, units)) { ids.push(charged.id); accepted.add(charged.by); }
|
|
142
|
+
for (const charged of await billing.reserveMany(accounts, kind, units, feed.id)) { ids.push(charged.id); accepted.add(charged.by); }
|
|
137
143
|
} else {
|
|
138
|
-
const results = await Promise.allSettled(accounts.map(async account => ({ by: account, id: await billing?.reserve(account, kind, units) })));
|
|
144
|
+
const results = await Promise.allSettled(accounts.map(async account => ({ by: account, id: await billing?.reserve(account, kind, units, feed.id) })));
|
|
139
145
|
let failure: unknown;
|
|
140
146
|
for (const result of results) {
|
|
141
147
|
if (result.status === "fulfilled") { accepted.add(result.value.by); if (result.value.id) ids.push(result.value.id); }
|
|
@@ -144,7 +150,7 @@ export class SharedTranslations {
|
|
|
144
150
|
if (failure) { await Promise.all(ids.map(id => billing!.refund(id))); throw failure; }
|
|
145
151
|
}
|
|
146
152
|
for (const member of [...feed.members]) if (accounts.includes(member.by) && !accepted.has(member.by)) {
|
|
147
|
-
try { member.send({ type: "error", error: "
|
|
153
|
+
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
154
|
catch { /* disconnected */ } finally { this.leave(feed, member); member.close(); }
|
|
149
155
|
}
|
|
150
156
|
if (!feed.members.size) { await Promise.all(ids.map(id => this.options.billing!.refund(id))); throw new SpeechError("No funded listeners.", 402); }
|
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();
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import type { Queryable } from "./follows.ts";
|
|
5
5
|
import { SpeechError } from "./speech.ts";
|
|
6
|
+
import { UpgradeAllowances, type UpgradeAllowance } from "./upgrade-allowance.ts";
|
|
6
7
|
|
|
7
8
|
export const TRANSLATION_MULTIPLIER = 5;
|
|
8
9
|
export const TRANSLATION_PLANS = [
|
|
@@ -13,10 +14,11 @@ export const TRANSLATION_PLANS = [
|
|
|
13
14
|
export type TranslationPlan = typeof TRANSLATION_PLANS[number];
|
|
14
15
|
export type TranslationUsage = "transcription" | "voice";
|
|
15
16
|
export interface TranslationMeter {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
begin?(by: string, resource: string): Promise<void>;
|
|
18
|
+
require(by: string, resource?: string): Promise<void>;
|
|
19
|
+
eligible?(accounts: string[], resource?: string): Promise<string[]>;
|
|
20
|
+
reserve(by: string, kind: TranslationUsage, units: number, resource?: string): Promise<string>;
|
|
21
|
+
reserveMany?(accounts: string[], kind: TranslationUsage, units: number, resource?: string): Promise<{ by: string; id: string }[]>;
|
|
20
22
|
commitMany?(ids: string[]): Promise<void>;
|
|
21
23
|
refundMany?(ids: string[]): Promise<void>;
|
|
22
24
|
commit(id: string): Promise<void>;
|
|
@@ -26,6 +28,7 @@ export interface TranslationAccess {
|
|
|
26
28
|
required: boolean; available: boolean; balanceMicros: number; expires: string | null;
|
|
27
29
|
plans: readonly TranslationPlan[]; coins: string[]; orders: { id: string; url: string; plan: string }[];
|
|
28
30
|
rates: { transcriptionHour: number; voiceThousand: number; multiplier: number };
|
|
31
|
+
free: UpgradeAllowance;
|
|
29
32
|
}
|
|
30
33
|
const COINS = ["USDC_POL", "USDC_SOL", "SOL", "POL", "BTC", "ETH", "USDC_ETH", "BCH", "DOGE"];
|
|
31
34
|
const ID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
|
|
@@ -57,11 +60,14 @@ export class TranslationPasses implements TranslationMeter {
|
|
|
57
60
|
private merchantUntil = 0;
|
|
58
61
|
private readonly fetcher: typeof fetch;
|
|
59
62
|
private readonly now: () => number;
|
|
63
|
+
readonly free: UpgradeAllowances;
|
|
60
64
|
constructor(private readonly options: { db: Queryable; key: string; site: string; fetcher?: typeof fetch; now?: () => number }) {
|
|
61
65
|
this.fetcher = options.fetcher ?? fetch; this.now = options.now ?? Date.now;
|
|
66
|
+
this.free = new UpgradeAllowances(options.db, this.now);
|
|
62
67
|
}
|
|
63
68
|
private async ensure(): Promise<void> {
|
|
64
69
|
this.schema ??= (async () => {
|
|
70
|
+
await this.free.ensure();
|
|
65
71
|
await this.options.db.query(`CREATE TABLE IF NOT EXISTS translation_wallets (
|
|
66
72
|
by_account TEXT PRIMARY KEY, balance_micros BIGINT NOT NULL CHECK (balance_micros >= 0), expires_at TIMESTAMPTZ NOT NULL)`);
|
|
67
73
|
await this.options.db.query(`CREATE TABLE IF NOT EXISTS translation_orders (
|
|
@@ -105,41 +111,45 @@ export class TranslationPasses implements TranslationMeter {
|
|
|
105
111
|
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;
|
|
106
112
|
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"]}` })) : [];
|
|
107
113
|
return { required: true, available: config.coins.length > 0, balanceMicros: Number(row?.["balance_micros"] ?? 0),
|
|
108
|
-
expires: row ? new Date(String(row["expires_at"])).toISOString() : null, plans: TRANSLATION_PLANS, coins: config.coins, orders,
|
|
114
|
+
expires: row ? new Date(String(row["expires_at"])).toISOString() : null, plans: TRANSLATION_PLANS, coins: config.coins, orders, free: await this.free.access(by),
|
|
109
115
|
rates: { transcriptionHour: 0.22, voiceThousand: 0.05, multiplier: TRANSLATION_MULTIPLIER } };
|
|
110
116
|
}
|
|
111
|
-
async
|
|
117
|
+
async begin(by: string, resource: string): Promise<void> {
|
|
112
118
|
await this.ensure();
|
|
119
|
+
if (await this.free.begin(by, "translation", resource)) return;
|
|
120
|
+
await this.require(by, resource);
|
|
121
|
+
}
|
|
122
|
+
async require(by: string, resource = ""): Promise<void> {
|
|
123
|
+
await this.ensure();
|
|
124
|
+
if ((await this.free.active([by], "translation", resource, true)).length) return;
|
|
113
125
|
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())]);
|
|
114
|
-
if (!found.rows.length) throw new SpeechError("Buy a
|
|
126
|
+
if (!found.rows.length) throw new SpeechError("Buy a pass or start a remaining free session: this session has no free time or audio credit.", 402);
|
|
115
127
|
}
|
|
116
|
-
async eligible(accounts: string[]): Promise<string[]> {
|
|
128
|
+
async eligible(accounts: string[], resource = ""): Promise<string[]> {
|
|
117
129
|
await this.ensure();
|
|
118
130
|
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())]);
|
|
119
|
-
return result.rows.map(row => String(row["by_account"]));
|
|
131
|
+
return [...new Set([...result.rows.map(row => String(row["by_account"])), ...await this.free.active(accounts, "translation", resource, true)])];
|
|
120
132
|
}
|
|
121
|
-
async reserve(by: string, kind: TranslationUsage, units: number): Promise<string> {
|
|
122
|
-
await this.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
UPDATE translation_wallets SET balance_micros = balance_micros - $2
|
|
126
|
-
WHERE by_account = $1 AND expires_at > $3 AND balance_micros >= $2 RETURNING by_account)
|
|
127
|
-
INSERT INTO translation_usage (id, by_account, kind, units, cost_micros, charge_micros, created_at)
|
|
128
|
-
SELECT $4, by_account, $5, $6, $7, $2, $3 FROM debit RETURNING id`,
|
|
129
|
-
[by, charge, new Date(this.now()), id, kind, units, cost]);
|
|
130
|
-
if (!result.rows.length) throw new SpeechError("Your translation balance is used up or expired. Buy another pass to continue.", 402);
|
|
131
|
-
return id;
|
|
133
|
+
async reserve(by: string, kind: TranslationUsage, units: number, resource = ""): Promise<string> {
|
|
134
|
+
const result = await this.reserveMany([by], kind, units, resource);
|
|
135
|
+
if (!result.length) throw new SpeechError("This free session ended and audio credit is used up. Start a remaining free session or buy a pass.", 402);
|
|
136
|
+
return result[0]!.id;
|
|
132
137
|
}
|
|
133
138
|
/** Batch a shared stream's access charges in one database round trip. */
|
|
134
|
-
async reserveMany(accounts: string[], kind: TranslationUsage, units: number): Promise<{ by: string; id: string }[]> {
|
|
139
|
+
async reserveMany(accounts: string[], kind: TranslationUsage, units: number, resource = ""): Promise<{ by: string; id: string }[]> {
|
|
135
140
|
await this.ensure();
|
|
136
141
|
const { cost, charge } = translationCost(kind, units);
|
|
137
|
-
const
|
|
142
|
+
const now = this.now(), key = resource ? this.free.key("translation", resource) : "";
|
|
143
|
+
const result = await this.options.db.query(`WITH free AS (
|
|
144
|
+
SELECT DISTINCT by_account FROM upgrade_daily_uses WHERE by_account = ANY($1::text[])
|
|
145
|
+
AND (sessions->>$7)::bigint > $8), debit AS (
|
|
138
146
|
UPDATE translation_wallets SET balance_micros = balance_micros - $2
|
|
139
|
-
WHERE by_account = ANY($1::text[]) AND expires_at > $3 AND balance_micros >= $2
|
|
147
|
+
WHERE by_account = ANY($1::text[]) AND expires_at > $3 AND balance_micros >= $2
|
|
148
|
+
AND by_account NOT IN (SELECT by_account FROM free) RETURNING by_account), admitted AS (
|
|
149
|
+
SELECT by_account, 0::bigint AS charge FROM free UNION ALL SELECT by_account, $2::bigint FROM debit)
|
|
140
150
|
INSERT INTO translation_usage (id, by_account, kind, units, cost_micros, charge_micros, created_at)
|
|
141
|
-
SELECT gen_random_uuid()::text, by_account, $4, $5, $6,
|
|
142
|
-
[[...new Set(accounts)], charge, new Date(
|
|
151
|
+
SELECT gen_random_uuid()::text, by_account, $4, $5, $6, charge, $3 FROM admitted RETURNING id, by_account`,
|
|
152
|
+
[[...new Set(accounts)], charge, new Date(now), kind, units, cost, key, now]);
|
|
143
153
|
return result.rows.map(row => ({ by: String(row["by_account"]), id: String(row["id"]) }));
|
|
144
154
|
}
|
|
145
155
|
async commit(id: string): Promise<void> {
|
|
@@ -8,6 +8,16 @@
|
|
|
8
8
|
* than smoothed over.
|
|
9
9
|
*/
|
|
10
10
|
declare module "@profullstack/auth-system" {
|
|
11
|
+
export function createPasswordUtils(options?: {
|
|
12
|
+
minLength?: number;
|
|
13
|
+
requireUppercase?: boolean;
|
|
14
|
+
requireLowercase?: boolean;
|
|
15
|
+
requireNumbers?: boolean;
|
|
16
|
+
requireSpecialChars?: boolean;
|
|
17
|
+
}): {
|
|
18
|
+
hashPassword(password: string): Promise<string>;
|
|
19
|
+
validatePassword(password: string): { valid: boolean; message: string };
|
|
20
|
+
};
|
|
11
21
|
export interface AuthUser {
|
|
12
22
|
id: string;
|
|
13
23
|
email: string;
|
|
@@ -0,0 +1,69 @@
|
|
|
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 type { Queryable } from "./follows.ts";
|
|
5
|
+
import { SpeechError } from "./speech.ts";
|
|
6
|
+
|
|
7
|
+
export const FREE_UPGRADE_SESSIONS = 5;
|
|
8
|
+
export const FREE_UPGRADE_RECONNECT_SECONDS = 90;
|
|
9
|
+
const DAY = 86_400_000;
|
|
10
|
+
export interface UpgradeAllowance {
|
|
11
|
+
limit: number; remaining: number; reconnectSeconds: number; resets: string;
|
|
12
|
+
activeUntil: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class UpgradeAllowances {
|
|
16
|
+
private schema: Promise<void> | null = null;
|
|
17
|
+
constructor(private readonly db: Queryable, private readonly now: () => number = Date.now) {}
|
|
18
|
+
async ensure(): Promise<void> {
|
|
19
|
+
this.schema ??= this.db.query(`CREATE TABLE IF NOT EXISTS upgrade_daily_uses (
|
|
20
|
+
by_account TEXT NOT NULL, day BIGINT NOT NULL,
|
|
21
|
+
uses INTEGER NOT NULL CHECK (uses BETWEEN 1 AND 5),
|
|
22
|
+
sessions JSONB NOT NULL, PRIMARY KEY (by_account, day))`).then(() => undefined)
|
|
23
|
+
.catch(error => { this.schema = null; throw error; });
|
|
24
|
+
await this.schema;
|
|
25
|
+
}
|
|
26
|
+
key(product: string, resource: string): string {
|
|
27
|
+
if (!/^[a-z][a-z0-9-]{0,63}$/.test(product) || !/^[\w-]{1,80}$/.test(resource)) throw new SpeechError("Choose a panel session.", 400);
|
|
28
|
+
return createHash("sha256").update(`${product}|${resource}`).digest("hex");
|
|
29
|
+
}
|
|
30
|
+
async access(by?: string): Promise<UpgradeAllowance> {
|
|
31
|
+
await this.ensure();
|
|
32
|
+
const now = this.now(), day = Math.floor(now / DAY);
|
|
33
|
+
const rows = by ? (await this.db.query("SELECT uses, day, sessions FROM upgrade_daily_uses WHERE by_account = $1", [by])).rows : [];
|
|
34
|
+
const today = rows.find(row => Number(row["day"]) === day);
|
|
35
|
+
const active = Math.max(0, ...rows.flatMap(row => Object.values(row["sessions"] as Record<string, number>).map(Number)));
|
|
36
|
+
return { limit: FREE_UPGRADE_SESSIONS, remaining: Math.max(0, FREE_UPGRADE_SESSIONS - Number(today?.["uses"] ?? 0)),
|
|
37
|
+
reconnectSeconds: FREE_UPGRADE_RECONNECT_SECONDS, resets: new Date((day + 1) * DAY).toISOString(), activeUntil: active > now ? new Date(active).toISOString() : null };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One atomic row serializes concurrent starts across tabs, products and replicas.
|
|
41
|
+
* Active use renews a short lease. Reconnecting reuses it; idle leases expire. */
|
|
42
|
+
async begin(by: string, product: string, resource: string): Promise<boolean> {
|
|
43
|
+
if (!by) throw new SpeechError("Sign in to use your free sessions.", 401);
|
|
44
|
+
const key = this.key(product, resource);
|
|
45
|
+
if ((await this.active([by], product, resource, true)).length) 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: string[], product: string, resource: string, renew = false): Promise<string[]> {
|
|
58
|
+
if (!resource || !accounts.length) return [];
|
|
59
|
+
const key = this.key(product, resource);
|
|
60
|
+
await this.ensure();
|
|
61
|
+
const now = this.now();
|
|
62
|
+
const result = renew
|
|
63
|
+
? await this.db.query(`UPDATE upgrade_daily_uses SET sessions = sessions || jsonb_build_object($2::text, $4::bigint)
|
|
64
|
+
WHERE by_account = ANY($1::text[]) AND (sessions->>$2)::bigint > $3 RETURNING by_account`, [accounts, key, now, now + FREE_UPGRADE_RECONNECT_SECONDS * 1000])
|
|
65
|
+
: await this.db.query(`SELECT by_account FROM upgrade_daily_uses
|
|
66
|
+
WHERE by_account = ANY($1::text[]) AND (sessions->>$2)::bigint > $3`, [accounts, key, now]);
|
|
67
|
+
return result.rows.map(row => String(row["by_account"]));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./index-
|
|
1
|
+
import{t as e}from"./index-CLeEWlms.js";var t=3;async function n(n){let{media:r,src:i,isTv:a}=n,{default:o}=await e(async()=>{let{default:e}=await import(`./hls-n74Cnh8A.js`);return{default:e}},[]);if(!o.isSupported())return n.onError(`This browser cannot play HLS streams.`),{destroy:()=>void 0,levels:()=>[]};let s=new o({...a?{maxBufferLength:60,maxMaxBufferLength:120,backBufferLength:30,liveSyncDurationCount:4}:{backBufferLength:90},enableWorker:!0}),c=0,l=!1;s.on(o.Events.ERROR,(e,r)=>{if(!l&&r.fatal){if(c>=t){n.onError(`This stream kept failing and has been stopped.`),s.destroy();return}switch(c+=1,r.type){case o.ErrorTypes.NETWORK_ERROR:n.onNotice(`Reconnecting…`),s.startLoad();break;case o.ErrorTypes.MEDIA_ERROR:n.onNotice(`Recovering…`),s.recoverMediaError();break;default:n.onError(`This stream could not be played.`),s.destroy()}}}),s.on(o.Events.MANIFEST_PARSED,()=>{l||(n.onNotice(null),n.onReady?.({live:s.levels.length>0&&!Number.isFinite(r.duration),levels:u()}))}),s.on(o.Events.LEVEL_LOADED,(e,t)=>{l||n.onReady?.({live:t.details.live,levels:u()})}),s.on(o.Events.FRAG_BUFFERED,()=>{l||n.onNotice(null)});function u(){return s.levels.map((e,t)=>({index:t,height:e.height||null,bitrate:e.bitrate||null,label:e.height?`${String(e.height)}p`:`${String(Math.round((e.bitrate||0)/1e3))}k`}))}return s.loadSource(i),s.attachMedia(r),{destroy(){l=!0,s.destroy()},levels:u,setLevel(e){s.currentLevel=e},currentLevel:()=>s.autoLevelEnabled?-1:s.currentLevel}}export{n as createHlsEngine};
|