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.
Files changed (87) hide show
  1. package/README.md +6 -1
  2. package/backtoschool/dist/assets/{ar-BJrZUvbj.js → ar-BQPKp0M2.js} +1 -1
  3. package/backtoschool/dist/assets/{cs-DlOUfUxw.js → cs-DjJnhGNB.js} +1 -1
  4. package/{web/dist/assets/da-qufG3QK3.js → backtoschool/dist/assets/da-CbTvkIH7.js} +1 -1
  5. package/{web/dist/assets/de-BddWhvFB.js → backtoschool/dist/assets/de-SsCkny26.js} +1 -1
  6. package/backtoschool/dist/assets/{es-DezARIfJ.js → es-BBlBPcq1.js} +1 -1
  7. package/backtoschool/dist/assets/{fi-B_zd8oyM.js → fi-ffo35_4W.js} +1 -1
  8. package/backtoschool/dist/assets/{fr-CMtbCk6s.js → fr-BVvavrk7.js} +1 -1
  9. package/{web/dist/assets/hi-BMTJ6Zea.js → backtoschool/dist/assets/hi-DDy_zxu6.js} +1 -1
  10. package/backtoschool/dist/assets/{hls-3VKVEQE3-BFn8NctW.js → hls-3VKVEQE3-QZWXCWjR.js} +1 -1
  11. package/backtoschool/dist/assets/{hu-DDqnEZUk.js → hu-BoJj7pZa.js} +1 -1
  12. package/backtoschool/dist/assets/{id-GE_XdNH1.js → id-CXN9gchF.js} +1 -1
  13. package/backtoschool/dist/assets/{index-Bg1kYd8S.css → index-BaiHu9CD.css} +1 -1
  14. package/backtoschool/dist/assets/index-Dovv39gH.js +112 -0
  15. package/backtoschool/dist/assets/{it-BbCfCf0u.js → it-C9g0xD6S.js} +1 -1
  16. package/backtoschool/dist/assets/{mpegts-D6OaCwgB.js → mpegts-GAwbg-pX.js} +1 -1
  17. package/backtoschool/dist/assets/{mpegts-LO6RVLD6-CmKDQL_J.js → mpegts-LO6RVLD6-BrVQzFhM.js} +1 -1
  18. package/{web/dist/assets/nl-gqa06B5b.js → backtoschool/dist/assets/nl-Ciq1Yrk4.js} +1 -1
  19. package/{web/dist/assets/ru-Bm2pudvY.js → backtoschool/dist/assets/ru-B_ZMUvh6.js} +1 -1
  20. package/backtoschool/dist/assets/{sv-CCLFqo_m.js → sv-DH0ZO3Eh.js} +1 -1
  21. package/{web/dist/assets/uk-CncP4EX7.js → backtoschool/dist/assets/uk-Cof-TUZd.js} +1 -1
  22. package/backtoschool/dist/assets/{vi-D6DJBpIt.js → vi-D0QPpzbE.js} +1 -1
  23. package/{web/dist/assets/zh-CtcojTCQ.js → backtoschool/dist/assets/zh-CLTzTBOU.js} +1 -1
  24. package/backtoschool/dist/index.html +59 -8
  25. package/dist/live-api.d.ts +10 -0
  26. package/dist/live-api.js +8 -0
  27. package/dist/live-voice.d.ts +3 -0
  28. package/dist/live-voice.js +27 -4
  29. package/dist/nixamp-link-api.d.ts +44 -0
  30. package/dist/nixamp-link-api.js +190 -0
  31. package/dist/nixamp-link-types.d.ts +33 -0
  32. package/dist/nixamp-link-types.js +5 -0
  33. package/dist/nixamp-link.d.ts +66 -0
  34. package/dist/nixamp-link.js +231 -0
  35. package/dist/notify.d.ts +6 -4
  36. package/dist/notify.js +14 -25
  37. package/dist/oauth-api.d.ts +1 -1
  38. package/dist/oauth-api.js +27 -9
  39. package/dist/oauth-server.d.ts +9 -1
  40. package/dist/oauth-server.js +24 -2
  41. package/dist/profiles.d.ts +85 -0
  42. package/dist/profiles.js +0 -0
  43. package/dist/server.d.ts +12 -2
  44. package/dist/server.js +205 -13
  45. package/dist/voice-provider.d.ts +9 -0
  46. package/dist/voice-provider.js +27 -0
  47. package/dist/watch-party.d.ts +8 -0
  48. package/dist/watch-party.js +21 -0
  49. package/package.json +6 -6
  50. package/src/live-api.ts +13 -0
  51. package/src/live-voice.ts +25 -3
  52. package/src/nixamp-link-api.ts +220 -0
  53. package/src/nixamp-link-types.ts +36 -0
  54. package/src/nixamp-link.ts +265 -0
  55. package/src/notify.ts +19 -34
  56. package/src/oauth-api.ts +30 -11
  57. package/src/oauth-server.ts +24 -2
  58. package/src/profiles.ts +0 -0
  59. package/src/server.ts +192 -15
  60. package/src/voice-provider.ts +22 -0
  61. package/src/watch-party.ts +23 -0
  62. package/web/dist/assets/{ar-tv7nkpiY.js → ar-BqLaLSMR.js} +1 -1
  63. package/web/dist/assets/{cs-D9o3fJ2P.js → cs-CfoQJwKM.js} +1 -1
  64. package/{backtoschool/dist/assets/da-C724NwZt.js → web/dist/assets/da-8MmH23fh.js} +1 -1
  65. package/{backtoschool/dist/assets/de-DGjw5Y71.js → web/dist/assets/de-Brry3OMh.js} +1 -1
  66. package/web/dist/assets/{es-DURiUJ4W.js → es-CGxJs7b8.js} +1 -1
  67. package/web/dist/assets/{fi-D4OG-FzU.js → fi-BDAAZtto.js} +1 -1
  68. package/web/dist/assets/{fr-CYahUXc4.js → fr-9sxYL0Qy.js} +1 -1
  69. package/{backtoschool/dist/assets/hi-B33KN-Pt.js → web/dist/assets/hi-Cc7ZsdL7.js} +1 -1
  70. package/web/dist/assets/{hls-3VKVEQE3-BblrzMwh.js → hls-3VKVEQE3-Dx7qltzA.js} +1 -1
  71. package/web/dist/assets/{hu-Cu0wj9R4.js → hu-CvB9aIyN.js} +1 -1
  72. package/web/dist/assets/{id-DbNfLRpW.js → id-Bmrh06Hw.js} +1 -1
  73. package/web/dist/assets/index-BQuoiKg9.js +3 -0
  74. package/web/dist/assets/{index-JvFXyU1l.css → index-Bo3utcDw.css} +1 -1
  75. package/web/dist/assets/{it-DrOc430y.js → it-SbaxFUIP.js} +1 -1
  76. package/web/dist/assets/{mpegts-D2RCQTRm.js → mpegts-BdwONu8t.js} +1 -1
  77. package/web/dist/assets/{mpegts-LO6RVLD6-SH6yVu0A.js → mpegts-LO6RVLD6-CqW3DT1n.js} +1 -1
  78. package/{backtoschool/dist/assets/nl-CDHaP4Aw.js → web/dist/assets/nl-CqimSo3B.js} +1 -1
  79. package/{backtoschool/dist/assets/ru-DiZnvRs-.js → web/dist/assets/ru-wIfpMpU9.js} +1 -1
  80. package/web/dist/assets/{sv-DjYUXKKI.js → sv-BAyIUHP5.js} +1 -1
  81. package/{backtoschool/dist/assets/uk-BtLBQT-n.js → web/dist/assets/uk-D2jFJm6l.js} +1 -1
  82. package/web/dist/assets/{vi-B4xhD0GU.js → vi-BdH3SLlR.js} +1 -1
  83. package/{backtoschool/dist/assets/zh-C7GUPWP3.js → web/dist/assets/zh-5xz5Agt-.js} +1 -1
  84. package/web/dist/index.html +33 -2
  85. package/web/dist/sw.js +23 -23
  86. package/backtoschool/dist/assets/index-CPUjs97F.js +0 -112
  87. 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
- * 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
  }
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
- if (caller === null) {
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.clientId || "nixamp";
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
- const view = (await parties.byCode(origin, reference).catch(() => null)) ?? (await parties.byEvent(reference));
434
- if (!view) {
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 {
@@ -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
- * The registered clients: the built-in one, plus whatever NIXAMP_OAUTH_CLIENTS
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>([[BITTORRENTED_CLIENT.id, BITTORRENTED_CLIENT]]);
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
 
Binary file