nixamp 0.27.1 → 0.27.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +53 -20
  2. package/backtoschool/dist/assets/{index-6vtCMzi1.css → index-Bot4FEXQ.css} +1 -1
  3. package/backtoschool/dist/assets/index-Cjai7LgT.js +93 -0
  4. package/backtoschool/dist/index.html +36 -9
  5. package/dist/accounts.d.ts +2 -0
  6. package/dist/accounts.js +9 -2
  7. package/dist/classroom.d.ts +15 -0
  8. package/dist/classroom.js +54 -0
  9. package/dist/event-writer.d.ts +39 -0
  10. package/dist/event-writer.js +139 -0
  11. package/dist/live-api.d.ts +2 -0
  12. package/dist/live-api.js +37 -2
  13. package/dist/live-events.d.ts +7 -1
  14. package/dist/live-events.js +88 -12
  15. package/dist/live-interpreter.d.ts +3 -1
  16. package/dist/live-interpreter.js +109 -83
  17. package/dist/live-recovery.d.ts +6 -0
  18. package/dist/live-recovery.js +11 -0
  19. package/dist/live-voice.d.ts +1 -1
  20. package/dist/live-voice.js +7 -7
  21. package/dist/mail-sender.d.ts +9 -0
  22. package/dist/mail-sender.js +11 -0
  23. package/dist/notify.d.ts +3 -3
  24. package/dist/notify.js +8 -5
  25. package/dist/password-reset-page.d.ts +2 -0
  26. package/dist/password-reset-page.js +106 -0
  27. package/dist/password-reset.d.ts +34 -0
  28. package/dist/password-reset.js +121 -0
  29. package/dist/server.d.ts +5 -0
  30. package/dist/server.js +109 -3
  31. package/dist/shared-translation.d.ts +1 -0
  32. package/dist/shared-translation.js +57 -40
  33. package/dist/tokens.d.ts +2 -0
  34. package/dist/tokens.js +2 -0
  35. package/dist/translation-passes.d.ts +13 -8
  36. package/dist/translation-passes.js +31 -20
  37. package/dist/upgrade-allowance.d.ts +23 -0
  38. package/dist/upgrade-allowance.js +70 -0
  39. package/package.json +1 -1
  40. package/src/accounts.ts +8 -2
  41. package/src/classroom.ts +52 -0
  42. package/src/event-writer.ts +126 -0
  43. package/src/live-api.ts +26 -2
  44. package/src/live-events.ts +81 -13
  45. package/src/live-interpreter.ts +87 -68
  46. package/src/live-recovery.ts +10 -0
  47. package/src/live-voice.ts +7 -7
  48. package/src/mail-sender.ts +12 -0
  49. package/src/notify.ts +10 -7
  50. package/src/password-reset-page.ts +108 -0
  51. package/src/password-reset.ts +134 -0
  52. package/src/server.ts +91 -3
  53. package/src/shared-translation.ts +47 -33
  54. package/src/tokens.ts +3 -0
  55. package/src/translation-passes.ts +35 -25
  56. package/src/types/auth-system.d.ts +10 -0
  57. package/src/upgrade-allowance.ts +69 -0
  58. package/web/dist/assets/background.worker-Hz7oaQ8X.js +547 -0
  59. package/web/dist/assets/{hls-3VKVEQE3-C1ttVP7K.js → hls-3VKVEQE3-_BiJh76x.js} +1 -1
  60. package/web/dist/assets/index-BgLxkXKe.js +3 -0
  61. package/web/dist/assets/{index-BmR7T0zX.css → index-D34dTY18.css} +1 -1
  62. package/web/dist/assets/{mpegts-CVYPgepU.js → mpegts-CV0glck7.js} +1 -1
  63. package/web/dist/assets/{mpegts-LO6RVLD6-Dw2p8qGf.js → mpegts-LO6RVLD6-iuTMg-O6.js} +1 -1
  64. package/web/dist/index.html +6 -5
  65. package/web/dist/sw.js +7 -7
  66. package/backtoschool/dist/assets/index-D7Oas1m5.js +0 -88
  67. package/web/dist/assets/background.worker-DH8ag5Q4.js +0 -547
  68. package/web/dist/assets/index-3WZwGw04.js +0 -3
@@ -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
- require(by: string): Promise<void>;
17
- eligible?(accounts: string[]): Promise<string[]>;
18
- reserve(by: string, kind: TranslationUsage, units: number): Promise<string>;
19
- reserveMany?(accounts: string[], kind: TranslationUsage, units: number): Promise<{ by: string; id: string }[]>;
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 require(by: string): Promise<void> {
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 translation pass to enable translated audio.", 402);
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.ensure();
123
- const { cost, charge } = translationCost(kind, units), id = randomUUID();
124
- const result = await this.options.db.query(`WITH debit AS (
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 result = await this.options.db.query(`WITH debit AS (
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 RETURNING by_account)
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, $2, $3 FROM debit RETURNING id, by_account`,
142
- [[...new Set(accounts)], charge, new Date(this.now()), kind, units, cost]);
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
+ }