Revision history for Punk 0.37 2026-08-30 - $c->stream sends the real chunk length wherever perl expands my_snprintf to a statement expression 0.36 2026-08-30 - ADDS: $c->stream($content_type, $cb) + Punk::Stream - a response body of unknown length on the SSE transports (Hyperman detach, psgi.streaming, blocking psgix.io), chunk-framed on the socket transports so a die is visible truncation, never a short success - Punk::Stream drain - a Punk::Future settled when the write buffer reaches the kernel; awaiting it after each write bounds memory to one chunk while the worker keeps serving other requests - Punk::Test declares psgi.streaming in every request env, so get_ok drives a streamed route instead of collecting its 501 - ADDS: the `last_modified` route option - Punk::Plugin:: ConditionalGet's date validator, a coderef returning an epoch, answered 304 before the handler runs; If-None-Match wins, the date comparison is exact (the file path's convention), a future epoch is clamped to now - ADDS: Punk::Test `upload` - multipart/form-data requests: file parts from a path or a scalar ref, streamed through one read buffer however large, boundary verified absent from the content - t/0410-upload.t posts through the client, so the encoder and the streaming parser test each other - ADDS: `max_record` on Punk::Plugin::Idempotency - a response larger than it (default 1MB) is served with Idempotency-Recorded: false and not stored; the single-flight lock still covers the request - ADDS: `signed => 1` on $c->cookie, both directions - HMAC over name=value with the session's secret and verify, so a tampered, swapped or unsigned cookie reads as undef 0.35 2026-08-29 - An SSE heartbeat that finds the client gone no longer re-arms itself on a stream it has just torn down. - Freeing an SSE stream drops its loop watchers before its socket. - t/0733-auth-sqitch.t skips unless `sqitch` is runnable, not merely installed. - t/1020-sse.t polls for the close rather than sleeping on it, and reports what answered and which backend when it does not. 0.34 2026-08-26 - ADDS: pk_abi v4 - on_log_ctx, a log tap that is handed the context the line was logged against, so a telemetry layer can correlate a record with its request. on_log is unchanged; a consumer registering both receives every record twice. - The logger resolves its context before running its observers. 0.33 2026-08-25 - Static mounts hold small files in memory per worker, so a hit does no file syscall 0.32 2026-08-23 - ADDS: `punk serve [DIR]` - a directory of files over HTTP under Hyperman, on 127.0.0.1:8000 by default - ADDS: `index` and `list` options on Punk::Static, so the `static` keyword serves a tree of index.html files - Return _abi_ptr as a UV, not an IV. - `punk doctor` loads every Punk::Command::* on @INC before it prints, so a plugin distribution's register_doctor row appears without that plugin's own command having been named first. - REMOVES: the plaintext-secret guardrail in Punk::Config, and the `secrets` option that set its mode. A value written into the configuration file in plaintext is the decision of whoever writes the file - ADDS: $app->auth_config - the frozen auth configuration read back 0.31 2026-08-23 - ADDS: kits. `punk new MyApp --kit diy` generates through Punk::Kit::Diy instead of the basic skeleton - A kit subclasses Punk::Generate, keeps its templates in skel/ beside its own module, and declares the options it takes, which are read out of argv before Getopt runs so they parse and show up under `punk new --kit NAME --help` like the command's own - ADDS: hook before_render. Runs once per $c->render, after Punk's own binds (url, csp_nonce, locale), with the data hashref the engine is about to receive - so a plugin can put a value into every template without the handler passing it. - ADDS: Punk::Command->load_app, the public form of what every application-loading command does, for a command in another distributio - ADDS: $t->request_header in Punk::Test - a header on every request from here on - ADDS: named routes. A route takes { name => 'book' }, and $c->url_for('book', id => 42) gives back its path - In a template, {% url.books %} for a static route and {% row | url_for('book') %} for one with captures - ADDS: `layout` as a render option. $c->render($tpl, \%data, layout => undef) renders a template without the configured wrapper - ADDS: $c->fragment($tpl, \%data, %opts) - render with no layout and Cache-Control: private - ADDS: $app->databases - the configured databases read back, a deep copy keyed by name with `default` for the unnamed one - ADDS: $app->on_compile($code, $owner?) - a callback for to_app, run in registration order after every keyword has recorded and before anything is compiled - ADDS: filter operators on Punk::Model search and count, on both backends: = != < <= > >= in not_in like starts_with, as { column => { op => value } } beside the plain equality. - ADDS: order_by on search - a column, or [ column => 'asc'|'desc', ... ] pairs - ADDS: count(\%filter) on the model and both backends. - ADDS: $c->txn(sub { my ($tx) = @_; ... }) and $c->txn($database => sub { ... }), with Punk::Txn 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::, 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