porthole / docs / self-host-relay

Run your own push relay

Install and configure the daemon · Run your own push relay · iOS background behaviour

The relay is only needed for background push on phones. Everything else in Porthole works without it. The store builds use the project’s relay; a self-built app can point at your own.

What it needs

Secret / var Purpose
GRANT_SIGNING_KEY Ed25519 private key (PKCS8, base64url) that signs push grants. Generate: node -e "crypto.subtle.generateKey({name:'Ed25519'},true,['sign','verify']).then(async k=>console.log(Buffer.from(await crypto.subtle.exportKey('pkcs8',k.privateKey)).toString('base64url')))"
FCM_SERVICE_ACCOUNT Firebase service account JSON with the Firebase Cloud Messaging API role
FCM_PROJECT_ID (var) Firebase project id (optional; read from the service account otherwise)
ENTITLEMENT_URL (var) + ENTITLEMENT_SECRET an HTTPS endpoint that answers whether a supporter key is active (see below)
ENTITLEMENT_STATIC_KEYS (var) alternative for a personal relay: comma-separated SHA-256 hex hashes of the supporter keys you hand out yourself
ATTEST_MODE (var) off, android (Play Integrity) or all; iOS App Attest is not yet implemented, so all refuses iOS grants
DENY (KV) revoked device ids and daily counters

Keep the origin of record for every secret outside the repo; set them with wrangler secret put.

The entitlement contract

The relay never decides who is a supporter. It calls POST $ENTITLEMENT_URL with Authorization: Bearer $ENTITLEMENT_SECRET and body {"key": "PH-XXXX-XXXX-XXXX-XXXX", "device_id": "...", "platform": "ios|android"} and expects {"active": true|false, "period_end": "<ISO 8601>", "reason": "..."}. Status 404 means unknown key, 429 means too many devices on that key. The grant it mints expires at period_end or 30 days, whichever is sooner, and the app re-mints within three days of expiry, so revoking a key on your side takes effect by the next refresh. Any backend that follows this contract works; Porthole’s own is a private Patreon-backed service.

Deploy

cd relay
npm install
npx wrangler kv namespace create DENY     # put the id into wrangler.jsonc
npx wrangler secret put GRANT_SIGNING_KEY
npx wrangler secret put FCM_SERVICE_ACCOUNT
npx wrangler secret put ENTITLEMENT_SECRET   # or set ENTITLEMENT_STATIC_KEYS as a var
npx wrangler deploy

Then build the app with --dart-define=RELAY_URL=https://your-worker.workers.dev and the Firebase options (FB_API_KEY, FB_APP_ID, FB_SENDER_ID, FB_PROJECT_ID; iOS also FB_IOS_BUNDLE_ID). Set relay_url in the daemon config to the same URL.

How a push travels

  1. The supporter enters their supporter key in the app, which obtains a platform push token from FCM.
  2. The app asks the relay for a grant: POST /v1/grant with the key. The relay asks the entitlement service whether the key is active and returns a signed grant binding the device id and push token, valid for at most 30 days.
  3. The app hands the grant to each paired daemon over the VPN.
  4. When a session needs you and the phone is not connected, the daemon encrypts a tiny notice with the key agreed at pairing and posts {grant, ct} to the relay.
  5. The relay checks the signature, expiry, deny list and rate limits, then sends the ciphertext through FCM. The phone decrypts it and shows the notification, and the app fetches details from the daemon on open.

The relay never sees session content, repository names or prompts. It sees a push token, a device id and a timestamp.

Blocking a device

npx wrangler kv key put --binding DENY <device-id> 1

Rotating the grant key

Set a new GRANT_SIGNING_KEY; existing grants stop verifying immediately and apps re-mint on their next refresh (within three days of a grant’s expiry).