Skip to content

Custom handles become free; record who invited a member - #6

Merged
kipavy merged 2 commits into
mainfrom
feat/free-custom-handles
Aug 16, 2026
Merged

Custom handles become free; record who invited a member#6
kipavy merged 2 commits into
mainfrom
feat/free-custom-handles

Conversation

@kipavy

@kipavy kipavy commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Two commits, both removing more than they add.

1. Custom handles become free

The 402 tier gate on PUT /v1/users/me/handle is deleted. Every hosted registration already gets a 14-day Pro trial (auth.rs:176), so the gate made anyone who claimed in their first fortnight permanently custom-handled anyway — it was a no-op carrying real machinery, and it landed on identity rather than on discovery. A member of a paying Business team was told "Custom handles are a Pro feature" because effective_tier_for_user reads only the user's own row.

Claiming now requires email_verified and returns 403 {"error":"EMAIL_NOT_VERIFIED"} otherwise, so a client can say "verify your email first" instead of "invalid handle". That response body already existed for the checkout gate; it moves to routes::mod so the two cannot drift.

Search is untouched. (handle_is_custom AND LOWER(handle) LIKE $1) already says "custom handles are fuzzy-searchable, generated ones are exact-match only", and that property is about generated vs custom, never paid vs free. No new column, no migration, no backfill.

The reserved-name lists are widened, since every account can now attempt a claim rather than only a paying one: mail-system roles, trust words, and the terms a billing or verification prompt would legitimately use. impersonation_key strips separators, so noreply covers no-reply and v3rified is caught.

USER_SEARCH_RATE_LIMIT was reviewed and deliberately left at 60/min per user_id. The enumeration ceiling is set by LIMIT 8 plus a stable ORDER BY u.handle with no offset — a larger searchable population makes each query less revealing, not more. Lowering it would only hurt type-ahead; the abuse brake that matters is STRANGER_KNOCK_RATE_LIMIT.

2. Record who invited a member — and actually admit them on registration

TeamMember.invited_by_display_name was null for every accepted invite: both accept paths inserted (team_id, user_id) and dropped the invitation's invited_by, so the roster's LEFT JOIN users inv had nothing to resolve.

The registration auto-accept was worse. Its INSERT named a role column that team_members has not had since the roles migration, the error was discarded by let _ =, and the invitations were marked accepted anyway — a user who registered with a pending invite consumed the invite and never joined the team, with no role row either.

All three paths now share admit_member(). The upsert only fills a NULL inviter, so re-accepting cannot rewrite who brought a member in, and a link-only invite that arrives first is backfilled by a later accept.

Verification

cargo test243 passed, 0 failed (against a real Postgres, so the DB-backed tests actually ran rather than skipping). cargo clippy --all-targets -- -D warnings clean.

New coverage: a free verified user can claim and is fuzzy-searchable afterwards; an unverified one is refused with 403; a generated handle is still not matched by a substring but does resolve exactly (the security property asserted directly); an expired trial can still claim; and invited_by is recorded, backfilled from NULL, and never rewritten.

Deploy order

The web portal ships first (VoltiusApp/web#7), then this, then the client. Migrations run in-process at startup — stop the old server, start the new one; no rolling restart.

kipavy added 2 commits August 16, 2026 09:30
…email

The 402 tier gate on PUT /v1/users/me/handle is gone. Every hosted
registration already gets a 14-day Pro trial (auth.rs:176), so the gate
made anyone who claimed in their first fortnight permanently custom-
handled anyway — it was a no-op carrying real machinery, and it landed on
identity rather than on discovery. A member of a paying Business team was
told "Custom handles are a Pro feature" because effective_tier_for_user
reads only the user's own row.

Claiming now requires email_verified and returns 403 EMAIL_NOT_VERIFIED
otherwise, so a client can say "verify your email first" instead of
"invalid handle". That response body already existed for the checkout
gate; it moves to routes::mod so the two cannot drift.

Search is untouched: (handle_is_custom AND handle LIKE ...) already says
"custom handles are fuzzy-searchable, generated ones are exact-match
only", and that property is about generated vs custom, never paid vs free.
No new column, no migration, no backfill.

The reserved-name lists are widened, since every account can now attempt a
claim rather than only a paying one: mail-system roles, trust words and
the terms a billing or verification prompt would use.
…egistration

TeamMember.invited_by_display_name was null for every accepted invite: the
two accept paths inserted (team_id, user_id) and dropped the invitation's
invited_by, so the roster's LEFT JOIN on users had nothing to resolve.

The registration auto-accept was worse. Its INSERT named a `role` column
that team_members has not had since the roles migration, the error was
discarded by `let _ =`, and the invitations were marked accepted anyway —
so a user who registered with a pending invite consumed the invite and
never joined the team, with no role row either.

All three paths now share admit_member(), which writes the membership row
with invited_by and assigns the builtin role. The upsert only fills a NULL
inviter, so re-accepting cannot rewrite who brought a member in, and a
link-only invite that arrives first is backfilled by a later accept.
@kipavy
kipavy merged commit 106c630 into main Aug 16, 2026
2 checks passed
@kipavy
kipavy deleted the feat/free-custom-handles branch August 16, 2026 13:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant