pi-roundtable-web 0.24.2 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +195 -16
  3. package/dist/account-zk4efb87.css +102 -0
  4. package/dist/chunk-account-hw49gd0d.js +1 -0
  5. package/dist/chunk-action-menu-rm5cyqkj.js +1 -0
  6. package/dist/chunk-agent-b017vswv.js +1 -0
  7. package/dist/chunk-arranged-rooms-q5v3dm1r.js +1 -0
  8. package/dist/chunk-character-creator-5dxcvd16.js +1 -0
  9. package/dist/chunk-codeeditor-pyt09fwg.js +2 -0
  10. package/dist/chunk-confirm-521yqmta.js +1 -0
  11. package/dist/chunk-connectors-hrm06nx8.js +1 -0
  12. package/dist/chunk-conversations-e26rrf01.js +1 -0
  13. package/dist/chunk-drag-v41gx2mc.js +1 -0
  14. package/dist/chunk-entry-jxxb3ath.js +1 -0
  15. package/dist/chunk-folder-list-b97xk12b.js +1 -0
  16. package/dist/chunk-folds-b8q7eags.js +1 -0
  17. package/dist/chunk-gate-8k61stsc.js +1 -0
  18. package/dist/chunk-group-scene-dx6wt19m.js +1 -0
  19. package/dist/chunk-group-table-e4jr8r3a.js +1 -0
  20. package/dist/chunk-host-state-g7e7c0k5.js +1 -0
  21. package/dist/chunk-host-ui-x7k6n07v.js +939 -0
  22. package/dist/chunk-index-014rjv9k.js +2 -0
  23. package/dist/chunk-index-2d0kg096.js +2 -0
  24. package/dist/chunk-index-2qd81wbx.js +2 -0
  25. package/dist/chunk-index-2stynxw6.css +1 -0
  26. package/dist/chunk-index-459drsft.js +2 -0
  27. package/dist/chunk-index-4mfj596y.js +2 -0
  28. package/dist/chunk-index-66nskrdj.js +47 -0
  29. package/dist/chunk-index-awx6nnta.js +2 -0
  30. package/dist/chunk-index-bbsytycb.js +2 -0
  31. package/dist/chunk-index-c07p8k0x.js +4 -0
  32. package/dist/chunk-index-cq1e9xdz.js +2 -0
  33. package/dist/chunk-index-d1prgfbp.js +2 -0
  34. package/dist/chunk-index-dhpt8qr9.js +2 -0
  35. package/dist/chunk-index-e5sd30xy.js +2 -0
  36. package/dist/chunk-index-g351921r.js +1 -0
  37. package/dist/chunk-index-hby0ap91.js +2 -0
  38. package/dist/chunk-index-hssjbr1k.js +2 -0
  39. package/dist/chunk-index-k2qq1gdj.js +2 -0
  40. package/dist/chunk-index-ke9ynm0n.js +2 -0
  41. package/dist/chunk-index-km40bhsz.js +2 -0
  42. package/dist/chunk-index-kpscvg85.js +2 -0
  43. package/dist/chunk-index-m0d100mg.js +2 -0
  44. package/dist/chunk-index-mb72snk9.js +2 -0
  45. package/dist/chunk-index-mjqqver5.js +4 -0
  46. package/dist/chunk-index-nqfkwkfp.js +2 -0
  47. package/dist/chunk-index-nwqk447w.js +2 -0
  48. package/dist/chunk-index-pcfhpm8v.js +302 -0
  49. package/dist/chunk-index-q8h8xzbm.js +2 -0
  50. package/dist/chunk-index-r5vszgfs.js +2 -0
  51. package/dist/{chunk-index-mpm90318.js → chunk-index-rhtvcbsq.js} +2 -2
  52. package/dist/chunk-index-rjv7vrwd.js +13 -0
  53. package/dist/chunk-index-v23m38p6.js +50 -0
  54. package/dist/chunk-index-va0g7d88.js +1 -0
  55. package/dist/{chunk-index-6t68gbex.js → chunk-index-wnjp6wv0.js} +2 -2
  56. package/dist/chunk-index-xgkd15n3.js +2 -0
  57. package/dist/chunk-index-y8rnq8zf.js +14 -0
  58. package/dist/chunk-index-z3k18atc.js +2 -0
  59. package/dist/chunk-lang-css-6bepqpnx.js +1 -0
  60. package/dist/{chunk-lang-dockerfile-ey0nvhhm.js → chunk-lang-dockerfile-hrav55t7.js} +1 -1
  61. package/dist/{chunk-lang-go-403qsw9f.js → chunk-lang-go-x9ahv6e5.js} +3 -3
  62. package/dist/chunk-lang-html-8jc72zt4.js +1 -0
  63. package/dist/chunk-lang-javascript-2p2qtbnf.js +1 -0
  64. package/dist/chunk-lang-json-hsemyd0r.js +1 -0
  65. package/dist/chunk-lang-markdown-zkwb4c9n.js +6 -0
  66. package/dist/{chunk-lang-python-9jk5a49y.js → chunk-lang-python-tewx7006.js} +2 -2
  67. package/dist/chunk-lang-rust-0ydx84s1.js +1 -0
  68. package/dist/{chunk-lang-shell-cgq6q0kj.js → chunk-lang-shell-gq1tmdz4.js} +1 -1
  69. package/dist/chunk-lang-sql-xt1qyycg.js +2 -0
  70. package/dist/{chunk-lang-toml-xh45nm68.js → chunk-lang-toml-0hamc5fc.js} +1 -1
  71. package/dist/chunk-lang-typescript-82xf7z2e.js +1 -0
  72. package/dist/{chunk-lang-xml-1mh879j9.js → chunk-lang-xml-wf6h5pt2.js} +1 -1
  73. package/dist/{chunk-lang-yaml-jac98srt.js → chunk-lang-yaml-7vk521h1.js} +1 -1
  74. package/dist/chunk-layout-actions-zh7bezk7.js +1 -0
  75. package/dist/chunk-menu-items-1n9qargb.js +2 -0
  76. package/dist/chunk-notes-t15bh04m.js +1 -0
  77. package/dist/chunk-notice-bell-w3y0h0sr.js +1 -0
  78. package/dist/chunk-notices-fa3g7qze.js +1 -0
  79. package/dist/chunk-onboarding-34v3nr17.js +1 -0
  80. package/dist/chunk-overview-km5vq375.js +1 -0
  81. package/dist/chunk-palette-a8txdgd0.js +1 -0
  82. package/dist/chunk-placement-items-wgaxsbvs.js +1 -0
  83. package/dist/chunk-profile-ypxdbsk1.js +1 -0
  84. package/dist/chunk-prompt-history-wm9dsy6s.js +1 -0
  85. package/dist/chunk-prompts-7mhhq7zs.js +1 -0
  86. package/dist/chunk-room-confirms-t11a1gsw.js +1 -0
  87. package/dist/chunk-room-menu-2tm2xtfx.js +1 -0
  88. package/dist/chunk-rooms-menu-button-wxnsxjwc.js +1 -0
  89. package/dist/chunk-schedules-2ft6xxyw.js +1 -0
  90. package/dist/chunk-seat-menu-d3npdtx1.js +1 -0
  91. package/dist/chunk-sheet-hosts-vngvp7wv.js +1 -0
  92. package/dist/chunk-skill-editor-65n2hsq4.js +8 -0
  93. package/dist/chunk-skills-shkdwes8.js +1 -0
  94. package/dist/chunk-switcher-list-g6hhmr1t.js +1 -0
  95. package/dist/chunk-table-1b6bsq2y.js +1 -0
  96. package/dist/chunk-table-seats-vy1qhdks.js +1 -0
  97. package/dist/chunk-table2d-n26b7gd0.js +1 -0
  98. package/dist/chunk-table3d-8aysyf56.js +4220 -0
  99. package/dist/chunk-transcript-355faceq.js +1 -0
  100. package/dist/chunk-where-j5qdt8a4.js +1 -0
  101. package/dist/chunk-workspace-vrbs6zx1.js +1 -0
  102. package/dist/chunk-zh-tw-zeg600bk.js +1 -0
  103. package/dist/index.html +1 -1
  104. package/dist/sw.js +1 -1
  105. package/package.json +5 -4
  106. package/src/api-types.ts +515 -6
  107. package/src/app/api-http.ts +66 -0
  108. package/src/app/app.ts +214 -33
  109. package/src/app/card-log.ts +6 -0
  110. package/src/app/desk.ts +47 -22
  111. package/src/app/files.ts +73 -7
  112. package/src/app/layout-routes.ts +239 -0
  113. package/src/app/layout.ts +133 -0
  114. package/src/app/presence.ts +2 -2
  115. package/src/app/prompt-routes.ts +167 -0
  116. package/src/app/protocol.ts +63 -1
  117. package/src/app/room-log.ts +148 -14
  118. package/src/app/rooms.ts +23 -0
  119. package/src/app/routes.ts +278 -156
  120. package/src/app/skill-doc-routes.ts +122 -0
  121. package/src/app/socket.ts +309 -77
  122. package/src/app/sockets.ts +108 -20
  123. package/src/app/surface.ts +72 -36
  124. package/src/app/team-routes.ts +457 -0
  125. package/src/app/threads.ts +14 -0
  126. package/src/app/uploads.ts +33 -7
  127. package/src/auth/authenticator.ts +71 -8
  128. package/src/auth/next.ts +52 -0
  129. package/src/auth/oidc-pages.ts +60 -0
  130. package/src/auth/oidc.ts +611 -0
  131. package/src/auth/sessions.ts +193 -0
  132. package/src/authz.ts +172 -0
  133. package/src/console-api.ts +122 -25
  134. package/src/console-server.ts +70 -6
  135. package/src/gate.ts +90 -0
  136. package/src/host/access.ts +161 -0
  137. package/src/host/cards.ts +99 -0
  138. package/src/host/context.ts +141 -0
  139. package/src/host/forms.ts +237 -0
  140. package/src/host/frames.ts +342 -0
  141. package/src/host/host-app.ts +176 -0
  142. package/src/host/host-context.ts +49 -0
  143. package/src/host/host-routes.ts +363 -0
  144. package/src/host/pages.ts +266 -0
  145. package/src/host/reads.ts +155 -0
  146. package/src/host/retention.ts +41 -0
  147. package/src/host/room-views.ts +137 -0
  148. package/src/host/rooms-service.ts +414 -0
  149. package/src/host/stored-json.ts +13 -0
  150. package/src/host/views.ts +103 -0
  151. package/src/i18n/zh-tw.ts +309 -10
  152. package/src/layout-order.ts +210 -0
  153. package/src/notices/api.ts +43 -0
  154. package/src/notices/mount.ts +292 -0
  155. package/src/notices/service.ts +294 -0
  156. package/src/notices/store.ts +298 -0
  157. package/src/notices/urls.ts +38 -0
  158. package/src/onboarding/api.ts +32 -0
  159. package/src/onboarding/service.ts +149 -0
  160. package/src/onboarding/steps.ts +75 -0
  161. package/src/onboarding/waivers.ts +40 -0
  162. package/src/options.ts +498 -34
  163. package/src/profile/api.ts +175 -0
  164. package/src/profile/links.ts +162 -0
  165. package/src/push/api.ts +189 -52
  166. package/src/push/dispatch.ts +156 -0
  167. package/src/push/drain.ts +139 -0
  168. package/src/push/mutes.ts +17 -0
  169. package/src/push/outbox.ts +218 -0
  170. package/src/push/prefs.ts +118 -0
  171. package/src/push/sender.ts +7 -2
  172. package/src/push/triggers.ts +56 -5
  173. package/src/push/vapid.ts +19 -0
  174. package/src/push/web-push-transport.ts +106 -0
  175. package/src/push-sender/index.ts +14 -0
  176. package/src/push-sender/run.ts +118 -0
  177. package/src/push-sender/session.ts +54 -0
  178. package/src/visitors.ts +94 -6
  179. package/src/web-app.ts +674 -0
  180. package/src/web-plugin.ts +293 -26
  181. package/dist/chunk-action-menu-7v5fce2v.js +0 -1
  182. package/dist/chunk-agent-2rw4ydw2.js +0 -1
  183. package/dist/chunk-agent-sheet-2q24dkkf.js +0 -3
  184. package/dist/chunk-character-creator-8q6ympxj.js +0 -1
  185. package/dist/chunk-codeeditor-fgmq298t.js +0 -2
  186. package/dist/chunk-connectors-mr53r7fh.js +0 -1
  187. package/dist/chunk-conversations-4vy2z6w9.js +0 -1
  188. package/dist/chunk-entry-jg1eem82.js +0 -1
  189. package/dist/chunk-group-scene-na95b1te.js +0 -1
  190. package/dist/chunk-index-11rjg8a8.js +0 -1
  191. package/dist/chunk-index-1y50d6sj.js +0 -2
  192. package/dist/chunk-index-5y2r0eaj.js +0 -47
  193. package/dist/chunk-index-7fphxxzd.js +0 -14
  194. package/dist/chunk-index-80f1zkp6.css +0 -1
  195. package/dist/chunk-index-87h45frg.js +0 -2
  196. package/dist/chunk-index-bnwmssyn.js +0 -2
  197. package/dist/chunk-index-ckgbszwt.js +0 -51
  198. package/dist/chunk-index-fmjjekhd.js +0 -2
  199. package/dist/chunk-index-t1m65yn6.js +0 -13
  200. package/dist/chunk-index-t711t17m.js +0 -2
  201. package/dist/chunk-index-txv7qvyy.js +0 -2
  202. package/dist/chunk-index-wr3dg24d.js +0 -2
  203. package/dist/chunk-index-yx683d5n.js +0 -2
  204. package/dist/chunk-lang-css-fkdg3x8x.js +0 -1
  205. package/dist/chunk-lang-html-v8h9g977.js +0 -1
  206. package/dist/chunk-lang-javascript-ykb2vhaa.js +0 -1
  207. package/dist/chunk-lang-json-2rze3rpy.js +0 -1
  208. package/dist/chunk-lang-markdown-6w2htnfc.js +0 -6
  209. package/dist/chunk-lang-rust-24fggcqx.js +0 -1
  210. package/dist/chunk-lang-sql-gjywb9ke.js +0 -2
  211. package/dist/chunk-lang-typescript-qh6rebyb.js +0 -1
  212. package/dist/chunk-menu-items-zjpyj51b.js +0 -2
  213. package/dist/chunk-notes-sjba7kw3.js +0 -1
  214. package/dist/chunk-overview-26r77kb6.js +0 -1
  215. package/dist/chunk-room-confirms-851184ep.js +0 -1
  216. package/dist/chunk-room-menu-8y9c026y.js +0 -1
  217. package/dist/chunk-skills-56s4hdbn.js +0 -1
  218. package/dist/chunk-table-qykpvp32.js +0 -1
  219. package/dist/chunk-table2d-k60ressj.js +0 -1
  220. package/dist/chunk-table3d-bv2ajk39.js +0 -4220
  221. package/dist/chunk-transcript-vekz1mh6.js +0 -1
  222. package/dist/chunk-workspace-hevefd0d.js +0 -1
  223. package/dist/chunk-zh-tw-5r2wjre0.js +0 -1
@@ -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
+ }
package/src/push/api.ts CHANGED
@@ -1,14 +1,18 @@
1
1
  import { createHash } from "node:crypto";
2
- import type { IdentityService } from "pi-roundtable";
2
+ import type { Tier } from "pi-roundtable";
3
3
  import type {
4
4
  PushDevicesView,
5
5
  PushDeviceView,
6
6
  PushKeyView,
7
+ PushPrefsView,
8
+ PushTestView,
7
9
  } from "../api-types.ts";
8
10
  import type { Authenticated } from "../auth/authenticator.ts";
9
11
  import { ConsoleHttpError as HttpError, json } from "../http.ts";
10
12
  import { readLimited } from "../request-body.ts";
11
13
  import type { EndpointPolicy } from "./endpoint.ts";
14
+ import type { ReceiptEvent } from "./outbox.ts";
15
+ import { checkPrefs, PrefsRefused, type PushPrefs } from "./prefs.ts";
12
16
  import type { PushSubscriptions } from "./subscriptions.ts";
13
17
  import type { Vapid } from "./vapid.ts";
14
18
 
@@ -21,15 +25,6 @@ const MAX_LABEL_CHARS = 80;
21
25
  const P256DH_BYTES = 65;
22
26
  const AUTH_BYTES = 16;
23
27
 
24
- export interface PushApiOptions {
25
- vapid: Pick<Vapid, "publicKey">;
26
- policy: EndpointPolicy;
27
- subscriptions: Pick<PushSubscriptions, "of" | "save" | "remove">;
28
- identity: Pick<IdentityService, "tierOf">;
29
- /** False when the server has no VAPID contact: devices cannot be added, since nothing would reach them. */
30
- enabled: boolean;
31
- }
32
-
33
28
  /** What identifies a device in a list without handing its endpoint around: the endpoint's SHA-256, hex. */
34
29
  const deviceId = (endpoint: string): string =>
35
30
  createHash("sha256").update(endpoint).digest("hex");
@@ -57,56 +52,41 @@ async function bodyOf(request: Request): Promise<Record<string, unknown>> {
57
52
  return value as Record<string, unknown>;
58
53
  }
59
54
 
55
+ export interface DeviceRoutesOptions {
56
+ policy: EndpointPolicy;
57
+ subscriptions: Pick<PushSubscriptions, "of" | "save" | "remove">;
58
+ /** False when the server has no VAPID contact: devices cannot be added, since nothing would reach them. */
59
+ enabled: boolean;
60
+ }
61
+
60
62
  /**
61
- * The push routes under `<mount>/api/push/`: the key a browser subscribes against, and the
62
- * signed-in owner's own devices: list, add, remove. A device is only ever read or removed by the
63
- * person who added it, and an endpoint is accepted only on a push service the policy allows.
63
+ * A person's own devices: list, add, remove. A device is only ever read or removed by the person
64
+ * who added it, and an endpoint is accepted only on a push service the policy allows. The app
65
+ * serves these under `api/push/subscriptions`, and an external sender under its own path.
64
66
  */
65
- export class PushApi {
66
- readonly #options: PushApiOptions;
67
+ export class DeviceRoutes {
68
+ readonly #options: DeviceRoutesOptions;
67
69
 
68
- constructor(options: PushApiOptions) {
70
+ constructor(options: DeviceRoutesOptions) {
69
71
  this.#options = options;
70
72
  }
71
73
 
72
- /** Answers `api/push/...`; undefined for any other path. */
74
+ /**
75
+ * Answers a request about the person's devices: `id` is the device in the path, if any. Throws
76
+ * `ConsoleHttpError`, which the caller turns into a response.
77
+ */
73
78
  async handle(
74
79
  request: Request,
75
- parts: readonly string[],
76
- admission: Authenticated,
77
- ): Promise<Response | undefined> {
78
- const [head, area, name, id] = parts;
79
- if (head !== "api" || area !== "push") return undefined;
80
- try {
81
- const principalId = await this.#owner(admission);
82
- const { method } = request;
83
- if (name === "key" && !id && method === "GET")
84
- return json({
85
- key: this.#options.vapid.publicKey,
86
- } satisfies PushKeyView);
87
- if (name !== "subscriptions")
88
- throw new HttpError(404, "There is no such API.");
89
- if (!id && method === "GET")
90
- return json(await this.#devices(principalId));
91
- if (!id && method === "POST")
92
- return await this.#add(request, principalId);
93
- if (!id && method === "DELETE")
94
- return await this.#removeEndpoint(request, principalId);
95
- if (id && method === "DELETE")
96
- return await this.#removeId(id, principalId);
97
- throw new HttpError(405, "Method not allowed.");
98
- } catch (error) {
99
- if (error instanceof HttpError)
100
- return json({ error: error.message }, error.status);
101
- throw error;
102
- }
103
- }
104
-
105
- async #owner(admission: Authenticated): Promise<string> {
106
- const { principal } = admission.visitor;
107
- if ((await this.#options.identity.tierOf(principal.id)) !== "owner")
108
- throw new HttpError(403, "Only an owner may do this.");
109
- return principal.id;
80
+ id: string | undefined,
81
+ principalId: string,
82
+ ): Promise<Response> {
83
+ const { method } = request;
84
+ if (!id && method === "GET") return json(await this.#devices(principalId));
85
+ if (!id && method === "POST") return await this.#add(request, principalId);
86
+ if (!id && method === "DELETE")
87
+ return await this.#removeEndpoint(request, principalId);
88
+ if (id && method === "DELETE") return await this.#removeId(id, principalId);
89
+ throw new HttpError(405, "Method not allowed.");
110
90
  }
111
91
 
112
92
  async #devices(principalId: string): Promise<PushDevicesView> {
@@ -188,3 +168,160 @@ export class PushApi {
188
168
  return json({ ok: true });
189
169
  }
190
170
  }
171
+
172
+ export interface PushApiOptions {
173
+ /** The key browsers subscribe against; an external sender's is the one the operator configured. */
174
+ vapid: Pick<Vapid, "publicKey">;
175
+ devices: DeviceRoutes;
176
+ /** The tiers whose people may use push. */
177
+ tiers: readonly Tier[];
178
+ /** False when the sender serves the subscription routes itself: here they answer 404. */
179
+ subscriptions: boolean;
180
+ /** Queues a test push to the person's devices; returns its tag. Undefined when push is not set up. */
181
+ test?(principalId: string): Promise<PushTestView | "no-device">;
182
+ /** Records a receipt the service worker posted; false when the tag is not the person's. */
183
+ receipt(
184
+ principalId: string,
185
+ tag: string,
186
+ event: ReceiptEvent,
187
+ ): Promise<boolean>;
188
+ prefs: {
189
+ get(principalId: string): Promise<PushPrefs | undefined>;
190
+ set(principalId: string, prefs: PushPrefs | undefined): Promise<void>;
191
+ };
192
+ }
193
+
194
+ const EVENTS: readonly string[] = ["received", "opened"];
195
+
196
+ /**
197
+ * The push routes under `<mount>/api/push/`: the key a browser subscribes against, the person's
198
+ * devices, a test push, the receipts the service worker posts back, and their quiet hours. All of
199
+ * it is the signed-in person's own: the principal is the session's, never one a request names.
200
+ */
201
+ export class PushApi {
202
+ readonly #options: PushApiOptions;
203
+
204
+ constructor(options: PushApiOptions) {
205
+ this.#options = options;
206
+ }
207
+
208
+ /** Answers `api/push/...`; undefined for any other path. */
209
+ async handle(
210
+ request: Request,
211
+ parts: readonly string[],
212
+ admission: Authenticated,
213
+ ): Promise<Response | undefined> {
214
+ const [head, area, name, id] = parts;
215
+ if (head !== "api" || area !== "push") return undefined;
216
+ try {
217
+ const { principal, tier } = admission.visitor;
218
+ const { method } = request;
219
+ // A receipt is the person's own whatever their tier: the notice or the test it names must be theirs.
220
+ if (name === "receipts" && !id && method === "POST")
221
+ return await this.#receipt(request, principal.id);
222
+ this.#allowed(tier);
223
+ if (name === "key" && !id && method === "GET")
224
+ return json({
225
+ key: this.#options.vapid.publicKey,
226
+ } satisfies PushKeyView);
227
+ if (name === "subscriptions" && this.#options.subscriptions)
228
+ return await this.#options.devices.handle(request, id, principal.id);
229
+ if (name === "test" && !id && method === "POST")
230
+ return await this.#test(principal.id);
231
+ if (name === "prefs" && !id)
232
+ return await this.#prefs(request, principal.id);
233
+ throw new HttpError(404, "There is no such API.");
234
+ } catch (error) {
235
+ if (error instanceof HttpError)
236
+ return json({ error: error.message }, error.status);
237
+ throw error;
238
+ }
239
+ }
240
+
241
+ /** The person's tier may use push; the tier is the one this request was admitted at. */
242
+ #allowed(tier: Tier): void {
243
+ if (this.#options.tiers.includes(tier)) return;
244
+ if (this.#options.tiers.length === 1 && this.#options.tiers[0] === "owner")
245
+ throw new HttpError(403, "Only an owner may do this.");
246
+ throw new HttpError(403, "Push is not available to you.");
247
+ }
248
+
249
+ async #test(principalId: string): Promise<Response> {
250
+ if (!this.#options.test)
251
+ throw new HttpError(
252
+ 503,
253
+ "Push is not set up: set push.subject on the server.",
254
+ );
255
+ const made = await this.#options.test(principalId);
256
+ if (made === "no-device")
257
+ throw new HttpError(
258
+ 409,
259
+ "This account has no device that gets push yet. Turn it on first.",
260
+ );
261
+ return json(made);
262
+ }
263
+
264
+ async #receipt(request: Request, principalId: string): Promise<Response> {
265
+ const body = await bodyOf(request);
266
+ const { tag, event } = body;
267
+ if (
268
+ typeof tag !== "string" ||
269
+ tag.length === 0 ||
270
+ tag.length > 200 ||
271
+ typeof event !== "string" ||
272
+ !EVENTS.includes(event)
273
+ )
274
+ throw new HttpError(400, "A receipt needs a tag and received or opened.");
275
+ const taken = await this.#options.receipt(
276
+ principalId,
277
+ tag,
278
+ event as ReceiptEvent,
279
+ );
280
+ if (!taken) throw new HttpError(404, "There is no such notification.");
281
+ return json({ ok: true });
282
+ }
283
+
284
+ async #prefs(request: Request, principalId: string): Promise<Response> {
285
+ const { prefs } = this.#options;
286
+ if (request.method === "GET") return json(await this.#view(principalId));
287
+ if (request.method !== "PUT")
288
+ throw new HttpError(405, "Method not allowed.");
289
+ const body = await bodyOf(request);
290
+ if (!("quiet" in body))
291
+ throw new HttpError(400, "Send quiet: {start, end, timeZone} or null.");
292
+ if (body.quiet === null) await prefs.set(principalId, undefined);
293
+ else {
294
+ const quiet = body.quiet as Partial<
295
+ Record<"start" | "end" | "timeZone", unknown>
296
+ > | null;
297
+ try {
298
+ await prefs.set(
299
+ principalId,
300
+ checkPrefs({
301
+ quietStart: quiet?.start,
302
+ quietEnd: quiet?.end,
303
+ timeZone: quiet?.timeZone,
304
+ }),
305
+ );
306
+ } catch (error) {
307
+ if (error instanceof PrefsRefused)
308
+ throw new HttpError(400, error.message);
309
+ throw error;
310
+ }
311
+ }
312
+ return json(await this.#view(principalId));
313
+ }
314
+
315
+ async #view(principalId: string): Promise<PushPrefsView> {
316
+ const prefs = await this.#options.prefs.get(principalId);
317
+ return prefs
318
+ ? {
319
+ quiet: {
320
+ start: prefs.quietStart,
321
+ end: prefs.quietEnd,
322
+ timeZone: prefs.timeZone,
323
+ },
324
+ }
325
+ : {};
326
+ }
327
+ }