Files
nucleic/cloud/nucleic-edge/src/index.ts
T

429 lines
22 KiB
TypeScript

// Nucleic edge Worker.
//
// Phase A — DAI heartbeat (daily active installs) → Workers Analytics Engine.
// Phase B — WebSocket relay (Durable Object `Room`) + relay-token admission + relay DAU.
// Phase C — APNS approval push (relay-triggered, content-free).
//
// See docs/CLOUD_INFRA.md and cloud/nucleic-edge/README.md.
import type { Env } from "./env";
import { parseHeartbeat, type Heartbeat } from "./heartbeat";
import { adoptLiveActivityPayload, apnsConfig, approvalPayload, clearPayload, liveActivityPayload, reconnectPayload, sendApns, sendApnsAdopt, sendApnsClear, sendApnsReconnect, sendLiveActivityApns } from "./apns";
import { authedHostId, enrollHost, registerPushToken, wakeableRecord, type PushRegisterBody } from "./push";
import { handleApply, handleReview } from "./testers";
import {
CONNECTION_TTL,
MEMBERSHIP_TTL,
mintClaims,
signToken,
type TokenClaims,
verifyToken,
} from "./relayToken";
import { authedHostRoom, enrollChallenge, enrollComplete } from "./relayEnroll";
export { Room } from "./room";
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const { pathname } = url;
if (request.method === "GET" && pathname === "/health") return new Response("ok\n");
if (pathname === "/v1/heartbeat") return method(request, "POST", () => handleHeartbeat(request, env));
// --- relay ---
if (pathname === "/relay") return handleRelay(request, env, url);
// Proof-of-possession enrollment: the host proves it holds the X25519 static private key that
// derives its roomId (relayEnroll.ts) before it can mint tokens for that room.
if (pathname === "/v1/relay/enroll/challenge")
return method(request, "POST", async () => enrollChallenge(env.NUCLEIC_RELAY_TOKENS, await safeJson(request) as { staticKey?: unknown } | null));
if (pathname === "/v1/relay/enroll")
return method(request, "POST", async () => enrollComplete(env.NUCLEIC_RELAY_TOKENS, await safeJson(request) as { challengeId?: unknown; proof?: unknown } | null));
if (pathname === "/v1/relay/token") return method(request, "POST", () => mintMembership(request, env));
if (pathname === "/v1/relay/connect") return method(request, "POST", () => mintConnection(request, env));
if (pathname === "/v1/relay/revoke") return method(request, "POST", () => revoke(request, env));
// --- push (out-of-band wake; the in-band path is the Room "wake" control frame) ---
if (pathname === "/v1/host/enroll")
return method(request, "POST", async () => enrollHost(env.NUCLEIC_PUSH_TOKENS));
if (pathname === "/v1/push/register") return method(request, "POST", () => pushRegister(request, env));
if (pathname === "/v1/push/notify") return method(request, "POST", () => pushNotify(request, env));
if (pathname === "/v1/push/clear") return method(request, "POST", () => pushClear(request, env));
if (pathname === "/v1/push/liveActivity") return method(request, "POST", () => pushLiveActivity(request, env));
if (pathname === "/v1/push/liveActivityAdopt") return method(request, "POST", () => pushLiveActivityAdopt(request, env));
if (pathname === "/v1/push/reconnect") return method(request, "POST", () => pushReconnect(request, env));
// --- trusted-tester program (apply is open + CORS'd for the site form; review is HMAC-gated) ---
if (pathname === "/v1/tester/apply") return handleApply(request, env);
if (pathname === "/v1/tester/review") return handleReview(request, env, url);
return new Response("not found", { status: 404 });
},
} satisfies ExportedHandler<Env>;
// MARK: - DAI — daily active installs (Phase A)
async function handleHeartbeat(request: Request, env: Env): Promise<Response> {
let body: unknown;
try {
body = await request.json();
} catch {
return new Response("bad json", { status: 400 });
}
const parsed = parseHeartbeat(body);
if (!parsed.ok) return new Response(`bad field: ${parsed.error}`, { status: 422 });
// No IP read or stored — the heartbeat is anonymous.
writeHeartbeat(env, parsed.value);
return new Response(null, { status: 204 });
}
// Indexed by the anonymous install UUID → `count(DISTINCT index1)` is a daily-active-*install*
// count (one user across two devices counts twice). For a per-user count, see writeRelayDau.
function writeHeartbeat(env: Env, hb: Heartbeat): void {
env.NUCLEIC_DAI.writeDataPoint({
indexes: [hb.installId],
blobs: [hb.platform, hb.osVersion, hb.channel, hb.appVersion, hb.build, hb.arch, hb.locale],
doubles: [1],
});
}
// MARK: - Relay (Phase B)
async function handleRelay(request: Request, env: Env, url: URL): Promise<Response> {
if (request.headers.get("upgrade") !== "websocket") {
return new Response("expected websocket", { status: 426 });
}
if (!env.RELAY_TOKEN_SECRET) return new Response("relay not configured", { status: 503 });
const token = url.searchParams.get("t") ?? bearer(request);
if (!token) return new Response("missing token", { status: 401 });
const v = await verifyToken(env.RELAY_TOKEN_SECRET, token);
if (!v.ok) return new Response(`token ${v.error}`, { status: 401 });
if (v.claims.typ !== "c") return new Response("connection token required", { status: 401 });
// Defense in depth for the reserved deviceId (also rejected at mint time — see
// mintMembership): a client tagged "host" would collide with the room's role tag.
if (v.claims.deviceId === "host" && v.claims.role !== "host") {
return new Response("reserved deviceId", { status: 403 });
}
if (await env.NUCLEIC_RELAY_TOKENS.get(`revoked:${v.claims.deviceId}`)) {
return new Response("revoked", { status: 403 });
}
// Admission succeeded — count this connection toward the relay DAU (indexed by roomId ≈ user).
writeRelayDau(env, v.claims);
// Route to the room's DO, carrying the verified identity (the DO trusts these headers because
// only this Worker can reach it).
const stub = env.ROOMS.get(env.ROOMS.idFromName(v.claims.roomId));
const headers = new Headers(request.headers);
headers.set("x-nucleic-role", v.claims.role);
headers.set("x-nucleic-device", v.claims.deviceId);
// The room's *name* — a DO can't recover it from its own id, and the presence→wake hook
// (docs/CARBON_RUNNER.md §3) needs the roomId to name the runner pool's room.
headers.set("x-nucleic-room", v.claims.roomId);
return stub.fetch(new Request(url.toString(), { method: request.method, headers, body: request.body }));
}
// Relay DAU — a true daily-active-*user* metric. Unlike the anonymous DAI heartbeat (indexed by a
// random install UUID), the relay carries a real identity: `roomId` is derived from the host's
// static public key (docs/CLOUD_INFRA.md §2.1), so one room ≈ one user/account. Indexing on it
// makes `count(DISTINCT index1)` count users, not installs. `role`/`deviceId` ride along as blobs
// for slicing (e.g. host vs client, distinct devices per user). Emitted on every admitted
// connection; same-day reconnects collapse under the DISTINCT.
function writeRelayDau(env: Env, claims: TokenClaims): void {
env.NUCLEIC_RELAY_DAU.writeDataPoint({
indexes: [claims.roomId],
blobs: [claims.role, claims.deviceId],
doubles: [1],
});
}
/** Host mints a long-lived membership token for a device in *its own* room. The room is the
* PoP-proven binding from enrollment (relayEnroll.ts) — never a client-supplied value — so a host
* can only ever mint for the room whose static private key it proved possession of. The admin
* bearer remains an operator override that may name any room explicitly. */
async function mintMembership(request: Request, env: Env): Promise<Response> {
if (!env.RELAY_TOKEN_SECRET) return new Response("relay not configured", { status: 503 });
const body = (await safeJson(request)) as { roomId?: string; deviceId?: string; role?: string } | null;
if (!body?.deviceId) return new Response("deviceId required", { status: 422 });
// "host" is the room's role tag in the DO's hibernation-tag namespace — a *client* named
// "host" would be matched by `getWebSockets("host")` and blackhole the room's routing.
if (body.deviceId === "host" && body.role !== "host") {
return new Response("reserved deviceId", { status: 422 });
}
let roomId: string;
if (adminOk(request, env)) {
// Operator override: an explicit roomId is required and trusted.
if (!body.roomId) return new Response("roomId required", { status: 422 });
roomId = body.roomId;
} else {
const host = await authedHostRoom(env.NUCLEIC_RELAY_TOKENS, bearer(request));
if (!host) return unauthorized();
roomId = host.roomId; // ignore any client-supplied roomId
}
const claims = mintClaims({
roomId,
deviceId: body.deviceId,
role: body.role === "host" ? "host" : "client",
typ: "m",
ttlSeconds: MEMBERSHIP_TTL,
});
return json({ token: await signToken(env.RELAY_TOKEN_SECRET, claims), exp: claims.exp });
}
/** Trade a membership token for a short-lived connection token (presented on the WS upgrade). */
async function mintConnection(request: Request, env: Env): Promise<Response> {
if (!env.RELAY_TOKEN_SECRET) return new Response("relay not configured", { status: 503 });
const m = bearer(request);
const v = m ? await verifyToken(env.RELAY_TOKEN_SECRET, m) : null;
if (!v || !v.ok || v.claims.typ !== "m") return unauthorized();
if (await env.NUCLEIC_RELAY_TOKENS.get(`revoked:${v.claims.deviceId}`)) return new Response("revoked", { status: 403 });
const claims = mintClaims({
roomId: v.claims.roomId,
deviceId: v.claims.deviceId,
role: v.claims.role,
typ: "c",
ttlSeconds: CONNECTION_TTL,
});
return json({ token: await signToken(env.RELAY_TOKEN_SECRET, claims), exp: claims.exp });
}
/** Revoke a device on unpair (admin). KV TTL outlives the longest membership token. */
async function revoke(request: Request, env: Env): Promise<Response> {
if (!adminOk(request, env)) return unauthorized();
const body = (await safeJson(request)) as { deviceId?: string } | null;
if (!body?.deviceId) return new Response("deviceId required", { status: 422 });
await env.NUCLEIC_RELAY_TOKENS.put(`revoked:${body.deviceId}`, "1", { expirationTtl: MEMBERSHIP_TTL });
return json({ revoked: body.deviceId });
}
// MARK: - Push (Phase C, out-of-band)
/** The caller's push identity: a self-enrolled host, or the operator's admin bearer
* (which may act on any device). Null ⇒ unauthorized. */
async function pushCaller(request: Request, env: Env): Promise<{ hostId: string; isAdmin: boolean } | null> {
if (adminOk(request, env)) return { hostId: "admin", isAdmin: true };
const hostId = await authedHostId(env.NUCLEIC_PUSH_TOKENS, bearer(request));
return hostId ? { hostId, isAdmin: false } : null;
}
/** Register a phone's APNS token (the host uploads it after the sync Hello). */
async function pushRegister(request: Request, env: Env): Promise<Response> {
const caller = await pushCaller(request, env);
if (!caller) return unauthorized();
const body = (await safeJson(request)) as PushRegisterBody | null;
return registerPushToken(env.NUCLEIC_PUSH_TOKENS, caller.hostId, body);
}
async function pushNotify(request: Request, env: Env): Promise<Response> {
const caller = await pushCaller(request, env);
if (!caller) return unauthorized();
const cfg = apnsConfig(env);
if (!cfg) return new Response("apns not configured", { status: 503 });
const body = (await safeJson(request)) as { deviceId?: string; kind?: string } | null;
if (!body?.deviceId) return new Response("deviceId required", { status: 422 });
// Scoped: a host may only wake devices it registered (404 either way, so a probe can't
// distinguish "unknown device" from "someone else's device").
const rec = await wakeableRecord(env.NUCLEIC_PUSH_TOKENS, caller.hostId, body.deviceId, caller.isAdmin);
if (!rec) return new Response("no push token for device", { status: 404 });
// The device record's environment + topic win (the host knows which build/channel its phone
// runs); the global APNS_ENV / APNS_TOPIC secrets only back records that predate them.
const apsEnv = rec.env ?? cfg.env;
const topic = rec.topic ?? cfg.topic;
// `kind:"question"` ⇒ the block is an agent question, so the alert reads "waiting for your
// answer" rather than "…approval". Absent/any other value keeps the approval wording.
const result = await sendApns({ ...cfg, env: apsEnv, topic }, rec.token, approvalPayload(body.kind === "question"));
logPush("notify", body.deviceId, topic, apsEnv, result);
return json(result, result.status === 200 ? 200 : 502);
}
/** Recall a device's approval wake tickle — the counterpart to `pushNotify`. The host calls this
* once an approval is resolved (on any device) and nothing else is pending, so a phone still
* showing "A session is waiting for your approval" clears it. Same ownership scoping as wake. */
async function pushClear(request: Request, env: Env): Promise<Response> {
const caller = await pushCaller(request, env);
if (!caller) return unauthorized();
const cfg = apnsConfig(env);
if (!cfg) return new Response("apns not configured", { status: 503 });
const body = (await safeJson(request)) as { deviceId?: string } | null;
if (!body?.deviceId) return new Response("deviceId required", { status: 422 });
const rec = await wakeableRecord(env.NUCLEIC_PUSH_TOKENS, caller.hostId, body.deviceId, caller.isAdmin);
if (!rec) return new Response("no push token for device", { status: 404 });
const apsEnv = rec.env ?? cfg.env;
const topic = rec.topic ?? cfg.topic;
const result = await sendApnsClear({ ...cfg, env: apsEnv, topic }, rec.token, clearPayload());
logPush("clear", body.deviceId, topic, apsEnv, result);
return json(result, result.status === 200 ? 200 : 502);
}
/** Silently wake a phone so it adopts a push-started Live Activity — comes online and registers the
* new activity's update token so the host can keep the glance fresh and end it cleanly (UX_IOS §5.3).
* A content-free `content-available` background push to the device token (not the activity token).
* Same ownership scoping as wake. */
async function pushLiveActivityAdopt(request: Request, env: Env): Promise<Response> {
const caller = await pushCaller(request, env);
if (!caller) return unauthorized();
const cfg = apnsConfig(env);
if (!cfg) return new Response("apns not configured", { status: 503 });
const body = (await safeJson(request)) as { deviceId?: string } | null;
if (!body?.deviceId) return new Response("deviceId required", { status: 422 });
const rec = await wakeableRecord(env.NUCLEIC_PUSH_TOKENS, caller.hostId, body.deviceId, caller.isAdmin);
if (!rec) return new Response("no push token for device", { status: 404 });
const apsEnv = rec.env ?? cfg.env;
const topic = rec.topic ?? cfg.topic;
const result = await sendApnsAdopt({ ...cfg, env: apsEnv, topic }, rec.token, adoptLiveActivityPayload());
logPush("liveActivityAdopt", body.deviceId, topic, apsEnv, result);
return json(result, result.status === 200 ? 200 : 502);
}
/** Silently nudge a device to reconnect over a non-local transport (tailnet/relay) after its LAN
* link drops (SYNC_PROTOCOL §3.2). A content-free `content-available` background push to the device
* token — it grants the app a moment of runtime to re-run its dial loop from anywhere. Same
* ownership scoping as wake: a host may only nudge devices it registered. */
async function pushReconnect(request: Request, env: Env): Promise<Response> {
const caller = await pushCaller(request, env);
if (!caller) return unauthorized();
const cfg = apnsConfig(env);
if (!cfg) return new Response("apns not configured", { status: 503 });
const body = (await safeJson(request)) as { deviceId?: string } | null;
if (!body?.deviceId) return new Response("deviceId required", { status: 422 });
const rec = await wakeableRecord(env.NUCLEIC_PUSH_TOKENS, caller.hostId, body.deviceId, caller.isAdmin);
if (!rec) return new Response("no push token for device", { status: 404 });
const apsEnv = rec.env ?? cfg.env;
const topic = rec.topic ?? cfg.topic;
const result = await sendApnsReconnect({ ...cfg, env: apsEnv, topic }, rec.token, reconnectPayload());
logPush("reconnect", body.deviceId, topic, apsEnv, result);
return json(result, result.status === 200 ? 200 : 502);
}
/** Surface a push send in `wrangler tail` — previously every APNs failure (a `BadTopic` for a
* mis-addressed per-channel build, a `BadDeviceToken` for an env mismatch) vanished silently.
* `result.reason` carries APNs's rejection string. */
function logPush(
kind: string,
deviceId: string,
topic: string,
env: string,
result: { status: number; reason?: string },
): void {
const line = `push ${kind} device=${deviceId} topic=${topic} env=${env} status=${result.status}${result.reason ? ` reason=${result.reason}` : ""}`;
if (result.status === 200) console.log(line);
else console.warn(line);
}
/** Push a Live Activity content-state update to a phone's activity (UX_IOS §5.3). The activity's
* push token rides inline (it's per-activity, not the device token); we still scope on device
* ownership — a host may only push to a device it registered — so the `deviceId` gates it. */
async function pushLiveActivity(request: Request, env: Env): Promise<Response> {
const caller = await pushCaller(request, env);
if (!caller) return unauthorized();
const cfg = apnsConfig(env);
if (!cfg) return new Response("apns not configured", { status: 503 });
const body = (await safeJson(request)) as
| {
deviceId?: string;
token?: string;
env?: string;
topic?: string;
event?: string;
contentState?: unknown;
// Present only on a push-to-start (`event:"start"`): the Activity's static attributes and
// the type name they must decode into on the phone.
attributes?: unknown;
attributesType?: string;
// Set on an `update` that should sound/vibrate through the glance (a session newly needs
// the user while the phone was away): "approval" | "input". The host sends this instead of
// a separate wake tickle / banner.
alert?: string;
}
| null;
if (!body?.deviceId || !body.token) return new Response("deviceId and token required", { status: 422 });
// Same scoping as wake: a host may only reach devices it registered (404 either way).
const rec = await wakeableRecord(env.NUCLEIC_PUSH_TOKENS, caller.hostId, body.deviceId, caller.isAdmin);
if (!rec) return new Response("no push token for device", { status: 404 });
const apsEnv =
body.env === "production" ? "production" : body.env === "sandbox" ? "sandbox" : (rec.env ?? cfg.env);
// The push body's topic wins (freshest, from this phone's hello), then the stored record, then
// the worker's configured fallback. `sendLiveActivityApns` appends `.push-type.liveactivity`.
const topic = body.topic ?? rec.topic ?? cfg.topic;
// A push-to-start carries the static attributes + their type name; update/end don't.
const start =
body.event === "start" && body.attributesType
? { type: body.attributesType, attributes: body.attributes ?? {} }
: null;
// An alerting update sounds/vibrates through the glance (replaces the wake tickle / banner for a
// phone that already has a Live Activity); the payload builder ignores it for start/end.
const alert = body.alert === "approval" || body.alert === "input" ? body.alert : null;
const event = body.event ?? "update";
const payload = liveActivityPayload(event, body.contentState ?? null, Date.now(), start, alert);
// Mix APNs priorities so routine traffic can't starve the ActivityKit budget (which shrinks the
// longer the app is backgrounded): a plain content-state update rides low priority `5` (off the
// budget), while the pushes that must land promptly — the push-to-`start`, the `end`, and an
// alerting "needs you" update — ride `10`. Without this, a busy session's frequent priority-10
// updates exhausted the budget while the phone was away and the next push-to-start got throttled
// — the glance failing to appear after a long background (docs/UX_IOS.md §5.3).
const priority = event === "update" && alert === null ? "5" : "10";
const result = await sendLiveActivityApns({ ...cfg, env: apsEnv, topic }, body.token, payload, priority);
logPush("liveActivity", body.deviceId, `${topic}.push-type.liveactivity`, apsEnv, result);
return json(result, result.status === 200 ? 200 : 502);
}
// MARK: - helpers
function method(request: Request, want: string, run: () => Promise<Response>): Promise<Response> | Response {
if (request.method !== want) return new Response("method not allowed", { status: 405, headers: { allow: want } });
return run();
}
function bearer(request: Request): string | null {
const h = request.headers.get("authorization");
const m = h && /^Bearer\s+(.+)$/i.exec(h);
return m ? m[1] : null;
}
function adminOk(request: Request, env: Env): boolean {
const want = env.RELAY_ADMIN_SECRET;
const got = bearer(request);
return !!want && !!got && safeEqual(got, want);
}
function safeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
async function safeJson(request: Request): Promise<unknown> {
try {
return await request.json();
} catch {
return null;
}
}
function json(obj: unknown, status = 200): Response {
return new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } });
}
function unauthorized(): Response {
return new Response("unauthorized", { status: 401 });
}