Impulse AI Docs
Intern dokumentasjon
skipLink.label

Autentisering og abonnement

Impulse AI bruker native-only autentisering — Sign in with Apple (iOS) og Sign in with Google (Android). Ingen email/passord, ingen tredjepartslogins. Abonnement håndteres via App Store / Google Play med en binær tilgangsmodell.

Arkitekturoversikt

heading.anchorLabel
┌──────────────────────────────────────────────────┐
│ iOS / Android App │
│ │
│ Native Sign-In ──▶ KMP AuthManager ──▶ StoreKit │
│ (ASAuth/Google) (token lifecycle) (IAP) │
└──────────────────────┬────────────────────────────┘
│ Bearer JWT
┌──────────────────────────────────────────────────┐
│ API (Railway) │
│ │
│ POST /api/v1/auth/native ◀── sign-in │
│ POST /api/v1/auth/refresh ◀── token refresh │
│ authenticateJWT(req) ◀── alle andre kall │
│ isSubscriptionActive() ◀── tilgangskontroll │
└──────────────────────┬────────────────────────────┘
┌──────────────────────────────────────────────────┐
│ Supabase (PostgreSQL) │
│ │
│ auth.users ──trigger──▶ profiles │
│ (Apple/Google ID) (tier, status, trial) │
│ │
│ iap_subscriptions (Apple/Google tilstand) │
└──────────────────────────────────────────────────┘

Designbeslutninger

heading.anchorLabel
BeslutningBegrunnelse
Kun native sign-inAuth-identitet = Store-identitet = Subscription-identitet. Én rett linje.
Ingen email/passordEliminerer forgot-password, brute-force, credential stuffing. Apple/Google har allerede verifisert brukeren.
Binær tilgangsmodellAktiv = tilgang. Ikke aktiv = paywall. Ingen «graceful expiry».
isSubscriptionActive() som single source of truthÉn funksjon, brukt av både middleware og quota-service.
Separate auth- og notification-emailApple relay-email kan ikke motta kommunikasjon. Bruker setter notification_email selv.

Autentiseringsflyt

heading.anchorLabel

Sign-in (ny bruker eller returning)

heading.anchorLabel
1. Bruker trykker "Sign in with Apple"
2. iOS viser native Apple Sign-In (Face ID / passord)
3. Apple returnerer ID token + nonce
4. App sender til API: POST /api/v1/auth/native
5. API kaller supabase.auth.signInWithIdToken()
6. Supabase verifiserer med Apple, oppretter auth.users om ny
7. DB-trigger oppretter profiles-rad (7-dagers trial)
8. API returnerer JWT-par (access + refresh token)
9. KMP AuthManager lagrer tokens i Keychain
10. AuthStateWrapper oppdateres → ImpulseApp viser ContentView

Token refresh

heading.anchorLabel
1. API-kall returnerer 401 (token utløpt)
2. ApiClient interceptor prøver POST /api/v1/auth/refresh
3. Supabase roterer tokens
4. Nytt JWT-par lagres i AuthManager
5. Originalt API-kall prøves på nytt med nytt token
6. Hvis refresh feiler → AuthManager.logout() → LoginView
1. Bruker trykker "Sign out" i ProfileView
2. KMP ProfileStore kaller authManager.logout()
3. Tokens slettes fra Keychain
4. AuthState → Unauthenticated
5. AuthStateWrapper detekterer → KMPStoreProvider.reset() (sletter cached data)
6. ImpulseApp observerer → viser LoginView

API-endepunkter

heading.anchorLabel

POST /api/v1/auth/native

heading.anchorLabel

Autentisering med plattform-ID-token. Ingen auth middleware (dette ER auth-endepunktet).

// Request
{
provider: "apple" | "google",
id_token: string, // OIDC ID token fra Apple/Google
nonce?: string // For Apple (nonce-verifisering)
}
// Response
{
success: true,
data: {
access_token: string, // Supabase JWT (1h TTL)
refresh_token: string, // For token refresh
expires_in: number,
user: {
id: string, // UUID
email: string,
is_new_user: boolean // true = første sign-in (trial startet)
}
}
}

POST /api/v1/auth/refresh

heading.anchorLabel

Fornyer utløpt access token.

// Request
{ refresh_token: string }
// Response
{
success: true,
data: {
access_token: string,
refresh_token: string, // Rotert refresh token
expires_in: number
}
}

Subscription-modell

heading.anchorLabel

To kategorier tiers

heading.anchorLabel

Tiers er delt i to kategorier basert paa hvordan tilgang styres:

KategoriTiersDatakildeAktivering
Profiles-onlyTrial, PartnerKun profiles-tabellenTrigger (trial) / Admin (partner)
IAP-backedFoundation, Masteryprofiles + iap_subscriptionsApp Store / Google Play kjoep

Profiles-only tiers har ingen rad i iap_*-tabellene. All tilstandsinformasjon lever i profiles.

Tier-oversikt

heading.anchorLabel
TierPrisImpulserSjekk-innKilde
TrialGratis3 totalt7 totaltDB-trigger ved sign-up (7 dager)
PartnerGratis30/mnd90/mndAdmin-aktivering (partner-service.ts)
Impulse (foundation)$6/mnd30/mnd90/mndStoreKit / Google Play kjoep
Pro (mastery)$12/mnd60/mnd180/mndStoreKit / Google Play kjoep

Kvoter er konfigurerbare i subscription_config-tabellen uten redeploy.

Livssyklus per tier-kategori

heading.anchorLabel

Profiles-only (trial/partner):

Ny bruker → trigger → profiles (trial/active, 7d)
Trial utloeper → profiles (trial/expired) → paywall
Partner-aktivering → profiles (partner/active) → ingen utloep
Partner-deaktivering → profiles (trial/expired) → paywall

IAP-backed (foundation/mastery):

Bruker kjoeper i StoreKit → POST /subscriptions/sync
→ iap_subscriptions opprettes (Apple-tilstand)
→ profiles oppdateres (tier + status)
Fornyelse → App Store webhook → iap_subscriptions + profiles oppdateres
Kansellering → webhook → profiles (cancelled, aktiv til subscription_end_date)
Utloep → webhook → profiles (expired) → paywall

Tilgangskontroll: isSubscriptionActive()

heading.anchorLabel

Een funksjon som brukes av baade subscription-middleware og quota-service:

function isSubscriptionActive(tier, status, trialEndsAt, subscriptionEndDate): boolean
TilstandKategoriResultat
Trial med gyldig trial_ends_atProfiles-only✅ Aktiv
Trial utloeptProfiles-only❌ Paywall
Partner med is_partner=true, status=activeProfiles-only✅ Aktiv
Paid status='active'IAP-backed✅ Aktiv
Cancelled med gyldig subscription_end_dateIAP-backed✅ Aktiv
Cancelled etter subscription_end_dateIAP-backed❌ Paywall
Expired / inactiveBegge❌ Paywall

Ingen grace_period paa profile-nivaa. Apple haandterer billing retry og grace period. Naar Apple sender EXPIRED-webhook, settes profiles.subscription_status = 'expired'.

Naar bruker proever aa opprette impuls eller sjekk-inn:

1. Klient: quotaStore.canCapture → false → vis paywall
2. Server: canCaptureImpulse(userId) → false → 403 IMPULSE_QUOTA_EXCEEDED

Dobbel sjekk: klient gates for UX, server enforcer uansett.

Databasetabeller

heading.anchorLabel

profiles (alle brukere — appens brukerrepresentasjon)

heading.anchorLabel

Appens primaere bruker-entitet. Opprettes av DB-trigger naar auth.users-rad insertes. Alle tiers (trial, partner, foundation, mastery) har en rad her.

KolonneTypeBeskrivelse
idUUIDFK → auth.users
emailTEXTFra Apple/Google (kan vaere relay-adresse)
notification_emailTEXT?Brukervalgt adresse for kommunikasjon
auth_providerTEXT?apple eller google
subscription_tierTEXTtrial, foundation, mastery, partner
subscription_statusTEXTactive, expired, inactive, cancelled
subscription_end_dateTIMESTAMPTZ?Naar IAP-subscription utloeper/fornyes
subscription_cancelled_atTIMESTAMPTZ?Naar bruker kansellerte
trial_started_atTIMESTAMPTZ?Trial-start (satt av trigger)
trial_ends_atTIMESTAMPTZ?Trial-slutt (konfigurerbar via subscription_config)
trial_convertedBOOLEANOm trial konverterte til betalt
is_partnerBOOLEANPartner-status
partner_sourceTEXT?influencer, beta_tester, ambassador, marketing

iap_subscriptions (kun betalte brukere)

heading.anchorLabel

Opprettes foerst naar en bruker kjoeper via App Store / Google Play. Trial- og partner-brukere har ingen rad her.

KolonneBeskrivelse
platformios eller android
platform_product_idf.eks. im.foundation.monthly
statusApple/Google-tilstand (active, grace_period, billing_retry, expired)
expires_dateNaar subscription fornyes eller utloeper
auto_renew_enabledOm auto-renewal er paa

Merk: iap_subscriptions.status har Apples detaljerte tilstander. profiles.subscription_status har vaare forenklede tilstander. Mapping skjer i webhook-handlers.

iap_transactions / iap_webhook_events (kun IAP-backed)

heading.anchorLabel

Logger for betalingstransaksjoner og App Store/Google Play webhooks. Brukes for feilsoeking og idempotent webhook-prosessering. Aldri involvert for trial/partner.

shared/src/commonMain/kotlin/com/im/shared/
auth/
AuthManager.kt # Singleton. Token lifecycle, authState StateFlow.
OAuthService.kt # Koordinerer native sign-in → API exchange.
OAuthProvider.kt # enum APPLE/GOOGLE + NativeOAuthHandler interface
TokenStorage.kt # expect/actual for Keychain/EncryptedSharedPreferences
shared/src/iosMain/
auth/
TokenStorage.ios.kt # Keychain-implementasjon (kSecAttrAccessibleAfterFirstUnlock)
shared/src/androidMain/
auth/
TokenStorage.android.kt # Jetpack Security EncryptedSharedPreferences
sealed class AuthState {
data object Unauthenticated : AuthState()
data class Authenticated(val user: AuthUser) : AuthState()
data object Loading : AuthState()
}

Observeres av AuthStateWrapper (Swift @Observable) som gates ImpulseApp mellom LoginView og ContentView.

401-interceptor

heading.anchorLabel

ApiClient.request() fanger 401-feil:

401 mottatt → tryRefreshToken() → POST /api/v1/auth/refresh
✅ Success → oppdater tokens, retry original request
❌ Failure → authManager.logout() → LoginView

Vises kun etter logout eller token-expiry (nye brukere ser Onboarding først):

  • App-ikon + “Impulse AI”
  • “Change your state. Transform your reality.”
  • Sign in with Apple-knapp
  • Terms of Service / Privacy Policy-lenker

ImpulseApp routing

heading.anchorLabel
if !hasCompletedOnboarding OnboardingView (første lansering)
else if !authState.isAuthenticated LoginView (etter logout)
else ContentView (autentisert)
FilBeskrivelse
apps/api/src/routes/auth/native.tsSign-in og refresh-endepunkter
apps/api/src/services/impulse-quota-service.tsisSubscriptionActive() + kvoteberegning
apps/api/src/middleware/subscription.tsSubscription-middleware (bruker isSubscriptionActive())
apps/api/src/middleware/auth.tsJWT-validering + dev token bypass
shared/.../auth/AuthManager.ktToken lifecycle, authState
shared/.../auth/OAuthService.ktNative → API token exchange
shared/.../api/ApiClient.kt401 interceptor med token refresh
iosApp/Stores/AuthStateWrapper.swift@Observable bridge for auth state
iosApp/Features/Auth/LoginView.swiftSign in with Apple UI
iosApp/Features/Auth/AppleSignInHandler.swiftNative Apple Sign-In med nonce
iosApp/ImpulseApp.swiftAuth state gate (login vs content)

Webhook-flyt (subscription lifecycle)

heading.anchorLabel
App Store Server Notification
→ POST /webhooks/appstore
→ DID_RENEW: updateSubscriptionStatus('active')
→ DID_FAIL_TO_RENEW: updateSubscriptionStatus('billing_retry')
→ EXPIRED: handleSubscriptionExpiry() → profiles.status = 'expired'
→ CANCEL: updateSubscriptionStatus('cancelled')
→ REFUNDED: handleRefund() → profiles.status = 'expired'

Samme mønster for Google Play RTDN via /webhooks/googleplay.

Apple Developer Portal — Oppsett

heading.anchorLabel
ParameterVerdi
Team IDVP5P9N253X
Bundle IDcom.digiteers.impulseai
Key ID (Sign in with Apple)3ZNPSK2892
Key-filAuthKey_3ZNPSK2892.p8

Krav i portalen

heading.anchorLabel
  1. App ID (com.digiteers.impulseai) — “Sign in with Apple” capability må være aktivert (enabled as primary App ID)
  2. Key — Sign in with Apple-key opprettet og knyttet til App ID-en
  3. Certificates — Distribution (Digiteers AS) + Development (Jan Fredrik Øveraasen), begge gyldige til 2027/03/22

Apple Client Secret

heading.anchorLabel

Apple krever en JWT-basert “client secret” for Supabase sin token-verifisering. Genereres fra .p8-nøkkelen:

Terminal window
node scripts/generate-apple-secret.js \
--key-file ~/Downloads/AuthKey_3ZNPSK2892.p8 \
--key-id 3ZNPSK2892 \
--team-id VP5P9N253X \
--client-id com.digiteers.impulseai

Gyldighet: 180 dager. Må regenereres ved utløp.

Supabase Apple Provider — Per miljø

heading.anchorLabel

Apple auth konfigureres forskjellig avhengig av miljø:

MiljøHvor konfigureresSupabase-prosjektStatus
Lokal devDoppler dev config → env vars → config.tomlLokal container✅ Konfigurert
StagingSupabase Management APIconxpuqktkeiphnnpfiv✅ Konfigurert
ProductionSupabase Management APIjksqfeutntcvukrisrno✅ Konfigurert

Lokal Supabase (config.toml)

heading.anchorLabel
[auth.external.apple]
enabled = true
client_id = "env(SUPABASE_AUTH_EXTERNAL_APPLE_CLIENT_ID)"
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
redirect_uri = "im://auth-callback"
skip_nonce_check = false

client_id være bundle ID (com.digiteers.impulseai) — ikke en Services ID. Native signInWithIdToken setter aud-claimet til bundle ID.

Hosted Supabase (stg/prd)

heading.anchorLabel

Konfigurert via Supabase Management API (PATCH /v1/projects/{ref}/config/auth). Endringer kan også gjøres i Supabase Dashboard → Authentication → Providers → Apple.

FeltVerdi
Client IDcom.digiteers.impulseai
Secret KeyJWT fra generate-apple-secret.js (180d gyldighet)

Ved secret-rotasjon må alle tre miljøer oppdateres: Doppler dev + Supabase API for stg/prd.

StoreKit Testing (Simulator)

heading.anchorLabel

Konfigurasjon

heading.anchorLabel

Configuration.storekit definerer sandbox-produkter for lokal testing:

ProduktIDPris
Foundation Monthlyim.foundation.monthly$6.00
Mastery Monthlyim.mastery.monthly$12.00

Innstillinger: _timeRate: 6 (1 time = 1 måned), _developerTeamID: VP5P9N253X.

JWS-validering lokalt

heading.anchorLabel

Backend aksepterer Xcode-genererte StoreKit-transaksjoner i dev:

// jws-utils.ts — cert-verifisering kun i production
const ENFORCE_CERT_VERIFICATION = process.env.NODE_ENV === 'production'
// Fallback: decode uten verifisering for sandbox/testing
if (process.env.NODE_ENV === 'production') {
throw new Error('Missing x5c certificate chain in JWS')
}
return JSON.parse(payload) // aksepterer self-signed JWS

Xcode-verktøy

heading.anchorLabel
VerktøyStedBruk
Transaction ManagerDebug → StoreKit → Manage TransactionsSe/slett/expire/refund kjøp
Subscription Renewal RateConfiguration.storekit _timeRateAkselerert tid (6 = 1t→1mnd)
Fail TransactionsConfiguration.storekit _failTransactionsEnabledTest feilscenarier

E2E Testflyt (Simulator)

heading.anchorLabel

Forutsetninger

heading.anchorLabel
Terminal window
# Start lokal infra med Doppler-injiserte secrets
doppler run --project im --config dev -- pnpm docker:start
# Start API (egen terminal)
doppler run --project im --config dev -- pnpm --filter api dev

Testscenarier

heading.anchorLabel
#ScenarioHvordanVerifiser
1Sign in with AppleTrykk knapp i Simulator → Apple dialogToken mottas, profil opprettes, ContentView vises
2Trial aktivNy bruker → auto trial (7 dager)QuotaStore viser trial, alle features tilgjengelig
3Trial utløperDB: UPDATE profiles SET trial_ends_at = NOW() - INTERVAL '1 day'Paywall vises ved neste API-kall
4Kjøp FoundationPaywallView → velg Foundation → StoreKit dialogTier = foundation, kvote = 30/mnd
5Maks kvoteDB: Sett used = 30 på impulse_quotaPaywall med upgrade CTA
6KansellerXcode → Debug → StoreKit → Cancel SubscriptionSubscriptionStatusCard med nedtelling
7Subscription utløperXcode Transaction Manager → ExpirePaywall vises
8Logg ut/innProfileView → Sign out → Sign in againTokens slettet → LoginView → ny auth

Dev Token (API-testing uten Apple Sign-In)

heading.anchorLabel

For å teste tier-spesifikk oppførsel uten kjøp:

Terminal window
# KMP AuthManager har loginWithDevToken() med test-brukere:
# trial: 11111111-1111-1111-1111-111111111111
# foundation: 22222222-2222-2222-2222-222222222222
# mastery: 33333333-3333-3333-3333-333333333333
# partner: 44444444-4444-4444-4444-444444444444
AspektTiltak
Token-lagringiOS: Keychain (kSecAttrAccessibleAfterFirstUnlock). Android: EncryptedSharedPreferences.
Nonce-verifiseringApple krever SHA256-hashet nonce for replay-protection.
Server-side valideringSupabase verifiserer ID-token direkte med Apple/Google via JWKS.
Kort token-levetidAccess token: 1 time. Refresh roterer ved bruk.
Ingen passordEliminerer credential stuffing, brute-force, phishing.
Dev token bypassKun når DEV_API_TOKEN er satt OG NODE_ENV !== 'production'.
JWS cert-verifiseringKun i production. Lokalt aksepteres Xcode-genererte transaksjoner.
Apple client secretJWT med 180-dagers gyldighet. Regenerer med scripts/generate-apple-secret.js.