feat: native APNs/FCM push transport + shared push-core (P2 push, slice 3a) - #261
Merged
Conversation
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
akashjavelin
approved these changes
Jul 27, 2026
…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
force-pushed
the
feat/push-native-transport
branch
from
July 27, 2026 15:24
1dddf46 to
ce7122e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 onnode:crypto/node:http2/fetch— no daemon imports):ApnsClient— ES256 (.p8) provider JWT (cached), HTTP/2 to APNs, content-blindapspayload, dead-token classification (410 /BadDeviceToken/Unregistered). The HTTP/2connectis 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_FOUNDclassification.PushSender— routes by platform (iOS→APNs, Android→FCM) over injectable channels.Both delivery paths converge on this one sender:
transport: native): daemon →NativePushTransport→PushSender→ APNs/FCM.transport: relay): daemon →RelayPushTransport(an HTTPS POST, no creds) → (future) relay service → the samePushSender→ APNs/FCM.RelayPushTransport(the daemon half of the relay seam) ships now; the standalone relay service is a thin wrapper aroundpush-core, added when the multi-user/hosted path is needed.Surface
createPushTransportselectsexpo | native | relay | none.config.pushgainstransport: native/relay+apns/fcmcreds +relayUrl/relayToken(+ env overrides). Native holds.p8/Firebase creds locally; relay holds none.push.nativewhen transport is native/relay (vspushfor expo), so the client registers the right token type (native device token vs Expo token). Dead tokens are pruned viaStore.pruneDeadToken.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);PushSenderrouting; native/relay transports + factory + dead-token prune.tsc+biomeclean; 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
getDevicePushTokenAsync) when the daemon advertisespush.native(separatecodeoid-mobilePR — completes 3a end-to-end).src/relay) wrappingpush-core— when multi-user / hosted.🤖 Generated with Claude Code