pi-roundtable-web 0.24.2 → 0.26.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.
Files changed (223) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +195 -16
  3. package/dist/account-zk4efb87.css +102 -0
  4. package/dist/chunk-account-hw49gd0d.js +1 -0
  5. package/dist/chunk-action-menu-rm5cyqkj.js +1 -0
  6. package/dist/chunk-agent-b017vswv.js +1 -0
  7. package/dist/chunk-arranged-rooms-q5v3dm1r.js +1 -0
  8. package/dist/chunk-character-creator-5dxcvd16.js +1 -0
  9. package/dist/chunk-codeeditor-pyt09fwg.js +2 -0
  10. package/dist/chunk-confirm-521yqmta.js +1 -0
  11. package/dist/chunk-connectors-hrm06nx8.js +1 -0
  12. package/dist/chunk-conversations-e26rrf01.js +1 -0
  13. package/dist/chunk-drag-v41gx2mc.js +1 -0
  14. package/dist/chunk-entry-jxxb3ath.js +1 -0
  15. package/dist/chunk-folder-list-b97xk12b.js +1 -0
  16. package/dist/chunk-folds-b8q7eags.js +1 -0
  17. package/dist/chunk-gate-8k61stsc.js +1 -0
  18. package/dist/chunk-group-scene-dx6wt19m.js +1 -0
  19. package/dist/chunk-group-table-e4jr8r3a.js +1 -0
  20. package/dist/chunk-host-state-g7e7c0k5.js +1 -0
  21. package/dist/chunk-host-ui-x7k6n07v.js +939 -0
  22. package/dist/chunk-index-014rjv9k.js +2 -0
  23. package/dist/chunk-index-2d0kg096.js +2 -0
  24. package/dist/chunk-index-2qd81wbx.js +2 -0
  25. package/dist/chunk-index-2stynxw6.css +1 -0
  26. package/dist/chunk-index-459drsft.js +2 -0
  27. package/dist/chunk-index-4mfj596y.js +2 -0
  28. package/dist/chunk-index-66nskrdj.js +47 -0
  29. package/dist/chunk-index-awx6nnta.js +2 -0
  30. package/dist/chunk-index-bbsytycb.js +2 -0
  31. package/dist/chunk-index-c07p8k0x.js +4 -0
  32. package/dist/chunk-index-cq1e9xdz.js +2 -0
  33. package/dist/chunk-index-d1prgfbp.js +2 -0
  34. package/dist/chunk-index-dhpt8qr9.js +2 -0
  35. package/dist/chunk-index-e5sd30xy.js +2 -0
  36. package/dist/chunk-index-g351921r.js +1 -0
  37. package/dist/chunk-index-hby0ap91.js +2 -0
  38. package/dist/chunk-index-hssjbr1k.js +2 -0
  39. package/dist/chunk-index-k2qq1gdj.js +2 -0
  40. package/dist/chunk-index-ke9ynm0n.js +2 -0
  41. package/dist/chunk-index-km40bhsz.js +2 -0
  42. package/dist/chunk-index-kpscvg85.js +2 -0
  43. package/dist/chunk-index-m0d100mg.js +2 -0
  44. package/dist/chunk-index-mb72snk9.js +2 -0
  45. package/dist/chunk-index-mjqqver5.js +4 -0
  46. package/dist/chunk-index-nqfkwkfp.js +2 -0
  47. package/dist/chunk-index-nwqk447w.js +2 -0
  48. package/dist/chunk-index-pcfhpm8v.js +302 -0
  49. package/dist/chunk-index-q8h8xzbm.js +2 -0
  50. package/dist/chunk-index-r5vszgfs.js +2 -0
  51. package/dist/{chunk-index-mpm90318.js → chunk-index-rhtvcbsq.js} +2 -2
  52. package/dist/chunk-index-rjv7vrwd.js +13 -0
  53. package/dist/chunk-index-v23m38p6.js +50 -0
  54. package/dist/chunk-index-va0g7d88.js +1 -0
  55. package/dist/{chunk-index-6t68gbex.js → chunk-index-wnjp6wv0.js} +2 -2
  56. package/dist/chunk-index-xgkd15n3.js +2 -0
  57. package/dist/chunk-index-y8rnq8zf.js +14 -0
  58. package/dist/chunk-index-z3k18atc.js +2 -0
  59. package/dist/chunk-lang-css-6bepqpnx.js +1 -0
  60. package/dist/{chunk-lang-dockerfile-ey0nvhhm.js → chunk-lang-dockerfile-hrav55t7.js} +1 -1
  61. package/dist/{chunk-lang-go-403qsw9f.js → chunk-lang-go-x9ahv6e5.js} +3 -3
  62. package/dist/chunk-lang-html-8jc72zt4.js +1 -0
  63. package/dist/chunk-lang-javascript-2p2qtbnf.js +1 -0
  64. package/dist/chunk-lang-json-hsemyd0r.js +1 -0
  65. package/dist/chunk-lang-markdown-zkwb4c9n.js +6 -0
  66. package/dist/{chunk-lang-python-9jk5a49y.js → chunk-lang-python-tewx7006.js} +2 -2
  67. package/dist/chunk-lang-rust-0ydx84s1.js +1 -0
  68. package/dist/{chunk-lang-shell-cgq6q0kj.js → chunk-lang-shell-gq1tmdz4.js} +1 -1
  69. package/dist/chunk-lang-sql-xt1qyycg.js +2 -0
  70. package/dist/{chunk-lang-toml-xh45nm68.js → chunk-lang-toml-0hamc5fc.js} +1 -1
  71. package/dist/chunk-lang-typescript-82xf7z2e.js +1 -0
  72. package/dist/{chunk-lang-xml-1mh879j9.js → chunk-lang-xml-wf6h5pt2.js} +1 -1
  73. package/dist/{chunk-lang-yaml-jac98srt.js → chunk-lang-yaml-7vk521h1.js} +1 -1
  74. package/dist/chunk-layout-actions-zh7bezk7.js +1 -0
  75. package/dist/chunk-menu-items-1n9qargb.js +2 -0
  76. package/dist/chunk-notes-t15bh04m.js +1 -0
  77. package/dist/chunk-notice-bell-w3y0h0sr.js +1 -0
  78. package/dist/chunk-notices-fa3g7qze.js +1 -0
  79. package/dist/chunk-onboarding-34v3nr17.js +1 -0
  80. package/dist/chunk-overview-km5vq375.js +1 -0
  81. package/dist/chunk-palette-a8txdgd0.js +1 -0
  82. package/dist/chunk-placement-items-wgaxsbvs.js +1 -0
  83. package/dist/chunk-profile-ypxdbsk1.js +1 -0
  84. package/dist/chunk-prompt-history-wm9dsy6s.js +1 -0
  85. package/dist/chunk-prompts-7mhhq7zs.js +1 -0
  86. package/dist/chunk-room-confirms-t11a1gsw.js +1 -0
  87. package/dist/chunk-room-menu-2tm2xtfx.js +1 -0
  88. package/dist/chunk-rooms-menu-button-wxnsxjwc.js +1 -0
  89. package/dist/chunk-schedules-2ft6xxyw.js +1 -0
  90. package/dist/chunk-seat-menu-d3npdtx1.js +1 -0
  91. package/dist/chunk-sheet-hosts-vngvp7wv.js +1 -0
  92. package/dist/chunk-skill-editor-65n2hsq4.js +8 -0
  93. package/dist/chunk-skills-shkdwes8.js +1 -0
  94. package/dist/chunk-switcher-list-g6hhmr1t.js +1 -0
  95. package/dist/chunk-table-1b6bsq2y.js +1 -0
  96. package/dist/chunk-table-seats-vy1qhdks.js +1 -0
  97. package/dist/chunk-table2d-n26b7gd0.js +1 -0
  98. package/dist/chunk-table3d-8aysyf56.js +4220 -0
  99. package/dist/chunk-transcript-355faceq.js +1 -0
  100. package/dist/chunk-where-j5qdt8a4.js +1 -0
  101. package/dist/chunk-workspace-vrbs6zx1.js +1 -0
  102. package/dist/chunk-zh-tw-zeg600bk.js +1 -0
  103. package/dist/index.html +1 -1
  104. package/dist/sw.js +1 -1
  105. package/package.json +5 -4
  106. package/src/api-types.ts +515 -6
  107. package/src/app/api-http.ts +66 -0
  108. package/src/app/app.ts +214 -33
  109. package/src/app/card-log.ts +6 -0
  110. package/src/app/desk.ts +47 -22
  111. package/src/app/files.ts +73 -7
  112. package/src/app/layout-routes.ts +239 -0
  113. package/src/app/layout.ts +133 -0
  114. package/src/app/presence.ts +2 -2
  115. package/src/app/prompt-routes.ts +167 -0
  116. package/src/app/protocol.ts +63 -1
  117. package/src/app/room-log.ts +148 -14
  118. package/src/app/rooms.ts +23 -0
  119. package/src/app/routes.ts +278 -156
  120. package/src/app/skill-doc-routes.ts +122 -0
  121. package/src/app/socket.ts +309 -77
  122. package/src/app/sockets.ts +108 -20
  123. package/src/app/surface.ts +72 -36
  124. package/src/app/team-routes.ts +457 -0
  125. package/src/app/threads.ts +14 -0
  126. package/src/app/uploads.ts +33 -7
  127. package/src/auth/authenticator.ts +71 -8
  128. package/src/auth/next.ts +52 -0
  129. package/src/auth/oidc-pages.ts +60 -0
  130. package/src/auth/oidc.ts +611 -0
  131. package/src/auth/sessions.ts +193 -0
  132. package/src/authz.ts +172 -0
  133. package/src/console-api.ts +122 -25
  134. package/src/console-server.ts +70 -6
  135. package/src/gate.ts +90 -0
  136. package/src/host/access.ts +161 -0
  137. package/src/host/cards.ts +99 -0
  138. package/src/host/context.ts +141 -0
  139. package/src/host/forms.ts +237 -0
  140. package/src/host/frames.ts +342 -0
  141. package/src/host/host-app.ts +176 -0
  142. package/src/host/host-context.ts +49 -0
  143. package/src/host/host-routes.ts +363 -0
  144. package/src/host/pages.ts +266 -0
  145. package/src/host/reads.ts +155 -0
  146. package/src/host/retention.ts +41 -0
  147. package/src/host/room-views.ts +137 -0
  148. package/src/host/rooms-service.ts +414 -0
  149. package/src/host/stored-json.ts +13 -0
  150. package/src/host/views.ts +103 -0
  151. package/src/i18n/zh-tw.ts +309 -10
  152. package/src/layout-order.ts +210 -0
  153. package/src/notices/api.ts +43 -0
  154. package/src/notices/mount.ts +292 -0
  155. package/src/notices/service.ts +294 -0
  156. package/src/notices/store.ts +298 -0
  157. package/src/notices/urls.ts +38 -0
  158. package/src/onboarding/api.ts +32 -0
  159. package/src/onboarding/service.ts +149 -0
  160. package/src/onboarding/steps.ts +75 -0
  161. package/src/onboarding/waivers.ts +40 -0
  162. package/src/options.ts +498 -34
  163. package/src/profile/api.ts +175 -0
  164. package/src/profile/links.ts +162 -0
  165. package/src/push/api.ts +189 -52
  166. package/src/push/dispatch.ts +156 -0
  167. package/src/push/drain.ts +139 -0
  168. package/src/push/mutes.ts +17 -0
  169. package/src/push/outbox.ts +218 -0
  170. package/src/push/prefs.ts +118 -0
  171. package/src/push/sender.ts +7 -2
  172. package/src/push/triggers.ts +56 -5
  173. package/src/push/vapid.ts +19 -0
  174. package/src/push/web-push-transport.ts +106 -0
  175. package/src/push-sender/index.ts +14 -0
  176. package/src/push-sender/run.ts +118 -0
  177. package/src/push-sender/session.ts +54 -0
  178. package/src/visitors.ts +94 -6
  179. package/src/web-app.ts +674 -0
  180. package/src/web-plugin.ts +293 -26
  181. package/dist/chunk-action-menu-7v5fce2v.js +0 -1
  182. package/dist/chunk-agent-2rw4ydw2.js +0 -1
  183. package/dist/chunk-agent-sheet-2q24dkkf.js +0 -3
  184. package/dist/chunk-character-creator-8q6ympxj.js +0 -1
  185. package/dist/chunk-codeeditor-fgmq298t.js +0 -2
  186. package/dist/chunk-connectors-mr53r7fh.js +0 -1
  187. package/dist/chunk-conversations-4vy2z6w9.js +0 -1
  188. package/dist/chunk-entry-jg1eem82.js +0 -1
  189. package/dist/chunk-group-scene-na95b1te.js +0 -1
  190. package/dist/chunk-index-11rjg8a8.js +0 -1
  191. package/dist/chunk-index-1y50d6sj.js +0 -2
  192. package/dist/chunk-index-5y2r0eaj.js +0 -47
  193. package/dist/chunk-index-7fphxxzd.js +0 -14
  194. package/dist/chunk-index-80f1zkp6.css +0 -1
  195. package/dist/chunk-index-87h45frg.js +0 -2
  196. package/dist/chunk-index-bnwmssyn.js +0 -2
  197. package/dist/chunk-index-ckgbszwt.js +0 -51
  198. package/dist/chunk-index-fmjjekhd.js +0 -2
  199. package/dist/chunk-index-t1m65yn6.js +0 -13
  200. package/dist/chunk-index-t711t17m.js +0 -2
  201. package/dist/chunk-index-txv7qvyy.js +0 -2
  202. package/dist/chunk-index-wr3dg24d.js +0 -2
  203. package/dist/chunk-index-yx683d5n.js +0 -2
  204. package/dist/chunk-lang-css-fkdg3x8x.js +0 -1
  205. package/dist/chunk-lang-html-v8h9g977.js +0 -1
  206. package/dist/chunk-lang-javascript-ykb2vhaa.js +0 -1
  207. package/dist/chunk-lang-json-2rze3rpy.js +0 -1
  208. package/dist/chunk-lang-markdown-6w2htnfc.js +0 -6
  209. package/dist/chunk-lang-rust-24fggcqx.js +0 -1
  210. package/dist/chunk-lang-sql-gjywb9ke.js +0 -2
  211. package/dist/chunk-lang-typescript-qh6rebyb.js +0 -1
  212. package/dist/chunk-menu-items-zjpyj51b.js +0 -2
  213. package/dist/chunk-notes-sjba7kw3.js +0 -1
  214. package/dist/chunk-overview-26r77kb6.js +0 -1
  215. package/dist/chunk-room-confirms-851184ep.js +0 -1
  216. package/dist/chunk-room-menu-8y9c026y.js +0 -1
  217. package/dist/chunk-skills-56s4hdbn.js +0 -1
  218. package/dist/chunk-table-qykpvp32.js +0 -1
  219. package/dist/chunk-table2d-k60ressj.js +0 -1
  220. package/dist/chunk-table3d-bv2ajk39.js +0 -4220
  221. package/dist/chunk-transcript-vekz1mh6.js +0 -1
  222. package/dist/chunk-workspace-hevefd0d.js +0 -1
  223. package/dist/chunk-zh-tw-5r2wjre0.js +0 -1
@@ -0,0 +1,414 @@
1
+ import type { ChannelKey, Logger } from "pi-roundtable";
2
+ import { PluginError } from "pi-roundtable";
3
+ import type { ServerFrame } from "../api-types.ts";
4
+ import { MAX_MESSAGE_CHARS } from "../api-types.ts";
5
+ import { type FileDir, isFileId, safeName } from "../app/files.ts";
6
+ import type { RoomLog } from "../app/room-log.ts";
7
+ import { viewerOfSocket } from "../app/socket.ts";
8
+ import type { AppSocket, Sockets } from "../app/sockets.ts";
9
+ import { imageTypeOf } from "../app/uploads.ts";
10
+ import type {
11
+ HostCard,
12
+ HostRoomKind,
13
+ HostRooms,
14
+ WebAppRegistry,
15
+ } from "../web-app.ts";
16
+ import type { HostAccess } from "./access.ts";
17
+ import type { HostCardLog } from "./cards.ts";
18
+ import { checkCard } from "./forms.ts";
19
+ import { cardView, checkPicture, type FoundRoom } from "./room-views.ts";
20
+
21
+ /** The most files one post carries. */
22
+ const MAX_POST_FILES = 20;
23
+ const AUTHOR_KINDS = new Set(["external", "member", "assistant", "notice"]);
24
+ const DAY_MS = 86_400_000;
25
+
26
+ export interface HostRoomsOptions {
27
+ registry: Pick<WebAppRegistry, "hostKindOf" | "kinds">;
28
+ sockets: Pick<Sockets, "each" | "deliver" | "reaches">;
29
+ log: Pick<RoomLog, "appendOnce" | "edit" | "remove" | "byReference" | "get">;
30
+ cards: Pick<HostCardLog, "create" | "update" | "get">;
31
+ /** The stored files, where `saveFile` keeps what a host saves. */
32
+ files: FileDir;
33
+ access: Pick<HostAccess, "forget" | "of">;
34
+ /** The most bytes of one saved file. */
35
+ maxFileBytes: number;
36
+ /** The same-origin URL of a room, and of one of its messages. */
37
+ roomUrl(room: ChannelKey, message?: number): string;
38
+ /** Which kinds a person's last list showed, so the list's changes reach only them. */
39
+ listed: ListedKinds;
40
+ now?: () => number;
41
+ logger: Logger;
42
+ }
43
+
44
+ /** What each person's latest `list` of each kind answered: whether it was defined for them. */
45
+ export class ListedKinds {
46
+ readonly #by = new Map<string, Set<string>>();
47
+
48
+ set(principalId: string, kind: string, defined: boolean): void {
49
+ const kinds = this.#by.get(principalId) ?? new Set<string>();
50
+ if (defined) kinds.add(kind);
51
+ else kinds.delete(kind);
52
+ this.#by.set(principalId, kinds);
53
+ }
54
+
55
+ has(principalId: string, kind: string): boolean {
56
+ return this.#by.get(principalId)?.has(kind) ?? false;
57
+ }
58
+ }
59
+
60
+ /** Collects a file's bytes, refusing more than `max`. */
61
+ async function bytesOf(
62
+ source: Uint8Array | ReadableStream,
63
+ max: number,
64
+ ): Promise<Uint8Array> {
65
+ if (source instanceof Uint8Array) {
66
+ if (source.byteLength > max)
67
+ throw new PluginError(`web app: a saved file is at most ${max} bytes`);
68
+ return source;
69
+ }
70
+ const chunks: Uint8Array[] = [];
71
+ let size = 0;
72
+ const reader = source.getReader();
73
+ for (;;) {
74
+ const { done, value } = await reader.read();
75
+ if (done) break;
76
+ const chunk = value as Uint8Array;
77
+ size += chunk.byteLength;
78
+ if (size > max) {
79
+ await reader.cancel();
80
+ throw new PluginError(`web app: a saved file is at most ${max} bytes`);
81
+ }
82
+ chunks.push(chunk);
83
+ }
84
+ const whole = new Uint8Array(size);
85
+ let at = 0;
86
+ for (const chunk of chunks) {
87
+ whole.set(chunk, at);
88
+ at += chunk.byteLength;
89
+ }
90
+ return whole;
91
+ }
92
+
93
+ /**
94
+ * What the app's stored file keeps as the type of a host's file: an image type only when the bytes
95
+ * are that image (the page shows those inline), anything else the host declared as given.
96
+ */
97
+ export function keptType(declared: string, bytes: Uint8Array): string {
98
+ const image = imageTypeOf(bytes.subarray(0, 16));
99
+ if (image) return image;
100
+ return declared.startsWith("image/") ? "application/octet-stream" : declared;
101
+ }
102
+
103
+ /** The PNG, JPEG or WebP the bytes are, if they are one: the images host Markdown may show. */
104
+ export function markdownImageType(bytes: Uint8Array): string | undefined {
105
+ const type = imageTypeOf(bytes.subarray(0, 16));
106
+ return type === "image/gif" ? undefined : type;
107
+ }
108
+
109
+ /**
110
+ * What a host writes into its rooms (`WebApp.rooms`): messages, their edits and removals, cards,
111
+ * and the files they carry. Everything the host passes is checked here, because it comes from
112
+ * a plugin, not from a person; what is stored is then shown, at once, to the sockets that follow
113
+ * the room and may read it now.
114
+ */
115
+ export class HostRoomsService implements HostRooms {
116
+ readonly #options: HostRoomsOptions;
117
+ /** One delivery at a time per room, so frames of quick changes arrive in the order they were made. */
118
+ readonly #chains = new Map<string, Promise<void>>();
119
+
120
+ constructor(options: HostRoomsOptions) {
121
+ this.#options = options;
122
+ }
123
+
124
+ #now(): number {
125
+ return (this.#options.now ?? Date.now)();
126
+ }
127
+
128
+ #found(room: string): FoundRoom {
129
+ const found = this.#options.registry.hostKindOf(room);
130
+ if (!found)
131
+ throw new PluginError(
132
+ `web app: ${JSON.stringify(room)} is not a room of a registered kind`,
133
+ );
134
+ return found;
135
+ }
136
+
137
+ /** When a file saved in a room of this kind goes, counted from `from`; undefined when the kind keeps files. */
138
+ expiryOf(kind: HostRoomKind, from: number): number | undefined {
139
+ return kind.retention ? from + kind.retention.fileDays * DAY_MS : undefined;
140
+ }
141
+
142
+ // ── Messages ──
143
+
144
+ async post(
145
+ room: ChannelKey,
146
+ m: Parameters<HostRooms["post"]>[1],
147
+ ): Promise<{ id: number }> {
148
+ const { kind } = this.#found(room);
149
+ const { author } = m;
150
+ if (!AUTHOR_KINDS.has(author.kind))
151
+ throw new PluginError(
152
+ "web app: a message's author is external, member, assistant or notice",
153
+ );
154
+ if (
155
+ typeof author.name !== "string" ||
156
+ author.name.trim() === "" ||
157
+ author.name.length > 120
158
+ )
159
+ throw new PluginError(
160
+ "web app: an author needs a name of at most 120 characters",
161
+ );
162
+ if (
163
+ author.label !== undefined &&
164
+ (typeof author.label !== "string" || author.label.length > 40)
165
+ )
166
+ throw new PluginError(
167
+ "web app: an author's label is at most 40 characters",
168
+ );
169
+ const picture = author.picture ? checkPicture(author.picture) : undefined;
170
+ const text = m.text ?? "";
171
+ if (typeof text !== "string" || text.length > MAX_MESSAGE_CHARS)
172
+ throw new PluginError(
173
+ `web app: a message's text is at most ${MAX_MESSAGE_CHARS} characters`,
174
+ );
175
+ const ids = m.files ?? [];
176
+ if (ids.length > MAX_POST_FILES || new Set(ids).size !== ids.length)
177
+ throw new PluginError(
178
+ `web app: a message carries at most ${MAX_POST_FILES} different files`,
179
+ );
180
+ if (text === "" && ids.length === 0)
181
+ throw new PluginError("web app: a message needs text or a file");
182
+ if (
183
+ m.reference !== undefined &&
184
+ (typeof m.reference !== "string" ||
185
+ m.reference === "" ||
186
+ m.reference.length > 300)
187
+ )
188
+ throw new PluginError("web app: a reference is 1 to 300 characters");
189
+ const files = ids.map((id) => {
190
+ const meta = this.#options.files.meta(id);
191
+ if (
192
+ !isFileId(id) ||
193
+ !meta ||
194
+ this.#options.files.binding(id)?.room !== room
195
+ )
196
+ throw new PluginError(
197
+ `web app: file ${id} was not saved in ${room}; save it with rooms.saveFile for this room`,
198
+ );
199
+ return meta;
200
+ });
201
+ const now = this.#now();
202
+ const at =
203
+ m.at && !Number.isNaN(m.at.getTime())
204
+ ? new Date(Math.min(m.at.getTime(), now))
205
+ : undefined;
206
+ const stored = await this.#options.log.appendOnce({
207
+ room,
208
+ // A person's words are `owner` lines, the assistant's an `agent`'s, a notice a `system` line.
209
+ kind:
210
+ author.kind === "assistant"
211
+ ? "agent"
212
+ : author.kind === "notice"
213
+ ? "system"
214
+ : "owner",
215
+ ...(author.principalId ? { principalId: author.principalId } : {}),
216
+ author: author.name.trim(),
217
+ text,
218
+ files,
219
+ authorKind: author.kind,
220
+ ...(author.label ? { authorLabel: author.label } : {}),
221
+ ...(picture ? { picture } : {}),
222
+ ...(m.reference ? { reference: m.reference } : {}),
223
+ ...(at ? { at } : {}),
224
+ });
225
+ if (!stored.created) return { id: stored.message.id };
226
+ // A file lives as long as the kind keeps files, counted from when the message was written.
227
+ if (at)
228
+ for (const file of files)
229
+ this.#options.files.bind(file.id, {
230
+ room,
231
+ ...(this.expiryOf(kind, at.getTime()) === undefined
232
+ ? {}
233
+ : { expiresAt: this.expiryOf(kind, at.getTime()) as number }),
234
+ });
235
+ this.#deliver(room, {
236
+ type: "host_message",
237
+ room,
238
+ message: stored.message,
239
+ });
240
+ this.#listChanged(kind.kind);
241
+ return { id: stored.message.id };
242
+ }
243
+
244
+ async edit(room: ChannelKey, id: number, text: string): Promise<void> {
245
+ this.#found(room);
246
+ if (typeof text !== "string" || text.length > MAX_MESSAGE_CHARS)
247
+ throw new PluginError(
248
+ `web app: a message's text is at most ${MAX_MESSAGE_CHARS} characters`,
249
+ );
250
+ const message = await this.#options.log.edit(room, id, text, true);
251
+ if (!message)
252
+ throw new PluginError(`web app: ${room} has no message ${id}`);
253
+ this.#deliver(room, { type: "host_edited", room, message });
254
+ }
255
+
256
+ async remove(room: ChannelKey, id: number): Promise<void> {
257
+ const { kind } = this.#found(room);
258
+ const row = await this.#options.log.remove(room, id);
259
+ if (!row) return;
260
+ for (const file of row.files)
261
+ if (this.#options.files.binding(file.id)?.room === room)
262
+ this.#options.files.remove(file.id);
263
+ this.#deliver(room, { type: "host_removed", room, id });
264
+ this.#listChanged(kind.kind);
265
+ }
266
+
267
+ byReference(
268
+ room: ChannelKey,
269
+ reference: string,
270
+ ): Promise<number | undefined> {
271
+ this.#found(room);
272
+ return this.#options.log.byReference(room, reference);
273
+ }
274
+
275
+ // ── Cards ──
276
+
277
+ async card(room: ChannelKey, card: HostCard): Promise<{ id: string }> {
278
+ this.#found(room);
279
+ checkCard(card);
280
+ const stored = await this.#options.cards.create(room, card);
281
+ this.#showCard(room, stored.id);
282
+ return { id: stored.id };
283
+ }
284
+
285
+ async updateCard(
286
+ room: ChannelKey,
287
+ id: string,
288
+ card: HostCard,
289
+ ): Promise<void> {
290
+ this.#found(room);
291
+ checkCard(card);
292
+ const stored = await this.#options.cards.update(room, id, card);
293
+ if (!stored) throw new PluginError(`web app: ${room} has no card ${id}`);
294
+ this.#showCard(room, id);
295
+ }
296
+
297
+ /** Shows a card to the room's readers, each its own buttons. */
298
+ #showCard(room: ChannelKey, id: string): void {
299
+ const found = this.#found(room);
300
+ this.#chain(room, async () => {
301
+ const stored = await this.#options.cards.get(room, id);
302
+ if (!stored) return;
303
+ const targets: AppSocket[] = [];
304
+ this.#options.sockets.each(
305
+ (connection) => this.#options.sockets.reaches(connection, { room }),
306
+ (connection) => targets.push(connection),
307
+ );
308
+ for (const connection of targets) {
309
+ const viewer = viewerOfSocket(connection);
310
+ const access = await this.#options.access.of(viewer, room);
311
+ if (!access) continue;
312
+ this.#options.sockets.deliver(
313
+ {
314
+ type: "host_card",
315
+ room,
316
+ card: cardView(viewer, found, access, stored),
317
+ },
318
+ { connection, audience: { room } },
319
+ );
320
+ }
321
+ });
322
+ }
323
+
324
+ // ── Files ──
325
+
326
+ async saveFile(
327
+ room: ChannelKey,
328
+ file: { name: string; type: string; bytes: Uint8Array | ReadableStream },
329
+ ): Promise<string> {
330
+ const { kind } = this.#found(room);
331
+ if (typeof file.type !== "string" || file.type === "")
332
+ throw new PluginError("web app: a saved file needs a type");
333
+ const bytes = await bytesOf(file.bytes, this.#options.maxFileBytes);
334
+ if (bytes.byteLength === 0)
335
+ throw new PluginError("web app: a saved file is not empty");
336
+ const expiresAt = this.expiryOf(kind, this.#now());
337
+ return this.#options.files.put(
338
+ safeName(file.name),
339
+ keptType(file.type, bytes),
340
+ bytes,
341
+ undefined,
342
+ { room, ...(expiresAt === undefined ? {} : { expiresAt }) },
343
+ ).id;
344
+ }
345
+
346
+ // ── What changed ──
347
+
348
+ link(room: ChannelKey, message?: number): string {
349
+ this.#found(room);
350
+ if (message !== undefined && !Number.isSafeInteger(message))
351
+ throw new PluginError("web app: a message number is a whole number");
352
+ return this.#options.roomUrl(room, message);
353
+ }
354
+
355
+ changed(room?: ChannelKey): void {
356
+ if (room === undefined) {
357
+ for (const kind of this.#options.registry.kinds())
358
+ this.#listChanged(kind.kind);
359
+ this.#options.access.forget({}).catch(() => undefined);
360
+ return;
361
+ }
362
+ const { kind } = this.#found(room);
363
+ // The followers are asked again first, so only those who still read the room are told to look again.
364
+ this.#chain(room, async () => {
365
+ await this.#options.access.forget({ room });
366
+ this.#deliver(room, { type: "room_changed", room }, false);
367
+ });
368
+ this.#listChanged(kind.kind);
369
+ }
370
+
371
+ accessChanged(principalId?: string): void {
372
+ this.#options.access
373
+ .forget(principalId === undefined ? {} : { principalId })
374
+ .catch(() => undefined);
375
+ // Their lists may differ now.
376
+ for (const kind of this.#options.registry.kinds())
377
+ this.#listChanged(kind.kind);
378
+ }
379
+
380
+ /** Tells those whose list of a kind was defined that it may differ; the frame holds nothing. */
381
+ #listChanged(kind: string): void {
382
+ const { sockets, listed } = this.#options;
383
+ sockets.each(
384
+ (connection) => listed.has(connection.principal.id, kind),
385
+ (connection) =>
386
+ sockets.deliver({ type: "list_changed", kind }, { connection }),
387
+ );
388
+ }
389
+
390
+ /** Puts a frame of a room on the sockets that follow it and may read it now, in order after earlier ones. */
391
+ #deliver(room: string, frame: ServerFrame, queued = true): void {
392
+ const send = async () => {
393
+ this.#options.sockets.deliver(frame, { room });
394
+ };
395
+ if (queued) this.#chain(room, send);
396
+ else send().catch(() => undefined);
397
+ }
398
+
399
+ #chain(room: string, step: () => Promise<void>): void {
400
+ const previous = this.#chains.get(room) ?? Promise.resolve();
401
+ const next = previous
402
+ .then(step)
403
+ .catch((error: unknown) =>
404
+ this.#options.logger.warn(
405
+ { room, err: error },
406
+ "a host room frame was not delivered",
407
+ ),
408
+ );
409
+ this.#chains.set(room, next);
410
+ void next.then(() => {
411
+ if (this.#chains.get(room) === next) this.#chains.delete(room);
412
+ });
413
+ }
414
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * A jsonb value as the driver returned it: already an object, or the text of one when the
3
+ * driver left it as text. Anything else the database holds here is a stored value gone wrong,
4
+ * which is named instead of surfacing as a bare parse error.
5
+ */
6
+ export function storedJson<T>(value: T | string, what: string): T {
7
+ if (typeof value !== "string") return value;
8
+ try {
9
+ return JSON.parse(value) as T;
10
+ } catch {
11
+ throw new Error(`the stored ${what} is not valid JSON`);
12
+ }
13
+ }
@@ -0,0 +1,103 @@
1
+ // What the host-room and page API answers, shared by the server (`src/host/`) and the page (`web/`).
2
+ // Times are ISO strings. A key is `<surface>:<kind>.<id>`.
3
+
4
+ import type {
5
+ FormSpecView,
6
+ HostActionView,
7
+ HostCardView,
8
+ PictureRef,
9
+ ReadMarkView,
10
+ RoomMessage,
11
+ } from "../api-types.ts";
12
+
13
+ /** One room of a kind, as the list shows it. */
14
+ export interface HostRoomSummaryView {
15
+ id: string;
16
+ key: string;
17
+ title: string;
18
+ subtitle?: string;
19
+ picture?: PictureRef;
20
+ /** ISO time of the newest message. */
21
+ lastAt?: string;
22
+ attention?: boolean;
23
+ /** Messages past the viewer's read cursor; absent when none. */
24
+ unread?: number;
25
+ }
26
+
27
+ /** `GET api/host/<kind>`: the rooms of one kind this viewer may open. */
28
+ export interface HostListView {
29
+ kind: string;
30
+ label: string;
31
+ rooms: HostRoomSummaryView[];
32
+ cursor?: string;
33
+ }
34
+
35
+ /** The header of one room: facts about it, what the viewer may do, and a banner. */
36
+ export interface HostHeaderView {
37
+ title: string;
38
+ fields: { label: string; value: string; link?: string }[];
39
+ actions: HostActionView[];
40
+ banner?: { tone: "warn" | "risk"; text: string };
41
+ }
42
+
43
+ /** `GET api/host/<kind>/<id>`: a room as it opens. */
44
+ export interface HostRoomView {
45
+ room: string;
46
+ kind: string;
47
+ id: string;
48
+ /** What this viewer may do in the room. */
49
+ access: { send: ("note" | "turn")[]; act: boolean };
50
+ header: HostHeaderView;
51
+ /** The newest messages, oldest first. */
52
+ messages: RoomMessage[];
53
+ more: boolean;
54
+ cards: HostCardView[];
55
+ /** The other readers' cursors; the viewer's own is `cursor`. */
56
+ reads: ReadMarkView[];
57
+ /** The viewer's own read cursor before they opened the room; absent when they never read it. */
58
+ cursor?: number;
59
+ }
60
+
61
+ /** `GET api/host/<kind>/<id>/messages`: a page of older messages. */
62
+ export interface HostMessagesView {
63
+ messages: RoomMessage[];
64
+ more: boolean;
65
+ reads: ReadMarkView[];
66
+ }
67
+
68
+ /** What the page shows of a section of a host page. */
69
+ export type PageSectionView =
70
+ | { kind: "markdown"; text: string }
71
+ | {
72
+ kind: "list";
73
+ title?: string;
74
+ rows: {
75
+ title: string;
76
+ subtitle?: string;
77
+ open?: string;
78
+ attention?: boolean;
79
+ actions?: HostActionView[];
80
+ }[];
81
+ }
82
+ | { kind: "form"; form: FormSpecView; action: string }
83
+ | {
84
+ kind: "card";
85
+ card: Omit<HostCardView, "id" | "room" | "at" | "updatedAt">;
86
+ };
87
+
88
+ /** `GET api/pages/<id>`. */
89
+ export interface HostPageView {
90
+ id: string;
91
+ title: string;
92
+ sections: PageSectionView[];
93
+ }
94
+
95
+ /** Where the page takes a person after an action: a room (and a message in it), or a page with a query. */
96
+ export type OpenView =
97
+ | { room: string; message?: number }
98
+ | { page: string; query?: Record<string, string> };
99
+
100
+ /** What an action answers, for a button, a form or a page's action. */
101
+ export type HostActionResult =
102
+ | { ok: true; notice?: string; open?: OpenView; url?: string }
103
+ | { ok: false; error: string; field?: string };