# Silo Push Relay — Implementation Plan **Date:** 2026-06-13 **Status:** Draft (implementation-grade, phased) **Service:** `silo-push-relay` (provisional repo name; the relay code lives in a **separate repository**) **Provisional hostname:** `relay.silo.app` **Stack:** Go 1.25+, PostgreSQL, Redis **Source documents (read before executing any phase):** - [`./00-relay-spec.md`](./00-relay-spec.md) — the engineering spec this plan implements. Section numbers below (e.g. "spec §6.4") refer to it. - [`./02-apns-fcm-2026-reference.md`](./02-apns-fcm-2026-reference.md) — 2026-current Apple/Google/Go reference (cited as "reference §N"). - [`../02-apns-relay.md`](../02-apns-relay.md) and [`../03-fcm-relay.md`](../03-fcm-relay.md) — the **authoritative external wire contract** (the `02`/`03` request/response shapes). The relay must honor these byte-for-byte. > **Commands assume the repository root is the cwd.** This document contains no local absolute filesystem paths or transient worktree IDs; all repo references are repository-relative. > **Repo boundary.** This plan describes a **separate repository** (`silo-push-relay`). The design docs are authored here in `silo-server` purely for co-location with the `02`/`03` contracts. The relay **code** lives elsewhere. The one deliberate coupling is the shared `pushwire` payload-builder package, mirrored into both repos (see §3 and Phase 3/4). --- ## 1. Overview ### 1.1 What is being built A small, **stateless-on-the-request-path** Go HTTP service that holds the official Silo Apple (`.p8` APNs auth key) and Google (Firebase service-account JSON) push credentials, accepts authenticated **content-free** push requests from opted-in self-hosted Silo servers on two endpoints, builds a fixed generic APNs/FCM payload, forwards it upstream, and maps the upstream result back to a narrow caller-facing response. The two send endpoints and their contract are fixed by `02`/`03` and reproduced in spec §5: | Endpoint | Purpose | Authoritative contract | |---|---|---| | `POST /v1/apple/send` | Forward one opaque push to APNs for one device token | `../02-apns-relay.md`, spec §5.1 | | `POST /v1/fcm/send` | Forward one opaque data-only push to FCM for one device token | `../03-fcm-relay.md`, spec §5.2 | | `GET /healthz` | Liveness (public) | spec §5.3 | | `GET /readyz` | Readiness incl. PG/Redis core deps + per-provider credential health (internal) | spec §5.4 | | `GET /metrics` | Prometheus exposition (internal) | endpoint spec §5.4; metric catalog §13.2 | Administration is a CLI (`relayctl`) that writes directly to the relay's PostgreSQL — **there is no public admin HTTP API** in v1 (spec §5.6). ### 1.2 The v1 cut line **In scope (v1):** - `POST /v1/apple/send` and `POST /v1/fcm/send` exactly per `02`/`03`. - Bearer auth (`rk_` keys), per-account allowlists, Redis token-bucket rate limiting, Redis idempotency. - APNs token-based (ES256 `.p8`) client with cached JWT; FCM HTTP v1 client with cached OAuth2 token. - PostgreSQL storage of accounts/keys/allowlists/redacted op-logs only; Goose-style migrations. - `relayctl` admin CLI; structured redacted logs; Prometheus metrics; `/healthz` + `/readyz`; graceful shutdown. - Single region, multiple stateless replicas behind a TLS load balancer. **Explicitly out of scope (v1)** — from spec §2.2 / NG1–NG7: - Token→user aliases, device subscriptions, any user/profile identity (stateless v1). - Arbitrary APNs/FCM JSON, custom titles/bodies, topic/condition broadcasts, badge-by-default. - Delivery receipts, analytics, open-tracking. - APNs broadcast / Live Activity channels (`/4/broadcasts/...`). - Web Push, HMS, ADM, or any non-APNs/non-FCM transport. - A public self-service signup / billing surface. - The `custom_apns` / `custom_fcm` direct paths (those run inside `silo-server`). - Multi-region (the request path is stateless, but Redis/Postgres regional strategy is deferred). ### 1.3 The real long pole: provider account provisioning **Apple/Firebase account provisioning is the critical-path dependency, not the code.** Obtaining the official Apple Developer team, generating a `.p8` APNs auth key (Key ID + Team ID), registering per-platform bundle topics, standing up the Firebase project(s), and minting a service-account JSON can take days-to-weeks of organizational/approval lead time and is **fully decoupled from coding**. > **Action: start the §7 provisioning checklist on day 1, in parallel with Phase 0.** Phases 0–2 (scaffold, storage, auth/rate-limit/idempotency) need **no** live provider credentials and can complete entirely against fakes and contract tests. Only Phase 3 (APNs integration tests against sandbox) and Phase 4 (FCM `validateOnly`) are gated on credentials. If provisioning slips, the code still reaches "everything but live upstream" without blocking. ```mermaid flowchart LR subgraph Track A — Code P0[Phase 0\nscaffold] --> P1[Phase 1\nstorage] --> P2[Phase 2\nauth/RL/idem] P2 --> P3[Phase 3\nAPNs] P2 --> P4[Phase 4\nFCM] P3 --> P5[Phase 5\nobservability] P4 --> P5 P5 --> P6[Phase 6\nharden+deploy] end subgraph Track B — Provisioning (parallel, long pole) A1[Apple team + .p8 + topics] G1[Firebase project + SA JSON + package] end A1 -. gates .-> P3 G1 -. gates .-> P4 A1 -. gates .-> P6 G1 -. gates .-> P6 ``` --- ## 2. Repository Bootstrap ### 2.1 Go module path ``` module github.com/silo-app/silo-push-relay ``` Target **Go 1.25+** (required by `jackc/pgx v5.10` and `firebase.google.com/go/v4 v4.20`; reference §3 version-targeting note). Pin in `go.mod` with `go 1.25`. ### 2.2 Directory layout ``` silo-push-relay/ ├── cmd/ │ ├── relay/ # main HTTP service entrypoint (wires config → stores → clients → server) │ │ └── main.go │ └── relayctl/ # admin CLI (account/key/allowlist/logs/ping-upstream); writes directly to PG │ └── main.go ├── internal/ │ ├── config/ # env + secret-manager config loading, validation, defaults │ ├── httpapi/ # router, handlers, middleware (auth, rate limit, idempotency, redaction), error bodies │ ├── apns/ # APNs upstream client: ES256 JWT lifecycle, HTTP/2 pool, reason→error mapping │ ├── fcm/ # FCM upstream client: OAuth2 token source, HTTP/2 client, status→error mapping │ ├── accounts/ # data-access for accounts/keys/allowlists (shared by httpapi + relayctl) │ ├── ratelimit/ # Redis Lua token-bucket limiter (per-account + coarse per-token) │ ├── idempotency/ # Redis SET NX lock / replay / 409 / 422 store │ ├── pushwire/ # SHARED payload builders (APNs + FCM payload/header construction) — mirrored into silo-server │ ├── oplog/ # redacted op-log writer (PG) + token hashing helper │ ├── observability/ # slog JSON handler + redaction, Prometheus registry/metrics, /healthz /readyz /metrics │ └── store/ # pgxpool + go-redis bootstrapping, health pings, migration runner hook ├── migrations/ │ └── sql/ # Goose-style timestamped SQL migrations (see §2.6) ├── deploy/ │ ├── Dockerfile │ └── (k8s / compose manifests as applicable) ├── Makefile ├── go.mod ├── go.sum ├── .golangci.yml ├── .github/workflows/ci.yml # or equivalent CI config └── README.md ``` Package ownership mirrors the Silo convention (CLAUDE.md "keep new code in the package that owns the behavior"): no catch-all `utils` package; shared payload logic lives in `pushwire`; shared data-access lives in `accounts`/`store` so `relayctl` and `httpapi` share schema and constraints (spec §5.6 "The CLI shares the relay's data-access package"). ### 2.3 The shared `pushwire` package (maintainability win) `internal/pushwire` is the single source of truth for **how an opaque request becomes an upstream payload + headers** — the APNs `aps`/`silo` dictionaries and `apns-*` headers (spec §5.1), and the FCM data-only `message` body + `android` config (spec §5.2). It is intentionally **dependency-light** (pure Go structs + JSON, no network) so it can be **mirrored into `silo-server`** under its `custom_apns` / `custom_fcm` paths, where the self-hosted server builds the *same* payloads when an admin uses their own credentials. One package, two repos: the relay and the direct-credential path can never drift on payload shape. (See §3 dependency table and Phase 3/4.) > **Mirroring mechanism & sync runbook (drift/ownership hazard — make it explicit).** Keep `pushwire` import-cycle-free and free of relay-only types (no DB, no config). **Designate one repo as the canonical source of truth** for `pushwire` (the relay repo `silo-push-relay`, since it owns the upstream payload contract). The copy in `silo-server` is a **checked-in generated artifact**, not a hand-edited file: commit a **content checksum** of the canonical package alongside it, and have **both** repos' CI verify their local copy against that checksum (a copied package in repo A cannot fail repo B's CI on its own, so a passive golden-file test in one repo is insufficient). Bump a **versioned payload-schema constant** on any payload change so a mismatch is unambiguous. Add a golden-file + closed-allowlist-key contract test on both sides (Phase 3/4 / §5.2). Document the ownership/sync runbook (who regenerates, in what order, how the checksum is bumped) so a payload change cannot silently diverge until the other repo happens to update. The preferred end state is a small shared module both repos import rather than a manual copy. ### 2.4 Config strategy Twelve-factor-ish, but credentials come from a **secret manager**, not env vars (spec §11, reference §4.5 ranking: secret-manager > mounted file > env var > in-image). `internal/config` resolves, in order of preference per secret: | Config | Source (preferred → fallback) | Notes | |---|---|---| | APNs `.p8` private key, Key ID, Team ID | secret manager → mounted file | never env var, never in image | | FCM SA JSON **or** Workload Identity binding | GCP attached SA / Workload Identity → secret manager JSON | off-GCP uses JSON; on-GCP keyless (reference §4.5) | | API-key HMAC pepper | secret manager | rotation is heavier (spec open Q#6) | | Postgres DSN, Redis URL | secret manager → env | pool-tuned | | Non-secret tuning (timeouts, rate defaults, listen addrs, log level, environment label) | env vars / flags | safe to log | `config.Load(ctx)` returns a validated, fully-populated struct or a hard error; the process refuses to start with a missing/invalid credential. Provide a `config.Validate()` that `cmd/relay` calls before opening any listener. Non-secret defaults (rate limits from spec §9.2, timeouts from spec §11) live as constants with env overrides. ### 2.5 Makefile (mirror Silo's Makefile-driven workflow) `make` targets, modeled on `silo-server`'s Makefile (CLAUDE.md §Build): ```make make build # go build ./cmd/relay ./cmd/relayctl make run # run cmd/relay locally against docker-compose PG+Redis make test # go test ./... (unit + contract; no live providers) make test-integration # go test -tags=integration ./... (APNs sandbox + FCM validateOnly; needs creds) make lint # golangci-lint run make fmt # gofmt -w + goimports make migrate-create NAME=add_thing # timestamped Goose migration scaffold make migrate-up # apply migrations make migrate-status # list migration state make relayctl ARGS="account list" # build+run the admin CLI make docker # build deploy/Dockerfile make loadtest # run the k6/vegeta burst scenario (Phase 6) ``` `make migrate-create NAME=...` must produce a **timestamped** filename (CLAUDE.md: "New migrations must use timestamped filenames created with `make migrate-create`; do not run `goose fix` or create paired `.up.sql`/`.down.sql` files"). The relay uses single-file Goose SQL migrations with `-- +goose Up` / `-- +goose Down`. ### 2.6 Migrations (Goose-style) `migrations/sql/_.sql`, run by a Goose runner wired in `internal/store` and invokable via `make migrate-up` and on `cmd/relay` startup (behind a flag). The v1 schema is exactly spec §8.1 (`relay_accounts`, `relay_api_keys`, `relay_apns_allowlist`, `relay_fcm_allowlist`, `relay_op_logs`). IDs are ULIDs stored as `text`; timestamps are `timestamptz` (Silo convention). ### 2.7 CI outline CI (GitHub Actions or equivalent) runs on every push/PR: ```yaml jobs: build-test: steps: - setup-go 1.25 - make lint # golangci-lint - make build - make test # unit + contract tests (no provider creds) - upload coverage migrate-check: services: [postgres, redis] steps: - make migrate-up # migrations apply cleanly on a fresh DB - make migrate-status integration: # gated: only when provider creds are available (Phase 3+) if: secrets.APNS_P8 != '' && secrets.FCM_SA_JSON != '' services: [postgres, redis] steps: - make test-integration # APNs sandbox + FCM validateOnly contract tests ``` The `integration` job is **conditional on secrets being present** so Phases 0–2 are green long before any credential exists. --- ## 3. Dependency Choices All versions are from the reference §3.7 pinned list. Pin exact versions in `go.mod`; bump deliberately. | Concern | Library | Version | One-line rationale | |---|---|---|---| | APNs client | `github.com/sideshow/apns2` **or** hand-rolled | `v0.25.0` / `x/net/http2 v0.56.0` + `golang-jwt/jwt/v5 v5.3.1` | De-facto Go APNs HTTP/2 client (auto JWT + conn reuse); **decide vs. hand-rolled before Phase 3** because `apns2` has had no release since Oct 2024 (bus-factor; reference §3.1, spec open Q#3). | | FCM auth | `golang.org/x/oauth2/google` | `v0.36.0` | `CredentialsFromJSONWithType(..., google.ServiceAccount, firebase.messaging scope)` gives an auto-caching/-refreshing `TokenSource`; lighter than the Admin SDK and gives direct control of conn reuse + error classification (reference §3.2 — preferred for a fixed payload). | | FCM (alt, batteries-included) | `firebase.google.com/go/v4/messaging` | `v4.20.0` | Acceptable alternative with `IsUnregistered`/`IsQuotaExceeded` helpers; **pick exactly one** FCM path, not both (reference §3.2). Plan defaults to the `x/oauth2` path. | | JWT (ES256 sign for APNs; RS256 for hand-rolled FCM grant) | `github.com/golang-jwt/jwt/v5` | `v5.3.1` | Maintained JWT lib; ES256 for APNs, RS256 if hand-rolling the FCM token exchange (reference §3.1). | | HTTP/2 transport tuning | `golang.org/x/net/http2` | `v0.56.0` | `ReadIdleTimeout`+`PingTimeout` to reap half-open upstream conns — "the single most important tuning for a long-lived relay" (reference §3.3). | | Postgres | `github.com/jackc/pgx/v5` + `pgxpool` | `v5.10.0` | Recommended driver/pool; `pgxpool.New` is concurrency-safe + health-checked (reference §3.4). | | Redis | `github.com/redis/go-redis/v9` | `v9.20.1` | Context-first client with pooling; atomic Lua for rate-limit, `SET NX` for idempotency (reference §3.4). | | Router | stdlib `net/http` (Go 1.22+ `http.ServeMux` patterns) | stdlib | Two routes + health/metrics need no framework; stdlib `ServeMux` supports method+path patterns and gets HTTP/2 over TLS automatically. Avoids a dependency; middleware is plain `http.Handler` wrapping. | | Structured logging | `log/slog` | stdlib (Go 1.21+) | `slog.NewJSONHandler` for production; redaction middleware so secrets can't leak (reference §3.5, spec §13.1). | | Metrics | `github.com/prometheus/client_golang` | `v1.23.2` | Custom registry + `promhttp.HandlerFor` for `/metrics`; per-provider latency histograms + outcome counters (reference §3.5, spec §13.2). | | Per-instance rate (outbound pacing helper) | `golang.org/x/time/rate` | current | `Reservation.Delay()` to compute relative `Retry-After`; used for in-process pacing, **not** the cross-replica cap (that is Redis — reference §4.2). | | ULID generation | `github.com/oklog/ulid/v2` (or equivalent) | current | Generate `request_id` and entity IDs as ULIDs (Silo convention; spec §8.1). | **Decision to make before Phase 3 (spec open Q#3):** adopt `sideshow/apns2` or hand-roll the APNs client. Recommendation: prototype both in Phase 3 behind the `internal/apns` interface; pick `apns2` if its recent commit/issue activity is acceptable, else hand-roll on `x/net/http2` + `golang-jwt/jwt/v5` mirroring the verified `apns2` internals (reference §3.1: `HostProduction`/`HostDevelopment`, `TokenTimeout=3000`s, `ReadIdleTimeout=15s`). Either way the rest of the service depends only on the `internal/apns` interface, not the concrete client. **`pushwire` maintainability note (reference + spec §5).** The shared `pushwire` package builds the exact APNs/FCM payloads and headers. Because `silo-server`'s `custom_apns`/`custom_fcm` paths must produce identical payloads, `pushwire` is mirrored into `silo-server` with **one canonical source repo** (the relay), a **committed checksum verified in CI on both sides**, and a **closed-allowlist-key** test so an added `data`/payload key fails CI regardless of golden-file regeneration order (§2.3 sync runbook, §5.2). This is also a **supply-chain control for the content-free guarantee**: the payload-builder that enforces "data-only, no `notification` block, fixed key set" is enforced in two repos that must not drift. Payload shape is defined once. --- ## 4. Phased Tasks Effort sizes: **S** ≈ 1 day, **M** ≈ 2–3 days, **L** ≈ 4–5 days (one engineer). Sizes are rough. ### Phase 0 — Scaffold, config, health, CI (size: M) **Goal.** A buildable, lintable, testable repo that boots `cmd/relay`, serves `/healthz`, loads config, and is green in CI — with **no** provider dependency. **Files to create/modify.** - `go.mod`, `go.sum`, `.golangci.yml`, `Makefile`, `README.md`, `.github/workflows/ci.yml`, `deploy/Dockerfile`. - `cmd/relay/main.go` — load config, build slog JSON logger, start an `http.Server` with explicit timeouts and graceful shutdown, mount `/healthz`. - `internal/config/config.go` — `Load(ctx)`, `Validate()`, defaults; secret-manager interface stub (real wiring in Phase 6). - `internal/observability/logger.go` — `slog.NewJSONHandler` setup. - `internal/httpapi/server.go`, `internal/httpapi/router.go`, `internal/httpapi/health.go` — router, `/healthz`. - `internal/httpapi/errors.go` — the standard error body (spec §5.5) and a `writeError(w, status, code, msg, requestID)` helper. **Implementation notes.** - Server timeouts are **mandatory** (zero = no timeout): `ReadHeaderTimeout`, `ReadTimeout`, `WriteTimeout`, `IdleTimeout`, `MaxHeaderBytes` (reference §3.3, spec §11). Body size cap ~16 KiB. - Graceful shutdown: on SIGTERM/SIGINT call `srv.Shutdown(ctx)` with a bounded context (spec §14.2). - `/healthz` returns `200 {"status":"ok"}`, unauthenticated, no dependency checks (spec §5.3). - Error body shape is fixed (spec §5.5): ```json { "error": { "code": "string_code", "message": "human readable", "request_id": "01JRELAY..." } } ``` Every request gets a ULID `request_id`, echoed as the `X-Request-Id` response header. **Tests.** - Unit: `config.Validate` rejects missing required fields; `writeError` emits exact JSON + `X-Request-Id`. - Unit: `/healthz` returns 200 with the exact body. - CI: `make lint`, `make build`, `make test` green; `migrate-check` job present (no migrations yet → no-op pass). **Acceptance criteria.** - [ ] `make build` produces `relay` and `relayctl` binaries. - [ ] `make lint` and `make test` pass in CI with zero provider credentials. - [ ] `cmd/relay` boots, serves `/healthz` 200, and shuts down gracefully on SIGTERM. - [ ] Every response carries `X-Request-Id`; errors use the §5.5 shape. --- ### Phase 1 — Storage, migrations, `relayctl` admin CLI (size: L) **Goal.** The full PostgreSQL schema, a migration runner, the shared data-access package, and a working `relayctl` that can provision accounts/keys/allowlists and read op-logs — all without touching providers. **Files to create/modify.** - `migrations/sql/_init_relay_schema.sql` — exact schema from spec §8.1: `relay_accounts`, `relay_api_keys`, `relay_apns_allowlist`, `relay_fcm_allowlist`, `relay_op_logs`, plus indexes (`relay_api_keys_prefix_uidx`, `relay_api_keys_account_idx`, `relay_op_logs_account_time_idx`, `relay_op_logs_time_idx`). - `internal/store/postgres.go` — `pgxpool.New(ctx, dsn)`, `Ping`, migration runner hook. - `internal/store/redis.go` — `go-redis` client + `PING` (used Phase 2). - `internal/accounts/` — data-access: `CreateAccount`, `ListAccounts`, `DisableAccount`, `IssueKey`, `ListKeys`, `RevokeKey`, `LookupKeyByPrefix`, `SetApnsAllowlist`, `SetFcmAllowlist`, `GetAllowlists`. Used by both `httpapi` and `relayctl`. - `internal/accounts/keys.go` — key generation + hashing (see notes). - `internal/oplog/oplog.go` — `Write(ctx, entry)` to `relay_op_logs`; `TokenHash(token) string` (SHA-256 hex). - `cmd/relayctl/main.go` + subcommands per spec §5.6 table. **Implementation notes.** - **API-key format & hashing** (spec §8.2, reference §4.1): - Format `rk__`; env ∈ {`live`,`test`}. Random secret = 32 bytes from `crypto/rand`. - **Prefix** = `rk__`, non-secret, stored cleartext, uniquely indexed. - Store `key_hash = HMAC-SHA256(pepper, secret)` (32 bytes). Never store plaintext. Fast keyed hash is correct for 256-bit random keys (do **not** use argon2id here). - `relayctl key issue` prints the full `rk_…` **exactly once** to stdout; only hash+prefix persist (spec §8.3). - `relayctl` connects directly to Postgres with a privileged DSN held by operators (no public admin API; spec §5.6). Each mutating command writes an `admin.*` op-log with operator identity (OS user / `--actor`) and never the secret token (spec §5.6). - Allowlist commands replace the set atomically (UPSERT/DELETE in one tx) for `relay_apns_allowlist` / `relay_fcm_allowlist`. - Official allowlist config values to seed in examples/tests (allowlist config, **not** the contract — spec §8.4): APNs topics `com.continuum.app.ios`, `com.continuum.app.tvos`, `com.continuum.app.macos`; FCM project `continuum-prod-android`, package `com.continuum.app.android`. **`relayctl` command surface (spec §5.6 / §13.3).** | Command | Effect | |---|---| | `relayctl account create --name