push notifications: design, and the device registry

Design in docs/push-notifications.md. The short version: Apple and Google are unavoidable —
iOS suspends apps so only APNs can wake one, and Android only accepts pushes from FCM — but
Expo is not. Its push service is a relay in front of both and does not remove either
credential, so we talk to Apple and Google ourselves.

Both protocols were verified in Bun before designing around them: node:http2 works as a
client (APNs is HTTP/2-only), and ES256 signing produces the raw 64-byte r||s form Apple
requires rather than Node's default DER, which is silently rejected. No push library is
needed for either channel.

The load-bearing decision is the payload: a push is a doorbell, not a message. Apple and
Google see metadata regardless, so they must not also see content — a notification carries a
category and an id, never a subject, sender or error, and the app composes the visible text
locally and fetches the real thing over the tailnet on tap.

This commit is the registry: push_devices, holding native APNs/FCM tokens. environment is a
column because APNs sandbox and production are different hosts AND different token
namespaces — a debug-build token fails against production with a silent BadDeviceToken, so
guessing is not an option. Registration upserts on (token, bundle_id) because tokens rotate
and the app re-registers every launch. Failure counting prunes dead tokens; a hard rejection
deletes at once.

Nothing sensitive lands here: a token is useless without the APNs key or FCM service
account, both of which stay in the sidecar's env.

NOTE: `bun db:push` will fail until the telegram/whatsapp/discord rows are deleted from
server_integrations — the CHECK constraint added earlier refuses while they exist. That is
the enforcement working, not a problem to route around.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 21:27:07 +00:00
co-authored by Claude Opus 5
parent cffb99b9d0
commit 15f262539f
6 changed files with 284 additions and 8 deletions
+9
View File
@@ -181,3 +181,12 @@ export type { WalletChainSnapshot } from './schema/wallet';
export { db } from './db';
export * as schema from './schema';
export {
upsertPushDevice,
getPushDevices,
deletePushDevice,
recordPushFailure,
markPushDeviceSeen,
} from './queries/notify';
export type { PushDeviceSelect, PushDeviceInsert } from './types';
@@ -0,0 +1,64 @@
import { eq, and, sql } from 'drizzle-orm';
import { db } from '../db';
import { pushDevices } from '../schema';
import type { PushDeviceSelect, PushDeviceInsert } from '../types';
// The push device registry. Only the officer-notify sidecar uses these.
/**
* Register a device, or refresh one already known. The app calls this on EVERY launch — push tokens
* rotate silently, and a stale row is a device that stops receiving with no error anywhere — so this is
* an upsert on (token, bundleId) rather than an insert, and it clears any accumulated failures.
*/
export async function upsertPushDevice(data: PushDeviceInsert): Promise<PushDeviceSelect> {
const [row] = await db
.insert(pushDevices)
.values(data)
.onConflictDoUpdate({
target: [pushDevices.token, pushDevices.bundleId],
set: {
userId: data.userId,
platform: data.platform,
environment: data.environment ?? 'production',
appSlug: data.appSlug,
failureCount: 0,
lastSeenAt: new Date(),
},
})
.returning();
return row!;
}
/** Every device for a user, optionally narrowed to one app. */
export async function getPushDevices(userId: number, appSlug?: string): Promise<PushDeviceSelect[]> {
const where = appSlug
? and(eq(pushDevices.userId, userId), eq(pushDevices.appSlug, appSlug))
: eq(pushDevices.userId, userId);
return db.select().from(pushDevices).where(where);
}
/** Sign-out, or a hard rejection from Apple/Google (410 Unregistered, UNREGISTERED). */
export async function deletePushDevice(token: string): Promise<void> {
await db.delete(pushDevices).where(eq(pushDevices.token, token));
}
/**
* A soft failure — a timeout, a 5xx. Three consecutive strikes and the row goes: without pruning, the
* registry fills with dead tokens and delivery quietly degrades. Any successful send resets the count
* via `upsertPushDevice`, so only *consecutive* failures accumulate.
*/
export async function recordPushFailure(token: string): Promise<void> {
await db
.update(pushDevices)
.set({ failureCount: sql`${pushDevices.failureCount} + 1` })
.where(eq(pushDevices.token, token));
await db.delete(pushDevices).where(and(eq(pushDevices.token, token), sql`${pushDevices.failureCount} >= 3`));
}
/** A send worked: clear the failure count and mark the device alive. */
export async function markPushDeviceSeen(token: string): Promise<void> {
await db
.update(pushDevices)
.set({ failureCount: 0, lastSeenAt: new Date() })
.where(eq(pushDevices.token, token));
}
+9 -8
View File
@@ -1,13 +1,14 @@
export * from './auth';
export * from './user-data';
export * from './dashboards';
export * from './operations';
export * from './server';
export * from './email';
export * from './pipeline-jobs';
export * from './chat-events';
export * from './music';
export * from './soulseek';
export * from './dashboards';
export * from './email';
export * from './headscale';
export * from './music';
export * from './notify';
export * from './operations';
export * from './pipeline-jobs';
export * from './server';
export * from './soulseek';
export * from './user-data';
export * from './vault';
export * from './wallet';
@@ -0,0 +1,65 @@
import { pgTable, serial, integer, text, timestamp, unique, index, check } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { users } from './auth';
// Devices that can receive a push, for the officer-notify sidecar.
//
// A token here is a NATIVE one — an APNs device token or an FCM registration token — because we send to
// Apple and Google directly rather than through Expo. See docs/push-notifications.md.
//
// Nothing sensitive is stored: a push token identifies a device's ability to receive, not the device or
// its owner, and it is useless without our APNs key or FCM service account. Those live in the sidecar's
// env and never touch this database. Notification CONTENT is never stored here either — payloads are a
// category and an id, assembled at send time and never retained.
//
// Only the officer-notify sidecar reads or writes this table.
const PLATFORMS = ['ios', 'android'] as const;
// APNs sandbox and production are different hosts AND different token namespaces: a token minted by a
// debug build fails against production with a silent BadDeviceToken. Android has no such split, but the
// column is not nullable — an android row is simply always 'production'.
const ENVIRONMENTS = ['production', 'sandbox'] as const;
export const pushDevices = pgTable(
'push_devices',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
token: text('token').notNull(),
platform: text('platform').notNull(),
environment: text('environment').notNull().default('production'),
/** The `apns-topic`, and what distinguishes the three apps from each other. */
bundleId: text('bundle_id').notNull(),
/** `mobile` | `music` | `read-aloud` — for targeting a notification at one app. */
appSlug: text('app_slug').notNull(),
/** Consecutive send failures. Three strikes and the row is deleted; a hard rejection deletes at once. */
failureCount: integer('failure_count').notNull().default(0),
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
// One row per token per app. The app re-registers on every launch because tokens rotate silently, so
// registration is an upsert on this pair rather than an insert.
unique('uq_push_devices_token_bundle').on(table.token, table.bundleId),
index('idx_push_devices_user').on(table.userId),
// The schema is the source of truth for what may exist, not just its shape — see
// src/databases/CLAUDE.md. sql.raw because an interpolated string binds as a parameter, which
// Postgres refuses inside a CHECK and which breaks push for the whole schema.
check(
'ck_push_devices_platform',
sql`${table.platform} IN (${sql.join(
PLATFORMS.map((p) => sql.raw(`'${p}'`)),
sql`, `,
)})`,
),
check(
'ck_push_devices_environment',
sql`${table.environment} IN (${sql.join(
ENVIRONMENTS.map((e) => sql.raw(`'${e}'`)),
sql`, `,
)})`,
),
],
);
+4
View File
@@ -73,3 +73,7 @@ export type ServerIntegrationInsert = typeof Schema.serverIntegrations.$inferIns
export type PipelineJobSelect = typeof Schema.pipelineJobs.$inferSelect;
export type PipelineJobInsert = typeof Schema.pipelineJobs.$inferInsert;
// Notifications
export type PushDeviceSelect = typeof Schema.pushDevices.$inferSelect;
export type PushDeviceInsert = typeof Schema.pushDevices.$inferInsert;