better-push
Operate in production

Mount Studio in an application

studioHandler, the permission model, server-side redaction, and the audit trail.

The mounted studio is a second handler you put wherever your admin surface lives. It is not a method on the core instance: an admin console should not exist unless someone deliberately mounted one, and keeping it in its own subpath keeps its code and assets out of your app's main bundle.

app/admin/push/[...all]/route.ts
import { studioHandler } from "@better-push/core/studio";
import { toNextJsHandler } from "@better-push/core/nextjs";
import { push } from "@/push";

const studio = studioHandler(push, {
  permissions: async (request) => {
    const session = await auth(request);
    if (!session?.user.isAdmin) return null;   // -> 401
    return {
      actor: session.user.id,
      view: true,
      content: session.user.role === "support",
      write: session.user.role === "ops",
    };
  },
  allowWrites: process.env.NODE_ENV !== "production",
});

export const { GET, POST, PUT, PATCH, DELETE } = toNextJsHandler(studio);

The handler serves the JSON API under /api/* relative to its mount, and the SPA for every other path, with an index fallback so deep links work.

Link to a screen, not the bare mount

Next route handlers do not match the parent segment of their own catch-all, so /admin/push alone answers 404 while /admin/push/overview works. Point your admin nav at a screen path. The optional catch-all ([[...all]]) is still the right shape - it is what lets every other client-side route resolve.

The permission model

A permission set, not a boolean, because the three questions are genuinely different:

view grants metrics, counts, statuses, error codes, timings, and identifiers. content additionally grants notification title, body, and data payloads. write grants operator actions when allowWrites is also enabled. The full permission result is generated from its TypeScript interface below.

actor identifies the admin in the audit trail and is required. Returning null means "not an admin" and answers 401 on every route.

Seeing that a send failed is not the same as reading what it said, and neither implies the right to delete someone's device. A support engineer usually wants view + content; an on-call engineer usually wants view + write.

Redaction is server-side

When content is false the API does not include title, body, or data at all - not empty strings, not masked strings, absent. Rows carry redacted: true so the UI can render the affordance.

{
  "id": "8f2a…",
  "type": "orderShipped",
  "createdAt": "2026-07-29T10:00:00.000Z",
  "redacted": true,
  "deliveries": [ … ]
}

The redaction happens in the query layer, not the transport and not the UI: a row that was never authorised should never have had its content loaded into a response object in the first place. There is nothing for a client bug or a serializer change to leak.

Reveal is a separate, audited call

An admin with content still asks explicitly, per notification:

POST /api/reveal   { "notificationIds": ["8f2a…"] }

Each id writes its own bp_audit row. That is the point of splitting reveal from list: the record says exactly what someone looked at, rather than "loaded a page". Without content it is a 403 and nothing is written.

Writes need both switches

Every write endpoint requires allowWrites === true on the handler and write === true from your callback. Either one missing is a 403 - including the case where your callback grants write but the handler was constructed read-only.

allowWrites defaults to off, and turning it on logs a loud warning at construction, so an accidental production enable is visible in the first boot's logs rather than discovered after someone deletes a device.

ActionEndpoint
Retry a dead jobPOST /api/jobs/:id/retry
Purge dead jobsPOST /api/jobs/purge
Disable a devicePOST /api/devices/:id/disable
Delete a deviceDELETE /api/devices/:id
Reset preferencesPOST /api/users/:userId/preferences/reset
Send a testPOST /api/send-test

The audit trail

Every privileged action writes a bp_audit row: actor, action, target, and metadata. Audited actions are reveal, retry_job, purge_jobs, disable_device, delete_device, reset_preferences, and send_test.

SELECT created_at, actor, action, target_type, target_id
FROM bp_audit
ORDER BY created_at DESC
LIMIT 50;

The studio shows the same list on its Audit screen. Give bp_audit a retention window if you do not want it kept forever.

Health for uptime monitors

GET /api/health returns machine-readable health. Configure a healthSecret and a monitor can poll it with a bearer token instead of an admin session:

studioHandler(push, {
  permissions: resolveAdmin,
  healthSecret: process.env.BETTER_PUSH_HEALTH_SECRET,
});
curl -H "authorization: Bearer $BETTER_PUSH_HEALTH_SECRET" \
  https://your.app/admin/push/api/health
# {"status":"degraded","issues":["3 job(s) dead-lettered"], …}

The comparison is timing-safe. Without the secret configured, health needs view like everything else.

Read-only tiers are the normal case

A studio with allowWrites: false is a complete monitoring and forensics console - every screen works, nothing can be changed. Enabling writes is a separate decision from enabling the studio, and it is worth keeping it that way.

Studio interfaces

Prop

Type

Prop

Type

On this page