Skip to content

feat(notifications): send APNs push directly from the fork server, no Connect #15

Description

@nohat

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 (prod stage 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.ts publishThreadUnsafe already projects thread shell to RelayAgentActivityState, dedupes, defers unconfirmed completions and suppresses historical terminal states. A local transport reuses all of that and replaces only publishState. Two early returns must be bypassed: the PUBLISH_AGENT_ACTIVITY_SECRET gate 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.ts request 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.ts is gated on relayTokenProvider, which only CloudAuthProvider.tsx sets (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.ts reads deepLink or environmentId and threadId from the payload, which notificationForActivity already produces.

Build identity is ready. T3CODE_IOS_TEAM_ID and T3CODE_IOS_BUNDLE_ID are 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

  1. Contract in packages/contracts: device registration (device id, token, bundle id, aps environment, preferences) over the paired-environment connection.
  2. Server: device registry table plus migration, APNs sender service (secrets by name, 410 drops the token), local transport beside the relay publisher.
  3. Mobile: second registration path that talks to the paired server; relay path untouched.
  4. Focused tests, including one that fails if an upstream merge drops the transport.
  5. Smoke test: one sandbox push to the iPad, tap opens the thread.

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

Activity

  1. nohat commented on Oct 4, 2026

    @nohat
    OwnerAuthor

    State check, 2026-10-04: the fork build (com.nohat.t3code 1.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).

  2. nohat commented on Oct 4, 2026

    @nohat
    OwnerAuthor

    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.unregisterDevice RPCs (orchestration:read scope, relay registration payload reused), push_devices table (migration 055), ApnsClient (ES256 provider token via jose, HTTP/2 via node:http2, host and topic taken from the registering device), PushNotifications service, and a hook in AgentAwarenessRelay after 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_ID in the environment of the server process; the key is read in place and never copied. For the production job that is the EnvironmentVariables block of local.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.

  3. nohat commented on Oct 4, 2026

    @nohat
    OwnerAuthor

    Implementation complete through the app side; not yet verified on a device.

    Branches (not merged, not deployed): feat/direct-apns-push (off main) and feat/direct-apns-push-prod (= fork/prod plus it, fast-forwardable; trial merge into feat/ios-fork-build is 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions