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.
Files changed (47) hide show
  1. package/README.md +14 -4
  2. package/backtoschool/dist/assets/{index-6vtCMzi1.css → index-OE2xWZUH.css} +1 -1
  3. package/backtoschool/dist/assets/index-pQ_uH4bZ.js +93 -0
  4. package/backtoschool/dist/index.html +25 -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/live-events.d.ts +7 -1
  10. package/dist/live-events.js +88 -12
  11. package/dist/live-voice.d.ts +1 -1
  12. package/dist/live-voice.js +7 -7
  13. package/dist/password-reset-page.d.ts +2 -0
  14. package/dist/password-reset-page.js +106 -0
  15. package/dist/password-reset.d.ts +35 -0
  16. package/dist/password-reset.js +119 -0
  17. package/dist/server.d.ts +3 -0
  18. package/dist/server.js +96 -2
  19. package/dist/shared-translation.js +25 -17
  20. package/dist/tokens.d.ts +2 -0
  21. package/dist/tokens.js +2 -0
  22. package/dist/translation-passes.d.ts +13 -8
  23. package/dist/translation-passes.js +31 -20
  24. package/dist/upgrade-allowance.d.ts +23 -0
  25. package/dist/upgrade-allowance.js +70 -0
  26. package/package.json +1 -1
  27. package/src/accounts.ts +8 -2
  28. package/src/classroom.ts +52 -0
  29. package/src/live-events.ts +81 -13
  30. package/src/live-voice.ts +7 -7
  31. package/src/password-reset-page.ts +108 -0
  32. package/src/password-reset.ts +134 -0
  33. package/src/server.ts +77 -2
  34. package/src/shared-translation.ts +20 -14
  35. package/src/tokens.ts +3 -0
  36. package/src/translation-passes.ts +35 -25
  37. package/src/types/auth-system.d.ts +10 -0
  38. package/src/upgrade-allowance.ts +69 -0
  39. package/web/dist/assets/{hls-3VKVEQE3-C1ttVP7K.js → hls-3VKVEQE3-CAL4qBEq.js} +1 -1
  40. package/web/dist/assets/index-CLeEWlms.js +3 -0
  41. package/web/dist/assets/{index-BmR7T0zX.css → index-D34dTY18.css} +1 -1
  42. package/web/dist/assets/{mpegts-CVYPgepU.js → mpegts-H9oC2fAK.js} +1 -1
  43. package/web/dist/assets/{mpegts-LO6RVLD6-Dw2p8qGf.js → mpegts-LO6RVLD6-Bkd9Sqr6.js} +1 -1
  44. package/web/dist/index.html +6 -5
  45. package/web/dist/sw.js +6 -6
  46. package/backtoschool/dist/assets/index-D7Oas1m5.js +0 -88
  47. package/web/dist/assets/index-3WZwGw04.js +0 -3
@@ -1,3 +1,4 @@
1
+ import { classroomBroadcast, publicWebUrl, type ClassroomSettings } from "./classroom.ts";
1
2
  import { createHash, randomBytes, randomUUID } from "node:crypto";
2
3
  import type { Queryable } from "./follows.ts";
3
4
 
@@ -37,7 +38,7 @@ export type LiveEventKind = (typeof LIVE_EVENT_KINDS)[number];
37
38
  export type InvitationRole = (typeof INVITATION_ROLES)[number];
38
39
  export type InvitationState = (typeof INVITATION_STATES)[number];
39
40
 
40
- export interface LiveEvent {
41
+ export interface LiveEvent extends ClassroomSettings {
41
42
  id: string;
42
43
  slug: string;
43
44
  ownerId: string;
@@ -94,6 +95,11 @@ export interface CreatedInvitation extends EventInvitation {
94
95
 
95
96
  export interface CreateLiveEventInput {
96
97
  title: unknown;
98
+ broadcastUrl?: unknown;
99
+ hostName?: unknown;
100
+ homepageUrl?: unknown;
101
+ avatarUrl?: unknown;
102
+ recurrence?: unknown;
97
103
  description?: unknown;
98
104
  topic?: unknown;
99
105
  kind?: unknown;
@@ -187,6 +193,7 @@ const EVENT_SCHEMA = `
187
193
  ALTER TABLE live_events ADD COLUMN IF NOT EXISTS ticket_currency TEXT NOT NULL DEFAULT 'USD';
188
194
  ALTER TABLE live_events ADD COLUMN IF NOT EXISTS ticket_minutes INTEGER NOT NULL DEFAULT 1440;
189
195
  ALTER TABLE live_events ADD COLUMN IF NOT EXISTS pay_to TEXT;
196
+ ALTER TABLE live_events ADD COLUMN IF NOT EXISTS classroom JSONB NOT NULL DEFAULT '{}'::jsonb;
190
197
  CREATE INDEX IF NOT EXISTS live_events_kind
191
198
  ON live_events (kind, status, starts_at);
192
199
 
@@ -203,7 +210,7 @@ const EVENT_SCHEMA = `
203
210
  END LOOP;
204
211
  FOR stale IN
205
212
  SELECT conname FROM pg_constraint
206
- WHERE conrelid = 'live_event_invitations'::regclass AND contype = 'c'
213
+ WHERE conrelid = to_regclass('live_event_invitations') AND contype = 'c'
207
214
  AND conname <> 'live_event_invitations_role_allowed'
208
215
  AND pg_get_constraintdef(oid) LIKE '%moderator%'
209
216
  LOOP
@@ -292,6 +299,37 @@ function text(value: unknown, name: string, limit: number, required = false): st
292
299
  return cleaned;
293
300
  }
294
301
 
302
+ function classroomSettings(input: CreateLiveEventInput | UpdateLiveEventInput, current: ClassroomSettings = {}): ClassroomSettings {
303
+ const settings: ClassroomSettings = {};
304
+ for (const key of ["hostName", "homepageUrl", "avatarUrl", "broadcastUrl"] as const) {
305
+ const value = input[key] === undefined ? current[key] ?? "" : text(input[key], key, key === "hostName" ? 100 : 2048);
306
+ if (!value) continue;
307
+ if (key === "hostName") settings[key] = value;
308
+ else if (key === "broadcastUrl") {
309
+ const broadcast = classroomBroadcast(value);
310
+ if (!broadcast) throw new LiveEventError("Use a Pairux live/join link or a Nixamp Share link with a stream selected. Admin links cannot be shared.", 422);
311
+ settings[key] = broadcast.url;
312
+ } else {
313
+ const safe = publicWebUrl(value);
314
+ if (!safe) throw new LiveEventError(`${key} must be an HTTPS link without credentials`, 422);
315
+ settings[key] = safe;
316
+ }
317
+ }
318
+ const recurrence = input.recurrence === undefined ? current.recurrence ?? "none" : input.recurrence;
319
+ if (recurrence !== "none" && recurrence !== "daily" && recurrence !== "weekly") {
320
+ throw new LiveEventError("recurrence must be none, daily, or weekly", 422);
321
+ }
322
+ if (recurrence !== "none") settings.recurrence = recurrence;
323
+ return settings;
324
+ }
325
+
326
+ function classroomTimezone(value: unknown): string {
327
+ const zone = text(value, "timezone", 100, true);
328
+ try { new Intl.DateTimeFormat("en", { timeZone: zone }).format(); }
329
+ catch { throw new LiveEventError("timezone must be a valid time zone", 422); }
330
+ return zone;
331
+ }
332
+
295
333
  function timestamp(value: unknown, name: string): string | null {
296
334
  if (value === undefined || value === null || value === "") return null;
297
335
  if (typeof value !== "string") throw new LiveEventError(`${name} must be an ISO timestamp`, 422);
@@ -374,6 +412,7 @@ export function payToAddress(value: unknown, name = "payTo"): string {
374
412
 
375
413
  function eventFrom(row: Record<string, unknown>): LiveEvent {
376
414
  return {
415
+ ...((row["classroom"] && typeof row["classroom"] === "object") ? row["classroom"] as ClassroomSettings : {}),
377
416
  id: String(row["id"] ?? ""),
378
417
  slug: String(row["slug"] ?? ""),
379
418
  ownerId: String(row["owner_id"] ?? ""),
@@ -544,6 +583,8 @@ export class LiveEvents {
544
583
  if (ticketPriceCents > 0 && !payTo) {
545
584
  throw new LiveEventError("a ticketed event needs a payTo address", 422);
546
585
  }
586
+ const classroom = classroomSettings(input);
587
+ if (classroom.recurrence && !startsAt) throw new LiveEventError("a recurring class needs a start time", 422);
547
588
  const baseSlug = eventSlug(title);
548
589
  const values = [
549
590
  id,
@@ -560,7 +601,7 @@ export class LiveEvents {
560
601
  payTo,
561
602
  startsAt,
562
603
  endsAt,
563
- text(input.timezone ?? "UTC", "timezone", 100, true),
604
+ classroomTimezone(input.timezone ?? "UTC"),
564
605
  optionalInteger(input.expectedDurationMinutes, "expectedDurationMinutes"),
565
606
  startsAt ? "scheduled" : "draft",
566
607
  enumValue(input.visibility, LIVE_EVENT_VISIBILITIES, "public", "visibility"),
@@ -569,6 +610,7 @@ export class LiveEvents {
569
610
  boolean(input.handRaiseEnabled, true, "handRaiseEnabled"),
570
611
  boolean(input.recordingEnabled, false, "recordingEnabled"),
571
612
  text(input.layoutId, "layoutId", 100),
613
+ JSON.stringify(classroom),
572
614
  ];
573
615
  await this.ensure();
574
616
  let rows: Record<string, unknown>[];
@@ -579,10 +621,10 @@ export class LiveEvents {
579
621
  ticket_price_cents, ticket_currency, ticket_minutes, pay_to,
580
622
  starts_at, ends_at, timezone,
581
623
  expected_duration_minutes, status, visibility, room_id, chat_enabled,
582
- hand_raise_enabled, recording_enabled, layout_id
624
+ hand_raise_enabled, recording_enabled, layout_id, classroom
583
625
  ) VALUES (
584
626
  $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NULLIF($12, ''), $13, $14, $15,
585
- $16, $17, $18, $19, $20, $21, $22, NULLIF($23, '')
627
+ $16, $17, $18, $19, $20, $21, $22, NULLIF($23, ''), $24::jsonb
586
628
  ) RETURNING *`,
587
629
  values,
588
630
  ));
@@ -595,10 +637,10 @@ export class LiveEvents {
595
637
  ticket_price_cents, ticket_currency, ticket_minutes, pay_to,
596
638
  starts_at, ends_at, timezone,
597
639
  expected_duration_minutes, status, visibility, room_id, chat_enabled,
598
- hand_raise_enabled, recording_enabled, layout_id
640
+ hand_raise_enabled, recording_enabled, layout_id, classroom
599
641
  ) VALUES (
600
642
  $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NULLIF($12, ''), $13, $14, $15,
601
- $16, $17, $18, $19, $20, $21, $22, NULLIF($23, '')
643
+ $16, $17, $18, $19, $20, $21, $22, NULLIF($23, ''), $24::jsonb
602
644
  ) RETURNING *`,
603
645
  values,
604
646
  ));
@@ -722,9 +764,9 @@ export class LiveEvents {
722
764
  }
723
765
 
724
766
  const title = input.title === undefined ? current.title : text(input.title, "title", 160, true);
725
- const startsAt = input.startsAt === undefined ? current.startsAt ?? null : timestamp(input.startsAt, "startsAt");
726
- const endsAt = input.endsAt === undefined ? current.endsAt ?? null : timestamp(input.endsAt, "endsAt");
727
- const doorsOpenAt = input.doorsOpenAt === undefined
767
+ let startsAt = input.startsAt === undefined ? current.startsAt ?? null : timestamp(input.startsAt, "startsAt");
768
+ let endsAt = input.endsAt === undefined ? current.endsAt ?? null : timestamp(input.endsAt, "endsAt");
769
+ let doorsOpenAt = input.doorsOpenAt === undefined
728
770
  ? current.doorsOpenAt ?? null
729
771
  : timestamp(input.doorsOpenAt, "doorsOpenAt");
730
772
  if (startsAt && endsAt && endsAt <= startsAt) {
@@ -738,7 +780,7 @@ export class LiveEvents {
738
780
  if (ticketPriceCents > 0 && !payTo) {
739
781
  throw new LiveEventError("a ticketed event needs a payTo address", 422);
740
782
  }
741
- const nextStatus = enumValue(input.status, LIVE_EVENT_STATUSES, current.status, "status");
783
+ let nextStatus = enumValue(input.status, LIVE_EVENT_STATUSES, current.status, "status");
742
784
  if (!canTransition(current.status, nextStatus)) {
743
785
  throw new LiveEventError(`an event cannot move from ${current.status} to ${nextStatus}`, 409);
744
786
  }
@@ -746,6 +788,30 @@ export class LiveEvents {
746
788
  throw new LiveEventError("a scheduled event needs startsAt", 422);
747
789
  }
748
790
 
791
+ const classroom = classroomSettings(input, current);
792
+ const timezone = input.timezone === undefined ? current.timezone : classroomTimezone(input.timezone);
793
+ if (classroom.recurrence && !startsAt) throw new LiveEventError("a recurring class needs a start time", 422);
794
+ let recurringNext = false;
795
+ if (nextStatus === "ended" && classroom.recurrence && startsAt) {
796
+ // Calendar arithmetic in Postgres preserves the host's local time over DST.
797
+ // Consider only the next three candidates, even if many sessions were missed.
798
+ const days = classroom.recurrence === "daily" ? 1 : 7;
799
+ const next = await this.db.query(`WITH anchor AS (
800
+ SELECT $1::timestamptz AT TIME ZONE $2 AS local_start
801
+ ), candidates AS (
802
+ SELECT (local_start + n * $3::int * interval '1 day') AT TIME ZONE $2 AS starts_at
803
+ FROM anchor CROSS JOIN LATERAL generate_series(
804
+ GREATEST(1, ((now() AT TIME ZONE $2)::date - local_start::date) / $3::int),
805
+ GREATEST(1, ((now() AT TIME ZONE $2)::date - local_start::date) / $3::int) + 2
806
+ ) n
807
+ ) SELECT starts_at FROM candidates
808
+ WHERE starts_at > GREATEST(now(), $1::timestamptz) ORDER BY starts_at LIMIT 1`, [startsAt, timezone, days]);
809
+ const nextStart = iso(next.rows[0]?.["starts_at"]);
810
+ if (!nextStart) throw new LiveEventError("could not schedule the next class", 503);
811
+ startsAt = nextStart; endsAt = null; doorsOpenAt = null;
812
+ nextStatus = "scheduled"; recurringNext = true;
813
+ }
814
+
749
815
  const { rows } = await this.db.query(
750
816
  `UPDATE live_events SET
751
817
  title = $4,
@@ -768,6 +834,7 @@ export class LiveEvents {
768
834
  ticket_currency = $21,
769
835
  ticket_minutes = $22,
770
836
  pay_to = NULLIF($23, ''),
837
+ classroom = $24::jsonb,
771
838
  version = version + 1,
772
839
  updated_at = now()
773
840
  WHERE (id = $1 OR slug = $1) AND owner_id = $2 AND version = $3
@@ -781,7 +848,7 @@ export class LiveEvents {
781
848
  input.topic === undefined ? current.topic ?? "" : text(input.topic, "topic", 100),
782
849
  startsAt,
783
850
  endsAt,
784
- input.timezone === undefined ? current.timezone : text(input.timezone, "timezone", 100, true),
851
+ timezone,
785
852
  input.expectedDurationMinutes === undefined
786
853
  ? current.expectedDurationMinutes ?? null
787
854
  : optionalInteger(input.expectedDurationMinutes, "expectedDurationMinutes"),
@@ -790,7 +857,7 @@ export class LiveEvents {
790
857
  boolean(input.chatEnabled, current.chatEnabled, "chatEnabled"),
791
858
  boolean(input.handRaiseEnabled, current.handRaiseEnabled, "handRaiseEnabled"),
792
859
  boolean(input.recordingEnabled, current.recordingEnabled, "recordingEnabled"),
793
- input.recordingId === undefined ? current.recordingId ?? null : text(input.recordingId, "recordingId", 160) || null,
860
+ recurringNext ? null : input.recordingId === undefined ? current.recordingId ?? null : text(input.recordingId, "recordingId", 160) || null,
794
861
  input.layoutId === undefined ? current.layoutId ?? null : text(input.layoutId, "layoutId", 100) || null,
795
862
  enumValue(input.kind, LIVE_EVENT_KINDS, current.kind, "kind"),
796
863
  doorsOpenAt,
@@ -800,6 +867,7 @@ export class LiveEvents {
800
867
  : text(input.ticketCurrency, "ticketCurrency", 8, true).toUpperCase(),
801
868
  minutes(input.ticketMinutes, current.ticketMinutes, "ticketMinutes"),
802
869
  payTo,
870
+ JSON.stringify(classroom),
803
871
  ],
804
872
  );
805
873
  const row = rows[0];
package/src/live-voice.ts CHANGED
@@ -57,9 +57,9 @@ export class LiveVoice {
57
57
  /** Optional diarization, billed only while a signed-in listener requests it.
58
58
  * Rolling audio is bounded to 15 seconds, including overlap. Every second
59
59
  * submitted (also repeated context) consumes the persistent provider budget. */
60
- async hear(bytes: Uint8Array, by: string, signal?: AbortSignal, meter = this.billing): Promise<SpeakerTranscript> {
60
+ async hear(bytes: Uint8Array, by: string, signal?: AbortSignal, meter = this.billing, resource = ""): Promise<SpeakerTranscript> {
61
61
  if (!this.available()) throw new SpeechError("speaker voices are unavailable", 503);
62
- await meter?.require(by);
62
+ await meter?.require(by, resource);
63
63
  const wav = decodeWav(bytes);
64
64
  const seconds = wav.samples.length / wav.rate;
65
65
  if (wav.rate !== 16000 || wav.channels !== 1 || seconds < 0.2 || seconds > 15.1) throw new SpeechError("send up to 15 seconds of mono 16 kHz WAV", 400);
@@ -86,7 +86,7 @@ export class LiveVoice {
86
86
  form.set("tag_audio_events", "false");
87
87
  form.set("timestamps_granularity", "word");
88
88
  // No language_code: preserve the source language, including Spanish.
89
- reservation = await meter?.reserve(by, "transcription", wav.samples.length);
89
+ reservation = await meter?.reserve(by, "transcription", wav.samples.length, resource);
90
90
  const answer = await this.fetcher("https://api.elevenlabs.io/v1/speech-to-text", {
91
91
  method: "POST", headers: { "xi-api-key": this.key }, body: form,
92
92
  signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000),
@@ -122,7 +122,7 @@ export class LiveVoice {
122
122
  async grant(by: string, channel: string): Promise<{ token: string; expires: number }> {
123
123
  if (!/^[\w-]{1,80}$/.test(channel)) throw new SpeechError("choose a playback session or live channel", 400);
124
124
  if (!this.requests.check(`grant:${by}`, { allowed: 10, windowMs: 60_000 }).ok) throw new SpeechError("too many audio authorization requests", 429);
125
- await this.billing?.require(by);
125
+ await this.billing?.require(by, channel);
126
126
  for (const [token, grant] of this.grants) if (grant.expires <= this.now()) this.grants.delete(token);
127
127
  if (this.grants.size >= 5000) throw new SpeechError("audio authorization is busy", 503);
128
128
  const token = `nxd_${randomBytes(32).toString("base64url")}`;
@@ -200,7 +200,7 @@ export class LiveVoice {
200
200
  /** Stream the first request immediately; concurrent listeners share its cached result. */
201
201
  async stream(ask: VoiceRequest, by: string, signal?: AbortSignal, meter = this.billing): Promise<Response> {
202
202
  this.checkRequest(by);
203
- await meter?.require(by);
203
+ await meter?.require(by, ask.channel);
204
204
  const text = typeof ask.text === "string" ? ask.text.trim() : "";
205
205
  if (!text || text.length > 600) throw new SpeechError("translated audio needs a caption of 1–600 characters", 400);
206
206
  if (!LIVE_VOICE_LANGUAGES.has(ask.language)) throw new SpeechError("this language is not supported by Flash voices", 400);
@@ -220,7 +220,7 @@ export class LiveVoice {
220
220
  if (cached || pending) {
221
221
  const bytes = cached?.bytes ?? await pending!;
222
222
  signal?.throwIfAborted();
223
- const paid = await meter?.reserve(by, "voice", text.length);
223
+ const paid = await meter?.reserve(by, "voice", text.length, ask.channel);
224
224
  if (paid) await meter!.commit(paid);
225
225
  return new Response(new Uint8Array(bytes), { headers: HEADERS });
226
226
  }
@@ -239,7 +239,7 @@ export class LiveVoice {
239
239
  try {
240
240
  await this.charge(by, ask.channel ?? "direct", text.length);
241
241
  signal?.throwIfAborted();
242
- reservation = await meter?.reserve(by, "voice", text.length);
242
+ reservation = await meter?.reserve(by, "voice", text.length, ask.channel);
243
243
  const response = await this.fetcher(`https://api.elevenlabs.io/v1/text-to-speech/${voice.id}/stream?output_format=pcm_16000`, {
244
244
  method: "POST",
245
245
  headers: { "xi-api-key": this.key, "content-type": "application/json" },
@@ -0,0 +1,108 @@
1
+ function escape(value: string): string {
2
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll('"', "&quot;");
3
+ }
4
+
5
+ /** A small recovery page shared by every branded client on this account server. */
6
+ export function passwordResetPage(site: string, nonce: string): string {
7
+ const host = new URL(site).hostname;
8
+ const school = host === "backtoschool.help" || host === "www.backtoschool.help";
9
+ const name = school ? "BackToSchool.help" : host;
10
+ const home = school ? "/#live" : "/";
11
+ return `<!doctype html><html lang="en"><head>
12
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
13
+ <meta name="referrer" content="no-referrer"><meta name="robots" content="noindex, nofollow">
14
+ <title>Reset your password — ${escape(name)}</title>
15
+ <style>
16
+ :root{font-family:system-ui,sans-serif;color:#34246d;background:#f7f3e9;color-scheme:light}
17
+ *{box-sizing:border-box}body{margin:0}header{padding:1.25rem max(1rem,calc((100% - 960px)/2));border-bottom:1px solid #ddd5e6}
18
+ a{color:#34246d;text-underline-offset:3px}header a{font-weight:800;text-decoration:none}
19
+ main{max-width:480px;margin:clamp(2rem,8vh,5rem) auto;padding:0 1rem 3rem}
20
+ .card{background:#fffdf8;border:1px solid #ddd5e6;border-radius:20px;padding:clamp(1.25rem,5vw,2rem)}
21
+ h1{font-family:Georgia,serif;font-size:2.25rem;line-height:1.1;margin:0 0 1rem}p{line-height:1.55}
22
+ label{display:block;font-weight:650;margin:1.25rem 0 .5rem}input{width:100%;font:inherit;padding:.85rem;border:1px solid #8a829f;border-radius:10px;background:white;color:#211747}
23
+ button{width:100%;font:inherit;font-weight:700;border:0;border-radius:30px;background:#34246d;color:white;padding:.9rem 1.1rem;margin-top:1.25rem;cursor:pointer}
24
+ button[aria-disabled=true]{opacity:.65;cursor:wait}a:focus-visible,input:focus-visible,button:focus-visible{outline:3px solid #b14b29;outline-offset:3px}
25
+ .help{font-size:.9rem;color:#514961}.message:empty{display:none}.error{color:#9b202a}.success{color:#215d3d}
26
+ .back{display:inline-block;margin-top:1.5rem}[hidden]{display:none!important}
27
+ @media(forced-colors:active){.card,button{border:1px solid CanvasText}}
28
+ </style></head><body>
29
+ <header><a href="${home}">${escape(name)}</a></header>
30
+ <main><div class="card">
31
+ <h1 id="title">Forgot your password?</h1>
32
+ <p id="intro">Enter your account’s email address and we’ll send you a link to choose a new password.</p>
33
+ <form id="request-form">
34
+ <label for="email">Email</label><input id="email" name="email" type="email" autocomplete="email" maxlength="320" required>
35
+ <button type="submit">Send reset link</button>
36
+ </form>
37
+ <form id="reset-form" hidden>
38
+ <label for="password">New password</label><input id="password" name="password" type="password" autocomplete="new-password" minlength="8" maxlength="200" aria-describedby="password-help" required>
39
+ <p class="help" id="password-help">Use at least 8 characters, including a number.</p>
40
+ <label for="confirm">Confirm new password</label><input id="confirm" name="confirm" type="password" autocomplete="new-password" minlength="8" maxlength="200" required>
41
+ <button type="submit">Save new password</button>
42
+ </form>
43
+ <p id="status" class="message success" role="status" aria-live="polite"></p>
44
+ <p id="error" class="message error" role="alert"></p>
45
+ <p id="another" hidden><a href="/reset-password">Request a new reset link</a></p>
46
+ <a class="back" id="back" href="${home}">Back to sign in</a>
47
+ <noscript><p>Enable JavaScript to reset your password.</p></noscript>
48
+ </div></main>
49
+ <script nonce="${nonce}">${SCRIPT}</script></body></html>`;
50
+ }
51
+
52
+ const SCRIPT = String.raw`
53
+ (() => {
54
+ const requestForm = document.querySelector('#request-form');
55
+ const resetForm = document.querySelector('#reset-form');
56
+ const status = document.querySelector('#status');
57
+ const error = document.querySelector('#error');
58
+ let token = ''; let pending = false; let done = false;
59
+ function readLink() {
60
+ const incoming = new URLSearchParams(location.hash.slice(1)).get('token');
61
+ if (location.hash) history.replaceState(null, '', location.pathname);
62
+ if (!incoming) return;
63
+ token = incoming; done = false;
64
+ status.textContent = ''; error.textContent = '';
65
+ document.querySelector('#another').hidden = true;
66
+ for (const field of resetForm.querySelectorAll('input')) {field.value = ''; field.readOnly = false;}
67
+ const button = resetForm.querySelector('button');
68
+ button.textContent = 'Save new password'; button.removeAttribute('aria-disabled');
69
+ requestForm.hidden = true; resetForm.hidden = false;
70
+ document.querySelector('#title').textContent = 'Choose a new password';
71
+ document.querySelector('#intro').textContent = 'Your new password works here and on NixAmp. Existing sessions will be signed out.';
72
+ }
73
+ readLink(); window.addEventListener('hashchange', readLink);
74
+ async function submit(form, path, input) {
75
+ if (pending || done) return;
76
+ pending = true; error.textContent = ''; status.textContent = '';
77
+ const button = form.querySelector('button');
78
+ button.setAttribute('aria-disabled', 'true'); form.setAttribute('aria-busy', 'true');
79
+ try {
80
+ const response = await fetch(path, {method:'POST', credentials:'same-origin', headers:{'content-type':'application/json'}, body:JSON.stringify(input)});
81
+ const result = await response.json();
82
+ if (!response.ok) {
83
+ if (form === resetForm && response.status === 400) document.querySelector('#another').hidden = false;
84
+ throw new Error(result.error || 'That did not work. Please try again.');
85
+ }
86
+ status.textContent = result.message;
87
+ if (form === resetForm) {
88
+ done = true; token = '';
89
+ for (const field of form.querySelectorAll('input')) {field.value = ''; field.readOnly = true;}
90
+ button.textContent = 'Password saved';
91
+ document.querySelector('#back').textContent = 'Sign in with your new password';
92
+ }
93
+ } catch (problem) {error.textContent = problem instanceof TypeError ? 'Could not connect. Please try again.' : problem.message;}
94
+ finally {pending = false; form.removeAttribute('aria-busy'); if (!done) button.removeAttribute('aria-disabled');}
95
+ }
96
+ requestForm.addEventListener('submit', event => {
97
+ event.preventDefault();
98
+ void submit(requestForm, '/api/v1/auth/password-reset/request', {email:requestForm.elements.email.value.trim()});
99
+ });
100
+ resetForm.addEventListener('submit', event => {
101
+ event.preventDefault();
102
+ if (pending || done) return;
103
+ const password = resetForm.elements.password.value;
104
+ if (password !== resetForm.elements.confirm.value) {error.textContent = 'The passwords do not match.'; return;}
105
+ if (!/[0-9]/.test(password)) {error.textContent = 'Include at least one number in your password.'; return;}
106
+ void submit(resetForm, '/api/v1/auth/password-reset/confirm', {token, password});
107
+ });
108
+ })();`;
@@ -0,0 +1,134 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import type { Queryable } from "./follows.ts";
3
+ import type { Tokens } from "./tokens.ts";
4
+
5
+ export const RESET_MESSAGE = "If an account exists for that email, you’ll receive a password reset link shortly.";
6
+ export const RESET_INVALID = "This reset link is invalid, expired, or already used. Request a new link.";
7
+ const LIFETIME_MS = 30 * 60_000;
8
+ const SCHEMA = `
9
+ CREATE TABLE IF NOT EXISTS nixamp_password_resets (
10
+ user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
11
+ token_hash TEXT UNIQUE,
12
+ password_hash TEXT,
13
+ expires_at TIMESTAMPTZ,
14
+ reset_at TIMESTAMPTZ
15
+ );
16
+ `;
17
+
18
+ interface Passwords {
19
+ hashPassword(password: string): Promise<string>;
20
+ validatePassword(password: string): { valid: boolean; message: string };
21
+ }
22
+
23
+ export type ResetMail = (email: string, link: string) => Promise<boolean>;
24
+
25
+ function digest(token: string): string {
26
+ return createHash("sha256").update(token).digest("hex");
27
+ }
28
+
29
+ export class PasswordResets {
30
+ private ready: Promise<void> | null = null;
31
+
32
+ constructor(
33
+ private readonly db: Queryable & { initialize?(): Promise<void> },
34
+ private readonly tokens: Tokens,
35
+ private readonly passwords: Passwords,
36
+ ) {}
37
+
38
+ private async ensure(): Promise<void> {
39
+ this.ready ??= (async () => {
40
+ await this.db.initialize?.();
41
+ await this.tokens.initialize();
42
+ await this.db.query(SCHEMA);
43
+ })().catch((error) => { this.ready = null; throw error; });
44
+ await this.ready;
45
+ }
46
+
47
+ /** The public response is identical for registered and unknown addresses. */
48
+ async request(email: string, site: string, deliver: ResetMail): Promise<void> {
49
+ await this.ensure();
50
+ const token = randomBytes(32).toString("base64url");
51
+ const { rows } = await this.db.query(
52
+ `INSERT INTO nixamp_password_resets (user_id, token_hash, password_hash, expires_at)
53
+ SELECT id, $2, password, now() + interval '30 minutes' FROM users WHERE lower(email) = $1
54
+ ON CONFLICT (user_id) DO UPDATE SET token_hash = EXCLUDED.token_hash,
55
+ password_hash = EXCLUDED.password_hash, expires_at = EXCLUDED.expires_at
56
+ RETURNING user_id`,
57
+ [email.trim().toLowerCase(), digest(token)],
58
+ );
59
+ if (!rows.length) return;
60
+ // Fragments never reach HTTP logs or referrers. Email delivery also never
61
+ // changes the HTTP response (or its timing) according to account existence.
62
+ const link = `${site.replace(/\/+$/, "")}/reset-password#token=${token}`;
63
+ void Promise.resolve().then(() => deliver(email.trim().toLowerCase(), link)).then((sent) => {
64
+ if (!sent) console.error("nixamp: password reset email delivery failed");
65
+ }).catch(() => console.error("nixamp: password reset email delivery failed"));
66
+ }
67
+
68
+ /** Claim the link, change the password, and revoke sessions in one statement. */
69
+ async confirm(token: unknown, password: unknown): Promise<string | null> {
70
+ if (typeof token !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(token)) return RESET_INVALID;
71
+ if (typeof password !== "string" || password.length > 200) return "Enter a password between 8 and 200 characters.";
72
+ const validation = this.passwords.validatePassword(password);
73
+ if (!validation.valid) return validation.message;
74
+ await this.ensure();
75
+ const tokenHash = digest(token);
76
+ // Reject bad tokens before doing the deliberately expensive password hash.
77
+ const candidate = await this.db.query(
78
+ `SELECT user_id FROM nixamp_password_resets WHERE token_hash = $1 AND expires_at > now()`, [tokenHash],
79
+ );
80
+ if (!candidate.rows.length) return RESET_INVALID;
81
+ const hashed = await this.passwords.hashPassword(password);
82
+ const { rows } = await this.db.query(
83
+ `WITH consumed AS (
84
+ UPDATE nixamp_password_resets r SET token_hash = NULL, expires_at = NULL,
85
+ password_hash = NULL, reset_at = clock_timestamp()
86
+ FROM users u WHERE r.token_hash = $1 AND r.expires_at > now()
87
+ AND u.id = r.user_id AND r.password_hash IS NOT DISTINCT FROM u.password
88
+ RETURNING r.user_id
89
+ ), changed AS (
90
+ UPDATE users u SET password = $2, updated_at = now()
91
+ FROM consumed c WHERE u.id = c.user_id RETURNING u.id
92
+ ), revoked AS (
93
+ DELETE FROM nixamp_tokens t USING changed c WHERE t.user_id = c.id RETURNING t.id
94
+ ) SELECT id FROM changed`,
95
+ [tokenHash, hashed],
96
+ );
97
+ return rows.length ? null : RESET_INVALID;
98
+ }
99
+
100
+ /** Older JWT sessions also stop working after recovery, across restarts. */
101
+ async acceptsLegacyToken(userId: string, token: string): Promise<boolean> {
102
+ await this.ensure();
103
+ const { rows } = await this.db.query("SELECT reset_at FROM nixamp_password_resets WHERE user_id = $1", [userId]);
104
+ if (!rows[0]?.["reset_at"]) return true;
105
+ try {
106
+ // Called only after the auth library has verified the JWT signature.
107
+ const claims = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
108
+ const issued = claims.iat;
109
+ const reset = rows[0]["reset_at"];
110
+ const resetAt = reset instanceof Date ? reset.getTime() : new Date(String(reset)).getTime();
111
+ return typeof issued === "number" && Number.isFinite(issued)
112
+ && issued * 1000 >= resetAt;
113
+ } catch { return false; }
114
+ }
115
+ }
116
+
117
+ /** Transactional recovery mail has no follower footer and never logs a token. */
118
+ export function resendPasswordReset(options: {
119
+ apiKey: string; from: string; fetch?: typeof globalThis.fetch;
120
+ }): ResetMail {
121
+ return async (email, link) => {
122
+ const host = new URL(link).hostname;
123
+ const response = await (options.fetch ?? globalThis.fetch)("https://api.resend.com/emails", {
124
+ method: "POST",
125
+ headers: { authorization: `Bearer ${options.apiKey}`, "content-type": "application/json" },
126
+ signal: AbortSignal.timeout(15_000),
127
+ body: JSON.stringify({
128
+ from: options.from, to: [email], subject: `Reset your ${host} password`,
129
+ text: `Someone requested a password reset for your account on ${host}.\n\nChoose a new password using this link:\n${link}\n\nThis link expires in ${LIFETIME_MS / 60_000} minutes and works once. Resetting your password signs out existing sessions.\n\nIf you did not request this, you can ignore this email. Your password will stay the same.`,
130
+ }),
131
+ });
132
+ return response.ok;
133
+ };
134
+ }