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

450 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](#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_BASE``https://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 `Frame`s 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](https://github.com/cloudflare/workerd/issues/4841)) — test via a deployed
Worker or `wrangler dev --remote`. Reference: [paje](https://github.com/jonesphillip/paje),
[cloudflare-apns2](https://github.com/FiveSheepCo/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/latest` → `302` 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 heartbeat** — **yes**, iOS sends too; macOS and iOS are differentiated by the `platform`
dimension (§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).