pi-roundtable-web 0.24.2 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -0
- package/README.md +195 -16
- package/dist/account-zk4efb87.css +102 -0
- package/dist/chunk-account-hw49gd0d.js +1 -0
- package/dist/chunk-action-menu-rm5cyqkj.js +1 -0
- package/dist/chunk-agent-b017vswv.js +1 -0
- package/dist/chunk-arranged-rooms-q5v3dm1r.js +1 -0
- package/dist/chunk-character-creator-5dxcvd16.js +1 -0
- package/dist/chunk-codeeditor-pyt09fwg.js +2 -0
- package/dist/chunk-confirm-521yqmta.js +1 -0
- package/dist/chunk-connectors-hrm06nx8.js +1 -0
- package/dist/chunk-conversations-e26rrf01.js +1 -0
- package/dist/chunk-drag-v41gx2mc.js +1 -0
- package/dist/chunk-entry-jxxb3ath.js +1 -0
- package/dist/chunk-folder-list-b97xk12b.js +1 -0
- package/dist/chunk-folds-b8q7eags.js +1 -0
- package/dist/chunk-gate-8k61stsc.js +1 -0
- package/dist/chunk-group-scene-dx6wt19m.js +1 -0
- package/dist/chunk-group-table-e4jr8r3a.js +1 -0
- package/dist/chunk-host-state-g7e7c0k5.js +1 -0
- package/dist/chunk-host-ui-x7k6n07v.js +939 -0
- package/dist/chunk-index-014rjv9k.js +2 -0
- package/dist/chunk-index-2d0kg096.js +2 -0
- package/dist/chunk-index-2qd81wbx.js +2 -0
- package/dist/chunk-index-2stynxw6.css +1 -0
- package/dist/chunk-index-459drsft.js +2 -0
- package/dist/chunk-index-4mfj596y.js +2 -0
- package/dist/chunk-index-66nskrdj.js +47 -0
- package/dist/chunk-index-awx6nnta.js +2 -0
- package/dist/chunk-index-bbsytycb.js +2 -0
- package/dist/chunk-index-c07p8k0x.js +4 -0
- package/dist/chunk-index-cq1e9xdz.js +2 -0
- package/dist/chunk-index-d1prgfbp.js +2 -0
- package/dist/chunk-index-dhpt8qr9.js +2 -0
- package/dist/chunk-index-e5sd30xy.js +2 -0
- package/dist/chunk-index-g351921r.js +1 -0
- package/dist/chunk-index-hby0ap91.js +2 -0
- package/dist/chunk-index-hssjbr1k.js +2 -0
- package/dist/chunk-index-k2qq1gdj.js +2 -0
- package/dist/chunk-index-ke9ynm0n.js +2 -0
- package/dist/chunk-index-km40bhsz.js +2 -0
- package/dist/chunk-index-kpscvg85.js +2 -0
- package/dist/chunk-index-m0d100mg.js +2 -0
- package/dist/chunk-index-mb72snk9.js +2 -0
- package/dist/chunk-index-mjqqver5.js +4 -0
- package/dist/chunk-index-nqfkwkfp.js +2 -0
- package/dist/chunk-index-nwqk447w.js +2 -0
- package/dist/chunk-index-pcfhpm8v.js +302 -0
- package/dist/chunk-index-q8h8xzbm.js +2 -0
- package/dist/chunk-index-r5vszgfs.js +2 -0
- package/dist/{chunk-index-mpm90318.js → chunk-index-rhtvcbsq.js} +2 -2
- package/dist/chunk-index-rjv7vrwd.js +13 -0
- package/dist/chunk-index-v23m38p6.js +50 -0
- package/dist/chunk-index-va0g7d88.js +1 -0
- package/dist/{chunk-index-6t68gbex.js → chunk-index-wnjp6wv0.js} +2 -2
- package/dist/chunk-index-xgkd15n3.js +2 -0
- package/dist/chunk-index-y8rnq8zf.js +14 -0
- package/dist/chunk-index-z3k18atc.js +2 -0
- package/dist/chunk-lang-css-6bepqpnx.js +1 -0
- package/dist/{chunk-lang-dockerfile-ey0nvhhm.js → chunk-lang-dockerfile-hrav55t7.js} +1 -1
- package/dist/{chunk-lang-go-403qsw9f.js → chunk-lang-go-x9ahv6e5.js} +3 -3
- package/dist/chunk-lang-html-8jc72zt4.js +1 -0
- package/dist/chunk-lang-javascript-2p2qtbnf.js +1 -0
- package/dist/chunk-lang-json-hsemyd0r.js +1 -0
- package/dist/chunk-lang-markdown-zkwb4c9n.js +6 -0
- package/dist/{chunk-lang-python-9jk5a49y.js → chunk-lang-python-tewx7006.js} +2 -2
- package/dist/chunk-lang-rust-0ydx84s1.js +1 -0
- package/dist/{chunk-lang-shell-cgq6q0kj.js → chunk-lang-shell-gq1tmdz4.js} +1 -1
- package/dist/chunk-lang-sql-xt1qyycg.js +2 -0
- package/dist/{chunk-lang-toml-xh45nm68.js → chunk-lang-toml-0hamc5fc.js} +1 -1
- package/dist/chunk-lang-typescript-82xf7z2e.js +1 -0
- package/dist/{chunk-lang-xml-1mh879j9.js → chunk-lang-xml-wf6h5pt2.js} +1 -1
- package/dist/{chunk-lang-yaml-jac98srt.js → chunk-lang-yaml-7vk521h1.js} +1 -1
- package/dist/chunk-layout-actions-zh7bezk7.js +1 -0
- package/dist/chunk-menu-items-1n9qargb.js +2 -0
- package/dist/chunk-notes-t15bh04m.js +1 -0
- package/dist/chunk-notice-bell-w3y0h0sr.js +1 -0
- package/dist/chunk-notices-fa3g7qze.js +1 -0
- package/dist/chunk-onboarding-34v3nr17.js +1 -0
- package/dist/chunk-overview-km5vq375.js +1 -0
- package/dist/chunk-palette-a8txdgd0.js +1 -0
- package/dist/chunk-placement-items-wgaxsbvs.js +1 -0
- package/dist/chunk-profile-ypxdbsk1.js +1 -0
- package/dist/chunk-prompt-history-wm9dsy6s.js +1 -0
- package/dist/chunk-prompts-7mhhq7zs.js +1 -0
- package/dist/chunk-room-confirms-t11a1gsw.js +1 -0
- package/dist/chunk-room-menu-2tm2xtfx.js +1 -0
- package/dist/chunk-rooms-menu-button-wxnsxjwc.js +1 -0
- package/dist/chunk-schedules-2ft6xxyw.js +1 -0
- package/dist/chunk-seat-menu-d3npdtx1.js +1 -0
- package/dist/chunk-sheet-hosts-vngvp7wv.js +1 -0
- package/dist/chunk-skill-editor-65n2hsq4.js +8 -0
- package/dist/chunk-skills-shkdwes8.js +1 -0
- package/dist/chunk-switcher-list-g6hhmr1t.js +1 -0
- package/dist/chunk-table-1b6bsq2y.js +1 -0
- package/dist/chunk-table-seats-vy1qhdks.js +1 -0
- package/dist/chunk-table2d-n26b7gd0.js +1 -0
- package/dist/chunk-table3d-8aysyf56.js +4220 -0
- package/dist/chunk-transcript-355faceq.js +1 -0
- package/dist/chunk-where-j5qdt8a4.js +1 -0
- package/dist/chunk-workspace-vrbs6zx1.js +1 -0
- package/dist/chunk-zh-tw-zeg600bk.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 +515 -6
- package/src/app/api-http.ts +66 -0
- package/src/app/app.ts +214 -33
- package/src/app/card-log.ts +6 -0
- package/src/app/desk.ts +47 -22
- package/src/app/files.ts +73 -7
- package/src/app/layout-routes.ts +239 -0
- package/src/app/layout.ts +133 -0
- package/src/app/presence.ts +2 -2
- package/src/app/prompt-routes.ts +167 -0
- package/src/app/protocol.ts +63 -1
- package/src/app/room-log.ts +148 -14
- package/src/app/rooms.ts +23 -0
- package/src/app/routes.ts +278 -156
- package/src/app/skill-doc-routes.ts +122 -0
- package/src/app/socket.ts +309 -77
- package/src/app/sockets.ts +108 -20
- package/src/app/surface.ts +72 -36
- package/src/app/team-routes.ts +457 -0
- package/src/app/threads.ts +14 -0
- package/src/app/uploads.ts +33 -7
- package/src/auth/authenticator.ts +71 -8
- package/src/auth/next.ts +52 -0
- package/src/auth/oidc-pages.ts +60 -0
- package/src/auth/oidc.ts +611 -0
- package/src/auth/sessions.ts +193 -0
- package/src/authz.ts +172 -0
- package/src/console-api.ts +122 -25
- package/src/console-server.ts +70 -6
- package/src/gate.ts +90 -0
- package/src/host/access.ts +161 -0
- package/src/host/cards.ts +99 -0
- package/src/host/context.ts +141 -0
- package/src/host/forms.ts +237 -0
- package/src/host/frames.ts +342 -0
- package/src/host/host-app.ts +176 -0
- package/src/host/host-context.ts +49 -0
- package/src/host/host-routes.ts +363 -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 +414 -0
- package/src/host/stored-json.ts +13 -0
- package/src/host/views.ts +103 -0
- package/src/i18n/zh-tw.ts +309 -10
- package/src/layout-order.ts +210 -0
- 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 +498 -34
- 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/mutes.ts +17 -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/visitors.ts +94 -6
- package/src/web-app.ts +674 -0
- package/src/web-plugin.ts +293 -26
- package/dist/chunk-action-menu-7v5fce2v.js +0 -1
- package/dist/chunk-agent-2rw4ydw2.js +0 -1
- package/dist/chunk-agent-sheet-2q24dkkf.js +0 -3
- package/dist/chunk-character-creator-8q6ympxj.js +0 -1
- package/dist/chunk-codeeditor-fgmq298t.js +0 -2
- package/dist/chunk-connectors-mr53r7fh.js +0 -1
- package/dist/chunk-conversations-4vy2z6w9.js +0 -1
- package/dist/chunk-entry-jg1eem82.js +0 -1
- package/dist/chunk-group-scene-na95b1te.js +0 -1
- package/dist/chunk-index-11rjg8a8.js +0 -1
- package/dist/chunk-index-1y50d6sj.js +0 -2
- package/dist/chunk-index-5y2r0eaj.js +0 -47
- package/dist/chunk-index-7fphxxzd.js +0 -14
- package/dist/chunk-index-80f1zkp6.css +0 -1
- package/dist/chunk-index-87h45frg.js +0 -2
- package/dist/chunk-index-bnwmssyn.js +0 -2
- package/dist/chunk-index-ckgbszwt.js +0 -51
- package/dist/chunk-index-fmjjekhd.js +0 -2
- package/dist/chunk-index-t1m65yn6.js +0 -13
- package/dist/chunk-index-t711t17m.js +0 -2
- package/dist/chunk-index-txv7qvyy.js +0 -2
- package/dist/chunk-index-wr3dg24d.js +0 -2
- package/dist/chunk-index-yx683d5n.js +0 -2
- package/dist/chunk-lang-css-fkdg3x8x.js +0 -1
- package/dist/chunk-lang-html-v8h9g977.js +0 -1
- package/dist/chunk-lang-javascript-ykb2vhaa.js +0 -1
- package/dist/chunk-lang-json-2rze3rpy.js +0 -1
- package/dist/chunk-lang-markdown-6w2htnfc.js +0 -6
- package/dist/chunk-lang-rust-24fggcqx.js +0 -1
- package/dist/chunk-lang-sql-gjywb9ke.js +0 -2
- package/dist/chunk-lang-typescript-qh6rebyb.js +0 -1
- package/dist/chunk-menu-items-zjpyj51b.js +0 -2
- package/dist/chunk-notes-sjba7kw3.js +0 -1
- package/dist/chunk-overview-26r77kb6.js +0 -1
- package/dist/chunk-room-confirms-851184ep.js +0 -1
- package/dist/chunk-room-menu-8y9c026y.js +0 -1
- package/dist/chunk-skills-56s4hdbn.js +0 -1
- package/dist/chunk-table-qykpvp32.js +0 -1
- package/dist/chunk-table2d-k60ressj.js +0 -1
- package/dist/chunk-table3d-bv2ajk39.js +0 -4220
- package/dist/chunk-transcript-vekz1mh6.js +0 -1
- package/dist/chunk-workspace-hevefd0d.js +0 -1
- package/dist/chunk-zh-tw-5r2wjre0.js +0 -1
package/src/console-server.ts
CHANGED
|
@@ -6,10 +6,16 @@ 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
|
|
12
|
+
import { type ConsolePresentation, message } from "./features.ts";
|
|
13
|
+
import type { Gate } from "./gate.ts";
|
|
14
|
+
import { json } from "./http.ts";
|
|
11
15
|
import { presentPage } from "./page-presentation.ts";
|
|
12
16
|
import { servePwa } from "./pwa.ts";
|
|
17
|
+
import type { Visitor } from "./visitors.ts";
|
|
18
|
+
import { viewerOf } from "./web-app.ts";
|
|
13
19
|
|
|
14
20
|
const encoder = new TextEncoder();
|
|
15
21
|
/** Changes closer together than this reach the page as one event. */
|
|
@@ -53,6 +59,10 @@ export interface ConsoleServerOptions {
|
|
|
53
59
|
): Promise<Response | undefined>;
|
|
54
60
|
websocket: WebSocketRoute<AppSocket>;
|
|
55
61
|
};
|
|
62
|
+
/** The onboarding gate; without it nobody is gated. */
|
|
63
|
+
gate?: Pick<Gate, "required">;
|
|
64
|
+
/** The app's surface, for the `next` a turned-away page navigation asks to come back to. */
|
|
65
|
+
surface?: string;
|
|
56
66
|
/** Registers the listener with every source whose change the console shows. */
|
|
57
67
|
subscribe(listener: () => void): void;
|
|
58
68
|
logger: Logger;
|
|
@@ -70,9 +80,11 @@ export interface ConsoleServerOptions {
|
|
|
70
80
|
|
|
71
81
|
/**
|
|
72
82
|
* 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
|
-
*
|
|
83
|
+
* it needs the authenticator to admit the request, apart from what it answers openly, such as a
|
|
84
|
+
* login page. An admitted request is then judged by the authorization table before any handler
|
|
85
|
+
* sees it: a route that is not listed answers 404, one for owners answers a member 403, and while
|
|
86
|
+
* the person owes a required step only the routes open to them answer. A request that changes
|
|
87
|
+
* data must also carry the console's own `Origin`.
|
|
76
88
|
*/
|
|
77
89
|
export class ConsoleServer {
|
|
78
90
|
readonly #options: ConsoleServerOptions;
|
|
@@ -154,6 +166,11 @@ export class ConsoleServer {
|
|
|
154
166
|
status: 302,
|
|
155
167
|
headers: { location: this.#base },
|
|
156
168
|
});
|
|
169
|
+
const decision = decide(request.method, rest, {
|
|
170
|
+
tier: admission.visitor.tier,
|
|
171
|
+
gated: await this.#gated(admission.visitor),
|
|
172
|
+
});
|
|
173
|
+
if (!decision.allow) return this.#denied(decision);
|
|
157
174
|
if (rest === "api/events" && request.method === "GET")
|
|
158
175
|
return this.#events();
|
|
159
176
|
const owned = await this.#options.app?.handle(request, rest, admission);
|
|
@@ -206,6 +223,43 @@ export class ConsoleServer {
|
|
|
206
223
|
return new Response("Not found", { status: 404 });
|
|
207
224
|
}
|
|
208
225
|
|
|
226
|
+
async #gated(visitor: Visitor): Promise<boolean> {
|
|
227
|
+
const { gate } = this.#options;
|
|
228
|
+
if (!gate) return false;
|
|
229
|
+
try {
|
|
230
|
+
return (await gate.required(viewerOf(visitor))).length > 0;
|
|
231
|
+
} catch (error) {
|
|
232
|
+
// A gate that cannot be read stays shut.
|
|
233
|
+
this.#options.logger.warn({ err: error }, "the gate was not read");
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The answer to a request the authorization table refused. */
|
|
239
|
+
#denied(decision: Extract<Decision, { allow: false }>): Response {
|
|
240
|
+
const { presentation, logger } = this.#options;
|
|
241
|
+
logger.debug({ reason: decision.reason }, "console request denied");
|
|
242
|
+
if (decision.gate)
|
|
243
|
+
return json(
|
|
244
|
+
{
|
|
245
|
+
error: message(presentation, "Finish setting up first."),
|
|
246
|
+
gate: true,
|
|
247
|
+
},
|
|
248
|
+
403,
|
|
249
|
+
);
|
|
250
|
+
return json(
|
|
251
|
+
{
|
|
252
|
+
error: message(
|
|
253
|
+
presentation,
|
|
254
|
+
decision.status === 404
|
|
255
|
+
? "There is no such API."
|
|
256
|
+
: "Only an owner may do this.",
|
|
257
|
+
),
|
|
258
|
+
},
|
|
259
|
+
decision.status,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
209
263
|
/**
|
|
210
264
|
* The answer to a request the authenticator refused: a page navigation that signing in would
|
|
211
265
|
* fix goes to the login page, everything else gets the status and a fixed body.
|
|
@@ -215,14 +269,24 @@ export class ConsoleServer {
|
|
|
215
269
|
refusal.login &&
|
|
216
270
|
READ_METHODS.has(request.method) &&
|
|
217
271
|
!rest.startsWith("api/")
|
|
218
|
-
)
|
|
272
|
+
) {
|
|
273
|
+
// 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.
|
|
274
|
+
const surface = this.#options.surface ?? "app";
|
|
275
|
+
const named = allowedNext(
|
|
276
|
+
URL.parse(request.url)?.searchParams.get("next"),
|
|
277
|
+
surface,
|
|
278
|
+
);
|
|
279
|
+
const next =
|
|
280
|
+
named ??
|
|
281
|
+
(this.#options.pathRouting ? nextOfPath(rest, surface) : undefined);
|
|
219
282
|
return new Response(null, {
|
|
220
283
|
status: 303,
|
|
221
284
|
headers: {
|
|
222
|
-
location: `${this.#options.mount}/login`,
|
|
285
|
+
location: `${this.#options.mount}/login${next ? `?next=${encodeURIComponent(next)}` : ""}`,
|
|
223
286
|
"cache-control": "no-store",
|
|
224
287
|
},
|
|
225
288
|
});
|
|
289
|
+
}
|
|
226
290
|
// A missing session is the normal way to meet a login page, not worth a line each time.
|
|
227
291
|
const { logger } = this.#options;
|
|
228
292
|
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,99 @@
|
|
|
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
|
+
async create(room: string, card: HostCard): Promise<StoredHostCard> {
|
|
53
|
+
const id = crypto.randomUUID();
|
|
54
|
+
const [row]: Row[] = await this.#sql`
|
|
55
|
+
INSERT INTO web_host_cards (id, room, card, state)
|
|
56
|
+
VALUES (${id}, ${room}, ${JSON.stringify(card)}::jsonb, ${card.state ?? null})
|
|
57
|
+
RETURNING *`;
|
|
58
|
+
if (!row) throw new Error("the card log stored nothing");
|
|
59
|
+
return cardOf(row);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Replaces a card of the room; undefined when the room holds no such card. */
|
|
63
|
+
async update(
|
|
64
|
+
room: string,
|
|
65
|
+
id: string,
|
|
66
|
+
card: HostCard,
|
|
67
|
+
): Promise<StoredHostCard | undefined> {
|
|
68
|
+
const [row]: Row[] = await this.#sql`
|
|
69
|
+
UPDATE web_host_cards
|
|
70
|
+
SET card = ${JSON.stringify(card)}::jsonb, state = ${card.state ?? null}, updated_at = now()
|
|
71
|
+
WHERE id = ${id} AND room = ${room} RETURNING *`;
|
|
72
|
+
return row && cardOf(row);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async get(room: string, id: string): Promise<StoredHostCard | undefined> {
|
|
76
|
+
const [row]: Row[] = await this.#sql`
|
|
77
|
+
SELECT * FROM web_host_cards WHERE id = ${id} AND room = ${room}`;
|
|
78
|
+
return row && cardOf(row);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The room's cards, oldest first. */
|
|
82
|
+
async list(room: string): Promise<StoredHostCard[]> {
|
|
83
|
+
const rows: Row[] = await this.#sql`
|
|
84
|
+
SELECT * FROM web_host_cards WHERE room = ${room} ORDER BY created_at, id`;
|
|
85
|
+
return rows.map(cardOf);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Deletes the cards of the rooms whose key starts with `prefix` that are older than `before`; how many. */
|
|
89
|
+
async sweepKind(prefix: string, before: Date): Promise<number> {
|
|
90
|
+
const rows: unknown[] = await this.#sql`
|
|
91
|
+
DELETE FROM web_host_cards
|
|
92
|
+
WHERE starts_with(room, ${prefix}) AND created_at < ${before} RETURNING 1`;
|
|
93
|
+
return rows.length;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async removeRoom(room: string): Promise<void> {
|
|
97
|
+
await this.#sql`DELETE FROM web_host_cards WHERE room = ${room}`;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { InboundMessage } from "pi-roundtable";
|
|
2
|
+
import type {
|
|
3
|
+
ChannelContext,
|
|
4
|
+
ChannelContextMessage,
|
|
5
|
+
ChannelContextOptions,
|
|
6
|
+
} from "pi-roundtable/discord";
|
|
7
|
+
import { formatChannelContext } from "pi-roundtable/discord";
|
|
8
|
+
import type { HostAuthorKind, RoomMessage } from "../api-types.ts";
|
|
9
|
+
import { PAGE_LIMIT } from "../app/room-log.ts";
|
|
10
|
+
import type { HostContext } from "./host-context.ts";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_FETCH = 50;
|
|
13
|
+
const DEFAULT_KEEP = 15;
|
|
14
|
+
const DEFAULT_CHARS = 500;
|
|
15
|
+
/** How many delivered message ids are remembered per room, and how many rooms. */
|
|
16
|
+
const SEEN_PER_ROOM = 500;
|
|
17
|
+
const SEEN_ROOMS = 1_000;
|
|
18
|
+
|
|
19
|
+
type Source = Parameters<NonNullable<ChannelContextOptions["authors"]>>[0];
|
|
20
|
+
|
|
21
|
+
const positive = (name: string, value: number, most: number): number => {
|
|
22
|
+
if (!Number.isInteger(value) || value < 1 || value > most)
|
|
23
|
+
throw new RangeError(
|
|
24
|
+
`channel context option ${name} must be a whole number from 1 to ${most}, got ${value}`,
|
|
25
|
+
);
|
|
26
|
+
return value;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Whose a stored message of a host room is; a message without a kind is read from its row's. */
|
|
30
|
+
function authorKindOf(message: RoomMessage): HostAuthorKind {
|
|
31
|
+
if (message.authorKind) return message.authorKind;
|
|
32
|
+
if (message.kind === "agent") return "assistant";
|
|
33
|
+
return message.kind === "system" ? "notice" : "member";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What the channel context reads of one stored message of a host room. */
|
|
37
|
+
function sourceOf(message: RoomMessage): Source {
|
|
38
|
+
const kind = authorKindOf(message);
|
|
39
|
+
return {
|
|
40
|
+
id: String(message.id),
|
|
41
|
+
authorId: message.principalId ?? kind,
|
|
42
|
+
authorName: message.author,
|
|
43
|
+
bot: false,
|
|
44
|
+
own: kind === "assistant",
|
|
45
|
+
...(kind === "external" ? { external: true as const } : {}),
|
|
46
|
+
attachments: message.files.map((file) => ({
|
|
47
|
+
name: file.name,
|
|
48
|
+
contentType: file.type,
|
|
49
|
+
size: file.size,
|
|
50
|
+
url: `api/files/${file.id}`,
|
|
51
|
+
})),
|
|
52
|
+
at: Date.parse(message.at),
|
|
53
|
+
text: message.text,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The app surface's `channelContext` for a host room: what was said in it since the assistant
|
|
59
|
+
* last posted, before the message that addressed it, as the core's block of channel messages. An
|
|
60
|
+
* outsider's words read as `external`, a notice line is not in the channel as far as the context
|
|
61
|
+
* goes, and a message already handed to the conversation is not handed again. Anything but a host
|
|
62
|
+
* room has none: an agent room is one person's.
|
|
63
|
+
*/
|
|
64
|
+
export function hostChannelContext(
|
|
65
|
+
ctx: Pick<HostContext, "registry" | "log">,
|
|
66
|
+
): (
|
|
67
|
+
message: InboundMessage,
|
|
68
|
+
options?: ChannelContextOptions,
|
|
69
|
+
) => Promise<ChannelContext | undefined> {
|
|
70
|
+
const seen = new Map<string, Set<string>>();
|
|
71
|
+
return async (message, options = {}) => {
|
|
72
|
+
const room = message.channel;
|
|
73
|
+
if (!ctx.registry.isHostRoom(room)) return undefined;
|
|
74
|
+
const fetch = positive("fetch", options.fetch ?? DEFAULT_FETCH, PAGE_LIMIT);
|
|
75
|
+
const keep = positive("keep", options.keep ?? DEFAULT_KEEP, 100);
|
|
76
|
+
const chars = positive(
|
|
77
|
+
"messageChars",
|
|
78
|
+
options.messageChars ?? DEFAULT_CHARS,
|
|
79
|
+
20_000,
|
|
80
|
+
);
|
|
81
|
+
const addressed = /^app-(\d+)$/.exec(message.messageId)?.[1];
|
|
82
|
+
const page = await ctx.log.page(
|
|
83
|
+
room,
|
|
84
|
+
addressed === undefined ? undefined : Number(addressed),
|
|
85
|
+
fetch,
|
|
86
|
+
);
|
|
87
|
+
const sources = page.messages.map((one) => ({
|
|
88
|
+
source: sourceOf(one),
|
|
89
|
+
notice: authorKindOf(one) === "notice",
|
|
90
|
+
}));
|
|
91
|
+
type Entry = (typeof sources)[number];
|
|
92
|
+
const verdict = (entry: Entry) => {
|
|
93
|
+
const given = options.authors?.(entry.source);
|
|
94
|
+
if (given === "keep" || given === "skip" || given === "end") return given;
|
|
95
|
+
if (entry.notice) return "skip";
|
|
96
|
+
return entry.source.own ? "end" : "keep";
|
|
97
|
+
};
|
|
98
|
+
const considered = sources.filter((entry) => verdict(entry) !== "skip");
|
|
99
|
+
const lastEnd = considered.findLastIndex(
|
|
100
|
+
(entry) => verdict(entry) === "end",
|
|
101
|
+
);
|
|
102
|
+
const known = seen.get(room) ?? new Set<string>();
|
|
103
|
+
const fresh = considered
|
|
104
|
+
.slice(lastEnd + 1)
|
|
105
|
+
.filter(
|
|
106
|
+
(entry) =>
|
|
107
|
+
verdict(entry) === "keep" &&
|
|
108
|
+
!known.has(entry.source.id) &&
|
|
109
|
+
entry.source.text !== "",
|
|
110
|
+
)
|
|
111
|
+
.slice(-keep);
|
|
112
|
+
// What was in the window is not handed over again, kept or not.
|
|
113
|
+
for (const entry of considered.slice(lastEnd + 1))
|
|
114
|
+
known.add(entry.source.id);
|
|
115
|
+
seen.delete(room);
|
|
116
|
+
seen.set(room, new Set([...known].slice(-SEEN_PER_ROOM)));
|
|
117
|
+
if (seen.size > SEEN_ROOMS) {
|
|
118
|
+
const oldest = seen.keys().next().value;
|
|
119
|
+
if (oldest !== undefined) seen.delete(oldest);
|
|
120
|
+
}
|
|
121
|
+
if (fresh.length === 0) return undefined;
|
|
122
|
+
const messages: ChannelContextMessage[] = fresh.map(({ source }) => ({
|
|
123
|
+
id: source.id,
|
|
124
|
+
authorId: source.authorId,
|
|
125
|
+
authorName: source.authorName,
|
|
126
|
+
bot: false,
|
|
127
|
+
owner: false,
|
|
128
|
+
...(source.external ? { external: true } : {}),
|
|
129
|
+
text:
|
|
130
|
+
source.text.length > chars
|
|
131
|
+
? `${source.text.slice(0, chars)}…`
|
|
132
|
+
: source.text,
|
|
133
|
+
at: new Date(source.at),
|
|
134
|
+
attachments: (source.attachments ?? []).map((file) => ({
|
|
135
|
+
...file,
|
|
136
|
+
messageId: source.id,
|
|
137
|
+
})),
|
|
138
|
+
}));
|
|
139
|
+
return { messages, text: formatChannelContext(messages) };
|
|
140
|
+
};
|
|
141
|
+
}
|