Skip to content

Add UnifiedPush application-server support with anonymous installation registry #378

@wax911

Description

@wax911

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:

jsr:@negrel/webpush

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?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions