Nucleic-Session: 4C62E994-D013-454A-B051-7D91EECFB69A Co-authored-by: Nucleic <[email protected]>
450 lines
31 KiB
Markdown
450 lines
31 KiB
Markdown
# 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. 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. `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 ~30–40 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).
|