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
@@ -6,10 +6,20 @@ 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";
11
- import { presentPage } from "./page-presentation.ts";
12
- import { servePwa } from "./pwa.ts";
12
+ import { type ConsolePresentation, message } from "./features.ts";
13
+ import type { Gate } from "./gate.ts";
14
+ import { json } from "./http.ts";
15
+ import {
16
+ defaultDescription,
17
+ type PageMeta,
18
+ presentPage,
19
+ } from "./page-presentation.ts";
20
+ import { hasMarkSvg, isPublicStatic, servePwa } from "./pwa.ts";
21
+ import type { Visitor } from "./visitors.ts";
22
+ import { viewerOf } from "./web-app.ts";
13
23
 
14
24
  const encoder = new TextEncoder();
15
25
  /** Changes closer together than this reach the page as one event. */
@@ -53,6 +63,10 @@ export interface ConsoleServerOptions {
53
63
  ): Promise<Response | undefined>;
54
64
  websocket: WebSocketRoute<AppSocket>;
55
65
  };
66
+ /** The onboarding gate; without it nobody is gated. */
67
+ gate?: Pick<Gate, "required">;
68
+ /** The app's surface, for the `next` a turned-away page navigation asks to come back to. */
69
+ surface?: string;
56
70
  /** Registers the listener with every source whose change the console shows. */
57
71
  subscribe(listener: () => void): void;
58
72
  logger: Logger;
@@ -66,17 +80,24 @@ export interface ConsoleServerOptions {
66
80
  /** Where the install icons come from; default the ones this package ships. */
67
81
  iconsDir?: string;
68
82
  look?: "modern" | "camelot";
83
+ /** The console's own origin, for the absolute `og:image` of a link preview. */
84
+ origin?: string;
85
+ /** What the page says about itself in a search result and a link preview; default derived from the title. */
86
+ description?: string;
69
87
  }
70
88
 
71
89
  /**
72
90
  * 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`.
91
+ * it needs the authenticator to admit the request, apart from what it answers openly, such as a
92
+ * login page. An admitted request is then judged by the authorization table before any handler
93
+ * sees it: a route that is not listed answers 404, one for owners answers a member 403, and while
94
+ * the person owes a required step only the routes open to them answer. A request that changes
95
+ * data must also carry the console's own `Origin`.
76
96
  */
77
97
  export class ConsoleServer {
78
98
  readonly #options: ConsoleServerOptions;
79
99
  readonly #base: string;
100
+ readonly #meta: PageMeta | undefined;
80
101
  readonly #streams = new Set<ReadableStreamDefaultController<Uint8Array>>();
81
102
  #pending: ReturnType<typeof setTimeout> | undefined;
82
103
  #ping: ReturnType<typeof setInterval> | undefined;
@@ -84,6 +105,16 @@ export class ConsoleServer {
84
105
  constructor(options: ConsoleServerOptions) {
85
106
  this.#options = options;
86
107
  this.#base = `${options.mount}/`;
108
+ const title = options.title ?? "Roundtable";
109
+ this.#meta = options.origin
110
+ ? {
111
+ title,
112
+ description: options.description ?? defaultDescription(title),
113
+ origin: options.origin,
114
+ mount: options.mount,
115
+ svg: hasMarkSvg(options.iconsDir),
116
+ }
117
+ : undefined;
87
118
  options.subscribe(() => this.#changed());
88
119
  }
89
120
 
@@ -145,6 +176,11 @@ export class ConsoleServer {
145
176
  if (path !== this.#options.mount && !path.startsWith(this.#base))
146
177
  return new Response("Not found", { status: 404 });
147
178
  const rest = path.slice(this.#base.length);
179
+ // Static and the same for everyone: a sign-in page and a link preview show the icon without a session.
180
+ if (READ_METHODS.has(request.method) && isPublicStatic(rest)) {
181
+ const shown = servePwa(rest, this.#pwa());
182
+ if (shown) return shown;
183
+ }
148
184
  const open = await this.#options.authenticator.open?.(request, rest);
149
185
  if (open) return open;
150
186
  const admission = await this.#options.authenticator.authenticate(request);
@@ -154,6 +190,11 @@ export class ConsoleServer {
154
190
  status: 302,
155
191
  headers: { location: this.#base },
156
192
  });
193
+ const decision = decide(request.method, rest, {
194
+ tier: admission.visitor.tier,
195
+ gated: await this.#gated(admission.visitor),
196
+ });
197
+ if (!decision.allow) return this.#denied(decision);
157
198
  if (rest === "api/events" && request.method === "GET")
158
199
  return this.#events();
159
200
  const owned = await this.#options.app?.handle(request, rest, admission);
@@ -166,10 +207,7 @@ export class ConsoleServer {
166
207
  );
167
208
  if (!READ_METHODS.has(request.method))
168
209
  return new Response("Method not allowed", { status: 405 });
169
- const installable = servePwa(rest, {
170
- ...this.#options,
171
- title: this.#options.title ?? "Roundtable",
172
- });
210
+ const installable = servePwa(rest, this.#pwa());
173
211
  if (installable) return installable;
174
212
  const asset = this.#options.assets.get(rest);
175
213
  if (asset && rest !== "index.html" && rest !== "sw.js")
@@ -188,6 +226,7 @@ export class ConsoleServer {
188
226
  this.#options.pathRouting ? this.#options.mount : undefined,
189
227
  this.#options.presentation,
190
228
  this.#options.look,
229
+ this.#meta,
191
230
  ),
192
231
  {
193
232
  headers: {
@@ -206,6 +245,47 @@ export class ConsoleServer {
206
245
  return new Response("Not found", { status: 404 });
207
246
  }
208
247
 
248
+ #pwa(): Parameters<typeof servePwa>[1] {
249
+ return { ...this.#options, title: this.#options.title ?? "Roundtable" };
250
+ }
251
+
252
+ async #gated(visitor: Visitor): Promise<boolean> {
253
+ const { gate } = this.#options;
254
+ if (!gate) return false;
255
+ try {
256
+ return (await gate.required(viewerOf(visitor))).length > 0;
257
+ } catch (error) {
258
+ // A gate that cannot be read stays shut.
259
+ this.#options.logger.warn({ err: error }, "the gate was not read");
260
+ return true;
261
+ }
262
+ }
263
+
264
+ /** The answer to a request the authorization table refused. */
265
+ #denied(decision: Extract<Decision, { allow: false }>): Response {
266
+ const { presentation, logger } = this.#options;
267
+ logger.debug({ reason: decision.reason }, "console request denied");
268
+ if (decision.gate)
269
+ return json(
270
+ {
271
+ error: message(presentation, "Finish setting up first."),
272
+ gate: true,
273
+ },
274
+ 403,
275
+ );
276
+ return json(
277
+ {
278
+ error: message(
279
+ presentation,
280
+ decision.status === 404
281
+ ? "There is no such API."
282
+ : "Only an owner may do this.",
283
+ ),
284
+ },
285
+ decision.status,
286
+ );
287
+ }
288
+
209
289
  /**
210
290
  * The answer to a request the authenticator refused: a page navigation that signing in would
211
291
  * fix goes to the login page, everything else gets the status and a fixed body.
@@ -215,14 +295,24 @@ export class ConsoleServer {
215
295
  refusal.login &&
216
296
  READ_METHODS.has(request.method) &&
217
297
  !rest.startsWith("api/")
218
- )
298
+ ) {
299
+ // 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.
300
+ const surface = this.#options.surface ?? "app";
301
+ const named = allowedNext(
302
+ URL.parse(request.url)?.searchParams.get("next"),
303
+ surface,
304
+ );
305
+ const next =
306
+ named ??
307
+ (this.#options.pathRouting ? nextOfPath(rest, surface) : undefined);
219
308
  return new Response(null, {
220
309
  status: 303,
221
310
  headers: {
222
- location: `${this.#options.mount}/login`,
311
+ location: `${this.#options.mount}/login${next ? `?next=${encodeURIComponent(next)}` : ""}`,
223
312
  "cache-control": "no-store",
224
313
  },
225
314
  });
315
+ }
226
316
  // A missing session is the normal way to meet a login page, not worth a line each time.
227
317
  const { logger } = this.#options;
228
318
  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,112 @@
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
+ /** The mark of a card the host keeps past its kind's retention (`HostCard.keep`). */
53
+ static readonly keepMigration: Migration = {
54
+ name: "web-host-cards-keep",
55
+ up: async (sql) => {
56
+ await sql`
57
+ ALTER TABLE web_host_cards ADD COLUMN IF NOT EXISTS keep boolean NOT NULL DEFAULT false`;
58
+ },
59
+ };
60
+
61
+ async create(room: string, card: HostCard): Promise<StoredHostCard> {
62
+ const id = crypto.randomUUID();
63
+ const [row]: Row[] = await this.#sql`
64
+ INSERT INTO web_host_cards (id, room, card, state, keep)
65
+ VALUES (${id}, ${room}, ${JSON.stringify(card)}::jsonb, ${card.state ?? null}, ${card.keep === true})
66
+ RETURNING *`;
67
+ if (!row) throw new Error("the card log stored nothing");
68
+ return cardOf(row);
69
+ }
70
+
71
+ /** Replaces a card of the room; undefined when the room holds no such card. */
72
+ async update(
73
+ room: string,
74
+ id: string,
75
+ card: HostCard,
76
+ ): Promise<StoredHostCard | undefined> {
77
+ const [row]: Row[] = await this.#sql`
78
+ UPDATE web_host_cards
79
+ SET card = ${JSON.stringify(card)}::jsonb, state = ${card.state ?? null}, keep = ${card.keep === true}, updated_at = now()
80
+ WHERE id = ${id} AND room = ${room} RETURNING *`;
81
+ return row && cardOf(row);
82
+ }
83
+
84
+ async get(room: string, id: string): Promise<StoredHostCard | undefined> {
85
+ const [row]: Row[] = await this.#sql`
86
+ SELECT * FROM web_host_cards WHERE id = ${id} AND room = ${room}`;
87
+ return row && cardOf(row);
88
+ }
89
+
90
+ /** The room's cards, oldest first. */
91
+ async list(room: string): Promise<StoredHostCard[]> {
92
+ const rows: Row[] = await this.#sql`
93
+ SELECT * FROM web_host_cards WHERE room = ${room} ORDER BY created_at, id`;
94
+ return rows.map(cardOf);
95
+ }
96
+
97
+ /**
98
+ * Deletes the cards of the rooms whose key starts with `prefix` that are older than `before`,
99
+ * except those the host marked `keep`; how many.
100
+ */
101
+ async sweepKind(prefix: string, before: Date): Promise<number> {
102
+ const rows: unknown[] = await this.#sql`
103
+ DELETE FROM web_host_cards
104
+ WHERE starts_with(room, ${prefix}) AND created_at < ${before}
105
+ AND NOT keep RETURNING 1`;
106
+ return rows.length;
107
+ }
108
+
109
+ async removeRoom(room: string): Promise<void> {
110
+ await this.#sql`DELETE FROM web_host_cards WHERE room = ${room}`;
111
+ }
112
+ }