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
@@ -1,3 +1,4 @@
1
+ import { audioError, transientAudioError } from "./live-recovery.js";
1
2
  import { encodeWav } from "./pcm-wav.js";
2
3
  /** Match provider labels across overlapping timestamps. A speaker absent from
3
4
  * the rolling context gets a new label; pitch alone never identifies someone. */
@@ -67,6 +68,8 @@ export class Interpreter {
67
68
  controller = null;
68
69
  translating = null;
69
70
  translationController = null;
71
+ recognitionFailures = 0;
72
+ translationFailures = 0;
70
73
  translations = [];
71
74
  constructor(options) {
72
75
  this.options = options;
@@ -78,6 +81,7 @@ export class Interpreter {
78
81
  this.pending = null;
79
82
  this.running = null;
80
83
  this.translating = null;
84
+ this.recognitionFailures = this.translationFailures = 0;
81
85
  this.translations = [];
82
86
  this.committedUntil = null;
83
87
  this.tracker.reset();
@@ -92,7 +96,7 @@ export class Interpreter {
92
96
  const response = await (this.options.fetcher ?? fetch)(path, { ...init, signal });
93
97
  const body = await response.json();
94
98
  if (!response.ok)
95
- throw new Error(body.error || "Audio translation is unavailable.");
99
+ throw audioError(body.error || "Audio translation is unavailable.", response.status);
96
100
  return body;
97
101
  }
98
102
  async run(generation) {
@@ -101,74 +105,85 @@ export class Interpreter {
101
105
  while (this.pending && generation === this.generation) {
102
106
  const window = this.pending;
103
107
  this.pending = null;
104
- if (Date.now() - window.until > 12_000)
105
- continue;
106
- const controller = new AbortController();
107
- this.controller = controller;
108
- const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(12_000)]);
109
- const speakers = this.options.speakers(), target = this.options.language();
110
- const heard = await this.json(speakers ? "/api/v1/speech/speakers" : "/api/v1/speech/transcribe?live=1", {
111
- method: "POST", headers: { "content-type": "audio/wav" }, body: new Uint8Array(encodeWav(speakers ? window.samples : window.samples.slice(-80_000))),
112
- }, signal);
113
- if (generation !== this.generation)
114
- return;
115
- const language = heard.language;
116
- let lines = [];
117
- if (speakers) {
118
- const links = this.tracker.reconcile(heard.turns ?? [], window.at, this.options.voices());
119
- // The watermark follows emitted words, not the newest capture
120
- // interval: an overlapping window can recover audio that arrived
121
- // while recognition/translation was busy. Keep unfinished phrases
122
- // in that overlap so short capture intervals do not split every
123
- // sentence (especially damaging when German changes word order).
124
- const cutoff = this.committedUntil ?? window.freshAt;
125
- const turns = heard.turns ?? [];
126
- for (const [index, turn] of turns.entries()) {
127
- const speaker = links.get(turn.speaker);
128
- const words = turn.words.filter(word => window.at + (word.start + word.end) * 500 >= cutoff &&
129
- (window.at + word.end * 1000 <= window.until - 100 || /[.!?。!?]$/.test(word.text)));
130
- let phrase = [];
131
- const emit = () => {
132
- if (!phrase.length)
133
- return;
134
- lines.push({ channel: this.options.channel(), at: window.at + phrase[0].start * 1000,
135
- until: window.at + phrase.at(-1).end * 1000, text: phrase.map(word => word.text).join(" ").trim(),
136
- language, speaker: speaker.id, voiceProfile: turn.profile });
137
- phrase = [];
138
- };
139
- for (const word of words) {
140
- if (phrase.length && word.start - phrase.at(-1).end >= 0.45)
141
- emit();
142
- phrase.push(word);
143
- if (/[.!?。!?]$/.test(word.text) || phrase.map(word => word.text).join(" ").length >= 400)
108
+ try {
109
+ if (Date.now() - window.until > 12_000)
110
+ continue;
111
+ const controller = new AbortController();
112
+ this.controller = controller;
113
+ const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(12_000)]);
114
+ const speakers = this.options.speakers(), target = this.options.language();
115
+ const heard = await this.json(speakers ? "/api/v1/speech/speakers" : "/api/v1/speech/transcribe?live=1", {
116
+ method: "POST", headers: { "content-type": "audio/wav" }, body: new Uint8Array(encodeWav(speakers ? window.samples : window.samples.slice(-80_000))),
117
+ }, signal);
118
+ if (generation !== this.generation)
119
+ return;
120
+ this.recognitionFailures = 0;
121
+ const language = heard.language;
122
+ let lines = [];
123
+ if (speakers) {
124
+ const links = this.tracker.reconcile(heard.turns ?? [], window.at, this.options.voices());
125
+ // The watermark follows emitted words, not the newest capture
126
+ // interval: an overlapping window can recover audio that arrived
127
+ // while recognition/translation was busy. Keep unfinished phrases
128
+ // in that overlap so short capture intervals do not split every
129
+ // sentence (especially damaging when German changes word order).
130
+ const cutoff = this.committedUntil ?? window.freshAt;
131
+ const turns = heard.turns ?? [];
132
+ for (const [index, turn] of turns.entries()) {
133
+ const speaker = links.get(turn.speaker);
134
+ const words = turn.words.filter(word => window.at + (word.start + word.end) * 500 >= cutoff &&
135
+ (window.at + word.end * 1000 <= window.until - 100 || /[.!?。!?]$/.test(word.text)));
136
+ let phrase = [];
137
+ const emit = () => {
138
+ if (!phrase.length)
139
+ return;
140
+ lines.push({ channel: this.options.channel(), at: window.at + phrase[0].start * 1000,
141
+ until: window.at + phrase.at(-1).end * 1000, text: phrase.map(word => word.text).join(" ").trim(),
142
+ language, speaker: speaker.id, voiceProfile: turn.profile });
143
+ phrase = [];
144
+ };
145
+ for (const word of words) {
146
+ if (phrase.length && word.start - phrase.at(-1).end >= 0.45)
147
+ emit();
148
+ phrase.push(word);
149
+ if (/[.!?。!?]$/.test(word.text) || phrase.map(word => word.text).join(" ").length >= 400)
150
+ emit();
151
+ }
152
+ if (phrase.length && (index < turns.length - 1 ||
153
+ window.until - (window.at + phrase.at(-1).end * 1000) >= 350 ||
154
+ window.until - (window.at + phrase[0].start * 1000) >= 3000))
144
155
  emit();
145
156
  }
146
- if (phrase.length && (index < turns.length - 1 ||
147
- window.until - (window.at + phrase.at(-1).end * 1000) >= 350 ||
148
- window.until - (window.at + phrase[0].start * 1000) >= 3000))
149
- emit();
150
157
  }
158
+ else if (heard.text) {
159
+ lines = [{ channel: this.options.channel(), at: window.freshAt, until: window.until, text: heard.text, language }];
160
+ }
161
+ if (!lines.length)
162
+ continue;
163
+ this.committedUntil = Math.max(this.committedUntil ?? window.freshAt, ...lines.map(line => line.until));
164
+ // Drop expired work instead of turning off an otherwise healthy stream.
165
+ this.translations = this.translations.filter(item => Date.now() - item.until <= 12_000).slice(-11);
166
+ this.translations.push({ lines, language, target, until: window.until });
167
+ // Recognition of the next audio window can proceed while the text
168
+ // model translates this phrase. Voice synthesis is a third stage.
169
+ if (this.translating === null)
170
+ void this.translate(this.generation);
151
171
  }
152
- else if (heard.text) {
153
- lines = [{ channel: this.options.channel(), at: window.freshAt, until: window.until, text: heard.text, language }];
172
+ catch (error) {
173
+ if (generation !== this.generation)
174
+ return;
175
+ if (!transientAudioError(error) || ++this.recognitionFailures >= 3)
176
+ throw error;
177
+ this.options.status("Speech recognition interrupted · listening for the next phrase…");
154
178
  }
155
- if (!lines.length)
156
- continue;
157
- this.committedUntil = Math.max(this.committedUntil ?? window.freshAt, ...lines.map(line => line.until));
158
- if (this.translations.length >= 12)
159
- throw new Error("Translation fell behind. Try again after the language model has warmed up.");
160
- this.translations.push({ lines, language, target, until: window.until });
161
- // Recognition of the next audio window can proceed while the text
162
- // model translates this phrase. Voice synthesis is a third stage.
163
- if (this.translating === null)
164
- void this.translate(this.generation);
165
179
  }
166
180
  }
167
181
  catch (error) {
168
182
  if (generation === this.generation) {
183
+ const message = error instanceof Error ? error.message : "Live translation stopped.";
169
184
  this.reset();
170
- this.options.failed();
171
- this.options.status(error instanceof Error ? error.message : "Live translation stopped.");
185
+ this.options.failed(message);
186
+ this.options.status(message);
172
187
  }
173
188
  }
174
189
  finally {
@@ -181,36 +196,47 @@ export class Interpreter {
181
196
  try {
182
197
  while (this.translations.length && generation === this.generation) {
183
198
  const item = this.translations.shift();
184
- let { lines } = item;
185
- const { language, target, until } = item;
186
- if (Date.now() - until > 12_000)
187
- throw new Error("Translation fell behind. Try again after the language model has warmed up.");
188
- const controller = new AbortController();
189
- this.translationController = controller;
190
- if (target && language !== target) {
191
- if (!language)
192
- throw new Error("The audio language could not be detected. Waiting for clearer speech.");
193
- const translated = await this.json("/api/v1/translate?live=1", {
194
- method: "POST", headers: { "content-type": "application/json" },
195
- body: JSON.stringify({ texts: lines.map(line => line.text), from: language, to: target }),
196
- }, AbortSignal.any([controller.signal, AbortSignal.timeout(12_000)]));
197
- if (translated.texts.length !== lines.length || translated.texts.some(text => !text.trim()))
198
- throw new Error("Translation omitted a phrase. Enable translated audio again to retry.");
199
- lines = lines.map((line, i) => ({ ...line, original: line.text, sourceLanguage: language, language: target, text: translated.texts[i] }));
199
+ try {
200
+ let { lines } = item;
201
+ const { language, target, until } = item;
202
+ if (Date.now() - until > 12_000)
203
+ continue;
204
+ const controller = new AbortController();
205
+ this.translationController = controller;
206
+ if (target && language !== target) {
207
+ if (!language)
208
+ throw audioError("The audio language could not be detected. Waiting for clearer speech.", 502);
209
+ const translated = await this.json("/api/v1/translate?live=1", {
210
+ method: "POST", headers: { "content-type": "application/json" },
211
+ body: JSON.stringify({ texts: lines.map(line => line.text), from: language, to: target }),
212
+ }, AbortSignal.any([controller.signal, AbortSignal.timeout(12_000)]));
213
+ if (translated.texts.length !== lines.length || translated.texts.some(text => !text.trim()))
214
+ throw audioError("Translation omitted a phrase. Waiting for the next phrase.", 502);
215
+ lines = lines.map((line, i) => ({ ...line, original: line.text, sourceLanguage: language, language: target, text: translated.texts[i] }));
216
+ }
217
+ if (generation !== this.generation)
218
+ return;
219
+ if (Date.now() - until > 12_000)
220
+ continue;
221
+ this.translationFailures = 0;
222
+ this.options.lines(lines.flatMap(line => splitCaption(line)));
223
+ this.options.status(target ? `${language} → ${target} · live translation` : `Original audio language: ${language || "detecting…"}`);
224
+ }
225
+ catch (error) {
226
+ if (generation !== this.generation)
227
+ return;
228
+ if (!transientAudioError(error) || ++this.translationFailures >= 3)
229
+ throw error;
230
+ this.options.status("Translation interrupted · catching up with live speech…");
200
231
  }
201
- if (generation !== this.generation)
202
- return;
203
- if (Date.now() - until > 12_000)
204
- throw new Error("Translation fell behind. Try again after the language model has warmed up.");
205
- this.options.lines(lines.flatMap(line => splitCaption(line)));
206
- this.options.status(target ? `${language} → ${target} · live translation` : `Original audio language: ${language || "detecting…"}`);
207
232
  }
208
233
  }
209
234
  catch (error) {
210
235
  if (generation === this.generation) {
236
+ const message = error instanceof Error ? error.message : "Live translation stopped.";
211
237
  this.reset();
212
- this.options.failed();
213
- this.options.status(error instanceof Error ? error.message : "Live translation stopped.");
238
+ this.options.failed(message);
239
+ this.options.status(message);
214
240
  }
215
241
  }
216
242
  finally {
@@ -0,0 +1,6 @@
1
+ /** Access, billing and throttling failures are terminal. Only transient service
2
+ * and transport failures may recover, with a bounded consecutive failure count. */
3
+ export declare function transientAudioError(error: unknown): boolean;
4
+ export declare function audioError(message: string, status: number): Error & {
5
+ status: number;
6
+ };
@@ -0,0 +1,11 @@
1
+ /** Access, billing and throttling failures are terminal. Only transient service
2
+ * and transport failures may recover, with a bounded consecutive failure count. */
3
+ export function transientAudioError(error) {
4
+ const status = error?.status;
5
+ if (typeof status === "number")
6
+ return [408, 502, 503, 504].includes(status);
7
+ return error instanceof TypeError || (error instanceof Error && ["AbortError", "TimeoutError"].includes(error.name));
8
+ }
9
+ export function audioError(message, status) {
10
+ return Object.assign(new Error(message), { status });
11
+ }
@@ -57,7 +57,7 @@ export declare 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
- hear(bytes: Uint8Array, by: string, signal?: AbortSignal, meter?: TranslationMeter | undefined): Promise<SpeakerTranscript>;
60
+ hear(bytes: Uint8Array, by: string, signal?: AbortSignal, meter?: TranslationMeter | undefined, resource?: string): Promise<SpeakerTranscript>;
61
61
  voices(): Promise<LiveVoiceChoice[]>;
62
62
  /** A 90-second capability for one channel, never the viewer's account credential. */
63
63
  grant(by: string, channel: string): Promise<{
@@ -47,10 +47,10 @@ export class LiveVoice {
47
47
  /** Optional diarization, billed only while a signed-in listener requests it.
48
48
  * Rolling audio is bounded to 15 seconds, including overlap. Every second
49
49
  * submitted (also repeated context) consumes the persistent provider budget. */
50
- async hear(bytes, by, signal, meter = this.billing) {
50
+ async hear(bytes, by, signal, meter = this.billing, resource = "") {
51
51
  if (!this.available())
52
52
  throw new SpeechError("speaker voices are unavailable", 503);
53
- await meter?.require(by);
53
+ await meter?.require(by, resource);
54
54
  const wav = decodeWav(bytes);
55
55
  const seconds = wav.samples.length / wav.rate;
56
56
  if (wav.rate !== 16000 || wav.channels !== 1 || seconds < 0.2 || seconds > 15.1)
@@ -82,7 +82,7 @@ export class LiveVoice {
82
82
  form.set("tag_audio_events", "false");
83
83
  form.set("timestamps_granularity", "word");
84
84
  // No language_code: preserve the source language, including Spanish.
85
- reservation = await meter?.reserve(by, "transcription", wav.samples.length);
85
+ reservation = await meter?.reserve(by, "transcription", wav.samples.length, resource);
86
86
  const answer = await this.fetcher("https://api.elevenlabs.io/v1/speech-to-text", {
87
87
  method: "POST", headers: { "xi-api-key": this.key }, body: form,
88
88
  signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000),
@@ -127,7 +127,7 @@ export class LiveVoice {
127
127
  throw new SpeechError("choose a playback session or live channel", 400);
128
128
  if (!this.requests.check(`grant:${by}`, { allowed: 10, windowMs: 60_000 }).ok)
129
129
  throw new SpeechError("too many audio authorization requests", 429);
130
- await this.billing?.require(by);
130
+ await this.billing?.require(by, channel);
131
131
  for (const [token, grant] of this.grants)
132
132
  if (grant.expires <= this.now())
133
133
  this.grants.delete(token);
@@ -217,7 +217,7 @@ export class LiveVoice {
217
217
  /** Stream the first request immediately; concurrent listeners share its cached result. */
218
218
  async stream(ask, by, signal, meter = this.billing) {
219
219
  this.checkRequest(by);
220
- await meter?.require(by);
220
+ await meter?.require(by, ask.channel);
221
221
  const text = typeof ask.text === "string" ? ask.text.trim() : "";
222
222
  if (!text || text.length > 600)
223
223
  throw new SpeechError("translated audio needs a caption of 1–600 characters", 400);
@@ -242,7 +242,7 @@ export class LiveVoice {
242
242
  if (cached || pending) {
243
243
  const bytes = cached?.bytes ?? await pending;
244
244
  signal?.throwIfAborted();
245
- const paid = await meter?.reserve(by, "voice", text.length);
245
+ const paid = await meter?.reserve(by, "voice", text.length, ask.channel);
246
246
  if (paid)
247
247
  await meter.commit(paid);
248
248
  return new Response(new Uint8Array(bytes), { headers: HEADERS });
@@ -265,7 +265,7 @@ export class LiveVoice {
265
265
  try {
266
266
  await this.charge(by, ask.channel ?? "direct", text.length);
267
267
  signal?.throwIfAborted();
268
- reservation = await meter?.reserve(by, "voice", text.length);
268
+ reservation = await meter?.reserve(by, "voice", text.length, ask.channel);
269
269
  const response = await this.fetcher(`https://api.elevenlabs.io/v1/text-to-speech/${voice.id}/stream?output_format=pcm_16000`, {
270
270
  method: "POST",
271
271
  headers: { "xi-api-key": this.key, "content-type": "application/json" },
@@ -0,0 +1,9 @@
1
+ export interface MailSender {
2
+ apiKey: string;
3
+ from: string;
4
+ }
5
+ export interface BrandedMailSender extends MailSender {
6
+ backtoschool?: MailSender;
7
+ }
8
+ /** Only the exact school origins select its verified sender and credentials. */
9
+ export declare function mailSender(options: BrandedMailSender, link: string): MailSender;
@@ -0,0 +1,11 @@
1
+ /** Only the exact school origins select its verified sender and credentials. */
2
+ export function mailSender(options, link) {
3
+ try {
4
+ const url = new URL(link);
5
+ if (options.backtoschool && url.protocol === "https:" &&
6
+ ["backtoschool.help", "www.backtoschool.help"].includes(url.hostname))
7
+ return options.backtoschool;
8
+ }
9
+ catch { /* A malformed link never changes the sender. */ }
10
+ return options;
11
+ }
package/dist/notify.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type BrandedMailSender } from "./mail-sender.ts";
1
2
  /**
2
3
  * Telling followers a broadcaster went live, wherever they are.
3
4
  *
@@ -58,9 +59,8 @@ export declare function notifyAll(audience: readonly Reachable[], note: Notifica
58
59
  * HTTP rather than SMTP so there is no connection to hold, no port to be
59
60
  * blocked, and no dependency: a fetch is the whole client.
60
61
  */
61
- export declare function resendEmail({ apiKey, from, fetch, onEvent }: {
62
- apiKey: string;
63
- from: string;
62
+ export declare function resendEmail({ apiKey, from, backtoschool, reason, fetch, onEvent }: BrandedMailSender & {
63
+ reason?: string;
64
64
  fetch?: typeof globalThis.fetch;
65
65
  onEvent?: (message: string) => void;
66
66
  }): (to: string, note: Notification) => Promise<boolean>;
package/dist/notify.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { mailSender } from "./mail-sender.js";
1
2
  /**
2
3
  * Tell an audience, on every channel each of them wants.
3
4
  *
@@ -52,20 +53,22 @@ export async function notifyAll(audience, note, channels) {
52
53
  * HTTP rather than SMTP so there is no connection to hold, no port to be
53
54
  * blocked, and no dependency: a fetch is the whole client.
54
55
  */
55
- export function resendEmail({ apiKey, from, fetch = globalThis.fetch, onEvent }) {
56
+ export function resendEmail({ apiKey, from, backtoschool, reason = "You are getting this because you follow them on nixamp.", fetch = globalThis.fetch, onEvent }) {
56
57
  return async (to, note) => {
57
58
  try {
59
+ const sender = mailSender({ apiKey, from, backtoschool }, note.url);
58
60
  const response = await fetch("https://api.resend.com/emails", {
59
61
  method: "POST",
60
- headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
62
+ headers: { authorization: `Bearer ${sender.apiKey}`, "content-type": "application/json" },
63
+ signal: AbortSignal.timeout(15_000),
61
64
  body: JSON.stringify({
62
- from,
65
+ from: sender.from,
63
66
  to: [to],
64
67
  subject: note.title,
65
- text: `${note.body}\n\n${note.url}\n\nYou are getting this because you follow them on nixamp.`,
68
+ text: `${note.body}\n\n${note.url}\n\n${reason}`,
66
69
  html: `<p>${escapeHtml(note.body)}</p>` +
67
70
  `<p><a href="${escapeHtml(note.url)}">${escapeHtml(note.url)}</a></p>` +
68
- `<p style="color:#666;font-size:12px">You are getting this because you follow them on nixamp.</p>`,
71
+ `<p style="color:#666;font-size:12px">${escapeHtml(reason)}</p>`,
69
72
  }),
70
73
  });
71
74
  if (!response.ok) {
@@ -0,0 +1,2 @@
1
+ /** A small recovery page shared by every branded client on this account server. */
2
+ export declare function passwordResetPage(site: string, nonce: string): string;
@@ -0,0 +1,106 @@
1
+ function escape(value) {
2
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll('"', "&quot;");
3
+ }
4
+ /** A small recovery page shared by every branded client on this account server. */
5
+ export function passwordResetPage(site, nonce) {
6
+ const host = new URL(site).hostname;
7
+ const school = host === "backtoschool.help" || host === "www.backtoschool.help";
8
+ const name = school ? "BackToSchool.help" : host;
9
+ const home = school ? "/#live" : "/";
10
+ return `<!doctype html><html lang="en"><head>
11
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
12
+ <meta name="referrer" content="no-referrer"><meta name="robots" content="noindex, nofollow">
13
+ <title>Reset your password — ${escape(name)}</title>
14
+ <style>
15
+ :root{font-family:system-ui,sans-serif;color:#34246d;background:#f7f3e9;color-scheme:light}
16
+ *{box-sizing:border-box}body{margin:0}header{padding:1.25rem max(1rem,calc((100% - 960px)/2));border-bottom:1px solid #ddd5e6}
17
+ a{color:#34246d;text-underline-offset:3px}header a{font-weight:800;text-decoration:none}
18
+ main{max-width:480px;margin:clamp(2rem,8vh,5rem) auto;padding:0 1rem 3rem}
19
+ .card{background:#fffdf8;border:1px solid #ddd5e6;border-radius:20px;padding:clamp(1.25rem,5vw,2rem)}
20
+ h1{font-family:Georgia,serif;font-size:2.25rem;line-height:1.1;margin:0 0 1rem}p{line-height:1.55}
21
+ 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}
22
+ 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}
23
+ button[aria-disabled=true]{opacity:.65;cursor:wait}a:focus-visible,input:focus-visible,button:focus-visible{outline:3px solid #b14b29;outline-offset:3px}
24
+ .help{font-size:.9rem;color:#514961}.message:empty{display:none}.error{color:#9b202a}.success{color:#215d3d}
25
+ .back{display:inline-block;margin-top:1.5rem}[hidden]{display:none!important}
26
+ @media(forced-colors:active){.card,button{border:1px solid CanvasText}}
27
+ </style></head><body>
28
+ <header><a href="${home}">${escape(name)}</a></header>
29
+ <main><div class="card">
30
+ <h1 id="title">Forgot your password?</h1>
31
+ <p id="intro">Enter your account’s email address and we’ll send you a link to choose a new password.</p>
32
+ <form id="request-form">
33
+ <label for="email">Email</label><input id="email" name="email" type="email" autocomplete="email" maxlength="320" required>
34
+ <button type="submit">Send reset link</button>
35
+ </form>
36
+ <form id="reset-form" hidden>
37
+ <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>
38
+ <p class="help" id="password-help">Use at least 8 characters, including a number.</p>
39
+ <label for="confirm">Confirm new password</label><input id="confirm" name="confirm" type="password" autocomplete="new-password" minlength="8" maxlength="200" required>
40
+ <button type="submit">Save new password</button>
41
+ </form>
42
+ <p id="status" class="message success" role="status" aria-live="polite"></p>
43
+ <p id="error" class="message error" role="alert"></p>
44
+ <p id="another" hidden><a href="/reset-password">Request a new reset link</a></p>
45
+ <a class="back" id="back" href="${home}">Back to sign in</a>
46
+ <noscript><p>Enable JavaScript to reset your password.</p></noscript>
47
+ </div></main>
48
+ <script nonce="${nonce}">${SCRIPT}</script></body></html>`;
49
+ }
50
+ const SCRIPT = String.raw `
51
+ (() => {
52
+ const requestForm = document.querySelector('#request-form');
53
+ const resetForm = document.querySelector('#reset-form');
54
+ const status = document.querySelector('#status');
55
+ const error = document.querySelector('#error');
56
+ let token = ''; let pending = false; let done = false;
57
+ function readLink() {
58
+ const incoming = new URLSearchParams(location.hash.slice(1)).get('token');
59
+ if (location.hash) history.replaceState(null, '', location.pathname);
60
+ if (!incoming) return;
61
+ token = incoming; done = false;
62
+ status.textContent = ''; error.textContent = '';
63
+ document.querySelector('#another').hidden = true;
64
+ for (const field of resetForm.querySelectorAll('input')) {field.value = ''; field.readOnly = false;}
65
+ const button = resetForm.querySelector('button');
66
+ button.textContent = 'Save new password'; button.removeAttribute('aria-disabled');
67
+ requestForm.hidden = true; resetForm.hidden = false;
68
+ document.querySelector('#title').textContent = 'Choose a new password';
69
+ document.querySelector('#intro').textContent = 'Your new password works here and on NixAmp. Existing sessions will be signed out.';
70
+ }
71
+ readLink(); window.addEventListener('hashchange', readLink);
72
+ async function submit(form, path, input) {
73
+ if (pending || done) return;
74
+ pending = true; error.textContent = ''; status.textContent = '';
75
+ const button = form.querySelector('button');
76
+ button.setAttribute('aria-disabled', 'true'); form.setAttribute('aria-busy', 'true');
77
+ try {
78
+ const response = await fetch(path, {method:'POST', credentials:'same-origin', headers:{'content-type':'application/json'}, body:JSON.stringify(input)});
79
+ const result = await response.json();
80
+ if (!response.ok) {
81
+ if (form === resetForm && response.status === 400) document.querySelector('#another').hidden = false;
82
+ throw new Error(result.error || 'That did not work. Please try again.');
83
+ }
84
+ status.textContent = result.message;
85
+ if (form === resetForm) {
86
+ done = true; token = '';
87
+ for (const field of form.querySelectorAll('input')) {field.value = ''; field.readOnly = true;}
88
+ button.textContent = 'Password saved';
89
+ document.querySelector('#back').textContent = 'Sign in with your new password';
90
+ }
91
+ } catch (problem) {error.textContent = problem instanceof TypeError ? 'Could not connect. Please try again.' : problem.message;}
92
+ finally {pending = false; form.removeAttribute('aria-busy'); if (!done) button.removeAttribute('aria-disabled');}
93
+ }
94
+ requestForm.addEventListener('submit', event => {
95
+ event.preventDefault();
96
+ void submit(requestForm, '/api/v1/auth/password-reset/request', {email:requestForm.elements.email.value.trim()});
97
+ });
98
+ resetForm.addEventListener('submit', event => {
99
+ event.preventDefault();
100
+ if (pending || done) return;
101
+ const password = resetForm.elements.password.value;
102
+ if (password !== resetForm.elements.confirm.value) {error.textContent = 'The passwords do not match.'; return;}
103
+ if (!/[0-9]/.test(password)) {error.textContent = 'Include at least one number in your password.'; return;}
104
+ void submit(resetForm, '/api/v1/auth/password-reset/confirm', {token, password});
105
+ });
106
+ })();`;
@@ -0,0 +1,34 @@
1
+ import type { Queryable } from "./follows.ts";
2
+ import type { Tokens } from "./tokens.ts";
3
+ import { type BrandedMailSender } from "./mail-sender.ts";
4
+ export declare const RESET_MESSAGE = "If an account exists for that email, you\u2019ll receive a password reset link shortly.";
5
+ export declare const RESET_INVALID = "This reset link is invalid, expired, or already used. Request a new link.";
6
+ interface Passwords {
7
+ hashPassword(password: string): Promise<string>;
8
+ validatePassword(password: string): {
9
+ valid: boolean;
10
+ message: string;
11
+ };
12
+ }
13
+ export type ResetMail = (email: string, link: string) => Promise<boolean>;
14
+ export declare class PasswordResets {
15
+ private readonly db;
16
+ private readonly tokens;
17
+ private readonly passwords;
18
+ private ready;
19
+ constructor(db: Queryable & {
20
+ initialize?(): Promise<void>;
21
+ }, tokens: Tokens, passwords: Passwords);
22
+ private ensure;
23
+ /** The public response is identical for registered and unknown addresses. */
24
+ request(email: string, site: string, deliver: ResetMail): Promise<void>;
25
+ /** Claim the link, change the password, and revoke sessions in one statement. */
26
+ confirm(token: unknown, password: unknown): Promise<string | null>;
27
+ /** Older JWT sessions also stop working after recovery, across restarts. */
28
+ acceptsLegacyToken(userId: string, token: string): Promise<boolean>;
29
+ }
30
+ /** Transactional recovery mail has no follower footer and never logs a token. */
31
+ export declare function resendPasswordReset(options: BrandedMailSender & {
32
+ fetch?: typeof globalThis.fetch;
33
+ }): ResetMail;
34
+ export {};