diff --git a/CHANGELOG.md b/CHANGELOG.md index 077a8a0..01cc306 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,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 02e9cee..39a0444 100644 --- a/app/src/lib/api.ts +++ b/app/src/lib/api.ts @@ -284,6 +284,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 }));