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,349 @@
|
|
|
1
|
+
import type { ActorFacts, InboundMessage } from "pi-roundtable";
|
|
2
|
+
import {
|
|
3
|
+
type ClientFrame,
|
|
4
|
+
MAX_MESSAGE_CHARS,
|
|
5
|
+
type RefusalCode,
|
|
6
|
+
} from "../api-types.ts";
|
|
7
|
+
import type { AppSocket, Audience } from "../app/sockets.ts";
|
|
8
|
+
import type {
|
|
9
|
+
ActionResult,
|
|
10
|
+
FormSpec,
|
|
11
|
+
FrameContext,
|
|
12
|
+
HostAction,
|
|
13
|
+
Viewer,
|
|
14
|
+
} from "../web-app.ts";
|
|
15
|
+
import { checkInput } from "./forms.ts";
|
|
16
|
+
import { asRoom, type HostContext } from "./host-context.ts";
|
|
17
|
+
import { buttonsFor, type FoundRoom } from "./room-views.ts";
|
|
18
|
+
|
|
19
|
+
/** The most host rooms one socket follows. */
|
|
20
|
+
export const MAX_SUBSCRIPTIONS = 200;
|
|
21
|
+
|
|
22
|
+
/** An id of a host room: what follows the dot of `<surface>:<kind>.<id>`. */
|
|
23
|
+
const ROOM_ID = /^[A-Za-z0-9_-]{8,64}$/;
|
|
24
|
+
|
|
25
|
+
/** The host room a key names, when its kind is registered and its id has the shape a room's has. */
|
|
26
|
+
export function hostRoomOf(
|
|
27
|
+
ctx: Pick<HostContext, "registry">,
|
|
28
|
+
key: string,
|
|
29
|
+
): FoundRoom | undefined {
|
|
30
|
+
const found = ctx.registry.hostKindOf(key);
|
|
31
|
+
return found && ROOM_ID.test(found.id) ? found : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Why an act did not reach the host. */
|
|
35
|
+
export type ActRefusal = "unknown" | "forbidden";
|
|
36
|
+
|
|
37
|
+
export type ActOutcome = { result: ActionResult } | { refused: ActRefusal };
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One press of a button or submit of a form in a host room: the viewer may read and act, the
|
|
41
|
+
* button is one this card offers them (or an action of the room's header), and the form's input is
|
|
42
|
+
* what the form asks for. Only then does the host hear of it, and the host decides what it means.
|
|
43
|
+
*/
|
|
44
|
+
export async function actInRoom(
|
|
45
|
+
ctx: HostContext,
|
|
46
|
+
viewer: Viewer,
|
|
47
|
+
room: string,
|
|
48
|
+
press: {
|
|
49
|
+
action: string;
|
|
50
|
+
card?: string;
|
|
51
|
+
input?: Readonly<Record<string, string | readonly string[]>>;
|
|
52
|
+
},
|
|
53
|
+
): Promise<ActOutcome> {
|
|
54
|
+
const found = hostRoomOf(ctx, room);
|
|
55
|
+
if (!found) return { refused: "unknown" };
|
|
56
|
+
const access = await ctx.access.of(viewer, room);
|
|
57
|
+
if (!access) return { refused: "unknown" };
|
|
58
|
+
if (!access.act) return { refused: "forbidden" };
|
|
59
|
+
let offered: readonly HostAction[];
|
|
60
|
+
if (press.card !== undefined) {
|
|
61
|
+
const card = await ctx.cards.get(room, press.card);
|
|
62
|
+
if (!card) return { refused: "unknown" };
|
|
63
|
+
offered = await buttonsFor(viewer, found, access, card);
|
|
64
|
+
} else {
|
|
65
|
+
const header = await found.kind
|
|
66
|
+
.header?.(viewer, found.id)
|
|
67
|
+
.catch((error: unknown) => {
|
|
68
|
+
ctx.logger.warn(
|
|
69
|
+
{ kind: found.kind.kind, err: error },
|
|
70
|
+
"a room's header could not be read",
|
|
71
|
+
);
|
|
72
|
+
return undefined;
|
|
73
|
+
});
|
|
74
|
+
offered = header?.actions ?? [];
|
|
75
|
+
}
|
|
76
|
+
const button = offered.find((one) => one.id === press.action);
|
|
77
|
+
if (!button) return { refused: "forbidden" };
|
|
78
|
+
if (!found.kind.act) return { refused: "unknown" };
|
|
79
|
+
const checked = checkInput(
|
|
80
|
+
button.form as FormSpec | undefined,
|
|
81
|
+
press.input ?? {},
|
|
82
|
+
);
|
|
83
|
+
if (!checked.ok)
|
|
84
|
+
return {
|
|
85
|
+
result: {
|
|
86
|
+
ok: false,
|
|
87
|
+
error: checked.error,
|
|
88
|
+
...(checked.field ? { field: checked.field } : {}),
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
let result: ActionResult;
|
|
92
|
+
try {
|
|
93
|
+
result = await found.kind.act(
|
|
94
|
+
viewer,
|
|
95
|
+
found.id,
|
|
96
|
+
{
|
|
97
|
+
action: press.action,
|
|
98
|
+
...(press.card === undefined ? {} : { card: press.card }),
|
|
99
|
+
},
|
|
100
|
+
checked.values,
|
|
101
|
+
);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
ctx.logger.warn(
|
|
104
|
+
{ kind: found.kind.kind, err: error },
|
|
105
|
+
"a host action failed",
|
|
106
|
+
);
|
|
107
|
+
result = { ok: false, error: "The action could not be done." };
|
|
108
|
+
}
|
|
109
|
+
ctx.events?.action?.({
|
|
110
|
+
kind: "act",
|
|
111
|
+
actor: viewer.speakerId,
|
|
112
|
+
principalId: viewer.principalId,
|
|
113
|
+
channel: asRoom(room),
|
|
114
|
+
ok: result.ok,
|
|
115
|
+
at: new Date(ctx.now()),
|
|
116
|
+
});
|
|
117
|
+
return { result };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The audience a room's `read` frames go to: a host room's readers, or the owners for an agent room. */
|
|
121
|
+
export const readAudience = (
|
|
122
|
+
ctx: Pick<HostContext, "registry">,
|
|
123
|
+
room: string,
|
|
124
|
+
): Audience => (ctx.registry.isHostRoom(room) ? { room } : { owners: true });
|
|
125
|
+
|
|
126
|
+
/** Registers the handlers of the frames host rooms take. */
|
|
127
|
+
export function installFrames(ctx: HostContext): void {
|
|
128
|
+
const { registry } = ctx;
|
|
129
|
+
|
|
130
|
+
registry.onFrame(
|
|
131
|
+
"subscribe",
|
|
132
|
+
async ({ frame, viewer, connection, refuse }) => {
|
|
133
|
+
if (!hostRoomOf(ctx, frame.room)) return refuse("unknown_room");
|
|
134
|
+
if (
|
|
135
|
+
!connection.subscriptions.has(frame.room) &&
|
|
136
|
+
connection.subscriptions.size >= MAX_SUBSCRIPTIONS
|
|
137
|
+
)
|
|
138
|
+
return refuse("not_accepted");
|
|
139
|
+
if (!(await ctx.access.read(viewer, asRoom(frame.room))))
|
|
140
|
+
return refuse("unknown_room");
|
|
141
|
+
connection.subscriptions.add(frame.room);
|
|
142
|
+
// A card that opened before the reader came is theirs to see, once they may read the room.
|
|
143
|
+
for (const { room, frame: card } of ctx.desk.openFrames())
|
|
144
|
+
if (room === frame.room)
|
|
145
|
+
ctx.sockets.deliver(card, {
|
|
146
|
+
connection,
|
|
147
|
+
audience: { room: frame.room },
|
|
148
|
+
});
|
|
149
|
+
// So is a turn already running: a seat at the round table shows it from the first frame, not after the next change.
|
|
150
|
+
const activity = ctx.app.activityOf(asRoom(frame.room));
|
|
151
|
+
if (activity.typing)
|
|
152
|
+
ctx.sockets.deliver(
|
|
153
|
+
{ type: "activity", room: frame.room, ...activity },
|
|
154
|
+
{ connection, audience: { room: frame.room } },
|
|
155
|
+
);
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
registry.onFrame("unsubscribe", async ({ frame, connection }) => {
|
|
160
|
+
connection.subscriptions.delete(frame.room);
|
|
161
|
+
if (connection.focus === frame.room) connection.focus = undefined;
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
registry.onFrame("act", async ({ frame, viewer, refuse }) => {
|
|
165
|
+
if (frame.page !== undefined) {
|
|
166
|
+
const outcome = await ctx.pages.act(viewer, frame.page, {
|
|
167
|
+
action: frame.action,
|
|
168
|
+
...(frame.input ? { input: frame.input } : {}),
|
|
169
|
+
});
|
|
170
|
+
if ("refused" in outcome) return refuse("forbidden", frame.id);
|
|
171
|
+
if (!outcome.result.ok) return refuse("not_accepted", frame.id);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const outcome = await actInRoom(ctx, viewer, frame.room ?? "", {
|
|
175
|
+
action: frame.action,
|
|
176
|
+
...(frame.card === undefined ? {} : { card: frame.card }),
|
|
177
|
+
...(frame.input ? { input: frame.input } : {}),
|
|
178
|
+
});
|
|
179
|
+
if ("refused" in outcome)
|
|
180
|
+
return refuse(
|
|
181
|
+
outcome.refused === "unknown" ? "unknown_room" : "forbidden",
|
|
182
|
+
frame.id,
|
|
183
|
+
);
|
|
184
|
+
if (!outcome.result.ok) refuse("not_accepted", frame.id);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
registry.onFrame("read", async ({ frame, viewer, connection, refuse }) => {
|
|
188
|
+
const audience = await readableAudience(ctx, viewer, frame.room);
|
|
189
|
+
if (!audience) return refuse("unknown_room");
|
|
190
|
+
const mark = await ctx.reads.advance(
|
|
191
|
+
frame.room,
|
|
192
|
+
viewer.principalId,
|
|
193
|
+
frame.upTo,
|
|
194
|
+
);
|
|
195
|
+
if (!mark) return;
|
|
196
|
+
ctx.sockets.deliver(
|
|
197
|
+
{
|
|
198
|
+
type: "read",
|
|
199
|
+
room: frame.room,
|
|
200
|
+
principal: viewer.principalId,
|
|
201
|
+
name: viewer.name,
|
|
202
|
+
upTo: mark.upTo,
|
|
203
|
+
at: mark.at,
|
|
204
|
+
},
|
|
205
|
+
audience,
|
|
206
|
+
);
|
|
207
|
+
void connection;
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
registry.onFrame("hostSend", async (context) => hostSend(ctx, context));
|
|
211
|
+
|
|
212
|
+
registry.onFrame("hostStop", async ({ frame, viewer, refuse }) => {
|
|
213
|
+
if (!hostRoomOf(ctx, frame.room)) return refuse("unknown_room");
|
|
214
|
+
const access = await ctx.access.of(viewer, frame.room);
|
|
215
|
+
if (!access) return refuse("unknown_room");
|
|
216
|
+
if (!access.send.includes("turn")) return refuse("forbidden");
|
|
217
|
+
ctx.conversations.stop(asRoom(frame.room));
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Who a read frame of this room is shown to when the viewer may read it now: a host room's readers
|
|
223
|
+
* (asked of the kind), or, for an agent or thread room, the owners. Undefined when the viewer may not.
|
|
224
|
+
*/
|
|
225
|
+
export async function readableAudience(
|
|
226
|
+
ctx: HostContext,
|
|
227
|
+
viewer: Viewer,
|
|
228
|
+
room: string,
|
|
229
|
+
): Promise<Audience | undefined> {
|
|
230
|
+
if (ctx.registry.isHostRoom(room)) {
|
|
231
|
+
if (!hostRoomOf(ctx, room)) return undefined;
|
|
232
|
+
return (await ctx.access.read(viewer, asRoom(room))) ? { room } : undefined;
|
|
233
|
+
}
|
|
234
|
+
if (viewer.tier !== "owner") return undefined;
|
|
235
|
+
return (await ctx.rooms.resolve(room)) ? { owners: true } : undefined;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** A viewer's message to a host room: stored and shown, and a `turn` is also handed to the room's claim. */
|
|
239
|
+
async function hostSend(
|
|
240
|
+
ctx: HostContext,
|
|
241
|
+
{
|
|
242
|
+
frame,
|
|
243
|
+
viewer,
|
|
244
|
+
connection,
|
|
245
|
+
refuse,
|
|
246
|
+
}: FrameContext<Extract<ClientFrame, { type: "send" }>>,
|
|
247
|
+
): Promise<void> {
|
|
248
|
+
const reject = (code: RefusalCode) => refuse(code, frame.id);
|
|
249
|
+
const found = hostRoomOf(ctx, frame.room);
|
|
250
|
+
if (!found) return reject("unknown_room");
|
|
251
|
+
const access = await ctx.access.of(viewer, frame.room);
|
|
252
|
+
if (!access) return reject("unknown_room");
|
|
253
|
+
const mode = frame.mode ?? "turn";
|
|
254
|
+
if (!access.send.includes(mode)) return reject("forbidden");
|
|
255
|
+
const text = frame.text.trim();
|
|
256
|
+
if (text.length > MAX_MESSAGE_CHARS) return reject("too_long");
|
|
257
|
+
const ids = frame.uploads ?? [];
|
|
258
|
+
if (text === "" && ids.length === 0) return reject("empty");
|
|
259
|
+
// A turn is written as the person; without the actor their credential vouched for, it cannot be.
|
|
260
|
+
if (mode === "turn" && !connection.actor) return reject("forbidden");
|
|
261
|
+
const files = ctx.uploads.claim(ids, viewer.principalId);
|
|
262
|
+
if (!files) return reject("unknown_upload");
|
|
263
|
+
if (!ctx.app.started) return reject("not_accepted");
|
|
264
|
+
if (found.kind.send) {
|
|
265
|
+
let verdict: "accepted" | { refused: string };
|
|
266
|
+
try {
|
|
267
|
+
verdict = await found.kind.send(viewer, found.id, { mode, text, files });
|
|
268
|
+
} catch (error) {
|
|
269
|
+
ctx.logger.warn(
|
|
270
|
+
{ kind: found.kind.kind, err: error },
|
|
271
|
+
"a kind's send failed",
|
|
272
|
+
);
|
|
273
|
+
return reject("not_accepted");
|
|
274
|
+
}
|
|
275
|
+
if (verdict !== "accepted") return reject("not_accepted");
|
|
276
|
+
}
|
|
277
|
+
const expiresAt = found.kind.retention
|
|
278
|
+
? ctx.now() + found.kind.retention.fileDays * 86_400_000
|
|
279
|
+
: undefined;
|
|
280
|
+
const message = await ctx.app.publish(
|
|
281
|
+
{
|
|
282
|
+
room: asRoom(frame.room),
|
|
283
|
+
kind: "owner",
|
|
284
|
+
principalId: viewer.principalId,
|
|
285
|
+
author: viewer.name,
|
|
286
|
+
text,
|
|
287
|
+
files: [...files],
|
|
288
|
+
authorKind: "member",
|
|
289
|
+
},
|
|
290
|
+
connection,
|
|
291
|
+
);
|
|
292
|
+
ctx.uploads.use(
|
|
293
|
+
files.map((file) => file.id),
|
|
294
|
+
{ room: frame.room, ...(expiresAt === undefined ? {} : { expiresAt }) },
|
|
295
|
+
);
|
|
296
|
+
ctx.sockets.deliver(
|
|
297
|
+
{ type: "accepted", id: frame.id, room: frame.room, message },
|
|
298
|
+
{ connection },
|
|
299
|
+
);
|
|
300
|
+
// What a person sends is read by them, and the others see the room has moved.
|
|
301
|
+
const mark = await ctx.reads.advance(
|
|
302
|
+
frame.room,
|
|
303
|
+
viewer.principalId,
|
|
304
|
+
message.id,
|
|
305
|
+
);
|
|
306
|
+
if (mark)
|
|
307
|
+
ctx.sockets.deliver(
|
|
308
|
+
{
|
|
309
|
+
type: "read",
|
|
310
|
+
room: frame.room,
|
|
311
|
+
principal: viewer.principalId,
|
|
312
|
+
name: viewer.name,
|
|
313
|
+
upTo: mark.upTo,
|
|
314
|
+
at: mark.at,
|
|
315
|
+
},
|
|
316
|
+
{ room: frame.room },
|
|
317
|
+
);
|
|
318
|
+
if (mode !== "turn" || !connection.actor) return;
|
|
319
|
+
// The router never records a lower tier from these facts: the app is not the authority on roles.
|
|
320
|
+
const actor: ActorFacts = {
|
|
321
|
+
...connection.actor,
|
|
322
|
+
surface: ctx.surface,
|
|
323
|
+
partial: true,
|
|
324
|
+
};
|
|
325
|
+
const inbound: InboundMessage = {
|
|
326
|
+
channel: asRoom(frame.room),
|
|
327
|
+
messageId: `app-${message.id}`,
|
|
328
|
+
actor,
|
|
329
|
+
authorId: viewer.principalId,
|
|
330
|
+
authorName: viewer.name,
|
|
331
|
+
authorIsBot: false,
|
|
332
|
+
isDirect: false,
|
|
333
|
+
mentionsBot: false,
|
|
334
|
+
repliesToBot: false,
|
|
335
|
+
text,
|
|
336
|
+
attachments: files.map((file) => ctx.uploads.refOf(file)),
|
|
337
|
+
};
|
|
338
|
+
try {
|
|
339
|
+
ctx.app.deliver(inbound);
|
|
340
|
+
} catch (error) {
|
|
341
|
+
ctx.logger.warn(
|
|
342
|
+
{ room: frame.room, err: error },
|
|
343
|
+
"a host room turn was not delivered",
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** The connection of a frame, for the pieces that need its socket. */
|
|
349
|
+
export type { AppSocket };
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChannelKey,
|
|
3
|
+
ConversationPort,
|
|
4
|
+
EventSink,
|
|
5
|
+
InboundMessage,
|
|
6
|
+
Logger,
|
|
7
|
+
} from "pi-roundtable";
|
|
8
|
+
import type {
|
|
9
|
+
ChannelContext,
|
|
10
|
+
ChannelContextOptions,
|
|
11
|
+
} from "pi-roundtable/discord";
|
|
12
|
+
import type { OwnerDesk } from "../app/desk.ts";
|
|
13
|
+
import type { FileDir } from "../app/files.ts";
|
|
14
|
+
import type { RoomLog } from "../app/room-log.ts";
|
|
15
|
+
import type { Rooms } from "../app/rooms.ts";
|
|
16
|
+
import type { AppSocket, Sockets } from "../app/sockets.ts";
|
|
17
|
+
import type { AppSurface } from "../app/surface.ts";
|
|
18
|
+
import type { Uploads } from "../app/uploads.ts";
|
|
19
|
+
import type { Viewer, WebAppRegistry } from "../web-app.ts";
|
|
20
|
+
import { HostAccess } from "./access.ts";
|
|
21
|
+
import type { HostCardLog } from "./cards.ts";
|
|
22
|
+
import { type HostRoomContext, hostRoomContext } from "./context.ts";
|
|
23
|
+
import { installFrames } from "./frames.ts";
|
|
24
|
+
import type { HostContext } from "./host-context.ts";
|
|
25
|
+
import { hostArea, pagesArea } from "./host-routes.ts";
|
|
26
|
+
import { HostPagesService, PAGE_BINDING } from "./pages.ts";
|
|
27
|
+
import type { RoomReads } from "./reads.ts";
|
|
28
|
+
import { type HostSweep, sweepHostKinds } from "./retention.ts";
|
|
29
|
+
import { HostRoomsService, ListedKinds } from "./rooms-service.ts";
|
|
30
|
+
|
|
31
|
+
export interface HostAppOptions {
|
|
32
|
+
surface: string;
|
|
33
|
+
registry: WebAppRegistry;
|
|
34
|
+
sockets: Sockets;
|
|
35
|
+
app: AppSurface;
|
|
36
|
+
desk: Pick<OwnerDesk, "openFrames" | "roomOf">;
|
|
37
|
+
log: RoomLog;
|
|
38
|
+
cards: HostCardLog;
|
|
39
|
+
reads: RoomReads;
|
|
40
|
+
files: FileDir;
|
|
41
|
+
uploads: Uploads;
|
|
42
|
+
rooms: Pick<Rooms, "resolve">;
|
|
43
|
+
conversations: Pick<ConversationPort, "stop">;
|
|
44
|
+
/** The name the app shows for a person. */
|
|
45
|
+
nameOf(principalId: string): Promise<string>;
|
|
46
|
+
/** The same-origin URL of a room, and of one of its messages. */
|
|
47
|
+
roomUrl(room: ChannelKey, message?: number): string;
|
|
48
|
+
/** The most bytes of one file a host saves. */
|
|
49
|
+
maxFileBytes: number;
|
|
50
|
+
events?: Pick<EventSink, "action">;
|
|
51
|
+
now?: () => number;
|
|
52
|
+
/** How long an answer of a kind's `access` is trusted; default 10 s. */
|
|
53
|
+
accessTtlMs?: number;
|
|
54
|
+
logger: Logger;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The host rooms of the web app: what a host plugin registers through `WEB_APP` (kinds, pages) and
|
|
59
|
+
* writes through it (`rooms`, `pages`), made real. It decides who reads a room, takes the frames
|
|
60
|
+
* the page sends for host rooms and the REST under `api/host` and `api/pages`, keeps the room's
|
|
61
|
+
* cards and read cursors, and applies the kinds' retention. The owner app creates one and calls
|
|
62
|
+
* `install` once, before the web service starts.
|
|
63
|
+
*/
|
|
64
|
+
export class HostApp {
|
|
65
|
+
readonly access: HostAccess;
|
|
66
|
+
readonly rooms: HostRoomsService;
|
|
67
|
+
readonly pages: HostPagesService;
|
|
68
|
+
readonly #context: HostContext;
|
|
69
|
+
readonly #roomContext: HostRoomContext;
|
|
70
|
+
|
|
71
|
+
constructor(options: HostAppOptions) {
|
|
72
|
+
const now = options.now ?? Date.now;
|
|
73
|
+
const listed = new ListedKinds();
|
|
74
|
+
this.access = new HostAccess({
|
|
75
|
+
registry: options.registry,
|
|
76
|
+
sockets: options.sockets,
|
|
77
|
+
logger: options.logger,
|
|
78
|
+
now,
|
|
79
|
+
...(options.accessTtlMs === undefined
|
|
80
|
+
? {}
|
|
81
|
+
: { ttlMs: options.accessTtlMs }),
|
|
82
|
+
// The person lost a room: their page is told to look again, which finds it gone.
|
|
83
|
+
revoked: (connection: AppSocket, room: string) =>
|
|
84
|
+
options.sockets.deliver({ type: "room_changed", room }, { connection }),
|
|
85
|
+
});
|
|
86
|
+
this.pages = new HostPagesService({
|
|
87
|
+
registry: options.registry,
|
|
88
|
+
sockets: options.sockets,
|
|
89
|
+
files: options.files,
|
|
90
|
+
maxFileBytes: options.maxFileBytes,
|
|
91
|
+
events: options.events,
|
|
92
|
+
now,
|
|
93
|
+
logger: options.logger,
|
|
94
|
+
});
|
|
95
|
+
this.rooms = new HostRoomsService({
|
|
96
|
+
registry: options.registry,
|
|
97
|
+
sockets: options.sockets,
|
|
98
|
+
log: options.log,
|
|
99
|
+
cards: options.cards,
|
|
100
|
+
files: options.files,
|
|
101
|
+
access: this.access,
|
|
102
|
+
maxFileBytes: options.maxFileBytes,
|
|
103
|
+
roomUrl: options.roomUrl,
|
|
104
|
+
listed,
|
|
105
|
+
context: (room, contextOptions) =>
|
|
106
|
+
this.#roomContext.peek(room, contextOptions),
|
|
107
|
+
now,
|
|
108
|
+
logger: options.logger,
|
|
109
|
+
});
|
|
110
|
+
this.#context = {
|
|
111
|
+
surface: options.surface,
|
|
112
|
+
registry: options.registry,
|
|
113
|
+
sockets: options.sockets,
|
|
114
|
+
app: options.app,
|
|
115
|
+
desk: options.desk,
|
|
116
|
+
log: options.log,
|
|
117
|
+
cards: options.cards,
|
|
118
|
+
reads: options.reads,
|
|
119
|
+
files: options.files,
|
|
120
|
+
uploads: options.uploads,
|
|
121
|
+
rooms: options.rooms,
|
|
122
|
+
conversations: options.conversations,
|
|
123
|
+
access: this.access,
|
|
124
|
+
listed,
|
|
125
|
+
pages: this.pages,
|
|
126
|
+
nameOf: options.nameOf,
|
|
127
|
+
events: options.events,
|
|
128
|
+
now,
|
|
129
|
+
logger: options.logger,
|
|
130
|
+
};
|
|
131
|
+
this.#roomContext = hostRoomContext(this.#context);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Plugs the pieces into the registry: who reads, the frames, the areas, and the services a host reaches. */
|
|
135
|
+
install(): void {
|
|
136
|
+
const { registry } = this.#context;
|
|
137
|
+
registry.setAccess(this.access);
|
|
138
|
+
registry.provide({ rooms: this.rooms, pages: this.pages });
|
|
139
|
+
installFrames(this.#context);
|
|
140
|
+
registry.onArea(hostArea(this.#context));
|
|
141
|
+
registry.onArea(pagesArea(this.#context));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
start(): void {
|
|
145
|
+
this.access.start();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
stop(): void {
|
|
149
|
+
this.access.stop();
|
|
150
|
+
this.pages.stop();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** What was said in a host room since the assistant last posted; undefined for any other room. */
|
|
154
|
+
channelContext(
|
|
155
|
+
message: InboundMessage,
|
|
156
|
+
options?: ChannelContextOptions,
|
|
157
|
+
): Promise<ChannelContext | undefined> {
|
|
158
|
+
return this.#roomContext.read(message, options);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Whether a stored file bound to a room or a page may be served to this viewer now. */
|
|
162
|
+
async mayRead(viewer: Viewer, binding: string): Promise<boolean> {
|
|
163
|
+
if (binding.startsWith(PAGE_BINDING))
|
|
164
|
+
return (
|
|
165
|
+
(await this.pages.visible(
|
|
166
|
+
viewer,
|
|
167
|
+
binding.slice(PAGE_BINDING.length),
|
|
168
|
+
)) !== undefined
|
|
169
|
+
);
|
|
170
|
+
if (!this.#context.registry.isHostRoom(binding)) return false;
|
|
171
|
+
return this.access.read(viewer, binding as ChannelKey);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Applies the kinds' retention; the counts of what it removed. */
|
|
175
|
+
sweep(): Promise<HostSweep> {
|
|
176
|
+
return sweepHostKinds(this.#context);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChannelKey,
|
|
3
|
+
ConversationPort,
|
|
4
|
+
EventSink,
|
|
5
|
+
Logger,
|
|
6
|
+
} from "pi-roundtable";
|
|
7
|
+
import type { OwnerDesk } from "../app/desk.ts";
|
|
8
|
+
import type { FileDir } from "../app/files.ts";
|
|
9
|
+
import type { RoomLog } from "../app/room-log.ts";
|
|
10
|
+
import type { Rooms } from "../app/rooms.ts";
|
|
11
|
+
import type { Sockets } from "../app/sockets.ts";
|
|
12
|
+
import type { AppSurface } from "../app/surface.ts";
|
|
13
|
+
import type { Uploads } from "../app/uploads.ts";
|
|
14
|
+
import type { WebAppRegistry } from "../web-app.ts";
|
|
15
|
+
import type { HostAccess } from "./access.ts";
|
|
16
|
+
import type { HostCardLog } from "./cards.ts";
|
|
17
|
+
import type { HostPagesService } from "./pages.ts";
|
|
18
|
+
import type { RoomReads } from "./reads.ts";
|
|
19
|
+
import type { ListedKinds } from "./rooms-service.ts";
|
|
20
|
+
|
|
21
|
+
/** Everything the pieces of the host rooms share. */
|
|
22
|
+
export interface HostContext {
|
|
23
|
+
/** The key prefix of the app's rooms: `app`. */
|
|
24
|
+
surface: string;
|
|
25
|
+
registry: WebAppRegistry;
|
|
26
|
+
sockets: Sockets;
|
|
27
|
+
app: AppSurface;
|
|
28
|
+
desk: Pick<OwnerDesk, "openFrames" | "roomOf">;
|
|
29
|
+
log: RoomLog;
|
|
30
|
+
cards: HostCardLog;
|
|
31
|
+
reads: RoomReads;
|
|
32
|
+
/** The stored files. */
|
|
33
|
+
files: FileDir;
|
|
34
|
+
uploads: Uploads;
|
|
35
|
+
/** The agent rooms, for the read marks of the owners. */
|
|
36
|
+
rooms: Pick<Rooms, "resolve">;
|
|
37
|
+
conversations: Pick<ConversationPort, "stop">;
|
|
38
|
+
access: HostAccess;
|
|
39
|
+
listed: ListedKinds;
|
|
40
|
+
pages: Pick<HostPagesService, "act" | "view" | "visible">;
|
|
41
|
+
/** The name the app shows for a person. */
|
|
42
|
+
nameOf(principalId: string): Promise<string>;
|
|
43
|
+
events: Pick<EventSink, "action"> | undefined;
|
|
44
|
+
now(): number;
|
|
45
|
+
logger: Logger;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A room key as the registry's `ChannelKey`. */
|
|
49
|
+
export const asRoom = (key: string): ChannelKey => key as ChannelKey;
|