Skip to main content

Playback Entitlement

:::info Status Implemented. Source: docs/device-management-module-architecture.md §6.0a, §6.1, §6.2, §6.3, §6.3a, §6.3b. :::

Playback entitlement answers: may this subject play this mosque's azaan, right now? Unlike device authorization, it is never stored — it's derived fresh on every request, and it applies to devices and app installs alike.

PLAYBACK_ENTITLED(s, m, t) ≡
CASE s.kind OF
transmitter → DEVICE_AUTHORIZED(s,cred) ∧ BOUND(s,m)
stream_player → DEVICE_AUTHORIZED(s,cred) ∧ BOUND(s,m) ∧ APPROVED(owner(s), m)
app_install → verify(cred, s.secret_hash) ∧ s.status=ACTIVE ∧ APPROVED(s.user, m)
END

Invariants:

#Invariant
I1PLAYBACK_ENTITLED(d,m,t) ⟹ DEVICE_AUTHORIZED(d,cred) for every device kind
I2DEVICE_AUTHORIZED ⇏ PLAYBACK_ENTITLED — the converse never holds
I3An app install's entitlement is defined without DEVICE_AUTHORIZED — there's nothing to check
I4BLUETOOTH_ALLOWED(d) ≡ TRUE, unconditionally
I5Neither predicate is ever a stored column
I6PLAYBACK_ENTITLED(s,m,t) ⇏ PLAYBACK_ENTITLED(s,m′,t) — entitlement is per mosque, never global

Shared infrastructure, per-subject policy

core/playback_authorization.py SHARED resolver + Entitlement + policy registry + cache
resolve_playback_entitlement(subject_type, subject_uid, credential, mosque_id, purpose)
├── "device" → core/device_authz.py :: authorize_device() (Track A)
└── "app_install" → core/app_authz.py :: authorize_app_stream() (Track B)
core/stream_tokens.py SHARED mint / verify / jti revocation
api/v1/endpoints/stream_auth.py SHARED /token, /verify, /icecast

The resolver knows nothing about devices or memberships; the policies know nothing about tokens or Caddy — a third subject type later is a policy registration, not an endpoint edit. Full device-side query: Device Authorization.

Stream gate

/stream/* is protected in two layers:

  1. Caddy forward_auth (primary) — every request to /stream/* is checked against /api/v1/stream-auth/verify before Icecast ever sees it.
  2. Icecast mount URL auth (defense in depth) — a listener_add hook pointing at /api/v1/stream-auth/icecast, in case something inside the Docker network bypasses layer 1. Icecast has no published host port in production for exactly this reason — see Backend Deployment — stream gate rollout.

Stream tokens are short-lived (120s TTL), subject-bound, and mount-bound — one endpoint (POST /api/v1/stream-auth/token) dispatches on subject_type with no if device / if app branch. Push payloads carry no pre-signed token; a subject exchanges its own credential for a token at play time.

The entitlement lease — bounding a device that's gone dark

Two requirements are in direct tension for a device the backend can't reach: autonomy (keep playing from cached config through a broker outage) and instant revocation. The worst case is a stolen unit deliberately kept off the network, which naive autonomy would let play forever.

Revocation is instant for a reachable device, bounded for an unreachable one.

ReachabilityAzaan stopsMechanism
Online< 5sdeauthorize on the open session + broker session kick
Offline, reconnects laterAt reconnect, before it can playConfig refresh precedes playback
Offline indefinitelyAt lease expiryBelow

Cached config carries entitlement.lease_seconds (default 604800 = 7 days), renewed only by a successful authenticated broker session, measured from monotonic uptime since the last such session — not wall clock (no RTC, an offline unit can't NTP). Past the lease, the device stops azaan (including any local fallback recording), publishes UNBOUND/lease_expired, keeps Bluetooth working, and keeps retrying forever — resuming on the next successful session with no reboot or re-claim needed.

LeaseBounds a stolen unit toRisk to legitimate units
24h1 dayAny outage or holiday over a day silences azaan — unacceptable
7 days (default)1 weekSurvives a long outage, maintenance, connectivity gaps
30 days1 monthOperationally safe; a stolen unit plays for a month

Broker recovery time and lease length are one coupled decision — see Design Decisions for why 7 days was chosen, and Operations — Monitoring for what a broker outage actually costs while the lease holds.

A lease is not a grace period — they're opposites:

Grace period (rejected)Entitlement lease (adopted)
Delaysthe backend decidingthe device acting on state it can't verify
Livesserver-sidedevice-side
Reachable devicewould keep playing after revocationnone — stops in < 5s
Unreachable devicenonestops it, after the window

Bluetooth is unaffected by lease expiry (I4) — a lease-expired unit is still the speaker its owner bought; Azan360 stops its service, not the hardware. Local-fallback audio stops with the lease too — exempting it would leave a flashed audio file as exactly the hole the lease exists to close.

effective_playback_state

One derived enum — the only thing anything else should read — first match wins:

StateMeaning
PLAYINGactively outputting azaan
READYwould play at the next trigger — the healthy idle state
NOT_AUTHORIZEDlifecycle ≠ ACTIVE, or inventory not ALLOCATED/DEPLOYED, or (stream player) no open ownership row
NOT_BOUNDno open assignment
MEMBERSHIP_LAPSEDopen assignment, membership gone
LEASE_EXPIREDoffline past the entitlement window
SUPPRESSEDentitled, silenced by the user (mute, quiet hours, prayer disabled)
UNREACHABLEentitled, offline right now

Only READY and PLAYING correspond to ACTIVE ∧ BOUND. SUPPRESSED and UNREACHABLE are deliberately distinct from the authorization states — a muted device and an unapproved device look identical to the member and are opposite problems.

Playback confirmation outcomes are stored verbatim in the audit trail — played, played_fallback, interrupted, refused, expired, failed, not_confirmed, skipped — and refused/skipped (intentional non-delivery) are never aggregated with failed: "40 devices did not play" needs to distinguish real faults from people who were asleep with quiet hours on. See Broadcast Audit.