pi-roundtable-web 0.24.2 → 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 (223) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +195 -16
  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-b017vswv.js +1 -0
  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-hrm06nx8.js +1 -0
  12. package/dist/chunk-conversations-e26rrf01.js +1 -0
  13. package/dist/chunk-drag-v41gx2mc.js +1 -0
  14. package/dist/chunk-entry-jxxb3ath.js +1 -0
  15. package/dist/chunk-folder-list-b97xk12b.js +1 -0
  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-014rjv9k.js +2 -0
  23. package/dist/chunk-index-2d0kg096.js +2 -0
  24. package/dist/chunk-index-2qd81wbx.js +2 -0
  25. package/dist/chunk-index-2stynxw6.css +1 -0
  26. package/dist/chunk-index-459drsft.js +2 -0
  27. package/dist/chunk-index-4mfj596y.js +2 -0
  28. package/dist/chunk-index-66nskrdj.js +47 -0
  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-km40bhsz.js +2 -0
  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-nqfkwkfp.js +2 -0
  47. package/dist/chunk-index-nwqk447w.js +2 -0
  48. package/dist/chunk-index-pcfhpm8v.js +302 -0
  49. package/dist/chunk-index-q8h8xzbm.js +2 -0
  50. package/dist/chunk-index-r5vszgfs.js +2 -0
  51. package/dist/{chunk-index-mpm90318.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-6t68gbex.js → chunk-index-wnjp6wv0.js} +2 -2
  56. package/dist/chunk-index-xgkd15n3.js +2 -0
  57. package/dist/chunk-index-y8rnq8zf.js +14 -0
  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-ey0nvhhm.js → chunk-lang-dockerfile-hrav55t7.js} +1 -1
  61. package/dist/{chunk-lang-go-403qsw9f.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-hsemyd0r.js +1 -0
  65. package/dist/chunk-lang-markdown-zkwb4c9n.js +6 -0
  66. package/dist/{chunk-lang-python-9jk5a49y.js → chunk-lang-python-tewx7006.js} +2 -2
  67. package/dist/chunk-lang-rust-0ydx84s1.js +1 -0
  68. package/dist/{chunk-lang-shell-cgq6q0kj.js → chunk-lang-shell-gq1tmdz4.js} +1 -1
  69. package/dist/chunk-lang-sql-xt1qyycg.js +2 -0
  70. package/dist/{chunk-lang-toml-xh45nm68.js → chunk-lang-toml-0hamc5fc.js} +1 -1
  71. package/dist/chunk-lang-typescript-82xf7z2e.js +1 -0
  72. package/dist/{chunk-lang-xml-1mh879j9.js → chunk-lang-xml-wf6h5pt2.js} +1 -1
  73. package/dist/{chunk-lang-yaml-jac98srt.js → chunk-lang-yaml-7vk521h1.js} +1 -1
  74. package/dist/chunk-layout-actions-zh7bezk7.js +1 -0
  75. package/dist/chunk-menu-items-1n9qargb.js +2 -0
  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-d3npdtx1.js +1 -0
  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-n26b7gd0.js +1 -0
  98. package/dist/chunk-table3d-8aysyf56.js +4220 -0
  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 +515 -6
  107. package/src/app/api-http.ts +66 -0
  108. package/src/app/app.ts +214 -33
  109. package/src/app/card-log.ts +6 -0
  110. package/src/app/desk.ts +47 -22
  111. package/src/app/files.ts +73 -7
  112. package/src/app/layout-routes.ts +239 -0
  113. package/src/app/layout.ts +133 -0
  114. package/src/app/presence.ts +2 -2
  115. package/src/app/prompt-routes.ts +167 -0
  116. package/src/app/protocol.ts +63 -1
  117. package/src/app/room-log.ts +148 -14
  118. package/src/app/rooms.ts +23 -0
  119. package/src/app/routes.ts +278 -156
  120. package/src/app/skill-doc-routes.ts +122 -0
  121. package/src/app/socket.ts +309 -77
  122. package/src/app/sockets.ts +108 -20
  123. package/src/app/surface.ts +72 -36
  124. package/src/app/team-routes.ts +457 -0
  125. package/src/app/threads.ts +14 -0
  126. package/src/app/uploads.ts +33 -7
  127. package/src/auth/authenticator.ts +71 -8
  128. package/src/auth/next.ts +52 -0
  129. package/src/auth/oidc-pages.ts +60 -0
  130. package/src/auth/oidc.ts +611 -0
  131. package/src/auth/sessions.ts +193 -0
  132. package/src/authz.ts +172 -0
  133. package/src/console-api.ts +122 -25
  134. package/src/console-server.ts +70 -6
  135. package/src/gate.ts +90 -0
  136. package/src/host/access.ts +161 -0
  137. package/src/host/cards.ts +99 -0
  138. package/src/host/context.ts +141 -0
  139. package/src/host/forms.ts +237 -0
  140. package/src/host/frames.ts +342 -0
  141. package/src/host/host-app.ts +176 -0
  142. package/src/host/host-context.ts +49 -0
  143. package/src/host/host-routes.ts +363 -0
  144. package/src/host/pages.ts +266 -0
  145. package/src/host/reads.ts +155 -0
  146. package/src/host/retention.ts +41 -0
  147. package/src/host/room-views.ts +137 -0
  148. package/src/host/rooms-service.ts +414 -0
  149. package/src/host/stored-json.ts +13 -0
  150. package/src/host/views.ts +103 -0
  151. package/src/i18n/zh-tw.ts +309 -10
  152. package/src/layout-order.ts +210 -0
  153. package/src/notices/api.ts +43 -0
  154. package/src/notices/mount.ts +292 -0
  155. package/src/notices/service.ts +294 -0
  156. package/src/notices/store.ts +298 -0
  157. package/src/notices/urls.ts +38 -0
  158. package/src/onboarding/api.ts +32 -0
  159. package/src/onboarding/service.ts +149 -0
  160. package/src/onboarding/steps.ts +75 -0
  161. package/src/onboarding/waivers.ts +40 -0
  162. package/src/options.ts +498 -34
  163. package/src/profile/api.ts +175 -0
  164. package/src/profile/links.ts +162 -0
  165. package/src/push/api.ts +189 -52
  166. package/src/push/dispatch.ts +156 -0
  167. package/src/push/drain.ts +139 -0
  168. package/src/push/mutes.ts +17 -0
  169. package/src/push/outbox.ts +218 -0
  170. package/src/push/prefs.ts +118 -0
  171. package/src/push/sender.ts +7 -2
  172. package/src/push/triggers.ts +56 -5
  173. package/src/push/vapid.ts +19 -0
  174. package/src/push/web-push-transport.ts +106 -0
  175. package/src/push-sender/index.ts +14 -0
  176. package/src/push-sender/run.ts +118 -0
  177. package/src/push-sender/session.ts +54 -0
  178. package/src/visitors.ts +94 -6
  179. package/src/web-app.ts +674 -0
  180. package/src/web-plugin.ts +293 -26
  181. package/dist/chunk-action-menu-7v5fce2v.js +0 -1
  182. package/dist/chunk-agent-2rw4ydw2.js +0 -1
  183. package/dist/chunk-agent-sheet-2q24dkkf.js +0 -3
  184. package/dist/chunk-character-creator-8q6ympxj.js +0 -1
  185. package/dist/chunk-codeeditor-fgmq298t.js +0 -2
  186. package/dist/chunk-connectors-mr53r7fh.js +0 -1
  187. package/dist/chunk-conversations-4vy2z6w9.js +0 -1
  188. package/dist/chunk-entry-jg1eem82.js +0 -1
  189. package/dist/chunk-group-scene-na95b1te.js +0 -1
  190. package/dist/chunk-index-11rjg8a8.js +0 -1
  191. package/dist/chunk-index-1y50d6sj.js +0 -2
  192. package/dist/chunk-index-5y2r0eaj.js +0 -47
  193. package/dist/chunk-index-7fphxxzd.js +0 -14
  194. package/dist/chunk-index-80f1zkp6.css +0 -1
  195. package/dist/chunk-index-87h45frg.js +0 -2
  196. package/dist/chunk-index-bnwmssyn.js +0 -2
  197. package/dist/chunk-index-ckgbszwt.js +0 -51
  198. package/dist/chunk-index-fmjjekhd.js +0 -2
  199. package/dist/chunk-index-t1m65yn6.js +0 -13
  200. package/dist/chunk-index-t711t17m.js +0 -2
  201. package/dist/chunk-index-txv7qvyy.js +0 -2
  202. package/dist/chunk-index-wr3dg24d.js +0 -2
  203. package/dist/chunk-index-yx683d5n.js +0 -2
  204. package/dist/chunk-lang-css-fkdg3x8x.js +0 -1
  205. package/dist/chunk-lang-html-v8h9g977.js +0 -1
  206. package/dist/chunk-lang-javascript-ykb2vhaa.js +0 -1
  207. package/dist/chunk-lang-json-2rze3rpy.js +0 -1
  208. package/dist/chunk-lang-markdown-6w2htnfc.js +0 -6
  209. package/dist/chunk-lang-rust-24fggcqx.js +0 -1
  210. package/dist/chunk-lang-sql-gjywb9ke.js +0 -2
  211. package/dist/chunk-lang-typescript-qh6rebyb.js +0 -1
  212. package/dist/chunk-menu-items-zjpyj51b.js +0 -2
  213. package/dist/chunk-notes-sjba7kw3.js +0 -1
  214. package/dist/chunk-overview-26r77kb6.js +0 -1
  215. package/dist/chunk-room-confirms-851184ep.js +0 -1
  216. package/dist/chunk-room-menu-8y9c026y.js +0 -1
  217. package/dist/chunk-skills-56s4hdbn.js +0 -1
  218. package/dist/chunk-table-qykpvp32.js +0 -1
  219. package/dist/chunk-table2d-k60ressj.js +0 -1
  220. package/dist/chunk-table3d-bv2ajk39.js +0 -4220
  221. package/dist/chunk-transcript-vekz1mh6.js +0 -1
  222. package/dist/chunk-workspace-hevefd0d.js +0 -1
  223. package/dist/chunk-zh-tw-5r2wjre0.js +0 -1
@@ -6,10 +6,16 @@ import type {
6
6
  Authenticator,
7
7
  Unauthenticated,
8
8
  } from "./auth/authenticator.ts";
9
+ import { allowedNext, nextOfPath } from "./auth/next.ts";
10
+ import { type Decision, decide } from "./authz.ts";
9
11
  import type { ConsoleApi } from "./console-api.ts";
10
- import type { ConsolePresentation } from "./features.ts";
12
+ import { type ConsolePresentation, message } from "./features.ts";
13
+ import type { Gate } from "./gate.ts";
14
+ import { json } from "./http.ts";
11
15
  import { presentPage } from "./page-presentation.ts";
12
16
  import { servePwa } from "./pwa.ts";
17
+ import type { Visitor } from "./visitors.ts";
18
+ import { viewerOf } from "./web-app.ts";
13
19
 
14
20
  const encoder = new TextEncoder();
15
21
  /** Changes closer together than this reach the page as one event. */
@@ -53,6 +59,10 @@ export interface ConsoleServerOptions {
53
59
  ): Promise<Response | undefined>;
54
60
  websocket: WebSocketRoute<AppSocket>;
55
61
  };
62
+ /** The onboarding gate; without it nobody is gated. */
63
+ gate?: Pick<Gate, "required">;
64
+ /** The app's surface, for the `next` a turned-away page navigation asks to come back to. */
65
+ surface?: string;
56
66
  /** Registers the listener with every source whose change the console shows. */
57
67
  subscribe(listener: () => void): void;
58
68
  logger: Logger;
@@ -70,9 +80,11 @@ export interface ConsoleServerOptions {
70
80
 
71
81
  /**
72
82
  * The console on the host's listener: `<mount>` redirects to `<mount>/`, and everything under
73
- * it needs the authenticator to admit the request as an owner's, apart from what it answers
74
- * openly, such as a login page. A request that changes data must also carry the console's own
75
- * `Origin`.
83
+ * it needs the authenticator to admit the request, apart from what it answers openly, such as a
84
+ * login page. An admitted request is then judged by the authorization table before any handler
85
+ * sees it: a route that is not listed answers 404, one for owners answers a member 403, and while
86
+ * the person owes a required step only the routes open to them answer. A request that changes
87
+ * data must also carry the console's own `Origin`.
76
88
  */
77
89
  export class ConsoleServer {
78
90
  readonly #options: ConsoleServerOptions;
@@ -154,6 +166,11 @@ export class ConsoleServer {
154
166
  status: 302,
155
167
  headers: { location: this.#base },
156
168
  });
169
+ const decision = decide(request.method, rest, {
170
+ tier: admission.visitor.tier,
171
+ gated: await this.#gated(admission.visitor),
172
+ });
173
+ if (!decision.allow) return this.#denied(decision);
157
174
  if (rest === "api/events" && request.method === "GET")
158
175
  return this.#events();
159
176
  const owned = await this.#options.app?.handle(request, rest, admission);
@@ -206,6 +223,43 @@ export class ConsoleServer {
206
223
  return new Response("Not found", { status: 404 });
207
224
  }
208
225
 
226
+ async #gated(visitor: Visitor): Promise<boolean> {
227
+ const { gate } = this.#options;
228
+ if (!gate) return false;
229
+ try {
230
+ return (await gate.required(viewerOf(visitor))).length > 0;
231
+ } catch (error) {
232
+ // A gate that cannot be read stays shut.
233
+ this.#options.logger.warn({ err: error }, "the gate was not read");
234
+ return true;
235
+ }
236
+ }
237
+
238
+ /** The answer to a request the authorization table refused. */
239
+ #denied(decision: Extract<Decision, { allow: false }>): Response {
240
+ const { presentation, logger } = this.#options;
241
+ logger.debug({ reason: decision.reason }, "console request denied");
242
+ if (decision.gate)
243
+ return json(
244
+ {
245
+ error: message(presentation, "Finish setting up first."),
246
+ gate: true,
247
+ },
248
+ 403,
249
+ );
250
+ return json(
251
+ {
252
+ error: message(
253
+ presentation,
254
+ decision.status === 404
255
+ ? "There is no such API."
256
+ : "Only an owner may do this.",
257
+ ),
258
+ },
259
+ decision.status,
260
+ );
261
+ }
262
+
209
263
  /**
210
264
  * The answer to a request the authenticator refused: a page navigation that signing in would
211
265
  * fix goes to the login page, everything else gets the status and a fixed body.
@@ -215,14 +269,24 @@ export class ConsoleServer {
215
269
  refusal.login &&
216
270
  READ_METHODS.has(request.method) &&
217
271
  !rest.startsWith("api/")
218
- )
272
+ ) {
273
+ // A link can name where to come back to (`?next=`), and so can a path route; a hash route's place never reaches the server.
274
+ const surface = this.#options.surface ?? "app";
275
+ const named = allowedNext(
276
+ URL.parse(request.url)?.searchParams.get("next"),
277
+ surface,
278
+ );
279
+ const next =
280
+ named ??
281
+ (this.#options.pathRouting ? nextOfPath(rest, surface) : undefined);
219
282
  return new Response(null, {
220
283
  status: 303,
221
284
  headers: {
222
- location: `${this.#options.mount}/login`,
285
+ location: `${this.#options.mount}/login${next ? `?next=${encodeURIComponent(next)}` : ""}`,
223
286
  "cache-control": "no-store",
224
287
  },
225
288
  });
289
+ }
226
290
  // A missing session is the normal way to meet a login page, not worth a line each time.
227
291
  const { logger } = this.#options;
228
292
  const fields = { refusal: refusal.refusal };
package/src/gate.ts ADDED
@@ -0,0 +1,90 @@
1
+ import type { GateStepView } from "./api-types.ts";
2
+ import type { Viewer, WebAppRegistry } from "./web-app.ts";
3
+
4
+ /** How long a person's owed steps are remembered, so a page of requests asks each step once. */
5
+ const MEMO_MS = 5_000;
6
+
7
+ export interface GateOptions {
8
+ registry: Pick<WebAppRegistry, "steps" | "waived">;
9
+ /** The clock, in milliseconds; default `Date.now`. */
10
+ now?: () => number;
11
+ }
12
+
13
+ interface Memo {
14
+ at: number;
15
+ steps: GateStepView[];
16
+ }
17
+
18
+ /**
19
+ * The onboarding gate. A person owes a step when it applies to them and is neither done nor
20
+ * waived. While they owe a required step, only the routes marked G and the few frames a gated
21
+ * socket may have answer them; the rest answer 403 with `gate: true`. A step that cannot be read
22
+ * counts as undone, so a failing lookup keeps the gate shut rather than opening it.
23
+ */
24
+ export class Gate {
25
+ readonly #options: GateOptions;
26
+ readonly #memo = new Map<string, Memo>();
27
+ readonly #listeners = new Set<(principalId: string) => void>();
28
+
29
+ constructor(options: GateOptions) {
30
+ this.#options = options;
31
+ }
32
+
33
+ #now(): number {
34
+ return (this.#options.now ?? Date.now)();
35
+ }
36
+
37
+ /** The steps this viewer still owes, required or not, in the order they were registered. */
38
+ async owed(viewer: Viewer): Promise<GateStepView[]> {
39
+ const found = this.#memo.get(viewer.principalId);
40
+ if (found && this.#now() - found.at < MEMO_MS) return found.steps;
41
+ const steps: GateStepView[] = [];
42
+ for (const step of this.#options.registry.steps()) {
43
+ try {
44
+ if (step.applies && !(await step.applies(viewer))) continue;
45
+ if (await step.done(viewer)) continue;
46
+ const waived = step.required
47
+ ? await this.#options.registry.waived(viewer.principalId, step.id)
48
+ : false;
49
+ if (waived) continue;
50
+ steps.push({
51
+ id: step.id,
52
+ label: step.label,
53
+ ...(step.help ? { help: step.help } : {}),
54
+ ...(step.builtin ? { builtin: step.builtin } : {}),
55
+ required: step.required,
56
+ open: step.open,
57
+ ...(step.required && step.waiver ? { waiver: step.waiver } : {}),
58
+ });
59
+ } catch {
60
+ // A step that cannot be read is undone; a required one keeps the gate shut.
61
+ steps.push({
62
+ id: step.id,
63
+ label: step.label,
64
+ ...(step.builtin ? { builtin: step.builtin } : {}),
65
+ required: step.required,
66
+ open: step.open,
67
+ });
68
+ }
69
+ }
70
+ this.#memo.set(viewer.principalId, { at: this.#now(), steps });
71
+ return steps;
72
+ }
73
+
74
+ /** Whether a required step stands undone; the steps it names are those. */
75
+ async required(viewer: Viewer): Promise<GateStepView[]> {
76
+ return (await this.owed(viewer)).filter((step) => step.required);
77
+ }
78
+
79
+ /** A person's steps may differ now (they linked an account, waived a step): ask again. */
80
+ changed(principalId: string): void {
81
+ this.#memo.delete(principalId);
82
+ for (const listener of this.#listeners) listener(principalId);
83
+ }
84
+
85
+ /** Hears of a change to a person's steps, so their open sockets can be told. */
86
+ onChanged(listener: (principalId: string) => void): () => void {
87
+ this.#listeners.add(listener);
88
+ return () => this.#listeners.delete(listener);
89
+ }
90
+ }
@@ -0,0 +1,161 @@
1
+ import type { ChannelKey, Logger } from "pi-roundtable";
2
+ import { viewerOfSocket } from "../app/socket.ts";
3
+ import type { AppSocket, Sockets } from "../app/sockets.ts";
4
+ import type {
5
+ HostRoomAccess,
6
+ RoomAccess,
7
+ Viewer,
8
+ WebAppRegistry,
9
+ } from "../web-app.ts";
10
+
11
+ /** How long an answer of a kind's `access` is trusted: 10 seconds. */
12
+ export const ACCESS_TTL_MS = 10_000;
13
+
14
+ interface Entry {
15
+ at: number;
16
+ /** What the kind said then; undefined = no access. */
17
+ access: RoomAccess | undefined;
18
+ }
19
+
20
+ export interface HostAccessOptions {
21
+ registry: Pick<WebAppRegistry, "hostKindOf">;
22
+ sockets: Pick<Sockets, "each">;
23
+ logger: Logger;
24
+ /** The clock, in milliseconds; default `Date.now`. */
25
+ now?: () => number;
26
+ /** How long an answer is trusted; default 10 s. */
27
+ ttlMs?: number;
28
+ /** Hears that a person's reading of a room was cut off, so their page can be told. */
29
+ revoked?(connection: AppSocket, room: string): void;
30
+ }
31
+
32
+ /**
33
+ * What decides who reads a host room right now: the kind's `access`, asked again at least every
34
+ * ten seconds for as long as anyone follows the room, and at once when the host says access
35
+ * changed. A frame is delivered only to a socket whose person the memo says may read, so a revoked
36
+ * reader stops receiving within the TTL even if the host never says so, and an error of the
37
+ * kind's is a refusal, never a pass.
38
+ */
39
+ export class HostAccess implements HostRoomAccess {
40
+ readonly #options: HostAccessOptions;
41
+ readonly #memo = new Map<string, Entry>();
42
+ readonly #inflight = new Map<string, Promise<RoomAccess | undefined>>();
43
+ readonly #ttl: number;
44
+ #timer: ReturnType<typeof setInterval> | undefined;
45
+
46
+ constructor(options: HostAccessOptions) {
47
+ this.#options = options;
48
+ this.#ttl = options.ttlMs ?? ACCESS_TTL_MS;
49
+ }
50
+
51
+ #now(): number {
52
+ return (this.#options.now ?? Date.now)();
53
+ }
54
+
55
+ static #key(principalId: string, room: string): string {
56
+ return `${principalId}\u0000${room}`;
57
+ }
58
+
59
+ /** Re-asks about the rooms that are followed, a few times per TTL. */
60
+ start(): void {
61
+ if (this.#timer) return;
62
+ this.#timer = setInterval(
63
+ () => this.#refresh(this.#ttl / 2),
64
+ Math.max(50, this.#ttl / 4),
65
+ );
66
+ this.#timer.unref?.();
67
+ }
68
+
69
+ stop(): void {
70
+ clearInterval(this.#timer);
71
+ this.#timer = undefined;
72
+ this.#memo.clear();
73
+ }
74
+
75
+ /** What the kind grants the viewer in the room now, from the memo when it is fresh; undefined for no access. */
76
+ async of(viewer: Viewer, room: string): Promise<RoomAccess | undefined> {
77
+ const key = HostAccess.#key(viewer.principalId, room);
78
+ const found = this.#memo.get(key);
79
+ if (found && this.#now() - found.at <= this.#ttl) return found.access;
80
+ return this.#ask(viewer, room);
81
+ }
82
+
83
+ async read(viewer: Viewer, room: ChannelKey): Promise<boolean> {
84
+ return (await this.of(viewer, room)) !== undefined;
85
+ }
86
+
87
+ /** Whether the memo, still fresh, says this socket's person may read the room. */
88
+ readNow(connection: AppSocket, room: string): boolean {
89
+ const found = this.#memo.get(
90
+ HostAccess.#key(connection.principal.id, room),
91
+ );
92
+ return (
93
+ found !== undefined &&
94
+ found.access !== undefined &&
95
+ this.#now() - found.at <= this.#ttl
96
+ );
97
+ }
98
+
99
+ #ask(viewer: Viewer, room: string): Promise<RoomAccess | undefined> {
100
+ const key = HostAccess.#key(viewer.principalId, room);
101
+ const running = this.#inflight.get(key);
102
+ if (running) return running;
103
+ const found = this.#options.registry.hostKindOf(room);
104
+ const answer = (async () => {
105
+ let access: RoomAccess | undefined;
106
+ if (found) {
107
+ try {
108
+ access = await found.kind.access(viewer, found.id);
109
+ } catch (error) {
110
+ this.#options.logger.warn(
111
+ { kind: found.kind.kind, err: error },
112
+ "a kind of room could not say who may read; the room stays closed",
113
+ );
114
+ }
115
+ }
116
+ this.#memo.set(key, { at: this.#now(), access });
117
+ return access;
118
+ })().finally(() => this.#inflight.delete(key));
119
+ this.#inflight.set(key, answer);
120
+ return answer;
121
+ }
122
+
123
+ /** Re-asks for every followed room whose answer is older than `olderThan`. */
124
+ #refresh(olderThan: number): Promise<unknown> {
125
+ const asks: Promise<unknown>[] = [];
126
+ this.#options.sockets.each(
127
+ (connection) => connection.subscriptions.size > 0,
128
+ (connection) => {
129
+ const viewer = viewerOfSocket(connection);
130
+ for (const room of [...connection.subscriptions]) {
131
+ const found = this.#memo.get(
132
+ HostAccess.#key(viewer.principalId, room),
133
+ );
134
+ if (found && this.#now() - found.at < olderThan) continue;
135
+ asks.push(
136
+ this.#ask(viewer, room).then((access) => {
137
+ if (access) return;
138
+ // The person lost the room: stop following it and tell their page.
139
+ connection.subscriptions.delete(room);
140
+ if (connection.focus === room) connection.focus = undefined;
141
+ this.#options.revoked?.(connection, room);
142
+ }),
143
+ );
144
+ }
145
+ },
146
+ );
147
+ return Promise.allSettled(asks);
148
+ }
149
+
150
+ /** Drops what is remembered of a room, of everyone's or one person's, and asks the followers again at once. */
151
+ forget(where: { room?: string; principalId?: string }): Promise<unknown> {
152
+ for (const key of [...this.#memo.keys()]) {
153
+ const [principalId, room] = key.split("\u0000");
154
+ if (where.room !== undefined && room !== where.room) continue;
155
+ if (where.principalId !== undefined && principalId !== where.principalId)
156
+ continue;
157
+ this.#memo.delete(key);
158
+ }
159
+ return this.#refresh(0);
160
+ }
161
+ }
@@ -0,0 +1,99 @@
1
+ import type { SQL } from "bun";
2
+ import type { Migration } from "pi-roundtable";
3
+ import type { HostCard, StoredHostCard } from "../web-app.ts";
4
+ import { storedJson } from "./stored-json.ts";
5
+
6
+ interface Row {
7
+ id: string;
8
+ room: string;
9
+ card: HostCard | string;
10
+ created_at: Date;
11
+ updated_at: Date;
12
+ }
13
+
14
+ function cardOf(row: Row): StoredHostCard {
15
+ return {
16
+ ...storedJson(row.card, "host card"),
17
+ id: row.id,
18
+ room: row.room as StoredHostCard["room"],
19
+ createdAt: row.created_at,
20
+ updatedAt: row.updated_at,
21
+ };
22
+ }
23
+
24
+ /**
25
+ * The cards hosts put in their rooms, kept in `web_host_cards`: a title and body, a state, links
26
+ * and buttons, which the host updates in place as the thing the card stands for moves on. Held-call
27
+ * approvals and questions are not here; they are the desk's cards.
28
+ */
29
+ export class HostCardLog {
30
+ readonly #sql: SQL;
31
+
32
+ constructor(sql: SQL) {
33
+ this.#sql = sql;
34
+ }
35
+
36
+ static readonly migration: Migration = {
37
+ name: "web-host-cards",
38
+ up: async (sql) => {
39
+ await sql`
40
+ CREATE TABLE IF NOT EXISTS web_host_cards (
41
+ id text PRIMARY KEY,
42
+ room text NOT NULL,
43
+ card jsonb NOT NULL,
44
+ state text,
45
+ created_at timestamptz NOT NULL DEFAULT now(),
46
+ updated_at timestamptz NOT NULL DEFAULT now()
47
+ )`;
48
+ await sql`CREATE INDEX IF NOT EXISTS web_host_cards_room_created ON web_host_cards (room, created_at)`;
49
+ },
50
+ };
51
+
52
+ async create(room: string, card: HostCard): Promise<StoredHostCard> {
53
+ const id = crypto.randomUUID();
54
+ const [row]: Row[] = await this.#sql`
55
+ INSERT INTO web_host_cards (id, room, card, state)
56
+ VALUES (${id}, ${room}, ${JSON.stringify(card)}::jsonb, ${card.state ?? null})
57
+ RETURNING *`;
58
+ if (!row) throw new Error("the card log stored nothing");
59
+ return cardOf(row);
60
+ }
61
+
62
+ /** Replaces a card of the room; undefined when the room holds no such card. */
63
+ async update(
64
+ room: string,
65
+ id: string,
66
+ card: HostCard,
67
+ ): Promise<StoredHostCard | undefined> {
68
+ const [row]: Row[] = await this.#sql`
69
+ UPDATE web_host_cards
70
+ SET card = ${JSON.stringify(card)}::jsonb, state = ${card.state ?? null}, updated_at = now()
71
+ WHERE id = ${id} AND room = ${room} RETURNING *`;
72
+ return row && cardOf(row);
73
+ }
74
+
75
+ async get(room: string, id: string): Promise<StoredHostCard | undefined> {
76
+ const [row]: Row[] = await this.#sql`
77
+ SELECT * FROM web_host_cards WHERE id = ${id} AND room = ${room}`;
78
+ return row && cardOf(row);
79
+ }
80
+
81
+ /** The room's cards, oldest first. */
82
+ async list(room: string): Promise<StoredHostCard[]> {
83
+ const rows: Row[] = await this.#sql`
84
+ SELECT * FROM web_host_cards WHERE room = ${room} ORDER BY created_at, id`;
85
+ return rows.map(cardOf);
86
+ }
87
+
88
+ /** Deletes the cards of the rooms whose key starts with `prefix` that are older than `before`; how many. */
89
+ async sweepKind(prefix: string, before: Date): Promise<number> {
90
+ const rows: unknown[] = await this.#sql`
91
+ DELETE FROM web_host_cards
92
+ WHERE starts_with(room, ${prefix}) AND created_at < ${before} RETURNING 1`;
93
+ return rows.length;
94
+ }
95
+
96
+ async removeRoom(room: string): Promise<void> {
97
+ await this.#sql`DELETE FROM web_host_cards WHERE room = ${room}`;
98
+ }
99
+ }
@@ -0,0 +1,141 @@
1
+ import type { InboundMessage } from "pi-roundtable";
2
+ import type {
3
+ ChannelContext,
4
+ ChannelContextMessage,
5
+ ChannelContextOptions,
6
+ } from "pi-roundtable/discord";
7
+ import { formatChannelContext } from "pi-roundtable/discord";
8
+ import type { HostAuthorKind, RoomMessage } from "../api-types.ts";
9
+ import { PAGE_LIMIT } from "../app/room-log.ts";
10
+ import type { HostContext } from "./host-context.ts";
11
+
12
+ const DEFAULT_FETCH = 50;
13
+ const DEFAULT_KEEP = 15;
14
+ const DEFAULT_CHARS = 500;
15
+ /** How many delivered message ids are remembered per room, and how many rooms. */
16
+ const SEEN_PER_ROOM = 500;
17
+ const SEEN_ROOMS = 1_000;
18
+
19
+ type Source = Parameters<NonNullable<ChannelContextOptions["authors"]>>[0];
20
+
21
+ const positive = (name: string, value: number, most: number): number => {
22
+ if (!Number.isInteger(value) || value < 1 || value > most)
23
+ throw new RangeError(
24
+ `channel context option ${name} must be a whole number from 1 to ${most}, got ${value}`,
25
+ );
26
+ return value;
27
+ };
28
+
29
+ /** Whose a stored message of a host room is; a message without a kind is read from its row's. */
30
+ function authorKindOf(message: RoomMessage): HostAuthorKind {
31
+ if (message.authorKind) return message.authorKind;
32
+ if (message.kind === "agent") return "assistant";
33
+ return message.kind === "system" ? "notice" : "member";
34
+ }
35
+
36
+ /** What the channel context reads of one stored message of a host room. */
37
+ function sourceOf(message: RoomMessage): Source {
38
+ const kind = authorKindOf(message);
39
+ return {
40
+ id: String(message.id),
41
+ authorId: message.principalId ?? kind,
42
+ authorName: message.author,
43
+ bot: false,
44
+ own: kind === "assistant",
45
+ ...(kind === "external" ? { external: true as const } : {}),
46
+ attachments: message.files.map((file) => ({
47
+ name: file.name,
48
+ contentType: file.type,
49
+ size: file.size,
50
+ url: `api/files/${file.id}`,
51
+ })),
52
+ at: Date.parse(message.at),
53
+ text: message.text,
54
+ };
55
+ }
56
+
57
+ /**
58
+ * The app surface's `channelContext` for a host room: what was said in it since the assistant
59
+ * last posted, before the message that addressed it, as the core's block of channel messages. An
60
+ * outsider's words read as `external`, a notice line is not in the channel as far as the context
61
+ * goes, and a message already handed to the conversation is not handed again. Anything but a host
62
+ * room has none: an agent room is one person's.
63
+ */
64
+ export function hostChannelContext(
65
+ ctx: Pick<HostContext, "registry" | "log">,
66
+ ): (
67
+ message: InboundMessage,
68
+ options?: ChannelContextOptions,
69
+ ) => Promise<ChannelContext | undefined> {
70
+ const seen = new Map<string, Set<string>>();
71
+ return async (message, options = {}) => {
72
+ const room = message.channel;
73
+ if (!ctx.registry.isHostRoom(room)) return undefined;
74
+ const fetch = positive("fetch", options.fetch ?? DEFAULT_FETCH, PAGE_LIMIT);
75
+ const keep = positive("keep", options.keep ?? DEFAULT_KEEP, 100);
76
+ const chars = positive(
77
+ "messageChars",
78
+ options.messageChars ?? DEFAULT_CHARS,
79
+ 20_000,
80
+ );
81
+ const addressed = /^app-(\d+)$/.exec(message.messageId)?.[1];
82
+ const page = await ctx.log.page(
83
+ room,
84
+ addressed === undefined ? undefined : Number(addressed),
85
+ fetch,
86
+ );
87
+ const sources = page.messages.map((one) => ({
88
+ source: sourceOf(one),
89
+ notice: authorKindOf(one) === "notice",
90
+ }));
91
+ type Entry = (typeof sources)[number];
92
+ const verdict = (entry: Entry) => {
93
+ const given = options.authors?.(entry.source);
94
+ if (given === "keep" || given === "skip" || given === "end") return given;
95
+ if (entry.notice) return "skip";
96
+ return entry.source.own ? "end" : "keep";
97
+ };
98
+ const considered = sources.filter((entry) => verdict(entry) !== "skip");
99
+ const lastEnd = considered.findLastIndex(
100
+ (entry) => verdict(entry) === "end",
101
+ );
102
+ const known = seen.get(room) ?? new Set<string>();
103
+ const fresh = considered
104
+ .slice(lastEnd + 1)
105
+ .filter(
106
+ (entry) =>
107
+ verdict(entry) === "keep" &&
108
+ !known.has(entry.source.id) &&
109
+ entry.source.text !== "",
110
+ )
111
+ .slice(-keep);
112
+ // What was in the window is not handed over again, kept or not.
113
+ for (const entry of considered.slice(lastEnd + 1))
114
+ known.add(entry.source.id);
115
+ seen.delete(room);
116
+ seen.set(room, new Set([...known].slice(-SEEN_PER_ROOM)));
117
+ if (seen.size > SEEN_ROOMS) {
118
+ const oldest = seen.keys().next().value;
119
+ if (oldest !== undefined) seen.delete(oldest);
120
+ }
121
+ if (fresh.length === 0) return undefined;
122
+ const messages: ChannelContextMessage[] = fresh.map(({ source }) => ({
123
+ id: source.id,
124
+ authorId: source.authorId,
125
+ authorName: source.authorName,
126
+ bot: false,
127
+ owner: false,
128
+ ...(source.external ? { external: true } : {}),
129
+ text:
130
+ source.text.length > chars
131
+ ? `${source.text.slice(0, chars)}…`
132
+ : source.text,
133
+ at: new Date(source.at),
134
+ attachments: (source.attachments ?? []).map((file) => ({
135
+ ...file,
136
+ messageId: source.id,
137
+ })),
138
+ }));
139
+ return { messages, text: formatChannelContext(messages) };
140
+ };
141
+ }