Files
nucleic/docs/CLOUD_INFRA.md
T
abkslmandnucleic 17f4056eae Adjust Notification Content
Nucleic-Session: 4C62E994-D013-454A-B051-7D91EECFB69A
Co-authored-by: Nucleic <[email protected]>
2026-07-06 14:39:14 -07:00

31 KiB
Raw Blame History

Nucleic Cloud Infrastructure Plan (Cloudflare)

Status (2026-06-26): Phase A shipped (DAI heartbeat both platforms + Sparkle→R2). Phase B/C cloud infrastructure is built and tested in cloud/nucleic-edge/ (relay Worker + Room Durable Object + APNS sender + two-tier relay tokens; typecheck + unit tests + dry-run green), and the iOS push-registration client is in place (PushRegistrar, entitlements, token wired into Hello.pushToken). Remaining: account provisioning (R2 bucket/custom domain, KV namespaces, secrets, APNS .p8 + deploy); the iOS Push Notifications capability (manual Xcode step) + an xcodebuild pass; and the host-side relay client — transport selection (LAN↔relay) and relayToken issuance at pairing — which is gated on LAN sync (M4). See Suggested build phasing.

Context

Nucleic is a local-first macOS host app (orchestrates Claude Code / Codex / Grok agents in git worktrees) with an iOS companion (NucleicRemote, xyz.blakeslee.nucleic.remote, Team L7UDTQ6F5W) that answers approval prompts. Today there is no cloud infrastructure:

  • Sparkle auto-update (v2.9.3, channels dev/canary/beta/rc/stable) is wired to host appcasts on GitHub Releases (NUCLEIC_FEED_BASE, scripts/package-app.sh:137, scripts/generate-appcast.sh:32). The signing key isn't generated yet and there's no release CI — nothing has shipped, so moving the host is a clean cutover with no stranded feeds.
  • APNS is designed but unbuilt (roadmap M5). The wire protocol already reserves Hello.pushToken: String? (Sources/NucleicProtocol/Sync/WireMessages.swift:49, threaded through SyncClient). docs/SYNC_PROTOCOL.md §3.2 / §10.2 specify a Worker + Durable Object relay that is E2EE-opaque, and lean toward push being delegated to the relay (no remote push until the relay exists).
  • Telemetry: none. No install ID, no analytics. Positioned as privacy-respecting.

Goal: stand up Cloudflare infrastructure for (1) Sparkle update hosting, (2) the relay + APNS approval-push path, and (3) a privacy-respecting DAI (daily active installs) heartbeat — all under *.nucleic.blakeslee.xyz, Workers-first. The relay additionally yields a true daily-active-user count (§2.2), since it carries a per-account identity the heartbeat doesn't.

Decisions

Decision Choice
Zone / naming blakeslee.xyz (Cloudflare), pattern *.nucleic.blakeslee.xyz
Sparkle hosting Migrate to R2 (custom domain, CDN-cached); GitHub release kept as documented fallback origin
Headline metric DAI — daily active installs (anonymous, keyed by a random per-install ID). It counts installs, not users: one person on a Mac + an iPhone is two. A true daily-active-user count comes from the relay, which carries a per-account identity (§2.2)
DAI posture Opt-out (on by default), anonymous random install ID, no PII, Settings toggle to disable
DAI clients Both macOS and iOS, differentiated by a first-class platform dimension
DAI store Analytics Engine to start (privacy-aligned, auto-expiring); D1 only if exact retention/cohorts are later wanted
relayToken Two-tier: long-lived membership credential (issued at pairing) + JIT short-lived connection token; host-minted, hashed in KV, revoked on unpair (see §2.1)

Topology

Hostname Backed by Purpose
updates.nucleic.blakeslee.xyz R2 bucket (custom domain) — appcast/DMG paths; tiny nucleic-updates Worker on the /<channel>/latest paths only (§1.1) Sparkle appcast XML + DMGs, CDN-cached; latest redirect aliases
api.nucleic.blakeslee.xyz Worker nucleic-edge POST /v1/heartbeat (DAI); internal APNS sender module
relay.nucleic.blakeslee.xyz same Worker nucleic-edge + Durable Object WebSocket relay, room routing, presence, push trigger, relay DAU (§2.2)

One Worker project (nucleic-edge) with path-based routing keeps the APNS-signing code shared between the heartbeat path and the relay's push trigger, and keeps ops to a single deploy. It can be split into nucleic-api + nucleic-relay later if needed. R2 is served directly via its custom domain — no Worker on the bulk update path (appcasts + DMGs stay simple, cheap, fully cacheable). The only exception is a surgical nucleic-updates Worker scoped to the four /<channel>/latest paths (§1.1): Worker routes take precedence over the R2 custom domain on the same host, so those paths redirect while everything else still streams straight from R2.

Suggested repo location for the Worker (when built): cloud/nucleic-edge/ (wrangler project), or a sibling repo if you prefer to decouple deploy cadence from the Swift app.


1. Sparkle update hosting on R2 (independent — buildable now)

What moves: only the host. EdDSA signing is unchanged (signatures live inside the appcast XML; SUPublicEDKey in the app is the verifier). generate_appcast rewrites enclosure URLs via --download-url-prefix "$FEED_BASE/$CHANNEL/" (scripts/generate-appcast.sh:65), so pointing NUCLEIC_FEED_BASE at R2 is sufficient for both the baked-in SUFeedURL and the (channel-foldered) enclosure URLs.

Object layout. DMGs are foldered by channel for tidy, predictable download links; the appcast feeds stay at the bucket root (where each build's permanent SUFeedURL points them):

appcast-beta.xml                          ← Sparkle feed (root)   SUFeedURL = …/appcast-beta.xml
beta/Nucleic-Beta-0.1.0.828.dmg           ← download              …/beta/Nucleic-Beta-0.1.0.828.dmg
beta/latest                               ← 302 → newest beta DMG (cloud/nucleic-updates Worker, §1.1)

Provisioning

  1. Create R2 bucket nucleic-updates; attach custom domain updates.nucleic.blakeslee.xyz (R2 auto-creates the CNAME; objects become public + CDN-cached at that host).
  2. Cache policy: short TTL on appcast-*.xml (e.g. Cache-Control: max-age=300) so new releases propagate quickly; long/immutable cache on */*.dmg (content-addressed by version). Set via object metadata on upload (scripts/upload-r2.sh does this) and/or a Cache Rule.

Code changes (done)

  • Default NUCLEIC_FEED_BASEhttps://updates.nucleic.blakeslee.xyz in scripts/package-app.sh and scripts/generate-appcast.sh (env still overridable).
  • scripts/upload-r2.sh pushes the channel's DMG(s) to the <channel>/ key prefix and the appcast-<channel>.xml to the root (wrangler r2 object put); scripts/release-macos.sh calls it after appcast generation.
  • Docs: BUILD.md "Distribution & auto-update" and signing/README.md (host is R2, GitHub = fallback).

Caveat: SUFeedURL is baked into each build's Info.plist permanently — the chosen domain and feed path must be stable forever. The feeds therefore stay at the root as appcast-<channel>.xml (only the DMG downloads are channel-foldered); updates.nucleic.blakeslee.xyz (not a *.workers.dev) satisfies the domain-stability requirement.

1.1 /<channel>/latest redirect aliases (built — cloud/nucleic-updates/)

DMGs are content-addressed by version (beta/Nucleic-Beta-0.2.0.829.dmg), so a bare "give me the newest beta" link has nowhere stable to point. R2 static serving can't redirect, so a tiny dedicated Worker (cloud/nucleic-updates/) provides four stable aliases for linking elsewhere (the website "Download" button, docs, chat):

GET …/canary/latest  → 302 → …/canary/Nucleic-Canary-<ver>.dmg
GET …/beta/latest    → 302 → …/beta/Nucleic-Beta-<ver>.dmg
GET …/rc/latest      → 302 → …/rc/Nucleic-RC-<ver>.dmg
GET …/stable/latest  → 302 → …/stable/Nucleic-<ver>.dmg
  • Scoped routes, R2 untouched. The Worker has path routes on /<channel>/latest only. Worker routes win over the R2 custom domain on the same host, so just those four paths hit the Worker; the root appcast-*.xml and the <channel>/*.dmg objects keep streaming straight from R2 — the "no Worker on the bulk update path" decision holds.
  • Self-maintaining. On each hit the Worker reads appcast-<channel>.xml from the bound R2 bucket and redirects to the enclosure with the highest sparkle:version (the monotonic build number) — the enclosure URL already carries the <channel>/ folder. Same source the in-app updater uses, so the alias and the updater never diverge, and a release needs no config change — publish, and latest follows.
  • 302, not 301. The target moves every release, so it must never be cached as permanent; the redirect carries a 5-min Cache-Control matching the appcast TTL.

Deploy: cd cloud/nucleic-updates && npm run deploy (needs the blakeslee.xyz zone + the nucleic-updates bucket from §1; no secrets/KV). Details in cloud/nucleic-updates/README.md.


2. Relay: Worker + Durable Object (M5 — after LAN sync is solid)

Implements docs/SYNC_PROTOCOL.md §3.2. The relay only ever sees opaque Noise frames.

  • Worker (relay.nucleic.blakeslee.xyz): on Upgrade: websocket, validate relayToken, derive roomID (from the pairing group / host identity), get the DO stub (env.ROOMS.idFromName(roomID)), and hand the socket to the DO.
  • Durable Object Room: holds the host + client sockets, forwards Frames between them, tracks presence. Use the WebSocket Hibernation API (ctx.acceptWebSocket, webSocketMessage/webSocketClose) so idle rooms evict from memory — important for a relay that's mostly idle. DO sees only roomID, frame sizes, timing.
  • Admission — relayToken: gates who may occupy a room, not what they can read (content is Noise-E2EE regardless). Full lifecycle in §2.1.
  • Transport selection (app side): client tries LAN (Bonjour) first, falls back to relay; host registers with the relay whenever reachable. SecureChannel + seq cursors already make mid-stream transport migration seamless.

The relay is where APNS plugs in (§3): when the host posts an approval.request for a client whose socket is absent, the Room triggers a push to wake that phone.

2.1 relayToken lifecycle

The token is an admission credential for the routing layer — it stops strangers from squatting a room, exhausting the DO's socket budget, or spoofing presence. It grants no ability to read traffic: every frame is Noise-encrypted end-to-end and the relay only ever sees ciphertext. Two tiers keep leaked-token blast radius small:

Tier What it is TTL Where it lives
Membership credential "this device belongs to room R, with scope S" long (e.g. 90d, rotatable) minted by host at pairing; client keeps it in Keychain; hash in KV NUCLEIC_RELAY_TOKENS
Connection token a just-in-time ticket to open one WebSocket short (e.g. 60s5m, single-use) derived from the membership credential right before connecting; hash in KV with native TTL
  • Issuance. The host is the root of trust (the phone already pins the host's static key at pairing — SYNC_PROTOCOL.md §4.1). The host mints a high-entropy (≥128-bit) random membership credential per paired device, bound to (roomID, deviceID, scope, exp), and stores only its hash in KV. roomID is derived from the host identity (e.g. hash of the host static public key), so it's stable and agreed without a server.
  • Provisioning before the relay exists. Pairing is LAN-first and the relay ships later, so the QR / pairing payload carries a forward-compat slot (roomID + the initial membership credential, or a seed to derive it). When the relay later comes online both sides already agree on roomID and hold a valid credential — no re-pairing (SYNC_PROTOCOL.md §10.3).
  • Presentation & validation. To connect, the client exchanges its membership credential for a fresh connection token (host-minted while they're already in contact, or via an authenticated Worker mint endpoint), then opens the WebSocket presenting that token. The Worker looks up the hash in KV and checks: exists, not expired, not revoked, matches the roomID being joined, and scope permits the role (host vs client). Pass → route to the DO; fail → 401/close. Validation failures are rate-limited to blunt brute force.
  • Rotation. Membership credentials rotate on a schedule or on demand: the host issues a replacement and the client picks it up over the encrypted channel next time they're in contact; the old hash is deleted from KV. Connection tokens are single-use and self-expire, so they never need explicit rotation.
  • Revocation. Unpairing (or suspected compromise) deletes the device's membership hash from KV and instructs the Room DO to force-close any live socket for that deviceID. KV TTLs make expiry automatic; revocation is the explicit early delete.

Net properties: tokens are unguessable, stored only hashed (a KV leak yields nothing usable), bound to one room/device/scope (no cross-room replay), and short-lived at the connection tier (small replay window) — while secrecy of the payload is provided independently by Noise.

2.2 Relay DAU — daily active users

The headline heartbeat (§4) is DAI — keyed by a random per-install UUID, it counts installs, not users (a Mac + an iPhone in one person's hands are two installs). The relay can do better: a verified connection token carries roomId, which is derived from the host's static public key (§2.1), so one room ≈ one user/account no matter how many devices that user connects. That makes the relay the right place to measure a true daily-active-user number.

  • Where: the Worker writes one Analytics Engine data point on every admitted /relay connection (after token verify + revocation check pass, in handleRelay). No new client work — the identity already rides in the token.
  • Store: AE dataset nucleic_relay_dau (binding NUCLEIC_RELAY_DAU), indexed by roomId so count(DISTINCT index1) is a per-user count. role (host/client) and deviceId ride along as blobs for slicing (e.g. distinct devices per user). Same-day reconnects collapse under the DISTINCT; AE auto-timestamps, retains ~90 days, and stores no IP — same privacy posture as DAI.
  • Query (last 30 days): SELECT toStartOfDay(timestamp) d, count(DISTINCT index1) dau FROM nucleic_relay_dau WHERE timestamp > now() - INTERVAL '30' DAY GROUP BY d.

Note roomId is pseudonymous (a public-key hash, not an email/name), so this is still privacy-respecting — it identifies an account, not a person. It only exists once the relay is live (M5); until then DAI is the only metric.


3. APNS approval push (M5 — interlocks with the relay)

Resolves protocol open question #2 in favor of relay-delegated push. Because the relay can't decrypt, the push carries no sensitive content — it's a "tickle" that says an approval is waiting; the phone wakes, connects over the encrypted channel, and pulls the real approval.

Apple-side provisioning

  • Apple Developer → Keys → create an APNS Auth Key (.p8) (token-based auth — preferred over certs; one key serves sandbox + production). Record Key ID; Team ID is L7UDTQ6F5W.
  • Enable the Push Notifications capability on App ID xyz.blakeslee.nucleic.remote; regenerate the provisioning profile with aps-environment. APNS topic = xyz.blakeslee.nucleic.remote.

Worker-side sender (module in nucleic-edge)

  • Sign an ES256 JWT with WebCrypto (crypto.subtle, import the PKCS#8 EC P-256 key, sign P-256/SHA-256). Header {alg:"ES256", kid:<KeyID>}, claims {iss:<TeamID>, iat:<now>}. Cache the JWT ~3040 min (Apple requires refresh < 60 min).
  • POST https://api.push.apple.com/3/device/<token> with authorization: bearer <jwt>, apns-topic: xyz.blakeslee.nucleic.remote, apns-push-type, apns-priority. HTTP/2 works on deployed Workers; local wrangler dev may fail the HTTP/2 handshake to APNS (workerd #4841) — test via a deployed Worker or wrangler dev --remote. Reference: paje, cloudflare-apns2.
  • Content-free payload, e.g. {"aps":{"alert":{"loc-key":"approval.pending"},"interruption-level":"time-sensitive","sound":"default"},"roomID":"…","nudge":1}. The only detail it carries is the block category: a notify tagged kind:"question" swaps the loc-key to question.pending ("…waiting for your answer") for an AskUserQuestion block, vs approval.pending ("…waiting for your approval") for a tool approval. Optionally pair a content-available:1 background push so the app can pre-connect silently.
  • Recall (/v1/push/clear). The tickle rides a stable apns-collapse-id (approval-pending), so repeat wakes coalesce into one lock-screen alert and the delivered notification has a known identifier. When an approval is resolved on any device and nothing else is pending, the host asks the relay to send a content-free silent background push ({"aps":{"content-available":1},"clear":1}, priority 5, same collapse-id) to the phones that were away; the app wakes in the background and removeDeliveredNotifications the stale tickle. A phone that's connected clears it off the approvalResolved frame instead, and any phone reconciles on next foreground — so the "waiting for your approval" nudge never lingers after the work is handled.
  • Secrets (wrangler secret put): APNS_KEY_P8, APNS_KEY_ID, APNS_TEAM_ID, APNS_TOPIC, APNS_ENV (sandbox/production).

Device-token handling

  • The phone already carries its token in Hello.pushToken (Sources/NucleicProtocol/Sync/WireMessages.swift:49). For relay push, the host forwards the deviceID → pushToken map to the relay at registration (the token isn't sensitive content). Store in KV NUCLEIC_PUSH_TOKENS keyed by roomID + deviceID, tagged with APNS env (sandbox vs production tokens differ).

iOS app work

  • Done: ios/.../NucleicRemote.entitlements (aps-environment); PushRegistrar + PushAppDelegate request UNUserNotificationCenter authorization, call registerForRemoteNotifications(), capture the token in didRegisterForRemoteNotificationsWithDeviceToken, and feed it into the existing Hello.pushToken slot via RemoteStore's SyncClient calls.
  • Manual (Xcode, one-time): add the Push Notifications capability (Signing & Capabilities) so the provisioning profile matches and CODE_SIGN_ENTITLEMENTS points at the entitlements file; then an xcodebuild pass to compile (not buildable in the Linux sandbox).
  • Remaining: notification actions per docs/UX_IOS.md — inline Approve/Deny for low/medium-risk; high-risk opens the app behind a biometric gate (UNNotificationCategory actions, interruption-level: time-sensitive).

4. DAI heartbeat — daily active installs (independent — buildable now; opt-out + anonymous)

This is the install metric: keyed by a random per-install UUID, it deliberately knows nothing about who is behind an install, so it counts installs, not users. For a true user count see the relay DAU (§2.2). "DAU" was the original name here; it was renamed to DAI to stop implying it de-duplicates a person across their Mac and iPhone (it doesn't).

Client (Swift — both macOS host and iOS). Both platforms hit the same endpoint and share the same payload shape; a platform dimension distinguishes them everywhere downstream.

  • Install ID: generate a random UUID once, store in Keychain, separate from the Noise identity keypair so telemetry is never correlatable with the crypto identity.
    • macOS: mirror Sources/NucleicCore/Sync/HostIdentityStore.swift, account com.nucleic.host.installid, kSecAttrAccessibleAfterFirstUnlock.
    • iOS: mirror ios/NucleicRemote/NucleicRemote/Models/IdentityStore.swift, account xyz.blakeslee.nucleic.remote.installid.
  • Opt-out setting: UserDefaults shareAnonymousUsage (default true) with a one-line privacy note; toggle in Sources/NucleicApp/SettingsView.swift (macOS) and ios/NucleicRemote/NucleicRemote/Views/SettingsView.swift (iOS). When off, never send.
  • Cadence: at most once per active calendar day — gate on a UserDefaults lastHeartbeatDay. macOS fires from app launch (Sources/NucleicApp/NucleicApp.swift, alongside the existing startQuotaPolling() / startStatusPolling()); iOS fires on foreground (scenePhase.active) from the app entry.
  • Payload (anonymous, no PII): installId, platform ("macos" | "ios" — the primary split between the two clients), osVersion (e.g. "26.0" / "17.4"), channel, appVersion (CFBundleShortVersionString), build (CFBundleVersion), arch (arm64), locale (language only, coarse). No IP, no content, no account.
    • channel: macOS reports dev/canary/beta/rc/stable; iOS reports testflight/appstore (TestFlight is detectable via the sandboxReceipt receipt name). macOS dev and iOS debug builds are skipped to avoid dev noise.

Endpoint + storage

  • Worker route POST /v1/heartbeat on nucleic-edge. Validate shape, ignore malformed, do not log/store the client IP.
  • Primary store: Workers Analytics Engine dataset nucleic_dai. Write with the install ID as the index so unique counts are accurate: env.NUCLEIC_DAI.writeDataPoint({ indexes:[installId], blobs:[platform,osVersion,channel,appVersion,arch,locale], doubles:[1] }). Query via the SQL API (DAI split by platform): SELECT toStartOfDay(timestamp) d, blob1 platform, count(DISTINCT index1) dai FROM nucleic_dai WHERE timestamp > now() - INTERVAL '30' DAY GROUP BY d, platform. AE auto-timestamps, retains ~90 days, requires no schema, and stores no IP — aligns with the privacy posture. (AE adaptively samples at high volume; fine at this scale.)
  • Alternative if you later want exact counts + retention/cohorts (WAU/MAU): a D1 table heartbeat(day, install_id, platform, channel, version, PRIMARY KEY(day, install_id)) with INSERT OR IGNORE; DAI = COUNT(*) GROUP BY day, platform. Add a Cron trigger to prune raw rows >90d. This stores the ID longer-term, so the default is AE unless exact cohorts are needed (trade-offs detailed in §4.1).
  • Abuse: add a light Cloudflare Rate Limiting rule on /v1/heartbeat (metrics-only, not security-critical).

Privacy compliance: add a PrivacyInfo.xcprivacy (declare the minimal, not-linked-to-user collection — no IDFA, no tracking) and update App Store privacy answers; document the heartbeat in a short privacy note reachable from Settings.

4.1 Analytics Engine vs D1 (why AE first)

Both can store the heartbeat; they answer different questions.

Workers Analytics Engine (chosen) D1 (SQLite)
Model write-only time-series events; query with a ClickHouse-style SQL API relational table you own; full SQL
DAI accuracy count(DISTINCT index1) on the install-ID index is accurate at this scale; AE adaptively samples event volume at high write rates (estimates, weighted by _sample_interval) exact counts, no sampling
Retention auto-expires (~90 days); no rows to manage you own it — rows persist until you prune; enables cohort/retention (WAU/MAU, "week-N still active in week-N+4")
Identifier footprint install ID lives only in a short-lived, aggregate-oriented store install ID kept in a growing table → larger "tracking surface"; needs a prune Cron
Ops & cost schema-less, fire-and-forget, very cheap/generous free tier schema + migrations + storage growth; still cheap, more to run
Best at "how many DAI per day, split by platform/channel/version" — the headline metric exact retention/cohort/funnel analysis

Decision: start with AE. It nails the headline DAI metric, auto-expires, stores no IP, and keeps the smallest identifier footprint — the right fit for a local-first, privacy-respecting app. Reach for D1 (or add it alongside) only when you specifically want exact retention/cohort analysis; the two aren't mutually exclusive, but there's no reason to carry D1's retention liability on day one.


5. Trusted Tester program (built — cloud/nucleic-edge/src/testers.ts)

The iOS companion ships through TestFlight. Instead of the open public join link, outside people apply to become trusted testers; each application is gated behind the owner's one-click email approval, and approved applicants are hand-invited into the internal TestFlight group (Apple's API can't add an outside email to an internal group, so this stays manual by design).

  • Intake: POST /v1/tester/apply — the site form (website/trusted-tester.html, linked from the homepage footer, the macOS Remote settings, and iOS Settings) submits an application. Open but CORS-scoped to the site, coarse per-IP rate-limited, honeypot + optional Turnstile. Stored in KV NUCLEIC_TESTER_APPS as app:<uuid> (pending), with an email:<addr> dedupe index.
  • Approval: the owner is emailed (OWNER_EMAIL send_email binding, pinned to [email protected]) with signed Approve/Reject links. GET /v1/tester/review?id&action&token verifies an HMAC over "<id>:<action>" (TESTER_APP_SECRET), flips the status idempotently, and renders a page that (on approve) shows the name + email to paste into App Store Connect.
  • No applicant email leaves the Worker — Apple sends the TestFlight invite once the owner adds them, so the send binding only ever reaches the owner.

Cloudflare resources & wrangler bindings (target state)

Resource Name Used by
R2 bucket nucleic-updates (+ custom domain) Sparkle host
Worker nucleic-updates (routes: updates.*/<channel>/latest; binding UPDATES → R2 bucket) latest redirect aliases (§1.1)
Worker nucleic-edge (routes: api.*, relay.*) heartbeat, APNS, relay
Durable Object Room (binding ROOMS) relay rooms / presence
Analytics Engine dataset nucleic_dai (binding NUCLEIC_DAI) DAI — daily active installs
Analytics Engine dataset nucleic_relay_dau (binding NUCLEIC_RELAY_DAU) relay DAU — daily active users (§2.2)
KV NUCLEIC_RELAY_TOKENS, NUCLEIC_PUSH_TOKENS, NUCLEIC_TESTER_APPS relay admission, device tokens, trusted-tester applications (§5)
Send Email OWNER_EMAIL[email protected] (Email Routing) trusted-tester owner notification (§5)
Secrets APNS_KEY_P8, APNS_KEY_ID, APNS_TEAM_ID, APNS_TOPIC, APNS_ENV, TESTER_APP_SECRET, TURNSTILE_SECRET? APNS sender; trusted-tester link signing + anti-spam
Cron (optional) prune expired tokens / stale heartbeat rows hygiene
DNS updates. (R2), api. + relay. (Worker custom domains) all

Suggested build phasing

  • Phase A — ship independently of the relay: (a) Sparkle → R2 migration (§1); (b) DAI heartbeat Worker + Swift client (§4). Neither needs E2EE or the relay. This is the natural "do it now" slice when implementation starts.
  • Phase B — relay (M5): Worker + Room DO + relayToken issuance at pairing + app-side transport selection (§2); relay DAU falls out of admission for free (§2.2).
  • Phase C — APNS (M5, interlocks with B): iOS push registration/entitlements + relay-triggered content-free push (§3).

Verification (per phase, when implemented)

  • Sparkle: build a beta DMG (host_exec for the Swift/Sparkle toolchain), run generate-appcast.sh with NUCLEIC_FEED_BASE=https://updates.nucleic.blakeslee.xyz, upload to R2, curl the appcast, install an older build, confirm Sparkle finds + EdDSA-validates the update.
  • latest aliases (§1.1): after a beta is published, curl -sI https://updates.nucleic.blakeslee.xyz/beta/latest302 whose location is the newest Nucleic-Beta-<ver>.dmg; curl -sIL …/beta/latest | tail -1 follows through to the DMG 200. Publish a newer build and confirm the alias moves with no config change.
  • Heartbeat (DAI): curl -XPOST https://api.nucleic.blakeslee.xyz/v1/heartbeat -d '{…}'; query the AE SQL API (nucleic_dai) for DAI; flip the Settings toggle and confirm no request is sent; confirm dev channel never sends.
  • APNS: deploy the Worker, send a test push to a sandbox device token (wrangler dev --remote or deployed), confirm delivery and that the content-free payload wakes the app to pull detail.
  • Relay: connect two test clients with a relayToken to one roomID, confirm frames are forwarded and opaque; drop the phone socket, post an approval, confirm APNS fires. Then query nucleic_relay_dau and confirm the two clients collapse to one DAU (both share the room's roomId).

Critical files (for the implementation passes)

  • Sparkle: scripts/package-app.sh:137, scripts/generate-appcast.sh:32, scripts/release-macos.sh (add R2 upload), BUILD.md, signing/README.md.
  • latest redirect Worker (§1.1): cloud/nucleic-updates/ (src/index.ts, src/appcast.ts, wrangler.jsonc).
  • Heartbeat (macOS): new Sources/NucleicCore/HeartbeatReporter.swift + install-ID store (modeled on Sources/NucleicCore/Sync/HostIdentityStore.swift); toggle in Sources/NucleicApp/SettingsView.swift; fire from Sources/NucleicApp/NucleicApp.swift.
  • Heartbeat (iOS): reporter + install-ID store under ios/NucleicRemote/ (modeled on ios/NucleicRemote/NucleicRemote/Models/IdentityStore.swift); toggle in ios/NucleicRemote/NucleicRemote/Views/SettingsView.swift; fire on scenePhase.active. Consider sharing the wire payload type via the NucleicProtocol module.
  • APNS / relay (iOS): new ios/NucleicRemote/NucleicRemote.entitlements; push registration in the iOS app entry; token → Hello.pushToken (Sources/NucleicProtocol/Sync/WireMessages.swift:49 + SyncClient).
  • Worker (new): cloud/nucleic-edge/wrangler.jsonc, heartbeat handler, APNS module, Room DO, relay handler.

Resolved open items

  • relayToken lifecycle — specified in §2.1 (two-tier host-minted credential, hashed in KV, rotated over the encrypted channel, revoked on unpair). The QR forward-compat field stays an implementation detail of pairing (SYNC_PROTOCOL.md §10.3).
  • iOS heartbeatyes, iOS sends too; macOS and iOS are differentiated by the platform dimension (§4).
  • AE vs D1AE first; D1 only if exact retention/cohort analysis is later wanted (§4.1).

Still to confirm during implementation: exact membership-credential TTL/rotation cadence, and the iOS channel mapping (TestFlight vs App Store detection).