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 | |
|---|---|---|
| Commercial | Sold, inventory-tracked | Free |
| Gate | Super Admin approval + inventory | Only an approved mosque membership |
| States | PENDING…RETIRED (see Device Lifecycle) | ACTIVE ⇄ BLOCKED only |
| Verification / inventory / assignment | Required | None |
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
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST | /api/v1/app-installs/enroll | user JWT | Idempotent on install_uid. Creates the install directly as ACTIVE and returns install_secret in the same response |
POST | /api/v1/app-installs/{id}/rotate-secret | owner | Issues a new secret |
POST | /api/v1/app-installs/{id}/push-token | owner | Registers a push token against this install (replaces the old one-per-user /users/me/*-token columns) |
GET/PUT | /api/v1/app-installs/{id}/preferences | owner | prayers / notify / quiet hours / volume / language |
POST | /api/v1/app-installs/{id}/played | install credential | Backgrounded playback confirmation |
GET | /api/v1/app-installs | superadmin | Directory of installs |
POST | /api/v1/app-installs/{id}/block · /unblock | superadmin | The 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.