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}}
+
+
+
+ );
+}
+
+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 (
+
+
+ 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.
+