Skip to content

feat: native APNs/FCM push transport + shared push-core (P2 push, slice 3a) - #261

Merged
saucam merged 1 commit into
mainfrom
feat/push-native-transport
Jul 27, 2026
Merged

feat: native APNs/FCM push transport + shared push-core (P2 push, slice 3a)#261
saucam merged 1 commit into
mainfrom
feat/push-native-transport

Conversation

@saucam

@saucam saucam commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

What

Self-hosted push, no third party (P2 push, slice 3a — daemon side). The daemon can now deliver approval wake-ups directly via APNs/FCM (transport: "native"), holding its own credentials — replacing Expo (a third party in the delivery path) for the operator who is the app publisher.

The decision behind this: on iOS, only the app publisher can push to the App Store app. For a personal self-hosted daemon (operator == publisher), the daemon can push directly — no relay/process/repo needed. A standalone relay is only required to serve other users' daemons or a hosted offering. So this ships the embedded path now, structured so the relay is a drop-in later.

Shared core — both modes, zero duplication

The credential-bearing logic lives once in src/push-core (depends only on node:crypto / node:http2 / fetch — no daemon imports):

  • ApnsClient — ES256 (.p8) provider JWT (cached), HTTP/2 to APNs, content-blind aps payload, dead-token classification (410 / BadDeviceToken / Unregistered). The HTTP/2 connect is injectable, so the send path is unit-tested without a real socket.
  • FcmClient — service-account RS256 JWT → OAuth token (cached) → FCM v1 POST, content-blind message, UNREGISTERED/NOT_FOUND classification.
  • PushSender — routes by platform (iOS→APNs, Android→FCM) over injectable channels.

Both delivery paths converge on this one sender:

  • Embedded (transport: native): daemon → NativePushTransportPushSender → APNs/FCM.
  • Relay (transport: relay): daemon → RelayPushTransport (an HTTPS POST, no creds) → (future) relay service → the same PushSender → APNs/FCM.

RelayPushTransport (the daemon half of the relay seam) ships now; the standalone relay service is a thin wrapper around push-core, added when the multi-user/hosted path is needed.

Surface

  • createPushTransport selects expo | native | relay | none.
  • config.push gains transport: native/relay + apns/fcm creds + relayUrl/relayToken (+ env overrides). Native holds .p8/Firebase creds locally; relay holds none.
  • Daemon advertises push.native when transport is native/relay (vs push for expo), so the client registers the right token type (native device token vs Expo token). Dead tokens are pruned via Store.pruneDeadToken.
  • Content-blind everywhere: payloads carry only an opaque sessionId + kind — never a tool name, args, or description.

Verification

bun test: ES256/RS256 sign→verify; APNs content-blind payload + response classification + the HTTP/2 send via injected connect (session reuse, unregistered); FCM via mocked fetch (OAuth caching, Bearer, content-blind body, unregistered); PushSender routing; native/relay transports + factory + dead-token prune. tsc + biome clean; broad regression (session-manager / config / protocol / core) green.

Real APNs/FCM delivery needs your .p8 + Firebase service account (config.push.apns/fcm) + a device build — the sending logic is verified headlessly (injected socket / mocked fetch).

Follow-ups

  • Mobile: register the native device token (getDevicePushTokenAsync) when the daemon advertises push.native (separate codeoid-mobile PR — completes 3a end-to-end).
  • The standalone relay service (src/relay) wrapping push-core — when multi-user / hosted.

🤖 Generated with Claude Code

@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

…ce 3a)

Self-hosted, no-third-party push: the daemon can now deliver approval
wake-ups DIRECTLY via APNs/FCM (transport: "native"), holding its own
credentials — replacing Expo (a third party in the path) for operators who
are the app publisher. The credential-bearing sending logic lives in a
shared, dependency-light push-core so the SAME code serves both the
embedded daemon path and a future standalone relay (transport: "relay",
seam included), with zero duplication.

push-core (src/push-core) — the shared APNs/FCM sender:
- ApnsClient: ES256 (.p8) provider JWT (cached), HTTP/2 to APNs, content-
  blind aps payload, dead-token classification (410 / BadDeviceToken /
  Unregistered). The HTTP/2 connect is injectable so the send path is
  unit-testable without a real socket.
- FcmClient: service-account RS256 JWT -> OAuth token (cached) -> FCM v1
  POST, content-blind message, UNREGISTERED/NOT_FOUND classification.
- PushSender: routes by platform (iOS->APNs, Android->FCM) over injectable
  channels; createPushSender builds them from creds.
- Depends only on node:crypto / node:http2 / fetch (no daemon imports), so
  a relay service can reuse it verbatim later.

Daemon:
- NativePushTransport (embedded): wraps PushSender, prunes dead tokens via
  a new Store.pruneDeadToken.
- RelayPushTransport: POSTs a content-blind {targets, note} to a relay (the
  daemon half of the relay seam; no creds in the daemon).
- createPushTransport now selects expo | native | relay | none.
- config.push gains transport native/relay + apns/fcm creds +
  relayUrl/relayToken + env overrides.
- The daemon advertises PUSH_NATIVE ("push.native") when transport is
  native/relay (vs PUSH "push" for expo), so clients register the right
  token type (native device token vs Expo token).

Content-blindness holds across every path: payloads carry only an opaque
sessionId + kind — never a tool name, args, or description.

Tests (bun): ES256/RS256 sign->verify; APNs payload content-blindness +
response classification + the HTTP/2 send (injected connect) incl. session
reuse + unregistered; FCM via mocked fetch (OAuth caching, Bearer,
content-blind body, unregistered); PushSender routing; the native/relay
transports + factory + dead-token prune. tsc + biome clean; no regressions.

Note: real APNs/FCM delivery needs your .p8 + Firebase service account
(config.push.apns/fcm) + a device build; the sending logic is verified
headlessly. Mobile registers native device tokens on push.native in a
follow-up PR.

Signed-off-by: Yash Datta <[email protected]>
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@saucam
saucam force-pushed the feat/push-native-transport branch from 1dddf46 to ce7122e Compare July 27, 2026 15:24
@saucam
saucam merged commit c43b2e2 into main Jul 27, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants