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
- The supporter enters their supporter key in the app, which obtains a platform push token from FCM.
- The app asks the relay for a grant:
POST /v1/grantwith 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. - The app hands the grant to each paired daemon over the VPN.
- 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. - 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).