pi-roundtable-web 0.22.5 → 0.23.0
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/CHANGELOG.md +41 -1
- package/README.md +427 -104
- package/assets/icons/192.png +0 -0
- package/assets/icons/512.png +0 -0
- package/assets/icons/apple-180.png +0 -0
- package/assets/icons/badge-96.png +0 -0
- package/assets/icons/mark-maskable.svg +1 -0
- package/assets/icons/mark.svg +1 -0
- package/assets/icons/maskable-512.png +0 -0
- package/dist/chunk-character-creator-6vdshaj9.js +1 -0
- package/dist/chunk-connectors-s33pc7b8.js +1 -0
- package/dist/chunk-conversations-zpztvesk.js +1 -0
- package/dist/chunk-entry-th44mrfe.js +1 -0
- package/dist/chunk-index-4w1rf52n.js +47 -0
- package/dist/chunk-index-5pnpf9v5.js +41 -0
- package/dist/chunk-index-dkvf85c7.css +1 -0
- package/dist/chunk-index-kkydgsrs.js +2 -0
- package/dist/chunk-index-mtcervy3.js +2 -0
- package/dist/chunk-index-x3cf4dsz.js +1 -0
- package/dist/chunk-notes-ytw8qcfk.js +1 -0
- package/dist/chunk-overview-fbea32dq.js +1 -0
- package/dist/chunk-skills-58f4qz3b.js +1 -0
- package/dist/chunk-table-ypqdgf66.js +1 -0
- package/dist/chunk-table2d-bk1khqzq.js +1 -0
- package/dist/chunk-table3d-5vpp4bzh.js +4229 -0
- package/dist/chunk-transcript-nabrrayp.js +1 -0
- package/dist/index.html +6 -2
- package/dist/sw.js +1 -0
- package/package.json +8 -3
- package/src/api-types.ts +448 -1
- package/src/app/app.ts +351 -0
- package/src/app/card-log.ts +119 -0
- package/src/app/desk.ts +533 -0
- package/src/app/files.ts +262 -0
- package/src/app/frames.ts +132 -0
- package/src/app/multipart.ts +172 -0
- package/src/app/presence.ts +73 -0
- package/src/app/protocol.ts +144 -0
- package/src/app/room-log.ts +137 -0
- package/src/app/rooms.ts +189 -0
- package/src/app/routes.ts +434 -0
- package/src/app/socket.ts +406 -0
- package/src/app/sockets.ts +84 -0
- package/src/app/surface-threads.ts +92 -0
- package/src/app/surface.ts +384 -0
- package/src/app/threads.ts +99 -0
- package/src/app/uploads.ts +363 -0
- package/src/assets.ts +1 -3
- package/src/auth/authenticator.ts +167 -0
- package/src/auth/login-page.ts +44 -0
- package/src/auth/none.ts +38 -0
- package/src/auth/password.ts +250 -0
- package/src/auth/session-key.ts +65 -0
- package/src/cleanup-api.ts +1 -1
- package/src/cloudflare-access.ts +5 -2
- package/src/console-api.ts +13 -15
- package/src/console-server.ts +77 -49
- package/src/http.ts +8 -0
- package/src/index.ts +1 -1
- package/src/options.ts +308 -12
- package/src/page-colors.ts +9 -0
- package/src/page-presentation.ts +17 -12
- package/src/push/api.ts +190 -0
- package/src/push/channel.ts +50 -0
- package/src/push/encrypt.ts +136 -0
- package/src/push/endpoint.ts +54 -0
- package/src/push/sender.ts +109 -0
- package/src/push/subscriptions.ts +140 -0
- package/src/push/test-fetch.ts +17 -0
- package/src/push/triggers.ts +155 -0
- package/src/push/vapid.ts +124 -0
- package/src/pwa.ts +130 -0
- package/src/request-body.ts +20 -0
- package/src/verifier.ts +7 -1
- package/src/web-plugin.ts +123 -11
- package/dist/chunk-82frzte9.js +0 -36
- package/dist/chunk-e8z9q554.css +0 -1
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import type { ActorFacts, Logger } from "pi-roundtable";
|
|
3
|
+
import { PluginError } from "pi-roundtable";
|
|
4
|
+
import { type ConsolePresentation, message } from "../features.ts";
|
|
5
|
+
import { MIN_PASSWORD_LENGTH } from "../options.ts";
|
|
6
|
+
import { readLimited } from "../request-body.ts";
|
|
7
|
+
import { LOGIN_CSP, loginPage } from "./login-page.ts";
|
|
8
|
+
|
|
9
|
+
/** The session cookie's name. */
|
|
10
|
+
const SESSION_COOKIE = "rt_session";
|
|
11
|
+
/** How many failed logins in the window lock the login page. */
|
|
12
|
+
const MAX_FAILURES = 10;
|
|
13
|
+
const WINDOW_MS = 15 * 60 * 1000;
|
|
14
|
+
/** A login form is a password; anything bigger is not one. */
|
|
15
|
+
const MAX_BODY_BYTES = 4096;
|
|
16
|
+
|
|
17
|
+
export interface PasswordOptions {
|
|
18
|
+
/** The secret, already read from the environment and checked. */
|
|
19
|
+
password: string;
|
|
20
|
+
/** The session key from the data directory. */
|
|
21
|
+
key: Buffer;
|
|
22
|
+
/** Who a session speaks for: the plugin identity's subject and name. */
|
|
23
|
+
actor: ActorFacts;
|
|
24
|
+
mount: string;
|
|
25
|
+
origin: string;
|
|
26
|
+
sessionDays: number;
|
|
27
|
+
title: string;
|
|
28
|
+
presentation: ConsolePresentation | undefined;
|
|
29
|
+
logger: Logger;
|
|
30
|
+
/** How long each failed login waits before answering; default 1000. */
|
|
31
|
+
loginDelayMs: number;
|
|
32
|
+
now: () => number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A valid session: when it ends. */
|
|
36
|
+
export type SessionCheck =
|
|
37
|
+
| { valid: true; expiresAt: Date }
|
|
38
|
+
| { valid: false; reason: string };
|
|
39
|
+
|
|
40
|
+
const sha256 = (value: string | Buffer) =>
|
|
41
|
+
createHash("sha256").update(value).digest();
|
|
42
|
+
|
|
43
|
+
const sign = (key: Buffer, payload: string) =>
|
|
44
|
+
createHmac("sha256", key).update(payload).digest();
|
|
45
|
+
|
|
46
|
+
/** Whether two buffers are the same, in time that does not depend on where they differ. */
|
|
47
|
+
const same = (a: Buffer, b: Buffer) =>
|
|
48
|
+
a.length === b.length && timingSafeEqual(a, b);
|
|
49
|
+
|
|
50
|
+
/** Whether a cookie part is the one base64url spelling of its bytes; unused trailing bits would let several spellings pass. */
|
|
51
|
+
const canonical = (part: string) =>
|
|
52
|
+
Buffer.from(part, "base64url").toString("base64url") === part;
|
|
53
|
+
|
|
54
|
+
/** Checks the secret's length and returns it; the message never holds it. */
|
|
55
|
+
export function checkPassword(
|
|
56
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
57
|
+
name: string,
|
|
58
|
+
): string {
|
|
59
|
+
const value = env[name];
|
|
60
|
+
if (value === undefined || value === "")
|
|
61
|
+
throw new PluginError(
|
|
62
|
+
`pi-roundtable-web: auth password reads the password from the environment variable ${name}, which is not set. Set it to a secret of at least ${MIN_PASSWORD_LENGTH} characters`,
|
|
63
|
+
);
|
|
64
|
+
if (Array.from(value).length < MIN_PASSWORD_LENGTH)
|
|
65
|
+
throw new PluginError(
|
|
66
|
+
`pi-roundtable-web: the password in ${name} is shorter than ${MIN_PASSWORD_LENGTH} characters; use at least ${MIN_PASSWORD_LENGTH}`,
|
|
67
|
+
);
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const cookieValue = (
|
|
72
|
+
header: string | null,
|
|
73
|
+
name: string,
|
|
74
|
+
): string | undefined => {
|
|
75
|
+
for (const part of (header ?? "").split(";")) {
|
|
76
|
+
const at = part.indexOf("=");
|
|
77
|
+
if (at > 0 && part.slice(0, at).trim() === name)
|
|
78
|
+
return part.slice(at + 1).trim();
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** The body of a login form, or undefined when it is larger than a password can be. */
|
|
84
|
+
async function readForm(
|
|
85
|
+
request: Request,
|
|
86
|
+
): Promise<URLSearchParams | undefined> {
|
|
87
|
+
const raw = await readLimited(request, MAX_BODY_BYTES);
|
|
88
|
+
return raw && new URLSearchParams(raw.toString("utf8"));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The password credential: a login page, a signed session cookie, a global rate limit.
|
|
93
|
+
*
|
|
94
|
+
* The cookie is `b64url({p, e, v: 1}).b64url(HMAC-SHA256)`. Its key is the HMAC of the session key
|
|
95
|
+
* over the password's digest, so a new password logs everyone out, and the session key alone
|
|
96
|
+
* (a file in the data directory) signs nothing a host with another password would accept.
|
|
97
|
+
*/
|
|
98
|
+
export class PasswordCredential {
|
|
99
|
+
readonly #options: PasswordOptions;
|
|
100
|
+
readonly #secret: Buffer;
|
|
101
|
+
readonly #signing: Buffer;
|
|
102
|
+
readonly #failures: number[] = [];
|
|
103
|
+
readonly #secure: boolean;
|
|
104
|
+
readonly #path: string;
|
|
105
|
+
|
|
106
|
+
constructor(options: PasswordOptions) {
|
|
107
|
+
this.#options = options;
|
|
108
|
+
this.#secret = sha256(options.password);
|
|
109
|
+
this.#signing = sign(options.key, this.#secret.toString("base64url"));
|
|
110
|
+
this.#secure = options.origin.startsWith("https:");
|
|
111
|
+
this.#path = `${options.mount}/`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Whether the request carries a session this host made and that has not ended. */
|
|
115
|
+
check(request: Request): SessionCheck {
|
|
116
|
+
const value = cookieValue(request.headers.get("cookie"), SESSION_COOKIE);
|
|
117
|
+
if (!value) return { valid: false, reason: "no session" };
|
|
118
|
+
const parts = value.split(".");
|
|
119
|
+
const [payload, signature] = parts;
|
|
120
|
+
if (
|
|
121
|
+
parts.length !== 2 ||
|
|
122
|
+
!payload ||
|
|
123
|
+
!signature ||
|
|
124
|
+
!canonical(payload) ||
|
|
125
|
+
!canonical(signature)
|
|
126
|
+
)
|
|
127
|
+
return { valid: false, reason: "malformed session" };
|
|
128
|
+
if (
|
|
129
|
+
!same(sign(this.#signing, payload), Buffer.from(signature, "base64url"))
|
|
130
|
+
)
|
|
131
|
+
return { valid: false, reason: "invalid session" };
|
|
132
|
+
let claims: { p?: unknown; e?: unknown; v?: unknown };
|
|
133
|
+
try {
|
|
134
|
+
claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
135
|
+
} catch {
|
|
136
|
+
return { valid: false, reason: "invalid session" };
|
|
137
|
+
}
|
|
138
|
+
if (
|
|
139
|
+
claims.v !== 1 ||
|
|
140
|
+
claims.p !== this.#options.actor.subject ||
|
|
141
|
+
typeof claims.e !== "number"
|
|
142
|
+
)
|
|
143
|
+
return { valid: false, reason: "invalid session" };
|
|
144
|
+
if (claims.e * 1000 <= this.#options.now())
|
|
145
|
+
return { valid: false, reason: "session ended" };
|
|
146
|
+
return { valid: true, expiresAt: new Date(claims.e * 1000) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The routes that need no session below the mount: the login page, `auth/login` and
|
|
151
|
+
* `auth/logout`. Undefined for any other path.
|
|
152
|
+
*/
|
|
153
|
+
async open(request: Request, rest: string): Promise<Response | undefined> {
|
|
154
|
+
if (rest === "login")
|
|
155
|
+
return request.method === "GET" || request.method === "HEAD"
|
|
156
|
+
? this.#page(200)
|
|
157
|
+
: new Response("Method not allowed", { status: 405 });
|
|
158
|
+
if (rest !== "auth/login" && rest !== "auth/logout") return undefined;
|
|
159
|
+
if (request.method !== "POST")
|
|
160
|
+
return new Response("Method not allowed", { status: 405 });
|
|
161
|
+
if (request.headers.get("origin") !== this.#options.origin)
|
|
162
|
+
return new Response("Forbidden", {
|
|
163
|
+
status: 403,
|
|
164
|
+
headers: { "cache-control": "no-store" },
|
|
165
|
+
});
|
|
166
|
+
return rest === "auth/login" ? this.#login(request) : this.#logout();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
#page(
|
|
170
|
+
status: number,
|
|
171
|
+
note?: string,
|
|
172
|
+
headers: Record<string, string> = {},
|
|
173
|
+
): Response {
|
|
174
|
+
const { mount, title, presentation } = this.#options;
|
|
175
|
+
return new Response(loginPage(mount, title, presentation, note), {
|
|
176
|
+
status,
|
|
177
|
+
headers: {
|
|
178
|
+
"content-type": "text/html;charset=utf-8",
|
|
179
|
+
"cache-control": "no-store",
|
|
180
|
+
"content-security-policy": LOGIN_CSP,
|
|
181
|
+
...headers,
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
#redirect(to: string, cookie: string): Response {
|
|
187
|
+
return new Response(null, {
|
|
188
|
+
status: 303,
|
|
189
|
+
headers: {
|
|
190
|
+
location: to,
|
|
191
|
+
"set-cookie": cookie,
|
|
192
|
+
"cache-control": "no-store",
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
#cookie(value: string, maxAge: number): string {
|
|
198
|
+
return `${SESSION_COOKIE}=${value}; Path=${this.#path}; Max-Age=${maxAge}; HttpOnly; SameSite=Strict${this.#secure ? "; Secure" : ""}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Seconds until the login page opens again, or 0 when it is open. */
|
|
202
|
+
#lockedFor(): number {
|
|
203
|
+
const cutoff = this.#options.now() - WINDOW_MS;
|
|
204
|
+
while (this.#failures.length > 0 && (this.#failures[0] ?? 0) <= cutoff)
|
|
205
|
+
this.#failures.shift();
|
|
206
|
+
if (this.#failures.length < MAX_FAILURES) return 0;
|
|
207
|
+
return Math.max(
|
|
208
|
+
1,
|
|
209
|
+
Math.ceil(
|
|
210
|
+
((this.#failures[0] ?? 0) + WINDOW_MS - this.#options.now()) / 1000,
|
|
211
|
+
),
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async #login(request: Request): Promise<Response> {
|
|
216
|
+
const { logger, presentation } = this.#options;
|
|
217
|
+
const form = await readForm(request);
|
|
218
|
+
if (!form) return new Response("Payload too large", { status: 413 });
|
|
219
|
+
const retry = this.#lockedFor();
|
|
220
|
+
if (retry > 0) {
|
|
221
|
+
logger.warn({ retryAfter: retry }, "web-console: login is locked");
|
|
222
|
+
return this.#page(
|
|
223
|
+
429,
|
|
224
|
+
message(presentation, "Too many failed attempts. Try again later."),
|
|
225
|
+
{ "retry-after": String(retry) },
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
const given = form.get("password") ?? "";
|
|
229
|
+
if (!same(sha256(given), this.#secret)) {
|
|
230
|
+
// Counted before the wait, so a burst of attempts at once cannot get past the limit.
|
|
231
|
+
this.#failures.push(this.#options.now());
|
|
232
|
+
logger.warn("web-console: a login failed");
|
|
233
|
+
if (this.#options.loginDelayMs > 0)
|
|
234
|
+
await Bun.sleep(this.#options.loginDelayMs);
|
|
235
|
+
return this.#page(401, message(presentation, "Wrong password."));
|
|
236
|
+
}
|
|
237
|
+
const { sessionDays, actor } = this.#options;
|
|
238
|
+
const expires =
|
|
239
|
+
Math.floor(this.#options.now() / 1000) + sessionDays * 86400;
|
|
240
|
+
const payload = Buffer.from(
|
|
241
|
+
JSON.stringify({ p: actor.subject, e: expires, v: 1 }),
|
|
242
|
+
).toString("base64url");
|
|
243
|
+
const value = `${payload}.${sign(this.#signing, payload).toString("base64url")}`;
|
|
244
|
+
return this.#redirect(this.#path, this.#cookie(value, sessionDays * 86400));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
#logout(): Response {
|
|
248
|
+
return this.#redirect(`${this.#options.mount}/login`, this.#cookie("", 0));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
linkSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
statSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import type { Logger } from "pi-roundtable";
|
|
13
|
+
import { PluginError } from "pi-roundtable";
|
|
14
|
+
|
|
15
|
+
const KEY_BYTES = 32;
|
|
16
|
+
|
|
17
|
+
const readKey = (file: string): Buffer => {
|
|
18
|
+
const key = readFileSync(file);
|
|
19
|
+
if (key.length !== KEY_BYTES)
|
|
20
|
+
throw new PluginError(
|
|
21
|
+
`pi-roundtable-web: ${file} holds ${key.length} bytes, not ${KEY_BYTES}. It is the console's session key: delete it to sign everyone out and make a new one, or restore it`,
|
|
22
|
+
);
|
|
23
|
+
return key;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The console's session key, `<dataDir>/web/session.key`: 32 random bytes, readable by its owner
|
|
28
|
+
* only, made at the first start and kept so a restart does not sign anyone out. A file of the
|
|
29
|
+
* wrong size stops startup instead of being used, since a short key would be a weak one.
|
|
30
|
+
*/
|
|
31
|
+
export function loadSessionKey(dataDir: string, logger: Logger): Buffer {
|
|
32
|
+
const dir = join(dataDir, "web");
|
|
33
|
+
const file = join(dir, "session.key");
|
|
34
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
35
|
+
let key: Buffer;
|
|
36
|
+
try {
|
|
37
|
+
key = readKey(file);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
40
|
+
// Written whole under a temporary name, then linked into place: a second start never
|
|
41
|
+
// reads a half-written key, and the first link wins.
|
|
42
|
+
const temp = join(
|
|
43
|
+
dir,
|
|
44
|
+
`.session.key.${process.pid}.${randomBytes(4).toString("hex")}`,
|
|
45
|
+
);
|
|
46
|
+
writeFileSync(temp, randomBytes(KEY_BYTES), { flag: "wx", mode: 0o600 });
|
|
47
|
+
try {
|
|
48
|
+
linkSync(temp, file);
|
|
49
|
+
} catch (linkError) {
|
|
50
|
+
if ((linkError as NodeJS.ErrnoException).code !== "EEXIST")
|
|
51
|
+
throw linkError;
|
|
52
|
+
} finally {
|
|
53
|
+
unlinkSync(temp);
|
|
54
|
+
}
|
|
55
|
+
key = readKey(file);
|
|
56
|
+
}
|
|
57
|
+
if (statSync(file).mode & 0o077) {
|
|
58
|
+
chmodSync(file, 0o600);
|
|
59
|
+
logger.warn(
|
|
60
|
+
{ file },
|
|
61
|
+
"web-console: the session key was readable by others; its mode is now 0600",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return key;
|
|
65
|
+
}
|
package/src/cleanup-api.ts
CHANGED
|
@@ -28,7 +28,7 @@ export async function cleanupConversation(
|
|
|
28
28
|
Boolean(team?.owns(key)) || Boolean(features?.party?.contains(key));
|
|
29
29
|
const group =
|
|
30
30
|
(await team?.status())?.groups.some(
|
|
31
|
-
(g) => `discord:${g.channelId}` === key,
|
|
31
|
+
(g) => g.channelId !== undefined && `discord:${g.channelId}` === key,
|
|
32
32
|
) ?? false;
|
|
33
33
|
const stored =
|
|
34
34
|
!owned &&
|
package/src/cloudflare-access.ts
CHANGED
|
@@ -39,7 +39,7 @@ export function cloudflareAccessIdentity(
|
|
|
39
39
|
* for this application, naming that email and a user. The token is checked on every request, so
|
|
40
40
|
* the console stays closed to a client that reaches it without passing through Access. It reports
|
|
41
41
|
* the user as an OpenID identity of the team's issuer, their `sub` as the subject and their email
|
|
42
|
-
* as the name.
|
|
42
|
+
* as the name, and the token's expiry as when the admission lapses.
|
|
43
43
|
*/
|
|
44
44
|
export function cloudflareAccess(
|
|
45
45
|
options: CloudflareAccessOptions,
|
|
@@ -66,6 +66,7 @@ export function cloudflareAccess(
|
|
|
66
66
|
if (!token) return refuse("no Access token");
|
|
67
67
|
let email: string;
|
|
68
68
|
let subject: string;
|
|
69
|
+
let expiresAt: Date | undefined;
|
|
69
70
|
try {
|
|
70
71
|
const { payload } = await jwtVerify(token, keys, { issuer, audience });
|
|
71
72
|
if (
|
|
@@ -77,9 +78,11 @@ export function cloudflareAccess(
|
|
|
77
78
|
return refuse("Access token names no user");
|
|
78
79
|
email = payload.email.toLowerCase();
|
|
79
80
|
subject = payload.sub;
|
|
81
|
+
if (typeof payload.exp === "number")
|
|
82
|
+
expiresAt = new Date(payload.exp * 1000);
|
|
80
83
|
} catch {
|
|
81
84
|
return refuse("invalid Access token");
|
|
82
85
|
}
|
|
83
|
-
return admitAs({ provider, subject, name: email });
|
|
86
|
+
return admitAs({ provider, subject, name: email }, expiresAt);
|
|
84
87
|
};
|
|
85
88
|
}
|
package/src/console-api.ts
CHANGED
|
@@ -80,11 +80,15 @@ export interface ConsolePorts {
|
|
|
80
80
|
features?: ConsoleFeatures;
|
|
81
81
|
presentation?: ConsolePresentation;
|
|
82
82
|
mountPath?: string;
|
|
83
|
+
look?: "modern" | "camelot";
|
|
84
|
+
/** Present when the owner app is served: its rooms' key prefix, for the page. */
|
|
85
|
+
app?: { surface: string; protocol: string };
|
|
83
86
|
}
|
|
84
87
|
|
|
85
88
|
import { cleanupConversation } from "./cleanup-api.ts";
|
|
86
89
|
import { connectorsView } from "./connectors-api.ts";
|
|
87
90
|
import { ConsoleHttpError as HttpError, json } from "./http.ts";
|
|
91
|
+
import { readLimited } from "./request-body.ts";
|
|
88
92
|
import { skillCatalog, skillDetail } from "./skills-api.ts";
|
|
89
93
|
|
|
90
94
|
/** Memory refusals as the Notes pane shows them. */
|
|
@@ -184,7 +188,7 @@ export class ConsoleApi {
|
|
|
184
188
|
})),
|
|
185
189
|
groups: view.groups.map((group) => ({
|
|
186
190
|
...group,
|
|
187
|
-
key: `discord:${group.channelId}
|
|
191
|
+
key: group.channelId ? `discord:${group.channelId}` : "",
|
|
188
192
|
})),
|
|
189
193
|
workspaces: (view.workspaces ?? []).map((c) => ({
|
|
190
194
|
...c,
|
|
@@ -240,7 +244,9 @@ export class ConsoleApi {
|
|
|
240
244
|
...(presentation?.messages ? { messages: presentation.messages } : {}),
|
|
241
245
|
...(features?.cleanup ? { cleanup: true } : {}),
|
|
242
246
|
...(connectorAdminUrl ? { connectorAdminUrl } : {}),
|
|
247
|
+
look: this.#ports.look ?? "modern",
|
|
243
248
|
...(this.#ports.mountPath ? { mountPath: this.#ports.mountPath } : {}),
|
|
249
|
+
...(this.#ports.app ? { app: this.#ports.app } : {}),
|
|
244
250
|
};
|
|
245
251
|
}
|
|
246
252
|
|
|
@@ -283,7 +289,8 @@ export class ConsoleApi {
|
|
|
283
289
|
groups: status.groups.map((group) => ({
|
|
284
290
|
name: group.name,
|
|
285
291
|
displayName: group.displayName,
|
|
286
|
-
|
|
292
|
+
// A group of a team without Discord has no channel; the pane then links none.
|
|
293
|
+
...(group.channelId ? { channelId: group.channelId } : {}),
|
|
287
294
|
members: group.members,
|
|
288
295
|
host: group.host,
|
|
289
296
|
busy: group.busy,
|
|
@@ -513,21 +520,12 @@ export class ConsoleApi {
|
|
|
513
520
|
}
|
|
514
521
|
}
|
|
515
522
|
|
|
516
|
-
/** The JSON body
|
|
523
|
+
/** The JSON body; undefined when it is not JSON. */
|
|
517
524
|
async function readBody(request: Request): Promise<unknown> {
|
|
518
|
-
const
|
|
519
|
-
if (
|
|
520
|
-
throw tooLarge();
|
|
521
|
-
const chunks: Uint8Array[] = [];
|
|
522
|
-
let size = 0;
|
|
523
|
-
if (request.body)
|
|
524
|
-
for await (const chunk of request.body) {
|
|
525
|
-
size += chunk.byteLength;
|
|
526
|
-
if (size > MAX_BODY_BYTES) throw tooLarge();
|
|
527
|
-
chunks.push(chunk);
|
|
528
|
-
}
|
|
525
|
+
const raw = await readLimited(request, MAX_BODY_BYTES);
|
|
526
|
+
if (!raw) throw new HttpError(413, "The request is too large.");
|
|
529
527
|
try {
|
|
530
|
-
return JSON.parse(
|
|
528
|
+
return JSON.parse(raw.toString("utf8"));
|
|
531
529
|
} catch {
|
|
532
530
|
return undefined;
|
|
533
531
|
}
|
package/src/console-server.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { HttpRoute, Logger, WebSocketRoute } from "pi-roundtable";
|
|
2
|
+
import type { AppSocket } from "./app/sockets.ts";
|
|
2
3
|
import type { AssetBundle } from "./assets.ts";
|
|
4
|
+
import type {
|
|
5
|
+
Authenticated,
|
|
6
|
+
Authenticator,
|
|
7
|
+
Unauthenticated,
|
|
8
|
+
} from "./auth/authenticator.ts";
|
|
3
9
|
import type { ConsoleApi } from "./console-api.ts";
|
|
4
10
|
import type { ConsolePresentation } from "./features.ts";
|
|
5
11
|
import { presentPage } from "./page-presentation.ts";
|
|
6
|
-
import
|
|
7
|
-
import type { Identified, Visitor } from "./visitors.ts";
|
|
12
|
+
import { servePwa } from "./pwa.ts";
|
|
8
13
|
|
|
9
14
|
const encoder = new TextEncoder();
|
|
10
15
|
/** Changes closer together than this reach the page as one event. */
|
|
@@ -33,12 +38,21 @@ export interface ConsoleServerOptions {
|
|
|
33
38
|
/** The path the console is served under, without a trailing slash: `/console`. */
|
|
34
39
|
mount: string;
|
|
35
40
|
assets: AssetBundle;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
identify(actor: ActorFacts | undefined): Promise<Identified>;
|
|
39
|
-
/** The console's own origin; requests that change data must come from it. */
|
|
40
|
-
origin: string;
|
|
41
|
+
/** Checks every request under the mount, and answers the few paths that need no session. */
|
|
42
|
+
authenticator: Authenticator;
|
|
41
43
|
api: Pick<ConsoleApi, "handle">;
|
|
44
|
+
/**
|
|
45
|
+
* The owner app, when the `rooms` pane is served: it answers its own paths below the mount
|
|
46
|
+
* before the console's API does, and its WebSocket takes the upgrades of the console's route.
|
|
47
|
+
*/
|
|
48
|
+
app?: {
|
|
49
|
+
handle(
|
|
50
|
+
request: Request,
|
|
51
|
+
rest: string,
|
|
52
|
+
admission: Authenticated,
|
|
53
|
+
): Promise<Response | undefined>;
|
|
54
|
+
websocket: WebSocketRoute<AppSocket>;
|
|
55
|
+
};
|
|
42
56
|
/** Registers the listener with every source whose change the console shows. */
|
|
43
57
|
subscribe(listener: () => void): void;
|
|
44
58
|
logger: Logger;
|
|
@@ -47,12 +61,18 @@ export interface ConsoleServerOptions {
|
|
|
47
61
|
/** Serve client page paths as the same authenticated document. */
|
|
48
62
|
pathRouting?: boolean;
|
|
49
63
|
presentation?: ConsolePresentation;
|
|
64
|
+
/** The manifest's name; default `Roundtable`. */
|
|
65
|
+
title?: string;
|
|
66
|
+
/** Where the install icons come from; default the ones this package ships. */
|
|
67
|
+
iconsDir?: string;
|
|
68
|
+
look?: "modern" | "camelot";
|
|
50
69
|
}
|
|
51
70
|
|
|
52
71
|
/**
|
|
53
72
|
* The console on the host's listener: `<mount>` redirects to `<mount>/`, and everything under
|
|
54
|
-
* it needs the
|
|
55
|
-
* changes data must also carry the console's own
|
|
73
|
+
* it needs the authenticator to admit the request as an owner's, apart from what it answers
|
|
74
|
+
* openly, such as a login page. A request that changes data must also carry the console's own
|
|
75
|
+
* `Origin`.
|
|
56
76
|
*/
|
|
57
77
|
export class ConsoleServer {
|
|
58
78
|
readonly #options: ConsoleServerOptions;
|
|
@@ -67,7 +87,11 @@ export class ConsoleServer {
|
|
|
67
87
|
options.subscribe(() => this.#changed());
|
|
68
88
|
}
|
|
69
89
|
|
|
70
|
-
|
|
90
|
+
/**
|
|
91
|
+
* The routes on `listener`. `maxRequestBodyBytes` is the largest body the console takes, for
|
|
92
|
+
* the owner app's uploads; without it the listener's default stands.
|
|
93
|
+
*/
|
|
94
|
+
routes(listener: string, maxRequestBodyBytes?: number): HttpRoute[] {
|
|
71
95
|
const handle = (request: Request) => this.handle(request);
|
|
72
96
|
return [
|
|
73
97
|
{
|
|
@@ -81,6 +105,10 @@ export class ConsoleServer {
|
|
|
81
105
|
listener,
|
|
82
106
|
path: { prefix: this.#base },
|
|
83
107
|
handle,
|
|
108
|
+
...(maxRequestBodyBytes === undefined ? {} : { maxRequestBodyBytes }),
|
|
109
|
+
...(this.#options.app
|
|
110
|
+
? { websocket: this.#options.app.websocket }
|
|
111
|
+
: {}),
|
|
84
112
|
},
|
|
85
113
|
];
|
|
86
114
|
}
|
|
@@ -116,25 +144,20 @@ export class ConsoleServer {
|
|
|
116
144
|
const path = new URL(request.url).pathname;
|
|
117
145
|
if (path !== this.#options.mount && !path.startsWith(this.#base))
|
|
118
146
|
return new Response("Not found", { status: 404 });
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
);
|
|
125
|
-
return new Response("Forbidden", {
|
|
126
|
-
status: 403,
|
|
127
|
-
headers: { "cache-control": "no-store" },
|
|
128
|
-
});
|
|
129
|
-
}
|
|
147
|
+
const rest = path.slice(this.#base.length);
|
|
148
|
+
const open = await this.#options.authenticator.open?.(request, rest);
|
|
149
|
+
if (open) return open;
|
|
150
|
+
const admission = await this.#options.authenticator.authenticate(request);
|
|
151
|
+
if ("refusal" in admission) return this.#refused(request, rest, admission);
|
|
130
152
|
if (path === this.#options.mount)
|
|
131
153
|
return new Response(null, {
|
|
132
154
|
status: 302,
|
|
133
155
|
headers: { location: this.#base },
|
|
134
156
|
});
|
|
135
|
-
const rest = path.slice(this.#base.length);
|
|
136
157
|
if (rest === "api/events" && request.method === "GET")
|
|
137
158
|
return this.#events();
|
|
159
|
+
const owned = await this.#options.app?.handle(request, rest, admission);
|
|
160
|
+
if (owned) return owned;
|
|
138
161
|
if (rest.startsWith("api/"))
|
|
139
162
|
return this.#options.api.handle(
|
|
140
163
|
request,
|
|
@@ -143,8 +166,13 @@ export class ConsoleServer {
|
|
|
143
166
|
);
|
|
144
167
|
if (!READ_METHODS.has(request.method))
|
|
145
168
|
return new Response("Method not allowed", { status: 405 });
|
|
169
|
+
const installable = servePwa(rest, {
|
|
170
|
+
...this.#options,
|
|
171
|
+
title: this.#options.title ?? "Roundtable",
|
|
172
|
+
});
|
|
173
|
+
if (installable) return installable;
|
|
146
174
|
const asset = this.#options.assets.get(rest);
|
|
147
|
-
if (asset && rest !== "index.html")
|
|
175
|
+
if (asset && rest !== "index.html" && rest !== "sw.js")
|
|
148
176
|
return new Response(new Uint8Array(asset.body), {
|
|
149
177
|
headers: {
|
|
150
178
|
"content-type": asset.type,
|
|
@@ -159,6 +187,7 @@ export class ConsoleServer {
|
|
|
159
187
|
page.body,
|
|
160
188
|
this.#options.pathRouting ? this.#options.mount : undefined,
|
|
161
189
|
this.#options.presentation,
|
|
190
|
+
this.#options.look,
|
|
162
191
|
),
|
|
163
192
|
{
|
|
164
193
|
headers: {
|
|
@@ -177,33 +206,32 @@ export class ConsoleServer {
|
|
|
177
206
|
return new Response("Not found", { status: 404 });
|
|
178
207
|
}
|
|
179
208
|
|
|
180
|
-
/**
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
try {
|
|
186
|
-
const verdict = await this.#options.verifier(request);
|
|
187
|
-
if (!verdict.admitted) return { refusal: String(verdict.reason) };
|
|
188
|
-
actor = verdict.actor;
|
|
189
|
-
} catch {
|
|
190
|
-
// A verifier that fails admits no one.
|
|
191
|
-
return { refusal: "verifier failed" };
|
|
192
|
-
}
|
|
193
|
-
let identified: Identified;
|
|
194
|
-
try {
|
|
195
|
-
identified = await this.#options.identify(actor);
|
|
196
|
-
} catch {
|
|
197
|
-
// Nor does a lookup of who they are that fails.
|
|
198
|
-
return { refusal: "identity failed" };
|
|
199
|
-
}
|
|
200
|
-
if ("refusal" in identified) return { refusal: String(identified.refusal) };
|
|
209
|
+
/**
|
|
210
|
+
* The answer to a request the authenticator refused: a page navigation that signing in would
|
|
211
|
+
* fix goes to the login page, everything else gets the status and a fixed body.
|
|
212
|
+
*/
|
|
213
|
+
#refused(request: Request, rest: string, refusal: Unauthenticated): Response {
|
|
201
214
|
if (
|
|
202
|
-
|
|
203
|
-
request.
|
|
215
|
+
refusal.login &&
|
|
216
|
+
READ_METHODS.has(request.method) &&
|
|
217
|
+
!rest.startsWith("api/")
|
|
204
218
|
)
|
|
205
|
-
return
|
|
206
|
-
|
|
219
|
+
return new Response(null, {
|
|
220
|
+
status: 303,
|
|
221
|
+
headers: {
|
|
222
|
+
location: `${this.#options.mount}/login`,
|
|
223
|
+
"cache-control": "no-store",
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
// A missing session is the normal way to meet a login page, not worth a line each time.
|
|
227
|
+
const { logger } = this.#options;
|
|
228
|
+
const fields = { refusal: refusal.refusal };
|
|
229
|
+
if (refusal.login) logger.debug(fields, "console request refused");
|
|
230
|
+
else logger.warn(fields, "console request refused");
|
|
231
|
+
return new Response(refusal.status === 401 ? "Unauthorized" : "Forbidden", {
|
|
232
|
+
status: refusal.status,
|
|
233
|
+
headers: { "cache-control": "no-store" },
|
|
234
|
+
});
|
|
207
235
|
}
|
|
208
236
|
|
|
209
237
|
/** A server-sent event stream that says `changed` whenever the console may differ. */
|
package/src/http.ts
CHANGED
|
@@ -9,5 +9,13 @@ export class ConsoleHttpError extends Error {
|
|
|
9
9
|
super(message);
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
|
+
/** Text safe in element content and in a quoted attribute value. */
|
|
13
|
+
export const escapeHtml = (value: string): string =>
|
|
14
|
+
value
|
|
15
|
+
.replaceAll("&", "&")
|
|
16
|
+
.replaceAll('"', """)
|
|
17
|
+
.replaceAll("<", "<")
|
|
18
|
+
.replaceAll(">", ">")
|
|
19
|
+
.replaceAll("'", "'");
|
|
12
20
|
export const json = (body: unknown, status = 200): Response =>
|
|
13
21
|
Response.json(body, { status, headers: { "cache-control": "no-store" } });
|
package/src/index.ts
CHANGED
|
@@ -35,7 +35,7 @@ export {
|
|
|
35
35
|
/** Summary for a trusted host's separately stored party sessions. */
|
|
36
36
|
export { conversationFiles as sessionSummary } from "./conversations.ts";
|
|
37
37
|
export type { ConsoleFeatures, ConsolePresentation } from "./features.ts";
|
|
38
|
-
export type { WebConsoleOptions } from "./options.ts";
|
|
38
|
+
export type { WebAuthOptions, WebConsoleOptions } from "./options.ts";
|
|
39
39
|
export { DEFAULT_RELAY_NOTE } from "./options.ts";
|
|
40
40
|
export type { RequestVerifier, Verdict } from "./verifier.ts";
|
|
41
41
|
export { admit, admitAs, refuse } from "./verifier.ts";
|