nixamp 0.28.37 → 0.28.41

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 (116) hide show
  1. package/README.md +6 -1
  2. package/backtoschool/dist/assets/{ar-CmpEhms8.js → ar-BQPKp0M2.js} +1 -1
  3. package/backtoschool/dist/assets/{cs-BiiJOLTm.js → cs-DjJnhGNB.js} +1 -1
  4. package/{web/dist/assets/da-M4GDhrcC.js → backtoschool/dist/assets/da-CbTvkIH7.js} +1 -1
  5. package/{web/dist/assets/de-D7yI8cd_.js → backtoschool/dist/assets/de-SsCkny26.js} +1 -1
  6. package/backtoschool/dist/assets/{es-BwQQh_C8.js → es-BBlBPcq1.js} +1 -1
  7. package/backtoschool/dist/assets/{fi-DKPYMsbo.js → fi-ffo35_4W.js} +1 -1
  8. package/backtoschool/dist/assets/{fr-DNekMW0h.js → fr-BVvavrk7.js} +1 -1
  9. package/{web/dist/assets/hi-DO7CO-fI.js → backtoschool/dist/assets/hi-DDy_zxu6.js} +1 -1
  10. package/{web/dist/assets/hls-3VKVEQE3-BCxdTCEU.js → backtoschool/dist/assets/hls-3VKVEQE3-QZWXCWjR.js} +1 -1
  11. package/backtoschool/dist/assets/hls-n74Cnh8A.js +42 -0
  12. package/backtoschool/dist/assets/{hu-gcw9ouWH.js → hu-BoJj7pZa.js} +1 -1
  13. package/backtoschool/dist/assets/{id-Bz51zxz6.js → id-CXN9gchF.js} +1 -1
  14. package/backtoschool/dist/assets/index-BaiHu9CD.css +1 -0
  15. package/backtoschool/dist/assets/index-Dovv39gH.js +112 -0
  16. package/backtoschool/dist/assets/{it-jO8u4ie7.js → it-C9g0xD6S.js} +1 -1
  17. package/{web/dist/assets/mpegts-DsEoUEPH.js → backtoschool/dist/assets/mpegts-GAwbg-pX.js} +1 -1
  18. package/{web/dist/assets/mpegts-LO6RVLD6-CC8jMmPy.js → backtoschool/dist/assets/mpegts-LO6RVLD6-BrVQzFhM.js} +1 -1
  19. package/backtoschool/dist/assets/native-C7JTKWJH-BUyIoj0P.js +1 -0
  20. package/{web/dist/assets/nl-BU0Mn-e-.js → backtoschool/dist/assets/nl-Ciq1Yrk4.js} +1 -1
  21. package/{web/dist/assets/ru-ClqNCOTW.js → backtoschool/dist/assets/ru-B_ZMUvh6.js} +1 -1
  22. package/backtoschool/dist/assets/{sv-CV2-POKP.js → sv-DH0ZO3Eh.js} +1 -1
  23. package/{web/dist/assets/uk-K5yQBF0h.js → backtoschool/dist/assets/uk-Cof-TUZd.js} +1 -1
  24. package/backtoschool/dist/assets/{vi-BDZew-O9.js → vi-D0QPpzbE.js} +1 -1
  25. package/{web/dist/assets/zh-a2v2x-3e.js → backtoschool/dist/assets/zh-CLTzTBOU.js} +1 -1
  26. package/backtoschool/dist/index.html +61 -10
  27. package/dist/classroom.d.ts +1 -1
  28. package/dist/classroom.js +4 -0
  29. package/dist/live-api.d.ts +10 -0
  30. package/dist/live-api.js +8 -0
  31. package/dist/live-events.js +1 -1
  32. package/dist/live-voice.d.ts +3 -0
  33. package/dist/live-voice.js +27 -4
  34. package/dist/locales/ar.d.ts +15 -0
  35. package/dist/locales/cs.d.ts +15 -0
  36. package/dist/locales/da.d.ts +15 -0
  37. package/dist/locales/de.d.ts +15 -0
  38. package/dist/locales/en.d.ts +15 -0
  39. package/dist/locales/es.d.ts +15 -0
  40. package/dist/locales/fi.d.ts +15 -0
  41. package/dist/locales/fr.d.ts +15 -0
  42. package/dist/locales/hi.d.ts +15 -0
  43. package/dist/locales/hu.d.ts +15 -0
  44. package/dist/locales/id.d.ts +15 -0
  45. package/dist/locales/it.d.ts +15 -0
  46. package/dist/locales/nl.d.ts +15 -0
  47. package/dist/locales/platforms.d.ts +19 -0
  48. package/dist/locales/platforms.js +19 -0
  49. package/dist/locales/ru.d.ts +15 -0
  50. package/dist/locales/sv.d.ts +15 -0
  51. package/dist/locales/uk.d.ts +15 -0
  52. package/dist/locales/vi.d.ts +15 -0
  53. package/dist/locales/zh.d.ts +15 -0
  54. package/dist/nixamp-link-api.d.ts +44 -0
  55. package/dist/nixamp-link-api.js +190 -0
  56. package/dist/nixamp-link-types.d.ts +33 -0
  57. package/dist/nixamp-link-types.js +5 -0
  58. package/dist/nixamp-link.d.ts +66 -0
  59. package/dist/nixamp-link.js +231 -0
  60. package/dist/notify.d.ts +6 -4
  61. package/dist/notify.js +14 -25
  62. package/dist/oauth-api.d.ts +1 -1
  63. package/dist/oauth-api.js +27 -9
  64. package/dist/oauth-server.d.ts +9 -1
  65. package/dist/oauth-server.js +24 -2
  66. package/dist/profiles.d.ts +85 -0
  67. package/dist/profiles.js +0 -0
  68. package/dist/server.d.ts +12 -2
  69. package/dist/server.js +205 -13
  70. package/dist/voice-provider.d.ts +9 -0
  71. package/dist/voice-provider.js +27 -0
  72. package/dist/watch-party.d.ts +8 -0
  73. package/dist/watch-party.js +21 -0
  74. package/package.json +6 -6
  75. package/src/classroom.ts +5 -1
  76. package/src/live-api.ts +13 -0
  77. package/src/live-events.ts +1 -1
  78. package/src/live-voice.ts +25 -3
  79. package/src/locales/platforms.ts +21 -0
  80. package/src/nixamp-link-api.ts +220 -0
  81. package/src/nixamp-link-types.ts +36 -0
  82. package/src/nixamp-link.ts +265 -0
  83. package/src/notify.ts +19 -34
  84. package/src/oauth-api.ts +30 -11
  85. package/src/oauth-server.ts +24 -2
  86. package/src/profiles.ts +0 -0
  87. package/src/server.ts +192 -15
  88. package/src/voice-provider.ts +22 -0
  89. package/src/watch-party.ts +23 -0
  90. package/web/dist/assets/{ar-DhdXPO3p.js → ar-BqLaLSMR.js} +1 -1
  91. package/web/dist/assets/{cs-CoTzYnZt.js → cs-CfoQJwKM.js} +1 -1
  92. package/{backtoschool/dist/assets/da-DueRd5zz.js → web/dist/assets/da-8MmH23fh.js} +1 -1
  93. package/{backtoschool/dist/assets/de-DIG6WxUZ.js → web/dist/assets/de-Brry3OMh.js} +1 -1
  94. package/web/dist/assets/{es-BMGb7XlT.js → es-CGxJs7b8.js} +1 -1
  95. package/web/dist/assets/{fi-CXNsmIrh.js → fi-BDAAZtto.js} +1 -1
  96. package/web/dist/assets/{fr-BpvyrESQ.js → fr-9sxYL0Qy.js} +1 -1
  97. package/{backtoschool/dist/assets/hi-CGlN72JQ.js → web/dist/assets/hi-Cc7ZsdL7.js} +1 -1
  98. package/web/dist/assets/hls-3VKVEQE3-Dx7qltzA.js +1 -0
  99. package/web/dist/assets/{hu-CosB3vlS.js → hu-CvB9aIyN.js} +1 -1
  100. package/web/dist/assets/{id-DNumDlwq.js → id-Bmrh06Hw.js} +1 -1
  101. package/web/dist/assets/index-BQuoiKg9.js +3 -0
  102. package/web/dist/assets/{index-jLCo7PIA.css → index-Bo3utcDw.css} +1 -1
  103. package/web/dist/assets/{it-IJKhyCGw.js → it-SbaxFUIP.js} +1 -1
  104. package/web/dist/assets/mpegts-BdwONu8t.js +3 -0
  105. package/web/dist/assets/mpegts-LO6RVLD6-CqW3DT1n.js +1 -0
  106. package/{backtoschool/dist/assets/nl-CnYXq0K4.js → web/dist/assets/nl-CqimSo3B.js} +1 -1
  107. package/{backtoschool/dist/assets/ru-BatrUfYu.js → web/dist/assets/ru-wIfpMpU9.js} +1 -1
  108. package/web/dist/assets/{sv-Df_aXTD6.js → sv-BAyIUHP5.js} +1 -1
  109. package/{backtoschool/dist/assets/uk-vLtqKpHm.js → web/dist/assets/uk-D2jFJm6l.js} +1 -1
  110. package/web/dist/assets/{vi-CYfcOUeA.js → vi-BdH3SLlR.js} +1 -1
  111. package/{backtoschool/dist/assets/zh-C9hBmQKA.js → web/dist/assets/zh-5xz5Agt-.js} +1 -1
  112. package/web/dist/index.html +33 -2
  113. package/web/dist/sw.js +23 -23
  114. package/backtoschool/dist/assets/index-CP4oTs6H.css +0 -1
  115. package/backtoschool/dist/assets/index-DDGO_FOw.js +0 -113
  116. package/web/dist/assets/index-CY3lAwM2.js +0 -3
@@ -1,5 +1,26 @@
1
1
  /* English fallback for BackToSchool platform and installation messages. */
2
2
  export default {
3
+ "Nixamp Share link or HTTPS media URL": "Nixamp Share link or HTTPS media URL",
4
+ "For TV playback, use a Nixamp Share link or a direct HTTPS media URL. Pairux viewing opens separately.": "For TV playback, use a Nixamp Share link or a direct HTTPS media URL. Pairux viewing opens separately.",
5
+
6
+ "Paused": "Paused",
7
+ "Broadcast ended": "Broadcast ended",
8
+ "Class broadcast": "Class broadcast",
9
+ "Play": "Play",
10
+ "Pause": "Pause",
11
+ "Retry playback": "Retry playback",
12
+ "Back 10 seconds": "Back 10 seconds",
13
+ "Forward 10 seconds": "Forward 10 seconds",
14
+ "Mute": "Mute",
15
+ "Unmute": "Unmute",
16
+ "Full screen": "Full screen",
17
+ "Exit full screen": "Exit full screen",
18
+ "Playing": "Playing",
19
+ "Select Play to watch the broadcast.": "Select Play to watch the broadcast.",
20
+ "The broadcast could not be loaded. Try again.": "The broadcast could not be loaded. Try again.",
21
+ "This Pairux broadcast opens separately. For playback here on Fire TV, the host needs to share a Nixamp stream or direct media link.": "This Pairux broadcast opens separately. For playback here on Fire TV, the host needs to share a Nixamp stream or direct media link.",
22
+ "Open on Pairux": "Open on Pairux",
23
+
3
24
  "Supported platforms": "Supported platforms",
4
25
  "In review": "In review",
5
26
  "Not available": "Not available",
@@ -0,0 +1,220 @@
1
+ /**
2
+ * The routes behind "Connect nixamp" on a site that runs on this codebase
3
+ * but is not nixamp.com.
4
+ *
5
+ * GET /api/v1/nixamp/connect send the browser to nixamp.com's consent page
6
+ * GET /api/v1/nixamp/callback the code comes back; tokens are kept
7
+ * GET /api/v1/nixamp/connection whether this account is connected, and as whom
8
+ * DELETE /api/v1/nixamp/connection withdraw the grant, both sides
9
+ * GET /api/v1/nixamp/streams the servers you run on nixamp, and what is live
10
+ *
11
+ * All five want the site's own session: the person, signed in here. What
12
+ * they hold on nixamp's side is a client token, and it never leaves the
13
+ * server -- the page only ever learns a handle and a list of streams.
14
+ */
15
+ import type { IncomingMessage, ServerResponse } from "node:http";
16
+ import { tokenFrom, type Accounts } from "./accounts.ts";
17
+ import type { NixampLinks } from "./nixamp-link.ts";
18
+ import type { ServerStreams, StreamPick } from "./nixamp-link-types.ts";
19
+ export type { ServerStreams, StreamPick } from "./nixamp-link-types.ts";
20
+
21
+ export interface NixampLinkApiOptions {
22
+ links: NixampLinks;
23
+ accounts: Accounts;
24
+ /** nixamp.com, or whatever is the issuer. */
25
+ issuer: string;
26
+ /** This site, as the redirect URI is built from it: https://backtoschool.help. */
27
+ site: string;
28
+ /** The redirect URIs the client registered on the issuer; the callback must be one. */
29
+ redirectUris: string[];
30
+ /** Where the browser lands after the callback. */
31
+ home?: string;
32
+ secureCookies?: boolean;
33
+ fetcher?: typeof fetch;
34
+ }
35
+
36
+ const COOKIE = "nixamp_connect";
37
+ const CALLBACK = "/api/v1/nixamp/callback";
38
+
39
+ export function nixampLinkPath(path: string): boolean {
40
+ return path === "/api/v1/nixamp/connect" || path === CALLBACK || path === "/api/v1/nixamp/connection" || path === "/api/v1/nixamp/streams";
41
+ }
42
+
43
+ function json(response: ServerResponse, code: number, body: unknown, headers: Record<string, string> = {}): void {
44
+ const value = JSON.stringify(body);
45
+ response.writeHead(code, {
46
+ ...headers,
47
+ "content-type": "application/json; charset=utf-8",
48
+ "content-length": Buffer.byteLength(value),
49
+ "cache-control": "no-store",
50
+ });
51
+ response.end(value);
52
+ }
53
+
54
+ function cookieValue(headers: IncomingMessage["headers"], name: string): string {
55
+ const raw = headers.cookie ?? "";
56
+ for (const part of raw.split(";")) {
57
+ const [key, ...rest] = part.trim().split("=");
58
+ if (key === name) return decodeURIComponent(rest.join("="));
59
+ }
60
+ return "";
61
+ }
62
+
63
+ function legCookie(value: string, secure: boolean): string {
64
+ const parts = [`${COOKIE}=${encodeURIComponent(value)}`, `Path=${CALLBACK}`, "Max-Age=600", "SameSite=Lax", "HttpOnly"];
65
+ if (secure) parts.push("Secure");
66
+ return parts.join("; ");
67
+ }
68
+
69
+ function clearedLegCookie(): string {
70
+ return `${COOKIE}=; Path=${CALLBACK}; Max-Age=0; SameSite=Lax; HttpOnly`;
71
+ }
72
+
73
+ function landing(home: string, outcome: string, reason = ""): string {
74
+ const url = new URL(home, "http://placeholder.invalid");
75
+ url.searchParams.set("nixamp", outcome);
76
+ if (reason) url.searchParams.set("reason", reason.slice(0, 200));
77
+ return `${url.pathname}${url.search}${url.hash || "#settings"}`;
78
+ }
79
+
80
+ /**
81
+ * Every server the nixamp account remembers, asked what it is doing.
82
+ *
83
+ * The account's own list comes from nixamp.com with the client token; each
84
+ * server is then asked directly, with the share key the account kept for it,
85
+ * and given four seconds. One that does not answer is listed as out of
86
+ * reach rather than dropped, so a host sees why a stream is missing.
87
+ */
88
+ export async function streamsFor(token: string, issuer: string, pageSite: string, fetcher: typeof fetch): Promise<ServerStreams[]> {
89
+ const base = issuer.replace(/\/+$/, "");
90
+ let servers: { id: string; name: string; url: string; key: string }[] = [];
91
+ const answer = await fetcher(`${base}/api/v1/servers`, {
92
+ headers: { authorization: `Bearer ${token}`, accept: "application/json" },
93
+ signal: AbortSignal.timeout(8000),
94
+ });
95
+ if (!answer.ok) throw new Error(answer.status === 401 ? "nixamp.com no longer accepts this connection" : `nixamp.com answered ${answer.status}`);
96
+ const body = (await answer.json().catch(() => ({}))) as { servers?: typeof servers };
97
+ servers = Array.isArray(body.servers) ? body.servers : [];
98
+ const page = pageSite.replace(/\/+$/, "");
99
+ const linkTo = (address: string, play: string): string => `${page}/?url=${encodeURIComponent(address)}&play=${encodeURIComponent(play)}`;
100
+ return Promise.all(servers.map(async (server): Promise<ServerStreams> => {
101
+ const out: ServerStreams = { id: server.id, name: server.name || server.url, url: server.url, reachable: false, playing: false, nowPlaying: "", live: "", channels: [] };
102
+ try {
103
+ const url = new URL("/api/streams", server.url.endsWith("/") ? server.url : `${server.url}/`);
104
+ if (server.key) url.searchParams.set("k", server.key);
105
+ const response = await fetcher(url.href, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(4000) });
106
+ if (!response.ok) return out;
107
+ const data = (await response.json()) as {
108
+ server?: { name?: string; nowPlaying?: string; playing?: boolean; url?: string };
109
+ channels?: { id: string; name: string; kind?: string; listeners?: number }[];
110
+ };
111
+ // What a viewer link is built on: the server's own view-only address
112
+ // when it publishes one, else its plain address. Never the key the
113
+ // account kept, which may open the controls.
114
+ const viewer = data.server?.url && /^https?:\/\//.test(data.server.url) ? data.server.url : server.url;
115
+ out.reachable = true;
116
+ out.name = data.server?.name || out.name;
117
+ out.playing = data.server?.playing === true;
118
+ out.nowPlaying = data.server?.nowPlaying ?? "";
119
+ out.live = out.playing ? linkTo(viewer, "live") : "";
120
+ out.channels = (data.channels ?? []).map((one): StreamPick => ({
121
+ server: out.name,
122
+ serverUrl: server.url,
123
+ id: one.id,
124
+ name: one.name,
125
+ kind: one.kind ?? "audio",
126
+ listeners: typeof one.listeners === "number" ? one.listeners : 0,
127
+ link: linkTo(viewer, `channel:${one.id}`),
128
+ }));
129
+ } catch {
130
+ // Out of reach: said so, above.
131
+ }
132
+ return out;
133
+ }));
134
+ }
135
+
136
+ export async function handleNixampLinkApi(
137
+ request: IncomingMessage,
138
+ response: ServerResponse,
139
+ url: URL,
140
+ options: NixampLinkApiOptions,
141
+ ): Promise<boolean> {
142
+ const path = url.pathname;
143
+ if (!nixampLinkPath(path)) return false;
144
+ const home = options.home ?? "/";
145
+ const redirectUri = `${options.site.replace(/\/+$/, "")}${CALLBACK}`;
146
+ const registered = options.redirectUris.includes(redirectUri);
147
+
148
+ const account = await options.accounts.whoIs(tokenFrom(request.headers));
149
+ if (account === null) {
150
+ if (path === CALLBACK) {
151
+ response.writeHead(302, { location: landing(home, "failed", "sign in here first, then connect again"), "set-cookie": clearedLegCookie() });
152
+ response.end();
153
+ return true;
154
+ }
155
+ json(response, 401, { error: "sign in first" });
156
+ return true;
157
+ }
158
+
159
+ if (path === "/api/v1/nixamp/connect") {
160
+ if (request.method !== "GET") { json(response, 405, { error: "GET only" }); return true; }
161
+ if (!registered) { json(response, 400, { error: "this site is not registered as a nixamp client" }); return true; }
162
+ const leg = options.links.begin(redirectUri);
163
+ response.writeHead(302, { location: leg.url, "set-cookie": legCookie(`${leg.state}.${leg.verifier}`, options.secureCookies ?? false), "cache-control": "no-store" });
164
+ response.end();
165
+ return true;
166
+ }
167
+
168
+ if (path === CALLBACK) {
169
+ if (request.method !== "GET") { json(response, 405, { error: "GET only" }); return true; }
170
+ const kept = cookieValue(request.headers, COOKIE);
171
+ const dot = kept.indexOf(".");
172
+ const state = dot > 0 ? kept.slice(0, dot) : "";
173
+ const verifier = dot > 0 ? kept.slice(dot + 1) : "";
174
+ const headers = { "set-cookie": clearedLegCookie(), "cache-control": "no-store" };
175
+ const refused = url.searchParams.get("error");
176
+ if (refused) {
177
+ response.writeHead(302, { ...headers, location: landing(home, refused === "access_denied" ? "denied" : "failed", url.searchParams.get("error_description") ?? refused) });
178
+ response.end();
179
+ return true;
180
+ }
181
+ const code = url.searchParams.get("code") ?? "";
182
+ if (state === "" || url.searchParams.get("state") !== state || code === "") {
183
+ response.writeHead(302, { ...headers, location: landing(home, "failed", "that link is not the one this browser started; try again") });
184
+ response.end();
185
+ return true;
186
+ }
187
+ try {
188
+ await options.links.finish(account.id, { code, verifier, redirectUri });
189
+ response.writeHead(302, { ...headers, location: landing(home, "connected") });
190
+ } catch (error) {
191
+ response.writeHead(302, { ...headers, location: landing(home, "failed", (error as Error).message) });
192
+ }
193
+ response.end();
194
+ return true;
195
+ }
196
+
197
+ if (path === "/api/v1/nixamp/connection") {
198
+ if (request.method === "GET") {
199
+ json(response, 200, { ...(await options.links.of(account.id)), available: registered });
200
+ return true;
201
+ }
202
+ if (request.method === "DELETE") {
203
+ json(response, 200, { ok: true, withdrawn: await options.links.disconnect(account.id) });
204
+ return true;
205
+ }
206
+ json(response, 405, { error: "GET or DELETE" });
207
+ return true;
208
+ }
209
+
210
+ // /api/v1/nixamp/streams
211
+ if (request.method !== "GET") { json(response, 405, { error: "GET only" }); return true; }
212
+ const token = await options.links.accessToken(account.id);
213
+ if (token === "") { json(response, 409, { error: "connect your nixamp account first", connected: false }); return true; }
214
+ try {
215
+ json(response, 200, { servers: await streamsFor(token, options.issuer, options.issuer, options.fetcher ?? fetch) });
216
+ } catch (error) {
217
+ json(response, 502, { error: (error as Error).message });
218
+ }
219
+ return true;
220
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * What "Connect nixamp" looks like from a page: a leaf module, so a client
3
+ * bundle can name these shapes without pulling the server in behind them.
4
+ */
5
+
6
+ /** The connection as the account holder sees it. Never the tokens. */
7
+ export interface LinkView {
8
+ connected: boolean;
9
+ handle: string;
10
+ nixampUserId: string;
11
+ scope: string;
12
+ since: number | null;
13
+ }
14
+
15
+ /** A stream somebody could pick: a channel on one of their servers, as a link a classroom accepts. */
16
+ export interface StreamPick {
17
+ server: string;
18
+ serverUrl: string;
19
+ id: string;
20
+ name: string;
21
+ kind: string;
22
+ listeners: number;
23
+ link: string;
24
+ }
25
+
26
+ export interface ServerStreams {
27
+ id: string;
28
+ name: string;
29
+ url: string;
30
+ reachable: boolean;
31
+ playing: boolean;
32
+ nowPlaying: string;
33
+ /** A link to what the server itself is playing, when it is. */
34
+ live: string;
35
+ channels: StreamPick[];
36
+ }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * A nixamp account, connected to an account here.
3
+ *
4
+ * backtoschool.help runs on the nixamp codebase, and for a while its sign-in
5
+ * dialog said so: "your BackToSchool identity is your NixAmp account". That
6
+ * made a password manager file the nixamp password under the school's
7
+ * address, and it made nixamp a thing every teacher had to have. Neither is
8
+ * wanted. A BackToSchool account is a BackToSchool account; nixamp is the
9
+ * broadcast backend a host may plug in, and plugging it in is a choice.
10
+ *
11
+ * So the school is an OAuth 2.1 client of nixamp.com, the way bittorrented
12
+ * is: PKCE, a consent page on nixamp.com, a refresh token kept here against
13
+ * the school account, and a grant the person can withdraw from either side.
14
+ * What the connection buys is the list of the servers they run on nixamp and
15
+ * what is live on them, so "go live" is a pick from a list rather than a
16
+ * link pasted from a terminal.
17
+ *
18
+ * The client half of OAuth is behind an interface, because the server on the
19
+ * other end is this same program in production and a stub in a test.
20
+ */
21
+ import { randomBytes } from "node:crypto";
22
+ import type { Queryable } from "./follows.ts";
23
+ import { challengeFor } from "./oauth-server.ts";
24
+ import type { LinkView } from "./nixamp-link-types.ts";
25
+ export type { LinkView } from "./nixamp-link-types.ts";
26
+
27
+ const TABLE = "nixamp_links";
28
+
29
+ const SCHEMA = `
30
+ CREATE TABLE IF NOT EXISTS ${TABLE} (
31
+ user_id TEXT PRIMARY KEY,
32
+ nixamp_user_id TEXT NOT NULL,
33
+ handle TEXT NOT NULL DEFAULT '',
34
+ scope TEXT NOT NULL DEFAULT '',
35
+ access_token TEXT NOT NULL DEFAULT '',
36
+ access_expires_at TIMESTAMPTZ,
37
+ refresh_token TEXT NOT NULL DEFAULT '',
38
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
39
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
40
+ );
41
+ `;
42
+
43
+ /** What the school asks nixamp for: who you are, your servers, and to keep it. */
44
+ export const LINK_SCOPE = "profile streams offline_access";
45
+
46
+ /** How long before an access token's end it is treated as spent. */
47
+ const EARLY_MS = 60_000;
48
+
49
+ export interface TokenGrant {
50
+ access_token: string;
51
+ refresh_token?: string;
52
+ expires_in?: number;
53
+ scope?: string;
54
+ }
55
+
56
+ export interface WhoAmI {
57
+ sub: string;
58
+ handle?: string;
59
+ }
60
+
61
+ /** The client side of OAuth 2.1, against the issuer, however it is reached. */
62
+ export interface OAuthExchange {
63
+ authorizeUrl(query: Record<string, string>): string;
64
+ /** The token endpoint. Rejects with an Error whose message is fit to show. */
65
+ token(form: URLSearchParams): Promise<TokenGrant>;
66
+ userinfo(accessToken: string): Promise<WhoAmI>;
67
+ revoke(token: string): Promise<void>;
68
+ }
69
+
70
+ export const NOT_CONNECTED: LinkView = { connected: false, handle: "", nixampUserId: "", scope: "", since: null };
71
+
72
+ /** The real thing: nixamp.com over HTTP. */
73
+ export function nixampExchange(issuer: string, clientId: string, fetcher: typeof fetch = fetch): OAuthExchange {
74
+ const base = issuer.replace(/\/+$/, "");
75
+ async function post(path: string, form: URLSearchParams, accept: "json" | "none"): Promise<Record<string, unknown>> {
76
+ let response: Response;
77
+ try {
78
+ response = await fetcher(`${base}${path}`, {
79
+ method: "POST",
80
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
81
+ body: form.toString(),
82
+ signal: AbortSignal.timeout(10_000),
83
+ });
84
+ } catch {
85
+ throw new Error("nixamp.com could not be reached");
86
+ }
87
+ const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
88
+ if (!response.ok && accept === "json") {
89
+ const description = typeof body["error_description"] === "string" ? body["error_description"] : typeof body["error"] === "string" ? body["error"] : `nixamp.com answered ${response.status}`;
90
+ throw new Error(description);
91
+ }
92
+ return body;
93
+ }
94
+ return {
95
+ authorizeUrl(query) {
96
+ const url = new URL(`${base}/api/v1/oauth/authorize`);
97
+ for (const [key, value] of Object.entries(query)) url.searchParams.set(key, value);
98
+ return url.href;
99
+ },
100
+ async token(form) {
101
+ form.set("client_id", clientId);
102
+ const body = await post("/api/v1/oauth/token", form, "json");
103
+ if (typeof body["access_token"] !== "string" || body["access_token"] === "") throw new Error("nixamp.com sent no token");
104
+ return body as unknown as TokenGrant;
105
+ },
106
+ async userinfo(accessToken) {
107
+ let response: Response;
108
+ try {
109
+ response = await fetcher(`${base}/api/v1/oauth/userinfo`, {
110
+ headers: { authorization: `Bearer ${accessToken}`, accept: "application/json" },
111
+ signal: AbortSignal.timeout(10_000),
112
+ });
113
+ } catch {
114
+ throw new Error("nixamp.com could not be reached");
115
+ }
116
+ if (!response.ok) throw new Error("nixamp.com did not say whose token that is");
117
+ const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
118
+ if (typeof body["sub"] !== "string" || body["sub"] === "") throw new Error("nixamp.com did not say whose token that is");
119
+ return { sub: body["sub"], ...(typeof body["handle"] === "string" ? { handle: body["handle"] } : {}) };
120
+ },
121
+ async revoke(token) {
122
+ const form = new URLSearchParams({ token, client_id: clientId });
123
+ await post("/api/v1/oauth/revoke", form, "none").catch(() => undefined);
124
+ },
125
+ };
126
+ }
127
+
128
+ function asTime(value: unknown): number | null {
129
+ if (value instanceof Date) return value.getTime();
130
+ if (typeof value === "string") {
131
+ const at = Date.parse(value);
132
+ return Number.isNaN(at) ? null : at;
133
+ }
134
+ return null;
135
+ }
136
+
137
+ function view(row: Record<string, unknown> | undefined): LinkView {
138
+ if (!row) return { ...NOT_CONNECTED };
139
+ return {
140
+ connected: true,
141
+ handle: String(row["handle"] ?? ""),
142
+ nixampUserId: String(row["nixamp_user_id"] ?? ""),
143
+ scope: String(row["scope"] ?? ""),
144
+ since: asTime(row["created_at"]),
145
+ };
146
+ }
147
+
148
+ export class NixampLinks {
149
+ private ready: Promise<void> | null = null;
150
+
151
+ constructor(
152
+ private readonly db: Queryable,
153
+ private readonly exchange: OAuthExchange,
154
+ private readonly clientId: string,
155
+ private readonly now: () => number = () => Date.now(),
156
+ /** How nixamp.com and the servers it lists are reached, for what the tokens are used on. */
157
+ readonly fetcher: typeof fetch = fetch,
158
+ ) {}
159
+
160
+ private async ensure(): Promise<void> {
161
+ this.ready ??= this.db.query(SCHEMA).then(() => undefined);
162
+ await this.ready;
163
+ }
164
+
165
+ /**
166
+ * The first leg: where to send the browser, and the two secrets the
167
+ * callback must bring back. The state is the CSRF check, the verifier is
168
+ * PKCE; both live in a short cookie on the school's origin, never here.
169
+ */
170
+ begin(redirectUri: string): { url: string; state: string; verifier: string } {
171
+ const state = randomBytes(24).toString("base64url");
172
+ const verifier = randomBytes(48).toString("base64url");
173
+ const url = this.exchange.authorizeUrl({
174
+ response_type: "code",
175
+ client_id: this.clientId,
176
+ redirect_uri: redirectUri,
177
+ scope: LINK_SCOPE,
178
+ state,
179
+ code_challenge: challengeFor(verifier),
180
+ code_challenge_method: "S256",
181
+ });
182
+ return { url, state, verifier };
183
+ }
184
+
185
+ /** The second leg: the code becomes tokens, the tokens say whose, and that is kept. */
186
+ async finish(userId: string, leg: { code: string; verifier: string; redirectUri: string }): Promise<LinkView> {
187
+ const grant = await this.exchange.token(new URLSearchParams({
188
+ grant_type: "authorization_code",
189
+ code: leg.code,
190
+ code_verifier: leg.verifier,
191
+ redirect_uri: leg.redirectUri,
192
+ }));
193
+ const who = await this.exchange.userinfo(grant.access_token);
194
+ await this.ensure();
195
+ await this.db.query(
196
+ `INSERT INTO ${TABLE} (user_id, nixamp_user_id, handle, scope, access_token, access_expires_at, refresh_token)
197
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
198
+ ON CONFLICT (user_id) DO UPDATE SET nixamp_user_id = EXCLUDED.nixamp_user_id, handle = EXCLUDED.handle,
199
+ scope = EXCLUDED.scope, access_token = EXCLUDED.access_token, access_expires_at = EXCLUDED.access_expires_at,
200
+ refresh_token = EXCLUDED.refresh_token, updated_at = NOW()`,
201
+ [userId, who.sub, who.handle ?? "", grant.scope ?? LINK_SCOPE, grant.access_token, this.expiry(grant), grant.refresh_token ?? ""],
202
+ );
203
+ return this.of(userId);
204
+ }
205
+
206
+ private expiry(grant: TokenGrant): Date {
207
+ const seconds = typeof grant.expires_in === "number" && grant.expires_in > 0 ? grant.expires_in : 3600;
208
+ return new Date(this.now() + seconds * 1000);
209
+ }
210
+
211
+ async of(userId: string): Promise<LinkView> {
212
+ await this.ensure();
213
+ const { rows } = await this.db.query(
214
+ `SELECT nixamp_user_id, handle, scope, created_at FROM ${TABLE} WHERE user_id = $1`,
215
+ [userId],
216
+ );
217
+ return view(rows[0]);
218
+ }
219
+
220
+ /**
221
+ * A token good for a call right now, refreshed when the one kept is about
222
+ * to end. "" when there is no connection, or the refresh was refused --
223
+ * which is what a grant withdrawn on nixamp.com looks like from here, and
224
+ * the row is dropped so the settings page says so too.
225
+ */
226
+ async accessToken(userId: string): Promise<string> {
227
+ await this.ensure();
228
+ const { rows } = await this.db.query(
229
+ `SELECT access_token, access_expires_at, refresh_token FROM ${TABLE} WHERE user_id = $1`,
230
+ [userId],
231
+ );
232
+ const row = rows[0];
233
+ if (!row) return "";
234
+ const access = String(row["access_token"] ?? "");
235
+ const until = asTime(row["access_expires_at"]);
236
+ if (access !== "" && until !== null && until - this.now() > EARLY_MS) return access;
237
+ const refresh = String(row["refresh_token"] ?? "");
238
+ if (refresh === "") return "";
239
+ let grant: TokenGrant;
240
+ try {
241
+ grant = await this.exchange.token(new URLSearchParams({ grant_type: "refresh_token", refresh_token: refresh }));
242
+ } catch {
243
+ await this.db.query(`DELETE FROM ${TABLE} WHERE user_id = $1`, [userId]);
244
+ return "";
245
+ }
246
+ await this.db.query(
247
+ `UPDATE ${TABLE} SET access_token = $2, access_expires_at = $3, refresh_token = $4, updated_at = NOW() WHERE user_id = $1`,
248
+ [userId, grant.access_token, this.expiry(grant), grant.refresh_token ?? refresh],
249
+ );
250
+ return grant.access_token;
251
+ }
252
+
253
+ /** Withdraw the grant on nixamp.com and forget it here. True when there was one. */
254
+ async disconnect(userId: string): Promise<boolean> {
255
+ await this.ensure();
256
+ const { rows } = await this.db.query(`SELECT refresh_token, access_token FROM ${TABLE} WHERE user_id = $1`, [userId]);
257
+ const row = rows[0];
258
+ if (!row) return false;
259
+ const refresh = String(row["refresh_token"] ?? "");
260
+ const access = String(row["access_token"] ?? "");
261
+ await this.exchange.revoke(refresh !== "" ? refresh : access);
262
+ await this.db.query(`DELETE FROM ${TABLE} WHERE user_id = $1`, [userId]);
263
+ return true;
264
+ }
265
+ }
package/src/notify.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { sendPush } from "@profullstack/notifications/server";
1
2
  import { mailSender, type BrandedMailSender } from "./mail-sender.ts";
2
3
 
3
4
  /**
@@ -168,53 +169,37 @@ function escapeHtml(value: string): string {
168
169
  /**
169
170
  * Web push, to a browser, a desktop app or an installed PWA.
170
171
  *
171
- * The library is imported lazily because it is only needed on the instance
172
- * that has VAPID keys -- which is nixamp.com and nowhere else. A laptop
173
- * running `nixamp serve` should not pay to load it.
172
+ * Sent with @profullstack/notifications, which does VAPID and the payload
173
+ * encryption with node:crypto and nothing else, so there is no library to load
174
+ * lazily on the one instance (nixamp.com) that has keys. The keys are the same
175
+ * base64url pair web-push used, so nothing had to be regenerated.
174
176
  *
175
177
  * 404 and 410 mean the vendor has retired the subscription. That is not a
176
178
  * failure to retry; it is a row to delete.
177
179
  */
178
180
  export function webPush(
179
- { publicKey, privateKey, subject, onEvent }: {
181
+ { publicKey, privateKey, subject, onEvent, fetch }: {
180
182
  publicKey: string;
181
183
  privateKey: string;
182
184
  /** A mailto: or https: URL identifying us to the push service. */
183
185
  subject: string;
184
186
  onEvent?: (message: string) => void;
187
+ fetch?: typeof globalThis.fetch;
185
188
  },
186
189
  ): (target: PushTarget, note: Notification) => Promise<PushResult> {
187
- let library: Promise<{ sendNotification: Function; setVapidDetails: Function }> | null = null;
188
-
189
- const load = async () => {
190
- library ??= import("web-push").then((mod) => {
191
- const wp = ((mod as Record<string, unknown>)["default"] ?? mod) as {
192
- sendNotification: Function;
193
- setVapidDetails: Function;
194
- };
195
- wp.setVapidDetails(subject, publicKey, privateKey);
196
- return wp;
197
- });
198
- return library;
199
- };
200
-
190
+ const keys = { publicKey, privateKey };
201
191
  return async (target, note) => {
202
- try {
203
- const wp = await load();
204
- await wp.sendNotification(
205
- { endpoint: target.endpoint, keys: { p256dh: target.p256dh, auth: target.auth } },
206
- JSON.stringify({ title: note.title, body: note.body, url: note.url }),
207
- { TTL: 60 * 30 },
208
- );
209
- return "sent";
210
- } catch (error) {
211
- const status = (error as { statusCode?: number }).statusCode;
212
- if (status === 404 || status === 410) {
213
- onEvent?.(` push endpoint retired by the vendor, dropping it`);
214
- return "gone";
215
- }
216
- onEvent?.(` push failed: ${status ?? (error as Error).message}`);
217
- return "failed";
192
+ const result = await sendPush(
193
+ { endpoint: target.endpoint, keys: { p256dh: target.p256dh, auth: target.auth } },
194
+ { title: note.title, body: note.body, url: note.url },
195
+ { keys, subject, ttl: 60 * 30, ...(fetch ? { fetch } : {}) },
196
+ );
197
+ if (result.sent) return "sent";
198
+ if (result.gone) {
199
+ onEvent?.(` push endpoint retired by the vendor, dropping it`);
200
+ return "gone";
218
201
  }
202
+ onEvent?.(` push failed: ${result.status ?? result.error}`);
203
+ return "failed";
219
204
  };
220
205
  }