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
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { SQL } from "bun";
|
|
3
|
+
import type {
|
|
4
|
+
IdentityService,
|
|
5
|
+
Logger,
|
|
6
|
+
Migration,
|
|
7
|
+
PersonProfiles,
|
|
8
|
+
} from "pi-roundtable";
|
|
9
|
+
import type { AppSocket, Sockets } from "../app/sockets.ts";
|
|
10
|
+
import type { Gate } from "../gate.ts";
|
|
11
|
+
import { onboardingArea } from "../onboarding/api.ts";
|
|
12
|
+
import { OnboardingService, PUSH_STEP } from "../onboarding/service.ts";
|
|
13
|
+
import { builtInSteps } from "../onboarding/steps.ts";
|
|
14
|
+
import { OnboardingWaivers } from "../onboarding/waivers.ts";
|
|
15
|
+
import type { ResolvedOptions } from "../options.ts";
|
|
16
|
+
import { linksArea, peopleArea, profileArea } from "../profile/api.ts";
|
|
17
|
+
import { LinkService } from "../profile/links.ts";
|
|
18
|
+
import { DeviceRoutes, PushApi } from "../push/api.ts";
|
|
19
|
+
import { PushDispatch } from "../push/dispatch.ts";
|
|
20
|
+
import { OutboxSender, type PushTransport } from "../push/drain.ts";
|
|
21
|
+
import type { EndpointPolicy } from "../push/endpoint.ts";
|
|
22
|
+
import {
|
|
23
|
+
type OutboxKind,
|
|
24
|
+
PushOutbox,
|
|
25
|
+
type ReceiptEvent,
|
|
26
|
+
} from "../push/outbox.ts";
|
|
27
|
+
import { PushPrefsStore } from "../push/prefs.ts";
|
|
28
|
+
import type { PushSubscriptions } from "../push/subscriptions.ts";
|
|
29
|
+
import type { OutboxRoute } from "../push/triggers.ts";
|
|
30
|
+
import type { Vapid } from "../push/vapid.ts";
|
|
31
|
+
import { webPushTransport } from "../push/web-push-transport.ts";
|
|
32
|
+
import type { WebAppRegistry } from "../web-app.ts";
|
|
33
|
+
import { ackFrame, noticesArea } from "./api.ts";
|
|
34
|
+
import { NoticeService } from "./service.ts";
|
|
35
|
+
import { NoticeStore } from "./store.ts";
|
|
36
|
+
|
|
37
|
+
/** The tables of notices, push delivery, preferences and waivers. */
|
|
38
|
+
export const NOTICE_MIGRATIONS: readonly Migration[] = [
|
|
39
|
+
NoticeStore.migration,
|
|
40
|
+
PushOutbox.migration,
|
|
41
|
+
PushPrefsStore.migration,
|
|
42
|
+
OnboardingWaivers.migration,
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/** The words of a push the app sends itself when its row carries none (`push.content: "none"`). */
|
|
46
|
+
const WORDS: Record<OutboxKind, string> = {
|
|
47
|
+
card: "Card waiting",
|
|
48
|
+
reply: "New reply",
|
|
49
|
+
notice: "Notice",
|
|
50
|
+
test: "Test notification",
|
|
51
|
+
clear: "",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export interface NoticesMountOptions {
|
|
55
|
+
database: SQL;
|
|
56
|
+
logger: Logger;
|
|
57
|
+
registry: WebAppRegistry;
|
|
58
|
+
gate: Gate;
|
|
59
|
+
sockets: Pick<Sockets, "anyVisible" | "deliver">;
|
|
60
|
+
identity: IdentityService;
|
|
61
|
+
/** Present on every host that has the identity plugin; the profile page needs it. */
|
|
62
|
+
profiles?: PersonProfiles;
|
|
63
|
+
subscriptions: PushSubscriptions;
|
|
64
|
+
policy: EndpointPolicy;
|
|
65
|
+
mount: string;
|
|
66
|
+
routing: "hash" | "path";
|
|
67
|
+
/** The tiers, content, sender and transports the options resolved to. */
|
|
68
|
+
push: Pick<
|
|
69
|
+
ResolvedOptions["push"],
|
|
70
|
+
| "tiers"
|
|
71
|
+
| "content"
|
|
72
|
+
| "preview"
|
|
73
|
+
| "sender"
|
|
74
|
+
| "transports"
|
|
75
|
+
| "subject"
|
|
76
|
+
| "endpointHosts"
|
|
77
|
+
| "fetch"
|
|
78
|
+
>;
|
|
79
|
+
/** The operator's quiet window for a normal notice; absent = none. */
|
|
80
|
+
quiet?: (at: number) => boolean;
|
|
81
|
+
/** The app's own key, present with the internal sender. */
|
|
82
|
+
vapid?: Vapid;
|
|
83
|
+
onboarding: ResolvedOptions["onboarding"];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface NoticesMount {
|
|
87
|
+
notices: NoticeService;
|
|
88
|
+
pushApi: PushApi;
|
|
89
|
+
/** Present when the app does not sign: the built-in pushes are queued for the external sender. */
|
|
90
|
+
route?: OutboxRoute;
|
|
91
|
+
outbox: PushOutbox;
|
|
92
|
+
/** Sends a socket its notices and the badge once it is ready. */
|
|
93
|
+
opened(connection: AppSocket): Promise<void>;
|
|
94
|
+
start(): void;
|
|
95
|
+
stop(): Promise<void>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Builds the notices, the push outbox and its in-process sender (internal mode), the onboarding
|
|
100
|
+
* service and the profile and link routes, and registers them with the web app. A host plugin
|
|
101
|
+
* reaches the first three through `WEB_APP` once the web service has started.
|
|
102
|
+
*/
|
|
103
|
+
export function mountNotices(options: NoticesMountOptions): NoticesMount {
|
|
104
|
+
const { database, registry, sockets, identity, logger, push } = options;
|
|
105
|
+
const outbox = new PushOutbox(database);
|
|
106
|
+
const prefs = new PushPrefsStore(database);
|
|
107
|
+
const external = push.sender !== "internal";
|
|
108
|
+
const enabled = external || push.subject !== undefined;
|
|
109
|
+
const transports: PushTransport[] = [];
|
|
110
|
+
if (!external && options.vapid && push.subject)
|
|
111
|
+
transports.push(
|
|
112
|
+
webPushTransport({
|
|
113
|
+
vapid: options.vapid,
|
|
114
|
+
subject: push.subject,
|
|
115
|
+
endpointHosts: push.endpointHosts,
|
|
116
|
+
database,
|
|
117
|
+
...(push.fetch ? { fetch: push.fetch } : {}),
|
|
118
|
+
logger,
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
const sender = external
|
|
122
|
+
? undefined
|
|
123
|
+
: new OutboxSender({
|
|
124
|
+
outbox,
|
|
125
|
+
transports,
|
|
126
|
+
render: (row) => ({
|
|
127
|
+
title: row.title ?? WORDS[row.kind],
|
|
128
|
+
...(row.body ? { body: row.body } : {}),
|
|
129
|
+
}),
|
|
130
|
+
logger,
|
|
131
|
+
});
|
|
132
|
+
const dispatch = new PushDispatch({
|
|
133
|
+
outbox,
|
|
134
|
+
prefs,
|
|
135
|
+
identity,
|
|
136
|
+
tiers: push.tiers,
|
|
137
|
+
content: push.content,
|
|
138
|
+
preview: push.preview,
|
|
139
|
+
visible: (principalId) => sockets.anyVisible(principalId),
|
|
140
|
+
...(options.quiet ? { quiet: options.quiet } : {}),
|
|
141
|
+
...(sender
|
|
142
|
+
? { kick: () => void sender.drain().catch(() => undefined) }
|
|
143
|
+
: {}),
|
|
144
|
+
enabled,
|
|
145
|
+
transports: push.transports,
|
|
146
|
+
logger,
|
|
147
|
+
});
|
|
148
|
+
const routing = { mount: options.mount, routing: options.routing };
|
|
149
|
+
const store = new NoticeStore(database);
|
|
150
|
+
const notices = new NoticeService({
|
|
151
|
+
store,
|
|
152
|
+
dispatch,
|
|
153
|
+
deliver: (frame, principal) => sockets.deliver(frame, { principal }),
|
|
154
|
+
routing,
|
|
155
|
+
logger,
|
|
156
|
+
});
|
|
157
|
+
const pushStatus = {
|
|
158
|
+
status: async (principalId: string) => {
|
|
159
|
+
const lastReceiptAt = await outbox.lastReceiptAt(principalId);
|
|
160
|
+
return {
|
|
161
|
+
devices: (await options.subscriptions.of(principalId)).length,
|
|
162
|
+
...(lastReceiptAt ? { lastReceiptAt } : {}),
|
|
163
|
+
waived: await registry.waived(principalId, PUSH_STEP),
|
|
164
|
+
};
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
const waivers = new OnboardingWaivers(database);
|
|
168
|
+
registry.setWaivers(waivers);
|
|
169
|
+
const onboarding = new OnboardingService({
|
|
170
|
+
registry,
|
|
171
|
+
waivers,
|
|
172
|
+
gate: options.gate,
|
|
173
|
+
identity,
|
|
174
|
+
...(options.profiles ? { profiles: options.profiles } : {}),
|
|
175
|
+
push: pushStatus,
|
|
176
|
+
logger,
|
|
177
|
+
});
|
|
178
|
+
const links = new LinkService({
|
|
179
|
+
registry,
|
|
180
|
+
steps: options.onboarding.links,
|
|
181
|
+
identity,
|
|
182
|
+
gate: options.gate,
|
|
183
|
+
logger,
|
|
184
|
+
});
|
|
185
|
+
registry.provide({ notices, onboarding, push: pushStatus });
|
|
186
|
+
for (const step of builtInSteps({
|
|
187
|
+
links: options.onboarding.links,
|
|
188
|
+
push: {
|
|
189
|
+
mode: options.onboarding.push,
|
|
190
|
+
waiver: options.onboarding.pushWaiver,
|
|
191
|
+
},
|
|
192
|
+
pushTiers: push.tiers,
|
|
193
|
+
prover: (provider) => registry.prover(provider),
|
|
194
|
+
identity,
|
|
195
|
+
outbox,
|
|
196
|
+
}))
|
|
197
|
+
registry.registerStep(step);
|
|
198
|
+
registry.onArea(noticesArea(notices));
|
|
199
|
+
registry.onArea(onboardingArea(onboarding));
|
|
200
|
+
registry.onArea(linksArea(links));
|
|
201
|
+
registry.onArea(
|
|
202
|
+
peopleArea({
|
|
203
|
+
profiles: options.profiles ?? { picture: async () => undefined },
|
|
204
|
+
gate: options.gate,
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
if (options.profiles)
|
|
208
|
+
registry.onArea(
|
|
209
|
+
profileArea({
|
|
210
|
+
profiles: options.profiles,
|
|
211
|
+
identity,
|
|
212
|
+
sessionLabel: "Sign-in account",
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
215
|
+
registry.onFrame("ack", ackFrame(notices));
|
|
216
|
+
|
|
217
|
+
const receipt = async (
|
|
218
|
+
principalId: string,
|
|
219
|
+
tag: string,
|
|
220
|
+
event: ReceiptEvent,
|
|
221
|
+
): Promise<boolean> => {
|
|
222
|
+
const mine = tag.startsWith("test:")
|
|
223
|
+
? await outbox.has(principalId, "test", tag)
|
|
224
|
+
: await notices.receipt(tag, principalId, event);
|
|
225
|
+
if (!mine) return false;
|
|
226
|
+
await outbox.receipt(principalId, tag, event);
|
|
227
|
+
// A receipt may finish the push step: the gate asks again.
|
|
228
|
+
onboarding.changed(principalId);
|
|
229
|
+
return true;
|
|
230
|
+
};
|
|
231
|
+
const pushApi = new PushApi({
|
|
232
|
+
vapid: {
|
|
233
|
+
publicKey:
|
|
234
|
+
push.sender === "internal"
|
|
235
|
+
? (options.vapid?.publicKey ?? "")
|
|
236
|
+
: push.sender.external.publicKey,
|
|
237
|
+
},
|
|
238
|
+
devices: new DeviceRoutes({
|
|
239
|
+
policy: options.policy,
|
|
240
|
+
subscriptions: options.subscriptions,
|
|
241
|
+
enabled,
|
|
242
|
+
}),
|
|
243
|
+
tiers: push.tiers,
|
|
244
|
+
subscriptions:
|
|
245
|
+
push.sender === "internal" || !push.sender.external.subscribePath,
|
|
246
|
+
...(enabled
|
|
247
|
+
? {
|
|
248
|
+
test: async (principalId: string) => {
|
|
249
|
+
if ((await options.subscriptions.of(principalId)).length === 0)
|
|
250
|
+
return "no-device" as const;
|
|
251
|
+
const tag = `test:${randomUUID()}`;
|
|
252
|
+
await dispatch.test(
|
|
253
|
+
principalId,
|
|
254
|
+
tag,
|
|
255
|
+
`${options.mount}/`,
|
|
256
|
+
await notices.badge(principalId),
|
|
257
|
+
);
|
|
258
|
+
return { tag };
|
|
259
|
+
},
|
|
260
|
+
}
|
|
261
|
+
: {}),
|
|
262
|
+
receipt,
|
|
263
|
+
prefs,
|
|
264
|
+
});
|
|
265
|
+
const route: OutboxRoute | undefined = external
|
|
266
|
+
? { outbox, content: push.content, badge: (id) => notices.badge(id) }
|
|
267
|
+
: undefined;
|
|
268
|
+
let sweeper: ReturnType<typeof setInterval> | undefined;
|
|
269
|
+
return {
|
|
270
|
+
notices,
|
|
271
|
+
pushApi,
|
|
272
|
+
...(route ? { route } : {}),
|
|
273
|
+
outbox,
|
|
274
|
+
opened: async (connection) => {
|
|
275
|
+
for (const frame of await notices.frames(connection.principal.id))
|
|
276
|
+
sockets.deliver(frame, { connection });
|
|
277
|
+
},
|
|
278
|
+
start: () => {
|
|
279
|
+
sender?.start();
|
|
280
|
+
const sweep = () =>
|
|
281
|
+
Promise.all([store.sweep(), outbox.sweep()]).catch((error: unknown) =>
|
|
282
|
+
logger.warn({ err: error }, "old notices were not deleted"),
|
|
283
|
+
);
|
|
284
|
+
void sweep();
|
|
285
|
+
sweeper = setInterval(sweep, 3_600_000);
|
|
286
|
+
},
|
|
287
|
+
stop: async () => {
|
|
288
|
+
clearInterval(sweeper);
|
|
289
|
+
await sender?.stop();
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { ConfigError, type Logger } from "pi-roundtable";
|
|
2
|
+
import type { NoticeView, ServerFrame } from "../api-types.ts";
|
|
3
|
+
import type { PushDispatch } from "../push/dispatch.ts";
|
|
4
|
+
import type { Notices, OpenTarget } from "../web-app.ts";
|
|
5
|
+
import type { NoticeFor, NoticeStore } from "./store.ts";
|
|
6
|
+
import { openUrl, type RoutingOptions } from "./urls.ts";
|
|
7
|
+
|
|
8
|
+
const MAX_TAG = 200;
|
|
9
|
+
const MAX_TITLE = 200;
|
|
10
|
+
const MAX_BODY = 2000;
|
|
11
|
+
const MAX_RECIPIENTS = 500;
|
|
12
|
+
const PAGE_ID = /^[a-z0-9-]{1,40}$/;
|
|
13
|
+
|
|
14
|
+
export interface NoticeServiceOptions {
|
|
15
|
+
store: NoticeStore;
|
|
16
|
+
dispatch: PushDispatch;
|
|
17
|
+
/** Puts a frame on the open sockets of one person. */
|
|
18
|
+
deliver(frame: ServerFrame, principalId: string): void;
|
|
19
|
+
routing: RoutingOptions;
|
|
20
|
+
logger: Logger;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A notice as the bell shows it. */
|
|
24
|
+
export function viewOf(notice: NoticeFor): NoticeView {
|
|
25
|
+
return {
|
|
26
|
+
tag: notice.tag,
|
|
27
|
+
title: notice.title,
|
|
28
|
+
...(notice.body ? { body: notice.body } : {}),
|
|
29
|
+
...(notice.open
|
|
30
|
+
? {
|
|
31
|
+
open:
|
|
32
|
+
"room" in notice.open
|
|
33
|
+
? {
|
|
34
|
+
room: notice.open.room,
|
|
35
|
+
...(notice.open.message === undefined
|
|
36
|
+
? {}
|
|
37
|
+
: { message: notice.open.message }),
|
|
38
|
+
}
|
|
39
|
+
: {
|
|
40
|
+
page: notice.open.page,
|
|
41
|
+
...(notice.open.query
|
|
42
|
+
? { query: { ...notice.open.query } }
|
|
43
|
+
: {}),
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
: {}),
|
|
47
|
+
urgency: notice.urgency,
|
|
48
|
+
ackable: notice.ackable,
|
|
49
|
+
raisedAt: notice.raisedAt.toISOString(),
|
|
50
|
+
...(notice.ackedAt ? { acked: true as const } : {}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const refuse = (message: string): never => {
|
|
55
|
+
throw new ConfigError(`notices: ${message}`);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function checkOpen(open: OpenTarget | undefined): void {
|
|
59
|
+
if (!open) return;
|
|
60
|
+
if ("room" in open) {
|
|
61
|
+
if (typeof open.room !== "string" || open.room.length === 0)
|
|
62
|
+
refuse("open.room must be a room key");
|
|
63
|
+
if (
|
|
64
|
+
open.message !== undefined &&
|
|
65
|
+
!(Number.isSafeInteger(open.message) && open.message >= 0)
|
|
66
|
+
)
|
|
67
|
+
refuse("open.message must be a message id");
|
|
68
|
+
} else if (!PAGE_ID.test(open.page))
|
|
69
|
+
refuse("open.page must match [a-z0-9-]{1,40}");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The notices of the host: person-addressed, with state. A notice is a row the bell shows to its
|
|
74
|
+
* addressees and, when push is on, a push to their devices; the badge is the count of those that
|
|
75
|
+
* ask for an answer and have none. Everything a person can do to it (read, answer) is checked
|
|
76
|
+
* against the addressee list, which only the host changes.
|
|
77
|
+
*/
|
|
78
|
+
export class NoticeService implements Notices {
|
|
79
|
+
readonly #options: NoticeServiceOptions;
|
|
80
|
+
readonly #listeners = new Set<(tag: string, principalId: string) => void>();
|
|
81
|
+
|
|
82
|
+
constructor(options: NoticeServiceOptions) {
|
|
83
|
+
this.#options = options;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async raise(notice: Parameters<Notices["raise"]>[0]): Promise<void> {
|
|
87
|
+
const { store } = this.#options;
|
|
88
|
+
const to = [...new Set(notice.to)];
|
|
89
|
+
if (notice.tag.length === 0 || notice.tag.length > MAX_TAG)
|
|
90
|
+
refuse(`the tag must be 1 to ${MAX_TAG} characters`);
|
|
91
|
+
if (to.length === 0 || to.length > MAX_RECIPIENTS)
|
|
92
|
+
refuse(`a notice goes to 1 to ${MAX_RECIPIENTS} people`);
|
|
93
|
+
if (notice.title.length === 0 || notice.title.length > MAX_TITLE)
|
|
94
|
+
refuse(`the title must be 1 to ${MAX_TITLE} characters`);
|
|
95
|
+
if ((notice.body?.length ?? 0) > MAX_BODY)
|
|
96
|
+
refuse(`the body is longer than ${MAX_BODY} characters`);
|
|
97
|
+
checkOpen(notice.open);
|
|
98
|
+
this.#checkVia(notice.via);
|
|
99
|
+
await store.raise(
|
|
100
|
+
{
|
|
101
|
+
tag: notice.tag,
|
|
102
|
+
title: notice.title,
|
|
103
|
+
...(notice.body ? { body: notice.body } : {}),
|
|
104
|
+
...(notice.open ? { open: notice.open } : {}),
|
|
105
|
+
urgency: notice.urgency,
|
|
106
|
+
ackable: notice.ackable === true,
|
|
107
|
+
},
|
|
108
|
+
to,
|
|
109
|
+
);
|
|
110
|
+
await Promise.all(
|
|
111
|
+
to.map((principalId) =>
|
|
112
|
+
this.#tell(notice.tag, principalId, {
|
|
113
|
+
push: notice.push !== false,
|
|
114
|
+
...(notice.via ? { via: notice.via } : {}),
|
|
115
|
+
}),
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async renotify(
|
|
121
|
+
tag: string,
|
|
122
|
+
add: readonly string[] = [],
|
|
123
|
+
options: { via?: readonly string[] } = {},
|
|
124
|
+
): Promise<void> {
|
|
125
|
+
this.#checkVia(options.via);
|
|
126
|
+
const people = await this.#options.store.renotify(tag, [...new Set(add)]);
|
|
127
|
+
if (!people) return;
|
|
128
|
+
await Promise.all(
|
|
129
|
+
people.map((principalId) =>
|
|
130
|
+
this.#tell(tag, principalId, {
|
|
131
|
+
push: true,
|
|
132
|
+
...(options.via ? { via: options.via } : {}),
|
|
133
|
+
}),
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async resolve(
|
|
139
|
+
tag: string,
|
|
140
|
+
by?: { principalId?: string; reason: string },
|
|
141
|
+
): Promise<void> {
|
|
142
|
+
const { store } = this.#options;
|
|
143
|
+
const before = new Map<string, NoticeFor | undefined>();
|
|
144
|
+
const open = (await store.pending(tag))?.to ?? [];
|
|
145
|
+
for (const principalId of open)
|
|
146
|
+
before.set(principalId, await store.of(tag, principalId));
|
|
147
|
+
const people = await store.resolve(tag, by);
|
|
148
|
+
if (!people) return;
|
|
149
|
+
await Promise.all(
|
|
150
|
+
people.map((principalId) =>
|
|
151
|
+
this.#cleared(tag, principalId, before.get(principalId)),
|
|
152
|
+
),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Tells one addressee's pages and devices a notice is gone. */
|
|
157
|
+
async #cleared(
|
|
158
|
+
tag: string,
|
|
159
|
+
principalId: string,
|
|
160
|
+
was: NoticeFor | undefined,
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
const { store, dispatch, routing } = this.#options;
|
|
163
|
+
this.#options.deliver({ type: "notice_resolved", tag }, principalId);
|
|
164
|
+
const count = await this.#sendBadge(principalId);
|
|
165
|
+
// Only a device that was pushed this notice has something to close.
|
|
166
|
+
if (await store.delivered(tag, principalId))
|
|
167
|
+
await dispatch.clear(
|
|
168
|
+
principalId,
|
|
169
|
+
tag,
|
|
170
|
+
openUrl(was?.open, routing),
|
|
171
|
+
count,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
pending(tag: string): ReturnType<Notices["pending"]> {
|
|
176
|
+
return this.#options.store.pending(tag);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
onAck(listener: (tag: string, principalId: string) => void): () => void {
|
|
180
|
+
this.#listeners.add(listener);
|
|
181
|
+
return () => this.#listeners.delete(listener);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── What the person does ──
|
|
185
|
+
|
|
186
|
+
/** The person's open notices and their count. */
|
|
187
|
+
async listFor(
|
|
188
|
+
principalId: string,
|
|
189
|
+
): Promise<{ notices: NoticeView[]; badge: number }> {
|
|
190
|
+
const { store } = this.#options;
|
|
191
|
+
const [notices, badge] = await Promise.all([
|
|
192
|
+
store.openFor(principalId),
|
|
193
|
+
store.unhandled(principalId),
|
|
194
|
+
]);
|
|
195
|
+
return { notices: notices.map(viewOf), badge };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The person handled a notice; false when it is not theirs, is closed, or asks for no answer. */
|
|
199
|
+
async ack(tag: string, principalId: string): Promise<boolean> {
|
|
200
|
+
const { store, dispatch, routing } = this.#options;
|
|
201
|
+
if (!(await store.ack(tag, principalId))) return false;
|
|
202
|
+
const notice = await store.of(tag, principalId);
|
|
203
|
+
if (notice)
|
|
204
|
+
this.#options.deliver(
|
|
205
|
+
{ type: "notice", notice: viewOf(notice) },
|
|
206
|
+
principalId,
|
|
207
|
+
);
|
|
208
|
+
const count = await this.#sendBadge(principalId);
|
|
209
|
+
// Their other devices stop showing it.
|
|
210
|
+
if (await store.delivered(tag, principalId))
|
|
211
|
+
await dispatch.clear(
|
|
212
|
+
principalId,
|
|
213
|
+
tag,
|
|
214
|
+
openUrl(notice?.open, routing),
|
|
215
|
+
count,
|
|
216
|
+
);
|
|
217
|
+
for (const listener of this.#listeners) {
|
|
218
|
+
try {
|
|
219
|
+
listener(tag, principalId);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
this.#options.logger.warn({ err: error, tag }, "an ack listener threw");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Records a receipt from the service worker; false when the notice is not addressed to the person. */
|
|
228
|
+
receipt(
|
|
229
|
+
tag: string,
|
|
230
|
+
principalId: string,
|
|
231
|
+
event: "received" | "opened",
|
|
232
|
+
): Promise<boolean> {
|
|
233
|
+
return this.#options.store.receipt(tag, principalId, event);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The count the app icon shows for the person: their notices not handled. */
|
|
237
|
+
badge(principalId: string): Promise<number> {
|
|
238
|
+
return this.#options.store.unhandled(principalId);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The frames a socket gets when it opens: every notice that is open for the person, and the count. */
|
|
242
|
+
async frames(principalId: string): Promise<ServerFrame[]> {
|
|
243
|
+
const { notices, badge } = await this.listFor(principalId);
|
|
244
|
+
return [
|
|
245
|
+
...notices.map((notice): ServerFrame => ({ type: "notice", notice })),
|
|
246
|
+
{ type: "badge", count: badge },
|
|
247
|
+
];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── Inside ──
|
|
251
|
+
|
|
252
|
+
#checkVia(via: readonly string[] | undefined): void {
|
|
253
|
+
if (!via) return;
|
|
254
|
+
const known = this.#options.dispatch.transports;
|
|
255
|
+
if (via.length === 0) refuse("via names no transport");
|
|
256
|
+
for (const name of via)
|
|
257
|
+
if (!known.includes(name))
|
|
258
|
+
refuse(
|
|
259
|
+
`via names ${JSON.stringify(name)}, but the sender has ${known.join(", ")}; list a host transport in push.transports`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async #sendBadge(principalId: string): Promise<number> {
|
|
264
|
+
const count = await this.#options.store.unhandled(principalId);
|
|
265
|
+
this.#options.deliver({ type: "badge", count }, principalId);
|
|
266
|
+
return count;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Shows a notice to one addressee and, when asked, pushes it to their devices. */
|
|
270
|
+
async #tell(
|
|
271
|
+
tag: string,
|
|
272
|
+
principalId: string,
|
|
273
|
+
options: { push: boolean; via?: readonly string[] },
|
|
274
|
+
): Promise<void> {
|
|
275
|
+
const { store, dispatch, routing } = this.#options;
|
|
276
|
+
const notice = await store.of(tag, principalId);
|
|
277
|
+
if (!notice) return;
|
|
278
|
+
this.#options.deliver(
|
|
279
|
+
{ type: "notice", notice: viewOf(notice) },
|
|
280
|
+
principalId,
|
|
281
|
+
);
|
|
282
|
+
const count = await this.#sendBadge(principalId);
|
|
283
|
+
if (!options.push) return;
|
|
284
|
+
await dispatch.notice(principalId, {
|
|
285
|
+
tag,
|
|
286
|
+
title: notice.title,
|
|
287
|
+
...(notice.body ? { body: notice.body } : {}),
|
|
288
|
+
open: openUrl(notice.open, routing),
|
|
289
|
+
urgency: notice.urgency,
|
|
290
|
+
badge: count,
|
|
291
|
+
...(options.via ? { via: options.via } : {}),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|