From 4b2c403f65282ebf208b45a285d736d44e7e912e Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Sat, 12 Sep 2026 16:24:04 +0300 Subject: [PATCH 01/12] Fix mobile account menu and E2EE resize disclosure --- app/src/styles/mobile-layout.test.ts | 18 ++++++++++++++++++ app/src/styles/shell.css | 2 ++ docs/content.json | 2 +- scripts/test-landing-seo.mjs | 2 ++ 4 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 app/src/styles/mobile-layout.test.ts diff --git a/app/src/styles/mobile-layout.test.ts b/app/src/styles/mobile-layout.test.ts new file mode 100644 index 0000000..ea0ee2f --- /dev/null +++ b/app/src/styles/mobile-layout.test.ts @@ -0,0 +1,18 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const css = readFileSync(new URL("./shell.css", import.meta.url), "utf8"); + +describe("mobile account menu", () => { + it("opens below the header account chip instead of beyond the top edge", () => { + const tablet = css.slice( + css.indexOf("@media (max-width: 900px)"), + css.indexOf("@media (max-width: 640px)"), + ); + const accountPopup = tablet.match(/\.account-pop\s*\{([^}]*)\}/u)?.[1] ?? ""; + + expect(accountPopup).toContain("top: calc(100% + 8px)"); + expect(accountPopup).toContain("bottom: auto"); + expect(accountPopup).toContain("right: 0"); + }); +}); diff --git a/app/src/styles/shell.css b/app/src/styles/shell.css index 4b69bad..59200ea 100644 --- a/app/src/styles/shell.css +++ b/app/src/styles/shell.css @@ -496,7 +496,9 @@ } .account-pop { + top: calc(100% + 8px); right: 0; + bottom: auto; left: auto; width: 232px; } diff --git a/docs/content.json b/docs/content.json index b9805f2..62c64e2 100644 --- a/docs/content.json +++ b/docs/content.json @@ -161,7 +161,7 @@ ], [ "Protected and visible data", - "Terminal input, output, snapshots, resizes, and latency probes are authenticated ciphertext. Cloudflare can still see connection IPs, timing, encrypted sizes, frame opcodes, access mode, labels, and lifecycle metadata." + "Terminal input, output, snapshots, and latency probes are authenticated ciphertext. Cloudflare can still see connection IPs, timing, encrypted sizes, frame opcodes, access mode, labels, lifecycle metadata, and the terminal grid dimensions carried by plaintext terminal_size control messages. The relay selects that shared grid so desktop and mobile viewers render one deterministic PTY size." ], [ "Limits, recovery, and opt-out", diff --git a/scripts/test-landing-seo.mjs b/scripts/test-landing-seo.mjs index 9f593f4..2e2c8b5 100644 --- a/scripts/test-landing-seo.mjs +++ b/scripts/test-landing-seo.mjs @@ -145,6 +145,8 @@ for (const guide of ["platforms", "mobile", "reliability", "security", "e2ee", " for (const guarantee of ["Any connected phone selects", "Paste input is split", "authenticated ciphertext", "e2ee_password", "docker compose up --build -d"]) { check(docsSource.includes(guarantee), `Versioned documentation guarantee is missing: ${guarantee}`); } +check(docsSource.includes("terminal_size control messages"), "E2EE docs must disclose plaintext terminal-size control metadata"); +check(!docsSource.includes("snapshots, resizes, and latency probes are authenticated ciphertext"), "E2EE docs must not claim relay-controlled resizes are ciphertext"); check(readme.includes("end-to-end encrypted by default"), "README default E2EE summary is missing"); check(readme.includes("--no-e2ee"), "README explicit E2EE opt-out is missing"); check(docsContent.version === packageMetadata.version, "Documentation version must match package version"); From bbb85d7559113a1c2818e809f0472aa6e0f52897 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Sat, 12 Sep 2026 16:41:28 +0300 Subject: [PATCH 02/12] Harden CLI callback rendering --- internal/account/login.go | 18 +++++++++++++++++- internal/account/login_test.go | 21 +++++++++++++++++++++ scripts/test-landing-seo.mjs | 6 +++++- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/internal/account/login.go b/internal/account/login.go index 1bf37bd..c366b38 100644 --- a/internal/account/login.go +++ b/internal/account/login.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "html" "io" "net" "net/http" @@ -44,8 +45,23 @@ const callbackPage = ` func writePage(writer http.ResponseWriter, status int, title, heading, body string) { writer.Header().Set("Content-Type", "text/html; charset=utf-8") writer.Header().Set("Cache-Control", "no-store") + writer.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'") + writer.Header().Set("X-Content-Type-Options", "nosniff") + writer.Header().Set("X-Frame-Options", "DENY") writer.WriteHeader(status) - fmt.Fprintf(writer, callbackPage, title, heading, body) + /* + The authorization server can return its description through the callback + query string. This loopback page is HTML, so every value is escaped even + though the normal headings are constants. Otherwise a crafted callback URL + could execute markup in the browser that is completing `shell login`. + */ + fmt.Fprintf( + writer, + callbackPage, + html.EscapeString(title), + html.EscapeString(heading), + html.EscapeString(body), + ) } // signedInPath is where a linked browser is sent once the CLI has its code. diff --git a/internal/account/login_test.go b/internal/account/login_test.go index b4014ed..7cfd413 100644 --- a/internal/account/login_test.go +++ b/internal/account/login_test.go @@ -88,6 +88,27 @@ func TestCallbackHandlerReportsAnAuthorizationError(t *testing.T) { } } +func TestCallbackHandlerEscapesAuthorizationErrors(t *testing.T) { + results := make(chan callbackResult, 1) + handler := newCallbackHandler("state-123", "", results) + + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, callbackRequest( + "error=access_denied&error_description="+url.QueryEscape(``))) + + <-results + body := recorder.Body.String() + if strings.Contains(body, " Date: Sat, 12 Sep 2026 16:39:00 +0300 Subject: [PATCH 03/12] Harden app session selection and handoffs --- app/server/app.test.ts | 103 ++++++++++++++++++++++++++++++ app/server/app.ts | 53 +++++++++------ app/server/lib/sessions.ts | 8 ++- app/server/routes/audit.ts | 11 ++++ app/src/routes/Workspace.tsx | 23 ++++++- app/src/terminal/TerminalPane.tsx | 30 +++++++-- app/src/terminal/tabs.test.ts | 22 ++++++- app/src/terminal/tabs.ts | 16 +++++ 8 files changed, 238 insertions(+), 28 deletions(-) diff --git a/app/server/app.test.ts b/app/server/app.test.ts index c5cff70..9e41e03 100644 --- a/app/server/app.test.ts +++ b/app/server/app.test.ts @@ -1146,6 +1146,41 @@ describe("session ownership and handoff", () => { expect(handed.body.session.assigneeUid).toBe("uid-1"); }); + it("returns only the caller's password copy after a handoff", async () => { + const { colleague } = await orgWithColleague(); + const path = `/api/sessions/${session.id}/keys`; + await call("PUT", path, { + auth: await idToken(), + body: { + shares: [ + { uid: "uid-1", sender_public_key: "OWNER_KEY", sealed: "OWNER_SHARE" }, + { uid: "uid-2", sender_public_key: "COLLEAGUE_KEY", sealed: "COLLEAGUE_SHARE" }, + ], + }, + }); + + const handed = await call("PUT", `/api/sessions/${session.id}/assignee`, { + auth: await idToken(), + body: { uids: ["uid-2"] }, + }); + expect(handed.status).toBe(200); + expect(handed.body.session.keyShare).toEqual({ + uid: "uid-1", + senderPublicKey: "OWNER_KEY", + sealed: "OWNER_SHARE", + }); + expect(handed.body.session.keyShares).toBeUndefined(); + expect(handed.body.session.sharedWith).toEqual(["uid-2"]); + + const colleagueView = await call("GET", `/api/sessions/${session.id}`, { auth: colleague }); + expect(colleagueView.body.session.keyShare).toMatchObject({ + uid: "uid-2", + sealed: "COLLEAGUE_SHARE", + }); + expect(colleagueView.body.session.keyShares).toBeUndefined(); + expect(colleagueView.body.session.sharedWith).toBeUndefined(); + }); + it("allows a session to be left unassigned", async () => { await orgWithColleague(); const handed = await call("PUT", `/api/sessions/${session.id}/assignee`, { @@ -1253,6 +1288,29 @@ describe("session ownership and handoff", () => { expect(attempt.status).toBe(404); }); + it("keeps another organization outside every session mutation path", async () => { + const tokens = await login(); + await call("POST", "/api/sessions", { auth: tokens.access_token, body: session }); + const stranger = await idToken({ sub: "uid-9", email: "stranger@elsewhere.com" }); + + expect((await call("GET", `/api/sessions/${session.id}`, { auth: stranger })).status).toBe(404); + expect((await call("PUT", `/api/sessions/${session.id}/assignee`, { + auth: stranger, + body: { uids: ["uid-9"] }, + })).status).toBe(404); + expect((await call("PUT", `/api/sessions/${session.id}/keys`, { + auth: stranger, + body: { + shares: [{ uid: "uid-9", sender_public_key: "STRANGER_KEY", sealed: "STRANGER_SHARE" }], + }, + })).status).toBe(404); + expect((await call("DELETE", `/api/sessions/${session.id}`, { auth: stranger })).status).toBe(404); + expect((await call("POST", "/api/commands", { + auth: stranger, + body: { kind: "kill", session_id: session.id }, + })).status).toBe(404); + }); + it("keeps an assignment when a persistent session re-registers", async () => { const tokens = await login(); await call("POST", "/api/sessions", { auth: tokens.access_token, body: session }); @@ -1376,6 +1434,51 @@ describe("audit log", () => { ]); }); + it("does not let a browser forge service-owned audit events", async () => { + await withSession(); + const forged = await call("POST", "/api/audit", { + auth: await idToken(), + body: { + entries: [ + { session_id: session.id, kind: "handoff", text: "assigned to attacker@example.com" }, + { session_id: session.id, kind: "stopped", text: "stopped" }, + { session_id: session.id, kind: "deleted", text: "deleted" }, + ], + }, + }); + expect(forged.body).toEqual({ written: 0, refused: 3 }); + const log = await call("GET", `/api/audit/${session.id}`, { auth: await idToken() }); + expect(log.body.events).toEqual([]); + }); + + it("accepts terminal input only from an owner or assignee", async () => { + const tokens = await login(); + await call("POST", "/api/sessions", { auth: tokens.access_token, body: session }); + const invite = await call("POST", "/api/org/invites", { + auth: await idToken(), + body: { role: "member" }, + }); + const colleague = await idToken({ sub: "uid-2", email: "colleague@example.com" }); + await call("GET", `/api/org?invite=${invite.body.invite.id}`, { auth: colleague }); + const input = { session_id: session.id, kind: "input", text: await sealedEntry(), at: 2000 }; + + const watching = await call("POST", "/api/audit", { + auth: colleague, + body: { entries: [input] }, + }); + expect(watching.body).toEqual({ written: 0, refused: 1 }); + + await call("PUT", `/api/sessions/${session.id}/assignee`, { + auth: await idToken(), + body: { uids: ["uid-2"] }, + }); + const assigned = await call("POST", "/api/audit", { + auth: colleague, + body: { entries: [input] }, + }); + expect(assigned.body).toEqual({ written: 1, refused: 0 }); + }); + /* Longer than the old plaintext cap: trimming ciphertext would destroy it. */ it("stores a long sealed entry whole", async () => { await withSession(); diff --git a/app/server/app.ts b/app/server/app.ts index 1ec20ef..0a87962 100644 --- a/app/server/app.ts +++ b/app/server/app.ts @@ -1,7 +1,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import type { Store } from "./lib/store"; import type { Invite, Membership } from "./lib/orgs"; -import type { AuditEvent } from "./lib/types"; +import type { AuditEvent, SessionRecord } from "./lib/types"; import type { VerifyResult } from "./lib/firebase-token"; import { exchangeCode, issueCode } from "./lib/codes"; import { @@ -32,7 +32,7 @@ import { notifyInvited, revokeInvite, } from "./routes/organizations"; -import { recordAudit, assignSession, auditCsv } from "./routes/audit"; +import { recordAudit, assignSession, auditCsv, SEALED_KINDS } from "./routes/audit"; import { addComment, inbox, notifyAssigned, notifySessionStarted } from "./routes/social"; import { deleteAccount } from "./routes/account"; import { callerAddress, rateLimiter } from "./lib/rate-limit"; @@ -159,6 +159,24 @@ function sharedWith( return (session.keyShares ?? []).map((share) => share.uid).filter((uid) => uid !== membership.uid); } +/** + * The session shape one signed-in member may receive. + * + * A stored session carries one sealed password per recipient. Even though a + * member cannot decrypt somebody else's copy, sending the whole array leaks + * who has a credential and makes an optimistic assignment response lose the + * caller's singular `keyShare` shape until the next poll. Every app response + * therefore goes through the same projection as the list and detail routes. + */ +function sessionForMember(membership: Membership, session: SessionRecord) { + const mine = session.keyShares?.find((share) => share.uid === membership.uid); + return { + ...sessionForApi(session), + keyShare: mine, + sharedWith: sharedWith(membership, session), + }; +} + /* * Copies of the team audit key as a browser sends them. Null for anything that * is not a list of well-formed copies, one per person: they are refused @@ -549,6 +567,15 @@ export function createApp(options: AppOptions) { let refused = 0; for (const entry of entries.slice(0, 100)) { const candidate = entry as Record; + /* + * Handoffs, stops and deletions are service facts written beside the + * action itself. Letting a browser submit those kinds would let any + * member forge the team's audit trail with a plain API request. + */ + if (!SEALED_KINDS.has(String(candidate.kind ?? "input"))) { + refused += 1; + continue; + } const result = await recordAudit(store, membership, { sessionId: String(candidate.session_id ?? ""), kind: String(candidate.kind ?? "input"), @@ -1008,15 +1035,9 @@ export function createApp(options: AppOptions) { * everyone's would be pointless, since they cannot open them, and * would put more sealed material on the wire than anyone needs. */ - const sessions = (await store.listOrgSessions(membership.orgId)).map((session) => { - const mine = session.keyShares?.find((share) => share.uid === membership.uid); - return { - ...sessionForApi(session), - keyShares: undefined, - keyShare: mine, - sharedWith: sharedWith(membership, session), - }; - }); + const sessions = (await store.listOrgSessions(membership.orgId)).map((session) => + sessionForMember(membership, session) + ); return send(response, 200, { sessions, members: await store.members(membership.orgId), @@ -1095,7 +1116,7 @@ export function createApp(options: AppOptions) { result.session.name || result.session.command, ); } - return send(response, 200, { session: result.session }); + return send(response, 200, { session: sessionForMember(membership, result.session) }); } /* ---- Driving a machine from the browser ---- */ @@ -1299,14 +1320,8 @@ export function createApp(options: AppOptions) { if (!membership) return send(response, 401, { error: "sign in first" }); const session = await store.sessionInOrg(membership.orgId, oneSession[1]); if (!session) return send(response, 404, { error: "no such session" }); - const mine = session.keyShares?.find((share) => share.uid === membership.uid); return send(response, 200, { - session: { - ...sessionForApi(session), - keyShares: undefined, - keyShare: mine, - sharedWith: sharedWith(membership, session), - }, + session: sessionForMember(membership, session), members: await store.members(membership.orgId), you: membership, comments: await store.comments(membership.orgId, oneSession[1]), diff --git a/app/server/lib/sessions.ts b/app/server/lib/sessions.ts index faa3be6..563138c 100644 --- a/app/server/lib/sessions.ts +++ b/app/server/lib/sessions.ts @@ -48,7 +48,13 @@ export function sessionSource(session: Pick): SessionSo /** Removes the storage envelope before a session is sent to the browser. */ export function sessionForApi(session: SessionRecord) { const source = sessionSource(session); - return { ...session, origin: source.origin, deviceId: source.deviceId }; + /* + * Key shares are recipient-specific credentials. Routes serving a browser + * add back only that caller's `keyShare`; CLI registration/close responses + * need none of them. + */ + const { keyShares: _keyShares, ...safe } = session; + return { ...safe, origin: source.origin, deviceId: source.deviceId }; } export type RegisterResult = diff --git a/app/server/routes/audit.ts b/app/server/routes/audit.ts index 730f1dc..1587feb 100644 --- a/app/server/routes/audit.ts +++ b/app/server/routes/audit.ts @@ -54,6 +54,17 @@ export async function recordAudit( if (!session) { return { ok: false, status: 404, error: "no such session in this organization" }; } + if (sealed) { + const owner = session.ownerUid ?? session.uid; + const assignees = session.assigneeUids?.length + ? session.assigneeUids + : session.assigneeUid + ? [session.assigneeUid] + : []; + if (session.readOnly || (membership.uid !== owner && !assignees.includes(membership.uid))) { + return { ok: false, status: 403, error: "only someone who can type may record terminal input" }; + } + } const event: AuditEvent = { id: newId("aud"), diff --git a/app/src/routes/Workspace.tsx b/app/src/routes/Workspace.tsx index 1de609b..6a468ac 100644 --- a/app/src/routes/Workspace.tsx +++ b/app/src/routes/Workspace.tsx @@ -14,7 +14,7 @@ import { AppShell } from "../components/AppShell"; import { useAuth } from "../auth/AuthProvider"; import { Alert } from "../components/Alert"; import { TerminalPane } from "../terminal/TerminalPane"; -import { EMPTY, reduce, tabFor } from "../terminal/tabs"; +import { EMPTY, reduce, sessionToOpen, tabFor } from "../terminal/tabs"; import { readOpenTabs, writeOpenTabs } from "../terminal/tab-store"; import { assignSession, @@ -264,6 +264,27 @@ export function Workspace() { dispatch({ type: "restore", tabs, activeId: remembered.activeId }); }, [sessions, you, user]); + /* + * The session detail page's primary action returns here with `?open=`. + * Consume it once the list is available, then remove it from the address so + * a later refresh does not reopen a tab somebody deliberately closed. + */ + const requestedSessionId = search.get("open"); + useEffect(() => { + if (!requestedSessionId || sessions === null) return; + const requested = sessionToOpen(sessions, requestedSessionId); + setSearch((current) => { + const next = new URLSearchParams(current); + next.delete("open"); + return next; + }, { replace: true }); + if (!requested) { + setError("That session has finished or is no longer available."); + return; + } + dispatch({ type: "open", session: requested, canType: canEdit(requested, you) }); + }, [requestedSessionId, sessions, you, setSearch]); + /* Written only after the restore, so an empty first render cannot erase it. */ useEffect(() => { if (!restoredTabs.current) return; diff --git a/app/src/terminal/TerminalPane.tsx b/app/src/terminal/TerminalPane.tsx index 4442040..4476c70 100644 --- a/app/src/terminal/TerminalPane.tsx +++ b/app/src/terminal/TerminalPane.tsx @@ -93,6 +93,13 @@ export function TerminalPane({ const [password, setPassword] = useState(""); const [unlocking, setUnlocking] = useState(false); + /* + * Assignment can change while this pane is open. Read the current answer + * from a ref inside xterm's long-lived input callback, rather than rebuilding + * the terminal and dropping its socket and scrollback on every handoff. + */ + const canTypeRef = useRef(canType); + /* * Only the visible pane measures itself. A hidden one is still laid out, so * it would measure fine here, but refusing to refit it at all means no @@ -249,7 +256,7 @@ export function TerminalPane({ }, onReadOnly: (value) => { setReadOnly(value); - term.options.disableStdin = value; + term.options.disableStdin = value || !canTypeRef.current; }, /* A portrait viewer takes a capable session to 80x40, and back when it leaves. */ onGrid: (next) => { @@ -289,11 +296,11 @@ export function TerminalPane({ : null; const typed = term.onData((data) => { - if (!canType) return; + if (!canTypeRef.current) return; connected.send(data); sink?.observe(data); }); - term.options.disableStdin = !canType; + term.options.disableStdin = !canTypeRef.current; const sessionId = sessionIdFromShareUrl(shareUrl); attempt.current = null; @@ -364,7 +371,18 @@ export function TerminalPane({ measure.current = null; connection.current = null; }; - }, [shareUrl, refit, canType]); + }, [shareUrl, refit]); + + /* + * Apply a handoff in place. The relay's own read-only bit still wins, and + * the callback above checks the same ref as a second guard against input + * arriving between a render and this effect. + */ + useEffect(() => { + canTypeRef.current = canType; + if (!terminal.current) return; + terminal.current.options.disableStdin = readOnly || !canType; + }, [canType, readOnly]); /* * A share that arrives while the pane is asking for a password, such as the @@ -391,10 +409,10 @@ export function TerminalPane({ if (!active) return; const frame = requestAnimationFrame(() => { refit(); - if (!readOnly) terminal.current?.focus(); + if (!readOnly && canType) terminal.current?.focus(); }); return () => cancelAnimationFrame(frame); - }, [active, readOnly, refit]); + }, [active, readOnly, canType, refit]); async function handleUnlock(event: FormEvent) { event.preventDefault(); diff --git a/app/src/terminal/tabs.test.ts b/app/src/terminal/tabs.test.ts index 0eba35c..18503c2 100644 --- a/app/src/terminal/tabs.test.ts +++ b/app/src/terminal/tabs.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { EMPTY, MAX_TABS, reduce, tabFor, type TabState } from "./tabs"; +import { EMPTY, MAX_TABS, reduce, sessionToOpen, tabFor, type TabState } from "./tabs"; import type { SessionRecord } from "../lib/api"; function session(id: string, command = "top"): SessionRecord { @@ -75,6 +75,26 @@ describe("open", () => { }); }); +describe("an open request from the session detail page", () => { + const sessions = [session("first"), session("second")]; + + it("selects the exact session named in the URL", () => { + expect(sessionToOpen(sessions, "second")?.id).toBe("second"); + }); + + it("does not treat a prefix as a session id", () => { + expect(sessionToOpen(sessions, "sec")).toBeNull(); + }); + + it("does not reopen a process that finished while its detail page was open", () => { + expect(sessionToOpen([{ ...session("done"), closedAt: 2 }], "done")).toBeNull(); + }); + + it("does nothing when there is no open request", () => { + expect(sessionToOpen(sessions, null)).toBeNull(); + }); +}); + describe("close", () => { it("removes the tab", () => { const state = reduce(open(open(EMPTY, "a"), "b"), { type: "close", id: "a" }); diff --git a/app/src/terminal/tabs.ts b/app/src/terminal/tabs.ts index ab4eba6..bc54a88 100644 --- a/app/src/terminal/tabs.ts +++ b/app/src/terminal/tabs.ts @@ -21,6 +21,22 @@ export const EMPTY: TabState = { tabs: [], activeId: null }; export const MAX_TABS = 8; +/** + * Resolves an `?open=` request against the current live session list. + * + * The detail page uses this route to hand one exact session back to the + * workspace. Keeping the lookup here makes two important rules explicit and + * testable: ids are exact (never prefixes), and a process that finished while + * the person was reading its details is not reopened as a dead terminal tab. + */ +export function sessionToOpen( + sessions: readonly SessionRecord[], + requestedId: string | null, +): SessionRecord | null { + if (!requestedId) return null; + return sessions.find((session) => session.id === requestedId && !session.closedAt) ?? null; +} + export type TabAction = | { type: "open"; session: SessionRecord; canType?: boolean } | { type: "close"; id: string } From 1159cd39100fd16766d5ce596e70d605f3b364da Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Sat, 12 Sep 2026 16:58:21 +0300 Subject: [PATCH 04/12] Keep team state inside its organization --- app/server/app.ts | 4 ++-- app/server/lib/store-conformance.test.ts | 25 ++++++++++++++---------- app/server/lib/store-memory.ts | 12 ++++++------ app/server/lib/store-postgres.ts | 18 ++++++++--------- app/server/lib/store.ts | 6 +++--- app/server/mobile-layout.test.ts | 13 ++++++++++++ app/server/routes/social.ts | 2 +- app/src/styles/mobile-layout.test.ts | 18 ----------------- app/src/terminal/TerminalPane.tsx | 1 + 9 files changed, 50 insertions(+), 49 deletions(-) create mode 100644 app/server/mobile-layout.test.ts delete mode 100644 app/src/styles/mobile-layout.test.ts diff --git a/app/server/app.ts b/app/server/app.ts index 0a87962..ac4ae88 100644 --- a/app/server/app.ts +++ b/app/server/app.ts @@ -1353,9 +1353,9 @@ export function createApp(options: AppOptions) { if (!membership) return send(response, 401, { error: "sign in first" }); const body = (await readBody(request)) as Record; if (typeof body.id === "string") { - await store.markNotificationRead(membership.uid, body.id); + await store.markNotificationRead(membership.orgId, membership.uid, body.id); } else { - await store.markAllNotificationsRead(membership.uid); + await store.markAllNotificationsRead(membership.orgId, membership.uid); } return send(response, 200, await inbox(store, membership)); } diff --git a/app/server/lib/store-conformance.test.ts b/app/server/lib/store-conformance.test.ts index c5748e1..4e93e21 100644 --- a/app/server/lib/store-conformance.test.ts +++ b/app/server/lib/store-conformance.test.ts @@ -800,7 +800,8 @@ for (const implementation of implementations) { await store.putNotification(notification()); await store.putNotification(notification({ id: "ntf_2", at: 3000 })); await store.putNotification(notification({ id: "ntf_3", uid: "uid-3" })); - expect((await store.notificationsFor("uid-2")).map((entry) => entry.id)).toEqual([ + await store.putNotification(notification({ id: "ntf_old_team", orgId: "org_2", at: 4000 })); + expect((await store.notificationsFor("org_1", "uid-2")).map((entry) => entry.id)).toEqual([ "ntf_2", "ntf_1", ]); @@ -810,23 +811,27 @@ for (const implementation of implementations) { for (const id of ["ntf_b", "ntf_c", "ntf_a"]) { await store.putNotification(notification({ id, at: 1000 })); } - const ids = (await store.notificationsFor("uid-2")).map((entry) => entry.id); + const ids = (await store.notificationsFor("org_1", "uid-2")).map((entry) => entry.id); expect(ids).toEqual(["ntf_c", "ntf_b", "ntf_a"]); }); it("marks one as read, once, and only for its owner", async () => { await store.putNotification(notification()); - expect(await store.markNotificationRead("uid-3", "ntf_1", 5000)).toBe(false); - expect(await store.markNotificationRead("uid-2", "ntf_1", 5000)).toBe(true); - expect(await store.markNotificationRead("uid-2", "ntf_1", 5001)).toBe(false); - expect((await store.notificationsFor("uid-2"))[0].readAt).toBe(5000); + await store.putNotification(notification({ id: "ntf_other_team", orgId: "org_2" })); + expect(await store.markNotificationRead("org_1", "uid-3", "ntf_1", 5000)).toBe(false); + expect(await store.markNotificationRead("org_1", "uid-2", "ntf_other_team", 5000)).toBe(false); + expect(await store.markNotificationRead("org_1", "uid-2", "ntf_1", 5000)).toBe(true); + expect(await store.markNotificationRead("org_1", "uid-2", "ntf_1", 5001)).toBe(false); + expect((await store.notificationsFor("org_1", "uid-2"))[0].readAt).toBe(5000); }); it("counts what marking everything read actually changed", async () => { await store.putNotification(notification()); await store.putNotification(notification({ id: "ntf_2", readAt: 100 })); - expect(await store.markAllNotificationsRead("uid-2", 5000)).toBe(1); - expect(await store.markAllNotificationsRead("uid-2", 5000)).toBe(0); + await store.putNotification(notification({ id: "ntf_other_team", orgId: "org_2" })); + expect(await store.markAllNotificationsRead("org_1", "uid-2", 5000)).toBe(1); + expect(await store.markAllNotificationsRead("org_1", "uid-2", 5000)).toBe(0); + expect((await store.notificationsFor("org_2", "uid-2"))[0].readAt).toBeUndefined(); }); }); @@ -962,8 +967,8 @@ for (const implementation of implementations) { expect(await store.listSessions("uid-1")).toEqual([]); expect(await store.accountKey("uid-1")).toBeNull(); expect(await store.comments("org_1", "s2")).toEqual([]); - expect(await store.notificationsFor("uid-1")).toEqual([]); - expect(await store.notificationsFor("uid-2")).toEqual([]); + expect(await store.notificationsFor("org_1", "uid-1")).toEqual([]); + expect(await store.notificationsFor("org_1", "uid-2")).toEqual([]); const kept = (await store.listOrgSessions("org_1")).find((entry) => entry.id === "s2"); expect(kept?.assigneeUids).toEqual(["uid-3"]); diff --git a/app/server/lib/store-memory.ts b/app/server/lib/store-memory.ts index 88bf407..c704521 100644 --- a/app/server/lib/store-memory.ts +++ b/app/server/lib/store-memory.ts @@ -745,16 +745,16 @@ export class MemoryStore implements Store { this.flush(); } - async notificationsFor(uid: string, limit = 100): Promise { + async notificationsFor(orgId: string, uid: string, limit = 100): Promise { return this.data.notifications - .filter((entry) => entry.uid === uid) + .filter((entry) => entry.orgId === orgId && entry.uid === uid) .sort(byTime((entry) => entry.at, (entry) => entry.id, true)) .slice(0, limit); } - async markNotificationRead(uid: string, id: string, now = Date.now()): Promise { + async markNotificationRead(orgId: string, uid: string, id: string, now = Date.now()): Promise { const notification = this.data.notifications.find( - (entry) => entry.id === id && entry.uid === uid, + (entry) => entry.orgId === orgId && entry.id === id && entry.uid === uid, ); if (!notification || notification.readAt) return false; notification.readAt = now; @@ -762,10 +762,10 @@ export class MemoryStore implements Store { return true; } - async markAllNotificationsRead(uid: string, now = Date.now()): Promise { + async markAllNotificationsRead(orgId: string, uid: string, now = Date.now()): Promise { let count = 0; for (const notification of this.data.notifications) { - if (notification.uid === uid && !notification.readAt) { + if (notification.orgId === orgId && notification.uid === uid && !notification.readAt) { notification.readAt = now; count += 1; } diff --git a/app/server/lib/store-postgres.ts b/app/server/lib/store-postgres.ts index 790d692..4229830 100644 --- a/app/server/lib/store-postgres.ts +++ b/app/server/lib/store-postgres.ts @@ -1419,26 +1419,26 @@ export class PostgresStore implements Store { ); } - async notificationsFor(uid: string, limit = 100): Promise { + async notificationsFor(orgId: string, uid: string, limit = 100): Promise { const rows = await this.rows( - 'SELECT * FROM notifications WHERE uid = $1 ORDER BY at DESC, id COLLATE "C" DESC LIMIT $2', - [uid, limit], + 'SELECT * FROM notifications WHERE org_id = $1 AND uid = $2 ORDER BY at DESC, id COLLATE "C" DESC LIMIT $3', + [orgId, uid, limit], ); return rows.map(toNotification); } - async markNotificationRead(uid: string, id: string, now = Date.now()): Promise { + async markNotificationRead(orgId: string, uid: string, id: string, now = Date.now()): Promise { const result = await this.pool.query( - "UPDATE notifications SET read_at = $3 WHERE id = $2 AND uid = $1 AND read_at IS NULL", - [uid, id, now], + "UPDATE notifications SET read_at = $4 WHERE org_id = $1 AND uid = $2 AND id = $3 AND read_at IS NULL", + [orgId, uid, id, now], ); return (result.rowCount ?? 0) > 0; } - async markAllNotificationsRead(uid: string, now = Date.now()): Promise { + async markAllNotificationsRead(orgId: string, uid: string, now = Date.now()): Promise { const result = await this.pool.query( - "UPDATE notifications SET read_at = $2 WHERE uid = $1 AND read_at IS NULL", - [uid, now], + "UPDATE notifications SET read_at = $3 WHERE org_id = $1 AND uid = $2 AND read_at IS NULL", + [orgId, uid, now], ); return result.rowCount ?? 0; } diff --git a/app/server/lib/store.ts b/app/server/lib/store.ts index 1063598..86147b2 100644 --- a/app/server/lib/store.ts +++ b/app/server/lib/store.ts @@ -215,9 +215,9 @@ export interface Store { putComment(comment: Comment): Promise; comments(orgId: string, sessionId: string): Promise; putNotification(notification: Notification): Promise; - notificationsFor(uid: string, limit?: number): Promise; - markNotificationRead(uid: string, id: string, now?: number): Promise; - markAllNotificationsRead(uid: string, now?: number): Promise; + notificationsFor(orgId: string, uid: string, limit?: number): Promise; + markNotificationRead(orgId: string, uid: string, id: string, now?: number): Promise; + markAllNotificationsRead(orgId: string, uid: string, now?: number): Promise; /* ---- Housekeeping ---- */ purgeExpired(now?: number): Promise; diff --git a/app/server/mobile-layout.test.ts b/app/server/mobile-layout.test.ts new file mode 100644 index 0000000..8a862a9 --- /dev/null +++ b/app/server/mobile-layout.test.ts @@ -0,0 +1,13 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const css = readFileSync(new URL("../src/styles/shell.css", import.meta.url), "utf8"); + +describe("mobile account menu", () => { + it("opens below the header account chip instead of beyond the top edge", () => { + const mobileRule = css.match(/@media\s*\(max-width:\s*900px\)[\s\S]*?\.account-pop\s*\{([\s\S]*?)\}/)?.[1] ?? ""; + expect(mobileRule).toMatch(/top:\s*calc\(100% \+ 8px\)/); + expect(mobileRule).toMatch(/right:\s*0/); + expect(mobileRule).toMatch(/bottom:\s*auto/); + }); +}); diff --git a/app/server/routes/social.ts b/app/server/routes/social.ts index e639eb7..5dd30ad 100644 --- a/app/server/routes/social.ts +++ b/app/server/routes/social.ts @@ -129,7 +129,7 @@ export async function inbox( unreadAssignments: number; members: Awaited>; }> { - const notifications = await store.notificationsFor(membership.uid); + const notifications = await store.notificationsFor(membership.orgId, membership.uid); return { members: await store.members(membership.orgId), notifications, diff --git a/app/src/styles/mobile-layout.test.ts b/app/src/styles/mobile-layout.test.ts deleted file mode 100644 index ea0ee2f..0000000 --- a/app/src/styles/mobile-layout.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; - -const css = readFileSync(new URL("./shell.css", import.meta.url), "utf8"); - -describe("mobile account menu", () => { - it("opens below the header account chip instead of beyond the top edge", () => { - const tablet = css.slice( - css.indexOf("@media (max-width: 900px)"), - css.indexOf("@media (max-width: 640px)"), - ); - const accountPopup = tablet.match(/\.account-pop\s*\{([^}]*)\}/u)?.[1] ?? ""; - - expect(accountPopup).toContain("top: calc(100% + 8px)"); - expect(accountPopup).toContain("bottom: auto"); - expect(accountPopup).toContain("right: 0"); - }); -}); diff --git a/app/src/terminal/TerminalPane.tsx b/app/src/terminal/TerminalPane.tsx index 4476c70..a53f744 100644 --- a/app/src/terminal/TerminalPane.tsx +++ b/app/src/terminal/TerminalPane.tsx @@ -99,6 +99,7 @@ export function TerminalPane({ * the terminal and dropping its socket and scrollback on every handoff. */ const canTypeRef = useRef(canType); + canTypeRef.current = canType; /* * Only the visible pane measures itself. A hidden one is still laid out, so From 5b3fb802d58c5838d9b19f0255ead70e111ad466 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Sat, 12 Sep 2026 16:58:21 +0300 Subject: [PATCH 05/12] Restore project growth and platform tables --- README.md | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 703b60f..fcb8de3 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,22 @@ Homebrew 6 asks you to trust a third-party tap once. Older versions have no Installers verify checksums. Release binaries and `SHA256SUMS` are available on the [releases page](https://github.com/TeoSlayer/shell.online/releases). +## Platform compatibility + +| OS | Architectures | Verification | +| --- | --- | --- | +| macOS | amd64, arm64 | Build | +| Windows | 386, amd64, arm64 | Native ConPTY on amd64; build on others | +| Linux | 386, amd64, armv5/6/7, arm64, LoongArch64, MIPS/MIPSLE/MIPS64/MIPS64LE, PPC64/PPC64LE, RISC-V 64, s390x | Runtime under QEMU | +| FreeBSD | 386, amd64, armv7, arm64 | Build | +| OpenBSD | 386, amd64, armv7, arm64, ppc64, riscv64 | Build | +| NetBSD | 386, amd64, armv7, arm64 | Build | +| DragonFly BSD | amd64 | Build | +| Solaris | amd64 | Build | + +See [platform details](https://shell.online/platforms/) for PTY, router, ROS, +installer, and test caveats. + ## Usage ```sh @@ -85,6 +101,10 @@ docker compose up -d docker compose logs shell-online ``` +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=TeoSlayer/shell.online&type=Date)](https://www.star-history.com/#TeoSlayer/shell.online&Date) + ## Documentation - [Quick start](https://shell.online/docs/) @@ -109,9 +129,9 @@ npm run test:app See [the contribution guide](.github/CONTRIBUTING.md) before opening a pull request. -## Star History +## Contributors -[![Star History Chart](https://api.star-history.com/svg?repos=TeoSlayer/shell.online&type=Date)](https://www.star-history.com/#TeoSlayer/shell.online&Date) +[![shell.online contributors](https://contrib.rocks/image?repo=TeoSlayer/shell.online)](https://github.com/TeoSlayer/shell.online/graphs/contributors) MIT licensed. See [`LICENSE`](LICENSE). From ed89b4b067701c3c15058c6cfac82e04f3de7e5b Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Sat, 12 Sep 2026 17:04:09 +0300 Subject: [PATCH 06/12] Harden team identities and encrypted key exchange --- app/server/app.test.ts | 111 ++++++++++++++++++++++------ app/server/app.ts | 59 +++++++++------ app/server/lib/browser-headers.ts | 22 ++++++ app/server/lib/firebase-token.ts | 3 + app/server/lib/static-files.test.ts | 2 + app/server/lib/static-files.ts | 11 +-- app/server/lib/vault.test.ts | 12 ++- app/server/lib/vault.ts | 20 ++++- app/server/routes/organizations.ts | 24 +++++- app/worker/index.ts | 11 +-- 10 files changed, 207 insertions(+), 68 deletions(-) create mode 100644 app/server/lib/browser-headers.ts diff --git a/app/server/app.test.ts b/app/server/app.test.ts index 9e41e03..134821e 100644 --- a/app/server/app.test.ts +++ b/app/server/app.test.ts @@ -12,6 +12,10 @@ import { createVault, sealToAccount } from "../src/lib/vault-crypto"; const PROJECT = "test-firebase-project"; const REDIRECT = "http://127.0.0.1:51234/callback"; const ORIGIN = "http://localhost:5173"; +const P256_PUBLIC_KEY_A = "BDxrse1_E7EAHDreFfDYFkHs7kcn3d2n_BqKorrlu6H-9FarvjSDUCUSY3EOYKRBJusTV2E2GwRZLdplZc3UbQY"; +const P256_PUBLIC_KEY_B = "BM4rJdocNKu-sk24tVjh1QKxfdLJN43q2NVO3NElj_H09ORvqFD6ZcX7xJ_DTef8pYUGo0AJz9bnFV8oxvkBElc"; +const SESSION_SHARE_A = base64url(Buffer.alloc(40, 0x41)); +const SESSION_SHARE_B = base64url(Buffer.alloc(40, 0x42)); let privateKey: KeyObject; let verifyIdToken: (token: string) => Promise<{ ok: boolean }>; @@ -21,7 +25,7 @@ let verifier: string; /* Signs a token that looks exactly like a Firebase ID token, minus Google. */ async function idToken(overrides: Record = {}) { - return new SignJWT({ email: "ana@example.com", name: "Ana Ferreira", ...overrides }) + return new SignJWT({ email: "ana@example.com", name: "Ana Ferreira", email_verified: true, ...overrides }) .setProtectedHeader({ alg: "RS256", kid: "test-key" }) .setIssuer(String(overrides.iss ?? `https://securetoken.google.com/${PROJECT}`)) .setAudience(String(overrides.aud ?? PROJECT)) @@ -867,16 +871,27 @@ describe("relaying a sealed password", () => { it("records the agent's published key so a browser can seal to it", async () => { const tokens = await login(); - await call("GET", "/api/agent/commands?key=AGENT_PUBLIC_KEY", { + await call("GET", `/api/agent/commands?key=${P256_PUBLIC_KEY_A}`, { auth: tokens.access_token, }); const listed = await call("GET", "/api/devices", { auth: await idToken() }); - expect(listed.body.devices[0].agentPublicKey).toBe("AGENT_PUBLIC_KEY"); + expect(listed.body.devices[0].agentPublicKey).toBe(P256_PUBLIC_KEY_A); + }); + + it("refuses a malformed agent key instead of publishing it", async () => { + const tokens = await login(); + const result = await call("GET", "/api/agent/commands?key=not-a-p256-key", { + auth: tokens.access_token, + }); + expect(result.status).toBe(400); + const listed = await call("GET", "/api/devices", { auth: await idToken() }); + expect(listed.body.devices[0].agentPublicKey).toBeUndefined(); + expect(listed.body.devices[0].agentSeenAt).toBeUndefined(); }); it("records the harnesses a polling agent found on its machine", async () => { const tokens = await login(); - await call("GET", "/api/agent/commands?key=K&harnesses=claude-code,openclaw", { + await call("GET", `/api/agent/commands?key=${P256_PUBLIC_KEY_A}&harnesses=claude-code,openclaw`, { auth: tokens.access_token, }); const listed = await call("GET", "/api/devices", { auth: await idToken() }); @@ -895,7 +910,7 @@ describe("relaying a sealed password", () => { it("says nothing about a machine that has never reported its harnesses", async () => { const tokens = await login(); - await call("GET", "/api/agent/commands?key=K", { auth: tokens.access_token }); + await call("GET", `/api/agent/commands?key=${P256_PUBLIC_KEY_A}`, { auth: tokens.access_token }); const listed = await call("GET", "/api/devices", { auth: await idToken() }); /* Undefined is "not known", which the browser must not read as "absent". */ expect(listed.body.devices[0].harnesses).toBeUndefined(); @@ -910,10 +925,10 @@ describe("relaying a sealed password", () => { it("takes a new key when the agent restarts", async () => { const tokens = await login(); - await call("GET", "/api/agent/commands?key=FIRST", { auth: tokens.access_token }); - await call("GET", "/api/agent/commands?key=SECOND", { auth: tokens.access_token }); + await call("GET", `/api/agent/commands?key=${P256_PUBLIC_KEY_A}`, { auth: tokens.access_token }); + await call("GET", `/api/agent/commands?key=${P256_PUBLIC_KEY_B}`, { auth: tokens.access_token }); const listed = await call("GET", "/api/devices", { auth: await idToken() }); - expect(listed.body.devices[0].agentPublicKey).toBe("SECOND"); + expect(listed.body.devices[0].agentPublicKey).toBe(P256_PUBLIC_KEY_B); }); it("ties a session back to the request that started it", async () => { @@ -958,6 +973,14 @@ describe("organizations", () => { expect(first.organization.id).not.toBe(second.organization.id); }); + it("refuses a malformed browser public key", async () => { + const result = await call("GET", "/api/org?key=not-a-p256-key", { + auth: await idToken(), + }); + expect(result.status).toBe(400); + expect((await store.membershipOf("uid-1"))?.publicKey).toBeUndefined(); + }); + it("puts someone who follows an invite into that organization", async () => { await orgFor("uid-1", "owner@acme.com"); const invite = await call("POST", "/api/org/invites", { @@ -1015,6 +1038,35 @@ describe("organizations", () => { expect(wrong.body.inviteError).toContain("different email"); }); + it("requires a verified identity before accepting an email-targeted invite", async () => { + await orgFor("uid-1", "owner@acme.com"); + const invite = await call("POST", "/api/org/invites", { + auth: await idToken({ sub: "uid-1", email: "owner@acme.com" }), + body: { role: "member", email: "wanted@acme.com" }, + }); + const attempted = await call("GET", `/api/org?invite=${invite.body.invite.id}`, { + auth: await idToken({ + sub: "uid-unverified", + email: "wanted@acme.com", + email_verified: false, + }), + }); + expect(attempted.body.joined).toBe(false); + expect(attempted.body.inviteError).toContain("Verify that email"); + }); + + it("keeps open-link invites available to unverified identities", async () => { + await orgFor("uid-1", "owner@acme.com"); + const invite = await call("POST", "/api/org/invites", { + auth: await idToken({ sub: "uid-1", email: "owner@acme.com" }), + body: { role: "member" }, + }); + const joined = await call("GET", `/api/org?invite=${invite.body.invite.id}`, { + auth: await idToken({ sub: "uid-unverified", email_verified: false }), + }); + expect(joined.body.joined).toBe(true); + }); + it("cannot use one invite twice", async () => { await orgFor("uid-1", "owner@acme.com"); const invite = await call("POST", "/api/org/invites", { @@ -1153,8 +1205,8 @@ describe("session ownership and handoff", () => { auth: await idToken(), body: { shares: [ - { uid: "uid-1", sender_public_key: "OWNER_KEY", sealed: "OWNER_SHARE" }, - { uid: "uid-2", sender_public_key: "COLLEAGUE_KEY", sealed: "COLLEAGUE_SHARE" }, + { uid: "uid-1", sender_public_key: P256_PUBLIC_KEY_A, sealed: SESSION_SHARE_A }, + { uid: "uid-2", sender_public_key: P256_PUBLIC_KEY_B, sealed: SESSION_SHARE_B }, ], }, }); @@ -1166,8 +1218,8 @@ describe("session ownership and handoff", () => { expect(handed.status).toBe(200); expect(handed.body.session.keyShare).toEqual({ uid: "uid-1", - senderPublicKey: "OWNER_KEY", - sealed: "OWNER_SHARE", + senderPublicKey: P256_PUBLIC_KEY_A, + sealed: SESSION_SHARE_A, }); expect(handed.body.session.keyShares).toBeUndefined(); expect(handed.body.session.sharedWith).toEqual(["uid-2"]); @@ -1175,7 +1227,7 @@ describe("session ownership and handoff", () => { const colleagueView = await call("GET", `/api/sessions/${session.id}`, { auth: colleague }); expect(colleagueView.body.session.keyShare).toMatchObject({ uid: "uid-2", - sealed: "COLLEAGUE_SHARE", + sealed: SESSION_SHARE_B, }); expect(colleagueView.body.session.keyShares).toBeUndefined(); expect(colleagueView.body.session.sharedWith).toBeUndefined(); @@ -1195,7 +1247,7 @@ describe("session ownership and handoff", () => { it("lets a colleague keep their own copy of a key, and nobody else's", async () => { const { colleague } = await orgWithColleague(); const path = `/api/sessions/${session.id}/keys`; - const share = { sender_public_key: "BASE64_PUBLIC_KEY", sealed: "v2.BASE64_SEALED" }; + const share = { sender_public_key: P256_PUBLIC_KEY_A, sealed: `v2.${SESSION_SHARE_A}` }; const forOwner = await call("PUT", path, { auth: colleague, body: { shares: [{ uid: "uid-1", ...share }] } }); expect(forOwner.status).toBe(403); @@ -1204,14 +1256,14 @@ describe("session ownership and handoff", () => { const own = await call("PUT", path, { auth: colleague, body: { shares: [{ uid: "uid-2", ...share }] } }); expect(own.status).toBe(200); const listed = await call("GET", "/api/sessions", { auth: colleague }); - expect(listed.body.sessions[0].keyShare).toMatchObject({ sealed: "v2.BASE64_SEALED" }); + expect(listed.body.sessions[0].keyShare).toMatchObject({ sealed: `v2.${SESSION_SHARE_A}` }); }); it("tells the owner, and only the owner, who holds a copy", async () => { const { colleague } = await orgWithColleague(); await call("PUT", `/api/sessions/${session.id}/keys`, { auth: await idToken(), - body: { shares: [{ uid: "uid-2", sender_public_key: "K", sealed: "S" }] }, + body: { shares: [{ uid: "uid-2", sender_public_key: P256_PUBLIC_KEY_A, sealed: SESSION_SHARE_A }] }, }); const owner = await call("GET", "/api/sessions", { auth: await idToken() }); expect(owner.body.sessions[0].sharedWith).toEqual(["uid-2"]); @@ -1224,8 +1276,8 @@ describe("session ownership and handoff", () => { const path = `/api/sessions/${session.id}/keys`; const shares = [{ uid: "uid-2", - sender_public_key: "BASE64_PUBLIC_KEY", - sealed: "BASE64_SEALED_PASSWORD", + sender_public_key: P256_PUBLIC_KEY_A, + sealed: SESSION_SHARE_A, }]; const shared = await call("PUT", path, { auth: await idToken(), body: { shares } }); @@ -1233,8 +1285,8 @@ describe("session ownership and handoff", () => { const listed = await call("GET", "/api/sessions", { auth: colleague }); expect(listed.body.sessions[0].keyShare).toMatchObject({ - senderPublicKey: "BASE64_PUBLIC_KEY", - sealed: "BASE64_SEALED_PASSWORD", + senderPublicKey: P256_PUBLIC_KEY_A, + sealed: SESSION_SHARE_A, }); /* A colleague may keep their own copy, but cannot write one for anyone else. */ @@ -1249,8 +1301,8 @@ describe("session ownership and handoff", () => { body: { shares: [{ uid: "uid-outside", - sender_public_key: "BASE64_PUBLIC_KEY", - sealed: "BASE64_SEALED_PASSWORD", + sender_public_key: P256_PUBLIC_KEY_A, + sealed: SESSION_SHARE_A, }], }, }); @@ -2242,8 +2294,8 @@ describe("team audit key", () => { const colleague = await withColleague(); await makeKey([{ uid: "uid-1", sealed: sealedCopy() }]); const first = sealedCopy(); - const put = (sealed: string) => - call("PUT", "/api/team-key/shares", { auth: colleague, body: { version: 1, shares: [{ uid: "uid-2", sealed }] } }); + const put = async (sealed: string) => + call("PUT", "/api/team-key/shares", { auth: await idToken(), body: { version: 1, shares: [{ uid: "uid-2", sealed }] } }); expect((await put(first)).body.shared).toBe(1); expect((await put(sealedCopy())).body.shared).toBe(0); expect((await call("GET", "/api/team-key", { auth: colleague })).body.share.sealed).toBe(first); @@ -2255,6 +2307,17 @@ describe("team audit key", () => { expect(overOwner.body.shared).toBe(0); }); + it("does not let a member without the team key poison missing shares", async () => { + const colleague = await withColleague(); + await makeKey([{ uid: "uid-1", sealed: sealedCopy() }]); + const attempt = await call("PUT", "/api/team-key/shares", { + auth: colleague, + body: { version: 1, shares: [{ uid: "uid-2", sealed: sealedCopy() }] }, + }); + expect(attempt.status).toBe(403); + expect((await call("GET", "/api/team-key", { auth: colleague })).body.share).toBeNull(); + }); + it("refuses copies of a key that is not the current one", async () => { await withColleague(); await makeKey([{ uid: "uid-1", sealed: sealedCopy() }]); diff --git a/app/server/app.ts b/app/server/app.ts index ac4ae88..8fb6cba 100644 --- a/app/server/app.ts +++ b/app/server/app.ts @@ -19,7 +19,14 @@ import { sessionSource, } from "./lib/sessions"; import { mintSecret } from "./lib/tokens"; -import { RESET_SIGN_IN_WINDOW_MS, isP256PublicKey, readOwnerShare, readVaultInput, vaultForApi } from "./lib/vault"; +import { + RESET_SIGN_IN_WINDOW_MS, + isP256PublicKey, + readOwnerShare, + readSessionKeyShare, + readVaultInput, + vaultForApi, +} from "./lib/vault"; import { isAuditEnvelope, isTeamKeyShare } from "./lib/audit-seal"; import { changeRole, @@ -483,6 +490,9 @@ export function createApp(options: AppOptions) { /* Publishing the browser key here keeps it current without a separate call on every sign-in. */ const publicKey = url.searchParams.get("key"); + if (publicKey && !(await isP256PublicKey(publicKey))) { + return send(response, 400, { error: "invalid browser public key" }); + } if (publicKey) await store.setMemberKey(identity.uid, publicKey); const described = await describeOrganization(store, resolved.membership); return send(response, described.status, { @@ -681,6 +691,14 @@ export function createApp(options: AppOptions) { } const shares = readTeamShares(body.shares); if (!shares) return send(response, 400, { error: "invalid key shares" }); + const current = await store.teamKeyShares(membership.orgId); + if (!current.some((share) => + share.uid === membership.uid && share.version === key.version + )) { + return send(response, 403, { + error: "open your own copy of the team key before sharing it", + }); + } const memberIds = new Set((await store.members(membership.orgId)).map((member) => member.uid)); if (shares.some((share) => !memberIds.has(share.uid))) { return send(response, 400, { error: "key shares may only be sent to organization members" }); @@ -1061,27 +1079,22 @@ export function createApp(options: AppOptions) { const body = (await readBody(request)) as Record; const incoming = Array.isArray(body.shares) ? body.shares : []; - const shares = incoming - .map((entry) => entry as Record) - .filter( - (entry) => - typeof entry.uid === "string" && - typeof entry.sender_public_key === "string" && - typeof entry.sealed === "string", - ) - .slice(0, 100) - .map((entry) => ({ - uid: String(entry.uid), - senderPublicKey: String(entry.sender_public_key ?? ""), - sealed: String(entry.sealed), - })); - - if (shares.length !== incoming.length || shares.some( - (share) => !share.uid || !share.senderPublicKey || !share.sealed || - share.senderPublicKey.length > 512 || share.sealed.length > 4096, - )) { + if (incoming.length > 100) { return send(response, 400, { error: "invalid key share" }); } + const shares: { uid: string; senderPublicKey: string; sealed: string }[] = []; + for (const candidate of incoming) { + if (!candidate || typeof candidate !== "object") { + return send(response, 400, { error: "invalid key share" }); + } + const entry = candidate as Record; + if (typeof entry.uid !== "string" || !entry.uid) { + return send(response, 400, { error: "invalid key share" }); + } + const parsed = await readSessionKeyShare(entry); + if (!parsed) return send(response, 400, { error: "invalid key share" }); + shares.push({ uid: entry.uid, ...parsed }); + } if (!isOwner && shares.some((share) => share.uid !== membership.uid)) { return send(response, 403, { error: "only the session owner can share its key" }); } @@ -1266,9 +1279,13 @@ export function createApp(options: AppOptions) { const token = await requireCli(request); if (!token) return send(response, 401, { error: "not signed in" }); /* The agent publishes its key on every poll, so a restart re-keys. */ + const agentPublicKey = url.searchParams.get("key") ?? undefined; + if (agentPublicKey && !(await isP256PublicKey(agentPublicKey))) { + return send(response, 400, { error: "invalid agent public key" }); + } await store.markAgentSeen( token.id, - url.searchParams.get("key") ?? undefined, + agentPublicKey, readHarnesses(url), ); return send(response, 200, { commands: await store.claimCommands(token.id) }); diff --git a/app/server/lib/browser-headers.ts b/app/server/lib/browser-headers.ts new file mode 100644 index 0000000..0f4db41 --- /dev/null +++ b/app/server/lib/browser-headers.ts @@ -0,0 +1,22 @@ +/** Security policy shared by the Node and Worker app frontends. */ +export const BROWSER_SECURITY_HEADERS: Readonly> = { + /* Firebase Auth uses a popup plus a small iframe on its hosted auth domain. */ + "Content-Security-Policy": [ + "default-src 'self'", + "base-uri 'self'", + "object-src 'none'", + "frame-ancestors 'none'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "font-src 'self' data:", + "img-src 'self' data: https:", + "connect-src 'self' wss: https://identitytoolkit.googleapis.com https://securetoken.googleapis.com https://www.googleapis.com https://firebaseinstallations.googleapis.com", + "frame-src https://*.firebaseapp.com https://*.web.app https://accounts.google.com", + "form-action 'self'", + ].join("; "), + "Cross-Origin-Opener-Policy": "same-origin-allow-popups", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Referrer-Policy": "no-referrer", + "Permissions-Policy": "camera=(), microphone=(), geolocation=()", +}; diff --git a/app/server/lib/firebase-token.ts b/app/server/lib/firebase-token.ts index 5503bf4..b232063 100644 --- a/app/server/lib/firebase-token.ts +++ b/app/server/lib/firebase-token.ts @@ -7,6 +7,8 @@ export interface Identity { uid: string; email: string; name: string; + /** True only when the identity provider has verified ownership of email. */ + emailVerified: boolean; /** * When this person last actually signed in, in milliseconds. A refreshed * token keeps the original time, so this is how an operation that deserves @@ -57,6 +59,7 @@ export function createVerifier(projectId: string, keys?: KeyLookup) { uid, email: typeof payload.email === "string" ? payload.email : "", name: typeof payload.name === "string" ? payload.name : "", + emailVerified: payload.email_verified === true, authTime: typeof payload.auth_time === "number" ? payload.auth_time * 1000 : undefined, }, }; diff --git a/app/server/lib/static-files.test.ts b/app/server/lib/static-files.test.ts index 4893760..cf62167 100644 --- a/app/server/lib/static-files.test.ts +++ b/app/server/lib/static-files.test.ts @@ -51,6 +51,8 @@ describe("staticFiles", () => { expect(response.headers.get("x-frame-options")).toBe("DENY"); expect(response.headers.get("referrer-policy")).toBe("no-referrer"); expect(response.headers.get("permissions-policy")).toBe("camera=(), microphone=(), geolocation=()"); + expect(response.headers.get("content-security-policy")).toContain("script-src 'self'"); + expect(response.headers.get("content-security-policy")).toContain("frame-ancestors 'none'"); }); it("caches fingerprinted assets forever and the document never", async () => { diff --git a/app/server/lib/static-files.ts b/app/server/lib/static-files.ts index ed51d38..f1d3f2a 100644 --- a/app/server/lib/static-files.ts +++ b/app/server/lib/static-files.ts @@ -2,6 +2,7 @@ import { createReadStream } from "node:fs"; import { stat, realpath } from "node:fs/promises"; import { extname, join, resolve, sep } from "node:path"; import type { IncomingMessage, ServerResponse } from "node:http"; +import { BROWSER_SECURITY_HEADERS } from "./browser-headers"; /** * Serves the built client, so the app and its API share an origin. @@ -33,14 +34,6 @@ const TYPES: Record = { * resolves. same-origin-allow-popups keeps the isolation and the handle. The * dev server sends the same header; this is the production half of it. */ -const DOCUMENT_HEADERS: Record = { - "Cross-Origin-Opener-Policy": "same-origin-allow-popups", - "X-Content-Type-Options": "nosniff", - "X-Frame-Options": "DENY", - "Referrer-Policy": "no-referrer", - "Permissions-Policy": "camera=(), microphone=(), geolocation=()", -}; - export interface StaticFiles { (request: IncomingMessage, response: ServerResponse): Promise; } @@ -127,7 +120,7 @@ export function staticFiles(root: string): StaticFiles { "Content-Length": found.size, "Cache-Control": immutable ? "public, max-age=31536000, immutable" : "no-cache", "Last-Modified": found.mtime.toUTCString(), - ...DOCUMENT_HEADERS, + ...BROWSER_SECURITY_HEADERS, }); if (request.method === "HEAD") { response.end(); diff --git a/app/server/lib/vault.test.ts b/app/server/lib/vault.test.ts index d30e352..ca45dd9 100644 --- a/app/server/lib/vault.test.ts +++ b/app/server/lib/vault.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { isP256PublicKey, readOwnerShare, readVaultInput } from "./vault"; +import { isP256PublicKey, readOwnerShare, readSessionKeyShare, readVaultInput } from "./vault"; import { createVault, openFromAccount, sealToAccount } from "../../src/lib/vault-crypto"; /* @@ -83,6 +83,16 @@ describe("the CLI's own copy of a password", () => { expect(await readOwnerShare({ sender_public_key: "junk", sealed: share.sealed })).toBeNull(); expect(await readOwnerShare({ sender_public_key: share.senderPublicKey, sealed: "v2." })).toBeNull(); }); + + it("accepts both vault and legacy browser envelopes but rejects malformed ciphertext", async () => { + const made = await createVault("uid-1"); + const share = await sealToAccount(made.bundle.publicKey, "sess_1", "uid-1", "Kw9eHbru"); + const input = { sender_public_key: share.senderPublicKey, sealed: share.sealed }; + expect(await readSessionKeyShare(input)).toEqual(share); + expect(await readSessionKeyShare({ ...input, sealed: share.sealed.slice(3) })) + .toEqual({ ...share, sealed: share.sealed.slice(3) }); + expect(await readSessionKeyShare({ ...input, sealed: "not base64" })).toBeNull(); + }); }); /* diff --git a/app/server/lib/vault.ts b/app/server/lib/vault.ts index 4b9e116..55f6b17 100644 --- a/app/server/lib/vault.ts +++ b/app/server/lib/vault.ts @@ -114,11 +114,27 @@ export function vaultForApi(key: AccountKey) { * register when this is wrong, so the caller drops it rather than failing. */ export async function readOwnerShare(value: unknown): Promise | null> { + const parsed = await readSessionKeyShare(value); + return parsed?.sealed.startsWith(VAULT_SHARE_PREFIX) ? parsed : null; +} + +/** + * A session-password copy, either the current account-vault envelope (`v2.`) + * or the legacy browser-key envelope. The relay cannot authenticate its + * plaintext, but it can reject malformed points and oversized/non-base64 + * ciphertext before either reaches persistent storage. + */ +export async function readSessionKeyShare( + value: unknown, +): Promise | null> { if (!value || typeof value !== "object") return null; const { sender_public_key: senderPublicKey, sealed } = value as Record; if (!(await isP256PublicKey(senderPublicKey))) return null; - if (typeof sealed !== "string" || !sealed.startsWith(VAULT_SHARE_PREFIX)) return null; - const length = decodedLength(sealed.slice(VAULT_SHARE_PREFIX.length)); + if (typeof sealed !== "string") return null; + const body = sealed.startsWith(VAULT_SHARE_PREFIX) + ? sealed.slice(VAULT_SHARE_PREFIX.length) + : sealed; + const length = decodedLength(body); if (length === null || length < 12 + 16 + 1 || length > SHARE_MAX_BYTES) return null; return { senderPublicKey: senderPublicKey as string, sealed }; } diff --git a/app/server/routes/organizations.ts b/app/server/routes/organizations.ts index a69f837..0ff09f9 100644 --- a/app/server/routes/organizations.ts +++ b/app/server/routes/organizations.ts @@ -18,6 +18,26 @@ export interface Result { body: unknown; } +/** + * A targeted invitation proves who the link was intended for only after the + * identity provider has verified that address. Without this check, someone + * who can create an unverified account for another address and obtains the + * bearer invite link can claim that person's seat. + */ +function checkInviteForIdentity( + invite: Invite | undefined, + identity: Identity, +): ReturnType { + const checked = checkInvite(invite, identity.email); + if (checked.ok && checked.invite.email && !identity.emailVerified) { + return { + ok: false, + reason: "Verify that email address before accepting this invitation.", + }; + } + return checked; +} + const ok = (body: unknown): Result => ({ status: 200, body }); const created = (body: unknown): Result => ({ status: 201, body }); const bad = (error: string): Result => ({ status: 400, body: { error } }); @@ -52,7 +72,7 @@ export async function ensureMembership( } if (inviteId) { - const check = checkInvite(await store.invite(inviteId), identity.email); + const check = checkInviteForIdentity(await store.invite(inviteId), identity); if (!check.ok) { /* * A bad invite still gets an organization, because the alternative is an @@ -99,7 +119,7 @@ async function acceptAsExistingMember( existing: Membership, inviteId: string, ): Promise<{ membership: Membership; joined: boolean; error?: string }> { - const check = checkInvite(await store.invite(inviteId), identity.email); + const check = checkInviteForIdentity(await store.invite(inviteId), identity); if (!check.ok) return { membership: existing, joined: false, error: check.reason }; if (check.invite.orgId === existing.orgId) { diff --git a/app/worker/index.ts b/app/worker/index.ts index e6e041c..e43f310 100644 --- a/app/worker/index.ts +++ b/app/worker/index.ts @@ -6,6 +6,7 @@ import type { Store } from "../server/lib/store"; import { PostgresStore } from "../server/lib/store-postgres"; import { callNodeHandler, type NodeHandler } from "./node-adapter"; import { allowedOriginsFor } from "../server/lib/config"; +import { BROWSER_SECURITY_HEADERS } from "../server/lib/browser-headers"; /** * The Worker deployment of the accounts service. @@ -59,14 +60,6 @@ const RELAY_PREFIX = "/relay"; * wrangler.jsonc sets run_worker_first so Cloudflare Assets cannot bypass * this function for a file that already exists. */ -const CLIENT_HEADERS: Record = { - "Cross-Origin-Opener-Policy": "same-origin-allow-popups", - "X-Content-Type-Options": "nosniff", - "X-Frame-Options": "DENY", - "Referrer-Policy": "no-referrer", - "Permissions-Policy": "camera=(), microphone=(), geolocation=()", -}; - /* * The store belonging to the request being served. * @@ -187,7 +180,7 @@ async function toRelay(request: Request, relayUrl: string): Promise { async function toClient(request: Request, env: Env): Promise { const served = await env.ASSETS.fetch(request); const response = new Response(served.body, served); - for (const [name, value] of Object.entries(CLIENT_HEADERS)) response.headers.set(name, value); + for (const [name, value] of Object.entries(BROWSER_SECURITY_HEADERS)) response.headers.set(name, value); return response; } From 5fd4585169c1f2a27df162d494e4e2b68f4256eb Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Sat, 12 Sep 2026 16:44:25 +0300 Subject: [PATCH 07/12] Add a portable standalone relay --- .github/workflows/ci.yml | 13 + .github/workflows/container.yml | 42 ++ CHANGELOG.md | 13 + README.md | 2 +- cmd/shell/session_output.go | 2 +- cmd/shell/session_output_test.go | 2 +- docs/content.json | 34 +- docs/self-hosting.md | 105 ++--- docs/third-party-notices.md | 4 + package-lock.json | 739 ++++++++++++++++++++++++++----- package.json | 10 +- public/THIRD_PARTY_NOTICES.txt | 4 + public/sitemap.xml | 1 + scripts/test-landing-seo.mjs | 4 +- shared/documentation.ts | 2 + standalone/Caddyfile | 4 + standalone/Dockerfile | 28 ++ standalone/compose.yaml | 42 ++ standalone/server.test.ts | 148 +++++++ standalone/server.ts | 693 +++++++++++++++++++++++++++++ tsconfig.standalone.json | 13 + web/documentation.ts | 3 + worker/index.ts | 3 +- wrangler.example.jsonc | 1 + 24 files changed, 1732 insertions(+), 180 deletions(-) create mode 100644 standalone/Caddyfile create mode 100644 standalone/Dockerfile create mode 100644 standalone/compose.yaml create mode 100644 standalone/server.test.ts create mode 100644 standalone/server.ts create mode 100644 tsconfig.standalone.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d133d8..ef32451 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -214,3 +214,16 @@ jobs: - run: docker build --build-arg VERSION=test -t shell-online:test . - run: test "$(docker run --rm --entrypoint shell shell-online:test --version)" = "shell test" - run: test "$(docker image inspect shell-online:test --format '{{.Config.User}}')" = "shellonline" + - name: Build the standalone relay image + run: docker build -f standalone/Dockerfile -t shell-online-relay:test . + - name: Exercise standalone health and static delivery + run: | + docker run -d --name shell-online-relay -p 18080:8080 shell-online-relay:test + trap 'docker rm -f shell-online-relay' EXIT + for attempt in $(seq 1 30); do + curl -fsS http://127.0.0.1:18080/api/health && break + test "$attempt" -lt 30 + sleep 1 + done + curl -fsS http://127.0.0.1:18080/ | grep -q 'shell.online' + test "$(docker image inspect shell-online-relay:test --format '{{.Config.User}}')" = "shellonline" diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml index 58e1cbe..329d656 100644 --- a/.github/workflows/container.yml +++ b/.github/workflows/container.yml @@ -57,3 +57,45 @@ jobs: cache-to: type=gha,mode=max,ignore-error=true provenance: mode=max sbom: true + + publish-standalone-relay: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 + - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - id: metadata + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ghcr.io/teoslayer/shell.online-relay + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=sha,prefix=sha- + labels: | + org.opencontainers.image.title=shell.online standalone relay + org.opencontainers.image.description=Portable single-node shell.online WebSocket relay + org.opencontainers.image.url=https://shell.online/self-hosting/ + org.opencontainers.image.source=https://github.com/TeoSlayer/shell.online + org.opencontainers.image.licenses=MIT + - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + file: standalone/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.metadata.outputs.tags }} + labels: ${{ steps.metadata.outputs.labels }} + cache-from: type=gha,scope=standalone-relay + cache-to: type=gha,scope=standalone-relay,mode=max,ignore-error=true + provenance: mode=max + sbom: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d2ac92..ddb2b97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable user-visible changes are recorded here. Versions follow [Semantic Ve ## Unreleased +### Added + +- A standalone, single-node relay for ordinary Docker hosts. It uses Node.js, + WebSockets, local metadata state and Caddy-managed TLS, and requires no + Cloudflare account or credentials. +- A versioned self-hosting documentation page and a release image at + `ghcr.io/teoslayer/shell.online-relay` for amd64 and arm64. + +### Changed + +- `--no-e2ee` output refers to the configured relay instead of assuming every + deployment runs on Cloudflare. + ### Fixed - Keep `--auto-close today` valid throughout the final second of the local diff --git a/README.md b/README.md index fcb8de3..ddab984 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ docker compose logs shell-online - [End-to-end encryption](https://shell.online/e2ee/) - [Containers](https://shell.online/docker/) - [Platforms](https://shell.online/platforms/) -- [Self-hosting](docs/self-hosting.md) +- [Self-hosting](https://shell.online/self-hosting/) — Docker or Cloudflare ## Development diff --git a/cmd/shell/session_output.go b/cmd/shell/session_output.go index 354d71a..5f468dc 100644 --- a/cmd/shell/session_output.go +++ b/cmd/shell/session_output.go @@ -46,7 +46,7 @@ func printSessionCard(writer io.Writer, result backgroundLaunchResult, backgroun fmt.Fprintf(writer, " %s %s, or when the task exits\n", label("Closes"), result.ClosesAt.Format(time.RFC3339)) } if !result.Encrypted { - fmt.Fprintf(writer, " %s %s\n", label("Privacy"), styleSessionText(color, "38;5;209", "Cloudflare can relay terminal plaintext (--no-e2ee)")) + fmt.Fprintf(writer, " %s %s\n", label("Privacy"), styleSessionText(color, "38;5;209", "The relay can read terminal plaintext (--no-e2ee)")) } if result.Handoff == claudeConversationHandoff { fmt.Fprintf(writer, " %s forked Claude conversation; the original stays open\n", label("Handoff")) diff --git a/cmd/shell/session_output_test.go b/cmd/shell/session_output_test.go index 2d6cd31..5f5b278 100644 --- a/cmd/shell/session_output_test.go +++ b/cmd/shell/session_output_test.go @@ -27,7 +27,7 @@ func TestSessionCardWarnsWhenE2EEIsDisabled(t *testing.T) { printSessionCard(&output, backgroundLaunchResult{ ID: "abcdefghijklmnopqrstuvwxyzABCDEF", ShareURL: "https://shell.online/s/example", }, true) - if !strings.Contains(output.String(), "transport encryption only") || !strings.Contains(output.String(), "Cloudflare can relay terminal plaintext") { + if !strings.Contains(output.String(), "transport encryption only") || !strings.Contains(output.String(), "The relay can read terminal plaintext") { t.Fatalf("plaintext boundary is unclear:\n%s", output.String()) } if strings.Contains(output.String(), "Password") { diff --git a/docs/content.json b/docs/content.json index 62c64e2..14b788d 100644 --- a/docs/content.json +++ b/docs/content.json @@ -211,7 +211,7 @@ ], [ "--no-e2ee", - "Disable payload E2EE for compatibility/debugging; Cloudflare can relay plaintext. Conflicts with passwords and persistence." + "Disable payload E2EE for compatibility/debugging; the configured relay can read plaintext. Conflicts with passwords and persistence." ], [ "--persistent ", @@ -541,6 +541,38 @@ ] ] }, + "self-hosting": { + "eyebrow": "Standalone relay", + "title": "Run the relay on any Docker host.", + "intro": "The standalone relay uses ordinary Node.js, WebSockets, local disk state, and Caddy. It needs no Cloudflare account or credentials and carries the same opaque E2EE frames as the hosted service.", + "seo": { + "description": "Self-host the shell.online relay outside Cloudflare with Docker, Caddy, local state, and the standard shell CLI.", + "socialTitle": "Self-host shell.online", + "socialDescription": "Run the shell.online relay on a normal Linux server without Cloudflare." + }, + "cards": [ + [ + "Deploy", + "Point a domain at the host, set SHELL_ONLINE_PUBLIC_URL and SHELL_ONLINE_SITE to its HTTPS URL and hostname, then run docker compose up -d --build in standalone/. Caddy obtains and renews TLS certificates automatically." + ], + [ + "Use it", + "Set SHELL_ONLINE_SERVER=https://relay.example.com or pass --server https://relay.example.com to the CLI. The generated share URL and WebSocket endpoint stay on that origin." + ], + [ + "Security boundary", + "Terminal payloads remain end-to-end encrypted by default. The relay stores session metadata and SHA-256 host-token hashes, never browser passwords, E2EE keys, or terminal contents. Same-origin checks, frame limits, viewer limits, rate limits, backpressure and non-root containers remain enforced." + ], + [ + "Scale and persistence", + "This deployment is deliberately single-node. Session metadata survives restarts in the relay-state volume and persistent clients reconnect with the same URL. Live WebSockets reconnect after a restart. Back up the volume, but do not run multiple relay replicas against one state file." + ], + [ + "Cloudflare deployment remains available", + "wrangler.example.jsonc is still the supported global edge deployment. The standalone server is the portable path for one host; it does not include the optional accounts app or hosted analytics dashboard." + ] + ] + }, "docker": { "eyebrow": "Persistent Docker terminal", "title": "One encrypted shell and one stable link across restarts.", diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 306edaa..f725a6b 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -1,88 +1,75 @@ # Self-hosting -shell.online has two services: +The CLI only needs a relay. Choose the standalone Docker deployment for a +normal server, or the Worker deployment for Cloudflare's edge. -- the relay, which serves the public site and carries encrypted terminal frames; -- the optional accounts app in `app/`. +## Standalone Docker relay -The CLI does not need the accounts app. +Requirements: Docker Engine with Compose, a public domain, and ports 80/443. -## Relay on Cloudflare Workers - -Requirements: +```sh +git clone https://github.com/TeoSlayer/shell.online.git +cd shell.online/standalone -- Node.js 22 and npm; -- Go 1.26.8 if the deployment should serve downloadable binaries; -- a Cloudflare account with Workers, Durable Objects, Rate Limiting, and - Analytics Engine available. +SHELL_ONLINE_PUBLIC_URL=https://relay.example.com \ +SHELL_ONLINE_SITE=relay.example.com \ +docker compose up -d --build +``` -Create a local Wrangler configuration: +Point the domain's A/AAAA record at the host first. Caddy obtains TLS +automatically. For a local HTTP test, the defaults work at `http://localhost`: ```sh -cp wrangler.example.jsonc wrangler.local.jsonc -npm ci -npm run build -npx wrangler deploy --config wrangler.local.jsonc +docker compose up -d --build +curl http://localhost/api/health ``` -`npm run build:web` is enough for relay development, but it does not create the -download artifacts used by `/install` and `/downloads`. - -Wrangler prints the deployment URL. Point the CLI at it with either form: +Use the relay without changing the installed CLI: ```sh -shell --server https://example.workers.dev -SHELL_ONLINE_SERVER=https://example.workers.dev shell +SHELL_ONLINE_SERVER=https://relay.example.com shell claude +shell --server https://relay.example.com claude ``` -To use a custom domain, add a `routes` entry to the copied configuration. The -example uses generic binding names and contains no account identifiers or -credentials. Choose unique Rate Limiting namespace IDs if the defaults conflict -with another Worker in your account. +`relay-state` stores session metadata and host-token hashes so persistent +clients can recover the same identity after a relay restart. It does not store +terminal contents, E2EE keys, or browser passwords. Back up this volume and run +one relay replica per volume. Live sockets reconnect after a restart. -## Accounts app +The standalone server enforces the hosted protocol's authentication, +same-origin browser policy, read-only mode, input lease, viewer/frame/traffic +limits, slow-client protection, stable terminal grid, and task-bound expiry. +Terminal frames remain opaque to the relay when the CLI's default E2EE is used. -The accounts app adds sign-in, organizations, linked machines, and a shared -session list. It needs Firebase Authentication and PostgreSQL. +Configuration: -```sh -cd app -cp .env.example .env -# Fill in Firebase values, POSTGRES_PASSWORD, WEB_ORIGIN, and RELAY_URL. -docker compose up --build -d -``` +| Variable | Default | Meaning | +| --- | --- | --- | +| `SHELL_ONLINE_PUBLIC_URL` | `http://localhost` in Compose | Exact public origin used in links and browser origin checks | +| `SHELL_ONLINE_SITE` | `http://localhost` | Caddy site address; use a hostname for automatic HTTPS | +| `SHELL_ONLINE_STATE_FILE` | `/var/lib/shell-online/relay.json` | Metadata state file inside the relay container | +| `SHELL_ONLINE_TRUST_PROXY` | `1` in Compose | Trust the first `X-Forwarded-For` address; enable only behind your proxy | -Add the value of `WEB_ORIGIN` to Firebase's authorized domains. The container -serves the client and API together on port 8080 and proxies `/relay/*` to -`RELAY_URL`. +The standalone relay is a single-node deployment. It does not include the +optional accounts app or hosted analytics dashboard. -Point account commands at a self-hosted app: - -```sh -SHELL_ONLINE_ACCOUNTS=https://app.example.com \ -SHELL_ONLINE_WEB=https://app.example.com \ -shell login -``` +## Cloudflare Workers relay -For a Cloudflare deployment, `app/wrangler.jsonc` is a template. Copy it if you -want to preserve the hosted defaults, set your Worker name, route, origins, -`HYPERDRIVE_ID`, `FIREBASE_PROJECT_ID`, and `MAIL_FROM`, then run: +Copy the credential-free example rather than editing it: ```sh -cd app +cp wrangler.example.jsonc wrangler.local.jsonc npm ci npm run build -npm run render:deploy-config -npx wrangler deploy --config wrangler.deploy.jsonc +npx wrangler deploy --config wrangler.local.jsonc ``` -The Worker expects a `HYPERDRIVE` binding to PostgreSQL. Set `MAIL_API_KEY` with -`wrangler secret put` only if invitation email is enabled. Database migrations -are in `app/server/lib/migrations/`; apply them with `npm run db:migrate` before -deploying code that depends on a new migration. +Set `SHELL_ONLINE_SERVER` to the URL Wrangler prints. Add a `routes` entry to +the copied config for a custom domain. The Worker path requires Durable +Objects, Rate Limiting, Analytics Engine, and static assets. -## Updating +## Accounts app -Pull the desired tag, rebuild, and deploy it. Do not reuse a persistent state -file with a different relay unless you intend to move that session. Back up the -PostgreSQL database and Docker volumes before upgrading the accounts app. +Accounts are optional and do not participate in terminal transport. The app in +`app/` uses Firebase Authentication and PostgreSQL; its local Docker deployment +is documented in [`app/README.md`](../app/README.md). diff --git a/docs/third-party-notices.md b/docs/third-party-notices.md index 6894b27..15e19c7 100644 --- a/docs/third-party-notices.md +++ b/docs/third-party-notices.md @@ -15,6 +15,10 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +## ws — MIT + +Copyright (c) 2011 Einar Otto Stangvik; Copyright (c) 2013 Arnout Kazemier and contributors; Copyright (c) 2016 Luigi Pinca and contributors. The MIT license above applies. + ## Uncut Sans — SIL Open Font License 1.1 The bundled font is Copyright (c) 2022 Kasper Nordkvist. “Uncut Sans” is a trademark of Kasper Nordkvist. Its full license is included at [`public/fonts/OFL-Uncut-Sans.txt`](../public/fonts/OFL-Uncut-Sans.txt). diff --git a/package-lock.json b/package-lock.json index ba7423b..3822a07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,14 @@ "version": "0.12.1", "license": "MIT", "dependencies": { - "@xterm/xterm": "^6.0.0" + "@xterm/xterm": "^6.0.0", + "ws": "^8.18.3" }, "devDependencies": { "@cloudflare/workers-types": "^5.20260905.1", + "@types/node": "^24.10.1", + "@types/ws": "^8.18.1", + "esbuild": "^0.28.2", "typescript": "^7.0.2", "vite": "^8.2.2", "vitest": "^4.0.0", @@ -166,9 +170,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], @@ -183,9 +187,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -200,9 +204,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -217,9 +221,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], @@ -234,9 +238,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -251,9 +255,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -268,9 +272,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -285,9 +289,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -302,9 +306,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -319,9 +323,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], @@ -336,9 +340,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], @@ -353,9 +357,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], @@ -370,9 +374,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], @@ -387,9 +391,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], @@ -404,9 +408,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], @@ -421,9 +425,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], @@ -438,9 +442,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], @@ -455,9 +459,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], @@ -472,9 +476,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], @@ -489,9 +493,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], @@ -506,9 +510,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -523,9 +527,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -540,9 +544,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -557,9 +561,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -574,9 +578,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -591,9 +595,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -1515,6 +1519,26 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "24.13.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz", + "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", @@ -2053,9 +2077,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2067,32 +2091,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/estree-walker": { @@ -2782,6 +2806,13 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, "node_modules/unenv": { "version": "2.0.0-rc.24", "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", @@ -2986,6 +3017,7 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -3036,12 +3068,495 @@ } } }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "node_modules/wrangler/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", "engines": { "node": ">=10.0.0" }, diff --git a/package.json b/package.json index e5604a7..4593221 100644 --- a/package.json +++ b/package.json @@ -20,20 +20,26 @@ "build": "npm run check && npm run build:web && npm run build:cli && npm run verify:downloads", "build:cli": "sh ./scripts/build-cli.sh", "build:web": "vite build", + "build:standalone": "npm run build:web && esbuild standalone/server.ts --bundle --platform=node --format=esm --target=node22 --external:ws --outfile=standalone/dist/server.mjs", + "start:standalone": "node standalone/dist/server.mjs", "deploy:production": "sh ./scripts/deploy-production.sh", "verify:downloads": "node ./scripts/verify-downloads.mjs", "check": "npm run typecheck && npm test", "test": "vitest run && sh ./scripts/test-install.sh && sh ./scripts/test-docker-entrypoint.sh && sh ./scripts/test-deploy-production.sh && sh ./scripts/test-qemu-linux.sh --check && node ./scripts/test-landing-seo.mjs && node ./scripts/test-mobile-controls.mjs && node ./scripts/check-formula.mjs", "test:qemu:manifest": "sh ./scripts/test-qemu-linux.sh --check", - "typecheck": "tsc -p tsconfig.worker.json --noEmit && tsc -p tsconfig.web.json --noEmit", + "typecheck": "tsc -p tsconfig.worker.json --noEmit && tsc -p tsconfig.web.json --noEmit && tsc -p tsconfig.standalone.json --noEmit", "test:app": "npm --prefix app ci && npm --prefix app test", "verify:published": "node ./scripts/verify-published.mjs" }, "dependencies": { - "@xterm/xterm": "^6.0.0" + "@xterm/xterm": "^6.0.0", + "ws": "^8.18.3" }, "devDependencies": { "@cloudflare/workers-types": "^5.20260905.1", + "@types/node": "^24.10.1", + "@types/ws": "^8.18.1", + "esbuild": "^0.28.2", "typescript": "^7.0.2", "vite": "^8.2.2", "vitest": "^4.0.0", diff --git a/public/THIRD_PARTY_NOTICES.txt b/public/THIRD_PARTY_NOTICES.txt index 4dfc6d0..2656740 100644 --- a/public/THIRD_PARTY_NOTICES.txt +++ b/public/THIRD_PARTY_NOTICES.txt @@ -13,6 +13,10 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +ws — MIT + +Copyright (c) 2011 Einar Otto Stangvik; Copyright (c) 2013 Arnout Kazemier and contributors; Copyright (c) 2016 Luigi Pinca and contributors. The MIT license above applies. + Uncut Sans — SIL Open Font License 1.1 Copyright (c) 2022 Kasper Nordkvist. "Uncut Sans" is a trademark of Kasper Nordkvist. Full license: /fonts/OFL-Uncut-Sans.txt diff --git a/public/sitemap.xml b/public/sitemap.xml index 1ec34e6..97a84ed 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -12,4 +12,5 @@ https://shell.online/security/2026-09-02 https://shell.online/e2ee/2026-09-02 https://shell.online/docker/2026-09-02 + https://shell.online/self-hosting/2026-09-12 diff --git a/scripts/test-landing-seo.mjs b/scripts/test-landing-seo.mjs index 2429827..0d4fccf 100644 --- a/scripts/test-landing-seo.mjs +++ b/scripts/test-landing-seo.mjs @@ -130,13 +130,13 @@ for (const example of [ check(sitemap.includes("https://shell.online/"), "Homepage is missing from sitemap"); check(sitemap.includes("2026-09-02"), "Sitemap lastmod is missing"); -check((sitemap.match(//gu) ?? []).length === 9, "Sitemap should list the homepage and knowledge base"); +check((sitemap.match(//gu) ?? []).length === 10, "Sitemap should list the homepage and knowledge base"); check(documentationHtml.includes("__DOC_DESCRIPTION__"), "Documentation description build token is missing"); check(documentationHtml.includes("__DOC_SOCIAL_TITLE__"), "Documentation title build token is missing"); check(documentationHtml.includes("__DOC_SOCIAL_DESCRIPTION__"), "Documentation social-description build token is missing"); check(documentationHtml.includes("__DOC_PATH__"), "Documentation canonical-path build token is missing"); check(documentationHtml.includes('{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" +CMD ["node", "/app/server.mjs"] diff --git a/standalone/compose.yaml b/standalone/compose.yaml new file mode 100644 index 0000000..cc03c6a --- /dev/null +++ b/standalone/compose.yaml @@ -0,0 +1,42 @@ +services: + relay: + image: "ghcr.io/teoslayer/shell.online-relay:${SHELL_ONLINE_VERSION:-latest}" + build: + context: .. + dockerfile: standalone/Dockerfile + restart: unless-stopped + init: true + read_only: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + environment: + SHELL_ONLINE_PUBLIC_URL: "${SHELL_ONLINE_PUBLIC_URL:-http://localhost}" + SHELL_ONLINE_TRUST_PROXY: "1" + volumes: + - relay-state:/var/lib/shell-online + tmpfs: + - /tmp:size=16m,mode=1777 + + caddy: + image: caddy:2.10.2-alpine + restart: unless-stopped + depends_on: + relay: + condition: service_healthy + environment: + SHELL_ONLINE_SITE: "${SHELL_ONLINE_SITE:-http://localhost}" + ports: + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + +volumes: + relay-state: + caddy-data: + caddy-config: diff --git a/standalone/server.test.ts b/standalone/server.test.ts new file mode 100644 index 0000000..ce0a693 --- /dev/null +++ b/standalone/server.test.ts @@ -0,0 +1,148 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AddressInfo } from "node:net"; +import { afterEach, describe, expect, test } from "vitest"; +import { WebSocket, type ClientOptions } from "ws"; + +import { Opcode } from "../shared/protocol"; +import { persistentSessionID } from "../shared/persistent-session"; +import { createStandaloneServer } from "./server"; + +const cleanup: Array<() => Promise | void> = []; + +afterEach(async () => { + while (cleanup.length) await cleanup.pop()?.(); +}); + +async function start(stateFile?: string) { + const root = mkdtempSync(join(tmpdir(), "shell-online-standalone-")); + mkdirSync(join(root, "web")); + writeFileSync(join(root, "web", "index.html"), "standalone"); + const runtime = createStandaloneServer({ port: 0, publicUrl: "http://127.0.0.1", webRoot: join(root, "web"), stateFile: stateFile ?? join(root, "state.json") }); + await new Promise((resolve) => runtime.server.listen(0, "127.0.0.1", resolve)); + const port = (runtime.server.address() as AddressInfo).port; + cleanup.push(async () => { await runtime.close(); rmSync(root, { recursive: true, force: true }); }); + return { runtime, base: `http://127.0.0.1:${port}`, ws: `ws://127.0.0.1:${port}`, stateFile: stateFile ?? join(root, "state.json") }; +} + +async function create(base: string, readOnly = false, encrypted = false) { + const response = await fetch(`${base}/api/sessions`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ label: "test", read_only: readOnly, encrypted }), + }); + expect(response.status).toBe(201); + return await response.json() as { session_id: string; host_token: string }; +} + +function open(url: string, options?: ClientOptions): Promise { + return new Promise((resolve, reject) => { + const socket = new WebSocket(url, options); + socket.once("open", () => resolve(socket)); + socket.once("error", reject); + }); +} + +function message(socket: WebSocket, predicate: (value: string | Buffer) => boolean): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { socket.off("message", listener); reject(new Error("message timeout")); }, 2_000); + const listener = (value: Buffer, binary: boolean) => { + const parsed = binary ? value : value.toString(); + if (!predicate(parsed)) return; + clearTimeout(timeout); socket.off("message", listener); resolve(parsed); + }; + socket.on("message", listener); + }); +} + +describe("standalone relay", () => { + test("serves the app and health endpoint", async () => { + const { base } = await start(); + expect(await (await fetch(base)).text()).toContain("standalone"); + expect(await (await fetch(`${base}/api/health`)).json()).toMatchObject({ ok: true, service: "shell.online-standalone" }); + }); + + test("authenticates the host and relays opaque terminal frames", async () => { + const { base, ws } = await start(); + const session = await create(base); + const host = await open(`${ws}/api/sessions/${session.session_id}/ws`, { headers: { Authorization: `Bearer ${session.host_token}`, "X-Shell-Terminal-Grid": "80x40" } }); + const viewer = await open(`${ws}/api/sessions/${session.session_id}/ws?layout=portrait`, { headers: { Origin: "http://127.0.0.1" } }); + cleanup.push(() => { host.terminate(); viewer.terminate(); }); + + const output = Buffer.from([Opcode.Output, 0xde, 0xad, 0xbe, 0xef]); + const received = message(viewer, (value) => Buffer.isBuffer(value) && value[0] === Opcode.Output); + host.send(output); + expect(await received).toEqual(output); + + const input = Buffer.from([Opcode.Input, 0x61]); + const returned = message(host, (value) => Buffer.isBuffer(value) && value[0] === Opcode.Input); + viewer.send(input); + expect(await returned).toEqual(input); + }); + + test("enforces read-only access in the relay", async () => { + const { base, ws } = await start(); + const session = await create(base, true); + const viewer = await open(`${ws}/api/sessions/${session.session_id}/ws`, { headers: { Origin: "http://127.0.0.1" } }); + cleanup.push(() => viewer.terminate()); + const denied = message(viewer, (value) => typeof value === "string" && value.includes("access_denied")); + viewer.send(Buffer.from([Opcode.Input, 0x61])); + expect(JSON.parse(await denied as string)).toEqual({ type: "access_denied", reason: "read_only" }); + }); + + test("routes E2EE envelopes byte-for-byte and rejects a false host token", async () => { + const { base, ws } = await start(); + const session = await create(base, false, true); + const rejected = await new Promise((resolve) => { + const socket = new WebSocket(`${ws}/api/sessions/${session.session_id}/ws`, { headers: { Authorization: "Bearer false-token" } }); + socket.once("unexpected-response", (_request, response) => resolve(response.statusCode ?? 0)); + socket.once("error", () => resolve(0)); + }); + expect(rejected).toBe(401); + + const host = await open(`${ws}/api/sessions/${session.session_id}/ws`, { headers: { Authorization: `Bearer ${session.host_token}` } }); + const viewer = await open(`${ws}/api/sessions/${session.session_id}/ws`, { headers: { Origin: "http://127.0.0.1" } }); + cleanup.push(() => { host.terminate(); viewer.terminate(); }); + const envelope = Buffer.concat([Buffer.from([Opcode.Output, 1]), Buffer.alloc(28, 0xa5)]); + const received = message(viewer, (value) => Buffer.isBuffer(value) && value[0] === Opcode.Output); + host.send(envelope); + expect(await received).toEqual(envelope); + }); + + test("persists only hashed credentials and resumes an identity after restart", async () => { + const stateRoot = mkdtempSync(join(tmpdir(), "shell-online-state-")); + const stateFile = join(stateRoot, "relay.json"); + cleanup.push(() => rmSync(stateRoot, { recursive: true, force: true })); + const first = await start(stateFile); + const hostToken = "a".repeat(43); + const id = await persistentSessionID(hostToken); + const response = await fetch(`${first.base}/api/sessions/resume`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: id, host_token: hostToken, label: "persistent", read_only: false, encrypted: true }), + }); + expect(response.status).toBe(201); + const onDisk = readText(stateFile); + expect(onDisk).not.toContain(hostToken); + await cleanup.pop()?.(); + + const second = await start(stateFile); + const status = await fetch(`${second.base}/api/sessions/${id}`); + expect(status.status).toBe(200); + expect(await status.json()).toMatchObject({ exists: true, status: "disconnected", encrypted: true }); + }); + + test("rejects browser websocket connections from another origin", async () => { + const { base, ws } = await start(); + const session = await create(base); + const error = await new Promise((resolve) => { + const socket = new WebSocket(`${ws}/api/sessions/${session.session_id}/ws`, { headers: { Origin: "https://attacker.example" } }); + socket.once("unexpected-response", (_request, response) => resolve(new Error(String(response.statusCode)))); + socket.once("error", resolve); + }); + expect(error.message).toContain("403"); + }); +}); + +function readText(path: string): string { + return readFileSync(path, "utf8"); +} diff --git a/standalone/server.ts b/standalone/server.ts new file mode 100644 index 0000000..3c269b7 --- /dev/null +++ b/standalone/server.ts @@ -0,0 +1,693 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { createReadStream, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { dirname, extname, join, normalize, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { WebSocket, WebSocketServer, type RawData } from "ws"; + +import { Opcode, decodeResize } from "../shared/protocol"; +import { viewerFrameAction } from "../shared/session-access"; +import { viewerAdmission } from "../shared/session-capacity"; +import { disconnectedSessionExpiry, PERSISTENT_TTL_MS, SESSION_TTL_MS } from "../shared/session-lifetime"; +import { persistentSessionID } from "../shared/persistent-session"; +import { terminalGridForDevices } from "../shared/terminal-grid"; + +const SESSION_ID = /^[A-Za-z0-9_-]{32}$/; +const MAX_BODY = 4_096; +const MAX_LIVE_FRAME = 64 * 1024; +const MAX_INPUT_FRAME = 16 * 1024 + 1; +const MAX_SNAPSHOT = 512 * 1024; +const ENCRYPTION_OVERHEAD = 29; +const TRAFFIC_WINDOW_MS = 10_000; +const HOST_WINDOW_BYTES = 40 * 1024 * 1024; +const VIEWER_WINDOW_BYTES = 1024 * 1024; +const MAX_FRAMES_PER_WINDOW = 2_000; +const TYPING_LEASE_MS = 1_800; +const MAX_BUFFERED_BYTES = 2 * 1024 * 1024; + +type Status = "waiting" | "connected" | "disconnected" | "exited"; +type Role = "host" | "viewer"; + +interface SessionMeta { + id: string; + hostTokenHash: string; + readOnly: boolean; + encrypted: boolean; + persistent: boolean; + label: string; + createdAt: number; + expiresAt: number; + status: Status; + exitCode?: number; +} + +interface Attachment { + role: Role; + id: number; + guestNumber?: number; + colorIndex?: number; + typingAt?: number; + localTypingAt?: number; + device?: string; + portrait?: boolean; + supportsPortraitGrid?: boolean; + snapshotRequestedAt?: number; + terminalCols?: number; + terminalRows?: number; +} + +interface TrafficWindow { startedAt: number; bytes: number; frames: number } + +export interface StandaloneOptions { + host?: string; + port?: number; + publicUrl?: string; + webRoot?: string; + stateFile?: string; + trustProxy?: boolean; +} + +interface RuntimeConfig { + host: string; + port: number; + publicUrl: URL; + webRoot: string; + stateFile: string; + trustProxy: boolean; +} + +class FixedWindowLimiter { + private readonly entries = new Map(); + + constructor(private readonly limit: number, private readonly periodMs: number) {} + + allow(key: string): boolean { + const now = Date.now(); + const current = this.entries.get(key); + if (!current || now - current.startedAt >= this.periodMs) { + if (!current && this.entries.size >= 10_000) { + for (const [candidate, entry] of this.entries) { + if (now - entry.startedAt >= this.periodMs) this.entries.delete(candidate); + } + if (this.entries.size >= 10_000) return false; + } + this.entries.set(key, { startedAt: now, count: 1 }); + return true; + } + current.count += 1; + return current.count <= this.limit; + } +} + +class SessionStore { + readonly sessions = new Map(); + + constructor(private readonly stateFile: string) { + this.load(); + } + + private load(): void { + if (!existsSync(this.stateFile)) return; + let records: unknown; + try { + records = JSON.parse(readFileSync(this.stateFile, "utf8")); + } catch (error) { + throw new Error(`cannot read relay state ${this.stateFile}: ${String(error)}`); + } + if (!Array.isArray(records)) throw new Error(`invalid relay state ${this.stateFile}`); + const now = Date.now(); + for (const value of records) { + if (!validMeta(value) || value.expiresAt <= now) continue; + value.status = "disconnected"; + this.sessions.set(value.id, new SessionRelay(value, this)); + } + this.save(); + } + + save(): void { + mkdirSync(dirname(this.stateFile), { recursive: true }); + const temporary = `${this.stateFile}.${process.pid}.tmp`; + const records = [...this.sessions.values()].map((session) => session.meta); + writeFileSync(temporary, `${JSON.stringify(records)}\n`, { mode: 0o600 }); + renameSync(temporary, this.stateFile); + } + + add(meta: SessionMeta): SessionRelay { + const relay = new SessionRelay(meta, this); + this.sessions.set(meta.id, relay); + this.save(); + return relay; + } + + delete(id: string): void { + this.sessions.delete(id); + this.save(); + } + + sweep(): void { + const now = Date.now(); + for (const relay of [...this.sessions.values()]) { + if (relay.meta.expiresAt <= now && !relay.hasHost()) relay.expire(); + } + } +} + +class SessionRelay { + readonly viewers = new Map(); + host: WebSocket | undefined; + hostAttachment: Attachment | undefined; + private readonly traffic = new Map(); + + constructor(public readonly meta: SessionMeta, private readonly store: SessionStore) {} + + hasHost(): boolean { return this.host?.readyState === WebSocket.OPEN; } + + persist(): void { this.store.save(); } + + accept(socket: WebSocket, request: IncomingMessage, role: Role): void { + if (role === "host") { + if (this.host && this.host !== socket) close(this.host, 4001, "host reconnected"); + this.host = socket; + this.hostAttachment = { + role, + id: 0, + supportsPortraitGrid: request.headers["x-shell-terminal-grid"] === "80x40", + }; + this.meta.status = "connected"; + this.meta.expiresAt = Date.now() + (this.meta.persistent ? PERSISTENT_TTL_MS : SESSION_TTL_MS); + delete this.meta.exitCode; + this.persist(); + this.broadcastStatus(); + for (const viewer of this.viewers.values()) sendJSON(socket, { type: "snapshot_request", viewerId: viewer.id }); + this.broadcastGrid(); + this.bind(socket, this.hostAttachment); + return; + } + + const guestNumber = this.nextGuestNumber(); + const attachment: Attachment = { + role, + id: randomBytes(4).readUInt32BE(0), + guestNumber, + colorIndex: (guestNumber - 1) % 8, + device: mobileUserAgent(request.headers["user-agent"]) ? "mobile" : "desktop", + portrait: new URL(request.url ?? "/", "http://relay").searchParams.get("layout") === "portrait", + }; + this.viewers.set(socket, attachment); + sendJSON(socket, this.statusMessage()); + sendJSON(socket, { type: "welcome", viewerId: attachment.id, readOnly: this.meta.readOnly, encrypted: this.meta.encrypted }); + sendJSON(socket, { type: "resize_control", allowed: false }); + if (this.host) sendJSON(this.host, { type: "snapshot_request", viewerId: attachment.id }); + this.broadcastGrid(); + this.broadcastPresence(); + this.bind(socket, attachment); + } + + private bind(socket: WebSocket, attachment: Attachment): void { + socket.on("message", (data, binary) => this.onMessage(socket, attachment, data, binary)); + socket.once("close", () => this.onClose(socket, attachment)); + socket.once("error", () => this.onClose(socket, attachment)); + } + + private onMessage(socket: WebSocket, attachment: Attachment, raw: RawData, binary: boolean): void { + if (!binary) { + const value = raw.toString(); + if (value.length > 1_024) return close(socket, 4002, "unexpected text frame"); + this.handleText(socket, attachment, value); + return; + } + const frame = rawData(raw); + if (frame.length < 1) return close(socket, 4002, "empty frame"); + const limit = attachment.role === "host" ? HOST_WINDOW_BYTES : VIEWER_WINDOW_BYTES; + if (!this.allowTraffic(`${attachment.role}:${attachment.id}`, frame.length, limit)) { + return close(socket, 4008, "traffic limit exceeded"); + } + if (attachment.role === "host") this.handleHostFrame(socket, frame); + else this.handleViewerFrame(socket, attachment, frame); + } + + private handleText(socket: WebSocket, attachment: Attachment, value: string): void { + let event: Record; + try { event = JSON.parse(value) as Record; } + catch { return close(socket, 4002, "invalid control message"); } + if (attachment.role === "viewer") { + if (event.type === "viewer_layout" && typeof event.portrait === "boolean") { + if (attachment.portrait === event.portrait) return; + attachment.portrait = event.portrait; + this.broadcastGrid(); + return; + } + if (event.type === "snapshot_request") { + const now = Date.now(); + if (now - (attachment.snapshotRequestedAt ?? 0) < 1_000) return; + attachment.snapshotRequestedAt = now; + if (this.host) sendJSON(this.host, { type: "snapshot_request", viewerId: attachment.id }); + return; + } + if (event.type !== "typing") return close(socket, 4002, "unknown viewer control message"); + if (!this.meta.readOnly) this.claimInputLease(attachment); + return; + } + if (event.type === "local_typing") { + if (Date.now() - (attachment.localTypingAt ?? 0) >= 400) { + attachment.localTypingAt = Date.now(); + this.broadcastPresence(); + } + return; + } + if (event.type === "local_attached" && typeof event.attached === "boolean") return; + if (event.type !== "exit") return close(socket, 4002, "unknown control message"); + if (this.meta.persistent) { + this.meta.status = "disconnected"; + this.meta.expiresAt = Date.now() + PERSISTENT_TTL_MS; + this.persist(); + this.broadcastStatus(); + sendJSON(socket, { type: "exit_ack" }); + return close(socket, 4000, "task finished"); + } + const exitCode = Number(event.code); + this.meta.status = "exited"; + this.meta.exitCode = Number.isInteger(exitCode) && exitCode >= 0 && exitCode <= 255 ? exitCode : 1; + this.broadcastStatus(); + sendJSON(socket, { type: "exit_ack" }); + this.expire(4000, "task finished"); + } + + private handleHostFrame(socket: WebSocket, frame: Buffer): void { + switch (frame[0]) { + case Opcode.Output: + if (frame.length > MAX_LIVE_FRAME + 1 + (this.meta.encrypted ? ENCRYPTION_OVERHEAD : 0)) return close(socket, 4009, "output frame too large"); + return this.broadcastBinary(frame, "viewer"); + case Opcode.Snapshot: { + if (frame.length < 5 || frame.length > MAX_SNAPSHOT + 5 + (this.meta.encrypted ? ENCRYPTION_OVERHEAD : 0)) return close(socket, 4009, "snapshot frame too large"); + const targetId = frame.readUInt32BE(1); + const target = [...this.viewers.entries()].find(([, viewer]) => viewer.id === targetId)?.[0]; + if (target) { + const outbound = Buffer.concat([Buffer.from([Opcode.Snapshot]), frame.subarray(5)]); + send(target, outbound); + } + return; + } + case Opcode.FinalSnapshot: + case Opcode.BroadcastSnapshot: + if (frame.length > MAX_SNAPSHOT + 1 + (this.meta.encrypted ? ENCRYPTION_OVERHEAD : 0)) return close(socket, 4009, "snapshot frame too large"); + return this.broadcastBinary(frame, "viewer"); + case Opcode.Pong: + if (frame.length !== (this.meta.encrypted ? 34 : 5)) return close(socket, 4002, "invalid latency response"); + return this.broadcastBinary(frame, "viewer"); + default: + return close(socket, 4002, "host opcode not allowed"); + } + } + + private handleViewerFrame(socket: WebSocket, attachment: Attachment, frame: Buffer): void { + const action = viewerFrameAction(frame[0], this.meta.readOnly); + if (action === "blocked-input") return sendJSON(socket, { type: "access_denied", reason: "read_only" }); + if (action === "input") { + if (frame.length > MAX_INPUT_FRAME + (this.meta.encrypted ? ENCRYPTION_OVERHEAD : 0)) return close(socket, 4009, "input frame too large"); + if (!this.claimInputLease(attachment)) return; + this.broadcastGrid(); + return this.broadcastBinary(frame, "host"); + } + if (action === "confirmed-eof") { + if (frame.length !== (this.meta.encrypted ? 30 : 1)) return close(socket, 4002, "invalid confirmed EOF frame"); + if (!this.claimInputLease(attachment)) return; + this.broadcastGrid(); + return this.broadcastBinary(frame, "host"); + } + if (action === "resize") { + if (this.meta.encrypted) { + if (frame.length !== 34) close(socket, 4002, "invalid encrypted terminal size"); + return; + } + const size = decodeResize(frame); + if (!size || size.cols < 10 || size.cols > 500 || size.rows < 4 || size.rows > 300) close(socket, 4002, "invalid terminal size"); + return; + } + if (action === "ping") { + if (frame.length !== (this.meta.encrypted ? 34 : 5)) return close(socket, 4002, "invalid latency probe"); + return this.broadcastBinary(frame, "host"); + } + close(socket, 4002, "viewer opcode not allowed"); + } + + private onClose(socket: WebSocket, attachment: Attachment): void { + if (attachment.role === "viewer") { + if (!this.viewers.delete(socket)) return; + this.broadcastGrid(); + this.broadcastPresence(); + return; + } + if (this.host !== socket) return; + this.host = undefined; + this.hostAttachment = undefined; + if (this.meta.status === "exited") return; + this.meta.status = "disconnected"; + this.meta.expiresAt = disconnectedSessionExpiry(Date.now(), this.meta.persistent); + this.persist(); + this.broadcastStatus(); + } + + expire(code = 4004, reason = "session expired"): void { + if (this.host) close(this.host, code, reason); + for (const viewer of this.viewers.keys()) close(viewer, code, reason); + this.host = undefined; + this.viewers.clear(); + this.traffic.clear(); + this.store.delete(this.meta.id); + } + + statusMessage(): Record { + return { + type: "status", status: this.meta.status, label: this.meta.label, + readOnly: this.meta.readOnly, encrypted: this.meta.encrypted, persistent: this.meta.persistent, + exitCode: this.meta.exitCode, expiresAt: new Date(this.meta.expiresAt).toISOString(), + }; + } + + private broadcastStatus(): void { for (const socket of this.viewers.keys()) sendJSON(socket, this.statusMessage()); } + + private nextGuestNumber(): number { + const used = new Set([...this.viewers.values()].map((viewer) => viewer.guestNumber)); + for (let number = 1; number <= 16; number += 1) if (!used.has(number)) return number; + return 16; + } + + private claimInputLease(attachment: Attachment): boolean { + const now = Date.now(); + if (this.hostAttachment?.localTypingAt && now - this.hostAttachment.localTypingAt < TYPING_LEASE_MS) return false; + const active = [...this.viewers.values()] + .filter((viewer) => viewer.typingAt && now - viewer.typingAt < TYPING_LEASE_MS) + .sort((left, right) => (right.typingAt ?? 0) - (left.typingAt ?? 0))[0]; + if (active && active.id !== attachment.id) return false; + if (now - (attachment.typingAt ?? 0) >= 400) { + attachment.typingAt = now; + this.broadcastPresence(); + } + return true; + } + + private broadcastPresence(): void { + const viewers = [...this.viewers.values()].map((viewer) => ({ + id: viewer.id, name: `Guest ${viewer.guestNumber ?? 1}`, color: viewer.colorIndex ?? 0, typingAt: viewer.typingAt, + })).sort((left, right) => left.name.localeCompare(right.name)); + const message = { type: "presence", viewers, localTypingAt: this.hostAttachment?.localTypingAt }; + for (const socket of this.viewers.keys()) sendJSON(socket, message); + } + + private broadcastGrid(): void { + const devices = [...this.viewers.values()].map((viewer) => viewer.portrait ? "portrait" : viewer.device ?? "unknown"); + const grid = terminalGridForDevices(devices, this.hostAttachment?.supportsPortraitGrid === true); + const sockets: [WebSocket, Attachment][] = [...this.viewers.entries()]; + if (this.host && this.hostAttachment) sockets.push([this.host, this.hostAttachment]); + for (const [socket, attachment] of sockets) { + if (attachment.terminalCols === grid.cols && attachment.terminalRows === grid.rows) continue; + attachment.terminalCols = grid.cols; + attachment.terminalRows = grid.rows; + sendJSON(socket, { type: "terminal_size", ...grid }); + } + } + + private broadcastBinary(frame: Buffer, role: Role): void { + if (role === "host") { + if (this.host) send(this.host, frame); + return; + } + for (const viewer of this.viewers.keys()) send(viewer, frame); + } + + private allowTraffic(key: string, bytes: number, byteLimit: number): boolean { + const now = Date.now(); + let window = this.traffic.get(key); + if (!window || now - window.startedAt >= TRAFFIC_WINDOW_MS) { + window = { startedAt: now, bytes: 0, frames: 0 }; + this.traffic.set(key, window); + } + window.bytes += bytes; + window.frames += 1; + return window.bytes <= byteLimit && window.frames <= MAX_FRAMES_PER_WINDOW; + } +} + +export function createStandaloneServer(options: StandaloneOptions = {}): { server: Server; close: () => Promise; config: RuntimeConfig } { + const config = runtimeConfig(options); + const store = new SessionStore(config.stateFile); + const createLimiter = new FixedWindowLimiter(10, 60_000); + const connectLimiter = new FixedWindowLimiter(120, 60_000); + const websocketServer = new WebSocketServer({ noServer: true, maxPayload: MAX_SNAPSHOT + 64 }); + const server = createServer((request, response) => void route(request, response, config, store, createLimiter)); + + server.on("upgrade", (request, socket, head) => { + void (async () => { + const url = new URL(request.url ?? "/", config.publicUrl); + const match = url.pathname.match(/^\/api\/sessions\/([A-Za-z0-9_-]{32})\/ws$/); + if (!match) return rejectUpgrade(socket, 404, "Not Found"); + if (!connectLimiter.allow(clientIP(request, config.trustProxy))) return rejectUpgrade(socket, 429, "Too Many Requests"); + const relay = store.sessions.get(match[1]); + if (!relay) return rejectUpgrade(socket, 404, "Session not found"); + if (relay.meta.expiresAt <= Date.now() && !relay.hasHost()) { + relay.expire(); + return rejectUpgrade(socket, 410, "Session expired"); + } + const origin = request.headers.origin; + if (origin && safeOrigin(origin) !== config.publicUrl.origin) return rejectUpgrade(socket, 403, "Origin not allowed"); + const authorization = request.headers.authorization; + let role: Role = "viewer"; + if (authorization !== undefined) { + if (!authorization.startsWith("Bearer ") || !secureEqual(sha256(authorization.slice(7)), relay.meta.hostTokenHash)) { + return rejectUpgrade(socket, 401, "Invalid host token"); + } + role = "host"; + } + const admission = viewerAdmission(relay.viewers.size); + websocketServer.handleUpgrade(request, socket, head, (websocket) => { + if (role === "viewer" && !admission.accepted) return close(websocket, admission.closeCode, admission.reason); + relay.accept(websocket, request, role); + }); + })().catch(() => rejectUpgrade(socket, 500, "Internal Server Error")); + }); + + const sweep = setInterval(() => store.sweep(), 30_000); + sweep.unref(); + return { + server, + config, + close: async () => { + clearInterval(sweep); + const sockets = new Set(); + for (const relay of store.sessions.values()) { + if (relay.host) sockets.add(relay.host); + for (const viewer of relay.viewers.keys()) sockets.add(viewer); + } + for (const socket of sockets) close(socket, 1001, "server shutting down"); + if (sockets.size > 0) await new Promise((resolveWait) => setTimeout(resolveWait, 100)); + for (const socket of sockets) if (socket.readyState !== WebSocket.CLOSED) socket.terminate(); + await new Promise((resolveClose, reject) => server.close((error) => error ? reject(error) : resolveClose())); + }, + }; +} + +async function route(request: IncomingMessage, response: ServerResponse, config: RuntimeConfig, store: SessionStore, createLimiter: FixedWindowLimiter): Promise { + const url = new URL(request.url ?? "/", config.publicUrl); + if (url.pathname === "/api/health" && request.method === "GET") return json(response, 200, { ok: true, service: "shell.online-standalone" }); + if (url.pathname === "/api/sessions" && request.method === "POST") { + if (!createLimiter.allow(clientIP(request, config.trustProxy))) return json(response, 429, { error: "too many sessions created" }, { "Retry-After": "60" }); + let body: Record; + try { body = await readJSON(request, MAX_BODY); } + catch (error) { return json(response, error instanceof BodyTooLarge ? 413 : 400, { error: error instanceof BodyTooLarge ? "request too large" : "invalid request" }); } + if (typeof body.encrypted !== "boolean") return json(response, 400, { error: "encrypted must be a boolean" }); + if (body.read_only !== undefined && typeof body.read_only !== "boolean") return json(response, 400, { error: "read_only must be a boolean" }); + if (body.persistent !== undefined && typeof body.persistent !== "boolean") return json(response, 400, { error: "persistent must be a boolean" }); + if (body.persistent === true) return json(response, 400, { error: "persistent sessions require saved client credentials" }); + const id = token(24); + const hostToken = token(32); + const now = Date.now(); + const relay = store.add({ + id, hostTokenHash: sha256(hostToken), readOnly: body.read_only === true, + encrypted: body.encrypted, persistent: false, label: sanitizeLabel(body.label), + createdAt: now, expiresAt: now + SESSION_TTL_MS, status: "waiting", + }); + return sessionResponse(response, config.publicUrl, relay.meta, hostToken); + } + if (url.pathname === "/api/sessions/resume" && request.method === "POST") { + if (!createLimiter.allow(clientIP(request, config.trustProxy))) return json(response, 429, { error: "too many sessions resumed" }, { "Retry-After": "60" }); + let body: Record; + try { body = await readJSON(request, MAX_BODY); } catch { return json(response, 400, { error: "invalid session" }); } + if (typeof body.session_id !== "string" || !SESSION_ID.test(body.session_id) || typeof body.host_token !== "string" || + typeof body.read_only !== "boolean" || typeof body.encrypted !== "boolean") return json(response, 400, { error: "invalid persistent session" }); + if (!secureEqual(await persistentSessionID(body.host_token), body.session_id)) return json(response, 403, { error: "persistent credentials rejected" }); + let relay = store.sessions.get(body.session_id); + const hostTokenHash = sha256(body.host_token); + if (relay && (!secureEqual(relay.meta.hostTokenHash, hostTokenHash) || !relay.meta.persistent || relay.meta.readOnly !== body.read_only || relay.meta.encrypted !== body.encrypted)) { + return json(response, 403, { error: "persistent credentials rejected" }); + } + const now = Date.now(); + if (!relay) { + relay = store.add({ id: body.session_id, hostTokenHash, readOnly: body.read_only, encrypted: body.encrypted, + persistent: true, label: sanitizeLabel(body.label), createdAt: now, expiresAt: now + PERSISTENT_TTL_MS, status: "waiting" }); + } else { + relay.meta.label = sanitizeLabel(body.label); + relay.meta.expiresAt = now + PERSISTENT_TTL_MS; + if (relay.meta.status === "exited") relay.meta.status = "waiting"; + relay.persist(); + } + return sessionResponse(response, config.publicUrl, relay.meta, body.host_token); + } + const status = url.pathname.match(/^\/api\/sessions\/([A-Za-z0-9_-]{32})$/); + if (status && request.method === "GET") { + const relay = store.sessions.get(status[1]); + if (!relay || (relay.meta.expiresAt <= Date.now() && !relay.hasHost())) return json(response, 404, { exists: false }); + return json(response, 200, { exists: true, status: relay.meta.status, read_only: relay.meta.readOnly, encrypted: relay.meta.encrypted }); + } + if (url.pathname === "/api/events" && request.method === "POST") return noContent(response); + if (url.pathname === "/api/github" && request.method === "GET") return proxyJSON(response, "https://api.github.com/repos/TeoSlayer/shell.online", { stars: null, url: "https://github.com/TeoSlayer/shell.online" }, (value) => ({ stars: typeof value.stargazers_count === "number" ? value.stargazers_count : null, url: value.html_url })); + if (url.pathname === "/api/docs/releases" && request.method === "GET") return proxyJSON(response, "https://api.github.com/repos/TeoSlayer/shell.online/releases?per_page=50", { releases: [] }, (value) => ({ releases: Array.isArray(value) ? value.flatMap((release) => typeof release?.tag_name === "string" && /^v\d+\.\d+\.\d+$/.test(release.tag_name) ? [{ version: release.tag_name.slice(1), publishedAt: release.published_at ?? null }] : []) : [] })); + if (url.pathname === "/api/docs/content" && request.method === "GET") { + const version = url.searchParams.get("version"); + if (!version || !/^\d+\.\d+\.\d+$/.test(version)) return json(response, 404, { error: "documentation version not found" }); + return proxyRaw(response, `https://raw.githubusercontent.com/TeoSlayer/shell.online/v${version}/docs/content.json`); + } + if (url.pathname.startsWith("/api/")) return json(response, 404, { error: "not found" }); + serveAsset(request, response, config.webRoot, url.pathname); +} + +function runtimeConfig(options: StandaloneOptions): RuntimeConfig { + const publicUrl = new URL(options.publicUrl ?? process.env.SHELL_ONLINE_PUBLIC_URL ?? "http://localhost:8080"); + if (!/^https?:$/.test(publicUrl.protocol) || publicUrl.pathname !== "/" || publicUrl.search || publicUrl.hash) throw new Error("SHELL_ONLINE_PUBLIC_URL must be an http(s) origin without a path"); + return { + host: options.host ?? process.env.HOST ?? "0.0.0.0", + port: options.port ?? Number(process.env.PORT ?? "8080"), + publicUrl, + webRoot: resolve(options.webRoot ?? process.env.SHELL_ONLINE_WEB_ROOT ?? "dist"), + stateFile: resolve(options.stateFile ?? process.env.SHELL_ONLINE_STATE_FILE ?? "data/relay.json"), + trustProxy: options.trustProxy ?? process.env.SHELL_ONLINE_TRUST_PROXY === "1", + }; +} + +function sessionResponse(response: ServerResponse, origin: URL, meta: SessionMeta, hostToken: string): void { + const websocket = new URL(`/api/sessions/${meta.id}/ws`, origin); + websocket.protocol = origin.protocol === "https:" ? "wss:" : "ws:"; + json(response, 201, { session_id: meta.id, share_url: new URL(`/s/${meta.id}`, origin).href, websocket_url: websocket.href, + host_token: hostToken, read_only: meta.readOnly, encrypted: meta.encrypted, persistent: meta.persistent, + expires_at: new Date(meta.expiresAt).toISOString() }); +} + +function serveAsset(request: IncomingMessage, response: ServerResponse, root: string, pathname: string): void { + if (request.method !== "GET" && request.method !== "HEAD") return json(response, 405, { error: "method not allowed" }, { Allow: "GET, HEAD" }); + let decoded: string; + try { decoded = decodeURIComponent(pathname); } catch { return json(response, 400, { error: "invalid path" }); } + const special = decoded.startsWith("/docs/v") ? "/docs/" : decoded; + const relative = special === "/" || special.startsWith("/s/") || special.endsWith("/") ? `${special.replace(/^\//, "")}index.html` : special.replace(/^\//, ""); + let candidate = resolve(root, normalize(relative)); + if (!candidate.startsWith(`${root}/`) && candidate !== root) return json(response, 404, { error: "not found" }); + if (!existsSync(candidate) || !statSync(candidate).isFile()) candidate = join(root, "index.html"); + if (!existsSync(candidate)) return json(response, 503, { error: "web assets are not built" }); + const headers = securityHeaders(); + headers["Content-Type"] = contentType(candidate); + headers["Content-Length"] = String(statSync(candidate).size); + response.writeHead(200, headers); + if (request.method === "HEAD") { + response.end(); + return; + } + createReadStream(candidate).pipe(response); +} + +function securityHeaders(): Record { + return { + "Cache-Control": "no-store", "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss: ws:; img-src 'self' data:; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'", + "Cross-Origin-Opener-Policy": "same-origin", "Permissions-Policy": "camera=(), microphone=(), geolocation=()", + "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", + }; +} + +function json(response: ServerResponse, status: number, body: unknown, extra: Record = {}): void { + const value = JSON.stringify(body); + response.writeHead(status, { ...securityHeaders(), ...extra, "Content-Type": "application/json; charset=utf-8", "Content-Length": String(Buffer.byteLength(value)) }); + response.end(value); +} + +function noContent(response: ServerResponse): void { response.writeHead(204, securityHeaders()); response.end(); } + +async function proxyJSON(response: ServerResponse, url: string, fallback: unknown, project: (value: any) => unknown): Promise { + try { + const upstream = await fetch(url, { headers: { Accept: "application/vnd.github+json", "User-Agent": "shell.online-standalone" }, signal: AbortSignal.timeout(5_000) }); + if (!upstream.ok) throw new Error("upstream failure"); + json(response, 200, project(await upstream.json())); + } catch { json(response, 200, fallback); } +} + +async function proxyRaw(response: ServerResponse, url: string): Promise { + try { + const upstream = await fetch(url, { signal: AbortSignal.timeout(5_000) }); + if (!upstream.ok) return json(response, 404, { error: "documentation version not found" }); + const body = await upstream.text(); + if (body.length > 128 * 1024) return json(response, 502, { error: "documentation is too large" }); + response.writeHead(200, { ...securityHeaders(), "Content-Type": "application/json; charset=utf-8" }); response.end(body); + } catch { json(response, 502, { error: "documentation version unavailable" }); } +} + +async function readJSON(request: IncomingMessage, maximum: number): Promise> { + const chunks: Buffer[] = []; + let length = 0; + for await (const chunk of request) { + const buffer = Buffer.from(chunk); + length += buffer.length; + if (length > maximum) throw new BodyTooLarge(); + chunks.push(buffer); + } + const value = JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown; + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("invalid JSON object"); + return value as Record; +} + +class BodyTooLarge extends Error {} + +function token(bytes: number): string { return randomBytes(bytes).toString("base64url"); } +function sha256(value: string): string { return createHash("sha256").update(value).digest("hex"); } +function secureEqual(left: string, right: string): boolean { + const a = Buffer.from(left); const b = Buffer.from(right); + return a.length === b.length && timingSafeEqual(a, b); +} +function sanitizeLabel(value: unknown): string { + if (typeof value !== "string") return "terminal"; + return value.replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, 80) || "terminal"; +} +function validMeta(value: unknown): value is SessionMeta { + if (typeof value !== "object" || value === null) return false; + const meta = value as Partial; + return typeof meta.id === "string" && SESSION_ID.test(meta.id) && typeof meta.hostTokenHash === "string" && /^[a-f0-9]{64}$/.test(meta.hostTokenHash) && + typeof meta.readOnly === "boolean" && typeof meta.encrypted === "boolean" && typeof meta.persistent === "boolean" && typeof meta.label === "string" && + typeof meta.createdAt === "number" && typeof meta.expiresAt === "number" && ["waiting", "connected", "disconnected", "exited"].includes(meta.status ?? ""); +} +function rawData(value: RawData): Buffer { return Array.isArray(value) ? Buffer.concat(value) : Buffer.from(value as ArrayBuffer); } +function send(socket: WebSocket, value: Buffer | string): void { + if (socket.readyState !== WebSocket.OPEN) return; + if (socket.bufferedAmount > MAX_BUFFERED_BYTES) return close(socket, 4008, "slow connection"); + socket.send(value, { binary: typeof value !== "string" }); +} +function sendJSON(socket: WebSocket, value: unknown): void { send(socket, JSON.stringify(value)); } +function close(socket: WebSocket, code: number, reason: string): void { if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) socket.close(code, reason); } +function safeOrigin(value: string): string { try { return new URL(value).origin; } catch { return ""; } } +function mobileUserAgent(value: string | undefined): boolean { return /Android|iPhone|iPad|iPod|Mobile/i.test(value ?? ""); } +function clientIP(request: IncomingMessage, trustProxy: boolean): string { + if (trustProxy) return String(request.headers["x-forwarded-for"] ?? "").split(",")[0].trim() || request.socket.remoteAddress || "unknown"; + return request.socket.remoteAddress || "unknown"; +} +function rejectUpgrade(socket: import("node:stream").Duplex, status: number, reason: string): void { + if (socket.destroyed) return; + socket.end(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`); +} +function contentType(path: string): string { + return ({ ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".woff2": "font/woff2", ".txt": "text/plain; charset=utf-8", ".webmanifest": "application/manifest+json" } as Record)[extname(path)] ?? "application/octet-stream"; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const runtime = createStandaloneServer(); + runtime.server.listen(runtime.config.port, runtime.config.host, () => { + process.stdout.write(`shell.online standalone relay listening on ${runtime.config.host}:${runtime.config.port} (${runtime.config.publicUrl.origin})\n`); + }); + const shutdown = () => void runtime.close().finally(() => process.exit(0)); + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); +} diff --git a/tsconfig.standalone.json b/tsconfig.standalone.json new file mode 100644 index 0000000..ef7a6dc --- /dev/null +++ b/tsconfig.standalone.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "types": ["node", "ws"], + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["standalone/**/*.ts", "shared/**/*.ts"] +} diff --git a/web/documentation.ts b/web/documentation.ts index d418bd6..a05e001 100644 --- a/web/documentation.ts +++ b/web/documentation.ts @@ -134,6 +134,9 @@ function documentationCommand(kind: DocumentationKind): string { $ SHELL_ONLINE_E2EE_PASSWORD='…' shell <command>`; if (kind === "docker") return `
$ docker compose up --build -d
 $ docker compose logs shell-online
`; + if (kind === "self-hosting") return `
$ cd standalone
+$ SHELL_ONLINE_PUBLIC_URL=https://relay.example.com \\
+  SHELL_ONLINE_SITE=relay.example.com docker compose up -d --build
`; if (kind === "platforms") return `
$ shell ros2 launch <package> <launch-file>
 PS> irm https://shell.online/install.ps1 | iex
`; if (kind === "cli") return `
$ shell help reference
diff --git a/worker/index.ts b/worker/index.ts
index 36c8333..e082fe0 100644
--- a/worker/index.ts
+++ b/worker/index.ts
@@ -524,6 +524,7 @@ function recordAssetAnalytics(
     ["/security/", "docs_security"],
     ["/e2ee/", "docs_e2ee"],
     ["/docker/", "docs_docker"],
+    ["/self-hosting/", "docs_self_hosting"],
   ]).get(url.pathname);
   const target = documentTarget ?? (
     SESSION_ID_PATTERN.test(url.pathname.replace(/^\/s\//, "").replace(/\/$/, ""))
@@ -1643,7 +1644,7 @@ function secureAssetResponse(response: Response, pathname: string, hostname: str
 }
 
 function isPublicDocumentPath(pathname: string): boolean {
-  return pathname === "/" || pathname === "/docs/" || pathname === "/mobile/" || pathname === "/reliability/" || pathname === "/security/" || pathname === "/e2ee/" || pathname === "/docker/";
+  return pathname === "/" || pathname === "/docs/" || pathname === "/mobile/" || pathname === "/reliability/" || pathname === "/security/" || pathname === "/e2ee/" || pathname === "/docker/" || pathname === "/self-hosting/";
 }
 
 function secureStatsResponse(response: Response): Response {
diff --git a/wrangler.example.jsonc b/wrangler.example.jsonc
index b41a67f..4a29ea6 100644
--- a/wrangler.example.jsonc
+++ b/wrangler.example.jsonc
@@ -23,6 +23,7 @@
       "/security/*",
       "/e2ee/*",
       "/docker/*",
+      "/self-hosting/*",
       "/"
     ]
   },

From c14efbfd99466930273f53f3fe89100158b3f83a Mon Sep 17 00:00:00 2001
From: Teodor Calin 
Date: Sat, 12 Sep 2026 17:10:20 +0300
Subject: [PATCH 08/12] Reject unsafe local terminal dimensions

---
 cmd/shell/persistent.go         |  6 +++---
 cmd/shell/process_unix.go       |  4 ++++
 cmd/shell/session_unix_test.go  | 15 +++++++++++++++
 cmd/shell/terminal_wait_unix.go |  4 ++++
 4 files changed, 26 insertions(+), 3 deletions(-)

diff --git a/cmd/shell/persistent.go b/cmd/shell/persistent.go
index 18b47ed..be06675 100644
--- a/cmd/shell/persistent.go
+++ b/cmd/shell/persistent.go
@@ -167,15 +167,15 @@ func writePersistentState(path string, state persistentSessionState) error {
 	temporaryPath := temporary.Name()
 	defer os.Remove(temporaryPath)
 	if err := temporary.Chmod(0o600); err != nil {
-		temporary.Close()
+		_ = temporary.Close()
 		return err
 	}
 	if err := json.NewEncoder(temporary).Encode(state); err != nil {
-		temporary.Close()
+		_ = temporary.Close()
 		return err
 	}
 	if err := temporary.Sync(); err != nil {
-		temporary.Close()
+		_ = temporary.Close()
 		return err
 	}
 	if err := temporary.Close(); err != nil {
diff --git a/cmd/shell/process_unix.go b/cmd/shell/process_unix.go
index df445c7..e8e6e70 100644
--- a/cmd/shell/process_unix.go
+++ b/cmd/shell/process_unix.go
@@ -3,6 +3,7 @@
 package main
 
 import (
+	"fmt"
 	"io"
 	"os"
 	"os/exec"
@@ -37,6 +38,9 @@ func (process *unixTerminalProcess) Wait() error          { return process.comma
 func (process *unixTerminalProcess) Process() *os.Process { return process.command.Process }
 func (process *unixTerminalProcess) Finish() error        { return nil }
 func (process *unixTerminalProcess) Resize(cols, rows int) error {
+	if cols < 1 || cols > 65_535 || rows < 1 || rows > 65_535 {
+		return fmt.Errorf("terminal size %dx%d is outside the PTY range", cols, rows)
+	}
 	return pty.Setsize(process.terminal, &pty.Winsize{Cols: uint16(cols), Rows: uint16(rows)})
 }
 
diff --git a/cmd/shell/session_unix_test.go b/cmd/shell/session_unix_test.go
index 63cd77c..b6382de 100644
--- a/cmd/shell/session_unix_test.go
+++ b/cmd/shell/session_unix_test.go
@@ -177,6 +177,21 @@ func TestTerminalProcessRoundTripsInputAndResize(t *testing.T) {
 	}
 }
 
+func TestTerminalProcessRejectsDimensionsThatWouldWrap(t *testing.T) {
+	process := &unixTerminalProcess{}
+	for _, size := range [][2]int{{0, 24}, {80, 0}, {65_536, 24}, {80, 65_536}} {
+		if err := process.Resize(size[0], size[1]); err == nil {
+			t.Fatalf("Resize(%d, %d) accepted a size outside uint16", size[0], size[1])
+		}
+	}
+}
+
+func TestWaitForTerminalInputRejectsInvalidDescriptor(t *testing.T) {
+	if _, err := waitForTerminalInput(-1, time.Millisecond); err == nil {
+		t.Fatal("negative file descriptor was accepted")
+	}
+}
+
 type capturedInput struct {
 	values chan []byte
 }
diff --git a/cmd/shell/terminal_wait_unix.go b/cmd/shell/terminal_wait_unix.go
index eaf2711..78d8bcb 100644
--- a/cmd/shell/terminal_wait_unix.go
+++ b/cmd/shell/terminal_wait_unix.go
@@ -4,12 +4,16 @@ package main
 
 import (
 	"errors"
+	"fmt"
 	"time"
 
 	"golang.org/x/sys/unix"
 )
 
 func waitForTerminalInput(fd int, timeout time.Duration) (bool, error) {
+	if fd < 0 || int64(fd) > int64(2_147_483_647) {
+		return false, fmt.Errorf("terminal file descriptor %d is outside the poll range", fd)
+	}
 	milliseconds := max(1, int((timeout+time.Millisecond-1)/time.Millisecond))
 	descriptors := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
 	for {

From 8f6bc7e07bee3bb80c15ef233a5a264553048e77 Mon Sep 17 00:00:00 2001
From: Teodor Calin 
Date: Sat, 12 Sep 2026 17:14:46 +0300
Subject: [PATCH 09/12] Test Worker browser security policy

---
 app/worker/index.test.ts | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/app/worker/index.test.ts b/app/worker/index.test.ts
index d1d7241..838aede 100644
--- a/app/worker/index.test.ts
+++ b/app/worker/index.test.ts
@@ -35,5 +35,7 @@ describe("Cloudflare app assets", () => {
     expect(response.headers.get("X-Frame-Options")).toBe("DENY");
     expect(response.headers.get("Referrer-Policy")).toBe("no-referrer");
     expect(response.headers.get("Permissions-Policy")).toBe("camera=(), microphone=(), geolocation=()");
+    expect(response.headers.get("Content-Security-Policy")).toContain("script-src 'self'");
+    expect(response.headers.get("Content-Security-Policy")).toContain("frame-ancestors 'none'");
   });
 });

From f733ea5b2a36ad078775c0b6c8397373e4a54c73 Mon Sep 17 00:00:00 2001
From: Teodor Calin 
Date: Sat, 12 Sep 2026 17:15:03 +0300
Subject: [PATCH 10/12] Secure session password recovery and rotation

---
 .github/SECURITY.md                      |   6 +-
 CHANGELOG.md                             |  16 +++
 README.md                                |   6 +
 app/server/app.test.ts                   |  34 +++++
 app/server/app.ts                        |  27 ++++
 app/server/lib/store-conformance.test.ts |  41 +++++-
 app/server/lib/store-memory.ts           |  20 +++
 app/server/lib/store-postgres.ts         |  77 +++++++++++-
 app/server/lib/store.ts                  |   8 ++
 app/src/components/SessionAudience.tsx   |   8 +-
 app/src/components/SessionClipboard.tsx  |   8 +-
 app/src/lib/session-passwords.test.ts    | Bin 11912 -> 12791 bytes
 app/src/lib/session-passwords.ts         | Bin 7798 -> 9588 bytes
 app/src/routes/Workspace.tsx             |  39 ++++--
 app/src/terminal/TerminalPane.tsx        |  15 +--
 app/src/vault/share-with.ts              |  13 +-
 cmd/shell/help.go                        |  53 +++++---
 cmd/shell/main.go                        |  51 +++++---
 cmd/shell/session_link.go                |  14 ++-
 cmd/shell/session_unix.go                | 154 +++++++++++++++++++----
 cmd/shell/session_unix_test.go           |  76 +++++++++++
 cmd/shell/sessions.go                    | 140 +++++++++++++++++++--
 cmd/shell/sessions_test.go               |  21 +++-
 cmd/shell/sessions_unix.go               |  63 +++++++++-
 docs/content.json                        |  26 +++-
 internal/account/client.go               |   3 +
 public/skill/shell-online/SKILL.md       |   2 +
 worker/index.ts                          |  23 +++-
 28 files changed, 825 insertions(+), 119 deletions(-)

diff --git a/.github/SECURITY.md b/.github/SECURITY.md
index c08a213..f0a0c88 100644
--- a/.github/SECURITY.md
+++ b/.github/SECURITY.md
@@ -20,8 +20,10 @@ You should receive an acknowledgement within three business days. We will valida
 - Session passwords are kept in a per-account session vault. The accounts service stores each password only sealed to the account's vault public key (ephemeral ECDH P-256, HKDF-SHA256, AES-256-GCM bound to the session id and recipient). The matching private key is stored encrypted under a random vault key, and the vault key is wrapped only by a 160-bit recovery key the service never receives. Any way for the accounts service, the relay, or a copy of the database to open a sealed password, the private key, or the vault key is in scope, as is substituting a vault public key without the browser or the CLI refusing it. Two points are trust-on-first-use by design: the first key seen for a colleague, and the account key a machine linked before the vault existed learns on its next session. The web app served by shell.online performs the unlock and is trusted to, as it is trusted with a typed session password.
 - Input typed into a session from the browser is recorded in the team's audit log and encrypted in the browser to the team's audit public key (ephemeral ECDH P-256, HKDF-SHA256, AES-256-GCM bound to the organization, session, entry kind, time and author). The matching private key reaches each member sealed to their session vault by a teammate's own vault key, and the service stores only the public key, the sealed copies and ciphertext. Any way for the accounts service, the relay, or a copy of the database to read audit input text or the team audit key is in scope, as is the service substituting a team key that members then encrypt to. Metadata stays readable by the service by design: who acted, in which session, the entry kind and time, and the lifecycle entries the service writes itself. Trust-on-first-use applies to a teammate's first-seen vault key, and the web app served by shell.online performs the encryption. Entries recorded before encryption existed may remain readable until an owner's or admin's browser encrypts them in place, and in database backups taken before then until those expire.
 - The CLI generates an eight-character base64url password with 48 bits of entropy when no password is supplied. This is an explicit convenience/security tradeoff for task-bound shares, not a claim of passphrase-strength protection. `SHELL_ONLINE_E2EE_PASSWORD` accepts a longer unique password for sensitive or long-lived sessions; recipients should receive the URL and password through separate channels when appropriate.
-- Persistent state files and Docker state volumes intentionally contain the host credential, browser password, and E2EE key material. Files created by shell.online must be owner-only. A saved password cannot be changed in place because the stable URL and key are bound to it; password rotation creates new state and a new URL. Disclosure caused by publishing, broadly mounting, or backing up that state outside shell.online is not a product vulnerability.
-- Active-session records in the per-user local control directory intentionally retain the browser password so `shell list` can reconstruct usable access. The directory and records must remain owner-only and are deleted when their processes close.
+- Persistent state files and Docker state volumes intentionally contain the host credential, browser password, and E2EE key material. Files created by shell.online must be owner-only. Live rotation replaces the salt, password, and key in that state before the host changes ciphers; the stable session path remains the same. Disclosure caused by publishing, broadly mounting, or backing up that state outside shell.online is not a product vulnerability.
+- Active-session records in the per-user local control directory intentionally retain the browser password so `shell password ` can recover it. Human `shell list` output does not print every password; `--json` deliberately includes them for agents. The directory and records must remain owner-only and are deleted when their processes close.
+- `shell password rotate ` changes only future access: it cannot erase output a viewer already received. The host rejects old-key input as soon as it swaps ciphers, the relay disconnects current viewers without receiving either credential, and the account registry atomically replaces sealed copies from the previous generation. Old URL/password pairs may still connect to an anonymous relay socket but cannot authenticate later encrypted frames.
+- Removing a team member deletes every password copy still sealed to that account, but cannot make them forget a password or terminal output they already received. Rotate each active session that person could open when immediate revocation matters.
 - Reports about leaked links are actionable when shell.online itself disclosed or made them predictable; links forwarded or published by their owner are not a product vulnerability.
 - Availability reports should demonstrate a way to bypass the configured rate, frame-size, audience, or lifetime limits.
 - The statistics dashboard is private and password-protected. Do not test it with credential stuffing or high-volume traffic.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ddb2b97..711d736 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,9 @@ All notable user-visible changes are recorded here. Versions follow [Semantic Ve
   Cloudflare account or credentials.
 - A versioned self-hosting documentation page and a release image at
   `ghcr.io/teoslayer/shell.online-relay` for amd64 and arm64.
+- `shell password ` retrieves an active session password from the local
+  owner-only record. `shell password rotate ` changes credentials without
+  restarting the process and persists the new generation for stable sessions.
 
 ### Changed
 
@@ -22,6 +25,19 @@ All notable user-visible changes are recorded here. Versions follow [Semantic Ve
 - Keep `--auto-close today` valid throughout the final second of the local
   day, rather than expiring at the instant that second begins.
 
+### Security
+
+- Password rotation switches the host cipher before disconnecting existing
+  viewers, atomically replaces the owner's sealed account-vault copy, and
+  removes stale teammate copies. The relay receives neither old nor new
+  plaintext credentials.
+- A verified browser cache can no longer overwrite a newer vault generation.
+  Vault credentials are tried first and replace stale local cache entries only
+  after successfully opening a live encrypted frame.
+- Removing a team member now deletes every session-password copy sealed to
+  that account. Owners must still rotate active sessions to revoke passwords a
+  former member may already have seen.
+
 ## [0.12.1] — 2026-09-12
 
 ### Added
diff --git a/README.md b/README.md
index ddab984..da76fd7 100644
--- a/README.md
+++ b/README.md
@@ -69,6 +69,8 @@ shell --auto-close 5m            # set an earlier deadline
 shell --persistent         # reuse a URL and password
 
 shell list                                # list local sessions (adapts to terminal width)
+shell password                        # retrieve an active password locally
+shell password rotate                 # revoke it without restarting the process
 shell attach                          # attach locally
 shell kill                            # stop a session
 ```
@@ -88,6 +90,10 @@ type with the permissions of the wrapped process; use `--read-only` when viewers
 should only watch. See the [security model](https://shell.online/security/) and
 [the security policy](.github/SECURITY.md).
 
+Active passwords remain recoverable on their owner machine; account-linked
+passwords are also sealed into the user's E2EE vault. Without either owner-held
+copy there is intentionally no service-side recovery key.
+
 ## Accounts and containers
 
 Accounts are optional. `shell login` groups sessions from linked machines in
diff --git a/app/server/app.test.ts b/app/server/app.test.ts
index 134821e..cb12107 100644
--- a/app/server/app.test.ts
+++ b/app/server/app.test.ts
@@ -2086,6 +2086,40 @@ describe("session vault", () => {
     expect(listed.body.sessions[0].keyShare).toMatchObject(share);
   });
 
+  it("replaces stale vault copies when the CLI rotates an active password", async () => {
+    const tokens = await login();
+    const invite = await call("POST", "/api/org/invites", { auth: await idToken(), body: { role: "member" } });
+    const colleague = await idToken({ sub: "uid-2", email: "colleague@example.com" });
+    await call("GET", `/api/org?invite=${invite.body.invite.id}`, { auth: colleague });
+    const { made, body } = await vaultBody();
+    await call("POST", "/api/vault", { auth: await idToken(), body });
+    const oldShare = await sealToAccount(made.bundle.publicKey, session.id, "uid-1", "old-pass");
+    await call("POST", "/api/sessions", {
+      auth: tokens.access_token,
+      body: { ...session, owner_share: { sender_public_key: oldShare.senderPublicKey, sealed: oldShare.sealed } },
+    });
+    await call("PUT", `/api/sessions/${session.id}/keys`, {
+      auth: await idToken(),
+      body: { shares: [{ uid: "uid-2", sender_public_key: "old-sender", sealed: "old-copy" }] },
+    });
+
+    const freshShare = await sealToAccount(made.bundle.publicKey, session.id, "uid-1", "new-pass");
+    const rotated = await call("POST", "/api/sessions", {
+      auth: tokens.access_token,
+      body: {
+        ...session,
+        share_url: `${session.share_url.slice(0, -22)}BBBBBBBBBBBBBBBBBBBBBB`,
+        credential_rotation: true,
+        owner_share: { sender_public_key: freshShare.senderPublicKey, sealed: freshShare.sealed },
+      },
+    });
+    expect(rotated.status).toBe(201);
+    const owner = await call("GET", "/api/sessions", { auth: await idToken() });
+    expect(owner.body.sessions[0].keyShare).toMatchObject(freshShare);
+    const other = await call("GET", "/api/sessions", { auth: colleague });
+    expect(other.body.sessions[0].keyShare).toBeUndefined();
+  });
+
   it("still registers a session whose sealed copy is not shaped like one", async () => {
     const tokens = await login();
     const registered = await call("POST", "/api/sessions", {
diff --git a/app/server/app.ts b/app/server/app.ts
index 8fb6cba..97dcf2c 100644
--- a/app/server/app.ts
+++ b/app/server/app.ts
@@ -940,6 +940,33 @@ export function createApp(options: AppOptions) {
         if (!token) return send(response, 401, { error: "not signed in" });
         const body = (await readBody(request)) as Record;
         const membership = await store.membershipOf(token.uid);
+        const rotation = body.credential_rotation === true;
+        const previous = rotation && membership
+          ? await store.sessionInOrg(membership.orgId, String(body.id ?? ""))
+          : null;
+        if (rotation && (!previous || (previous.ownerUid ?? previous.uid) !== token.uid)) {
+          return send(response, 404, { error: "no such owned session to rotate" });
+        }
+        if (rotation) {
+          const nextURL = String(body.share_url ?? "");
+          if (
+            !previous?.encrypted || body.encrypted !== true ||
+            nextURL === previous.shareUrl || !/#salt=[A-Za-z0-9_-]{22}$/.test(nextURL)
+          ) {
+            return send(response, 400, { error: "invalid credential rotation" });
+          }
+          const ownerShare = await readOwnerShare(body.owner_share);
+          const shares = ownerShare ? [{ uid: token.uid, ...ownerShare }] : [];
+          const rotated = await store.rotateSessionCredentials(
+            membership!.orgId,
+            previous!.id,
+            token.uid,
+            nextURL,
+            shares,
+          );
+          if (!rotated) return send(response, 404, { error: "no such owned session to rotate" });
+          return send(response, 201, { session: sessionForApi(rotated) });
+        }
         const result = await registerSession(store, token.uid, {
           id: String(body.id ?? ""),
           shareUrl: String(body.share_url ?? ""),
diff --git a/app/server/lib/store-conformance.test.ts b/app/server/lib/store-conformance.test.ts
index 4e93e21..918ec33 100644
--- a/app/server/lib/store-conformance.test.ts
+++ b/app/server/lib/store-conformance.test.ts
@@ -425,6 +425,32 @@ for (const implementation of implementations) {
         expect(shares.find((share) => share.uid === "uid-1")?.sealed).toBe("one");
       });
 
+      it("rotates the public salt and sealed copies as one owner-scoped generation", async () => {
+        await store.upsertSession(session({ shareUrl: "https://shell.online/s/s1#salt=old" }));
+        await store.putKeyShares("org_1", "s1", [
+          { uid: "uid-1", senderPublicKey: "pk1", sealed: "old-owner" },
+          { uid: "uid-2", senderPublicKey: "pk1", sealed: "old-member" },
+        ]);
+        expect(await store.rotateSessionCredentials(
+          "org_1",
+          "s1",
+          "uid-2",
+          "https://shell.online/s/s1#salt=forbidden",
+          [],
+        )).toBeNull();
+        const rotated = await store.rotateSessionCredentials(
+          "org_1",
+          "s1",
+          "uid-1",
+          "https://shell.online/s/s1#salt=new",
+          [{ uid: "uid-1", senderPublicKey: "pk2", sealed: "new-owner" }],
+        );
+        expect(rotated?.shareUrl).toBe("https://shell.online/s/s1#salt=new");
+        expect(rotated?.keyShares).toEqual([
+          { uid: "uid-1", senderPublicKey: "pk2", sealed: "new-owner" },
+        ]);
+      });
+
       it("removes a session's row and reports whether there was one", async () => {
         await store.upsertSession(session());
         expect(await store.deleteSession("org_1", "s1")).toBe(true);
@@ -655,11 +681,20 @@ for (const implementation of implementations) {
 
       it("changes a role and removes a member", async () => {
         await store.putMembership(membership());
+        await store.putMembership(membership({ uid: "uid-2", role: "member" }));
+        await store.upsertSession(session());
+        await store.putKeyShares("org_1", "s1", [
+          { uid: "uid-1", senderPublicKey: "owner-key", sealed: "for-owner" },
+          { uid: "uid-2", senderPublicKey: "owner-key", sealed: "for-member" },
+        ]);
         expect(await store.setRole("org_1", "uid-1", "admin")).toBe(true);
         expect((await store.membershipOf("uid-1"))?.role).toBe("admin");
-        expect(await store.removeMember("org_1", "uid-1")).toBe(true);
-        expect(await store.removeMember("org_1", "uid-1")).toBe(false);
-        expect(await store.membershipOf("uid-1")).toBeNull();
+        expect(await store.removeMember("org_1", "uid-2")).toBe(true);
+        expect(await store.removeMember("org_1", "uid-2")).toBe(false);
+        expect(await store.membershipOf("uid-2")).toBeNull();
+        expect((await store.sessionInOrg("org_1", "s1"))?.keyShares).toEqual([
+          { uid: "uid-1", senderPublicKey: "owner-key", sealed: "for-owner" },
+        ]);
       });
     });
 
diff --git a/app/server/lib/store-memory.ts b/app/server/lib/store-memory.ts
index c704521..1ce90c1 100644
--- a/app/server/lib/store-memory.ts
+++ b/app/server/lib/store-memory.ts
@@ -252,6 +252,21 @@ export class MemoryStore implements Store {
     return true;
   }
 
+  async rotateSessionCredentials(
+    orgId: string,
+    sessionId: string,
+    ownerUid: string,
+    shareUrl: string,
+    shares: SessionKeyShare[],
+  ): Promise {
+    const session = await this.sessionInOrg(orgId, sessionId);
+    if (!session || (session.ownerUid ?? session.uid) !== ownerUid || !session.encrypted) return null;
+    session.shareUrl = shareUrl;
+    session.keyShares = [...shares];
+    this.flush();
+    return session;
+  }
+
   async accountKey(uid: string): Promise {
     const found = this.data.accountKeys.find((entry) => entry.uid === uid);
     return found ? { ...found } : null;
@@ -573,6 +588,11 @@ export class MemoryStore implements Store {
       (entry) => !(entry.orgId === orgId && entry.uid === uid),
     );
     if (this.data.memberships.length === before) return false;
+    for (const session of this.data.sessions) {
+      if (session.orgId === orgId && session.keyShares) {
+        session.keyShares = session.keyShares.filter((share) => share.uid !== uid);
+      }
+    }
     this.flush();
     return true;
   }
diff --git a/app/server/lib/store-postgres.ts b/app/server/lib/store-postgres.ts
index 4229830..ffe32aa 100644
--- a/app/server/lib/store-postgres.ts
+++ b/app/server/lib/store-postgres.ts
@@ -857,6 +857,54 @@ export class PostgresStore implements Store {
     return true;
   }
 
+  async rotateSessionCredentials(
+    orgId: string,
+    sessionId: string,
+    ownerUid: string,
+    shareUrl: string,
+    shares: SessionKeyShare[],
+  ): Promise {
+    const client = await this.pool.connect();
+    try {
+      await client.query("BEGIN");
+      const found = await client.query(
+        `SELECT * FROM sessions
+         WHERE org_id = $1 AND id = $2 AND COALESCE(owner_uid, uid) = $3 AND encrypted = true
+         FOR UPDATE`,
+        [orgId, sessionId, ownerUid],
+      );
+      const row = found.rows[0];
+      if (!row) {
+        await client.query("ROLLBACK");
+        return null;
+      }
+      const sessionUid = row.uid as string;
+      await client.query(
+        "UPDATE sessions SET share_url = $4 WHERE org_id = $1 AND id = $2 AND uid = $3",
+        [orgId, sessionId, sessionUid, shareUrl],
+      );
+      await client.query(
+        "DELETE FROM session_key_shares WHERE session_uid = $1 AND session_id = $2",
+        [sessionUid, sessionId],
+      );
+      for (const share of shares) {
+        await client.query(
+          `INSERT INTO session_key_shares
+             (session_uid, session_id, uid, sender_public_key, sealed)
+           VALUES ($1, $2, $3, $4, $5)`,
+          [sessionUid, sessionId, share.uid, share.senderPublicKey, share.sealed],
+        );
+      }
+      await client.query("COMMIT");
+      return toSession({ ...row, share_url: shareUrl }, shares);
+    } catch (error) {
+      await client.query("ROLLBACK");
+      throw error;
+    } finally {
+      client.release();
+    }
+  }
+
   /* ---- Session vault ---- */
 
   async accountKey(uid: string): Promise {
@@ -1159,11 +1207,30 @@ export class PostgresStore implements Store {
   }
 
   async removeMember(orgId: string, uid: string): Promise {
-    const result = await this.pool.query(
-      "DELETE FROM memberships WHERE org_id = $1 AND uid = $2",
-      [orgId, uid],
-    );
-    return (result.rowCount ?? 0) > 0;
+    const client = await this.pool.connect();
+    try {
+      await client.query("BEGIN");
+      const result = await client.query(
+        "DELETE FROM memberships WHERE org_id = $1 AND uid = $2",
+        [orgId, uid],
+      );
+      if ((result.rowCount ?? 0) > 0) {
+        await client.query(
+          `DELETE FROM session_key_shares AS keys
+           USING sessions
+           WHERE keys.session_uid = sessions.uid AND keys.session_id = sessions.id
+             AND sessions.org_id = $1 AND keys.uid = $2`,
+          [orgId, uid],
+        );
+      }
+      await client.query("COMMIT");
+      return (result.rowCount ?? 0) > 0;
+    } catch (error) {
+      await client.query("ROLLBACK");
+      throw error;
+    } finally {
+      client.release();
+    }
   }
 
   async setRole(orgId: string, uid: string, role: Role): Promise {
diff --git a/app/server/lib/store.ts b/app/server/lib/store.ts
index 86147b2..e1a7307 100644
--- a/app/server/lib/store.ts
+++ b/app/server/lib/store.ts
@@ -121,6 +121,14 @@ export interface Store {
    */
   deleteSession(orgId: string, id: string): Promise;
   putKeyShares(orgId: string, sessionId: string, shares: SessionKeyShare[]): Promise;
+  /** Changes the public salt and every sealed copy as one credential generation. */
+  rotateSessionCredentials(
+    orgId: string,
+    sessionId: string,
+    ownerUid: string,
+    shareUrl: string,
+    shares: SessionKeyShare[],
+  ): Promise;
 
   /* ---- Session vault ---- */
   accountKey(uid: string): Promise;
diff --git a/app/src/components/SessionAudience.tsx b/app/src/components/SessionAudience.tsx
index 16a4aab..2a3c785 100644
--- a/app/src/components/SessionAudience.tsx
+++ b/app/src/components/SessionAudience.tsx
@@ -4,11 +4,12 @@ import { Avatar } from "./Avatar";
 import { Button } from "./Button";
 import { PersonPicker } from "./PersonPicker";
 import { shareSessionKeys, type Member, type SessionRecord } from "../lib/api";
-import { addToAudience, audienceFor, passwordFor } from "../lib/session-passwords";
+import { addToAudience, audienceFor, verifiedPasswordFor } from "../lib/session-passwords";
 import { keyTrust, trustKey } from "../lib/known-keys";
 import { displayName } from "../lib/people";
 import { assigneeIds } from "../lib/session-view";
 import { useVault } from "../vault/VaultProvider";
+import { isVaultShare } from "../lib/vault-crypto";
 
 /**
  * Who else can open this session.
@@ -45,7 +46,10 @@ export function SessionAudience({
   useEffect(() => {
     let live = true;
     void (async () => {
-      const opened = passwordFor(session.id) ?? (await vault.openShare(session.id, session.keyShare));
+      const opened = isVaultShare(session.keyShare?.sealed)
+        ? await vault.openShare(session.id, session.keyShare)
+        : verifiedPasswordFor(session.id, session.shareUrl) ??
+          (await vault.openShare(session.id, session.keyShare));
       if (live) setPassword(opened);
     })();
     return () => {
diff --git a/app/src/components/SessionClipboard.tsx b/app/src/components/SessionClipboard.tsx
index 92abae9..234f2c8 100644
--- a/app/src/components/SessionClipboard.tsx
+++ b/app/src/components/SessionClipboard.tsx
@@ -1,9 +1,10 @@
 import { useEffect, useRef, useState } from "react";
 import { CaretDown, Copy, Check, Link as LinkIcon, Lock, Terminal, Warning } from "@phosphor-icons/react";
 import type { Member, SessionRecord } from "../lib/api";
-import { passwordFor } from "../lib/session-passwords";
+import { verifiedPasswordFor } from "../lib/session-passwords";
 import { COPY_FAILED, useCopy } from "../lib/clipboard";
 import { useVault } from "../vault/VaultProvider";
+import { isVaultShare } from "../lib/vault-crypto";
 
 /**
  * The one place a session can be copied from.
@@ -29,7 +30,10 @@ async function readPassword(
   session: SessionRecord,
   openShare: (sessionId: string, share: SessionRecord["keyShare"]) => Promise,
 ): Promise {
-  return passwordFor(session.id) ?? openShare(session.id, session.keyShare);
+  /* A vault share is the current credential generation. A locally verified
+   * cache may be from before rotation and is only a fallback for legacy rows. */
+  if (isVaultShare(session.keyShare?.sealed)) return openShare(session.id, session.keyShare);
+  return verifiedPasswordFor(session.id, session.shareUrl) ?? openShare(session.id, session.keyShare);
 }
 
 export function SessionClipboard({
diff --git a/app/src/lib/session-passwords.test.ts b/app/src/lib/session-passwords.test.ts
index a531527e6013c15eedb3163855c027b7282ee8ae..72212413e42aa0dbb690f7e500b8d9da909369dc 100644
GIT binary patch
delta 669
zcmeB({hqvmlTlkip&+rixIDioB_uyMH8mwHu{5Vdhf6`BEVU>zEi*MG04nX4znP73
z0>67+Do{otC$*$lAu&%OKPLsQQXxGxFSRJKBr_i<4pdZLlv$Fhkf>0UUy@jo3RDL&
z8fM+(j}j7-_v(n4AT;Ub=cOv?C@5u=loS+O>FXC~q~_%4<>%#O=B4Tv>u07Y7boVF
z*aB5e-pDL&kK|qrrQ+1$;&?-#?vkR?RN~CA*OQ+7mW#syyE$ozImJX6R8pQl8EQ}j
zMFxTVuBoY4lJAtNp@bSHlLIAHkb?S^0b581bpQYW

diff --git a/app/src/lib/session-passwords.ts b/app/src/lib/session-passwords.ts
index d8c09892ad3bab6314aa1b9c6201538ae6c0bf07..92588c72d18fd7152f4c4721b34844ed189279ef 100644
GIT binary patch
delta 1640
zcmb_c&rcIU6pkqbENaoxV#LD(6x%|!dLzZsn4k&90P;f)RRX|8e=s42VT7E&68(|NA>Ntvs)4M!fj{gz3+QJzWKgi-n;%f)^l@(*e;lY
zls74Jft%n~DCmM3c(jNOWy+`^uEiPNSPoQ);gEhuvi%$fX57KT8tXmgMPw&1h-7cjH=Wu2(=~%Elu8gJXi==p4=%LcA0Ie!08OuacCsQDlYR1P
zqH!V0`4@gh?jEzLCdl9T(hlE-4nupXT`s0qD!EGG)kIc
zfr$xF+4#9%I8ZDiW(QsADr~R-t-C{Sj-+O~XiKV23F$lifo4&}@?QE(bl6FcM~AoR
z^Ud~PeYHoLD*RE5ifRO?1z?_S&%_(?cs`elLk^aM7wiB5o9o2J;Rj~I4%&!sM>XQ1
zNmYo`1jf4^i7>;*b1Nj+8@ks9wK*$0;PSx8a91vFdTsfJ8V-^PTDBaQqQ`;BMLUi<
z4qBY%qlNmY5!*vABn+u*6nK?3(Wkok7XT|jyiF7dtgBV612GZa2lnxJ9UT+|cZo}b
z!WJekp<1{vwoMPU#VBTC<$$B$b!5Qe1+Cgd$6uZ1gIYBC=tN5XNcIn`@)b&rTmD?+
z$JB5W{OJV872m81u)(=a3G+9h>`U!?Djq5m{ny`3=(4|ogihODU+A2w&85!sxyD5*
S {
     try {
       const result = await fetchSessions();
+      /* The service's current generation is authoritative. Rotation clears
+       * old recipients, so an in-memory "already shared" set must clear too. */
+      for (const session of result.sessions) {
+        sharedWith.current.set(session.id, new Set(session.sharedWith ?? []));
+      }
       /* A session that came from this browser inherits the password it chose. */
-      for (const session of result.sessions) adoptOrigin(session.origin, session.id);
+      for (const session of result.sessions) adoptOrigin(session.origin, session.id, session.shareUrl);
       /*
        * A machine has to poll, launch and publish before a session exists, so
        * the row arrives some seconds after the request. Saying "it will turn
@@ -434,9 +441,11 @@ export function Workspace() {
      * safe to repeat: it compares with what the vault holds and writes only
      * when that is missing or known to be wrong. Sources, best first:
      *
-     * - a password cached here and known to be right, because this browser
-     *   chose it or it opened the session. It replaces a vault copy that
-     *   differs, since a session has one password and this one is proven;
+     * - the vault copy, when present. A locally verified password can be from
+     *   the credential generation before a live rotation and therefore must
+     *   never overwrite a newer vault copy merely because it worked once;
+     * - a password cached here and proven against this exact salted URL seeds
+     *   an empty vault;
      * - a copy a colleague sealed to this browser's key before the vault. The
      *   service holds it in the slot the vault copy takes, so keeping it is
      *   moving it;
@@ -449,12 +458,16 @@ export function Workspace() {
      */
     async function keepOwnCopy(session: SessionRecord): Promise {
       const share = session.keyShare;
-      const inVault = isVaultShare(share?.sealed) ? await vault.openShare(session.id, share) : null;
+      const hasVaultShare = isVaultShare(share?.sealed);
+      const inVault = hasVaultShare ? await vault.openShare(session.id, share) : null;
       const cached = cachedPassword(session.id);
-      if (cached?.verified) {
-        return cached.password === inVault || vault.keep(session.id, cached.password);
-      }
       if (inVault) return true;
+      /* A v2 share is authoritative even when this browser cannot open it.
+       * Only a password that opens a live frame may replace that generation;
+       * TerminalPane performs that proof-bound write. */
+      if (hasVaultShare) return false;
+      const seed = passwordToSeedVault(session.id, false, session.shareUrl);
+      if (seed) return vault.keep(session.id, seed);
       const legacyShare = share && !isVaultShare(share.sealed) ? await vault.openShare(session.id, share) : null;
       const candidate = legacyShare ?? (cached?.legacy ? cached.password : null);
       if (candidate) return vault.keep(session.id, candidate);
@@ -467,7 +480,7 @@ export function Workspace() {
       const pending = session.origin ? pendingShares.current.get(session.origin) : undefined;
       if (pending) {
         pendingShares.current.delete(session.origin!);
-        rememberFor(session.id, pending);
+        rememberFor(session.id, pending, session.shareUrl);
       }
 
       /*
@@ -479,7 +492,6 @@ export function Workspace() {
       }
 
       if (session.closedAt) continue;
-      const cached = cachedPassword(session.id);
 
       /* Only the owner shares with colleagues. */
       if (!me || session.ownerUid !== me.uid) continue;
@@ -498,9 +510,10 @@ export function Workspace() {
         done: sharedWith.current.get(session.id),
       });
       if (missing.length === 0) continue;
-      /* A proven password before a vault copy nobody can vouch for; see TerminalPane. */
-      const password =
-        (cached?.verified ? cached.password : null) ?? (await vault.openShare(session.id, session.keyShare));
+      const vaultPassword = await vault.openShare(session.id, session.keyShare);
+      const password = isVaultShare(session.keyShare?.sealed)
+        ? vaultPassword
+        : verifiedPasswordFor(session.id, session.shareUrl) ?? vaultPassword;
       if (!password) continue;
 
       const done = sharedWith.current.get(session.id) ?? new Set();
diff --git a/app/src/terminal/TerminalPane.tsx b/app/src/terminal/TerminalPane.tsx
index a53f744..b4c7e0e 100644
--- a/app/src/terminal/TerminalPane.tsx
+++ b/app/src/terminal/TerminalPane.tsx
@@ -242,8 +242,11 @@ export function TerminalPane({
           pending.current = [];
           if (!worked || !sessionId) return;
           /* Written only now that it has proved itself; see handleUnlock. */
-          if (worked.source === "typed") rememberVerified(sessionId, worked.password);
-          if (worked.source === "cache") markVerified(sessionId, worked.password);
+          if (worked.source === "typed" || worked.source === "vault") {
+            /* Also replaces a locally verified password from before rotation. */
+            rememberVerified(sessionId, worked.password, shareUrl);
+          }
+          if (worked.source === "cache") markVerified(sessionId, worked.password, shareUrl);
           /*
            * A password that opened the session but did not come from the
            * vault goes into it now, so no browser has to be told it again.
@@ -318,10 +321,9 @@ export function TerminalPane({
 
     /*
      * Every password within reach is tried before anyone is asked. One this
-     * browser has already seen open the session goes first: a vault copy is
-     * sealed with an ephemeral key, so anyone holding the public key could
-     * have made one, and a proven password should not give way to a copy
-     * nobody can vouch for. Then the vault's copy, then a cached guess, then
+     * The current vault copy goes first. A password cached as verified may
+     * belong to the credential generation before a live rotation; "worked in
+     * the past" is not proof that it is current. Then a cached password, then
      * one a colleague sealed to this browser's old key. The gate appears only
      * when all of them fail, or there are none.
      */
@@ -335,7 +337,6 @@ export function TerminalPane({
       };
       const opener = vaultRef.current;
       const cached = cachedPassword(sessionId);
-      if (cached?.verified) add("cache", cached.password);
       if (initial && isVaultShare(initial.sealed)) add("vault", await opener.openShare(sessionId, initial));
       add("cache", cached?.password);
       if (initial && !isVaultShare(initial.sealed)) add("legacy", await opener.openShare(sessionId, initial));
diff --git a/app/src/vault/share-with.ts b/app/src/vault/share-with.ts
index 278103d..b56912f 100644
--- a/app/src/vault/share-with.ts
+++ b/app/src/vault/share-with.ts
@@ -1,7 +1,8 @@
 import { shareSessionKeys, type Member, type SessionRecord } from "../lib/api";
-import { addToAudience, cachedPassword } from "../lib/session-passwords";
+import { addToAudience, verifiedPasswordFor } from "../lib/session-passwords";
 import { keyTrust, trustKey } from "../lib/known-keys";
 import type { useVault } from "./VaultProvider";
+import { isVaultShare } from "../lib/vault-crypto";
 
 type Vault = Pick, "openShare" | "sealTo">;
 
@@ -18,13 +19,13 @@ type Vault = Pick, "openShare" | "sealTo">;
 export async function shareWith(
   vault: Vault,
   owner: string,
-  session: Pick,
+  session: Pick,
   recipients: Member[],
 ): Promise {
-  /* A proven password before a vault copy nobody can vouch for; see TerminalPane. */
-  const cached = cachedPassword(session.id);
-  const password =
-    (cached?.verified ? cached.password : null) ?? (await vault.openShare(session.id, session.keyShare));
+  const vaultPassword = await vault.openShare(session.id, session.keyShare);
+  const password = isVaultShare(session.keyShare?.sealed)
+    ? vaultPassword
+    : verifiedPasswordFor(session.id, session.shareUrl) ?? vaultPassword;
   if (!password) return [];
 
   const eligible = recipients.filter(
diff --git a/cmd/shell/help.go b/cmd/shell/help.go
index c7cec95..1de7dac 100644
--- a/cmd/shell/help.go
+++ b/cmd/shell/help.go
@@ -19,6 +19,8 @@ both. Shares are interactive by default and end-to-end encrypted.
 
 Then
   shell list                       See active shares and uptime
+  shell password               Print an active share's password locally
+  shell password rotate        Revoke it and make a fresh password
   shell attach                 Rejoin locally; browser access stays live
   Press Ctrl-X, then D to detach   Leave the process running
   shell kill                   Safely stop the process and close its link
@@ -35,7 +37,7 @@ Common options
   --persistent         Keep one encrypted URL across restarts
   --auto-close