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
package/src/console-server.ts
CHANGED
|
@@ -6,10 +6,20 @@ import type {
|
|
|
6
6
|
Authenticator,
|
|
7
7
|
Unauthenticated,
|
|
8
8
|
} from "./auth/authenticator.ts";
|
|
9
|
+
import { allowedNext, nextOfPath } from "./auth/next.ts";
|
|
10
|
+
import { type Decision, decide } from "./authz.ts";
|
|
9
11
|
import type { ConsoleApi } from "./console-api.ts";
|
|
10
|
-
import type
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
12
|
+
import { type ConsolePresentation, message } from "./features.ts";
|
|
13
|
+
import type { Gate } from "./gate.ts";
|
|
14
|
+
import { json } from "./http.ts";
|
|
15
|
+
import {
|
|
16
|
+
defaultDescription,
|
|
17
|
+
type PageMeta,
|
|
18
|
+
presentPage,
|
|
19
|
+
} from "./page-presentation.ts";
|
|
20
|
+
import { hasMarkSvg, isPublicStatic, servePwa } from "./pwa.ts";
|
|
21
|
+
import type { Visitor } from "./visitors.ts";
|
|
22
|
+
import { viewerOf } from "./web-app.ts";
|
|
13
23
|
|
|
14
24
|
const encoder = new TextEncoder();
|
|
15
25
|
/** Changes closer together than this reach the page as one event. */
|
|
@@ -53,6 +63,10 @@ export interface ConsoleServerOptions {
|
|
|
53
63
|
): Promise<Response | undefined>;
|
|
54
64
|
websocket: WebSocketRoute<AppSocket>;
|
|
55
65
|
};
|
|
66
|
+
/** The onboarding gate; without it nobody is gated. */
|
|
67
|
+
gate?: Pick<Gate, "required">;
|
|
68
|
+
/** The app's surface, for the `next` a turned-away page navigation asks to come back to. */
|
|
69
|
+
surface?: string;
|
|
56
70
|
/** Registers the listener with every source whose change the console shows. */
|
|
57
71
|
subscribe(listener: () => void): void;
|
|
58
72
|
logger: Logger;
|
|
@@ -66,17 +80,24 @@ export interface ConsoleServerOptions {
|
|
|
66
80
|
/** Where the install icons come from; default the ones this package ships. */
|
|
67
81
|
iconsDir?: string;
|
|
68
82
|
look?: "modern" | "camelot";
|
|
83
|
+
/** The console's own origin, for the absolute `og:image` of a link preview. */
|
|
84
|
+
origin?: string;
|
|
85
|
+
/** What the page says about itself in a search result and a link preview; default derived from the title. */
|
|
86
|
+
description?: string;
|
|
69
87
|
}
|
|
70
88
|
|
|
71
89
|
/**
|
|
72
90
|
* The console on the host's listener: `<mount>` redirects to `<mount>/`, and everything under
|
|
73
|
-
* it needs the authenticator to admit the request
|
|
74
|
-
*
|
|
75
|
-
*
|
|
91
|
+
* it needs the authenticator to admit the request, apart from what it answers openly, such as a
|
|
92
|
+
* login page. An admitted request is then judged by the authorization table before any handler
|
|
93
|
+
* sees it: a route that is not listed answers 404, one for owners answers a member 403, and while
|
|
94
|
+
* the person owes a required step only the routes open to them answer. A request that changes
|
|
95
|
+
* data must also carry the console's own `Origin`.
|
|
76
96
|
*/
|
|
77
97
|
export class ConsoleServer {
|
|
78
98
|
readonly #options: ConsoleServerOptions;
|
|
79
99
|
readonly #base: string;
|
|
100
|
+
readonly #meta: PageMeta | undefined;
|
|
80
101
|
readonly #streams = new Set<ReadableStreamDefaultController<Uint8Array>>();
|
|
81
102
|
#pending: ReturnType<typeof setTimeout> | undefined;
|
|
82
103
|
#ping: ReturnType<typeof setInterval> | undefined;
|
|
@@ -84,6 +105,16 @@ export class ConsoleServer {
|
|
|
84
105
|
constructor(options: ConsoleServerOptions) {
|
|
85
106
|
this.#options = options;
|
|
86
107
|
this.#base = `${options.mount}/`;
|
|
108
|
+
const title = options.title ?? "Roundtable";
|
|
109
|
+
this.#meta = options.origin
|
|
110
|
+
? {
|
|
111
|
+
title,
|
|
112
|
+
description: options.description ?? defaultDescription(title),
|
|
113
|
+
origin: options.origin,
|
|
114
|
+
mount: options.mount,
|
|
115
|
+
svg: hasMarkSvg(options.iconsDir),
|
|
116
|
+
}
|
|
117
|
+
: undefined;
|
|
87
118
|
options.subscribe(() => this.#changed());
|
|
88
119
|
}
|
|
89
120
|
|
|
@@ -145,6 +176,11 @@ export class ConsoleServer {
|
|
|
145
176
|
if (path !== this.#options.mount && !path.startsWith(this.#base))
|
|
146
177
|
return new Response("Not found", { status: 404 });
|
|
147
178
|
const rest = path.slice(this.#base.length);
|
|
179
|
+
// Static and the same for everyone: a sign-in page and a link preview show the icon without a session.
|
|
180
|
+
if (READ_METHODS.has(request.method) && isPublicStatic(rest)) {
|
|
181
|
+
const shown = servePwa(rest, this.#pwa());
|
|
182
|
+
if (shown) return shown;
|
|
183
|
+
}
|
|
148
184
|
const open = await this.#options.authenticator.open?.(request, rest);
|
|
149
185
|
if (open) return open;
|
|
150
186
|
const admission = await this.#options.authenticator.authenticate(request);
|
|
@@ -154,6 +190,11 @@ export class ConsoleServer {
|
|
|
154
190
|
status: 302,
|
|
155
191
|
headers: { location: this.#base },
|
|
156
192
|
});
|
|
193
|
+
const decision = decide(request.method, rest, {
|
|
194
|
+
tier: admission.visitor.tier,
|
|
195
|
+
gated: await this.#gated(admission.visitor),
|
|
196
|
+
});
|
|
197
|
+
if (!decision.allow) return this.#denied(decision);
|
|
157
198
|
if (rest === "api/events" && request.method === "GET")
|
|
158
199
|
return this.#events();
|
|
159
200
|
const owned = await this.#options.app?.handle(request, rest, admission);
|
|
@@ -166,10 +207,7 @@ export class ConsoleServer {
|
|
|
166
207
|
);
|
|
167
208
|
if (!READ_METHODS.has(request.method))
|
|
168
209
|
return new Response("Method not allowed", { status: 405 });
|
|
169
|
-
const installable = servePwa(rest,
|
|
170
|
-
...this.#options,
|
|
171
|
-
title: this.#options.title ?? "Roundtable",
|
|
172
|
-
});
|
|
210
|
+
const installable = servePwa(rest, this.#pwa());
|
|
173
211
|
if (installable) return installable;
|
|
174
212
|
const asset = this.#options.assets.get(rest);
|
|
175
213
|
if (asset && rest !== "index.html" && rest !== "sw.js")
|
|
@@ -188,6 +226,7 @@ export class ConsoleServer {
|
|
|
188
226
|
this.#options.pathRouting ? this.#options.mount : undefined,
|
|
189
227
|
this.#options.presentation,
|
|
190
228
|
this.#options.look,
|
|
229
|
+
this.#meta,
|
|
191
230
|
),
|
|
192
231
|
{
|
|
193
232
|
headers: {
|
|
@@ -206,6 +245,47 @@ export class ConsoleServer {
|
|
|
206
245
|
return new Response("Not found", { status: 404 });
|
|
207
246
|
}
|
|
208
247
|
|
|
248
|
+
#pwa(): Parameters<typeof servePwa>[1] {
|
|
249
|
+
return { ...this.#options, title: this.#options.title ?? "Roundtable" };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async #gated(visitor: Visitor): Promise<boolean> {
|
|
253
|
+
const { gate } = this.#options;
|
|
254
|
+
if (!gate) return false;
|
|
255
|
+
try {
|
|
256
|
+
return (await gate.required(viewerOf(visitor))).length > 0;
|
|
257
|
+
} catch (error) {
|
|
258
|
+
// A gate that cannot be read stays shut.
|
|
259
|
+
this.#options.logger.warn({ err: error }, "the gate was not read");
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The answer to a request the authorization table refused. */
|
|
265
|
+
#denied(decision: Extract<Decision, { allow: false }>): Response {
|
|
266
|
+
const { presentation, logger } = this.#options;
|
|
267
|
+
logger.debug({ reason: decision.reason }, "console request denied");
|
|
268
|
+
if (decision.gate)
|
|
269
|
+
return json(
|
|
270
|
+
{
|
|
271
|
+
error: message(presentation, "Finish setting up first."),
|
|
272
|
+
gate: true,
|
|
273
|
+
},
|
|
274
|
+
403,
|
|
275
|
+
);
|
|
276
|
+
return json(
|
|
277
|
+
{
|
|
278
|
+
error: message(
|
|
279
|
+
presentation,
|
|
280
|
+
decision.status === 404
|
|
281
|
+
? "There is no such API."
|
|
282
|
+
: "Only an owner may do this.",
|
|
283
|
+
),
|
|
284
|
+
},
|
|
285
|
+
decision.status,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
209
289
|
/**
|
|
210
290
|
* The answer to a request the authenticator refused: a page navigation that signing in would
|
|
211
291
|
* fix goes to the login page, everything else gets the status and a fixed body.
|
|
@@ -215,14 +295,24 @@ export class ConsoleServer {
|
|
|
215
295
|
refusal.login &&
|
|
216
296
|
READ_METHODS.has(request.method) &&
|
|
217
297
|
!rest.startsWith("api/")
|
|
218
|
-
)
|
|
298
|
+
) {
|
|
299
|
+
// A link can name where to come back to (`?next=`), and so can a path route; a hash route's place never reaches the server.
|
|
300
|
+
const surface = this.#options.surface ?? "app";
|
|
301
|
+
const named = allowedNext(
|
|
302
|
+
URL.parse(request.url)?.searchParams.get("next"),
|
|
303
|
+
surface,
|
|
304
|
+
);
|
|
305
|
+
const next =
|
|
306
|
+
named ??
|
|
307
|
+
(this.#options.pathRouting ? nextOfPath(rest, surface) : undefined);
|
|
219
308
|
return new Response(null, {
|
|
220
309
|
status: 303,
|
|
221
310
|
headers: {
|
|
222
|
-
location: `${this.#options.mount}/login`,
|
|
311
|
+
location: `${this.#options.mount}/login${next ? `?next=${encodeURIComponent(next)}` : ""}`,
|
|
223
312
|
"cache-control": "no-store",
|
|
224
313
|
},
|
|
225
314
|
});
|
|
315
|
+
}
|
|
226
316
|
// A missing session is the normal way to meet a login page, not worth a line each time.
|
|
227
317
|
const { logger } = this.#options;
|
|
228
318
|
const fields = { refusal: refusal.refusal };
|
package/src/gate.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { GateStepView } from "./api-types.ts";
|
|
2
|
+
import type { Viewer, WebAppRegistry } from "./web-app.ts";
|
|
3
|
+
|
|
4
|
+
/** How long a person's owed steps are remembered, so a page of requests asks each step once. */
|
|
5
|
+
const MEMO_MS = 5_000;
|
|
6
|
+
|
|
7
|
+
export interface GateOptions {
|
|
8
|
+
registry: Pick<WebAppRegistry, "steps" | "waived">;
|
|
9
|
+
/** The clock, in milliseconds; default `Date.now`. */
|
|
10
|
+
now?: () => number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface Memo {
|
|
14
|
+
at: number;
|
|
15
|
+
steps: GateStepView[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The onboarding gate. A person owes a step when it applies to them and is neither done nor
|
|
20
|
+
* waived. While they owe a required step, only the routes marked G and the few frames a gated
|
|
21
|
+
* socket may have answer them; the rest answer 403 with `gate: true`. A step that cannot be read
|
|
22
|
+
* counts as undone, so a failing lookup keeps the gate shut rather than opening it.
|
|
23
|
+
*/
|
|
24
|
+
export class Gate {
|
|
25
|
+
readonly #options: GateOptions;
|
|
26
|
+
readonly #memo = new Map<string, Memo>();
|
|
27
|
+
readonly #listeners = new Set<(principalId: string) => void>();
|
|
28
|
+
|
|
29
|
+
constructor(options: GateOptions) {
|
|
30
|
+
this.#options = options;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#now(): number {
|
|
34
|
+
return (this.#options.now ?? Date.now)();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The steps this viewer still owes, required or not, in the order they were registered. */
|
|
38
|
+
async owed(viewer: Viewer): Promise<GateStepView[]> {
|
|
39
|
+
const found = this.#memo.get(viewer.principalId);
|
|
40
|
+
if (found && this.#now() - found.at < MEMO_MS) return found.steps;
|
|
41
|
+
const steps: GateStepView[] = [];
|
|
42
|
+
for (const step of this.#options.registry.steps()) {
|
|
43
|
+
try {
|
|
44
|
+
if (step.applies && !(await step.applies(viewer))) continue;
|
|
45
|
+
if (await step.done(viewer)) continue;
|
|
46
|
+
const waived = step.required
|
|
47
|
+
? await this.#options.registry.waived(viewer.principalId, step.id)
|
|
48
|
+
: false;
|
|
49
|
+
if (waived) continue;
|
|
50
|
+
steps.push({
|
|
51
|
+
id: step.id,
|
|
52
|
+
label: step.label,
|
|
53
|
+
...(step.help ? { help: step.help } : {}),
|
|
54
|
+
...(step.builtin ? { builtin: step.builtin } : {}),
|
|
55
|
+
required: step.required,
|
|
56
|
+
open: step.open,
|
|
57
|
+
...(step.required && step.waiver ? { waiver: step.waiver } : {}),
|
|
58
|
+
});
|
|
59
|
+
} catch {
|
|
60
|
+
// A step that cannot be read is undone; a required one keeps the gate shut.
|
|
61
|
+
steps.push({
|
|
62
|
+
id: step.id,
|
|
63
|
+
label: step.label,
|
|
64
|
+
...(step.builtin ? { builtin: step.builtin } : {}),
|
|
65
|
+
required: step.required,
|
|
66
|
+
open: step.open,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
this.#memo.set(viewer.principalId, { at: this.#now(), steps });
|
|
71
|
+
return steps;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Whether a required step stands undone; the steps it names are those. */
|
|
75
|
+
async required(viewer: Viewer): Promise<GateStepView[]> {
|
|
76
|
+
return (await this.owed(viewer)).filter((step) => step.required);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A person's steps may differ now (they linked an account, waived a step): ask again. */
|
|
80
|
+
changed(principalId: string): void {
|
|
81
|
+
this.#memo.delete(principalId);
|
|
82
|
+
for (const listener of this.#listeners) listener(principalId);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Hears of a change to a person's steps, so their open sockets can be told. */
|
|
86
|
+
onChanged(listener: (principalId: string) => void): () => void {
|
|
87
|
+
this.#listeners.add(listener);
|
|
88
|
+
return () => this.#listeners.delete(listener);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { ChannelKey, Logger } from "pi-roundtable";
|
|
2
|
+
import { viewerOfSocket } from "../app/socket.ts";
|
|
3
|
+
import type { AppSocket, Sockets } from "../app/sockets.ts";
|
|
4
|
+
import type {
|
|
5
|
+
HostRoomAccess,
|
|
6
|
+
RoomAccess,
|
|
7
|
+
Viewer,
|
|
8
|
+
WebAppRegistry,
|
|
9
|
+
} from "../web-app.ts";
|
|
10
|
+
|
|
11
|
+
/** How long an answer of a kind's `access` is trusted: 10 seconds. */
|
|
12
|
+
export const ACCESS_TTL_MS = 10_000;
|
|
13
|
+
|
|
14
|
+
interface Entry {
|
|
15
|
+
at: number;
|
|
16
|
+
/** What the kind said then; undefined = no access. */
|
|
17
|
+
access: RoomAccess | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface HostAccessOptions {
|
|
21
|
+
registry: Pick<WebAppRegistry, "hostKindOf">;
|
|
22
|
+
sockets: Pick<Sockets, "each">;
|
|
23
|
+
logger: Logger;
|
|
24
|
+
/** The clock, in milliseconds; default `Date.now`. */
|
|
25
|
+
now?: () => number;
|
|
26
|
+
/** How long an answer is trusted; default 10 s. */
|
|
27
|
+
ttlMs?: number;
|
|
28
|
+
/** Hears that a person's reading of a room was cut off, so their page can be told. */
|
|
29
|
+
revoked?(connection: AppSocket, room: string): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What decides who reads a host room right now: the kind's `access`, asked again at least every
|
|
34
|
+
* ten seconds for as long as anyone follows the room, and at once when the host says access
|
|
35
|
+
* changed. A frame is delivered only to a socket whose person the memo says may read, so a revoked
|
|
36
|
+
* reader stops receiving within the TTL even if the host never says so, and an error of the
|
|
37
|
+
* kind's is a refusal, never a pass.
|
|
38
|
+
*/
|
|
39
|
+
export class HostAccess implements HostRoomAccess {
|
|
40
|
+
readonly #options: HostAccessOptions;
|
|
41
|
+
readonly #memo = new Map<string, Entry>();
|
|
42
|
+
readonly #inflight = new Map<string, Promise<RoomAccess | undefined>>();
|
|
43
|
+
readonly #ttl: number;
|
|
44
|
+
#timer: ReturnType<typeof setInterval> | undefined;
|
|
45
|
+
|
|
46
|
+
constructor(options: HostAccessOptions) {
|
|
47
|
+
this.#options = options;
|
|
48
|
+
this.#ttl = options.ttlMs ?? ACCESS_TTL_MS;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#now(): number {
|
|
52
|
+
return (this.#options.now ?? Date.now)();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
static #key(principalId: string, room: string): string {
|
|
56
|
+
return `${principalId}\u0000${room}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Re-asks about the rooms that are followed, a few times per TTL. */
|
|
60
|
+
start(): void {
|
|
61
|
+
if (this.#timer) return;
|
|
62
|
+
this.#timer = setInterval(
|
|
63
|
+
() => this.#refresh(this.#ttl / 2),
|
|
64
|
+
Math.max(50, this.#ttl / 4),
|
|
65
|
+
);
|
|
66
|
+
this.#timer.unref?.();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
stop(): void {
|
|
70
|
+
clearInterval(this.#timer);
|
|
71
|
+
this.#timer = undefined;
|
|
72
|
+
this.#memo.clear();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** What the kind grants the viewer in the room now, from the memo when it is fresh; undefined for no access. */
|
|
76
|
+
async of(viewer: Viewer, room: string): Promise<RoomAccess | undefined> {
|
|
77
|
+
const key = HostAccess.#key(viewer.principalId, room);
|
|
78
|
+
const found = this.#memo.get(key);
|
|
79
|
+
if (found && this.#now() - found.at <= this.#ttl) return found.access;
|
|
80
|
+
return this.#ask(viewer, room);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async read(viewer: Viewer, room: ChannelKey): Promise<boolean> {
|
|
84
|
+
return (await this.of(viewer, room)) !== undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Whether the memo, still fresh, says this socket's person may read the room. */
|
|
88
|
+
readNow(connection: AppSocket, room: string): boolean {
|
|
89
|
+
const found = this.#memo.get(
|
|
90
|
+
HostAccess.#key(connection.principal.id, room),
|
|
91
|
+
);
|
|
92
|
+
return (
|
|
93
|
+
found !== undefined &&
|
|
94
|
+
found.access !== undefined &&
|
|
95
|
+
this.#now() - found.at <= this.#ttl
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#ask(viewer: Viewer, room: string): Promise<RoomAccess | undefined> {
|
|
100
|
+
const key = HostAccess.#key(viewer.principalId, room);
|
|
101
|
+
const running = this.#inflight.get(key);
|
|
102
|
+
if (running) return running;
|
|
103
|
+
const found = this.#options.registry.hostKindOf(room);
|
|
104
|
+
const answer = (async () => {
|
|
105
|
+
let access: RoomAccess | undefined;
|
|
106
|
+
if (found) {
|
|
107
|
+
try {
|
|
108
|
+
access = await found.kind.access(viewer, found.id);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
this.#options.logger.warn(
|
|
111
|
+
{ kind: found.kind.kind, err: error },
|
|
112
|
+
"a kind of room could not say who may read; the room stays closed",
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
this.#memo.set(key, { at: this.#now(), access });
|
|
117
|
+
return access;
|
|
118
|
+
})().finally(() => this.#inflight.delete(key));
|
|
119
|
+
this.#inflight.set(key, answer);
|
|
120
|
+
return answer;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Re-asks for every followed room whose answer is older than `olderThan`. */
|
|
124
|
+
#refresh(olderThan: number): Promise<unknown> {
|
|
125
|
+
const asks: Promise<unknown>[] = [];
|
|
126
|
+
this.#options.sockets.each(
|
|
127
|
+
(connection) => connection.subscriptions.size > 0,
|
|
128
|
+
(connection) => {
|
|
129
|
+
const viewer = viewerOfSocket(connection);
|
|
130
|
+
for (const room of [...connection.subscriptions]) {
|
|
131
|
+
const found = this.#memo.get(
|
|
132
|
+
HostAccess.#key(viewer.principalId, room),
|
|
133
|
+
);
|
|
134
|
+
if (found && this.#now() - found.at < olderThan) continue;
|
|
135
|
+
asks.push(
|
|
136
|
+
this.#ask(viewer, room).then((access) => {
|
|
137
|
+
if (access) return;
|
|
138
|
+
// The person lost the room: stop following it and tell their page.
|
|
139
|
+
connection.subscriptions.delete(room);
|
|
140
|
+
if (connection.focus === room) connection.focus = undefined;
|
|
141
|
+
this.#options.revoked?.(connection, room);
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
return Promise.allSettled(asks);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Drops what is remembered of a room, of everyone's or one person's, and asks the followers again at once. */
|
|
151
|
+
forget(where: { room?: string; principalId?: string }): Promise<unknown> {
|
|
152
|
+
for (const key of [...this.#memo.keys()]) {
|
|
153
|
+
const [principalId, room] = key.split("\u0000");
|
|
154
|
+
if (where.room !== undefined && room !== where.room) continue;
|
|
155
|
+
if (where.principalId !== undefined && principalId !== where.principalId)
|
|
156
|
+
continue;
|
|
157
|
+
this.#memo.delete(key);
|
|
158
|
+
}
|
|
159
|
+
return this.#refresh(0);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import type { Migration } from "pi-roundtable";
|
|
3
|
+
import type { HostCard, StoredHostCard } from "../web-app.ts";
|
|
4
|
+
import { storedJson } from "./stored-json.ts";
|
|
5
|
+
|
|
6
|
+
interface Row {
|
|
7
|
+
id: string;
|
|
8
|
+
room: string;
|
|
9
|
+
card: HostCard | string;
|
|
10
|
+
created_at: Date;
|
|
11
|
+
updated_at: Date;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function cardOf(row: Row): StoredHostCard {
|
|
15
|
+
return {
|
|
16
|
+
...storedJson(row.card, "host card"),
|
|
17
|
+
id: row.id,
|
|
18
|
+
room: row.room as StoredHostCard["room"],
|
|
19
|
+
createdAt: row.created_at,
|
|
20
|
+
updatedAt: row.updated_at,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The cards hosts put in their rooms, kept in `web_host_cards`: a title and body, a state, links
|
|
26
|
+
* and buttons, which the host updates in place as the thing the card stands for moves on. Held-call
|
|
27
|
+
* approvals and questions are not here; they are the desk's cards.
|
|
28
|
+
*/
|
|
29
|
+
export class HostCardLog {
|
|
30
|
+
readonly #sql: SQL;
|
|
31
|
+
|
|
32
|
+
constructor(sql: SQL) {
|
|
33
|
+
this.#sql = sql;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
static readonly migration: Migration = {
|
|
37
|
+
name: "web-host-cards",
|
|
38
|
+
up: async (sql) => {
|
|
39
|
+
await sql`
|
|
40
|
+
CREATE TABLE IF NOT EXISTS web_host_cards (
|
|
41
|
+
id text PRIMARY KEY,
|
|
42
|
+
room text NOT NULL,
|
|
43
|
+
card jsonb NOT NULL,
|
|
44
|
+
state text,
|
|
45
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
46
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
47
|
+
)`;
|
|
48
|
+
await sql`CREATE INDEX IF NOT EXISTS web_host_cards_room_created ON web_host_cards (room, created_at)`;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** The mark of a card the host keeps past its kind's retention (`HostCard.keep`). */
|
|
53
|
+
static readonly keepMigration: Migration = {
|
|
54
|
+
name: "web-host-cards-keep",
|
|
55
|
+
up: async (sql) => {
|
|
56
|
+
await sql`
|
|
57
|
+
ALTER TABLE web_host_cards ADD COLUMN IF NOT EXISTS keep boolean NOT NULL DEFAULT false`;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
async create(room: string, card: HostCard): Promise<StoredHostCard> {
|
|
62
|
+
const id = crypto.randomUUID();
|
|
63
|
+
const [row]: Row[] = await this.#sql`
|
|
64
|
+
INSERT INTO web_host_cards (id, room, card, state, keep)
|
|
65
|
+
VALUES (${id}, ${room}, ${JSON.stringify(card)}::jsonb, ${card.state ?? null}, ${card.keep === true})
|
|
66
|
+
RETURNING *`;
|
|
67
|
+
if (!row) throw new Error("the card log stored nothing");
|
|
68
|
+
return cardOf(row);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Replaces a card of the room; undefined when the room holds no such card. */
|
|
72
|
+
async update(
|
|
73
|
+
room: string,
|
|
74
|
+
id: string,
|
|
75
|
+
card: HostCard,
|
|
76
|
+
): Promise<StoredHostCard | undefined> {
|
|
77
|
+
const [row]: Row[] = await this.#sql`
|
|
78
|
+
UPDATE web_host_cards
|
|
79
|
+
SET card = ${JSON.stringify(card)}::jsonb, state = ${card.state ?? null}, keep = ${card.keep === true}, updated_at = now()
|
|
80
|
+
WHERE id = ${id} AND room = ${room} RETURNING *`;
|
|
81
|
+
return row && cardOf(row);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async get(room: string, id: string): Promise<StoredHostCard | undefined> {
|
|
85
|
+
const [row]: Row[] = await this.#sql`
|
|
86
|
+
SELECT * FROM web_host_cards WHERE id = ${id} AND room = ${room}`;
|
|
87
|
+
return row && cardOf(row);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The room's cards, oldest first. */
|
|
91
|
+
async list(room: string): Promise<StoredHostCard[]> {
|
|
92
|
+
const rows: Row[] = await this.#sql`
|
|
93
|
+
SELECT * FROM web_host_cards WHERE room = ${room} ORDER BY created_at, id`;
|
|
94
|
+
return rows.map(cardOf);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Deletes the cards of the rooms whose key starts with `prefix` that are older than `before`,
|
|
99
|
+
* except those the host marked `keep`; how many.
|
|
100
|
+
*/
|
|
101
|
+
async sweepKind(prefix: string, before: Date): Promise<number> {
|
|
102
|
+
const rows: unknown[] = await this.#sql`
|
|
103
|
+
DELETE FROM web_host_cards
|
|
104
|
+
WHERE starts_with(room, ${prefix}) AND created_at < ${before}
|
|
105
|
+
AND NOT keep RETURNING 1`;
|
|
106
|
+
return rows.length;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async removeRoom(room: string): Promise<void> {
|
|
110
|
+
await this.#sql`DELETE FROM web_host_cards WHERE room = ${room}`;
|
|
111
|
+
}
|
|
112
|
+
}
|