nixamp 0.28.38 → 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.
- package/README.md +6 -1
- package/backtoschool/dist/assets/{ar-BJrZUvbj.js → ar-BQPKp0M2.js} +1 -1
- package/backtoschool/dist/assets/{cs-DlOUfUxw.js → cs-DjJnhGNB.js} +1 -1
- package/{web/dist/assets/da-qufG3QK3.js → backtoschool/dist/assets/da-CbTvkIH7.js} +1 -1
- package/{web/dist/assets/de-BddWhvFB.js → backtoschool/dist/assets/de-SsCkny26.js} +1 -1
- package/backtoschool/dist/assets/{es-DezARIfJ.js → es-BBlBPcq1.js} +1 -1
- package/backtoschool/dist/assets/{fi-B_zd8oyM.js → fi-ffo35_4W.js} +1 -1
- package/backtoschool/dist/assets/{fr-CMtbCk6s.js → fr-BVvavrk7.js} +1 -1
- package/{web/dist/assets/hi-BMTJ6Zea.js → backtoschool/dist/assets/hi-DDy_zxu6.js} +1 -1
- package/backtoschool/dist/assets/{hls-3VKVEQE3-BFn8NctW.js → hls-3VKVEQE3-QZWXCWjR.js} +1 -1
- package/backtoschool/dist/assets/{hu-DDqnEZUk.js → hu-BoJj7pZa.js} +1 -1
- package/backtoschool/dist/assets/{id-GE_XdNH1.js → id-CXN9gchF.js} +1 -1
- package/backtoschool/dist/assets/{index-Bg1kYd8S.css → index-BaiHu9CD.css} +1 -1
- package/backtoschool/dist/assets/index-Dovv39gH.js +112 -0
- package/backtoschool/dist/assets/{it-BbCfCf0u.js → it-C9g0xD6S.js} +1 -1
- package/backtoschool/dist/assets/{mpegts-D6OaCwgB.js → mpegts-GAwbg-pX.js} +1 -1
- package/backtoschool/dist/assets/{mpegts-LO6RVLD6-CmKDQL_J.js → mpegts-LO6RVLD6-BrVQzFhM.js} +1 -1
- package/{web/dist/assets/nl-gqa06B5b.js → backtoschool/dist/assets/nl-Ciq1Yrk4.js} +1 -1
- package/{web/dist/assets/ru-Bm2pudvY.js → backtoschool/dist/assets/ru-B_ZMUvh6.js} +1 -1
- package/backtoschool/dist/assets/{sv-CCLFqo_m.js → sv-DH0ZO3Eh.js} +1 -1
- package/{web/dist/assets/uk-CncP4EX7.js → backtoschool/dist/assets/uk-Cof-TUZd.js} +1 -1
- package/backtoschool/dist/assets/{vi-D6DJBpIt.js → vi-D0QPpzbE.js} +1 -1
- package/{web/dist/assets/zh-CtcojTCQ.js → backtoschool/dist/assets/zh-CLTzTBOU.js} +1 -1
- package/backtoschool/dist/index.html +59 -8
- package/dist/live-api.d.ts +10 -0
- package/dist/live-api.js +8 -0
- package/dist/live-voice.d.ts +3 -0
- package/dist/live-voice.js +27 -4
- package/dist/nixamp-link-api.d.ts +44 -0
- package/dist/nixamp-link-api.js +190 -0
- package/dist/nixamp-link-types.d.ts +33 -0
- package/dist/nixamp-link-types.js +5 -0
- package/dist/nixamp-link.d.ts +66 -0
- package/dist/nixamp-link.js +231 -0
- package/dist/notify.d.ts +6 -4
- package/dist/notify.js +14 -25
- package/dist/oauth-api.d.ts +1 -1
- package/dist/oauth-api.js +27 -9
- package/dist/oauth-server.d.ts +9 -1
- package/dist/oauth-server.js +24 -2
- package/dist/profiles.d.ts +85 -0
- package/dist/profiles.js +0 -0
- package/dist/server.d.ts +12 -2
- package/dist/server.js +205 -13
- package/dist/voice-provider.d.ts +9 -0
- package/dist/voice-provider.js +27 -0
- package/dist/watch-party.d.ts +8 -0
- package/dist/watch-party.js +21 -0
- package/package.json +6 -6
- package/src/live-api.ts +13 -0
- package/src/live-voice.ts +25 -3
- package/src/nixamp-link-api.ts +220 -0
- package/src/nixamp-link-types.ts +36 -0
- package/src/nixamp-link.ts +265 -0
- package/src/notify.ts +19 -34
- package/src/oauth-api.ts +30 -11
- package/src/oauth-server.ts +24 -2
- package/src/profiles.ts +0 -0
- package/src/server.ts +192 -15
- package/src/voice-provider.ts +22 -0
- package/src/watch-party.ts +23 -0
- package/web/dist/assets/{ar-tv7nkpiY.js → ar-BqLaLSMR.js} +1 -1
- package/web/dist/assets/{cs-D9o3fJ2P.js → cs-CfoQJwKM.js} +1 -1
- package/{backtoschool/dist/assets/da-C724NwZt.js → web/dist/assets/da-8MmH23fh.js} +1 -1
- package/{backtoschool/dist/assets/de-DGjw5Y71.js → web/dist/assets/de-Brry3OMh.js} +1 -1
- package/web/dist/assets/{es-DURiUJ4W.js → es-CGxJs7b8.js} +1 -1
- package/web/dist/assets/{fi-D4OG-FzU.js → fi-BDAAZtto.js} +1 -1
- package/web/dist/assets/{fr-CYahUXc4.js → fr-9sxYL0Qy.js} +1 -1
- package/{backtoschool/dist/assets/hi-B33KN-Pt.js → web/dist/assets/hi-Cc7ZsdL7.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-BblrzMwh.js → hls-3VKVEQE3-Dx7qltzA.js} +1 -1
- package/web/dist/assets/{hu-Cu0wj9R4.js → hu-CvB9aIyN.js} +1 -1
- package/web/dist/assets/{id-DbNfLRpW.js → id-Bmrh06Hw.js} +1 -1
- package/web/dist/assets/index-BQuoiKg9.js +3 -0
- package/web/dist/assets/{index-JvFXyU1l.css → index-Bo3utcDw.css} +1 -1
- package/web/dist/assets/{it-DrOc430y.js → it-SbaxFUIP.js} +1 -1
- package/web/dist/assets/{mpegts-D2RCQTRm.js → mpegts-BdwONu8t.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-SH6yVu0A.js → mpegts-LO6RVLD6-CqW3DT1n.js} +1 -1
- package/{backtoschool/dist/assets/nl-CDHaP4Aw.js → web/dist/assets/nl-CqimSo3B.js} +1 -1
- package/{backtoschool/dist/assets/ru-DiZnvRs-.js → web/dist/assets/ru-wIfpMpU9.js} +1 -1
- package/web/dist/assets/{sv-DjYUXKKI.js → sv-BAyIUHP5.js} +1 -1
- package/{backtoschool/dist/assets/uk-BtLBQT-n.js → web/dist/assets/uk-D2jFJm6l.js} +1 -1
- package/web/dist/assets/{vi-B4xhD0GU.js → vi-BdH3SLlR.js} +1 -1
- package/{backtoschool/dist/assets/zh-C7GUPWP3.js → web/dist/assets/zh-5xz5Agt-.js} +1 -1
- package/web/dist/index.html +33 -2
- package/web/dist/sw.js +23 -23
- package/backtoschool/dist/assets/index-CPUjs97F.js +0 -112
- package/web/dist/assets/index-CzW_V6oA.js +0 -3
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { tokenFrom } from "./accounts.js";
|
|
2
|
+
const COOKIE = "nixamp_connect";
|
|
3
|
+
const CALLBACK = "/api/v1/nixamp/callback";
|
|
4
|
+
export function nixampLinkPath(path) {
|
|
5
|
+
return path === "/api/v1/nixamp/connect" || path === CALLBACK || path === "/api/v1/nixamp/connection" || path === "/api/v1/nixamp/streams";
|
|
6
|
+
}
|
|
7
|
+
function json(response, code, body, headers = {}) {
|
|
8
|
+
const value = JSON.stringify(body);
|
|
9
|
+
response.writeHead(code, {
|
|
10
|
+
...headers,
|
|
11
|
+
"content-type": "application/json; charset=utf-8",
|
|
12
|
+
"content-length": Buffer.byteLength(value),
|
|
13
|
+
"cache-control": "no-store",
|
|
14
|
+
});
|
|
15
|
+
response.end(value);
|
|
16
|
+
}
|
|
17
|
+
function cookieValue(headers, name) {
|
|
18
|
+
const raw = headers.cookie ?? "";
|
|
19
|
+
for (const part of raw.split(";")) {
|
|
20
|
+
const [key, ...rest] = part.trim().split("=");
|
|
21
|
+
if (key === name)
|
|
22
|
+
return decodeURIComponent(rest.join("="));
|
|
23
|
+
}
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
function legCookie(value, secure) {
|
|
27
|
+
const parts = [`${COOKIE}=${encodeURIComponent(value)}`, `Path=${CALLBACK}`, "Max-Age=600", "SameSite=Lax", "HttpOnly"];
|
|
28
|
+
if (secure)
|
|
29
|
+
parts.push("Secure");
|
|
30
|
+
return parts.join("; ");
|
|
31
|
+
}
|
|
32
|
+
function clearedLegCookie() {
|
|
33
|
+
return `${COOKIE}=; Path=${CALLBACK}; Max-Age=0; SameSite=Lax; HttpOnly`;
|
|
34
|
+
}
|
|
35
|
+
function landing(home, outcome, reason = "") {
|
|
36
|
+
const url = new URL(home, "http://placeholder.invalid");
|
|
37
|
+
url.searchParams.set("nixamp", outcome);
|
|
38
|
+
if (reason)
|
|
39
|
+
url.searchParams.set("reason", reason.slice(0, 200));
|
|
40
|
+
return `${url.pathname}${url.search}${url.hash || "#settings"}`;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Every server the nixamp account remembers, asked what it is doing.
|
|
44
|
+
*
|
|
45
|
+
* The account's own list comes from nixamp.com with the client token; each
|
|
46
|
+
* server is then asked directly, with the share key the account kept for it,
|
|
47
|
+
* and given four seconds. One that does not answer is listed as out of
|
|
48
|
+
* reach rather than dropped, so a host sees why a stream is missing.
|
|
49
|
+
*/
|
|
50
|
+
export async function streamsFor(token, issuer, pageSite, fetcher) {
|
|
51
|
+
const base = issuer.replace(/\/+$/, "");
|
|
52
|
+
let servers = [];
|
|
53
|
+
const answer = await fetcher(`${base}/api/v1/servers`, {
|
|
54
|
+
headers: { authorization: `Bearer ${token}`, accept: "application/json" },
|
|
55
|
+
signal: AbortSignal.timeout(8000),
|
|
56
|
+
});
|
|
57
|
+
if (!answer.ok)
|
|
58
|
+
throw new Error(answer.status === 401 ? "nixamp.com no longer accepts this connection" : `nixamp.com answered ${answer.status}`);
|
|
59
|
+
const body = (await answer.json().catch(() => ({})));
|
|
60
|
+
servers = Array.isArray(body.servers) ? body.servers : [];
|
|
61
|
+
const page = pageSite.replace(/\/+$/, "");
|
|
62
|
+
const linkTo = (address, play) => `${page}/?url=${encodeURIComponent(address)}&play=${encodeURIComponent(play)}`;
|
|
63
|
+
return Promise.all(servers.map(async (server) => {
|
|
64
|
+
const out = { id: server.id, name: server.name || server.url, url: server.url, reachable: false, playing: false, nowPlaying: "", live: "", channels: [] };
|
|
65
|
+
try {
|
|
66
|
+
const url = new URL("/api/streams", server.url.endsWith("/") ? server.url : `${server.url}/`);
|
|
67
|
+
if (server.key)
|
|
68
|
+
url.searchParams.set("k", server.key);
|
|
69
|
+
const response = await fetcher(url.href, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(4000) });
|
|
70
|
+
if (!response.ok)
|
|
71
|
+
return out;
|
|
72
|
+
const data = (await response.json());
|
|
73
|
+
// What a viewer link is built on: the server's own view-only address
|
|
74
|
+
// when it publishes one, else its plain address. Never the key the
|
|
75
|
+
// account kept, which may open the controls.
|
|
76
|
+
const viewer = data.server?.url && /^https?:\/\//.test(data.server.url) ? data.server.url : server.url;
|
|
77
|
+
out.reachable = true;
|
|
78
|
+
out.name = data.server?.name || out.name;
|
|
79
|
+
out.playing = data.server?.playing === true;
|
|
80
|
+
out.nowPlaying = data.server?.nowPlaying ?? "";
|
|
81
|
+
out.live = out.playing ? linkTo(viewer, "live") : "";
|
|
82
|
+
out.channels = (data.channels ?? []).map((one) => ({
|
|
83
|
+
server: out.name,
|
|
84
|
+
serverUrl: server.url,
|
|
85
|
+
id: one.id,
|
|
86
|
+
name: one.name,
|
|
87
|
+
kind: one.kind ?? "audio",
|
|
88
|
+
listeners: typeof one.listeners === "number" ? one.listeners : 0,
|
|
89
|
+
link: linkTo(viewer, `channel:${one.id}`),
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// Out of reach: said so, above.
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
export async function handleNixampLinkApi(request, response, url, options) {
|
|
99
|
+
const path = url.pathname;
|
|
100
|
+
if (!nixampLinkPath(path))
|
|
101
|
+
return false;
|
|
102
|
+
const home = options.home ?? "/";
|
|
103
|
+
const redirectUri = `${options.site.replace(/\/+$/, "")}${CALLBACK}`;
|
|
104
|
+
const registered = options.redirectUris.includes(redirectUri);
|
|
105
|
+
const account = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
106
|
+
if (account === null) {
|
|
107
|
+
if (path === CALLBACK) {
|
|
108
|
+
response.writeHead(302, { location: landing(home, "failed", "sign in here first, then connect again"), "set-cookie": clearedLegCookie() });
|
|
109
|
+
response.end();
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
json(response, 401, { error: "sign in first" });
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
if (path === "/api/v1/nixamp/connect") {
|
|
116
|
+
if (request.method !== "GET") {
|
|
117
|
+
json(response, 405, { error: "GET only" });
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
if (!registered) {
|
|
121
|
+
json(response, 400, { error: "this site is not registered as a nixamp client" });
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
const leg = options.links.begin(redirectUri);
|
|
125
|
+
response.writeHead(302, { location: leg.url, "set-cookie": legCookie(`${leg.state}.${leg.verifier}`, options.secureCookies ?? false), "cache-control": "no-store" });
|
|
126
|
+
response.end();
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
if (path === CALLBACK) {
|
|
130
|
+
if (request.method !== "GET") {
|
|
131
|
+
json(response, 405, { error: "GET only" });
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
const kept = cookieValue(request.headers, COOKIE);
|
|
135
|
+
const dot = kept.indexOf(".");
|
|
136
|
+
const state = dot > 0 ? kept.slice(0, dot) : "";
|
|
137
|
+
const verifier = dot > 0 ? kept.slice(dot + 1) : "";
|
|
138
|
+
const headers = { "set-cookie": clearedLegCookie(), "cache-control": "no-store" };
|
|
139
|
+
const refused = url.searchParams.get("error");
|
|
140
|
+
if (refused) {
|
|
141
|
+
response.writeHead(302, { ...headers, location: landing(home, refused === "access_denied" ? "denied" : "failed", url.searchParams.get("error_description") ?? refused) });
|
|
142
|
+
response.end();
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
const code = url.searchParams.get("code") ?? "";
|
|
146
|
+
if (state === "" || url.searchParams.get("state") !== state || code === "") {
|
|
147
|
+
response.writeHead(302, { ...headers, location: landing(home, "failed", "that link is not the one this browser started; try again") });
|
|
148
|
+
response.end();
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
await options.links.finish(account.id, { code, verifier, redirectUri });
|
|
153
|
+
response.writeHead(302, { ...headers, location: landing(home, "connected") });
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
response.writeHead(302, { ...headers, location: landing(home, "failed", error.message) });
|
|
157
|
+
}
|
|
158
|
+
response.end();
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
if (path === "/api/v1/nixamp/connection") {
|
|
162
|
+
if (request.method === "GET") {
|
|
163
|
+
json(response, 200, { ...(await options.links.of(account.id)), available: registered });
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
if (request.method === "DELETE") {
|
|
167
|
+
json(response, 200, { ok: true, withdrawn: await options.links.disconnect(account.id) });
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
json(response, 405, { error: "GET or DELETE" });
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
// /api/v1/nixamp/streams
|
|
174
|
+
if (request.method !== "GET") {
|
|
175
|
+
json(response, 405, { error: "GET only" });
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
const token = await options.links.accessToken(account.id);
|
|
179
|
+
if (token === "") {
|
|
180
|
+
json(response, 409, { error: "connect your nixamp account first", connected: false });
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
json(response, 200, { servers: await streamsFor(token, options.issuer, options.issuer, options.fetcher ?? fetch) });
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
json(response, 502, { error: error.message });
|
|
188
|
+
}
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
/** The connection as the account holder sees it. Never the tokens. */
|
|
6
|
+
export interface LinkView {
|
|
7
|
+
connected: boolean;
|
|
8
|
+
handle: string;
|
|
9
|
+
nixampUserId: string;
|
|
10
|
+
scope: string;
|
|
11
|
+
since: number | null;
|
|
12
|
+
}
|
|
13
|
+
/** A stream somebody could pick: a channel on one of their servers, as a link a classroom accepts. */
|
|
14
|
+
export interface StreamPick {
|
|
15
|
+
server: string;
|
|
16
|
+
serverUrl: string;
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
kind: string;
|
|
20
|
+
listeners: number;
|
|
21
|
+
link: string;
|
|
22
|
+
}
|
|
23
|
+
export interface ServerStreams {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
url: string;
|
|
27
|
+
reachable: boolean;
|
|
28
|
+
playing: boolean;
|
|
29
|
+
nowPlaying: string;
|
|
30
|
+
/** A link to what the server itself is playing, when it is. */
|
|
31
|
+
live: string;
|
|
32
|
+
channels: StreamPick[];
|
|
33
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Queryable } from "./follows.ts";
|
|
2
|
+
import type { LinkView } from "./nixamp-link-types.ts";
|
|
3
|
+
export type { LinkView } from "./nixamp-link-types.ts";
|
|
4
|
+
/** What the school asks nixamp for: who you are, your servers, and to keep it. */
|
|
5
|
+
export declare const LINK_SCOPE = "profile streams offline_access";
|
|
6
|
+
export interface TokenGrant {
|
|
7
|
+
access_token: string;
|
|
8
|
+
refresh_token?: string;
|
|
9
|
+
expires_in?: number;
|
|
10
|
+
scope?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface WhoAmI {
|
|
13
|
+
sub: string;
|
|
14
|
+
handle?: string;
|
|
15
|
+
}
|
|
16
|
+
/** The client side of OAuth 2.1, against the issuer, however it is reached. */
|
|
17
|
+
export interface OAuthExchange {
|
|
18
|
+
authorizeUrl(query: Record<string, string>): string;
|
|
19
|
+
/** The token endpoint. Rejects with an Error whose message is fit to show. */
|
|
20
|
+
token(form: URLSearchParams): Promise<TokenGrant>;
|
|
21
|
+
userinfo(accessToken: string): Promise<WhoAmI>;
|
|
22
|
+
revoke(token: string): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
export declare const NOT_CONNECTED: LinkView;
|
|
25
|
+
/** The real thing: nixamp.com over HTTP. */
|
|
26
|
+
export declare function nixampExchange(issuer: string, clientId: string, fetcher?: typeof fetch): OAuthExchange;
|
|
27
|
+
export declare class NixampLinks {
|
|
28
|
+
private readonly db;
|
|
29
|
+
private readonly exchange;
|
|
30
|
+
private readonly clientId;
|
|
31
|
+
private readonly now;
|
|
32
|
+
/** How nixamp.com and the servers it lists are reached, for what the tokens are used on. */
|
|
33
|
+
readonly fetcher: typeof fetch;
|
|
34
|
+
private ready;
|
|
35
|
+
constructor(db: Queryable, exchange: OAuthExchange, clientId: string, now?: () => number,
|
|
36
|
+
/** How nixamp.com and the servers it lists are reached, for what the tokens are used on. */
|
|
37
|
+
fetcher?: typeof fetch);
|
|
38
|
+
private ensure;
|
|
39
|
+
/**
|
|
40
|
+
* The first leg: where to send the browser, and the two secrets the
|
|
41
|
+
* callback must bring back. The state is the CSRF check, the verifier is
|
|
42
|
+
* PKCE; both live in a short cookie on the school's origin, never here.
|
|
43
|
+
*/
|
|
44
|
+
begin(redirectUri: string): {
|
|
45
|
+
url: string;
|
|
46
|
+
state: string;
|
|
47
|
+
verifier: string;
|
|
48
|
+
};
|
|
49
|
+
/** The second leg: the code becomes tokens, the tokens say whose, and that is kept. */
|
|
50
|
+
finish(userId: string, leg: {
|
|
51
|
+
code: string;
|
|
52
|
+
verifier: string;
|
|
53
|
+
redirectUri: string;
|
|
54
|
+
}): Promise<LinkView>;
|
|
55
|
+
private expiry;
|
|
56
|
+
of(userId: string): Promise<LinkView>;
|
|
57
|
+
/**
|
|
58
|
+
* A token good for a call right now, refreshed when the one kept is about
|
|
59
|
+
* to end. "" when there is no connection, or the refresh was refused --
|
|
60
|
+
* which is what a grant withdrawn on nixamp.com looks like from here, and
|
|
61
|
+
* the row is dropped so the settings page says so too.
|
|
62
|
+
*/
|
|
63
|
+
accessToken(userId: string): Promise<string>;
|
|
64
|
+
/** Withdraw the grant on nixamp.com and forget it here. True when there was one. */
|
|
65
|
+
disconnect(userId: string): Promise<boolean>;
|
|
66
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
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 { challengeFor } from "./oauth-server.js";
|
|
23
|
+
const TABLE = "nixamp_links";
|
|
24
|
+
const SCHEMA = `
|
|
25
|
+
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
26
|
+
user_id TEXT PRIMARY KEY,
|
|
27
|
+
nixamp_user_id TEXT NOT NULL,
|
|
28
|
+
handle TEXT NOT NULL DEFAULT '',
|
|
29
|
+
scope TEXT NOT NULL DEFAULT '',
|
|
30
|
+
access_token TEXT NOT NULL DEFAULT '',
|
|
31
|
+
access_expires_at TIMESTAMPTZ,
|
|
32
|
+
refresh_token TEXT NOT NULL DEFAULT '',
|
|
33
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
34
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
35
|
+
);
|
|
36
|
+
`;
|
|
37
|
+
/** What the school asks nixamp for: who you are, your servers, and to keep it. */
|
|
38
|
+
export const LINK_SCOPE = "profile streams offline_access";
|
|
39
|
+
/** How long before an access token's end it is treated as spent. */
|
|
40
|
+
const EARLY_MS = 60_000;
|
|
41
|
+
export const NOT_CONNECTED = { connected: false, handle: "", nixampUserId: "", scope: "", since: null };
|
|
42
|
+
/** The real thing: nixamp.com over HTTP. */
|
|
43
|
+
export function nixampExchange(issuer, clientId, fetcher = fetch) {
|
|
44
|
+
const base = issuer.replace(/\/+$/, "");
|
|
45
|
+
async function post(path, form, accept) {
|
|
46
|
+
let response;
|
|
47
|
+
try {
|
|
48
|
+
response = await fetcher(`${base}${path}`, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
51
|
+
body: form.toString(),
|
|
52
|
+
signal: AbortSignal.timeout(10_000),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
throw new Error("nixamp.com could not be reached");
|
|
57
|
+
}
|
|
58
|
+
const body = (await response.json().catch(() => ({})));
|
|
59
|
+
if (!response.ok && accept === "json") {
|
|
60
|
+
const description = typeof body["error_description"] === "string" ? body["error_description"] : typeof body["error"] === "string" ? body["error"] : `nixamp.com answered ${response.status}`;
|
|
61
|
+
throw new Error(description);
|
|
62
|
+
}
|
|
63
|
+
return body;
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
authorizeUrl(query) {
|
|
67
|
+
const url = new URL(`${base}/api/v1/oauth/authorize`);
|
|
68
|
+
for (const [key, value] of Object.entries(query))
|
|
69
|
+
url.searchParams.set(key, value);
|
|
70
|
+
return url.href;
|
|
71
|
+
},
|
|
72
|
+
async token(form) {
|
|
73
|
+
form.set("client_id", clientId);
|
|
74
|
+
const body = await post("/api/v1/oauth/token", form, "json");
|
|
75
|
+
if (typeof body["access_token"] !== "string" || body["access_token"] === "")
|
|
76
|
+
throw new Error("nixamp.com sent no token");
|
|
77
|
+
return body;
|
|
78
|
+
},
|
|
79
|
+
async userinfo(accessToken) {
|
|
80
|
+
let response;
|
|
81
|
+
try {
|
|
82
|
+
response = await fetcher(`${base}/api/v1/oauth/userinfo`, {
|
|
83
|
+
headers: { authorization: `Bearer ${accessToken}`, accept: "application/json" },
|
|
84
|
+
signal: AbortSignal.timeout(10_000),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
throw new Error("nixamp.com could not be reached");
|
|
89
|
+
}
|
|
90
|
+
if (!response.ok)
|
|
91
|
+
throw new Error("nixamp.com did not say whose token that is");
|
|
92
|
+
const body = (await response.json().catch(() => ({})));
|
|
93
|
+
if (typeof body["sub"] !== "string" || body["sub"] === "")
|
|
94
|
+
throw new Error("nixamp.com did not say whose token that is");
|
|
95
|
+
return { sub: body["sub"], ...(typeof body["handle"] === "string" ? { handle: body["handle"] } : {}) };
|
|
96
|
+
},
|
|
97
|
+
async revoke(token) {
|
|
98
|
+
const form = new URLSearchParams({ token, client_id: clientId });
|
|
99
|
+
await post("/api/v1/oauth/revoke", form, "none").catch(() => undefined);
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function asTime(value) {
|
|
104
|
+
if (value instanceof Date)
|
|
105
|
+
return value.getTime();
|
|
106
|
+
if (typeof value === "string") {
|
|
107
|
+
const at = Date.parse(value);
|
|
108
|
+
return Number.isNaN(at) ? null : at;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
function view(row) {
|
|
113
|
+
if (!row)
|
|
114
|
+
return { ...NOT_CONNECTED };
|
|
115
|
+
return {
|
|
116
|
+
connected: true,
|
|
117
|
+
handle: String(row["handle"] ?? ""),
|
|
118
|
+
nixampUserId: String(row["nixamp_user_id"] ?? ""),
|
|
119
|
+
scope: String(row["scope"] ?? ""),
|
|
120
|
+
since: asTime(row["created_at"]),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
export class NixampLinks {
|
|
124
|
+
db;
|
|
125
|
+
exchange;
|
|
126
|
+
clientId;
|
|
127
|
+
now;
|
|
128
|
+
fetcher;
|
|
129
|
+
ready = null;
|
|
130
|
+
constructor(db, exchange, clientId, now = () => Date.now(),
|
|
131
|
+
/** How nixamp.com and the servers it lists are reached, for what the tokens are used on. */
|
|
132
|
+
fetcher = fetch) {
|
|
133
|
+
this.db = db;
|
|
134
|
+
this.exchange = exchange;
|
|
135
|
+
this.clientId = clientId;
|
|
136
|
+
this.now = now;
|
|
137
|
+
this.fetcher = fetcher;
|
|
138
|
+
}
|
|
139
|
+
async ensure() {
|
|
140
|
+
this.ready ??= this.db.query(SCHEMA).then(() => undefined);
|
|
141
|
+
await this.ready;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* The first leg: where to send the browser, and the two secrets the
|
|
145
|
+
* callback must bring back. The state is the CSRF check, the verifier is
|
|
146
|
+
* PKCE; both live in a short cookie on the school's origin, never here.
|
|
147
|
+
*/
|
|
148
|
+
begin(redirectUri) {
|
|
149
|
+
const state = randomBytes(24).toString("base64url");
|
|
150
|
+
const verifier = randomBytes(48).toString("base64url");
|
|
151
|
+
const url = this.exchange.authorizeUrl({
|
|
152
|
+
response_type: "code",
|
|
153
|
+
client_id: this.clientId,
|
|
154
|
+
redirect_uri: redirectUri,
|
|
155
|
+
scope: LINK_SCOPE,
|
|
156
|
+
state,
|
|
157
|
+
code_challenge: challengeFor(verifier),
|
|
158
|
+
code_challenge_method: "S256",
|
|
159
|
+
});
|
|
160
|
+
return { url, state, verifier };
|
|
161
|
+
}
|
|
162
|
+
/** The second leg: the code becomes tokens, the tokens say whose, and that is kept. */
|
|
163
|
+
async finish(userId, leg) {
|
|
164
|
+
const grant = await this.exchange.token(new URLSearchParams({
|
|
165
|
+
grant_type: "authorization_code",
|
|
166
|
+
code: leg.code,
|
|
167
|
+
code_verifier: leg.verifier,
|
|
168
|
+
redirect_uri: leg.redirectUri,
|
|
169
|
+
}));
|
|
170
|
+
const who = await this.exchange.userinfo(grant.access_token);
|
|
171
|
+
await this.ensure();
|
|
172
|
+
await this.db.query(`INSERT INTO ${TABLE} (user_id, nixamp_user_id, handle, scope, access_token, access_expires_at, refresh_token)
|
|
173
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
174
|
+
ON CONFLICT (user_id) DO UPDATE SET nixamp_user_id = EXCLUDED.nixamp_user_id, handle = EXCLUDED.handle,
|
|
175
|
+
scope = EXCLUDED.scope, access_token = EXCLUDED.access_token, access_expires_at = EXCLUDED.access_expires_at,
|
|
176
|
+
refresh_token = EXCLUDED.refresh_token, updated_at = NOW()`, [userId, who.sub, who.handle ?? "", grant.scope ?? LINK_SCOPE, grant.access_token, this.expiry(grant), grant.refresh_token ?? ""]);
|
|
177
|
+
return this.of(userId);
|
|
178
|
+
}
|
|
179
|
+
expiry(grant) {
|
|
180
|
+
const seconds = typeof grant.expires_in === "number" && grant.expires_in > 0 ? grant.expires_in : 3600;
|
|
181
|
+
return new Date(this.now() + seconds * 1000);
|
|
182
|
+
}
|
|
183
|
+
async of(userId) {
|
|
184
|
+
await this.ensure();
|
|
185
|
+
const { rows } = await this.db.query(`SELECT nixamp_user_id, handle, scope, created_at FROM ${TABLE} WHERE user_id = $1`, [userId]);
|
|
186
|
+
return view(rows[0]);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* A token good for a call right now, refreshed when the one kept is about
|
|
190
|
+
* to end. "" when there is no connection, or the refresh was refused --
|
|
191
|
+
* which is what a grant withdrawn on nixamp.com looks like from here, and
|
|
192
|
+
* the row is dropped so the settings page says so too.
|
|
193
|
+
*/
|
|
194
|
+
async accessToken(userId) {
|
|
195
|
+
await this.ensure();
|
|
196
|
+
const { rows } = await this.db.query(`SELECT access_token, access_expires_at, refresh_token FROM ${TABLE} WHERE user_id = $1`, [userId]);
|
|
197
|
+
const row = rows[0];
|
|
198
|
+
if (!row)
|
|
199
|
+
return "";
|
|
200
|
+
const access = String(row["access_token"] ?? "");
|
|
201
|
+
const until = asTime(row["access_expires_at"]);
|
|
202
|
+
if (access !== "" && until !== null && until - this.now() > EARLY_MS)
|
|
203
|
+
return access;
|
|
204
|
+
const refresh = String(row["refresh_token"] ?? "");
|
|
205
|
+
if (refresh === "")
|
|
206
|
+
return "";
|
|
207
|
+
let grant;
|
|
208
|
+
try {
|
|
209
|
+
grant = await this.exchange.token(new URLSearchParams({ grant_type: "refresh_token", refresh_token: refresh }));
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
await this.db.query(`DELETE FROM ${TABLE} WHERE user_id = $1`, [userId]);
|
|
213
|
+
return "";
|
|
214
|
+
}
|
|
215
|
+
await this.db.query(`UPDATE ${TABLE} SET access_token = $2, access_expires_at = $3, refresh_token = $4, updated_at = NOW() WHERE user_id = $1`, [userId, grant.access_token, this.expiry(grant), grant.refresh_token ?? refresh]);
|
|
216
|
+
return grant.access_token;
|
|
217
|
+
}
|
|
218
|
+
/** Withdraw the grant on nixamp.com and forget it here. True when there was one. */
|
|
219
|
+
async disconnect(userId) {
|
|
220
|
+
await this.ensure();
|
|
221
|
+
const { rows } = await this.db.query(`SELECT refresh_token, access_token FROM ${TABLE} WHERE user_id = $1`, [userId]);
|
|
222
|
+
const row = rows[0];
|
|
223
|
+
if (!row)
|
|
224
|
+
return false;
|
|
225
|
+
const refresh = String(row["refresh_token"] ?? "");
|
|
226
|
+
const access = String(row["access_token"] ?? "");
|
|
227
|
+
await this.exchange.revoke(refresh !== "" ? refresh : access);
|
|
228
|
+
await this.db.query(`DELETE FROM ${TABLE} WHERE user_id = $1`, [userId]);
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
}
|
package/dist/notify.d.ts
CHANGED
|
@@ -67,17 +67,19 @@ export declare function resendEmail({ apiKey, from, backtoschool, reason, fetch,
|
|
|
67
67
|
/**
|
|
68
68
|
* Web push, to a browser, a desktop app or an installed PWA.
|
|
69
69
|
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
70
|
+
* Sent with @profullstack/notifications, which does VAPID and the payload
|
|
71
|
+
* encryption with node:crypto and nothing else, so there is no library to load
|
|
72
|
+
* lazily on the one instance (nixamp.com) that has keys. The keys are the same
|
|
73
|
+
* base64url pair web-push used, so nothing had to be regenerated.
|
|
73
74
|
*
|
|
74
75
|
* 404 and 410 mean the vendor has retired the subscription. That is not a
|
|
75
76
|
* failure to retry; it is a row to delete.
|
|
76
77
|
*/
|
|
77
|
-
export declare function webPush({ publicKey, privateKey, subject, onEvent }: {
|
|
78
|
+
export declare function webPush({ publicKey, privateKey, subject, onEvent, fetch }: {
|
|
78
79
|
publicKey: string;
|
|
79
80
|
privateKey: string;
|
|
80
81
|
/** A mailto: or https: URL identifying us to the push service. */
|
|
81
82
|
subject: string;
|
|
82
83
|
onEvent?: (message: string) => void;
|
|
84
|
+
fetch?: typeof globalThis.fetch;
|
|
83
85
|
}): (target: PushTarget, note: Notification) => Promise<PushResult>;
|
package/dist/notify.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sendPush } from "@profullstack/notifications/server";
|
|
1
2
|
import { mailSender } from "./mail-sender.js";
|
|
2
3
|
/**
|
|
3
4
|
* Tell an audience, on every channel each of them wants.
|
|
@@ -93,37 +94,25 @@ function escapeHtml(value) {
|
|
|
93
94
|
/**
|
|
94
95
|
* Web push, to a browser, a desktop app or an installed PWA.
|
|
95
96
|
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
97
|
+
* Sent with @profullstack/notifications, which does VAPID and the payload
|
|
98
|
+
* encryption with node:crypto and nothing else, so there is no library to load
|
|
99
|
+
* lazily on the one instance (nixamp.com) that has keys. The keys are the same
|
|
100
|
+
* base64url pair web-push used, so nothing had to be regenerated.
|
|
99
101
|
*
|
|
100
102
|
* 404 and 410 mean the vendor has retired the subscription. That is not a
|
|
101
103
|
* failure to retry; it is a row to delete.
|
|
102
104
|
*/
|
|
103
|
-
export function webPush({ publicKey, privateKey, subject, onEvent }) {
|
|
104
|
-
|
|
105
|
-
const load = async () => {
|
|
106
|
-
library ??= import("web-push").then((mod) => {
|
|
107
|
-
const wp = (mod["default"] ?? mod);
|
|
108
|
-
wp.setVapidDetails(subject, publicKey, privateKey);
|
|
109
|
-
return wp;
|
|
110
|
-
});
|
|
111
|
-
return library;
|
|
112
|
-
};
|
|
105
|
+
export function webPush({ publicKey, privateKey, subject, onEvent, fetch }) {
|
|
106
|
+
const keys = { publicKey, privateKey };
|
|
113
107
|
return async (target, note) => {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
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 });
|
|
108
|
+
const result = await sendPush({ endpoint: target.endpoint, keys: { p256dh: target.p256dh, auth: target.auth } }, { title: note.title, body: note.body, url: note.url }, { keys, subject, ttl: 60 * 30, ...(fetch ? { fetch } : {}) });
|
|
109
|
+
if (result.sent)
|
|
117
110
|
return "sent";
|
|
111
|
+
if (result.gone) {
|
|
112
|
+
onEvent?.(` push endpoint retired by the vendor, dropping it`);
|
|
113
|
+
return "gone";
|
|
118
114
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (status === 404 || status === 410) {
|
|
122
|
-
onEvent?.(` push endpoint retired by the vendor, dropping it`);
|
|
123
|
-
return "gone";
|
|
124
|
-
}
|
|
125
|
-
onEvent?.(` push failed: ${status ?? error.message}`);
|
|
126
|
-
return "failed";
|
|
127
|
-
}
|
|
115
|
+
onEvent?.(` push failed: ${result.status ?? result.error}`);
|
|
116
|
+
return "failed";
|
|
128
117
|
};
|
|
129
118
|
}
|
package/dist/oauth-api.d.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*
|
|
14
14
|
* POST /api/v1/watch-parties bridge one, idempotently
|
|
15
15
|
* GET /api/v1/watch-parties the ones you could join
|
|
16
|
-
* GET /api/v1/watch-parties/<code> one, with where playback is
|
|
16
|
+
* GET /api/v1/watch-parties/<code> one, with where playback is (open: the code is the invitation)
|
|
17
17
|
* POST /api/v1/watch-parties/<code>/playback the host moving everybody
|
|
18
18
|
* POST /api/v1/watch-parties/<code>/end the host ending it
|
|
19
19
|
*
|