Skip to content

UCCA Push Notifications — Alex Brief

SURFACE: api.ucca.online (ucca-api) + engine-db DO NOT TOUCH: ucca-site, ucca-keys, ucca-vcc, ucca-trust, ucca-ir, rtopacks-site


Locked Credentials

Item Value
APNs Key ID BDRMM4PZB6
APNs Key Name UCCA Authenticator Push
APNs .p8 file AuthKey_BDRMM4PZB6.p8 — in 1Password
Apple Team ID B29TSCBPHD
APNs Environment Sandbox & Production
APNs Scope Team Scoped (All Topics)
Bundle ID (existing app) com.ucca.school
Bundle ID (Authenticator — TBC) online.ucca.authenticator
Firebase Project To be created — ucca-authenticator under admin@ucca.online
engine-db ID 0efa8970-0053-4623-8436-4e877af10887

What This Brief Covers

  1. Three new D1 tables in engine-db
  2. Four new endpoints on ucca-api Worker
  3. APNs delivery function (iOS)
  4. FCM delivery function (Android) — stub only until Firebase project created
  5. Chain event integration — push fires on specific chain events

Phase 1 — D1 Schema (engine-db)

Run these migrations against engine-db 0efa8970-0053-4623-8436-4e877af10887.

-- Device tokens — one row per registered device
CREATE TABLE IF NOT EXISTS device_tokens (
  id           TEXT PRIMARY KEY,          -- UUID
  contact_hash TEXT NOT NULL,             -- FK to contacts.hash
  platform     TEXT NOT NULL              -- 'ios' | 'android'
               CHECK (platform IN ('ios', 'android')),
  token        TEXT NOT NULL,             -- APNs device token or FCM registration token
  bundle_id    TEXT NOT NULL,             -- e.g. online.ucca.authenticator
  created_at   TEXT NOT NULL,
  last_seen    TEXT NOT NULL,
  active       INTEGER NOT NULL DEFAULT 1,
  UNIQUE (contact_hash, token)
);

-- OTP codes — SMS and voice delivery
CREATE TABLE IF NOT EXISTS otp_codes (
  id           TEXT PRIMARY KEY,          -- UUID
  contact_hash TEXT NOT NULL,
  code         TEXT NOT NULL,             -- SHA-256 hashed at rest
  method       TEXT NOT NULL              -- 'sms' | 'voice'
               CHECK (method IN ('sms', 'voice')),
  expires_at   TEXT NOT NULL,             -- ISO8601, 10 min from creation
  used         INTEGER NOT NULL DEFAULT 0,
  created_at   TEXT NOT NULL
);

-- Passkey credentials — WebAuthn
CREATE TABLE IF NOT EXISTS passkey_credentials (
  id             TEXT PRIMARY KEY,        -- UUID
  contact_hash   TEXT NOT NULL,
  credential_id  TEXT NOT NULL UNIQUE,    -- WebAuthn credential ID (base64url)
  public_key     TEXT NOT NULL,           -- COSE public key (base64url)
  sign_count     INTEGER NOT NULL DEFAULT 0,
  aaguid         TEXT,                    -- Authenticator AAGUID
  created_at     TEXT NOT NULL,
  last_used      TEXT
);

CREATE INDEX IF NOT EXISTS idx_device_tokens_contact ON device_tokens(contact_hash);
CREATE INDEX IF NOT EXISTS idx_otp_codes_contact ON otp_codes(contact_hash);
CREATE INDEX IF NOT EXISTS idx_passkey_contact ON passkey_credentials(contact_hash);

Phase 2 — ucca-api Endpoints

Add four routes to the ucca-api Worker src/index.ts.

Bindings required (add to wrangler.toml)

[[d1_databases]]
binding = "ENGINE_DB"
database_name = "engine-db"
database_id = "0efa8970-0053-4623-8436-4e877af10887"

[vars]
APNS_KEY_ID = "BDRMM4PZB6"
APNS_TEAM_ID = "B29TSCBPHD"
APNS_BUNDLE_ID = "online.ucca.authenticator"

Add to secrets (via wrangler secret put): - APNS_PRIVATE_KEY — contents of AuthKey_BDRMM4PZB6.p8 (the PEM string) - FCM_SERVER_KEY — from Firebase project (stub empty string until project created) - PUSH_SECRET — 64-char hex shared secret for server-to-server calls from ucca-keys


Route 1 — Register device token

POST /api/push/register
Content-Type: application/json
Authorization: Bearer {contact_hash}  (or X-UCCA-Hash header)

Body:
{
  "contact_hash": "abc123...",
  "platform": "ios" | "android",
  "token": "device_token_string",
  "bundle_id": "online.ucca.authenticator"
}

Response 200:
{ "registered": true, "id": "uuid" }

Logic: - Validate contact_hash exists in contacts table - Upsert into device_tokens (update last_seen + active=1 if token exists) - Return registered: true


Route 2 — Send push notification

POST /api/push/send
X-Push-Secret: {PUSH_SECRET}   ← server-to-server only, never from client

Body:
{
  "contact_hash": "abc123...",
  "title": "string",
  "body": "string",
  "data": { "event_type": "k2_delivered", "chain_url": "..." }  // optional
}

Response 200:
{ "sent": true, "ios": 1, "android": 0, "failed": 0 }

Logic: - Validate X-Push-Secret (constant-time comparison) - Query device_tokens WHERE contact_hash = ? AND active = 1 - For each iOS token → send via APNs (see APNs function below) - For each Android token → send via FCM (stub until Firebase ready) - Return counts


Route 3 — Deregister device token

DELETE /api/push/register
X-UCCA-Hash: {contact_hash}

Body: { "token": "device_token_string" }

Response 200:
{ "deregistered": true }

Logic: - Set active = 0 WHERE contact_hash = ? AND token = ?


Route 4 — Push health check (ops only)

GET /api/push/health
X-Push-Secret: {PUSH_SECRET}

Response 200:
{
  "status": "ok",
  "registered_devices": { "ios": 12, "android": 4 },
  "apns_key_id": "BDRMM4PZB6",
  "bundle_id": "online.ucca.authenticator"
}

Phase 3 — APNs Delivery Function

APNs uses JWT authentication with the .p8 key. Cloudflare Workers support the Web Crypto API natively — no Node crypto needed.

async function sendAPNs(
  token: string,
  title: string,
  body: string,
  data: Record<string, unknown>,
  env: Env
): Promise<{ success: boolean; error?: string }> {

  // Build JWT
  const header = btoa(JSON.stringify({ alg: 'ES256', kid: env.APNS_KEY_ID }))
    .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');

  const now = Math.floor(Date.now() / 1000);
  const claims = btoa(JSON.stringify({ iss: env.APNS_TEAM_ID, iat: now }))
    .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');

  const signingInput = `${header}.${claims}`;

  // Import the ES256 private key from PEM
  const pemBody = env.APNS_PRIVATE_KEY
    .replace('-----BEGIN PRIVATE KEY-----', '')
    .replace('-----END PRIVATE KEY-----', '')
    .replace(/\s/g, '');

  const keyData = Uint8Array.from(atob(pemBody), c => c.charCodeAt(0));

  const cryptoKey = await crypto.subtle.importKey(
    'pkcs8',
    keyData,
    { name: 'ECDSA', namedCurve: 'P-256' },
    false,
    ['sign']
  );

  const signature = await crypto.subtle.sign(
    { name: 'ECDSA', hash: 'SHA-256' },
    cryptoKey,
    new TextEncoder().encode(signingInput)
  );

  const sig = btoa(String.fromCharCode(...new Uint8Array(signature)))
    .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');

  const jwt = `${signingInput}.${sig}`;

  // Send to APNs
  const payload = JSON.stringify({
    aps: {
      alert: { title, body },
      sound: 'default',
      'content-available': 1
    },
    ...data
  });

  const response = await fetch(
    `https://api.push.apple.com/3/device/${token}`,
    {
      method: 'POST',
      headers: {
        'authorization': `bearer ${jwt}`,
        'apns-topic': env.APNS_BUNDLE_ID,
        'apns-push-type': 'alert',
        'apns-priority': '10',
        'content-type': 'application/json'
      },
      body: payload
    }
  );

  if (response.status === 200) return { success: true };

  const err = await response.json() as { reason?: string };

  // Token no longer valid — deactivate it
  if (err.reason === 'BadDeviceToken' || err.reason === 'Unregistered') {
    return { success: false, error: err.reason, deactivate: true };
  }

  return { success: false, error: err.reason };
}

Note: Cache the JWT — it's valid for 1 hour. Generate once per Worker instance, reuse across requests. Add apnsJwt and apnsJwtGeneratedAt to a module-level cache object.


Phase 4 — FCM Stub (Android)

async function sendFCM(
  token: string,
  title: string,
  body: string,
  data: Record<string, unknown>,
  env: Env
): Promise<{ success: boolean; error?: string }> {

  if (!env.FCM_SERVER_KEY) {
    return { success: false, error: 'FCM not configured' };
  }

  // Full FCM implementation goes here when Firebase project is created
  // POST https://fcm.googleapis.com/fcm/send
  // Authorization: key={FCM_SERVER_KEY}

  return { success: false, error: 'FCM stub — not yet implemented' };
}

Phase 5 — Chain Event Integration

The following chain events in ucca-keys Worker should trigger a push notification by calling POST /api/push/send internally (server-to-server, using PUSH_SECRET).

Event Title Body
email_confirmed Your record is verified Email confirmed — UCCA chain active
k2_delivered Your key has arrived K2 delivered to your device
record_verified Verification complete Your UCCA record is fully verified
document_viewed Document access recorded {document_title} added to your chain
access_requested Access request received Someone requested access to your record

In ucca-keys, after writing each chain event to the ledger, call:

async function firePushEvent(
  contactHash: string,
  eventType: string,
  env: Env,
  ctx: ExecutionContext
): Promise<void> {

  const eventMap: Record<string, { title: string; body: string }> = {
    email_confirmed:   { title: 'Your record is verified',      body: 'Email confirmed — UCCA chain active' },
    k2_delivered:      { title: 'Your key has arrived',         body: 'K2 delivered to your device' },
    record_verified:   { title: 'Verification complete',        body: 'Your UCCA record is fully verified' },
    document_viewed:   { title: 'Document access recorded',     body: 'Added to your chain' },
    access_requested:  { title: 'Access request received',      body: 'Someone requested access to your record' }
  };

  const event = eventMap[eventType];
  if (!event) return;

  ctx.waitUntil(
    fetch('https://api.ucca.online/api/push/send', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Push-Secret': env.PUSH_SECRET
      },
      body: JSON.stringify({
        contact_hash: contactHash,
        title: event.title,
        body: event.body,
        data: { event_type: eventType }
      })
    }).catch(e => console.error('push fire failed:', e.message))
  );
}

Add PUSH_SECRET as a secret binding in ucca-keys wrangler.toml.


Ops Console — Apps World Block (reference)

When the ops console Apps world block is built, it pulls from /api/push/health and displays:

PUSH NOTIFICATIONS
──────────────────────────────────
Registered devices    iOS: 12  Android: 4
APNs Key ID           BDRMM4PZB6
Bundle ID             online.ucca.authenticator
Environment           Sandbox & Production
FCM                   ⚠ Not configured
──────────────────────────────────

Acceptance Criteria

  • Three tables created in engine-db (device_tokens, otp_codes, passkey_credentials)
  • POST /api/push/register — registers a device token, returns { registered: true }
  • POST /api/push/send — sends to all active tokens for a contact_hash
  • DELETE /api/push/register — deactivates a token
  • GET /api/push/health — returns device counts and key info
  • APNs JWT generation works via Web Crypto (no Node dependencies)
  • BadDeviceToken / Unregistered responses → token deactivated automatically
  • FCM stub returns { success: false, error: 'FCM stub' } cleanly
  • firePushEvent added to ucca-keys, fires on 5 chain event types
  • PUSH_SECRET added to both ucca-api and ucca-keys secrets
  • APNS_PRIVATE_KEY added to ucca-api secrets
  • Stop and confirm before touching ucca-keys chain event integration

Deploy Order

  1. Run D1 migrations on engine-db
  2. Add secrets to ucca-api (wrangler secret put)
  3. Deploy ucca-api with new routes + APNs function
  4. Test POST /api/push/register with a dummy token
  5. Test POST /api/push/send — confirm APNs call fires (will fail with dummy token, that's fine)
  6. Test GET /api/push/health
  7. Stop and confirm — then proceed to ucca-keys chain event integration

Brief locked: 13 March 2026 APNs Key: BDRMM4PZB6 · Team: B29TSCBPHD · engine-db: 0efa8970-0053-4623-8436-4e877af10887