Skip to content

Suprnova v0.8.0

Choose a tag to compare

@eas4ai eas4ai released this 31 Jul 02:11
· 128 commits to main since this release

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_DRIVER unset, 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. Point RATE_LIMIT_DRIVER at redis, or set
    RATE_LIMIT_ALLOW_MEMORY_IN_PRODUCTION=true if 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:

  • fill and first_or_new reject malformed values. A value that
    cannot decode into its field's type used to become that field's
    Default and return Okfill(attrs!{ age: "abc" }) set age = 0
    and reported success. It now returns a ValidationError naming the
    field, and leaves the model untouched. Unknown columns are still skipped
    silently (Laravel parity), and numeric widening still works.
  • /_suprnova/health?db=true no longer returns the driver error. The
    detail moves to the log; the body keeps "database": "error". Debug
    builds still include it. Dashboards parsing status / database are
    unaffected.
  • url::signature_has_not_expired now requires a valid signature, and
    is deprecated. It used to answer true for 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
    to has_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's URL::signatureHasNotExpired, deliberately.

Two additions that need something from you only if you opt in:

  • QueueDriver gained settle and release, both with default
    implementations, so existing driver impls keep compiling unchanged.
    Implement settle if your backend can commit a follow-up write and an
    acknowledgement in one transaction; implement release if it can requeue
    a reserved message in place.
  • Batch accounting can now be durable. DatabaseBatchRepository needs
    two new tables, job_batches and job_batch_settlements — add them to
    your migrations, as with jobs and failed_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_CONNECTIONS permit, indefinitely. Now armed and
    configurable via SERVER_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-signature reached 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, .env written 0600,
    and symlinked targets refused.

  • Health endpoint (P2-01, CI-05). It decided whether to query the
    database with query.contains("db=true") — a substring test, so
    ?nodb=true ran 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 raw x-forwarded-for header —
    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 fresh Uuid::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 on env.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 — while Request::query_param returned the
    first. A legitimately signed ?user=victim could therefore be
    replayed as ?user=attacker&user=victim with the original signature
    untouched: verification canonicalised over victim and passed, and the
    handler acted on attacker.

    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
    signature or expires is refused outright, since two of either leaves
    no non-arbitrary answer to which one governs.

    Request::query_param now resolves a repeated key to its last value,
    matching query_params and Context::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_expired still 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, dropping pinecone-sdk 0.1.2
    — whose newest release dates from 2024-09-06 — and with it
    tonic 0.11 → rustls 0.22 → rustls-webpki 0.102 and
    RUSTSEC-2026-0049 / -0098 / -0099 / -0104. All four were fixed upstream
    in rustls-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.toml is 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 an OWNER and an EXPIRES date, and scripts/check-audit.sh
    fails the release gate on a missing owner, a missing or unparseable
    date, or a lapsed one. cargo audit has 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.sh resolves real dependency trees and
    asserts that no build — including --all-features, which is what
    cargo audit actually reads — contains pinecone-sdk,
    rustls-webpki 0.102.x or tonic 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 busy WithoutOverlapping lock, a rate-limiter
    backoff — was implemented as "push a copy, then ack the original". The
    envelope id is the jobs table'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, no JobReleased event
    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 a driver.push failed mid-loop,
    PendingBatch::dispatch deleted 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_signature rejects 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 declared default-run, so all nine CLI
    wrappers that shell out to cargo run failed on a fresh project. The
    generated Dockerfile had five independent defects — a missing lockfile
    COPY, npm ci without a lock, a cache stage stubbing one of two
    declared binaries, a frontend build copied from a path vite never
    creates, and a missing frontend/src/pages copy that
    inertia_response! validates at compile time. A stock scaffold's image
    could not build.
  • docker:init emitted one Dockerfile for every project type. On an
    --api project 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, never Then.
  • Builder::clone silently 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, and tokio's fair RwLock
    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:check could 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 serve installed cargo-watch unpinned (P2-13). Now
    --locked with 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:sync treated the database schema as trusted input (CLI-01).
  • migrate:fresh is gated behind --force plus a typed confirmation
    (CLI-02)
    , in the app binary as well as the CLI.
  • The log mail 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 reports Settled::Stale, so it
    cannot enqueue work for a message another consumer now owns. Drivers that
    cannot do this answer Settled::Unsupported and keep the documented
    push-before-ack ordering.
  • DatabaseBatchRepository (DATA-02). Batch accounting survives a
    restart, and pending_jobs/failed_jobs are 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/live and /_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/health keeps 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_ENCRYPTIONstarttls | tls | none, with ssl and
    null accepted 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_CONNECTIONS and SERVER_HEADER_READ_TIMEOUT documented
    in manual/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 aim DROP TABLE at whatever
    Postgres was on localhost:5432 by default, and neither had ever
    initialised Crypt, 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.example advertising 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-default vector-pinecone feature.
    Motivation is under
    Security; the surface changes are:
    • client() is gone — there is no PineconeClient any more. Replacing
      it are control_plane_get, control_plane_post and data_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_metadatametadata_from_json, and metadata is now
      serde_json::Map rather than prost_types::Struct. decode_match_fields
      decode_match, taking a PineconeMatch. 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 the PineconeVector / PineconeMatch wire types.
    • from_env still reads PINECONE_API_KEY and
      PINECONE_CONTROLLER_HOST, and now also PINECONE_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 Index per
      name behind a tokio::Mutex because pinecone-sdk exposed 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.
    • Debug is 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_KEY and 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
    wiremock fake 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.