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
package/src/app/app.ts CHANGED
@@ -6,6 +6,7 @@ import type {
6
6
  ConversationModes,
7
7
  ConversationPort,
8
8
  DirectChannelProvider,
9
+ EventSink,
9
10
  IdentityService,
10
11
  Logger,
11
12
  Migration,
@@ -13,9 +14,21 @@ import type {
13
14
  WebSocketRoute,
14
15
  } from "pi-roundtable";
15
16
  import { channelKey } from "pi-roundtable";
17
+ import { APP_CLOSE_EXPIRED } from "../api-types.ts";
16
18
  import type { Authenticated, Authenticator } from "../auth/authenticator.ts";
17
19
  import type { ConsolePresentation } from "../features.ts";
18
- import { PushApi } from "../push/api.ts";
20
+ import type { Gate } from "../gate.ts";
21
+ import { HostCardLog } from "../host/cards.ts";
22
+ import { HostApp } from "../host/host-app.ts";
23
+ import { RoomReads } from "../host/reads.ts";
24
+ import {
25
+ mountNotices,
26
+ NOTICE_MIGRATIONS,
27
+ type NoticesMount,
28
+ type NoticesMountOptions,
29
+ } from "../notices/mount.ts";
30
+ import type { ResolvedOptions } from "../options.ts";
31
+ import type { PushApi } from "../push/api.ts";
19
32
  import { webPushChannel } from "../push/channel.ts";
20
33
  import { EndpointPolicy } from "../push/endpoint.ts";
21
34
  import { PushMutes, roomMuted } from "../push/mutes.ts";
@@ -24,6 +37,7 @@ import { PushSender } from "../push/sender.ts";
24
37
  import { PushSubscriptions } from "../push/subscriptions.ts";
25
38
  import { PushService } from "../push/triggers.ts";
26
39
  import type { Vapid } from "../push/vapid.ts";
40
+ import type { WebAppRegistry } from "../web-app.ts";
27
41
  import { CardLog } from "./card-log.ts";
28
42
  import { OwnerDesk } from "./desk.ts";
29
43
  import { FileDir } from "./files.ts";
@@ -33,7 +47,7 @@ import { RoomLog } from "./room-log.ts";
33
47
  import { Rooms } from "./rooms.ts";
34
48
  import { AppApi, type AppApiOptions, partsOf } from "./routes.ts";
35
49
  import { SocketRoute } from "./socket.ts";
36
- import { type AppSocket, Sockets } from "./sockets.ts";
50
+ import { type AppSocket, type Audience, Sockets } from "./sockets.ts";
37
51
  import { AppSurface } from "./surface.ts";
38
52
  import { WebThreads } from "./threads.ts";
39
53
  import { Uploads } from "./uploads.ts";
@@ -47,11 +61,15 @@ const SWEEP_EVERY_MS = 3_600_000;
47
61
  /** The tables the app keeps; the plugin lists them as its own migrations. */
48
62
  export const APP_MIGRATIONS: readonly Migration[] = [
49
63
  RoomLog.migration,
64
+ RoomLog.hostMigration,
65
+ HostCardLog.migration,
66
+ RoomReads.migration,
50
67
  CardLog.migration,
51
68
  WebThreads.migration,
52
69
  PushSubscriptions.migration,
53
70
  PushMutes.migration,
54
71
  RoomLayout.migration,
72
+ ...NOTICE_MIGRATIONS,
55
73
  ];
56
74
 
57
75
  export interface OwnerAppOptions {
@@ -69,14 +87,36 @@ export interface OwnerAppOptions {
69
87
  cardGraceSeconds: number;
70
88
  /** The most bytes of one uploaded file. */
71
89
  uploadMaxBytes: number;
72
- authenticator: Pick<Authenticator, "authenticate">;
90
+ /** How many days a stored file of an agent room is kept; default 30. */
91
+ uploadKeepDays?: number;
92
+ /** The name the app shows for a person; default the principal's own. */
93
+ nameOf?(principalId: string): Promise<string>;
94
+ /** The same-origin URL of a room, and of one of its messages, for a host's links. */
95
+ roomUrl(room: ChannelKey, message?: number): string;
96
+ /** Where the app reports what people do in it (a press on a host's button). */
97
+ events?: Pick<EventSink, "action">;
98
+ authenticator: Pick<Authenticator, "authenticate" | "recheck">;
73
99
  /** How the console signs the person in. */
74
- authMode: "none" | "password" | "verifier";
100
+ authMode: ResolvedOptions["auth"]["mode"];
101
+ /** Who the app admits; the page and the session report the tier only where it admits more than owners. */
102
+ admission: ResolvedOptions["admission"];
103
+ /** What the pieces of the app plug in: the kinds of host room, their handlers, who reads them. */
104
+ registry: WebAppRegistry;
105
+ /** The onboarding gate. */
106
+ gate: Gate;
107
+ /** The tiers whose people may use push. */
108
+ pushTiers: ResolvedOptions["push"]["tiers"];
109
+ /** Whether the yolo and long-running switches are served. */
110
+ modeSwitches: boolean;
75
111
  /** The longest an open socket lives; for a credential that logout or a new password does not reach. */
76
112
  socketLifeMs?: number;
77
- /** Web Push: the server's key, and how notifications read. */
113
+ /** The notices, onboarding and profile pieces: what they read of the options. */
114
+ extras: Pick<NoticesMountOptions, "profiles" | "routing" | "onboarding"> & {
115
+ push: NoticesMountOptions["push"];
116
+ };
117
+ /** Web Push: the server's key (absent with an external sender, which holds it), and how notifications read. */
78
118
  push: {
79
- vapid: Vapid;
119
+ vapid?: Vapid;
80
120
  /** The `sub` of the VAPID token; absent = push is not set up and nothing is sent. */
81
121
  subject: string | undefined;
82
122
  preview: boolean;
@@ -124,10 +164,12 @@ export class OwnerApp {
124
164
  readonly directChannel: DirectChannelProvider;
125
165
  readonly #api: AppApi;
126
166
  readonly #push: PushApi;
167
+ readonly #mounted: NoticesMount;
127
168
  readonly #sockets: Sockets;
128
169
  readonly #desk: OwnerDesk;
129
170
  readonly #presence: Presence;
130
171
  readonly #uploads: Uploads;
172
+ readonly #host: HostApp;
131
173
  readonly #options: OwnerAppOptions;
132
174
  readonly #unsubscribe: (() => void)[] = [];
133
175
  #sweeper: ReturnType<typeof setInterval> | undefined;
@@ -143,7 +185,17 @@ export class OwnerApp {
143
185
  const waiting = new FileDir(join(options.dataDir, "web", "uploads"));
144
186
  const uploads = new Uploads(waiting, stored, {
145
187
  maxBytes: options.uploadMaxBytes,
188
+ ...(options.uploadKeepDays === undefined
189
+ ? {}
190
+ : { keepDays: options.uploadKeepDays }),
146
191
  });
192
+ const reads = new RoomReads(options.database);
193
+ const hostCards = new HostCardLog(options.database);
194
+ const nameOf =
195
+ options.nameOf ??
196
+ (async (id: string) => (await identity.principal(id))?.displayName ?? id);
197
+ // The host rooms read their context once the surface exists; the surface asks for it per turn.
198
+ let hostApp: HostApp | undefined;
147
199
  const threads = new WebThreads(options.database);
148
200
  const subscriptions = new PushSubscriptions(options.database);
149
201
  const mutes = new PushMutes(options.database);
@@ -151,34 +203,63 @@ export class OwnerApp {
151
203
  const quietClock = options.push.quietHours
152
204
  ? new QuietClock(options.push.quietHours, options.push.timeZone)
153
205
  : undefined;
206
+ const quiet = quietClock
207
+ ? (at: number) => quietClock.quietAt(at)
208
+ : undefined;
209
+ const mounted = mountNotices({
210
+ database: options.database,
211
+ logger,
212
+ registry: options.registry,
213
+ gate: options.gate,
214
+ sockets,
215
+ identity,
216
+ ...(options.extras.profiles ? { profiles: options.extras.profiles } : {}),
217
+ subscriptions,
218
+ policy,
219
+ mount: options.mount,
220
+ routing: options.extras.routing,
221
+ push: options.extras.push,
222
+ ...(quiet ? { quiet } : {}),
223
+ ...(options.push.vapid ? { vapid: options.push.vapid } : {}),
224
+ onboarding: options.extras.onboarding,
225
+ });
154
226
  const push = new PushService({
227
+ hostRoom: (room) => options.registry.isHostRoom(room),
155
228
  subscriptions,
156
- sender: new PushSender({
157
- vapid: options.push.vapid,
158
- subject: options.push.subject,
159
- policy,
160
- ...(options.push.fetch ? { fetch: options.push.fetch } : {}),
161
- logger,
162
- }),
229
+ // The app signs only with its own key; with an external sender it queues for it instead.
230
+ ...(options.push.vapid
231
+ ? {
232
+ sender: new PushSender({
233
+ vapid: options.push.vapid,
234
+ subject: options.push.subject,
235
+ policy,
236
+ ...(options.push.fetch ? { fetch: options.push.fetch } : {}),
237
+ logger,
238
+ }),
239
+ }
240
+ : {}),
241
+ ...(mounted.route ? { route: mounted.route } : {}),
163
242
  identity,
164
243
  visible: (principalId) => sockets.anyVisible(principalId),
165
244
  preview: options.push.preview,
166
- ...(quietClock ? { quiet: (at: number) => quietClock.quietAt(at) } : {}),
245
+ ...(quiet ? { quiet } : {}),
167
246
  muted: roomMuted(mutes, threads, channelKey(options.surface, "thread.")),
168
247
  urlOf: options.push.urlOf,
169
248
  logger,
170
249
  });
171
- this.#push = new PushApi({
172
- vapid: options.push.vapid,
173
- policy,
174
- subscriptions,
175
- identity,
176
- enabled: options.push.subject !== undefined,
177
- });
250
+ this.#mounted = mounted;
251
+ this.#push = mounted.pushApi;
252
+ const { registry } = options;
253
+ sockets.setRoomReader((connection, room) =>
254
+ registry.access.readNow(connection, room),
255
+ );
256
+ // An agent room's frames are the owners'; a host room's are its readers'.
257
+ const audienceOf = (room: string): Audience =>
258
+ registry.isHostRoom(room) ? { room } : { owners: true };
178
259
  // The desk starts a late answer's turn through the socket route, which is made after it.
179
260
  let route: SocketRoute | undefined;
180
261
  const desk = new OwnerDesk({
181
- broadcast: (frame) => sockets.broadcast(frame),
262
+ broadcast: (frame, room) => sockets.deliver(frame, audienceOf(room)),
182
263
  graceMs: options.cardGraceSeconds * 1000,
183
264
  closed: (card) => cards.record(card),
184
265
  opened: (room, frame) => {
@@ -213,6 +294,20 @@ export class OwnerApp {
213
294
  const room = await rooms.resolve(parent);
214
295
  return room !== undefined && room.kind !== "thread";
215
296
  },
297
+ audienceOf,
298
+ isHostRoom: (room) => registry.isHostRoom(room),
299
+ fileBinding: (room) => {
300
+ const kind = registry.hostKindOf(room)?.kind;
301
+ return {
302
+ room,
303
+ ...(kind?.retention
304
+ ? { expiresAt: Date.now() + kind.retention.fileDays * 86_400_000 }
305
+ : {}),
306
+ };
307
+ },
308
+ channelContext: (message, contextOptions) =>
309
+ hostApp?.channelContext(message, contextOptions) ??
310
+ Promise.resolve(undefined),
216
311
  changed: () => this.changed(),
217
312
  unprompted: (room, author, text) => {
218
313
  push.reply(room, author, text).catch(() => undefined);
@@ -238,6 +333,27 @@ export class OwnerApp {
238
333
  mutes,
239
334
  activity: (room) => surface.activityOf(room),
240
335
  });
336
+ hostApp = new HostApp({
337
+ surface: options.surface,
338
+ registry,
339
+ sockets,
340
+ app: surface,
341
+ desk,
342
+ log,
343
+ cards: hostCards,
344
+ reads,
345
+ files: stored,
346
+ uploads,
347
+ rooms,
348
+ conversations: options.conversations,
349
+ nameOf,
350
+ roomUrl: options.roomUrl,
351
+ maxFileBytes: options.uploadMaxBytes,
352
+ ...(options.events ? { events: options.events } : {}),
353
+ logger,
354
+ });
355
+ hostApp.install();
356
+ this.#host = hostApp;
241
357
  route = new SocketRoute({
242
358
  path: `${options.mount}/api/socket`,
243
359
  origin: options.origin,
@@ -251,10 +367,17 @@ export class OwnerApp {
251
367
  rooms,
252
368
  uploads,
253
369
  presence,
254
- identity,
370
+ registry,
371
+ gate: options.gate,
372
+ audienceOf,
373
+ opened: (connection) => mounted.opened(connection),
255
374
  conversations: options.conversations,
256
375
  logger,
257
376
  });
377
+ const socketRoute = route;
378
+ this.#unsubscribe.push(
379
+ options.gate.onChanged((principalId) => socketRoute.refresh(principalId)),
380
+ );
258
381
  this.websocket = route.route();
259
382
  this.directChannel = webPushChannel({
260
383
  surface: options.surface,
@@ -291,8 +414,16 @@ export class OwnerApp {
291
414
  ...(options.skills ? { skills: options.skills } : {}),
292
415
  ...(options.prompts ? { prompts: options.prompts } : {}),
293
416
  avatars: agents.avatars,
417
+ reads,
418
+ host: hostApp,
419
+ nameOf,
294
420
  mount: options.mount,
295
421
  authMode: options.authMode,
422
+ admission: options.admission,
423
+ registry,
424
+ gate: options.gate,
425
+ pushTiers: options.pushTiers,
426
+ modeSwitches: options.modeSwitches,
296
427
  ...(options.push.quietHours
297
428
  ? {
298
429
  quietHours: {
@@ -312,12 +443,15 @@ export class OwnerApp {
312
443
  this.#unsubscribe.push(
313
444
  modes.onChange((channel, mode) => {
314
445
  if (!rooms.ownsKey(channel)) return;
315
- sockets.broadcast({
316
- type: "mode",
317
- room: channel,
318
- yolo: mode.yolo,
319
- longRunning: mode.longRunning,
320
- });
446
+ sockets.deliver(
447
+ {
448
+ type: "mode",
449
+ room: channel,
450
+ yolo: mode.yolo,
451
+ longRunning: mode.longRunning,
452
+ },
453
+ { owners: true },
454
+ );
321
455
  }),
322
456
  modes.onAutoApproved((entry) => {
323
457
  if (!rooms.ownsKey(entry.channel)) return;
@@ -342,17 +476,28 @@ export class OwnerApp {
342
476
  return pushed ?? this.#api.handle(request, rest, admission);
343
477
  }
344
478
 
345
- /** Tells every open socket the team or the room list may differ, once changes settle. */
479
+ /** Closes the sockets that came in on a session that has ended. */
480
+ sessionEnded(id: string): void {
481
+ this.#sockets.closeWhere(
482
+ (connection) => connection.session === id,
483
+ APP_CLOSE_EXPIRED,
484
+ "expired",
485
+ );
486
+ }
487
+
488
+ /** Tells the owners' sockets the team or the room list may differ, once changes settle. */
346
489
  changed(): void {
347
490
  if (this.#pending) return;
348
491
  this.#pending = setTimeout(() => {
349
492
  this.#pending = undefined;
350
- this.#sockets.broadcast({ type: "changed" });
493
+ this.#sockets.deliver({ type: "changed" }, { owners: true });
351
494
  }, CHANGED_COALESCE_MS);
352
495
  }
353
496
 
354
497
  start(): void {
355
498
  this.#uploads.adopt();
499
+ this.#host.start();
500
+ this.#mounted.start();
356
501
  this.#sweep();
357
502
  this.#sweeper = setInterval(() => this.#sweep(), SWEEP_EVERY_MS);
358
503
  }
@@ -364,6 +509,8 @@ export class OwnerApp {
364
509
  clearTimeout(this.#pending);
365
510
  this.#pending = undefined;
366
511
  for (const unsubscribe of this.#unsubscribe.splice(0)) unsubscribe();
512
+ this.#host.stop();
513
+ this.#mounted.stop().catch(() => undefined);
367
514
  this.#desk.stop();
368
515
  this.#presence.stop();
369
516
  this.#sockets.closeAll(1001, "stopping");
@@ -377,6 +524,20 @@ export class OwnerApp {
377
524
  } catch (error) {
378
525
  this.#options.logger.warn({ err: error }, "old app files not deleted");
379
526
  }
527
+ this.#host.sweep().then(
528
+ (gone) => {
529
+ if (gone.messages + gone.cards + gone.files > 0)
530
+ this.#options.logger.info(
531
+ gone,
532
+ "host room history past retention deleted",
533
+ );
534
+ },
535
+ (error: unknown) =>
536
+ this.#options.logger.warn(
537
+ { err: error },
538
+ "host room history not swept",
539
+ ),
540
+ );
380
541
  }
381
542
 
382
543
  /** Records and shows that yolo mode approved a held call in a room. */
@@ -391,6 +552,9 @@ export class OwnerApp {
391
552
  author: "Yolo mode",
392
553
  text: `Yolo mode approved: ${action}`,
393
554
  });
394
- this.#sockets.broadcast({ type: "auto_approved", room, tool, action });
555
+ this.#sockets.deliver(
556
+ { type: "auto_approved", room, tool, action },
557
+ { owners: true },
558
+ );
395
559
  }
396
560
  }
package/src/app/desk.ts CHANGED
@@ -50,13 +50,15 @@ export interface Answerer {
50
50
  /** The identity they spoke as, `<provider>:<subject>`. */
51
51
  speakerId: string;
52
52
  name: string;
53
+ /** The roles their sign-in carried. */
54
+ roles?: readonly string[];
53
55
  /** The actor their credential vouched for, which a message they start is written as. */
54
56
  actor?: ActorFacts;
55
57
  }
56
58
 
57
59
  export interface OwnerDeskOptions {
58
- /** Sends a frame to every open socket of every owner. */
59
- broadcast(frame: ServerFrame): void;
60
+ /** Puts a frame about a room on the sockets that may have it: the owners' for an agent room, the room's readers' for a host room. */
61
+ broadcast(frame: ServerFrame, room: ChannelKey): void;
60
62
  /** How long a turn that can be resumed waits on its card before it goes on without the answer. */
61
63
  graceMs: number;
62
64
  /** A card nobody answers closes after this long; default 24 hours. */
@@ -143,9 +145,14 @@ function answerOf(ended: Ended): CardAnswer | undefined {
143
145
  * after a day. A turn that can be resumed stops waiting after the grace period while the card
144
146
  * stays open (`late`); answered then, the card starts a turn in its room with the answer.
145
147
  *
146
- * Who answers: any owner, except a card that is the speaker's alone (`speakerOnly`), which only
147
- * the speaker's principal answers, and a card in a private conversation, which only its person
148
- * does. An approval is answered at the tier it needs, read again when the answer comes.
148
+ * Who answers (the same rule as Discord's `CardAudiences`, so a room's readers cannot widen it):
149
+ * - an approval that names approvers: a named approver who holds its tier now, or an owner;
150
+ * - an approval that names none: the turn's speaker at its tier, and the owners when the scope
151
+ * escalates;
152
+ * - a question: the speaker, and the owners when the scope escalates;
153
+ * - a card that is the speaker's alone (`speakerOnly`): only the speaker's principal;
154
+ * - without a scope: the owners.
155
+ * It applies to approve, decline, answer and cancel alike, and the tier is the answerer's now.
149
156
  */
150
157
  export class OwnerDesk {
151
158
  readonly #open = new Map<string, Open>();
@@ -273,15 +280,19 @@ export class OwnerDesk {
273
280
  };
274
281
  }
275
282
 
276
- /** The open cards as frames, to send again when a socket opens. */
277
- openFrames(): ServerFrame[] {
283
+ /** The open cards as frames with their rooms, to send again when a socket opens; each goes only to those who may have its room. */
284
+ openFrames(): { room: ChannelKey; frame: ServerFrame }[] {
278
285
  return [...this.#open.values()].map((open) => ({
279
- type: "prompt",
280
286
  room: open.room,
281
- prompt: open.frame,
287
+ frame: { type: "prompt", room: open.room, prompt: open.frame },
282
288
  }));
283
289
  }
284
290
 
291
+ /** The room a card belongs to, while it is open. */
292
+ roomOf(prompt: string): ChannelKey | undefined {
293
+ return this.#open.get(prompt)?.room;
294
+ }
295
+
285
296
  /** Whether the card is open and past its grace, so an answer to it starts a turn. */
286
297
  isLate(prompt: string): boolean {
287
298
  const frame = this.#open.get(prompt)?.frame;
@@ -357,7 +368,7 @@ export class OwnerDesk {
357
368
 
358
369
  /**
359
370
  * Who may approve a call held at `minTier`, as a test of the person who answers; undefined when
360
- * no one may, so the call is unavailable at once. The primary audience is every owner.
371
+ * no one may, so the call is unavailable at once.
361
372
  */
362
373
  #approvalAudience(
363
374
  scope: PromptScope | undefined,
@@ -366,6 +377,7 @@ export class OwnerDesk {
366
377
  speakerOnly: boolean,
367
378
  ): ((from: Answerer) => boolean) | undefined {
368
379
  const tierOk = (from: Answerer) => atLeast(from.tier, minTier);
380
+ const owner = (from: Answerer) => from.tier === "owner";
369
381
  if (speakerOnly) {
370
382
  // The speaker's alone: below the tier, or a speaker who is no person, no one answers.
371
383
  if (
@@ -377,28 +389,38 @@ export class OwnerDesk {
377
389
  const speaker = scope.principalId;
378
390
  return (from) => from.principalId === speaker && tierOk(from);
379
391
  }
380
- if (scope?.escalate === "none") {
392
+ // Without a scope a card is the owners'.
393
+ if (!scope) return owner;
394
+ const person = scope.principalId;
395
+ if (scope.escalate === "none") {
381
396
  // A private conversation's person is the only one who answers, and a call naming others is theirs only if named or an owner.
382
397
  if (!atLeast(scope.tier, minTier)) return undefined;
383
398
  if (
384
399
  approvers?.length &&
385
- !approvers.includes(scope.principalId) &&
400
+ !approvers.includes(person) &&
386
401
  scope.tier !== "owner"
387
402
  )
388
403
  return undefined;
389
- const person = scope.principalId;
390
404
  return (from) => from.principalId === person && tierOk(from);
391
405
  }
392
- return tierOk;
406
+ if (approvers?.length) {
407
+ // Named approvers answer it, at the tier it needs now, and so do the owners; the speaker does not unless named.
408
+ const named = new Set(approvers);
409
+ return (from) =>
410
+ (named.has(from.principalId) && tierOk(from)) || owner(from);
411
+ }
412
+ return (from) =>
413
+ (from.principalId === person && tierOk(from)) || owner(from);
393
414
  }
394
415
 
395
- /** Who may answer a question: every owner, or a private conversation's person alone. */
416
+ /** Who may answer a question: the speaker, and the owners where the scope escalates; without a scope, the owners. */
396
417
  #questionAudience(scope: PromptScope | undefined) {
397
- if (scope?.escalate === "none") {
398
- const person = scope.principalId;
418
+ if (!scope) return (from: Answerer) => from.tier === "owner";
419
+ const person = scope.principalId;
420
+ if (scope.escalate === "none")
399
421
  return (from: Answerer) => from.principalId === person;
400
- }
401
- return (_from: Answerer) => true;
422
+ return (from: Answerer) =>
423
+ from.principalId === person || from.tier === "owner";
402
424
  }
403
425
 
404
426
  /**
@@ -463,7 +485,10 @@ export class OwnerDesk {
463
485
  };
464
486
  // Kept before the sockets are told, so a page that reloads on the news finds it.
465
487
  this.#keep({ room, frame: open.frame, ...ended, closedAt });
466
- broadcast({ type: "prompt_closed", prompt: id, ...ended, closedAt });
488
+ broadcast(
489
+ { type: "prompt_closed", prompt: id, ...ended, closedAt },
490
+ room,
491
+ );
467
492
  };
468
493
  const onAbort = () => {
469
494
  if (!this.#open.has(id)) return;
@@ -488,13 +513,13 @@ export class OwnerDesk {
488
513
  late = true;
489
514
  signal?.removeEventListener("abort", onAbort);
490
515
  open.frame = { ...open.frame, late: true } as PromptFrame;
491
- broadcast({ type: "prompt", room, prompt: open.frame });
516
+ broadcast({ type: "prompt", room, prompt: open.frame }, room);
492
517
  resolve(card.pending as T);
493
518
  }, graceMs);
494
519
  signal?.addEventListener("abort", onAbort, { once: true });
495
520
  supersede?.addEventListener("abort", onWithdraw, { once: true });
496
521
  this.#open.set(id, open);
497
- broadcast({ type: "prompt", room, prompt: open.frame });
522
+ broadcast({ type: "prompt", room, prompt: open.frame }, room);
498
523
  try {
499
524
  this.#options.opened?.(room, open.frame);
500
525
  } catch (error) {
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
  }
@@ -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 {