30 KiB
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 +RoomDurable 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 intoHello.pushToken). Remaining: account provisioning (R2 bucket/custom domain, KV namespaces, secrets, APNS.p8+ deploy); the iOS Push Notifications capability (manual Xcode step) + anxcodebuildpass; 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 throughSyncClient).docs/SYNC_PROTOCOL.md §3.2 / §10.2specify 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
- Create R2 bucket
nucleic-updates; attach custom domainupdates.nucleic.blakeslee.xyz(R2 auto-creates the CNAME; objects become public + CDN-cached at that host). - 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.shdoes this) and/or a Cache Rule.
Code changes (done)
- Default
NUCLEIC_FEED_BASE→https://updates.nucleic.blakeslee.xyzinscripts/package-app.shandscripts/generate-appcast.sh(env still overridable). scripts/upload-r2.shpushes the channel's DMG(s) to the<channel>/key prefix and theappcast-<channel>.xmlto the root (wrangler r2 object put);scripts/release-macos.shcalls it after appcast generation.- Docs:
BUILD.md"Distribution & auto-update" andsigning/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>/latestonly. Worker routes win over the R2 custom domain on the same host, so just those four paths hit the Worker; the rootappcast-*.xmland the<channel>/*.dmgobjects 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>.xmlfrom the bound R2 bucket and redirects to the enclosure with the highestsparkle: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, andlatestfollows. - 302, not 301. The target moves every release, so it must never be cached as permanent; the
redirect carries a 5-min
Cache-Controlmatching 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): onUpgrade: websocket, validaterelayToken, deriveroomID(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, forwardsFrames 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 onlyroomID, 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+seqcursors 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. 60s–5m, 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.roomIDis 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 onroomIDand 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
roomIDbeing 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
RoomDO to force-close any live socket for thatdeviceID. 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
/relayconnection (after token verify + revocation check pass, inhandleRelay). No new client work — the identity already rides in the token. - Store: AE dataset
nucleic_relay_dau(bindingNUCLEIC_RELAY_DAU), indexed byroomIdsocount(DISTINCT index1)is a per-user count.role(host/client) anddeviceIdride along as blobs for slicing (e.g. distinct devices per user). Same-day reconnects collapse under theDISTINCT; 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 withaps-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 ~30–40 min (Apple requires refresh < 60 min). POST https://api.push.apple.com/3/device/<token>withauthorization: bearer <jwt>,apns-topic: xyz.blakeslee.nucleic.remote,apns-push-type,apns-priority. HTTP/2 works on deployed Workers; localwrangler devmay fail the HTTP/2 handshake to APNS (workerd #4841) — test via a deployed Worker orwrangler 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}. Optionally pair acontent-available:1background push so the app can pre-connect silently. - 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 thedeviceID → pushTokenmap to the relay at registration (the token isn't sensitive content). Store in KVNUCLEIC_PUSH_TOKENSkeyed byroomID + deviceID, tagged with APNS env (sandbox vs production tokens differ).
iOS app work
- Done:
ios/.../NucleicRemote.entitlements(aps-environment);PushRegistrar+PushAppDelegaterequestUNUserNotificationCenterauthorization, callregisterForRemoteNotifications(), capture the token indidRegisterForRemoteNotificationsWithDeviceToken, and feed it into the existingHello.pushTokenslot viaRemoteStore'sSyncClientcalls. - Manual (Xcode, one-time): add the Push Notifications capability (Signing &
Capabilities) so the provisioning profile matches and
CODE_SIGN_ENTITLEMENTSpoints at the entitlements file; then anxcodebuildpass 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 (UNNotificationCategoryactions,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
UUIDonce, 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, accountcom.nucleic.host.installid,kSecAttrAccessibleAfterFirstUnlock. - iOS: mirror
ios/NucleicRemote/NucleicRemote/Models/IdentityStore.swift, accountxyz.blakeslee.nucleic.remote.installid.
- macOS: mirror
- Opt-out setting:
UserDefaultsshareAnonymousUsage(defaulttrue) with a one-line privacy note; toggle inSources/NucleicApp/SettingsView.swift(macOS) andios/NucleicRemote/NucleicRemote/Views/SettingsView.swift(iOS). When off, never send. - Cadence: at most once per active calendar day — gate on a
UserDefaultslastHeartbeatDay. macOS fires from app launch (Sources/NucleicApp/NucleicApp.swift, alongside the existingstartQuotaPolling()/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 reportsdev/canary/beta/rc/stable; iOS reportstestflight/appstore(TestFlight is detectable via thesandboxReceiptreceipt name). macOSdevand iOS debug builds are skipped to avoid dev noise.
Endpoint + storage
- Worker route
POST /v1/heartbeatonnucleic-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))withINSERT 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 KVNUCLEIC_TESTER_APPSasapp:<uuid>(pending), with anemail:<addr>dedupe index. - Approval: the owner is emailed (
OWNER_EMAILsend_email binding, pinned to[email protected]) with signed Approve/Reject links.GET /v1/tester/review?id&action&tokenverifies 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 +
RoomDO +relayTokenissuance 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_execfor the Swift/Sparkle toolchain), rungenerate-appcast.shwithNUCLEIC_FEED_BASE=https://updates.nucleic.blakeslee.xyz, upload to R2,curlthe appcast, install an older build, confirm Sparkle finds + EdDSA-validates the update. latestaliases (§1.1): after a beta is published,curl -sI https://updates.nucleic.blakeslee.xyz/beta/latest→302whoselocationis the newestNucleic-Beta-<ver>.dmg;curl -sIL …/beta/latest | tail -1follows through to the DMG200. 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 --remoteor deployed), confirm delivery and that the content-free payload wakes the app to pull detail. - Relay: connect two test clients with a
relayTokento oneroomID, confirm frames are forwarded and opaque; drop the phone socket, post an approval, confirm APNS fires. Then querynucleic_relay_dauand confirm the two clients collapse to one DAU (both share the room'sroomId).
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. latestredirect 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 onSources/NucleicCore/Sync/HostIdentityStore.swift); toggle inSources/NucleicApp/SettingsView.swift; fire fromSources/NucleicApp/NucleicApp.swift. - Heartbeat (iOS): reporter + install-ID store under
ios/NucleicRemote/(modeled onios/NucleicRemote/NucleicRemote/Models/IdentityStore.swift); toggle inios/NucleicRemote/NucleicRemote/Views/SettingsView.swift; fire onscenePhase→.active. Consider sharing the wire payload type via theNucleicProtocolmodule. - 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,RoomDO, relay handler.
Resolved open items
relayTokenlifecycle — 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 heartbeat — yes, iOS sends too; macOS and iOS are differentiated by the
platformdimension (§4). - AE vs D1 — AE 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).