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
@@ -1,4 +1,5 @@
1
1
  import type { IdentityService, Logger } from "pi-roundtable";
2
+ import type { NewOutboxRow, PushOutbox } from "./outbox.ts";
2
3
  import type { PushPayload, PushSender, SendResult } from "./sender.ts";
3
4
  import type { PushSubscriptions, Subscription } from "./subscriptions.ts";
4
5
 
@@ -7,12 +8,26 @@ export const PREVIEW_CHARS = 120;
7
8
  /** Replies in one room closer together than this share a notification. */
8
9
  const REPLY_QUIET_MS = 15_000;
9
10
 
11
+ /**
12
+ * Where the push goes when the app does not sign it: the outbox an external sender empties. The
13
+ * app then holds no VAPID key and a row carries a title and a preview only with `content: "full"`.
14
+ */
15
+ export interface OutboxRoute {
16
+ outbox: Pick<PushOutbox, "enqueue">;
17
+ content: "full" | "none";
18
+ /** The count the app icon shows for the person: their notices not handled. */
19
+ badge(principalId: string): Promise<number>;
20
+ }
21
+
10
22
  export interface PushServiceOptions {
11
23
  subscriptions: Pick<
12
24
  PushSubscriptions,
13
25
  "all" | "delivered" | "forget" | "failed"
14
26
  >;
15
- sender: Pick<PushSender, "send">;
27
+ /** Signs and sends in this process; absent when `route` queues the pushes for an external sender. */
28
+ sender?: Pick<PushSender, "send">;
29
+ /** Queue the pushes instead of signing them here; absent = sign and send in this process. */
30
+ route?: OutboxRoute;
16
31
  identity: Pick<IdentityService, "tierOf">;
17
32
  /** Whether a page of this person is in view now; their devices are not pushed while one is. */
18
33
  visible(principalId: string): boolean;
@@ -33,6 +48,8 @@ export interface PushServiceOptions {
33
48
  * card is. Default never.
34
49
  */
35
50
  muted?: (room: string) => Promise<boolean>;
51
+ /** Whether a room is a host's: those reach phones only through notices, never through a card or reply push. */
52
+ hostRoom?: (room: string) => boolean;
36
53
  }
37
54
 
38
55
  /** What a trigger says; the service shapes it into a notification. */
@@ -72,6 +89,7 @@ export class PushService {
72
89
 
73
90
  /** A card opened in a room: an approval or a question waits for an owner. */
74
91
  card(room: string, title: string, longRunningCap: boolean): Promise<void> {
92
+ if (this.#options.hostRoom?.(room)) return Promise.resolve();
75
93
  return this.#push({
76
94
  kind: longRunningCap ? "cap" : "card",
77
95
  title: longRunningCap ? "Long-running cap reached" : "Card waiting",
@@ -83,6 +101,7 @@ export class PushService {
83
101
 
84
102
  /** An agent posted in a room, such as a finished job's report or an answer nobody was watching for. */
85
103
  async reply(room: string, author: string, text: string): Promise<void> {
104
+ if (this.#options.hostRoom?.(room)) return;
86
105
  const now = this.#now();
87
106
  if (this.#options.quiet?.(now)) return;
88
107
  if (await this.#muted(room)) return;
@@ -142,12 +161,15 @@ export class PushService {
142
161
  ...(preview && event.text ? { body: previewOf(event.text) } : {}),
143
162
  };
144
163
  const targets = await this.#targets(event.principalId);
164
+ if (this.#options.route) {
165
+ await this.#queue(this.#options.route, event, payload, targets);
166
+ return;
167
+ }
168
+ const { sender } = this.#options;
169
+ if (!sender) return;
145
170
  await Promise.all(
146
171
  targets.map(async (target) =>
147
- this.#settle(
148
- target,
149
- await this.#options.sender.send(target, payload),
150
- ),
172
+ this.#settle(target, await sender.send(target, payload)),
151
173
  ),
152
174
  );
153
175
  } catch (error) {
@@ -155,6 +177,35 @@ export class PushService {
155
177
  }
156
178
  }
157
179
 
180
+ /** One outbox row for each person the push reaches; the sender finds their devices. */
181
+ async #queue(
182
+ route: OutboxRoute,
183
+ event: Event,
184
+ payload: PushPayload,
185
+ targets: readonly Subscription[],
186
+ ): Promise<void> {
187
+ const people = [...new Set(targets.map((target) => target.principalId))];
188
+ await Promise.all(
189
+ people.map(async (principalId) => {
190
+ const row: NewOutboxRow = {
191
+ kind: payload.kind === "cap" ? "card" : payload.kind,
192
+ tag: payload.tag,
193
+ principalId,
194
+ open: payload.url ?? "",
195
+ urgency: payload.kind === "reply" ? "normal" : "high",
196
+ badge: await route.badge(principalId),
197
+ ...(route.content === "full"
198
+ ? {
199
+ title: event.title,
200
+ ...(payload.body ? { body: payload.body } : {}),
201
+ }
202
+ : {}),
203
+ };
204
+ await route.outbox.enqueue(row);
205
+ }),
206
+ );
207
+ }
208
+
158
209
  async #targets(principalId?: string): Promise<Subscription[]> {
159
210
  const owners = new Map<string, boolean>();
160
211
  const isOwner = async (id: string): Promise<boolean> => {
package/src/push/vapid.ts CHANGED
@@ -71,6 +71,25 @@ async function importStored(stored: Stored, path: string): Promise<Vapid> {
71
71
  return { publicKey: stored.publicKey, privateKey };
72
72
  }
73
73
 
74
+ /**
75
+ * The VAPID keys in `path`, which must exist: for a sender process that holds the key the app
76
+ * never sees. A missing or unreadable file stops it, since a new key would not match the one the
77
+ * devices subscribed against.
78
+ */
79
+ export async function readVapid(path: string): Promise<Vapid> {
80
+ let raw: string;
81
+ try {
82
+ raw = readFileSync(path, "utf8");
83
+ } catch (error) {
84
+ if ((error as NodeJS.ErrnoException).code === "ENOENT")
85
+ throw new Error(
86
+ `${path} does not exist; the sender needs the VAPID key file the devices subscribed against`,
87
+ );
88
+ throw error;
89
+ }
90
+ return importStored(parse(raw, path), path);
91
+ }
92
+
74
93
  /**
75
94
  * The VAPID keys in `path`, made on first use with WebCrypto and kept readable by this user
76
95
  * alone. Subscriptions are bound to the public key, so the file must outlive a restart; a file
@@ -0,0 +1,106 @@
1
+ import type { SQL } from "bun";
2
+ import type { Logger } from "pi-roundtable";
3
+ import type { PushTransport } from "./drain.ts";
4
+ import { EndpointPolicy } from "./endpoint.ts";
5
+ import { type PushPayload, PushSender } from "./sender.ts";
6
+ import { PushSubscriptions } from "./subscriptions.ts";
7
+ import { readVapid, type Vapid } from "./vapid.ts";
8
+
9
+ /** The name of the Web Push transport, which a notice's `via` may name. */
10
+ export const WEB_PUSH = "web-push";
11
+
12
+ export interface WebPushTransportOptions {
13
+ /** The key the devices subscribed against. Read when the first push is sent; it must exist. */
14
+ vapidFile?: string;
15
+ /** The key itself, when the caller already holds it (the app's own sender). */
16
+ vapid?: Vapid;
17
+ /** The VAPID `sub`: a `mailto:` or `https:` contact for the push services. */
18
+ subject: string;
19
+ /** Push services besides the browsers' own, as `push.endpointHosts` writes them. */
20
+ endpointHosts?: readonly string[];
21
+ database: SQL;
22
+ /** The HTTP client; default `fetch`. */
23
+ fetch?: typeof fetch;
24
+ logger?: Logger;
25
+ }
26
+
27
+ /** A logger that says nothing, for a sender run without one. */
28
+ const silent: Logger = {
29
+ debug: () => undefined,
30
+ info: () => undefined,
31
+ warn: () => undefined,
32
+ error: () => undefined,
33
+ fatal: () => undefined,
34
+ child: () => silent,
35
+ };
36
+
37
+ /** What a push says on a device, from the row and the words the sender gave it. */
38
+ export function payloadOf(
39
+ row: Parameters<PushTransport["send"]>[0],
40
+ rendered: Parameters<PushTransport["send"]>[1],
41
+ ): PushPayload {
42
+ return {
43
+ kind: row.kind,
44
+ title: rendered.title,
45
+ ...(rendered.body ? { body: rendered.body } : {}),
46
+ tag: row.tag,
47
+ url: row.open,
48
+ badge: row.badge,
49
+ urgency: row.urgency,
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Web Push to all the devices a person subscribed. It reads their subscriptions, posts a sealed
55
+ * payload to each under the VAPID key, forgets a device the push service says is gone, and counts
56
+ * a failure against one that keeps failing. `sent` when at least one device took it; `gone` when
57
+ * every device was gone; `skipped` when the person has no device.
58
+ */
59
+ export function webPushTransport(
60
+ options: WebPushTransportOptions,
61
+ ): PushTransport {
62
+ const subscriptions = new PushSubscriptions(options.database);
63
+ const policy = new EndpointPolicy(options.endpointHosts);
64
+ const logger = options.logger ?? silent;
65
+ let sender: Promise<PushSender> | undefined;
66
+ const senderOf = (): Promise<PushSender> => {
67
+ sender ??= (async () => {
68
+ const vapid =
69
+ options.vapid ??
70
+ (options.vapidFile ? await readVapid(options.vapidFile) : undefined);
71
+ if (!vapid) throw new Error("webPushTransport needs vapidFile or vapid");
72
+ return new PushSender({
73
+ vapid,
74
+ subject: options.subject,
75
+ policy,
76
+ ...(options.fetch ? { fetch: options.fetch } : {}),
77
+ logger,
78
+ });
79
+ })();
80
+ return sender;
81
+ };
82
+ return {
83
+ name: WEB_PUSH,
84
+ async send(row, rendered, signal) {
85
+ const devices = await subscriptions.of(row.principalId);
86
+ if (devices.length === 0) return "skipped";
87
+ const push = await senderOf();
88
+ const payload = payloadOf(row, rendered);
89
+ let sent = 0;
90
+ let gone = 0;
91
+ for (const device of devices) {
92
+ if (signal.aborted) break;
93
+ const result = await push.send(device, payload);
94
+ if (result === "sent") {
95
+ sent += 1;
96
+ await subscriptions.delivered(device.endpoint);
97
+ } else if (result === "gone") {
98
+ gone += 1;
99
+ await subscriptions.forget(device.endpoint);
100
+ } else await subscriptions.failed(device.endpoint);
101
+ }
102
+ if (sent > 0) return "sent";
103
+ return gone === devices.length ? "gone" : "failed";
104
+ },
105
+ };
106
+ }
@@ -0,0 +1,14 @@
1
+ // The entry `pi-roundtable-web/push-sender`: the process that signs and sends the web app's pushes
2
+ // when the app holds no VAPID key (`push.sender: { external }`). The app queues each push as a row
3
+ // in `web_push_outbox`; this empties it through transports (Web Push here, a host's own beside it).
4
+
5
+ export type { PushTransport } from "../push/drain.ts";
6
+ export type { DeliveryResult, OutboxKind, OutboxRow } from "../push/outbox.ts";
7
+ export { WEB_PUSH, webPushTransport } from "../push/web-push-transport.ts";
8
+ export type { PushSenderOptions } from "./run.ts";
9
+ export { runPushSender } from "./run.ts";
10
+ export {
11
+ SESSION_COOKIE,
12
+ webSessionClaims,
13
+ webSessionPrincipal,
14
+ } from "./session.ts";
@@ -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/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
+ }