Skip to main content

App Installation

:::info Status Implemented. Source: docs/device-management-module-architecture.md §4.5, §5b, §7; fastapi-backend/api/v1/endpoints/app_installs.py. :::

app_installs is the mobile app's identity — and it is deliberately not a device. See Device Architecture for why devices and app_installs are kept fully separate tables rather than one with a device_kind discriminator.

Device (transmitter/stream player)App install
CommercialSold, inventory-trackedFree
GateSuper Admin approval + inventoryOnly an approved mosque membership
StatesPENDING…RETIRED (see Device Lifecycle)ACTIVEBLOCKED only
Verification / inventory / assignmentRequiredNone

An install with no approved membership is ACTIVE and simply streams nothing — that's the normal state between downloading the app and joining a mosque. The UI is expected to say "join a mosque," never "awaiting approval" — there is no approval step for an install.

The /enroll vs. /announce asymmetry, in one line

/app-installs/enroll hands a working credential to any signed-in user, immediately — the app is free, so gating happens downstream at membership, not at enrollment. /device-registry/announce (see Device Management module) hands out nothing and waits for a human, because a physical unit was sold and the gate is Super Admin. This asymmetry is the design in miniature.

Every management route is owner-gated, not install-credential-gated

core/app_authz.py's policy accepts an install's own secret_hash as a credential for /stream-auth/token — that's the playback path. Every route below instead trusts the caller's JWT and checks app_installs.user_id against it — the same shape /my-devices uses for device-owner routes. An install has no login of its own; whoever is signed into the app manages it.

API

MethodPathAuthPurpose
POST/api/v1/app-installs/enrolluser JWTIdempotent on install_uid. Creates the install directly as ACTIVE and returns install_secret in the same response
POST/api/v1/app-installs/{id}/rotate-secretownerIssues a new secret
POST/api/v1/app-installs/{id}/push-tokenownerRegisters a push token against this install (replaces the old one-per-user /users/me/*-token columns)
GET/PUT/api/v1/app-installs/{id}/preferencesownerprayers / notify / quiet hours / volume / language
POST/api/v1/app-installs/{id}/playedinstall credentialBackgrounded playback confirmation
GET/api/v1/app-installssuperadminDirectory of installs
POST/api/v1/app-installs/{id}/block · /unblocksuperadminThe only lifecycle lever here — the abuse case, not the common path

install_secret is returned in the response body exactly twice — once from /enroll, once from /rotate-secret — after that it's only a bcrypt hash and can't be read back, the same rule a device's device_secret follows. Re-enrolling the same install_uid (a reinstall that lost its stored secret) always issues a fresh secret rather than trying to signal whether the row was new.

Why push tokens moved to per-install

The legacy users.push_token/voip_push_token/fcm_push_token/ apns_push_token columns held one token per user — a second phone silently overwrote the first. app_install_push_tokens ties a token to a specific install instead; UNIQUE (kind, token) makes a token handed to a new install (e.g. a wiped, handed-on phone reissued the same FCM token) a detectable reassignment rather than a silent share — the old install's row is explicitly invalidated (is_valid=false, invalid_reason='reassigned') and an audit row (token_reassigned) names both installs. See Backend Deployment — recipient fan-out rollout for how this interacts with the FANOUT_V2 staged migration off the legacy columns.

Full schemas: API Reference.