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¶
- Three new D1 tables in engine-db
- Four new endpoints on ucca-api Worker
- APNs delivery function (iOS)
- FCM delivery function (Android) — stub only until Firebase project created
- 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 -
firePushEventadded 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¶
- Run D1 migrations on engine-db
- Add secrets to ucca-api (
wrangler secret put) - Deploy ucca-api with new routes + APNs function
- Test
POST /api/push/registerwith a dummy token - Test
POST /api/push/send— confirm APNs call fires (will fail with dummy token, that's fine) - Test
GET /api/push/health - 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