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.
- package/CHANGELOG.md +54 -0
- package/README.md +165 -12
- package/dist/account-zk4efb87.css +102 -0
- package/dist/chunk-account-ghq105t4.js +1 -0
- package/dist/chunk-action-menu-me0g9qsc.js +1 -0
- package/dist/{chunk-agent-0pm9p3nn.js → chunk-agent-rdv7ekrt.js} +1 -1
- package/dist/chunk-arranged-rooms-81rqhx9x.js +1 -0
- package/dist/chunk-character-creator-bd426yjw.js +1 -0
- package/dist/chunk-codeeditor-ajhgd33t.js +2 -0
- package/dist/chunk-confirm-p3mts8mx.js +1 -0
- package/dist/{chunk-connectors-anhghy3k.js → chunk-connectors-bkv3xn83.js} +1 -1
- package/dist/chunk-conversations-0e0vbxwr.js +1 -0
- package/dist/{chunk-drag-q4fx9b22.js → chunk-drag-peyb8t0k.js} +1 -1
- package/dist/{chunk-entry-he1embgp.js → chunk-entry-mv7q8br0.js} +1 -1
- package/dist/chunk-folder-list-era3tksh.js +1 -0
- package/dist/chunk-folds-hvws0bd5.js +1 -0
- package/dist/chunk-gate-hc7dd0fx.js +1 -0
- package/dist/chunk-group-scene-dkbpmbh5.js +1 -0
- package/dist/chunk-group-table-fqmxgdfw.js +1 -0
- package/dist/chunk-host-icons-4xde9ntk.js +1 -0
- package/dist/chunk-host-picture-014yzk20.js +1 -0
- package/dist/chunk-host-state-t56e5sdy.js +1 -0
- package/dist/chunk-host-ui-p1zy9nmp.js +1 -0
- package/dist/chunk-index-0fmmvgee.js +2 -0
- package/dist/chunk-index-0q15ysrb.js +1 -0
- package/dist/chunk-index-0xh24y25.js +2 -0
- package/dist/chunk-index-2yebd9tb.js +426 -0
- package/dist/chunk-index-4t0r3t3b.js +2 -0
- package/dist/chunk-index-5yzfrf7b.js +138 -0
- package/dist/{chunk-index-d5abvq7v.js → chunk-index-6twb7ccd.js} +2 -2
- package/dist/chunk-index-73gh7kgr.js +920 -0
- package/dist/chunk-index-7xffskfa.js +8 -0
- package/dist/chunk-index-aqw7cy5j.js +1 -0
- package/dist/chunk-index-b6ca02e8.js +2 -0
- package/dist/chunk-index-b8nvhcx5.js +2 -0
- package/dist/{chunk-index-bvfqjh77.js → chunk-index-cvbdf2ca.js} +2 -2
- package/dist/chunk-index-czdketze.js +2 -0
- package/dist/{chunk-index-9jqcskb4.js → chunk-index-dt9775ka.js} +5 -5
- package/dist/chunk-index-eg55mqqe.js +1 -0
- package/dist/chunk-index-gr6e0hse.js +2 -0
- package/dist/chunk-index-gyary7np.js +13 -0
- package/dist/chunk-index-j214tczc.js +1 -0
- package/dist/chunk-index-jd6632ef.js +2 -0
- package/dist/chunk-index-jw7dtshr.js +2 -0
- package/dist/{chunk-index-krtk6nn2.js → chunk-index-k7t14rwa.js} +2 -2
- package/dist/chunk-index-kwcx62wb.js +4 -0
- package/dist/chunk-index-m7fvw92f.js +2 -0
- package/dist/chunk-index-mmg788a1.js +2 -0
- package/dist/chunk-index-mq0dacwr.js +2 -0
- package/dist/chunk-index-p53ak25v.js +2 -0
- package/dist/{chunk-index-27qca4mr.js → chunk-index-pk1yxkv4.js} +2 -2
- package/dist/{chunk-index-9b1kt7qw.css → chunk-index-rbcz9jd4.css} +1 -1
- package/dist/{chunk-index-s681nny9.js → chunk-index-rmxbdmn2.js} +2 -2
- package/dist/chunk-index-rnxj3z8k.js +2 -0
- package/dist/chunk-index-snzzfhk3.js +2 -0
- package/dist/chunk-index-v3bmr0nw.js +2 -0
- package/dist/chunk-index-vcyzwtk2.js +2 -0
- package/dist/{chunk-index-zfd00hwb.js → chunk-index-w2j3njb6.js} +2 -2
- package/dist/chunk-index-w31c4yjv.js +2 -0
- package/dist/chunk-index-wrm8w165.js +2 -0
- package/dist/chunk-index-xh0mg9cw.js +2 -0
- package/dist/chunk-index-ybtvvx85.js +2 -0
- package/dist/chunk-index-yzsgsvnp.js +2 -0
- package/dist/{chunk-index-18gxy79n.js → chunk-index-z9x4cnzf.js} +2 -2
- package/dist/chunk-index-zrvg23jd.js +2 -0
- package/dist/chunk-index-zynq0b0w.js +50 -0
- package/dist/chunk-lang-css-88v3hp3d.js +1 -0
- package/dist/{chunk-lang-dockerfile-d5mzxs60.js → chunk-lang-dockerfile-k728ewsf.js} +1 -1
- package/dist/{chunk-lang-go-830fmhba.js → chunk-lang-go-wjwgay4v.js} +3 -3
- package/dist/chunk-lang-html-07d47es4.js +1 -0
- package/dist/chunk-lang-javascript-fmhhgzwd.js +1 -0
- package/dist/{chunk-lang-json-5eaarz7j.js → chunk-lang-json-7gd89mvs.js} +1 -1
- package/dist/chunk-lang-markdown-b6qbe8bt.js +6 -0
- package/dist/{chunk-lang-python-fjw84w10.js → chunk-lang-python-3v56y31b.js} +2 -2
- package/dist/{chunk-lang-rust-5rbp2zdk.js → chunk-lang-rust-kh0z74d1.js} +1 -1
- package/dist/{chunk-lang-shell-vzagpym3.js → chunk-lang-shell-9zv0zvbx.js} +1 -1
- package/dist/{chunk-lang-sql-0c2w9c2y.js → chunk-lang-sql-yw6bf3t7.js} +2 -2
- package/dist/{chunk-lang-toml-yr9fv6eb.js → chunk-lang-toml-tg1r96nq.js} +1 -1
- package/dist/chunk-lang-typescript-etjjvr2g.js +1 -0
- package/dist/{chunk-lang-xml-eze08p42.js → chunk-lang-xml-ev1mndd4.js} +1 -1
- package/dist/{chunk-lang-yaml-c4b6ft3y.js → chunk-lang-yaml-fz5qpb3r.js} +1 -1
- package/dist/chunk-layout-actions-48ecgtkg.js +1 -0
- package/dist/{chunk-menu-items-jrwxycr1.js → chunk-menu-items-1epy3h0t.js} +2 -2
- package/dist/chunk-mode-toggle-fxmfxsa4.js +1 -0
- package/dist/chunk-note-row-kd0fy7ee.js +75 -0
- package/dist/chunk-notes-yb8f057c.js +1 -0
- package/dist/chunk-notice-bell-wr4f1rph.js +1 -0
- package/dist/chunk-notices-x07da5bb.js +1 -0
- package/dist/chunk-onboarding-csrkbh9z.js +1 -0
- package/dist/chunk-overview-avc2d30t.js +1 -0
- package/dist/chunk-palette-9nhp95bz.js +1 -0
- package/dist/chunk-placement-items-q694dpgk.js +1 -0
- package/dist/chunk-profile-6qemf3m7.js +1 -0
- package/dist/chunk-prompt-history-7enzp2p3.js +1 -0
- package/dist/chunk-prompts-nv6mrn0q.js +1 -0
- package/dist/chunk-room-confirms-zz7n61y7.js +1 -0
- package/dist/chunk-room-menu-rr6nzap1.js +1 -0
- package/dist/chunk-rooms-menu-button-zbtwj47d.js +1 -0
- package/dist/chunk-schedules-rw57nr0m.js +1 -0
- package/dist/{chunk-seat-menu-jv4s7hmj.js → chunk-seat-menu-ys5v9abz.js} +1 -1
- package/dist/chunk-sheet-hosts-jhvaacst.js +1 -0
- package/dist/chunk-skill-editor-sz8an8qr.js +8 -0
- package/dist/chunk-skills-tnr1h16h.js +1 -0
- package/dist/chunk-switcher-list-17enhrse.js +1 -0
- package/dist/chunk-tab-dot-r6brdbmw.js +1 -0
- package/dist/chunk-table-r12cja6p.js +1 -0
- package/dist/chunk-table-seats-g3t392zz.js +1 -0
- package/dist/{chunk-table2d-4sje1fr0.js → chunk-table2d-p3ezsdcj.js} +1 -1
- package/dist/{chunk-table3d-h64skazp.js → chunk-table3d-4x2n2b00.js} +23 -23
- package/dist/chunk-thread-actions-qc6ezkha.js +1 -0
- package/dist/chunk-transcript-n2s6dher.js +1 -0
- package/dist/chunk-where-f3scdydc.js +1 -0
- package/dist/chunk-workspace-xs023bxm.js +1 -0
- package/dist/chunk-zh-tw-efvmk3fd.js +1 -0
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/package.json +5 -4
- package/src/api-types.ts +356 -4
- package/src/app/app.ts +201 -32
- package/src/app/desk.ts +47 -22
- package/src/app/files.ts +73 -7
- package/src/app/presence.ts +2 -2
- package/src/app/protocol.ts +63 -1
- package/src/app/room-log.ts +153 -14
- package/src/app/rooms.ts +39 -0
- package/src/app/routes.ts +168 -28
- package/src/app/socket.ts +349 -81
- package/src/app/sockets.ts +108 -20
- package/src/app/surface.ts +72 -36
- package/src/app/threads.ts +31 -2
- package/src/app/uploads.ts +33 -7
- package/src/auth/authenticator.ts +77 -8
- package/src/auth/login-page.ts +4 -2
- package/src/auth/next.ts +52 -0
- package/src/auth/oidc-pages.ts +62 -0
- package/src/auth/oidc.ts +649 -0
- package/src/auth/sessions.ts +193 -0
- package/src/authz.ts +173 -0
- package/src/console-api.ts +122 -25
- package/src/console-server.ts +102 -12
- package/src/gate.ts +90 -0
- package/src/host/access.ts +161 -0
- package/src/host/cards.ts +112 -0
- package/src/host/context.ts +188 -0
- package/src/host/forms.ts +240 -0
- package/src/host/frames.ts +349 -0
- package/src/host/host-app.ts +178 -0
- package/src/host/host-context.ts +49 -0
- package/src/host/host-routes.ts +396 -0
- package/src/host/pages.ts +266 -0
- package/src/host/reads.ts +155 -0
- package/src/host/retention.ts +41 -0
- package/src/host/room-views.ts +137 -0
- package/src/host/rooms-service.ts +429 -0
- package/src/host/stored-json.ts +13 -0
- package/src/host/views.ts +106 -0
- package/src/i18n/zh-tw.ts +139 -0
- package/src/index.ts +15 -1
- package/src/notices/api.ts +43 -0
- package/src/notices/mount.ts +292 -0
- package/src/notices/service.ts +294 -0
- package/src/notices/store.ts +298 -0
- package/src/notices/urls.ts +38 -0
- package/src/onboarding/api.ts +32 -0
- package/src/onboarding/service.ts +149 -0
- package/src/onboarding/steps.ts +75 -0
- package/src/onboarding/waivers.ts +40 -0
- package/src/options.ts +534 -34
- package/src/page-presentation.ts +47 -2
- package/src/profile/api.ts +175 -0
- package/src/profile/links.ts +162 -0
- package/src/push/api.ts +189 -52
- package/src/push/dispatch.ts +156 -0
- package/src/push/drain.ts +139 -0
- package/src/push/outbox.ts +218 -0
- package/src/push/prefs.ts +118 -0
- package/src/push/sender.ts +7 -2
- package/src/push/triggers.ts +56 -5
- package/src/push/vapid.ts +19 -0
- package/src/push/web-push-transport.ts +106 -0
- package/src/push-sender/index.ts +14 -0
- package/src/push-sender/run.ts +118 -0
- package/src/push-sender/session.ts +54 -0
- package/src/pwa.ts +32 -4
- package/src/visitors.ts +94 -6
- package/src/web-app.ts +718 -0
- package/src/web-plugin.ts +279 -26
- package/dist/chunk-action-menu-qe839g4b.js +0 -1
- package/dist/chunk-arranged-rooms-enpfswmg.js +0 -1
- package/dist/chunk-character-creator-6yf29xwx.js +0 -1
- package/dist/chunk-codeeditor-gdbdg7rp.js +0 -2
- package/dist/chunk-confirm-xrw7d732.js +0 -1
- package/dist/chunk-conversations-ry228aes.js +0 -1
- package/dist/chunk-folder-list-5cp8stna.js +0 -1
- package/dist/chunk-folds-hff104tk.js +0 -1
- package/dist/chunk-group-scene-h228jdat.js +0 -1
- package/dist/chunk-index-0p5h2kdz.js +0 -2
- package/dist/chunk-index-0pt08yd9.js +0 -1
- package/dist/chunk-index-12bw9yj8.js +0 -2
- package/dist/chunk-index-2ad17eh4.js +0 -2
- package/dist/chunk-index-31wh5twj.js +0 -52
- package/dist/chunk-index-4dy94pbz.js +0 -2
- package/dist/chunk-index-4ecjtdy8.js +0 -13
- package/dist/chunk-index-4v1b7ks9.js +0 -2
- package/dist/chunk-index-5d8yjj4m.js +0 -2
- package/dist/chunk-index-89ajmxh7.js +0 -2
- package/dist/chunk-index-b9ny4623.js +0 -2
- package/dist/chunk-index-e1dr6g10.js +0 -2
- package/dist/chunk-index-gpkzhq1g.js +0 -2
- package/dist/chunk-index-hk3xyy20.js +0 -2
- package/dist/chunk-index-jm23vn03.js +0 -4
- package/dist/chunk-index-k7gh0jvz.js +0 -2
- package/dist/chunk-index-ky42ev0b.js +0 -2
- package/dist/chunk-index-pa2yyqh0.js +0 -2
- package/dist/chunk-index-pcvt668q.js +0 -2
- package/dist/chunk-index-pqw27eye.js +0 -1
- package/dist/chunk-index-wmbmmrd4.js +0 -302
- package/dist/chunk-index-ymbjrsp6.js +0 -2
- package/dist/chunk-lang-css-t85r8rz0.js +0 -1
- package/dist/chunk-lang-html-n6jparge.js +0 -1
- package/dist/chunk-lang-javascript-xj00cp97.js +0 -1
- package/dist/chunk-lang-markdown-zxx8eke8.js +0 -6
- package/dist/chunk-lang-typescript-8f6yywt3.js +0 -1
- package/dist/chunk-layout-actions-bv0chj41.js +0 -1
- package/dist/chunk-notes-9b7pgqfe.js +0 -1
- package/dist/chunk-overview-8yw71esz.js +0 -1
- package/dist/chunk-palette-frms7axe.js +0 -1
- package/dist/chunk-placement-items-fvfy9m0y.js +0 -1
- package/dist/chunk-prompt-history-yrqehads.js +0 -1
- package/dist/chunk-prompts-g6609tqx.js +0 -1
- package/dist/chunk-room-confirms-0a2y0925.js +0 -1
- package/dist/chunk-room-menu-g11q97br.js +0 -1
- package/dist/chunk-rooms-menu-button-h9qge48n.js +0 -1
- package/dist/chunk-schedules-ezmacrbj.js +0 -1
- package/dist/chunk-sheet-hosts-b0azyfhb.js +0 -1
- package/dist/chunk-skill-editor-h6k6whwg.js +0 -8
- package/dist/chunk-skills-gqcfhq56.js +0 -1
- package/dist/chunk-switcher-list-mcpamqnj.js +0 -1
- package/dist/chunk-table-s8cde1xz.js +0 -1
- package/dist/chunk-table-seats-wqkjn8bc.js +0 -1
- package/dist/chunk-transcript-c59c6mpm.js +0 -1
- package/dist/chunk-workspace-0cyspkk4.js +0 -1
- 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
|
+
}
|
package/src/console-api.ts
CHANGED
|
@@ -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
|
-
|
|
158
|
-
|
|
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.
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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.");
|