Suprnova v0.8.0
Remediation of an external red-team audit. The audit returned 19 P1
findings and a NO-GO verdict for 1.0; this release closes all nineteen,
plus a number of defects found while fixing them that the audit had not
named.
Several fixes deliberately turn a silent misconfiguration into a refused
boot. Read Upgrading before deploying — a production app that has been
running happily may not start.
Upgrading
Three configurations that used to boot with a warning (or in silence) now
fail closed in production. Each error names the variable that unblocks it,
and each has an explicit override for the deployment where the risk is
genuinely absent.
- A non-delivering mail driver.
MAIL_DRIVERunset,log,memory,
or an unrecognised value all resolved to a transport that renders mail
and discards it — so password resets reported success while nothing was
sent. Override:MAIL_ALLOW_NON_DELIVERING_IN_PRODUCTION=true. - Cleartext SMTP. Three of the four credential combinations landed on
an unencrypted transport, and the both-unset case logged a warning and
sent anyway. Override:MAIL_ALLOW_INSECURE_SMTP_IN_PRODUCTION=true. - The in-memory rate limiter. Its buckets live in one process's heap,
so behind N replicas every quota is really N× and each deploy resets
them. PointRATE_LIMIT_DRIVERatredis, or set
RATE_LIMIT_ALLOW_MEMORY_IN_PRODUCTION=trueif you genuinely run one
process. An unrecognised driver value fails for the same reason,
because it fell back to memory —RATE_LIMIT_DRIVER=Redis, capitalised,
is the case most likely to reach production because it looks configured.
Development, testing and staging are unchanged in all three cases. Staging
is deliberately not gated: hard-failing it pushes teams to set the
override globally, which disarms the check where it matters.
Two behaviour changes that are not boot failures:
fillandfirst_or_newreject malformed values. A value that
cannot decode into its field's type used to become that field's
Defaultand returnOk—fill(attrs!{ age: "abc" })setage = 0
and reported success. It now returns aValidationErrornaming the
field, and leaves the model untouched. Unknown columns are still skipped
silently (Laravel parity), and numeric widening still works./_suprnova/health?db=trueno longer returns the driver error. The
detail moves to the log; the body keeps"database": "error". Debug
builds still include it. Dashboards parsingstatus/databaseare
unaffected.url::signature_has_not_expirednow requires a valid signature, and
is deprecated. It used to answertruefor a forged URL — a bad
signature is not "expired", because it never had an expiry to miss — so
any handler guarding on it alone accepted forgeries. It is now identical
tohas_valid_signature. If you were using it to tell expired from
invalid (to render "request a fresh link" rather than a 403), switch to
url::signature_verdict, which returns all three states. This diverges
from Laravel'sURL::signatureHasNotExpired, deliberately.
Two additions that need something from you only if you opt in:
QueueDrivergainedsettleandrelease, both with default
implementations, so existing driver impls keep compiling unchanged.
Implementsettleif your backend can commit a follow-up write and an
acknowledgement in one transaction; implementreleaseif it can requeue
a reserved message in place.- Batch accounting can now be durable.
DatabaseBatchRepositoryneeds
two new tables,job_batchesandjob_batch_settlements— add them to
your migrations, as withjobsandfailed_jobs. The schema is in
manual/queues.md. Nothing changes if you stay on
MemoryBatchRepository.
Security
-
Slowloris (SEC-07). hyper's header-read timeout was documented as
30s but inert — it only arms when a timer is installed on the connection
builder, and none was. A client could hold a connection, and a
SERVER_MAX_CONNECTIONSpermit, indefinitely. Now armed and
configurable viaSERVER_HEADER_READ_TIMEOUT. -
Multipart uploads (SEC-05). The cap applied to individual part
payloads but not to the raw stream, so a body could exceed the limit in
aggregate. Now capped at the stream. -
Webhook HMAC with an empty key (SEC-08). Both payment adapters
accepted a blank secret, which verifies anything. Refused on both. -
Paddle signature parsing (P2-11). An odd-length or non-hex
paddle-signaturereached the pinned SDK and panicked inside it. Now
validated first: a malformed signature is a 401. -
Passkey enrolment and reset tokens (SEC-01, SEC-02). Anonymous
enrolment against an existing email, non-owner enrolment, and owner
enrolment without recent reauth are each refused with distinct statuses.
A password login now stamps the reauth window. -
dev:tls(SEC-10). A project could choose the CA the command
trusts. -
Generated Docker Compose (P2-12). Published Postgres and Redis on
all interfaces with credentials committed in this repository. Now bound
to loopback with per-scaffold generated passwords,.envwritten 0600,
and symlinked targets refused. -
Health endpoint (P2-01, CI-05). It decided whether to query the
database withquery.contains("db=true")— a substring test, so
?nodb=trueran the probe too. Now parsed properly. The 503 no longer
embeds the driver error, which named hosts, ports, schemas and versions. -
Credential issuance throttling (P2-02). The four auth-issuance
routes in the reference app carried no rate limit at all, and the one
route that did keyed its bucket on the rawx-forwarded-forheader —
which any client can vary per request to get a fresh bucket. Both fixed;
the issuance budget is shared across the four routes so rotating between
them does not multiply it. -
A redelivered chain step re-pushed its successor under a new id
(DATA-02b, partial). Settlement pushes the next chain link before
acking, deliberately: acking first means a crash in that window loses
the chain permanently, and a duplicate is recoverable where silent loss
is not. But the successor's envelope got a freshUuid::new_v4()on
every push, so the duplicate produced by that trade was
indistinguishable from a legitimate new step — to the driver, to an
outbox, and to the handler.That last one is the real cost. The framework's delivery contract is
at-least-once and its answer to duplicates is "handlers must be
idempotent" — but a handler keyed onenv.id, the only identifier it
receives, could not satisfy that contract for a chained job, because the
duplicate arrived under a new id every time. The contract was
unsatisfiable by construction.The successor's id is now a UUIDv5 derived from its predecessor's, which
is stable across that predecessor's own redeliveries. A redelivered step
re-pushes the id it pushed before. No schema change, no new field, no
new dependency.This makes the duplicate detectable, which is the primitive the rest
of DATA-02b was missing. It does not make the push atomic with the ack
(that needs the outbox), and nothing yet rejects the duplicate on the way
in. Both remain open. -
Signed URLs verified one URL and executed another (SEC-04). The
canonical form collapsed query pairs into a map, so a repeated key kept
only its last value — whileRequest::query_paramreturned the
first. A legitimately signed?user=victimcould therefore be
replayed as?user=attacker&user=victimwith the original signature
untouched: verification canonicalised overvictimand passed, and the
handler acted onattacker.The canonical form now carries every pair, sorted by
(key, value), so
the signature covers the exact multiset of parameters — adding,
removing, or substituting any value breaks the HMAC. A repeated
signatureorexpiresis refused outright, since two of either leaves
no non-arbitrary answer to which one governs.Request::query_paramnow resolves a repeated key to its last value,
matchingquery_paramsandContext::query_param; it was the only one
of the three that disagreed, and that disagreement was the other half of
the defect. Existing signed links keep working — with no repeated
keys the payload bytes are unchanged, which a test pins, because a
canonical-form change that silently invalidated every outstanding
password-reset link would be worse than the bug.Six regression tests, including both attack orderings, a legitimately
repeated key that must still sign and verify, and the reordering
guarantee. Not changed:signature_has_not_expiredstill reports a
forged signature as "not expired". That is Laravel's behaviour, was
settled deliberately as a documentation fix, and has its own test
pinning it against a well-meaning "correction". -
RBAC under Postgres. Verified against a real Postgres rather than
SQLite alone. -
Four RustSec advisories eliminated, not renewed. The Pinecone driver
was rewritten against Pinecone's REST API, droppingpinecone-sdk 0.1.2
— whose newest release dates from 2024-09-06 — and with it
tonic 0.11 → rustls 0.22 → rustls-webpki 0.102and
RUSTSEC-2026-0049 / -0098 / -0099 / -0104. All four were fixed upstream
inrustls-webpki >= 0.103.13, which this workspace already resolved
for its other TLS users; one abandoned crate held the tree on the
vulnerable line..cargo/audit.tomlis down from five ignores to one.
See Changed for what this means for the driver's API. -
Audit exceptions now expire. Every entry in
.cargo/audit.toml
carries anOWNERand anEXPIRESdate, andscripts/check-audit.sh
fails the release gate on a missing owner, a missing or unparseable
date, or a lapsed one.cargo audithas no notion of an expiring
ignore, so one added "temporarily" stayed until somebody re-read the
file. The remaining entry (RUSTSEC-2023-0071,rsa, which has no fixed
release at all) is owned and dated. -
Reachability claims are checked, not asserted.
scripts/check-feature-matrix.shresolves real dependency trees and
asserts that no build — including--all-features, which is what
cargo auditactually reads — containspinecone-sdk,
rustls-webpki 0.102.xortonic 0.11.x. An exception justified by a
comment nothing verifies stops being true the first time someone adds a
dependency.
Fixed
- Every release on a database-backed queue was silently a no-op.
JobOutcome::Released— a busyWithoutOverlappinglock, a rate-limiter
backoff — was implemented as "push a copy, then ack the original". The
envelope id is thejobstable's primary key, so the copy collided with
the row still holding the live reservation and the push failed with
UNIQUE constraint failed: jobs.id. The worker then correctly declined
to ack, so the requested delay was never applied, noJobReleasedevent
fired, and the job simply parked until visibility expiry redelivered it.
Releases are now one driver call, done in place. - A partial batch dispatch orphaned the jobs it had already queued
(DATA-02). When adriver.pushfailed mid-loop,
PendingBatch::dispatchdeleted the batch row — but the envelopes
already in the queue were still stamped with that batch id, so each of
them settled against a batch that no longer existed, returning
Err(batch not found)on every delivery, forever. The batch is now
settled instead: undispatched jobs are recorded as failures and the batch
is cancelled, so the queued ones settle normally and the terminal
callbacks still fire. - Nothing tested that
url::has_valid_signaturerejects a forged URL.
Found while verifying the SEC-04 fix: the entire framework suite passed
with the primary signed-URL guard rewritten to accept any signature. - A scaffolded app could not migrate its database or build its image
(REL-01b). Neither scaffold declareddefault-run, so all nine CLI
wrappers that shell out tocargo runfailed on a fresh project. The
generated Dockerfile had five independent defects — a missing lockfile
COPY,npm ciwithout a lock, a cache stage stubbing one of two
declared binaries, a frontend build copied from a path vite never
creates, and a missingfrontend/src/pagescopy that
inertia_response!validates at compile time. A stock scaffold's image
could not build. docker:initemitted one Dockerfile for every project type. On an
--apiproject its first instruction,COPY frontend/package.json,
failed outright. API projects now get a frontend-free Dockerfile.- SQL placeholders (DATA-01). Rendered per backend rather than
assuming one dialect. - Queue settlement (DATA-02a, P2-06c). Follow-ups settle before the
reservation is acked, and a lock-release error no longer converts an
already-succeeded job into a retry. - A cancelled batch fired
Catch, neverThen. Builder::clonesilently dropped the eager-load plan (P2-09a).
User::query().with("posts")cloned anywhere — pagination,count(),
any scope that clones — returned rows with no relations and no error.- Presence rosters lost members (P2-08). The roster was snapshotted
before subscribing, so anyone joining in that window appeared in
neither, permanently. - Pinecone serialised every index acquisition (P2-14). The write lock
was held across two network round trips, andtokio's fairRwLock
meant one cold index stalled every warm one. - The type watcher discarded bursts (P2-13). Leading-edge debounce
regenerated on the first file of a burst and dropped the rest with no
trailing run, so the last save never took effect. ssr:checkcould hang, and tried one address (P2-13). DNS ran
outside the timeout entirely, and only the first resolved address was
tried — so a host with an AAAA record and no IPv6 route reported the
worker down while it was listening on v4.suprnova serveinstalledcargo-watchunpinned (P2-13). Now
--lockedwith a major-version bound.- The release bumper rewrote five READMEs and nothing else. Four
manual chapters and a public doc comment pinned tags that no release
ever updated — the doc comment was two releases stale. Discovery now
replaces the hand-maintained list, and the smoke test greps the bumped
tree independently rather than trusting the bumper's own verify step. db:synctreated the database schema as trusted input (CLI-01).migrate:freshis gated behind--forceplus a typed confirmation
(CLI-02), in the app binary as well as the CLI.- The
logmail driver now logs the whole message, as Laravel does,
and no longer writes bearer links to the log in production.
Added
- Atomic terminal settlement (
QueueDriver::settle, DATA-02). The
chain successor and the acknowledgement now commit together on
DatabaseQueueDriver, closing the window where a crash between them
either lost the rest of a chain or ran its next step twice. The
reservation-keyed delete doubles as a fence: a worker whose visibility
expired mid-run commits nothing and reportsSettled::Stale, so it
cannot enqueue work for a message another consumer now owns. Drivers that
cannot do this answerSettled::Unsupportedand keep the documented
push-before-ack ordering. DatabaseBatchRepository(DATA-02). Batch accounting survives a
restart, andpending_jobs/failed_jobsare derived from settlement
rows keyed(batch_id, job_id)rather than stored and decremented — so a
redelivered job cannot drive a batch to "finished" while its other jobs
are still running, and the guard holds across processes rather than
within one./_suprnova/health/liveand/_suprnova/health/ready. Liveness
touches nothing; readiness probes dependencies. Wiring a database check
into a liveness probe turns a database blip into a rolling restart of
every replica, which the single previous endpoint invited.
/_suprnova/healthkeeps working exactly as documented.SERVER_HEALTH_READINESS_TOKEN. Optional shared secret for the
readiness probe, compared in constant time. Without it, readiness
answers 404 — indistinguishable from an unrouted path, because it is
the router's own 404. Unset by default so existing probes keep working.MAIL_SMTP_ENCRYPTION—starttls|tls|none, withssland
nullaccepted as Laravel-compatible aliases. Unset derives from the
credentials, reproducing the previous behaviour exactly. This also makes
implicit TLS on port 465 reachable: the transport supported it, but no
combination of environment variables could select it.SERVER_MAX_CONNECTIONSandSERVER_HEADER_READ_TIMEOUTdocumented
inmanual/env-vars.md, where they had been missing entirely.
Changed
The audit's own conclusion was that the gate passed in 470s and caught
none of the 19 P1s. Most of this release's test work is aimed at that.
- Postgres runs in the gate. Twelve tests across six files had never
executed. Two of them turned out to aimDROP TABLEat whatever
Postgres was onlocalhost:5432by default, and neither had ever
initialisedCrypt, so both failed the first time they ran. - Scaffold assertions read the bytes a user receives, after
substitution, rather than the template source. Found an API project
shipping a doc comment naming a database literally{package_name}, and
a.env.exampleadvertising five mail keys the framework never reads. - Queue fault injection. ACK loss, redelivery, lease lapse and partial
dispatch are driven by a decorator that fails a named operation on a
named call, so every case is deterministic rather than a sleep race. - Payment adapters have negative tests. Stripe's
verify()had never
been exercised with a valid signature, so every rejection path that
depends on reaching the HMAC comparison was unproven. - The Pinecone driver speaks REST. Breaking, behind the
off-by-defaultvector-pineconefeature. Motivation is under
Security; the surface changes are:client()is gone — there is noPineconeClientany more. Replacing
it arecontrol_plane_get,control_plane_postanddata_plane_post,
which reach any Pinecone endpoint with your own request and response
types over the driver's authenticated, host-resolved transport. That
is strictly more reach than the old trapdoor had.json_to_metadata→metadata_from_json, and metadata is now
serde_json::Maprather thanprost_types::Struct.decode_match_fields
→decode_match, taking aPineconeMatch.namespace()returns
&str.- New:
with_control_plane,with_api_version,with_index_host
(pins a known host and skips the control-plane round trip),
index_host, and thePineconeVector/PineconeMatchwire types. from_envstill readsPINECONE_API_KEYand
PINECONE_CONTROLLER_HOST, and now alsoPINECONE_API_VERSION.- The REST API version is pinned, not floated —
2025-04, the version
the driver's request and response shapes were written against. - Nothing serializes any more. The old driver cached one
Indexper
name behind atokio::Mutexbecausepinecone-sdkexposed it only
behind&mut self; the new one caches a host string and shares
reqwest's connection pool. - A host learned from the control plane is always contacted over
https, whatever scheme the response carries. Debugis implemented by hand with the API key redacted, so a
#[derive(Debug)]on a struct holding a driver can't print it.
- Wire-contract tests for Pinecone. The live integration tests need a
PINECONE_API_KEYand so cannot run in the gate — which left a REST
rewrite's field names (topK,includeMetadata,vectorCount) resting
on nothing. Thirteen tests now drive the driver against a local
wiremockfake and assert the exact method, path, headers and JSON body
it puts on the wire, plus that a non-2xx is never decoded as a result
and that an error message never carries the API key. They pin the driver
to Pinecone's documented contract; only the#[ignore]d tests can
confirm the documentation matches the live service.