R6(a): de-stage code, docs, READMEs; split stage6_test

Mechanical, behaviour-preserving removal of Stage N / TODO-N / phase (RN)
references from comments, doc-comments, service READMEs, the current-state docs
(ARCHITECTURE, FUNCTIONAL+_ru, TESTING, UI_DESIGN), config-file comments, and the
.fbs/.proto schema comments. PLAN.md / PRERELEASE.md / CLAUDE.md keep the stage
history.

- Rename the only stage-named identifiers: registerStage8 -> registerSocialOps,
  registerStage11 -> registerLinkOps (gateway transcode).
- Split stage6_test.go: TestEmailLoginFlow -> email_test.go,
  TestGuestAutoMatchLeavesNoStats (+ provisionGuest) -> account_test.go.
- Regenerated proto bindings (push.pb.go, telegram_grpc.pb.go) from the de-staged
  .proto comments; FB Go/TS bindings unchanged (flatc strips schema comments).

go build/vet/gofmt clean across modules; integration typecheck and pnpm check green.
This commit is contained in:
Ilia Denisov
2026-06-10 16:56:03 +02:00
parent a372343797
commit 8881214213
156 changed files with 749 additions and 778 deletions
+7 -7
View File
@@ -24,8 +24,8 @@ import (
// Identity kinds recognised by the backend. Email is modelled as an identity
// alongside platform identities; its confirmed flag is driven by the email
// confirm-code flow in a later stage. Robot is a synthetic kind: each pooled
// robot opponent is a durable account bound to one robot identity (Stage 5).
// confirm-code flow. Robot is a synthetic kind: each pooled
// robot opponent is a durable account bound to one robot identity.
const (
KindTelegram = "telegram"
KindEmail = "email"
@@ -66,19 +66,19 @@ type Account struct {
IsGuest bool
// NotificationsInAppOnly confines notifications to the in-app live stream when
// true (the default): the platform side-service skips out-of-app push for the
// account (Stage 9).
// account.
NotificationsInAppOnly bool
// PaidAccount marks a lifetime one-time-payment account. It is a service field
// (no purchase flow yet); an account linking & merge ORs it so a paid status is
// never lost when accounts are consolidated (Stage 11).
// never lost when accounts are consolidated.
PaidAccount bool
// MergedInto is the primary account a retired (merged) secondary points at, or
// uuid.Nil for a live account. A tombstone keeps the row so the no-cascade
// foreign keys of a shared finished game stay valid (Stage 11).
// foreign keys of a shared finished game stay valid.
MergedInto uuid.UUID
// FlaggedHighRateAt is the soft, reversible "suspected high-rate" marker: the
// zero time for an unflagged account, otherwise when the gateway-reported
// rate-limiter rejections first crossed the sustained threshold (R3). An
// rate-limiter rejections first crossed the sustained threshold. An
// operator clears it in the admin console; it never gates any request.
FlaggedHighRateAt time.Time
CreatedAt time.Time
@@ -430,7 +430,7 @@ func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) {
// FlagHighRate stamps the soft "suspected high-rate" marker with at, only when
// the account is not already flagged — the first sustained episode wins, and a
// re-flag after an operator clear starts a fresh timestamp. An infra marker, not
// a profile edit, so updated_at is untouched; it never gates any request (R3).
// a profile edit, so updated_at is untouched; it never gates any request.
// It reports whether the flag was newly set.
func (s *Store) FlagHighRate(ctx context.Context, id uuid.UUID, at time.Time) (bool, error) {
stmt := table.Accounts.
+4 -4
View File
@@ -33,7 +33,7 @@ var (
// ErrInvalidEmail is returned for an unparseable email address.
ErrInvalidEmail = errors.New("account: invalid email address")
// ErrEmailTaken is returned when the email is already confirmed by another
// account; binding it would be a merge, which Stage 11 owns.
// account; binding it would be a merge, which the link/merge flow owns.
ErrEmailTaken = errors.New("account: email already confirmed by another account")
// ErrAlreadyConfirmed is returned when the email is already confirmed by the
// requesting account.
@@ -52,8 +52,8 @@ var (
// Mailer and verifies it, binding a confirmed email identity to the requesting
// account. Only the SHA-256 hash of a code is stored (never the plaintext),
// matching the session model. Binding an email already confirmed by a different
// account is refused (ErrEmailTaken) — merging two accounts is Stage 11 — and
// using an email as a login is Stage 6, which reuses this mechanism.
// account is refused (ErrEmailTaken) — merging two accounts is the link/merge flow —
// and using an email as a login reuses this mechanism.
type EmailService struct {
store *Store
mailer Mailer
@@ -128,7 +128,7 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema
// RequestLoginCode issues a login confirm-code to the account that owns email,
// provisioning a fresh (unconfirmed) durable account when the email is new. It is
// the unauthenticated email-login entry point (Stage 6) and, unlike RequestCode,
// the unauthenticated email-login entry point and, unlike RequestCode,
// does not refuse an already-confirmed email — that is the ordinary returning-user
// login. The code is mailed to the address, so only its real owner can complete
// the login. It returns the target account id for the subsequent LoginWithCode.
+5 -5
View File
@@ -13,14 +13,14 @@ import (
)
// ErrIdentityTaken is returned when a platform identity being linked already
// belongs to another account; the caller turns it into a merge (Stage 11).
// belongs to another account; the caller turns it into a merge.
var ErrIdentityTaken = errors.New("account: identity already linked to another account")
// RequestLinkCode issues and mails a confirm-code for email to accountID,
// replacing any prior pending code. Unlike RequestCode it never refuses up front
// (taken or already-confirmed): possession of the address is the authorization for
// a later link or merge, and the merge is only revealed once the code is verified,
// so a probe cannot learn whether an address is registered (Stage 11).
// so a probe cannot learn whether an address is registered.
func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID, email string) error {
addr, err := normalizeEmail(email)
if err != nil {
@@ -94,7 +94,7 @@ func (s *EmailService) verifyPendingCode(ctx context.Context, accountID uuid.UUI
// AccountIDByIdentity returns the account owning (kind, externalID) and true, or
// (uuid.Nil, false) when the identity is free. It backs the platform-identity link
// flow (Stage 11).
// flow.
func (s *Store) AccountIDByIdentity(ctx context.Context, kind, externalID string) (uuid.UUID, bool, error) {
acc, err := s.findByIdentity(ctx, kind, externalID)
if errors.Is(err, ErrNotFound) {
@@ -109,7 +109,7 @@ func (s *Store) AccountIDByIdentity(ctx context.Context, kind, externalID string
// AttachIdentity links a new (kind, externalID) identity to an existing account.
// A unique-constraint violation means the identity was taken meanwhile, surfaced
// as ErrIdentityTaken. It is used to attach a platform identity (e.g. Telegram)
// to the current account during linking (Stage 11).
// to the current account during linking.
func (s *Store) AttachIdentity(ctx context.Context, accountID uuid.UUID, kind, externalID string, confirmed bool) error {
id, err := uuid.NewV7()
if err != nil {
@@ -129,7 +129,7 @@ func (s *Store) AttachIdentity(ctx context.Context, accountID uuid.UUID, kind, e
}
// ClearGuest removes the is_guest flag from accountID, promoting an ephemeral guest
// to a durable account once it gains its first identity (Stage 11). It is a no-op
// to a durable account once it gains its first identity. It is a no-op
// for an already-durable account.
func (s *Store) ClearGuest(ctx context.Context, accountID uuid.UUID) error {
upd := table.Accounts.UPDATE(table.Accounts.IsGuest, table.Accounts.UpdatedAt).
+3 -3
View File
@@ -25,16 +25,16 @@ const maxDisplayName = 32
// maxDisplayNameSpecials caps the total special characters (the "." / "_" separators —
// every name rune that is neither a letter nor a space) an editable display name may
// carry, so a still-well-formed name cannot be made of mostly punctuation (Stage 17).
// carry, so a still-well-formed name cannot be made of mostly punctuation.
const maxDisplayNameSpecials = 5
// maxAwayWindow bounds the daily away window's duration (midnight-wrap aware).
const maxAwayWindow = 12 * time.Hour
// displayNameRe enforces the editable display-name format (Stage 8): Unicode letters
// displayNameRe enforces the editable display-name format: Unicode letters
// joined by single space / "." / "_" separators, where a "." or "_" may be followed
// by a single space. No leading separator and no two adjacent separators (except
// "<dot|underscore> <space>"); a single trailing "." is allowed (Stage 17), so
// "<dot|underscore> <space>"); a single trailing "." is allowed, so
// "Name_P. Last" and "Anna B." are valid, "Name P._Last" is not.
var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$`)
+1 -1
View File
@@ -12,7 +12,7 @@ import (
// TestUpdateProfileValidation checks that bad fields are rejected before any
// database access, so a nil-backed Store is enough to exercise the guards. It also
// confirms UpdateProfile wires the Stage 8 validators (name format, away window,
// confirms UpdateProfile wires the validators (name format, away window,
// offset/IANA timezone), not just their unit tests in validate_test.go.
func TestUpdateProfileValidation(t *testing.T) {
s := &Store{}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
)
// offsetZoneRe matches a fixed UTC offset like "+03:00" or "-05:30" — the form the
// Stage 8 profile editor stores (an offset dropdown rather than an IANA name).
// profile editor stores (an offset dropdown rather than an IANA name).
var offsetZoneRe = regexp.MustCompile(`^([+-])(\d{2}):(\d{2})$`)
// parseOffsetZone parses a "±HH:MM" offset into a fixed-offset location, reporting
+2 -2
View File
@@ -20,7 +20,7 @@ type UserListItem struct {
IsGuest bool
IsRobot bool
// FlaggedHighRateAt is the soft high-rate marker (zero when unflagged), shown
// as a badge in the console list (R3).
// as a badge in the console list.
FlaggedHighRateAt time.Time
CreatedAt time.Time
}
@@ -105,7 +105,7 @@ type FlaggedAccount struct {
const flaggedListCap = 200
// ListFlaggedHighRate returns the accounts carrying the high-rate flag, most
// recently flagged first (R3).
// recently flagged first.
func (s *Store) ListFlaggedHighRate(ctx context.Context) ([]FlaggedAccount, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT account_id, display_name, flagged_high_rate_at