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
@@ -0,0 +1,118 @@
1
+ import type { Server, SQL } from "bun";
2
+ import type { Logger } from "pi-roundtable";
3
+ import { ConsoleHttpError, json } from "../http.ts";
4
+ import { DeviceRoutes } from "../push/api.ts";
5
+ import { OutboxSender, type PushTransport } from "../push/drain.ts";
6
+ import { EndpointPolicy } from "../push/endpoint.ts";
7
+ import { type OutboxRow, PushOutbox } from "../push/outbox.ts";
8
+ import { PushSubscriptions } from "../push/subscriptions.ts";
9
+
10
+ export interface PushSenderOptions {
11
+ /**
12
+ * The sender's own database role. It needs to SELECT the outbox, subscriptions, sessions and
13
+ * principals, UPDATE the outbox, and INSERT deliveries and subscriptions.
14
+ */
15
+ database: SQL;
16
+ /** Tried in order for each row until one says `sent`; a row's `via` narrows them. */
17
+ transports: readonly PushTransport[];
18
+ /** Words a row; with `push.content: "none"` the row carries no text, so this is where it comes from. */
19
+ render(row: OutboxRow): { title: string; body?: string };
20
+ /** Serves the subscription routes at `path`, for `push.sender.external.subscribePath`. */
21
+ subscribe?: {
22
+ listener: { port: number; hostname?: string } | { socketPath: string };
23
+ /** The path the page posts devices to, such as `/push/subscriptions`. */
24
+ path: string;
25
+ /** Every request that is not a GET must carry exactly this `Origin`: the console's. */
26
+ origin: string;
27
+ /** The principal behind the request's session cookie, or undefined (use `webSessionPrincipal`). */
28
+ authenticate(request: Request): Promise<string | undefined>;
29
+ /** Push services besides the browsers' own, as `push.endpointHosts` writes them. */
30
+ endpointHosts?: readonly string[];
31
+ };
32
+ logger: Logger;
33
+ /** How often the outbox is looked at; default 1 s. */
34
+ intervalMs?: number;
35
+ }
36
+
37
+ /**
38
+ * Runs the process that signs and sends the app's pushes when the app holds no key
39
+ * (`push.sender.external`). It empties the outbox through the transports and, when asked, serves
40
+ * the subscription routes with the app's session cookie and the console's `Origin`.
41
+ */
42
+ export function runPushSender(options: PushSenderOptions): {
43
+ stop(): Promise<void>;
44
+ /** Takes and sends what is queued now, for a test; returns how many rows it finished. */
45
+ drain(): Promise<number>;
46
+ /** The port the subscription routes listen on, when they are served over TCP. */
47
+ port?: number;
48
+ } {
49
+ const sender = new OutboxSender({
50
+ outbox: new PushOutbox(options.database),
51
+ transports: options.transports,
52
+ render: options.render,
53
+ logger: options.logger,
54
+ ...(options.intervalMs ? { intervalMs: options.intervalMs } : {}),
55
+ });
56
+ sender.start();
57
+ const server = options.subscribe ? serve(options) : undefined;
58
+ return {
59
+ stop: async () => {
60
+ await server?.stop(true);
61
+ await sender.stop();
62
+ },
63
+ drain: () => sender.drain(),
64
+ ...(server?.port ? { port: server.port } : {}),
65
+ };
66
+ }
67
+
68
+ function serve(options: PushSenderOptions): Server<undefined> {
69
+ const { subscribe, logger } = options;
70
+ if (!subscribe) throw new Error("no subscribe options");
71
+ const devices = new DeviceRoutes({
72
+ policy: new EndpointPolicy(subscribe.endpointHosts),
73
+ subscriptions: new PushSubscriptions(options.database),
74
+ enabled: true,
75
+ });
76
+ const base = subscribe.path.replace(/\/$/, "");
77
+ return Bun.serve({
78
+ ...("socketPath" in subscribe.listener
79
+ ? { unix: subscribe.listener.socketPath }
80
+ : {
81
+ port: subscribe.listener.port,
82
+ hostname: subscribe.listener.hostname ?? "127.0.0.1",
83
+ }),
84
+ fetch: async (request) => {
85
+ const path = URL.parse(request.url)?.pathname ?? "";
86
+ if (path !== base && !path.startsWith(`${base}/`))
87
+ return new Response("Not Found", { status: 404 });
88
+ let id: string | undefined;
89
+ try {
90
+ id =
91
+ path === base
92
+ ? undefined
93
+ : decodeURIComponent(path.slice(base.length + 1));
94
+ } catch {
95
+ return json({ error: "There is no such device." }, 404);
96
+ }
97
+ // A page on a sibling host of the same site carries the cookie too; the Origin is what stops it.
98
+ if (
99
+ request.method !== "GET" &&
100
+ request.headers.get("origin") !== subscribe.origin
101
+ )
102
+ return json({ error: "The request did not come from the app." }, 403);
103
+ try {
104
+ const principalId = await subscribe.authenticate(request);
105
+ if (!principalId) return json({ error: "Sign in again." }, 401);
106
+ return await devices.handle(request, id, principalId);
107
+ } catch (error) {
108
+ if (error instanceof ConsoleHttpError)
109
+ return json({ error: error.message }, error.status);
110
+ logger.error({ err: error }, "a subscription request failed");
111
+ return json(
112
+ { error: "The sender could not complete the request." },
113
+ 500,
114
+ );
115
+ }
116
+ },
117
+ });
118
+ }
@@ -0,0 +1,54 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { SQL } from "bun";
3
+ import { WebSessions } from "../auth/sessions.ts";
4
+
5
+ /** The session cookie the app sets: `__Secure-` because the console is never served at the root. */
6
+ export const SESSION_COOKIE = "__Secure-rt_session";
7
+
8
+ const cookieValue = (
9
+ header: string | null,
10
+ name: string,
11
+ ): string | undefined => {
12
+ for (const part of (header ?? "").split(";")) {
13
+ const at = part.indexOf("=");
14
+ if (at > 0 && part.slice(0, at).trim() === name)
15
+ return part.slice(at + 1).trim();
16
+ }
17
+ return undefined;
18
+ };
19
+
20
+ /**
21
+ * Looks the app's session cookie up in `web_sessions` (read only) and answers the principal it
22
+ * belongs to, or undefined for a cookie that names no session, a session past its limit, or a
23
+ * principal that is disabled. The cookie holds a random id; only its SHA-256 is stored.
24
+ */
25
+ export function webSessionPrincipal(options: {
26
+ database: SQL;
27
+ /** The cookie's name; default the app's own. */
28
+ cookieName?: string;
29
+ }): (request: Request) => Promise<string | undefined> {
30
+ const name = options.cookieName ?? SESSION_COOKIE;
31
+ return async (request) => {
32
+ const token = cookieValue(request.headers.get("cookie"), name);
33
+ if (!token) return undefined;
34
+ const [found]: { principal_id: string }[] = await options.database`
35
+ SELECT s.principal_id FROM web_sessions s
36
+ JOIN principals p ON p.id = s.principal_id
37
+ WHERE s.id_hash = ${createHash("sha256").update(token).digest()}
38
+ AND s.expires_at > now() AND p.disabled_at IS NULL`;
39
+ return found?.principal_id;
40
+ };
41
+ }
42
+
43
+ /**
44
+ * The id_token claims `oidc.storeClaims` kept for a person, from their newest live session;
45
+ * undefined when they have no live session or kept no claims. For a sender that needs, say, the
46
+ * person's mailbox address. Claims are the host's own data: never log them.
47
+ */
48
+ export function webSessionClaims(
49
+ database: SQL,
50
+ principalId: string,
51
+ ): Promise<Readonly<Record<string, unknown>> | undefined> {
52
+ // The idle limit only matters for creating a session; reading one does not use it.
53
+ return new WebSessions(database, { days: 30 }).claimsOf(principalId);
54
+ }
package/src/pwa.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { basename, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import type { AssetBundle } from "./assets.ts";
@@ -34,7 +34,27 @@ export const DEFAULT_ICONS_DIR = fileURLToPath(
34
34
  new URL("../assets/icons/", import.meta.url),
35
35
  );
36
36
 
37
- const ICON_FILE = /^[A-Za-z0-9-]+\.png$/;
37
+ const ICON_FILE = /^[A-Za-z0-9-]+\.(png|svg)$/;
38
+
39
+ /** The icon files the page, the manifest and the sign-in pages link. */
40
+ const PUBLIC_ICONS: ReadonlySet<string> = new Set([
41
+ "192.png",
42
+ "512.png",
43
+ "maskable-512.png",
44
+ "apple-180.png",
45
+ "badge-96.png",
46
+ "mark.svg",
47
+ "mark-maskable.svg",
48
+ ]);
49
+
50
+ /** The manifest and the linked icons answer without a session: a sign-in page and a link preview have none. Any other file of a host's icon directory stays behind it. */
51
+ export const isPublicStatic = (rest: string): boolean =>
52
+ rest === "manifest.webmanifest" ||
53
+ (rest.startsWith("icons/") && PUBLIC_ICONS.has(rest.slice("icons/".length)));
54
+
55
+ /** Whether the icon directory has the round-table mark as an SVG, which a tab shows crisper than a PNG. */
56
+ export const hasMarkSvg = (iconsDir: string | undefined): boolean =>
57
+ existsSync(join(iconsDir ?? DEFAULT_ICONS_DIR, "mark.svg"));
38
58
 
39
59
  export interface PwaOptions {
40
60
  mount: string;
@@ -116,10 +136,18 @@ export function servePwa(
116
136
  const body = readFileSync(
117
137
  join(options.iconsDir ?? DEFAULT_ICONS_DIR, name),
118
138
  );
139
+ const svg = name.endsWith(".svg");
119
140
  return new Response(new Uint8Array(body), {
120
141
  headers: {
121
- "content-type": "image/png",
122
- "cache-control": "private, max-age=86400",
142
+ "content-type": svg ? "image/svg+xml" : "image/png",
143
+ "cache-control": "public, max-age=86400",
144
+ // An SVG opened as a page must not run anything of its own.
145
+ ...(svg
146
+ ? {
147
+ "content-security-policy":
148
+ "default-src 'none'; style-src 'unsafe-inline'",
149
+ }
150
+ : {}),
123
151
  },
124
152
  });
125
153
  } catch {
package/src/visitors.ts CHANGED
@@ -1,13 +1,24 @@
1
1
  import type {
2
2
  ActorFacts,
3
+ ChannelKey,
3
4
  IdentityService,
4
5
  Logger,
5
6
  Principal,
7
+ Tier,
6
8
  } from "pi-roundtable";
7
9
 
8
- /** Who a request the console admitted comes from: an owner's principal. */
10
+ /**
11
+ * Who a request the console admitted comes from: the principal, the tier they hold on this
12
+ * request (read again for every request and every socket frame, never carried over from the
13
+ * sign-in), the identity they spoke as, and the roles their sign-in carried.
14
+ */
9
15
  export interface Visitor {
10
16
  principal: Principal;
17
+ tier: Tier;
18
+ /** The identity they spoke as, `<provider>:<subject>`; the principal's id when the credential names none. */
19
+ speakerId: string;
20
+ /** The roles the sign-in carried, each `<surface>:role:<name>`. */
21
+ roles: readonly string[];
11
22
  }
12
23
 
13
24
  /** The visitor of a request, or why it is refused, for the operator's log. */
@@ -19,6 +30,12 @@ export type ConsoleIdentity = Pick<
19
30
  "principalOf" | "principal" | "tierOf" | "owners"
20
31
  >;
21
32
 
33
+ /** The reads (and the one first-contact write) of `IDENTITY` that admitting members uses. */
34
+ export type AccessIdentity = Pick<
35
+ IdentityService,
36
+ "principalOf" | "principal" | "peek" | "resolve"
37
+ >;
38
+
22
39
  export interface VisitorOptions {
23
40
  /** Whom a request from a verifier that reports no actor stands for; default the primary owner. */
24
41
  ownerId?: string;
@@ -50,13 +67,24 @@ export function consoleVisitors(
50
67
  logger.warn(`web-console: ${message}`);
51
68
  };
52
69
 
53
- const owner = async (id: string): Promise<Identified> => {
70
+ const owner = async (
71
+ id: string,
72
+ actor: ActorFacts | undefined,
73
+ ): Promise<Identified> => {
54
74
  const principal = await identity.principal(id);
55
75
  if (!principal) return { refusal: `there is no principal ${id}` };
56
76
  if (principal.disabled) return { refusal: `principal ${id} is disabled` };
57
- if ((await identity.tierOf(id)) !== "owner")
77
+ const tier = await identity.tierOf(id);
78
+ if (tier !== "owner")
58
79
  return { refusal: `principal ${id} holds no owner role` };
59
- return { visitor: { principal } };
80
+ return {
81
+ visitor: {
82
+ principal,
83
+ tier,
84
+ speakerId: actor ? `${actor.provider}:${actor.subject}` : id,
85
+ roles: actor?.roles ?? [],
86
+ },
87
+ };
60
88
  };
61
89
 
62
90
  return async (actor) => {
@@ -68,7 +96,7 @@ export function consoleVisitors(
68
96
  const id = options.ownerId ?? (await identity.owners())[0]?.id;
69
97
  return id === undefined
70
98
  ? { refusal: "the host has no owner" }
71
- : owner(id);
99
+ : owner(id, undefined);
72
100
  }
73
101
  const named = `${actor.provider}:${actor.subject}`;
74
102
  if (actor.provider === LEGACY_PROVIDER)
@@ -79,7 +107,7 @@ export function consoleVisitors(
79
107
  } catch {
80
108
  return { refusal: "the verifier's actor is no identity" };
81
109
  }
82
- if (id !== undefined) return owner(id);
110
+ if (id !== undefined) return owner(id, actor);
83
111
  warnOnce(
84
112
  named,
85
113
  `${named} is linked to no principal, so the console refuses it. If it is an owner's, add "${named}" to that owner's access.owners[].identities, or run: roundtable principal link <owner's principal id> ${named}`,
@@ -87,3 +115,63 @@ export function consoleVisitors(
87
115
  return { refusal: `${named} is linked to no principal` };
88
116
  };
89
117
  }
118
+
119
+ export interface AccessOptions {
120
+ /** The app's surface, such as `app`: the surface the facts name and the conversation `<surface>:session` the tier is read for. */
121
+ surface: string;
122
+ logger: Logger;
123
+ }
124
+
125
+ /**
126
+ * Identifies the person behind each request when the app admits everyone `IDENTITY` admits on its
127
+ * surface (`admission: "access"`). The credential must name a person: one shared actor names no
128
+ * one. The facts are the app's (`surface`, `partial`, and the roles the sign-in carried), so a
129
+ * sign-in shows less than a person may hold and never lowers a tier recorded elsewhere.
130
+ *
131
+ * A linked identity is only read (`peek`): a request writes nothing. An unlinked one goes through
132
+ * `resolve`, which admits it as a new principal where `access.provisioning` says so for the
133
+ * surface. A disabled principal, and a person the rules give no tier, are refused.
134
+ */
135
+ export function accessVisitors(
136
+ identity: AccessIdentity,
137
+ options: AccessOptions,
138
+ ): (actor: ActorFacts | undefined) => Promise<Identified> {
139
+ const { surface, logger } = options;
140
+ const scope = { conversation: `${surface}:session` as ChannelKey };
141
+ return async (actor) => {
142
+ if (!actor) return { refusal: "the credential names no one" };
143
+ if (actor.provider === LEGACY_PROVIDER)
144
+ return { refusal: "a 0.8 id is not an identity" };
145
+ const named = `${actor.provider}:${actor.subject}`;
146
+ const facts: ActorFacts = { ...actor, surface, partial: true };
147
+ let linked: string | undefined;
148
+ try {
149
+ linked = await identity.principalOf(named);
150
+ } catch {
151
+ return { refusal: "the credential's actor is no identity" };
152
+ }
153
+ const speaker =
154
+ linked === undefined
155
+ ? await identity.resolve(facts, scope)
156
+ : await identity.peek(facts, scope);
157
+ if (!speaker) {
158
+ logger.debug(
159
+ { linked: linked !== undefined },
160
+ "web-console: the access rules serve no one by this sign-in",
161
+ );
162
+ return { refusal: `${named} holds no tier on this surface` };
163
+ }
164
+ const principal = await identity.principal(speaker.principalId);
165
+ if (!principal) return { refusal: "the principal is gone" };
166
+ if (principal.disabled)
167
+ return { refusal: `principal ${principal.id} is disabled` };
168
+ return {
169
+ visitor: {
170
+ principal,
171
+ tier: speaker.tier,
172
+ speakerId: named,
173
+ roles: actor.roles ?? [],
174
+ },
175
+ };
176
+ };
177
+ }