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.
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.
| Action | Endpoint |
|---|---|
| Retry a dead job | POST /api/jobs/:id/retry |
| Purge dead jobs | POST /api/jobs/purge |
| Disable a device | POST /api/devices/:id/disable |
| Delete a device | DELETE /api/devices/:id |
| Reset preferences | POST /api/users/:userId/preferences/reset |
| Send a test | POST /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