pi-roundtable-web 0.26.0 → 0.26.2

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 (226) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +32 -12
  3. package/dist/chunk-account-nhpd9j90.js +1 -0
  4. package/dist/chunk-action-menu-ec9jqtxq.js +1 -0
  5. package/dist/chunk-agent-d1xfrawq.js +1 -0
  6. package/dist/chunk-arranged-rooms-bga8yk2v.js +1 -0
  7. package/dist/chunk-character-creator-k67f77w5.js +1 -0
  8. package/dist/chunk-codeeditor-jm2sm333.js +2 -0
  9. package/dist/chunk-confirm-wc5xraev.js +1 -0
  10. package/dist/chunk-connectors-mhnw6d79.js +1 -0
  11. package/dist/chunk-conversations-yjyq4g1y.js +1 -0
  12. package/dist/{chunk-drag-v41gx2mc.js → chunk-drag-nyq52ck7.js} +1 -1
  13. package/dist/chunk-entry-red1597w.js +1 -0
  14. package/dist/chunk-folder-list-x56y26yq.js +1 -0
  15. package/dist/chunk-folds-3p7s2nvx.js +1 -0
  16. package/dist/{chunk-gate-8k61stsc.js → chunk-gate-h41bv4y9.js} +1 -1
  17. package/dist/chunk-group-scene-7eb6jyev.js +1 -0
  18. package/dist/chunk-group-table-rca48kt0.js +1 -0
  19. package/dist/chunk-host-icons-x318gjqp.js +1 -0
  20. package/dist/chunk-host-picture-xswcakgy.js +1 -0
  21. package/dist/chunk-host-state-bpahvk54.js +1 -0
  22. package/dist/chunk-host-ui-tsbwwk38.js +1 -0
  23. package/dist/{chunk-index-hby0ap91.js → chunk-index-029209sy.js} +2 -2
  24. package/dist/chunk-index-066vzexw.js +2 -0
  25. package/dist/chunk-index-0h4h76kw.js +2 -0
  26. package/dist/chunk-index-0n6ja7my.js +1 -0
  27. package/dist/chunk-index-0q15ysrb.js +1 -0
  28. package/dist/chunk-index-0xj30mzw.js +13 -0
  29. package/dist/{chunk-index-rhtvcbsq.js → chunk-index-1f0j335v.js} +2 -2
  30. package/dist/chunk-index-2f5nfekb.js +8 -0
  31. package/dist/chunk-index-31ays46f.js +2 -0
  32. package/dist/chunk-index-43k1rjmx.js +2 -0
  33. package/dist/chunk-index-4gk88a0x.js +4 -0
  34. package/dist/chunk-index-4jf4jvfd.css +1 -0
  35. package/dist/chunk-index-4mf29sbx.js +2 -0
  36. package/dist/chunk-index-51n20yhb.js +2 -0
  37. package/dist/{chunk-index-014rjv9k.js → chunk-index-6tpr6r6d.js} +2 -2
  38. package/dist/{chunk-index-e5sd30xy.js → chunk-index-7bbqtdce.js} +2 -2
  39. package/dist/chunk-index-84v7j6vh.js +50 -0
  40. package/dist/chunk-index-8f9jer8r.js +2 -0
  41. package/dist/chunk-index-91z3g774.js +1 -0
  42. package/dist/{chunk-index-dhpt8qr9.js → chunk-index-934zgd18.js} +2 -2
  43. package/dist/{chunk-index-awx6nnta.js → chunk-index-9nents16.js} +2 -2
  44. package/dist/chunk-index-9w0sar6s.js +2 -0
  45. package/dist/chunk-index-a768zmp6.js +2 -0
  46. package/dist/{chunk-index-nqfkwkfp.js → chunk-index-b7jjca66.js} +2 -2
  47. package/dist/chunk-index-c5x70d9g.js +2 -0
  48. package/dist/chunk-index-cmxs5y22.js +42 -0
  49. package/dist/chunk-index-cwc1vvpw.js +2 -0
  50. package/dist/chunk-index-d85x96q0.js +2 -0
  51. package/dist/chunk-index-datrrs6b.js +2 -0
  52. package/dist/chunk-index-dep2e3f3.js +2 -0
  53. package/dist/chunk-index-dtste2ed.js +2 -0
  54. package/dist/chunk-index-ewj52be9.js +2 -0
  55. package/dist/{chunk-index-pcfhpm8v.js → chunk-index-f3b5nch2.js} +142 -18
  56. package/dist/{chunk-index-km40bhsz.js → chunk-index-frwa2fmc.js} +2 -2
  57. package/dist/chunk-index-grkczq2c.js +2 -0
  58. package/dist/chunk-index-gzvkpe3e.js +2 -0
  59. package/dist/chunk-index-kfv996qe.js +920 -0
  60. package/dist/chunk-index-kw85ajap.js +138 -0
  61. package/dist/chunk-index-satam151.js +2 -0
  62. package/dist/{chunk-index-y8rnq8zf.js → chunk-index-smw1n2pt.js} +5 -5
  63. package/dist/chunk-index-th43f690.js +2 -0
  64. package/dist/chunk-index-ths2hxpe.js +1 -0
  65. package/dist/{chunk-index-wnjp6wv0.js → chunk-index-tmx30r1w.js} +2 -2
  66. package/dist/{chunk-index-hssjbr1k.js → chunk-index-y00wke93.js} +2 -2
  67. package/dist/chunk-index-y4sqmenv.js +2 -0
  68. package/dist/{chunk-index-66nskrdj.js → chunk-index-yrhbhmxz.js} +2 -2
  69. package/dist/chunk-index-zd76pr2k.js +2 -0
  70. package/dist/chunk-lang-css-fa7c0zfj.js +1 -0
  71. package/dist/{chunk-lang-dockerfile-hrav55t7.js → chunk-lang-dockerfile-46rk929v.js} +1 -1
  72. package/dist/{chunk-lang-go-x9ahv6e5.js → chunk-lang-go-qb0qdpg7.js} +3 -3
  73. package/dist/chunk-lang-html-47mf2y0v.js +1 -0
  74. package/dist/chunk-lang-javascript-d32qrvxz.js +1 -0
  75. package/dist/{chunk-lang-json-hsemyd0r.js → chunk-lang-json-1q4fp694.js} +1 -1
  76. package/dist/{chunk-lang-markdown-zkwb4c9n.js → chunk-lang-markdown-5w7t28wa.js} +4 -4
  77. package/dist/{chunk-lang-python-tewx7006.js → chunk-lang-python-3dq0p4rf.js} +2 -2
  78. package/dist/{chunk-lang-rust-0ydx84s1.js → chunk-lang-rust-70pa0agr.js} +1 -1
  79. package/dist/{chunk-lang-shell-gq1tmdz4.js → chunk-lang-shell-4pxt7hf4.js} +1 -1
  80. package/dist/{chunk-lang-sql-xt1qyycg.js → chunk-lang-sql-kgcbh2a9.js} +2 -2
  81. package/dist/{chunk-lang-toml-0hamc5fc.js → chunk-lang-toml-99t95pab.js} +1 -1
  82. package/dist/chunk-lang-typescript-4k1d8z7s.js +1 -0
  83. package/dist/{chunk-lang-xml-wf6h5pt2.js → chunk-lang-xml-mnbv9yfa.js} +1 -1
  84. package/dist/{chunk-lang-yaml-7vk521h1.js → chunk-lang-yaml-2by9g1dy.js} +1 -1
  85. package/dist/chunk-layout-actions-cgghzj1w.js +1 -0
  86. package/dist/chunk-menu-items-24cfwawa.js +2 -0
  87. package/dist/chunk-mode-toggle-v4baz3r1.js +1 -0
  88. package/dist/chunk-note-row-72n6cqyk.js +75 -0
  89. package/dist/chunk-notes-83s3tsey.js +1 -0
  90. package/dist/chunk-notice-bell-a4ty5h1p.js +1 -0
  91. package/dist/chunk-notices-evny44be.js +1 -0
  92. package/dist/chunk-onboarding-q1avjbrx.js +1 -0
  93. package/dist/chunk-overview-42h45ehd.js +1 -0
  94. package/dist/chunk-palette-e44qepmh.js +1 -0
  95. package/dist/chunk-placement-items-v466t0sv.js +1 -0
  96. package/dist/chunk-profile-bqcgkgk4.js +1 -0
  97. package/dist/chunk-prompt-history-hgbtcn4b.js +1 -0
  98. package/dist/chunk-prompts-czs8y6r1.js +1 -0
  99. package/dist/chunk-room-confirms-h8cdj3ng.js +1 -0
  100. package/dist/chunk-room-menu-7s0feajw.js +1 -0
  101. package/dist/chunk-rooms-menu-button-gawqvpsr.js +1 -0
  102. package/dist/{chunk-schedules-2ft6xxyw.js → chunk-schedules-zba7krac.js} +1 -1
  103. package/dist/{chunk-seat-menu-d3npdtx1.js → chunk-seat-menu-ca5p4e9q.js} +1 -1
  104. package/dist/chunk-sheet-hosts-ssbz6hf2.js +1 -0
  105. package/dist/chunk-skill-editor-n0y9pd90.js +8 -0
  106. package/dist/chunk-skills-ea4vb2s4.js +1 -0
  107. package/dist/chunk-switcher-list-pj5csqr9.js +1 -0
  108. package/dist/chunk-tab-dot-4tkpxqeh.js +1 -0
  109. package/dist/chunk-table-j8nmtdnn.js +1 -0
  110. package/dist/chunk-table-seats-qeytqe0m.js +1 -0
  111. package/dist/chunk-table2d-18y029k9.js +1 -0
  112. package/dist/chunk-table3d-q2s31ae0.js +4220 -0
  113. package/dist/chunk-thread-actions-w3eadnh7.js +1 -0
  114. package/dist/chunk-thread-cards-fbpky3c9.js +1 -0
  115. package/dist/chunk-thread-list-pghe84be.js +1 -0
  116. package/dist/chunk-transcript-eqzxgqys.js +1 -0
  117. package/dist/chunk-where-ygyr4jdj.js +1 -0
  118. package/dist/chunk-workspace-h0p32ygy.js +578 -0
  119. package/dist/chunk-zh-tw-k6xx1hp2.js +1 -0
  120. package/dist/index.html +1 -2
  121. package/dist/sw.js +1 -1
  122. package/package.json +2 -2
  123. package/src/api-types.ts +63 -3
  124. package/src/app/api-http.ts +1 -1
  125. package/src/app/app.ts +5 -0
  126. package/src/app/room-log.ts +16 -4
  127. package/src/app/rooms.ts +39 -0
  128. package/src/app/routes.ts +96 -2
  129. package/src/app/socket.ts +45 -9
  130. package/src/app/surface-threads.ts +1 -1
  131. package/src/app/surface.ts +2 -1
  132. package/src/app/threads.ts +51 -2
  133. package/src/auth/authenticator.ts +18 -5
  134. package/src/auth/login-page.ts +4 -2
  135. package/src/auth/oidc-pages.ts +2 -0
  136. package/src/auth/oidc.ts +39 -1
  137. package/src/auth/sessions.ts +10 -3
  138. package/src/authz.ts +2 -1
  139. package/src/console-server.ts +32 -6
  140. package/src/host/cards.ts +18 -5
  141. package/src/host/context.ts +72 -25
  142. package/src/host/forms.ts +3 -0
  143. package/src/host/frames.ts +8 -1
  144. package/src/host/host-app.ts +6 -4
  145. package/src/host/host-routes.ts +36 -3
  146. package/src/host/room-views.ts +6 -6
  147. package/src/host/rooms-service.ts +16 -1
  148. package/src/host/views.ts +3 -0
  149. package/src/i18n/zh-tw.ts +103 -79
  150. package/src/index.ts +15 -1
  151. package/src/options.ts +37 -1
  152. package/src/page-presentation.ts +47 -2
  153. package/src/pwa.ts +32 -4
  154. package/src/web-app.ts +47 -3
  155. package/src/web-plugin.ts +32 -10
  156. package/dist/chunk-account-hw49gd0d.js +0 -1
  157. package/dist/chunk-action-menu-rm5cyqkj.js +0 -1
  158. package/dist/chunk-agent-b017vswv.js +0 -1
  159. package/dist/chunk-arranged-rooms-q5v3dm1r.js +0 -1
  160. package/dist/chunk-character-creator-5dxcvd16.js +0 -1
  161. package/dist/chunk-codeeditor-pyt09fwg.js +0 -2
  162. package/dist/chunk-confirm-521yqmta.js +0 -1
  163. package/dist/chunk-connectors-hrm06nx8.js +0 -1
  164. package/dist/chunk-conversations-e26rrf01.js +0 -1
  165. package/dist/chunk-entry-jxxb3ath.js +0 -1
  166. package/dist/chunk-folder-list-b97xk12b.js +0 -1
  167. package/dist/chunk-folds-b8q7eags.js +0 -1
  168. package/dist/chunk-group-scene-dx6wt19m.js +0 -1
  169. package/dist/chunk-group-table-e4jr8r3a.js +0 -1
  170. package/dist/chunk-host-state-g7e7c0k5.js +0 -1
  171. package/dist/chunk-host-ui-x7k6n07v.js +0 -939
  172. package/dist/chunk-index-2d0kg096.js +0 -2
  173. package/dist/chunk-index-2qd81wbx.js +0 -2
  174. package/dist/chunk-index-2stynxw6.css +0 -1
  175. package/dist/chunk-index-459drsft.js +0 -2
  176. package/dist/chunk-index-4mfj596y.js +0 -2
  177. package/dist/chunk-index-bbsytycb.js +0 -2
  178. package/dist/chunk-index-c07p8k0x.js +0 -4
  179. package/dist/chunk-index-cq1e9xdz.js +0 -2
  180. package/dist/chunk-index-d1prgfbp.js +0 -2
  181. package/dist/chunk-index-g351921r.js +0 -1
  182. package/dist/chunk-index-k2qq1gdj.js +0 -2
  183. package/dist/chunk-index-ke9ynm0n.js +0 -2
  184. package/dist/chunk-index-kpscvg85.js +0 -2
  185. package/dist/chunk-index-m0d100mg.js +0 -2
  186. package/dist/chunk-index-mb72snk9.js +0 -2
  187. package/dist/chunk-index-mjqqver5.js +0 -4
  188. package/dist/chunk-index-nwqk447w.js +0 -2
  189. package/dist/chunk-index-q8h8xzbm.js +0 -2
  190. package/dist/chunk-index-r5vszgfs.js +0 -2
  191. package/dist/chunk-index-rjv7vrwd.js +0 -13
  192. package/dist/chunk-index-v23m38p6.js +0 -50
  193. package/dist/chunk-index-va0g7d88.js +0 -1
  194. package/dist/chunk-index-xgkd15n3.js +0 -2
  195. package/dist/chunk-index-z3k18atc.js +0 -2
  196. package/dist/chunk-lang-css-6bepqpnx.js +0 -1
  197. package/dist/chunk-lang-html-8jc72zt4.js +0 -1
  198. package/dist/chunk-lang-javascript-2p2qtbnf.js +0 -1
  199. package/dist/chunk-lang-typescript-82xf7z2e.js +0 -1
  200. package/dist/chunk-layout-actions-zh7bezk7.js +0 -1
  201. package/dist/chunk-menu-items-1n9qargb.js +0 -2
  202. package/dist/chunk-notes-t15bh04m.js +0 -1
  203. package/dist/chunk-notice-bell-w3y0h0sr.js +0 -1
  204. package/dist/chunk-notices-fa3g7qze.js +0 -1
  205. package/dist/chunk-onboarding-34v3nr17.js +0 -1
  206. package/dist/chunk-overview-km5vq375.js +0 -1
  207. package/dist/chunk-palette-a8txdgd0.js +0 -1
  208. package/dist/chunk-placement-items-wgaxsbvs.js +0 -1
  209. package/dist/chunk-profile-ypxdbsk1.js +0 -1
  210. package/dist/chunk-prompt-history-wm9dsy6s.js +0 -1
  211. package/dist/chunk-prompts-7mhhq7zs.js +0 -1
  212. package/dist/chunk-room-confirms-t11a1gsw.js +0 -1
  213. package/dist/chunk-room-menu-2tm2xtfx.js +0 -1
  214. package/dist/chunk-rooms-menu-button-wxnsxjwc.js +0 -1
  215. package/dist/chunk-sheet-hosts-vngvp7wv.js +0 -1
  216. package/dist/chunk-skill-editor-65n2hsq4.js +0 -8
  217. package/dist/chunk-skills-shkdwes8.js +0 -1
  218. package/dist/chunk-switcher-list-g6hhmr1t.js +0 -1
  219. package/dist/chunk-table-1b6bsq2y.js +0 -1
  220. package/dist/chunk-table-seats-vy1qhdks.js +0 -1
  221. package/dist/chunk-table2d-n26b7gd0.js +0 -1
  222. package/dist/chunk-table3d-8aysyf56.js +0 -4220
  223. package/dist/chunk-transcript-355faceq.js +0 -1
  224. package/dist/chunk-where-j5qdt8a4.js +0 -1
  225. package/dist/chunk-workspace-vrbs6zx1.js +0 -1
  226. package/dist/chunk-zh-tw-zeg600bk.js +0 -1
package/src/app/routes.ts CHANGED
@@ -19,11 +19,14 @@ import type {
19
19
  CharacterRefusal,
20
20
  MessagesView,
21
21
  ModelsView,
22
+ RoomOneView,
22
23
  RoomsView,
23
24
  SchedulesView,
24
25
  ScheduleView,
25
26
  SessionView,
26
27
  SkillView,
28
+ ThreadsDismissed,
29
+ ThreadsRestored,
27
30
  WorkspaceConflict,
28
31
  WorkspaceNew,
29
32
  WorkspaceRefusalBody,
@@ -48,7 +51,7 @@ import type { LayoutStore } from "./layout.ts";
48
51
  import { LayoutRoutes } from "./layout-routes.ts";
49
52
  import { PromptApi, type PromptApiOptions } from "./prompt-routes.ts";
50
53
  import { PAGE_LIMIT, type RoomLog } from "./room-log.ts";
51
- import type { Rooms } from "./rooms.ts";
54
+ import type { ResolvedRoom, Rooms } from "./rooms.ts";
52
55
  import { SkillDocApi } from "./skill-doc-routes.ts";
53
56
  import type { AppSurface } from "./surface.ts";
54
57
  import { TeamApi } from "./team-routes.ts";
@@ -61,6 +64,9 @@ import {
61
64
  WorkspaceRefusal,
62
65
  } from "./workspace-files.ts";
63
66
 
67
+ /** The most threads one request puts back on the room list: more than the list ever shows. */
68
+ const MAX_RESTORED = 100;
69
+
64
70
  export interface AppApiOptions {
65
71
  /** The host's wording: a refusal reaches a client of the API in this locale when the catalog has it. */
66
72
  presentation?: ConsolePresentation;
@@ -75,7 +81,15 @@ export interface AppApiOptions {
75
81
  /** The rooms whose push the owners muted. */
76
82
  mutes: Pick<PushMutes, "set" | "setMany">;
77
83
  /** The dispatch threads, which go with the room they hang under. */
78
- threads: Pick<WebThreads, "under" | "removeUnder">;
84
+ threads: Pick<
85
+ WebThreads,
86
+ | "under"
87
+ | "removeUnder"
88
+ | "dismiss"
89
+ | "dismissFinished"
90
+ | "restore"
91
+ | "restoreFinished"
92
+ >;
79
93
  /** The owners' shared arrangement of the room list: pins and folders. */
80
94
  layout: LayoutStore;
81
95
  conversations: Pick<ConversationPort, "startFresh">;
@@ -496,6 +510,24 @@ export class AppApi {
496
510
  }
497
511
  const room = await rooms.resolve(key);
498
512
  if (!room) throw notFound();
513
+ if (!action && method === "GET") {
514
+ // A thread dismissed from the list still opens by its address; the page asks for its view.
515
+ const view = await rooms.threadView(room.key);
516
+ if (!view) throw notFound();
517
+ return json({ room: view } satisfies RoomOneView);
518
+ }
519
+ if (action === "dismiss" && method === "POST" && parts.length === 4)
520
+ return this.#dismiss(room, admission);
521
+ if (action === "dismiss-threads" && method === "POST" && parts.length === 4)
522
+ return this.#dismissFinished(room, admission);
523
+ if (action === "undismiss" && method === "POST" && parts.length === 4)
524
+ return this.#restore(room, admission);
525
+ if (
526
+ action === "undismiss-threads" &&
527
+ method === "POST" &&
528
+ parts.length === 4
529
+ )
530
+ return this.#restoreFinished(room, request, admission);
499
531
  if (action === "messages" && method === "GET" && parts.length === 4) {
500
532
  const params = new URL(request.url).searchParams;
501
533
  const before = params.get("before");
@@ -573,6 +605,68 @@ export class AppApi {
573
605
  throw new HttpError(404, "There is no such API.");
574
606
  }
575
607
 
608
+ /** Takes one finished thread off the room list for every owner; an open one is refused. */
609
+ async #dismiss(
610
+ room: ResolvedRoom,
611
+ admission: Authenticated,
612
+ ): Promise<Response> {
613
+ if (room.kind !== "thread") throw notFound();
614
+ await this.#owner(admission);
615
+ const outcome = await this.#options.threads.dismiss(room.name);
616
+ if (outcome === "missing") throw notFound();
617
+ if (outcome === "open")
618
+ throw new HttpError(409, "The thread is still open.");
619
+ this.#options.changed();
620
+ return json({ dismissed: 1 } satisfies ThreadsDismissed);
621
+ }
622
+
623
+ /** Takes every finished thread of a room off the room list; the open ones stay. */
624
+ async #dismissFinished(
625
+ room: ResolvedRoom,
626
+ admission: Authenticated,
627
+ ): Promise<Response> {
628
+ if (room.kind === "thread") throw notFound();
629
+ await this.#owner(admission);
630
+ const dismissed = await this.#options.threads.dismissFinished(room.key);
631
+ if (dismissed) this.#options.changed();
632
+ return json({ dismissed } satisfies ThreadsDismissed);
633
+ }
634
+
635
+ /** Puts one dismissed thread back on the room list for every owner: the undo of a dismissal. */
636
+ async #restore(
637
+ room: ResolvedRoom,
638
+ admission: Authenticated,
639
+ ): Promise<Response> {
640
+ if (room.kind !== "thread") throw notFound();
641
+ await this.#owner(admission);
642
+ const outcome = await this.#options.threads.restore(room.name);
643
+ if (outcome === "missing") throw notFound();
644
+ this.#options.changed();
645
+ return json({ restored: 1 } satisfies ThreadsRestored);
646
+ }
647
+
648
+ /** Puts back the threads of a room that one clear-all dismissed, named by their keys. */
649
+ async #restoreFinished(
650
+ room: ResolvedRoom,
651
+ request: Request,
652
+ admission: Authenticated,
653
+ ): Promise<Response> {
654
+ if (room.kind === "thread") throw notFound();
655
+ await this.#owner(admission);
656
+ const { threads } = await bodyOf(request);
657
+ if (
658
+ !Array.isArray(threads) ||
659
+ threads.length > MAX_RESTORED ||
660
+ !threads.every((one) => typeof one === "string")
661
+ )
662
+ throw bad("threads must be a list of at most 100 keys.");
663
+ // A key is `<surface>:<thread id>`; the store knows the id.
664
+ const ids = threads.map((key: string) => key.slice(key.indexOf(":") + 1));
665
+ const restored = await this.#options.threads.restoreFinished(room.key, ids);
666
+ if (restored) this.#options.changed();
667
+ return json({ restored } satisfies ThreadsRestored);
668
+ }
669
+
576
670
  async #file(id: string, admission: Authenticated): Promise<Response> {
577
671
  if (!isFileId(id)) throw notFound();
578
672
  const { files, host } = this.#options;
package/src/app/socket.ts CHANGED
@@ -559,7 +559,9 @@ export class SocketRoute {
559
559
  this.#refuse(connection, code, frame.id);
560
560
  // The agent rooms are the owners', and they take only turns.
561
561
  if (who.tier !== "owner") return refuse("forbidden");
562
- if (frame.mode === "note") return refuse("bad_frame");
562
+ // A note is for the other people who read the rooms; where nobody else does, it is no frame.
563
+ const note = frame.mode === "note";
564
+ if (note && !(await rooms.notesOn())) return refuse("bad_frame");
563
565
  const room = await rooms.resolve(frame.room);
564
566
  if (!room) return refuse("unknown_room");
565
567
  if (room.readOnly) return refuse("read_only");
@@ -567,18 +569,22 @@ export class SocketRoute {
567
569
  if (text.length > MAX_MESSAGE_CHARS) return refuse("too_long");
568
570
  const ids = frame.uploads ?? [];
569
571
  if (text === "" && ids.length === 0) return refuse("empty");
570
- // Without the actor the credential vouched for, the router cannot tell whose message it is.
571
- if (!who.actor) return refuse("forbidden");
572
+ // Without the actor the credential vouched for, the router cannot tell whose message it is;
573
+ // a note goes to no router.
574
+ if (!note && !who.actor) return refuse("forbidden");
572
575
  const files = uploads.claim(ids, who.principalId);
573
576
  if (!files) return refuse("unknown_upload");
574
577
  if (!surface.started) return refuse("not_accepted");
575
578
  try {
576
- await this.say(room.key, who, text, files, connection, (message) =>
577
- sockets.deliver(
578
- { type: "accepted", id: frame.id, room: room.key, message },
579
- { connection },
580
- ),
581
- );
579
+ if (note)
580
+ await this.#note(connection, who, room.key, frame.id, text, files);
581
+ else
582
+ await this.say(room.key, who, text, files, connection, (message) =>
583
+ sockets.deliver(
584
+ { type: "accepted", id: frame.id, room: room.key, message },
585
+ { connection },
586
+ ),
587
+ );
582
588
  } catch (error) {
583
589
  logger.warn(
584
590
  { room: room.key, err: error },
@@ -588,6 +594,36 @@ export class SocketRoute {
588
594
  }
589
595
  }
590
596
 
597
+ /**
598
+ * Keeps an owner's internal note in a room: stored and shown to the other readers, and given to
599
+ * no one else. It does not reach the host, so the agent runs no turn, the note is not in its
600
+ * conversation, and nothing about the room reads as asked.
601
+ */
602
+ async #note(
603
+ connection: AppSocket,
604
+ who: Answerer,
605
+ room: ChannelKey,
606
+ id: string,
607
+ text: string,
608
+ files: readonly RoomFile[],
609
+ ): Promise<void> {
610
+ const { surface, uploads, sockets } = this.#options;
611
+ const message = await surface.publish(
612
+ {
613
+ room,
614
+ kind: "owner",
615
+ principalId: who.principalId,
616
+ author: who.name,
617
+ text,
618
+ files: [...files],
619
+ note: true,
620
+ },
621
+ connection,
622
+ );
623
+ uploads.use(files.map((file) => file.id));
624
+ sockets.deliver({ type: "accepted", id, room, message }, { connection });
625
+ }
626
+
591
627
  /**
592
628
  * Takes a message of the owner into a room: stores it, shows it to the other sockets, and
593
629
  * hands it to the host as the owner's. `files` are uploads the sender claimed, which move to
@@ -9,7 +9,7 @@ import type { NewRoomMessage } from "./room-log.ts";
9
9
  import type { WebThreads } from "./threads.ts";
10
10
 
11
11
  /** The name a post shows when its sender names none. */
12
- const ASSISTANT = "Assistant";
12
+ const ASSISTANT = "Knight";
13
13
  /** Discord's small-print marker, which the notices of the host carry. */
14
14
  const NOTICE = /^-# /;
15
15
 
@@ -31,7 +31,7 @@ const APP_FORMATTING =
31
31
  "Reply formatting (web app): Markdown renders, including tables, headings, lists, quotes, links, inline code and fenced code blocks. Raw HTML does not render. Files you attach appear under your message.";
32
32
 
33
33
  /** The name a reply shows when its sender names none. */
34
- const ASSISTANT = "Assistant";
34
+ const ASSISTANT = "Knight";
35
35
 
36
36
  export interface AppSurfaceOptions {
37
37
  /** The key prefix of the rooms, such as `app`. */
@@ -105,6 +105,7 @@ export interface RoomActivity {
105
105
  export class AppSurface implements ChatSurface {
106
106
  readonly surface: string;
107
107
  readonly agentRooms = true;
108
+ readonly label = "the web app";
108
109
  readonly supportsFiles = true;
109
110
  readonly formatting = APP_FORMATTING;
110
111
  /** The turn takes an upload up to the most the app lets a person upload. */
@@ -60,6 +60,15 @@ export class WebThreads {
60
60
  },
61
61
  };
62
62
 
63
+ /** The mark of a finished thread the owners took off the room list; the thread itself stays. */
64
+ static readonly dismissedMigration: Migration = {
65
+ name: "web-threads-dismissed",
66
+ up: async (sql) => {
67
+ await sql`
68
+ ALTER TABLE web_threads ADD COLUMN IF NOT EXISTS dismissed_at timestamptz`;
69
+ },
70
+ };
71
+
63
72
  /** Starts a thread under `parent`. */
64
73
  async open(parent: string, title: string): Promise<WebThread> {
65
74
  const id = `thread.${crypto.randomUUID()}`;
@@ -76,6 +85,46 @@ export class WebThreads {
76
85
  await this.#sql`UPDATE web_threads SET open = false WHERE id = ${id}`;
77
86
  }
78
87
 
88
+ /**
89
+ * Takes a finished thread off the room list. It stays readable by its address, and asking again
90
+ * changes nothing. An open thread is refused: `open` says so, `missing` that there is none.
91
+ */
92
+ async dismiss(id: string): Promise<"dismissed" | "open" | "missing"> {
93
+ const rows: unknown[] = await this.#sql`
94
+ UPDATE web_threads SET dismissed_at = COALESCE(dismissed_at, now())
95
+ WHERE id = ${id} AND NOT open RETURNING 1`;
96
+ if (rows.length) return "dismissed";
97
+ return (await this.get(id)) ? "open" : "missing";
98
+ }
99
+
100
+ /** Dismisses every finished thread under `parent` that is still listed; returns how many. */
101
+ async dismissFinished(parent: string): Promise<number> {
102
+ const rows: unknown[] = await this.#sql`
103
+ UPDATE web_threads SET dismissed_at = now()
104
+ WHERE parent = ${parent} AND NOT open AND dismissed_at IS NULL RETURNING 1`;
105
+ return rows.length;
106
+ }
107
+
108
+ /** Puts a dismissed thread back on the room list; asking again changes nothing. */
109
+ async restore(id: string): Promise<"restored" | "missing"> {
110
+ const rows: unknown[] = await this.#sql`
111
+ UPDATE web_threads SET dismissed_at = NULL WHERE id = ${id} RETURNING 1`;
112
+ return rows.length ? "restored" : "missing";
113
+ }
114
+
115
+ /** Puts back the dismissed threads of `ids` that hang under `parent`; returns how many were dismissed. */
116
+ async restoreFinished(
117
+ parent: string,
118
+ ids: readonly string[],
119
+ ): Promise<number> {
120
+ if (ids.length === 0) return 0;
121
+ const rows: unknown[] = await this.#sql`
122
+ UPDATE web_threads SET dismissed_at = NULL
123
+ WHERE parent = ${parent} AND id IN ${this.#sql([...ids])}
124
+ AND dismissed_at IS NOT NULL RETURNING 1`;
125
+ return rows.length;
126
+ }
127
+
79
128
  async get(id: string): Promise<WebThread | undefined> {
80
129
  const [row]: Row[] = await this
81
130
  .#sql`SELECT * FROM web_threads WHERE id = ${id}`;
@@ -98,14 +147,14 @@ export class WebThreads {
98
147
 
99
148
  /**
100
149
  * The threads the room list shows, newest first: every open one, and the `closedLimit` newest
101
- * closed ones, which stay readable but are not kept in view for ever.
150
+ * closed ones that were not dismissed. The rest stay readable but are not kept in view.
102
151
  */
103
152
  async list(closedLimit = CLOSED_LISTED): Promise<WebThread[]> {
104
153
  const rows: Row[] = await this.#sql`
105
154
  SELECT * FROM (
106
155
  SELECT * FROM web_threads WHERE open
107
156
  UNION ALL
108
- (SELECT * FROM web_threads WHERE NOT open
157
+ (SELECT * FROM web_threads WHERE NOT open AND dismissed_at IS NULL
109
158
  ORDER BY created_at DESC, id LIMIT ${closedLimit})
110
159
  ) shown ORDER BY created_at DESC, id`;
111
160
  return rows.map(threadOf);
@@ -78,8 +78,10 @@ export interface AuthenticatorOptions {
78
78
  listener: { id: string; address: Readonly<ListenerAddress> | undefined };
79
79
  /** The sign-in flow and the session store, for `oidc`. */
80
80
  oidc?: {
81
- login: Pick<OidcLogin, "open" | "credential">;
81
+ login: Pick<OidcLogin, "open" | "credential" | "verified">;
82
82
  sessions: Pick<WebSessions, "alive">;
83
+ /** Hours the provider's word holds for one session; unset for no limit. */
84
+ reauthHours?: number | undefined;
83
85
  };
84
86
  /** Where the password is read from; default `process.env`. */
85
87
  env?: Readonly<Record<string, string | undefined>>;
@@ -205,6 +207,8 @@ export function createAuthenticator(
205
207
  )
206
208
  return denied("foreign origin");
207
209
  const { session } = checked as Admitted;
210
+ if (session)
211
+ options.oidc?.login.verified(session, identified.visitor.principal.id);
208
212
  return {
209
213
  visitor: identified.visitor,
210
214
  ...(checked.actor ? { actor: checked.actor } : {}),
@@ -215,16 +219,25 @@ export function createAuthenticator(
215
219
  async recheck(held) {
216
220
  if (held.session) {
217
221
  try {
218
- if (!(await options.oidc?.sessions.alive(held.session)))
222
+ if (
223
+ !(await options.oidc?.sessions.alive(
224
+ held.session,
225
+ options.oidc?.reauthHours,
226
+ ))
227
+ )
219
228
  return signedOut("session ended");
220
229
  } catch {
221
230
  return denied("session lookup failed");
222
231
  }
223
232
  }
224
233
  const identified = await identify(held.actor);
225
- return "refusal" in identified
226
- ? denied(String(identified.refusal))
227
- : { visitor: identified.visitor };
234
+ if ("refusal" in identified) return denied(String(identified.refusal));
235
+ if (held.session)
236
+ options.oidc?.login.verified(
237
+ held.session,
238
+ identified.visitor.principal.id,
239
+ );
240
+ return { visitor: identified.visitor };
228
241
  },
229
242
  };
230
243
  }
@@ -1,9 +1,10 @@
1
1
  import { type ConsolePresentation, message } from "../features.ts";
2
2
  import { escapeHtml } from "../http.ts";
3
+ import { iconLinks } from "../page-presentation.ts";
3
4
 
4
- /** The login page's CSP: no script at all, one inline style, and the form posts only to this origin. */
5
+ /** The login page's CSP: no script at all, one inline style, its own icon, and the form posts only to this origin. */
5
6
  export const LOGIN_CSP =
6
- "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
7
+ "default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
7
8
 
8
9
  /** The login page, as one server-rendered document with no script; `note` is a line above the field. */
9
10
  export function loginPage(
@@ -19,6 +20,7 @@ export function loginPage(
19
20
  <meta charset="utf-8">
20
21
  <meta name="viewport" content="width=device-width, initial-scale=1">
21
22
  <meta name="robots" content="noindex">
23
+ ${iconLinks(mount, false)}
22
24
  <title>${escapeHtml(title)}</title>
23
25
  <style>
24
26
  body{font:16px/1.5 system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#f6f6f4;color:#1c1c1a}
@@ -1,5 +1,6 @@
1
1
  import { type ConsolePresentation, message } from "../features.ts";
2
2
  import { escapeHtml } from "../http.ts";
3
+ import { iconLinks } from "../page-presentation.ts";
3
4
  import { LOGIN_CSP } from "./login-page.ts";
4
5
 
5
6
  /** What a page of the sign-in flow tells, and the link it offers. */
@@ -30,6 +31,7 @@ export function oidcPage(
30
31
  <meta charset="utf-8">
31
32
  <meta name="viewport" content="width=device-width, initial-scale=1">
32
33
  <meta name="robots" content="noindex">
34
+ ${iconLinks(mount, false)}
33
35
  <title>${escapeHtml(title)}</title>
34
36
  <style>
35
37
  body{font:16px/1.5 system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#f6f6f4;color:#1c1c1a}
package/src/auth/oidc.ts CHANGED
@@ -27,6 +27,10 @@ const MAX_NAME_CHARS = 100;
27
27
  const MAX_SUBJECT_CHARS = 256;
28
28
  /** The most characters of one stored claim, as JSON. */
29
29
  const MAX_CLAIM_CHARS = 2000;
30
+ /** How seldom a live session is reported again to `onSessionVerified`. */
31
+ const VERIFIED_EVERY_MS = 300_000;
32
+ /** The most sessions whose last report is remembered before the lapsed ones are dropped. */
33
+ const VERIFIED_PRUNE_AT = 1024;
30
34
  /** Answers to a silent request that mean "the person must be asked", not "something is wrong". */
31
35
  const INTERACTION_NEEDED = new Set([
32
36
  "login_required",
@@ -133,6 +137,8 @@ export interface OidcLoginOptions {
133
137
  onSignIn?(info: SignedIn): void | Promise<void>;
134
138
  /** A session ended (logout), so the sockets on it can close. */
135
139
  onEnded?(sessionId: string): void;
140
+ /** A request or an open socket proved a live session of this person: the host's `onSessionVerified`. */
141
+ onSessionVerified?(principalId: string): void | Promise<void>;
136
142
  logger: Logger;
137
143
  /** The provider's signing keys; the published ones by default. */
138
144
  keys?: JWTVerifyGetKey;
@@ -210,6 +216,8 @@ export class OidcLogin {
210
216
  readonly #outCookie = "__Secure-rt_out";
211
217
  /** States already used, until they would have lapsed. */
212
218
  readonly #spent = new Map<string, number>();
219
+ /** When each session was last reported to `onSessionVerified`, by session id. */
220
+ readonly #verified = new Map<string, number>();
213
221
 
214
222
  constructor(options: OidcLoginOptions) {
215
223
  this.#options = options;
@@ -576,6 +584,33 @@ export class OidcLogin {
576
584
  return { admitted: true, actor: facts, session: session.id };
577
585
  };
578
586
 
587
+ /**
588
+ * Reports that a request or an open socket proved this session live, so the host may count the
589
+ * person as present. At most once per session every few minutes; never waits for the hook and
590
+ * never throws: a hook that fails is logged.
591
+ */
592
+ verified(session: string, principalId: string): void {
593
+ const hook = this.#options.onSessionVerified;
594
+ if (!hook) return;
595
+ const now = this.#now();
596
+ const last = this.#verified.get(session);
597
+ if (last !== undefined && now - last < VERIFIED_EVERY_MS) return;
598
+ if (this.#verified.size >= VERIFIED_PRUNE_AT)
599
+ for (const [id, at] of this.#verified)
600
+ if (now - at >= VERIFIED_EVERY_MS) this.#verified.delete(id);
601
+ this.#verified.set(session, now);
602
+ const failed = (cause: unknown) =>
603
+ this.#options.logger.warn(
604
+ { err: cause },
605
+ "web-console: the session-verified hook failed",
606
+ );
607
+ try {
608
+ Promise.resolve(hook(principalId)).catch(failed);
609
+ } catch (cause) {
610
+ failed(cause);
611
+ }
612
+ }
613
+
579
614
  async #logout(request: Request): Promise<Response> {
580
615
  const { mount, origin, settings, metadata } = this.#options;
581
616
  if (request.headers.get("origin") !== origin)
@@ -589,7 +624,10 @@ export class OidcLogin {
589
624
  );
590
625
  if (token) {
591
626
  const id = await this.#options.sessions.end(token).catch(() => undefined);
592
- if (id) this.#options.onEnded?.(id);
627
+ if (id) {
628
+ this.#verified.delete(id);
629
+ this.#options.onEnded?.(id);
630
+ }
593
631
  }
594
632
  const base = `${mount}/`;
595
633
  let to = `${mount}/auth/signed-out`;
@@ -154,11 +154,18 @@ export class WebSessions {
154
154
  return { ...found, lastSeenAt: now, expiresAt: expires };
155
155
  }
156
156
 
157
- /** Whether the session still stands, without moving its limit; for the recheck of an open socket. */
158
- async alive(id: string): Promise<boolean> {
157
+ /**
158
+ * Whether the session still stands, without moving its limit; for the recheck of an open socket.
159
+ * With `reauthHours`, a session the provider has not vouched for within that many hours does not
160
+ * stand, as on a request.
161
+ */
162
+ async alive(id: string, reauthHours?: number): Promise<boolean> {
163
+ const now = this.#now();
159
164
  const [row]: { n: number }[] = await this.#sql`
160
165
  SELECT count(*)::int AS n FROM web_sessions
161
- WHERE id_hash = ${Buffer.from(id, "hex")} AND expires_at > ${this.#now()}`;
166
+ WHERE id_hash = ${Buffer.from(id, "hex")} AND expires_at > ${now}
167
+ AND (${reauthHours ?? null}::float8 IS NULL
168
+ OR verified_at >= ${now}::timestamptz - make_interval(secs => ${(reauthHours ?? 0) * 3600}))`;
162
169
  return (row?.n ?? 0) > 0;
163
170
  }
164
171
 
package/src/authz.ts CHANGED
@@ -50,7 +50,8 @@ export const ROUTES: readonly Rule[] = [
50
50
  row("*", "api/notes", "V"),
51
51
  row("*", "api/notes/:id", "V"),
52
52
  row("*", "api/principals", "V"),
53
- // The agent rooms are the owners'. The route says 404, not 403, to anyone else.
53
+ // The agent rooms are the owners'. The route says 404, not 403, to anyone else. Their threads are under
54
+ // it too: a thread's view and `…/dismiss`, and `…/dismiss-threads` of its parent.
54
55
  row("*", "api/rooms", "O", true),
55
56
  row("*", "api/rooms/**", "O", true),
56
57
  row("*", "api/folders/**", "O"),
@@ -12,8 +12,12 @@ import type { ConsoleApi } from "./console-api.ts";
12
12
  import { type ConsolePresentation, message } from "./features.ts";
13
13
  import type { Gate } from "./gate.ts";
14
14
  import { json } from "./http.ts";
15
- import { presentPage } from "./page-presentation.ts";
16
- import { servePwa } from "./pwa.ts";
15
+ import {
16
+ defaultDescription,
17
+ type PageMeta,
18
+ presentPage,
19
+ } from "./page-presentation.ts";
20
+ import { hasMarkSvg, isPublicStatic, servePwa } from "./pwa.ts";
17
21
  import type { Visitor } from "./visitors.ts";
18
22
  import { viewerOf } from "./web-app.ts";
19
23
 
@@ -76,6 +80,10 @@ export interface ConsoleServerOptions {
76
80
  /** Where the install icons come from; default the ones this package ships. */
77
81
  iconsDir?: string;
78
82
  look?: "modern" | "camelot";
83
+ /** The console's own origin, for the absolute `og:image` of a link preview. */
84
+ origin?: string;
85
+ /** What the page says about itself in a search result and a link preview; default derived from the title. */
86
+ description?: string;
79
87
  }
80
88
 
81
89
  /**
@@ -89,6 +97,7 @@ export interface ConsoleServerOptions {
89
97
  export class ConsoleServer {
90
98
  readonly #options: ConsoleServerOptions;
91
99
  readonly #base: string;
100
+ readonly #meta: PageMeta | undefined;
92
101
  readonly #streams = new Set<ReadableStreamDefaultController<Uint8Array>>();
93
102
  #pending: ReturnType<typeof setTimeout> | undefined;
94
103
  #ping: ReturnType<typeof setInterval> | undefined;
@@ -96,6 +105,16 @@ export class ConsoleServer {
96
105
  constructor(options: ConsoleServerOptions) {
97
106
  this.#options = options;
98
107
  this.#base = `${options.mount}/`;
108
+ const title = options.title ?? "Roundtable";
109
+ this.#meta = options.origin
110
+ ? {
111
+ title,
112
+ description: options.description ?? defaultDescription(title),
113
+ origin: options.origin,
114
+ mount: options.mount,
115
+ svg: hasMarkSvg(options.iconsDir),
116
+ }
117
+ : undefined;
99
118
  options.subscribe(() => this.#changed());
100
119
  }
101
120
 
@@ -157,6 +176,11 @@ export class ConsoleServer {
157
176
  if (path !== this.#options.mount && !path.startsWith(this.#base))
158
177
  return new Response("Not found", { status: 404 });
159
178
  const rest = path.slice(this.#base.length);
179
+ // Static and the same for everyone: a sign-in page and a link preview show the icon without a session.
180
+ if (READ_METHODS.has(request.method) && isPublicStatic(rest)) {
181
+ const shown = servePwa(rest, this.#pwa());
182
+ if (shown) return shown;
183
+ }
160
184
  const open = await this.#options.authenticator.open?.(request, rest);
161
185
  if (open) return open;
162
186
  const admission = await this.#options.authenticator.authenticate(request);
@@ -183,10 +207,7 @@ export class ConsoleServer {
183
207
  );
184
208
  if (!READ_METHODS.has(request.method))
185
209
  return new Response("Method not allowed", { status: 405 });
186
- const installable = servePwa(rest, {
187
- ...this.#options,
188
- title: this.#options.title ?? "Roundtable",
189
- });
210
+ const installable = servePwa(rest, this.#pwa());
190
211
  if (installable) return installable;
191
212
  const asset = this.#options.assets.get(rest);
192
213
  if (asset && rest !== "index.html" && rest !== "sw.js")
@@ -205,6 +226,7 @@ export class ConsoleServer {
205
226
  this.#options.pathRouting ? this.#options.mount : undefined,
206
227
  this.#options.presentation,
207
228
  this.#options.look,
229
+ this.#meta,
208
230
  ),
209
231
  {
210
232
  headers: {
@@ -223,6 +245,10 @@ export class ConsoleServer {
223
245
  return new Response("Not found", { status: 404 });
224
246
  }
225
247
 
248
+ #pwa(): Parameters<typeof servePwa>[1] {
249
+ return { ...this.#options, title: this.#options.title ?? "Roundtable" };
250
+ }
251
+
226
252
  async #gated(visitor: Visitor): Promise<boolean> {
227
253
  const { gate } = this.#options;
228
254
  if (!gate) return false;
package/src/host/cards.ts CHANGED
@@ -49,11 +49,20 @@ export class HostCardLog {
49
49
  },
50
50
  };
51
51
 
52
+ /** The mark of a card the host keeps past its kind's retention (`HostCard.keep`). */
53
+ static readonly keepMigration: Migration = {
54
+ name: "web-host-cards-keep",
55
+ up: async (sql) => {
56
+ await sql`
57
+ ALTER TABLE web_host_cards ADD COLUMN IF NOT EXISTS keep boolean NOT NULL DEFAULT false`;
58
+ },
59
+ };
60
+
52
61
  async create(room: string, card: HostCard): Promise<StoredHostCard> {
53
62
  const id = crypto.randomUUID();
54
63
  const [row]: Row[] = await this.#sql`
55
- INSERT INTO web_host_cards (id, room, card, state)
56
- VALUES (${id}, ${room}, ${JSON.stringify(card)}::jsonb, ${card.state ?? null})
64
+ INSERT INTO web_host_cards (id, room, card, state, keep)
65
+ VALUES (${id}, ${room}, ${JSON.stringify(card)}::jsonb, ${card.state ?? null}, ${card.keep === true})
57
66
  RETURNING *`;
58
67
  if (!row) throw new Error("the card log stored nothing");
59
68
  return cardOf(row);
@@ -67,7 +76,7 @@ export class HostCardLog {
67
76
  ): Promise<StoredHostCard | undefined> {
68
77
  const [row]: Row[] = await this.#sql`
69
78
  UPDATE web_host_cards
70
- SET card = ${JSON.stringify(card)}::jsonb, state = ${card.state ?? null}, updated_at = now()
79
+ SET card = ${JSON.stringify(card)}::jsonb, state = ${card.state ?? null}, keep = ${card.keep === true}, updated_at = now()
71
80
  WHERE id = ${id} AND room = ${room} RETURNING *`;
72
81
  return row && cardOf(row);
73
82
  }
@@ -85,11 +94,15 @@ export class HostCardLog {
85
94
  return rows.map(cardOf);
86
95
  }
87
96
 
88
- /** Deletes the cards of the rooms whose key starts with `prefix` that are older than `before`; how many. */
97
+ /**
98
+ * Deletes the cards of the rooms whose key starts with `prefix` that are older than `before`,
99
+ * except those the host marked `keep`; how many.
100
+ */
89
101
  async sweepKind(prefix: string, before: Date): Promise<number> {
90
102
  const rows: unknown[] = await this.#sql`
91
103
  DELETE FROM web_host_cards
92
- WHERE starts_with(room, ${prefix}) AND created_at < ${before} RETURNING 1`;
104
+ WHERE starts_with(room, ${prefix}) AND created_at < ${before}
105
+ AND NOT keep RETURNING 1`;
93
106
  return rows.length;
94
107
  }
95
108