A lightweight push notification relay that forwards notifications to APNs (iOS) and FCM (Android) on behalf of ACP Bridge instances.
Bridge instances run on user machines. Distributing APNs .p8 keys or FCM service account credentials to every bridge would be a critical security risk. The relay centralizes credential management while bridges only need the relay URL.
Bridge ──POST /push──→ [ Push Relay Worker ] ──→ APNs / FCM
Bearer JWT │
┌───────────┴───────────┐
│ KV: DEVICE_TOKENS │ client_id → [devices]
│ KV: AUTH_TOKENS │ JWKS cache + APNs/FCM tokens
└───────────────────────┘
│
Cron (every 45 min) refreshes APNs/FCM auth tokens
cf-token Worker (token.aptove.com)
├── POST /token → issues RS256 JWT (1h TTL) to authenticated bridges
└── GET /.well-known/jwks.json → JWKS for relay JWT verification
Two logical workers in one script:
- Push Worker (
fetchhandler) – Routes HTTP requests, verifies RS256 Bearer JWT from cf-token, reads cached APNs/FCM tokens from KV - Token Worker (
scheduledhandler) – Cron-triggered, refreshes APNs JWT and FCM OAuth2 token
Authentication flow:
- Bridge obtains a short-lived RS256 JWT from cf-token (
POST /tokenwith itsclient_id/client_secret) - Bridge includes
Authorization: Bearer <jwt>on every push relay request - Push relay fetches JWKS from cf-token (cached 1h in KV), verifies signature + claims
- The JWT
subclaim (the bridge'sclient_id) is used as the isolation key for device storage
Health check. Returns { "ok": true, "status": "healthy" }.
All endpoints except /health require a valid Bearer JWT issued by cf-token:
Authorization: Bearer <jwt>
Register a device for push notifications.
{
"device_token": "<APNs or FCM device token>",
"platform": "ios" | "android",
"bundle_id": "com.example.app" // optional
}Unregister a device.
{
"device_token": "<device token to remove>"
}Send a push notification to all devices registered under this bridge's JWT identity.
{
"title": "New Tool Request",
"body": "Agent wants to run 'rm -rf /'"
}Response:
{
"ok": true,
"results": [
{ "platform": "ios", "status": "sent" },
{ "platform": "android", "status": "sent" }
]
}- Node.js ≥ 18
- Wrangler CLI (
npm i -g wrangler) - A Cloudflare account
- cf-token deployed and running (see
cf-token/README.md— must be set up first)
cd cf-push-relay
npm installwrangler kv namespace create DEVICE_TOKENS
wrangler kv namespace create AUTH_TOKENSCopy the IDs into wrangler.toml.
# APNs (iOS)
wrangler secret put APNS_PRIVATE_KEY # paste .p8 file contents
wrangler secret put APNS_KEY_ID # 10-char key ID
wrangler secret put APNS_TEAM_ID # 10-char team ID
# FCM (Android)
wrangler secret put FCM_PRIVATE_KEY # RSA key from service account JSON
wrangler secret put FCM_CLIENT_EMAIL # service account emailEdit wrangler.toml:
[vars]
APNS_BUNDLE_ID = "com.yourapp.bundle"
APNS_SANDBOX = "true" # "false" for production
FCM_PROJECT_ID = "your-firebase-project-id"
TOKEN_SERVICE_URL = "https://token.aptove.com" # cf-token base URLnpm run deploy
# or: wrangler deployPushing to main automatically runs tests and deploys to Cloudflare Workers.
Required GitHub Secrets (Settings → Secrets and variables → Actions):
| Secret | Description | How to get it |
|---|---|---|
CLOUDFLARE_API_TOKEN |
Scoped API token for Workers | Cloudflare Dashboard → My Profile → API Tokens → Create Token → use "Edit Cloudflare Workers" template. Scope to your account and aptov.com zone only. |
CLOUDFLARE_ACCOUNT_ID |
Your Cloudflare account identifier | Cloudflare Dashboard → any domain → right sidebar under Account ID (32-char hex). |
Security note: Use the "Edit Cloudflare Workers" token template — it grants only Workers write + Account read permissions. Restrict the token to your specific account and zone so a leaked token cannot affect other resources.
# Create .dev.vars for local secrets
cat > .dev.vars << 'EOF'
APNS_PRIVATE_KEY=...
APNS_KEY_ID=ABC1234567
APNS_TEAM_ID=XYZ9876543
FCM_PRIVATE_KEY=...
FCM_CLIENT_EMAIL=firebase@project.iam.gserviceaccount.com
EOF
npm run devNote: In local dev mode the relay fetches JWKS from the live cf-token URL (
TOKEN_SERVICE_URL). Tests generate their own RSA key pair and seed JWKS into the in-memory KV directly, so they run entirely offline.
npm test # run all tests
npm run test:watch # watch mode
npm run typecheck # TypeScript type checkingAPNs (iOS): The device token appears in the URL path:
POST https://api.push.apple.com/3/device/<DEVICE_TOKEN>
Authorization: bearer <JWT>
The JWT identifies the publisher (Team ID + Key ID). Apple uses the device token to look up which physical device to deliver to via its persistent APNs connection.
FCM (Android): The device token appears in the request body:
POST https://fcm.googleapis.com/v1/projects/<PROJECT>/messages:send
{
"message": {
"token": "<DEVICE_TOKEN>",
"notification": { "title": "...", "body": "..." }
}
}The OAuth2 bearer token identifies the publisher (service account). Google uses the device token to route via its persistent GCM connection to the device.
Key insight: Auth credentials (JWT/OAuth2) = "who is sending". Device tokens = "where to deliver". These are completely separate concerns.
- APNs/FCM credentials never leave the relay (stored as Cloudflare Secrets)
- Bridge instances only know the relay URL and their own
client_id/client_secret— never the push credentials - Each bridge authenticates with cf-token to obtain a short-lived RS256 JWT (1-hour TTL)
- The relay verifies JWT signatures against JWKS fetched from cf-token (cached 1h in KV)
- Device tokens are isolated per JWT
sub(bridgeclient_id) in KV — no cross-bridge access is possible - On key rotation, the relay detects an unknown
kid, invalidates its JWKS cache, and retries once — enabling zero-downtime rotation - Stale device tokens are automatically cleaned up when APNs/FCM reports them invalid