Quickback Docs

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 ({ organizationId, targetRoles? }, { userIds }, { targetTags }, { 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 the VapidConfig from VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / VAPID_SUBJECT.
  • A generated queue consumer — resolves declarative targets against push_subscriptions server-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 a 0600 file and never printed.
  • CMS + Account SPA bundles automatically carry VITE_QUICKBACK_VAPID_PUBLIC_KEY (and vapidPublicKey on window.__QUICKBACK_RUNTIME) so pushManager.subscribe({ applicationServerKey }) works without extra plumbing.

Quickstart

1. Mint a VAPID keypair

quickback push generate-keys --out .vapid-private-key

The 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-key

Avoid 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. A views.mine projection exposes only id, userAgent, lastSeenAt, createdAt for "Your devices" UIs.
  • Owner firewall — owner_id is 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), p256dh must decode to a 65-byte uncompressed P-256 point, and auth to a 16-byte secret. The https: check is not the whole SSRF story: at send time the push sender runs the endpoint through the same destination policy as outbound webhooks (src/lib/webhooks/url-policy.ts): every IANA special-purpose IPv4/IPv6 range (loopback, link-local and the cloud metadata address, RFC 1918, CGNAT, IPv4-mapped, ULA, NAT64, 6to4, Teredo), plus localhost, .local, .internal, .home.arpa and single-label names. Unlike webhooks it does not resolve DNS, so a public hostname that resolves to a private address is not caught. Together — https + the destination policy — this narrows the SSRF surface; treat it as defense-in-depth, not a guarantee that SSRF is impossible.
  • Proven identity tags — a tags column is stamped server-side from ctx: user:<id>, and — only when the request carries a scoped token — scope:<kind>:<subject> plus one scope:<kind>:<instance>#<role> per scope role the verified claim grants (the same dialect realtime tickets use). Client-supplied tags are ignored: they drive fan-out targeting. Org roles are never stamped — a device is user-owned, and org audiences are resolved from live membership at send time; 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(),   // owner column — auto-stamped on insert
  tags:       text("tags"),                 // JSON array of proven identity tags
  userAgent:  text("user_agent"),
  lastSeenAt: timestamp("last_seen_at", { withTimezone: true }),
  // ── quickback:audit (compiler-managed — edits are validated, not merged) ──
  createdAt:  timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
  modifiedAt: timestamp("modified_at", { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()),
  createdBy:  text("created_by"),
  modifiedBy: text("modified_by"),
  deletedAt:  timestamp("deleted_at", { withTimezone: true }),
  deletedBy:  text("deleted_by"),
});

export default defineTable(pushSubscriptions, {
  // `roles: ["USER"]` below requires an explicit owner predicate — the
  // pseudo-role validator reads the authored firewall, not the auto-detected one.
  firewall: [{ field: 'ownerId', equals: 'ctx.userId' }],
  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, ctx, env, effects }) {
    // ...your committed write happens here...

    // After-commit: notify this organization's admins (owners too, via
    // auth.roleHierarchy). The scope is on the send — resolved from
    // membership when the queue consumer runs, never from the device.
    const organizationId = ctx.activeOrgId;
    if (organizationId) {
      effects.enqueue("push:eventPublished", () =>
        enqueuePushFanout(
          env.PUSH_FANOUT_QUEUE!,
          { organizationId, 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 an organization with { organizationId, targetRoles? } or specific people with { userIds } — both work for every subscription, because they resolve against the row's owner rather than a stamped claim.

Targeting

A declarative target names exactly one audience:

TargetSelects
{ organizationId: 'org_1' }Devices of every current member of that organization
{ organizationId: 'org_1', targetRoles: ['admin'] }Devices of that org's members holding a listed role — auth.roleHierarchy-expanded, so an owner satisfies 'admin'
{ organizationId: 'org_1', targetRoles: [...], userIds: [...] }The same, narrowed to those users
{ userIds: ['u1', 'u2'] }Subscriptions owned by those users
{ targetTags: ['scope:event:e9#attendee'] }Rows whose stamped tags include ANY listed tag — scope:* tags exist only on scoped-token subscribes (see note below)
{ all: true }Every live subscription — broadcast must be this explicit
{ subscriptions: [...] }An explicit list you already hold

Org audiences are resolved at send time. The queue consumer reads the organization's Better Auth membership when it resolves the fan-out (one query; on D1 through the AUTH_DB binding), filters by role, and delivers to those members' devices. A member who is demoted or removed stops receiving on the very next send, and one organization's roles never reach another organization's members — a user who is an admin in one org and a member in another is an admin only for the first. targetRoles is only valid beside organizationId: a role without an organization has no tenant, so { targetRoles } alone is a type error and throws at runtime. The organizationId arm needs the Better Auth organization plugin; without it the target throws.

Selection is fail-closed: an empty array selects nobody (nothing is enqueued), a target with no arm throws, and arms do not combine ({ userIds, targetTags } throws) — accidental broadcast is structurally impossible. Tags are proven at subscribe time from ctx; clients cannot forge their way into an audience.

scope:* 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>; it never stamps a scope:* tag. Because targeting is fail-closed, a { targetTags: ['scope:event:e9#attendee'] } fan-out over session-created subscriptions delivers to nobody. Scope tags — identity and role alike — are frozen at subscribe (re-subscribing refreshes them), so a scope role revoked afterwards keeps matching until then; org roles have no such window because they are never stamped.

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.statusWhenAction
sentHTTP 200/201/202 from push serviceDone. messageId (from Location header) optionally available.
goneHTTP 404 or 410Subscription is permanently dead — soft-delete it.
failedOther 4xx/5xx, or network errorCaller 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) that enqueuePushFanout writes to, in batches of ≤100.
  • Consumer — a push-fanout branch in the unified src/queue-consumer.ts dispatcher, with max_batch_size: 1 so each ≤250-recipient chunk gets its own invocation. Declarative targets arrive as one resolve message; the consumer reads push_subscriptions (server-internal trust zone), applies the fail-closed tag match, and re-enqueues send chunks.
  • Per-recipient handling — one bad endpoint doesn't doom the chunk, and gone results are soft-deleted in the same invocation.
  • Dead letter queue — after 3 retries, failed chunks land on <queue>-dlq and 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.mine projection exposes only id, userAgent, lastSeenAt, createdAt — never the cryptographic material — for "Your devices" UIs.
  • VAPID_PRIVATE_KEY is 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

On this page