From 710ab06333336f010c6918a77f45332e2567bcc2 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 11:50:14 +0200 Subject: [PATCH 01/12] feat(db): retention schema for account deletion (migration 00007) Additive/expand-contract: new append-only retained_identities table (the legal dossier of every detached credential, keyed by account_id, TTL'd by detached_at) plus nullable accounts columns last_login_at/last_login_ip (cold-load stamp) and deleted_at/deleted_display_name (tombstone + retained real name). Regenerates the go-jet models for accounts + retained_identities only. --- .../postgres/jet/backend/model/accounts.go | 4 + .../jet/backend/model/retained_identities.go | 24 +++++ .../postgres/jet/backend/table/accounts.go | 16 ++- .../jet/backend/table/retained_identities.go | 99 +++++++++++++++++++ .../jet/backend/table/table_use_schema.go | 1 + .../00007_account_deletion_retention.sql | 47 +++++++++ 6 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 backend/internal/postgres/jet/backend/model/retained_identities.go create mode 100644 backend/internal/postgres/jet/backend/table/retained_identities.go create mode 100644 backend/internal/postgres/migrations/00007_account_deletion_retention.sql diff --git a/backend/internal/postgres/jet/backend/model/accounts.go b/backend/internal/postgres/jet/backend/model/accounts.go index c69445d..b091195 100644 --- a/backend/internal/postgres/jet/backend/model/accounts.go +++ b/backend/internal/postgres/jet/backend/model/accounts.go @@ -32,4 +32,8 @@ type Accounts struct { MergedAt *time.Time FlaggedHighRateAt *time.Time VariantPreferences pq.StringArray + LastLoginAt *time.Time + LastLoginIP *string + DeletedAt *time.Time + DeletedDisplayName *string } diff --git a/backend/internal/postgres/jet/backend/model/retained_identities.go b/backend/internal/postgres/jet/backend/model/retained_identities.go new file mode 100644 index 0000000..15d0e36 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/retained_identities.go @@ -0,0 +1,24 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "github.com/google/uuid" + "time" +) + +type RetainedIdentities struct { + RetainedID uuid.UUID `sql:"primary_key"` + AccountID uuid.UUID + Kind string + ExternalID string + Confirmed bool + LinkedAt time.Time + DetachedAt time.Time + Reason string +} diff --git a/backend/internal/postgres/jet/backend/table/accounts.go b/backend/internal/postgres/jet/backend/table/accounts.go index 3cafd35..8ca2ca5 100644 --- a/backend/internal/postgres/jet/backend/table/accounts.go +++ b/backend/internal/postgres/jet/backend/table/accounts.go @@ -35,6 +35,10 @@ type accountsTable struct { MergedAt postgres.ColumnTimestampz FlaggedHighRateAt postgres.ColumnTimestampz VariantPreferences postgres.ColumnStringArray + LastLoginAt postgres.ColumnTimestampz + LastLoginIP postgres.ColumnString + DeletedAt postgres.ColumnTimestampz + DeletedDisplayName postgres.ColumnString AllColumns postgres.ColumnList MutableColumns postgres.ColumnList @@ -94,8 +98,12 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable { MergedAtColumn = postgres.TimestampzColumn("merged_at") FlaggedHighRateAtColumn = postgres.TimestampzColumn("flagged_high_rate_at") VariantPreferencesColumn = postgres.StringArrayColumn("variant_preferences") - allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn} - mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn} + LastLoginAtColumn = postgres.TimestampzColumn("last_login_at") + LastLoginIPColumn = postgres.StringColumn("last_login_ip") + DeletedAtColumn = postgres.TimestampzColumn("deleted_at") + DeletedDisplayNameColumn = postgres.StringColumn("deleted_display_name") + allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn, LastLoginAtColumn, LastLoginIPColumn, DeletedAtColumn, DeletedDisplayNameColumn} + mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn, LastLoginAtColumn, LastLoginIPColumn, DeletedAtColumn, DeletedDisplayNameColumn} defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, VariantPreferencesColumn} ) @@ -121,6 +129,10 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable { MergedAt: MergedAtColumn, FlaggedHighRateAt: FlaggedHighRateAtColumn, VariantPreferences: VariantPreferencesColumn, + LastLoginAt: LastLoginAtColumn, + LastLoginIP: LastLoginIPColumn, + DeletedAt: DeletedAtColumn, + DeletedDisplayName: DeletedDisplayNameColumn, AllColumns: allColumns, MutableColumns: mutableColumns, diff --git a/backend/internal/postgres/jet/backend/table/retained_identities.go b/backend/internal/postgres/jet/backend/table/retained_identities.go new file mode 100644 index 0000000..17851eb --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/retained_identities.go @@ -0,0 +1,99 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var RetainedIdentities = newRetainedIdentitiesTable("backend", "retained_identities", "") + +type retainedIdentitiesTable struct { + postgres.Table + + // Columns + RetainedID postgres.ColumnString + AccountID postgres.ColumnString + Kind postgres.ColumnString + ExternalID postgres.ColumnString + Confirmed postgres.ColumnBool + LinkedAt postgres.ColumnTimestampz + DetachedAt postgres.ColumnTimestampz + Reason postgres.ColumnString + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type RetainedIdentitiesTable struct { + retainedIdentitiesTable + + EXCLUDED retainedIdentitiesTable +} + +// AS creates new RetainedIdentitiesTable with assigned alias +func (a RetainedIdentitiesTable) AS(alias string) *RetainedIdentitiesTable { + return newRetainedIdentitiesTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new RetainedIdentitiesTable with assigned schema name +func (a RetainedIdentitiesTable) FromSchema(schemaName string) *RetainedIdentitiesTable { + return newRetainedIdentitiesTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new RetainedIdentitiesTable with assigned table prefix +func (a RetainedIdentitiesTable) WithPrefix(prefix string) *RetainedIdentitiesTable { + return newRetainedIdentitiesTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new RetainedIdentitiesTable with assigned table suffix +func (a RetainedIdentitiesTable) WithSuffix(suffix string) *RetainedIdentitiesTable { + return newRetainedIdentitiesTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newRetainedIdentitiesTable(schemaName, tableName, alias string) *RetainedIdentitiesTable { + return &RetainedIdentitiesTable{ + retainedIdentitiesTable: newRetainedIdentitiesTableImpl(schemaName, tableName, alias), + EXCLUDED: newRetainedIdentitiesTableImpl("", "excluded", ""), + } +} + +func newRetainedIdentitiesTableImpl(schemaName, tableName, alias string) retainedIdentitiesTable { + var ( + RetainedIDColumn = postgres.StringColumn("retained_id") + AccountIDColumn = postgres.StringColumn("account_id") + KindColumn = postgres.StringColumn("kind") + ExternalIDColumn = postgres.StringColumn("external_id") + ConfirmedColumn = postgres.BoolColumn("confirmed") + LinkedAtColumn = postgres.TimestampzColumn("linked_at") + DetachedAtColumn = postgres.TimestampzColumn("detached_at") + ReasonColumn = postgres.StringColumn("reason") + allColumns = postgres.ColumnList{RetainedIDColumn, AccountIDColumn, KindColumn, ExternalIDColumn, ConfirmedColumn, LinkedAtColumn, DetachedAtColumn, ReasonColumn} + mutableColumns = postgres.ColumnList{AccountIDColumn, KindColumn, ExternalIDColumn, ConfirmedColumn, LinkedAtColumn, DetachedAtColumn, ReasonColumn} + defaultColumns = postgres.ColumnList{ConfirmedColumn, DetachedAtColumn} + ) + + return retainedIdentitiesTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + RetainedID: RetainedIDColumn, + AccountID: AccountIDColumn, + Kind: KindColumn, + ExternalID: ExternalIDColumn, + Confirmed: ConfirmedColumn, + LinkedAt: LinkedAtColumn, + DetachedAt: DetachedAtColumn, + Reason: ReasonColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/internal/postgres/jet/backend/table/table_use_schema.go b/backend/internal/postgres/jet/backend/table/table_use_schema.go index 19113ed..540bcec 100644 --- a/backend/internal/postgres/jet/backend/table/table_use_schema.go +++ b/backend/internal/postgres/jet/backend/table/table_use_schema.go @@ -34,6 +34,7 @@ func UseSchema(schema string) { GameSetupDraws = GameSetupDraws.FromSchema(schema) Games = Games.FromSchema(schema) Identities = Identities.FromSchema(schema) + RetainedIdentities = RetainedIdentities.FromSchema(schema) Sessions = Sessions.FromSchema(schema) SuspensionReasons = SuspensionReasons.FromSchema(schema) } diff --git a/backend/internal/postgres/migrations/00007_account_deletion_retention.sql b/backend/internal/postgres/migrations/00007_account_deletion_retention.sql new file mode 100644 index 0000000..9cfa130 --- /dev/null +++ b/backend/internal/postgres/migrations/00007_account_deletion_retention.sql @@ -0,0 +1,47 @@ +-- Account deletion as legal retention (not erasure). Two additive pieces. +-- +-- retained_identities is an append-only journal of every credential detached from an +-- account — on unlink, email change, or account deletion (reason). It preserves the legal +-- dossier (which email/vk/tg was linked, and when) while the live identities row is +-- removed, so the (kind, external_id) frees for a new account to reuse. No unique +-- constraint and no kind CHECK: the same credential may recur across accounts and events, +-- and the log stays robust to future identity kinds. detached_at drives the retention TTL. +-- +-- accounts gains: last_login_at / last_login_ip (stamped on a cold app-load, throttled), +-- deleted_at (the tombstone marker), and deleted_display_name (the real name retained for +-- the admin dossier after the live display_name is scrubbed to the anonymised label). +-- +-- Expand-contract: everything is additive (a new table plus nullable columns), so a +-- backend image rollback stays DB-safe — older code simply ignores them. The accounts +-- table shape changes, so its generated go-jet model is regenerated. + +-- +goose Up +CREATE TABLE backend.retained_identities ( + retained_id uuid NOT NULL, + account_id uuid NOT NULL, + kind text NOT NULL, + external_id text NOT NULL, + confirmed boolean DEFAULT false NOT NULL, + linked_at timestamp with time zone NOT NULL, + detached_at timestamp with time zone DEFAULT now() NOT NULL, + reason text NOT NULL, + CONSTRAINT retained_identities_pkey PRIMARY KEY (retained_id), + CONSTRAINT retained_identities_reason_chk + CHECK ((reason = ANY (ARRAY['unlink'::text, 'change'::text, 'delete'::text]))) +); +CREATE INDEX retained_identities_account_id_idx ON backend.retained_identities (account_id); +CREATE INDEX retained_identities_detached_at_idx ON backend.retained_identities (detached_at); + +ALTER TABLE backend.accounts + ADD COLUMN last_login_at timestamp with time zone, + ADD COLUMN last_login_ip text, + ADD COLUMN deleted_at timestamp with time zone, + ADD COLUMN deleted_display_name text; + +-- +goose Down +ALTER TABLE backend.accounts + DROP COLUMN last_login_at, + DROP COLUMN last_login_ip, + DROP COLUMN deleted_at, + DROP COLUMN deleted_display_name; +DROP TABLE backend.retained_identities; -- 2.52.0 From 15986460214b514f1b851b94ae89757659cb86a7 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 11:52:48 +0200 Subject: [PATCH 02/12] feat(account): journal detached credentials to the retention log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On unlink (RemoveIdentity, reason=unlink) and email change (replaceEmailIdentity, reason=change) write the outgoing credential to retained_identities before removing the live identities row — so the legal dossier survives while the (kind, external_id) frees for reuse. Same transaction, so the dossier and live state cannot diverge. Integration tests cover both reasons. --- backend/internal/account/email.go | 19 +++++ backend/internal/account/link.go | 14 +++- backend/internal/account/retention.go | 43 ++++++++++ backend/internal/inttest/retention_test.go | 92 ++++++++++++++++++++++ 4 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 backend/internal/account/retention.go create mode 100644 backend/internal/inttest/retention_test.go diff --git a/backend/internal/account/email.go b/backend/internal/account/email.go index 3b65cbb..1b1420f 100644 --- a/backend/internal/account/email.go +++ b/backend/internal/account/email.go @@ -546,6 +546,25 @@ func (s *Store) replaceEmailIdentity(ctx context.Context, confirmationID, accoun if _, err := upd.ExecContext(ctx, tx); err != nil { return fmt.Errorf("consume confirmation: %w", err) } + // Journal the outgoing email before replacing it, so the legal dossier keeps the + // address the account used to hold (see retention.go). + var old model.Identities + sel := postgres.SELECT( + table.Identities.ExternalID, table.Identities.Confirmed, table.Identities.CreatedAt, + ).FROM(table.Identities).WHERE( + table.Identities.AccountID.EQ(postgres.UUID(accountID)). + AND(table.Identities.Kind.EQ(postgres.String(KindEmail))), + ).LIMIT(1) + switch err := sel.QueryContext(ctx, tx, &old); { + case err == nil: + if err := retainIdentityTx(ctx, tx, accountID, KindEmail, old.ExternalID, old.Confirmed, old.CreatedAt, retainChange); err != nil { + return err + } + case errors.Is(err, qrm.ErrNoRows): + // No prior email (this doubles as an attach); nothing to retain. + default: + return fmt.Errorf("load outgoing email identity: %w", err) + } del := table.Identities.DELETE().WHERE( table.Identities.AccountID.EQ(postgres.UUID(accountID)). AND(table.Identities.Kind.EQ(postgres.String(KindEmail))), diff --git a/backend/internal/account/link.go b/backend/internal/account/link.go index fe21dc7..fc9db93 100644 --- a/backend/internal/account/link.go +++ b/backend/internal/account/link.go @@ -31,21 +31,29 @@ func (s *Store) RemoveIdentity(ctx context.Context, accountID uuid.UUID, kind st if err != nil { return err } - hasKind, others := false, 0 + var toRetain []Identity + others := 0 for _, id := range ids { if id.Kind == kind { - hasKind = true + toRetain = append(toRetain, id) } else { others++ } } - if !hasKind { + if len(toRetain) == 0 { return ErrNotFound } if others == 0 { return ErrLastIdentity } return withTx(ctx, s.db, func(tx *sql.Tx) error { + // Journal the detached credential before removing it, so the legal dossier + // survives while the identity frees for reuse (see retention.go). + for _, id := range toRetain { + if err := retainIdentityTx(ctx, tx, accountID, id.Kind, id.ExternalID, id.Confirmed, id.CreatedAt, retainUnlink); err != nil { + return err + } + } delID := table.Identities.DELETE().WHERE( table.Identities.AccountID.EQ(postgres.UUID(accountID)). AND(table.Identities.Kind.EQ(postgres.String(kind))), diff --git a/backend/internal/account/retention.go b/backend/internal/account/retention.go new file mode 100644 index 0000000..8e60945 --- /dev/null +++ b/backend/internal/account/retention.go @@ -0,0 +1,43 @@ +package account + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/google/uuid" + + "scrabble/backend/internal/postgres/jet/backend/table" +) + +// Reasons recorded on a retained_identities row: what detached the credential from its +// account. The row is written just before the live identities row is removed, preserving +// the legal dossier (which email/vk/tg was linked, and when) even as the identity frees +// for reuse. See docs/ARCHITECTURE.md §9.1. +const ( + retainUnlink = "unlink" + retainChange = "change" + retainDelete = "delete" +) + +// retainIdentityTx appends a retention-journal row for one identity being detached, inside +// tx. linkedAt is the identity's original creation time; detached_at defaults to now(). It +// must run in the same transaction as the identity removal, so the dossier and the live +// state can never diverge. +func retainIdentityTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind, externalID string, confirmed bool, linkedAt time.Time, reason string) error { + id, err := uuid.NewV7() + if err != nil { + return fmt.Errorf("account: new retained id: %w", err) + } + ins := table.RetainedIdentities.INSERT( + table.RetainedIdentities.RetainedID, table.RetainedIdentities.AccountID, + table.RetainedIdentities.Kind, table.RetainedIdentities.ExternalID, + table.RetainedIdentities.Confirmed, table.RetainedIdentities.LinkedAt, + table.RetainedIdentities.Reason, + ).VALUES(id, accountID, kind, externalID, confirmed, linkedAt, reason) + if _, err := ins.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("account: retain identity (%s, %s): %w", kind, externalID, err) + } + return nil +} diff --git a/backend/internal/inttest/retention_test.go b/backend/internal/inttest/retention_test.go new file mode 100644 index 0000000..95fbba6 --- /dev/null +++ b/backend/internal/inttest/retention_test.go @@ -0,0 +1,92 @@ +//go:build integration + +package inttest + +import ( + "context" + "testing" + + "github.com/google/uuid" + + "scrabble/backend/internal/account" +) + +// retainedRow is one row of the retention journal, read directly for assertions. +type retainedRow struct { + kind, externalID, reason string +} + +// retainedRows reads the retention journal for an account, oldest detach first. +func retainedRows(t *testing.T, accountID uuid.UUID) []retainedRow { + t.Helper() + rows, err := testDB.QueryContext(context.Background(), + "SELECT kind, external_id, reason FROM retained_identities WHERE account_id = $1 ORDER BY detached_at", + accountID) + if err != nil { + t.Fatalf("query retained_identities %s: %v", accountID, err) + } + defer rows.Close() + var out []retainedRow + for rows.Next() { + var r retainedRow + if err := rows.Scan(&r.kind, &r.externalID, &r.reason); err != nil { + t.Fatalf("scan retained row: %v", err) + } + out = append(out, r) + } + return out +} + +// TestUnlinkJournalsRetainedIdentity: detaching a provider records it in the retention +// journal (reason=unlink) before the live identity is removed. +func TestUnlinkJournalsRetainedIdentity(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + + tgExt := "tg-" + uuid.NewString() + acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, tgExt) + if err != nil { + t.Fatalf("provision: %v", err) + } + email := "keep-" + uuid.NewString() + "@example.com" + if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, email, true); err != nil { + t.Fatalf("attach email: %v", err) + } + + if err := store.RemoveIdentity(ctx, acc.ID, account.KindTelegram); err != nil { + t.Fatalf("unlink telegram: %v", err) + } + got := retainedRows(t, acc.ID) + if len(got) != 1 || got[0].kind != account.KindTelegram || got[0].externalID != tgExt || got[0].reason != "unlink" { + t.Fatalf("retained rows = %+v, want one unlink telegram %q", got, tgExt) + } +} + +// TestChangeEmailJournalsOldAddress: an email change records the outgoing address in the +// retention journal (reason=change). +func TestChangeEmailJournalsOldAddress(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + mailer := &capturingMailer{} + svc := account.NewEmailService(store, mailer, "https://erudit-game.ru") + + acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString()) + if err != nil { + t.Fatalf("provision: %v", err) + } + oldAddr := "old-" + uuid.NewString() + "@example.com" + if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, oldAddr, true); err != nil { + t.Fatalf("attach old email: %v", err) + } + newAddr := "new-" + uuid.NewString() + "@example.com" + if err := svc.RequestChangeCode(ctx, acc.ID, newAddr); err != nil { + t.Fatalf("request change: %v", err) + } + if _, err := svc.ConfirmChange(ctx, acc.ID, newAddr, sixDigit.FindString(mailer.lastBody)); err != nil { + t.Fatalf("confirm change: %v", err) + } + got := retainedRows(t, acc.ID) + if len(got) != 1 || got[0].kind != account.KindEmail || got[0].externalID != oldAddr || got[0].reason != "change" { + t.Fatalf("retained rows = %+v, want one change email %q", got, oldAddr) + } +} -- 2.52.0 From 12af378b184b764c378b1e9a90537c9d5cc39a23 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 11:54:37 +0200 Subject: [PATCH 03/12] feat(account): stamp last-login time + IP on cold app-load The profile GET (fetched once per cold app-load by the SPA) stamps accounts .last_login_at/.last_login_ip, throttled to at most once per hour per account (best-effort, never blocks the read). IP from the gateway-forwarded X-Forwarded-For. Feeds the account-deletion dossier. Integration test covers the throttle. --- backend/internal/account/retention.go | 22 +++++++++++++ backend/internal/inttest/retention_test.go | 36 ++++++++++++++++++++++ backend/internal/server/handlers_user.go | 4 +++ 3 files changed, 62 insertions(+) diff --git a/backend/internal/account/retention.go b/backend/internal/account/retention.go index 8e60945..6014718 100644 --- a/backend/internal/account/retention.go +++ b/backend/internal/account/retention.go @@ -6,6 +6,7 @@ import ( "fmt" "time" + "github.com/go-jet/jet/v2/postgres" "github.com/google/uuid" "scrabble/backend/internal/postgres/jet/backend/table" @@ -41,3 +42,24 @@ func retainIdentityTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind } return nil } + +// StampLastLogin records the account's last cold-load time and client IP, but only when +// the stored value is missing or older than an hour — so it costs at most one write per +// account per hour (its caller, the profile fetch, runs once per cold app-load). It is a +// best-effort audit signal that feeds the account-deletion dossier. +func (s *Store) StampLastLogin(ctx context.Context, accountID uuid.UUID, ip string) error { + now := time.Now().UTC() + upd := table.Accounts.UPDATE(table.Accounts.LastLoginAt, table.Accounts.LastLoginIP). + SET(postgres.TimestampzT(now), postgres.String(ip)). + WHERE( + table.Accounts.AccountID.EQ(postgres.UUID(accountID)). + AND( + table.Accounts.LastLoginAt.IS_NULL(). + OR(table.Accounts.LastLoginAt.LT(postgres.TimestampzT(now.Add(-time.Hour)))), + ), + ) + if _, err := upd.ExecContext(ctx, s.db); err != nil { + return fmt.Errorf("account: stamp last login %s: %w", accountID, err) + } + return nil +} diff --git a/backend/internal/inttest/retention_test.go b/backend/internal/inttest/retention_test.go index 95fbba6..9b5e440 100644 --- a/backend/internal/inttest/retention_test.go +++ b/backend/internal/inttest/retention_test.go @@ -4,6 +4,7 @@ package inttest import ( "context" + "database/sql" "testing" "github.com/google/uuid" @@ -11,6 +12,18 @@ import ( "scrabble/backend/internal/account" ) +// lastLoginIP reads an account's stamped last-login IP ("" when unset). +func lastLoginIP(t *testing.T, accountID uuid.UUID) string { + t.Helper() + var ip sql.NullString + err := testDB.QueryRowContext(context.Background(), + "SELECT last_login_ip FROM accounts WHERE account_id = $1", accountID).Scan(&ip) + if err != nil { + t.Fatalf("read last_login_ip %s: %v", accountID, err) + } + return ip.String +} + // retainedRow is one row of the retention journal, read directly for assertions. type retainedRow struct { kind, externalID, reason string @@ -90,3 +103,26 @@ func TestChangeEmailJournalsOldAddress(t *testing.T) { t.Fatalf("retained rows = %+v, want one change email %q", got, oldAddr) } } + +// TestStampLastLoginThrottles: the first cold-load stamp writes the IP; a second within +// the hour is a no-op (throttled), so it costs at most one write per account per hour. +func TestStampLastLoginThrottles(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString()) + if err != nil { + t.Fatalf("provision: %v", err) + } + if err := store.StampLastLogin(ctx, acc.ID, "1.2.3.4"); err != nil { + t.Fatalf("first stamp: %v", err) + } + if got := lastLoginIP(t, acc.ID); got != "1.2.3.4" { + t.Fatalf("first stamp ip = %q, want 1.2.3.4", got) + } + if err := store.StampLastLogin(ctx, acc.ID, "9.9.9.9"); err != nil { + t.Fatalf("second stamp: %v", err) + } + if got := lastLoginIP(t, acc.ID); got != "1.2.3.4" { + t.Fatalf("throttled ip = %q, want unchanged 1.2.3.4", got) + } +} diff --git a/backend/internal/server/handlers_user.go b/backend/internal/server/handlers_user.go index 9faf71e..f5bfaca 100644 --- a/backend/internal/server/handlers_user.go +++ b/backend/internal/server/handlers_user.go @@ -25,6 +25,10 @@ func (s *Server) handleProfile(c *gin.Context) { s.abortErr(c, err) return } + // The SPA fetches the profile once per cold app-load, so stamp the account's last + // login time and client IP here (throttled to at most once an hour). Best-effort: it + // feeds the deletion dossier, never blocks the profile read. + _ = s.accounts.StampLastLogin(c.Request.Context(), uid, clientIP(c)) c.JSON(http.StatusOK, s.profileResponse(c.Request.Context(), acc)) } -- 2.52.0 From 52e6378f404de3f58134580514d9c371ef286629 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 12:01:43 +0200 Subject: [PATCH 04/12] feat(accountdelete): anonymize-and-tombstone deletion core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New accountdelete package: AnonymizeAndTombstone journals every credential to the retention log (reason=delete) then removes them (freeing email/vk/tg for reuse), snapshots the real display name into deleted_display_name and scrubs the live one to 'Удалённый игрок', sets deleted_at, anonymizes the account's game-seat snapshots, and drops its friendships/blocks/invitations/friend-codes/drafts/pending-codes — all in one transaction. Chat, feedback and complaints are kept (the tombstone keeps their no-cascade FKs valid). Session revocation + game forfeit are orchestrated a layer up. Integration test covers journalling, tombstone/scrub and credential reuse. --- backend/internal/accountdelete/delete.go | 189 +++++++++++++++++++++++ backend/internal/inttest/delete_test.go | 92 +++++++++++ 2 files changed, 281 insertions(+) create mode 100644 backend/internal/accountdelete/delete.go create mode 100644 backend/internal/inttest/delete_test.go diff --git a/backend/internal/accountdelete/delete.go b/backend/internal/accountdelete/delete.go new file mode 100644 index 0000000..cab653f --- /dev/null +++ b/backend/internal/accountdelete/delete.go @@ -0,0 +1,189 @@ +// Package accountdelete deactivates an account as legal retention, not erasure: it keeps +// the account row as a tombstone (its chat/complaint foreign keys have no cascade, so a +// hard delete is impossible) while journalling and freeing the account's credentials, +// anonymising the live surfaces, and dropping the account's own social/ephemeral rows. +// The retained_identities journal plus the tombstone (deleted_at, deleted_display_name, +// last_login_at/ip) form the admin/legal dossier; messages are deliberately kept. Session +// revocation and active-game forfeit are orchestrated one layer up (they need the session +// cache and the game service). See docs/ARCHITECTURE.md §9.1 and the retention TTL reaper. +package accountdelete + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/go-jet/jet/v2/postgres" + "github.com/go-jet/jet/v2/qrm" + "github.com/google/uuid" + + "scrabble/backend/internal/postgres/jet/backend/model" + "scrabble/backend/internal/postgres/jet/backend/table" +) + +// AnonymizedName is the label a deleted account shows to opponents. Display names are +// stored strings resolved identically for every viewer (no per-viewer localisation in this +// codebase), so a single canonical label is used. +const AnonymizedName = "Удалённый игрок" + +// retainDelete is the retained_identities reason written when a credential is journalled +// because its account is being deleted. +const retainDelete = "delete" + +// Deleter performs the SQL-atomic part of account deletion over a Postgres handle. +type Deleter struct { + db *sql.DB + now func() time.Time +} + +// NewDeleter constructs a Deleter over db. +func NewDeleter(db *sql.DB) *Deleter { + return &Deleter{db: db, now: func() time.Time { return time.Now().UTC() }} +} + +// AnonymizeAndTombstone retires accountID atomically: it journals every live identity into +// retained_identities (reason=delete) then removes them so the credentials free for reuse, +// snapshots the real display name into deleted_display_name and scrubs the live one to +// AnonymizedName, sets deleted_at, anonymises the account's game-seat snapshots, and drops +// its friendships, blocks, invitations, friend codes, drafts and pending codes. Chat, +// feedback and complaints are kept (the surviving tombstone keeps their no-cascade foreign +// keys valid). It is idempotent-safe on an already-tombstoned account (re-journalling +// nothing, since the identities are already gone). +func (d *Deleter) AnonymizeAndTombstone(ctx context.Context, accountID uuid.UUID) error { + now := d.now() + return withTx(ctx, d.db, func(tx *sql.Tx) error { + if err := journalAndDropIdentities(ctx, tx, accountID, now); err != nil { + return err + } + if err := tombstone(ctx, tx, accountID, now); err != nil { + return err + } + if _, err := table.GamePlayers.UPDATE(table.GamePlayers.DisplayName). + SET(postgres.String(AnonymizedName)). + WHERE(table.GamePlayers.AccountID.EQ(postgres.UUID(accountID))). + ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: anonymise seats: %w", err) + } + return dropSocialAndEphemerals(ctx, tx, accountID) + }) +} + +// journalAndDropIdentities copies the account's live identities into the retention journal +// (reason=delete) and then removes them, freeing each (kind, external_id) for reuse. +func journalAndDropIdentities(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, now time.Time) error { + var ids []model.Identities + err := postgres.SELECT(table.Identities.AllColumns). + FROM(table.Identities). + WHERE(table.Identities.AccountID.EQ(postgres.UUID(accountID))). + QueryContext(ctx, tx, &ids) + if err != nil && !errors.Is(err, qrm.ErrNoRows) { + return fmt.Errorf("accountdelete: load identities: %w", err) + } + for _, id := range ids { + rid, err := uuid.NewV7() + if err != nil { + return fmt.Errorf("accountdelete: new retained id: %w", err) + } + ins := table.RetainedIdentities.INSERT( + table.RetainedIdentities.RetainedID, table.RetainedIdentities.AccountID, + table.RetainedIdentities.Kind, table.RetainedIdentities.ExternalID, + table.RetainedIdentities.Confirmed, table.RetainedIdentities.LinkedAt, + table.RetainedIdentities.DetachedAt, table.RetainedIdentities.Reason, + ).VALUES(rid, accountID, id.Kind, id.ExternalID, id.Confirmed, id.CreatedAt, now, retainDelete) + if _, err := ins.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: retain identity %s: %w", id.Kind, err) + } + } + if _, err := table.Identities.DELETE(). + WHERE(table.Identities.AccountID.EQ(postgres.UUID(accountID))). + ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: delete identities: %w", err) + } + return nil +} + +// tombstone marks the account deleted, snapshotting the real display name into +// deleted_display_name (evaluated from the old row) before scrubbing the live one. +func tombstone(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, now time.Time) error { + upd := table.Accounts.UPDATE( + table.Accounts.DeletedAt, table.Accounts.DeletedDisplayName, + table.Accounts.DisplayName, table.Accounts.UpdatedAt, + ).SET( + postgres.TimestampzT(now), table.Accounts.DisplayName, + postgres.String(AnonymizedName), postgres.TimestampzT(now), + ).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(accountID))) + if _, err := upd.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: tombstone account: %w", err) + } + return nil +} + +// dropSocialAndEphemerals removes the account's own friendships, blocks, invitations +// (as inviter and as invitee), friend codes, drafts and pending confirm-codes. These are +// the deleting user's private data with no dossier value; chat and feedback are kept. +func dropSocialAndEphemerals(ctx context.Context, tx *sql.Tx, accountID uuid.UUID) error { + id := postgres.UUID(accountID) + // Friendships and blocks are two-account edges keyed on either endpoint. + if _, err := table.Friendships.DELETE(). + WHERE(table.Friendships.RequesterID.EQ(id).OR(table.Friendships.AddresseeID.EQ(id))). + ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: delete friendships: %w", err) + } + if _, err := table.Blocks.DELETE(). + WHERE(table.Blocks.BlockerID.EQ(id).OR(table.Blocks.BlockedID.EQ(id))). + ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: delete blocks: %w", err) + } + // Invitations: drop the account's invitee rows, then its own invitations' invitees and + // the invitations themselves (children first, to respect the foreign key). + if _, err := table.GameInvitationInvitees.DELETE(). + WHERE(table.GameInvitationInvitees.AccountID.EQ(id)). + ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: delete invitee rows: %w", err) + } + ownInvitations := postgres.SELECT(table.GameInvitations.InvitationID). + FROM(table.GameInvitations). + WHERE(table.GameInvitations.InviterID.EQ(id)) + if _, err := table.GameInvitationInvitees.DELETE(). + WHERE(table.GameInvitationInvitees.InvitationID.IN(ownInvitations)). + ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: delete own invitation invitees: %w", err) + } + if _, err := table.GameInvitations.DELETE(). + WHERE(table.GameInvitations.InviterID.EQ(id)). + ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: delete invitations: %w", err) + } + // Ephemerals: friend codes, move drafts, pending confirm-codes. + if _, err := table.FriendCodes.DELETE(). + WHERE(table.FriendCodes.AccountID.EQ(id)).ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: delete friend codes: %w", err) + } + if _, err := table.GameDrafts.DELETE(). + WHERE(table.GameDrafts.AccountID.EQ(id)).ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: delete drafts: %w", err) + } + if _, err := table.EmailConfirmations.DELETE(). + WHERE(table.EmailConfirmations.AccountID.EQ(id)).ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountdelete: delete confirmations: %w", err) + } + return nil +} + +// withTx runs fn inside a transaction, committing on success and rolling back on error. +func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("accountdelete: begin tx: %w", err) + } + if err := fn(tx); err != nil { + _ = tx.Rollback() + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("accountdelete: commit tx: %w", err) + } + return nil +} diff --git a/backend/internal/inttest/delete_test.go b/backend/internal/inttest/delete_test.go new file mode 100644 index 0000000..553b90f --- /dev/null +++ b/backend/internal/inttest/delete_test.go @@ -0,0 +1,92 @@ +//go:build integration + +package inttest + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/google/uuid" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/accountdelete" +) + +// deletedFields reads a tombstoned account's retained real name and its deleted_at. +func deletedFields(t *testing.T, accountID uuid.UUID) (name string, deletedAt sql.NullTime) { + t.Helper() + var dn sql.NullString + err := testDB.QueryRowContext(context.Background(), + "SELECT deleted_display_name, deleted_at FROM accounts WHERE account_id = $1", accountID). + Scan(&dn, &deletedAt) + if err != nil { + t.Fatalf("read deleted fields %s: %v", accountID, err) + } + return dn.String, deletedAt +} + +// TestAnonymizeAndTombstone: deletion journals + frees the credentials, tombstones the +// account, scrubs the live name while retaining the real one, and frees the creds for a +// new account to reuse. +func TestAnonymizeAndTombstone(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + deleter := accountdelete.NewDeleter(testDB) + + acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "handle", "Иван", "+03:00") + if err != nil { + t.Fatalf("provision: %v", err) + } + email := "del-" + uuid.NewString() + "@example.com" + if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, email, true); err != nil { + t.Fatalf("attach email: %v", err) + } + before, err := store.GetByID(ctx, acc.ID) + if err != nil { + t.Fatalf("load before: %v", err) + } + + if err := deleter.AnonymizeAndTombstone(ctx, acc.ID); err != nil { + t.Fatalf("delete: %v", err) + } + + // The live identities are gone. + if ids, err := store.Identities(ctx, acc.ID); err != nil || len(ids) != 0 { + t.Fatalf("identities after delete = %+v (err %v), want none", ids, err) + } + // Both credentials are journalled with reason=delete. + got := retainedRows(t, acc.ID) + if len(got) != 2 { + t.Fatalf("retained rows = %+v, want 2", got) + } + for _, r := range got { + if r.reason != "delete" { + t.Errorf("retained reason = %q, want delete", r.reason) + } + } + // The live name is scrubbed; the real one is retained; deleted_at is set. + after, err := store.GetByID(ctx, acc.ID) + if err != nil { + t.Fatalf("load after: %v", err) + } + if after.DisplayName != accountdelete.AnonymizedName { + t.Errorf("live display name = %q, want %q", after.DisplayName, accountdelete.AnonymizedName) + } + name, deletedAt := deletedFields(t, acc.ID) + if name != before.DisplayName { + t.Errorf("retained name = %q, want %q", name, before.DisplayName) + } + if !deletedAt.Valid || time.Since(deletedAt.Time) > time.Minute { + t.Errorf("deleted_at = %+v, want a recent timestamp", deletedAt) + } + // The credentials are free: a new account can claim the same email. + other, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Other", "") + if err != nil { + t.Fatalf("provision other: %v", err) + } + if err := store.AttachIdentity(ctx, other.ID, account.KindEmail, email, true); err != nil { + t.Fatalf("email should be free after deletion, got: %v", err) + } +} -- 2.52.0 From d889edfdb993e6c4878116e98ea58a98e436064b Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 12:08:55 +0200 Subject: [PATCH 05/12] feat(account): retention TTL reaper (2-year legal hold) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Daily background reaper purges the deletion dossier past its two-year TTL: every retained_identities row by detached_at (covering unlink/change on live accounts too), and — for accounts tombstoned before the cutoff — the retained feedback thread plus the dossier PII (deleted_display_name, last_login_ip). Chat is kept (a shared game artifact) and the tombstone row stays. Started from main next to the guest reaper. Integration test covers the cutoff boundary and the deleted-account feedback/PII purge. --- backend/cmd/backend/main.go | 9 +++ backend/internal/account/retention.go | 89 ++++++++++++++++++++++ backend/internal/inttest/retention_test.go | 64 ++++++++++++++++ 3 files changed, 162 insertions(+) diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index a976387..f0eceeb 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -162,6 +162,15 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { zap.Duration("interval", cfg.GuestReapInterval), zap.Duration("retention", cfg.GuestRetention)) + // Purge the account-deletion legal dossier past its retention TTL: the + // retained-identities journal, and the feedback thread + dossier PII of long-deleted + // accounts (chat is kept). Checked daily; the TTL is a two-year policy constant. + retentionReaper := account.NewRetentionReaper(accounts, account.RetentionTTL, logger) + go retentionReaper.Run(ctx, 24*time.Hour) + logger.Info("retention reaper started", + zap.Duration("interval", 24*time.Hour), + zap.Duration("retention", account.RetentionTTL)) + // Re-evaluate moderated-chat write access when a temporary block self-expires: // no operator action fires then, so the sweeper emits the chat-access-changed // event for lapsed blocks and the gateway re-pushes the chat-gate command. diff --git a/backend/internal/account/retention.go b/backend/internal/account/retention.go index 6014718..4c00386 100644 --- a/backend/internal/account/retention.go +++ b/backend/internal/account/retention.go @@ -8,10 +8,15 @@ import ( "github.com/go-jet/jet/v2/postgres" "github.com/google/uuid" + "go.uber.org/zap" "scrabble/backend/internal/postgres/jet/backend/table" ) +// RetentionTTL bounds how long the account-deletion legal dossier is kept before the +// reaper purges it: two years from the detach/deletion event (owner policy, 2026-07-03). +const RetentionTTL = 2 * 365 * 24 * time.Hour + // Reasons recorded on a retained_identities row: what detached the credential from its // account. The row is written just before the live identities row is removed, preserving // the legal dossier (which email/vk/tg was linked, and when) even as the identity frees @@ -63,3 +68,87 @@ func (s *Store) StampLastLogin(ctx context.Context, accountID uuid.UUID, ip stri } return nil } + +// ReapExpiredRetention purges retention data whose event is older than cutoff: every +// retained_identities row by its detached_at (covering unlink/change on live accounts as +// well as deleted ones), plus — for accounts tombstoned before cutoff — the retained +// feedback thread and the dossier PII (deleted_display_name, last_login_ip). Chat is kept +// (a shared game artifact), and the tombstone account row itself stays (its no-cascade +// foreign keys). It returns how many journal rows and feedback messages were removed. +func (s *Store) ReapExpiredRetention(ctx context.Context, cutoff time.Time) (identities, feedback int64, err error) { + cut := postgres.TimestampzT(cutoff) + delJournal := table.RetainedIdentities.DELETE(). + WHERE(table.RetainedIdentities.DetachedAt.LT(cut)) + res, err := delJournal.ExecContext(ctx, s.db) + if err != nil { + return 0, 0, fmt.Errorf("account: reap retained identities: %w", err) + } + identities, _ = res.RowsAffected() + + expired := postgres.SELECT(table.Accounts.AccountID). + FROM(table.Accounts). + WHERE(table.Accounts.DeletedAt.IS_NOT_NULL().AND(table.Accounts.DeletedAt.LT(cut))) + delFeedback := table.FeedbackMessages.DELETE(). + WHERE(table.FeedbackMessages.AccountID.IN(expired)) + fbRes, err := delFeedback.ExecContext(ctx, s.db) + if err != nil { + return identities, 0, fmt.Errorf("account: reap deleted feedback: %w", err) + } + feedback, _ = fbRes.RowsAffected() + + clearPII := table.Accounts.UPDATE(table.Accounts.DeletedDisplayName, table.Accounts.LastLoginIP). + SET(postgres.NULL, postgres.NULL). + WHERE( + table.Accounts.DeletedAt.IS_NOT_NULL(). + AND(table.Accounts.DeletedAt.LT(cut)). + AND(table.Accounts.DeletedDisplayName.IS_NOT_NULL(). + OR(table.Accounts.LastLoginIP.IS_NOT_NULL())), + ) + if _, err := clearPII.ExecContext(ctx, s.db); err != nil { + return identities, feedback, fmt.Errorf("account: clear expired dossier PII: %w", err) + } + return identities, feedback, nil +} + +// RetentionReaper periodically purges expired account-deletion retention data via +// Store.ReapExpiredRetention, mirroring GuestReaper: one background goroutine started once +// from main. +type RetentionReaper struct { + store *Store + ttl time.Duration + clock func() time.Time + log *zap.Logger +} + +// NewRetentionReaper constructs a reaper purging retention data older than ttl. log may be +// nil. +func NewRetentionReaper(store *Store, ttl time.Duration, log *zap.Logger) *RetentionReaper { + if log == nil { + log = zap.NewNop() + } + return &RetentionReaper{ + store: store, + ttl: ttl, + clock: func() time.Time { return time.Now().UTC() }, + log: log, + } +} + +// Run purges expired retention data on each tick until ctx is cancelled. +func (r *RetentionReaper) Run(ctx context.Context, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + idn, fb, err := r.store.ReapExpiredRetention(ctx, r.clock().Add(-r.ttl)) + if err != nil { + r.log.Warn("retention reap failed", zap.Error(err)) + } else if idn > 0 || fb > 0 { + r.log.Info("reaped expired retention", zap.Int64("identities", idn), zap.Int64("feedback", fb)) + } + } + } +} diff --git a/backend/internal/inttest/retention_test.go b/backend/internal/inttest/retention_test.go index 9b5e440..68cafd8 100644 --- a/backend/internal/inttest/retention_test.go +++ b/backend/internal/inttest/retention_test.go @@ -6,10 +6,12 @@ import ( "context" "database/sql" "testing" + "time" "github.com/google/uuid" "scrabble/backend/internal/account" + "scrabble/backend/internal/accountdelete" ) // lastLoginIP reads an account's stamped last-login IP ("" when unset). @@ -126,3 +128,65 @@ func TestStampLastLoginThrottles(t *testing.T) { t.Fatalf("throttled ip = %q, want unchanged 1.2.3.4", got) } } + +// TestReapExpiredRetention: the reaper keeps journal rows newer than the cutoff and purges +// older ones, and drops a long-deleted account's feedback thread + dossier PII. +func TestReapExpiredRetention(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + deleter := accountdelete.NewDeleter(testDB) + + // An unlinked provider leaves a journal row detached "now". + tgExt := "tg-" + uuid.NewString() + acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, tgExt) + if err != nil { + t.Fatalf("provision: %v", err) + } + if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, "keep-"+uuid.NewString()+"@example.com", true); err != nil { + t.Fatalf("attach email: %v", err) + } + if err := store.RemoveIdentity(ctx, acc.ID, account.KindTelegram); err != nil { + t.Fatalf("unlink: %v", err) + } + // A cutoff before the detach keeps the row. + if _, _, err := store.ReapExpiredRetention(ctx, time.Now().Add(-time.Hour)); err != nil { + t.Fatalf("reap (early cutoff): %v", err) + } + if got := retainedRows(t, acc.ID); len(got) != 1 { + t.Fatalf("journal after early-cutoff reap = %+v, want kept", got) + } + // A cutoff after the detach purges it. + if _, _, err := store.ReapExpiredRetention(ctx, time.Now().Add(time.Hour)); err != nil { + t.Fatalf("reap (late cutoff): %v", err) + } + if got := retainedRows(t, acc.ID); len(got) != 0 { + t.Fatalf("journal after late-cutoff reap = %+v, want purged", got) + } + + // A deleted account past the cutoff loses its feedback thread and dossier PII. + del, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Иван", "") + if err != nil { + t.Fatalf("provision deletee: %v", err) + } + if _, err := testDB.ExecContext(ctx, + "INSERT INTO feedback_messages (message_id, account_id, body, channel) VALUES ($1, $2, 'hi', 'web')", + uuid.New(), del.ID); err != nil { + t.Fatalf("insert feedback: %v", err) + } + if err := deleter.AnonymizeAndTombstone(ctx, del.ID); err != nil { + t.Fatalf("delete: %v", err) + } + if _, fb, err := store.ReapExpiredRetention(ctx, time.Now().Add(time.Hour)); err != nil || fb == 0 { + t.Fatalf("reap deleted = (fb %d, err %v), want fb>=1", fb, err) + } + if name, _ := deletedFields(t, del.ID); name != "" { + t.Errorf("deleted_display_name after reap = %q, want cleared", name) + } + var fbCount int + if err := testDB.QueryRowContext(ctx, "SELECT count(*) FROM feedback_messages WHERE account_id = $1", del.ID).Scan(&fbCount); err != nil { + t.Fatalf("count feedback: %v", err) + } + if fbCount != 0 { + t.Errorf("feedback rows after reap = %d, want 0", fbCount) + } +} -- 2.52.0 From fcde7d3db6b8c3e8b62d7b13db551dc9af40949a Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 13:02:14 +0200 Subject: [PATCH 06/12] feat(accountdelete): drop all-robot games + unspoofable [Deleted] label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Q2=B: DropAllRobotGames deletes the deletee's games with no human opponent (vs-AI or auto-match-robot; children cascade), keeping games with any human seat (anonymized instead). Q1=A: the anon label is the sentinel [Deleted] — the editable-name rule forbids brackets, so no live player can impersonate a deleted account. Integration test covers drop-vs-keep. --- backend/internal/accountdelete/delete.go | 38 +++++++++++++++++++-- backend/internal/inttest/delete_test.go | 43 ++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/backend/internal/accountdelete/delete.go b/backend/internal/accountdelete/delete.go index cab653f..b36c28e 100644 --- a/backend/internal/accountdelete/delete.go +++ b/backend/internal/accountdelete/delete.go @@ -25,8 +25,10 @@ import ( // AnonymizedName is the label a deleted account shows to opponents. Display names are // stored strings resolved identically for every viewer (no per-viewer localisation in this -// codebase), so a single canonical label is used. -const AnonymizedName = "Удалённый игрок" +// codebase), so a single canonical label is used. The brackets are deliberate: the +// editable-name rule (account.displayNameRe) forbids them, so a live player can never set a +// name that impersonates a deleted account. +const AnonymizedName = "[Deleted]" // retainDelete is the retained_identities reason written when a credential is journalled // because its account is being deleted. @@ -70,6 +72,38 @@ func (d *Deleter) AnonymizeAndTombstone(ctx context.Context, accountID uuid.UUID }) } +// dropAllRobotGamesSQL deletes every game in which the account plays and no other seat is a +// human — a robot seat is one whose account holds a 'robot' identity, so this covers both +// honest vs-AI games and disguised auto-match substitutes. The game rows are deleted; their +// moves/chat/players/complaints fall away through ON DELETE CASCADE. +const dropAllRobotGamesSQL = ` +DELETE FROM games g +WHERE EXISTS ( + SELECT 1 FROM game_players p WHERE p.game_id = g.game_id AND p.account_id = $1 +) AND NOT EXISTS ( + SELECT 1 FROM game_players o + WHERE o.game_id = g.game_id AND o.account_id <> $1 + AND NOT EXISTS ( + SELECT 1 FROM identities i WHERE i.account_id = o.account_id AND i.kind = 'robot' + ) +)` + +// DropAllRobotGames deletes the account's games that have no human opponent (solo vs-AI or +// auto-match-robot games), returning how many were removed. Games with any human seat are +// kept — their seat is anonymised by AnonymizeAndTombstone instead. Run it after the +// account's active games are resigned, so no live game is removed under the robot driver. +func (d *Deleter) DropAllRobotGames(ctx context.Context, accountID uuid.UUID) (int64, error) { + res, err := d.db.ExecContext(ctx, dropAllRobotGamesSQL, accountID) + if err != nil { + return 0, fmt.Errorf("accountdelete: drop all-robot games: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return 0, fmt.Errorf("accountdelete: dropped games count: %w", err) + } + return n, nil +} + // journalAndDropIdentities copies the account's live identities into the retention journal // (reason=delete) and then removes them, freeing each (kind, external_id) for reuse. func journalAndDropIdentities(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, now time.Time) error { diff --git a/backend/internal/inttest/delete_test.go b/backend/internal/inttest/delete_test.go index 553b90f..04697fa 100644 --- a/backend/internal/inttest/delete_test.go +++ b/backend/internal/inttest/delete_test.go @@ -12,6 +12,8 @@ import ( "scrabble/backend/internal/account" "scrabble/backend/internal/accountdelete" + "scrabble/backend/internal/engine" + "scrabble/backend/internal/game" ) // deletedFields reads a tombstoned account's retained real name and its deleted_at. @@ -90,3 +92,44 @@ func TestAnonymizeAndTombstone(t *testing.T) { t.Fatalf("email should be free after deletion, got: %v", err) } } + +// TestDropAllRobotGames drops the deletee's solo vs-AI game but keeps a game with a human +// opponent. +func TestDropAllRobotGames(t *testing.T) { + ctx := context.Background() + gsvc := newGameService() + robots := newRobotService(t, gsvc) + if err := robots.EnsurePool(ctx); err != nil { + t.Fatalf("ensure pool: %v", err) + } + mm := newMatchmaker(t, robots, time.Minute, 0) + deleter := accountdelete.NewDeleter(testDB) + + user := provisionAccount(t) + other := provisionAccount(t) + + aiRes, err := mm.StartVsAI(ctx, user, engine.VariantEnglish, true) + if err != nil { + t.Fatalf("start vs AI: %v", err) + } + humanGame, err := gsvc.Create(ctx, game.CreateParams{ + Variant: engine.VariantEnglish, Seats: []uuid.UUID{user, other}, TurnTimeout: time.Hour, Seed: 1, + }) + if err != nil { + t.Fatalf("create human game: %v", err) + } + + n, err := deleter.DropAllRobotGames(ctx, user) + if err != nil { + t.Fatalf("drop: %v", err) + } + if n != 1 { + t.Fatalf("dropped %d games, want 1 (the vs-AI game)", n) + } + if _, err := gsvc.GameByID(ctx, aiRes.Game.ID); err == nil { + t.Error("the vs-AI game should be dropped") + } + if _, err := gsvc.GameByID(ctx, humanGame.ID); err != nil { + t.Errorf("the human game should be kept, got: %v", err) + } +} -- 2.52.0 From aa2290b7b4dd87ff1bef9a5afc5252bf494f500a Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 13:10:34 +0200 Subject: [PATCH 07/12] feat(account): deletion orchestration + step-up + gateway edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step-up: email accounts confirm with a mailed code (purpose=delete, no deeplink — ConfirmByToken refuses a delete token so a stray click can't delete); platform-only accounts type a fixed phrase (anti-impulse). Endpoints /user/delete/{request,confirm}; the confirm orchestration resigns active games, drops all-robot games, tombstones + anonymizes the account (freeing its creds), and revokes its sessions — the tombstone is the point of no return, the rest best-effort. Gateway account.delete.{request,confirm} ops + fbs AccountDeleteConfirm/AccountDeleteRequestResult + branded ru/en delete email. Integration tests cover the step-up (code + no-email) and the orchestration pieces. --- backend/internal/account/email.go | 37 ++++- backend/internal/account/emailtemplate.go | 18 +++ backend/internal/account/link.go | 43 ++++++ backend/internal/inttest/delete_test.go | 46 ++++++ backend/internal/server/handlers.go | 4 + backend/internal/server/handlers_delete.go | 131 ++++++++++++++++++ gateway/internal/backendclient/api_social.go | 20 +++ gateway/internal/transcode/encode.go | 11 ++ gateway/internal/transcode/transcode_link.go | 26 ++++ pkg/fbs/scrabble.fbs | 13 ++ pkg/fbs/scrabblefb/AccountDeleteConfirm.go | 71 ++++++++++ .../scrabblefb/AccountDeleteRequestResult.go | 60 ++++++++ ui/src/gen/fbs/scrabblefb.ts | 2 + .../fbs/scrabblefb/account-delete-confirm.ts | 60 ++++++++ .../account-delete-request-result.ts | 48 +++++++ 15 files changed, 589 insertions(+), 1 deletion(-) create mode 100644 backend/internal/server/handlers_delete.go create mode 100644 pkg/fbs/scrabblefb/AccountDeleteConfirm.go create mode 100644 pkg/fbs/scrabblefb/AccountDeleteRequestResult.go create mode 100644 ui/src/gen/fbs/scrabblefb/account-delete-confirm.ts create mode 100644 ui/src/gen/fbs/scrabblefb/account-delete-request-result.ts diff --git a/backend/internal/account/email.go b/backend/internal/account/email.go index 1b1420f..d65015d 100644 --- a/backend/internal/account/email.go +++ b/backend/internal/account/email.go @@ -42,6 +42,7 @@ const ( purposeLogin = "login" purposeLink = "link" purposeChange = "change" + purposeDelete = "delete" ) // Errors returned by the email confirm-code flow. @@ -65,6 +66,9 @@ var ( // ErrTooManyRequests is returned when confirm-code sends to an address are being // requested too frequently (the resend cooldown or the rolling-hour cap). ErrTooManyRequests = errors.New("account: too many code requests") + // ErrNoEmail is returned when an email-code step-up is requested for an account that + // holds no confirmed email (the caller must use the typed-phrase path instead). + ErrNoEmail = errors.New("account: no confirmed email") ) // EmailService runs the email confirm-code flow: it issues a 6-digit code over a @@ -115,7 +119,14 @@ func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, tokenHash, purpose, s.now().Add(emailCodeTTL)); err != nil { return err } - msg, err := renderConfirmationEmail(purpose, code, s.confirmURL(token, locale), s.baseURL, locale) + // Account deletion is never a one-tap link (a prefetch or a stray click must not delete + // an account): the delete code is entered in the app only, so the email omits the + // deeplink and ConfirmByToken refuses a delete token. + deeplink := s.confirmURL(token, locale) + if purpose == purposeDelete { + deeplink = "" + } + msg, err := renderConfirmationEmail(purpose, code, deeplink, s.baseURL, locale) if err != nil { return err } @@ -347,6 +358,9 @@ func (s *EmailService) ConfirmByToken(ctx context.Context, token string) (LinkCo return LinkConfirmation{}, err } return LinkConfirmation{Purpose: purposeChange, Account: pend.accountID}, nil + case purposeDelete: + // Deletion is confirmed in the app with the code, never via a one-tap link. + return LinkConfirmation{}, fmt.Errorf("account: deletion cannot be confirmed by link") default: return LinkConfirmation{}, fmt.Errorf("account: unsupported confirmation purpose %q", pend.purpose) } @@ -380,6 +394,27 @@ func (s *Store) confirmedEmailAccount(ctx context.Context, email string) (uuid.U return row.AccountID, true, nil } +// confirmedEmailOf returns the account's confirmed email address and true, or ("", false) +// when it holds none. It backs the deletion step-up, which mails a code to the account's +// own address. +func (s *Store) confirmedEmailOf(ctx context.Context, accountID uuid.UUID) (string, bool, error) { + stmt := postgres.SELECT(table.Identities.ExternalID). + FROM(table.Identities). + WHERE( + table.Identities.AccountID.EQ(postgres.UUID(accountID)). + AND(table.Identities.Kind.EQ(postgres.String(KindEmail))). + AND(table.Identities.Confirmed.EQ(postgres.Bool(true))), + ).LIMIT(1) + var row model.Identities + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return "", false, nil + } + return "", false, fmt.Errorf("account: confirmed email of %s: %w", accountID, err) + } + return row.ExternalID, true, nil +} + // replacePendingConfirmation clears any pending code for (accountID, email) and // inserts a fresh one, inside one transaction. func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.UUID, email, codeHash, linkTokenHash, purpose string, expiresAt time.Time) error { diff --git a/backend/internal/account/emailtemplate.go b/backend/internal/account/emailtemplate.go index 9222c2c..7bff757 100644 --- a/backend/internal/account/emailtemplate.go +++ b/backend/internal/account/emailtemplate.go @@ -98,6 +98,24 @@ var confirmEmailCopy = map[string]map[string]emailCopy{ FooterIgnore: "Если вы не запрашивали смену, просто проигнорируйте письмо — адрес останется прежним.", }, }, + purposeDelete: { + "en": { + Subject: "Confirm your Erudit account deletion", + Preheader: "Confirm account deletion", + Heading: "Delete your account", + Intro: "Enter this code in the app to permanently delete your account:", + CTALabel: "", + FooterIgnore: "If you didn't request this, ignore it — your account stays as it is.", + }, + "ru": { + Subject: "Подтвердите удаление аккаунта Эрудит", + Preheader: "Подтверждение удаления аккаунта", + Heading: "Удаление аккаунта", + Intro: "Введите этот код в приложении, чтобы удалить аккаунт без восстановления:", + CTALabel: "", + FooterIgnore: "Если вы не запрашивали удаление, проигнорируйте письмо — аккаунт останется.", + }, + }, } // emailBrand is the brand wordmark per locale. diff --git a/backend/internal/account/link.go b/backend/internal/account/link.go index fc9db93..c897e15 100644 --- a/backend/internal/account/link.go +++ b/backend/internal/account/link.go @@ -178,6 +178,49 @@ func (s *EmailService) ConfirmChange(ctx context.Context, accountID uuid.UUID, n return s.store.GetByID(ctx, accountID) } +// HasEmail reports whether accountID owns a confirmed email. The account-deletion step-up +// mails a confirm-code when it does, and falls back to a typed phrase otherwise. +func (s *EmailService) HasEmail(ctx context.Context, accountID uuid.UUID) (bool, error) { + _, ok, err := s.store.confirmedEmailOf(ctx, accountID) + return ok, err +} + +// RequestDeleteCode mails an account-deletion confirm-code to the account's own confirmed +// email (no deeplink — deletion is confirmed in the app). It returns ErrNoEmail when the +// account holds no email, ErrTooManyRequests when throttled. +func (s *EmailService) RequestDeleteCode(ctx context.Context, accountID uuid.UUID) error { + addr, ok, err := s.store.confirmedEmailOf(ctx, accountID) + if err != nil { + return err + } + if !ok { + return ErrNoEmail + } + if !s.allowSend(addr) { + return ErrTooManyRequests + } + return s.issueCode(ctx, accountID, addr, purposeDelete, s.accountLocale(ctx, accountID)) +} + +// VerifyDeleteCode verifies the account-deletion code against the account's own email and +// consumes it on success. It returns ErrNoEmail (no email), the usual confirm-code errors +// (ErrNoPendingCode, ErrCodeExpired, ErrTooManyAttempts, ErrCodeMismatch), or nil when the +// code is valid — the caller then performs the deletion. +func (s *EmailService) VerifyDeleteCode(ctx context.Context, accountID uuid.UUID, code string) error { + addr, ok, err := s.store.confirmedEmailOf(ctx, accountID) + if err != nil { + return err + } + if !ok { + return ErrNoEmail + } + conf, err := s.verifyPendingCode(ctx, accountID, addr, code) + if err != nil { + return err + } + return s.store.consumeConfirmation(ctx, conf.id, s.now()) +} + // verifyPendingCode loads and checks the pending confirm-code for (accountID, // addr), counting a wrong attempt. It returns the confirmation on success. func (s *EmailService) verifyPendingCode(ctx context.Context, accountID uuid.UUID, addr, code string) (emailConfirmation, error) { diff --git a/backend/internal/inttest/delete_test.go b/backend/internal/inttest/delete_test.go index 04697fa..7dcabcd 100644 --- a/backend/internal/inttest/delete_test.go +++ b/backend/internal/inttest/delete_test.go @@ -5,6 +5,8 @@ package inttest import ( "context" "database/sql" + "errors" + "strings" "testing" "time" @@ -133,3 +135,47 @@ func TestDropAllRobotGames(t *testing.T) { t.Errorf("the human game should be kept, got: %v", err) } } + +// TestDeleteStepUpEmail: an email account's delete code verifies (wrong code rejected, no +// deeplink in the mail); a platform-only account has no email and cannot request a code. +func TestDeleteStepUpEmail(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + mailer := &capturingMailer{} + svc := account.NewEmailService(store, mailer, "https://erudit-game.ru") + + acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString()) + if err != nil { + t.Fatalf("provision: %v", err) + } + if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, "del-"+uuid.NewString()+"@example.com", true); err != nil { + t.Fatalf("attach email: %v", err) + } + if has, err := svc.HasEmail(ctx, acc.ID); err != nil || !has { + t.Fatalf("HasEmail = (%v, %v), want true", has, err) + } + if err := svc.RequestDeleteCode(ctx, acc.ID); err != nil { + t.Fatalf("request delete code: %v", err) + } + if strings.Contains(mailer.lastBody, "/confirm/") { + t.Error("a delete email must not carry a one-tap deeplink") + } + code := sixDigit.FindString(mailer.lastBody) + if err := svc.VerifyDeleteCode(ctx, acc.ID, "000000"); err == nil { + t.Error("a wrong delete code must be rejected") + } + if err := svc.VerifyDeleteCode(ctx, acc.ID, code); err != nil { + t.Fatalf("verify correct delete code: %v", err) + } + + noEmail, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString()) + if err != nil { + t.Fatalf("provision no-email: %v", err) + } + if has, _ := svc.HasEmail(ctx, noEmail.ID); has { + t.Error("HasEmail must be false for a platform-only account") + } + if err := svc.RequestDeleteCode(ctx, noEmail.ID); !errors.Is(err, account.ErrNoEmail) { + t.Errorf("request delete for no-email account = %v, want ErrNoEmail", err) + } +} diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index dc442a6..6d55e1e 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -80,6 +80,10 @@ func (s *Server) registerRoutes() { // refused without disclosure, never merged). u.POST("/link/email/change/request", s.handleChangeEmailRequest) u.POST("/link/email/change/confirm", s.handleChangeEmailConfirm) + // Account deletion (legal retention, not erasure): step-up via a mailed code + // (email accounts) or a typed phrase (platform-only), then tombstone + free creds. + u.POST("/delete/request", s.handleRequestDelete) + u.POST("/delete/confirm", s.handleConfirmDelete) } if s.games != nil { u.GET("/games", s.handleListGames) diff --git a/backend/internal/server/handlers_delete.go b/backend/internal/server/handlers_delete.go new file mode 100644 index 0000000..9bd9da8 --- /dev/null +++ b/backend/internal/server/handlers_delete.go @@ -0,0 +1,131 @@ +package server + +import ( + "context" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.uber.org/zap" + + "scrabble/backend/internal/accountdelete" + "scrabble/backend/internal/game" +) + +// Account deletion is legal retention, not erasure (docs/ARCHITECTURE.md §9.1): the account +// row survives as a tombstone while its credentials are journalled + freed, its live +// surfaces anonymised, and its own social/ephemeral data dropped. Messages are kept. The +// step-up is a mailed code for an account with a confirmed email, or a typed phrase for a +// platform-only account (possession-proof is unattainable there, so the phrase is +// anti-impulse only). + +// deletePhrase is the fixed confirmation phrase a no-email account types to delete. +// Compared case-insensitively; the client localises only the surrounding instruction. +const deletePhrase = "DELETE" + +// deleteRequestResponse tells the client which step-up the account uses. +type deleteRequestResponse struct { + Method string `json:"method"` // "email" | "phrase" +} + +// handleRequestDelete starts account deletion: it mails a delete code to an account with a +// confirmed email, or reports the typed-phrase path otherwise. +func (s *Server) handleRequestDelete(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + ctx := c.Request.Context() + hasEmail, err := s.emails.HasEmail(ctx, uid) + if err != nil { + s.abortErr(c, err) + return + } + if !hasEmail { + c.JSON(http.StatusOK, deleteRequestResponse{Method: "phrase"}) + return + } + if err := s.emails.RequestDeleteCode(ctx, uid); err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, deleteRequestResponse{Method: "email"}) +} + +// deleteConfirmBody carries the step-up proof: a mailed code (email account) or the typed +// phrase (platform-only account). +type deleteConfirmBody struct { + Code string `json:"code"` + Phrase string `json:"phrase"` +} + +// handleConfirmDelete verifies the step-up and deletes the account. +func (s *Server) handleConfirmDelete(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + var req deleteConfirmBody + if err := c.ShouldBindJSON(&req); err != nil { + abortBadRequest(c, "invalid request body") + return + } + ctx := c.Request.Context() + hasEmail, err := s.emails.HasEmail(ctx, uid) + if err != nil { + s.abortErr(c, err) + return + } + if hasEmail { + if err := s.emails.VerifyDeleteCode(ctx, uid, req.Code); err != nil { + s.abortErr(c, err) + return + } + } else if !strings.EqualFold(strings.TrimSpace(req.Phrase), deletePhrase) { + abortBadRequest(c, "confirmation phrase does not match") + return + } + if err := s.deleteAccount(ctx, uid); err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, okResponse{OK: true}) +} + +// deleteAccount runs the deletion orchestration after the step-up passed: it resigns the +// account's active games (so opponents are not stranded and robot games end cleanly), +// drops its all-robot games, tombstones + anonymises the account (journalling and freeing +// its credentials), and revokes its sessions. The tombstone is the point of no return — +// its failure aborts; the game cleanup and session revocation around it are best-effort. +func (s *Server) deleteAccount(ctx context.Context, uid uuid.UUID) error { + deleter := accountdelete.NewDeleter(s.db) + if s.games != nil { + if games, err := s.games.ListForAccount(ctx, uid); err == nil { + for _, g := range games { + if g.Status != game.StatusActive { + continue + } + if _, err := s.games.Resign(ctx, g.ID, uid); err != nil { + s.log.Warn("delete: resign game failed", zap.String("game", g.ID.String()), zap.Error(err)) + } + } + } else { + s.log.Warn("delete: list games failed", zap.Error(err)) + } + } + if _, err := deleter.DropAllRobotGames(ctx, uid); err != nil { + s.log.Warn("delete: drop all-robot games failed", zap.Error(err)) + } + if err := deleter.AnonymizeAndTombstone(ctx, uid); err != nil { + return err + } + if s.sessions != nil { + if err := s.sessions.RevokeAllForAccount(ctx, uid); err != nil { + s.log.Warn("delete: revoke sessions failed", zap.Error(err)) + } + } + return nil +} diff --git a/gateway/internal/backendclient/api_social.go b/gateway/internal/backendclient/api_social.go index 164edf0..7a79288 100644 --- a/gateway/internal/backendclient/api_social.go +++ b/gateway/internal/backendclient/api_social.go @@ -351,6 +351,26 @@ func (c *Client) ChangeEmailConfirm(ctx context.Context, userID, email, code str return out, err } +// DeleteRequestResp reports which account-deletion step-up the account uses. +type DeleteRequestResp struct { + Method string `json:"method"` +} + +// DeleteRequest starts account deletion: the backend mails a delete code (email accounts) +// or reports the typed-phrase path. +func (c *Client) DeleteRequest(ctx context.Context, userID string) (DeleteRequestResp, error) { + var out DeleteRequestResp + err := c.do(ctx, http.MethodPost, "/api/v1/user/delete/request", userID, "", nil, &out) + return out, err +} + +// DeleteConfirm verifies the step-up (a mailed code or the typed phrase) and deletes the +// account. +func (c *Client) DeleteConfirm(ctx context.Context, userID, code, phrase string) error { + return c.do(ctx, http.MethodPost, "/api/v1/user/delete/confirm", userID, "", + map[string]string{"code": code, "phrase": phrase}, nil) +} + // LinkUnlink detaches a platform identity (kind = "telegram" | "vk") from the caller // and returns the refreshed profile in the result. func (c *Client) LinkUnlink(ctx context.Context, userID, kind string) (LinkResultResp, error) { diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 8cb2562..afc5434 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -37,6 +37,17 @@ func encodeAck(ok bool) []byte { return b.FinishedBytes() } +// encodeDeleteRequestResult builds an AccountDeleteRequestResult payload reporting which +// deletion step-up the account uses ("email" | "phrase"). +func encodeDeleteRequestResult(method string) []byte { + b := flatbuffers.NewBuilder(32) + m := b.CreateString(method) + fb.AccountDeleteRequestResultStart(b) + fb.AccountDeleteRequestResultAddMethod(b, m) + b.Finish(fb.AccountDeleteRequestResultEnd(b)) + return b.FinishedBytes() +} + // encodeConfirmLinkResult builds an EmailConfirmLinkResult payload, embedding the // minted Session for a login. All strings and the nested Session table are built // before the result table is opened. diff --git a/gateway/internal/transcode/transcode_link.go b/gateway/internal/transcode/transcode_link.go index 5d6d3af..72f2c70 100644 --- a/gateway/internal/transcode/transcode_link.go +++ b/gateway/internal/transcode/transcode_link.go @@ -21,6 +21,8 @@ const ( MsgLinkUnlink = "link.unlink" MsgEmailChangeRequest = "link.email.change.request" MsgEmailChangeConfirm = "link.email.change.confirm" + MsgAccountDeleteReq = "account.delete.request" + MsgAccountDeleteConf = "account.delete.confirm" ) // registerLinkOps adds the linking & merge operations. The telegram ops need the @@ -32,6 +34,8 @@ func registerLinkOps(r *Registry, backend *backendclient.Client, tg TelegramVali r.ops[MsgLinkUnlink] = Op{Handler: linkUnlinkHandler(backend), Auth: true} r.ops[MsgEmailChangeRequest] = Op{Handler: changeEmailRequestHandler(backend), Auth: true, Email: true} r.ops[MsgEmailChangeConfirm] = Op{Handler: changeEmailConfirmHandler(backend), Auth: true, Email: true} + r.ops[MsgAccountDeleteReq] = Op{Handler: deleteRequestHandler(backend), Auth: true, Email: true} + r.ops[MsgAccountDeleteConf] = Op{Handler: deleteConfirmHandler(backend), Auth: true} if tg != nil { r.ops[MsgLinkTelegram] = Op{Handler: linkTelegramHandler(backend, tg, false), Auth: true} r.ops[MsgLinkTelegramMerge] = Op{Handler: linkTelegramHandler(backend, tg, true), Auth: true} @@ -94,6 +98,28 @@ func changeEmailConfirmHandler(backend *backendclient.Client) Handler { } } +// deleteRequestHandler starts account deletion, returning which step-up the account uses. +func deleteRequestHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + res, err := backend.DeleteRequest(ctx, req.UserID) + if err != nil { + return nil, err + } + return encodeDeleteRequestResult(res.Method), nil + } +} + +// deleteConfirmHandler verifies the step-up proof and deletes the account. +func deleteConfirmHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsAccountDeleteConfirm(req.Payload, 0) + if err := backend.DeleteConfirm(ctx, req.UserID, string(in.Code()), string(in.Phrase())); err != nil { + return nil, err + } + return encodeAck(true), nil + } +} + // linkUnlinkHandler detaches a platform identity (telegram|vk) from the caller and // returns the refreshed link result (status "unlinked" + the updated profile). func linkUnlinkHandler(backend *backendclient.Client) Handler { diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 758a212..c078538 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -507,6 +507,19 @@ table LinkUnlinkRequest { kind:string; } +// AccountDeleteConfirm carries the account-deletion step-up proof: a mailed code (email +// accounts) or the typed phrase (platform-only accounts). +table AccountDeleteConfirm { + code:string; + phrase:string; +} + +// AccountDeleteRequestResult reports which deletion step-up the account uses: "email" +// (a code was mailed) or "phrase" (type the confirmation phrase). +table AccountDeleteRequestResult { + method:string; +} + // LinkResult is the unified result of a confirm or merge step. status is "linked" // (bound to the caller), "merge_required" (the identity belongs to another account — // the secondary_* fields summarise it for the irreversible confirmation), or diff --git a/pkg/fbs/scrabblefb/AccountDeleteConfirm.go b/pkg/fbs/scrabblefb/AccountDeleteConfirm.go new file mode 100644 index 0000000..e28fa49 --- /dev/null +++ b/pkg/fbs/scrabblefb/AccountDeleteConfirm.go @@ -0,0 +1,71 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type AccountDeleteConfirm struct { + _tab flatbuffers.Table +} + +func GetRootAsAccountDeleteConfirm(buf []byte, offset flatbuffers.UOffsetT) *AccountDeleteConfirm { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &AccountDeleteConfirm{} + x.Init(buf, n+offset) + return x +} + +func FinishAccountDeleteConfirmBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsAccountDeleteConfirm(buf []byte, offset flatbuffers.UOffsetT) *AccountDeleteConfirm { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &AccountDeleteConfirm{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedAccountDeleteConfirmBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *AccountDeleteConfirm) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *AccountDeleteConfirm) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *AccountDeleteConfirm) Code() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *AccountDeleteConfirm) Phrase() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func AccountDeleteConfirmStart(builder *flatbuffers.Builder) { + builder.StartObject(2) +} +func AccountDeleteConfirmAddCode(builder *flatbuffers.Builder, code flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(code), 0) +} +func AccountDeleteConfirmAddPhrase(builder *flatbuffers.Builder, phrase flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(phrase), 0) +} +func AccountDeleteConfirmEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/AccountDeleteRequestResult.go b/pkg/fbs/scrabblefb/AccountDeleteRequestResult.go new file mode 100644 index 0000000..40837b4 --- /dev/null +++ b/pkg/fbs/scrabblefb/AccountDeleteRequestResult.go @@ -0,0 +1,60 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type AccountDeleteRequestResult struct { + _tab flatbuffers.Table +} + +func GetRootAsAccountDeleteRequestResult(buf []byte, offset flatbuffers.UOffsetT) *AccountDeleteRequestResult { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &AccountDeleteRequestResult{} + x.Init(buf, n+offset) + return x +} + +func FinishAccountDeleteRequestResultBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsAccountDeleteRequestResult(buf []byte, offset flatbuffers.UOffsetT) *AccountDeleteRequestResult { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &AccountDeleteRequestResult{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedAccountDeleteRequestResultBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *AccountDeleteRequestResult) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *AccountDeleteRequestResult) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *AccountDeleteRequestResult) Method() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func AccountDeleteRequestResultStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func AccountDeleteRequestResultAddMethod(builder *flatbuffers.Builder, method flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(method), 0) +} +func AccountDeleteRequestResultEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts index ce02ba1..73aa84a 100644 --- a/ui/src/gen/fbs/scrabblefb.ts +++ b/ui/src/gen/fbs/scrabblefb.ts @@ -1,5 +1,7 @@ // automatically generated by the FlatBuffers compiler, do not modify +export { AccountDeleteConfirm } from './scrabblefb/account-delete-confirm.js'; +export { AccountDeleteRequestResult } from './scrabblefb/account-delete-request-result.js'; export { AccountRef } from './scrabblefb/account-ref.js'; export { Ack } from './scrabblefb/ack.js'; export { AlphabetEntry } from './scrabblefb/alphabet-entry.js'; diff --git a/ui/src/gen/fbs/scrabblefb/account-delete-confirm.ts b/ui/src/gen/fbs/scrabblefb/account-delete-confirm.ts new file mode 100644 index 0000000..f0f120b --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/account-delete-confirm.ts @@ -0,0 +1,60 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class AccountDeleteConfirm { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):AccountDeleteConfirm { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsAccountDeleteConfirm(bb:flatbuffers.ByteBuffer, obj?:AccountDeleteConfirm):AccountDeleteConfirm { + return (obj || new AccountDeleteConfirm()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsAccountDeleteConfirm(bb:flatbuffers.ByteBuffer, obj?:AccountDeleteConfirm):AccountDeleteConfirm { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new AccountDeleteConfirm()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +code():string|null +code(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +code(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +phrase():string|null +phrase(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +phrase(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +static startAccountDeleteConfirm(builder:flatbuffers.Builder) { + builder.startObject(2); +} + +static addCode(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, codeOffset, 0); +} + +static addPhrase(builder:flatbuffers.Builder, phraseOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, phraseOffset, 0); +} + +static endAccountDeleteConfirm(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createAccountDeleteConfirm(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset, phraseOffset:flatbuffers.Offset):flatbuffers.Offset { + AccountDeleteConfirm.startAccountDeleteConfirm(builder); + AccountDeleteConfirm.addCode(builder, codeOffset); + AccountDeleteConfirm.addPhrase(builder, phraseOffset); + return AccountDeleteConfirm.endAccountDeleteConfirm(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/account-delete-request-result.ts b/ui/src/gen/fbs/scrabblefb/account-delete-request-result.ts new file mode 100644 index 0000000..1c92dae --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/account-delete-request-result.ts @@ -0,0 +1,48 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class AccountDeleteRequestResult { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):AccountDeleteRequestResult { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsAccountDeleteRequestResult(bb:flatbuffers.ByteBuffer, obj?:AccountDeleteRequestResult):AccountDeleteRequestResult { + return (obj || new AccountDeleteRequestResult()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsAccountDeleteRequestResult(bb:flatbuffers.ByteBuffer, obj?:AccountDeleteRequestResult):AccountDeleteRequestResult { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new AccountDeleteRequestResult()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +method():string|null +method(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +method(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +static startAccountDeleteRequestResult(builder:flatbuffers.Builder) { + builder.startObject(1); +} + +static addMethod(builder:flatbuffers.Builder, methodOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, methodOffset, 0); +} + +static endAccountDeleteRequestResult(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createAccountDeleteRequestResult(builder:flatbuffers.Builder, methodOffset:flatbuffers.Offset):flatbuffers.Offset { + AccountDeleteRequestResult.startAccountDeleteRequestResult(builder); + AccountDeleteRequestResult.addMethod(builder, methodOffset); + return AccountDeleteRequestResult.endAccountDeleteRequestResult(builder); +} +} -- 2.52.0 From cabcd94d92f0cb512af4797a075aa550e205ed5d Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 13:18:37 +0200 Subject: [PATCH 08/12] feat(profile): account-deletion flow + terminal deleted screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profile gains a Delete-account control (durable accounts) opening a step-up dialog: a mailed code for an email account, or the typed DELETE phrase for a platform-only one. On success the app swaps to a terminal AccountDeleted screen ('Учётная запись удалена') with a Close that closes the host Mini App (telegramClose / vkClose; web = no close). Wires deleteRequest/deleteConfirm through client/transport/mock/codec; ru/en i18n; codec wire test + Chromium/WebKit e2e. --- ui/e2e/social.spec.ts | 14 ++++++ ui/src/App.svelte | 3 ++ ui/src/lib/app.svelte.ts | 40 ++++++++++++++- ui/src/lib/client.ts | 5 ++ ui/src/lib/codec.test.ts | 17 +++++++ ui/src/lib/codec.ts | 15 ++++++ ui/src/lib/i18n/en.ts | 9 ++++ ui/src/lib/i18n/ru.ts | 9 ++++ ui/src/lib/mock/client.ts | 4 ++ ui/src/lib/telegram.ts | 7 +++ ui/src/lib/transport.ts | 6 +++ ui/src/lib/vk.ts | 12 +++++ ui/src/screens/AccountDeleted.svelte | 53 ++++++++++++++++++++ ui/src/screens/Profile.svelte | 75 +++++++++++++++++++++++++++- 14 files changed, 267 insertions(+), 2 deletions(-) create mode 100644 ui/src/screens/AccountDeleted.svelte diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index 69be846..ac06423 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -345,6 +345,20 @@ test('link then unlink Telegram from the sign-in methods', async ({ page }) => { await expect(page.getByRole('button', { name: 'Link Telegram' })).toBeVisible(); }); +test('account deletion: the mailed-code step-up leads to the terminal deleted screen', async ({ page }) => { + await loginLobby(page); + await openProfile(page); + + // The mock profile holds an email, so deletion asks for the mailed code. + await page.getByRole('button', { name: 'Delete account' }).click(); + await expect(page.getByText('Delete your account?')).toBeVisible(); + await page.getByRole('dialog').locator('.codein').fill('123456'); + await page.getByRole('dialog').getByRole('button', { name: 'Delete permanently' }).click(); + + // The app swaps to the terminal account-deleted screen. + await expect(page.getByText('Account deleted')).toBeVisible(); +}); + test('chat: one message per turn — the field shows, then a caption replaces it after sending', async ({ page }) => { await loginLobby(page); await page.getByRole('button', { name: /Ann/ }).click(); // g1: your turn diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 215f47b..a1b8824 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -20,6 +20,7 @@ import CommsHub from './game/CommsHub.svelte'; import Feedback from './screens/Feedback.svelte'; import Blocked from './screens/Blocked.svelte'; + import AccountDeleted from './screens/AccountDeleted.svelte'; import BootError from './screens/BootError.svelte'; import TelegramLaunchError from './screens/TelegramLaunchError.svelte'; @@ -83,6 +84,8 @@ +{:else if app.accountDeleted} + {:else if app.blocked} {:else} diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 2cce2a4..38593c7 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -13,6 +13,7 @@ import { startLocalEvalMetrics } from './localeval-metrics'; import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref, type TelegramThemeParams } from './theme'; import { insideTelegram, + telegramClose, collectTelegramDiag, type TelegramDiag, onTelegramPath, @@ -32,7 +33,7 @@ import { telegramCloudGet, telegramCloudSet, } from './telegram'; -import { onVKPath, insideVK, vkInit, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk'; +import { onVKPath, insideVK, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk'; import { haptic } from './haptics'; import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs'; import { parseStartParam } from './deeplink'; @@ -92,6 +93,9 @@ export const app = $state<{ /** The caller's active manual block, or null. When set, App.svelte replaces every screen with * the terminal blocked screen and all push/poll is stopped. */ blocked: BlockStatus | null; + /** Set once the account has been deleted: App.svelte shows the terminal "account deleted" + * screen and all push/poll is stopped. Reopening the app just creates a fresh account. */ + accountDeleted: boolean; toast: Toast | null; lastEvent: PushEvent | null; theme: ThemePref; @@ -140,6 +144,7 @@ export const app = $state<{ session: null, profile: null, blocked: null, + accountDeleted: false, toast: null, lastEvent: null, theme: 'auto', @@ -544,6 +549,39 @@ export async function applyLinkResult(r: LinkResult): Promise { void persistLanguageToServer(app.locale); } +/** + * requestDeleteAccount starts account deletion and reports which step-up the account uses: + * 'email' (a code was mailed) or 'phrase' (type the confirmation phrase). + */ +export async function requestDeleteAccount(): Promise<'email' | 'phrase'> { + return (await gateway.deleteRequest()).method; +} + +/** + * confirmDeleteAccount confirms deletion with the mailed code or the typed phrase. On + * success it switches to the terminal "account deleted" screen and stops all push/poll; it + * throws on a bad code/phrase so the caller can let the user retry. + */ +export async function confirmDeleteAccount(code: string, phrase: string): Promise { + await gateway.deleteConfirm(code, phrase); + closeStream(); + app.accountDeleted = true; +} + +/** + * closeDeletedApp closes the host Mini App from the terminal deleted screen (Telegram / VK). + * A plain browser has nothing to close, so it is a no-op there — the terminal screen stays. + */ +export function closeDeletedApp(): void { + if (insideTelegram()) { + telegramClose(); + return; + } + if (insideVK()) { + void vkClose(); + } +} + /** * syncTelegramChrome paints Telegram's header/background/bottom bar from the app's live * theme tokens, so the surrounding chrome matches the UI. Called after the theme is applied. diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index 3769da8..f6a235c 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -185,6 +185,11 @@ export interface GatewayClient { * refused without disclosure. */ changeEmailRequest(email: string): Promise; changeEmailConfirm(email: string, code: string): Promise; + /** Start account deletion; the backend mails a code (method 'email') or asks for the + * typed phrase (method 'phrase'). */ + deleteRequest(): Promise<{ method: 'email' | 'phrase' }>; + /** Confirm and perform account deletion with the mailed code or the typed phrase. */ + deleteConfirm(code: string, phrase: string): Promise; // --- live stream --- subscribe(onEvent: (e: PushEvent) => void, onError?: (err: unknown) => void): Unsubscribe; diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 377921a..82d13c6 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -27,6 +27,8 @@ import { encodeEnqueue, encodeExchange, encodeExportUrlRequest, + encodeAccountDeleteConfirm, + decodeDeleteRequestResult, encodeGuestLogin, encodeLinkUnlink, encodeStateRequest, @@ -464,6 +466,21 @@ describe('codec', () => { expect(r.kind()).toBe('telegram'); }); + it('round-trips the account-delete confirm + request-result wire', () => { + const c = fb.AccountDeleteConfirm.getRootAsAccountDeleteConfirm( + new ByteBuffer(encodeAccountDeleteConfirm('123456', 'DELETE')), + ); + expect(c.code()).toBe('123456'); + expect(c.phrase()).toBe('DELETE'); + + const b = new Builder(32); + const m = b.createString('email'); + fb.AccountDeleteRequestResult.startAccountDeleteRequestResult(b); + fb.AccountDeleteRequestResult.addMethod(b, m); + b.finish(fb.AccountDeleteRequestResult.endAccountDeleteRequestResult(b)); + expect(decodeDeleteRequestResult(b.asUint8Array()).method).toBe('email'); + }); + it('passes an unlinked / changed LinkResult status straight through', () => { for (const status of ['unlinked', 'changed'] as const) { const b = new Builder(64); diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 6ff6739..4c34194 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -739,6 +739,21 @@ export function encodeLinkUnlink(kind: string): Uint8Array { return finish(b, fb.LinkUnlinkRequest.endLinkUnlinkRequest(b)); } +export function encodeAccountDeleteConfirm(code: string, phrase: string): Uint8Array { + const b = new Builder(64); + const c = b.createString(code); + const p = b.createString(phrase); + fb.AccountDeleteConfirm.startAccountDeleteConfirm(b); + fb.AccountDeleteConfirm.addCode(b, c); + fb.AccountDeleteConfirm.addPhrase(b, p); + return finish(b, fb.AccountDeleteConfirm.endAccountDeleteConfirm(b)); +} + +export function decodeDeleteRequestResult(buf: Uint8Array): { method: 'email' | 'phrase' } { + const r = fb.AccountDeleteRequestResult.getRootAsAccountDeleteRequestResult(new ByteBuffer(buf)); + return { method: (s(r.method()) || 'phrase') as 'email' | 'phrase' }; +} + export function decodeLinkResult(buf: Uint8Array): LinkResult { const r = fb.LinkResult.getRootAsLinkResult(new ByteBuffer(buf)); const sess = r.session(); diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 9796b94..5aef200 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -188,6 +188,15 @@ export const en = { 'profile.unlinked': 'Account unlinked.', 'profile.unlinkTitle': 'Unlink account?', 'profile.unlinkBody': 'Remove {provider} as a sign-in method? You can link it again later.', + 'profile.deleteAccount': 'Delete account', + 'profile.deleteTitle': 'Delete your account?', + 'profile.deleteWarn': 'This removes your account and frees your sign-in methods. It cannot be undone.', + 'profile.deleteEmailPrompt': 'We sent a code to your email. Enter it to delete your account.', + 'profile.deletePhrasePrompt': 'Type DELETE to confirm.', + 'profile.deletePhrasePlaceholder': 'DELETE', + 'profile.deleteConfirm': 'Delete permanently', + 'deleted.title': 'Account deleted', + 'deleted.body': 'Your account has been removed. Reopening the app just creates a new account.', 'settings.title': 'Settings', 'settings.theme': 'Theme', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 9b5503c..9023d99 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -188,6 +188,15 @@ export const ru: Record = { 'profile.unlinked': 'Аккаунт отвязан.', 'profile.unlinkTitle': 'Отвязать аккаунт?', 'profile.unlinkBody': 'Убрать {provider} как способ входа? Позже можно привязать снова.', + 'profile.deleteAccount': 'Удалить аккаунт', + 'profile.deleteTitle': 'Удалить аккаунт?', + 'profile.deleteWarn': 'Аккаунт будет удалён, а способы входа освобождены. Отменить нельзя.', + 'profile.deleteEmailPrompt': 'Мы отправили код на вашу почту. Введите его, чтобы удалить аккаунт.', + 'profile.deletePhrasePrompt': 'Введите DELETE для подтверждения.', + 'profile.deletePhrasePlaceholder': 'DELETE', + 'profile.deleteConfirm': 'Удалить навсегда', + 'deleted.title': 'Учётная запись удалена', + 'deleted.body': 'Аккаунт удалён. Если снова откроете приложение — будет создан новый аккаунт.', 'settings.title': 'Настройки', 'settings.theme': 'Тема', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 28649af..f0d27cc 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -687,6 +687,10 @@ export class MockGateway implements GatewayClient { this.profile.email = email; return { ...emptyLinked(), status: 'changed' }; } + async deleteRequest(): Promise<{ method: 'email' | 'phrase' }> { + return { method: this.profile.email ? 'email' : 'phrase' }; + } + async deleteConfirm(_code: string, _phrase: string): Promise {} async statsGet(): Promise { return { ...this.stats }; } diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts index e9ad334..74f23da 100644 --- a/ui/src/lib/telegram.ts +++ b/ui/src/lib/telegram.ts @@ -36,6 +36,7 @@ interface TelegramWebApp { contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number }; ready?: () => void; expand?: () => void; + close?: () => void; openTelegramLink?: (url: string) => void; openLink?: (url: string) => void; onEvent?: (event: string, handler: () => void) => void; @@ -84,6 +85,12 @@ export function insideTelegram(): boolean { return !!w && typeof w.initData === 'string' && w.initData.length > 0; } +/** telegramClose closes the Mini App (Telegram.WebApp.close), used on the terminal + * account-deleted screen. A no-op outside Telegram. */ +export function telegramClose(): void { + webApp()?.close?.(); +} + // The ?NN suffix pins the Bot API SDK version Telegram serves (and busts the cache); keep it at the // version the official Mini Apps page currently recommends so newer client features (fullscreen, // safe-area insets, vertical-swipe guard, …) are available. Bump it when Telegram bumps theirs. diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 99bf7df..12863af 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -277,6 +277,12 @@ export function createTransport(baseUrl: string): GatewayClient { async changeEmailConfirm(email, code) { return codec.decodeLinkResult(await exec('link.email.change.confirm', codec.encodeLinkEmailConfirm(email, code))); }, + async deleteRequest() { + return codec.decodeDeleteRequestResult(await exec('account.delete.request', codec.empty())); + }, + async deleteConfirm(code, phrase) { + await exec('account.delete.confirm', codec.encodeAccountDeleteConfirm(code, phrase)); + }, async statsGet() { return codec.decodeStats(await exec('stats.get', codec.empty())); }, diff --git a/ui/src/lib/vk.ts b/ui/src/lib/vk.ts index aa236ff..2fb04fe 100644 --- a/ui/src/lib/vk.ts +++ b/ui/src/lib/vk.ts @@ -52,6 +52,18 @@ export async function vkInit(): Promise { } } +/** + * vkClose closes the VK Mini App (VKWebAppClose), used on the terminal account-deleted + * screen. Best-effort: a no-op outside VK. + */ +export async function vkClose(): Promise { + try { + await (await bridge()).send('VKWebAppClose', { status: 'success' }); + } catch { + // Outside VK there is no client to receive it. + } +} + /** * vkUserName fetches the launching user's display name via VKWebAppGetUserInfo, since VK omits it * from the signed launch params. Returns '' on any failure or outside VK, so the backend falls back diff --git a/ui/src/screens/AccountDeleted.svelte b/ui/src/screens/AccountDeleted.svelte new file mode 100644 index 0000000..372e71f --- /dev/null +++ b/ui/src/screens/AccountDeleted.svelte @@ -0,0 +1,53 @@ + + +
+
+

{t('deleted.title')}

+

{t('deleted.body')}

+ {#if canClose} + + {/if} +
+
+ + diff --git a/ui/src/screens/Profile.svelte b/ui/src/screens/Profile.svelte index db1b42a..3ae6d94 100644 --- a/ui/src/screens/Profile.svelte +++ b/ui/src/screens/Profile.svelte @@ -1,7 +1,15 @@
@@ -381,6 +416,10 @@ {/if} + {#if !p.isGuest} + + {/if} + @@ -409,6 +448,28 @@ {/if} +{#if deleting} + {@const d = deleting} + (deleting = null)}> +

{t('profile.deleteWarn')}

+ {#if d.method === 'email'} +

{t('profile.deleteEmailPrompt')}

+
+ +
+ {:else} +

{t('profile.deletePhrasePrompt')}

+
+ +
+ {/if} +
+ + +
+
+{/if} +