nixamp 0.3.0 → 0.4.1

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.
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Following a broadcaster, and where to reach the people who do.
3
+ *
4
+ * The phone line got here first, and its reminder is a different thing: you
5
+ * key a code, press 1, and are told once when that particular stream comes
6
+ * back. It is per-stream, one-shot, and tied to the handset you called from.
7
+ * That is right for somebody who dialled a number, and useless for somebody
8
+ * who wants to know whenever a person they like goes live, on whatever device
9
+ * they happen to be holding.
10
+ *
11
+ * So a follow is account to account, and delivery is a set of addresses rather
12
+ * than a phone number: an email, a phone if they gave one, and any number of
13
+ * browsers that have granted permission. One person with a laptop, a phone and
14
+ * a desktop app is three push subscriptions and one account.
15
+ *
16
+ * Unlike the rest of nixamp this is durable. The directory can afford to be a
17
+ * four-minute TTL because a stream that stops is a stream nobody is listening
18
+ * to; a follow has to outlive the stream by definition -- the whole point is
19
+ * to be told about a broadcast that is not happening yet.
20
+ *
21
+ * The query function is injected rather than a Pool being constructed here, so
22
+ * a test can describe a database instead of running one.
23
+ */
24
+ /** The slice of `pg` this needs. Postgres in production, a fake in tests. */
25
+ export interface Queryable {
26
+ query(text: string, values?: unknown[]): Promise<{
27
+ rows: Record<string, unknown>[];
28
+ }>;
29
+ }
30
+ /** A browser, desktop app or phone that has granted notification permission. */
31
+ export interface PushTarget {
32
+ endpoint: string;
33
+ p256dh: string;
34
+ auth: string;
35
+ }
36
+ /** Everywhere one follower can be reached. */
37
+ export interface Reachable {
38
+ accountId: string;
39
+ email: string;
40
+ /** E.164, if they gave one. Empty when they never did. */
41
+ phone: string;
42
+ /** Whether each channel is on. A follower who wants none is still a follower. */
43
+ wantsEmail: boolean;
44
+ wantsSms: boolean;
45
+ wantsWeb: boolean;
46
+ push: PushTarget[];
47
+ }
48
+ /** E.164, or nothing. A number we cannot dial is not a number worth storing. */
49
+ export declare function phoneFrom(value: unknown): string;
50
+ export declare class Follows {
51
+ private readonly db;
52
+ private ready;
53
+ constructor(db: Queryable);
54
+ /** Make the tables, once per process, on first use. */
55
+ private ensure;
56
+ follow(followerId: string, streamerId: string): Promise<boolean>;
57
+ unfollow(followerId: string, streamerId: string): Promise<void>;
58
+ /** Who this account follows. */
59
+ following(followerId: string): Promise<string[]>;
60
+ isFollowing(followerId: string, streamerId: string): Promise<boolean>;
61
+ followerCount(streamerId: string): Promise<number>;
62
+ /** Remember a browser that has granted permission. */
63
+ addPush(accountId: string, target: PushTarget): Promise<void>;
64
+ /**
65
+ * Forget a browser.
66
+ *
67
+ * Called when somebody turns notifications off, and again when a push is
68
+ * rejected as gone: a subscription outlives the browser that made it, and
69
+ * pushing to a dead endpoint forever is how a table becomes mostly rubbish.
70
+ */
71
+ removePush(endpoint: string): Promise<void>;
72
+ setPrefs(accountId: string, prefs: {
73
+ phone?: string;
74
+ wantsEmail?: boolean;
75
+ wantsSms?: boolean;
76
+ wantsWeb?: boolean;
77
+ }): Promise<void>;
78
+ prefs(accountId: string): Promise<{
79
+ phone: string;
80
+ wantsEmail: boolean;
81
+ wantsSms: boolean;
82
+ wantsWeb: boolean;
83
+ }>;
84
+ /**
85
+ * Everyone following this broadcaster, and every way to reach them.
86
+ *
87
+ * One query rather than one per follower. A broadcaster with a thousand
88
+ * followers going live should not be a thousand round trips while the
89
+ * publisher's heartbeat waits on the response.
90
+ */
91
+ audience(streamerId: string): Promise<Reachable[]>;
92
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Following a broadcaster, and where to reach the people who do.
3
+ *
4
+ * The phone line got here first, and its reminder is a different thing: you
5
+ * key a code, press 1, and are told once when that particular stream comes
6
+ * back. It is per-stream, one-shot, and tied to the handset you called from.
7
+ * That is right for somebody who dialled a number, and useless for somebody
8
+ * who wants to know whenever a person they like goes live, on whatever device
9
+ * they happen to be holding.
10
+ *
11
+ * So a follow is account to account, and delivery is a set of addresses rather
12
+ * than a phone number: an email, a phone if they gave one, and any number of
13
+ * browsers that have granted permission. One person with a laptop, a phone and
14
+ * a desktop app is three push subscriptions and one account.
15
+ *
16
+ * Unlike the rest of nixamp this is durable. The directory can afford to be a
17
+ * four-minute TTL because a stream that stops is a stream nobody is listening
18
+ * to; a follow has to outlive the stream by definition -- the whole point is
19
+ * to be told about a broadcast that is not happening yet.
20
+ *
21
+ * The query function is injected rather than a Pool being constructed here, so
22
+ * a test can describe a database instead of running one.
23
+ */
24
+ /**
25
+ * The tables.
26
+ *
27
+ * Created on demand rather than in a migration because the auth module owns
28
+ * the schema this sits beside and there is no migration runner to hook into.
29
+ * `IF NOT EXISTS` throughout, so starting a second instance is not a race that
30
+ * takes the first one down.
31
+ *
32
+ * `follows` is keyed on the pair, which makes following twice a no-op rather
33
+ * than a duplicate to deduplicate later. `push_subscriptions` is keyed on the
34
+ * endpoint alone: an endpoint is issued by the browser vendor and is already
35
+ * unique, and the same browser re-subscribing should replace its row rather
36
+ * than accumulate one per sign-in.
37
+ */
38
+ const SCHEMA = `
39
+ CREATE TABLE IF NOT EXISTS follows (
40
+ follower_id TEXT NOT NULL,
41
+ streamer_id TEXT NOT NULL,
42
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
43
+ PRIMARY KEY (follower_id, streamer_id)
44
+ );
45
+ CREATE INDEX IF NOT EXISTS follows_streamer ON follows (streamer_id);
46
+
47
+ CREATE TABLE IF NOT EXISTS push_subscriptions (
48
+ endpoint TEXT PRIMARY KEY,
49
+ account_id TEXT NOT NULL,
50
+ p256dh TEXT NOT NULL,
51
+ auth TEXT NOT NULL,
52
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
53
+ );
54
+ CREATE INDEX IF NOT EXISTS push_account ON push_subscriptions (account_id);
55
+
56
+ CREATE TABLE IF NOT EXISTS notify_prefs (
57
+ account_id TEXT PRIMARY KEY,
58
+ phone TEXT NOT NULL DEFAULT '',
59
+ want_email BOOLEAN NOT NULL DEFAULT TRUE,
60
+ want_sms BOOLEAN NOT NULL DEFAULT FALSE,
61
+ want_web BOOLEAN NOT NULL DEFAULT TRUE
62
+ );
63
+ `;
64
+ /** E.164, or nothing. A number we cannot dial is not a number worth storing. */
65
+ export function phoneFrom(value) {
66
+ if (typeof value !== "string")
67
+ return "";
68
+ const digits = value.replace(/[^\d+]/g, "");
69
+ if (/^\+[1-9]\d{7,14}$/.test(digits))
70
+ return digits;
71
+ // A bare US ten-digit number is the common case and is unambiguous.
72
+ if (/^\d{10}$/.test(digits))
73
+ return `+1${digits}`;
74
+ if (/^1\d{10}$/.test(digits))
75
+ return `+${digits}`;
76
+ return "";
77
+ }
78
+ export class Follows {
79
+ db;
80
+ ready = null;
81
+ constructor(db) {
82
+ this.db = db;
83
+ }
84
+ /** Make the tables, once per process, on first use. */
85
+ async ensure() {
86
+ this.ready ??= this.db.query(SCHEMA).then(() => undefined);
87
+ await this.ready;
88
+ }
89
+ async follow(followerId, streamerId) {
90
+ // Following yourself is not an error worth an error, but it is not a
91
+ // follow either: you do not need telling that you went live.
92
+ if (!followerId || !streamerId || followerId === streamerId)
93
+ return false;
94
+ await this.ensure();
95
+ await this.db.query(`INSERT INTO follows (follower_id, streamer_id) VALUES ($1, $2)
96
+ ON CONFLICT DO NOTHING`, [followerId, streamerId]);
97
+ return true;
98
+ }
99
+ async unfollow(followerId, streamerId) {
100
+ await this.ensure();
101
+ await this.db.query("DELETE FROM follows WHERE follower_id = $1 AND streamer_id = $2", [followerId, streamerId]);
102
+ }
103
+ /** Who this account follows. */
104
+ async following(followerId) {
105
+ await this.ensure();
106
+ const { rows } = await this.db.query("SELECT streamer_id FROM follows WHERE follower_id = $1 ORDER BY created_at", [followerId]);
107
+ return rows.map((r) => String(r["streamer_id"]));
108
+ }
109
+ async isFollowing(followerId, streamerId) {
110
+ await this.ensure();
111
+ const { rows } = await this.db.query("SELECT 1 FROM follows WHERE follower_id = $1 AND streamer_id = $2", [followerId, streamerId]);
112
+ return rows.length > 0;
113
+ }
114
+ async followerCount(streamerId) {
115
+ await this.ensure();
116
+ const { rows } = await this.db.query("SELECT COUNT(*)::int AS n FROM follows WHERE streamer_id = $1", [streamerId]);
117
+ return Number(rows[0]?.["n"] ?? 0);
118
+ }
119
+ /** Remember a browser that has granted permission. */
120
+ async addPush(accountId, target) {
121
+ if (!accountId || !target.endpoint)
122
+ return;
123
+ await this.ensure();
124
+ await this.db.query(`INSERT INTO push_subscriptions (endpoint, account_id, p256dh, auth)
125
+ VALUES ($1, $2, $3, $4)
126
+ ON CONFLICT (endpoint) DO UPDATE
127
+ SET account_id = EXCLUDED.account_id,
128
+ p256dh = EXCLUDED.p256dh,
129
+ auth = EXCLUDED.auth`, [target.endpoint, accountId, target.p256dh, target.auth]);
130
+ }
131
+ /**
132
+ * Forget a browser.
133
+ *
134
+ * Called when somebody turns notifications off, and again when a push is
135
+ * rejected as gone: a subscription outlives the browser that made it, and
136
+ * pushing to a dead endpoint forever is how a table becomes mostly rubbish.
137
+ */
138
+ async removePush(endpoint) {
139
+ if (!endpoint)
140
+ return;
141
+ await this.ensure();
142
+ await this.db.query("DELETE FROM push_subscriptions WHERE endpoint = $1", [endpoint]);
143
+ }
144
+ async setPrefs(accountId, prefs) {
145
+ if (!accountId)
146
+ return;
147
+ await this.ensure();
148
+ // Every column is COALESCEd on the way IN as well as on conflict. A caller
149
+ // setting only the switches sends no phone, and an unset field arrives as
150
+ // null -- which the ON CONFLICT branch handles fine and the INSERT branch
151
+ // does not, because these columns are NOT NULL. Saving preferences for the
152
+ // first time was a 500 until this said so.
153
+ //
154
+ // The casts are not decoration either: Postgres cannot infer the type of a
155
+ // parameter that is only ever seen inside COALESCE against a null.
156
+ await this.db.query(`INSERT INTO notify_prefs (account_id, phone, want_email, want_sms, want_web)
157
+ VALUES ($1,
158
+ COALESCE($2::text, ''),
159
+ COALESCE($3::boolean, TRUE),
160
+ COALESCE($4::boolean, FALSE),
161
+ COALESCE($5::boolean, TRUE))
162
+ ON CONFLICT (account_id) DO UPDATE
163
+ SET phone = COALESCE($2::text, notify_prefs.phone),
164
+ want_email = COALESCE($3::boolean, notify_prefs.want_email),
165
+ want_sms = COALESCE($4::boolean, notify_prefs.want_sms),
166
+ want_web = COALESCE($5::boolean, notify_prefs.want_web)`, [
167
+ accountId,
168
+ prefs.phone === undefined ? null : phoneFrom(prefs.phone),
169
+ prefs.wantsEmail ?? null,
170
+ prefs.wantsSms ?? null,
171
+ prefs.wantsWeb ?? null,
172
+ ]);
173
+ }
174
+ async prefs(accountId) {
175
+ await this.ensure();
176
+ const { rows } = await this.db.query("SELECT phone, want_email, want_sms, want_web FROM notify_prefs WHERE account_id = $1", [accountId]);
177
+ const row = rows[0];
178
+ // Defaults for somebody who has never opened the settings: mail yes, web
179
+ // yes, texts no. A text costs them nothing but is the most intrusive of
180
+ // the three, so it is the one you have to ask for.
181
+ return {
182
+ phone: String(row?.["phone"] ?? ""),
183
+ wantsEmail: row === undefined ? true : row["want_email"] !== false,
184
+ wantsSms: row === undefined ? false : row["want_sms"] === true,
185
+ wantsWeb: row === undefined ? true : row["want_web"] !== false,
186
+ };
187
+ }
188
+ /**
189
+ * Everyone following this broadcaster, and every way to reach them.
190
+ *
191
+ * One query rather than one per follower. A broadcaster with a thousand
192
+ * followers going live should not be a thousand round trips while the
193
+ * publisher's heartbeat waits on the response.
194
+ */
195
+ async audience(streamerId) {
196
+ if (!streamerId)
197
+ return [];
198
+ await this.ensure();
199
+ const { rows } = await this.db.query(`SELECT f.follower_id AS account_id,
200
+ COALESCE(u.email, '') AS email,
201
+ COALESCE(p.phone, '') AS phone,
202
+ COALESCE(p.want_email, TRUE) AS want_email,
203
+ COALESCE(p.want_sms, FALSE) AS want_sms,
204
+ COALESCE(p.want_web, TRUE) AS want_web,
205
+ COALESCE(
206
+ json_agg(json_build_object('endpoint', s.endpoint, 'p256dh', s.p256dh, 'auth', s.auth))
207
+ FILTER (WHERE s.endpoint IS NOT NULL),
208
+ '[]'
209
+ ) AS push
210
+ FROM follows f
211
+ LEFT JOIN users u ON u.id = f.follower_id
212
+ LEFT JOIN notify_prefs p ON p.account_id = f.follower_id
213
+ LEFT JOIN push_subscriptions s ON s.account_id = f.follower_id
214
+ WHERE f.streamer_id = $1
215
+ GROUP BY f.follower_id, u.email, p.phone, p.want_email, p.want_sms, p.want_web`, [streamerId]);
216
+ return rows.map((r) => ({
217
+ accountId: String(r["account_id"] ?? ""),
218
+ email: String(r["email"] ?? ""),
219
+ phone: String(r["phone"] ?? ""),
220
+ wantsEmail: r["want_email"] !== false,
221
+ wantsSms: r["want_sms"] === true,
222
+ wantsWeb: r["want_web"] !== false,
223
+ push: readPush(r["push"]),
224
+ }));
225
+ }
226
+ }
227
+ /** json_agg comes back as an array or as a string, depending on the driver. */
228
+ function readPush(value) {
229
+ const list = typeof value === "string" ? safeParse(value) : value;
230
+ if (!Array.isArray(list))
231
+ return [];
232
+ return list
233
+ .map((item) => (item ?? {}))
234
+ .filter((item) => typeof item["endpoint"] === "string" && item["endpoint"] !== "")
235
+ .map((item) => ({
236
+ endpoint: String(item["endpoint"]),
237
+ p256dh: String(item["p256dh"] ?? ""),
238
+ auth: String(item["auth"] ?? ""),
239
+ }));
240
+ }
241
+ function safeParse(text) {
242
+ try {
243
+ return JSON.parse(text);
244
+ }
245
+ catch {
246
+ return [];
247
+ }
248
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Telling followers a broadcaster went live, wherever they are.
3
+ *
4
+ * Three channels, because "any device" is not one thing. A browser that has
5
+ * granted permission gets a push -- which is also how the desktop app and a
6
+ * phone with the PWA installed hear about it, since all three are the same
7
+ * subscription under different chrome. An inbox gets mail. A handset that gave
8
+ * us a number gets a text.
9
+ *
10
+ * Every channel is optional at both ends. The operator may configure none of
11
+ * them, in which case nothing is sent and nothing throws; and a follower may
12
+ * want mail but not texts, which is the default because a text is the most
13
+ * intrusive of the three and the one you should have to ask for.
14
+ *
15
+ * Nothing here retries. A missed "so-and-so is live" is worth very little an
16
+ * hour later, and a retry queue for a message with that shelf life is machinery
17
+ * that will outlive its usefulness. What it does do is notice a push endpoint
18
+ * the browser vendor has retired, and say so, so the row can be dropped rather
19
+ * than pushed at forever.
20
+ */
21
+ import type { PushTarget, Reachable } from "./follows.ts";
22
+ import type { Sms } from "./partyline.ts";
23
+ export interface Notification {
24
+ /** "Chovy is live" */
25
+ title: string;
26
+ /** "Playing Top Gun: Maverick. Call 408-357-2326 and key 482917." */
27
+ body: string;
28
+ /** Where a click should land. */
29
+ url: string;
30
+ }
31
+ /** What happened to one push. `gone` means the subscription should be dropped. */
32
+ export type PushResult = "sent" | "gone" | "failed";
33
+ export interface NotifyChannels {
34
+ email?: (to: string, note: Notification) => Promise<boolean>;
35
+ sms?: Sms;
36
+ push?: (target: PushTarget, note: Notification) => Promise<PushResult>;
37
+ /** Called with an endpoint the vendor has retired, so it can be forgotten. */
38
+ onGone?: (endpoint: string) => Promise<void>;
39
+ onEvent?: (message: string) => void;
40
+ }
41
+ export interface NotifyReport {
42
+ email: number;
43
+ sms: number;
44
+ push: number;
45
+ dropped: number;
46
+ }
47
+ /**
48
+ * Tell an audience, on every channel each of them wants.
49
+ *
50
+ * Sent in parallel across people and channels. A thousand followers is a
51
+ * thousand independent HTTP calls, and doing them in sequence would mean the
52
+ * last person hears about a stream that has already finished.
53
+ */
54
+ export declare function notifyAll(audience: readonly Reachable[], note: Notification, channels: NotifyChannels): Promise<NotifyReport>;
55
+ /**
56
+ * Mail, over Resend's HTTP API.
57
+ *
58
+ * HTTP rather than SMTP so there is no connection to hold, no port to be
59
+ * blocked, and no dependency: a fetch is the whole client.
60
+ */
61
+ export declare function resendEmail({ apiKey, from, fetch, onEvent }: {
62
+ apiKey: string;
63
+ from: string;
64
+ fetch?: typeof globalThis.fetch;
65
+ onEvent?: (message: string) => void;
66
+ }): (to: string, note: Notification) => Promise<boolean>;
67
+ /**
68
+ * Web push, to a browser, a desktop app or an installed PWA.
69
+ *
70
+ * The library is imported lazily because it is only needed on the instance
71
+ * that has VAPID keys -- which is nixamp.com and nowhere else. A laptop
72
+ * running `nixamp serve` should not pay to load it.
73
+ *
74
+ * 404 and 410 mean the vendor has retired the subscription. That is not a
75
+ * failure to retry; it is a row to delete.
76
+ */
77
+ export declare function webPush({ publicKey, privateKey, subject, onEvent }: {
78
+ publicKey: string;
79
+ privateKey: string;
80
+ /** A mailto: or https: URL identifying us to the push service. */
81
+ subject: string;
82
+ onEvent?: (message: string) => void;
83
+ }): (target: PushTarget, note: Notification) => Promise<PushResult>;
package/dist/notify.js ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Tell an audience, on every channel each of them wants.
3
+ *
4
+ * Sent in parallel across people and channels. A thousand followers is a
5
+ * thousand independent HTTP calls, and doing them in sequence would mean the
6
+ * last person hears about a stream that has already finished.
7
+ */
8
+ export async function notifyAll(audience, note, channels) {
9
+ const report = { email: 0, sms: 0, push: 0, dropped: 0 };
10
+ const gone = [];
11
+ const jobs = [];
12
+ for (const person of audience) {
13
+ if (person.wantsEmail && person.email && channels.email) {
14
+ jobs.push(channels.email(person.email, note).then((ok) => {
15
+ if (ok)
16
+ report.email += 1;
17
+ }));
18
+ }
19
+ if (person.wantsSms && person.phone && channels.sms) {
20
+ // The text carries the same words, plus how to stop getting them --
21
+ // which is not optional on an automated message to a US number.
22
+ const text = `${note.title}. ${note.body} Reply STOP to opt out.`;
23
+ jobs.push(channels.sms.send(person.phone, text).then((ok) => {
24
+ if (ok)
25
+ report.sms += 1;
26
+ }));
27
+ }
28
+ if (person.wantsWeb && channels.push) {
29
+ for (const target of person.push) {
30
+ jobs.push(channels.push(target, note).then((result) => {
31
+ if (result === "sent")
32
+ report.push += 1;
33
+ else if (result === "gone")
34
+ gone.push(target.endpoint);
35
+ }));
36
+ }
37
+ }
38
+ }
39
+ // allSettled, not all: one bad address must not cancel everybody else's.
40
+ await Promise.allSettled(jobs);
41
+ if (channels.onGone) {
42
+ await Promise.allSettled(gone.map((endpoint) => channels.onGone(endpoint)));
43
+ report.dropped = gone.length;
44
+ }
45
+ channels.onEvent?.(` told followers: ${report.push} push, ${report.email} email, ${report.sms} sms` +
46
+ (report.dropped ? `, dropped ${report.dropped} dead subscription(s)` : ""));
47
+ return report;
48
+ }
49
+ /**
50
+ * Mail, over Resend's HTTP API.
51
+ *
52
+ * HTTP rather than SMTP so there is no connection to hold, no port to be
53
+ * blocked, and no dependency: a fetch is the whole client.
54
+ */
55
+ export function resendEmail({ apiKey, from, fetch = globalThis.fetch, onEvent }) {
56
+ return async (to, note) => {
57
+ try {
58
+ const response = await fetch("https://api.resend.com/emails", {
59
+ method: "POST",
60
+ headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
61
+ body: JSON.stringify({
62
+ from,
63
+ to: [to],
64
+ subject: note.title,
65
+ text: `${note.body}\n\n${note.url}\n\nYou are getting this because you follow them on nixamp.`,
66
+ html: `<p>${escapeHtml(note.body)}</p>` +
67
+ `<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>`,
69
+ }),
70
+ });
71
+ if (!response.ok) {
72
+ onEvent?.(` email to ${to} -> ${response.status}`);
73
+ return false;
74
+ }
75
+ return true;
76
+ }
77
+ catch (error) {
78
+ onEvent?.(` email to ${to} failed: ${error.message}`);
79
+ return false;
80
+ }
81
+ };
82
+ }
83
+ function escapeHtml(value) {
84
+ return value
85
+ .replace(/&/g, "&amp;")
86
+ .replace(/</g, "&lt;")
87
+ .replace(/>/g, "&gt;")
88
+ .replace(/"/g, "&quot;");
89
+ }
90
+ /**
91
+ * Web push, to a browser, a desktop app or an installed PWA.
92
+ *
93
+ * The library is imported lazily because it is only needed on the instance
94
+ * that has VAPID keys -- which is nixamp.com and nowhere else. A laptop
95
+ * running `nixamp serve` should not pay to load it.
96
+ *
97
+ * 404 and 410 mean the vendor has retired the subscription. That is not a
98
+ * failure to retry; it is a row to delete.
99
+ */
100
+ export function webPush({ publicKey, privateKey, subject, onEvent }) {
101
+ let library = null;
102
+ const load = async () => {
103
+ library ??= import("web-push").then((mod) => {
104
+ const wp = (mod["default"] ?? mod);
105
+ wp.setVapidDetails(subject, publicKey, privateKey);
106
+ return wp;
107
+ });
108
+ return library;
109
+ };
110
+ return async (target, note) => {
111
+ try {
112
+ const wp = await load();
113
+ await wp.sendNotification({ endpoint: target.endpoint, keys: { p256dh: target.p256dh, auth: target.auth } }, JSON.stringify({ title: note.title, body: note.body, url: note.url }), { TTL: 60 * 30 });
114
+ return "sent";
115
+ }
116
+ catch (error) {
117
+ const status = error.statusCode;
118
+ if (status === 404 || status === 410) {
119
+ onEvent?.(` push endpoint retired by the vendor, dropping it`);
120
+ return "gone";
121
+ }
122
+ onEvent?.(` push failed: ${status ?? error.message}`);
123
+ return "failed";
124
+ }
125
+ };
126
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The page that explains the text messages.
3
+ *
4
+ * Not decoration and not marketing. A carrier reviewing a toll-free number for
5
+ * A2P messaging asks to see where the consent comes from, and answers "a
6
+ * screenshot" -- which is awkward when the consent is somebody pressing 1 on a
7
+ * telephone and there is no screen to shoot. This page is that evidence: the
8
+ * exact prompt the caller hears, what they get, how often, and how to stop.
9
+ *
10
+ * It is also the honest thing to publish regardless of who is asking. Anyone
11
+ * who gets a text from us can find out here why, and stop it, without having
12
+ * to reply to a number they do not recognise.
13
+ *
14
+ * Served as a page of its own rather than a route in the app, because it has
15
+ * to be readable by someone with no JavaScript and no account -- a reviewer,
16
+ * or a person holding a phone that just buzzed.
17
+ */
18
+ export declare const OPT_IN_PATH = "/sms";
19
+ /**
20
+ * The number a caller dials.
21
+ *
22
+ * Local, not the toll-free one, and the reason is billing rather than taste.
23
+ * Only standard DIDs are eligible for channel billing -- a flat fee for
24
+ * unlimited inbound minutes -- while toll-free is pay-per-minute forever, at
25
+ * roughly five times the rate. On a line people stay on for hours that is the
26
+ * whole cost of the product, so the number we print is the cheap one.
27
+ *
28
+ * 888-ROOM-818 still answers, for anyone who has it. It is a vanity alias, not
29
+ * the number to publish, and it cannot reach the cheap tier at any volume.
30
+ */
31
+ export declare const CALL_IN_NUMBER = "408-357-2326";
32
+ /** The number a reminder is sent from. Not the one above; see partyline.ts. */
33
+ export declare const SMS_FROM_NUMBER = "408-426-9127";
34
+ export declare function optInPage({ callIn, smsFrom }?: {
35
+ callIn?: string | undefined;
36
+ smsFrom?: string | undefined;
37
+ }): string;