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,118 @@
|
|
|
1
|
+
import type { Server, SQL } from "bun";
|
|
2
|
+
import type { Logger } from "pi-roundtable";
|
|
3
|
+
import { ConsoleHttpError, json } from "../http.ts";
|
|
4
|
+
import { DeviceRoutes } from "../push/api.ts";
|
|
5
|
+
import { OutboxSender, type PushTransport } from "../push/drain.ts";
|
|
6
|
+
import { EndpointPolicy } from "../push/endpoint.ts";
|
|
7
|
+
import { type OutboxRow, PushOutbox } from "../push/outbox.ts";
|
|
8
|
+
import { PushSubscriptions } from "../push/subscriptions.ts";
|
|
9
|
+
|
|
10
|
+
export interface PushSenderOptions {
|
|
11
|
+
/**
|
|
12
|
+
* The sender's own database role. It needs to SELECT the outbox, subscriptions, sessions and
|
|
13
|
+
* principals, UPDATE the outbox, and INSERT deliveries and subscriptions.
|
|
14
|
+
*/
|
|
15
|
+
database: SQL;
|
|
16
|
+
/** Tried in order for each row until one says `sent`; a row's `via` narrows them. */
|
|
17
|
+
transports: readonly PushTransport[];
|
|
18
|
+
/** Words a row; with `push.content: "none"` the row carries no text, so this is where it comes from. */
|
|
19
|
+
render(row: OutboxRow): { title: string; body?: string };
|
|
20
|
+
/** Serves the subscription routes at `path`, for `push.sender.external.subscribePath`. */
|
|
21
|
+
subscribe?: {
|
|
22
|
+
listener: { port: number; hostname?: string } | { socketPath: string };
|
|
23
|
+
/** The path the page posts devices to, such as `/push/subscriptions`. */
|
|
24
|
+
path: string;
|
|
25
|
+
/** Every request that is not a GET must carry exactly this `Origin`: the console's. */
|
|
26
|
+
origin: string;
|
|
27
|
+
/** The principal behind the request's session cookie, or undefined (use `webSessionPrincipal`). */
|
|
28
|
+
authenticate(request: Request): Promise<string | undefined>;
|
|
29
|
+
/** Push services besides the browsers' own, as `push.endpointHosts` writes them. */
|
|
30
|
+
endpointHosts?: readonly string[];
|
|
31
|
+
};
|
|
32
|
+
logger: Logger;
|
|
33
|
+
/** How often the outbox is looked at; default 1 s. */
|
|
34
|
+
intervalMs?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Runs the process that signs and sends the app's pushes when the app holds no key
|
|
39
|
+
* (`push.sender.external`). It empties the outbox through the transports and, when asked, serves
|
|
40
|
+
* the subscription routes with the app's session cookie and the console's `Origin`.
|
|
41
|
+
*/
|
|
42
|
+
export function runPushSender(options: PushSenderOptions): {
|
|
43
|
+
stop(): Promise<void>;
|
|
44
|
+
/** Takes and sends what is queued now, for a test; returns how many rows it finished. */
|
|
45
|
+
drain(): Promise<number>;
|
|
46
|
+
/** The port the subscription routes listen on, when they are served over TCP. */
|
|
47
|
+
port?: number;
|
|
48
|
+
} {
|
|
49
|
+
const sender = new OutboxSender({
|
|
50
|
+
outbox: new PushOutbox(options.database),
|
|
51
|
+
transports: options.transports,
|
|
52
|
+
render: options.render,
|
|
53
|
+
logger: options.logger,
|
|
54
|
+
...(options.intervalMs ? { intervalMs: options.intervalMs } : {}),
|
|
55
|
+
});
|
|
56
|
+
sender.start();
|
|
57
|
+
const server = options.subscribe ? serve(options) : undefined;
|
|
58
|
+
return {
|
|
59
|
+
stop: async () => {
|
|
60
|
+
await server?.stop(true);
|
|
61
|
+
await sender.stop();
|
|
62
|
+
},
|
|
63
|
+
drain: () => sender.drain(),
|
|
64
|
+
...(server?.port ? { port: server.port } : {}),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function serve(options: PushSenderOptions): Server<undefined> {
|
|
69
|
+
const { subscribe, logger } = options;
|
|
70
|
+
if (!subscribe) throw new Error("no subscribe options");
|
|
71
|
+
const devices = new DeviceRoutes({
|
|
72
|
+
policy: new EndpointPolicy(subscribe.endpointHosts),
|
|
73
|
+
subscriptions: new PushSubscriptions(options.database),
|
|
74
|
+
enabled: true,
|
|
75
|
+
});
|
|
76
|
+
const base = subscribe.path.replace(/\/$/, "");
|
|
77
|
+
return Bun.serve({
|
|
78
|
+
...("socketPath" in subscribe.listener
|
|
79
|
+
? { unix: subscribe.listener.socketPath }
|
|
80
|
+
: {
|
|
81
|
+
port: subscribe.listener.port,
|
|
82
|
+
hostname: subscribe.listener.hostname ?? "127.0.0.1",
|
|
83
|
+
}),
|
|
84
|
+
fetch: async (request) => {
|
|
85
|
+
const path = URL.parse(request.url)?.pathname ?? "";
|
|
86
|
+
if (path !== base && !path.startsWith(`${base}/`))
|
|
87
|
+
return new Response("Not Found", { status: 404 });
|
|
88
|
+
let id: string | undefined;
|
|
89
|
+
try {
|
|
90
|
+
id =
|
|
91
|
+
path === base
|
|
92
|
+
? undefined
|
|
93
|
+
: decodeURIComponent(path.slice(base.length + 1));
|
|
94
|
+
} catch {
|
|
95
|
+
return json({ error: "There is no such device." }, 404);
|
|
96
|
+
}
|
|
97
|
+
// A page on a sibling host of the same site carries the cookie too; the Origin is what stops it.
|
|
98
|
+
if (
|
|
99
|
+
request.method !== "GET" &&
|
|
100
|
+
request.headers.get("origin") !== subscribe.origin
|
|
101
|
+
)
|
|
102
|
+
return json({ error: "The request did not come from the app." }, 403);
|
|
103
|
+
try {
|
|
104
|
+
const principalId = await subscribe.authenticate(request);
|
|
105
|
+
if (!principalId) return json({ error: "Sign in again." }, 401);
|
|
106
|
+
return await devices.handle(request, id, principalId);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error instanceof ConsoleHttpError)
|
|
109
|
+
return json({ error: error.message }, error.status);
|
|
110
|
+
logger.error({ err: error }, "a subscription request failed");
|
|
111
|
+
return json(
|
|
112
|
+
{ error: "The sender could not complete the request." },
|
|
113
|
+
500,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { SQL } from "bun";
|
|
3
|
+
import { WebSessions } from "../auth/sessions.ts";
|
|
4
|
+
|
|
5
|
+
/** The session cookie the app sets: `__Secure-` because the console is never served at the root. */
|
|
6
|
+
export const SESSION_COOKIE = "__Secure-rt_session";
|
|
7
|
+
|
|
8
|
+
const cookieValue = (
|
|
9
|
+
header: string | null,
|
|
10
|
+
name: string,
|
|
11
|
+
): string | undefined => {
|
|
12
|
+
for (const part of (header ?? "").split(";")) {
|
|
13
|
+
const at = part.indexOf("=");
|
|
14
|
+
if (at > 0 && part.slice(0, at).trim() === name)
|
|
15
|
+
return part.slice(at + 1).trim();
|
|
16
|
+
}
|
|
17
|
+
return undefined;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Looks the app's session cookie up in `web_sessions` (read only) and answers the principal it
|
|
22
|
+
* belongs to, or undefined for a cookie that names no session, a session past its limit, or a
|
|
23
|
+
* principal that is disabled. The cookie holds a random id; only its SHA-256 is stored.
|
|
24
|
+
*/
|
|
25
|
+
export function webSessionPrincipal(options: {
|
|
26
|
+
database: SQL;
|
|
27
|
+
/** The cookie's name; default the app's own. */
|
|
28
|
+
cookieName?: string;
|
|
29
|
+
}): (request: Request) => Promise<string | undefined> {
|
|
30
|
+
const name = options.cookieName ?? SESSION_COOKIE;
|
|
31
|
+
return async (request) => {
|
|
32
|
+
const token = cookieValue(request.headers.get("cookie"), name);
|
|
33
|
+
if (!token) return undefined;
|
|
34
|
+
const [found]: { principal_id: string }[] = await options.database`
|
|
35
|
+
SELECT s.principal_id FROM web_sessions s
|
|
36
|
+
JOIN principals p ON p.id = s.principal_id
|
|
37
|
+
WHERE s.id_hash = ${createHash("sha256").update(token).digest()}
|
|
38
|
+
AND s.expires_at > now() AND p.disabled_at IS NULL`;
|
|
39
|
+
return found?.principal_id;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The id_token claims `oidc.storeClaims` kept for a person, from their newest live session;
|
|
45
|
+
* undefined when they have no live session or kept no claims. For a sender that needs, say, the
|
|
46
|
+
* person's mailbox address. Claims are the host's own data: never log them.
|
|
47
|
+
*/
|
|
48
|
+
export function webSessionClaims(
|
|
49
|
+
database: SQL,
|
|
50
|
+
principalId: string,
|
|
51
|
+
): Promise<Readonly<Record<string, unknown>> | undefined> {
|
|
52
|
+
// The idle limit only matters for creating a session; reading one does not use it.
|
|
53
|
+
return new WebSessions(database, { days: 30 }).claimsOf(principalId);
|
|
54
|
+
}
|
package/src/pwa.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { basename, join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import type { AssetBundle } from "./assets.ts";
|
|
@@ -34,7 +34,27 @@ export const DEFAULT_ICONS_DIR = fileURLToPath(
|
|
|
34
34
|
new URL("../assets/icons/", import.meta.url),
|
|
35
35
|
);
|
|
36
36
|
|
|
37
|
-
const ICON_FILE = /^[A-Za-z0-9-]+\.png$/;
|
|
37
|
+
const ICON_FILE = /^[A-Za-z0-9-]+\.(png|svg)$/;
|
|
38
|
+
|
|
39
|
+
/** The icon files the page, the manifest and the sign-in pages link. */
|
|
40
|
+
const PUBLIC_ICONS: ReadonlySet<string> = new Set([
|
|
41
|
+
"192.png",
|
|
42
|
+
"512.png",
|
|
43
|
+
"maskable-512.png",
|
|
44
|
+
"apple-180.png",
|
|
45
|
+
"badge-96.png",
|
|
46
|
+
"mark.svg",
|
|
47
|
+
"mark-maskable.svg",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
/** The manifest and the linked icons answer without a session: a sign-in page and a link preview have none. Any other file of a host's icon directory stays behind it. */
|
|
51
|
+
export const isPublicStatic = (rest: string): boolean =>
|
|
52
|
+
rest === "manifest.webmanifest" ||
|
|
53
|
+
(rest.startsWith("icons/") && PUBLIC_ICONS.has(rest.slice("icons/".length)));
|
|
54
|
+
|
|
55
|
+
/** Whether the icon directory has the round-table mark as an SVG, which a tab shows crisper than a PNG. */
|
|
56
|
+
export const hasMarkSvg = (iconsDir: string | undefined): boolean =>
|
|
57
|
+
existsSync(join(iconsDir ?? DEFAULT_ICONS_DIR, "mark.svg"));
|
|
38
58
|
|
|
39
59
|
export interface PwaOptions {
|
|
40
60
|
mount: string;
|
|
@@ -116,10 +136,18 @@ export function servePwa(
|
|
|
116
136
|
const body = readFileSync(
|
|
117
137
|
join(options.iconsDir ?? DEFAULT_ICONS_DIR, name),
|
|
118
138
|
);
|
|
139
|
+
const svg = name.endsWith(".svg");
|
|
119
140
|
return new Response(new Uint8Array(body), {
|
|
120
141
|
headers: {
|
|
121
|
-
"content-type": "image/png",
|
|
122
|
-
"cache-control": "
|
|
142
|
+
"content-type": svg ? "image/svg+xml" : "image/png",
|
|
143
|
+
"cache-control": "public, max-age=86400",
|
|
144
|
+
// An SVG opened as a page must not run anything of its own.
|
|
145
|
+
...(svg
|
|
146
|
+
? {
|
|
147
|
+
"content-security-policy":
|
|
148
|
+
"default-src 'none'; style-src 'unsafe-inline'",
|
|
149
|
+
}
|
|
150
|
+
: {}),
|
|
123
151
|
},
|
|
124
152
|
});
|
|
125
153
|
} catch {
|
package/src/visitors.ts
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
ActorFacts,
|
|
3
|
+
ChannelKey,
|
|
3
4
|
IdentityService,
|
|
4
5
|
Logger,
|
|
5
6
|
Principal,
|
|
7
|
+
Tier,
|
|
6
8
|
} from "pi-roundtable";
|
|
7
9
|
|
|
8
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Who a request the console admitted comes from: the principal, the tier they hold on this
|
|
12
|
+
* request (read again for every request and every socket frame, never carried over from the
|
|
13
|
+
* sign-in), the identity they spoke as, and the roles their sign-in carried.
|
|
14
|
+
*/
|
|
9
15
|
export interface Visitor {
|
|
10
16
|
principal: Principal;
|
|
17
|
+
tier: Tier;
|
|
18
|
+
/** The identity they spoke as, `<provider>:<subject>`; the principal's id when the credential names none. */
|
|
19
|
+
speakerId: string;
|
|
20
|
+
/** The roles the sign-in carried, each `<surface>:role:<name>`. */
|
|
21
|
+
roles: readonly string[];
|
|
11
22
|
}
|
|
12
23
|
|
|
13
24
|
/** The visitor of a request, or why it is refused, for the operator's log. */
|
|
@@ -19,6 +30,12 @@ export type ConsoleIdentity = Pick<
|
|
|
19
30
|
"principalOf" | "principal" | "tierOf" | "owners"
|
|
20
31
|
>;
|
|
21
32
|
|
|
33
|
+
/** The reads (and the one first-contact write) of `IDENTITY` that admitting members uses. */
|
|
34
|
+
export type AccessIdentity = Pick<
|
|
35
|
+
IdentityService,
|
|
36
|
+
"principalOf" | "principal" | "peek" | "resolve"
|
|
37
|
+
>;
|
|
38
|
+
|
|
22
39
|
export interface VisitorOptions {
|
|
23
40
|
/** Whom a request from a verifier that reports no actor stands for; default the primary owner. */
|
|
24
41
|
ownerId?: string;
|
|
@@ -50,13 +67,24 @@ export function consoleVisitors(
|
|
|
50
67
|
logger.warn(`web-console: ${message}`);
|
|
51
68
|
};
|
|
52
69
|
|
|
53
|
-
const owner = async (
|
|
70
|
+
const owner = async (
|
|
71
|
+
id: string,
|
|
72
|
+
actor: ActorFacts | undefined,
|
|
73
|
+
): Promise<Identified> => {
|
|
54
74
|
const principal = await identity.principal(id);
|
|
55
75
|
if (!principal) return { refusal: `there is no principal ${id}` };
|
|
56
76
|
if (principal.disabled) return { refusal: `principal ${id} is disabled` };
|
|
57
|
-
|
|
77
|
+
const tier = await identity.tierOf(id);
|
|
78
|
+
if (tier !== "owner")
|
|
58
79
|
return { refusal: `principal ${id} holds no owner role` };
|
|
59
|
-
return {
|
|
80
|
+
return {
|
|
81
|
+
visitor: {
|
|
82
|
+
principal,
|
|
83
|
+
tier,
|
|
84
|
+
speakerId: actor ? `${actor.provider}:${actor.subject}` : id,
|
|
85
|
+
roles: actor?.roles ?? [],
|
|
86
|
+
},
|
|
87
|
+
};
|
|
60
88
|
};
|
|
61
89
|
|
|
62
90
|
return async (actor) => {
|
|
@@ -68,7 +96,7 @@ export function consoleVisitors(
|
|
|
68
96
|
const id = options.ownerId ?? (await identity.owners())[0]?.id;
|
|
69
97
|
return id === undefined
|
|
70
98
|
? { refusal: "the host has no owner" }
|
|
71
|
-
: owner(id);
|
|
99
|
+
: owner(id, undefined);
|
|
72
100
|
}
|
|
73
101
|
const named = `${actor.provider}:${actor.subject}`;
|
|
74
102
|
if (actor.provider === LEGACY_PROVIDER)
|
|
@@ -79,7 +107,7 @@ export function consoleVisitors(
|
|
|
79
107
|
} catch {
|
|
80
108
|
return { refusal: "the verifier's actor is no identity" };
|
|
81
109
|
}
|
|
82
|
-
if (id !== undefined) return owner(id);
|
|
110
|
+
if (id !== undefined) return owner(id, actor);
|
|
83
111
|
warnOnce(
|
|
84
112
|
named,
|
|
85
113
|
`${named} is linked to no principal, so the console refuses it. If it is an owner's, add "${named}" to that owner's access.owners[].identities, or run: roundtable principal link <owner's principal id> ${named}`,
|
|
@@ -87,3 +115,63 @@ export function consoleVisitors(
|
|
|
87
115
|
return { refusal: `${named} is linked to no principal` };
|
|
88
116
|
};
|
|
89
117
|
}
|
|
118
|
+
|
|
119
|
+
export interface AccessOptions {
|
|
120
|
+
/** The app's surface, such as `app`: the surface the facts name and the conversation `<surface>:session` the tier is read for. */
|
|
121
|
+
surface: string;
|
|
122
|
+
logger: Logger;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Identifies the person behind each request when the app admits everyone `IDENTITY` admits on its
|
|
127
|
+
* surface (`admission: "access"`). The credential must name a person: one shared actor names no
|
|
128
|
+
* one. The facts are the app's (`surface`, `partial`, and the roles the sign-in carried), so a
|
|
129
|
+
* sign-in shows less than a person may hold and never lowers a tier recorded elsewhere.
|
|
130
|
+
*
|
|
131
|
+
* A linked identity is only read (`peek`): a request writes nothing. An unlinked one goes through
|
|
132
|
+
* `resolve`, which admits it as a new principal where `access.provisioning` says so for the
|
|
133
|
+
* surface. A disabled principal, and a person the rules give no tier, are refused.
|
|
134
|
+
*/
|
|
135
|
+
export function accessVisitors(
|
|
136
|
+
identity: AccessIdentity,
|
|
137
|
+
options: AccessOptions,
|
|
138
|
+
): (actor: ActorFacts | undefined) => Promise<Identified> {
|
|
139
|
+
const { surface, logger } = options;
|
|
140
|
+
const scope = { conversation: `${surface}:session` as ChannelKey };
|
|
141
|
+
return async (actor) => {
|
|
142
|
+
if (!actor) return { refusal: "the credential names no one" };
|
|
143
|
+
if (actor.provider === LEGACY_PROVIDER)
|
|
144
|
+
return { refusal: "a 0.8 id is not an identity" };
|
|
145
|
+
const named = `${actor.provider}:${actor.subject}`;
|
|
146
|
+
const facts: ActorFacts = { ...actor, surface, partial: true };
|
|
147
|
+
let linked: string | undefined;
|
|
148
|
+
try {
|
|
149
|
+
linked = await identity.principalOf(named);
|
|
150
|
+
} catch {
|
|
151
|
+
return { refusal: "the credential's actor is no identity" };
|
|
152
|
+
}
|
|
153
|
+
const speaker =
|
|
154
|
+
linked === undefined
|
|
155
|
+
? await identity.resolve(facts, scope)
|
|
156
|
+
: await identity.peek(facts, scope);
|
|
157
|
+
if (!speaker) {
|
|
158
|
+
logger.debug(
|
|
159
|
+
{ linked: linked !== undefined },
|
|
160
|
+
"web-console: the access rules serve no one by this sign-in",
|
|
161
|
+
);
|
|
162
|
+
return { refusal: `${named} holds no tier on this surface` };
|
|
163
|
+
}
|
|
164
|
+
const principal = await identity.principal(speaker.principalId);
|
|
165
|
+
if (!principal) return { refusal: "the principal is gone" };
|
|
166
|
+
if (principal.disabled)
|
|
167
|
+
return { refusal: `principal ${principal.id} is disabled` };
|
|
168
|
+
return {
|
|
169
|
+
visitor: {
|
|
170
|
+
principal,
|
|
171
|
+
tier: speaker.tier,
|
|
172
|
+
speakerId: named,
|
|
173
|
+
roles: actor.roles ?? [],
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
};
|
|
177
|
+
}
|