Revision history for Punk
0.30 2026-08-22
- `csrf: true`, `cors: true` and `headers: true` in punk.yml now
register.
- The config guardrail no longer flags a key that only names
where a secret lives: `token_model`, `password_field`,
`secret_path` - anything ending in _model, _class, _table,
_field, _column, _header, _cookie, _path, _dir, _name or
_kind - holds a model, a column or a location, not a secret.
0.29 2026-08-22
- Punk::Plugin::Sitemap: a missing `base` now croaks at to_app
rather than at `plugin`
- ADDS: `host ..., allow => [...]` - the hosts that may stand in
for the canonical one, and $c->origin / $c->host_allowed: the
request's origin only when its Host is declared, the canonical
origin otherwise, never the raw header.
- ADDS: `favicon` - GET /favicon.ico from bytes frozen at
to_app, with Cache-Control, a strong ETag and 304s. A missing
file croaks at boot. Replaces the send_file boilerplate.
- `host` and `favicon` are now DSL keywords: an application
defining its own sub with either name sees a redefinition
warning at boot.
- Test fix: t/1011-ws-live.t and t/1030-room-bus.t read one
frame per sysread and threw away the rest of the buffer
0.28 2026-08-21
- ADDS: Punk::Plugin::I18n - translations and language
negotiation.
- REQUIRES Template::Stencil 0.10, for tied hashes in a path.
The `locale` hash is tied so a template lookup reads the
shared catalogue directly
- Punk::Cache single-flight fix: a loser that acquired the lock
after the winner finished went straight to computing without
looking again, so the herd computed twice.
- Improve test coverage
0.27 2026-08-21
- Fix SEGV/BUS on every request carrying a session cookie
- t/0914-session-pool.t now names the signal when a forked
worker dies, instead of reporting four uninitialized values
and hiding the crash that caused them.
0.26 2026-08-21
- `session store => ...` keeps the session server-side and the
cookie carries a signed 128-bit id.
- `$c->session_expire` now REVOKES a stored session: it deletes
the entry, so a cookie somebody copied is dead on its next
request. Without a store it is unchanged, and still can only
ask the browser in front of it to forget.
- `$c->session_rotate` keeps the session and gives it a new id,
deleting the old entry.
- `session sliding => 1` extends a stored session while it is
being used
- `session tier => $seconds` lets a stored session be read
through the cache's memory tier, which it otherwise goes
round. The number is how long a revoked session may keep
working on a worker that missed the invalidation, so it is
capped and checked against the store's own memory_ttl.
0.25 2026-08-21
- Punk::Cache::stats no longer panics on a tiered store. The
XSUB asked the value stack for four fewer slots than it
pushes, which a DEBUGGING perl catches and turns into
"failed to extend arg stack",
- Punk::Plugin::Blob accepts a CODEREF `namespace` again. The
required-option check read the length of a scalar that has
no string body, which aborts a DEBUGGING perl outright.
- Punk::Plugin::Blob resolves Apophis on every perl. The ABI
pointer was popped with a macro that mentions its argument
twice, so it came off the wrong stack slot and the plugin
refused to load
- Punk::Plugin::Metrics reports its numbers on a long double or
quadmath perl.
0.24 2026-08-20
- Large uploads no longer cost what they weigh. A
multipart part over 64KB is written to a temp file as it
arrives rather than copied into memory, and Punk::Upload
carries a `path` and an `fh` to it. Measured end to end
through a socket into a handler holding the upload, a 128MB
upload costs a worker 15.5MB of RSS against roughly 275MB
before.
- ADDS: the `upload_dir` keyword, naming where a large part is
spilled. It decides the filesystem, which decides whether
save() is a rename, and it decides what shares a filesystem
with attacker controlled bytes. Temp file names owe nothing
to the client's filename, and every file is removed when its
request ends.
- ADDS: Punk::Plugin::Blob stores an on-disk upload without reading
it into memory: hashed where it lies through Apophis's
identify_fh, then moved into the store.
- ADDS: Punk::Plugin::Idempotency - Idempotency-Key on unsafe
methods, replaying the stored response.
- ADDS: Punk::Plugin::Metrics - a Prometheus /metrics endpoint
- ADDS: Punk::Plugin::Health - /healthz and /readyz,
- ADDS: Punk::Plugin::CSP - Content-Security-Policy with a
per request nonce.
- ADDS: Punk::Plugin::ConditionalGet - ETags and 304s for
dynamic responses.
- ADDS: Punk::Plugin::Blob, content addressed uploads on
Apophis. $c->blob_put stores by contents, so a user's filename
never becomes a filesystem path. $c->blob_send serves through
send_file as a download.
- ADDS: Apophis to the prerequisites.
0.23 2026-08-20
- ADDS: Punk::Plugin::Sitemap sitemap.xml/robots.txt
- ADDS: static mounts can say how fresh a file is. `max_age`
and `cache_control` set Cache-Control on a plain URL, and
$c->send_file takes cache_control too.
- ADDS: content-addressed asset URLs. $c->asset('/static/app.css')
returns /static/app.9f3a1c2b0d4e5f60.css. The digest - the first 8
bytes of SHA-256 over the contents.
- ADDS: Punk::Plugin::RequestId per request identifier
0.22 2026-08-20
- ADDS: Punk::Plugin::Sitemap, phase 1 - which of an
application's routes are actually URLs. Punk compiles every
route at boot, so the application already holds a complete
list of what it serves, and a sitemap is that list with a
filter over it. A route is listed only when it is a GET, its
path holds no capture, it carries no guard, and it did not opt
out. Out is the safe direction: a page missing from a sitemap
is still crawled if anything links to it, while a page wrongly
present is a crawler fetching a 404 or a login redirect on a
schedule.
The part a hand-written sitemap cannot do: a page behind a
guard is excluded WITHOUT anyone maintaining a list, because
`under($prefix, $guard)` copies its guard chain into every
route declared inside it and the plugin reads it off the
record.
`base` is required and is configuration. The protocol wants
absolute URLs, and taking the host from the request would let
`Host: evil.example` produce a sitemap naming that host for
every page on the site - delivered to search engines, and
invisible to the owner, whose own request produces a correct
file.
New route option: `sitemap => 0` keeps a route out, and
`sitemap => 1` puts one in despite a guard, because a scope
guard may be an authentication check or an ordinary filter and
Punk cannot read which. The method and the shape are not
overridable - sitemap => 1 on a POST or on /users/:id is a
mistake rather than an instruction.
Rendering, dynamic sections and robots.txt are phases 2 to 4;
the plan is in plan_punk_sitemap/.
- FIX: change abi version checks from == to <=
- Adds: Punk::Cache, Punk::Cache::Memory and Punk::Cache::File
0.21 2026-08-20
- FIX: A Punk::WebSocket::Room now reaches the whole pool.
- FIX: SSE also now works correctly accross workers.
- ADDS: $c->publish and $c->subscribe hyperman event bus hooks
0.20 2026-08-19
- The dynamic-route path no longer requires Open::API. It
checked that api_mounts EXISTED rather than that it held
anything, and api_mounts is always present in the compiled
state - so an app that never declared an `api` still reached
the API branch on any route that was not a static hit, and
croaked there when Open::API's C ABI did not match.
- Punk publishes a C ABI of its own, pk_abi.h, installed through
ExtUtils::Depends and reached at runtime through
Punk::_abi_ptr.
0.19 2026-08-18
- SECURITY: a session had no server-side lifetime. `expires` set
only the cookie's Max-Age, which is a request to a client that
is free to ignore it - the signature carried no time at all, so
a cookie captured once (a shared machine, a log, a backup, a
proxy) stayed valid for as long as the secret did, and
session_expire only asked the browser to forget a value that
still authenticated. The expiry is now stamped inside the signed payload
- New $c->safe_path($path, $fallback?): returns the path when it
is same-origin and relative, otherwise the fallback. Punk had
no such guard while auth_guard hands every application a ?to=
to redirect to, which made the obvious login form an open
redirect. Same rules as Punk::OAuth2's same_origin_path, for
the same reasons (CVE-2026-75628)
- The static traversal guard splits segments on a backslash as
well as a slash. On POSIX "..\.." is one ordinary filename and
traverses nothing, but Windows reads it as a separator, so a
guard that split on "/" alone saw one harmless segment where
the OS would see two levels up.
- New hook phase `before_request`, which runs BEFORE routing:
hook before_request => sub {
my ($c) = @_;
$c->stash->{t0} = Time::HiRes::time();
return;
};
The earliest phase until now was before_dispatch, which runs
only after the router has matched - so nothing could observe a
request from its start, and nothing ran at all for a 404, a
405, or anything answered by a PSGI or static mount.
before_request runs for all of those.
- The logger takes a record: a lone unblessed hashref is a set of
fields, and its `message` key is the message.
$c->log->info({ message => 'listing books',
books => 12, user => $id });
0.18 2026-08-18
A security release. issue reported by CPANSec.
- SECURITY (CVE-2026-75870): `session` declared without a secret
signed the cookie with an EMPTY HMAC key. The keyword froze
whatever options it was given without requiring a secret,
warning, or refusing to start, and both the cookie read and the
write-back defaulted that key to "". a secret is now required
0.17 2026-08-17
- Fixed t/14-futures.t failing on every perl whose Future is
older than the one it was developed against - reported by
CPAN Testers against 0.16 on 5.20.0, 5.22.2 and 5.24.0 with
"Expected __ANON__(Future.pm line 1140) to return a Future".
- A route may now be declared as one hashref, with the handler
under `cb`:
post "/upload" => { cb => "Web::File#create",
max_body => 50_000_000 };
instead of the positional form, which is unchanged and stays
the one `punk new` generates:
post "/upload" => "Web::File#create",
{ max_body => 50_000_000 };
- New route option { compress => 0 }, which opts a route out of
the server response compression Hyperman 0.25 adds. Punk does
not compress - that belongs to the write path - so the option
is spelled as a plain response header, Content-Encoding:
identity, which the server honours and strips.
- New `max_body` keyword and per-route { max_body => N }: refuse
a request whose CONTENT_LENGTH is over a ceiling, with the
same 413 an oversize API operation gets.
- Punk::Upload documents what an upload actually costs: it
arrives whole in memory before a handler sees it, ->save is a
write and not a stream, and `max_body` does not reduce the
memory.
- SECURITY: the `markdown` mount reflected the request path into
the Location header of its canonical 301 (the redirect that
strips a trailing slash or a .md suffix). PATH_INFO reaches a
PSGI app percent-DECODED, so that echoed bytes the client
chose: a decoded CR/LF split the response at any mount point,
and at a root mount "//evil.example/" came back as a
protocol-relative 301 off the site. Found while fixing the
same class of bug in Punk::OAuth2 (CVE-2026-75628).
The redirect now only names a path that IS a page - the
stripped target is looked up in the page table first - so the
only thing it can echo is one of the mount's own keys.
Anything else falls through to the 404 it was always going to
get. pmd_redirect also stops at the first control byte, in
case a mount prefix ever carries one.
0.16 2026-08-17
- New `proxy` keyword: reverse-proxy trust. Behind nginx, an ELB
or a CDN, REMOTE_ADDR is the proxys on every request - and
because rate_limit's counters live in Hyperman's shared arena
they are exact across the whole worker pool, so every client
landed in ONE bucket and a limit => 100 rule throttled the
entire site at 100/min. block_ip, keyed the same way, it would ban
the load balancer. `proxy` resolves the real client at the
top of the dispatcher and OVERWRITES REMOTE_ADDR, so
rate_limit, block_ip, the access log and $c->req->address are
all correct with no change to any of them; the connecting
address stays as $c->env->{'punk.peer_addr'}.
- New $c->req->address: REMOTE_ADDR, which is the socket peer
directly exposed and the resolved client behind `proxy`.
0.15 2026-08-17
- A trailing slash on the request path no longer 404s:
GET /account/ answers the route declared as GET /account.
The retry runs only after every declared route, API operation
and mount has missed, so nothing that already matched changes
- a *splat still captures its trailing slash, and a mounted
app is still handed the path it was sent, since only it knows
whether /docs and /docs/ differ. Repeated slashes collapse,
and "/" is left alone. A trimmed path that matches a
different method still answers 405 with its Allow list.
0.14 2026-08-16
- Requires from the C layer go through pk_require_once (an %INC
check, then at most one eval per module per process) instead
of a bare eval_pv per call. Perl's own eval_sv spends immortal
references on every call on perls through 5.20 - whatever is
evaluated, success or failure - and before 5.20 the immortals'
refcounts are small and unprotected, so a path that
re-requires per build (the markdown mount ran two) drains
PL_sv_undef toward a segfault under punk dev's rebuild loop.
Found by t/42-immortal-refcount.t on a CPAN smoker running
perl 5.20.0; verified fixed on 5.20.3.
- t/37-ua.t pins the per-request agent clones it compares by
address: a freed clone's memory is the allocator's to hand
straight back, so two DEAD clones can legally compare equal,
which a smoker's malloc duly demonstrated. Held alive,
distinct objects must differ.
0.13 2026-08-16
- $c->send_file($path_or_scalarref, %opts): a finished download
response
- Static files inherit the send_file core
- The punk command line rebuilt around a registry: commands are
specs (abstract, options with docs and defaults, nested verbs,
code), and help is generated from them
- Plugin subcommands: Punk::Command->register($name => \%spec)
with owner-collision croaks; an unknown command gets one
require of Punk::Command::<Ucfirst>, whose load registers it
- In-process testing seam: $Punk::Command::OUT/$ERR replace the
streams and main() returns the exit code.
- routes: --method, --path GLOB, --kind, and --json.
- dev: --workers, --watch (repeatable), --env; one watch loop.
- console: a history file, --eval 'CODE', and q/exit.
- New commands: `generate controller|model` into an existing
application
0.12 2026-08-15
- Adds Punk::Auth. The `auth` keyword freezes the config at
to_app (model, a fields map so existing schemas need no renames,
session_key, roles hook, rank ladder); croaks without a session, on any
unknown option, and on a roles target typo - at boot.
- Context surface: $c->login / logout / auth_id /
current_user (memoized per request, model loaded through an
await seam so the blocking DBI backend and the
future-returning DBIx::Loop backend both work).
- auth_guard for `under`: the bare is-anyone-signed-in form
runs entirely in C. Denial negotiates - a browser is
redirected to login_path with a relative-only ?to= return-to,
an API client gets the house 401 - with on_denied '403',
'404' or a coderef. The roles hook may return one role, a
list or an arrayref; a required role on the rank ladder means
"this or better", one outside it matches exactly (a staff
role next to tenant roles). Guards record what they learned
in $c->stash->{auth}.
- Bare `model;` registers everything under MyApp::Model::*
0.11 2026-08-15
- Security response headers (Punk::Headers): a `headers` keyword
freezing a header policy at to_app, applied in C on the way
out of the dispatcher.
- Content negotiation ($c->respond_to): calls the handler for
the most acceptable offered format (json/html/text/xml or a
full media type) and returns its response.
0.10 2026-08-15
- Development error pages (Punk::DevError): in development a die
renders an HTML debug page
- The environment now defaults to production; development is an
opt-in: punk dev sets PUNK_ENV=development for its server, or
set it yourself in the Env.
- Punk::Validate: request validation, all in C on the
JSON::Schema::Fast C ABI. $c->validate($schema) collects into
a Result - errors in the Open::API shape plus name, valid()
hands back typed filtered params - and a bare $c->validate
reads the Result a route-level check stashed. Routes take an
options hashref: get '/x' => $target, { validate => \%schema }
(or { schema, source, on_invalid }), compiled once at to_app
and run as a C guard after any auth guards; failures answer
the OpenAPI-mount-shaped 400 or the on_invalid target.
- New on_not_found keyword: the on_error contract for a 404 so
you can return a custom response
- The getentropy probe now links instead of only compiling.
FreeBSD before 12 has no getentropy and its old compilers take
the implicit declaration as a warning, so 0.09's compile-only
probe false-positived and the .so failed to load with
"Undefined symbol getentropy".
0.09 2026-08-15
- Adds Punk::Test, Punk::Test::WS and Punk::Test::WS::Conn
- Flash messages, in C (punk_flash.h): $c->flash and
$c->flash_keep over the session's reserved punk.flash key.
0.08 2026-08-14
- t/42-immortal-refcount.t failed the distribution on perl 5.18,
where perl itself moves the refcount it measures. Before 5.20
av_extend filled the slots it allocated with &PL_sv_undef and
av_undef released every slot up to the fill.
0.07 2026-08-13
- The router stored &PL_sv_yes bare in three set-membership hashes
- New PUNK_SET_TRUE (include/punk/punk_compat.h) is the sanctioned
way to put a true value in a set slot - it takes the reference
first, so the container has one to spend, with no allocation.
0.06 2026-08-13
- Rate limiting and IP blocking over Hyperman's shared arena
(requires Hyperman 0.18, ABI v3; fails open without it).
- New `rate_limit` keyword: installs a before_dispatch that answers
429 (with Retry-After and the X-RateLimit-* headers) when a
caller is over the limit. Key by client IP (default), a request
header (by => 'header:X-Api-Key'), or a coderef; scope to a path
prefix with `for`. Chainable for layered limits. The counters
live in the shared arena, so a limit is exact across the worker
pool rather than per worker.
- New context methods: $c->block_ip([$ip [, $ttl]]) and
$c->unblock_ip([$ip]) add or lift an IP on the edge denylist
($ip defaults to the request's REMOTE_ADDR), and $c->rate_hit
for a raw counter check.
0.05 2026-08-12
- Fixed a segfault on perl before 5.20. Six places handed one of
perl's immortal SVs to something that would release it.
- t/42-immortal-refcount.t asserts the invariant rather than the
symptom: an immortal's refcount may not move across these
calls. That fails on any perl, including the ones where the bug
is invisible.
- Update min version of deps
0.04 2026-08-08
- Fixed the configure-time dependency declaration, which is why
0.03 could not be installed from CPAN at all.
- $app->install_kw($name => $code, $owner?): a plugin can install
a declaration keyword of its own into the application class
instead of assigning to a glob there.
- params now takes names: `$c->params(@names)` and `$req->params(@names)`
return just those, as a list of values in the order asked for, or -
in scalar context - a hashref of only the names that were passed.
0.03 2026-08-07
- Punk::Model::DBIx::Loop, a non-blocking model backend. The same
six-method contract, the same SQL and the same result shapes as
Punk::Model::DBI, but every method returns a Punk::Future and the
statement runs on DBIx::Loop over the worker's own event loop.
- It runs on DBIx::Loop's C ABI (dbil_abi.h, via ExtUtils::Depends)
- Fixed Punk::Model's delegation returning the caller's own
arguments alongside the backend's result.
- Punk::Model::DBI is ~40% faster per statement: quote_identifier
and the fixed-shape get/delete SQL are memoised on the pooled
connection instead of rebuilt every call. A get is 2.05us against
3.61us. The table is part of the statement cache key - the cache
is shared by every model on a dsn.
- Add the `markdown` mount/keyword: point it at a nested directory of .md
files and get a documentation site. Navigation reflecting the
tree, a per-page table of contents from the headings, syntax
highlighting, ranked search, and the images sitting alongside
the markdown served as static files.
- The site is built at boot. The tree is walked, every page is
rendered through Markdown::Simple and wrapped by
Template::Stencil, the search index is filled, and the finished
bytes are frozen. A request is then a hash lookup and a triplet,
with no markdown parse, no template render and no Perl frame.
`reload => 1` re-renders a page whose source has changed, for
the edit loop under `punk dev`.
- Refactor the stat / conditional-request / sendfile half of
punk_static.h out as ps_serve_file, so the markdown mount serves
the assets in its tree through the same code rather than a
second implementation that would drift from it.
0.02 2026-08-06
- $c->ua: an outbound HTTP agent on the context
- The agent is one per worker, not one per request: it owns a
keep-alive pool and its DNS state.
- More than one agent: `ua partner => { ... }` names a second, the
same shape `database` uses for a second database, and
$c->ua('partner') asks for it.
- Needs Fetch 0.11 for the per-request jar (Fetch->clone).
0.01 2026-08-06
First version