From f177556e6a77d2802a2ba5f30e572bee4ad0de17 Mon Sep 17 00:00:00 2001 From: Alexgodoroja Date: Fri, 11 Sep 2026 15:01:44 -0700 Subject: [PATCH] Publish a privacy policy and let people delete their account The app had no /privacy route and no way to delete an account, though the Terms describe ending one. Both are launch blockers. /privacy is a public page laid out like the Terms. It names what the service keeps, what it never receives, what a team sees, the three processors (Google, Cloudflare, Twilio SendGrid), what the browser stores, how long each kind of record lasts, and what deletion removes. Sign-in, sign-up, the Terms and the Account page link to it. Delete account on the Account page asks for the account's email typed back and a fresh sign-in, then: - DELETE /api/account removes the person's tokens, sessions, sealed password copies, vault, comments, notifications and membership in one transaction. What they typed into colleagues' sessions stays in the team's trail with the email replaced. A team nobody else is in is dissolved; a team they own is handed to its longest-standing admin, or member if there is none. - The browser then clears its vault key, cached passwords and open tabs, and deletes the Firebase user. A deleted uid is remembered for two hours (migration 008), because an ID token issued before the deletion still verifies for up to an hour, and the first request from a tab left open would otherwise build the person a new team. --- CHANGELOG.md | 6 + app/server/app.test.ts | 68 +++ app/server/app.ts | 18 +- .../lib/migrations/008_deleted_accounts.sql | 11 + app/server/lib/orgs.test.ts | 27 + app/server/lib/orgs.ts | 18 + app/server/lib/store-conformance.test.ts | 111 +++- app/server/lib/store-memory.ts | 81 ++- app/server/lib/store-postgres.ts | 83 ++- app/server/lib/store.ts | 36 ++ app/server/routes/account.ts | 60 +++ app/server/routes/organizations.ts | 14 +- app/src/App.tsx | 2 + app/src/auth/AuthProvider.tsx | 48 +- app/src/components/Button.tsx | 2 +- app/src/components/DeleteAccount.tsx | 142 +++++ app/src/lib/account-deletion.test.ts | 45 ++ app/src/lib/account-deletion.ts | 22 + app/src/lib/api.ts | 11 + app/src/routes/Account.tsx | 20 +- app/src/routes/Privacy.tsx | 489 ++++++++++++++++++ app/src/routes/SignIn.tsx | 10 + app/src/routes/SignUp.tsx | 4 + app/src/routes/Terms.tsx | 11 +- app/src/styles/auth.css | 55 ++ app/src/terminal/tab-store.ts | 12 + 26 files changed, 1394 insertions(+), 12 deletions(-) create mode 100644 app/server/lib/migrations/008_deleted_accounts.sql create mode 100644 app/server/routes/account.ts create mode 100644 app/src/components/DeleteAccount.tsx create mode 100644 app/src/lib/account-deletion.test.ts create mode 100644 app/src/lib/account-deletion.ts create mode 100644 app/src/routes/Privacy.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 36f62d5..9b33633 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ All notable user-visible changes are recorded here. Versions follow [Semantic Ve - `shell` seals each encrypted session's password to your vault when it registers the session, using the vault key the browser handed it at `shell login`, and will not seal to a key that has changed since. +- A privacy policy at `/privacy`, linked from sign-in, sign-up, the Terms and + the Account page. +- Delete your account from the Account page. It removes your sign-in, your + machines' tokens, your sessions, vault, comments and notifications; hands a + team you own to its longest-standing admin, or member if there is none; and + deletes the team when nobody else is in it. ### Changed diff --git a/app/server/app.test.ts b/app/server/app.test.ts index 501683f..553d492 100644 --- a/app/server/app.test.ts +++ b/app/server/app.test.ts @@ -1908,3 +1908,71 @@ describe("session vault", () => { expect(member.accountKey).toBe(body.public_key); }); }); + +describe("DELETE /api/account", () => { + /* A token from a sign-in that just happened, which deletion asks for. */ + const freshSignIn = (claims: Record = {}) => + idToken({ auth_time: Math.floor(Date.now() / 1000), ...claims }); + + async function remove(claims: Record = {}, confirm = "ana@example.com") { + return call("DELETE", "/api/account", { auth: await freshSignIn(claims), body: { confirm } }); + } + + it("asks for the account's email address, typed back", async () => { + await call("GET", "/api/org", { auth: await idToken() }); + const result = await remove({}, "someone@else.com"); + expect(result.status).toBe(400); + expect((await call("GET", "/api/org", { auth: await idToken() })).status).toBe(200); + }); + + it("asks for a recent sign-in", async () => { + const stale = await call("DELETE", "/api/account", { + auth: await idToken({ auth_time: Math.floor(Date.now() / 1000) - 3600 }), + body: { confirm: "ana@example.com" }, + }); + expect(stale).toMatchObject({ status: 403, body: { reauthenticate: true } }); + }); + + it("removes a lone account with its team and machines", async () => { + const tokens = await login(); + expect(await devices()).toHaveLength(1); + + const result = await remove(); + expect(result).toMatchObject({ status: 200, body: { deleted: true, owner: null } }); + + expect(await devices()).toEqual([]); + expect((await call("GET", "/api/cli/me", { auth: tokens.access_token })).status).toBe(401); + }); + + it("does not build a new team for a token that outlived its account", async () => { + await call("GET", "/api/org", { auth: await idToken() }); + await remove(); + + expect((await call("GET", "/api/org", { auth: await idToken() })).status).toBe(401); + expect((await call("GET", "/api/sessions", { auth: await idToken() })).status).toBe(401); + }); + + it("hands the team to its longest-standing admin", async () => { + const owner = await idToken(); + const forMember = await call("POST", "/api/org/invites", { auth: owner, body: { role: "member" } }); + const forAdmin = await call("POST", "/api/org/invites", { auth: owner, body: { role: "admin" } }); + const member = await idToken({ sub: "uid-2", email: "bo@example.com" }); + const admin = await idToken({ sub: "uid-3", email: "cy@example.com" }); + await call("GET", `/api/org?invite=${forMember.body.invite.id}`, { auth: member }); + await call("GET", `/api/org?invite=${forAdmin.body.invite.id}`, { auth: admin }); + + const result = await remove(); + expect(result.body.owner).toMatchObject({ uid: "uid-3", email: "cy@example.com" }); + + const view = await call("GET", "/api/org", { auth: admin }); + expect(view.body.you.role).toBe("owner"); + const uids = view.body.members.map((entry: { uid: string }) => entry.uid).sort(); + expect(uids).toEqual(["uid-2", "uid-3"]); + }); + + it("succeeds again when the browser retries", async () => { + await call("GET", "/api/org", { auth: await idToken() }); + expect((await remove()).status).toBe(200); + expect((await remove()).status).toBe(200); + }); +}); diff --git a/app/server/app.ts b/app/server/app.ts index 089a841..246298b 100644 --- a/app/server/app.ts +++ b/app/server/app.ts @@ -33,6 +33,7 @@ import { } from "./routes/organizations"; import { recordAudit, assignSession, auditCsv } from "./routes/audit"; import { addComment, inbox, notifyAssigned, notifySessionStarted } from "./routes/social"; +import { deleteAccount } from "./routes/account"; import { callerAddress, rateLimiter } from "./lib/rate-limit"; import { logMailer, type Mailer } from "./lib/mail"; @@ -104,6 +105,7 @@ const CREDENTIAL_ROUTES = new Set([ "POST /api/cli/token", "POST /api/cli/refresh", "POST /api/cli/revoke", + "DELETE /api/account", ]); const CREDENTIAL_BUCKET = { burst: 12, perSecond: 0.2 }; const GENERAL_BUCKET = { burst: 240, perSecond: 40 }; @@ -290,7 +292,7 @@ export function createApp(options: AppOptions) { async function requireMember(request: IncomingMessage, inviteId?: string) { const identity = await requireUser(request); if (!identity) return null; - return (await ensureMembership(store, identity, inviteId)).membership; + return (await ensureMembership(store, identity, inviteId))?.membership ?? null; } /* The CLI authenticates with an opaque access token issued by this service. */ @@ -443,6 +445,7 @@ export function createApp(options: AppOptions) { const identity = await requireUser(request); if (!identity) return send(response, 401, { error: "sign in first" }); const resolved = await ensureMembership(store, identity, invite); + if (!resolved) return send(response, 401, { error: "sign in first" }); /* Publishing the browser key here keeps it current without a separate call on every sign-in. */ const publicKey = url.searchParams.get("key"); @@ -675,6 +678,19 @@ export function createApp(options: AppOptions) { return send(response, 200, { public_key: key.publicKey, version: key.version }); } + /* + * Deleting an account. An ID token reaches it, not a membership, so a + * second request -- the browser retrying after Firebase refused to + * delete the sign-in -- finds nothing left to remove and still succeeds. + */ + if (route === "DELETE /api/account") { + const identity = await requireUser(request); + if (!identity) return send(response, 401, { error: "sign in first" }); + const body = (await readBody(request)) as Record; + const result = await deleteAccount(store, identity, body.confirm); + return send(response, result.status, result.body); + } + /* ---- Session registry ---- */ if (route === "POST /api/sessions") { const token = await requireCli(request); diff --git a/app/server/lib/migrations/008_deleted_accounts.sql b/app/server/lib/migrations/008_deleted_accounts.sql new file mode 100644 index 0000000..61573d2 --- /dev/null +++ b/app/server/lib/migrations/008_deleted_accounts.sql @@ -0,0 +1,11 @@ +-- Accounts deleted in the last two hours. +-- +-- A browser still signed in to an account that has just been deleted holds a +-- Firebase ID token that verifies for up to an hour. Any request it makes in +-- that time finds no membership, and without this the service would create a +-- new team for a person who asked to be removed. Only the uid is kept, and +-- only as long as such a token can live, with slack; purging drops it after. +CREATE TABLE IF NOT EXISTS deleted_accounts ( + uid TEXT PRIMARY KEY, + deleted_at BIGINT NOT NULL +); diff --git a/app/server/lib/orgs.test.ts b/app/server/lib/orgs.test.ts index 6d854f7..e173c08 100644 --- a/app/server/lib/orgs.test.ts +++ b/app/server/lib/orgs.test.ts @@ -6,8 +6,10 @@ import { checkInvite, newId, outranks, + successorFor, suggestOrgName, type Invite, + type Membership, } from "./orgs"; function invite(overrides: Partial = {}): Invite { @@ -22,6 +24,31 @@ function invite(overrides: Partial = {}): Invite { }; } +describe("successorFor", () => { + function member(uid: string, role: Membership["role"], joinedAt: number): Membership { + return { orgId: "org_1", uid, email: `${uid}@example.com`, name: uid, role, joinedAt }; + } + + it("prefers the longest-standing admin over an earlier member", () => { + const members = [ + member("owner", "owner", 1), + member("early-member", "member", 2), + member("late-admin", "admin", 4), + member("early-admin", "admin", 3), + ]; + expect(successorFor(members, "owner")?.uid).toBe("early-admin"); + }); + + it("falls back to the longest-standing member", () => { + const members = [member("owner", "owner", 1), member("b", "member", 3), member("a", "member", 2)]; + expect(successorFor(members, "owner")?.uid).toBe("a"); + }); + + it("names nobody for an owner who is alone", () => { + expect(successorFor([member("owner", "owner", 1)], "owner")).toBeUndefined(); + }); +}); + describe("suggestOrgName", () => { it("uses the company domain for a work address", async () => { expect(suggestOrgName("alex@vulturelabs.io")).toBe("Vulturelabs"); diff --git a/app/server/lib/orgs.ts b/app/server/lib/orgs.ts index 1d82d53..1d3c813 100644 --- a/app/server/lib/orgs.ts +++ b/app/server/lib/orgs.ts @@ -128,6 +128,24 @@ export function outranks(actor: Role, target: Role): boolean { return RANK[actor] > RANK[target]; } +/** + * Who takes over when an owner deletes their account. + * + * The longest-standing admin, who already helps run the team, and failing that + * the longest-standing member. Undefined when nobody else is in it, in which + * case the organization goes with its owner. A tie on joinedAt falls to the + * uid, so the answer never depends on the order rows come back in. + */ +export function successorFor(members: Membership[], leavingUid: string): Membership | undefined { + const others = members.filter((entry) => entry.uid !== leavingUid); + const byTenure = (a: Membership, b: Membership) => + a.joinedAt - b.joinedAt || (a.uid < b.uid ? -1 : a.uid > b.uid ? 1 : 0); + return ( + others.filter((entry) => entry.role === "admin").sort(byTenure)[0] ?? + others.sort(byTenure)[0] + ); +} + export type InviteCheck = | { ok: true; invite: Invite } | { ok: false; reason: string }; diff --git a/app/server/lib/store-conformance.test.ts b/app/server/lib/store-conformance.test.ts index 31294ff..5c6d646 100644 --- a/app/server/lib/store-conformance.test.ts +++ b/app/server/lib/store-conformance.test.ts @@ -1,7 +1,7 @@ import { afterAll, beforeEach, describe, expect, it } from "vitest"; import { MemoryStore } from "./store-memory"; import { PostgresStore } from "./store-postgres"; -import type { Store } from "./store"; +import { DELETED_ACCOUNT_MEMORY_MS, DELETED_ACTOR_EMAIL, type Store } from "./store"; import type { AgentCommand, AuditEvent, CliToken, Notification, SessionRecord } from "./types"; import type { Invite, Membership, Organization } from "./orgs"; @@ -128,6 +128,7 @@ function notification(overrides: Partial = {}): Notification { type Implementation = { name: string; open: () => Promise; reset: (store: Store) => Promise }; const TABLES = [ + "deleted_accounts", "account_keys", "session_key_shares", "sessions", @@ -820,5 +821,113 @@ for (const implementation of implementations) { expect(["org_1", "org_2"]).toContain(after?.orgId); }); }); + + describe("account deletion", () => { + async function team() { + await store.putOrganization(organization()); + await store.putMembership(membership()); + await store.putMembership( + membership({ uid: "uid-2", email: "bo@example.com", name: "Bo", role: "member", joinedAt: 2000 }), + ); + await store.putMembership( + membership({ uid: "uid-3", email: "cy@example.com", name: "Cy", role: "admin", joinedAt: 3000 }), + ); + } + + it("removes what the account held and keeps the team's trail without its email", async () => { + await team(); + await store.putToken(token()); + await store.putCommand(command()); + await store.upsertSession(session()); + await store.upsertSession( + session({ id: "s2", uid: "uid-2", ownerUid: "uid-2", assigneeUid: "uid-1", assigneeUids: ["uid-1", "uid-3"] }), + ); + await store.putKeyShares("org_1", "s2", [ + { uid: "uid-1", senderPublicKey: "spk", sealed: "for-ana" }, + { uid: "uid-3", senderPublicKey: "spk", sealed: "for-cy" }, + ]); + await store.putAccountKey({ + uid: "uid-1", + publicKey: "pk", + encryptedPrivateKey: "enc", + recoveryWrap: "wrap", + version: 1, + createdAt: 1000, + updatedAt: 1000, + }); + await store.putAudit(auditEvent({ sessionId: "s2" })); + await store.putComment({ + id: "cmt_1", + orgId: "org_1", + sessionId: "s2", + authorUid: "uid-1", + body: "looks done", + at: 1000, + mentions: [], + }); + await store.putNotification(notification()); + await store.putNotification(notification({ id: "ntf_2", uid: "uid-1", actorUid: "uid-2" })); + + await store.deleteAccount("uid-1", { orgId: "org_1", dissolve: false, successorUid: "uid-3" }, 5000); + + expect(await store.membershipOf("uid-1")).toBeNull(); + expect((await store.membershipOf("uid-3"))?.role).toBe("owner"); + expect((await store.membershipOf("uid-2"))?.role).toBe("member"); + expect(await store.findByAccessHash("access-hash")).toBeNull(); + expect(await store.listCommands("uid-1")).toEqual([]); + 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([]); + + const kept = (await store.listOrgSessions("org_1")).find((entry) => entry.id === "s2"); + expect(kept?.assigneeUids).toEqual(["uid-3"]); + expect(kept?.assigneeUid).toBe("uid-3"); + expect(kept?.keyShares?.map((share) => share.uid)).toEqual(["uid-3"]); + + const trail = await store.auditFor("org_1", "s2"); + expect(trail).toHaveLength(1); + expect(trail[0].actorEmail).toBe(DELETED_ACTOR_EMAIL); + expect(trail[0].text).toBe("ls -la"); + }); + + it("dissolves a team nobody else is in", async () => { + await store.putOrganization(organization()); + await store.putMembership(membership()); + await store.putInvite(invite()); + await store.upsertSession(session()); + await store.putAudit(auditEvent()); + + await store.deleteAccount("uid-1", { orgId: "org_1", dissolve: true }, 5000); + + expect(await store.organization("org_1")).toBeNull(); + expect(await store.members("org_1")).toEqual([]); + expect(await store.invites("org_1")).toEqual([]); + expect(await store.listOrgSessions("org_1")).toEqual([]); + expect(await store.auditFor("org_1", "s1")).toEqual([]); + }); + + it("forgets the address an invite was sent to once its recipient is gone", async () => { + await team(); + await store.putInvite(invite({ email: "bo@example.com", acceptedBy: "uid-2", acceptedAt: 2000 })); + + await store.deleteAccount("uid-2", { orgId: "org_1", dissolve: false }, 5000); + + expect((await store.invite("inv_1"))?.email).toBeFalsy(); + expect((await store.membershipOf("uid-1"))?.role).toBe("owner"); + }); + + it("remembers a deleted uid for DELETED_ACCOUNT_MEMORY_MS and no longer", async () => { + await store.deleteAccount("uid-9", { dissolve: false }, 5000); + + expect(await store.recentlyDeleted("uid-9", 4000)).toBe(true); + expect(await store.recentlyDeleted("uid-9", 6000)).toBe(false); + expect(await store.recentlyDeleted("uid-8", 0)).toBe(false); + + await store.purgeExpired(5000 + DELETED_ACCOUNT_MEMORY_MS); + expect(await store.recentlyDeleted("uid-9", 0)).toBe(false); + }); + }); }); } diff --git a/app/server/lib/store-memory.ts b/app/server/lib/store-memory.ts index 7ab6385..5cb3b48 100644 --- a/app/server/lib/store-memory.ts +++ b/app/server/lib/store-memory.ts @@ -2,7 +2,14 @@ import { mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs"; import { randomBytes } from "node:crypto"; import { dirname, join } from "node:path"; import type { Invite, Membership, Organization, Role } from "./orgs"; -import type { AuditPage, AuditPageQuery, Store } from "./store"; +import { + DELETED_ACCOUNT_MEMORY_MS, + DELETED_ACTOR_EMAIL, + type AccountDeletion, + type AuditPage, + type AuditPageQuery, + type Store, +} from "./store"; import type { AccountKey, AgentCommand, @@ -48,12 +55,13 @@ interface Shape { comments: Comment[]; notifications: Notification[]; accountKeys: AccountKey[]; + deletedAccounts: { uid: string; deletedAt: number }[]; } const EMPTY: Shape = { codes: [], tokens: [], sessions: [], commands: [], organizations: [], memberships: [], invites: [], audit: [], - comments: [], notifications: [], accountKeys: [], + comments: [], notifications: [], accountKeys: [], deletedAccounts: [], }; /** @@ -117,6 +125,7 @@ export class MemoryStore implements Store { comments: parsed.comments ?? [], notifications: parsed.notifications ?? [], accountKeys: parsed.accountKeys ?? [], + deletedAccounts: parsed.deletedAccounts ?? [], }; } catch { return structuredClone(EMPTY); @@ -245,6 +254,64 @@ export class MemoryStore implements Store { return true; } + async deleteAccount(uid: string, plan: AccountDeletion, now = Date.now()): Promise { + const data = this.data; + const { orgId } = plan; + if (orgId && plan.dissolve) { + data.organizations = data.organizations.filter((entry) => entry.id !== orgId); + data.memberships = data.memberships.filter((entry) => entry.orgId !== orgId); + data.invites = data.invites.filter((entry) => entry.orgId !== orgId); + data.sessions = data.sessions.filter((entry) => entry.orgId !== orgId); + data.audit = data.audit.filter((entry) => entry.orgId !== orgId); + data.comments = data.comments.filter((entry) => entry.orgId !== orgId); + data.notifications = data.notifications.filter((entry) => entry.orgId !== orgId); + } + if (orgId && plan.successorUid) { + const successor = data.memberships.find( + (entry) => entry.orgId === orgId && entry.uid === plan.successorUid, + ); + if (successor) successor.role = "owner"; + } + + data.memberships = data.memberships.filter((entry) => entry.uid !== uid); + data.codes = data.codes.filter((entry) => entry.uid !== uid); + data.tokens = data.tokens.filter((entry) => entry.uid !== uid); + data.commands = data.commands.filter((entry) => entry.uid !== uid); + data.sessions = data.sessions.filter((entry) => entry.uid !== uid); + for (const session of data.sessions) { + if (session.keyShares) { + session.keyShares = session.keyShares.filter((share) => share.uid !== uid); + } + const assignees = session.assigneeUids ?? []; + if (assignees.includes(uid) || session.assigneeUid === uid) { + session.assigneeUids = assignees.filter((entry) => entry !== uid); + if (session.assigneeUid === uid) session.assigneeUid = session.assigneeUids[0]; + } + if (session.ownerUid === uid) session.ownerUid = session.uid; + } + for (const invite of data.invites) { + /* The address an invite was sent to is theirs once they accepted it. */ + if (invite.acceptedBy === uid) delete invite.email; + } + data.accountKeys = data.accountKeys.filter((entry) => entry.uid !== uid); + data.comments = data.comments.filter((entry) => entry.authorUid !== uid); + data.notifications = data.notifications.filter( + (entry) => entry.uid !== uid && entry.actorUid !== uid, + ); + for (const event of data.audit) { + if (event.actorUid === uid) event.actorEmail = DELETED_ACTOR_EMAIL; + } + data.deletedAccounts = [ + ...data.deletedAccounts.filter((entry) => entry.uid !== uid), + { uid, deletedAt: now }, + ]; + this.flush(); + } + + async recentlyDeleted(uid: string, since: number): Promise { + return this.data.deletedAccounts.some((entry) => entry.uid === uid && entry.deletedAt >= since); + } + /** Records that `shell agent` is polling, and what it publishes about itself. */ async markAgentSeen( id: string, @@ -636,7 +703,15 @@ export class MemoryStore implements Store { this.data.commands = this.data.commands.filter( (entry) => !entry.doneAt || now - entry.doneAt < 10 * 60_000, ); - if (this.data.codes.length !== before || this.data.commands.length !== commandsBefore) { + const deletedBefore = this.data.deletedAccounts.length; + this.data.deletedAccounts = this.data.deletedAccounts.filter( + (entry) => now - entry.deletedAt < DELETED_ACCOUNT_MEMORY_MS, + ); + if ( + this.data.codes.length !== before || + this.data.commands.length !== commandsBefore || + this.data.deletedAccounts.length !== deletedBefore + ) { this.flush(); } } diff --git a/app/server/lib/store-postgres.ts b/app/server/lib/store-postgres.ts index 8698a14..93d6225 100644 --- a/app/server/lib/store-postgres.ts +++ b/app/server/lib/store-postgres.ts @@ -4,7 +4,14 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import pg from "pg"; import type { Invite, Membership, Organization, Role } from "./orgs"; -import type { AuditPage, AuditPageQuery, Store } from "./store"; +import { + DELETED_ACCOUNT_MEMORY_MS, + DELETED_ACTOR_EMAIL, + type AccountDeletion, + type AuditPage, + type AuditPageQuery, + type Store, +} from "./store"; import type { AccountKey, AgentCommand, @@ -855,6 +862,77 @@ export class PostgresStore implements Store { return (result.rowCount ?? 0) > 0; } + /* ---- Account deletion ---- */ + + /* + * One transaction. An account half deleted is worse than either state: it + * can leave a team with no owner, or a session assigned to nobody. + */ + async deleteAccount(uid: string, plan: AccountDeletion, now = Date.now()): Promise { + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + if (plan.orgId && plan.dissolve) { + /* Key shares go with their sessions, through the foreign key's cascade. */ + for (const table of ["sessions", "audit_events", "comments", "notifications", "invites", "memberships"]) { + await client.query(`DELETE FROM ${table} WHERE org_id = $1`, [plan.orgId]); + } + await client.query("DELETE FROM organizations WHERE id = $1", [plan.orgId]); + } + if (plan.orgId && plan.successorUid) { + await client.query("UPDATE memberships SET role = 'owner' WHERE org_id = $1 AND uid = $2", [ + plan.orgId, + plan.successorUid, + ]); + } + for (const statement of [ + "DELETE FROM memberships WHERE uid = $1", + "DELETE FROM auth_codes WHERE uid = $1", + "DELETE FROM cli_tokens WHERE uid = $1", + "DELETE FROM agent_commands WHERE uid = $1", + "DELETE FROM sessions WHERE uid = $1", + "DELETE FROM session_key_shares WHERE uid = $1", + "DELETE FROM account_keys WHERE uid = $1", + "DELETE FROM comments WHERE author_uid = $1", + "DELETE FROM notifications WHERE uid = $1 OR actor_uid = $1", + /* The address an invite was sent to is theirs once they accepted it. */ + "UPDATE invites SET email = NULL WHERE accepted_by = $1", + /* The right-hand sides read the row as it was, so [1] is the next assignee. */ + `UPDATE sessions SET + assignee_uids = array_remove(assignee_uids, $1), + assignee_uid = CASE WHEN assignee_uid = $1 + THEN (array_remove(assignee_uids, $1))[1] ELSE assignee_uid END, + owner_uid = CASE WHEN owner_uid = $1 THEN uid ELSE owner_uid END + WHERE $1 = ANY(assignee_uids) OR assignee_uid = $1 OR owner_uid = $1`, + ]) { + await client.query(statement, [uid]); + } + await client.query("UPDATE audit_events SET actor_email = $2 WHERE actor_uid = $1", [ + uid, + DELETED_ACTOR_EMAIL, + ]); + await client.query( + `INSERT INTO deleted_accounts (uid, deleted_at) VALUES ($1, $2) + ON CONFLICT (uid) DO UPDATE SET deleted_at = EXCLUDED.deleted_at`, + [uid, now], + ); + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK").catch(() => {}); + throw error; + } finally { + client.release(); + } + } + + async recentlyDeleted(uid: string, since: number): Promise { + const row = await this.row( + "SELECT 1 FROM deleted_accounts WHERE uid = $1 AND deleted_at >= $2", + [uid, since], + ); + return row !== null; + } + /* ---- Agent commands ---- */ async putCommand(command: AgentCommand): Promise { @@ -1279,6 +1357,9 @@ export class PostgresStore implements Store { await this.pool.query("DELETE FROM agent_commands WHERE done_at IS NOT NULL AND done_at < $1", [ now - 10 * 60_000, ]); + await this.pool.query("DELETE FROM deleted_accounts WHERE deleted_at <= $1", [ + now - DELETED_ACCOUNT_MEMORY_MS, + ]); } async close(): Promise { diff --git a/app/server/lib/store.ts b/app/server/lib/store.ts index 904a177..92c5922 100644 --- a/app/server/lib/store.ts +++ b/app/server/lib/store.ts @@ -29,6 +29,25 @@ export interface AuditPage { total: number; } +/** + * How long a deleted account's uid is remembered. A Firebase ID token lives an + * hour; the second hour is slack for clock skew and for a token minted just + * before the deletion. + */ +export const DELETED_ACCOUNT_MEMORY_MS = 2 * 60 * 60_000; + +/** What the activity trail shows in place of a deleted account's email. */ +export const DELETED_ACTOR_EMAIL = "deleted account"; + +/** What deleting an account does to the organization it belonged to. */ +export interface AccountDeletion { + orgId?: string; + /** Delete the organization and everything in it, because nobody else is in it. */ + dissolve: boolean; + /** The member who becomes owner, when the owner is the one leaving. */ + successorUid?: string; +} + /** * Everything the service keeps. * @@ -111,6 +130,23 @@ export interface Store { */ putAccountKey(key: AccountKey, expectedVersion?: number): Promise; + /* ---- Account deletion ---- */ + /** + * Removes what this service holds for one person, in one step. + * + * Their machines' tokens, their sessions and the passwords sealed to them, + * their vault, comments, notifications and membership. What they typed into + * colleagues' sessions stays in the team's trail, since it is the record of + * what happened on those machines, but no longer carries their email. The + * plan says what happens to the organization: dissolved when nobody else is + * in it, handed to `successorUid` when its owner is the one leaving. + * + * The uid is then remembered for DELETED_ACCOUNT_MEMORY_MS. + */ + deleteAccount(uid: string, plan: AccountDeletion, now?: number): Promise; + /** Whether this uid was deleted at or after `since`. */ + recentlyDeleted(uid: string, since: number): Promise; + /* ---- Agent commands ---- */ putCommand(command: AgentCommand): Promise; claimCommands(deviceId: string, now?: number): Promise; diff --git a/app/server/routes/account.ts b/app/server/routes/account.ts new file mode 100644 index 0000000..e3c5a9d --- /dev/null +++ b/app/server/routes/account.ts @@ -0,0 +1,60 @@ +import type { Store } from "../lib/store"; +import type { Identity } from "../lib/firebase-token"; +import { successorFor } from "../lib/orgs"; +import { RESET_SIGN_IN_WINDOW_MS } from "../lib/vault"; +import type { Result } from "./organizations"; + +/** + * Deletes the caller's account from this service. + * + * Two checks stand in front of it: the account's email address typed back, so + * a stray request cannot do this by accident, and a sign-in from the last few + * minutes, the proof of presence a vault reset asks for, so a token lifted + * from an idle browser cannot either. + * + * The Firebase account is deleted by the browser afterwards. This service + * verifies Firebase tokens and holds no credential that could manage them. + */ +export async function deleteAccount( + store: Store, + identity: Identity, + confirm: unknown, + now = Date.now(), +): Promise { + const email = identity.email.trim().toLowerCase(); + if (typeof confirm !== "string" || !email || confirm.trim().toLowerCase() !== email) { + return { status: 400, body: { error: "Type your account's email address to confirm." } }; + } + if (!identity.authTime || now - identity.authTime > RESET_SIGN_IN_WINDOW_MS) { + return { + status: 403, + body: { + error: "Deleting your account needs a recent sign-in. Sign in again and retry.", + reauthenticate: true, + }, + }; + } + + const membership = await store.membershipOf(identity.uid); + const members = membership ? await store.members(membership.orgId) : []; + const others = members.filter((entry) => entry.uid !== identity.uid); + /* An owner hands the team over; anyone else simply leaves it. */ + const successor = membership?.role === "owner" ? successorFor(members, identity.uid) : undefined; + + await store.deleteAccount( + identity.uid, + { + orgId: membership?.orgId, + dissolve: membership !== null && others.length === 0, + successorUid: successor?.uid, + }, + now, + ); + return { + status: 200, + body: { + deleted: true, + owner: successor ? { uid: successor.uid, email: successor.email, name: successor.name } : null, + }, + }; +} diff --git a/app/server/routes/organizations.ts b/app/server/routes/organizations.ts index fb3562f..153da09 100644 --- a/app/server/routes/organizations.ts +++ b/app/server/routes/organizations.ts @@ -1,4 +1,4 @@ -import type { Store } from "../lib/store"; +import { DELETED_ACCOUNT_MEMORY_MS, type Store } from "../lib/store"; import type { Identity } from "../lib/firebase-token"; import { invitationMessage, type Mailer } from "../lib/mail"; import { @@ -33,7 +33,7 @@ export async function ensureMembership( store: Store, identity: Identity, inviteId?: string, -): Promise<{ membership: Membership; joined: boolean; error?: string }> { +): Promise<{ membership: Membership; joined: boolean; error?: string } | null> { const existing = await store.membershipOf(identity.uid); if (existing && inviteId) { @@ -41,6 +41,16 @@ export async function ensureMembership( } if (existing) return { membership: existing, joined: false }; + /* + * Someone who deleted their account moments ago can still present an ID + * token that verifies, from a tab left open or another browser. Building + * them a new team from it would quietly undo the deletion, so an account + * deleted recently gets no membership at all. + */ + if (await store.recentlyDeleted(identity.uid, Date.now() - DELETED_ACCOUNT_MEMORY_MS)) { + return null; + } + if (inviteId) { const check = checkInvite(await store.invite(inviteId), identity.email); if (!check.ok) { diff --git a/app/src/App.tsx b/app/src/App.tsx index 2394d7b..ce3e467 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -10,6 +10,7 @@ import { Machines } from "./routes/Machines"; import { Team } from "./routes/Team"; import { Join } from "./routes/Join"; import { Terms } from "./routes/Terms"; +import { Privacy } from "./routes/Privacy"; import { Session } from "./routes/Session"; import { Audit } from "./routes/Audit"; import { CliAuthorize } from "./routes/CliAuthorize"; @@ -85,6 +86,7 @@ export default function App() { } /> {/* Public: it has to be readable before anyone has an account. */} } /> + } /> Promise; resendVerification: () => Promise; signOutUser: () => Promise; + /** + * Deletes the account from shell.online and from Firebase. Signs in again + * first: with the password for an email account, a Google popup otherwise. + */ + deleteAccount: (confirmEmail: string, password?: string) => Promise; } const AuthContext = createContext(null); @@ -108,6 +119,39 @@ export function AuthProvider({ children }: { children: ReactNode }) { await signOut(auth); }, []); + const deleteAccount = useCallback(async (confirmEmail: string, password?: string) => { + const current = auth.currentUser; + if (!current) throw new Error("Sign in first."); + /* + * Proof of presence before anything is deleted. The service refuses a + * sign-in older than ten minutes and Firebase refuses to delete a user + * after about five, so signing in again here, first, means neither can + * refuse halfway through. + */ + if (current.providerData.some((entry) => entry.providerId === "password")) { + if (!password) throw new Error("Enter your password."); + await reauthenticateWithCredential( + current, + EmailAuthProvider.credential(current.email ?? "", password), + ); + } else { + await reauthenticateWithPopup(current, googleProvider); + } + /* A token that carries the sign-in that just happened. */ + await current.getIdToken(true); + /* + * The service first, then the sign-in. The other order, interrupted, + * would leave data behind for an account nobody can sign in to again. + * This one leaves an empty account, and deleting it again finishes the + * job: the service treats a second request as nothing left to remove. + */ + await deleteAccountData(confirmEmail); + forgetAll(); + forgetOpenTabs(current.uid); + await clearLocalVault(current.uid); + await deleteUser(current); + }, []); + const value = useMemo( () => ({ user, @@ -118,6 +162,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { resetPassword, resendVerification, signOutUser, + deleteAccount, }), [ user, @@ -128,6 +173,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { resetPassword, resendVerification, signOutUser, + deleteAccount, ], ); diff --git a/app/src/components/Button.tsx b/app/src/components/Button.tsx index 84f0fb6..81fa837 100644 --- a/app/src/components/Button.tsx +++ b/app/src/components/Button.tsx @@ -1,7 +1,7 @@ import type { ButtonHTMLAttributes, ReactNode, Ref } from "react"; interface ButtonProps extends ButtonHTMLAttributes { - variant?: "primary" | "ghost"; + variant?: "primary" | "ghost" | "danger"; busy?: boolean; busyLabel?: string; children: ReactNode; diff --git a/app/src/components/DeleteAccount.tsx b/app/src/components/DeleteAccount.tsx new file mode 100644 index 0000000..585a4a8 --- /dev/null +++ b/app/src/components/DeleteAccount.tsx @@ -0,0 +1,142 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { FirebaseError } from "firebase/app"; +import { Button } from "./Button"; +import { Field } from "./Field"; +import { Alert } from "./Alert"; +import { useAuth } from "../auth/AuthProvider"; +import { fetchOrg, type OrgView } from "../lib/api"; +import { authErrorMessage } from "../lib/auth-errors"; +import { emailMatches, successorFor } from "../lib/account-deletion"; + +/* + * What deleting an account removes, said before it happens. The list follows + * what the service does (Store.deleteAccount), and the team lines depend on + * who else is in it, so the roster is fetched rather than assumed. + */ +export function DeleteAccount({ onCancel }: { onCancel: () => void }) { + const { user, deleteAccount } = useAuth(); + const navigate = useNavigate(); + const [org, setOrg] = useState(null); + const [confirm, setConfirm] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + useEffect(() => { + let cancelled = false; + fetchOrg() + .then((view) => { + if (!cancelled) setOrg(view); + }) + .catch(() => { + /* The list still reads correctly without the team's details. */ + }); + return () => { + cancelled = true; + }; + }, []); + + if (!user) return null; + + const usesPassword = user.providerData.some((entry) => entry.providerId === "password"); + const teamName = org?.organization.name ?? "your team"; + const others = org ? org.members.filter((entry) => entry.uid !== user.uid) : []; + const successor = org?.you.role === "owner" ? successorFor(org.members, user.uid) : undefined; + const ready = emailMatches(confirm, user.email) && (!usesPassword || password.length > 0); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (!ready) return; + setError(""); + setBusy(true); + try { + await deleteAccount(confirm, usesPassword ? password : undefined); + navigate("/login", { replace: true, state: { deleted: true } }); + } catch (caught) { + setError(deletionError(caught)); + setBusy(false); + } + } + + return ( +
+

Delete your account?

+

This cannot be undone. Deleting your account:

+
    +
  • removes your sign-in;
  • +
  • + unlinks every machine you linked, so shell on them stops + publishing sessions; +
  • +
  • + deletes your sessions, the passwords sealed to you, your vault, your + comments and your notifications; +
  • + {org && others.length === 0 && ( +
  • deletes {teamName} and everything in it, since nobody else is in it;
  • + )} + {others.length > 0 && ( +
  • + keeps what you typed into sessions in {teamName}’s activity + trail, no longer linked to your email address; +
  • + )} + {successor && ( +
  • + makes {successor.name || successor.email} the owner of {teamName}; +
  • + )} +
  • clears your vault key and cached passwords from this browser.
  • +
+

+ Share links you already gave out keep working until those sessions end. + The privacy policy has the details. +

+ + {error && {error}} + +
+ + {usesPassword ? ( + + ) : ( +

You will be asked to sign in with Google once more.

+ )} +
+ + +
+ +
+ ); +} + +function deletionError(error: unknown): string { + if ( + error instanceof FirebaseError && + (error.code === "auth/invalid-credential" || error.code === "auth/wrong-password") + ) { + return "That password is not right."; + } + return authErrorMessage(error); +} diff --git a/app/src/lib/account-deletion.test.ts b/app/src/lib/account-deletion.test.ts new file mode 100644 index 0000000..3e09a96 --- /dev/null +++ b/app/src/lib/account-deletion.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import type { Member } from "./api"; +import { emailMatches, successorFor } from "./account-deletion"; + +function member(uid: string, role: Member["role"], joinedAt: number): Member { + return { orgId: "org_1", uid, email: `${uid}@example.com`, name: uid, role, joinedAt }; +} + +describe("successorFor", () => { + it("hands the team to the longest-standing admin", () => { + const members = [ + member("owner", "owner", 1), + member("early-member", "member", 2), + member("late-admin", "admin", 4), + member("early-admin", "admin", 3), + ]; + expect(successorFor(members, "owner")?.uid).toBe("early-admin"); + }); + + it("falls back to the longest-standing member when there is no admin", () => { + const members = [member("owner", "owner", 1), member("b", "member", 3), member("a", "member", 2)]; + expect(successorFor(members, "owner")?.uid).toBe("a"); + }); + + it("names nobody when the owner is alone", () => { + expect(successorFor([member("owner", "owner", 1)], "owner")).toBeUndefined(); + }); + + it("breaks a tie on the uid, as the service does", () => { + const members = [member("owner", "owner", 1), member("zed", "admin", 2), member("amy", "admin", 2)]; + expect(successorFor(members, "owner")?.uid).toBe("amy"); + }); +}); + +describe("emailMatches", () => { + it("ignores case and surrounding spaces", () => { + expect(emailMatches(" Ana@Example.com ", "ana@example.com")).toBe(true); + }); + + it("refuses anything else", () => { + expect(emailMatches("ana@example.co", "ana@example.com")).toBe(false); + expect(emailMatches("", "")).toBe(false); + expect(emailMatches("ana@example.com", null)).toBe(false); + }); +}); diff --git a/app/src/lib/account-deletion.ts b/app/src/lib/account-deletion.ts new file mode 100644 index 0000000..15d7ae6 --- /dev/null +++ b/app/src/lib/account-deletion.ts @@ -0,0 +1,22 @@ +import type { Member } from "./api"; + +/* + * Mirrors successorFor on the service, so the confirmation can name who takes + * over before anything is deleted. The service decides for itself; this only + * predicts what it will decide. + */ +export function successorFor(members: Member[], leavingUid: string): Member | undefined { + const others = members.filter((entry) => entry.uid !== leavingUid); + const byTenure = (a: Member, b: Member) => + a.joinedAt - b.joinedAt || (a.uid < b.uid ? -1 : a.uid > b.uid ? 1 : 0); + return ( + others.filter((entry) => entry.role === "admin").sort(byTenure)[0] ?? + [...others].sort(byTenure)[0] + ); +} + +/** Whether what was typed is the account's email, ignoring case and spaces. */ +export function emailMatches(typed: string, email: string | null | undefined): boolean { + const expected = (email ?? "").trim().toLowerCase(); + return expected !== "" && typed.trim().toLowerCase() === expected; +} diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts index 92141da..79b0e00 100644 --- a/app/src/lib/api.ts +++ b/app/src/lib/api.ts @@ -268,6 +268,17 @@ async function request(path: string, init: RequestInit = {}): Promise { return body as T; } +/** + * Deletes this account's data from the service. AuthProvider.deleteAccount + * signs in again before calling it and deletes the Firebase account after. + */ +export function deleteAccountData(confirm: string) { + return request<{ deleted: boolean; owner: { uid: string; email: string; name: string } | null }>( + "/api/account", + { method: "DELETE", body: JSON.stringify({ confirm }) }, + ); +} + export interface AuthorizeInput { redirectUri: string; codeChallenge: string; diff --git a/app/src/routes/Account.tsx b/app/src/routes/Account.tsx index 15f0a5d..e182888 100644 --- a/app/src/routes/Account.tsx +++ b/app/src/routes/Account.tsx @@ -1,6 +1,8 @@ import { useState } from "react"; -import { SealCheck, SignOut, Warning } from "@phosphor-icons/react"; +import { Link } from "react-router-dom"; +import { SealCheck, SignOut, Trash, Warning } from "@phosphor-icons/react"; import { AppShell } from "../components/AppShell"; +import { DeleteAccount } from "../components/DeleteAccount"; import { Button } from "../components/Button"; import { Alert } from "../components/Alert"; import { useAuth } from "../auth/AuthProvider"; @@ -24,6 +26,7 @@ export function Account() { const [error, setError] = useState(""); const [busy, setBusy] = useState(false); const [resetting, setResetting] = useState(false); + const [deleting, setDeleting] = useState(false); if (!user) return null; @@ -98,6 +101,13 @@ export function Account() { )} +
+
Your data
+
+ What shell.online keeps, and for how long, is in the{" "} + privacy policy. +
+
{resetting && ( @@ -115,6 +125,8 @@ export function Account() { )} + {deleting && setDeleting(false)} />} + {/* Sign out lives here as well as in the sidebar. On a phone the sidebar becomes a bar of destinations with no room for the account row, and @@ -142,6 +154,12 @@ export function Account() { Sign out + {!deleting && ( + + )} ); diff --git a/app/src/routes/Privacy.tsx b/app/src/routes/Privacy.tsx new file mode 100644 index 0000000..242e04b --- /dev/null +++ b/app/src/routes/Privacy.tsx @@ -0,0 +1,489 @@ +import type { ReactNode } from "react"; +import { Link } from "react-router-dom"; +import { usePageTitle } from "../lib/page-title"; +import { Wordmark } from "../components/Wordmark"; + +/* + * Laid out like the Terms and styled by the same sheet. Every sentence here is + * a claim about what the code does, so a change to what the service keeps, or + * for how long, belongs in the same pull request as a change to this page. + */ +const SECTIONS = [ + { id: "who", title: "Who We Are" }, + { id: "what-we-keep", title: "What We Keep" }, + { id: "never-received", title: "What We Never Receive" }, + { id: "use", title: "How We Use It" }, + { id: "team", title: "What Your Team Sees" }, + { id: "providers", title: "Service Providers" }, + { id: "email", title: "Email" }, + { id: "browser", title: "What Your Browser Stores" }, + { id: "analytics", title: "Analytics" }, + { id: "retention", title: "How Long We Keep It" }, + { id: "deletion", title: "Deleting Your Account" }, + { id: "rights", title: "Your Rights" }, + { id: "security", title: "Security" }, + { id: "transfers", title: "Where Data Is Processed" }, + { id: "children", title: "Children" }, + { id: "changes", title: "Changes to This Policy" }, + { id: "contact", title: "Contact" }, +] as const; + +type SectionId = (typeof SECTIONS)[number]["id"]; + +function numberOf(id: SectionId) { + return SECTIONS.findIndex((section) => section.id === id) + 1; +} + +function Section({ id, children }: { id: SectionId; children: ReactNode }) { + const index = numberOf(id) - 1; + return ( +
+

+ + {SECTIONS[index].title} +

+ {children} +
+ ); +} + +function Ref({ id }: { id: SectionId }) { + return section {numberOf(id)}; +} + +const CONTACT = "founders@pilotprotocol.network"; + +export function Privacy() { + usePageTitle("Privacy policy"); + return ( +
+
+ + + Terms of service + +
+ +
+
+

Privacy Policy

+

+ Effective: 11 September 2026 · Last updated: 11 September 2026 +

+

+ This policy explains what personal data Vulture Labs, Inc.{" "} + (“we,” “us”) keeps when you use + shell.online, the shell command-line tool and the + services behind them (the “Services”): why we keep it, + who else handles it, how long it is kept, and how to delete it. +

+

+ It sits beside the Terms of Service, which + describe the same system from the other side. Where the two use the + same words, they mean the same things. +

+
+ + + +
+

+ shell.online is operated by Vulture Labs, Inc., a Delaware + corporation trading as Pilot Protocol. We decide what data the + Services keep and why, which makes us responsible for it. You can + reach us at {CONTACT}. +

+

+ You can use the shell CLI without an account. Without + one, nothing in is kept about you; the + relay only carries your encrypted terminal traffic. +

+
+ +
+

With an account, the Services keep the following.

+
+
+
Your account
+
+ Your email address, your name if you give one, and how you sign + in: email and password, or Google. Google’s Firebase + Authentication holds the account and your password. We never + see the password. +
+
+
+
Your team
+
+ Its name, its members’ names, email addresses and roles, + and its invitations, including the email address an invitation + was sent to. +
+
+
+
Linked machines
+
+ For each machine you link with shell login: a + label, a random machine identifier, when it was linked and last + seen, the public key used to seal session passwords to it, and + which coding-agent commands it found on its PATH. + Of its tokens we store only SHA-256 hashes. +
+
+
+
Session records
+
+ For each session: the share link with its encryption key + removed, the command line as written, the machine’s host + name, the session name, its flags, when it started and ended, + and its exit code. +
+
+
+
What is typed from a browser
+
+ Commands, prompts and anything else entered into a session from + a browser, stored in plaintext, as the Terms describe. What is + typed in the terminal a session was started from is not sent to + us. +
+
+
+
Collaboration
+
+ Comments, @mentions, notifications, assignments and handoffs. +
+
+
+
Your session vault
+
+ Its public key, and its private key encrypted under a key we + never receive. We cannot open it. +
+
+
+
Sealed passwords
+
+ Session passwords sealed to you or to your machines. We store + and pass them on, but cannot open them. +
+
+
+
Browser-started sessions
+
+ When you start a session from a browser, its command line and a + sealed password wait for your machine to collect them. +
+
+
+
+ +
+

+ Terminal output, which is end-to-end encrypted and reaches us only + as ciphertext. The encryption key in a share link, which browsers + never send to a server. Session passwords in a form we can read. + Your vault’s recovery key. The contents of your files and + projects. +

+

+ We use no advertising trackers, and the web app sets no cookies of + its own. +

+
+ +
+
    +
  • + To provide the Services: signing you in, showing your team its + sessions, relaying terminal traffic, and delivering sealed + passwords to the machines that need them. +
  • +
  • + To keep them working and safe: limiting how often one network + address may call the service (the address is held in memory for + that and not stored), and asking for a recent sign-in before a + change that cannot be undone. +
  • +
  • To send the email described in .
  • +
+

+ We do not sell personal data, and we do not share it for + advertising. +

+
+ +
+

+ A team is a shared workspace. Everyone in your team can see every + member’s session records, what was typed into sessions from a + browser, comments and handoff history, and the names, email + addresses and roles of the team’s members. Anyone you give a + share link and its password can watch that session, and type into + it unless it is read-only. +

+
+ +
+

These companies handle data for us to run the Services:

+
+
+
Google
+
+ Firebase Authentication, for accounts, sign-in, and the emails + that verify an address or reset a password. Google Cloud, in + the United States, for the database and the machine that + connects to it. +
+
+
+
Cloudflare
+
+ Hosts the web app, the service behind it and the relay that + carries encrypted terminal traffic, and carries the network + traffic to all three. +
+
+
+
Twilio SendGrid
+
Sends team invitation emails.
+
+
+

+ Each handles data only to provide its service to us. We do not + share personal data with anyone else, except where the law + requires it. +

+
+ +
+

+ Firebase sends the email that verifies your address and the one + that resets your password. SendGrid sends team invitations. An + invitation contains the inviter’s name, the team’s name + and a link to join, with click and open tracking turned off. We + send no marketing email. +

+
+ +
+

The web app keeps these in your browser:

+
+
+
Sign-in state
+
Firebase keeps you signed in using the browser’s local storage.
+
+
+
Your unlocked vault
+
+ A key held in IndexedDB that script cannot read out, so the + browser can open session passwords without asking for your + recovery key every time. Signing out removes it. +
+
+
+
Cached session passwords
+
In local storage, kept apart for each account.
+
+
+
Tabs and view
+
+ Which sessions you had open, and whether you use the list or + the board. +
+
+
+
An older key pair
+
+ A browser used before the vault existed may still hold one in + local storage. +
+
+
+

+ Deleting your account clears the first four from the browser you + delete it from. Clearing this site’s data in your browser + clears all of them. +

+
+ +
+

+ The web app runs no analytics. The shell.online site and the relay + count events such as page views, installer downloads and sessions + opened, with a device class, a client name and the referring site. + They record no IP addresses, session identifiers, URLs, commands, + terminal content or full user-agent strings. +

+
+ +
+
    +
  • + Account, team, machine and session records: until you delete + them, or delete your account. +
  • +
  • + The codes that complete shell login expire within + minutes. A browser-started session’s command is deleted ten + minutes after the machine finishes it. +
  • +
  • + An unlinked machine’s token stops working at once. The + record that it was linked stays until you delete your account. +
  • +
  • + The activity trail, comments and handoffs stay with the team for + as long as the team exists. +
  • +
  • + When an account is deleted, its user identifier alone is kept for + two hours, so a browser still signed in to it cannot bring it + back. +
  • +
  • + The database keeps seven daily backups and seven days of + transaction logs, so deleted data is gone from backups within + eight days. +
  • +
  • + The service keeps no request logs of its own. When something + fails, it writes an error message to our hosting provider’s + logs, which can include details of the request that failed, such + as the address of an invitation that could not be sent. +
  • +
+
+ +
+

+ Delete your account from the Account page. You type your email + address and sign in once more, and then: +

+
    +
  • your account is removed from Firebase Authentication;
  • +
  • every machine you linked is unlinked, and its tokens stop working;
  • +
  • + your session records, and the session passwords sealed to you, + are deleted; +
  • +
  • your vault, comments and notifications are deleted;
  • +
  • if nobody else is in your team, the team and everything in it is deleted;
  • +
  • + if others are, what you typed into sessions stays in the + team’s activity trail, no longer linked to your email + address, because it is part of the record of what happened on + their machines; +
  • +
  • + if you owned the team, ownership passes to its longest-standing + admin or, if it has none, its longest-standing member; +
  • +
  • + this browser’s copies of your vault key, cached passwords + and open tabs are cleared. +
  • +
+

+ Processes on your machines keep running, and share links you gave + out keep working until those sessions end. Anything a teammate + already exported, such as an audit CSV, is theirs, and we cannot + recall it. +

+

+ If you cannot sign in, email{" "} + {CONTACT} from the address on the + account and we will delete it for you. +

+
+ +
+

+ Depending on where you live, you may have the right to access, + correct, export or delete your personal data, to object to or + restrict how we use it, and to complain to a data protection + authority. Deletion is in the app. For anything else, email{" "} + {CONTACT}. We answer within 30 + days, and we will not treat you differently for asking. +

+

+ If you are in the European Economic Area or the United Kingdom: we + keep account, team and session data because it is needed to provide + the Services you asked for. We keep the team’s activity trail + and the safeguards described above because a team has a legitimate + interest in a record of what was done on its machines, and in a + service that resists abuse. +

+
+ +
+

+ Terminal traffic is end-to-end encrypted between your browser and + your machine. Traffic to the service uses TLS. Machine tokens are + stored as hashes, and your vault is encrypted under a key we never + receive. No system is perfectly secure; if you find a weakness, + tell us at {CONTACT}. +

+
+ +
+

+ The database is in the United States, and Cloudflare handles + traffic in data centers around the world. If you use the Services + from outside the United States, your data is transferred to and + processed there. +

+
+ +
+

+ The Services are not directed at children under 16, and we do not + knowingly keep data about them. If you believe a child has an + account, email us and we will delete it. +

+
+ +
+

+ We will post changes to this page and update the “Last + updated” date. For material changes, we will give additional + notice, by a banner in the app or by email. +

+
+ +
+

+ Email: {CONTACT} +

+

+ shell.online is operated by Vulture Labs, Inc., a Delaware + corporation, trading as Pilot Protocol,{" "} + + pilotprotocol.network + + . +

+
+ +
+ Terms of service + + Create an account + + Sign in +
+
+
+ ); +} diff --git a/app/src/routes/SignIn.tsx b/app/src/routes/SignIn.tsx index 085865f..aec9873 100644 --- a/app/src/routes/SignIn.tsx +++ b/app/src/routes/SignIn.tsx @@ -16,6 +16,8 @@ export function SignIn() { const location = useLocation(); const destination = (location.state as { from?: string } | null)?.from ?? "/sessions"; + /* Set by the account page after a deletion, so the person is told it worked. */ + const deleted = (location.state as { deleted?: boolean } | null)?.deleted === true; const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -82,7 +84,15 @@ export function SignIn() { . } + legal={ + <> + The terms and the{" "} + privacy policy describe what shell.online + keeps and why. + + } > + {deleted && !formError && Your account has been deleted.} {formError && {formError}}
diff --git a/app/src/routes/SignUp.tsx b/app/src/routes/SignUp.tsx index 093377c..8296972 100644 --- a/app/src/routes/SignUp.tsx +++ b/app/src/routes/SignUp.tsx @@ -224,6 +224,10 @@ export function SignUp() { I accept the{" "} terms of service + {" "} + and have read the{" "} + + privacy policy . diff --git a/app/src/routes/Terms.tsx b/app/src/routes/Terms.tsx index c6bd0ca..e9974aa 100644 --- a/app/src/routes/Terms.tsx +++ b/app/src/routes/Terms.tsx @@ -97,7 +97,7 @@ export function Terms() {

Terms of Service

- Effective: 7 September 2026 · Last updated: 7 September 2026 + Effective: 7 September 2026 · Last updated: 11 September 2026

These Terms of Service (“Terms”) are a binding agreement @@ -114,6 +114,8 @@ export function Terms() { process running on your own machine through a browser link, and these Terms are written to describe plainly what the software does on your machine, what leaves it, and what we each owe the other. + The Privacy Policy describes the + personal data we keep, who else handles it, and for how long.

@@ -653,6 +655,11 @@ export function Terms() { valid on our side until it expires, so prefer{" "} shell logout.

+

+ You can delete your account from the Account page. The{" "} + Privacy Policy sets out what + that removes and what stays with your team. +

We may suspend or terminate access to an account at any time for violation of these Terms, or where we need to in order to protect @@ -723,6 +730,8 @@ export function Terms() { Create an account Sign in + + Privacy policy diff --git a/app/src/styles/auth.css b/app/src/styles/auth.css index 945d474..6af0827 100644 --- a/app/src/styles/auth.css +++ b/app/src/styles/auth.css @@ -309,6 +309,19 @@ transform: translateY(0); } +/* Irreversible actions: quiet until pointed at, so never the obvious button. */ +.btn-danger { + border-color: color-mix(in srgb, var(--danger) 45%, var(--line)); + background: var(--white); + color: var(--danger); +} + +.btn-danger:hover:not(:disabled) { + border-color: var(--danger); + background: var(--danger); + color: var(--white); +} + .btn:disabled { cursor: not-allowed; opacity: 0.55; @@ -531,6 +544,48 @@ min-width: 168px; } +.account-delete { + margin-top: 28px; +} + +.account-delete h2 { + margin: 0 0 10px; + font-size: 18px; +} + +.account-delete-list { + margin: 10px 0 14px; + padding-left: 20px; + color: var(--ink-soft); + font-size: 14px; + line-height: 1.6; +} + +/* The card's own paragraph rule narrows and tightens text meant for a dialog. */ +.account-delete p { + max-width: none; +} + +.account-delete .account-delete-note { + max-width: none; + margin: 0 0 22px; + color: var(--faint); + font-size: 13px; + line-height: 1.55; +} + +.account-delete-actions { + display: flex; + margin-top: 18px; + flex-wrap: wrap; + gap: 12px; +} + +.account-delete-actions .btn { + width: auto; + min-width: 168px; +} + /* --------------------------------------------------------------- Route-level loading --------------------------------------------------------------- */ diff --git a/app/src/terminal/tab-store.ts b/app/src/terminal/tab-store.ts index 77160a3..a2eddf7 100644 --- a/app/src/terminal/tab-store.ts +++ b/app/src/terminal/tab-store.ts @@ -38,6 +38,18 @@ export function readOpenTabs(uid: string): OpenTabs { } } +/** Forgets the open tabs, if they are this account's. Used when it is deleted. */ +export function forgetOpenTabs(uid: string): void { + try { + const raw = window.localStorage.getItem(KEY); + if (!raw) return; + const held = JSON.parse(raw) as { uid?: string }; + if (held.uid === uid) window.localStorage.removeItem(KEY); + } catch { + /* nothing to clear */ + } +} + export function writeOpenTabs(uid: string, open: OpenTabs): void { try { window.localStorage.setItem(KEY, JSON.stringify({ uid, ...open }));