Owner invitations & email ownership proof
Creating a gym (POST /organizations) requires an owner grant issued by a platform admin. Without one, a signed-in user with no memberships is routed to /{lang}/invitation-required (course buyers go to their /{lang}/library instead). This runbook covers issuing grants, the email-ownership rules that guard them, and rolling out the join-link email-proof flag.
Background: users-auth behavior, onboarding behavior, memberships behavior, admin-app.
Model in one paragraph
A grant is a row in owner_invitations (pending → consumed / revoked / expired, 30-day expiry). One grant creates exactly one organization. The claim happens inside the org-creation transaction (row lock), so a double submit makes the second request fail with 409 OWNER_INVITATION_ALREADY_USED. A retry after a lost response (same user, same gym name, grant already consumed) returns the org that was created. A grant failing before commit stays pending, so the owner can simply retry.
Two ways to grant:
| Mode | Use when | Mechanics |
|---|---|---|
| New email (default) | The person has no Taikan/Clerk account yet. | Admin app creates the grant and Clerk emails an invitation (/sign-up, publicMetadata.ownerInvitationId). The new account claims the grant only if the invitation id and the account’s primary email match. |
| Existing account | The person already has an account (e.g. invited from the Clerk Dashboard, or a member of another gym). | Grant is bound to their Clerk user id; no email is sent. Their next GET /users/me returns canCreateOrganization: true and /onboarding opens (even for someone who already belongs to another gym). |
Invite a new owner
- Admin app → Owner Invitations → Invite owner.
- Enter the email, leave Existing account off, submit.
- The person receives a Clerk invitation email, signs up, and lands on
/onboarding. - Check the row:
pendinguntil they create the gym, thenconsumedwith the organization shown.
Errors:
| Error | Meaning / action |
|---|---|
409 OWNER_INVITATION_EMAIL_HAS_ACCOUNT | That email already has an account. Use Existing account instead (see the proof caveat below). |
409 OWNER_INVITATION_PENDING_EXISTS | A live pending grant exists. Resend or revoke it. |
Resend (row action) re-mints the Clerk invitation and resets the expiry to 30 days; new-email grants only. Revoke cancels a pending grant. Revoking the Clerk invitation from the Clerk Dashboard also revokes the grant (webhook). An expired or revoked grant cannot be revived: create a new one.
Grant an existing account
- Admin app → Owner Invitations → Invite owner, enter the account’s email, turn Existing account on.
- The user must have a linked account (
404 OWNER_INVITATION_ACCOUNT_NOT_FOUNDotherwise) and its email must be proven (next section). - Tell them to reload; they are sent to the onboarding sheet.
Backfill: accounts invited from the Clerk Dashboard who have no gym yet
People invited through the Clerk Dashboard before this change have no ownerInvitationId metadata, so after deploy they land on invitation-required. List the candidates:
SELECT u.email, u.clerk_id
FROM users u
LEFT JOIN memberships m ON m.user_id = u.id
WHERE m.id IS NULL
AND u.clerk_id IS NOT NULL
AND u.deleted_at IS NULL;For each legitimate person (not stray sign-ups, course-store buyers or test accounts), create an Existing account grant in the admin app. Their accounts carry no joinToken, so they are normally proven and the grant succeeds directly. Open the user in Admin → Users to see the Email Ownership tag.
OWNER_INVITATION_EMAIL_UNPROVEN
An existing-account grant is refused with 409 OWNER_INVITATION_EMAIL_UNPROVEN (“This account has not proven it owns its email…”) when the account is unproven: it was created from a join-link sign-up whose link was handed to the browser (flag OFF), so whoever typed that email could have registered it — including someone pre-registering an address you are about to grant. Also returned when Clerk cannot be read (fail closed).
Handle it:
- Confirm out-of-band that the real owner controls that inbox (phone call, reply from a known thread, ask them to forward a code). Do not rely on the sign-up itself.
- Admin → Users → open the user → Mark email verified (
POST /admin/users/:id/verify-email-ownership). This writesemailProvenAt/emailProvenBy='admin'andemailProvenByActor(your Clerk id) to the Clerk user. - Create the Existing account grant again.
If you cannot confirm the person, do not grant. Delete or ignore the squatted account and issue a new-email grant only once the address has no account (a new-email grant is refused while an account exists: EMAIL_HAS_ACCOUNT). The same Mark email verified action releases a held gym invitation (/users/me.invitationHeld, audit membership.invitation_held) for a legitimate invitee who cannot open the invitation email.
Join-link email delivery
POST /join/:token/request-invite emails a branded, localized sign-up link to the address typed and returns { deliveryMode: 'email', email }; the Clerk URL never reaches the browser. Accounts created from that link are proven. It was rolled out per org behind join-email-ownership-proof, which is retired (never reuse the key).
503 JOIN_EMAIL_DELIVERY_FAILEDwhen the provider does not accept the mail (no URL leaked; the visitor resubmits). Watchjoin.blockedwithreason: 'email_delivery_failed'and thejoin.invite_emailedcount in PostHog, and 503s onrequest-invitein Sentry.- The invite caps (
DEFAULT_JOIN_INVITE_CAPSor thejoin-invite-capspayload) always apply, so the form cannot be used as a mail relay. - Accounts created earlier from a browser-delivered ticket stay
unproven(member-role join flows are unaffected; only staff-sent invitations to them are held).
Related guardrails shipped with this change
-
Held invitations — every staff-sent invitation (any role) is held until the account’s email is proven; only
source='join_link'invitations auto-accept for unproven accounts. The signed-in join link refuses to join an unproven account around a held invitation (403 INVITATION_HELD) and never restores a staff role. Opening the invitation email does not release a hold; only Mark email verified does. See incident-response.md. -
Bulk invite and the Clerk limit — bulk invite can reach every invitable member; a Clerk
429stops the batch and fails the rest withrate_limited(Clerk allows 100 invitations/hour), shown in the dialog. The audience is bounded by the tier member cap below. -
Import seat cap — an import that would exceed the tier’s active-member limit is refused whole, with a localized error on the import job. Elite is unlimited.
-
Join link never hands out a ticket for someone else’s identity (not flagged; it closes an account takeover):
- An address that already has a login gets
409 ACCOUNT_EXISTSand a Sign in button that returns to the link. - An address holding a profile someone else created (import, staff invitation, other gym, named guardian) gets its link emailed, with nothing from the form attached. That email counts against the invite caps.
- New addresses get their link by email too (see above).
- At claim time, an unproven login can’t take over a row a gym filed after the ticket was minted (
403 EMAIL_OWNERSHIP_REQUIRED). Support releases it with Mark email verified.
See memberships behavior.
- An address that already has a login gets
Deploy / migration
Migration 0167_solid_shadow_king (owner_invitations table, enum, FK, 3 indexes + partial unique index on pending email) is applied per migrations.md. Deploy order: migration → API → web/admin. Between API and web deploys, new users may briefly be treated as unable to create an organization; nothing else is affected. The owner-grant requirement itself cannot be rolled back without redeploying. If the API deploys before the web, a visitor whose address has a login sees the generic “already a member” screen with a Sign in button (409) rather than the new copy; no ticket is issued either way.