Skip to content

feat(push): add public APNs gateway#1770

Merged
tlongwell-block merged 18 commits into
mainfrom
wren/nip-pl-push-gateway
Jul 14, 2026
Merged

feat(push): add public APNs gateway#1770
tlongwell-block merged 18 commits into
mainfrom
wren/nip-pl-push-gateway

Conversation

@tlongwell-block

@tlongwell-block tlongwell-block commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR lands the infrastructure and protocol foundation for privacy-preserving iOS push notifications without sending message content through the push provider:

  • defines the normative NIP-PL Push Leases protocol (kind:30350) and its fixed wake-payload invariant;
  • adds strict, authenticated relay lease admission, author-only lease reads, effective-lease projection, generation fencing, and durable wake-outbox database primitives;
  • adds a standalone public APNs gateway with App Attest enrollment, opaque relay-bound endpoint grants, NIP-98 delivery authentication, encrypted APNs-token custody, PostgreSQL-backed authority, replay/quota fencing, bounded APNs behavior, retention, and bounded-cardinality telemetry;
  • adds a dedicated production image/build lane and Helm chart with migration job, least-privilege runtime role, private health/metrics, network policies, PDB, alerts, release contract, provenance attestation, and deployment runbook;
  • adds executable formal models and focused race, migration, protocol, and APNs-boundary tests.

Architecture and privacy boundary

The protocol object stored at the relay is an authorization, not a device token.

  1. The iOS app enrolls directly with the gateway using App Attest and gives the gateway its APNs token. The gateway encrypts that token at rest.
  2. The app delegates one installation endpoint to a specific relay public key. The gateway returns an opaque, AEAD-sealed endpoint grant containing no APNs token.
  3. The app places that grant and its notification filters inside a NIP-44-encrypted kind:30350 lease addressed to its relay.
  4. An authorized relay delivery request is NIP-98 signed and may select only the opaque grant, request ID, and bounded expiry.
  5. The gateway sends exactly one compiled-in APNs application body:
{"aps":{"alert":{"body":"Reconnect to your relay now"},"mutable-content":1}}

No relay-supplied message, sender, channel, event ID, count, or preview crosses the APNs boundary. Apple still observes the destination and timing/frequency of wakes; the gateway observes installation-to-relay delegation and delivery timing; the relay decrypts and stores the user's notification-interest filters.

The enrollment bootstrap has one documented platform limitation: Apple does not provide a cryptographic APNs-token-to-App-Attest-key binding, so token provenance is accepted from the successfully attested app instance.

Relay and migration behavior

  • Push lease acceptance is disabled unless BUZZ_PUSH_GATEWAY_DELIVERY_URL is configured.
  • kind:30350 is author-only on reads, excluded from search, and not revoked through NIP-09 deletion; revocation uses a higher-generation inactive lease.
  • Existing event kinds and ordinary channel/DM/auth/subscription behavior are unchanged.
  • Fresh databases retain main's FTS allowlist while excluding 30350.
  • Brownfield databases preserve their actual existing/operator-managed generated FTS expression and wrap only kind:30350 with a NULL search vector. The migration does not force the fresh-install allowlist onto existing installations.
  • Migration 0014 recreates the generated FTS column and GIN index, so operators should plan for the one-time table rewrite/lock on populated relays.

Gateway packaging and deployment

The gateway is intentionally not bundled into the normal relay image. It owns Apple credentials, App Attest configuration, independent grant/token AEAD keyrings, encrypted device tokens, and a separate PostgreSQL authority database, so it ships across a separate secret and network boundary:

  • image: ghcr.io/block/buzz-push-gateway;
  • build definition: Dockerfile.push-gateway;
  • multi-architecture CI publication for linux/amd64 and linux/arm64 on pushes to main, with main and sha-* tags plus build-provenance attestation;
  • chart: deploy/charts/buzz-push-gateway;
  • deployment guide: docs/push-gateway-deployment.md.

Production deployments should verify and pin the published immutable sha256: digest rather than relying on the mutable main tag.

Explicit non-goals / follow-up work

This PR does not claim end-to-end user-visible push delivery. It intentionally does not include:

  • the iOS enrollment/delegation client integration;
  • relay event matching and authorization rechecks;
  • the worker that enqueues and delivers wakes to the gateway;
  • rich notification rendering on the device.

Accordingly, merging this PR alone does not cause users to receive notifications and does not start background APNs traffic. The relay matcher/worker and iOS client are follow-up integration lanes.

Validation on the final PR head

Final PR head before squash merge: ce0de8e86ea3b4288ade416f84e9d33de183ee61 (merge of the feature head with then-current origin/main 458c7f9154fc9580a766117cad0e3e378ffe5ac0). GitHub squash-merged the PR as 1c006822e4484d68e33fce14f9139c2f70ce9d66.

Automated and database-backed tests

  • buzz-db: 79 passed, 86 ignored.
  • buzz-relay: 578 passed, 11 ignored, plus 1 auxiliary test passed.
  • buzz-push-gateway: 15 passed, 6 ignored.
  • All six ignored push-gateway PostgreSQL tests passed against a dedicated migrated database, covering concurrent admission, replay fencing, quota ceilings, retry release, readiness/permissions, and retention.
  • Fixed-payload formal model: 256 combinations hold; all 8 mutation categories caught.
  • Push hooks passed under the repository's Hermit Rust 1.95 toolchain.
  • PR CI passed, including both Build public push gateway architecture jobs, relay E2E, backend integration, Rust lint/unit/security, both server cross-compiles, desktop suites, chart render validation, DCO, and workflow security checks.

Live local regression testing

A merged-tree relay was run against a fresh dedicated PostgreSQL database. The ignored relay WebSocket E2E suite passed 32/33 tests, covering NIP-42 authentication, event send/store/subscription/filtering, connection close behavior, concurrent clients, ephemeral events, NIP-11, standard NIP-29 flows, private-channel permissions, membership guards, and live thread summaries.

The sole failure, test_unarchive_emits_member_added_notification, timed out waiting for kind:44100. It was rebuilt and reproduced identically against a fresh database on clean main at the exact merge parent 458c7f9154fc9580a766117cad0e3e378ffe5ac0, establishing it as a pre-existing main regression rather than a regression from this PR.

A separate manual two-principal CLI regression passed:

  • profile writes;
  • open, forum, and private channel creation;
  • top-level and threaded channel messages;
  • a second user posting and reading in an open channel;
  • DM creation and bidirectional DM send/read.

Live gateway exercise

The real merged buzz-push-gateway executable was started with Apple's correctly pinned App Attestation root, a locally generated APNs-format EC key, independent ephemeral AEAD keyrings, and a dedicated PostgreSQL database/runtime role.

Verified behavior:

  • --migrate-only created exactly the six gateway authority tables;
  • migration removed database/schema CREATE from the runtime role;
  • the runtime started under that least-privilege role;
  • private liveness and readiness returned 200;
  • readiness returned 503 when required table DML was revoked and recovered to 200 when restored;
  • health was absent from the public listener (404);
  • a valid challenge request returned 200 and persisted its challenge;
  • unknown/duplicate JSON was rejected with 400;
  • a request body over 8 KiB was rejected with 413;
  • metrics were available only on the private listener.

A genuine enrollment or APNs acceptance could not be produced locally without Apple-issued App Attest material, provider credentials, and a real device token. Those boundaries are covered by strict verification and transport-shim tests, including byte-exact assertions for the immutable APNs payload and headers; this description does not represent them as a live Apple E2E.

npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 added 12 commits July 11, 2026 20:25
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Define the Push Leases protocol and add the capability-gated public gateway, durable PostgreSQL authority, fixed APNs payload, deployment chart, supply-chain publication, observability, and conformance coverage. Keep end-to-end relay delivery explicitly gated on the follow-up client capability handoff, matcher, and worker.

Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Keep the formal noninterference constant byte-identical to the normative payload by including mutable-content.

Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
@tlongwell-block
tlongwell-block requested a review from a team as a code owner July 12, 2026 15:29
Comment thread .github/workflows/docker.yml Fixed
npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 added 6 commits July 12, 2026 14:42
Formalize the public APNs profile and stateful safety model, harden PostgreSQL migration and runtime-role isolation, and complete reviewable image and Helm publication contracts without claiming relay end-to-end delivery.

Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
* origin/main:
  fix(mobile): mirror app bar title padding when actions are empty (#1832)
  docs(mobile): backfill mobile changelog (#1835)

Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>

# Conflicts:
#	mobile/CHANGELOG.md
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
@tlongwell-block
tlongwell-block merged commit 1c00682 into main Jul 14, 2026
58 of 60 checks passed
@tlongwell-block
tlongwell-block deleted the wren/nip-pl-push-gateway branch July 14, 2026 15:17
brow pushed a commit that referenced this pull request Jul 14, 2026
…image-paste

* origin/main:
  refactor(clients): standardize product naming on community (#1858)
  fix(desktop): retain live rows after window exhaustion (#1810)
  Add private product feedback sidecar (#1857)
  fix(desktop): resolve Doctor install shell and command detection on Windows (#1854)
  fix(desktop): navigate to channel from inbox thread header (#1847)
  feat(desktop): surface needsRestart badge on live UI surfaces (#1853)
  fix(desktop): prevent menu-to-dialog UI lockups (#1839)
  feat(relay): add durable community archival (#1834)
  feat(desktop): add conversation-style DM composer (#1768)
  feat(push): add public APNs gateway (#1770)
  feat(relay): add atomic community ownership transfer (#1845)
  fix(desktop): stabilize agent identity restore (#1831)
  fix(buzz-acp): distinguish idle vs hard-cap timeout, dead-letter hard kills immediately (#1844)
  fix(observer): align scroll-anchor ids with transcript display-block keys (#1849)
  [codex] Add view activity label to agent popover (#1748)
  ci(desktop): surface flaky E2E tests instead of retry-masking them (#1838)
  fix(desktop): treat channel creator as member before 39002 provisioning (#1830)
  fix(mobile): mirror app bar title padding when actions are empty (#1832)

Co-authored-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co>
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