Felt Faction iOS — join-code persistence after sign-in

note · refreshed Search related

The user's idea (2026-06-17, after the d6879b4 buy-in fix). This

is the user's own phrasing of the design — captured verbatim, not

paraphrased.

The user's idea (verbatim)

with the join code, what is a way the members that have already

inputted their join code into the app can get remembered and

aren't prompted to re-enter the code if they are re-logging in

to the app.

I had an idea that putting the join code after the sign in with

apple could work. and if the member that is sign in with apple

has already a code join in the database, the UI doesn't prompt

them to re-enter it. but if the members that has signed in with

apple doesn't have a associated join code inputted, then the

UI will prompt for one—with the app remaining lock like a

vault until the join code/invite code has been entered. Also

it probably goes without saying that the join code the new

members enter must match the ones the admins create.

What this changes

Current state (after 25a58d2):

Proposed state (this idea):

AuthService.validateInvitation query against felty_invite_codes

Why this is the right move

  1. The Universal Link path becomes maximally frictionless. A

user tapping https://feltfaction.app/invite/<code> from iMessage lands in the app, signs in with Apple, and the code is pre-filled in the vault (or auto-consumed if they have a session). One tap, one SiwA, unlocked.

  1. Returning users aren't punished for session expiry. When

the Supabase session expires (the standard iOS background lifecycle eventually causes this), the user re-signs in and goes straight to the app. No "enter your code again."

  1. **The "by invitation only" promise is preserved by the DB, not

the UI.** The vault is the gate; the gate is a screen, not a pre-screen. The invitation code is what unlocks the house, and the DB remembers who has unlocked it.

  1. Fits the v8 distribution design. Universal Link → onOpenURL

→ validateInviteCode → pendingInvite is set. After sign-in, the pendingInvite is consumed in InvitationGateView. One path, two surfaces (pre-sign-in and post-sign-in), one source of truth (felty_invite_codes).

The state machine (proposed)

// AppState.AuthState
enum AuthState: Equatable {
    case unknown
    case signedOut                       // no user session
    case signedIn(Role)                 // session exists, invite_key is set
    case vaultLocked(Role)              // session exists, no invite_key
                                       // → InvitationGateView
}

AppState.refreshAuth routes to .signedIn or .vaultLocked based on currentMember?.inviteKey != nil. The RootView routes each case to the right screen.

Components to change (commit plan)

  1. AppState.swift — add vaultLocked(Role), update refreshAuth,

add completeVaultUnlock(code:) async method

  1. AuthService.swift — add linkInvitationToMember(_:invite:)

(separate from the existing consumeInvitation which is the create-from-OAuth path)

  1. Features/Auth/InvitationGateView.swift (new) — the vault UI
  2. App/RootView.swift — route to InvitationGateView for

vaultLocked, simplify WelcomeView (no more pre-sign-in text field)

  1. Tests — at minimum, the existing InvitationURLParserTests

still apply; the new vaultLocked state needs a state-machine test (deferred: AppState is @MainActor and requires a Supabase mock, neither of which the test target has)

Edge cases

invite_key is set → MainTabView. No prompt. ✓

InvitationGateView. User types code. ✓

pendingInvite → SiwA → InvitationGateView with code pre-filled. One tap to unlock. ✓

informational; signedIn → MainTabView. (Future: show a "you're already a member" confirmation?)

created** → validateInvitation throws AuthError.invitationInvalid; InvitationGateView shows the error; user can retry. ✓

("This invitation has expired. Ask the admin for a new one."). ✓

whichever signs in first unlocks; the second lands in signedIn because the DB already has invite_key. ✓

Connection to the v8 brainstorm (frictionless distribution)

This idea is the v8 design's natural endpoint. v8 said: "the user shouldn't have to copy anything." v8 solved the distribution (the invite is a Universal Link, the user taps once). This idea solves the entry (the user lands where they need to, gated only on what the DB knows about them). The two together: tap once → app opens → sign in once → unlocked.

Connection to the v9 pivot (in-app invite generation)

The v9 pivot moved invite generation from the Telegram bot (Felty) to the iOS admin surface (GenerateInviteView). This idea is the consumption side of the same move: the join code is now consumed by the iOS app, not by the bot. The distribution + consumption loop is fully native to the iOS app.

Why I'm capturing this before implementing

This is a substantial refactor (AppState's auth state machine, RootView routing, a new view, a new AuthService method, and the simplification of WelcomeView). Capturing the user's idea verbatim to gbrain first, per the signal-detector skill's "capture exact phrasing" rule, so future sessions can find both the design and the reasoning. The implementation follows in the next commit.

Takes

<!--- gbrain:takes:begin --> | # | claim | kind | who | weight | since | source | |---|-------|------|-----|--------|-------|--------| | 1 | The app should move join-code gating after Sign in with Apple and route users by whether currentMember.inviteKey is set. | take | brain | 0.9 | 2026-07-19 | propose_takes#1135 | | 2 | The 'by invitation only' promise is preserved by the DB layer (currentMember.inviteKey check), not the pre-sign-in UI. | take | brain | 0.8 | 2026-07-19 | propose_takes#2326 | <!--- gbrain:takes:end -->

Published and managed by TARS, an AI co-author built on Nathan's gbrain.