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
package/src/app/files.ts CHANGED
@@ -73,6 +73,16 @@ interface Meta {
73
73
  name: string;
74
74
  type: string;
75
75
  size: number;
76
+ /** The host room the file is bound to: only someone who may read that room is served it. */
77
+ room?: string;
78
+ /** Epoch milliseconds after which the sweep deletes the file; absent = the file is kept. */
79
+ expiresAt?: number;
80
+ }
81
+
82
+ /** What binds a stored file to a host room. */
83
+ export interface FileBinding {
84
+ room: string;
85
+ expiresAt?: number;
76
86
  }
77
87
 
78
88
  /** A file being written as its bytes arrive; `finish` makes it a stored file, `abort` leaves nothing. */
@@ -85,6 +95,13 @@ export interface FileWriter {
85
95
  abort(): Promise<void>;
86
96
  }
87
97
 
98
+ const bindingFields = (
99
+ binding: FileBinding,
100
+ ): Pick<Meta, "room" | "expiresAt"> => ({
101
+ room: binding.room,
102
+ ...(binding.expiresAt === undefined ? {} : { expiresAt: binding.expiresAt }),
103
+ });
104
+
88
105
  /**
89
106
  * A directory of files kept by id, each with a small record of its name and type beside it.
90
107
  * Both stores of the app, the files of replies and sent messages and the uploads waiting for a
@@ -101,20 +118,61 @@ export class FileDir {
101
118
  return join(this.dir, id);
102
119
  }
103
120
 
104
- /** Keeps the bytes under a new id. */
121
+ /** Keeps the bytes under a new id, bound to a host room when `binding` is given. */
105
122
  put(
106
123
  name: string,
107
124
  type: string,
108
125
  bytes: Uint8Array,
109
126
  id = crypto.randomUUID(),
127
+ binding?: FileBinding,
110
128
  ): RoomFile {
111
129
  mkdirSync(this.dir, { recursive: true, mode: 0o700 });
112
- const meta: Meta = { name: safeName(name), type, size: bytes.byteLength };
130
+ const meta: Meta = {
131
+ name: safeName(name),
132
+ type,
133
+ size: bytes.byteLength,
134
+ ...(binding ? bindingFields(binding) : {}),
135
+ };
113
136
  writeFileSync(this.#path(id), bytes, { mode: 0o600 });
114
137
  writeFileSync(`${this.#path(id)}.json`, JSON.stringify(meta), {
115
138
  mode: 0o600,
116
139
  });
117
- return { id, ...meta };
140
+ return { id, name: meta.name, type, size: meta.size };
141
+ }
142
+
143
+ #read(id: string): Meta | undefined {
144
+ if (!isFileId(id)) return undefined;
145
+ try {
146
+ return JSON.parse(readFileSync(`${this.#path(id)}.json`, "utf8")) as Meta;
147
+ } catch {
148
+ return undefined;
149
+ }
150
+ }
151
+
152
+ /** The room a file is bound to and when it expires; undefined for a file that is bound to none. */
153
+ binding(id: string): FileBinding | undefined {
154
+ const meta = this.#read(id);
155
+ return meta?.room === undefined
156
+ ? undefined
157
+ : {
158
+ room: meta.room,
159
+ ...(meta.expiresAt === undefined
160
+ ? {}
161
+ : { expiresAt: meta.expiresAt }),
162
+ };
163
+ }
164
+
165
+ /** Binds a stored file to a host room, or changes its expiry; false when there is no such file. */
166
+ bind(id: string, binding: FileBinding): boolean {
167
+ const meta = this.#read(id);
168
+ if (!meta || !existsSync(this.#path(id))) return false;
169
+ const { room: _room, expiresAt: _expiresAt, ...plain } = meta;
170
+ writeFileSync(
171
+ `${this.#path(id)}.json`,
172
+ JSON.stringify({ ...plain, ...bindingFields(binding) }),
173
+ { mode: 0o600 },
174
+ );
175
+ return true;
118
176
  }
119
177
 
120
178
  /** Starts a file under a new id whose bytes arrive in pieces, so it is never held whole. */
@@ -161,12 +219,20 @@ export class FileDir {
161
219
  }
162
220
 
163
221
  meta(id: string): RoomFile | undefined {
222
+ const meta = this.#read(id);
223
+ return meta && { id, name: meta.name, type: meta.type, size: meta.size };
224
+ }
225
+
226
+ /** Whether the file's bytes are still stored. */
227
+ has(id: string): boolean {
228
+ return isFileId(id) && existsSync(this.#path(id));
229
+ }
230
+
231
+ /** How long ago the file was written, in milliseconds; undefined when there is none. */
232
+ ageOf(id: string, now: number): number | undefined {
164
233
  if (!isFileId(id)) return undefined;
165
234
  try {
166
- const meta = JSON.parse(
167
- readFileSync(`${this.#path(id)}.json`, "utf8"),
168
- ) as Meta;
169
- return { id, name: meta.name, type: meta.type, size: meta.size };
235
+ return now - statSync(this.#path(id)).mtimeMs;
170
236
  } catch {
171
237
  return undefined;
172
238
  }
@@ -0,0 +1,239 @@
1
+ import type { Speaker } from "pi-roundtable";
2
+ import type { ApiError, LayoutView } from "../api-types.ts";
3
+ import type { Authenticated } from "../auth/authenticator.ts";
4
+ import { json } from "../http.ts";
5
+ import {
6
+ createFolder,
7
+ LayoutError,
8
+ placeFolder,
9
+ placeRoom,
10
+ removeFolder,
11
+ renameFolder,
12
+ setPinned,
13
+ shown,
14
+ } from "../layout-order.ts";
15
+ import type { PushMutes } from "../push/mutes.ts";
16
+ import { readLimited } from "../request-body.ts";
17
+ import type { LayoutStore } from "./layout.ts";
18
+ import type { ResolvedRoom } from "./rooms.ts";
19
+
20
+ /** The largest JSON body of a layout write: 4 KiB. */
21
+ const MAX_BODY_BYTES = 4 * 1024;
22
+ const FOLDER_ID = /^folder\.[0-9a-f-]{36}$/;
23
+
24
+ /** A refusal with the status the owner is answered with. */
25
+ class LayoutRefusal extends Error {
26
+ constructor(
27
+ readonly status: number,
28
+ message: string,
29
+ ) {
30
+ super(message);
31
+ }
32
+ }
33
+
34
+ const bad = (message: string) => new LayoutRefusal(400, message);
35
+
36
+ /** A refusal of the pure layout edits as the answer the owner reads. */
37
+ function refusalOf(error: LayoutError): LayoutRefusal {
38
+ switch (error.code) {
39
+ case "bad-name":
40
+ return new LayoutRefusal(
41
+ 400,
42
+ "Give the folder a name of 1 to 40 characters.",
43
+ );
44
+ case "no-folder":
45
+ return new LayoutRefusal(404, "There is no such folder.");
46
+ case "duplicate":
47
+ return new LayoutRefusal(409, "A folder of that name exists.");
48
+ case "full":
49
+ return new LayoutRefusal(409, "There are 50 folders already.");
50
+ }
51
+ }
52
+
53
+ async function bodyOf(request: Request): Promise<Record<string, unknown>> {
54
+ const raw = await readLimited(request, MAX_BODY_BYTES);
55
+ if (!raw) throw new LayoutRefusal(413, "The body is too large.");
56
+ let value: unknown;
57
+ try {
58
+ value = JSON.parse(raw.toString("utf8"));
59
+ } catch {
60
+ throw bad("The body is not JSON.");
61
+ }
62
+ if (typeof value !== "object" || value === null || Array.isArray(value))
63
+ throw bad("The body is not an object.");
64
+ return value as Record<string, unknown>;
65
+ }
66
+
67
+ /** `before` of a body: a key or id, none for the end; anything else is refused. */
68
+ function beforeOf(body: Record<string, unknown>): string | null {
69
+ const { before } = body;
70
+ if (before === undefined || before === null) return null;
71
+ if (typeof before !== "string") throw bad("before must be a key or null.");
72
+ return before;
73
+ }
74
+
75
+ export interface LayoutRoutesOptions {
76
+ layout: LayoutStore;
77
+ /** The rooms whose push the owners muted. */
78
+ mutes: Pick<PushMutes, "setMany">;
79
+ /** The keys of the rooms that may be placed: the active agents' and groups' rooms but Home. */
80
+ placeable(): ReadonlySet<string>;
81
+ /** Home's key, which stays at the top. */
82
+ home(): string | undefined;
83
+ /** Reads the person's tier again; refuses with 403 when they are no owner. */
84
+ owner(admission: Authenticated): Promise<Speaker>;
85
+ /** Called after a change the other pages should refetch. */
86
+ changed(): void;
87
+ }
88
+
89
+ /**
90
+ * The room list's arrangement over HTTP: `GET api/rooms` carries it, `api/folders` makes, renames,
91
+ * places and removes folders, and `PUT api/rooms/<key>/folder` and `…/pin` place one room. Every
92
+ * write is an owner's (tier read again) and answers with the whole layout, so the writing page
93
+ * and the others converge on what the server stored.
94
+ */
95
+ export class LayoutRoutes {
96
+ readonly #options: LayoutRoutesOptions;
97
+
98
+ constructor(options: LayoutRoutesOptions) {
99
+ this.#options = options;
100
+ }
101
+
102
+ /** The layout as readers see it: only the rooms that exist. */
103
+ async view(): Promise<LayoutView> {
104
+ const rooms = this.#options.placeable();
105
+ return shown(await this.#options.layout.view(), (key) => rooms.has(key));
106
+ }
107
+
108
+ /** `api/folders` and `api/folders/<id>`; `parts` is the path split after the mount. */
109
+ async folders(
110
+ request: Request,
111
+ parts: string[],
112
+ admission: Authenticated,
113
+ ): Promise<Response> {
114
+ return this.#guard(async () => {
115
+ const { method } = request;
116
+ const [, , id, action] = parts;
117
+ if (parts.length > 4 || (parts.length === 4 && action !== "mute"))
118
+ throw new LayoutRefusal(404, notAnApi);
119
+ if (action === "mute") return this.#muteFolder(request, id, admission);
120
+ if (!id) {
121
+ if (method !== "POST") throw new LayoutRefusal(405, notAllowed);
122
+ await this.#options.owner(admission);
123
+ const { name } = await bodyOf(request);
124
+ if (typeof name !== "string")
125
+ throw new LayoutRefusal(
126
+ 400,
127
+ "Give the folder a name of 1 to 40 characters.",
128
+ );
129
+ const folder = `folder.${crypto.randomUUID()}`;
130
+ return json(
131
+ await this.#write((layout) => createFolder(layout, folder, name)),
132
+ 201,
133
+ );
134
+ }
135
+ if (method !== "PATCH" && method !== "DELETE")
136
+ throw new LayoutRefusal(405, notAllowed);
137
+ await this.#options.owner(admission);
138
+ if (!FOLDER_ID.test(id))
139
+ throw new LayoutRefusal(404, "There is no such folder.");
140
+ if (method === "DELETE")
141
+ return json(await this.#write((layout) => removeFolder(layout, id)));
142
+ const body = await bodyOf(request);
143
+ const { name } = body;
144
+ if (name !== undefined && typeof name !== "string")
145
+ throw bad("name must be text.");
146
+ const placed = "before" in body;
147
+ if (name === undefined && !placed) throw bad("Give a name or a place.");
148
+ const before = beforeOf(body);
149
+ return json(
150
+ await this.#write((layout) => {
151
+ let next = layout;
152
+ if (name !== undefined) next = renameFolder(next, id, name);
153
+ if (placed) next = placeFolder(next, id, before);
154
+ return next;
155
+ }),
156
+ );
157
+ });
158
+ }
159
+
160
+ /**
161
+ * `PUT api/folders/<id>/mute`: mutes or unmutes every room in the folder as one step, through the
162
+ * rooms' own mutes. A room moved into the folder later keeps its own mute.
163
+ */
164
+ async #muteFolder(
165
+ request: Request,
166
+ id: string | undefined,
167
+ admission: Authenticated,
168
+ ): Promise<Response> {
169
+ if (request.method !== "PUT") throw new LayoutRefusal(405, notAllowed);
170
+ const owner = await this.#options.owner(admission);
171
+ const folder = (await this.view()).folders.find((one) => one.id === id);
172
+ if (!folder) throw new LayoutRefusal(404, "There is no such folder.");
173
+ const { muted } = await bodyOf(request);
174
+ if (typeof muted !== "boolean") throw bad("muted must be true or false.");
175
+ await this.#options.mutes.setMany(folder.rooms, muted, owner.principalId);
176
+ this.#options.changed();
177
+ return json({ muted, rooms: folder.rooms });
178
+ }
179
+
180
+ /** `PUT api/rooms/<key>/folder` and `…/pin`, for a room that resolved. */
181
+ async place(
182
+ request: Request,
183
+ room: ResolvedRoom,
184
+ action: "folder" | "pin",
185
+ admission: Authenticated,
186
+ ): Promise<Response> {
187
+ return this.#guard(async () => {
188
+ await this.#options.owner(admission);
189
+ if (room.kind === "thread") throw bad("A thread stays under its room.");
190
+ if (room.key === this.#options.home())
191
+ throw bad("Home stays at the top.");
192
+ const body = await bodyOf(request);
193
+ const before = beforeOf(body);
194
+ if (action === "pin") {
195
+ const { pinned } = body;
196
+ if (typeof pinned !== "boolean")
197
+ throw bad("pinned must be true or false.");
198
+ return json(
199
+ await this.#write((layout) =>
200
+ setPinned(layout, room.key, pinned, before),
201
+ ),
202
+ );
203
+ }
204
+ const { folder } = body;
205
+ if (folder !== null && typeof folder !== "string")
206
+ throw bad("folder must be a folder id or null.");
207
+ return json(
208
+ await this.#write((layout) =>
209
+ placeRoom(layout, room.key, folder, before),
210
+ ),
211
+ );
212
+ });
213
+ }
214
+
215
+ /** Stores an edit, tells the other pages, and answers with the layout as readers see it. */
216
+ async #write(edit: (layout: LayoutView) => LayoutView): Promise<LayoutView> {
217
+ const stored = await this.#options.layout.change(edit);
218
+ this.#options.changed();
219
+ const rooms = this.#options.placeable();
220
+ return shown(stored, (key) => rooms.has(key));
221
+ }
222
+
223
+ async #guard(run: () => Promise<Response>): Promise<Response> {
224
+ try {
225
+ return await run();
226
+ } catch (error) {
227
+ const refusal = error instanceof LayoutError ? refusalOf(error) : error;
228
+ if (refusal instanceof LayoutRefusal)
229
+ return json(
230
+ { error: refusal.message } satisfies ApiError,
231
+ refusal.status,
232
+ );
233
+ throw error;
234
+ }
235
+ }
236
+ }
237
+
238
+ const notAllowed = "Method not allowed.";
239
+ const notAnApi = "There is no such API.";
@@ -0,0 +1,133 @@
1
+ import type { SQL } from "bun";
2
+ import type { Migration } from "pi-roundtable";
3
+ import type { LayoutView } from "../api-types.ts";
4
+ import { forgetRoom } from "../layout-order.ts";
5
+
6
+ /** What the layout routes need of the stored arrangement; `RoomLayout` is the database's. */
7
+ export interface LayoutStore {
8
+ /** Everything stored, including rooms that no longer exist. */
9
+ view(): Promise<LayoutView>;
10
+ /**
11
+ * Applies an edit to the stored layout as one step that no other edit overlaps, and returns the
12
+ * result. An edit that throws changes nothing.
13
+ */
14
+ change(edit: (layout: LayoutView) => LayoutView): Promise<LayoutView>;
15
+ /**
16
+ * Drops a room's pin and its place in a folder, for a room that is deleted for good (an archived
17
+ * agent returns to its place when it comes back, so archiving does not call this).
18
+ */
19
+ forget(room: string): Promise<void>;
20
+ }
21
+
22
+ interface FolderRow {
23
+ id: string;
24
+ name: string;
25
+ }
26
+ interface RoomRow {
27
+ room: string;
28
+ folder: string;
29
+ }
30
+
31
+ /**
32
+ * The arrangement of the room list, shared by every owner and device: the pinned rooms
33
+ * (`web_room_pins`), the folders (`web_room_folders`) and the rooms in them
34
+ * (`web_room_folder_rooms`). Positions are dense (0..n-1) within each list and rewritten on every
35
+ * change under one advisory lock, so two devices editing at once cannot leave a gap or a duplicate.
36
+ * Rows of rooms that no longer exist are kept, so a re-created agent returns to its place.
37
+ */
38
+ export class RoomLayout implements LayoutStore {
39
+ readonly #sql: SQL;
40
+
41
+ constructor(sql: SQL) {
42
+ this.#sql = sql;
43
+ }
44
+
45
+ static readonly migration: Migration = {
46
+ name: "web-room-layout",
47
+ up: async (sql) => {
48
+ await sql`
49
+ CREATE TABLE IF NOT EXISTS web_room_folders (
50
+ id text PRIMARY KEY,
51
+ name text NOT NULL,
52
+ position integer NOT NULL,
53
+ created_at timestamptz NOT NULL DEFAULT now()
54
+ )`;
55
+ await sql`
56
+ CREATE TABLE IF NOT EXISTS web_room_folder_rooms (
57
+ room text PRIMARY KEY,
58
+ folder text NOT NULL REFERENCES web_room_folders (id) ON DELETE CASCADE,
59
+ position integer NOT NULL
60
+ )`;
61
+ await sql`CREATE INDEX IF NOT EXISTS web_room_folder_rooms_folder ON web_room_folder_rooms (folder, position)`;
62
+ await sql`
63
+ CREATE TABLE IF NOT EXISTS web_room_pins (
64
+ room text PRIMARY KEY,
65
+ position integer NOT NULL
66
+ )`;
67
+ },
68
+ };
69
+
70
+ async forget(room: string): Promise<void> {
71
+ await this.change((layout) => forgetRoom(layout, room));
72
+ }
73
+
74
+ async view(): Promise<LayoutView> {
75
+ // The three SELECTs share one snapshot, so an edit that commits between them is seen whole or not at all.
76
+ return this.#sql.begin("isolation level repeatable read, read only", read);
77
+ }
78
+
79
+ async change(edit: (layout: LayoutView) => LayoutView): Promise<LayoutView> {
80
+ return this.#sql.begin(async (tx) => {
81
+ await tx`SELECT pg_advisory_xact_lock(hashtextextended('web-room-layout', 0))`;
82
+ const before = await read(tx);
83
+ const after = edit(before);
84
+ if (JSON.stringify(after) !== JSON.stringify(before))
85
+ await write(tx, after);
86
+ return after;
87
+ });
88
+ }
89
+ }
90
+
91
+ async function read(tx: SQL): Promise<LayoutView> {
92
+ const folders: FolderRow[] = await tx`
93
+ SELECT id, name FROM web_room_folders ORDER BY position, created_at, id`;
94
+ const inside: RoomRow[] = await tx`
95
+ SELECT room, folder FROM web_room_folder_rooms ORDER BY folder, position`;
96
+ const pins: { room: string }[] = await tx`
97
+ SELECT room FROM web_room_pins ORDER BY position`;
98
+ return {
99
+ pins: pins.map((row) => row.room),
100
+ folders: folders.map((folder) => ({
101
+ id: folder.id,
102
+ name: folder.name,
103
+ rooms: inside
104
+ .filter((row) => row.folder === folder.id)
105
+ .map((r) => r.room),
106
+ })),
107
+ };
108
+ }
109
+
110
+ /** Rewrites the three tables to match the layout, positions dense. */
111
+ async function write(tx: SQL, layout: LayoutView): Promise<void> {
112
+ await tx`DELETE FROM web_room_folder_rooms`;
113
+ await tx`DELETE FROM web_room_pins`;
114
+ const ids = layout.folders.map((folder) => folder.id);
115
+ if (ids.length === 0) await tx`DELETE FROM web_room_folders`;
116
+ else await tx`DELETE FROM web_room_folders WHERE id NOT IN ${tx(ids)}`;
117
+ for (const [position, folder] of layout.folders.entries())
118
+ await tx`
119
+ INSERT INTO web_room_folders (id, name, position)
120
+ VALUES (${folder.id}, ${folder.name}, ${position})
121
+ ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, position = EXCLUDED.position`;
122
+ const pins = layout.pins.map((room, position) => ({ room, position }));
123
+ if (pins.length > 0) await tx`INSERT INTO web_room_pins ${tx(pins)}`;
124
+ const placed = layout.folders.flatMap((folder) =>
125
+ folder.rooms.map((room, position) => ({
126
+ room,
127
+ folder: folder.id,
128
+ position,
129
+ })),
130
+ );
131
+ if (placed.length > 0)
132
+ await tx`INSERT INTO web_room_folder_rooms ${tx(placed)}`;
133
+ }
@@ -46,7 +46,7 @@ export class Presence {
46
46
  return { type: "presence", agents };
47
47
  }
48
48
 
49
- /** Sends the table to every socket once changes settle, if it changed. */
49
+ /** Sends the table to the owners' sockets once changes settle, if it changed. */
50
50
  schedule(): void {
51
51
  if (this.#pending || this.#stopped) return;
52
52
  this.#pending = setTimeout(() => {
@@ -62,7 +62,7 @@ export class Presence {
62
62
  const text = JSON.stringify(frame.agents);
63
63
  if (text === this.#last || this.#stopped) return;
64
64
  this.#last = text;
65
- this.#options.sockets.broadcast(frame);
65
+ this.#options.sockets.deliver(frame, { owners: true });
66
66
  }
67
67
 
68
68
  stop(): void {
@@ -0,0 +1,167 @@
1
+ import type {
2
+ PromptDocuments,
3
+ PromptDocumentView,
4
+ PromptVersion,
5
+ } from "pi-roundtable";
6
+ import { PromptConflictError } from "pi-roundtable";
7
+ import type {
8
+ PromptConflict,
9
+ PromptHistoryView,
10
+ PromptRevert,
11
+ PromptSave,
12
+ PromptsView,
13
+ PromptVersionView,
14
+ PromptView,
15
+ } from "../api-types.ts";
16
+ import { json } from "../http.ts";
17
+ import { bad, bodyOf, HttpError } from "./api-http.ts";
18
+ import type { OwnerOf } from "./team-routes.ts";
19
+
20
+ /** The most bytes a character takes in a JSON body: a control character is escaped as `\u00XX`. */
21
+ const BYTES_PER_CHAR = 6;
22
+ /** What a body carries besides the texts: the braces, the keys and some room. */
23
+ const BODY_OVERHEAD = 4 * 1024;
24
+
25
+ /**
26
+ * The largest body of a write to a document: the new text, at most `maxChars` characters, and
27
+ * `base`, the text in force when the edit began, which may be longer (a file version has no limit).
28
+ */
29
+ const bodyLimit = (document: PromptDocumentView): number =>
30
+ (Math.max(document.maxChars, document.text.length, document.fileText.length) +
31
+ document.maxChars) *
32
+ BYTES_PER_CHAR +
33
+ BODY_OVERHEAD;
34
+
35
+ export interface PromptApiOptions {
36
+ prompts: Pick<PromptDocuments, "list" | "get" | "set" | "revert" | "history">;
37
+ }
38
+
39
+ const viewOf = (document: PromptDocumentView): PromptView => ({
40
+ id: document.id,
41
+ label: document.label,
42
+ ...(document.description ? { description: document.description } : {}),
43
+ differs: document.differs,
44
+ ...(document.broken ? { broken: document.broken } : {}),
45
+ ...(document.override
46
+ ? {
47
+ override: {
48
+ by: document.override.by,
49
+ at: document.override.at.toISOString(),
50
+ },
51
+ }
52
+ : {}),
53
+ text: document.text,
54
+ fileText: document.fileText,
55
+ maxChars: document.maxChars,
56
+ });
57
+
58
+ /** The answer to a write, or 409 with the document as it is now when it was not the text the writer saw. */
59
+ async function changed(
60
+ write: () => Promise<PromptDocumentView>,
61
+ ): Promise<Response> {
62
+ try {
63
+ return json(viewOf(await write()));
64
+ } catch (error) {
65
+ if (!(error instanceof PromptConflictError)) throw error;
66
+ const refused = new HttpError(
67
+ 409,
68
+ "This prompt changed while you were editing it.",
69
+ );
70
+ return json(
71
+ {
72
+ error: refused.message,
73
+ conflict: "changed",
74
+ current: viewOf(error.current),
75
+ } satisfies PromptConflict,
76
+ refused.status,
77
+ );
78
+ }
79
+ }
80
+
81
+ export const promptVersionView = (
82
+ version: PromptVersion,
83
+ ): PromptVersionView => ({
84
+ id: version.id,
85
+ action: version.action,
86
+ text: version.text,
87
+ previous: version.previous,
88
+ by: version.by,
89
+ at: version.at.toISOString(),
90
+ });
91
+
92
+ /**
93
+ * The app's REST for the prompt documents: the main prompts the owner edits outside the files.
94
+ * Every call, a read too, is an owner's (the tier read again); a write is also checked for the
95
+ * console's `Origin` before it gets here. A save names the text it was edited from, and a document
96
+ * that changed meanwhile answers 409 with what is there now.
97
+ */
98
+ export class PromptApi {
99
+ readonly #options: PromptApiOptions;
100
+
101
+ constructor(options: PromptApiOptions) {
102
+ this.#options = options;
103
+ }
104
+
105
+ async handle(
106
+ request: Request,
107
+ parts: string[],
108
+ owner: OwnerOf,
109
+ ): Promise<Response> {
110
+ const { method } = request;
111
+ const [, , id, action] = parts;
112
+ const { prompts } = this.#options;
113
+ if (!id) {
114
+ if (method !== "GET") throw new HttpError(405, "Method not allowed.");
115
+ await owner();
116
+ return json({
117
+ documents: prompts
118
+ .list()
119
+ .map(
120
+ ({ differs, id: key, label, description, override, broken }) => ({
121
+ id: key,
122
+ label,
123
+ ...(description ? { description } : {}),
124
+ differs,
125
+ ...(broken ? { broken } : {}),
126
+ ...(override
127
+ ? {
128
+ override: {
129
+ by: override.by,
130
+ at: override.at.toISOString(),
131
+ },
132
+ }
133
+ : {}),
134
+ }),
135
+ ),
136
+ } satisfies PromptsView);
137
+ }
138
+ // The tier is read before the id is looked up, so a person below the owner learns of no id.
139
+ const speaker = await owner();
140
+ const document = prompts.get(id);
141
+ if (!document) throw new HttpError(404, "There is no such prompt.");
142
+ if (!action && method === "GET") return json(viewOf(document));
143
+ if (!action && method === "PUT") {
144
+ const body = await bodyOf(request, bodyLimit(document));
145
+ const { text, base } = body;
146
+ if (typeof text !== "string") throw bad("text must be text.");
147
+ if (typeof base !== "string")
148
+ throw bad("base must be the text the prompt was edited from.");
149
+ const save: PromptSave = { text, base };
150
+ // The registry compares `base` where the write waits its turn, so two saves cannot both pass.
151
+ return changed(() => prompts.set(id, save.text, speaker.name, save.base));
152
+ }
153
+ if (action === "revert" && method === "POST" && parts.length === 4) {
154
+ const body = await bodyOf(request, bodyLimit(document));
155
+ if (typeof body.base !== "string")
156
+ throw bad("base must be the text the prompt was reverted from.");
157
+ const revert: PromptRevert = { base: body.base };
158
+ return changed(() => prompts.revert(id, speaker.name, revert.base));
159
+ }
160
+ if (action === "history" && method === "GET" && parts.length === 4) {
161
+ return json({
162
+ versions: (await prompts.history(id)).map(promptVersionView),
163
+ } satisfies PromptHistoryView);
164
+ }
165
+ throw new HttpError(404, "There is no such API.");
166
+ }
167
+ }