Summary
Add UnifiedPush application-server support to on-the-edge so AniTrend can register Android app installations, validate push delivery channels, store UnifiedPush/Web Push endpoint state, and send lightweight push events to active installations.
This issue is intentionally standalone. It captures the desired backend architecture, current constraints, rollout phases, endpoint contracts, storage model, security requirements, and long-term user-targeting path.
Companion Android issue: AniTrend/anitrend-v2#1249
Background
AniTrend has already implemented the Android-side UnifiedPush integration. The missing piece is the backend application-server layer.
UnifiedPush does not require AniTrend to run a distributor or push server. The Android app registers with the user's chosen UnifiedPush distributor, receives a push endpoint and Web Push key material, then sends that registration to AniTrend's application server. The application server stores the registration and later sends encrypted Web Push payloads to the endpoint.
Relevant references:
Current constraints
- The old Django backend has been deprecated.
- The app currently has no general backend-auth flow available to
on-the-edge.
- The app uses AniList auth locally and can know the authenticated AniList user id.
on-the-edge should not receive AniList access tokens for this phase.
- Long term, AniTrend can associate an installation with a client-declared AniList user id sent by the app after local auth.
- Server funding is constrained, so
on-the-edge must not become a heavy analytics, personalization, or notification-rendering platform.
- The Android app already sends app/client metadata through
x-* headers.
on-the-edge already has request/client header processing in src/middleware/header.middleware.ts.
on-the-edge already uses a package-module structure under src/package/*.
- MongoDB is already available through
src/database/mongo.service.ts.
- The current global mutation guard only validates basic mutation headers. It should not be treated as authentication.
Core design decision
Use an anonymous installation registry first.
Do not model anonymous devices as users. Do not fingerprint users. Generate a stable app installation id on the Android side and use that as the anonymous identity. When the app is locally authenticated with AniList, it may send the AniList user id as client-declared metadata so on-the-edge can associate multiple installations with the same user id.
Identity model:
Installation = one AniTrend app install on one device
User id association = optional client-declared AniList user id sent by the app after local AniList auth
Registration = current UnifiedPush/Web Push endpoint for that installation
Profile = client-provided capabilities, topics, app version, locale, and coarse view metadata
Current low-cost/no-backend-auth flow:
anonymous installation
-> user logs in with AniList inside the app
-> app already knows the authenticated AniList user id locally
-> app sends installationId + anilistUserId as profile/link metadata to on-the-edge
-> on-the-edge stores the association as client-declared metadata
-> future non-sensitive user-associated notifications can fan out to all active installations with that anilistUserId
Important limitation:
Because on-the-edge will not receive or verify AniList access tokens in this phase, anilistUserId is not a server-verified identity claim.
It is acceptable for coarse targeting, experiments, public announcements, and non-sensitive notification routing.
It must not be used for private, security-sensitive, or account-sensitive notifications unless a proper backend auth or signed assertion layer is added later.
Goals
- Add a
PushModule to on-the-edge.
- Store UnifiedPush/Web Push endpoint registrations.
- Support anonymous installation registration without app auth.
- Challenge-confirm push registrations before activating them.
- Store lightweight client/app metadata for cheap targeting.
- Optionally associate an active installation with a client-declared AniList user id.
- Send compact push events, not rich rendered notifications.
- Allow the Android app to decide whether to show a visible notification, silently sync, or ignore the event.
- Support public topics first, such as
news, appAnnouncements, and sync.
- Provide a clean path to user-associated targeting without requiring AniList access tokens on
on-the-edge.
- Keep backend compute low and query patterns simple.
Non-goals
- Do not build a UnifiedPush distributor.
- Do not build a custom push server.
- Do not build a full analytics pipeline.
- Do not implement complex segmentation in the first pass.
- Do not send user-personal or account-sensitive notifications based only on client-declared identity.
- Do not receive, store, or verify AniList access tokens in this phase.
- Do not render notification UI/server-side rich notification content in
on-the-edge.
Proposed module structure
Add:
src/package/push/
index.ts
push.module.ts
push.controller.ts
push.service.ts
push.repository.ts
push.sender.ts
push.document.ts
push.schema.ts
push.types.ts
push.swagger.ts
push.errors.ts
Then wire the module into src/package/package.module.ts:
import { PushModule } from './push/index.ts';
@Module({
imports: [
ConfigModule,
NewsModule,
EpisodeModule,
SeriesModule,
StudioModule,
PeopleModule,
CharacterModule,
PushModule,
],
})
export class PackageModule {}
Dependency direction
Use a Web Push library instead of implementing encryption/VAPID by hand.
Candidate to evaluate:
Requirements for the selected library:
- Supports current Web Push encryption.
- Supports VAPID.
- Supports
aes128gcm content encoding.
- Works in Deno.
- Allows detecting gone/expired endpoints, either through status codes or explicit error helpers.
Environment variables
Add required server-side push configuration:
PUSH_VAPID_PUBLIC_KEY=<base64url public key>
PUSH_VAPID_PRIVATE_KEY=<base64url private key>
PUSH_VAPID_SUBJECT=mailto:admin@example.com or https://anitrend.co
PUSH_PAYLOAD_MAX_BYTES=3072
PUSH_SEND_TIMEOUT_MS=5000
PUSH_CHALLENGE_TTL_SECONDS=300
The VAPID private key must never be sent to the Android app. The app may fetch the VAPID public key.
Public API surface
GET /v1/push/vapid
Returns the application server public key.
Response:
{
"publicKey": "base64url-vapid-public-key"
}
POST /v1/push/installations
Registers or updates one app installation's UnifiedPush endpoint.
Request:
{
"installationId": "stable-app-generated-id",
"instance": "default",
"endpoint": "https://push-provider.example/...",
"keys": {
"p256dh": "client-public-key",
"auth": "client-auth-secret"
},
"platform": "android",
"appVersion": "2.x.x",
"appCode": 21000,
"appBuild": "release",
"locale": "en-ZA",
"timezone": "Africa/Johannesburg",
"distributor": "ntfy",
"topics": ["news", "appAnnouncements"]
}
Server behavior:
1. Validate request schema.
2. Validate endpoint URL shape.
3. Upsert by installationId + instance.
4. Store endpoint and Web Push keys.
5. Set status to pending.
6. Generate a random challenge token.
7. Store only hash(token), not raw token.
8. Send a push.challenge payload to the endpoint.
9. Return pending registration state.
Response:
{
"installationId": "stable-app-generated-id",
"instance": "default",
"status": "pending"
}
POST /v1/push/installations/:installationId/confirm
Confirms that the app received the challenge push.
Request:
{
"instance": "default",
"token": "raw-token-received-through-push"
}
Server behavior:
1. Lookup installation by installationId + instance.
2. Ensure status is pending.
3. Hash submitted token.
4. Compare hash with stored challenge hash.
5. Ensure challenge has not expired.
6. Mark installation active.
7. Clear challenge.
8. Set lastConfirmedAt.
Response:
{
"installationId": "stable-app-generated-id",
"instance": "default",
"status": "active"
}
PUT /v1/push/installations/:installationId/profile
Stores a low-cost client profile snapshot. This should not be sent on every screen render. The app should send it when meaningful state changes.
Request:
{
"instance": "default",
"app": {
"version": "2.1.0",
"code": 21000,
"build": "release",
"source": "github"
},
"device": {
"platform": "android",
"sdk": 35,
"manufacturer": "Google",
"model": "Pixel 8"
},
"locale": {
"language": "en",
"region": "ZA",
"timezone": "Africa/Johannesburg"
},
"capabilities": {
"unifiedPush": true,
"notificationRuntimePermission": true,
"supportsSilentSync": true,
"supportsRichNotifications": true
},
"views": {
"lastSeen": "news.feed",
"lastSeenAt": 1781620000000
},
"topics": {
"news": true,
"appAnnouncements": true,
"sync": true,
"airing": false,
"mediaUpdates": false
},
"identity": {
"provider": "anilist",
"anilistUserId": 123456,
"state": "client-declared"
}
}
The identity block is optional. It should only be sent when the app has local AniList auth state. It must not contain AniList access tokens.
Server behavior:
1. Validate schema.
2. Lookup installation.
3. Merge/update profile metadata.
4. If identity is present, store the AniList user id as client-declared association.
5. Set lastProfileSyncAt.
6. Do not create analytics event spam.
PATCH /v1/push/installations/:installationId/preferences
Updates topic preferences only.
Request:
{
"instance": "default",
"topics": {
"news": true,
"appAnnouncements": true,
"sync": true,
"airing": false,
"mediaUpdates": false
}
}
DELETE /v1/push/installations/:installationId
Disables or revokes the installation.
Request:
{
"instance": "default",
"reason": "user-disabled"
}
Server behavior:
1. Mark status as disabled or revoked.
2. Stop sending future pushes to this installation.
3. Keep delivery history for observability.
POST /v1/push/installations/:installationId/test
Sends a test push to the current installation.
This is primarily for development and QA. It should be protected by a cheap guard, environment restriction, or rate limit.
Test payload:
{
"type": "push.test",
"id": "uuid",
"createdAt": 1781620000000
}
Future optional: POST /v1/push/installations/:installationId/link
Only add this if profile sync is not sufficient for identity association.
Request:
{
"instance": "default",
"provider": "anilist",
"anilistUserId": 123456,
"state": "client-declared"
}
Server behavior:
1. Lookup active installation.
2. Store anilistUserId as client-declared association.
3. Do not require or accept AniList access token in this phase.
4. Return linked state.
Response:
{
"installationId": "stable-app-generated-id",
"linked": true,
"provider": "anilist",
"anilistUserId": 123456,
"state": "client-declared"
}
Future optional: POST /v1/push/installations/:installationId/unlink
Unlinks the installation from a client-declared AniList user id without deleting the anonymous installation.
Use case: user logs out but may still want public notifications.
Header strategy
Continue using small x-* headers for request context.
Suggested lightweight headers:
x-app-name: anitrend
x-app-version: 2.1.0
x-app-code: 21000
x-app-build: release
x-app-source: github
x-app-locale: en-ZA
x-view-name: news.feed
x-view-version: 1
x-push-installation-id: <stable-installation-id>
x-push-profile-version: 1
x-anilist-user-id: <optional client-declared id when locally authenticated>
Rules:
- Headers are for small scalar metadata only.
- Larger structured state belongs in the profile endpoint body.
x-anilist-user-id is metadata only, not a server-verified identity claim.
- Do not send AniList access tokens in headers.
- Do not send full navigation history.
- Do not send search query text.
- Do not send private/local state.
- Do not send every click.
- Use controlled enums for view surfaces.
Suggested view surface enum:
type ViewSurface =
| 'home'
| 'news.feed'
| 'media.detail'
| 'search.results'
| 'settings.notifications'
| 'onboarding.push';
Storage model
push_installations
type PushInstallationDocument = {
installationId: string;
instance: string;
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
status:
| 'pending'
| 'active'
| 'disabled'
| 'expired'
| 'revoked';
anilistUserId?: number;
identityState?: 'anonymous' | 'client-declared';
linkedAt?: number;
unlinkedAt?: number;
platform: 'android';
distributor?: string;
app: {
version?: string;
code?: number;
build?: string;
source?: string;
};
device?: {
sdk?: number;
manufacturer?: string;
model?: string;
};
locale?: {
language?: string;
region?: string;
timezone?: string;
};
capabilities: {
unifiedPush?: boolean;
notificationRuntimePermission?: boolean;
supportsSilentSync?: boolean;
supportsRichNotifications?: boolean;
};
topics: {
news?: boolean;
appAnnouncements?: boolean;
sync?: boolean;
airing?: boolean;
mediaUpdates?: boolean;
};
lastView?: {
name: string;
version?: number;
seenAt: number;
};
challenge?: {
tokenHash: string;
expiresAt: number;
attempts: number;
};
lastProfileSyncAt?: number;
lastConfirmedAt?: number;
lastDeliveredAt?: number;
lastFailedAt?: number;
failureCount: number;
createdAt: number;
updatedAt: number;
};
Indexes:
unique(installationId, instance)
unique(endpoint)
status
anilistUserId + identityState
topics.news + status
topics.appAnnouncements + status
topics.sync + status
app.code + status
locale.region + status
lastProfileSyncAt
challenge.expiresAt
push_delivery_attempts
type PushDeliveryAttemptDocument = {
installationId: string;
instance: string;
endpointHash: string;
notificationId: string;
type: string;
status:
| 'sent'
| 'failed'
| 'gone'
| 'retryable'
| 'rejected';
httpStatus?: number;
error?: string;
attemptedAt: number;
latencyMs?: number;
};
push_intents - optional/future
This can be skipped in the first pass unless a queue/retry worker is introduced.
type PushIntentDocument = {
id: string;
type:
| 'news.available'
| 'app.announcement'
| 'sync.requested'
| 'airing.soon'
| 'media.updated';
target:
| { kind: 'installation'; installationId: string }
| { kind: 'clientDeclaredAniListUser'; anilistUserId: number }
| { kind: 'topic'; topic: string }
| { kind: 'segment'; segmentId: string };
payload: Record<string, unknown>;
visibility: 'silent' | 'visible-candidate';
createdAt: number;
};
Push payload strategy
Do not send rich notification rendering data in the first implementation.
Send compact wake/sync events:
{
"type": "news.available",
"id": "notification-uuid",
"sync": {
"resource": "news",
"since": 1781620000000
}
}
The Android app decides whether to:
show a visible notification
silently sync
ignore because the app is foregrounded
ignore because local preferences disable the category
ignore because the user is already on the relevant screen
This keeps the backend thin and avoids duplicating Android notification rendering logic server-side.
Initial topics
Start with public/non-personal topics only:
news
appAnnouncements
sync
Client-declared AniList user association may later support non-sensitive user-associated topics:
userActivity
watchlistUpdates
mediaUpdates
airing
personalRecommendations
Do not support these from client-declared identity alone:
privateMessages
accountAlerts
securityEvents
Target resolution
Anonymous topic push
Find push_installations where:
status = active
topics.news = true
capabilities.unifiedPush = true
App upgrade notice
Find push_installations where:
status = active
topics.appAnnouncements = true
app.code < minimumSupportedCode
Client-declared AniList user-associated push
Find push_installations where:
status = active
anilistUserId = targetUserId
identityState = client-declared
Use only for non-sensitive notifications until a verified auth layer exists.
A single AniList user id can have multiple active installations. Send to all active installations unless the installation has disabled the topic.
Security requirements
The server must treat client-sent metadata as hints, not verified proof.
Required:
- Do not receive, store, or log AniList access tokens in this phase.
- Do not describe
anilistUserId as server-verified.
- Do not use client-declared
anilistUserId for private, security-sensitive, or account-sensitive notifications.
- Challenge-confirm push registrations before marking them active.
- Store only challenge token hashes.
- Expire pending challenges.
- Rate-limit registration, confirmation, test push, and profile update endpoints.
- Enforce payload size limits.
- Use timeouts for outbound push sends.
- Never expose VAPID private key.
Endpoint safety is mandatory because push endpoints are user-submitted URLs.
Before sending to an endpoint:
1. Require HTTPS, except explicit local development allowlist.
2. Resolve hostname.
3. Reject localhost.
4. Reject private IPv4 ranges.
5. Reject loopback.
6. Reject link-local.
7. Reject multicast.
8. Reject IPv6 unique-local/link-local.
9. Apply send timeout.
10. Apply max payload size.
This is required to reduce SSRF risk.
Delivery handling
2xx
-> mark delivery attempt as sent
-> update lastDeliveredAt
-> reset failureCount
404 / 410 / gone
-> mark installation expired
-> stop future sends
429
-> mark retryable
-> retry later if queue/retry support exists
5xx / timeout
-> mark retryable
-> retry with capped backoff if queue/retry support exists
400 / 401 / 403
-> mark failed
-> likely invalid keys, bad VAPID, provider rejection, or malformed payload
Suggested retry policy if retry support is implemented:
attempt 1: immediate
attempt 2: +1 minute
attempt 3: +5 minutes
attempt 4: +30 minutes
then stop
Low-cost backend strategy
The app should be the smart client.
Android app responsibilities:
compute client capabilities
send coarse view metadata
send topic preferences
own local notification rendering
own local foreground/background decisioning
own local sync behavior
send profile snapshot only when meaningful changes happen
send optional client-declared AniList user id after local auth
on-the-edge responsibilities:
store installation/profile/endpoint state
challenge-confirm push channels
resolve simple targets with Mongo queries
send encrypted Web Push payloads
record delivery outcome
expire gone endpoints
support future client-declared AniList user id association
Avoid:
ingesting every view render
ingesting every click
building full analytics
doing heavy personalization server-side
performing complex dynamic segmentation in the first rollout
handling AniList access tokens in this phase
App profile sync cadence
The app should call PUT /v1/push/installations/:installationId/profile only when meaningful state changes:
first install
app upgrade
UnifiedPush endpoint changed
notification permission changed
locale/timezone changed
user changes notification preferences
user logs in or logs out
feature capability set changes
periodic cheap heartbeat, e.g. every few days
Suggested implementation phases
Phase 1: Skeleton and VAPID
- Add
PushModule.
- Add
PushController, PushService, PushRepository, PushSender.
- Add VAPID env configuration.
- Add
GET /v1/push/vapid.
- Add unit tests for schema validation and VAPID response.
Acceptance criteria:
GET /v1/push/vapid returns only the public key.
- Missing VAPID env vars fail fast during startup or first use.
- Swagger output includes the endpoint.
Phase 2: Anonymous installation registration
- Add
POST /v1/push/installations.
- Store pending registrations in Mongo.
- Upsert by
installationId + instance.
- Enforce unique endpoint.
- Add request validation.
Acceptance criteria:
- A new app installation can register an endpoint.
- Re-registering the same installation updates endpoint/key/profile fields.
- Registration status starts as
pending.
Phase 3: Challenge confirmation
- Generate challenge token after registration.
- Send challenge payload through Web Push.
- Add
POST /v1/push/installations/:installationId/confirm.
- Mark installation active after valid token.
Acceptance criteria:
- Registration is not eligible for normal pushes until confirmed.
- Expired/invalid challenges are rejected.
- Raw challenge tokens are not stored.
Phase 4: Manual test push
- Add
POST /v1/push/installations/:installationId/test.
- Send
push.test payload to active installation.
- Record delivery attempt.
Acceptance criteria:
- Android app receives a test push through
PushService.onMessage.
- Failed sends are recorded.
- Gone endpoints are marked expired.
Phase 5: Profile and topic preferences
- Add
PUT /v1/push/installations/:installationId/profile.
- Add
PATCH /v1/push/installations/:installationId/preferences.
- Add topic indexes.
- Store optional client-declared AniList user id if present in profile payload.
Acceptance criteria:
- App can update capabilities, locale, app version, coarse view, topics, and optional client-declared AniList user id.
- Server can query active installations by public topic.
- Server can query active installations by client-declared AniList user id for non-sensitive targeting.
- Profile endpoint does not create per-event analytics spam.
Phase 6: News fan-out
- Use the existing news flow as the first real public push trigger.
- When new news items are inserted, send
news.available to active installations with topics.news = true.
- Keep payload compact and let the app sync/render locally.
Acceptance criteria:
- New news availability can trigger push to active news subscribers.
- Payload does not include rich rendering data.
- Delivery attempts are recorded.
Phase 7: Cleanup and reliability
- Expire stale pending registrations.
- Cleanup or mark expired gone endpoints.
- Add capped retry behavior if needed.
- Add rate limits.
- Add observability logs/metrics.
Acceptance criteria:
- Expired endpoints stop receiving push attempts.
- Registration/test endpoints cannot be abused easily.
- Logs expose enough context to debug delivery without exposing secrets.
Phase 8: Future client-declared AniList user id association
- Support profile or link endpoint updates that associate an installation with an AniList user id known locally by the app.
- Do not accept or verify AniList access tokens.
- Mark association state as
client-declared.
- Support multiple installations per AniList user id.
Acceptance criteria:
- A user id can be associated with multiple devices.
- User-associated fan-out sends to all active linked installations.
- Client-declared identity is never described as server-verified.
- Private/security-sensitive topics remain unsupported until a proper verified auth layer exists.
Observability
Log/measure at minimum:
push.registration.created
push.registration.updated
push.registration.challenge_sent
push.registration.confirmed
push.registration.challenge_failed
push.profile.updated
push.identity.client_declared
push.identity.unlinked
push.preferences.updated
push.delivery.sent
push.delivery.failed
push.delivery.gone
push.delivery.retryable
push.endpoint.rejected_ssrf
push.payload.too_large
Do not log raw endpoint URLs, auth secrets, p256dh keys, challenge tokens, or access tokens. Access tokens should not be accepted by this feature in the first place. Use hashes where correlation is required.
Testing expectations
Unit tests:
- Registration schema validation.
- Profile schema validation.
- Preference schema validation.
- Client-declared identity schema validation.
- Challenge token hashing and verification.
- Status transitions.
- Target query construction.
- Delivery status mapping.
- Endpoint URL validation.
Integration tests:
- Register installation -> pending.
- Confirm challenge -> active.
- Update profile.
- Update profile with optional client-declared AniList user id.
- Update preferences.
- Send test push through mocked Web Push sender.
- Mark endpoint expired on gone response.
Manual QA:
- Install Android app with UnifiedPush distributor.
- App receives endpoint.
- App registers with
on-the-edge.
- Backend sends challenge.
- App confirms challenge.
- Backend marks installation active.
- Test push is received in
PushService.onMessage.
- App can disable topic and stop receiving that topic.
- App can send optional client-declared AniList user id after local auth.
- App logout can remove/disassociate the AniList user id while keeping anonymous public notification support.
Open questions
- Which Deno Web Push library should be used permanently?
- Should profile updates be guarded by challenge-active state only, or also a per-installation secret?
- Should local development allow HTTP endpoints for self-hosted testing?
- Should delivery attempts be stored indefinitely or TTL-expired?
- Should
news.available fan-out happen inline after insert, or through a lightweight queue/worker?
- Should client-declared AniList user id be sent only through profile sync, a dedicated link endpoint, or both?
Summary
Add UnifiedPush application-server support to
on-the-edgeso AniTrend can register Android app installations, validate push delivery channels, store UnifiedPush/Web Push endpoint state, and send lightweight push events to active installations.This issue is intentionally standalone. It captures the desired backend architecture, current constraints, rollout phases, endpoint contracts, storage model, security requirements, and long-term user-targeting path.
Companion Android issue: AniTrend/anitrend-v2#1249
Background
AniTrend has already implemented the Android-side UnifiedPush integration. The missing piece is the backend application-server layer.
UnifiedPush does not require AniTrend to run a distributor or push server. The Android app registers with the user's chosen UnifiedPush distributor, receives a push endpoint and Web Push key material, then sends that registration to AniTrend's application server. The application server stores the registration and later sends encrypted Web Push payloads to the endpoint.
Relevant references:
Current constraints
on-the-edge.on-the-edgeshould not receive AniList access tokens for this phase.on-the-edgemust not become a heavy analytics, personalization, or notification-rendering platform.x-*headers.on-the-edgealready has request/client header processing insrc/middleware/header.middleware.ts.on-the-edgealready uses a package-module structure undersrc/package/*.src/database/mongo.service.ts.Core design decision
Use an anonymous installation registry first.
Do not model anonymous devices as users. Do not fingerprint users. Generate a stable app installation id on the Android side and use that as the anonymous identity. When the app is locally authenticated with AniList, it may send the AniList user id as client-declared metadata so
on-the-edgecan associate multiple installations with the same user id.Identity model:
Current low-cost/no-backend-auth flow:
Important limitation:
Goals
PushModuletoon-the-edge.news,appAnnouncements, andsync.on-the-edge.Non-goals
on-the-edge.Proposed module structure
Add:
Then wire the module into
src/package/package.module.ts:Dependency direction
Use a Web Push library instead of implementing encryption/VAPID by hand.
Candidate to evaluate:
Requirements for the selected library:
aes128gcmcontent encoding.Environment variables
Add required server-side push configuration:
The VAPID private key must never be sent to the Android app. The app may fetch the VAPID public key.
Public API surface
GET /v1/push/vapidReturns the application server public key.
Response:
{ "publicKey": "base64url-vapid-public-key" }POST /v1/push/installationsRegisters or updates one app installation's UnifiedPush endpoint.
Request:
{ "installationId": "stable-app-generated-id", "instance": "default", "endpoint": "https://push-provider.example/...", "keys": { "p256dh": "client-public-key", "auth": "client-auth-secret" }, "platform": "android", "appVersion": "2.x.x", "appCode": 21000, "appBuild": "release", "locale": "en-ZA", "timezone": "Africa/Johannesburg", "distributor": "ntfy", "topics": ["news", "appAnnouncements"] }Server behavior:
Response:
{ "installationId": "stable-app-generated-id", "instance": "default", "status": "pending" }POST /v1/push/installations/:installationId/confirmConfirms that the app received the challenge push.
Request:
{ "instance": "default", "token": "raw-token-received-through-push" }Server behavior:
Response:
{ "installationId": "stable-app-generated-id", "instance": "default", "status": "active" }PUT /v1/push/installations/:installationId/profileStores a low-cost client profile snapshot. This should not be sent on every screen render. The app should send it when meaningful state changes.
Request:
{ "instance": "default", "app": { "version": "2.1.0", "code": 21000, "build": "release", "source": "github" }, "device": { "platform": "android", "sdk": 35, "manufacturer": "Google", "model": "Pixel 8" }, "locale": { "language": "en", "region": "ZA", "timezone": "Africa/Johannesburg" }, "capabilities": { "unifiedPush": true, "notificationRuntimePermission": true, "supportsSilentSync": true, "supportsRichNotifications": true }, "views": { "lastSeen": "news.feed", "lastSeenAt": 1781620000000 }, "topics": { "news": true, "appAnnouncements": true, "sync": true, "airing": false, "mediaUpdates": false }, "identity": { "provider": "anilist", "anilistUserId": 123456, "state": "client-declared" } }The
identityblock is optional. It should only be sent when the app has local AniList auth state. It must not contain AniList access tokens.Server behavior:
PATCH /v1/push/installations/:installationId/preferencesUpdates topic preferences only.
Request:
{ "instance": "default", "topics": { "news": true, "appAnnouncements": true, "sync": true, "airing": false, "mediaUpdates": false } }DELETE /v1/push/installations/:installationIdDisables or revokes the installation.
Request:
{ "instance": "default", "reason": "user-disabled" }Server behavior:
POST /v1/push/installations/:installationId/testSends a test push to the current installation.
This is primarily for development and QA. It should be protected by a cheap guard, environment restriction, or rate limit.
Test payload:
{ "type": "push.test", "id": "uuid", "createdAt": 1781620000000 }Future optional:
POST /v1/push/installations/:installationId/linkOnly add this if profile sync is not sufficient for identity association.
Request:
{ "instance": "default", "provider": "anilist", "anilistUserId": 123456, "state": "client-declared" }Server behavior:
Response:
{ "installationId": "stable-app-generated-id", "linked": true, "provider": "anilist", "anilistUserId": 123456, "state": "client-declared" }Future optional:
POST /v1/push/installations/:installationId/unlinkUnlinks the installation from a client-declared AniList user id without deleting the anonymous installation.
Use case: user logs out but may still want public notifications.
Header strategy
Continue using small
x-*headers for request context.Suggested lightweight headers:
Rules:
x-anilist-user-idis metadata only, not a server-verified identity claim.Suggested view surface enum:
Storage model
push_installationsIndexes:
push_delivery_attemptspush_intents- optional/futureThis can be skipped in the first pass unless a queue/retry worker is introduced.
Push payload strategy
Do not send rich notification rendering data in the first implementation.
Send compact wake/sync events:
{ "type": "news.available", "id": "notification-uuid", "sync": { "resource": "news", "since": 1781620000000 } }The Android app decides whether to:
This keeps the backend thin and avoids duplicating Android notification rendering logic server-side.
Initial topics
Start with public/non-personal topics only:
Client-declared AniList user association may later support non-sensitive user-associated topics:
Do not support these from client-declared identity alone:
Target resolution
Anonymous topic push
App upgrade notice
Client-declared AniList user-associated push
Use only for non-sensitive notifications until a verified auth layer exists.
A single AniList user id can have multiple active installations. Send to all active installations unless the installation has disabled the topic.
Security requirements
The server must treat client-sent metadata as hints, not verified proof.
Required:
anilistUserIdas server-verified.anilistUserIdfor private, security-sensitive, or account-sensitive notifications.Endpoint safety is mandatory because push endpoints are user-submitted URLs.
Before sending to an endpoint:
This is required to reduce SSRF risk.
Delivery handling
Suggested retry policy if retry support is implemented:
Low-cost backend strategy
The app should be the smart client.
Android app responsibilities:
on-the-edgeresponsibilities:Avoid:
App profile sync cadence
The app should call
PUT /v1/push/installations/:installationId/profileonly when meaningful state changes:Suggested implementation phases
Phase 1: Skeleton and VAPID
PushModule.PushController,PushService,PushRepository,PushSender.GET /v1/push/vapid.Acceptance criteria:
GET /v1/push/vapidreturns only the public key.Phase 2: Anonymous installation registration
POST /v1/push/installations.installationId + instance.Acceptance criteria:
pending.Phase 3: Challenge confirmation
POST /v1/push/installations/:installationId/confirm.Acceptance criteria:
Phase 4: Manual test push
POST /v1/push/installations/:installationId/test.push.testpayload to active installation.Acceptance criteria:
PushService.onMessage.Phase 5: Profile and topic preferences
PUT /v1/push/installations/:installationId/profile.PATCH /v1/push/installations/:installationId/preferences.Acceptance criteria:
Phase 6: News fan-out
news.availableto active installations withtopics.news = true.Acceptance criteria:
Phase 7: Cleanup and reliability
Acceptance criteria:
Phase 8: Future client-declared AniList user id association
client-declared.Acceptance criteria:
Observability
Log/measure at minimum:
Do not log raw endpoint URLs, auth secrets, p256dh keys, challenge tokens, or access tokens. Access tokens should not be accepted by this feature in the first place. Use hashes where correlation is required.
Testing expectations
Unit tests:
Integration tests:
Manual QA:
on-the-edge.PushService.onMessage.Open questions
news.availablefan-out happen inline after insert, or through a lightweight queue/worker?