Repository navigation
feat(notifications): send APNs push directly from the fork server, no Connect #15
Description
Activity
State check, 2026-10-04: the fork build (
com.nohat.t3code1.3.1) is installed on the iPad and the iPhone, and both hold unrevoked paired sessions on the production server (read from a copy of the state database; no live writes). The iPad is active on the tailnet. Not yet known: notification permission on each device, and whether the installed builds are Debug (sandbox APNs) or Release (production). The app reports its APNs environment at registration, so the server routes per device. Starting on the server pieces (plan steps 1 and 2).Server side done on
feat/direct-apns-push(8ff34f8a8c, off main; not merged, not deployed).Built (plan steps 1, 2, 4 for the server):
push.registerDevice/push.unregisterDeviceRPCs (orchestration:read scope, relay registration payload reused),push_devicestable (migration 055),ApnsClient(ES256 provider token via jose, HTTP/2 via node:http2, host and topic taken from the registering device),PushNotificationsservice, and a hook inAgentAwarenessRelayafter its existing guards. The relay path is unchanged when a relay is linked; direct push no longer requires a link.Verified: 11 new tests (routing by APNs environment, per-kind preferences, provider token reuse, 410 and BadDeviceToken drop the token while a 500 keeps it, re-registration updates in place, replay after restart stays quiet), a publisher test with no relay linked, 16 relay tests green, server typecheck clean. Mutation check: breaking dead-token handling fails the test. Live check against the sandbox host with a deliberately fake token returned
400 BadDeviceToken, which means the key, key id, team id, topic, signing and HTTP/2 transport are all accepted (a bad key returns 403).Configuration:
T3CODE_APNS_KEY_PATH,T3CODE_APNS_KEY_ID,T3CODE_APNS_TEAM_IDin the environment of the server process; the key is read in place and never copied. For the production job that is theEnvironmentVariablesblock oflocal.t3code.prod. Unset means direct push is off and nothing changes.Not done: mobile registration (plan step 3), so no device can receive a push yet; deploy and the end-to-end smoke test to the iPad (steps 5). Known limitation: if a relay is linked while the server is already running with direct push on, the relay misses its catch-up publish until each thread's state next changes.
Implementation complete through the app side; not yet verified on a device.
Branches (not merged, not deployed):
feat/direct-apns-push(off main) andfeat/direct-apns-push-prod(= fork/prod plus it, fast-forwardable; trial merge intofeat/ios-fork-buildis also clean).Added since the server comment: app registration over each paired server connection with retry and backoff (
directPushRegistration.ts), a Notifications row and switch in Settings when the build has no Connect, and fixes from two independent adversarial reviews. Confirmed defects fixed, each with a test that fails on revert: double alerts (a linked relay is now the only delivery, decided on the server), a first-seen approval dropped as a replay, registration that never retried, a valid token deleted during an Apple outage, revoked sessions still receiving pushes, one unreadable row silencing everyone, unvalidated token and bundle id, a transport that could hang or report a no-response stream as an answer, a relay catch-up skipped after a late link, migration 055 unusable on a database that ran the first draft.Verified on the merged fork/prod tree: server typecheck clean, 319 server tests, 69 mobile tests, contracts typecheck clean. Live check against Apple's sandbox with a fake token returned
400 BadDeviceToken(key, team, topic accepted).Known limits: pushes only while the server runs; removing a server in the app does not unregister the device (revoke the session); the app's retry loop has no automated test; an approval landing in the first second after a server restart can be swallowed as a replay; a session that stays connected past its expiry stops receiving pushes.
Remaining (the user's steps, tracked in the shared doc): land on fork/prod, set the three APNs variables on the production launch job, deploy and reload the job, rebuild and install the app on the iPad and iPhone, grant permission, and confirm a real push. This issue stays open as fixed-unverified until a push is seen on a device.
Problem
The fork app builds under its own team and bundle id, so it has no T3 Connect and gets no push. Standing up the upstream relay needs Cloudflare, a domain, Clerk, Axiom and a PlanetScale cluster (
prodstage is PS_20 + 2 replicas; no free tier). Decision 2026-10-04: send APNs directly from the fork server (mobile.md option 2). Cost: the existing Apple membership.Spike findings (2026-10-04, main be11d0d35e)
Server seam.
apps/server/src/relay/AgentAwarenessRelay.tspublishThreadUnsafealready projects thread shell toRelayAgentActivityState, dedupes, defers unconfirmed completions and suppresses historical terminal states. A local transport reuses all of that and replaces onlypublishState. Two early returns must be bypassed: thePUBLISH_AGENT_ACTIVITY_SECRETgate and the missing relay credential gate.Reusable from
infra/relay/src/agentActivity(depend only on Effect, noble and a small credentials type):apnsJwt.ts,ApnsProviderTokens.ts(45 minute JWT reuse),ApnsClient.tsrequest builders (alert push, sandbox vs production host,thread-id),agentActivityAlerts.ts(which transitions alert, per-kind preferences),agentActivityPayloads.ts(notificationForActivity, sanitizing). The relay's transition logic needs the previous aggregate, so the local transport keeps that baseline per device.Mobile seam is the hard part. Every registration path in
apps/mobile/src/features/agent-awareness/remoteRegistration.tsis gated onrelayTokenProvider, which onlyCloudAuthProvider.tsxsets (Clerk sign-in). With no sign-in nothing registers, so the native token is never sent anywhere. Native token read (getDevicePushTokenAsync), permission flow and the token-rotation listener need no sign-in and can be reused. Tap routing needs no change:notificationPayload.tsreadsdeepLinkorenvironmentIdandthreadIdfrom the payload, whichnotificationForActivityalready produces.Build identity is ready.
T3CODE_IOS_TEAM_IDandT3CODE_IOS_BUNDLE_IDare set in the gitignored.env;supportsAgentAwarenessPush()is true for a non-personal-team build. APNs key is stored under~/.t3/secrets(mode 600, never in git).Plan
packages/contracts: device registration (device id, token, bundle id, aps environment, preferences) over the paired-environment connection.Later, separate issues: Live Activities (extra tokens; payload code exists), and routing, seen and dismissal per
docs/fork/notifications.md(#12, #13).Effect on existing data
Adds one table. No existing rows change. No data leaves the machine except title and project text in the push payload to APNs (assumption recorded in notifications.md).
Assumptions