pi-roundtable-web 0.25.0 → 0.26.1

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 (243) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +165 -12
  3. package/dist/account-zk4efb87.css +102 -0
  4. package/dist/chunk-account-ghq105t4.js +1 -0
  5. package/dist/chunk-action-menu-me0g9qsc.js +1 -0
  6. package/dist/{chunk-agent-0pm9p3nn.js → chunk-agent-rdv7ekrt.js} +1 -1
  7. package/dist/chunk-arranged-rooms-81rqhx9x.js +1 -0
  8. package/dist/chunk-character-creator-bd426yjw.js +1 -0
  9. package/dist/chunk-codeeditor-ajhgd33t.js +2 -0
  10. package/dist/chunk-confirm-p3mts8mx.js +1 -0
  11. package/dist/{chunk-connectors-anhghy3k.js → chunk-connectors-bkv3xn83.js} +1 -1
  12. package/dist/chunk-conversations-0e0vbxwr.js +1 -0
  13. package/dist/{chunk-drag-q4fx9b22.js → chunk-drag-peyb8t0k.js} +1 -1
  14. package/dist/{chunk-entry-he1embgp.js → chunk-entry-mv7q8br0.js} +1 -1
  15. package/dist/chunk-folder-list-era3tksh.js +1 -0
  16. package/dist/chunk-folds-hvws0bd5.js +1 -0
  17. package/dist/chunk-gate-hc7dd0fx.js +1 -0
  18. package/dist/chunk-group-scene-dkbpmbh5.js +1 -0
  19. package/dist/chunk-group-table-fqmxgdfw.js +1 -0
  20. package/dist/chunk-host-icons-4xde9ntk.js +1 -0
  21. package/dist/chunk-host-picture-014yzk20.js +1 -0
  22. package/dist/chunk-host-state-t56e5sdy.js +1 -0
  23. package/dist/chunk-host-ui-p1zy9nmp.js +1 -0
  24. package/dist/chunk-index-0fmmvgee.js +2 -0
  25. package/dist/chunk-index-0q15ysrb.js +1 -0
  26. package/dist/chunk-index-0xh24y25.js +2 -0
  27. package/dist/chunk-index-2yebd9tb.js +426 -0
  28. package/dist/chunk-index-4t0r3t3b.js +2 -0
  29. package/dist/chunk-index-5yzfrf7b.js +138 -0
  30. package/dist/{chunk-index-d5abvq7v.js → chunk-index-6twb7ccd.js} +2 -2
  31. package/dist/chunk-index-73gh7kgr.js +920 -0
  32. package/dist/chunk-index-7xffskfa.js +8 -0
  33. package/dist/chunk-index-aqw7cy5j.js +1 -0
  34. package/dist/chunk-index-b6ca02e8.js +2 -0
  35. package/dist/chunk-index-b8nvhcx5.js +2 -0
  36. package/dist/{chunk-index-bvfqjh77.js → chunk-index-cvbdf2ca.js} +2 -2
  37. package/dist/chunk-index-czdketze.js +2 -0
  38. package/dist/{chunk-index-9jqcskb4.js → chunk-index-dt9775ka.js} +5 -5
  39. package/dist/chunk-index-eg55mqqe.js +1 -0
  40. package/dist/chunk-index-gr6e0hse.js +2 -0
  41. package/dist/chunk-index-gyary7np.js +13 -0
  42. package/dist/chunk-index-j214tczc.js +1 -0
  43. package/dist/chunk-index-jd6632ef.js +2 -0
  44. package/dist/chunk-index-jw7dtshr.js +2 -0
  45. package/dist/{chunk-index-krtk6nn2.js → chunk-index-k7t14rwa.js} +2 -2
  46. package/dist/chunk-index-kwcx62wb.js +4 -0
  47. package/dist/chunk-index-m7fvw92f.js +2 -0
  48. package/dist/chunk-index-mmg788a1.js +2 -0
  49. package/dist/chunk-index-mq0dacwr.js +2 -0
  50. package/dist/chunk-index-p53ak25v.js +2 -0
  51. package/dist/{chunk-index-27qca4mr.js → chunk-index-pk1yxkv4.js} +2 -2
  52. package/dist/{chunk-index-9b1kt7qw.css → chunk-index-rbcz9jd4.css} +1 -1
  53. package/dist/{chunk-index-s681nny9.js → chunk-index-rmxbdmn2.js} +2 -2
  54. package/dist/chunk-index-rnxj3z8k.js +2 -0
  55. package/dist/chunk-index-snzzfhk3.js +2 -0
  56. package/dist/chunk-index-v3bmr0nw.js +2 -0
  57. package/dist/chunk-index-vcyzwtk2.js +2 -0
  58. package/dist/{chunk-index-zfd00hwb.js → chunk-index-w2j3njb6.js} +2 -2
  59. package/dist/chunk-index-w31c4yjv.js +2 -0
  60. package/dist/chunk-index-wrm8w165.js +2 -0
  61. package/dist/chunk-index-xh0mg9cw.js +2 -0
  62. package/dist/chunk-index-ybtvvx85.js +2 -0
  63. package/dist/chunk-index-yzsgsvnp.js +2 -0
  64. package/dist/{chunk-index-18gxy79n.js → chunk-index-z9x4cnzf.js} +2 -2
  65. package/dist/chunk-index-zrvg23jd.js +2 -0
  66. package/dist/chunk-index-zynq0b0w.js +50 -0
  67. package/dist/chunk-lang-css-88v3hp3d.js +1 -0
  68. package/dist/{chunk-lang-dockerfile-d5mzxs60.js → chunk-lang-dockerfile-k728ewsf.js} +1 -1
  69. package/dist/{chunk-lang-go-830fmhba.js → chunk-lang-go-wjwgay4v.js} +3 -3
  70. package/dist/chunk-lang-html-07d47es4.js +1 -0
  71. package/dist/chunk-lang-javascript-fmhhgzwd.js +1 -0
  72. package/dist/{chunk-lang-json-5eaarz7j.js → chunk-lang-json-7gd89mvs.js} +1 -1
  73. package/dist/chunk-lang-markdown-b6qbe8bt.js +6 -0
  74. package/dist/{chunk-lang-python-fjw84w10.js → chunk-lang-python-3v56y31b.js} +2 -2
  75. package/dist/{chunk-lang-rust-5rbp2zdk.js → chunk-lang-rust-kh0z74d1.js} +1 -1
  76. package/dist/{chunk-lang-shell-vzagpym3.js → chunk-lang-shell-9zv0zvbx.js} +1 -1
  77. package/dist/{chunk-lang-sql-0c2w9c2y.js → chunk-lang-sql-yw6bf3t7.js} +2 -2
  78. package/dist/{chunk-lang-toml-yr9fv6eb.js → chunk-lang-toml-tg1r96nq.js} +1 -1
  79. package/dist/chunk-lang-typescript-etjjvr2g.js +1 -0
  80. package/dist/{chunk-lang-xml-eze08p42.js → chunk-lang-xml-ev1mndd4.js} +1 -1
  81. package/dist/{chunk-lang-yaml-c4b6ft3y.js → chunk-lang-yaml-fz5qpb3r.js} +1 -1
  82. package/dist/chunk-layout-actions-48ecgtkg.js +1 -0
  83. package/dist/{chunk-menu-items-jrwxycr1.js → chunk-menu-items-1epy3h0t.js} +2 -2
  84. package/dist/chunk-mode-toggle-fxmfxsa4.js +1 -0
  85. package/dist/chunk-note-row-kd0fy7ee.js +75 -0
  86. package/dist/chunk-notes-yb8f057c.js +1 -0
  87. package/dist/chunk-notice-bell-wr4f1rph.js +1 -0
  88. package/dist/chunk-notices-x07da5bb.js +1 -0
  89. package/dist/chunk-onboarding-csrkbh9z.js +1 -0
  90. package/dist/chunk-overview-avc2d30t.js +1 -0
  91. package/dist/chunk-palette-9nhp95bz.js +1 -0
  92. package/dist/chunk-placement-items-q694dpgk.js +1 -0
  93. package/dist/chunk-profile-6qemf3m7.js +1 -0
  94. package/dist/chunk-prompt-history-7enzp2p3.js +1 -0
  95. package/dist/chunk-prompts-nv6mrn0q.js +1 -0
  96. package/dist/chunk-room-confirms-zz7n61y7.js +1 -0
  97. package/dist/chunk-room-menu-rr6nzap1.js +1 -0
  98. package/dist/chunk-rooms-menu-button-zbtwj47d.js +1 -0
  99. package/dist/chunk-schedules-rw57nr0m.js +1 -0
  100. package/dist/{chunk-seat-menu-jv4s7hmj.js → chunk-seat-menu-ys5v9abz.js} +1 -1
  101. package/dist/chunk-sheet-hosts-jhvaacst.js +1 -0
  102. package/dist/chunk-skill-editor-sz8an8qr.js +8 -0
  103. package/dist/chunk-skills-tnr1h16h.js +1 -0
  104. package/dist/chunk-switcher-list-17enhrse.js +1 -0
  105. package/dist/chunk-tab-dot-r6brdbmw.js +1 -0
  106. package/dist/chunk-table-r12cja6p.js +1 -0
  107. package/dist/chunk-table-seats-g3t392zz.js +1 -0
  108. package/dist/{chunk-table2d-4sje1fr0.js → chunk-table2d-p3ezsdcj.js} +1 -1
  109. package/dist/{chunk-table3d-h64skazp.js → chunk-table3d-4x2n2b00.js} +23 -23
  110. package/dist/chunk-thread-actions-qc6ezkha.js +1 -0
  111. package/dist/chunk-transcript-n2s6dher.js +1 -0
  112. package/dist/chunk-where-f3scdydc.js +1 -0
  113. package/dist/chunk-workspace-xs023bxm.js +1 -0
  114. package/dist/chunk-zh-tw-efvmk3fd.js +1 -0
  115. package/dist/index.html +1 -1
  116. package/dist/sw.js +1 -1
  117. package/package.json +5 -4
  118. package/src/api-types.ts +356 -4
  119. package/src/app/app.ts +201 -32
  120. package/src/app/desk.ts +47 -22
  121. package/src/app/files.ts +73 -7
  122. package/src/app/presence.ts +2 -2
  123. package/src/app/protocol.ts +63 -1
  124. package/src/app/room-log.ts +153 -14
  125. package/src/app/rooms.ts +39 -0
  126. package/src/app/routes.ts +168 -28
  127. package/src/app/socket.ts +349 -81
  128. package/src/app/sockets.ts +108 -20
  129. package/src/app/surface.ts +72 -36
  130. package/src/app/threads.ts +31 -2
  131. package/src/app/uploads.ts +33 -7
  132. package/src/auth/authenticator.ts +77 -8
  133. package/src/auth/login-page.ts +4 -2
  134. package/src/auth/next.ts +52 -0
  135. package/src/auth/oidc-pages.ts +62 -0
  136. package/src/auth/oidc.ts +649 -0
  137. package/src/auth/sessions.ts +193 -0
  138. package/src/authz.ts +173 -0
  139. package/src/console-api.ts +122 -25
  140. package/src/console-server.ts +102 -12
  141. package/src/gate.ts +90 -0
  142. package/src/host/access.ts +161 -0
  143. package/src/host/cards.ts +112 -0
  144. package/src/host/context.ts +188 -0
  145. package/src/host/forms.ts +240 -0
  146. package/src/host/frames.ts +349 -0
  147. package/src/host/host-app.ts +178 -0
  148. package/src/host/host-context.ts +49 -0
  149. package/src/host/host-routes.ts +396 -0
  150. package/src/host/pages.ts +266 -0
  151. package/src/host/reads.ts +155 -0
  152. package/src/host/retention.ts +41 -0
  153. package/src/host/room-views.ts +137 -0
  154. package/src/host/rooms-service.ts +429 -0
  155. package/src/host/stored-json.ts +13 -0
  156. package/src/host/views.ts +106 -0
  157. package/src/i18n/zh-tw.ts +139 -0
  158. package/src/index.ts +15 -1
  159. package/src/notices/api.ts +43 -0
  160. package/src/notices/mount.ts +292 -0
  161. package/src/notices/service.ts +294 -0
  162. package/src/notices/store.ts +298 -0
  163. package/src/notices/urls.ts +38 -0
  164. package/src/onboarding/api.ts +32 -0
  165. package/src/onboarding/service.ts +149 -0
  166. package/src/onboarding/steps.ts +75 -0
  167. package/src/onboarding/waivers.ts +40 -0
  168. package/src/options.ts +534 -34
  169. package/src/page-presentation.ts +47 -2
  170. package/src/profile/api.ts +175 -0
  171. package/src/profile/links.ts +162 -0
  172. package/src/push/api.ts +189 -52
  173. package/src/push/dispatch.ts +156 -0
  174. package/src/push/drain.ts +139 -0
  175. package/src/push/outbox.ts +218 -0
  176. package/src/push/prefs.ts +118 -0
  177. package/src/push/sender.ts +7 -2
  178. package/src/push/triggers.ts +56 -5
  179. package/src/push/vapid.ts +19 -0
  180. package/src/push/web-push-transport.ts +106 -0
  181. package/src/push-sender/index.ts +14 -0
  182. package/src/push-sender/run.ts +118 -0
  183. package/src/push-sender/session.ts +54 -0
  184. package/src/pwa.ts +32 -4
  185. package/src/visitors.ts +94 -6
  186. package/src/web-app.ts +718 -0
  187. package/src/web-plugin.ts +279 -26
  188. package/dist/chunk-action-menu-qe839g4b.js +0 -1
  189. package/dist/chunk-arranged-rooms-enpfswmg.js +0 -1
  190. package/dist/chunk-character-creator-6yf29xwx.js +0 -1
  191. package/dist/chunk-codeeditor-gdbdg7rp.js +0 -2
  192. package/dist/chunk-confirm-xrw7d732.js +0 -1
  193. package/dist/chunk-conversations-ry228aes.js +0 -1
  194. package/dist/chunk-folder-list-5cp8stna.js +0 -1
  195. package/dist/chunk-folds-hff104tk.js +0 -1
  196. package/dist/chunk-group-scene-h228jdat.js +0 -1
  197. package/dist/chunk-index-0p5h2kdz.js +0 -2
  198. package/dist/chunk-index-0pt08yd9.js +0 -1
  199. package/dist/chunk-index-12bw9yj8.js +0 -2
  200. package/dist/chunk-index-2ad17eh4.js +0 -2
  201. package/dist/chunk-index-31wh5twj.js +0 -52
  202. package/dist/chunk-index-4dy94pbz.js +0 -2
  203. package/dist/chunk-index-4ecjtdy8.js +0 -13
  204. package/dist/chunk-index-4v1b7ks9.js +0 -2
  205. package/dist/chunk-index-5d8yjj4m.js +0 -2
  206. package/dist/chunk-index-89ajmxh7.js +0 -2
  207. package/dist/chunk-index-b9ny4623.js +0 -2
  208. package/dist/chunk-index-e1dr6g10.js +0 -2
  209. package/dist/chunk-index-gpkzhq1g.js +0 -2
  210. package/dist/chunk-index-hk3xyy20.js +0 -2
  211. package/dist/chunk-index-jm23vn03.js +0 -4
  212. package/dist/chunk-index-k7gh0jvz.js +0 -2
  213. package/dist/chunk-index-ky42ev0b.js +0 -2
  214. package/dist/chunk-index-pa2yyqh0.js +0 -2
  215. package/dist/chunk-index-pcvt668q.js +0 -2
  216. package/dist/chunk-index-pqw27eye.js +0 -1
  217. package/dist/chunk-index-wmbmmrd4.js +0 -302
  218. package/dist/chunk-index-ymbjrsp6.js +0 -2
  219. package/dist/chunk-lang-css-t85r8rz0.js +0 -1
  220. package/dist/chunk-lang-html-n6jparge.js +0 -1
  221. package/dist/chunk-lang-javascript-xj00cp97.js +0 -1
  222. package/dist/chunk-lang-markdown-zxx8eke8.js +0 -6
  223. package/dist/chunk-lang-typescript-8f6yywt3.js +0 -1
  224. package/dist/chunk-layout-actions-bv0chj41.js +0 -1
  225. package/dist/chunk-notes-9b7pgqfe.js +0 -1
  226. package/dist/chunk-overview-8yw71esz.js +0 -1
  227. package/dist/chunk-palette-frms7axe.js +0 -1
  228. package/dist/chunk-placement-items-fvfy9m0y.js +0 -1
  229. package/dist/chunk-prompt-history-yrqehads.js +0 -1
  230. package/dist/chunk-prompts-g6609tqx.js +0 -1
  231. package/dist/chunk-room-confirms-0a2y0925.js +0 -1
  232. package/dist/chunk-room-menu-g11q97br.js +0 -1
  233. package/dist/chunk-rooms-menu-button-h9qge48n.js +0 -1
  234. package/dist/chunk-schedules-ezmacrbj.js +0 -1
  235. package/dist/chunk-sheet-hosts-b0azyfhb.js +0 -1
  236. package/dist/chunk-skill-editor-h6k6whwg.js +0 -8
  237. package/dist/chunk-skills-gqcfhq56.js +0 -1
  238. package/dist/chunk-switcher-list-mcpamqnj.js +0 -1
  239. package/dist/chunk-table-s8cde1xz.js +0 -1
  240. package/dist/chunk-table-seats-wqkjn8bc.js +0 -1
  241. package/dist/chunk-transcript-c59c6mpm.js +0 -1
  242. package/dist/chunk-workspace-0cyspkk4.js +0 -1
  243. package/dist/chunk-zh-tw-nc93jm10.js +0 -1
package/src/app/socket.ts CHANGED
@@ -1,8 +1,6 @@
1
1
  import type {
2
- ActorFacts,
3
2
  ChannelKey,
4
3
  ConversationPort,
5
- IdentityService,
6
4
  InboundMessage,
7
5
  Logger,
8
6
  RouteSocket,
@@ -23,11 +21,13 @@ import {
23
21
  type RoomMessage,
24
22
  } from "../api-types.ts";
25
23
  import type { Authenticator } from "../auth/authenticator.ts";
24
+ import type { Gate } from "../gate.ts";
25
+ import type { PluggedFrames, Viewer, WebAppRegistry } from "../web-app.ts";
26
26
  import type { Answerer, OwnerDesk } from "./desk.ts";
27
27
  import type { Presence } from "./presence.ts";
28
28
  import { parseClientFrame } from "./protocol.ts";
29
29
  import type { Rooms } from "./rooms.ts";
30
- import type { AppSocket, Sockets } from "./sockets.ts";
30
+ import type { AppSocket, Audience, Sockets } from "./sockets.ts";
31
31
  import type { AppSurface } from "./surface.ts";
32
32
  import type { Uploads } from "./uploads.ts";
33
33
 
@@ -46,15 +46,25 @@ export interface SocketRouteOptions {
46
46
  path: string;
47
47
  /** The console's own origin, the only one a browser may open the socket from. */
48
48
  origin: string;
49
- authenticator: Pick<Authenticator, "authenticate">;
49
+ authenticator: Pick<Authenticator, "authenticate" | "recheck">;
50
50
  sockets: Sockets;
51
51
  surface: AppSurface;
52
52
  desk: OwnerDesk;
53
53
  rooms: Rooms;
54
- /** The table of who is busy, sent after `ready`; absent in a route built without one. */
54
+ /** The table of who is busy, sent to the owners after `ready`; absent in a route built without one. */
55
55
  presence?: Pick<Presence, "frame">;
56
56
  uploads: Uploads;
57
- identity: Pick<IdentityService, "tierOf">;
57
+ /** What the pieces of the app plug in: host rooms, their frame handlers and the rule for who reads them. */
58
+ registry: Pick<WebAppRegistry, "frameHandler" | "isHostRoom" | "access">;
59
+ /** The onboarding gate: a socket whose person owes a required step is gated. */
60
+ gate: Pick<Gate, "required" | "onChanged">;
61
+ /** Who has a room's frames. */
62
+ audienceOf(room: string): Audience;
63
+ /**
64
+ * Called once the socket has had `ready` and before anything else is replayed to it, gated or
65
+ * not: the pieces of the app send what is per person there, such as the notices and the badge.
66
+ */
67
+ opened?(connection: AppSocket): Promise<void>;
58
68
  conversations: Pick<ConversationPort, "stop">;
59
69
  logger: Logger;
60
70
  /**
@@ -69,20 +79,28 @@ export interface SocketRouteOptions {
69
79
  now?: () => number;
70
80
  }
71
81
 
72
- /** The identity string a person's messages are written as: `<provider>:<subject>`. */
73
- const identityOf = (actor: ActorFacts | undefined, fallback: string) =>
74
- actor ? `${actor.provider}:${actor.subject}` : fallback;
82
+ /** The viewer behind a socket, at the tier it holds now. */
83
+ export const viewerOfSocket = (connection: AppSocket): Viewer => ({
84
+ principalId: connection.principal.id,
85
+ speakerId: connection.speakerId,
86
+ tier: connection.tier,
87
+ roles: connection.roles,
88
+ name: connection.principal.name,
89
+ });
75
90
 
76
91
  /**
77
- * The WebSocket of the owner app: the upgrade passes the console's authenticator, so it is the
78
- * same check as a page's and the API's, and every frame after it is a write by an owner whose
79
- * tier is read again. A frame is answered with a frame; a refused one says why, and nothing a
80
- * client names is taken on trust: a room must be one of the app's, an upload must be the
81
- * sender's own, a card must be open and answerable by them.
92
+ * The WebSocket of the app: the upgrade passes the console's authenticator, so it is the same
93
+ * check as a page's and the API's, and every frame after it asks again whether the person is still
94
+ * admitted and at which tier (a person who is not is refused and the socket closed). A frame is
95
+ * answered with a frame; a refused one says why, and nothing a client names is taken on trust: a
96
+ * room must be one of the app's and readable by the person, an upload must be the sender's own, a
97
+ * card must be open and answerable by them. What a socket receives is addressed: see `Audience`.
82
98
  */
83
99
  export class SocketRoute {
84
100
  readonly #options: SocketRouteOptions;
85
101
  readonly #now: () => number;
102
+ /** The steps each socket was last told it owes, so a change of them is announced. */
103
+ readonly #owed = new WeakMap<AppSocket, string>();
86
104
 
87
105
  constructor(options: SocketRouteOptions) {
88
106
  this.#options = options;
@@ -124,14 +142,21 @@ export class SocketRoute {
124
142
  { status: admission.status },
125
143
  );
126
144
  }
127
- const { principal } = admission.visitor;
145
+ const { visitor } = admission;
146
+ const { principal } = visitor;
128
147
  return {
129
148
  data: {
130
149
  principal: { id: principal.id, name: principal.displayName },
131
150
  actor: admission.actor,
132
151
  expiresAt: admission.expiresAt,
152
+ tier: visitor.tier,
153
+ speakerId: visitor.speakerId,
154
+ roles: visitor.roles,
155
+ gated: false,
156
+ session: admission.session,
133
157
  visible: false,
134
158
  focus: undefined,
159
+ subscriptions: new Set<string>(),
135
160
  },
136
161
  headers: { "Sec-WebSocket-Protocol": APP_PROTOCOL },
137
162
  };
@@ -139,7 +164,7 @@ export class SocketRoute {
139
164
 
140
165
  #open(socket: RouteSocket<AppSocket>): void {
141
166
  const connection = socket.data;
142
- const { sockets, desk, uploads } = this.#options;
167
+ const { sockets } = this.#options;
143
168
  sockets.add(connection, socket);
144
169
  const now = this.#now();
145
170
  const lapses = [
@@ -160,61 +185,171 @@ export class SocketRoute {
160
185
  );
161
186
  }
162
187
  connection.recheck = setInterval(() => {
163
- this.#owner(connection).then(
164
- (who) => {
165
- if (!who) socket.close(APP_CLOSE_FORBIDDEN, "forbidden");
166
- },
167
- (error: unknown) =>
168
- this.#options.logger.warn(
169
- { err: error },
170
- "an app socket's person was not looked up",
171
- ),
188
+ this.#state(connection).catch((error: unknown) =>
189
+ this.#options.logger.warn(
190
+ { err: error },
191
+ "an app socket's person was not looked up",
192
+ ),
172
193
  );
173
194
  }, this.#options.recheckMs ?? RECHECK_MS);
174
- sockets.send(connection, {
175
- type: "ready",
176
- protocol: APP_PROTOCOL_VERSION,
177
- principal: connection.principal.id,
178
- name: connection.principal.name,
179
- uploads: {
180
- perMessage: MAX_MESSAGE_FILES,
181
- maxBytes: uploads.maxBytes,
182
- },
195
+ this.#begin(connection).catch((error: unknown) => {
196
+ this.#options.logger.warn({ err: error }, "an app socket did not start");
197
+ socket.close(APP_CLOSE_FORBIDDEN, "forbidden");
183
198
  });
184
- for (const frame of desk.openFrames()) sockets.send(connection, frame);
185
- const { presence } = this.#options;
199
+ }
200
+
201
+ /**
202
+ * Admits the socket again, reads its gate, and sends `ready`. Until it has, no frame but ones
203
+ * addressed to the socket reach it, so nothing overtakes `ready`.
204
+ */
205
+ async #begin(connection: AppSocket): Promise<void> {
206
+ const { sockets, uploads } = this.#options;
207
+ const who = await this.#state(connection, { announce: false });
208
+ if (!who) return;
209
+ const owed = connection.gated ? await this.#steps(connection) : [];
210
+ sockets.deliver(
211
+ {
212
+ type: "ready",
213
+ protocol: APP_PROTOCOL_VERSION,
214
+ principal: connection.principal.id,
215
+ name: connection.principal.name,
216
+ uploads: {
217
+ perMessage: MAX_MESSAGE_FILES,
218
+ maxBytes: uploads.maxBytes,
219
+ },
220
+ ...(owed.length > 0 ? { gate: owed } : {}),
221
+ },
222
+ { connection },
223
+ );
224
+ // The socket is started before the hook runs, so the frames it sends reach the socket.
225
+ connection.started = true;
226
+ await this.#options
227
+ .opened?.(connection)
228
+ .catch((error: unknown) =>
229
+ this.#options.logger.warn(
230
+ { err: error },
231
+ "an app socket was not given its notices",
232
+ ),
233
+ );
234
+ if (connection.gated) return;
235
+ this.#replay(connection);
236
+ }
237
+
238
+ /** Sends a socket the open cards it may have and the table of who is busy, and starts its live frames. */
239
+ #replay(connection: AppSocket): void {
240
+ const { sockets, desk, presence, audienceOf } = this.#options;
241
+ // The replay goes through the same audience filter as a live frame, which needs the socket started.
242
+ connection.started = true;
243
+ for (const { room, frame } of desk.openFrames())
244
+ sockets.deliver(frame, {
245
+ connection,
246
+ audience: audienceOf(room),
247
+ });
186
248
  presence?.frame().then(
187
- (frame) => sockets.send(connection, frame),
249
+ (frame) =>
250
+ sockets.deliver(frame, {
251
+ connection,
252
+ audience: { owners: true },
253
+ }),
188
254
  (error: unknown) =>
189
255
  this.#options.logger.warn({ err: error }, "presence was not sent"),
190
256
  );
191
257
  }
192
258
 
193
- /** The person behind a socket as an answerer at their tier now; undefined when they are no owner any more. */
194
- async #owner(connection: AppSocket): Promise<Answerer | undefined> {
195
- const tier = await this.#options.identity.tierOf(connection.principal.id);
196
- if (tier !== "owner") return undefined;
259
+ async #steps(connection: AppSocket) {
260
+ return this.#options.gate.required(viewerOfSocket(connection));
261
+ }
262
+
263
+ /**
264
+ * Asks again whether the person behind a socket is still admitted and at which tier, and
265
+ * whether a required step stands in the way. Loses the socket when they are not admitted; a
266
+ * change of tier or of the gate takes effect on what the socket is sent from here on.
267
+ */
268
+ async #state(
269
+ connection: AppSocket,
270
+ options: { announce: boolean } = { announce: true },
271
+ ): Promise<Answerer | undefined> {
272
+ const { authenticator, sockets, gate } = this.#options;
273
+ const checked = await authenticator.recheck({
274
+ actor: connection.actor,
275
+ session: connection.session,
276
+ });
277
+ if ("refusal" in checked) {
278
+ connection.socket?.close(
279
+ checked.status === 401 ? APP_CLOSE_EXPIRED : APP_CLOSE_FORBIDDEN,
280
+ checked.status === 401 ? "expired" : "forbidden",
281
+ );
282
+ return undefined;
283
+ }
284
+ const { visitor } = checked;
285
+ connection.tier = visitor.tier;
286
+ connection.roles = visitor.roles;
287
+ connection.speakerId = visitor.speakerId;
288
+ connection.principal.name = visitor.principal.displayName;
289
+ // A page the person may not show is not shown: a focus on an agent room is the owners'.
290
+ if (
291
+ connection.focus !== undefined &&
292
+ !this.#mayFocus(connection, connection.focus)
293
+ )
294
+ connection.focus = undefined;
295
+ const steps = await gate.required(viewerOfSocket(connection));
296
+ const gated = steps.length > 0;
297
+ // A change of which steps are owed is announced too, not only a change of whether any is.
298
+ const owed = steps.map((step) => step.id).join(",");
299
+ const listChanged = owed !== this.#owed.get(connection);
300
+ this.#owed.set(connection, owed);
301
+ if (gated !== connection.gated || (gated && listChanged)) {
302
+ const opens = connection.gated && !gated;
303
+ connection.gated = gated;
304
+ if (options.announce) {
305
+ sockets.deliver({ type: "gate", steps }, { connection });
306
+ if (opens) this.#replay(connection);
307
+ }
308
+ }
197
309
  return {
198
310
  principalId: connection.principal.id,
199
- tier,
200
- speakerId: identityOf(connection.actor, connection.principal.id),
311
+ tier: connection.tier,
312
+ speakerId: connection.speakerId,
201
313
  name: connection.principal.name,
314
+ roles: connection.roles,
202
315
  ...(connection.actor ? { actor: connection.actor } : {}),
203
316
  };
204
317
  }
205
318
 
319
+ /** Tells every open socket of a person what they now owe; the gate calls it when their steps change. */
320
+ refresh(principalId: string): void {
321
+ this.#options.sockets.each(
322
+ (c) => c.principal.id === principalId,
323
+ (c) => {
324
+ this.#state(c).catch((error: unknown) =>
325
+ this.#options.logger.warn({ err: error }, "a gate was not read"),
326
+ );
327
+ },
328
+ );
329
+ }
330
+
331
+ /** Whether a socket may show a room: an agent room to an owner, a host room it follows. */
332
+ #mayFocus(connection: AppSocket, room: string): boolean {
333
+ return this.#options.registry.isHostRoom(room)
334
+ ? connection.subscriptions.has(room)
335
+ : connection.tier === "owner";
336
+ }
337
+
206
338
  #refuse(
207
339
  connection: AppSocket,
208
340
  code: RefusalCode,
209
341
  id?: string,
210
342
  prompt?: string,
211
343
  ): void {
212
- this.#options.sockets.send(connection, {
213
- type: "refused",
214
- code,
215
- ...(id === undefined ? {} : { id }),
216
- ...(prompt === undefined ? {} : { prompt }),
217
- });
344
+ this.#options.sockets.deliver(
345
+ {
346
+ type: "refused",
347
+ code,
348
+ ...(id === undefined ? {} : { id }),
349
+ ...(prompt === undefined ? {} : { prompt }),
350
+ },
351
+ { connection },
352
+ );
218
353
  }
219
354
 
220
355
  async #message(
@@ -227,32 +362,37 @@ export class SocketRoute {
227
362
  this.#refuse(connection, "bad_frame");
228
363
  return;
229
364
  }
230
- // What a socket shows or watches applies the moment its frame arrives: the owner lookup
231
- // below awaits, and frames that waited on it could land out of order.
365
+ // What a socket shows applies the moment its frame arrives: the lookup below awaits, and
366
+ // frames that waited on it could land out of order.
232
367
  switch (frame.type) {
233
368
  case "hello":
234
369
  connection.visible = frame.visible;
235
- connection.focus = frame.focus;
370
+ connection.focus =
371
+ frame.focus !== undefined && this.#mayFocus(connection, frame.focus)
372
+ ? frame.focus
373
+ : undefined;
236
374
  break;
237
375
  case "visibility":
238
376
  connection.visible = frame.visible;
239
377
  break;
240
378
  case "focus":
241
- connection.focus = frame.room;
379
+ connection.focus =
380
+ frame.room !== undefined && this.#mayFocus(connection, frame.room)
381
+ ? frame.room
382
+ : undefined;
242
383
  break;
243
384
  default:
244
385
  break;
245
386
  }
246
- // Every frame comes from an owner, at their tier now; a socket whose person is no owner
247
- // any more is closed rather than left to receive what the owners are shown.
248
- const who = await this.#owner(connection);
387
+ // Every frame comes from a person admitted now, at the tier they hold now; a socket whose
388
+ // person is no longer admitted is closed rather than left to receive what the room's readers are shown.
389
+ const who = await this.#state(connection);
249
390
  if (!who) {
250
391
  this.#refuse(
251
392
  connection,
252
393
  "forbidden",
253
394
  frame.type === "send" ? frame.id : undefined,
254
395
  );
255
- socket.close(APP_CLOSE_FORBIDDEN, "forbidden");
256
396
  return;
257
397
  }
258
398
  if (
@@ -261,6 +401,15 @@ export class SocketRoute {
261
401
  frame.type === "focus"
262
402
  )
263
403
  return;
404
+ // While a required step stands undone a socket may only handle notices.
405
+ if (connection.gated && frame.type !== "ack") {
406
+ this.#refuse(
407
+ connection,
408
+ "gated",
409
+ frame.type === "send" || frame.type === "act" ? frame.id : undefined,
410
+ );
411
+ return;
412
+ }
264
413
  await this.#write(connection, who, frame);
265
414
  }
266
415
 
@@ -269,36 +418,44 @@ export class SocketRoute {
269
418
  who: Answerer,
270
419
  frame: Exclude<ClientFrame, { type: "hello" | "visibility" | "focus" }>,
271
420
  ): Promise<void> {
272
- const { desk, rooms, conversations } = this.#options;
421
+ const { rooms, conversations, registry } = this.#options;
273
422
  let refused: RefusalCode | undefined;
274
423
  switch (frame.type) {
275
424
  case "send":
425
+ if (registry.isHostRoom(frame.room)) {
426
+ await this.#plugged(connection, "hostSend", frame);
427
+ return;
428
+ }
276
429
  await this.#send(connection, who, frame);
277
430
  return;
278
431
  case "stop": {
432
+ if (registry.isHostRoom(frame.room)) {
433
+ await this.#plugged(connection, "hostStop", frame);
434
+ return;
435
+ }
436
+ // The agent rooms are the owners'.
437
+ if (who.tier !== "owner") {
438
+ refused = "forbidden";
439
+ break;
440
+ }
279
441
  const room = await rooms.resolve(frame.room);
280
442
  if (!room || room.readOnly) refused = "unknown_room";
281
443
  else conversations.stop(room.key);
282
444
  break;
283
445
  }
284
446
  case "approval":
285
- refused = this.#needsActor(who, frame.prompt)
286
- ? "forbidden"
287
- : desk.approve(who, frame.prompt, frame.approved);
288
- break;
289
447
  case "answer":
290
- refused = this.#needsActor(who, frame.prompt)
291
- ? "forbidden"
292
- : desk.answer(who, frame.prompt, frame.answer);
293
- break;
294
448
  case "answers":
295
- refused = this.#needsActor(who, frame.prompt)
296
- ? "forbidden"
297
- : desk.answers(who, frame.prompt, frame.answers, frame.note);
298
- break;
299
449
  case "cancel":
300
- refused = desk.cancel(who, frame.prompt);
450
+ refused = await this.#card(who, frame);
301
451
  break;
452
+ case "subscribe":
453
+ case "unsubscribe":
454
+ case "act":
455
+ case "ack":
456
+ case "read":
457
+ await this.#plugged(connection, frame.type, frame);
458
+ return;
302
459
  }
303
460
  if (refused)
304
461
  this.#refuse(
@@ -309,6 +466,80 @@ export class SocketRoute {
309
466
  );
310
467
  }
311
468
 
469
+ /** Hands a frame to the piece of the app that handles it; a frame nobody handles is refused. */
470
+ async #plugged<K extends keyof PluggedFrames>(
471
+ connection: AppSocket,
472
+ type: K,
473
+ frame: PluggedFrames[K],
474
+ ): Promise<void> {
475
+ const handler = this.#options.registry.frameHandler(type);
476
+ const id = "id" in frame ? frame.id : undefined;
477
+ if (!handler) {
478
+ this.#refuse(connection, "unknown_room", id);
479
+ return;
480
+ }
481
+ try {
482
+ await handler({
483
+ frame,
484
+ viewer: viewerOfSocket(connection),
485
+ connection,
486
+ refuse: (code, ref) => this.#refuse(connection, code, ref ?? id),
487
+ });
488
+ } catch (error) {
489
+ this.#options.logger.warn(
490
+ { frame: type, err: error },
491
+ "an app frame was not handled",
492
+ );
493
+ this.#refuse(connection, "not_accepted", id);
494
+ }
495
+ }
496
+
497
+ /**
498
+ * An answer to a card: who may give it is the card's own audience (the turn's speaker, the
499
+ * named approvers, the owners where the scope escalates), which the room's access cannot widen.
500
+ * A card of a host room is first something the person must be able to read; a card of an agent
501
+ * room is the owners'.
502
+ */
503
+ async #card(
504
+ who: Answerer,
505
+ frame: Extract<
506
+ ClientFrame,
507
+ { type: "approval" | "answer" | "answers" | "cancel" }
508
+ >,
509
+ ): Promise<RefusalCode | undefined> {
510
+ const { desk, registry } = this.#options;
511
+ const room = desk.roomOf(frame.prompt);
512
+ if (room !== undefined) {
513
+ if (registry.isHostRoom(room)) {
514
+ const viewer: Viewer = {
515
+ principalId: who.principalId,
516
+ speakerId: who.speakerId,
517
+ tier: who.tier,
518
+ roles: who.roles ?? [],
519
+ name: who.name,
520
+ };
521
+ if (!(await registry.access.read(viewer, room as ChannelKey)))
522
+ return "unknown_prompt";
523
+ } else if (who.tier !== "owner") return "forbidden";
524
+ }
525
+ switch (frame.type) {
526
+ case "approval":
527
+ return this.#needsActor(who, frame.prompt)
528
+ ? "forbidden"
529
+ : desk.approve(who, frame.prompt, frame.approved);
530
+ case "answer":
531
+ return this.#needsActor(who, frame.prompt)
532
+ ? "forbidden"
533
+ : desk.answer(who, frame.prompt, frame.answer);
534
+ case "answers":
535
+ return this.#needsActor(who, frame.prompt)
536
+ ? "forbidden"
537
+ : desk.answers(who, frame.prompt, frame.answers, frame.note);
538
+ case "cancel":
539
+ return desk.cancel(who, frame.prompt);
540
+ }
541
+ }
542
+
312
543
  /**
313
544
  * Whether this answer cannot be taken: a late card's answer starts a turn written as the
314
545
  * answerer, which needs the actor their credential vouched for. Refused up front, the card
@@ -326,6 +557,11 @@ export class SocketRoute {
326
557
  const { rooms, uploads, surface, sockets, logger } = this.#options;
327
558
  const refuse = (code: RefusalCode) =>
328
559
  this.#refuse(connection, code, frame.id);
560
+ // The agent rooms are the owners', and they take only turns.
561
+ if (who.tier !== "owner") return refuse("forbidden");
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");
329
565
  const room = await rooms.resolve(frame.room);
330
566
  if (!room) return refuse("unknown_room");
331
567
  if (room.readOnly) return refuse("read_only");
@@ -333,20 +569,22 @@ export class SocketRoute {
333
569
  if (text.length > MAX_MESSAGE_CHARS) return refuse("too_long");
334
570
  const ids = frame.uploads ?? [];
335
571
  if (text === "" && ids.length === 0) return refuse("empty");
336
- // Without the actor the credential vouched for, the router cannot tell whose message it is.
337
- 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");
338
575
  const files = uploads.claim(ids, who.principalId);
339
576
  if (!files) return refuse("unknown_upload");
340
577
  if (!surface.started) return refuse("not_accepted");
341
578
  try {
342
- await this.say(room.key, who, text, files, connection, (message) =>
343
- sockets.send(connection, {
344
- type: "accepted",
345
- id: frame.id,
346
- room: room.key,
347
- message,
348
- }),
349
- );
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
+ );
350
588
  } catch (error) {
351
589
  logger.warn(
352
590
  { room: room.key, err: error },
@@ -356,6 +594,36 @@ export class SocketRoute {
356
594
  }
357
595
  }
358
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
+
359
627
  /**
360
628
  * Takes a message of the owner into a room: stores it, shows it to the other sockets, and
361
629
  * hands it to the host as the owner's. `files` are uploads the sender claimed, which move to