# Web Push

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

```bash
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:

```bash
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`

```typescript
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 additionally rejects any endpoint whose host resolves to a private, link-local, loopback, `localhost`, or `.internal` address. 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 `tags` column is stamped **server-side** from `ctx` (`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 stamps `user:` and `role:` but no `scope:` tag; see [Targeting](#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`:

```typescript
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](/define/access) 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):

```javascript
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:**

```typescript
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`:

```typescript
// 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](#targeting)); against ordinary session-created rows it selects nobody.

### Targeting

`enqueuePushFanout` speaks the same identity-tag dialect as [realtime targeting](/platform/realtime/durable-objects). 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](/platform/realtime/durable-objects) 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

```typescript
{
  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](/define/masking) 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`) 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

- [RFC 8030](https://datatracker.ietf.org/doc/html/rfc8030) — Generic Event Delivery Using HTTP Push
- [RFC 8291](https://datatracker.ietf.org/doc/html/rfc8291) — Message Encryption for Web Push (aes128gcm)
- [RFC 8292](https://datatracker.ietf.org/doc/html/rfc8292) — VAPID for Web Push
- [Web Push Protocol — MDN](https://developer.mozilla.org/en-US/docs/Web/API/Push_API)
