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,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
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
}
|
package/src/oauth-api.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
|
*
|
|
@@ -189,12 +189,12 @@ async function callerFor(
|
|
|
189
189
|
return account ? { account, clientId: "", scope: SCOPE_NAMES } : null;
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
function partyBody(parties: WatchParties, view: PartyView, caller: Caller): Record<string, unknown> {
|
|
192
|
+
function partyBody(parties: WatchParties, view: PartyView, caller: Caller | null): Record<string, unknown> {
|
|
193
193
|
return {
|
|
194
194
|
party: { ...view.party, positionNow: parties.positionNow(view.party) },
|
|
195
195
|
event: view.event,
|
|
196
196
|
links: parties.links(view.party),
|
|
197
|
-
host: view.event.ownerId === caller.account.id,
|
|
197
|
+
host: caller !== null && view.event.ownerId === caller.account.id,
|
|
198
198
|
};
|
|
199
199
|
}
|
|
200
200
|
|
|
@@ -381,16 +381,29 @@ export async function handleOAuthApi(
|
|
|
381
381
|
return true;
|
|
382
382
|
}
|
|
383
383
|
const caller = await callerFor(request, options, "parties");
|
|
384
|
-
|
|
384
|
+
const rest = path.slice("/api/v1/watch-parties/".length).split("/");
|
|
385
|
+
const reference = decodeURIComponent(rest[0] ?? "");
|
|
386
|
+
const action = rest[1];
|
|
387
|
+
// Reading one party is open, the way its /live/<slug> page is: the code
|
|
388
|
+
// or the room link IS the invitation, and a person who was handed one
|
|
389
|
+
// on a television or in a terminal has no session on nixamp.com yet.
|
|
390
|
+
// Everything else -- the list, bridging, moving playback, ending --
|
|
391
|
+
// still needs a session or a token with the parties scope.
|
|
392
|
+
const openRead = path !== "/api/v1/watch-parties" && !action && request.method === "GET";
|
|
393
|
+
if (caller === null && !openRead) {
|
|
385
394
|
json(response, 401, { error: "a session, or a token granted the parties scope, is needed here" });
|
|
386
395
|
return true;
|
|
387
396
|
}
|
|
388
397
|
// A client bridges parties under its own origin, so two sites cannot
|
|
389
398
|
// collide on a six-character code; a person acting directly is filed
|
|
390
399
|
// under nixamp itself.
|
|
391
|
-
const origin = caller
|
|
400
|
+
const origin = caller?.clientId || "nixamp";
|
|
392
401
|
|
|
393
402
|
if (path === "/api/v1/watch-parties") {
|
|
403
|
+
if (caller === null) {
|
|
404
|
+
json(response, 401, { error: "a session, or a token granted the parties scope, is needed here" });
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
394
407
|
if (request.method === "GET") {
|
|
395
408
|
const wanted = url.searchParams.get("origin");
|
|
396
409
|
const found = await parties.list({
|
|
@@ -424,14 +437,16 @@ export async function handleOAuthApi(
|
|
|
424
437
|
return true;
|
|
425
438
|
}
|
|
426
439
|
|
|
427
|
-
const rest = path.slice("/api/v1/watch-parties/".length).split("/");
|
|
428
|
-
const reference = decodeURIComponent(rest[0] ?? "");
|
|
429
|
-
const action = rest[1];
|
|
430
440
|
// A party is findable by its code on the origin that bridged it, or by
|
|
431
441
|
// the nixamp slug or room a client was handed, because a nixamp client
|
|
432
|
-
// arriving from a share link has only the latter.
|
|
433
|
-
|
|
434
|
-
|
|
442
|
+
// arriving from a share link has only the latter. A person on
|
|
443
|
+
// nixamp.com with only a code gets the party that code names on
|
|
444
|
+
// whichever site bridged it.
|
|
445
|
+
const view =
|
|
446
|
+
(await parties.byCode(origin, reference).catch(() => null)) ??
|
|
447
|
+
(await parties.byEvent(reference)) ??
|
|
448
|
+
(caller === null || caller.clientId === "" ? await parties.byAnyCode(reference).catch(() => null) : null);
|
|
449
|
+
if (!view || (caller === null && view.event.visibility === "private")) {
|
|
435
450
|
json(response, 404, { error: "watch party not found" });
|
|
436
451
|
return true;
|
|
437
452
|
}
|
|
@@ -440,6 +455,10 @@ export async function handleOAuthApi(
|
|
|
440
455
|
json(response, 200, partyBody(parties, view, caller));
|
|
441
456
|
return true;
|
|
442
457
|
}
|
|
458
|
+
if (caller === null) {
|
|
459
|
+
json(response, 401, { error: "a session, or a token granted the parties scope, is needed here" });
|
|
460
|
+
return true;
|
|
461
|
+
}
|
|
443
462
|
if (action === "playback" && request.method === "POST") {
|
|
444
463
|
let input: Record<string, unknown>;
|
|
445
464
|
try {
|
package/src/oauth-server.ts
CHANGED
|
@@ -52,6 +52,7 @@ export const SCOPES = {
|
|
|
52
52
|
profile: "who you are on nixamp (your handle)",
|
|
53
53
|
email: "the address on your account",
|
|
54
54
|
parties: "host and join watch parties as you",
|
|
55
|
+
streams: "see the servers you run on nixamp, and what is live on them",
|
|
55
56
|
offline_access: "stay connected without asking again",
|
|
56
57
|
} as const;
|
|
57
58
|
|
|
@@ -83,13 +84,33 @@ export const BITTORRENTED_CLIENT: OAuthClient = {
|
|
|
83
84
|
};
|
|
84
85
|
|
|
85
86
|
/**
|
|
86
|
-
*
|
|
87
|
+
* backtoschool.help runs on this codebase and used to tell its teachers that
|
|
88
|
+
* their school identity *was* a nixamp account. It is not, any more: a school
|
|
89
|
+
* account is a school account, and nixamp is the broadcast backend a host may
|
|
90
|
+
* connect -- through this client, like any other site. Public, PKCE only.
|
|
91
|
+
*/
|
|
92
|
+
export const BACKTOSCHOOL_CLIENT: OAuthClient = {
|
|
93
|
+
id: "backtoschool",
|
|
94
|
+
name: "BackToSchool.help",
|
|
95
|
+
homepage: "https://backtoschool.help",
|
|
96
|
+
redirectUris: [
|
|
97
|
+
"https://backtoschool.help/api/v1/nixamp/callback",
|
|
98
|
+
"https://www.backtoschool.help/api/v1/nixamp/callback",
|
|
99
|
+
"http://localhost:5174/api/v1/nixamp/callback",
|
|
100
|
+
],
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The registered clients: the built-in ones, plus whatever NIXAMP_OAUTH_CLIENTS
|
|
87
105
|
* names. The variable is a JSON list of `{id, name, redirectUris, secret?,
|
|
88
106
|
* homepage?}`; an entry with the built-in id replaces it, so a staging
|
|
89
107
|
* bittorrented can point the callback somewhere else.
|
|
90
108
|
*/
|
|
91
109
|
export function clientsFrom(env: Record<string, string | undefined>): OAuthClient[] {
|
|
92
|
-
const byId = new Map<string, OAuthClient>([
|
|
110
|
+
const byId = new Map<string, OAuthClient>([
|
|
111
|
+
[BITTORRENTED_CLIENT.id, BITTORRENTED_CLIENT],
|
|
112
|
+
[BACKTOSCHOOL_CLIENT.id, BACKTOSCHOOL_CLIENT],
|
|
113
|
+
]);
|
|
93
114
|
const raw = env["NIXAMP_OAUTH_CLIENTS"];
|
|
94
115
|
if (raw) {
|
|
95
116
|
let parsed: unknown = [];
|
|
@@ -116,6 +137,7 @@ export function clientsFrom(env: Record<string, string | undefined>): OAuthClien
|
|
|
116
137
|
}
|
|
117
138
|
}
|
|
118
139
|
if (env["NIXAMP_OAUTH_BITTORRENTED"] === "off") byId.delete(BITTORRENTED_CLIENT.id);
|
|
140
|
+
if (env["NIXAMP_OAUTH_BACKTOSCHOOL"] === "off") byId.delete(BACKTOSCHOOL_CLIENT.id);
|
|
119
141
|
return [...byId.values()];
|
|
120
142
|
}
|
|
121
143
|
|
package/src/profiles.ts
ADDED
|
Binary file
|