Web Push
VAPID + RFC 8291 push delivery from generated Workers code, with no Node polyfills.
Quickback's push pillar emits a self-contained Web Push delivery module into your project when you set push.provider: 'web-push'. The helper handles VAPID JWT signing (RFC 8292), aes128gcm payload encryption (RFC 8291), HTTP delivery to the push service, and 404/410 cleanup — using only crypto.subtle and fetch. No web-push npm package, no Node polyfills, runs unchanged in Cloudflare Workers, Bun, and Deno.
APNs is planned as a second provider. The helper signature is shared, so most code that calls sendPush today will keep working.
What you get
enqueuePushFanout(queue, target, payload, opts?)— the primary send surface. Takes a declarative audience ({ userIds },{ targetTags },{ targetRoles },{ all: true }) or an explicit subscription list, and routes between inline delivery and queue fan-out.sendPush(subscription, payload, vapid)— single-recipient delivery. Returns{ status: 'sent' | 'gone' | 'failed', statusCode, ... }.sendPushBulk(subscriptions, payload, vapid, opts?)— in-request fan-out with a default concurrency cap of 8 (Workers subrequest-budget friendly).vapidFromEnv(env)— assembles theVapidConfigfromVAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY/VAPID_SUBJECT.- A generated queue consumer — resolves declarative targets against
push_subscriptionsserver-side and delivers chunks of up to 250 recipients per invocation, with automatic 404/410 cleanup. quickback push generate-keys [--out <file>]— mints a VAPID keypair in the right format. With--out, the private key is written to a0600file and never printed.- CMS + Account SPA bundles automatically carry
VITE_QUICKBACK_VAPID_PUBLIC_KEY(andvapidPublicKeyonwindow.__QUICKBACK_RUNTIME) sopushManager.subscribe({ applicationServerKey })works without extra plumbing.
Quickstart
1. Mint a VAPID keypair
quickback push generate-keys --out .vapid-private-keyThe public key prints to the terminal; the private key goes only into the file (mode 0600). Load it into a Wrangler secret and delete the file:
npx wrangler secret put VAPID_PRIVATE_KEY < .vapid-private-key
rm .vapid-private-keyAvoid echo "<key>" | wrangler secret put … — that writes the private key into your shell history. Without --out, the key prints once to the terminal and wrangler secret put prompts for an interactive paste.
The private key is minted only once. Losing it means re-subscribing every user.
2. Configure quickback.config.ts
push: {
provider: 'web-push',
vapidPublicKey: 'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U',
vapidPrivateKey: 'env:VAPID_PRIVATE_KEY',
vapidSubject: 'mailto:notifications@example.com',
defaultSchema: true,
}env:VAPID_PRIVATE_KEY is the convention for "resolve from wrangler secret put at runtime" — a literal private key in config is a compile error, and the scalar is never written into wrangler.toml or any committed file. The worker refuses to boot (503 with an explicit missing-var list) until all three VAPID vars are present.
The public key is not a secret. Keep it literal in config so it can be baked into the CMS/Account SPA bundles — an env:-referenced public key can't be resolved at compile time, so nothing is baked and your subscribe UI has to source it some other way.
3. The push_subscriptions table
With push.defaultSchema: true, the compiler injects a hardened, owner-scoped push_subscriptions resource:
- INTERNAL-only reads — the crypto fields (
endpoint,p256dh,auth) never leave the worker via any HTTP path. Aviews.mineprojection exposes onlyid,userAgent,lastSeenAt,createdAtfor "Your devices" UIs. - Owner firewall —
owner_idis auto-stamped on create; user A can never read or delete user B's subscription. - Create-time validation — the endpoint must be
https:(the worker POSTs VAPID-signed requests at that URL, so an arbitrary endpoint is an SSRF primitive),p256dhmust decode to a 65-byte uncompressed P-256 point, andauthto a 16-byte secret. Thehttps:check is not the whole SSRF story: at send time the push sender additionally rejects any endpoint whose host resolves to a private, link-local, loopback,localhost, or.internaladdress. Together — https + a non-public-host block — this narrows the SSRF surface; treat it as defense-in-depth, not a guarantee that SSRF is impossible. - Proven identity tags — a
tagscolumn is stamped server-side fromctx(user:<id>,role:<name>, and — only when the request carries a scoped token —scope:<kind>:<subject>, the same dialect realtime tickets use). Client-supplied tags are ignored: they drive fan-out targeting. A normal session-cookie subscribe therefore stampsuser:androle:but noscope:tag; see Targeting. - Rate limit + cap — creates are limited to 10/minute per caller, and only the newest 10 live subscriptions per user are kept (older rows are soft-deleted, so a user's new device always works).
This create-time hardening (endpoint/key validation, server-side tag stamping, the per-user cap) runs in JS before/after-insert hooks that fire only on audit-wrapped writes — the REST create path and actions. The normal browser subscribe is REST, so it's fully covered; but raw-SQL writes, queue/cron inserts, or direct INTERNAL-context inserts into push_subscriptions bypass all of it. Don't hand-insert subscription rows expecting the guards to apply.
Declaring your own push_subscriptions table while defaultSchema: true is set is a compile error — pick one owner for the schema.
If you roll your own (write it in canonical pgTable form; the compiler transforms per target dialect), keep the same security stance and column names — the generated fan-out consumer resolves targets against endpoint / p256dh / auth / ownerId / tags / deletedAt:
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { defineTable } from "@quickback/compiler";
export const pushSubscriptions = pgTable("push_subscriptions", {
id: text("id").primaryKey(),
endpoint: text("endpoint").notNull().unique(),
p256dh: text("p256dh").notNull(),
auth: text("auth").notNull(),
ownerId: text("owner_id").notNull(), // user-scoped firewall auto-detected
tags: text("tags"), // JSON array of proven identity tags
userAgent: text("user_agent"),
lastSeenAt: timestamp("last_seen_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
deletedAt: timestamp("deleted_at", { withTimezone: true }),
});
export default defineTable(pushSubscriptions, {
read: {
// Crypto fields never leave the worker — even the owning user can't
// enumerate their own subscriptions via the API. A compromised session
// must not be able to leak the user's other devices.
access: { roles: ['INTERNAL'] },
views: {
mine: {
fields: ['id', 'userAgent', 'lastSeenAt', 'createdAt'],
access: { roles: ['USER'] },
},
},
},
crud: {
create: { access: { roles: ['USER'] } },
delete: { access: { roles: ['USER'] }, mode: 'soft' },
},
});See Access — INTERNAL pseudo-role for the full security stance.
4. Subscribe from the browser (DIY for now)
Quickback does not yet generate a service worker or subscribe UI — this half is yours. Two pieces:
A minimal sw.js (serve it from your app's origin root):
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {};
event.waitUntil(
self.registration.showNotification(data.title ?? 'Notification', {
body: data.body,
icon: data.icon,
tag: data.tag,
data, // url + your structured data, read back in notificationclick
}),
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = event.notification.data?.url;
if (url) event.waitUntil(clients.openWindow(url));
});Subscribe + register with the API:
import { api } from './api-client';
async function subscribeToPush() {
await navigator.serviceWorker.register('/sw.js');
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
// Baked at compile time; also available as
// window.__QUICKBACK_RUNTIME.vapidPublicKey on served SPA shells.
applicationServerKey: import.meta.env.VITE_QUICKBACK_VAPID_PUBLIC_KEY,
});
// The subscription object exposes endpoint + base64url-encoded keys.
const json = sub.toJSON() as { endpoint: string; keys: { p256dh: string; auth: string } };
await api.post('/push_subscriptions', {
endpoint: json.endpoint,
p256dh: json.keys.p256dh,
auth: json.keys.auth,
userAgent: navigator.userAgent,
});
}Don't send a tags field — the server stamps it from the session and ignores anything the client supplies.
5. Send from an action
Sends ride the after-commit effects phase, so a rolled-back write never notifies anyone and dryRun sends nothing. Enqueue the fan-out from inside execute:
// features/events/actions/publish.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
import { enqueuePushFanout } from "../../../lib/web-push";
export default defineAction({
description: "Publish an event and notify admins.",
input: z.object({}),
access: { roles: ["owner", "admin"] },
async execute({ record, env, effects }) {
// ...your committed write happens here...
// After-commit: notify every subscribed admin.
effects.enqueue("push:eventPublished", () =>
enqueuePushFanout(
env.PUSH_FANOUT_QUEUE!,
{ targetRoles: ["admin"] },
{
title: record.name,
body: "Event published — tap to review",
url: `https://app.example.com/events/${record.id}`,
tag: `event-${record.id}`,
},
),
);
return { published: true };
},
});The declarative form enqueues one small message; the generated queue consumer resolves the audience against push_subscriptions inside the server-internal trust zone and fans out delivery chunks. Subscription crypto never enters your action code.
Target by { targetRoles } or { userIds } — these match the role:<name> and user:<id> tags that every subscription carries, because they're stamped from ctx on a normal session-cookie subscribe. { targetTags: ['scope:events:e9'] } only matches subscriptions created inside a scoped context (see the note under Targeting); against ordinary session-created rows it selects nobody.
Targeting
enqueuePushFanout speaks the same identity-tag dialect as realtime targeting. For the common browser subscribe flow, reach for { targetRoles } or { userIds } first — those tags are stamped on every session-created subscription:
| Target | Selects |
|---|---|
{ targetRoles: ['admin'] } | Rows whose tags include role:admin (any listed role) |
{ userIds: ['u1', 'u2'] } | Subscriptions owned by those users |
{ all: true } | Every live subscription — broadcast must be this explicit |
{ targetTags: ['scope:events:e9'] } | Rows whose stamped tags include ANY listed tag — but scope:* tags exist only on scoped-token subscribes (see note below); against session-created rows this selects nobody |
{ targetTags: [...], targetRoles: [...] } | Rows matching BOTH arms (AND across arms, union within) |
{ subscriptions: [...] } | An explicit list you already hold |
Selection is fail-closed: an empty array selects nobody (nothing is enqueued), and a target with no arms at all throws — accidental broadcast is structurally impossible. Tags are proven at subscribe time from ctx; clients cannot forge their way into an audience.
scope:<kind>:<subject> tags need a scoped token. ctx.scope is populated only from a verified scoped-JWT claim — the same relationship-derived scope the realtime pillar uses. A normal browser subscribe authenticated with a session cookie stamps only user:<id> and role:<name>; it never stamps a scope:* tag. Because targeting is fail-closed, a { targetTags: ['scope:events:e9'] } fan-out over session-created subscriptions silently delivers to nobody. Scope targeting works only if your app subscribes users from within a scoped context; otherwise select by role or user id.
With an explicit { subscriptions } list, passing opts.vapid (via vapidFromEnv(env)) lets lists at or under push.fanout.threshold (default 500) deliver inline with per-recipient results; larger lists chunk onto the queue.
Notifications API payload
{
title: 'Your invite is ready',
body: 'Tap to RSVP for The Summit',
url: 'https://attend.vip/rsvp/abc123', // click destination
icon: 'https://attend.vip/icon-192.png', // 192px PNG
tag: 'invite-abc123', // collapse with existing notif of same tag
data: { eventId, guestId }, // arbitrary structured data
topic: 'invites', // push-service collapse key
ttl: 86400, // seconds; default push.defaultTtl (86400)
urgency: 'high', // very-low | low | normal | high; default push.defaultUrgency
}The browser's service worker decides what to do with the data (see the sw.js above).
Masking does not cover push payloads
Push payloads are an egress channel the masking pillar does not see. Masking applies to API reads — a payload you build in an action ships exactly what you put in it, to every device in the audience. Before putting a field in title/body/data, ask whether every targeted recipient is allowed to see it unmasked. Prefer notification-shaped summaries plus a url — let the click-through fetch the details through the API, where firewall, access, and masking all apply.
Status semantics
result.status | When | Action |
|---|---|---|
sent | HTTP 200/201/202 from push service | Done. messageId (from Location header) optionally available. |
gone | HTTP 404 or 410 | Subscription is permanently dead — soft-delete it. |
failed | Other 4xx/5xx, or network error | Caller decides whether to retry. retryAfter populated if the service returned a Retry-After header. |
Treat gone as authoritative. Once a push service returns 410, the endpoint will never work again. The generated queue consumer soft-deletes gone rows automatically; only inline sendPush/sendPushBulk callers need to handle it themselves.
Queue fan-out
Fan-out above the Workers subrequest budget runs through Cloudflare Queues. The compiler wires everything when push.provider is set:
- Producer — a
[[queues.producers]]binding (PUSH_FANOUT_QUEUE) thatenqueuePushFanoutwrites to, in batches of ≤100. - Consumer — a
push-fanoutbranch in the unifiedsrc/queue-consumer.tsdispatcher, withmax_batch_size: 1so each ≤250-recipient chunk gets its own invocation. Declarative targets arrive as oneresolvemessage; the consumer readspush_subscriptions(server-internal trust zone), applies the fail-closed tag match, and re-enqueuessendchunks. - Per-recipient handling — one bad endpoint doesn't doom the chunk, and
goneresults are soft-deleted in the same invocation. - Dead letter queue — after 3 retries, failed chunks land on
<queue>-dlqand are logged by count only: fan-out payloads carry subscription crypto and must never hit logs.
Tune via push.fanout.{threshold, chunkSize, queueName, maxConcurrency} — defaults 500 / 250 / {name}-push-fanout / 5. chunkSize is hard-capped at 250 (~25% of the subrequest budget, ~62KB message size).
Browser support
- ✅ Chrome (Android, desktop) via FCM
- ✅ Firefox (Android, desktop) via Mozilla autopush
- ✅ Edge (desktop) via Windows Push Notification Service
- ✅ Safari iOS 16.4+ — only inside installed PWAs (Add to Home Screen). Apple's push endpoints have known quirks around payload size and TTL semantics; test on real hardware.
- ❌ Safari macOS — partial support; the helper works but iOS PWA is the supported path.
Privacy posture
Push subscription rows contain endpoints that uniquely identify a user's browser instance. Quickback's defaults treat them as personal data:
read.access: { roles: ['INTERNAL'] }on the table — no public CRUD path, even for the owning user. Compromised sessions cannot enumerate the user's other devices.views.mineprojection exposes onlyid,userAgent,lastSeenAt,createdAt— never the cryptographic material — for "Your devices" UIs.VAPID_PRIVATE_KEYis wrangler-secret-only; a literal scalar in config is a compile error.- Declarative targeting keeps subscription crypto out of action code entirely — the audience resolves inside the queue consumer.
- 410 cleanup is automatic in the queue consumer, so dead subscription rows don't accumulate.
GDPR Article 32 maps cleanly: push endpoints are restricted to those who need them (the worker code that sends), not anyone who could read the table via the API.
References
- RFC 8030 — Generic Event Delivery Using HTTP Push
- RFC 8291 — Message Encryption for Web Push (aes128gcm)
- RFC 8292 — VAPID for Web Push
- Web Push Protocol — MDN