pi-roundtable-web 0.25.0 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (243) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +165 -12
  3. package/dist/account-zk4efb87.css +102 -0
  4. package/dist/chunk-account-ghq105t4.js +1 -0
  5. package/dist/chunk-action-menu-me0g9qsc.js +1 -0
  6. package/dist/{chunk-agent-0pm9p3nn.js → chunk-agent-rdv7ekrt.js} +1 -1
  7. package/dist/chunk-arranged-rooms-81rqhx9x.js +1 -0
  8. package/dist/chunk-character-creator-bd426yjw.js +1 -0
  9. package/dist/chunk-codeeditor-ajhgd33t.js +2 -0
  10. package/dist/chunk-confirm-p3mts8mx.js +1 -0
  11. package/dist/{chunk-connectors-anhghy3k.js → chunk-connectors-bkv3xn83.js} +1 -1
  12. package/dist/chunk-conversations-0e0vbxwr.js +1 -0
  13. package/dist/{chunk-drag-q4fx9b22.js → chunk-drag-peyb8t0k.js} +1 -1
  14. package/dist/{chunk-entry-he1embgp.js → chunk-entry-mv7q8br0.js} +1 -1
  15. package/dist/chunk-folder-list-era3tksh.js +1 -0
  16. package/dist/chunk-folds-hvws0bd5.js +1 -0
  17. package/dist/chunk-gate-hc7dd0fx.js +1 -0
  18. package/dist/chunk-group-scene-dkbpmbh5.js +1 -0
  19. package/dist/chunk-group-table-fqmxgdfw.js +1 -0
  20. package/dist/chunk-host-icons-4xde9ntk.js +1 -0
  21. package/dist/chunk-host-picture-014yzk20.js +1 -0
  22. package/dist/chunk-host-state-t56e5sdy.js +1 -0
  23. package/dist/chunk-host-ui-p1zy9nmp.js +1 -0
  24. package/dist/chunk-index-0fmmvgee.js +2 -0
  25. package/dist/chunk-index-0q15ysrb.js +1 -0
  26. package/dist/chunk-index-0xh24y25.js +2 -0
  27. package/dist/chunk-index-2yebd9tb.js +426 -0
  28. package/dist/chunk-index-4t0r3t3b.js +2 -0
  29. package/dist/chunk-index-5yzfrf7b.js +138 -0
  30. package/dist/{chunk-index-d5abvq7v.js → chunk-index-6twb7ccd.js} +2 -2
  31. package/dist/chunk-index-73gh7kgr.js +920 -0
  32. package/dist/chunk-index-7xffskfa.js +8 -0
  33. package/dist/chunk-index-aqw7cy5j.js +1 -0
  34. package/dist/chunk-index-b6ca02e8.js +2 -0
  35. package/dist/chunk-index-b8nvhcx5.js +2 -0
  36. package/dist/{chunk-index-bvfqjh77.js → chunk-index-cvbdf2ca.js} +2 -2
  37. package/dist/chunk-index-czdketze.js +2 -0
  38. package/dist/{chunk-index-9jqcskb4.js → chunk-index-dt9775ka.js} +5 -5
  39. package/dist/chunk-index-eg55mqqe.js +1 -0
  40. package/dist/chunk-index-gr6e0hse.js +2 -0
  41. package/dist/chunk-index-gyary7np.js +13 -0
  42. package/dist/chunk-index-j214tczc.js +1 -0
  43. package/dist/chunk-index-jd6632ef.js +2 -0
  44. package/dist/chunk-index-jw7dtshr.js +2 -0
  45. package/dist/{chunk-index-krtk6nn2.js → chunk-index-k7t14rwa.js} +2 -2
  46. package/dist/chunk-index-kwcx62wb.js +4 -0
  47. package/dist/chunk-index-m7fvw92f.js +2 -0
  48. package/dist/chunk-index-mmg788a1.js +2 -0
  49. package/dist/chunk-index-mq0dacwr.js +2 -0
  50. package/dist/chunk-index-p53ak25v.js +2 -0
  51. package/dist/{chunk-index-27qca4mr.js → chunk-index-pk1yxkv4.js} +2 -2
  52. package/dist/{chunk-index-9b1kt7qw.css → chunk-index-rbcz9jd4.css} +1 -1
  53. package/dist/{chunk-index-s681nny9.js → chunk-index-rmxbdmn2.js} +2 -2
  54. package/dist/chunk-index-rnxj3z8k.js +2 -0
  55. package/dist/chunk-index-snzzfhk3.js +2 -0
  56. package/dist/chunk-index-v3bmr0nw.js +2 -0
  57. package/dist/chunk-index-vcyzwtk2.js +2 -0
  58. package/dist/{chunk-index-zfd00hwb.js → chunk-index-w2j3njb6.js} +2 -2
  59. package/dist/chunk-index-w31c4yjv.js +2 -0
  60. package/dist/chunk-index-wrm8w165.js +2 -0
  61. package/dist/chunk-index-xh0mg9cw.js +2 -0
  62. package/dist/chunk-index-ybtvvx85.js +2 -0
  63. package/dist/chunk-index-yzsgsvnp.js +2 -0
  64. package/dist/{chunk-index-18gxy79n.js → chunk-index-z9x4cnzf.js} +2 -2
  65. package/dist/chunk-index-zrvg23jd.js +2 -0
  66. package/dist/chunk-index-zynq0b0w.js +50 -0
  67. package/dist/chunk-lang-css-88v3hp3d.js +1 -0
  68. package/dist/{chunk-lang-dockerfile-d5mzxs60.js → chunk-lang-dockerfile-k728ewsf.js} +1 -1
  69. package/dist/{chunk-lang-go-830fmhba.js → chunk-lang-go-wjwgay4v.js} +3 -3
  70. package/dist/chunk-lang-html-07d47es4.js +1 -0
  71. package/dist/chunk-lang-javascript-fmhhgzwd.js +1 -0
  72. package/dist/{chunk-lang-json-5eaarz7j.js → chunk-lang-json-7gd89mvs.js} +1 -1
  73. package/dist/chunk-lang-markdown-b6qbe8bt.js +6 -0
  74. package/dist/{chunk-lang-python-fjw84w10.js → chunk-lang-python-3v56y31b.js} +2 -2
  75. package/dist/{chunk-lang-rust-5rbp2zdk.js → chunk-lang-rust-kh0z74d1.js} +1 -1
  76. package/dist/{chunk-lang-shell-vzagpym3.js → chunk-lang-shell-9zv0zvbx.js} +1 -1
  77. package/dist/{chunk-lang-sql-0c2w9c2y.js → chunk-lang-sql-yw6bf3t7.js} +2 -2
  78. package/dist/{chunk-lang-toml-yr9fv6eb.js → chunk-lang-toml-tg1r96nq.js} +1 -1
  79. package/dist/chunk-lang-typescript-etjjvr2g.js +1 -0
  80. package/dist/{chunk-lang-xml-eze08p42.js → chunk-lang-xml-ev1mndd4.js} +1 -1
  81. package/dist/{chunk-lang-yaml-c4b6ft3y.js → chunk-lang-yaml-fz5qpb3r.js} +1 -1
  82. package/dist/chunk-layout-actions-48ecgtkg.js +1 -0
  83. package/dist/{chunk-menu-items-jrwxycr1.js → chunk-menu-items-1epy3h0t.js} +2 -2
  84. package/dist/chunk-mode-toggle-fxmfxsa4.js +1 -0
  85. package/dist/chunk-note-row-kd0fy7ee.js +75 -0
  86. package/dist/chunk-notes-yb8f057c.js +1 -0
  87. package/dist/chunk-notice-bell-wr4f1rph.js +1 -0
  88. package/dist/chunk-notices-x07da5bb.js +1 -0
  89. package/dist/chunk-onboarding-csrkbh9z.js +1 -0
  90. package/dist/chunk-overview-avc2d30t.js +1 -0
  91. package/dist/chunk-palette-9nhp95bz.js +1 -0
  92. package/dist/chunk-placement-items-q694dpgk.js +1 -0
  93. package/dist/chunk-profile-6qemf3m7.js +1 -0
  94. package/dist/chunk-prompt-history-7enzp2p3.js +1 -0
  95. package/dist/chunk-prompts-nv6mrn0q.js +1 -0
  96. package/dist/chunk-room-confirms-zz7n61y7.js +1 -0
  97. package/dist/chunk-room-menu-rr6nzap1.js +1 -0
  98. package/dist/chunk-rooms-menu-button-zbtwj47d.js +1 -0
  99. package/dist/chunk-schedules-rw57nr0m.js +1 -0
  100. package/dist/{chunk-seat-menu-jv4s7hmj.js → chunk-seat-menu-ys5v9abz.js} +1 -1
  101. package/dist/chunk-sheet-hosts-jhvaacst.js +1 -0
  102. package/dist/chunk-skill-editor-sz8an8qr.js +8 -0
  103. package/dist/chunk-skills-tnr1h16h.js +1 -0
  104. package/dist/chunk-switcher-list-17enhrse.js +1 -0
  105. package/dist/chunk-tab-dot-r6brdbmw.js +1 -0
  106. package/dist/chunk-table-r12cja6p.js +1 -0
  107. package/dist/chunk-table-seats-g3t392zz.js +1 -0
  108. package/dist/{chunk-table2d-4sje1fr0.js → chunk-table2d-p3ezsdcj.js} +1 -1
  109. package/dist/{chunk-table3d-h64skazp.js → chunk-table3d-4x2n2b00.js} +23 -23
  110. package/dist/chunk-thread-actions-qc6ezkha.js +1 -0
  111. package/dist/chunk-transcript-n2s6dher.js +1 -0
  112. package/dist/chunk-where-f3scdydc.js +1 -0
  113. package/dist/chunk-workspace-xs023bxm.js +1 -0
  114. package/dist/chunk-zh-tw-efvmk3fd.js +1 -0
  115. package/dist/index.html +1 -1
  116. package/dist/sw.js +1 -1
  117. package/package.json +5 -4
  118. package/src/api-types.ts +356 -4
  119. package/src/app/app.ts +201 -32
  120. package/src/app/desk.ts +47 -22
  121. package/src/app/files.ts +73 -7
  122. package/src/app/presence.ts +2 -2
  123. package/src/app/protocol.ts +63 -1
  124. package/src/app/room-log.ts +153 -14
  125. package/src/app/rooms.ts +39 -0
  126. package/src/app/routes.ts +168 -28
  127. package/src/app/socket.ts +349 -81
  128. package/src/app/sockets.ts +108 -20
  129. package/src/app/surface.ts +72 -36
  130. package/src/app/threads.ts +31 -2
  131. package/src/app/uploads.ts +33 -7
  132. package/src/auth/authenticator.ts +77 -8
  133. package/src/auth/login-page.ts +4 -2
  134. package/src/auth/next.ts +52 -0
  135. package/src/auth/oidc-pages.ts +62 -0
  136. package/src/auth/oidc.ts +649 -0
  137. package/src/auth/sessions.ts +193 -0
  138. package/src/authz.ts +173 -0
  139. package/src/console-api.ts +122 -25
  140. package/src/console-server.ts +102 -12
  141. package/src/gate.ts +90 -0
  142. package/src/host/access.ts +161 -0
  143. package/src/host/cards.ts +112 -0
  144. package/src/host/context.ts +188 -0
  145. package/src/host/forms.ts +240 -0
  146. package/src/host/frames.ts +349 -0
  147. package/src/host/host-app.ts +178 -0
  148. package/src/host/host-context.ts +49 -0
  149. package/src/host/host-routes.ts +396 -0
  150. package/src/host/pages.ts +266 -0
  151. package/src/host/reads.ts +155 -0
  152. package/src/host/retention.ts +41 -0
  153. package/src/host/room-views.ts +137 -0
  154. package/src/host/rooms-service.ts +429 -0
  155. package/src/host/stored-json.ts +13 -0
  156. package/src/host/views.ts +106 -0
  157. package/src/i18n/zh-tw.ts +139 -0
  158. package/src/index.ts +15 -1
  159. package/src/notices/api.ts +43 -0
  160. package/src/notices/mount.ts +292 -0
  161. package/src/notices/service.ts +294 -0
  162. package/src/notices/store.ts +298 -0
  163. package/src/notices/urls.ts +38 -0
  164. package/src/onboarding/api.ts +32 -0
  165. package/src/onboarding/service.ts +149 -0
  166. package/src/onboarding/steps.ts +75 -0
  167. package/src/onboarding/waivers.ts +40 -0
  168. package/src/options.ts +534 -34
  169. package/src/page-presentation.ts +47 -2
  170. package/src/profile/api.ts +175 -0
  171. package/src/profile/links.ts +162 -0
  172. package/src/push/api.ts +189 -52
  173. package/src/push/dispatch.ts +156 -0
  174. package/src/push/drain.ts +139 -0
  175. package/src/push/outbox.ts +218 -0
  176. package/src/push/prefs.ts +118 -0
  177. package/src/push/sender.ts +7 -2
  178. package/src/push/triggers.ts +56 -5
  179. package/src/push/vapid.ts +19 -0
  180. package/src/push/web-push-transport.ts +106 -0
  181. package/src/push-sender/index.ts +14 -0
  182. package/src/push-sender/run.ts +118 -0
  183. package/src/push-sender/session.ts +54 -0
  184. package/src/pwa.ts +32 -4
  185. package/src/visitors.ts +94 -6
  186. package/src/web-app.ts +718 -0
  187. package/src/web-plugin.ts +279 -26
  188. package/dist/chunk-action-menu-qe839g4b.js +0 -1
  189. package/dist/chunk-arranged-rooms-enpfswmg.js +0 -1
  190. package/dist/chunk-character-creator-6yf29xwx.js +0 -1
  191. package/dist/chunk-codeeditor-gdbdg7rp.js +0 -2
  192. package/dist/chunk-confirm-xrw7d732.js +0 -1
  193. package/dist/chunk-conversations-ry228aes.js +0 -1
  194. package/dist/chunk-folder-list-5cp8stna.js +0 -1
  195. package/dist/chunk-folds-hff104tk.js +0 -1
  196. package/dist/chunk-group-scene-h228jdat.js +0 -1
  197. package/dist/chunk-index-0p5h2kdz.js +0 -2
  198. package/dist/chunk-index-0pt08yd9.js +0 -1
  199. package/dist/chunk-index-12bw9yj8.js +0 -2
  200. package/dist/chunk-index-2ad17eh4.js +0 -2
  201. package/dist/chunk-index-31wh5twj.js +0 -52
  202. package/dist/chunk-index-4dy94pbz.js +0 -2
  203. package/dist/chunk-index-4ecjtdy8.js +0 -13
  204. package/dist/chunk-index-4v1b7ks9.js +0 -2
  205. package/dist/chunk-index-5d8yjj4m.js +0 -2
  206. package/dist/chunk-index-89ajmxh7.js +0 -2
  207. package/dist/chunk-index-b9ny4623.js +0 -2
  208. package/dist/chunk-index-e1dr6g10.js +0 -2
  209. package/dist/chunk-index-gpkzhq1g.js +0 -2
  210. package/dist/chunk-index-hk3xyy20.js +0 -2
  211. package/dist/chunk-index-jm23vn03.js +0 -4
  212. package/dist/chunk-index-k7gh0jvz.js +0 -2
  213. package/dist/chunk-index-ky42ev0b.js +0 -2
  214. package/dist/chunk-index-pa2yyqh0.js +0 -2
  215. package/dist/chunk-index-pcvt668q.js +0 -2
  216. package/dist/chunk-index-pqw27eye.js +0 -1
  217. package/dist/chunk-index-wmbmmrd4.js +0 -302
  218. package/dist/chunk-index-ymbjrsp6.js +0 -2
  219. package/dist/chunk-lang-css-t85r8rz0.js +0 -1
  220. package/dist/chunk-lang-html-n6jparge.js +0 -1
  221. package/dist/chunk-lang-javascript-xj00cp97.js +0 -1
  222. package/dist/chunk-lang-markdown-zxx8eke8.js +0 -6
  223. package/dist/chunk-lang-typescript-8f6yywt3.js +0 -1
  224. package/dist/chunk-layout-actions-bv0chj41.js +0 -1
  225. package/dist/chunk-notes-9b7pgqfe.js +0 -1
  226. package/dist/chunk-overview-8yw71esz.js +0 -1
  227. package/dist/chunk-palette-frms7axe.js +0 -1
  228. package/dist/chunk-placement-items-fvfy9m0y.js +0 -1
  229. package/dist/chunk-prompt-history-yrqehads.js +0 -1
  230. package/dist/chunk-prompts-g6609tqx.js +0 -1
  231. package/dist/chunk-room-confirms-0a2y0925.js +0 -1
  232. package/dist/chunk-room-menu-g11q97br.js +0 -1
  233. package/dist/chunk-rooms-menu-button-h9qge48n.js +0 -1
  234. package/dist/chunk-schedules-ezmacrbj.js +0 -1
  235. package/dist/chunk-sheet-hosts-b0azyfhb.js +0 -1
  236. package/dist/chunk-skill-editor-h6k6whwg.js +0 -8
  237. package/dist/chunk-skills-gqcfhq56.js +0 -1
  238. package/dist/chunk-switcher-list-mcpamqnj.js +0 -1
  239. package/dist/chunk-table-s8cde1xz.js +0 -1
  240. package/dist/chunk-table-seats-wqkjn8bc.js +0 -1
  241. package/dist/chunk-transcript-c59c6mpm.js +0 -1
  242. package/dist/chunk-workspace-0cyspkk4.js +0 -1
  243. package/dist/chunk-zh-tw-nc93jm10.js +0 -1
@@ -0,0 +1,193 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import type { SQL } from "bun";
3
+ import type { ActorFacts, Migration } from "pi-roundtable";
4
+
5
+ /** A session's idle limit slides at most this often, so a busy page costs no write per request. */
6
+ const SLIDE_AFTER_MS = 3_600_000;
7
+ /** Bytes of the random id the cookie holds. */
8
+ const TOKEN_BYTES = 32;
9
+
10
+ /** The actor a session was signed in as, and the id_token claims the host asked to keep. */
11
+ export interface SessionActor extends ActorFacts {
12
+ /** Only the claims `oidc.storeClaims` names; replaced at each sign-in and re-verify, never logged. */
13
+ claims?: Readonly<Record<string, unknown>>;
14
+ }
15
+
16
+ /** A live session. */
17
+ export interface Session {
18
+ /** The SHA-256 of the cookie's id, in hex: what the rest of the app calls the session. */
19
+ id: string;
20
+ principalId: string;
21
+ actor: SessionActor;
22
+ createdAt: Date;
23
+ lastSeenAt: Date;
24
+ /** When the IdP last vouched for the person: the sign-in, or the latest silent re-verify. */
25
+ verifiedAt: Date;
26
+ /** When the idle limit ends it. */
27
+ expiresAt: Date;
28
+ }
29
+
30
+ interface Row {
31
+ id_hash: Buffer;
32
+ principal_id: string;
33
+ actor: SessionActor | string;
34
+ created_at: Date;
35
+ last_seen_at: Date;
36
+ verified_at: Date;
37
+ expires_at: Date;
38
+ }
39
+
40
+ /** A jsonb column as the driver hands it back: parsed, or as text. */
41
+ function actorOf(value: SessionActor | string): SessionActor {
42
+ if (typeof value !== "string") return value;
43
+ try {
44
+ return JSON.parse(value) as SessionActor;
45
+ } catch (error) {
46
+ throw new Error("the session store holds JSON that cannot be read", {
47
+ cause: error,
48
+ });
49
+ }
50
+ }
51
+
52
+ const hashOf = (token: string): Buffer =>
53
+ createHash("sha256").update(token).digest();
54
+
55
+ const sessionOf = (row: Row): Session => ({
56
+ id: Buffer.from(row.id_hash).toString("hex"),
57
+ principalId: row.principal_id,
58
+ actor: actorOf(row.actor),
59
+ createdAt: row.created_at,
60
+ lastSeenAt: row.last_seen_at,
61
+ verifiedAt: row.verified_at,
62
+ expiresAt: row.expires_at,
63
+ });
64
+
65
+ export interface WebSessionsOptions {
66
+ /** The idle limit: a session ends after this many days without a visit. */
67
+ days: number;
68
+ /** The clock, in milliseconds; default `Date.now`. Tests set it. */
69
+ now?: () => number;
70
+ }
71
+
72
+ /**
73
+ * The app's own sessions, in `web_sessions`: a 256-bit random id in the cookie, of which only the
74
+ * SHA-256 is stored, with the actor facts the sign-in produced. The session slides: it ends after
75
+ * `days` without a visit, and a visit moves the limit at most once an hour. Nothing here decides
76
+ * what tier the person holds; that is read from `IDENTITY` for every request.
77
+ */
78
+ export class WebSessions {
79
+ readonly #sql: SQL;
80
+ readonly #options: WebSessionsOptions;
81
+
82
+ constructor(sql: SQL, options: WebSessionsOptions) {
83
+ this.#sql = sql;
84
+ this.#options = options;
85
+ }
86
+
87
+ static readonly migration: Migration = {
88
+ name: "web-sessions",
89
+ up: async (sql) => {
90
+ await sql`
91
+ CREATE TABLE IF NOT EXISTS web_sessions (
92
+ id_hash bytea PRIMARY KEY,
93
+ principal_id text NOT NULL,
94
+ actor jsonb NOT NULL,
95
+ created_at timestamptz NOT NULL,
96
+ last_seen_at timestamptz NOT NULL,
97
+ verified_at timestamptz NOT NULL,
98
+ expires_at timestamptz NOT NULL
99
+ )`;
100
+ await sql`CREATE INDEX IF NOT EXISTS web_sessions_principal ON web_sessions (principal_id)`;
101
+ await sql`CREATE INDEX IF NOT EXISTS web_sessions_expires ON web_sessions (expires_at)`;
102
+ },
103
+ };
104
+
105
+ #now(): Date {
106
+ return new Date((this.#options.now ?? Date.now)());
107
+ }
108
+
109
+ #limit(from: Date): Date {
110
+ return new Date(from.getTime() + this.#options.days * 86_400_000);
111
+ }
112
+
113
+ /** Starts a session for a person signed in as `actor`; returns the cookie's id. */
114
+ async create(
115
+ actor: SessionActor,
116
+ principalId: string,
117
+ ): Promise<{ token: string; session: Session }> {
118
+ const token = randomBytes(TOKEN_BYTES).toString("base64url");
119
+ const now = this.#now();
120
+ const expires = this.#limit(now);
121
+ await this.#sql`
122
+ INSERT INTO web_sessions (id_hash, principal_id, actor, created_at, last_seen_at, verified_at, expires_at)
123
+ VALUES (${hashOf(token)}, ${principalId}, ${JSON.stringify(actor)}::jsonb, ${now}, ${now}, ${now}, ${expires})`;
124
+ return {
125
+ token,
126
+ session: {
127
+ id: hashOf(token).toString("hex"),
128
+ principalId,
129
+ actor,
130
+ createdAt: now,
131
+ lastSeenAt: now,
132
+ verifiedAt: now,
133
+ expiresAt: expires,
134
+ },
135
+ };
136
+ }
137
+
138
+ /**
139
+ * The live session the cookie's id names, with its limit moved on if it was last seen more than
140
+ * an hour ago; undefined when there is none or it ended.
141
+ */
142
+ async touch(token: string): Promise<Session | undefined> {
143
+ const now = this.#now();
144
+ const [row]: Row[] = await this.#sql`
145
+ SELECT * FROM web_sessions WHERE id_hash = ${hashOf(token)} AND expires_at > ${now}`;
146
+ if (!row) return undefined;
147
+ const found = sessionOf(row);
148
+ if (now.getTime() - found.lastSeenAt.getTime() < SLIDE_AFTER_MS)
149
+ return found;
150
+ const expires = this.#limit(now);
151
+ await this.#sql`
152
+ UPDATE web_sessions SET last_seen_at = ${now}, expires_at = ${expires}
153
+ WHERE id_hash = ${hashOf(token)}`;
154
+ return { ...found, lastSeenAt: now, expiresAt: expires };
155
+ }
156
+
157
+ /** Whether the session still stands, without moving its limit; for the recheck of an open socket. */
158
+ async alive(id: string): Promise<boolean> {
159
+ const [row]: { n: number }[] = await this.#sql`
160
+ SELECT count(*)::int AS n FROM web_sessions
161
+ WHERE id_hash = ${Buffer.from(id, "hex")} AND expires_at > ${this.#now()}`;
162
+ return (row?.n ?? 0) > 0;
163
+ }
164
+
165
+ /** Ends the session the cookie's id names; returns its id, undefined for none. */
166
+ async end(token: string): Promise<string | undefined> {
167
+ const [row]: { id_hash: Buffer }[] = await this.#sql`
168
+ DELETE FROM web_sessions WHERE id_hash = ${hashOf(token)} RETURNING id_hash`;
169
+ return row ? Buffer.from(row.id_hash).toString("hex") : undefined;
170
+ }
171
+
172
+ /** Deletes the sessions past their limit; returns how many. */
173
+ async sweep(): Promise<number> {
174
+ const rows: unknown[] = await this.#sql`
175
+ DELETE FROM web_sessions WHERE expires_at <= ${this.#now()} RETURNING id_hash`;
176
+ return rows.length;
177
+ }
178
+
179
+ /**
180
+ * The id_token claims kept for a person from their newest live session; undefined when they
181
+ * have none or kept no claims. For a sender that needs, say, a mailbox address.
182
+ */
183
+ async claimsOf(
184
+ principalId: string,
185
+ ): Promise<Readonly<Record<string, unknown>> | undefined> {
186
+ const [row]: Pick<Row, "actor">[] = await this.#sql`
187
+ SELECT actor FROM web_sessions
188
+ WHERE principal_id = ${principalId} AND expires_at > ${this.#now()}
189
+ ORDER BY verified_at DESC LIMIT 1`;
190
+ if (!row) return undefined;
191
+ return actorOf(row.actor).claims;
192
+ }
193
+ }
package/src/authz.ts ADDED
@@ -0,0 +1,173 @@
1
+ import type { Tier } from "pi-roundtable";
2
+
3
+ /**
4
+ * Who a route answers, as the authorization table of the design names them:
5
+ * - `O`: a person whose tier is owner, read again for the request;
6
+ * - `V`: any admitted viewer (owner, admin, member) who is past the gate;
7
+ * - `G`: any admitted viewer, gated or not.
8
+ * A route answers more narrowly inside its handler (a member's own notes, a room the kind
9
+ * grants); this table is the first check, and it runs before any handler does.
10
+ */
11
+ export type Who = "G" | "V" | "O";
12
+
13
+ export interface Rule {
14
+ /** The methods the row covers; `*` is every method. */
15
+ methods: readonly string[] | "*";
16
+ /**
17
+ * The path below the mount: segments joined by `/`. `:name` is one segment; a last `**` is any
18
+ * number of further segments, none included.
19
+ */
20
+ path: string;
21
+ who: Who;
22
+ /** A refusal says 404, not 403: the route's existence is not for this person to learn. */
23
+ hide?: true;
24
+ }
25
+
26
+ const row = (
27
+ methods: Rule["methods"],
28
+ path: string,
29
+ who: Who,
30
+ hide?: true,
31
+ ): Rule => ({ methods, path, who, ...(hide ? { hide } : {}) });
32
+
33
+ /** The app's REST routes by who they answer. A route that is not listed answers 404 to everyone. */
34
+ export const ROUTES: readonly Rule[] = [
35
+ // The page and its data.
36
+ row(["GET"], "api/config", "G"),
37
+ row(["GET"], "api/session", "G"),
38
+ row(["GET"], "api/events", "O"),
39
+ row("*", "api/overview", "O"),
40
+ row("*", "api/dashboard", "O"),
41
+ row("*", "api/channels/:id/:action", "O"),
42
+ row("*", "api/skills", "O"),
43
+ row("*", "api/skills/:name", "O"),
44
+ row("*", "api/connectors", "O"),
45
+ row("*", "api/models", "O"),
46
+ // The owner's conversations; a member's own are filtered inside the handler.
47
+ row("*", "api/conversations", "V"),
48
+ row("*", "api/conversations/:id", "V"),
49
+ // Notes: the visitor's own (inside the handler); the owner's reads of others stay as they were.
50
+ row("*", "api/notes", "V"),
51
+ row("*", "api/notes/:id", "V"),
52
+ row("*", "api/principals", "V"),
53
+ // The agent rooms are the owners'. The route says 404, not 403, to anyone else. Their threads are under
54
+ // it too: a thread's view and `…/dismiss`, and `…/dismiss-threads` of its parent.
55
+ row("*", "api/rooms", "O", true),
56
+ row("*", "api/rooms/**", "O", true),
57
+ row("*", "api/folders/**", "O"),
58
+ row("*", "api/agents/**", "O"),
59
+ row("*", "api/groups/**", "O"),
60
+ row("*", "api/archive/**", "O"),
61
+ row("*", "api/prompts/**", "O"),
62
+ row("*", "api/skill-docs/**", "O"),
63
+ row("*", "api/workspace/**", "O"),
64
+ row(["GET", "HEAD"], "avatars/:file", "V"),
65
+ // Uploads and files: the upload is the sender's own; a file answers by the room it is bound to.
66
+ row("*", "api/uploads", "V"),
67
+ row("*", "api/uploads/:id", "V"),
68
+ row("*", "api/files/:id", "V"),
69
+ // Schedules: the owner's all, a member's own (inside the handler).
70
+ row("*", "api/schedules", "V"),
71
+ row("*", "api/schedules/:id", "V"),
72
+ // Host rooms and pages: the kind's `access` and the page's `visible` decide inside.
73
+ row(["GET"], "api/host/:kind", "V"),
74
+ row(["GET"], "api/host/:kind/:id", "V"),
75
+ row(["GET"], "api/host/:kind/:id/messages", "V"),
76
+ row(["POST"], "api/host/:kind/:id/act", "V"),
77
+ row(["GET"], "api/pages/:id", "V"),
78
+ row(["POST"], "api/pages/:id/act", "V"),
79
+ // Notices, push, the profile, links and onboarding are the person's own (the session's principal).
80
+ row(["GET"], "api/notices", "G"),
81
+ row(["POST"], "api/notices/:tag/ack", "G"),
82
+ row(["GET"], "api/push/key", "G"),
83
+ row("*", "api/push/subscriptions", "G"),
84
+ row("*", "api/push/subscriptions/:id", "G"),
85
+ row(["POST"], "api/push/test", "G"),
86
+ row(["POST"], "api/push/receipts", "G"),
87
+ row(["GET", "PUT"], "api/push/prefs", "G"),
88
+ row(["GET"], "api/profile", "G"),
89
+ row(["PUT"], "api/profile/source", "G"),
90
+ row(["GET"], "api/people/:id/picture", "G"),
91
+ row(["GET"], "api/links", "G"),
92
+ row(["POST"], "api/links/:provider/:step", "G"),
93
+ row(["DELETE"], "api/links/:provider", "G"),
94
+ row(["GET"], "api/onboarding", "G"),
95
+ row(["POST"], "api/onboarding/:step/waive", "G"),
96
+ ];
97
+
98
+ function matches(pattern: string, parts: readonly string[]): boolean {
99
+ const want = pattern.split("/");
100
+ const open = want[want.length - 1] === "**";
101
+ const fixed = open ? want.slice(0, -1) : want;
102
+ if (open ? parts.length < fixed.length : parts.length !== fixed.length)
103
+ return false;
104
+ return fixed.every(
105
+ (segment, at) => segment.startsWith(":") || segment === parts[at],
106
+ );
107
+ }
108
+
109
+ /** The parts of a path below the mount, decoded; undefined for one that does not decode. */
110
+ function partsOf(rest: string): string[] | undefined {
111
+ try {
112
+ return rest.split("/").filter(Boolean).map(decodeURIComponent);
113
+ } catch {
114
+ return undefined;
115
+ }
116
+ }
117
+
118
+ /** The path areas the table governs: everything below `api/` and the avatars. */
119
+ const governed = (parts: readonly string[]): boolean =>
120
+ parts[0] === "api" || parts[0] === "avatars";
121
+
122
+ export interface Subject {
123
+ tier: Tier;
124
+ /** A required onboarding step stands undone. */
125
+ gated: boolean;
126
+ }
127
+
128
+ export type Decision =
129
+ | { allow: true; rule?: Rule }
130
+ | { allow: false; status: 403 | 404; gate?: true; reason: string };
131
+
132
+ /**
133
+ * Decides a request before any handler sees it. A page, an asset or the install files (anything
134
+ * outside `api/` and `avatars/`) needs only an admitted visitor. A route under them that the table
135
+ * does not list answers 404. A listed route whose method the row does not list is judged by the
136
+ * strictest row of its path and then left to the handler to refuse as the method it is.
137
+ */
138
+ export function decide(
139
+ method: string,
140
+ rest: string,
141
+ subject: Subject,
142
+ ): Decision {
143
+ const parts = partsOf(rest);
144
+ if (!parts) return { allow: false, status: 404, reason: "undecodable path" };
145
+ if (!governed(parts)) return { allow: true };
146
+ const found = ROUTES.filter((rule) => matches(rule.path, parts));
147
+ if (found.length === 0)
148
+ return { allow: false, status: 404, reason: "not in the table" };
149
+ const exact = found.find(
150
+ (rule) => rule.methods === "*" || rule.methods.includes(method),
151
+ );
152
+ const rule = exact ?? strictest(found);
153
+ if (subject.gated && rule.who !== "G")
154
+ return {
155
+ allow: false,
156
+ status: 403,
157
+ gate: true,
158
+ reason: "an onboarding step stands undone",
159
+ };
160
+ if (rule.who === "O" && subject.tier !== "owner")
161
+ return {
162
+ allow: false,
163
+ status: rule.hide ? 404 : 403,
164
+ reason: "owners only",
165
+ };
166
+ return { allow: true, rule };
167
+ }
168
+
169
+ const ORDER: Record<Who, number> = { G: 0, V: 1, O: 2 };
170
+
171
+ function strictest(rules: readonly Rule[]): Rule {
172
+ return rules.reduce((a, b) => (ORDER[b.who] > ORDER[a.who] ? b : a));
173
+ }
@@ -9,6 +9,7 @@ import type {
9
9
  MemoryKind,
10
10
  QueuePort,
11
11
  SpeakerMemory,
12
+ Tier,
12
13
  } from "pi-roundtable";
13
14
  import { MemoryError, parseChannelKey, SYSTEM_PRINCIPAL } from "pi-roundtable";
14
15
  import { thinkingLabel } from "pi-roundtable/kit";
@@ -20,9 +21,12 @@ import type {
20
21
  ConversationsView,
21
22
  ConversationView,
22
23
  DashboardView,
24
+ GateStepView,
25
+ KindLinkView,
23
26
  NoteInput,
24
27
  NoteView,
25
28
  OverviewView,
29
+ PageLinkView,
26
30
  PaneName,
27
31
  PrincipalName,
28
32
  PrincipalsView,
@@ -51,10 +55,32 @@ const NOTE_SEARCH_LIMIT = 500;
51
55
  /** The largest note body the console reads. */
52
56
  const MAX_BODY_BYTES = 64 * 1024;
53
57
 
58
+ /** What the app adds to the page's config for one visitor, where it admits more than the owners. */
59
+ export interface AccessView {
60
+ /** Who may use push, and the key browsers subscribe against when an external sender holds it. */
61
+ push: { tiers: readonly Tier[]; key?: string; subscribe?: string };
62
+ /** The required steps the visitor still owes. */
63
+ gate(visitor: Visitor): Promise<GateStepView[]>;
64
+ }
65
+
54
66
  export interface ConsolePorts {
55
67
  title: string;
56
68
  timeZone: string;
69
+ /** The owners' panes, which every host serves. */
57
70
  panes: readonly PaneName[];
71
+ /** The panes of the other tiers, when the host admits more than its owners; absent = none. */
72
+ tierPanes?: Readonly<Record<Tier, readonly PaneName[]>>;
73
+ /** Present when the host admits more than its owners (`admission: "access"`). */
74
+ access?: AccessView;
75
+ /**
76
+ * The pages and kinds of room the host registered, as far as this visitor may see them. The
77
+ * config carries them whatever the admission, but only when there are any, so a host that
78
+ * registers none sends the page exactly what it did before.
79
+ */
80
+ hosted?: (visitor: Visitor) => Promise<{
81
+ pages: PageLinkView[];
82
+ kinds: KindLinkView[];
83
+ }>;
58
84
  /** The host's `sessions/` directory. */
59
85
  sessionsDir: string;
60
86
  /** Present when the overview pane is served, or the agent server is there to classify channels. */
@@ -68,8 +94,8 @@ export interface ConsolePorts {
68
94
  ) => Pick<SpeakerMemory, "list" | "search" | "add" | "update" | "removeById">;
69
95
  /** The host's principals, read only: whose notes the pane may show, and the names of conversations' principals. */
70
96
  people?: Pick<IdentityService, "principal" | "list">;
71
- /** Conversations the console must not list or read. */
72
- exclude?: (key: ChannelKey) => boolean;
97
+ /** Conversations the console must not list or read; `record` is what the registry holds of it, if anything. */
98
+ exclude?: (key: ChannelKey, record?: ConversationRecord) => boolean;
73
99
  /** The host's conversation registry; without it only the conversations found by name are listed. */
74
100
  registry?: Pick<ConversationRegistry, "list" | "get">;
75
101
  /** Text a relayed message begins with, which is not the owner's words. */
@@ -84,7 +110,7 @@ export interface ConsolePorts {
84
110
  /** The host's six skin tones, which the page draws every character with. */
85
111
  characterSkins?: readonly string[];
86
112
  /** Present when the owner app is served: its rooms' key prefix, for the page. */
87
- app?: { surface: string; protocol: string };
113
+ app?: { surface: string; protocol: string; modes?: false };
88
114
  }
89
115
 
90
116
  import { cleanupConversation } from "./cleanup-api.ts";
@@ -154,8 +180,15 @@ export class ConsoleApi {
154
180
  }
155
181
  }
156
182
 
157
- #pane(name: PaneName): void {
158
- if (!this.#ports.panes.includes(name))
183
+ /** The panes this visitor is served: the owners' list, or their own tier's. */
184
+ #panesOf(visitor: Visitor): readonly PaneName[] {
185
+ return visitor.tier === "owner"
186
+ ? this.#ports.panes
187
+ : (this.#ports.tierPanes?.[visitor.tier] ?? []);
188
+ }
189
+
190
+ #pane(name: PaneName, visitor: Visitor): void {
191
+ if (!this.#panesOf(visitor).includes(name))
159
192
  throw new HttpError(404, "This pane is not served.");
160
193
  }
161
194
 
@@ -173,13 +206,13 @@ export class ConsoleApi {
173
206
  }
174
207
  const [head, id] = parts;
175
208
  if (method === "GET" && head === "config" && !id)
176
- return json(this.config());
209
+ return json(await this.configFor(visitor));
177
210
  if (
178
211
  method === "GET" &&
179
212
  (head === "overview" || head === "dashboard") &&
180
213
  !id
181
214
  ) {
182
- this.#pane("overview");
215
+ this.#pane("overview", visitor);
183
216
  const view = await this.overview();
184
217
  if (head === "overview") return json(view);
185
218
  return json({
@@ -201,39 +234,73 @@ export class ConsoleApi {
201
234
  } satisfies DashboardView);
202
235
  }
203
236
  if (method === "GET" && head === "conversations" && parts.length <= 2) {
204
- this.#pane("conversations");
205
- if (!id) return json(await this.conversations());
237
+ this.#pane("conversations", visitor);
238
+ if (!id) return json(await this.conversations(visitor));
206
239
  const archive = new URL(request.url).searchParams.get("archive");
207
- return json(await this.transcript(id, archive ?? undefined));
240
+ return json(await this.transcript(id, archive ?? undefined, visitor));
208
241
  }
209
242
  if (method === "POST" && head === "channels" && id && parts.length === 3) {
210
- this.#pane("overview");
243
+ this.#pane("overview", visitor);
211
244
  if (parts[2] === "start-over" || parts[2] === "delete")
212
245
  return cleanupConversation(this.#ports, id, parts[2]);
213
246
  }
214
247
  if (method === "GET" && head === "skills" && parts.length <= 2) {
215
- this.#pane("skills");
248
+ this.#pane("skills", visitor);
216
249
  const skills = this.#ports.features?.skills;
217
250
  if (!skills) throw new HttpError(404, "This pane is not served.");
218
251
  return json(id ? await skillDetail(skills, id) : skillCatalog(skills));
219
252
  }
220
253
  if (method === "GET" && head === "connectors" && parts.length === 1) {
221
- this.#pane("connectors");
254
+ this.#pane("connectors", visitor);
222
255
  const connectors = this.#ports.features?.connectors;
223
256
  if (!connectors) throw new HttpError(404, "This pane is not served.");
224
257
  return json(await connectorsView(connectors));
225
258
  }
226
259
  if (head === "notes" && parts.length <= 2) {
227
- this.#pane("notes");
260
+ this.#pane("notes", visitor);
228
261
  return this.#notes(request, id, visitor);
229
262
  }
230
263
  if (method === "GET" && head === "principals" && !id) {
231
- this.#pane("notes");
264
+ this.#pane("notes", visitor);
232
265
  return json(await this.principals(visitor));
233
266
  }
234
267
  throw new HttpError(404, "There is no such API.");
235
268
  }
236
269
 
270
+ /**
271
+ * The page's config for a visitor: the owners' view as it was, and for another tier the panes
272
+ * of that tier. Where the app admits more than its owners it also says the visitor's tier, the
273
+ * steps they owe, the pages and kinds of room they may see, and who gets push.
274
+ */
275
+ async configFor(visitor: Visitor): Promise<ConfigView> {
276
+ const view = this.config();
277
+ const { access } = this.#ports;
278
+ const hosted = (await this.#ports.hosted?.(visitor)) ?? {
279
+ pages: [],
280
+ kinds: [],
281
+ };
282
+ if (!access)
283
+ return {
284
+ ...view,
285
+ ...(hosted.pages.length > 0 ? { pages: hosted.pages } : {}),
286
+ ...(hosted.kinds.length > 0 ? { kinds: hosted.kinds } : {}),
287
+ };
288
+ const owed = await access.gate(visitor);
289
+ return {
290
+ ...view,
291
+ panes: [...this.#panesOf(visitor)],
292
+ tier: visitor.tier,
293
+ ...(owed.length > 0 ? { gate: owed } : {}),
294
+ pages: hosted.pages,
295
+ kinds: hosted.kinds,
296
+ push: {
297
+ tiers: [...access.push.tiers],
298
+ ...(access.push.key ? { key: access.push.key } : {}),
299
+ ...(access.push.subscribe ? { subscribe: access.push.subscribe } : {}),
300
+ },
301
+ };
302
+ }
303
+
237
304
  config(): ConfigView {
238
305
  const { title, panes, timeZone } = this.#ports;
239
306
  const { presentation, features } = this.#ports;
@@ -306,20 +373,33 @@ export class ConsoleApi {
306
373
 
307
374
  // ── Conversations ──────────────────────────────────────────────────────
308
375
 
309
- async conversations(): Promise<ConversationsView> {
376
+ async conversations(visitor?: Visitor): Promise<ConversationsView> {
310
377
  const { sessionsDir, relayNotes, registry } = this.#ports;
378
+ const owner = visitor === undefined || visitor.tier === "owner";
379
+ // A visitor who is not an owner reads their own private conversations, found by the registry alone.
380
+ const records = registry
381
+ ? await registry.list(
382
+ owner ? undefined : { principal: visitor.principal.id },
383
+ )
384
+ : [];
385
+ const byKey = new Map(records.map((record) => [record.key, record]));
311
386
  const filter = {
312
- excluded: (key: ChannelKey) => this.#hidden(key),
387
+ excluded: (key: ChannelKey) => this.#hidden(key, byKey.get(key)),
313
388
  relayNotes,
314
389
  };
315
- const records = registry ? await registry.list() : [];
390
+ const mine = owner
391
+ ? records
392
+ : records.filter(
393
+ (record) =>
394
+ record.visibility === "private" &&
395
+ record.principalId === visitor.principal.id,
396
+ );
316
397
  // The registry first; the directory scan reads the conversations from before it, by name.
317
- const recorded = registeredConversations(sessionsDir, records, filter);
398
+ const recorded = registeredConversations(sessionsDir, mine, filter);
318
399
  const stored = [
319
400
  ...recorded,
320
- ...storedConversations(sessionsDir, filter),
401
+ ...(owner ? storedConversations(sessionsDir, filter) : []),
321
402
  ].sort((a, b) => (b.lastActive ?? "").localeCompare(a.lastActive ?? ""));
322
- const byKey = new Map(records.map((record) => [record.key, record]));
323
403
  const names = new Map<string, Promise<PrincipalName>>();
324
404
  return {
325
405
  conversations: await Promise.all(
@@ -328,9 +408,9 @@ export class ConsoleApi {
328
408
  };
329
409
  }
330
410
 
331
- #hidden(key: ChannelKey): boolean {
411
+ #hidden(key: ChannelKey, record?: ConversationRecord): boolean {
332
412
  return (
333
- (this.#ports.exclude?.(key) ?? false) ||
413
+ (this.#ports.exclude?.(key, record) ?? false) ||
334
414
  (this.#ports.features?.party?.contains(key) ?? false)
335
415
  );
336
416
  }
@@ -356,10 +436,18 @@ export class ConsoleApi {
356
436
  async transcript(
357
437
  key: string,
358
438
  archive: string | undefined,
439
+ visitor?: Visitor,
359
440
  ): Promise<TranscriptView> {
360
441
  const { sessionsDir, relayNotes } = this.#ports;
442
+ const record = await this.#ports.registry?.get(key as ChannelKey);
361
443
  const parsed = await this.#located(key);
362
- if (!parsed || this.#hidden(key as ChannelKey))
444
+ // Anyone but an owner reads only a private conversation of their own, which the registry records.
445
+ const mayRead =
446
+ visitor === undefined ||
447
+ visitor.tier === "owner" ||
448
+ (record?.visibility === "private" &&
449
+ record.principalId === visitor.principal.id);
450
+ if (!parsed || !mayRead || this.#hidden(key as ChannelKey, record))
363
451
  throw new HttpError(404, "There is no such conversation.");
364
452
  const dir = join(sessionsDir, parsed.dir);
365
453
  const files = conversationFiles(dir);
@@ -378,7 +466,7 @@ export class ConsoleApi {
378
466
  ...(parsed.member ? { member: parsed.member } : {}),
379
467
  ...files,
380
468
  },
381
- await this.#ports.registry?.get(key as ChannelKey),
469
+ record,
382
470
  ),
383
471
  archives,
384
472
  ...(archive !== undefined ? { archive } : {}),
@@ -455,6 +543,12 @@ export class ConsoleApi {
455
543
  /** The people whose notes the pane can show: every principal but the host's own, the visitor first. */
456
544
  async principals(visitor: Visitor): Promise<PrincipalsView> {
457
545
  const self = visitor.principal.id;
546
+ // Anyone but an owner has their own notes and no one else's to choose from.
547
+ if (visitor.tier !== "owner")
548
+ return {
549
+ self,
550
+ principals: [{ id: self, name: visitor.principal.displayName }],
551
+ };
458
552
  const everyone = (await this.#ports.people?.list()) ?? [visitor.principal];
459
553
  const listed = [
460
554
  ...everyone.filter((p) => p.id === self),
@@ -477,6 +571,9 @@ export class ConsoleApi {
477
571
  const named = new URL(request.url).searchParams.get("principal");
478
572
  if (named === null || named === visitor.principal.id)
479
573
  return memoryOf(visitor.principal.id);
574
+ // Another person's notes are the owners' to read.
575
+ if (visitor.tier !== "owner")
576
+ throw new HttpError(403, "Only an owner may do this.");
480
577
  // The host's own principal keeps no notes; anyone else must be a principal the host knows.
481
578
  if (named === SYSTEM_PRINCIPAL || !(await people?.principal(named)))
482
579
  throw new HttpError(404, "There is no such person.");