pi-roundtable-web 0.22.5 → 0.23.1

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 (83) hide show
  1. package/CHANGELOG.md +53 -1
  2. package/README.md +444 -104
  3. package/assets/icons/192.png +0 -0
  4. package/assets/icons/512.png +0 -0
  5. package/assets/icons/apple-180.png +0 -0
  6. package/assets/icons/badge-96.png +0 -0
  7. package/assets/icons/mark-maskable.svg +1 -0
  8. package/assets/icons/mark.svg +1 -0
  9. package/assets/icons/maskable-512.png +0 -0
  10. package/dist/chunk-character-creator-660d2t6p.js +1 -0
  11. package/dist/chunk-connectors-d19v8cjc.js +1 -0
  12. package/dist/chunk-conversations-4cmexmqq.js +1 -0
  13. package/dist/chunk-entry-8xpx35ef.js +1 -0
  14. package/dist/chunk-index-19g8jmr8.js +2 -0
  15. package/dist/chunk-index-362dfedj.js +1 -0
  16. package/dist/chunk-index-6g69tqt0.js +47 -0
  17. package/dist/chunk-index-89q2qdkj.css +1 -0
  18. package/dist/chunk-index-9tvf6es7.js +41 -0
  19. package/dist/chunk-index-p8h0e861.js +2 -0
  20. package/dist/chunk-index-xw5hhmfq.js +2 -0
  21. package/dist/chunk-notes-fefwds14.js +1 -0
  22. package/dist/chunk-overview-bxpwpmn6.js +1 -0
  23. package/dist/chunk-skills-pce3zgxy.js +1 -0
  24. package/dist/chunk-table-zmpbrfvz.js +1 -0
  25. package/dist/chunk-table2d-ac2gez71.js +1 -0
  26. package/dist/chunk-table3d-g258jemr.js +4229 -0
  27. package/dist/chunk-transcript-yhtjp3pd.js +1 -0
  28. package/dist/chunk-zh-tw-anqhqpaz.js +1 -0
  29. package/dist/index.html +6 -2
  30. package/dist/sw.js +1 -0
  31. package/package.json +8 -3
  32. package/src/api-types.ts +448 -1
  33. package/src/app/app.ts +351 -0
  34. package/src/app/card-log.ts +119 -0
  35. package/src/app/desk.ts +533 -0
  36. package/src/app/files.ts +262 -0
  37. package/src/app/frames.ts +132 -0
  38. package/src/app/multipart.ts +172 -0
  39. package/src/app/presence.ts +73 -0
  40. package/src/app/protocol.ts +144 -0
  41. package/src/app/room-log.ts +137 -0
  42. package/src/app/rooms.ts +189 -0
  43. package/src/app/routes.ts +434 -0
  44. package/src/app/socket.ts +406 -0
  45. package/src/app/sockets.ts +84 -0
  46. package/src/app/surface-threads.ts +92 -0
  47. package/src/app/surface.ts +384 -0
  48. package/src/app/threads.ts +99 -0
  49. package/src/app/uploads.ts +363 -0
  50. package/src/assets.ts +1 -3
  51. package/src/auth/authenticator.ts +167 -0
  52. package/src/auth/login-page.ts +44 -0
  53. package/src/auth/none.ts +38 -0
  54. package/src/auth/password.ts +250 -0
  55. package/src/auth/session-key.ts +65 -0
  56. package/src/cleanup-api.ts +1 -1
  57. package/src/cloudflare-access.ts +5 -2
  58. package/src/console-api.ts +13 -15
  59. package/src/console-server.ts +77 -49
  60. package/src/features.ts +6 -1
  61. package/src/http.ts +8 -0
  62. package/src/i18n/index.ts +13 -0
  63. package/src/i18n/locale.ts +11 -0
  64. package/src/i18n/zh-tw.ts +696 -0
  65. package/src/index.ts +1 -1
  66. package/src/options.ts +308 -12
  67. package/src/page-colors.ts +9 -0
  68. package/src/page-presentation.ts +17 -12
  69. package/src/push/api.ts +190 -0
  70. package/src/push/channel.ts +50 -0
  71. package/src/push/encrypt.ts +136 -0
  72. package/src/push/endpoint.ts +54 -0
  73. package/src/push/sender.ts +109 -0
  74. package/src/push/subscriptions.ts +140 -0
  75. package/src/push/test-fetch.ts +17 -0
  76. package/src/push/triggers.ts +155 -0
  77. package/src/push/vapid.ts +124 -0
  78. package/src/pwa.ts +130 -0
  79. package/src/request-body.ts +20 -0
  80. package/src/verifier.ts +7 -1
  81. package/src/web-plugin.ts +123 -11
  82. package/dist/chunk-82frzte9.js +0 -36
  83. package/dist/chunk-e8z9q554.css +0 -1
@@ -0,0 +1,124 @@
1
+ import type { webcrypto } from "node:crypto";
2
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { SignJWT } from "jose";
5
+
6
+ /** How long a VAPID token is valid; push services accept up to 24 hours. */
7
+ const TOKEN_LIFE_SECONDS = 12 * 3600;
8
+ const ES256 = { name: "ECDSA", namedCurve: "P-256" } as const;
9
+
10
+ /** The server's VAPID identity (RFC 8292): the public key browsers subscribe against, and the key that signs for it. */
11
+ export interface Vapid {
12
+ /** The uncompressed P-256 point, base64url: the `applicationServerKey` and the `k=` of the header. */
13
+ publicKey: string;
14
+ privateKey: CryptoKey;
15
+ }
16
+
17
+ interface Stored {
18
+ publicKey: string;
19
+ privateKey: webcrypto.JsonWebKey;
20
+ }
21
+
22
+ function parse(raw: string, path: string): Stored {
23
+ let value: unknown;
24
+ try {
25
+ value = JSON.parse(raw);
26
+ } catch {
27
+ throw new Error(
28
+ `${path} is not JSON; delete it to make a new VAPID key (every device must subscribe again)`,
29
+ );
30
+ }
31
+ const stored = value as Partial<Stored> | null;
32
+ if (
33
+ typeof stored?.publicKey !== "string" ||
34
+ typeof stored.privateKey?.d !== "string"
35
+ )
36
+ throw new Error(
37
+ `${path} is not a VAPID key file; delete it to make a new one (every device must subscribe again)`,
38
+ );
39
+ return stored as Stored;
40
+ }
41
+
42
+ async function importStored(stored: Stored, path: string): Promise<Vapid> {
43
+ let privateKey: CryptoKey;
44
+ try {
45
+ privateKey = await crypto.subtle.importKey(
46
+ "jwk",
47
+ stored.privateKey,
48
+ ES256,
49
+ false,
50
+ ["sign"],
51
+ );
52
+ } catch {
53
+ throw new Error(
54
+ `${path} does not hold a P-256 key; delete it to make a new one (every device must subscribe again)`,
55
+ );
56
+ }
57
+ // The header's `k=` must be the key that signs, or every push service refuses the token.
58
+ const { x, y } = stored.privateKey;
59
+ const point =
60
+ typeof x === "string" && typeof y === "string"
61
+ ? Buffer.concat([
62
+ Buffer.from([4]),
63
+ Buffer.from(x, "base64url"),
64
+ Buffer.from(y, "base64url"),
65
+ ]).toString("base64url")
66
+ : undefined;
67
+ if (point !== stored.publicKey)
68
+ throw new Error(
69
+ `${path} holds a public key that does not belong to its private key; delete it to make a new one (every device must subscribe again)`,
70
+ );
71
+ return { publicKey: stored.publicKey, privateKey };
72
+ }
73
+
74
+ /**
75
+ * The VAPID keys in `path`, made on first use with WebCrypto and kept readable by this user
76
+ * alone. Subscriptions are bound to the public key, so the file must outlive a restart; a file
77
+ * that cannot be read stops startup rather than being replaced.
78
+ */
79
+ export async function loadVapid(path: string): Promise<Vapid> {
80
+ let raw: string | undefined;
81
+ try {
82
+ raw = readFileSync(path, "utf8");
83
+ } catch (error) {
84
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
85
+ }
86
+ if (raw !== undefined) return importStored(parse(raw, path), path);
87
+ const pair = await crypto.subtle.generateKey(ES256, true, ["sign", "verify"]);
88
+ const stored: Stored = {
89
+ publicKey: Buffer.from(
90
+ await crypto.subtle.exportKey("raw", pair.publicKey),
91
+ ).toString("base64url"),
92
+ privateKey: await crypto.subtle.exportKey("jwk", pair.privateKey),
93
+ };
94
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
95
+ try {
96
+ // `wx`: a second starter that lost the race reads the winner's file instead of replacing it.
97
+ writeFileSync(path, `${JSON.stringify(stored)}\n`, {
98
+ flag: "wx",
99
+ mode: 0o600,
100
+ });
101
+ } catch (error) {
102
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
103
+ return importStored(parse(readFileSync(path, "utf8"), path), path);
104
+ }
105
+ chmodSync(path, 0o600);
106
+ return importStored(stored, path);
107
+ }
108
+
109
+ /** The `Authorization` header value for a push to an endpoint (RFC 8292): a token for its origin, signed by the key. */
110
+ export async function vapidAuthorization(
111
+ vapid: Vapid,
112
+ endpoint: string,
113
+ subject: string,
114
+ now: Date = new Date(),
115
+ ): Promise<string> {
116
+ const issued = Math.floor(now.getTime() / 1000);
117
+ const token = await new SignJWT({})
118
+ .setProtectedHeader({ alg: "ES256", typ: "JWT" })
119
+ .setAudience(new URL(endpoint).origin)
120
+ .setSubject(subject)
121
+ .setExpirationTime(issued + TOKEN_LIFE_SECONDS)
122
+ .sign(vapid.privateKey);
123
+ return `vapid t=${token}, k=${vapid.publicKey}`;
124
+ }
package/src/pwa.ts ADDED
@@ -0,0 +1,130 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import type { AssetBundle } from "./assets.ts";
5
+ import { PAGE_COLORS } from "./page-colors.ts";
6
+
7
+ /** The manifest's splash colours under each look; modern keeps the page's own. */
8
+ const LOOK_COLORS = {
9
+ modern: {
10
+ theme: PAGE_COLORS.modern.light,
11
+ background: PAGE_COLORS.modern.light,
12
+ },
13
+ camelot: {
14
+ theme: PAGE_COLORS.camelot.light,
15
+ background: PAGE_COLORS.camelot.dark,
16
+ },
17
+ } as const;
18
+
19
+ /**
20
+ * Applies the stored look and colour scheme before the page paints. It is a file of its own, not
21
+ * an inline script, because the page's policy allows scripts from itself only; the page loads it
22
+ * in the head, where it blocks the first paint for the few bytes it is. It does what `initLook`
23
+ * and `initTheme` do once the bundle runs (`rt-look` and `rt-theme`, in `web/lib`): the device's
24
+ * own look wins over the host's, and the toolbar colour follows the look and the scheme.
25
+ */
26
+ export const themeInitScript = (host: "modern" | "camelot"): string =>
27
+ `(function(){try{var C=${JSON.stringify(PAGE_COLORS)};var l=localStorage.getItem("rt-look");if(l!=="modern"&&l!=="camelot")l="${host}";var t=localStorage.getItem("rt-theme");var r=document.documentElement;r.dataset.look=l;var x=t==="light"||t==="dark";if(x)r.dataset.theme=t;var m=document.querySelectorAll('meta[name="theme-color"]');for(var i=0;i<m.length;i++){var s=x?t:(m[i].getAttribute("media")||"").indexOf("dark")>-1?"dark":"light";m[i].setAttribute("content",C[l][s])}}catch(e){}})();\n`;
28
+
29
+ /** The script for a modern host. */
30
+ export const THEME_INIT_SCRIPT = themeInitScript("modern");
31
+
32
+ /** The icons this package ships, which `options.icons.dir` replaces. */
33
+ export const DEFAULT_ICONS_DIR = fileURLToPath(
34
+ new URL("../assets/icons/", import.meta.url),
35
+ );
36
+
37
+ const ICON_FILE = /^[A-Za-z0-9-]+\.png$/;
38
+
39
+ export interface PwaOptions {
40
+ mount: string;
41
+ title: string;
42
+ assets: AssetBundle;
43
+ iconsDir?: string;
44
+ look?: "modern" | "camelot";
45
+ }
46
+
47
+ /** The web app manifest for a console served under `mount`; its icon URLs are relative to it. */
48
+ function manifestFor(
49
+ mount: string,
50
+ title: string,
51
+ look: "modern" | "camelot" = "modern",
52
+ ): string {
53
+ const base = `${mount}/`;
54
+ return JSON.stringify({
55
+ id: base,
56
+ start_url: base,
57
+ scope: base,
58
+ name: title,
59
+ short_name: title,
60
+ display: "standalone",
61
+ // A manifest has one colour for the splash; the light page is the one most owners see.
62
+ theme_color: LOOK_COLORS[look].theme,
63
+ background_color: LOOK_COLORS[look].background,
64
+ icons: [
65
+ { src: "icons/192.png", sizes: "192x192", type: "image/png" },
66
+ { src: "icons/512.png", sizes: "512x512", type: "image/png" },
67
+ {
68
+ src: "icons/maskable-512.png",
69
+ sizes: "512x512",
70
+ type: "image/png",
71
+ purpose: "maskable",
72
+ },
73
+ ],
74
+ });
75
+ }
76
+
77
+ /**
78
+ * The installability files under the mount: the manifest, the service worker and the icons. A
79
+ * path that is none of them returns undefined. The caller has already admitted the request.
80
+ */
81
+ export function servePwa(
82
+ rest: string,
83
+ options: PwaOptions,
84
+ ): Response | undefined {
85
+ if (rest === "manifest.webmanifest")
86
+ return new Response(
87
+ manifestFor(options.mount, options.title, options.look),
88
+ {
89
+ headers: {
90
+ "content-type": "application/manifest+json;charset=utf-8",
91
+ "cache-control": "no-cache",
92
+ },
93
+ },
94
+ );
95
+ if (rest === "theme-init.js")
96
+ return new Response(themeInitScript(options.look ?? "modern"), {
97
+ headers: {
98
+ "content-type": "text/javascript;charset=utf-8",
99
+ "cache-control": "no-cache",
100
+ },
101
+ });
102
+ if (rest === "sw.js") {
103
+ const worker = options.assets.get("sw.js");
104
+ if (!worker) return undefined;
105
+ return new Response(new Uint8Array(worker.body), {
106
+ headers: {
107
+ "content-type": "text/javascript;charset=utf-8",
108
+ "cache-control": "no-cache",
109
+ },
110
+ });
111
+ }
112
+ if (rest.startsWith("icons/")) {
113
+ const name = rest.slice("icons/".length);
114
+ if (!ICON_FILE.test(name) || basename(name) !== name) return undefined;
115
+ try {
116
+ const body = readFileSync(
117
+ join(options.iconsDir ?? DEFAULT_ICONS_DIR, name),
118
+ );
119
+ return new Response(new Uint8Array(body), {
120
+ headers: {
121
+ "content-type": "image/png",
122
+ "cache-control": "private, max-age=86400",
123
+ },
124
+ });
125
+ } catch {
126
+ return undefined;
127
+ }
128
+ }
129
+ return undefined;
130
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * A request's whole body, read in chunks so one past `limit` bytes is refused without being held
3
+ * whole: by its declared length first, then by the bytes that arrive. Undefined when it is over.
4
+ */
5
+ export async function readLimited(
6
+ request: Request,
7
+ limit: number,
8
+ ): Promise<Buffer | undefined> {
9
+ if (Number(request.headers.get("content-length") ?? 0) > limit)
10
+ return undefined;
11
+ const chunks: Uint8Array[] = [];
12
+ let size = 0;
13
+ if (request.body)
14
+ for await (const chunk of request.body) {
15
+ size += chunk.byteLength;
16
+ if (size > limit) return undefined;
17
+ chunks.push(chunk);
18
+ }
19
+ return Buffer.concat(chunks);
20
+ }
package/src/verifier.ts CHANGED
@@ -10,6 +10,11 @@ export type Verdict =
10
10
  * owner's, as before 0.9, and the console warns once.
11
11
  */
12
12
  actor?: ActorFacts;
13
+ /**
14
+ * When the proxy's assertion lapses, such as the `exp` of a Cloudflare Access token. A
15
+ * connection that outlives requests, such as a WebSocket, ends then and signs in again.
16
+ */
17
+ expiresAt?: Date;
13
18
  }
14
19
  | {
15
20
  admitted: false;
@@ -30,9 +35,10 @@ export type RequestVerifier = (request: Request) => Verdict | Promise<Verdict>;
30
35
  /** Admits the request without saying who it is from, as a verifier before 0.9 did. */
31
36
  export const admit = (): Verdict => ({ admitted: true });
32
37
  /** Admits the request as coming from the person these facts describe. */
33
- export const admitAs = (actor: ActorFacts): Verdict => ({
38
+ export const admitAs = (actor: ActorFacts, expiresAt?: Date): Verdict => ({
34
39
  admitted: true,
35
40
  actor,
41
+ ...(expiresAt ? { expiresAt } : {}),
36
42
  });
37
43
  export const refuse = (reason: string): Verdict => ({
38
44
  admitted: false,
package/src/web-plugin.ts CHANGED
@@ -1,13 +1,23 @@
1
+ import { join } from "node:path";
1
2
  import {
2
3
  AGENTS,
3
4
  CONVERSATIONS,
4
5
  definePlugin,
5
6
  IDENTITY,
6
7
  MEMORY,
8
+ MODES,
7
9
  type RoundtablePlugin,
10
+ SCHEDULES,
8
11
  } from "pi-roundtable";
9
12
  import { DISCORD } from "pi-roundtable/discord";
13
+ import { APP_MIGRATIONS, OwnerApp } from "./app/app.ts";
14
+ import { APP_PROTOCOL } from "./app/protocol.ts";
15
+ import { UPLOAD_FRAMING_BYTES } from "./app/uploads.ts";
10
16
  import { loadAssets } from "./assets.ts";
17
+ import {
18
+ createAuthenticator,
19
+ WEB_CONSOLE_IDENTITY,
20
+ } from "./auth/authenticator.ts";
11
21
  import { ConsoleApi } from "./console-api.ts";
12
22
  import { ConsoleServer } from "./console-server.ts";
13
23
  import {
@@ -15,21 +25,43 @@ import {
15
25
  resolveOptions,
16
26
  type WebConsoleOptions,
17
27
  } from "./options.ts";
28
+ import { loadVapid } from "./push/vapid.ts";
18
29
  import { consoleVisitors } from "./visitors.ts";
19
30
 
31
+ /** The longest a WebSocket stays open under `password` auth: the session cookie is looked at again after this. */
32
+ const PASSWORD_SOCKET_LIFE_MS = 3_600_000;
33
+
20
34
  /**
21
35
  * The owners' web console: conversations, transcripts, memory notes, and the agent team, served
22
- * on a route of the host's listener and updated live. Every request needs the verifier to vouch
23
- * for the person and `IDENTITY` to name them an owner. The options are checked here, so a missing
24
- * verifier or a bad setting throws when the config is loaded.
36
+ * on a route of the host's listener and updated live. Every request needs the configured `auth`
37
+ * to vouch for the person and `IDENTITY` to name them an owner. The options are checked here, so
38
+ * missing authentication or a bad setting throws when the config is loaded; whether `none` fits
39
+ * the listener and whether the password is long enough are checked when the host sets up.
25
40
  */
26
41
  export function webConsole(options: WebConsoleOptions): RoundtablePlugin {
27
42
  const settings = resolveOptions(options);
43
+ const { auth } = settings;
44
+ const rooms = settings.panes.includes("rooms");
45
+ const schedules = settings.panes.includes("schedules");
28
46
  return definePlugin({
29
47
  name: "web-console",
48
+ ...(rooms ? { migrations: APP_MIGRATIONS } : {}),
49
+ // `none` and `password` speak for one principal through this identity; a verifier brings its own.
50
+ ...(auth.mode === "verifier"
51
+ ? {}
52
+ : {
53
+ identities: [
54
+ {
55
+ identity: WEB_CONSOLE_IDENTITY,
56
+ ...(auth.principal ? { principal: auth.principal } : {}),
57
+ },
58
+ ],
59
+ }),
30
60
  requires: [
31
61
  IDENTITY,
32
- ...(settings.panes.includes("overview") ? [AGENTS] : []),
62
+ ...(settings.panes.includes("overview") || rooms ? [AGENTS] : []),
63
+ ...(rooms ? [MODES] : []),
64
+ ...(schedules ? [SCHEDULES] : []),
33
65
  ...(settings.panes.includes("notes") ? [MEMORY] : []),
34
66
  ],
35
67
  setup: (context) => build(settings, context),
@@ -48,6 +80,7 @@ async function build(settings: ResolvedOptions, context: Context) {
48
80
  if (settings.panes.includes(pane) && !features?.[pane])
49
81
  throw new Error(`web-console: the ${pane} pane needs its feature port`);
50
82
  }
83
+ const rooms = settings.panes.includes("rooms");
51
84
  const team = services.find(AGENTS)?.team;
52
85
  const connection = services.find(DISCORD)?.connection;
53
86
  // Absent on a host without the registry: the console then lists conversations found by name only.
@@ -82,21 +115,87 @@ async function build(settings: ResolvedOptions, context: Context) {
82
115
  changed,
83
116
  logger,
84
117
  ...(features ? { features } : {}),
118
+ look: settings.look,
85
119
  ...(settings.routing === "path" ? { mountPath: settings.mount } : {}),
120
+ ...(rooms
121
+ ? { app: { surface: settings.surface, protocol: APP_PROTOCOL } }
122
+ : {}),
86
123
  ...(settings.presentation ? { presentation: settings.presentation } : {}),
87
124
  });
88
- const server = new ConsoleServer({
89
- mount: settings.mount,
90
- assets,
91
- verifier: settings.verifier,
125
+ const authenticator = createAuthenticator({
126
+ auth: settings.auth,
92
127
  identify: consoleVisitors(identity, {
93
128
  logger,
94
129
  ...(settings.ownerId ? { ownerId: settings.ownerId } : {}),
95
130
  }),
96
131
  origin: settings.origin,
132
+ mount: settings.mount,
133
+ title: settings.title,
134
+ ...(settings.presentation ? { presentation: settings.presentation } : {}),
135
+ logger,
136
+ dataDir: settings.dataDir,
137
+ listener: {
138
+ id: settings.listener,
139
+ address: context.listener(settings.listener),
140
+ },
141
+ });
142
+ if (rooms && !settings.push.subject)
143
+ logger.warn(
144
+ "web push is off: the origin is not https, so set push.subject to a mailto: or https: contact",
145
+ );
146
+ const vapid = rooms
147
+ ? await loadVapid(join(settings.dataDir, "web", "vapid.json"))
148
+ : undefined;
149
+ const app =
150
+ rooms && vapid
151
+ ? new OwnerApp({
152
+ push: {
153
+ vapid,
154
+ subject: settings.push.subject,
155
+ preview: settings.push.preview,
156
+ endpointHosts: settings.push.endpointHosts,
157
+ ...(settings.push.fetch ? { fetch: settings.push.fetch } : {}),
158
+ urlOf: (room) =>
159
+ settings.routing === "path"
160
+ ? `${settings.mount}/room/${encodeURIComponent(room)}`
161
+ : `${settings.mount}/#/room/${encodeURIComponent(room)}`,
162
+ },
163
+ surface: settings.surface,
164
+ mount: settings.mount,
165
+ origin: settings.origin,
166
+ dataDir: settings.dataDir,
167
+ cardGraceSeconds: settings.cardGraceSeconds,
168
+ uploadMaxBytes: settings.uploadMaxBytes,
169
+ authenticator,
170
+ authMode: settings.auth.mode,
171
+ // A password session outlives a logout or a new password on an open socket, so the socket is capped.
172
+ ...(settings.auth.mode === "password"
173
+ ? { socketLifeMs: PASSWORD_SOCKET_LIFE_MS }
174
+ : {}),
175
+ database: context.database(),
176
+ agents: services.get(AGENTS),
177
+ modes: services.get(MODES),
178
+ identity,
179
+ ...(settings.panes.includes("schedules")
180
+ ? { schedules: services.get(SCHEDULES) }
181
+ : {}),
182
+ conversations: context.conversations,
183
+ changed,
184
+ logger,
185
+ })
186
+ : undefined;
187
+ if (app) listeners.push(() => app.changed());
188
+ const server = new ConsoleServer({
189
+ mount: settings.mount,
190
+ assets,
191
+ authenticator,
97
192
  pathRouting: settings.routing === "path",
193
+ title: settings.title,
194
+ ...(settings.iconsDir ? { iconsDir: settings.iconsDir } : {}),
195
+ look: settings.look,
98
196
  ...(settings.presentation ? { presentation: settings.presentation } : {}),
99
197
  api,
198
+ ...(app ? { app } : {}),
100
199
  subscribe: (listener) => {
101
200
  listeners.push(listener);
102
201
  queue.onChange(listener);
@@ -108,11 +207,24 @@ async function build(settings: ResolvedOptions, context: Context) {
108
207
  services: [
109
208
  {
110
209
  name: "web-console",
111
- start: () => server.start(),
112
- stop: () => server.stop(),
210
+ start: () => {
211
+ server.start();
212
+ app?.start();
213
+ },
214
+ stop: () => {
215
+ app?.stop();
216
+ server.stop();
217
+ },
113
218
  },
114
219
  ],
115
- http: server.routes(settings.listener),
220
+ ...(app
221
+ ? { surfaces: [app.surface], directChannels: [app.directChannel] }
222
+ : {}),
223
+ // An upload is a form with one file in it: the limit of the file and its framing.
224
+ http: server.routes(
225
+ settings.listener,
226
+ rooms ? settings.uploadMaxBytes + UPLOAD_FRAMING_BYTES : undefined,
227
+ ),
116
228
  dashboard: [`Web console: ${settings.origin}${settings.mount}/`],
117
229
  };
118
230
  }