Skip to content

feat(invitations): karma-gate link creation, pay the reward at redemption - #212

Open
chai-inu wants to merge 1 commit into
mainfrom
dev/invite-karma-gate-and-reward
Open

chai-inu wants to merge 1 commit into
mainfrom
dev/invite-karma-gate-and-reward

Conversation

@chai-inu

@chai-inu chai-inu commented Sep 2, 2026

Copy link
Copy Markdown
Member

Why

Two things were quietly not true about inviting users.

Nobody was gated. POST /invitation-links/ only required a valid JWT, and create_invitation_link carried a # TODO we didn't check if this user is allowed to invite new users — so any logged-in account could mint unlimited links, each good for 100 signups.

The invite reward was dead code. pay_reward_for_invitation had no caller. INVITE_NEW_USER_REWARD and INVITE_NEW_USER_MAX_REWARDED read as promises the site makes and enforced nothing, and sent_new_user_invitataions was never written to. The rules-applied ratchet stayed green the whole time, because it looks for readers of a rule, not reachable callers — worth knowing about that check's blind spot.

What changed

Karma gate. Link creation now requires MIN_KARMA_INVITE_USER = 100, the same shape as the upload and site-creation gates. The superuser is exempt, following services/sites.py — otherwise the admin (karma 0) could not mint links at all, which matters more once /daily is gone.

Reward at redemption. try_consume_invitation_link_by_uuid returned a bool, which hid the inviter from open_account. It splits into a read-only resolve_usable_invitation_link and a separate consume. open_account validates the link up front and, only once crud.user.create has succeeded, spends the quota and pays the inviter.

That split also fixes an ordering bug: the old code decremented the quota before checking the email, handle and verification code, so every rejected signup cost the inviter a slot.

Two corrections inside pay_reward_for_invitation beyond wiring it up:

  • Returns early when the inviter is the superuser, who funds the reward — otherwise the shared daily link books superuser→superuser transfers.
  • Counts every invitation that brought someone in, rewarded or not, checking the cap against the pre-increment value. Otherwise a spell of the admin being broke silently hands people extra rewarded slots.

Expiry is now enforced at redemption. The old consume path checked only the quota, so an expired link still opened accounts. Both that path and the serialized valid field now read one is_usable_invitation_link in model_utils.py, next to is_live_answer/is_live_article — what a caller is shown is what redemption enforces.

INITIAL_USER_COINS 0 → 50, resolving the TODO in rules.py. A new account could not afford any coin cost, so no amount of invite reward reached the person actually being invited. Link quota and lifetime also move out of crud_invitation_link.py into rules.py alongside the other numbers.

Deliberately not in this PR

GET /invitation-links/daily is untouched and still unauthenticated. The karma gate does not really bind while anyone can fetch a 100-use link from it; turning that endpoint off is a separate change. Note tests/app/api/api_v1/test_users.py:42 sources its signup link from /daily, so retiring it means touching that test.

sent_new_user_invitataions keeps its typo — it is now actually written to, so this is the cheapest moment to rename it, but that needs a migration.

The Invitation model and crud/crud_invitation.py remain dead (referenced only by tests).

Testing

  • 432 passed, 9 skipped across tests/app/api/, tests/app/crud/ and the unit tests.
  • All three architecture ratchets pass; rules-applied reports 24/24, covering the two new constants.
  • New tests/app/api/api_v1/test_invitation_links.py covers: karma gate below/at threshold, rejected signup leaves quota intact, successful signup consumes one and pays the inviter (invitee starts with 50 coins), daily link pays nobody.

🤖 Generated with Claude Code

https://claude.ai/code/session_01H7WGABXVRz5MYheF6vT3js

…tion

Creating an invitation link was ungated -- `create_invitation_link` carried a
TODO saying as much -- so any logged-in account could mint unlimited 100-use
links. It is now gated on MIN_KARMA_INVITE_USER, following the same shape as
the upload and site-creation gates, with the superuser exempt as in
`services/sites.py`.

The invite reward was dead code: `pay_reward_for_invitation` had no caller, so
INVITE_NEW_USER_REWARD and INVITE_NEW_USER_MAX_REWARDED were promises nothing
kept, and `sent_new_user_invitataions` was never written. (The rules-applied
ratchet was green throughout, because it looks for readers, not reachable
callers.) The reward is now paid when a signup actually produces an account.

To make that possible, `try_consume_invitation_link_by_uuid` -- which returned
a bool and so hid the inviter from `open_account` -- splits into a read-only
`resolve_usable_invitation_link` and a separate `consume`. That split also
fixes the ordering: the old code decremented the quota before checking the
email, handle and verification code, so every rejected signup cost the inviter
a slot. Nothing is spent now until `crud.user.create` has succeeded.

Two further corrections inside `pay_reward_for_invitation`:

  * Return early when the inviter is the superuser, who funds the reward, so
    the shared daily link cannot book superuser-to-superuser transfers.
  * Count every invitation that brought someone in, rewarded or not, checking
    the cap against the pre-increment value -- otherwise a spell of the admin
    being broke would silently hand people extra rewarded slots.

Redemption also checks expiry now, which the old consume path did not: it
tested only the quota, so an expired link still opened accounts. Both that
path and the serialized `valid` field read one `is_usable_invitation_link` in
`model_utils.py`, so what a caller is shown is what redemption enforces.

INITIAL_USER_COINS goes from 0 to 50, resolving the TODO in `rules.py`: a new
account could not afford any coin cost, so no amount of invite reward reached
the person actually being invited. The link quota and lifetime move out of
`crud_invitation_link.py` into `rules.py` alongside the other numbers.

The `/invitation-links/daily` endpoint is deliberately untouched, and is still
unauthenticated -- the karma gate does not bind while anyone can fetch a
100-use link from it. Turning it off is a separate change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7WGABXVRz5MYheF6vT3js

This branch has not been deployed

No deployments
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