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
@@ -2,7 +2,7 @@ import type { ConsolePresentation } from "./features.ts";
2
2
  import { escapeHtml } from "./http.ts";
3
3
  import { PAGE_COLORS } from "./page-colors.ts";
4
4
 
5
- /** The tags that make the page installable; the URLs resolve against the page, so the mount does not matter. */
5
+ /** The tags that make the page installable; their URLs resolve against the base, so they follow it. */
6
6
  const pwaTags = (look: "modern" | "camelot"): string =>
7
7
  '<link rel="manifest" href="./manifest.webmanifest" crossorigin="use-credentials">' +
8
8
  `<meta name="theme-color" content="${PAGE_COLORS[look].light}" media="(prefers-color-scheme: light)">` +
@@ -10,21 +10,66 @@ const pwaTags = (look: "modern" | "camelot"): string =>
10
10
  '<link rel="apple-touch-icon" href="./icons/apple-180.png">' +
11
11
  '<meta name="apple-mobile-web-app-capable" content="yes">';
12
12
 
13
+ /**
14
+ * The icon links of a page served under `mount`: the round-table mark as an SVG when the icon
15
+ * directory has one, and the 192 px PNG every icon directory has (the manifest names it). Without
16
+ * them a browser asks the site root for `/favicon.ico`, which is not this app's.
17
+ */
18
+ export const iconLinks = (mount: string, svg: boolean): string => {
19
+ const base = escapeHtml(mount);
20
+ const mark = svg
21
+ ? `<link rel="icon" href="${base}/icons/mark.svg" type="image/svg+xml" sizes="any">`
22
+ : "";
23
+ return `${mark}<link rel="icon" href="${base}/icons/192.png" type="image/png">`;
24
+ };
25
+
26
+ /** What a page says about itself to a tab, a search result and a link preview. */
27
+ export interface PageMeta {
28
+ title: string;
29
+ description: string;
30
+ /** The console's origin, so `og:image` is an absolute URL. */
31
+ origin: string;
32
+ /** The path the console is served under. */
33
+ mount: string;
34
+ /** Whether the icon directory has `mark.svg`. */
35
+ svg: boolean;
36
+ }
37
+
38
+ /** The text a host that sets no `description` gets. */
39
+ export const defaultDescription = (title: string): string =>
40
+ `Chat with your agent team in ${title}.`;
41
+
42
+ const metaTags = (meta: PageMeta): string =>
43
+ `<meta name="description" content="${escapeHtml(meta.description)}">` +
44
+ `<meta property="og:title" content="${escapeHtml(meta.title)}">` +
45
+ `<meta property="og:description" content="${escapeHtml(meta.description)}">` +
46
+ `<meta property="og:image" content="${escapeHtml(`${meta.origin}${meta.mount}/icons/512.png`)}">`;
47
+
13
48
  /** Authenticated, inert bootstrap data localizes even a failed first API request without inline scripts. */
14
49
  export function presentPage(
15
50
  body: Uint8Array,
16
51
  mount: string | undefined,
17
52
  presentation: ConsolePresentation | undefined,
18
53
  look: "modern" | "camelot" = "modern",
54
+ meta?: PageMeta,
19
55
  ): Uint8Array<ArrayBuffer> | string {
20
56
  // Not in web/index.html: the bundler would try to resolve and rewrite these URLs.
21
- let head = `<head>${pwaTags(look)}`;
57
+ // The base comes first: under path routing a relative link before it resolves against a deep URL.
58
+ let head = "<head>";
22
59
  if (mount) head += `<base href="${escapeHtml(`${mount}/`)}">`;
60
+ head += pwaTags(look);
61
+ if (meta) head += iconLinks(meta.mount, meta.svg) + metaTags(meta);
23
62
  if (presentation)
24
63
  head += `<meta name="roundtable-console-presentation" content="${escapeHtml(JSON.stringify(presentation))}">`;
25
64
  // After the base, so it resolves under the mount; a script of the page's own origin, which its policy allows.
26
65
  head += '<script src="./theme-init.js"></script>';
27
66
  let html = new TextDecoder().decode(body).replace("<head>", () => head);
67
+ // web/index.html ships a placeholder: the page is not rendered until the bundle sets the title.
68
+ if (meta)
69
+ html = html.replace(
70
+ /<title>[^<]*<\/title>/,
71
+ () => `<title>${escapeHtml(meta.title)}</title>`,
72
+ );
28
73
  if (presentation?.locale)
29
74
  html = html.replace(
30
75
  /(<html[^>]*lang=")[^"]*(")/,
@@ -0,0 +1,175 @@
1
+ import {
2
+ IdentityError,
3
+ type IdentityService,
4
+ type PersonProfiles,
5
+ } from "pi-roundtable";
6
+ import type { ProfileView } from "../api-types.ts";
7
+ import type { Gate } from "../gate.ts";
8
+ import { ConsoleHttpError as HttpError, json } from "../http.ts";
9
+ import { readLimited } from "../request-body.ts";
10
+ import type { RouteArea, Viewer } from "../web-app.ts";
11
+ import { LinkRefused, type LinkService } from "./links.ts";
12
+
13
+ const MAX_BODY_BYTES = 4 * 1024;
14
+
15
+ async function bodyOf(request: Request): Promise<Record<string, unknown>> {
16
+ const raw = await readLimited(request, MAX_BODY_BYTES);
17
+ if (!raw) throw new HttpError(413, "The body is too large.");
18
+ let value: unknown;
19
+ try {
20
+ value = JSON.parse(raw.toString("utf8"));
21
+ } catch {
22
+ throw new HttpError(400, "The body is not JSON.");
23
+ }
24
+ if (typeof value !== "object" || value === null || Array.isArray(value))
25
+ throw new HttpError(400, "The body is not an object.");
26
+ return value as Record<string, unknown>;
27
+ }
28
+
29
+ /** Turns what a handler throws into the response a person reads; anything else is a bug and rethrown. */
30
+ function answer(error: unknown): Response {
31
+ if (error instanceof HttpError)
32
+ return json({ error: error.message }, error.status);
33
+ if (error instanceof LinkRefused)
34
+ return json({ error: error.message }, error.status);
35
+ if (error instanceof IdentityError)
36
+ return json({ error: error.message }, 400);
37
+ throw error;
38
+ }
39
+
40
+ export interface ProfileAreaOptions {
41
+ profiles: PersonProfiles;
42
+ identity: Pick<IdentityService, "identities">;
43
+ /**
44
+ * What the account the person signed in with is called when it is not a registered source: a
45
+ * sign-in reports its name to `PROFILES.observed`, so it can name the person without a fetch.
46
+ */
47
+ sessionLabel: string;
48
+ }
49
+
50
+ /** `GET api/profile` and `PUT api/profile/source`: how the person is shown and which account names them. */
51
+ export function profileArea(options: ProfileAreaOptions): RouteArea {
52
+ const view = async (viewer: Viewer, sessionProvider: string | undefined) => {
53
+ const { profiles, identity } = options;
54
+ const [display, linked] = await Promise.all([
55
+ profiles.of(viewer.principalId),
56
+ identity.identities(viewer.principalId),
57
+ ]);
58
+ const linkedProviders = new Set(linked.map((link) => link.provider));
59
+ const sources: ProfileView["sources"] = profiles
60
+ .sources()
61
+ .filter((source) => linkedProviders.has(source.provider))
62
+ .map((source) => ({ provider: source.provider, label: source.label }));
63
+ if (
64
+ sessionProvider &&
65
+ linkedProviders.has(sessionProvider) &&
66
+ !sources.some((source) => source.provider === sessionProvider)
67
+ )
68
+ sources.push({ provider: sessionProvider, label: options.sessionLabel });
69
+ return {
70
+ name: display.name,
71
+ ...(display.source ? { source: display.source } : {}),
72
+ picture: display.picture,
73
+ principal: viewer.principalId,
74
+ tier: viewer.tier,
75
+ sources,
76
+ } satisfies ProfileView;
77
+ };
78
+ return {
79
+ area: "profile",
80
+ async handle(request, parts, { viewer, visitor }) {
81
+ try {
82
+ const provider = visitor.speakerId.split(":").slice(0, -1).join(":");
83
+ if (parts.length === 0 && request.method === "GET")
84
+ return json(await view(viewer, provider || undefined));
85
+ if (parts.length === 1 && parts[0] === "source") {
86
+ if (request.method !== "PUT")
87
+ throw new HttpError(405, "Method not allowed.");
88
+ const body = await bodyOf(request);
89
+ const chosen = body.provider;
90
+ if (chosen !== null && typeof chosen !== "string")
91
+ throw new HttpError(
92
+ 400,
93
+ "Send provider: the account's provider, or null for the default.",
94
+ );
95
+ await options.profiles.choose(viewer.principalId, chosen);
96
+ return json(await view(viewer, provider || undefined));
97
+ }
98
+ throw new HttpError(404, "There is no such API.");
99
+ } catch (error) {
100
+ return answer(error);
101
+ }
102
+ },
103
+ };
104
+ }
105
+
106
+ /**
107
+ * `GET api/people/<principal>/picture`: the person's picture through the picture proxy. Anyone past
108
+ * the gate may fetch another person's picture; a person still at the gate only their own.
109
+ */
110
+ export function peopleArea(options: {
111
+ profiles: Pick<PersonProfiles, "picture">;
112
+ gate: Pick<Gate, "required">;
113
+ }): RouteArea {
114
+ return {
115
+ area: "people",
116
+ async handle(request, parts, { viewer }) {
117
+ try {
118
+ const [id, what] = parts;
119
+ if (id === undefined || what !== "picture" || parts.length !== 2)
120
+ throw new HttpError(404, "There is no such API.");
121
+ if (request.method !== "GET" && request.method !== "HEAD")
122
+ throw new HttpError(405, "Method not allowed.");
123
+ if (
124
+ id !== viewer.principalId &&
125
+ (await options.gate.required(viewer)).length > 0
126
+ )
127
+ throw new HttpError(403, "Finish setting up first.");
128
+ const picture = await options.profiles.picture(id);
129
+ if (!picture) throw new HttpError(404, "There is no picture.");
130
+ return picture;
131
+ } catch (error) {
132
+ return answer(error);
133
+ }
134
+ },
135
+ };
136
+ }
137
+
138
+ /** `GET api/links`, `POST api/links/<provider>/issue|confirm|cancel` and `DELETE api/links/<provider>`. */
139
+ export function linksArea(links: LinkService): RouteArea {
140
+ return {
141
+ area: "links",
142
+ async handle(request, parts, { viewer }) {
143
+ try {
144
+ const me = viewer.principalId;
145
+ const [provider, action] = parts;
146
+ const { method } = request;
147
+ if (provider === undefined && method === "GET")
148
+ return json(await links.list(me));
149
+ if (provider !== undefined && action === undefined) {
150
+ if (method === "GET") return json(await links.one(provider, me));
151
+ if (method === "DELETE")
152
+ return json(await links.unlink(provider, me));
153
+ throw new HttpError(405, "Method not allowed.");
154
+ }
155
+ if (provider !== undefined && parts.length === 2 && method === "POST") {
156
+ if (action === "issue") return json(await links.issue(provider, me));
157
+ if (action === "cancel")
158
+ return json(await links.cancel(provider, me));
159
+ if (action === "confirm") {
160
+ const { request: ref } = await bodyOf(request);
161
+ if (typeof ref !== "string" || ref.length === 0 || ref.length > 200)
162
+ throw new HttpError(
163
+ 400,
164
+ "Send request: the id the code came with.",
165
+ );
166
+ return json(await links.confirm(provider, me, ref));
167
+ }
168
+ }
169
+ throw new HttpError(404, "There is no such API.");
170
+ } catch (error) {
171
+ return answer(error);
172
+ }
173
+ },
174
+ };
175
+ }
@@ -0,0 +1,162 @@
1
+ import type { IdentityService, Logger } from "pi-roundtable";
2
+ import type { LinksView, LinkView } from "../api-types.ts";
3
+ import type { Gate } from "../gate.ts";
4
+ import type { LinkProver, WebAppRegistry } from "../web-app.ts";
5
+
6
+ /** Why a link request was refused, in words the page shows. `status` is the HTTP status. */
7
+ export class LinkRefused extends Error {
8
+ readonly status: number;
9
+ constructor(message: string, status = 409) {
10
+ super(message);
11
+ this.name = "LinkRefused";
12
+ this.status = status;
13
+ }
14
+ }
15
+
16
+ export interface LinkServiceOptions {
17
+ registry: Pick<WebAppRegistry, "provers" | "prover">;
18
+ /** `onboarding.links`: which providers are required or only nudged. */
19
+ steps: Readonly<Record<string, "required" | "nudge">>;
20
+ identity: Pick<IdentityService, "identities">;
21
+ gate: Pick<Gate, "changed">;
22
+ logger: Logger;
23
+ }
24
+
25
+ /** Link sources that the configuration, the CLI or a plugin made, which a person cannot remove. */
26
+ const LOCKED = new Set(["config", "cli", "plugin"]);
27
+
28
+ /**
29
+ * The accounts a person can link and how they stand. Every call to a prover carries the session's
30
+ * principal, never one a request names; a prover's `requires` must be linked first, and a link
31
+ * the configuration, CLI or a plugin made is never unlinked here.
32
+ */
33
+ export class LinkService {
34
+ readonly #options: LinkServiceOptions;
35
+
36
+ constructor(options: LinkServiceOptions) {
37
+ this.#options = options;
38
+ }
39
+
40
+ prover(provider: string): LinkProver {
41
+ const found = this.#options.registry.prover(provider);
42
+ if (!found) throw new LinkRefused("There is no such account to link.", 404);
43
+ return found;
44
+ }
45
+
46
+ async list(principalId: string): Promise<LinksView> {
47
+ const links = await Promise.all(
48
+ this.#options.registry
49
+ .provers()
50
+ .map((prover) => this.#view(prover, principalId)),
51
+ );
52
+ return { links };
53
+ }
54
+
55
+ async one(provider: string, principalId: string): Promise<LinkView> {
56
+ return this.#view(this.prover(provider), principalId);
57
+ }
58
+
59
+ async issue(provider: string, principalId: string) {
60
+ const prover = this.prover(provider);
61
+ const blocked = await this.#blockedBy(prover, principalId);
62
+ if (blocked.length > 0)
63
+ throw new LinkRefused(
64
+ `Link ${blocked.map((p) => this.#options.registry.prover(p)?.label ?? p).join(" and ")} first.`,
65
+ );
66
+ if ((await prover.status(principalId)).linked)
67
+ throw new LinkRefused("That account is already linked.");
68
+ const issued = await prover.issue(principalId);
69
+ return {
70
+ code: issued.code,
71
+ request: issued.request,
72
+ expiresAt: issued.expiresAt.toISOString(),
73
+ instructions: issued.instructions,
74
+ };
75
+ }
76
+
77
+ async confirm(provider: string, principalId: string, request: string) {
78
+ const prover = this.prover(provider);
79
+ await prover.confirm(principalId, request);
80
+ this.#options.gate.changed(principalId);
81
+ return this.#view(prover, principalId);
82
+ }
83
+
84
+ async cancel(provider: string, principalId: string): Promise<LinkView> {
85
+ const prover = this.prover(provider);
86
+ await prover.cancel(principalId);
87
+ return this.#view(prover, principalId);
88
+ }
89
+
90
+ async unlink(provider: string, principalId: string): Promise<LinkView> {
91
+ const prover = this.prover(provider);
92
+ const locked = (await this.#options.identity.identities(principalId)).some(
93
+ (link) => link.provider === provider && LOCKED.has(link.source),
94
+ );
95
+ if (locked)
96
+ throw new LinkRefused(
97
+ "That account was linked by the host's configuration, so it cannot be removed here.",
98
+ );
99
+ await prover.unlink(principalId);
100
+ this.#options.gate.changed(principalId);
101
+ return this.#view(prover, principalId);
102
+ }
103
+
104
+ /** The providers `prover` requires that the person has not linked. */
105
+ async #blockedBy(prover: LinkProver, principalId: string): Promise<string[]> {
106
+ const blocked: string[] = [];
107
+ for (const needed of prover.requires ?? []) {
108
+ const other = this.#options.registry.prover(needed);
109
+ const linked = other
110
+ ? Boolean((await other.status(principalId)).linked)
111
+ : false;
112
+ const known = (await this.#options.identity.identities(principalId)).some(
113
+ (link) => link.provider === needed,
114
+ );
115
+ if (!linked && !known) blocked.push(needed);
116
+ }
117
+ return blocked;
118
+ }
119
+
120
+ async #view(prover: LinkProver, principalId: string): Promise<LinkView> {
121
+ const [state, identities, blocked] = await Promise.all([
122
+ prover.status(principalId),
123
+ this.#options.identity.identities(principalId),
124
+ this.#blockedBy(prover, principalId),
125
+ ]);
126
+ const own = identities.find((link) => link.provider === prover.provider);
127
+ const linked = state.linked
128
+ ? {
129
+ ...(state.linked.displayName
130
+ ? { displayName: state.linked.displayName }
131
+ : {}),
132
+ linkedAt: state.linked.linkedAt.toISOString(),
133
+ ...(own && LOCKED.has(own.source) ? { locked: true as const } : {}),
134
+ }
135
+ : own
136
+ ? {
137
+ linkedAt: own.linkedAt.toISOString(),
138
+ ...(LOCKED.has(own.source) ? { locked: true as const } : {}),
139
+ }
140
+ : undefined;
141
+ const step = this.#options.steps[prover.provider];
142
+ return {
143
+ provider: prover.provider,
144
+ label: prover.label,
145
+ ...(step ? { step } : {}),
146
+ requires: [...(prover.requires ?? [])],
147
+ blockedBy: blocked,
148
+ ...(linked ? { linked } : {}),
149
+ ...(state.pending && !linked
150
+ ? {
151
+ pending: {
152
+ request: state.pending.request,
153
+ expiresAt: state.pending.expiresAt.toISOString(),
154
+ ...(state.pending.redeemedBy
155
+ ? { redeemedBy: state.pending.redeemedBy }
156
+ : {}),
157
+ },
158
+ }
159
+ : {}),
160
+ };
161
+ }
162
+ }