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
package/src/options.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  import { join, resolve } from "node:path";
2
- import type { ChannelKey, RoundtablePlugin } from "pi-roundtable";
3
- import { PluginError } from "pi-roundtable";
4
- import { PANES, type PaneName } from "./api-types.ts";
2
+ import type {
3
+ ChannelKey,
4
+ ConversationRecord,
5
+ RoundtablePlugin,
6
+ Tier,
7
+ } from "pi-roundtable";
8
+ import { PluginError, TIERS } from "pi-roundtable";
9
+ import { OWNER_ONLY_PANES, PANES, type PaneName } from "./api-types.ts";
5
10
  import {
6
11
  DEFAULT_UPLOAD_MAX_BYTES,
7
12
  UPLOAD_FRAMING_BYTES,
@@ -70,7 +75,48 @@ export type WebAuthOptions =
70
75
  /** A request verifier, such as `cloudflareAccess(...)`; the same as the `verifier` option. */
71
76
  mode: "verifier";
72
77
  verifier: RequestVerifier;
73
- };
78
+ }
79
+ | OidcOptions;
80
+
81
+ /**
82
+ * Sign-in with an OpenID Connect provider (authorization code with PKCE). The id_token is checked
83
+ * once, at the callback; the app then keeps its own server-side session, which slides: it ends
84
+ * after `sessionDays` without a visit. Who the person is, and the tier they hold, come from
85
+ * `IDENTITY` for every request. The origin must be https.
86
+ */
87
+ export interface OidcOptions {
88
+ mode: "oidc";
89
+ /** The exact `iss` of the id_token. */
90
+ issuer: string;
91
+ /** Default `${issuer}/.well-known/openid-configuration`; read at start, which fails if it cannot be. */
92
+ discoveryUrl?: string;
93
+ clientId: string;
94
+ /** The environment variable that holds the client secret; set = a confidential client, left out = a public client with PKCE. */
95
+ clientSecretEnv?: string;
96
+ /** Default `openid`, `profile`. `offline_access` is refused: the app keeps no refresh token. */
97
+ scopes?: readonly string[];
98
+ /** The claim that names the person; default `sub` (Entra: `oid`). */
99
+ subjectClaim?: string;
100
+ /** The claim for their name; default `name`, then `preferred_username`, then the subject. */
101
+ nameClaim?: string;
102
+ /** The claim that carries app roles, each becoming `<surface>:role:<name>`; default `roles`; `false` reads none. */
103
+ rolesClaim?: string | false;
104
+ /** Decides at sign-in whether this token is acceptable at all. */
105
+ check?: (claims: Readonly<Record<string, unknown>>) => boolean;
106
+ /** How many days without a visit end a session, 1 to 365; default 30. */
107
+ sessionDays?: number;
108
+ /** Whether logging out also signs out at the provider (without `id_token_hint`); default false. */
109
+ endSession?: boolean;
110
+ /**
111
+ * Asks the provider again after this many hours: the next page load goes through a silent
112
+ * `prompt=none` round trip, which refreshes the stored facts and roles; when the provider wants
113
+ * the person, they sign in as usual. Until then an API or socket request of that session is
114
+ * answered 401. Default: never.
115
+ */
116
+ reauthHours?: number;
117
+ /** The id_token claims to keep with the session (replaced at each sign-in and re-verify, never logged); default none. */
118
+ storeClaims?: readonly string[];
119
+ }
74
120
 
75
121
  /** `auth` checked and filled in. */
76
122
  export type ResolvedAuth =
@@ -81,7 +127,33 @@ export type ResolvedAuth =
81
127
  sessionDays: number;
82
128
  principal?: string;
83
129
  }
84
- | { mode: "verifier"; verifier: RequestVerifier };
130
+ | { mode: "verifier"; verifier: RequestVerifier }
131
+ | ResolvedOidc;
132
+
133
+ export interface ResolvedOidc {
134
+ mode: "oidc";
135
+ issuer: string;
136
+ discoveryUrl: string;
137
+ clientId: string;
138
+ clientSecretEnv: string | undefined;
139
+ scopes: readonly string[];
140
+ subjectClaim: string;
141
+ nameClaim: string | undefined;
142
+ rolesClaim: string | false;
143
+ check: ((claims: Readonly<Record<string, unknown>>) => boolean) | undefined;
144
+ sessionDays: number;
145
+ endSession: boolean;
146
+ reauthHours: number | undefined;
147
+ storeClaims: readonly string[];
148
+ }
149
+
150
+ /** The panes each tier is served, when the list differs by tier. */
151
+ export interface TierPanes {
152
+ owner: readonly PaneName[];
153
+ /** Default: the member's list. */
154
+ admin?: readonly PaneName[];
155
+ member: readonly PaneName[];
156
+ }
85
157
 
86
158
  export interface WebConsoleOptions {
87
159
  /**
@@ -123,6 +195,14 @@ export interface WebConsoleOptions {
123
195
  listener?: string;
124
196
  /** Hash routes by default; path routes retain links such as /console/skills. */
125
197
  routing?: "hash" | "path";
198
+ /**
199
+ * Who the app admits: `owners` (default, as before 0.26) lets in only people who hold the owner
200
+ * role; `access` lets in everyone `IDENTITY` admits on the app's surface, owners, admins and
201
+ * members, and provisions a new principal at a first contact where `access.provisioning` says so
202
+ * for the surface. `access` needs a credential that names a person (`oidc`, or a verifier that
203
+ * reports an actor), not `none` or `password`.
204
+ */
205
+ admission?: "owners" | "access";
126
206
  /**
127
207
  * The panes to serve, in the order the page lists them; default `rooms`, `table`, `workspace`,
128
208
  * `overview`, `schedules`, `conversations`, `notes`. `rooms` is the owner app: the same agent
@@ -132,9 +212,14 @@ export interface WebConsoleOptions {
132
212
  * `workspace` out when there is no directory; an explicit list that names it must have one.
133
213
  * An explicit list is kept as written, so a host that listed the panes before 0.23 adds
134
214
  * `rooms`, `table` and `schedules` by hand, and one that listed them before 0.24 adds
135
- * `workspace`.
215
+ * `workspace`. With `admission: "access"` the default also has `profile`, and a member is
216
+ * served `rooms` (the host's rooms only), `conversations`, `notes`, `schedules` and `profile`
217
+ * of those the owners have; give `{ owner, admin?, member }` to choose per tier. A member is
218
+ * never served `overview`, `table`, `workspace`, `skills` or `connectors`, and naming one for
219
+ * them is an error. `notes` in the default list is dropped, with a warning, when the host keeps
220
+ * no memory; naming it with no memory is an error.
136
221
  */
137
- panes?: readonly PaneName[];
222
+ panes?: readonly PaneName[] | TierPanes;
138
223
  /**
139
224
  * What the `workspace` pane shows. The agents' working directory (`agents`) and their scratch
140
225
  * directory (`scratch`, when the host has one) are shown without being named, from
@@ -205,7 +290,45 @@ export interface WebConsoleOptions {
205
290
  * until this is set.
206
291
  */
207
292
  subject?: string;
293
+ /** The tiers whose people may subscribe and receive push; default `["owner"]`. */
294
+ tiers?: readonly Tier[];
295
+ /**
296
+ * Where a push is signed and sent: `internal` (default), by this process with its own VAPID
297
+ * key; or `external`, by a sender process the operator runs with the key, so this process
298
+ * never holds it. `publicKey` is the key browsers subscribe against; `subscribePath` is where
299
+ * the sender serves the subscription routes, when it does.
300
+ */
301
+ sender?:
302
+ | "internal"
303
+ | { external: { publicKey: string; subscribePath?: string } };
304
+ /**
305
+ * What leaves the app for a push: `full` (a title and a preview) or `none` (the kind, a tag and
306
+ * a count only). Default `full` for an internal sender and `none` for an external one. A host
307
+ * room never previews.
308
+ */
309
+ content?: "full" | "none";
310
+ /**
311
+ * The names of the transports besides `web-push` that the sender carries, such as `slack`. A
312
+ * notice's `via` may name only these and `web-push`; naming any other is a ConfigError when
313
+ * the notice is raised. The app never loads a transport: the sender process does.
314
+ */
315
+ transports?: readonly string[];
316
+ };
317
+ /**
318
+ * What a person owes before the app opens. `links` names providers whose accounts they must
319
+ * link (`required` gates the app, `nudge` only asks); each needs a `LinkProver` registered
320
+ * through `WEB_APP`. `push` asks them to turn push on and receive a test; `pushWaiver` lets
321
+ * them waive a required push step.
322
+ */
323
+ onboarding?: {
324
+ links?: Readonly<Record<string, "required" | "nudge">>;
325
+ push?: "required" | "nudge" | "off";
326
+ pushWaiver?: boolean;
208
327
  };
328
+ /** Leave out the yolo and long-running switches: no mode is shown or set, and `PUT api/rooms/<key>/mode` answers 404. */
329
+ modes?: false;
330
+ /** The files of the app's agent rooms; `keepDays` is how long stored files are kept, default 30. */
331
+ uploads?: { keepDays?: number };
209
332
  /** The page's title and heading; default `Roundtable`. */
210
333
  title?: string;
211
334
  /** Where the install icons come from; default the ones this package ships. */
@@ -214,8 +337,11 @@ export interface WebConsoleOptions {
214
337
  appearance?: { look?: "modern" | "camelot" };
215
338
  /** Text a relayed message begins with, taken off the owner's words; default the remote MCP note. */
216
339
  relayNotes?: readonly string[];
217
- /** Conversations the console neither lists nor reads. */
218
- exclude?: (key: ChannelKey) => boolean;
340
+ /**
341
+ * Conversations the console neither lists nor reads. `record` is what the host's registry
342
+ * holds of the conversation, if anything, so a host can decide by its kind or owner.
343
+ */
344
+ exclude?: (key: ChannelKey, record?: ConversationRecord) => boolean;
219
345
  /** The built page's directory; default the `dist/` this package ships. Set it only in tests. */
220
346
  assetDir?: string;
221
347
  }
@@ -230,7 +356,11 @@ export interface ResolvedOptions {
230
356
  mount: string;
231
357
  listener: string;
232
358
  routing: "hash" | "path";
359
+ admission: "owners" | "access";
360
+ /** The owners' panes: what `panes` named, or the default. */
233
361
  panes: PaneName[];
362
+ /** The panes each tier is served. Admins and members are served none unless `admission` is `access`. */
363
+ tierPanes: Readonly<Record<Tier, readonly PaneName[]>>;
234
364
  /** Whether `panes` was written out, which makes a missing directory for the workspace pane an error. */
235
365
  panesGiven: boolean;
236
366
  /** The `workspace` option checked and filled in. */
@@ -250,12 +380,29 @@ export interface ResolvedOptions {
250
380
  /** The VAPID contact; undefined = push is off. */
251
381
  subject: string | undefined;
252
382
  fetch: typeof fetch | undefined;
383
+ tiers: readonly Tier[];
384
+ sender:
385
+ | "internal"
386
+ | { external: { publicKey: string; subscribePath?: string } };
387
+ content: "full" | "none";
388
+ /** `web-push` and the host's own transports. */
389
+ transports: readonly string[];
253
390
  };
391
+ onboarding: {
392
+ links: Readonly<Record<string, "required" | "nudge">>;
393
+ push: "required" | "nudge" | "off";
394
+ pushWaiver: boolean;
395
+ };
396
+ /** Whether the yolo and long-running switches are served. */
397
+ modes: boolean;
398
+ uploadKeepDays: number;
254
399
  title: string;
255
400
  iconsDir: string | undefined;
256
401
  look: "modern" | "camelot";
257
402
  relayNotes: readonly string[];
258
- exclude: ((key: ChannelKey) => boolean) | undefined;
403
+ exclude:
404
+ | ((key: ChannelKey, record?: ConversationRecord) => boolean)
405
+ | undefined;
259
406
  assetDir: string | undefined;
260
407
  features: WebConsoleOptions["features"];
261
408
  presentation: ConsolePresentation | undefined;
@@ -302,31 +449,105 @@ function checkOrigin(origin: string): string {
302
449
  return url.origin;
303
450
  }
304
451
 
305
- function checkPanes(panes: readonly PaneName[] | undefined): PaneName[] {
306
- if (panes === undefined)
307
- return [
308
- "rooms",
309
- "table",
310
- "workspace",
311
- "overview",
312
- "schedules",
313
- "conversations",
314
- "notes",
315
- ];
316
- if (panes.length === 0) fail("panes is empty; serve at least one pane");
452
+ const DEFAULT_OWNER_PANES: readonly PaneName[] = [
453
+ "rooms",
454
+ "table",
455
+ "workspace",
456
+ "overview",
457
+ "schedules",
458
+ "conversations",
459
+ "notes",
460
+ ];
461
+
462
+ /** The panes a member is served by default, of those the owners have. */
463
+ const DEFAULT_MEMBER_PANES: readonly PaneName[] = [
464
+ "rooms",
465
+ "conversations",
466
+ "notes",
467
+ "schedules",
468
+ "profile",
469
+ ];
470
+
471
+ function checkPaneList(panes: readonly PaneName[], label: string): PaneName[] {
472
+ if (!Array.isArray(panes)) fail(`${label} must be a list of panes`);
473
+ if (panes.length === 0) fail(`${label} is empty; serve at least one pane`);
317
474
  for (const pane of panes)
318
475
  if (!PANES.includes(pane))
319
476
  fail(
320
- `unknown pane ${JSON.stringify(pane)}; the panes are ${PANES.join(", ")}`,
477
+ `unknown pane ${JSON.stringify(pane)} in ${label}; the panes are ${PANES.join(", ")}`,
321
478
  );
322
- if (new Set(panes).size !== panes.length) fail("panes lists a pane twice");
323
- if (panes.includes("schedules") && !panes.includes("rooms"))
479
+ if (new Set(panes).size !== panes.length) fail(`${label} lists a pane twice`);
480
+ return [...panes];
481
+ }
482
+
483
+ /** The panes of the owners, as before: what the other panes need of them is checked here. */
484
+ function checkOwnerPanes(panes: readonly PaneName[]): PaneName[] {
485
+ const list = checkPaneList(panes, "panes");
486
+ if (list.includes("schedules") && !list.includes("rooms"))
324
487
  fail("the schedules pane needs the rooms pane, which serves its API");
325
- if (panes.includes("table") && !panes.includes("rooms"))
488
+ if (list.includes("table") && !list.includes("rooms"))
326
489
  fail("the table pane needs the rooms pane, whose agents it seats");
327
- if (panes.includes("workspace") && !panes.includes("rooms"))
490
+ if (list.includes("workspace") && !list.includes("rooms"))
328
491
  fail("the workspace pane needs the rooms pane, which serves its API");
329
- return [...panes];
492
+ return list;
493
+ }
494
+
495
+ interface CheckedPanes {
496
+ owner: PaneName[];
497
+ admin: PaneName[];
498
+ member: PaneName[];
499
+ given: boolean;
500
+ }
501
+
502
+ function checkPanes(
503
+ panes: WebConsoleOptions["panes"],
504
+ admission: "owners" | "access",
505
+ ): CheckedPanes {
506
+ const tiered =
507
+ panes !== undefined && !Array.isArray(panes)
508
+ ? (panes as TierPanes)
509
+ : undefined;
510
+ if (tiered && (typeof tiered !== "object" || tiered === null))
511
+ fail("panes must be a list, or { owner, admin?, member }");
512
+ const owner = checkOwnerPanes(
513
+ tiered
514
+ ? tiered.owner
515
+ : ((panes as readonly PaneName[] | undefined) ?? [
516
+ ...DEFAULT_OWNER_PANES,
517
+ ...(admission === "access" ? (["profile"] as const) : []),
518
+ ]),
519
+ );
520
+ if (!tiered || admission !== "access") {
521
+ if (tiered && admission === "owners")
522
+ fail(
523
+ 'panes names panes per tier, but only admission "access" admits anyone but owners',
524
+ );
525
+ const member =
526
+ admission === "access"
527
+ ? DEFAULT_MEMBER_PANES.filter((pane) => owner.includes(pane))
528
+ : [];
529
+ return { owner, admin: member, member, given: panes !== undefined };
530
+ }
531
+ const member = checkPaneList(tiered.member, "panes.member");
532
+ const admin = tiered.admin
533
+ ? checkPaneList(tiered.admin, "panes.admin")
534
+ : [...member];
535
+ for (const [label, list] of [
536
+ ["panes.member", member],
537
+ ["panes.admin", admin],
538
+ ] as const) {
539
+ for (const pane of list) {
540
+ if ((OWNER_ONLY_PANES as readonly string[]).includes(pane))
541
+ fail(
542
+ `${label} names the ${pane} pane, which only owners are served: it shows the whole host`,
543
+ );
544
+ if (!owner.includes(pane))
545
+ fail(
546
+ `${label} names the ${pane} pane, which the owners' panes do not have; the pane's API is served for the owners' list`,
547
+ );
548
+ }
549
+ }
550
+ return { owner, admin, member, given: true };
330
551
  }
331
552
 
332
553
  /**
@@ -443,13 +664,143 @@ function checkAuth(options: WebConsoleOptions): ResolvedAuth {
443
664
  );
444
665
  return { mode, verifier: given as RequestVerifier };
445
666
  }
667
+ case "oidc":
668
+ return checkOidc(auth as OidcOptions);
446
669
  default:
447
670
  fail(
448
- `auth.mode ${JSON.stringify(mode)} is unknown; the modes are none, password and verifier`,
671
+ `auth.mode ${JSON.stringify(mode)} is unknown; the modes are none, password, verifier and oidc`,
449
672
  );
450
673
  }
451
674
  }
452
675
 
676
+ const isHttpsUrl = (value: unknown): value is string =>
677
+ typeof value === "string" && URL.parse(value)?.protocol === "https:";
678
+
679
+ /** A provider on this machine, which a test or a local identity provider may use over plain http. */
680
+ const isLoopbackHttp = (value: unknown): boolean => {
681
+ const url = typeof value === "string" ? URL.parse(value) : null;
682
+ return (
683
+ url?.protocol === "http:" &&
684
+ ["127.0.0.1", "[::1]", "localhost"].includes(url.hostname)
685
+ );
686
+ };
687
+
688
+ const providerUrl = (value: unknown): value is string =>
689
+ isHttpsUrl(value) || isLoopbackHttp(value);
690
+
691
+ function checkOidc(auth: OidcOptions): ResolvedOidc {
692
+ const known = new Set([
693
+ "mode",
694
+ "issuer",
695
+ "discoveryUrl",
696
+ "clientId",
697
+ "clientSecretEnv",
698
+ "scopes",
699
+ "subjectClaim",
700
+ "nameClaim",
701
+ "rolesClaim",
702
+ "check",
703
+ "sessionDays",
704
+ "endSession",
705
+ "reauthHours",
706
+ "storeClaims",
707
+ ]);
708
+ for (const key of Object.keys(auth))
709
+ if (!known.has(key)) fail(`auth.${key} is not an oidc option`);
710
+ if (!providerUrl(auth.issuer))
711
+ fail("auth.issuer must be the provider's https URL, exactly its `iss`");
712
+ const { issuer } = auth;
713
+ const discoveryUrl =
714
+ auth.discoveryUrl ??
715
+ `${issuer.replace(/\/+$/, "")}/.well-known/openid-configuration`;
716
+ if (!providerUrl(discoveryUrl))
717
+ fail("auth.discoveryUrl must be an https URL");
718
+ if (typeof auth.clientId !== "string" || !auth.clientId.trim())
719
+ fail("auth.clientId is empty");
720
+ if (
721
+ auth.clientSecretEnv !== undefined &&
722
+ (typeof auth.clientSecretEnv !== "string" || !auth.clientSecretEnv.trim())
723
+ )
724
+ fail(
725
+ "auth.clientSecretEnv must name an environment variable; leave it out for a public client",
726
+ );
727
+ const scopes = auth.scopes ?? ["openid", "profile"];
728
+ if (
729
+ !Array.isArray(scopes) ||
730
+ scopes.length === 0 ||
731
+ !scopes.every(
732
+ (scope) =>
733
+ typeof scope === "string" && /^[\x21\x23-\x5b\x5d-\x7e]+$/.test(scope),
734
+ )
735
+ )
736
+ fail("auth.scopes must be a list of scope names");
737
+ if (scopes.includes("offline_access"))
738
+ fail(
739
+ "auth.scopes has offline_access, but the app keeps no refresh token: its own session is what lasts",
740
+ );
741
+ if (!scopes.includes("openid")) fail("auth.scopes must include openid");
742
+ const claim = (name: string, value: unknown) => {
743
+ if (value !== undefined && (typeof value !== "string" || !value.trim()))
744
+ fail(`auth.${name} must be the name of a claim`);
745
+ };
746
+ claim("subjectClaim", auth.subjectClaim);
747
+ claim("nameClaim", auth.nameClaim);
748
+ if (
749
+ auth.rolesClaim !== undefined &&
750
+ auth.rolesClaim !== false &&
751
+ (typeof auth.rolesClaim !== "string" || !auth.rolesClaim.trim())
752
+ )
753
+ fail("auth.rolesClaim must be the name of a claim, or false");
754
+ if (auth.check !== undefined && typeof auth.check !== "function")
755
+ fail("auth.check must be a function");
756
+ const sessionDays = auth.sessionDays ?? DEFAULT_SESSION_DAYS;
757
+ if (
758
+ typeof sessionDays !== "number" ||
759
+ !Number.isInteger(sessionDays) ||
760
+ sessionDays < 1 ||
761
+ sessionDays > 365
762
+ )
763
+ fail("auth.sessionDays must be a whole number of days from 1 to 365");
764
+ if (auth.endSession !== undefined && typeof auth.endSession !== "boolean")
765
+ fail("auth.endSession must be true or false");
766
+ if (
767
+ auth.reauthHours !== undefined &&
768
+ (typeof auth.reauthHours !== "number" ||
769
+ !(auth.reauthHours > 0) ||
770
+ auth.reauthHours > sessionDays * 24)
771
+ )
772
+ fail(
773
+ "auth.reauthHours must be a number of hours above 0 and within the session's own days",
774
+ );
775
+ const storeClaims = auth.storeClaims ?? [];
776
+ if (
777
+ !Array.isArray(storeClaims) ||
778
+ !storeClaims.every((name) => typeof name === "string" && name.trim())
779
+ )
780
+ fail("auth.storeClaims must be a list of claim names");
781
+ return {
782
+ mode: "oidc",
783
+ issuer,
784
+ discoveryUrl,
785
+ clientId: auth.clientId.trim(),
786
+ clientSecretEnv: auth.clientSecretEnv?.trim(),
787
+ scopes: [...scopes],
788
+ subjectClaim: auth.subjectClaim?.trim() ?? "sub",
789
+ nameClaim: auth.nameClaim?.trim(),
790
+ rolesClaim:
791
+ auth.rolesClaim === undefined
792
+ ? "roles"
793
+ : typeof auth.rolesClaim === "string"
794
+ ? auth.rolesClaim.trim()
795
+ : false,
796
+ check: auth.check,
797
+ sessionDays,
798
+ endSession: auth.endSession === true,
799
+ reauthHours: auth.reauthHours,
800
+ storeClaims: [...storeClaims],
801
+ };
802
+ }
803
+
453
804
  function checkSurface(surface: string | undefined): string {
454
805
  if (surface === undefined) return DEFAULT_SURFACE;
455
806
  if (typeof surface !== "string" || !/^[a-z][a-z0-9-]*$/.test(surface))
@@ -516,15 +867,108 @@ function checkPush(
516
867
  fail(
517
868
  "push.subject must be a mailto: address or an https: URL, such as mailto:ops@example.com",
518
869
  );
870
+ const tiers = push?.tiers ?? ["owner"];
871
+ if (
872
+ !Array.isArray(tiers) ||
873
+ tiers.length === 0 ||
874
+ !tiers.every((tier) => TIERS.includes(tier))
875
+ )
876
+ fail(`push.tiers must be a list of ${TIERS.join(", ")}`);
877
+ const sender = push?.sender ?? "internal";
878
+ if (sender !== "internal") {
879
+ const external = (sender as { external?: unknown } | null)?.external as
880
+ | { publicKey?: unknown; subscribePath?: unknown }
881
+ | undefined;
882
+ if (
883
+ typeof external !== "object" ||
884
+ external === null ||
885
+ typeof external.publicKey !== "string" ||
886
+ !/^[A-Za-z0-9_-]{80,100}$/.test(external.publicKey) ||
887
+ (external.subscribePath !== undefined &&
888
+ (typeof external.subscribePath !== "string" ||
889
+ !external.subscribePath.startsWith("/")))
890
+ )
891
+ fail(
892
+ 'push.sender must be "internal" or { external: { publicKey, subscribePath? } } with the VAPID public key (base64url) and, when the sender serves the subscription routes, their path',
893
+ );
894
+ }
895
+ const content = push?.content ?? (sender === "internal" ? "full" : "none");
896
+ if (content !== "full" && content !== "none")
897
+ fail('push.content must be "full" or "none"');
898
+ const extra = push?.transports ?? [];
899
+ if (
900
+ !Array.isArray(extra) ||
901
+ !extra.every(
902
+ (name) => typeof name === "string" && /^[a-z][a-z0-9-]{0,39}$/.test(name),
903
+ )
904
+ )
905
+ fail("push.transports must be a list of transport names such as slack");
519
906
  return {
520
907
  preview: push?.preview ?? true,
521
908
  quietHours,
522
909
  endpointHosts: [...hosts],
523
910
  subject: given ?? (origin.startsWith("https:") ? origin : undefined),
524
911
  fetch: pushFetchOf(options),
912
+ tiers: [...new Set(tiers)],
913
+ sender:
914
+ sender === "internal"
915
+ ? "internal"
916
+ : {
917
+ external: {
918
+ publicKey: (sender as { external: { publicKey: string } })
919
+ .external.publicKey,
920
+ ...((sender as { external: { subscribePath?: string } }).external
921
+ .subscribePath
922
+ ? {
923
+ subscribePath: (
924
+ sender as { external: { subscribePath: string } }
925
+ ).external.subscribePath,
926
+ }
927
+ : {}),
928
+ },
929
+ },
930
+ content,
931
+ transports: [
932
+ "web-push",
933
+ ...new Set(extra.filter((name) => name !== "web-push")),
934
+ ],
525
935
  };
526
936
  }
527
937
 
938
+ function checkOnboarding(
939
+ options: WebConsoleOptions,
940
+ ): ResolvedOptions["onboarding"] {
941
+ const given = options.onboarding;
942
+ if (given === undefined) return { links: {}, push: "off", pushWaiver: false };
943
+ if (typeof given !== "object" || given === null)
944
+ fail("onboarding must be an object such as { links: { line: 'nudge' } }");
945
+ const links = given.links ?? {};
946
+ if (typeof links !== "object" || links === null || Array.isArray(links))
947
+ fail("onboarding.links must map a provider to required or nudge");
948
+ for (const [provider, step] of Object.entries(links)) {
949
+ if (!provider.trim())
950
+ fail("onboarding.links names a provider with an empty name");
951
+ if (step !== "required" && step !== "nudge")
952
+ fail(
953
+ `onboarding.links.${provider} must be required or nudge, not ${JSON.stringify(step)}`,
954
+ );
955
+ }
956
+ const push = given.push ?? "off";
957
+ if (push !== "required" && push !== "nudge" && push !== "off")
958
+ fail("onboarding.push must be required, nudge or off");
959
+ if (given.pushWaiver !== undefined && typeof given.pushWaiver !== "boolean")
960
+ fail("onboarding.pushWaiver must be true or false");
961
+ return { links: { ...links }, push, pushWaiver: given.pushWaiver === true };
962
+ }
963
+
964
+ function checkUploads(options: WebConsoleOptions): number {
965
+ const days = options.uploads?.keepDays;
966
+ if (days === undefined) return 30;
967
+ if (!Number.isInteger(days) || days < 1 || days > 3650)
968
+ fail("uploads.keepDays must be a whole number of days from 1 to 3650");
969
+ return days;
970
+ }
971
+
528
972
  /** The largest `uploadMaxBytes`: the limit is also the listener's body limit. */
529
973
  const MAX_UPLOAD_MAX_BYTES = 4 * 1024 ** 3;
530
974
 
@@ -558,7 +1002,22 @@ export function resolveOptions(options: WebConsoleOptions): ResolvedOptions {
558
1002
  fail("presentation.locale is not a valid locale");
559
1003
  }
560
1004
  }
561
- const panes = checkPanes(options.panes);
1005
+ const admission = options.admission ?? "owners";
1006
+ if (admission !== "owners" && admission !== "access")
1007
+ fail('admission must be "owners" or "access"');
1008
+ if (
1009
+ admission === "access" &&
1010
+ (auth.mode === "none" || auth.mode === "password")
1011
+ )
1012
+ fail(
1013
+ `admission "access" needs a credential that names a person, but auth ${auth.mode} speaks for one shared actor; use auth oidc or a verifier that reports who signed in`,
1014
+ );
1015
+ const origin = checkOrigin(options.origin);
1016
+ if (auth.mode === "oidc" && !origin.startsWith("https:"))
1017
+ fail("auth oidc needs an https origin: the session cookie is Secure");
1018
+ if (options.modes !== undefined && options.modes !== false)
1019
+ fail("modes can only be false, to leave the mode switches out");
1020
+ const panes = checkPanes(options.panes, admission);
562
1021
  const ownerId = options.ownerId?.trim();
563
1022
  if (options.ownerId !== undefined && !ownerId)
564
1023
  fail(
@@ -571,20 +1030,25 @@ export function resolveOptions(options: WebConsoleOptions): ResolvedOptions {
571
1030
  if (!title) fail("title is empty");
572
1031
  return {
573
1032
  auth,
574
- origin: checkOrigin(options.origin),
1033
+ origin,
575
1034
  ownerId,
576
1035
  dataDir: options.dataDir ?? "data",
577
1036
  sessionsDir: join(options.dataDir ?? "data", "sessions"),
578
1037
  mount: checkMount(options.mountPath ?? "/console"),
579
1038
  listener: options.listener ?? "public",
580
1039
  routing: options.routing ?? "hash",
581
- panes,
582
- panesGiven: options.panes !== undefined,
1040
+ admission,
1041
+ panes: panes.owner,
1042
+ tierPanes: { owner: panes.owner, admin: panes.admin, member: panes.member },
1043
+ panesGiven: panes.given,
583
1044
  workspace: checkWorkspace(options, options.dataDir ?? "data"),
584
1045
  surface: checkSurface(options.surface),
585
1046
  cardGraceSeconds: checkGrace(options.cardGraceSeconds),
586
1047
  uploadMaxBytes: checkUploadMax(options.uploadMaxBytes),
587
- push: checkPush(options, checkOrigin(options.origin)),
1048
+ push: checkPush(options, origin),
1049
+ onboarding: checkOnboarding(options),
1050
+ modes: options.modes !== false,
1051
+ uploadKeepDays: checkUploads(options),
588
1052
  title,
589
1053
  iconsDir: options.icons?.dir,
590
1054
  look,