NAME
Punk::Plugin::Push - Web Push notifications, encrypted end to end
SYNOPSIS
package MyApp;
use Punk;
use Punk::Plugin::Push;
host 'https://example.com';
auth ...; # subscribe and unsubscribe are guarded
plugin 'Push' => {
subject => 'mailto:ops@example.com',
public_key => { '$env' => 'VAPID_PUBLIC' },
private_key => { '$env' => 'VAPID_PRIVATE' },
};
post '/reports' => sub {
my ($c) = @_;
$c->push_send($c->auth_id, {
title => 'Your report is ready',
body => 'Three pages, as usual.',
url => '/reports/2026-09',
});
return $c->json({ ok => 1 });
};
DESCRIPTION
An application that wants to reach a user who has closed the tab has one standard way to do it: the Push API, delivered through the browser vendor's push service. This is that, for Punk.
The message is encrypted end to end, so the push service relays bytes it cannot read. VAPID does the cryptography - RFC 8291 message encryption over the RFC 8188 aes128gcm content encoding, and RFC 8292 identification - and this plugin supplies the routes, the storage, the delivery and the pruning around it.
Generate a keypair once with punk push keys.
What it serves
GET /push/key the VAPID public key, for the browser
POST /push/subscribe store a subscription (guarded)
POST /push/unsubscribe remove one (guarded)
GET /push/push.js the client half (assets => 1)
GET /push/push-sw.js a minimal service worker (assets => 1)
prefix moves all five. The assets are read once at to_app and served from frozen bytes with an ETag.
An unguarded subscribe is an open relay
A subscription belongs to a user. An unauthenticated POST that writes one lets anybody who can guess a user id register their own browser to receive that user's notifications - a disclosure bug with a delivery mechanism attached.
So the write routes are guarded, by the application's auth unless guard names something else, and an application with neither is refused at to_app rather than served unguarded. GET /push/key is not guarded; it is a public key.
Unsubscribe removes a row only when it belongs to the authenticated user. Deleting by endpoint alone would let any authenticated user unsubscribe any other, and an endpoint is not a secret: it is sent to a third party on every delivery.
The keys are configuration and are never generated for you
subject, public_key and private_key croak at the plugin line, because nothing declared later can supply them.
The tempting convenience is to mint a keypair when none is configured. It is wrong in a way that takes weeks to notice: Hyperman runs a pool, so each worker would generate a different key, a subscription created against one worker would be undeliverable from every other, and a restart would invalidate the lot. Every failure would look like an intermittent push service.
subject is required by RFC 8292 and push services reject a JWT without one, so leaving it out produces a 403 whose body explains nothing. It must be a mailto: address or an http:/https: URL, and anything else croaks at the plugin line rather than at the first delivery.
An endpoint is a URL you will POST to, so it is validated like one
The endpoint a browser hands you is a URL this server will POST to, on a schedule the client chose, with a body the client cannot read. Unconstrained, that is a server-side request forgery primitive.
So it must be an absolute https URL. p256dh must decode to exactly 65 bytes beginning 0x04 and auth to exactly 16, both checked on the decoded bytes - a base64url decoder that ignores a bad character turns a corrupt key into a short one, and the length is what catches that. See Punk::Push::Subscription.
One audience per endpoint
The VAPID token's aud is the endpoint's origin, including a non-default port. A token minted for one push service is not valid at another, so it is computed per endpoint and cached against that origin. Caching one across a fan-out is the bug that makes Firefox work and Chrome fail on the same send.
A 410 means gone, and gone means deleted
A 404 or 410 from the push service means the subscription is permanently gone, and the row is deleted. Anything else - a 5xx included - leaves it alone: that is the push service having a bad day.
The asymmetry is the point. Deleting on the wrong signal costs a subscription that cannot be recreated without the user, because they have to grant permission again and browsers make asking twice deliberately hard. Keeping a dead one costs a wasted request.
Storage is only touched for a subscription that came from storage. One handed to "send_to" directly belongs to its caller.
There is a hard size limit, and it is smaller than it looks
RFC 8291 guarantees a push service will accept only 4096 octets of encrypted payload, and the encoding spends 86 bytes on the record header, one on the padding delimiter and 16 on the authentication tag before any of your message.
An oversize payload croaks with its measured size and by how much to shorten it. The alternative is discovering it as a 413 from a push service, per subscription.
It sends on the worker's own loop
Inside a request the send goes through $c->ua - the one Fetch agent per worker that Punk::UA builds, bound to the same event loop that serves inbound requests, with its pooled connections and its fork check.
That is the difference between a send costing the worker nothing and costing it the whole round trip to the push service. A privately constructed agent gets a standalone loop, and awaiting a future on it pumps that loop: the worker stops answering anything else until the push service replies, which is not a number this application controls.
Outside a request - a job, a cron, punk push send - there is no worker loop to join, so the plugin builds its own agent and ->get blocks. That is the degradation Punk::UA documents for $c->ua itself: slower, never broken.
"send_to" and "send" therefore take either a context or the application. Hand them the context when you have one.
Stale by nobody's design
With queue => 0, the default, a 5xx is reported to the caller and not retried. This plugin does not queue, sleep or retry on its own.
queue => 1 hands delivery to Punk::Queue, which is where retries and backoff already live. Note that only the $c->push_send helper enqueues: "send_to" always sends inline, which is what the worker running the task must do, or a job would enqueue itself forever.
The worker is served with Service-Worker-Allowed
A service worker's scope defaults to the directory it is served from, so one at /push/push-sw.js could only ever control /push/*. Registering it for the site is refused outright:
The path of the provided scope ('/') is not under the max scope
allowed ('/push/')
Notifications are site-wide - notificationclick focuses whatever page the user has open - so the worker is served with Service-Worker-Allowed: /, which raises that ceiling.
If you serve the worker yourself (assets => 0), either put it at the site root or send the same header. This is the first thing to check when registration fails.
A service worker outlives your deploy
assets => 1 is for getting a demo working.
A service worker is a cache with a lifetime of its own: the browser keeps the one it has and updates it on its own schedule, so once installed browsers are asking for /push/push-sw.js you cannot simply stop serving it. Past a demo, copy both files into your own tree, serve them from your own static mount, and set assets => 0.
There is also only one service worker per scope. If you already register one for offline support, merge the two handlers into it rather than registering a second, or whichever registered last wins and the other quietly stops working.
OPTIONS
plugin 'Push' => {
subject => 'mailto:ops@example.com', # REQUIRED
public_key => '...', # REQUIRED
private_key => '...', # REQUIRED
prefix => '/push',
guard => undef, # default: the application's auth
assets => 1, # serve push.js and push-sw.js
model => 'PushSubscription',
ttl => 2419200, # seconds a push service may hold it
urgency => 'normal', # very-low | low | normal | high
queue => 0, # deliver through Punk::Queue
sqitch => 0, # ship the DDL as a Sqitch project
timeout => 10,
};
An unknown option croaks, naming what was available. A misspelled option is a setting that silently did not apply, and urgencey would leave every notification at the default while the application believed otherwise.
STORAGE
A model, Punk::Model::PushSubscription, registered under its full class name when the application has not declared one of its own. Declare model => 'MyName' to use yours; the DDL is in that module's POD, and sqitch => 1 ships it as the punk_push Sqitch project when Punk::Sqitch is installed.
endpoint is unique, and that is load-bearing: a browser re-subscribing produces the same endpoint, and without the constraint every re-subscribe adds a row and one send fans out across the duplicates.
HELPERS
$c->push_send($who, \%payload, %opts)
$who is a user id, or one subscription hashref. A user id fans out over every subscription that user has - people have a phone and a laptop, and a notification that reaches only one of them is a bug report.
Returns a Punk::Push::Result per subscription. One failure does not abort the rest. A user with no subscriptions is an empty list, not an error: that is the ordinary state of most users.
%opts takes ttl, urgency and topic. topic is worth knowing about: a push service replaces an undelivered message carrying the same topic rather than queueing both, which is the difference between a phone showing one badge on reconnect and thirty.
$c->push_subscribe(\%subscription)
Store a subscription for the current user. Upserts on the endpoint.
$c->push_unsubscribe($endpoint)
Remove one, only when it belongs to the current user.
$c->push_key
The VAPID public key, for a template that would rather inline it than fetch /push/key.
OUTSIDE A REQUEST
A job, a cron and the CLI have no $c. These take the compiled application, which is where the keys and the subscription store live.
send_to
Punk::Plugin::Push->send_to($app, $subscription, \%payload, %opts);
Punk::Plugin::Push->send_to($c, $subscription, \%payload, %opts);
One subscription, sent inline. Never enqueues, whatever queue says - this is what the queue worker itself calls.
Takes a context or the application. With a context the send goes on that worker's loop-bound agent; with the application it builds its own, which is right where there is no worker loop to join.
send
Punk::Plugin::Push->send($app, $user_id, \%payload, %opts);
Punk::Plugin::Push->send($c, $user_id, \%payload, %opts);
The same fan-out $c->push_send performs. Takes a context or the application, as "send_to" does.
SEE ALSO
Punk::Push, Punk::Push::Subscription, Punk::Push::Result, Punk::Model::PushSubscription, Punk::Command::Push, VAPID, Punk, Punk::Plugin.
AUTHOR
LNATION <email@lnation.org>
LICENSE AND COPYRIGHT
This software is Copyright (c) 2026 by LNATION <email@lnation.org>.
This is free software, licensed under:
The Artistic License 2.0 (GPL Compatible)