Extend the push pipeline to Android devices through the Silo push
relay's /v1/fcm/send endpoint. push_devices gains platform-conditional
FCM token columns (encrypted at rest with row AAD, hashed like APNs
tokens), the generic POST /notifications/push/devices endpoint the
Android client already calls registers FCM tokens, and fanout,
operational dispatch, retries, and terminal UNREGISTERED device
disabling all reuse the existing Apple machinery. Delivery is gated by
a new notifications.android_push_delivery_enabled setting, advertised
through the capability endpoint's android_push block, and testable via
POST /admin/notifications/push/fcm/test.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Add push notifications support
* fix(notifications): address push notification review findings
- Gate the capability endpoint's apple_push availability on the admin
delivery toggle, matching web push: Available now means setup will
actually deliver.
- Reject direct admin writes to push_relay_deployment_id/api_key; the
relay issues them as a pair during registration and a lone write
desyncs them (and poisons the next rotation request).
- Purge a device's registrations under other profiles when it
re-registers, so a profile switch on a shared device stops the old
profile's pushes (attempts cascade); adds a DB-backed test.
- Extract the shared channelDispatcher core + retry sweep and rebuild
the webhook/web push/Apple push dispatchers on it instead of keeping
three copies of the worker-pool/retry loop.
- Deduplicate relay URL validation (admin setting + register flow) and
the push outbox attempt-building loops behind shared helpers.
- Cap free-text decline reasons in notification display bodies.
- Fix TestHandleApplePushDisplayDB expectations to match the shared
display copy (test previously failed under SILO_TEST_DATABASE_URL).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(notifications): route push relay URL writes through registration only
Direct writes to notifications.push_relay_url via the admin settings
endpoint bypassed the relay registration flow, letting the stored URL
drift out of sync with the deployment id / API key pair the relay
minted for it. Reject the URL alongside the deployment id and API key
in the settings handler; POST /admin/notifications/push/relay/register
remains the only path that persists all three together.
The admin UI's Relay URL field now edits local draft state and is
applied by the Register/Rotate action instead of the settings save.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Requests previously only notified the community server channels for
submitted/approved/declined and the requester personally for fulfilled.
This closes the gap and makes request posts addressable:
- New request.approved / request.declined delivery types ride the
operational dispatch path to the requesting profile: inbox, websocket
toast, email, Discord DM, personal webhooks (gated by the existing
notify_requests flag), and web push. Submitted stays broadcast-only
(the requester performed the action themselves). Title/year/decline
reason travel in reason_flags since no catalog item exists yet.
- Request status notices are transactional: digest-mode recipients get
an off-schedule early send (watermark-durable, last_digest_at left
alone) instead of waiting for the digest hour. Per-episode recipients
were already immediate via the dispatch nudge.
- At-most-once per (profile, request, type) via a partial unique index
(migration 20260612100000), mirroring the fulfilled dedupe.
- Server-channel Discord request posts can @mention the requester via
their OAuth-linked identity (notifications.server_channels.
mention_requesters, default off). Resolved lazily in the sweep worker
only when a Discord destination is about to receive the event; the
ping uses content-level mention with pinned allowed_mentions, and the
Discord identity never leaks into generic webhook payloads.
Android/Apple clients render the new inbox types with their generic
fallback until they add them.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Upgrade all outbound Discord surfaces (personal webhooks, bot DMs, server
channels, request events) from bare title/description embeds to rich ones:
poster thumbnail, overview teaser, TMDB/IMDb/TVDB links, rating and genre
fields, content-rating footer, and a clickable title URL.
Artwork respects the v1 privacy contract via a new admin poster mode
(notifications.discord.poster_mode): "provider" (default) only emits public
provider-CDN URLs, "server" additionally presigns locally cached posters
from this server's image storage, "off" drops images entirely. Builders
never derive artwork URLs themselves; the sender layer resolves PosterURL
through System.discordPosterURL.
To keep provider-CDN URLs derivable after image caching rewrites
poster_path to a local storage key, media_items gains poster_source_path,
captured during cacheItemImages, preserved across refreshes that keep the
cached poster, and cleared on explicit poster overrides.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add admin-owned broadcast destinations ("community channels"): Discord or
generic webhooks fed straight from release_events by a per-channel watermark
sweep, announcing newly added movies/episodes as grouped digest posts plus
configurable media request lifecycle events (submitted/approved/declined/
fulfilled).
- Extend release_events with a kind discriminator and add a movie
availability spine (movie_availability + kind-keyed
notification_content_seed_state; first full scan seeds silently so
upgrades never flood the movie back catalog)
- Sweep worker reads events by (created_at, id) cursor with batch-window
grouping, per-channel backoff, and auto-disable; request events post
best-effort via new requests.LifecycleNotifier hooks
- Reuse the webhook stack throughout: URL encryption (new AAD namespace),
SSRF guard, embed limits, HMAC signing; shared type/name validation
extracted for both services
- Admin CRUD API under /admin/notifications/server-channels and a Server
Channels section in the notifications admin settings UI
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds Discord direct messages as a notification channel. Users link their
Discord account via OAuth2 (identify scope only, one-time server-side
state rows); a bot delivers their inbox notifications as DMs.
- Extract the email channel's watermark sweep into a generic
account-channel engine; email and Discord are now thin adapters, so
the SKIP LOCKED claim / watermark-after-send durability logic exists
once.
- New internal/discord REST client (token exchange, identity, open DM,
send message) — no Gateway connection, no new dependencies.
- Opt-in master switch (notifications.discord_enabled, default off)
gates delivery, linking, capability, and the admin settings reveal.
- Admin UI: credentials (secret + bot token encrypted at rest), dev
portal setup checklist, bot invite link buttons, and a test button
that bypasses the settings read cache and is disabled while
credential edits are unsaved.
- DM failures from missing shared guild (Discord 50007) surface as link
health in user settings and self-heal via capped backoff.
- New combined mode (per_episode_and_digest) for email and Discord:
instant sends all day plus a daily digest recapping the whole window
since the previous digest.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Letting users point server-originated HTTP at arbitrary destinations is
an admin decision, so notifications.webhooks_enabled now defaults to
off instead of acting as a default-on kill switch. The flag is also
enforced at webhook creation and test sends (delivery was already gated
at enqueue and dispatch); existing webhooks stay manageable while
disabled so rows are never stranded. The admin toggle moves into the
Webhook Guards group with an off default, and the user settings page
hides the Webhooks section entirely when the capability is unavailable,
matching the other channel sections.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds email as a notification channel built on the shared SMTP core
(mail.Sender). Email mode is a per-account preference (off, daily
digest, or per-episode) stored in notification_email_prefs; delivery is
an account-watermark sweep over notification_deliveries that dedupes
cross-profile duplicates, advancing the watermark only after a
successful send. Admin controls cover the channel kill switch, the
per-episode allowance (off coerces those accounts to the digest),
digest hour, and an external URL for deep links inside emails.
Availability is advertised through /notifications/capability and the
user settings page gains an Email section for opt-in.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implements the notification system foundation and all v1 delivery channels
that need no external infrastructure (specs 00/01/04/05 in
docs/superpowers/plans/notifications/):
Foundation (spec 01):
- episode_availability seeding + per-library seed markers: "newly available"
means newly released to this server, so back-catalog imports and first
scans never flood (verified on dev: 1.13M episodes seeded silently)
- release_events -> profile_series_interest fanout worker with settling
delay, per-series burst caps, FOR UPDATE SKIP LOCKED multi-node claims,
and a guarded last-notified cursor
- interest index maintained via a userstore provider decorator so every
favorites/watchlist/progress mutation path (REST, jellycompat, imports,
playback) feeds it; progress writes only recompute on state transitions
- durable per-profile inbox + read state, forward-sync cursor API,
websocket channel with short-lived single-use handshake tickets
- web UI: sidebar badge, inbox page, toasts, per-profile preferences
- startup/daily tasks: availability seeding, interest rebuild, retention
Outbound webhooks (spec 04):
- Discord embeds (text-only per the v1 privacy contract) and generic
JSON signed Stripe-style with per-webhook secrets
- HTTPS-only + private-destination guard enforced at registration and at
connect time (DNS-rebinding mitigation); URLs/secrets encrypted at rest
- durable per-target outbox enqueued in the fanout transaction, lease-based
claims, 24h exponential retry, 3x-consecutive-4xx auto-disable with an
in-app notice (loop-guarded)
Web push (spec 05):
- VAPID keypair self-provisioned at startup (single atomic JSON setting,
private half encrypted at rest) — no third-party accounts needed
- payloads E2E-encrypted (RFC 8291); 404/410 treated as unsubscribe
- service worker + subscribe flow in Settings -> Notifications
Shared SMTP core (internal/mail):
- feature-agnostic mail.Sender over live email.* settings, STARTTLS or
implicit TLS, encrypted password, admin Email settings page with
synchronous test send; no consumer yet by design (digest is v1.5)
APNs/FCM (specs 02/03) are deferred to v2; the capability endpoint reports
them unavailable so clients render truthfully.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>