pi-roundtable-web 0.25.0 → 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 (222) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +145 -8
  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-0pm9p3nn.js → chunk-agent-b017vswv.js} +1 -1
  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-anhghy3k.js → chunk-connectors-hrm06nx8.js} +1 -1
  12. package/dist/chunk-conversations-e26rrf01.js +1 -0
  13. package/dist/{chunk-drag-q4fx9b22.js → chunk-drag-v41gx2mc.js} +1 -1
  14. package/dist/{chunk-entry-he1embgp.js → chunk-entry-jxxb3ath.js} +1 -1
  15. package/dist/{chunk-folder-list-5cp8stna.js → chunk-folder-list-b97xk12b.js} +1 -1
  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-bvfqjh77.js → chunk-index-014rjv9k.js} +2 -2
  23. package/dist/{chunk-index-d5abvq7v.js → chunk-index-2d0kg096.js} +2 -2
  24. package/dist/chunk-index-2qd81wbx.js +2 -0
  25. package/dist/{chunk-index-9b1kt7qw.css → chunk-index-2stynxw6.css} +1 -1
  26. package/dist/{chunk-index-gpkzhq1g.js → chunk-index-459drsft.js} +2 -2
  27. package/dist/chunk-index-4mfj596y.js +2 -0
  28. package/dist/{chunk-index-18gxy79n.js → chunk-index-66nskrdj.js} +2 -2
  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-s681nny9.js → chunk-index-km40bhsz.js} +2 -2
  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-zfd00hwb.js → chunk-index-nqfkwkfp.js} +2 -2
  47. package/dist/chunk-index-nwqk447w.js +2 -0
  48. package/dist/{chunk-index-wmbmmrd4.js → chunk-index-pcfhpm8v.js} +4 -4
  49. package/dist/chunk-index-q8h8xzbm.js +2 -0
  50. package/dist/chunk-index-r5vszgfs.js +2 -0
  51. package/dist/{chunk-index-27qca4mr.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-krtk6nn2.js → chunk-index-wnjp6wv0.js} +2 -2
  56. package/dist/chunk-index-xgkd15n3.js +2 -0
  57. package/dist/{chunk-index-9jqcskb4.js → chunk-index-y8rnq8zf.js} +5 -5
  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-d5mzxs60.js → chunk-lang-dockerfile-hrav55t7.js} +1 -1
  61. package/dist/{chunk-lang-go-830fmhba.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-5eaarz7j.js → chunk-lang-json-hsemyd0r.js} +1 -1
  65. package/dist/chunk-lang-markdown-zkwb4c9n.js +6 -0
  66. package/dist/{chunk-lang-python-fjw84w10.js → chunk-lang-python-tewx7006.js} +2 -2
  67. package/dist/{chunk-lang-rust-5rbp2zdk.js → chunk-lang-rust-0ydx84s1.js} +1 -1
  68. package/dist/{chunk-lang-shell-vzagpym3.js → chunk-lang-shell-gq1tmdz4.js} +1 -1
  69. package/dist/{chunk-lang-sql-0c2w9c2y.js → chunk-lang-sql-xt1qyycg.js} +2 -2
  70. package/dist/{chunk-lang-toml-yr9fv6eb.js → chunk-lang-toml-0hamc5fc.js} +1 -1
  71. package/dist/chunk-lang-typescript-82xf7z2e.js +1 -0
  72. package/dist/{chunk-lang-xml-eze08p42.js → chunk-lang-xml-wf6h5pt2.js} +1 -1
  73. package/dist/{chunk-lang-yaml-c4b6ft3y.js → chunk-lang-yaml-7vk521h1.js} +1 -1
  74. package/dist/chunk-layout-actions-zh7bezk7.js +1 -0
  75. package/dist/{chunk-menu-items-jrwxycr1.js → chunk-menu-items-1n9qargb.js} +2 -2
  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-jv4s7hmj.js → chunk-seat-menu-d3npdtx1.js} +1 -1
  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-4sje1fr0.js → chunk-table2d-n26b7gd0.js} +1 -1
  98. package/dist/{chunk-table3d-h64skazp.js → chunk-table3d-8aysyf56.js} +12 -12
  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 +301 -4
  107. package/src/app/app.ts +196 -32
  108. package/src/app/desk.ts +47 -22
  109. package/src/app/files.ts +73 -7
  110. package/src/app/presence.ts +2 -2
  111. package/src/app/protocol.ts +63 -1
  112. package/src/app/room-log.ts +141 -14
  113. package/src/app/routes.ts +124 -26
  114. package/src/app/socket.ts +309 -77
  115. package/src/app/sockets.ts +108 -20
  116. package/src/app/surface.ts +72 -36
  117. package/src/app/uploads.ts +33 -7
  118. package/src/auth/authenticator.ts +71 -8
  119. package/src/auth/next.ts +52 -0
  120. package/src/auth/oidc-pages.ts +60 -0
  121. package/src/auth/oidc.ts +611 -0
  122. package/src/auth/sessions.ts +193 -0
  123. package/src/authz.ts +172 -0
  124. package/src/console-api.ts +122 -25
  125. package/src/console-server.ts +70 -6
  126. package/src/gate.ts +90 -0
  127. package/src/host/access.ts +161 -0
  128. package/src/host/cards.ts +99 -0
  129. package/src/host/context.ts +141 -0
  130. package/src/host/forms.ts +237 -0
  131. package/src/host/frames.ts +342 -0
  132. package/src/host/host-app.ts +176 -0
  133. package/src/host/host-context.ts +49 -0
  134. package/src/host/host-routes.ts +363 -0
  135. package/src/host/pages.ts +266 -0
  136. package/src/host/reads.ts +155 -0
  137. package/src/host/retention.ts +41 -0
  138. package/src/host/room-views.ts +137 -0
  139. package/src/host/rooms-service.ts +414 -0
  140. package/src/host/stored-json.ts +13 -0
  141. package/src/host/views.ts +103 -0
  142. package/src/i18n/zh-tw.ts +132 -0
  143. package/src/notices/api.ts +43 -0
  144. package/src/notices/mount.ts +292 -0
  145. package/src/notices/service.ts +294 -0
  146. package/src/notices/store.ts +298 -0
  147. package/src/notices/urls.ts +38 -0
  148. package/src/onboarding/api.ts +32 -0
  149. package/src/onboarding/service.ts +149 -0
  150. package/src/onboarding/steps.ts +75 -0
  151. package/src/onboarding/waivers.ts +40 -0
  152. package/src/options.ts +498 -34
  153. package/src/profile/api.ts +175 -0
  154. package/src/profile/links.ts +162 -0
  155. package/src/push/api.ts +189 -52
  156. package/src/push/dispatch.ts +156 -0
  157. package/src/push/drain.ts +139 -0
  158. package/src/push/outbox.ts +218 -0
  159. package/src/push/prefs.ts +118 -0
  160. package/src/push/sender.ts +7 -2
  161. package/src/push/triggers.ts +56 -5
  162. package/src/push/vapid.ts +19 -0
  163. package/src/push/web-push-transport.ts +106 -0
  164. package/src/push-sender/index.ts +14 -0
  165. package/src/push-sender/run.ts +118 -0
  166. package/src/push-sender/session.ts +54 -0
  167. package/src/visitors.ts +94 -6
  168. package/src/web-app.ts +674 -0
  169. package/src/web-plugin.ts +268 -26
  170. package/dist/chunk-action-menu-qe839g4b.js +0 -1
  171. package/dist/chunk-arranged-rooms-enpfswmg.js +0 -1
  172. package/dist/chunk-character-creator-6yf29xwx.js +0 -1
  173. package/dist/chunk-codeeditor-gdbdg7rp.js +0 -2
  174. package/dist/chunk-confirm-xrw7d732.js +0 -1
  175. package/dist/chunk-conversations-ry228aes.js +0 -1
  176. package/dist/chunk-folds-hff104tk.js +0 -1
  177. package/dist/chunk-group-scene-h228jdat.js +0 -1
  178. package/dist/chunk-index-0p5h2kdz.js +0 -2
  179. package/dist/chunk-index-0pt08yd9.js +0 -1
  180. package/dist/chunk-index-12bw9yj8.js +0 -2
  181. package/dist/chunk-index-2ad17eh4.js +0 -2
  182. package/dist/chunk-index-31wh5twj.js +0 -52
  183. package/dist/chunk-index-4dy94pbz.js +0 -2
  184. package/dist/chunk-index-4ecjtdy8.js +0 -13
  185. package/dist/chunk-index-4v1b7ks9.js +0 -2
  186. package/dist/chunk-index-5d8yjj4m.js +0 -2
  187. package/dist/chunk-index-89ajmxh7.js +0 -2
  188. package/dist/chunk-index-b9ny4623.js +0 -2
  189. package/dist/chunk-index-e1dr6g10.js +0 -2
  190. package/dist/chunk-index-hk3xyy20.js +0 -2
  191. package/dist/chunk-index-jm23vn03.js +0 -4
  192. package/dist/chunk-index-k7gh0jvz.js +0 -2
  193. package/dist/chunk-index-ky42ev0b.js +0 -2
  194. package/dist/chunk-index-pa2yyqh0.js +0 -2
  195. package/dist/chunk-index-pcvt668q.js +0 -2
  196. package/dist/chunk-index-pqw27eye.js +0 -1
  197. package/dist/chunk-index-ymbjrsp6.js +0 -2
  198. package/dist/chunk-lang-css-t85r8rz0.js +0 -1
  199. package/dist/chunk-lang-html-n6jparge.js +0 -1
  200. package/dist/chunk-lang-javascript-xj00cp97.js +0 -1
  201. package/dist/chunk-lang-markdown-zxx8eke8.js +0 -6
  202. package/dist/chunk-lang-typescript-8f6yywt3.js +0 -1
  203. package/dist/chunk-layout-actions-bv0chj41.js +0 -1
  204. package/dist/chunk-notes-9b7pgqfe.js +0 -1
  205. package/dist/chunk-overview-8yw71esz.js +0 -1
  206. package/dist/chunk-palette-frms7axe.js +0 -1
  207. package/dist/chunk-placement-items-fvfy9m0y.js +0 -1
  208. package/dist/chunk-prompt-history-yrqehads.js +0 -1
  209. package/dist/chunk-prompts-g6609tqx.js +0 -1
  210. package/dist/chunk-room-confirms-0a2y0925.js +0 -1
  211. package/dist/chunk-room-menu-g11q97br.js +0 -1
  212. package/dist/chunk-rooms-menu-button-h9qge48n.js +0 -1
  213. package/dist/chunk-schedules-ezmacrbj.js +0 -1
  214. package/dist/chunk-sheet-hosts-b0azyfhb.js +0 -1
  215. package/dist/chunk-skill-editor-h6k6whwg.js +0 -8
  216. package/dist/chunk-skills-gqcfhq56.js +0 -1
  217. package/dist/chunk-switcher-list-mcpamqnj.js +0 -1
  218. package/dist/chunk-table-s8cde1xz.js +0 -1
  219. package/dist/chunk-table-seats-wqkjn8bc.js +0 -1
  220. package/dist/chunk-transcript-c59c6mpm.js +0 -1
  221. package/dist/chunk-workspace-0cyspkk4.js +0 -1
  222. package/dist/chunk-zh-tw-nc93jm10.js +0 -1
@@ -1,6 +1,7 @@
1
1
  import { QUESTIONNAIRE_LIMITS } from "pi-roundtable";
2
2
  import {
3
3
  type ClientFrame,
4
+ type FormInput,
4
5
  MAX_MESSAGE_FILES,
5
6
  type PromptAnswer,
6
7
  } from "../api-types.ts";
@@ -40,6 +41,31 @@ function parseAnswer(value: unknown): PromptAnswer | undefined {
40
41
  };
41
42
  }
42
43
 
44
+ /** The most fields and the longest value a form's input may carry. */
45
+ const FORM_FIELDS = 40;
46
+ const FORM_VALUE_CHARS = 32_000;
47
+
48
+ /** A form's input: text, or a list of text, by field name. */
49
+ function parseInput(value: unknown): FormInput | undefined {
50
+ if (!isRecord(value)) return undefined;
51
+ const entries = Object.entries(value);
52
+ if (entries.length > FORM_FIELDS) return undefined;
53
+ const input: Record<string, string | string[]> = {};
54
+ for (const [name, field] of entries) {
55
+ if (name.length === 0 || name.length > ID_CHARS) return undefined;
56
+ if (typeof field === "string" && field.length <= FORM_VALUE_CHARS)
57
+ input[name] = field;
58
+ else if (
59
+ Array.isArray(field) &&
60
+ field.length <= FORM_FIELDS &&
61
+ field.every((one) => isText(one) && one.length <= FORM_VALUE_CHARS)
62
+ )
63
+ input[name] = field as string[];
64
+ else return undefined;
65
+ }
66
+ return input;
67
+ }
68
+
43
69
  function parseAnswers(value: Record<string, unknown>): ClientFrame | undefined {
44
70
  const { prompt, answers, note } = value;
45
71
  if (!isId(prompt) || !Array.isArray(answers)) return undefined;
@@ -99,8 +125,10 @@ export function parseClientFrame(
99
125
  ...(value.room === undefined ? {} : { room: value.room as string }),
100
126
  };
101
127
  case "send": {
102
- const { id, room, text, uploads } = value;
128
+ const { id, room, text, uploads, mode } = value;
103
129
  if (!isId(id) || !isId(room) || !isText(text)) return undefined;
130
+ if (mode !== undefined && mode !== "note" && mode !== "turn")
131
+ return undefined;
104
132
  if (
105
133
  uploads !== undefined &&
106
134
  !(
@@ -118,8 +146,42 @@ export function parseClientFrame(
118
146
  ...(uploads === undefined || uploads.length === 0
119
147
  ? {}
120
148
  : { uploads: uploads as string[] }),
149
+ ...(mode === undefined ? {} : { mode }),
121
150
  };
122
151
  }
152
+ case "subscribe":
153
+ case "unsubscribe":
154
+ return isId(value.room)
155
+ ? { type: value.type, room: value.room }
156
+ : undefined;
157
+ case "act": {
158
+ const { id, room, page, action, card, input } = value;
159
+ if (!isId(id) || !isId(action)) return undefined;
160
+ // One target: a room or a page, never both and never neither.
161
+ if ((room === undefined) === (page === undefined)) return undefined;
162
+ if (!optional(room, isId) || !optional(page, isId)) return undefined;
163
+ if (!optional(card, isId)) return undefined;
164
+ const parsed = input === undefined ? undefined : parseInput(input);
165
+ if (input !== undefined && !parsed) return undefined;
166
+ return {
167
+ type: "act",
168
+ id,
169
+ action,
170
+ ...(room === undefined ? {} : { room: room as string }),
171
+ ...(page === undefined ? {} : { page: page as string }),
172
+ ...(card === undefined ? {} : { card: card as string }),
173
+ ...(parsed ? { input: parsed } : {}),
174
+ };
175
+ }
176
+ case "ack":
177
+ return isId(value.tag) ? { type: "ack", tag: value.tag } : undefined;
178
+ case "read":
179
+ return isId(value.room) &&
180
+ typeof value.upTo === "number" &&
181
+ Number.isSafeInteger(value.upTo) &&
182
+ value.upTo >= 0
183
+ ? { type: "read", room: value.room, upTo: value.upTo }
184
+ : undefined;
123
185
  case "stop":
124
186
  return isId(value.room) ? { type: "stop", room: value.room } : undefined;
125
187
  case "approval":
@@ -1,6 +1,12 @@
1
1
  import type { SQL } from "bun";
2
2
  import type { Migration } from "pi-roundtable";
3
- import type { RoomFile, RoomMessage } from "../api-types.ts";
3
+ import type {
4
+ HostAuthorKind,
5
+ PictureRef,
6
+ RoomFile,
7
+ RoomMessage,
8
+ } from "../api-types.ts";
9
+ import { storedJson } from "../host/stored-json.ts";
4
10
 
5
11
  /** A message to store; the log numbers it and stamps its time. */
6
12
  export interface NewRoomMessage {
@@ -11,6 +17,14 @@ export interface NewRoomMessage {
11
17
  author: string;
12
18
  text: string;
13
19
  files?: RoomFile[];
20
+ /** Host rooms: who wrote it, as the host's kinds of author name them. */
21
+ authorKind?: HostAuthorKind;
22
+ authorLabel?: string;
23
+ picture?: PictureRef;
24
+ /** Host rooms: the host's own id for the message, unique within the room. */
25
+ reference?: string;
26
+ /** When it was written, for an import; the log's clock otherwise. Retention counts from it. */
27
+ at?: Date;
14
28
  }
15
29
 
16
30
  interface Row {
@@ -23,16 +37,17 @@ interface Row {
23
37
  text: string;
24
38
  files: RoomFile[] | string;
25
39
  created_at: Date;
40
+ author_kind: HostAuthorKind | null;
41
+ author_label: string | null;
42
+ picture: PictureRef | string | null;
43
+ edited_at: Date | null;
26
44
  }
27
45
 
28
46
  /** The most messages one page of history holds. */
29
47
  export const PAGE_LIMIT = 100;
30
48
 
31
49
  function messageOf(row: Row): RoomMessage {
32
- const files =
33
- typeof row.files === "string"
34
- ? (JSON.parse(row.files) as RoomFile[])
35
- : row.files;
50
+ const files = storedJson(row.files, "message files");
36
51
  return {
37
52
  id: Number(row.id),
38
53
  room: row.room,
@@ -43,6 +58,14 @@ function messageOf(row: Row): RoomMessage {
43
58
  text: row.text,
44
59
  files,
45
60
  at: row.created_at.toISOString(),
61
+ ...(row.author_kind === null ? {} : { authorKind: row.author_kind }),
62
+ ...(row.author_label === null ? {} : { authorLabel: row.author_label }),
63
+ ...(row.picture === null
64
+ ? {}
65
+ : {
66
+ picture: storedJson(row.picture, "picture"),
67
+ }),
68
+ ...(row.edited_at === null ? {} : { edited: true as const }),
46
69
  };
47
70
  }
48
71
 
@@ -79,29 +102,133 @@ export class RoomLog {
79
102
  },
80
103
  };
81
104
 
105
+ /** The columns a host room's messages add: who wrote it, the host's reference, and when it was changed. */
106
+ static readonly hostMigration: Migration = {
107
+ name: "web-room-messages-host",
108
+ up: async (sql) => {
109
+ await sql`ALTER TABLE web_room_messages ADD COLUMN IF NOT EXISTS author_kind text`;
110
+ await sql`ALTER TABLE web_room_messages ADD COLUMN IF NOT EXISTS author_label text`;
111
+ await sql`ALTER TABLE web_room_messages ADD COLUMN IF NOT EXISTS picture jsonb`;
112
+ await sql`ALTER TABLE web_room_messages ADD COLUMN IF NOT EXISTS reference text`;
113
+ await sql`ALTER TABLE web_room_messages ADD COLUMN IF NOT EXISTS edited_at timestamptz`;
114
+ await sql`CREATE UNIQUE INDEX IF NOT EXISTS web_room_messages_reference ON web_room_messages (room, reference) WHERE reference IS NOT NULL`;
115
+ await sql`CREATE INDEX IF NOT EXISTS web_room_messages_created ON web_room_messages (created_at)`;
116
+ },
117
+ };
118
+
82
119
  async append(message: NewRoomMessage): Promise<RoomMessage> {
83
- const files = JSON.stringify(message.files ?? []);
84
- const [row]: Row[] = await this.#sql`
85
- INSERT INTO web_room_messages (room, kind, principal_id, agent, author_name, text, files)
86
- VALUES (${message.room}, ${message.kind}, ${message.principalId ?? null},
87
- ${message.agent ?? null}, ${message.author}, ${message.text}, ${files}::jsonb)
88
- RETURNING *`;
120
+ const [row] = await this.#insert(message, false);
89
121
  if (!row) throw new Error("the room log stored nothing");
90
122
  return messageOf(row);
91
123
  }
92
124
 
93
- /** Changes the text of a message stored in the room; undefined when the room holds no such message. */
125
+ /**
126
+ * Stores a host room's message once per `reference`: a second post with the same reference in
127
+ * the room stores nothing and answers the message already there, with `created` false.
128
+ */
129
+ async appendOnce(
130
+ message: NewRoomMessage,
131
+ ): Promise<{ message: RoomMessage; created: boolean }> {
132
+ const [row] = await this.#insert(message, message.reference !== undefined);
133
+ if (row) return { message: messageOf(row), created: true };
134
+ const [existing]: Row[] = await this.#sql`
135
+ SELECT * FROM web_room_messages
136
+ WHERE room = ${message.room} AND reference = ${message.reference ?? null}`;
137
+ if (!existing) throw new Error("the room log stored nothing");
138
+ return { message: messageOf(existing), created: false };
139
+ }
140
+
141
+ async #insert(message: NewRoomMessage, once: boolean): Promise<Row[]> {
142
+ const files = JSON.stringify(message.files ?? []);
143
+ const picture = message.picture ? JSON.stringify(message.picture) : null;
144
+ const at = message.at ?? new Date();
145
+ return once
146
+ ? await this.#sql`
147
+ INSERT INTO web_room_messages (room, kind, principal_id, agent, author_name, text, files,
148
+ author_kind, author_label, picture, reference, created_at)
149
+ VALUES (${message.room}, ${message.kind}, ${message.principalId ?? null},
150
+ ${message.agent ?? null}, ${message.author}, ${message.text}, ${files}::jsonb,
151
+ ${message.authorKind ?? null}, ${message.authorLabel ?? null}, ${picture}::jsonb,
152
+ ${message.reference ?? null}, ${at})
153
+ ON CONFLICT (room, reference) WHERE reference IS NOT NULL DO NOTHING
154
+ RETURNING *`
155
+ : await this.#sql`
156
+ INSERT INTO web_room_messages (room, kind, principal_id, agent, author_name, text, files,
157
+ author_kind, author_label, picture, reference, created_at)
158
+ VALUES (${message.room}, ${message.kind}, ${message.principalId ?? null},
159
+ ${message.agent ?? null}, ${message.author}, ${message.text}, ${files}::jsonb,
160
+ ${message.authorKind ?? null}, ${message.authorLabel ?? null}, ${picture}::jsonb,
161
+ ${message.reference ?? null}, ${at})
162
+ RETURNING *`;
163
+ }
164
+
165
+ /**
166
+ * Changes the text of a message stored in the room; undefined when the room holds no such
167
+ * message. `mark` shows it as edited, as a host's edit of someone's words does.
168
+ */
94
169
  async edit(
95
170
  room: string,
96
171
  id: number,
97
172
  text: string,
173
+ mark = false,
98
174
  ): Promise<RoomMessage | undefined> {
175
+ const [row]: Row[] = mark
176
+ ? await this.#sql`
177
+ UPDATE web_room_messages SET text = ${text}, edited_at = now()
178
+ WHERE id = ${id} AND room = ${room} RETURNING *`
179
+ : await this.#sql`
180
+ UPDATE web_room_messages SET text = ${text}
181
+ WHERE id = ${id} AND room = ${room} RETURNING *`;
182
+ return row && messageOf(row);
183
+ }
184
+
185
+ /** The message the host's reference names, if the room holds one. */
186
+ async byReference(
187
+ room: string,
188
+ reference: string,
189
+ ): Promise<number | undefined> {
190
+ const [row]: { id: string | number | bigint }[] = await this.#sql`
191
+ SELECT id FROM web_room_messages WHERE room = ${room} AND reference = ${reference}`;
192
+ return row === undefined ? undefined : Number(row.id);
193
+ }
194
+
195
+ /** One stored message of the room, or undefined. */
196
+ async get(room: string, id: number): Promise<RoomMessage | undefined> {
197
+ const [row]: Row[] = await this.#sql`
198
+ SELECT * FROM web_room_messages WHERE room = ${room} AND id = ${id}`;
199
+ return row && messageOf(row);
200
+ }
201
+
202
+ /** Deletes one message of the room; the message as it was, or undefined when the room held none. */
203
+ async remove(room: string, id: number): Promise<RoomMessage | undefined> {
99
204
  const [row]: Row[] = await this.#sql`
100
- UPDATE web_room_messages SET text = ${text}
101
- WHERE id = ${id} AND room = ${room} RETURNING *`;
205
+ DELETE FROM web_room_messages WHERE room = ${room} AND id = ${id} RETURNING *`;
102
206
  return row && messageOf(row);
103
207
  }
104
208
 
209
+ /**
210
+ * Deletes the messages older than `before` of the rooms whose key starts with `prefix`, the
211
+ * kind's rooms; the deleted messages, so their files can go too. Age is the message's own time.
212
+ */
213
+ async sweepKind(prefix: string, before: Date): Promise<RoomMessage[]> {
214
+ const rows: Row[] = await this.#sql`
215
+ DELETE FROM web_room_messages
216
+ WHERE starts_with(room, ${prefix}) AND created_at < ${before} RETURNING *`;
217
+ return rows.map(messageOf);
218
+ }
219
+
220
+ /** The newest message id of each room that has one, for the read cursors. */
221
+ async newestIds(rooms: readonly string[]): Promise<Map<string, number>> {
222
+ const found = new Map<string, number>();
223
+ if (rooms.length === 0) return found;
224
+ const rows: { room: string; id: string | number | bigint }[] = await this
225
+ .#sql`
226
+ SELECT room, max(id) AS id FROM web_room_messages
227
+ WHERE room IN ${this.#sql(rooms)} GROUP BY room`;
228
+ for (const row of rows) found.set(row.room, Number(row.id));
229
+ return found;
230
+ }
231
+
105
232
  /** The newest `limit` messages older than `before` (all of them when absent), oldest first. */
106
233
  async page(
107
234
  room: string,
package/src/app/routes.ts CHANGED
@@ -33,8 +33,14 @@ import type {
33
33
  } from "../api-types.ts";
34
34
  import type { Authenticated } from "../auth/authenticator.ts";
35
35
  import { type ConsolePresentation, message } from "../features.ts";
36
+ import type { Gate } from "../gate.ts";
37
+ import type { HostApp } from "../host/host-app.ts";
38
+ import type { RoomReads } from "../host/reads.ts";
39
+ import { readsOf, withExpiredFiles } from "../host/room-views.ts";
36
40
  import { json } from "../http.ts";
41
+ import type { ResolvedOptions } from "../options.ts";
37
42
  import type { PushMutes } from "../push/mutes.ts";
43
+ import { viewerOf, type WebAppRegistry } from "../web-app.ts";
38
44
  import { bad, bodyOf, HttpError, notFound } from "./api-http.ts";
39
45
  import type { CardLog } from "./card-log.ts";
40
46
  import { type FileDir, fileResponse, isFileId } from "./files.ts";
@@ -73,9 +79,9 @@ export interface AppApiOptions {
73
79
  /** The owners' shared arrangement of the room list: pins and folders. */
74
80
  layout: LayoutStore;
75
81
  conversations: Pick<ConversationPort, "startFresh">;
76
- identity: Pick<IdentityService, "tierOf">;
82
+ identity: Pick<IdentityService, "principal">;
77
83
  /** Present when the schedules pane is served. */
78
- schedules?: Pick<ScheduleStore, "all" | "remove">;
84
+ schedules?: Pick<ScheduleStore, "all" | "get" | "remove">;
79
85
  directory: Pick<AgentDirectory, "agent" | "agents" | "group" | "groups">;
80
86
  team: Pick<
81
87
  AgentTeam,
@@ -109,12 +115,28 @@ export interface AppApiOptions {
109
115
  /** Present when the host has prompt documents (the prompts plugin): the main prompts the owner edits. */
110
116
  prompts?: PromptApiOptions["prompts"];
111
117
  avatars: Pick<AvatarStudio, "serve">;
118
+ /** The read cursors, for each room's unread count and who has read what. */
119
+ reads?: Pick<RoomReads, "unread" | "cursorOf" | "marks">;
120
+ /** Who may be served a file bound to a host room or a page. */
121
+ host?: Pick<HostApp, "mayRead">;
122
+ /** The name the app shows for a person. */
123
+ nameOf?(principalId: string): Promise<string>;
112
124
  /** Present when the workspace pane is served. */
113
125
  workspace?: WorkspaceFiles;
114
126
  /** The mount, which a room's agent picture hangs under. */
115
127
  mount: string;
116
128
  /** How the console signs the person in, as the session reports it. */
117
129
  authMode: SessionView["auth"];
130
+ /** Who the app admits; the session reports the person's tier only where it admits more than owners. */
131
+ admission?: ResolvedOptions["admission"];
132
+ /** The area handlers the pieces of the app plug in; without it the API is the owners' as in 0.25. */
133
+ registry?: Pick<WebAppRegistry, "areaFor">;
134
+ /** The onboarding gate; without it nobody is gated. */
135
+ gate?: Pick<Gate, "required">;
136
+ /** The tiers whose people may use push; default owners. */
137
+ pushTiers?: ResolvedOptions["push"]["tiers"];
138
+ /** Whether the yolo and long-running switches are served; `PUT rooms/<key>/mode` answers 404 without them. Default true. */
139
+ modeSwitches?: boolean;
118
140
  /** The quiet hours of push, as the settings page shows them. */
119
141
  quietHours?: NonNullable<SessionView["quietHours"]>;
120
142
  /** Called after a change the other pages should refetch. */
@@ -296,7 +318,12 @@ export class AppApi {
296
318
  const parts = partsOf(rest);
297
319
  if (!parts) return undefined;
298
320
  const [first, second] = parts;
321
+ const area =
322
+ first === "api" && second !== undefined
323
+ ? this.#options.registry?.areaFor(second)
324
+ : undefined;
299
325
  const mine =
326
+ area !== undefined ||
300
327
  (first === "avatars" && parts.length === 2) ||
301
328
  (first === "api" &&
302
329
  [
@@ -316,6 +343,11 @@ export class AppApi {
316
343
  ].includes(second ?? ""));
317
344
  if (!mine) return undefined;
318
345
  try {
346
+ if (area)
347
+ return await area.handle(request, parts.slice(2), {
348
+ visitor: admission.visitor,
349
+ viewer: viewerOf(admission.visitor),
350
+ });
319
351
  return await this.#route(request, parts, admission);
320
352
  } catch (error) {
321
353
  if (error instanceof HttpError || error instanceof UploadRefusal)
@@ -338,16 +370,13 @@ export class AppApi {
338
370
  }
339
371
  }
340
372
 
341
- /** The owner who writes: their tier read again, as a speaker for the calls that need one. */
373
+ /** The owner who writes: the tier the request was admitted at, as a speaker for the calls that need one. */
342
374
  async #owner(admission: Authenticated): Promise<Speaker> {
343
- const { principal } = admission.visitor;
344
- const tier = await this.#options.identity.tierOf(principal.id);
375
+ const { principal, tier, speakerId } = admission.visitor;
345
376
  if (tier !== "owner")
346
377
  throw new HttpError(403, "Only an owner may do this.");
347
378
  return {
348
- id: admission.actor
349
- ? `${admission.actor.provider}:${admission.actor.subject}`
350
- : principal.id,
379
+ id: speakerId,
351
380
  name: principal.displayName,
352
381
  tier,
353
382
  principalId: principal.id,
@@ -368,7 +397,8 @@ export class AppApi {
368
397
  new Response("Not found", { status: 404 })
369
398
  );
370
399
  }
371
- if (area === "session" && method === "GET") return this.#session(admission);
400
+ if (area === "session" && method === "GET")
401
+ return await this.#session(admission);
372
402
  if (area === "models" && method === "GET")
373
403
  return json({
374
404
  models: await this.#options.team.usableModels(),
@@ -376,20 +406,21 @@ export class AppApi {
376
406
  if (area === "rooms") return this.#rooms(request, parts, admission);
377
407
  if (area === "folders")
378
408
  return this.#layout.folders(request, parts, admission);
379
- if (area === "uploads" && method === "POST" && !id) {
380
- const owner = await this.#owner(admission);
409
+ // An upload is the sender's own, whoever they are; a file answers by the room it is bound to.
410
+ if (area === "uploads" && method === "POST" && !id)
381
411
  return json(
382
- await this.#options.uploads.receive(request, owner.principalId),
412
+ await this.#options.uploads.receive(
413
+ request,
414
+ admission.visitor.principal.id,
415
+ ),
383
416
  );
384
- }
385
417
  if (area === "uploads" && method === "DELETE" && id && !action) {
386
- const owner = await this.#owner(admission);
387
- if (!this.#options.uploads.discard(id, owner.principalId))
418
+ if (!this.#options.uploads.discard(id, admission.visitor.principal.id))
388
419
  throw notFound();
389
420
  return json({ ok: true });
390
421
  }
391
422
  if (area === "files" && method === "GET" && id && !action)
392
- return this.#file(id);
423
+ return await this.#file(id, admission);
393
424
  if (area === "workspace") return this.#workspace(request, parts, admission);
394
425
  if (area === "schedules") return this.#schedules(method, id, admission);
395
426
  const owner = () => this.#owner(admission);
@@ -410,13 +441,21 @@ export class AppApi {
410
441
  throw new HttpError(404, "There is no such API.");
411
442
  }
412
443
 
413
- #session(admission: Authenticated): Response {
414
- const { principal } = admission.visitor;
444
+ async #session(admission: Authenticated): Promise<Response> {
445
+ const { principal, tier } = admission.visitor;
446
+ const { admission: admits = "owners", gate } = this.#options;
447
+ const pushTiers = this.#options.pushTiers ?? ["owner"];
448
+ const owed =
449
+ admits === "access" && gate
450
+ ? await gate.required(viewerOf(admission.visitor))
451
+ : [];
415
452
  return json({
416
453
  principal: { id: principal.id, name: principal.displayName },
417
454
  surface: this.#options.surface.surface,
418
455
  auth: this.#options.authMode,
419
- push: true,
456
+ ...(admits === "access" ? { tier } : {}),
457
+ ...(owed.length > 0 ? { gate: owed } : {}),
458
+ ...(pushTiers.includes(tier) ? { push: true as const } : {}),
420
459
  ...(this.#options.prompts ? { prompts: true } : {}),
421
460
  ...(this.#options.skills ? { skillEdit: true } : {}),
422
461
  ...(this.#options.quietHours
@@ -436,10 +475,22 @@ export class AppApi {
436
475
  const { method } = request;
437
476
  const [, , key, action] = parts;
438
477
  const { rooms, log, cards, modes, mutes, conversations } = this.#options;
478
+ // The agent rooms are the owners'; to anyone else this API does not exist.
479
+ if (admission.visitor.tier !== "owner") throw notFound();
439
480
  if (!key) {
440
481
  if (method !== "GET") throw new HttpError(405, "Method not allowed.");
482
+ const listed = await rooms.list();
483
+ // What each room holds past this person's cursor; a room they never read starts from what it has now.
484
+ const unread = await this.#options.reads?.unread(
485
+ admission.visitor.principal.id,
486
+ listed.map((one) => one.key),
487
+ new Set(listed.map((one) => one.key)),
488
+ );
441
489
  return json({
442
- rooms: await rooms.list(),
490
+ rooms: listed.map((one) => {
491
+ const count = unread?.get(one.key);
492
+ return count ? { ...one, unread: count } : one;
493
+ }),
443
494
  ...(await this.#layout.view()),
444
495
  } satisfies RoomsView);
445
496
  }
@@ -461,17 +512,26 @@ export class AppApi {
461
512
  // The cards stand among the messages by the time they opened; a page that has older
462
513
  // messages leaves the cards before its first one to the older page.
463
514
  const first = page.messages[0];
515
+ const { reads, nameOf } = this.#options;
516
+ const self = admission.visitor.principal.id;
517
+ const cursor = await reads?.cursorOf(room.key, self);
464
518
  return json({
465
519
  ...page,
520
+ messages: withExpiredFiles(page.messages, this.#options.files),
466
521
  cards: await cards.during(room.key, {
467
522
  ...(page.more && first ? { from: first.at } : {}),
468
523
  ...(beforeId === undefined ? {} : { before: beforeId }),
469
524
  }),
525
+ ...(reads && nameOf
526
+ ? { reads: await readsOf(reads, room.key, self, nameOf) }
527
+ : {}),
528
+ ...(cursor === undefined ? {} : { cursor }),
470
529
  } satisfies MessagesView);
471
530
  }
472
531
  if (action === "mode" && method === "PUT" && parts.length === 4) {
473
532
  // Only an agent's or group's room has modes; a thread runs no turn of the owner's.
474
- if (room.readOnly) throw notFound();
533
+ if (room.readOnly || this.#options.modeSwitches === false)
534
+ throw notFound();
475
535
  const owner = await this.#owner(admission);
476
536
  const body = await bodyOf(request);
477
537
  const change: ModeChange = {};
@@ -513,10 +573,20 @@ export class AppApi {
513
573
  throw new HttpError(404, "There is no such API.");
514
574
  }
515
575
 
516
- #file(id: string): Response {
576
+ async #file(id: string, admission: Authenticated): Promise<Response> {
517
577
  if (!isFileId(id)) throw notFound();
518
- const meta = this.#options.files.meta(id);
519
- const blob = this.#options.files.blob(id);
578
+ const { files, host } = this.#options;
579
+ // A file bound to a host room or page is for the people it is open to, owners too; every other stored file belongs to an agent room, which only owners read.
580
+ const bound = files.binding(id)?.room;
581
+ if (bound === undefined) {
582
+ if (admission.visitor.tier !== "owner") throw notFound();
583
+ } else if (
584
+ !host ||
585
+ !(await host.mayRead(viewerOf(admission.visitor), bound))
586
+ )
587
+ throw notFound();
588
+ const meta = files.meta(id);
589
+ const blob = files.blob(id);
520
590
  if (!meta || !blob) throw notFound();
521
591
  return fileResponse(meta, blob);
522
592
  }
@@ -636,6 +706,18 @@ export class AppApi {
636
706
  throw new HttpError(404, "There is no such API.");
637
707
  }
638
708
 
709
+ /** Whether a schedule is the visitor's own: its creator id names their principal. */
710
+ async #ownSchedule(
711
+ createdById: string,
712
+ principalId: string,
713
+ ): Promise<boolean> {
714
+ if (createdById === principalId) return true;
715
+ // A schedule from 0.8 names its creator by a user id that the backfill made a principal's id.
716
+ return (
717
+ (await this.#options.identity.principal(createdById))?.id === principalId
718
+ );
719
+ }
720
+
639
721
  async #schedules(
640
722
  method: string,
641
723
  id: string | undefined,
@@ -643,11 +725,19 @@ export class AppApi {
643
725
  ): Promise<Response> {
644
726
  const { schedules, rooms } = this.#options;
645
727
  if (!schedules) throw new HttpError(404, "This pane is not served.");
728
+ const { tier, principal } = admission.visitor;
729
+ const owner = tier === "owner";
646
730
  if (method === "GET" && !id) {
647
731
  const all = await schedules.all();
648
732
  const views: ScheduleView[] = [];
649
733
  for (const schedule of all) {
650
- const room = await rooms.resolve(schedule.channel);
734
+ // The owners see every schedule; anyone else sees their own, and no room of the owners.
735
+ if (
736
+ !owner &&
737
+ !(await this.#ownSchedule(schedule.createdById, principal.id))
738
+ )
739
+ continue;
740
+ const room = owner ? await rooms.resolve(schedule.channel) : undefined;
651
741
  views.push({
652
742
  id: schedule.id,
653
743
  ...(room ? { room: room.key } : {}),
@@ -667,9 +757,17 @@ export class AppApi {
667
757
  return json({ schedules: views } satisfies SchedulesView);
668
758
  }
669
759
  if (method === "DELETE" && id) {
670
- await this.#owner(admission);
671
760
  const number = Number(id);
672
761
  if (!Number.isInteger(number)) throw bad("The schedule id is a number.");
762
+ // Another person's schedule is not there to remove, for anyone but the owners.
763
+ if (!owner) {
764
+ const found = await schedules.get(number);
765
+ if (
766
+ !found ||
767
+ !(await this.#ownSchedule(found.createdById, principal.id))
768
+ )
769
+ throw new HttpError(404, "There is no such schedule.");
770
+ }
673
771
  const removed = await schedules.remove(number);
674
772
  if (!removed) throw new HttpError(404, "There is no such schedule.");
675
773
  this.#options.changed();