pi-roundtable-web 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +145 -8
  3. package/dist/account-zk4efb87.css +102 -0
  4. package/dist/chunk-account-hw49gd0d.js +1 -0
  5. package/dist/chunk-action-menu-rm5cyqkj.js +1 -0
  6. package/dist/{chunk-agent-0pm9p3nn.js → chunk-agent-b017vswv.js} +1 -1
  7. package/dist/chunk-arranged-rooms-q5v3dm1r.js +1 -0
  8. package/dist/chunk-character-creator-5dxcvd16.js +1 -0
  9. package/dist/chunk-codeeditor-pyt09fwg.js +2 -0
  10. package/dist/chunk-confirm-521yqmta.js +1 -0
  11. package/dist/{chunk-connectors-anhghy3k.js → chunk-connectors-hrm06nx8.js} +1 -1
  12. package/dist/chunk-conversations-e26rrf01.js +1 -0
  13. package/dist/{chunk-drag-q4fx9b22.js → chunk-drag-v41gx2mc.js} +1 -1
  14. package/dist/{chunk-entry-he1embgp.js → chunk-entry-jxxb3ath.js} +1 -1
  15. package/dist/{chunk-folder-list-5cp8stna.js → chunk-folder-list-b97xk12b.js} +1 -1
  16. package/dist/chunk-folds-b8q7eags.js +1 -0
  17. package/dist/chunk-gate-8k61stsc.js +1 -0
  18. package/dist/chunk-group-scene-dx6wt19m.js +1 -0
  19. package/dist/chunk-group-table-e4jr8r3a.js +1 -0
  20. package/dist/chunk-host-state-g7e7c0k5.js +1 -0
  21. package/dist/chunk-host-ui-x7k6n07v.js +939 -0
  22. package/dist/{chunk-index-bvfqjh77.js → chunk-index-014rjv9k.js} +2 -2
  23. package/dist/{chunk-index-d5abvq7v.js → chunk-index-2d0kg096.js} +2 -2
  24. package/dist/chunk-index-2qd81wbx.js +2 -0
  25. package/dist/{chunk-index-9b1kt7qw.css → chunk-index-2stynxw6.css} +1 -1
  26. package/dist/{chunk-index-gpkzhq1g.js → chunk-index-459drsft.js} +2 -2
  27. package/dist/chunk-index-4mfj596y.js +2 -0
  28. package/dist/{chunk-index-18gxy79n.js → chunk-index-66nskrdj.js} +2 -2
  29. package/dist/chunk-index-awx6nnta.js +2 -0
  30. package/dist/chunk-index-bbsytycb.js +2 -0
  31. package/dist/chunk-index-c07p8k0x.js +4 -0
  32. package/dist/chunk-index-cq1e9xdz.js +2 -0
  33. package/dist/chunk-index-d1prgfbp.js +2 -0
  34. package/dist/chunk-index-dhpt8qr9.js +2 -0
  35. package/dist/chunk-index-e5sd30xy.js +2 -0
  36. package/dist/chunk-index-g351921r.js +1 -0
  37. package/dist/chunk-index-hby0ap91.js +2 -0
  38. package/dist/chunk-index-hssjbr1k.js +2 -0
  39. package/dist/chunk-index-k2qq1gdj.js +2 -0
  40. package/dist/chunk-index-ke9ynm0n.js +2 -0
  41. package/dist/{chunk-index-s681nny9.js → chunk-index-km40bhsz.js} +2 -2
  42. package/dist/chunk-index-kpscvg85.js +2 -0
  43. package/dist/chunk-index-m0d100mg.js +2 -0
  44. package/dist/chunk-index-mb72snk9.js +2 -0
  45. package/dist/chunk-index-mjqqver5.js +4 -0
  46. package/dist/{chunk-index-zfd00hwb.js → chunk-index-nqfkwkfp.js} +2 -2
  47. package/dist/chunk-index-nwqk447w.js +2 -0
  48. package/dist/{chunk-index-wmbmmrd4.js → chunk-index-pcfhpm8v.js} +4 -4
  49. package/dist/chunk-index-q8h8xzbm.js +2 -0
  50. package/dist/chunk-index-r5vszgfs.js +2 -0
  51. package/dist/{chunk-index-27qca4mr.js → chunk-index-rhtvcbsq.js} +2 -2
  52. package/dist/chunk-index-rjv7vrwd.js +13 -0
  53. package/dist/chunk-index-v23m38p6.js +50 -0
  54. package/dist/chunk-index-va0g7d88.js +1 -0
  55. package/dist/{chunk-index-krtk6nn2.js → chunk-index-wnjp6wv0.js} +2 -2
  56. package/dist/chunk-index-xgkd15n3.js +2 -0
  57. package/dist/{chunk-index-9jqcskb4.js → chunk-index-y8rnq8zf.js} +5 -5
  58. package/dist/chunk-index-z3k18atc.js +2 -0
  59. package/dist/chunk-lang-css-6bepqpnx.js +1 -0
  60. package/dist/{chunk-lang-dockerfile-d5mzxs60.js → chunk-lang-dockerfile-hrav55t7.js} +1 -1
  61. package/dist/{chunk-lang-go-830fmhba.js → chunk-lang-go-x9ahv6e5.js} +3 -3
  62. package/dist/chunk-lang-html-8jc72zt4.js +1 -0
  63. package/dist/chunk-lang-javascript-2p2qtbnf.js +1 -0
  64. package/dist/{chunk-lang-json-5eaarz7j.js → chunk-lang-json-hsemyd0r.js} +1 -1
  65. package/dist/chunk-lang-markdown-zkwb4c9n.js +6 -0
  66. package/dist/{chunk-lang-python-fjw84w10.js → chunk-lang-python-tewx7006.js} +2 -2
  67. package/dist/{chunk-lang-rust-5rbp2zdk.js → chunk-lang-rust-0ydx84s1.js} +1 -1
  68. package/dist/{chunk-lang-shell-vzagpym3.js → chunk-lang-shell-gq1tmdz4.js} +1 -1
  69. package/dist/{chunk-lang-sql-0c2w9c2y.js → chunk-lang-sql-xt1qyycg.js} +2 -2
  70. package/dist/{chunk-lang-toml-yr9fv6eb.js → chunk-lang-toml-0hamc5fc.js} +1 -1
  71. package/dist/chunk-lang-typescript-82xf7z2e.js +1 -0
  72. package/dist/{chunk-lang-xml-eze08p42.js → chunk-lang-xml-wf6h5pt2.js} +1 -1
  73. package/dist/{chunk-lang-yaml-c4b6ft3y.js → chunk-lang-yaml-7vk521h1.js} +1 -1
  74. package/dist/chunk-layout-actions-zh7bezk7.js +1 -0
  75. package/dist/{chunk-menu-items-jrwxycr1.js → chunk-menu-items-1n9qargb.js} +2 -2
  76. package/dist/chunk-notes-t15bh04m.js +1 -0
  77. package/dist/chunk-notice-bell-w3y0h0sr.js +1 -0
  78. package/dist/chunk-notices-fa3g7qze.js +1 -0
  79. package/dist/chunk-onboarding-34v3nr17.js +1 -0
  80. package/dist/chunk-overview-km5vq375.js +1 -0
  81. package/dist/chunk-palette-a8txdgd0.js +1 -0
  82. package/dist/chunk-placement-items-wgaxsbvs.js +1 -0
  83. package/dist/chunk-profile-ypxdbsk1.js +1 -0
  84. package/dist/chunk-prompt-history-wm9dsy6s.js +1 -0
  85. package/dist/chunk-prompts-7mhhq7zs.js +1 -0
  86. package/dist/chunk-room-confirms-t11a1gsw.js +1 -0
  87. package/dist/chunk-room-menu-2tm2xtfx.js +1 -0
  88. package/dist/chunk-rooms-menu-button-wxnsxjwc.js +1 -0
  89. package/dist/chunk-schedules-2ft6xxyw.js +1 -0
  90. package/dist/{chunk-seat-menu-jv4s7hmj.js → chunk-seat-menu-d3npdtx1.js} +1 -1
  91. package/dist/chunk-sheet-hosts-vngvp7wv.js +1 -0
  92. package/dist/chunk-skill-editor-65n2hsq4.js +8 -0
  93. package/dist/chunk-skills-shkdwes8.js +1 -0
  94. package/dist/chunk-switcher-list-g6hhmr1t.js +1 -0
  95. package/dist/chunk-table-1b6bsq2y.js +1 -0
  96. package/dist/chunk-table-seats-vy1qhdks.js +1 -0
  97. package/dist/{chunk-table2d-4sje1fr0.js → chunk-table2d-n26b7gd0.js} +1 -1
  98. package/dist/{chunk-table3d-h64skazp.js → chunk-table3d-8aysyf56.js} +12 -12
  99. package/dist/chunk-transcript-355faceq.js +1 -0
  100. package/dist/chunk-where-j5qdt8a4.js +1 -0
  101. package/dist/chunk-workspace-vrbs6zx1.js +1 -0
  102. package/dist/chunk-zh-tw-zeg600bk.js +1 -0
  103. package/dist/index.html +1 -1
  104. package/dist/sw.js +1 -1
  105. package/package.json +5 -4
  106. package/src/api-types.ts +301 -4
  107. package/src/app/app.ts +196 -32
  108. package/src/app/desk.ts +47 -22
  109. package/src/app/files.ts +73 -7
  110. package/src/app/presence.ts +2 -2
  111. package/src/app/protocol.ts +63 -1
  112. package/src/app/room-log.ts +141 -14
  113. package/src/app/routes.ts +124 -26
  114. package/src/app/socket.ts +309 -77
  115. package/src/app/sockets.ts +108 -20
  116. package/src/app/surface.ts +72 -36
  117. package/src/app/uploads.ts +33 -7
  118. package/src/auth/authenticator.ts +71 -8
  119. package/src/auth/next.ts +52 -0
  120. package/src/auth/oidc-pages.ts +60 -0
  121. package/src/auth/oidc.ts +611 -0
  122. package/src/auth/sessions.ts +193 -0
  123. package/src/authz.ts +172 -0
  124. package/src/console-api.ts +122 -25
  125. package/src/console-server.ts +70 -6
  126. package/src/gate.ts +90 -0
  127. package/src/host/access.ts +161 -0
  128. package/src/host/cards.ts +99 -0
  129. package/src/host/context.ts +141 -0
  130. package/src/host/forms.ts +237 -0
  131. package/src/host/frames.ts +342 -0
  132. package/src/host/host-app.ts +176 -0
  133. package/src/host/host-context.ts +49 -0
  134. package/src/host/host-routes.ts +363 -0
  135. package/src/host/pages.ts +266 -0
  136. package/src/host/reads.ts +155 -0
  137. package/src/host/retention.ts +41 -0
  138. package/src/host/room-views.ts +137 -0
  139. package/src/host/rooms-service.ts +414 -0
  140. package/src/host/stored-json.ts +13 -0
  141. package/src/host/views.ts +103 -0
  142. package/src/i18n/zh-tw.ts +132 -0
  143. package/src/notices/api.ts +43 -0
  144. package/src/notices/mount.ts +292 -0
  145. package/src/notices/service.ts +294 -0
  146. package/src/notices/store.ts +298 -0
  147. package/src/notices/urls.ts +38 -0
  148. package/src/onboarding/api.ts +32 -0
  149. package/src/onboarding/service.ts +149 -0
  150. package/src/onboarding/steps.ts +75 -0
  151. package/src/onboarding/waivers.ts +40 -0
  152. package/src/options.ts +498 -34
  153. package/src/profile/api.ts +175 -0
  154. package/src/profile/links.ts +162 -0
  155. package/src/push/api.ts +189 -52
  156. package/src/push/dispatch.ts +156 -0
  157. package/src/push/drain.ts +139 -0
  158. package/src/push/outbox.ts +218 -0
  159. package/src/push/prefs.ts +118 -0
  160. package/src/push/sender.ts +7 -2
  161. package/src/push/triggers.ts +56 -5
  162. package/src/push/vapid.ts +19 -0
  163. package/src/push/web-push-transport.ts +106 -0
  164. package/src/push-sender/index.ts +14 -0
  165. package/src/push-sender/run.ts +118 -0
  166. package/src/push-sender/session.ts +54 -0
  167. package/src/visitors.ts +94 -6
  168. package/src/web-app.ts +674 -0
  169. package/src/web-plugin.ts +268 -26
  170. package/dist/chunk-action-menu-qe839g4b.js +0 -1
  171. package/dist/chunk-arranged-rooms-enpfswmg.js +0 -1
  172. package/dist/chunk-character-creator-6yf29xwx.js +0 -1
  173. package/dist/chunk-codeeditor-gdbdg7rp.js +0 -2
  174. package/dist/chunk-confirm-xrw7d732.js +0 -1
  175. package/dist/chunk-conversations-ry228aes.js +0 -1
  176. package/dist/chunk-folds-hff104tk.js +0 -1
  177. package/dist/chunk-group-scene-h228jdat.js +0 -1
  178. package/dist/chunk-index-0p5h2kdz.js +0 -2
  179. package/dist/chunk-index-0pt08yd9.js +0 -1
  180. package/dist/chunk-index-12bw9yj8.js +0 -2
  181. package/dist/chunk-index-2ad17eh4.js +0 -2
  182. package/dist/chunk-index-31wh5twj.js +0 -52
  183. package/dist/chunk-index-4dy94pbz.js +0 -2
  184. package/dist/chunk-index-4ecjtdy8.js +0 -13
  185. package/dist/chunk-index-4v1b7ks9.js +0 -2
  186. package/dist/chunk-index-5d8yjj4m.js +0 -2
  187. package/dist/chunk-index-89ajmxh7.js +0 -2
  188. package/dist/chunk-index-b9ny4623.js +0 -2
  189. package/dist/chunk-index-e1dr6g10.js +0 -2
  190. package/dist/chunk-index-hk3xyy20.js +0 -2
  191. package/dist/chunk-index-jm23vn03.js +0 -4
  192. package/dist/chunk-index-k7gh0jvz.js +0 -2
  193. package/dist/chunk-index-ky42ev0b.js +0 -2
  194. package/dist/chunk-index-pa2yyqh0.js +0 -2
  195. package/dist/chunk-index-pcvt668q.js +0 -2
  196. package/dist/chunk-index-pqw27eye.js +0 -1
  197. package/dist/chunk-index-ymbjrsp6.js +0 -2
  198. package/dist/chunk-lang-css-t85r8rz0.js +0 -1
  199. package/dist/chunk-lang-html-n6jparge.js +0 -1
  200. package/dist/chunk-lang-javascript-xj00cp97.js +0 -1
  201. package/dist/chunk-lang-markdown-zxx8eke8.js +0 -6
  202. package/dist/chunk-lang-typescript-8f6yywt3.js +0 -1
  203. package/dist/chunk-layout-actions-bv0chj41.js +0 -1
  204. package/dist/chunk-notes-9b7pgqfe.js +0 -1
  205. package/dist/chunk-overview-8yw71esz.js +0 -1
  206. package/dist/chunk-palette-frms7axe.js +0 -1
  207. package/dist/chunk-placement-items-fvfy9m0y.js +0 -1
  208. package/dist/chunk-prompt-history-yrqehads.js +0 -1
  209. package/dist/chunk-prompts-g6609tqx.js +0 -1
  210. package/dist/chunk-room-confirms-0a2y0925.js +0 -1
  211. package/dist/chunk-room-menu-g11q97br.js +0 -1
  212. package/dist/chunk-rooms-menu-button-h9qge48n.js +0 -1
  213. package/dist/chunk-schedules-ezmacrbj.js +0 -1
  214. package/dist/chunk-sheet-hosts-b0azyfhb.js +0 -1
  215. package/dist/chunk-skill-editor-h6k6whwg.js +0 -8
  216. package/dist/chunk-skills-gqcfhq56.js +0 -1
  217. package/dist/chunk-switcher-list-mcpamqnj.js +0 -1
  218. package/dist/chunk-table-s8cde1xz.js +0 -1
  219. package/dist/chunk-table-seats-wqkjn8bc.js +0 -1
  220. package/dist/chunk-transcript-c59c6mpm.js +0 -1
  221. package/dist/chunk-workspace-0cyspkk4.js +0 -1
  222. package/dist/chunk-zh-tw-nc93jm10.js +0 -1
@@ -1,32 +1,79 @@
1
- import type { ActorFacts, Logger, RouteSocket } from "pi-roundtable";
1
+ import type { ActorFacts, Logger, RouteSocket, Tier } from "pi-roundtable";
2
2
  import type { ServerFrame } from "../api-types.ts";
3
3
 
4
- /** One open WebSocket of an owner: who, what the page shows, and when its credential lapses. */
4
+ /** One open WebSocket: who, what the page shows, and when its credential lapses. */
5
5
  export interface AppSocket {
6
6
  principal: { id: string; name: string };
7
7
  /** The actor the credential vouched for, which the person's messages are written as. */
8
8
  actor: ActorFacts | undefined;
9
9
  expiresAt: Date | undefined;
10
+ /** The tier they hold, as of the upgrade or the latest frame or recheck. */
11
+ tier: Tier;
12
+ /** The identity they spoke as; the principal's id when the credential names none. */
13
+ speakerId: string;
14
+ /** The roles their sign-in carried. */
15
+ roles: readonly string[];
16
+ /** A required onboarding step stands undone: only the frames a gated socket may have reach it. */
17
+ gated: boolean;
18
+ /** The session the socket came in on, for the recheck that asks whether it still stands. */
19
+ session?: string | undefined;
10
20
  /** The page is in view; a push goes out only when no socket is. */
11
21
  visible: boolean;
12
22
  /** The room the page shows, whose progress streams to it. */
13
23
  focus: string | undefined;
24
+ /** The host rooms the socket follows. */
25
+ subscriptions: Set<string>;
26
+ /** Set once `ready` and the replay went out: until then nothing but a frame addressed to the socket reaches it. */
27
+ started?: boolean;
14
28
  socket?: RouteSocket<AppSocket>;
15
29
  /** Closes the socket when its credential lapses or the cap on its life is reached. */
16
30
  expiry?: ReturnType<typeof setTimeout>;
17
- /** Looks again at whether the person is still an owner. */
31
+ /** Looks again at whether the person is still admitted, and at which tier. */
18
32
  recheck?: ReturnType<typeof setInterval>;
19
33
  }
20
34
 
21
- /** The open sockets. Every one belongs to an owner, so a frame for the owners goes to them all. */
35
+ /**
36
+ * Who a frame is for. There is no unaddressed send: every frame names its audience, and the
37
+ * open-time replay goes through the same filter as a live frame.
38
+ */
39
+ export type Audience =
40
+ /** Sockets whose tier is owner now; with `focus`, those showing that room. */
41
+ | { owners: true; focus?: string }
42
+ /** Every socket of one person. */
43
+ | { principal: string }
44
+ /** The sockets that follow a host room and may read it now; with `focus`, those also showing it. */
45
+ | { room: string; focus?: true }
46
+ /** One socket; with `audience`, only if it is also in that audience (the replay on open). */
47
+ | { connection: AppSocket; audience?: Audience };
48
+
49
+ /** The frames a gated socket still gets: the gate itself, the person's notices and badge, and refusals. */
50
+ const GATED_FRAMES: ReadonlySet<ServerFrame["type"]> = new Set([
51
+ "ready",
52
+ "refused",
53
+ "gate",
54
+ "notice",
55
+ "notice_resolved",
56
+ "badge",
57
+ ]);
58
+
59
+ /** Whether a socket may read a host room now; answered from a memo, so it costs no lookup. */
60
+ export type RoomReader = (connection: AppSocket, room: string) => boolean;
61
+
62
+ /** The open sockets, and the one way to put a frame on them. */
22
63
  export class Sockets {
23
64
  readonly #open = new Set<AppSocket>();
24
65
  readonly #logger: Logger;
66
+ #reader: RoomReader = () => false;
25
67
 
26
68
  constructor(logger: Logger) {
27
69
  this.#logger = logger;
28
70
  }
29
71
 
72
+ /** Sets who may read which host room; until it is set, no host room frame reaches anyone. */
73
+ setRoomReader(reader: RoomReader): void {
74
+ this.#reader = reader;
75
+ }
76
+
30
77
  add(connection: AppSocket, socket: RouteSocket<AppSocket>): void {
31
78
  connection.socket = socket;
32
79
  this.#open.add(connection);
@@ -40,8 +87,42 @@ export class Sockets {
40
87
  this.#open.delete(connection);
41
88
  }
42
89
 
43
- /** Sends a frame on one socket; a dropped send is logged, never thrown. */
44
- send(connection: AppSocket, frame: ServerFrame): void {
90
+ /** Whether the connection is in the audience now. */
91
+ reaches(connection: AppSocket, audience: Audience): boolean {
92
+ if ("connection" in audience)
93
+ return (
94
+ audience.connection === connection &&
95
+ (!audience.audience || this.reaches(connection, audience.audience))
96
+ );
97
+ if (!connection.started) return false;
98
+ if ("owners" in audience)
99
+ return (
100
+ connection.tier === "owner" &&
101
+ (audience.focus === undefined || connection.focus === audience.focus)
102
+ );
103
+ if ("principal" in audience)
104
+ return connection.principal.id === audience.principal;
105
+ return (
106
+ connection.subscriptions.has(audience.room) &&
107
+ this.#reader(connection, audience.room) &&
108
+ (!audience.focus || connection.focus === audience.room)
109
+ );
110
+ }
111
+
112
+ /**
113
+ * Puts a frame on the sockets in `audience`, except `except`. A gated socket gets only what the
114
+ * gate lets through. A dropped send is logged, never thrown.
115
+ */
116
+ deliver(frame: ServerFrame, audience: Audience, except?: AppSocket): void {
117
+ for (const connection of this.#open) {
118
+ if (connection === except || !this.reaches(connection, audience))
119
+ continue;
120
+ if (connection.gated && !GATED_FRAMES.has(frame.type)) continue;
121
+ this.#send(connection, frame);
122
+ }
123
+ }
124
+
125
+ #send(connection: AppSocket, frame: ServerFrame): void {
45
126
  const result = connection.socket?.send(JSON.stringify(frame));
46
127
  if (result === "dropped")
47
128
  this.#logger.warn(
@@ -50,18 +131,7 @@ export class Sockets {
50
131
  );
51
132
  }
52
133
 
53
- /** Sends a frame to every open socket, or those `where` accepts, except `except`. */
54
- broadcast(
55
- frame: ServerFrame,
56
- where?: (connection: AppSocket) => boolean,
57
- except?: AppSocket,
58
- ): void {
59
- for (const connection of this.#open)
60
- if (connection !== except && (!where || where(connection)))
61
- this.send(connection, frame);
62
- }
63
-
64
- /** Whether a page of this person is in view now; of any owner when no one is named. */
134
+ /** Whether a page of this person is in view now; of any person when none is named. */
65
135
  anyVisible(principalId?: string): boolean {
66
136
  for (const connection of this.#open)
67
137
  if (
@@ -72,10 +142,28 @@ export class Sockets {
72
142
  return false;
73
143
  }
74
144
 
145
+ /** Calls `visit` on each open socket `where` accepts. */
146
+ each(
147
+ where: (connection: AppSocket) => boolean,
148
+ visit: (connection: AppSocket) => void,
149
+ ): void {
150
+ for (const connection of [...this.#open])
151
+ if (where(connection)) visit(connection);
152
+ }
153
+
154
+ /** Closes the sockets `where` accepts, such as those of a session that ended. */
155
+ closeWhere(
156
+ where: (connection: AppSocket) => boolean,
157
+ code: number,
158
+ reason: string,
159
+ ): void {
160
+ for (const connection of [...this.#open])
161
+ if (where(connection)) connection.socket?.close(code, reason);
162
+ }
163
+
75
164
  /** Closes every socket, when the app stops. */
76
165
  closeAll(code: number, reason: string): void {
77
- for (const connection of [...this.#open])
78
- connection.socket?.close(code, reason);
166
+ this.closeWhere(() => true, code, reason);
79
167
  }
80
168
 
81
169
  get size(): number {
@@ -16,9 +16,9 @@ import {
16
16
  } from "pi-roundtable";
17
17
  import type { RoomFile, RoomMessage } from "../api-types.ts";
18
18
  import type { OwnerDesk } from "./desk.ts";
19
- import { type FileDir, typeByName } from "./files.ts";
19
+ import { type FileBinding, type FileDir, typeByName } from "./files.ts";
20
20
  import type { NewRoomMessage, RoomLog } from "./room-log.ts";
21
- import type { AppSocket, Sockets } from "./sockets.ts";
21
+ import type { AppSocket, Audience, Sockets } from "./sockets.ts";
22
22
  import { AppSurfaceThreads } from "./surface-threads.ts";
23
23
  import type { WebThreads } from "./threads.ts";
24
24
  import type { Uploads } from "./uploads.ts";
@@ -45,6 +45,12 @@ export interface AppSurfaceOptions {
45
45
  threads: Pick<WebThreads, "open" | "close">;
46
46
  /** Whether a room can host a thread: an agent's or group's room that exists. */
47
47
  canHost(parent: ChannelKey): Promise<boolean>;
48
+ /** Who has a room's frames: the owners' sockets for an agent room, a host room's readers for a host room. */
49
+ audienceOf(room: string): Audience;
50
+ /** Whether the room is a host's (its frames are the `host_*` ones). */
51
+ isHostRoom(room: string): boolean;
52
+ /** What binds a file an agent attaches in a host room, so its readers are served it. */
53
+ fileBinding?(room: string): FileBinding | undefined;
48
54
  /** Tells the sockets the room list differs, once changes settle. */
49
55
  changed(): void;
50
56
  /**
@@ -54,6 +60,8 @@ export interface AppSurfaceOptions {
54
60
  unprompted?(room: ChannelKey, author: string, text: string): void;
55
61
  /** Called when a room's activity changes, so the presence table can follow. */
56
62
  activity?(): void;
63
+ /** What was said in a host room since the assistant last posted; the host rooms' reader. */
64
+ channelContext?: ChatSurface["channelContext"];
57
65
  /** The clock, in milliseconds; default `Date.now`. */
58
66
  now?: () => number;
59
67
  logger: Logger;
@@ -88,8 +96,8 @@ export interface RoomActivity {
88
96
  }
89
97
 
90
98
  /**
91
- * The owner app's rooms as a chat surface: what the host shows in a room reaches every socket of
92
- * every owner as a frame, and what a room says is stored in the room log. The agent team runs
99
+ * The owner app's rooms as a chat surface: what the host shows in a room reaches the sockets that
100
+ * may have it (the owners' for an agent room) as a frame, and what a room says is stored in the room log. The agent team runs
93
101
  * in the rooms (`agentRooms`) as it runs in its Discord channels, but each room is a
94
102
  * conversation of its own. A reply arrives whole, in Markdown, with its files stored, and an
95
103
  * agent's interim text is shown live until the final reply replaces it.
@@ -101,6 +109,8 @@ export class AppSurface implements ChatSurface {
101
109
  readonly formatting = APP_FORMATTING;
102
110
  /** The turn takes an upload up to the most the app lets a person upload. */
103
111
  readonly maxAttachmentBytes: number;
112
+ /** Present on a surface with host rooms: what was said in one since the assistant last posted. */
113
+ channelContext?: ChatSurface["channelContext"];
104
114
  readonly threads: AppSurfaceThreads;
105
115
  readonly #options: AppSurfaceOptions;
106
116
  readonly #activity = new Map<ChannelKey, Activity>();
@@ -114,6 +124,7 @@ export class AppSurface implements ChatSurface {
114
124
  this.surface = options.surface;
115
125
  this.maxAttachmentBytes = options.uploads.maxBytes;
116
126
  this.#options = options;
127
+ if (options.channelContext) this.channelContext = options.channelContext;
117
128
  this.threads = new AppSurfaceThreads({
118
129
  surface: options.surface,
119
130
  threads: options.threads,
@@ -147,26 +158,29 @@ export class AppSurface implements ChatSurface {
147
158
  return this.#deliver !== undefined;
148
159
  }
149
160
 
150
- /** Stores a message of a room and shows it to every open socket, except `except`. */
161
+ /** Stores a message of a room and shows it to the sockets that may have the room, except `except`. */
151
162
  async publish(row: NewRoomMessage, except?: AppSocket): Promise<RoomMessage> {
152
163
  const message = await this.#options.log.append(row);
153
- this.#options.sockets.broadcast(
154
- { type: "message", room: row.room, message },
155
- undefined,
164
+ this.#options.sockets.deliver(
165
+ this.#options.isHostRoom(row.room)
166
+ ? { type: "host_message", room: row.room, message }
167
+ : { type: "message", room: row.room, message },
168
+ this.#options.audienceOf(row.room),
156
169
  except,
157
170
  );
158
171
  return message;
159
172
  }
160
173
 
161
- /** Changes the text of a stored message and shows the change to every open socket. */
174
+ /** Changes the text of a stored message and shows the change to the sockets that may have the room. */
162
175
  async edit(room: ChannelKey, id: number, text: string): Promise<void> {
163
176
  const message = await this.#options.log.edit(room, id, text);
164
177
  if (!message) return;
165
- this.#options.sockets.broadcast({
166
- type: "edited",
167
- room: message.room,
168
- message,
169
- });
178
+ this.#options.sockets.deliver(
179
+ this.#options.isHostRoom(room)
180
+ ? { type: "host_edited", room: message.room, message }
181
+ : { type: "edited", room: message.room, message },
182
+ this.#options.audienceOf(room),
183
+ );
170
184
  }
171
185
 
172
186
  /** Whether a turn works in the room and whether Stop applies, as `activity` frames say. */
@@ -225,12 +239,15 @@ export class AppSurface implements ChatSurface {
225
239
  }
226
240
 
227
241
  #announce(room: ChannelKey, outcome?: "done" | "failed"): void {
228
- this.#options.sockets.broadcast({
229
- type: "activity",
230
- room,
231
- ...this.activityOf(room),
232
- ...(outcome ? { outcome } : {}),
233
- });
242
+ this.#options.sockets.deliver(
243
+ {
244
+ type: "activity",
245
+ room,
246
+ ...this.activityOf(room),
247
+ ...(outcome ? { outcome } : {}),
248
+ },
249
+ this.#options.audienceOf(room),
250
+ );
234
251
  this.#options.activity?.();
235
252
  }
236
253
 
@@ -283,7 +300,15 @@ export class AppSurface implements ChatSurface {
283
300
  const entry = this.#activity.get(channel);
284
301
  if (entry) entry.outcome = reply.failed ? "failed" : "done";
285
302
  const files: RoomFile[] = (reply.files ?? []).map((file) =>
286
- this.#options.files.put(file.name, typeByName(file.name), file.data),
303
+ this.#options.files.put(
304
+ file.name,
305
+ typeByName(file.name),
306
+ file.data,
307
+ undefined,
308
+ this.#options.isHostRoom(channel)
309
+ ? this.#options.fileBinding?.(channel)
310
+ : undefined,
311
+ ),
287
312
  );
288
313
  await this.publish({
289
314
  room: channel,
@@ -328,21 +353,26 @@ export class AppSurface implements ChatSurface {
328
353
  this.#speaks(channel, as?.agent);
329
354
  const live = crypto.randomUUID();
330
355
  const show = (current: string) =>
331
- this.#options.sockets.broadcast({
332
- type: "message",
333
- room: channel,
334
- message: {
335
- id: 0,
356
+ this.#options.sockets.deliver(
357
+ {
358
+ type: this.#options.isHostRoom(channel)
359
+ ? "host_message"
360
+ : "message",
336
361
  room: channel,
337
- kind: "agent",
338
- ...(as?.agent ? { agent: as.agent } : {}),
339
- author: as?.name ?? ASSISTANT,
340
- text: current,
341
- files: [],
342
- at: new Date().toISOString(),
343
- live,
362
+ message: {
363
+ id: 0,
364
+ room: channel,
365
+ kind: "agent",
366
+ ...(as?.agent ? { agent: as.agent } : {}),
367
+ author: as?.name ?? ASSISTANT,
368
+ text: current,
369
+ files: [],
370
+ at: new Date().toISOString(),
371
+ live,
372
+ },
344
373
  },
345
- });
374
+ this.#options.audienceOf(channel),
375
+ );
346
376
  show(text);
347
377
  return {
348
378
  edit: async (edited) => {
@@ -362,9 +392,15 @@ export class AppSurface implements ChatSurface {
362
392
 
363
393
  progress(channel: ChannelKey, event: TurnProgress): void {
364
394
  this.#toolEvent(channel, event);
365
- this.#options.sockets.broadcast(
395
+ // Only the sockets showing the room get its progress, and only those that may have the room.
396
+ const audience = this.#options.audienceOf(channel);
397
+ this.#options.sockets.deliver(
366
398
  { type: "progress", room: channel, event },
367
- (connection) => connection.focus === channel,
399
+ "owners" in audience
400
+ ? { owners: true, focus: channel }
401
+ : "room" in audience
402
+ ? { room: audience.room, focus: true }
403
+ : audience,
368
404
  );
369
405
  }
370
406
 
@@ -1,7 +1,12 @@
1
1
  import { extname } from "node:path";
2
2
  import type { AttachmentFile, AttachmentRef } from "pi-roundtable";
3
3
  import type { RoomFile, UploadView } from "../api-types.ts";
4
- import { FILE_RETENTION_MS, type FileDir, safeName } from "./files.ts";
4
+ import {
5
+ FILE_RETENTION_MS,
6
+ type FileBinding,
7
+ type FileDir,
8
+ safeName,
9
+ } from "./files.ts";
5
10
  import {
6
11
  boundaryOf,
7
12
  MultipartError,
@@ -32,7 +37,7 @@ const startsWith = (bytes: Uint8Array, signature: readonly number[], at = 0) =>
32
37
  const ascii = (text: string) => [...text].map((char) => char.charCodeAt(0));
33
38
 
34
39
  /** The image type the first bytes say, if any; these four are the ones the page shows itself. */
35
- function imageTypeOf(head: Uint8Array): string | undefined {
40
+ export function imageTypeOf(head: Uint8Array): string | undefined {
36
41
  if (startsWith(head, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
37
42
  return "image/png";
38
43
  if (startsWith(head, [0xff, 0xd8, 0xff])) return "image/jpeg";
@@ -153,6 +158,8 @@ export class Uploads {
153
158
  readonly #now: () => number;
154
159
  readonly maxBytes: number;
155
160
  readonly #pendingBytes: number;
161
+ /** How long a stored file of an agent room is kept, in milliseconds. */
162
+ readonly #keepMs: number;
156
163
 
157
164
  constructor(
158
165
  waiting: FileDir,
@@ -161,6 +168,8 @@ export class Uploads {
161
168
  maxBytes?: number;
162
169
  /** The most bytes one person may have waiting; default 400 MiB. */
163
170
  pendingBytes?: number;
171
+ /** How many days a stored file of an agent room is kept; default 30. */
172
+ keepDays?: number;
164
173
  now?: () => number;
165
174
  } = {},
166
175
  ) {
@@ -169,6 +178,10 @@ export class Uploads {
169
178
  this.#now = options.now ?? Date.now;
170
179
  this.maxBytes = options.maxBytes ?? DEFAULT_UPLOAD_MAX_BYTES;
171
180
  this.#pendingBytes = options.pendingBytes ?? PENDING_BYTES_PER_PRINCIPAL;
181
+ this.#keepMs =
182
+ options.keepDays === undefined
183
+ ? FILE_RETENTION_MS
184
+ : options.keepDays * 24 * 3_600_000;
172
185
  }
173
186
 
174
187
  #waitingOf(principalId: string): { files: number; bytes: number } {
@@ -303,11 +316,15 @@ export class Uploads {
303
316
  return files;
304
317
  }
305
318
 
306
- /** Moves claimed uploads into the stored files, once their message is accepted. */
307
- use(ids: readonly string[]): void {
319
+ /**
320
+ * Moves claimed uploads into the stored files, once their message is accepted. `binding` ties
321
+ * them to the host room the message is in, so only that room's readers are served them.
322
+ */
323
+ use(ids: readonly string[], binding?: FileBinding): void {
308
324
  for (const id of ids) {
309
325
  this.#pending.delete(id);
310
- this.#waiting.moveTo(id, this.#stored);
326
+ if (this.#waiting.moveTo(id, this.#stored) && binding)
327
+ this.#stored.bind(id, binding);
311
328
  }
312
329
  }
313
330
 
@@ -334,7 +351,11 @@ export class Uploads {
334
351
  return { path };
335
352
  }
336
353
 
337
- /** Deletes the waiting uploads older than a day and the stored files older than 30 days; returns how many. */
354
+ /**
355
+ * Deletes the waiting uploads older than a day and the stored files that are due; returns how
356
+ * many. A file of an agent room is due after `keepDays` (default 30); a file bound to a host
357
+ * room is due when its own expiry passes, and kept when it has none.
358
+ */
338
359
  sweep(): number {
339
360
  const now = this.#now();
340
361
  let dropped = 0;
@@ -343,7 +364,12 @@ export class Uploads {
343
364
  this.#pending.delete(id);
344
365
  dropped += 1;
345
366
  }
346
- for (const id of this.#stored.older(FILE_RETENTION_MS, now)) {
367
+ for (const id of this.#stored.ids()) {
368
+ const binding = this.#stored.binding(id);
369
+ const due = binding
370
+ ? binding.expiresAt !== undefined && binding.expiresAt <= now
371
+ : (this.#stored.ageOf(id, now) ?? 0) > this.#keepMs;
372
+ if (!due) continue;
347
373
  this.#stored.remove(id);
348
374
  dropped += 1;
349
375
  }
@@ -4,8 +4,10 @@ import type { ResolvedAuth } from "../options.ts";
4
4
  import type { Verdict } from "../verifier.ts";
5
5
  import type { Identified, Visitor } from "../visitors.ts";
6
6
  import { checkNoneListener } from "./none.ts";
7
+ import type { OidcLogin } from "./oidc.ts";
7
8
  import { checkPassword, PasswordCredential } from "./password.ts";
8
9
  import { loadSessionKey } from "./session-key.ts";
10
+ import type { WebSessions } from "./sessions.ts";
9
11
 
10
12
  /** The plugin identity `none` and `password` speak through; `RoundtablePlugin.identities` links it to the owner. */
11
13
  export const WEB_CONSOLE_IDENTITY = "token:web-console";
@@ -22,6 +24,14 @@ export interface Authenticated {
22
24
  visitor: Visitor;
23
25
  actor?: ActorFacts;
24
26
  expiresAt?: Date;
27
+ /** The session the credential is, when it is one: what an open socket asks about again. */
28
+ session?: string;
29
+ }
30
+
31
+ /** What a socket holds of the credential it came in on, to ask again whether it still stands. */
32
+ export interface Held {
33
+ actor: ActorFacts | undefined;
34
+ session?: string | undefined;
25
35
  }
26
36
 
27
37
  /** A request that may not, with the status that says so; `login` when signing in would help. */
@@ -38,6 +48,12 @@ export interface Unauthenticated {
38
48
  */
39
49
  export interface Authenticator {
40
50
  authenticate(request: Request): Promise<Authenticated | Unauthenticated>;
51
+ /**
52
+ * Asks again about a credential already admitted, as an open socket does for every frame and on
53
+ * a timer: does the session still stand, and who is the person now, at which tier. It reads the
54
+ * same `IDENTITY` as a request does, so a person disabled or demoted since is seen at once.
55
+ */
56
+ recheck(held: Held): Promise<{ visitor: Visitor } | Unauthenticated>;
41
57
  /**
42
58
  * Answers the paths below the mount that need no session, such as the login page; undefined
43
59
  * for any other, and absent for a mode with none.
@@ -60,6 +76,11 @@ export interface AuthenticatorOptions {
60
76
  dataDir: string;
61
77
  /** The listener the console is served on and its address, for the check of `none`. */
62
78
  listener: { id: string; address: Readonly<ListenerAddress> | undefined };
79
+ /** The sign-in flow and the session store, for `oidc`. */
80
+ oidc?: {
81
+ login: Pick<OidcLogin, "open" | "credential">;
82
+ sessions: Pick<WebSessions, "alive">;
83
+ };
63
84
  /** Where the password is read from; default `process.env`. */
64
85
  env?: Readonly<Record<string, string | undefined>>;
65
86
  /** How long each failed login waits; default 1000 ms. Tests set it lower. */
@@ -68,8 +89,15 @@ export interface AuthenticatorOptions {
68
89
  now?: () => number;
69
90
  }
70
91
 
92
+ /** A credential that admits: who it vouches for, and the session it is when it is one. */
93
+ export type Admitted = Extract<Verdict, { admitted: true }> & {
94
+ session?: string;
95
+ };
96
+
71
97
  /** What a credential says about a request, before the person is identified. */
72
- type Credential = (request: Request) => Promise<Verdict | Unauthenticated>;
98
+ export type Credential = (
99
+ request: Request,
100
+ ) => Promise<Verdict | Admitted | Unauthenticated>;
73
101
 
74
102
  const READ_METHODS = new Set(["GET", "HEAD"]);
75
103
 
@@ -78,6 +106,16 @@ const denied = (refusal: string): Unauthenticated => ({
78
106
  status: 403,
79
107
  });
80
108
 
109
+ /** A refusal in the shape identification answers with. */
110
+ const refused = (reason: string): Identified => ({ refusal: reason });
111
+
112
+ /** A session that is gone, which signing in again would fix. */
113
+ const signedOut = (reason: string): Unauthenticated => ({
114
+ refusal: reason,
115
+ status: 401,
116
+ login: true,
117
+ });
118
+
81
119
  /** Admits as the verifier says; one that throws or rejects admits no one. */
82
120
  function verifierCredential(
83
121
  verifier: (request: Request) => Verdict | Promise<Verdict>,
@@ -112,6 +150,11 @@ export function createAuthenticator(
112
150
  logger,
113
151
  );
114
152
  credential = async () => ({ admitted: true, actor: WEB_CONSOLE_ACTOR });
153
+ } else if (auth.mode === "oidc") {
154
+ if (!options.oidc) throw new Error("the oidc sign-in is not set up");
155
+ credential = options.oidc.login.credential;
156
+ open = (request, rest) =>
157
+ options.oidc?.login.open(request, rest) ?? Promise.resolve(undefined);
115
158
  } else {
116
159
  const password = new PasswordCredential({
117
160
  password: checkPassword(options.env ?? process.env, auth.passwordEnv),
@@ -138,30 +181,50 @@ export function createAuthenticator(
138
181
  };
139
182
  open = (request, rest) => password.open(request, rest);
140
183
  }
184
+ const identify = async (
185
+ actor: ActorFacts | undefined,
186
+ ): Promise<Identified> => {
187
+ try {
188
+ return await options.identify(actor);
189
+ } catch {
190
+ // A lookup of who they are that fails admits no one.
191
+ return refused("identity failed");
192
+ }
193
+ };
141
194
  return {
142
195
  ...(open ? { open } : {}),
143
196
  async authenticate(request) {
144
197
  const checked = await credential(request);
145
198
  if ("refusal" in checked) return checked;
146
199
  if (!checked.admitted) return denied(String(checked.reason));
147
- let identified: Identified;
148
- try {
149
- identified = await options.identify(checked.actor);
150
- } catch {
151
- // A lookup of who they are that fails admits no one.
152
- return denied("identity failed");
153
- }
200
+ const identified = await identify(checked.actor);
154
201
  if ("refusal" in identified) return denied(String(identified.refusal));
155
202
  if (
156
203
  !READ_METHODS.has(request.method) &&
157
204
  request.headers.get("origin") !== options.origin
158
205
  )
159
206
  return denied("foreign origin");
207
+ const { session } = checked as Admitted;
160
208
  return {
161
209
  visitor: identified.visitor,
162
210
  ...(checked.actor ? { actor: checked.actor } : {}),
163
211
  ...(checked.expiresAt ? { expiresAt: checked.expiresAt } : {}),
212
+ ...(session ? { session } : {}),
164
213
  };
165
214
  },
215
+ async recheck(held) {
216
+ if (held.session) {
217
+ try {
218
+ if (!(await options.oidc?.sessions.alive(held.session)))
219
+ return signedOut("session ended");
220
+ } catch {
221
+ return denied("session lookup failed");
222
+ }
223
+ }
224
+ const identified = await identify(held.actor);
225
+ return "refusal" in identified
226
+ ? denied(String(identified.refusal))
227
+ : { visitor: identified.visitor };
228
+ },
166
229
  };
167
230
  }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Where a sign-in goes back to. A page link (from the host's own dashboard, a notification, or a
3
+ * page the server turned away) names it as `?next=`, and only these shapes are taken: anything
4
+ * else is dropped, so the callback never redirects to a place a link chose.
5
+ *
6
+ * - `profile`, `notices`, `settings`: a page of the app;
7
+ * - `room:<key>`: a room of this app's surface (`<surface>:<kind>.<id>`);
8
+ * - `p:<id>`: a host page, `[a-z0-9-]{1,40}`.
9
+ */
10
+ export function allowedNext(
11
+ value: string | null | undefined,
12
+ surface: string,
13
+ ): string | undefined {
14
+ if (!value) return undefined;
15
+ if (value === "profile" || value === "notices" || value === "settings")
16
+ return value;
17
+ const room = /^room:(.+)$/.exec(value)?.[1];
18
+ if (room !== undefined)
19
+ return new RegExp(`^${surface}:[A-Za-z0-9._~-]{1,160}$`).test(room)
20
+ ? value
21
+ : undefined;
22
+ const page = /^p:(.+)$/.exec(value)?.[1];
23
+ return page !== undefined && /^[a-z0-9-]{1,40}$/.test(page)
24
+ ? value
25
+ : undefined;
26
+ }
27
+
28
+ /** The app route a `next` names, as the page's router writes it: `room/<key>`, `p/<id>` or the page's name. */
29
+ export function routeOfNext(next: string): string {
30
+ if (next.startsWith("room:"))
31
+ return `room/${encodeURIComponent(next.slice("room:".length))}`;
32
+ if (next.startsWith("p:")) return `p/${next.slice("p:".length)}`;
33
+ return next;
34
+ }
35
+
36
+ /**
37
+ * The `next` a page path stands for, for a server that turns away a page navigation (path
38
+ * routing only: a hash route never reaches the server). Undefined for a path that names none.
39
+ */
40
+ export function nextOfPath(rest: string, surface: string): string | undefined {
41
+ const [first, second, ...more] = rest.split("/").filter(Boolean);
42
+ if (more.length > 0 || first === undefined) return undefined;
43
+ try {
44
+ if (second === undefined) return allowedNext(first, surface);
45
+ const key = decodeURIComponent(second);
46
+ if (first === "room") return allowedNext(`room:${key}`, surface);
47
+ if (first === "p") return allowedNext(`p:${key}`, surface);
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ return undefined;
52
+ }