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.
- package/README.md +53 -20
- package/backtoschool/dist/assets/{index-6vtCMzi1.css → index-Bot4FEXQ.css} +1 -1
- package/backtoschool/dist/assets/index-Cjai7LgT.js +93 -0
- package/backtoschool/dist/index.html +36 -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/event-writer.d.ts +39 -0
- package/dist/event-writer.js +139 -0
- package/dist/live-api.d.ts +2 -0
- package/dist/live-api.js +37 -2
- package/dist/live-events.d.ts +7 -1
- package/dist/live-events.js +88 -12
- package/dist/live-interpreter.d.ts +3 -1
- package/dist/live-interpreter.js +109 -83
- package/dist/live-recovery.d.ts +6 -0
- package/dist/live-recovery.js +11 -0
- package/dist/live-voice.d.ts +1 -1
- package/dist/live-voice.js +7 -7
- package/dist/mail-sender.d.ts +9 -0
- package/dist/mail-sender.js +11 -0
- package/dist/notify.d.ts +3 -3
- package/dist/notify.js +8 -5
- package/dist/password-reset-page.d.ts +2 -0
- package/dist/password-reset-page.js +106 -0
- package/dist/password-reset.d.ts +34 -0
- package/dist/password-reset.js +121 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +109 -3
- package/dist/shared-translation.d.ts +1 -0
- package/dist/shared-translation.js +57 -40
- 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/event-writer.ts +126 -0
- package/src/live-api.ts +26 -2
- package/src/live-events.ts +81 -13
- package/src/live-interpreter.ts +87 -68
- package/src/live-recovery.ts +10 -0
- package/src/live-voice.ts +7 -7
- package/src/mail-sender.ts +12 -0
- package/src/notify.ts +10 -7
- package/src/password-reset-page.ts +108 -0
- package/src/password-reset.ts +134 -0
- package/src/server.ts +91 -3
- package/src/shared-translation.ts +47 -33
- 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/background.worker-Hz7oaQ8X.js +547 -0
- package/web/dist/assets/{hls-3VKVEQE3-C1ttVP7K.js → hls-3VKVEQE3-_BiJh76x.js} +1 -1
- package/web/dist/assets/index-BgLxkXKe.js +3 -0
- package/web/dist/assets/{index-BmR7T0zX.css → index-D34dTY18.css} +1 -1
- package/web/dist/assets/{mpegts-CVYPgepU.js → mpegts-CV0glck7.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-Dw2p8qGf.js → mpegts-LO6RVLD6-iuTMg-O6.js} +1 -1
- package/web/dist/index.html +6 -5
- package/web/dist/sw.js +7 -7
- package/backtoschool/dist/assets/index-D7Oas1m5.js +0 -88
- package/web/dist/assets/background.worker-DH8ag5Q4.js +0 -547
- package/web/dist/assets/index-3WZwGw04.js +0 -3
package/dist/live-interpreter.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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(
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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(
|
|
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
|
+
}
|
package/dist/live-voice.d.ts
CHANGED
|
@@ -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<{
|
package/dist/live-voice.js
CHANGED
|
@@ -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
|
-
|
|
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\
|
|
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"
|
|
71
|
+
`<p style="color:#666;font-size:12px">${escapeHtml(reason)}</p>`,
|
|
69
72
|
}),
|
|
70
73
|
});
|
|
71
74
|
if (!response.ok) {
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
function escape(value) {
|
|
2
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll('"', """);
|
|
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 {};
|