Imported the Continuum-era notification specs (durable inbox + websocket foundation, APNs relay, FCM relay, outbound webhooks) and amended them for Silo. Amendments from the 2026-06-11 review: wire contracts normalized to Silo naming, back-catalog seeding and per-series burst suppression, durable dispatch outbox, cross-library episode dedupe, forward-sync wake API, ticket-based websocket handshake, relay threat-model additions (egress IP, keyed collapse IDs), and webhook 4xx/SSRF hardening. Includes a self-contained design-decisions.html visual overview. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
34 KiB
Privacy-Preserving APNs Relay Spec
Date: 2026-04-27 (refined 2026-04-28) Status: Draft (refined; open questions resolved) Scope: Silo server push integration, Apple clients, opt-in central APNs relay, custom APNs provider configuration Depends On:
00-architecture-overview.md— read first for cross-cutting context01-release-events-and-inbox.md— foundational durable inbox + fanout Sibling spec:03-fcm-relay.md— Android push using the same threat model and architecture
Summary
Silo should support remote Apple notifications through either an opt-in central APNs relay or admin-configured APNs credentials, while keeping notification metadata on the user's own Silo server.
The hosted relay should be a constrained transport service. It should receive only the minimum fields needed to submit an APNs request:
- an APNs device token
- APNs environment and app topic
- a push mode
- an opaque server/device correlation key
- an opaque delivery identifier
- optional badge count
Neither provider path may send titles, item names, usernames, profile names, library names, server URLs, artwork URLs, watched-state metadata, or notification body text through APNs.
When the Apple device wakes, the app should fetch real notification metadata directly from the user's Silo server using the existing authenticated server connection.
Problem
Reliable Apple remote notifications require APNs. A self-hosted Silo server cannot directly wake an iPhone, iPad, or Apple TV app in the background without going through Apple's push infrastructure.
That creates two product concerns:
- Privacy: notification contents should not be exposed to Apple or a Silo operated relay.
- Operational friction: most server admins should not need to create and manage their own Apple developer account, APNs key, bundle topic, and push service.
Silo should offer a practical default that preserves the self-hosted trust model as much as the platform allows.
Decision
Add configurable Apple push providers for the official Apple app:
off: no remote Apple pushsilo_relay: Silo's hosted APNs relaycustom_apns: admin-supplied APNs credentials used directly by the server
The relay submits APNs requests using Silo-controlled Apple credentials for the official app bundle. A self-hosted server admin may opt in to use this relay. The relay's API should intentionally prevent notification content from being sent through the relay.
If custom_apns is selected, the user's Silo server submits directly to
APNs using admin-provided Apple developer credentials. This avoids the Silo
relay entirely, but it must use the same minimal payload and metadata-fetch
rules as the hosted relay path.
The default push mode should be Private Alert:
- APNs displays a generic Silo notification.
- The APNs payload contains only opaque identifiers.
- The app fetches the actual notification row from the user's server when it is opened or granted background execution.
The implementation should also support Background Wake for low-noise sync, with the explicit understanding that iOS background pushes are opportunistic and not a reliable user-visible notification mechanism.
Goals
- Make Apple push possible without every admin managing APNs credentials.
- Keep actual notification metadata on the user's Silo server.
- Make relay participation explicit and disabled by default.
- Make APNs credentials configurable for admins who want to avoid the hosted relay.
- Keep the relay stateless for device subscriptions where possible.
- Keep APNs payloads opaque and generic.
- Keep the hosted relay and custom APNs paths behaviorally equivalent from the app's point of view.
- Fit on top of the durable notification inbox design.
Non-Goals
- Replacing APNs for Apple remote push.
- Shipping notification content through the central relay.
- Requiring Firebase or Google services for this Apple path.
- Solving Android push in this spec.
- Adding marketing, analytics, or delivery-tracking exports to the relay.
- Hiding APNs itself from Apple devices. Custom APNs still uses Apple APNs.
- Making silent background delivery as reliable as visible push.
Threat Model
What The User's Server Knows
The user's Silo server knows:
- the notification content
- the profile and user recipients
- the registered local devices
- APNs device tokens for those devices
- push delivery attempts and failures
This is acceptable because the server is already the authority for the user's media, profiles, sessions, and notification inbox.
What The Central Relay May Know
The relay may see:
- an opt-in relay account or install identifier
- the server's egress IP address (inherent to any hosted relay — the server connects to it directly; for home hosting this identifies the household's connection, making it the closest thing to an identity leak in this design)
- APNs device tokens submitted in send requests
- request timestamps
- coarse push mode, such as
private_alertorbackground_wake - opaque
collapse_idvalues (per-server-keyed HMACs of series identity; the relay can group one server's pushes into series-equivalence classes but cannot recover which series, nor correlate across servers — see "Send Apple Push" validation) - APNs response status and APNs request IDs
- opaque delivery identifiers
The relay must not receive enough information to know what media item, notification type, profile, user, library, server hostname, or server URL caused the push.
What Apple May Know
Apple/APNs may see:
- the official app topic
- the target device token
- APNs headers
- request timing
- the generic APNs payload
Apple must not receive media titles, usernames, library names, server URLs, artwork URLs, or other notification metadata in the payload.
Residual Metadata Leakage
This design cannot hide that a device received a Silo push at a particular time, nor the egress IP of the server sending relay requests. It can only hide the meaning and content of the push. Admins for whom IP exposure matters can route relay traffic through their existing VPN/proxy egress; the relay path is plain HTTPS and needs no special handling.
User And Admin Controls
Server Admin Control
The server admin must explicitly choose an Apple push provider.
Suggested setting:
notifications.apple_push.provider = off | silo_relay | custom_apns
Default:
off
If silo_relay is selected, the admin configures a relay API key and
accepts a clear privacy notice:
Silo Relay can wake Apple devices through APNs, but notification details
stay on this server. The relay receives APNs device tokens, timestamps, opaque
delivery IDs, and generic push mode only.
If custom_apns is selected, the admin configures their own Apple developer
APNs credentials:
notifications.apple_push.custom.team_id
notifications.apple_push.custom.key_id
notifications.apple_push.custom.private_key
notifications.apple_push.custom.default_environment = production | sandbox
notifications.apple_push.custom.allowed_topics = [...]
The server then sends directly to APNs and does not call the Silo relay. The same private payload rules still apply.
Device User Control
Each Apple device must opt in independently:
- the OS notification permission must be granted
- the app must be signed into a Silo server
- the active profile must enable push notifications for that device
- the server admin must have enabled an Apple push provider
Admins must not be able to silently enroll every profile/device into remote push without the device having granted OS notification permission.
Profile Control
Push preferences should sit on top of the durable notification preferences from the inbox design.
Suggested profile/device modes:
off: never send remote push to this devicein_app_only: websocket and inbox onlyprivate_push: generic APNs wake/alert, fetch details from serverfull_preview: out of scope for v1; would require explicit user consent and a separate privacy warning
V1 should ship only off, in_app_only, and private_push.
System Architecture
sequenceDiagram
participant App as Apple App
participant Server as User Silo Server
participant Relay as Silo APNs Relay
participant APNs as Apple APNs
App->>App: Request OS notification permission
App->>App: Register for remote notifications
App->>Server: Register device token and opaque server_device_id
Server->>Server: Store encrypted token and device preferences
Server->>Server: Create durable notification_delivery
Server->>Relay: Send minimal APNs relay request
Relay->>APNs: Submit generic APNs payload
APNs->>App: Deliver private alert or background wake
App->>Server: Fetch notification metadata directly
Server->>App: Return inbox rows for active profile
Ownership Boundaries
This Repository
Silo owns:
- admin settings for selecting a push provider
- profile and device notification preferences
- APNs token registration from Apple clients
- durable storage of device registrations
- push fanout from
notification_deliveries - relay client integration
- retry, backoff, and APNs error handling
- inbox sync APIs used after wake
Apple Client Repository
The Apple app owns:
- APNs registration
- notification permission UX
- device registration API calls
- local server/device correlation
- wake handling
- fetching metadata from the user's server
- rendering local/in-app notification details
Central Relay Service
The central relay owns:
- APNs provider credentials for official Silo bundle topics
- relay API authentication
- request validation and rate limiting
- APNs request submission
- redacted operational logs
- minimal delivery status reporting
The relay should live outside this repo unless the project later decides to co-locate the service code. This repo should define the contract and implement the self-hosted server side.
Custom APNs Mode
When custom_apns is selected, there is no central relay in the send path.
The user's Silo server owns:
- APNs provider authentication using the configured team ID, key ID, and private key
- topic allowlisting for the official Silo app bundle IDs it intends to support
- sandbox vs production environment selection
- APNs request construction
- APNs response handling
- APNs credential rotation and secret storage
Custom APNs mode must not unlock richer payloads. It exists to let admins avoid the Silo relay, not to bypass the privacy-preserving notification contract.
Data Model
This spec assumes notification_deliveries exists from the durable notification
inbox design.
push_devices
Purpose: profile/device-scoped remote push registration. Shared across Apple and Android — see 03-fcm-relay.md for the FCM-specific columns.
Columns:
id text primary keyuser_id integer not null(matchesusers.id integer)profile_id text not nulldevice_id text not nullplatform text not null—'apple'or'android'. Required; the row is invalid without it.provider text not null— for Apple rows:'silo_relay'or'custom_apns'. For Android rows:'silo_relay'or'custom_fcm'. (Effectively the active configured provider at registration time; updates rewrite the row.)apns_environment text—'production'/'sandbox'. Nullable; populated only forplatform = 'apple'.apns_topic text— bundle topic. Nullable; populated only forplatform = 'apple'.apns_token_ciphertext bytea— nullable; populated only forplatform = 'apple'.apns_token_hash text— nullable; populated only forplatform = 'apple'.fcm_token_ciphertext bytea— nullable; populated only forplatform = 'android'. Defined here so the table is forward-compatible with03-fcm-relay.md; the FCM spec adds the rest of the Android-specific columns.fcm_token_hash text— nullable; Android only.fcm_project_id text— nullable; Android only.fcm_package_name text— nullable; Android only.server_device_id text not null— random opaque value, generated server-side at registration. Stable across token rotation; rotates only when the device is removed and re-registered.push_mode text not null default 'private_push'— profile-level mode (see00-architecture-overview.md"Mode terminology").enabled boolean not null default truelast_seen_at timestamptzlast_success_at timestamptzlast_failure_at timestamptzlast_failure_code textcreated_at timestamptz not null default now()updated_at timestamptz not null default now()
Constraints:
- unique on
(profile_id, device_id, platform)— a device can register exactly once per platform per profile, regardless of which provider the admin happens to have configured. This matters because admins may flip betweensilo_relayandcustom_apnsmid-rotation; aprovider-scoped unique key would leave stale rows behind. - unique on
(server_device_id) CHECK ((platform = 'apple' AND apns_token_hash IS NOT NULL AND fcm_token_hash IS NULL) OR (platform = 'android' AND fcm_token_hash IS NOT NULL AND apns_token_hash IS NULL))— exactly the platform-appropriate token columns are populated.
Notes:
server_device_idmust be random and opaque. It is the value included in the APNs payload so the app can map the push back to the correct local server account.- Store APNs tokens encrypted at rest when the local encryption facility exists.
- Store
apns_token_hashfor dedupe and diagnostics without logging raw tokens.
push_delivery_attempts
Purpose: operational record of push attempts without notification content.
Columns:
id text primary keynotification_delivery_id text not nullpush_device_id text not nullprovider text not nullattempt_number integer not null— 1-based counter incremented on each retry. Used as theIdempotency-Keysuffix for relay calls (see Send Apple Push), and lets diagnostics distinguish "10 attempts of one delivery" from "10 deliveries each first-attempted".relay_request_id textapns_id text— Apple's APNs ID returned in the response. Nullable; null for FCM rows.fcm_message_name text— FCM v1 message name (see03-fcm-relay.md). Nullable; null for APNs rows.status text not null—'pending','delivered','retrying','failed','device_disabled'.pendingrows are the dispatch outbox: the fanout transaction in01-release-events-and-inbox.mdinserts attempt 1 aspendingin the same transaction as the delivery; the push dispatcher claims and sends them post-commit.failure_code textattempted_at timestamptz not null default now()next_retry_at timestamptz
Constraints:
- unique on
(notification_delivery_id, push_device_id, attempt_number)to make the relay'sIdempotency-Keydeterministic and prevent double-claims of an attempt. - add an index on
(status, next_retry_at)for retry workers. The same index serves outbox recovery: a sweeper claimspendingrows older than ~60s (crash between delivery commit and dispatch) withFOR UPDATE SKIP LOCKED.
Retention:
- keep successful attempts for a short operational window, such as 14 days
- keep failed attempts longer, such as 30 to 90 days, to diagnose device issues
Server API Surface
Register Apple Push Device
POST /api/v1/devices/push/apple
Profile-scoped. Requires normal auth plus active profile context.
Request:
{
"device_id": "apple-device-local-id",
"apns_token": "hex-or-base64-token",
"apns_environment": "production",
"apns_topic": "com.continuum.app.ios",
"push_mode": "private_push"
}
Response:
{
"id": "01J...",
"server_device_id": "01JOPAQUE...",
"enabled": true,
"push_mode": "private_push"
}
Rules:
apns_topicmust be allowlisted by app build/channel.apns_environmentmust besandboxorproduction.- registration is idempotent by
(profile_id, device_id, provider). - token rotation updates the encrypted token and token hash.
- a disabled server push provider should still allow registration, but should report that remote push is unavailable in a capability field if the frontend needs it.
Disable Push Device
DELETE /api/v1/devices/push/{id}
Profile-scoped and idempotent.
The server may either delete the row or mark enabled = false. Marking disabled
is preferred for diagnostics and future re-enable flows.
Push Capability
GET /api/v1/notifications/capability
The full envelope is defined in 00-architecture-overview.md. The Apple-relevant slice is:
{
"apple_push": {
"available": true,
"provider": "silo_relay",
"available_providers": ["silo_relay", "custom_apns"],
"supported_modes": ["private_push", "in_app_only"],
"privacy_mode": "metadata_fetch"
}
}
The full response also includes in_app, android_push (see 03-fcm-relay.md), and webhooks (see 04-outbound-webhooks.md).
This lets clients present truthful setup UI without guessing whether the admin has enabled remote push.
Relay API Contract
The relay API should be intentionally narrow. The user server should not send an arbitrary APNs payload.
Send Apple Push
POST /v1/apple/send
Authorization: Bearer <relay_api_key>
Idempotency-Key: <notification_delivery_id>:<push_device_id>:<attempt_number>
Request:
{
"token": "apns-token",
"environment": "production",
"topic": "com.continuum.app.ios",
"mode": "private_alert",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY...",
"badge": null,
"collapse_id": "01JOPAQUE_COLLAPSE"
}
Response:
{
"request_id": "01JRELAY...",
"apns_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "accepted"
}
Validation:
tokenis required and must be plausible for APNs.environmentmust besandboxorproduction. The relay forwardsproductionrequests tohttps://api.push.apple.comandsandboxrequests tohttps://api.development.push.apple.com(Apple's current canonical hostnames; older docs/libraries also referenceapi.sandbox.push.apple.com, butapi.development.push.apple.comis the current Apple-documented form).topicmust be allowlisted for the relay account.modemust beprivate_alertorbackground_wake.server_device_idanddelivery_idmust be opaque values with length limits (recommend ≤128 chars, ULID-shaped).collapse_idmust be opaque and ≤64 bytes — Apple enforces a 64-byte cap onapns-collapse-id. Reject longer values with HTTP 400.collapse_idderivation (server-side rule, not relay-enforced): the server computescollapse_id = base32(HMAC-SHA256(server_collapse_secret, series_id))truncated to 26 chars, whereserver_collapse_secretis a random per-server secret generated at first use. Never send raw or plainly-hashedseries_id— an unkeyed hash would let the relay (or Apple) dictionary-match popular series IDs. Residual: the relay can still group one server's pushes into per-series equivalence classes (that is what collapse is for); the per-server key prevents recovering the series or correlating across servers.badgeis optional. Default should be omitted to avoid leaking unread counts through APNs unless the user/admin explicitly enables badge sync.- no free-form notification title or body fields are accepted.
- no image URL, media ID, username, server hostname, or server URL field is accepted.
Relay-Built APNs Payloads
For private_alert, the relay constructs:
{
"aps": {
"alert": {
"title-loc-key": "SILO_NOTIFICATION_TITLE",
"loc-key": "SILO_NOTIFICATION_GENERIC_BODY"
},
"sound": "default"
},
"silo": {
"v": 1,
"wake": "notifications.changed",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY..."
}
}
For background_wake, the relay constructs:
{
"aps": {
"content-available": 1
},
"silo": {
"v": 1,
"wake": "notifications.changed",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY..."
}
}
Headers:
apns-topic: selected from the allowlistedtopicapns-push-type:alertforprivate_alert,backgroundforbackground_wakeapns-priority:10forprivate_alert,5forbackground_wakeapns-collapse-id: optional opaquecollapse_id
The relay should not allow callers to override this payload shape in v1.
Custom APNs Contract
custom_apns should share the same internal send model as silo_relay,
but replace the relay HTTP call with a direct APNs provider request.
The server-side push sender should accept only a structured internal request:
{
"token": "apns-token",
"environment": "production",
"topic": "com.continuum.app.ios",
"mode": "private_alert",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY...",
"badge": null,
"collapse_id": "01JOPAQUE_COLLAPSE"
}
The sender builds the APNs payload locally using the exact same payload shapes
defined above for private_alert and background_wake.
Configuration:
team_id: Apple developer team ID.key_id: APNs auth key ID.private_key: APNs.p8private key, stored as a secret.default_environment:productionorsandbox.allowed_topics: explicit bundle topics this server is allowed to send for.
Rules:
- The admin may configure credentials, environment, and topics.
- The server must not expose a free-form APNs JSON payload setting.
- The server must not expose custom title/body templates for remote push in v1.
- Topic values must match the device registration topic and the configured allowlist.
- Sandbox tokens must be sent to the sandbox endpoint and production tokens to the production endpoint.
- APNs auth tokens should be cached briefly and regenerated before expiry.
- Credential validation should provide a test-send or dry-run diagnostic that does not include notification content.
Wake And Metadata Fetch
When the app receives a push:
- Read
silo.v,server_device_id, anddelivery_id. - Find the local server account that owns
server_device_id. - If background execution is available, call the server immediately.
- If the app is opened from the notification, call the server before rendering the target screen.
- Fetch notification metadata from the user's server.
- Render the real notification from server data.
Suggested fetch:
GET /api/v1/notifications/sync?since=<last_cursor>
or:
GET /api/v1/notifications/{delivery_id}
The sync endpoint is preferred because push delivery is not guaranteed and multiple notification deliveries can be coalesced.
If the app cannot reach the server, it should keep the generic notification and retry the inbox sync later.
Fanout Rules
Push fanout should happen after durable inbox delivery commits.
Flow:
notification_deliveriesrow is created. In the same transaction, the fanout worker enqueues onependingpush_delivery_attemptsrow per enabledpush_deviceof the recipient profile (the dispatch outbox — see01-release-events-and-inbox.md"Transaction and Concurrency Rules").- The realtime websocket event is published for connected clients.
- The push dispatcher claims the
pendingattempt rows post-commit (FOR UPDATE SKIP LOCKED); a recovery sweeper claims rows older than ~60s whose dispatch never ran. - Each claimed attempt sends through the configured provider:
silo_relay: send the minimal relay requestcustom_apns: build the same minimal APNs payload locally and send it directly to APNs
- APNs errors update device state and attempt status.
Do not block notification delivery creation on APNs or relay availability.
Relay pacing
The dispatcher paces relay calls client-side with a token bucket (default 5
req/sec, setting notifications.push.relay_send_rate), shared across the APNs
and FCM dispatchers since both consume the same relay account quota. On a
server with hundreds of users, a popular release can enqueue several hundred
push attempts at once; pacing drains the queue smoothly instead of slamming
into the relay's rate limit and burning retries on 429s. TooManyRequests
from the relay is still honored with backoff — pacing is the steady-state
mechanism, backoff is the safety net. The per-series burst cap in 01 bounds
worst-case queue depth. Custom APNs sends are paced separately and more
generously (Apple's own limits are far higher than any single-server relay
quota).
APNs Error Handling
The server should handle relay/APNs failures without exposing notification content.
Recommended handling:
BadDeviceToken/InvalidToken: disable the token and require re-registration. (Apple has migrated some responses fromBadDeviceTokentoInvalidToken; treat them equivalently.)Unregistered: disable the token.DeviceTokenNotForTopic: disable and log a build/topic mismatch.TooManyRequests: retry with backoff.ExpiredProviderToken: applies only tocustom_apns. The cached APNs JWT has expired. Regenerate the JWT immediately and retry the request once before backing off. The relay path never surfaces this code because the relay manages its own JWTs.TooManyProviderTokenUpdates: applies only tocustom_apns. Apple has rate-limited JWT regeneration. Stop regenerating; reuse the existing token; retry after backoff. Indicates a bug in the JWT cache (regenerating more often than every 20 minutes).- relay unavailable, APNs unavailable, or network timeout: retry with backoff.
- malformed request: mark failed and surface admin diagnostics.
Errors should be visible to admins as operational status, not to normal users unless their device needs reauthorization.
Privacy Requirements
The implementation must satisfy these requirements before shipping:
- Relay requests contain no notification title or body.
- Relay requests contain no media identifiers.
- Relay requests contain no server hostname or base URL.
- Relay requests contain no profile, username, library, collection, or item names.
- Relay requests do carry
apns-topic(e.g.,com.continuum.app.ios) andmode(private_alert/background_wake). These are platform/build identifiers that Apple already sees; documenting them here so the privacy claim list is exhaustive rather than over-strong. - Direct APNs payloads contain no notification title or body beyond generic localizable keys.
- Direct APNs payloads contain no media identifiers, server hostname, server URL, profile name, username, library name, collection name, or item name.
- Relay logs redact raw APNs tokens.
- Relay logs redact authorization headers.
- Relay logs do not persist full request payloads.
- Server logs redact APNs tokens and relay API keys.
- Badge count is disabled by default.
- Push registration and remote push provider use are opt-in.
- Device unregister disables further push attempts for that device.
Security Requirements
- Relay API uses TLS only.
- Relay API keys are scoped to a relay account or install identifier.
- Relay API keys can be revoked without changing the user's Silo server authentication.
- Relay requests are rate limited by API key and coarse token hash.
- Relay supports idempotency keys to prevent duplicate APNs sends during retry.
- Relay does not accept arbitrary APNs payload JSON in v1.
- Server stores relay API keys as secrets.
- Server stores custom APNs private keys as secrets.
- Server never logs custom APNs private keys, generated provider tokens, or APNs auth headers.
- Server stores APNs tokens encrypted at rest where local secret storage exists.
- Admin diagnostics must not print raw APNs tokens.
Settings And UX
Admin settings should explain the tradeoff plainly:
Apple Push Provider
Off
No Apple remote push. Devices still receive in-app realtime updates while open.
Silo Relay
Uses Silo's APNs relay to wake Apple devices. Notification details stay on
this server. The relay receives APNs device tokens, timestamps, and opaque
delivery IDs.
Custom APNs
Advanced. Send directly to Apple APNs using your own Apple developer
credentials. Notification details still stay on this server.
Device settings should describe the user-visible mode:
Private Push
Show a generic Silo notification, then fetch details from your server when
this device wakes.
Do not claim the central relay is fully self-hosted. The truthful claim is:
Notification content stays on your server. Apple, and the Silo relay if
selected, may still process generic wake messages needed for Apple push
delivery.
Implementation Plan
Task 1: Add Push Provider Settings
Files likely involved:
internal/api/handlers/settings.goweb/src/lib/settingsManifest.ts- settings UI files as needed
Add server/admin settings for:
- provider selection
- relay endpoint
- relay API key
- custom APNs team ID
- custom APNs key ID
- custom APNs private key
- custom APNs environment
- custom APNs topic allowlist
- badge sync enabled or disabled
- relay send rate (
notifications.push.relay_send_rate, shared with FCM pacing)
Default provider must be off.
Task 2: Add Push Device Registration
Files likely involved:
- new migration under
migrations/ - new package or service under
internal/notifications internal/api/handlers/notifications.gointernal/api/router.go
Add profile-scoped Apple push device registration, token rotation, and disable APIs.
Task 3: Add Apple Push Provider Clients
Files likely involved:
internal/notifications/apple_relay.gointernal/notifications/apple_apns.go- config/settings accessors
Implement the narrow /v1/apple/send client. The client should not accept a
free-form title/body/content payload.
Implement the direct APNs client for custom_apns using the same internal
request shape and payload builder.
Task 4: Add Push Fanout Worker
Files likely involved:
internal/notifications/push_fanout.go- notification delivery creation path from the durable inbox implementation
Trigger push fanout after notification_deliveries commit. Use retries and
record push_delivery_attempts.
Task 5: Add Apple Client Registration And Wake Handling
Files live in the Apple client repository, not this server repo.
Required client behavior:
- request notification permission
- register APNs token with the user's server
- store
server_device_idlocally - handle token rotation
- fetch notification metadata after wake/open
- unregister or disable on sign-out/profile removal
Task 6: Add Admin Diagnostics
Expose high-level status:
- provider enabled/disabled
- number of registered Apple devices
- last relay success
- last relay failure code
- custom APNs credential presence and topic/environment status
- last custom APNs success
- last custom APNs failure code
- token/topic mismatch warnings
Do not expose raw APNs tokens.
Validation Plan
Use minimal verification while the work is still design-only. When implemented, verify:
- relay-disabled servers never call the relay
custom_apnsservers never call the relay- device registration is profile-scoped and idempotent
- APNs tokens are redacted in logs
- relay request bodies contain no titles, body text, item IDs, server URLs, or profile names
- direct APNs payloads contain no titles, body text, item IDs, server URLs, or profile names beyond the generic localizable notification keys
- custom APNs private keys and generated provider tokens are redacted in logs
- creating one
notification_deliverysends at most one push per enabled device - APNs token rotation updates the stored token without duplicating devices
UnregisteredandBadDeviceTokendisable the affected device- app wake fetches notification metadata from the user's server
- server offline after push leaves only the generic notification visible
Resolved Questions (was: Open Questions)
These four open questions from the original draft were resolved during the multi-channel refinement:
- Topics: per-platform. Official Silo builds use separate APNs topics for iOS, tvOS, and macOS. Each is a different bundle ID with its own provisioning. The
apns_topicallowlist on a relay account enumerates the per-platform topics the account may push to. - Badge counts: disabled in v1. Badge count leaks unread volume through APNs. v1 ships without badge updates; an explicit per-profile opt-in to badge sync can land in v2 alongside a privacy notice.
- Relay statefulness: stateless v1. No stored token aliases. The relay accepts raw APNs tokens on every request. Token aliases add complexity for a benefit (reduced repeat-token exposure to relay logs) that is small compared to "redact tokens from logs." Add aliases later if abuse-pattern analysis or rate-limit pressure justifies it.
- Relay deployment repo: out of scope for this spec. The relay implementation lives in a separate repo (provisional name
silo-push-relay). This document defines the contract the user-facing Silo server implements; the relay's internal architecture (deployment platform, dependency tree, etc.) is the relay repo's concern.
Remaining Open Questions
- Multi-bundle topic allowlist UX. When an admin configures custom APNs, how do they declare allowlisted topics — free-text textarea, or a structured list? Recommendation: structured list with platform tags (iOS / tvOS / macOS) so the UI can show which platforms have working push.
- APNs JWT cache lifetime. APNs JWTs are valid for 60 minutes per Apple's docs but may be reused across requests. Should the custom APNs path cache and reuse, or sign per-request? Recommendation: cache with regeneration at 50 minutes to leave headroom, matching Apple's recommendation.