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.
- package/README.md +6 -1
- package/backtoschool/dist/assets/{ar-CmpEhms8.js → ar-BQPKp0M2.js} +1 -1
- package/backtoschool/dist/assets/{cs-BiiJOLTm.js → cs-DjJnhGNB.js} +1 -1
- package/{web/dist/assets/da-M4GDhrcC.js → backtoschool/dist/assets/da-CbTvkIH7.js} +1 -1
- package/{web/dist/assets/de-D7yI8cd_.js → backtoschool/dist/assets/de-SsCkny26.js} +1 -1
- package/backtoschool/dist/assets/{es-BwQQh_C8.js → es-BBlBPcq1.js} +1 -1
- package/backtoschool/dist/assets/{fi-DKPYMsbo.js → fi-ffo35_4W.js} +1 -1
- package/backtoschool/dist/assets/{fr-DNekMW0h.js → fr-BVvavrk7.js} +1 -1
- package/{web/dist/assets/hi-DO7CO-fI.js → backtoschool/dist/assets/hi-DDy_zxu6.js} +1 -1
- package/{web/dist/assets/hls-3VKVEQE3-BCxdTCEU.js → backtoschool/dist/assets/hls-3VKVEQE3-QZWXCWjR.js} +1 -1
- package/backtoschool/dist/assets/hls-n74Cnh8A.js +42 -0
- package/backtoschool/dist/assets/{hu-gcw9ouWH.js → hu-BoJj7pZa.js} +1 -1
- package/backtoschool/dist/assets/{id-Bz51zxz6.js → id-CXN9gchF.js} +1 -1
- package/backtoschool/dist/assets/index-BaiHu9CD.css +1 -0
- package/backtoschool/dist/assets/index-Dovv39gH.js +112 -0
- package/backtoschool/dist/assets/{it-jO8u4ie7.js → it-C9g0xD6S.js} +1 -1
- package/{web/dist/assets/mpegts-DsEoUEPH.js → backtoschool/dist/assets/mpegts-GAwbg-pX.js} +1 -1
- package/{web/dist/assets/mpegts-LO6RVLD6-CC8jMmPy.js → backtoschool/dist/assets/mpegts-LO6RVLD6-BrVQzFhM.js} +1 -1
- package/backtoschool/dist/assets/native-C7JTKWJH-BUyIoj0P.js +1 -0
- package/{web/dist/assets/nl-BU0Mn-e-.js → backtoschool/dist/assets/nl-Ciq1Yrk4.js} +1 -1
- package/{web/dist/assets/ru-ClqNCOTW.js → backtoschool/dist/assets/ru-B_ZMUvh6.js} +1 -1
- package/backtoschool/dist/assets/{sv-CV2-POKP.js → sv-DH0ZO3Eh.js} +1 -1
- package/{web/dist/assets/uk-K5yQBF0h.js → backtoschool/dist/assets/uk-Cof-TUZd.js} +1 -1
- package/backtoschool/dist/assets/{vi-BDZew-O9.js → vi-D0QPpzbE.js} +1 -1
- package/{web/dist/assets/zh-a2v2x-3e.js → backtoschool/dist/assets/zh-CLTzTBOU.js} +1 -1
- package/backtoschool/dist/index.html +61 -10
- package/dist/classroom.d.ts +1 -1
- package/dist/classroom.js +4 -0
- package/dist/live-api.d.ts +10 -0
- package/dist/live-api.js +8 -0
- package/dist/live-events.js +1 -1
- package/dist/live-voice.d.ts +3 -0
- package/dist/live-voice.js +27 -4
- package/dist/locales/ar.d.ts +15 -0
- package/dist/locales/cs.d.ts +15 -0
- package/dist/locales/da.d.ts +15 -0
- package/dist/locales/de.d.ts +15 -0
- package/dist/locales/en.d.ts +15 -0
- package/dist/locales/es.d.ts +15 -0
- package/dist/locales/fi.d.ts +15 -0
- package/dist/locales/fr.d.ts +15 -0
- package/dist/locales/hi.d.ts +15 -0
- package/dist/locales/hu.d.ts +15 -0
- package/dist/locales/id.d.ts +15 -0
- package/dist/locales/it.d.ts +15 -0
- package/dist/locales/nl.d.ts +15 -0
- package/dist/locales/platforms.d.ts +19 -0
- package/dist/locales/platforms.js +19 -0
- package/dist/locales/ru.d.ts +15 -0
- package/dist/locales/sv.d.ts +15 -0
- package/dist/locales/uk.d.ts +15 -0
- package/dist/locales/vi.d.ts +15 -0
- package/dist/locales/zh.d.ts +15 -0
- 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/classroom.ts +5 -1
- package/src/live-api.ts +13 -0
- package/src/live-events.ts +1 -1
- package/src/live-voice.ts +25 -3
- package/src/locales/platforms.ts +21 -0
- 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-DhdXPO3p.js → ar-BqLaLSMR.js} +1 -1
- package/web/dist/assets/{cs-CoTzYnZt.js → cs-CfoQJwKM.js} +1 -1
- package/{backtoschool/dist/assets/da-DueRd5zz.js → web/dist/assets/da-8MmH23fh.js} +1 -1
- package/{backtoschool/dist/assets/de-DIG6WxUZ.js → web/dist/assets/de-Brry3OMh.js} +1 -1
- package/web/dist/assets/{es-BMGb7XlT.js → es-CGxJs7b8.js} +1 -1
- package/web/dist/assets/{fi-CXNsmIrh.js → fi-BDAAZtto.js} +1 -1
- package/web/dist/assets/{fr-BpvyrESQ.js → fr-9sxYL0Qy.js} +1 -1
- package/{backtoschool/dist/assets/hi-CGlN72JQ.js → web/dist/assets/hi-Cc7ZsdL7.js} +1 -1
- package/web/dist/assets/hls-3VKVEQE3-Dx7qltzA.js +1 -0
- package/web/dist/assets/{hu-CosB3vlS.js → hu-CvB9aIyN.js} +1 -1
- package/web/dist/assets/{id-DNumDlwq.js → id-Bmrh06Hw.js} +1 -1
- package/web/dist/assets/index-BQuoiKg9.js +3 -0
- package/web/dist/assets/{index-jLCo7PIA.css → index-Bo3utcDw.css} +1 -1
- package/web/dist/assets/{it-IJKhyCGw.js → it-SbaxFUIP.js} +1 -1
- package/web/dist/assets/mpegts-BdwONu8t.js +3 -0
- package/web/dist/assets/mpegts-LO6RVLD6-CqW3DT1n.js +1 -0
- package/{backtoschool/dist/assets/nl-CnYXq0K4.js → web/dist/assets/nl-CqimSo3B.js} +1 -1
- package/{backtoschool/dist/assets/ru-BatrUfYu.js → web/dist/assets/ru-wIfpMpU9.js} +1 -1
- package/web/dist/assets/{sv-Df_aXTD6.js → sv-BAyIUHP5.js} +1 -1
- package/{backtoschool/dist/assets/uk-vLtqKpHm.js → web/dist/assets/uk-D2jFJm6l.js} +1 -1
- package/web/dist/assets/{vi-CYfcOUeA.js → vi-BdH3SLlR.js} +1 -1
- package/{backtoschool/dist/assets/zh-C9hBmQKA.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-CP4oTs6H.css +0 -1
- package/backtoschool/dist/assets/index-DDGO_FOw.js +0 -113
- package/web/dist/assets/index-CY3lAwM2.js +0 -3
|
@@ -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
|
*
|
package/dist/oauth-api.js
CHANGED
|
@@ -121,7 +121,7 @@ function partyBody(parties, view, caller) {
|
|
|
121
121
|
party: { ...view.party, positionNow: parties.positionNow(view.party) },
|
|
122
122
|
event: view.event,
|
|
123
123
|
links: parties.links(view.party),
|
|
124
|
-
host: view.event.ownerId === caller.account.id,
|
|
124
|
+
host: caller !== null && view.event.ownerId === caller.account.id,
|
|
125
125
|
};
|
|
126
126
|
}
|
|
127
127
|
// --- the handler ---------------------------------------------------------------
|
|
@@ -282,15 +282,28 @@ export async function handleOAuthApi(request, response, url, options) {
|
|
|
282
282
|
return true;
|
|
283
283
|
}
|
|
284
284
|
const caller = await callerFor(request, options, "parties");
|
|
285
|
-
|
|
285
|
+
const rest = path.slice("/api/v1/watch-parties/".length).split("/");
|
|
286
|
+
const reference = decodeURIComponent(rest[0] ?? "");
|
|
287
|
+
const action = rest[1];
|
|
288
|
+
// Reading one party is open, the way its /live/<slug> page is: the code
|
|
289
|
+
// or the room link IS the invitation, and a person who was handed one
|
|
290
|
+
// on a television or in a terminal has no session on nixamp.com yet.
|
|
291
|
+
// Everything else -- the list, bridging, moving playback, ending --
|
|
292
|
+
// still needs a session or a token with the parties scope.
|
|
293
|
+
const openRead = path !== "/api/v1/watch-parties" && !action && request.method === "GET";
|
|
294
|
+
if (caller === null && !openRead) {
|
|
286
295
|
json(response, 401, { error: "a session, or a token granted the parties scope, is needed here" });
|
|
287
296
|
return true;
|
|
288
297
|
}
|
|
289
298
|
// A client bridges parties under its own origin, so two sites cannot
|
|
290
299
|
// collide on a six-character code; a person acting directly is filed
|
|
291
300
|
// under nixamp itself.
|
|
292
|
-
const origin = caller
|
|
301
|
+
const origin = caller?.clientId || "nixamp";
|
|
293
302
|
if (path === "/api/v1/watch-parties") {
|
|
303
|
+
if (caller === null) {
|
|
304
|
+
json(response, 401, { error: "a session, or a token granted the parties scope, is needed here" });
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
294
307
|
if (request.method === "GET") {
|
|
295
308
|
const wanted = url.searchParams.get("origin");
|
|
296
309
|
const found = await parties.list({
|
|
@@ -324,14 +337,15 @@ export async function handleOAuthApi(request, response, url, options) {
|
|
|
324
337
|
json(response, 405, { error: "GET or POST" });
|
|
325
338
|
return true;
|
|
326
339
|
}
|
|
327
|
-
const rest = path.slice("/api/v1/watch-parties/".length).split("/");
|
|
328
|
-
const reference = decodeURIComponent(rest[0] ?? "");
|
|
329
|
-
const action = rest[1];
|
|
330
340
|
// A party is findable by its code on the origin that bridged it, or by
|
|
331
341
|
// the nixamp slug or room a client was handed, because a nixamp client
|
|
332
|
-
// arriving from a share link has only the latter.
|
|
333
|
-
|
|
334
|
-
|
|
342
|
+
// arriving from a share link has only the latter. A person on
|
|
343
|
+
// nixamp.com with only a code gets the party that code names on
|
|
344
|
+
// whichever site bridged it.
|
|
345
|
+
const view = (await parties.byCode(origin, reference).catch(() => null)) ??
|
|
346
|
+
(await parties.byEvent(reference)) ??
|
|
347
|
+
(caller === null || caller.clientId === "" ? await parties.byAnyCode(reference).catch(() => null) : null);
|
|
348
|
+
if (!view || (caller === null && view.event.visibility === "private")) {
|
|
335
349
|
json(response, 404, { error: "watch party not found" });
|
|
336
350
|
return true;
|
|
337
351
|
}
|
|
@@ -339,6 +353,10 @@ export async function handleOAuthApi(request, response, url, options) {
|
|
|
339
353
|
json(response, 200, partyBody(parties, view, caller));
|
|
340
354
|
return true;
|
|
341
355
|
}
|
|
356
|
+
if (caller === null) {
|
|
357
|
+
json(response, 401, { error: "a session, or a token granted the parties scope, is needed here" });
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
342
360
|
if (action === "playback" && request.method === "POST") {
|
|
343
361
|
let input;
|
|
344
362
|
try {
|
package/dist/oauth-server.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ export declare const SCOPES: {
|
|
|
18
18
|
readonly profile: "who you are on nixamp (your handle)";
|
|
19
19
|
readonly email: "the address on your account";
|
|
20
20
|
readonly parties: "host and join watch parties as you";
|
|
21
|
+
readonly streams: "see the servers you run on nixamp, and what is live on them";
|
|
21
22
|
readonly offline_access: "stay connected without asking again";
|
|
22
23
|
};
|
|
23
24
|
export type Scope = keyof typeof SCOPES;
|
|
@@ -35,7 +36,14 @@ export declare const REFRESH_TTL_MS: number;
|
|
|
35
36
|
*/
|
|
36
37
|
export declare const BITTORRENTED_CLIENT: OAuthClient;
|
|
37
38
|
/**
|
|
38
|
-
*
|
|
39
|
+
* backtoschool.help runs on this codebase and used to tell its teachers that
|
|
40
|
+
* their school identity *was* a nixamp account. It is not, any more: a school
|
|
41
|
+
* account is a school account, and nixamp is the broadcast backend a host may
|
|
42
|
+
* connect -- through this client, like any other site. Public, PKCE only.
|
|
43
|
+
*/
|
|
44
|
+
export declare const BACKTOSCHOOL_CLIENT: OAuthClient;
|
|
45
|
+
/**
|
|
46
|
+
* The registered clients: the built-in ones, plus whatever NIXAMP_OAUTH_CLIENTS
|
|
39
47
|
* names. The variable is a JSON list of `{id, name, redirectUris, secret?,
|
|
40
48
|
* homepage?}`; an entry with the built-in id replaces it, so a staging
|
|
41
49
|
* bittorrented can point the callback somewhere else.
|
package/dist/oauth-server.js
CHANGED
|
@@ -34,6 +34,7 @@ export const SCOPES = {
|
|
|
34
34
|
profile: "who you are on nixamp (your handle)",
|
|
35
35
|
email: "the address on your account",
|
|
36
36
|
parties: "host and join watch parties as you",
|
|
37
|
+
streams: "see the servers you run on nixamp, and what is live on them",
|
|
37
38
|
offline_access: "stay connected without asking again",
|
|
38
39
|
};
|
|
39
40
|
export const SCOPE_NAMES = Object.keys(SCOPES);
|
|
@@ -59,13 +60,32 @@ export const BITTORRENTED_CLIENT = {
|
|
|
59
60
|
],
|
|
60
61
|
};
|
|
61
62
|
/**
|
|
62
|
-
*
|
|
63
|
+
* backtoschool.help runs on this codebase and used to tell its teachers that
|
|
64
|
+
* their school identity *was* a nixamp account. It is not, any more: a school
|
|
65
|
+
* account is a school account, and nixamp is the broadcast backend a host may
|
|
66
|
+
* connect -- through this client, like any other site. Public, PKCE only.
|
|
67
|
+
*/
|
|
68
|
+
export const BACKTOSCHOOL_CLIENT = {
|
|
69
|
+
id: "backtoschool",
|
|
70
|
+
name: "BackToSchool.help",
|
|
71
|
+
homepage: "https://backtoschool.help",
|
|
72
|
+
redirectUris: [
|
|
73
|
+
"https://backtoschool.help/api/v1/nixamp/callback",
|
|
74
|
+
"https://www.backtoschool.help/api/v1/nixamp/callback",
|
|
75
|
+
"http://localhost:5174/api/v1/nixamp/callback",
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* The registered clients: the built-in ones, plus whatever NIXAMP_OAUTH_CLIENTS
|
|
63
80
|
* names. The variable is a JSON list of `{id, name, redirectUris, secret?,
|
|
64
81
|
* homepage?}`; an entry with the built-in id replaces it, so a staging
|
|
65
82
|
* bittorrented can point the callback somewhere else.
|
|
66
83
|
*/
|
|
67
84
|
export function clientsFrom(env) {
|
|
68
|
-
const byId = new Map([
|
|
85
|
+
const byId = new Map([
|
|
86
|
+
[BITTORRENTED_CLIENT.id, BITTORRENTED_CLIENT],
|
|
87
|
+
[BACKTOSCHOOL_CLIENT.id, BACKTOSCHOOL_CLIENT],
|
|
88
|
+
]);
|
|
69
89
|
const raw = env["NIXAMP_OAUTH_CLIENTS"];
|
|
70
90
|
if (raw) {
|
|
71
91
|
let parsed = [];
|
|
@@ -95,6 +115,8 @@ export function clientsFrom(env) {
|
|
|
95
115
|
}
|
|
96
116
|
if (env["NIXAMP_OAUTH_BITTORRENTED"] === "off")
|
|
97
117
|
byId.delete(BITTORRENTED_CLIENT.id);
|
|
118
|
+
if (env["NIXAMP_OAUTH_BACKTOSCHOOL"] === "off")
|
|
119
|
+
byId.delete(BACKTOSCHOOL_CLIENT.id);
|
|
98
120
|
return [...byId.values()];
|
|
99
121
|
}
|
|
100
122
|
/** Loopback is the address; the port is whatever the CLI got (RFC 8252 §7.3). */
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Queryable } from "./follows.ts";
|
|
2
|
+
/** The card as the account holder sees it. */
|
|
3
|
+
export interface Profile {
|
|
4
|
+
name: string;
|
|
5
|
+
homepage: string;
|
|
6
|
+
/** A picture somewhere on the web, when they gave one instead of uploading. */
|
|
7
|
+
avatarUrl: string;
|
|
8
|
+
bio: string;
|
|
9
|
+
/** Set when a photo is stored here; the etag changes with the bytes. */
|
|
10
|
+
photo: string;
|
|
11
|
+
}
|
|
12
|
+
export interface ProfileInput {
|
|
13
|
+
name?: unknown;
|
|
14
|
+
homepage?: unknown;
|
|
15
|
+
avatarUrl?: unknown;
|
|
16
|
+
bio?: unknown;
|
|
17
|
+
}
|
|
18
|
+
/** What a classroom shows about its host. */
|
|
19
|
+
export interface HostCard {
|
|
20
|
+
hostName: string;
|
|
21
|
+
homepageUrl: string;
|
|
22
|
+
avatarUrl: string;
|
|
23
|
+
}
|
|
24
|
+
export declare const EMPTY_PROFILE: Profile;
|
|
25
|
+
/** A photo is a picture, not a document: a megabyte is plenty for a face. */
|
|
26
|
+
export declare const PHOTO_LIMIT: number;
|
|
27
|
+
export declare const NAME_LIMIT = 100;
|
|
28
|
+
export declare const BIO_LIMIT = 1000;
|
|
29
|
+
/**
|
|
30
|
+
* The type is read from the bytes, never from the header: a browser sends
|
|
31
|
+
* whatever it was told, and what is served back must be what the file is.
|
|
32
|
+
*/
|
|
33
|
+
export declare function sniffImage(bytes: Uint8Array): string;
|
|
34
|
+
/**
|
|
35
|
+
* What may be written. Every field is optional and what is not sent is
|
|
36
|
+
* kept; an empty string clears. A link must be https without credentials,
|
|
37
|
+
* the same rule a classroom applies, so a card never fails at class time.
|
|
38
|
+
*/
|
|
39
|
+
export declare function cleanProfile(input: ProfileInput): {
|
|
40
|
+
fields: Partial<Profile>;
|
|
41
|
+
error: string;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* The parts of an OpenProfile.md that say who somebody is: the `#` line is
|
|
45
|
+
* the name, the identity block gives `Avatar` and `Web`, the first prose
|
|
46
|
+
* line is the headline, and the first bullet under `## Photos` stands in
|
|
47
|
+
* for a missing Avatar. Everything else is somebody else's business.
|
|
48
|
+
*/
|
|
49
|
+
export declare function readOpenProfile(markdown: string): Partial<Profile>;
|
|
50
|
+
/** The three things a classroom shows, from the card, with a stored photo served from here. */
|
|
51
|
+
export declare function hostCard(profile: Profile, userId: string, site: string): HostCard;
|
|
52
|
+
export declare class AccountProfiles {
|
|
53
|
+
private readonly db;
|
|
54
|
+
private readonly fetcher;
|
|
55
|
+
private ready;
|
|
56
|
+
constructor(db: Queryable, fetcher?: typeof fetch);
|
|
57
|
+
private ensure;
|
|
58
|
+
of(userId: string): Promise<Profile>;
|
|
59
|
+
/** Write what was sent, keep what was not. */
|
|
60
|
+
set(userId: string, input: ProfileInput): Promise<{
|
|
61
|
+
profile: Profile | null;
|
|
62
|
+
error: string;
|
|
63
|
+
}>;
|
|
64
|
+
/** Keep a photo. The type comes from the bytes; a file that is not a picture is refused. */
|
|
65
|
+
setPhoto(userId: string, bytes: Uint8Array): Promise<{
|
|
66
|
+
profile: Profile | null;
|
|
67
|
+
error: string;
|
|
68
|
+
}>;
|
|
69
|
+
removePhoto(userId: string): Promise<Profile>;
|
|
70
|
+
/** The stored photo, to serve. Null when there is none. */
|
|
71
|
+
photo(userId: string): Promise<{
|
|
72
|
+
bytes: Uint8Array;
|
|
73
|
+
type: string;
|
|
74
|
+
etag: string;
|
|
75
|
+
} | null>;
|
|
76
|
+
/**
|
|
77
|
+
* Fill the card from an OpenProfile.md. What the file says replaces what
|
|
78
|
+
* is here; what the file does not say is kept. A stored photo stays: an
|
|
79
|
+
* Avatar link is only used where no photo was uploaded.
|
|
80
|
+
*/
|
|
81
|
+
importFrom(userId: string, url: string): Promise<{
|
|
82
|
+
profile: Profile | null;
|
|
83
|
+
error: string;
|
|
84
|
+
}>;
|
|
85
|
+
}
|
package/dist/profiles.js
ADDED
|
Binary file
|