Skip to content

Releases: eas4ai/suprnova

v1.2.1

Choose a tag to compare

@eas4ai eas4ai released this 10 Aug 00:05

GitHub account migration: every repository URL (manual in all seven languages, README, Cargo repository fields, git dependency sources, scaffold templates) now points at github.com/eas4ai instead of the renamed github.com/entrepeneur4lyf. Scaffolded projects also carry a monitored author email. No behavior changes.

Suprnova v1.2.0

Choose a tag to compare

@eas4ai eas4ai released this 08 Aug 19:00

Added

  • The manual ships in seven languages. manual/es/, manual/fr/,
    manual/de/, manual/pt-BR/, manual/ja/ and manual/zh-Hans/ each
    carry the full 104-chapter manual - every chapter, the table of
    contents, and this changelog - translated from the English source.
    English remains canonical: chapter structure, code blocks, identifiers,
    CLI commands and environment variables are held byte-identical to the
    source, so a translated chapter can never disagree with the English
    about what the framework does, only say it in the reader's language.

    The translations were produced and reviewed for suprnova.app, which
    renders this manual as its /docs. Every section carries a review
    ledger there: verdicts are recorded against content hashes of both the
    English and the translation, two independent reviewers must pass the
    exact bytes for a section to count as approved, and per-locale
    glossaries pin the terminology rulings (which terms stay English,
    which take the native word, and why). Corrections are welcome in
    either repo - a fix here reaches the site on its next sync.

Suprnova v1.1.0

Choose a tag to compare

@eas4ai eas4ai released this 02 Aug 20:03

Added

  • Per-locale fallback chains. LocalizationConfig gains parents
    (APP_LOCALE_PARENTS, comma-separated child=parent pairs, or the
    chainable .parent(child, parent) builder): a locale can inherit from a
    configured sibling before falling further back to the global
    fallback_localept-PT from pt-BR, en-AU from en-GB, and so
    on, transitively. Lang::get/try_get/get_with/try_get_with/has
    all walk the chain, current locale first, so this works for any
    Translator driver, not just the bundled one. A malformed pair, an
    invalid locale, a child named twice, or a cycle (including a locale
    naming itself as its own parent) fails loudly at config load rather
    than degrading at request time.

    Served catalogs stay chain-flattened ahead of time: FluentTranslator
    now builds each locale's /_suprnova/lang/<locale>.ftl catalog as a
    fold — the embedded framework catalog at the bottom for en/en-*
    locales, then the locale's configured parent chain, then its own
    *.ftl files — so a chained locale is still one self-contained file
    the browser fetches once, with no client-side chain awareness needed.
    Flattening covers configured parents only; the terminal
    fallback_locale is still a Lang-facade-level fallback, not baked
    into the served bytes.

    This makes delta-style catalogs practical: a lang/pt-PT/ directory
    can hold only the handful of strings that actually differ from
    lang/pt-BR/, rather than a full duplicate catalog. The merge that
    makes it possible works at the Fluent AST level — a child's value
    replaces the parent's, attributes merge by name (an override that
    doesn't mention an attribute no longer loses it), select expressions
    replace whole (CLDR plural categories are locale-dependent, so
    variant-by-variant merging isn't coherent), and child-only entries
    append. See manual/localization.md's new "Fallback chains" section
    for the full contract.

Changed

  • LocalizationConfig gained the parents field. from_env() and
    the builder are unaffected; a literal struct constructor (tests
    building a LocalizationConfig by hand) needs one more field.
  • Served catalog text is now serializer-normalized for every locale,
    and intra-locale multi-file merging (several .ftl files in one
    locale directory) now goes through the same AST-level merge as parent
    chains rather than simple bundle-overriding. Resolved translations are
    unchanged except for the two strict improvements below; the
    underlying bytes rotate regardless — ETag/?v=<hash> rotates once
    on upgrade. The improvements: an override no longer silently drops
    the attributes it doesn't mention, and an attributes-only override no
    longer strips the message's own value (previously an error or a
    fallback resolution; it now resolves to the earlier override's
    value).

Suprnova v1.0.0

Choose a tag to compare

@eas4ai eas4ai released this 02 Aug 14:32

Added

  • Localization. Message catalogs in lang/<locale>/*.ftl
    (Fluent), a Lang facade with the
    __!("key", name: value) macro, per-request locale detection
    (LocaleMiddleware: session → cookie → Accept-Language
    APP_LOCALE), and locale-aware formatting for numbers, currency,
    dates, times, lists, and relative times over ICU4X. manual/localization.md
    is the chapter.

    The built-in validation rules stop hardcoding English. Each returns a
    keyed message (validation-min plus its arguments and an English
    fallback), translated once at the serialization boundary — so a Spanish
    app gets Spanish validation errors by dropping in
    lang/es/validation.ftl, with no rule wrapping and no forked copy of
    the framework's messages. Field names humanize through a field-<name>
    lookup. Rule::passes (and ContextualRule / AsyncRule) now return
    Result<(), ValidationMessage>; a custom rule's Err("…".into()) body
    still compiles and still renders verbatim, but the signature in your
    impl needs the new type.

    The browser gets the same bytes the server resolved: the merged catalog
    is served at /_suprnova/lang/<locale>.ftl with an ETag and an
    immutable ?v=<hash> form, the three starter kits parse it with
    @fluent/bundle, and suprnova generate-types emits a MessageKey
    union so renaming a message points the TypeScript compiler at every
    call site.

    Fluent rather than Laravel-style PHP arrays because one format has to
    serve both the server and the browser, and because CLDR plural
    categories are what gets Russian, Polish, and Arabic right —
    trans_choice's integer ranges cannot, which is why there is no
    trans_choice here. Behind a default-on localization feature;
    --no-default-features still compiles and still validates, using the
    embedded English fallbacks.

  • IntoInertiaScroll for Paginator. The trait was implemented for
    LengthAwarePaginator and CursorPaginator but not for the simple
    paginator, so simple_paginate results could not feed
    Inertia::paginate at all — despite simple.rs's own module docs
    pointing at it as the URL-generation path. That left offset-paginated
    Inertia collections with a choice between a COUNT(*) per request and
    hand-rolling the scroll metadata. next_page comes from the
    LIMIT n+1 overflow probe rather than a computed last page, there
    being no total to compute one from.

Fixed

  • suprnova generate-types emitted a different file on every run.
    The topological sort seeded its work queue by iterating a HashMap,
    and Rust randomises hash iteration order per process, so consecutive
    runs ordered the same interfaces differently. The output is a
    checked-in artifact, so every run produced a diff — and a generated
    file that churns for no reason is one people stop regenerating, after
    which it quietly stops describing the Rust it claims to. The directory
    walk is sorted too, so the output no longer depends on filesystem
    order either. Two runs of the same source are now byte-identical.

  • topological_sort did the opposite of its doc comment, emitting
    dependents before dependencies. Harmless — a TypeScript interface may
    reference one declared later in the same file — so the comment is
    corrected rather than the order, which would have reshuffled a tracked
    file for no benefit.

Suprnova v0.9.1

Choose a tag to compare

@eas4ai eas4ai released this 01 Aug 11:57

Three defects, all found by running the dogfood app under a containerised
harness rather than by reading the code. Every one of them is invisible to
a test suite that never stops a process the way production stops it.

They compound in a specific order: a rolling deploy SIGKILLs a worker
mid-job (the first), and that job then takes a reclaim path that never
counted the attempt (the second).

Fixed

  • schedule:work, queue:work and workflow:work ignored SIGTERM.
    Each selected on tokio::signal::ctrl_c() alone, which installs a
    SIGINT handler — so SIGTERM had no handler anywhere in the process, and
    SIGTERM is what docker stop, Coolify, systemd and Kubernetes send. All
    three already had a careful bounded drain behind that select!; none of
    it had ever executed under a supervisor. Measured before the fix: a
    docker stop on a queue:work container burned its whole 40s grace
    window and exited 137 with the in-flight job destroyed. As PID 1 — which
    is what a container runs — the kernel discards an unhandled SIGTERM
    outright, so the process did not die badly; it did not die at all until
    SIGKILL. Server::run already handled both signals correctly and its
    listener is now shared, which also closes a missed-signal window in the
    scheduler's loop.

  • A job that killed its worker could never be dead-lettered. A job
    whose handler fails is nacked and its attempt counted, so it
    dead-letters after max_tries. A job that kills its worker — OOM,
    abort, segfault, or the SIGKILL above — settles nothing; its reservation
    merely lapses, and every driver used to redeliver it byte-identical.
    Such a job is immortal: it kills each worker that claims it, comes back
    unchanged, and kills the next one, for as long as anything restarts
    workers. All three drivers now charge the attempt where they learn a
    worker died, because swapping QUEUE_DRIVER must not change whether a
    poison job can be stopped. attempts now means "deliveries to a worker"
    rather than "handler failures" — documented in manual/queues.md,
    because a worker lost for unrelated reasons burns an attempt too.

  • …and the exhausted job is now dead-lettered before it is dispatched.
    Counting the attempt was necessary and not sufficient. Every
    dead-letter decision lived in the worker's settlement path, which
    assumes the handler returns — so it never ran for exactly the jobs that
    could not return. With the driver fix alone the counter climbed
    (measured: 0 → 1 → 2 across three killed workers) and nothing acted on
    it. The budget is now spent before the handler runs. Caught only by
    re-running the container experiment after the first fix looked correct.

  • The daemons had no tracing subscriber. serve gets one from
    init_telemetry; queue:work, schedule:work, schedule:run and
    workflow:work come through a different boot path and got nothing, so
    every tracing:: line they emit went nowhere and LOG_LEVEL was inert
    for them. That is most of what they have to say — a worker
    dead-lettering a job, a scheduler skipping a tick it lost, a lock it
    could not release. In a container the only visible output was the
    startup banner, and the process looked idle while doing all of it. Two
    of the defects in this release were invisible until this was fixed.

  • A dead-letter with no failed-jobs store bound was a silent deletion.
    The persist step sat inside if let Some(store) = .., so with no store
    the arm did not match and execution fell through to the ack — quieter
    than the failure path directly above it, which at least leaves the
    reservation intact. An absent store was treated as more successful than
    a broken one. It now logs the full envelope at ERROR, because that is
    what queue:retry re-pushes: the difference between work recoverable by
    hand and work that ceased to exist.

  • QUEUE_DRIVER=database now binds a failed-jobs store. failed_jobs
    is part of that driver's contract — queue:retry reads it and
    Queue::retry_failed cannot work without it — but bootstrap_from_env
    wired the driver and left the store unset, so a database-backed queue
    dead-lettered into nothing unless the app bound one by hand. Configurable
    via QUEUE_FAILED_DB_TABLE. Only for this driver: memory is ephemeral
    by construction and redis has no table to write to.

  • Redis reclaim latency now follows --visibility-timeout. The flag
    sets XAUTOCLAIM's idle threshold, but a separate clock governs how often
    a consumer looks, and the driver left it at sea-streamer's 30s default —
    so --visibility-timeout 5 really meant "up to 35 seconds". The
    interval now tracks the configured timeout, clamped to 1s..=30s so a
    short timeout cannot become an XAUTOCLAIM storm and a long one can only
    make reclaim faster than before.

Added

  • TaskBuilder::on_one_server() / on_one_server_for(ttl) — run a
    scheduled task exactly once per due tick across replicas. Without it
    nothing elects a leader for a tick: each schedule:work process
    evaluates the schedule independently, and three replicas were measured
    running every due task three times, every minute, with no variance. A
    nightly billing job on three replicas billed every customer three times.

    without_overlapping() does not cover this and cannot: its lock is
    keyed on the task and released when the handler returns, so a fast task
    frees it before a second replica looks. on_one_server keys on the task
    and the tick and holds the lock past the handler, letting it expire on
    TTL. The two compose.

    Opt-in, matching Laravel. Diverges from Laravel in failing closed: the
    election is only as shared as the cache behind it, so a production boot
    with CACHE_DRIVER=memory and a single-server task is refused, naming
    the offending tasks, with SCHEDULE_ALLOW_MEMORY_LOCK_IN_PRODUCTION=true
    for deployments that genuinely run one scheduler.

Changed

  • manual/deployment.md no longer says "run exactly one schedule:work
    process" as the only option, and gains a Stopping cleanly section
    covering the drain windows per subsystem, how to size a platform's
    termination grace above them, and why PID 1 makes a missing signal
    handler worse than it sounds.

Suprnova v0.9.0

Choose a tag to compare

@eas4ai eas4ai released this 01 Aug 07:01

Security

  • Auth issuance could only be throttled per caller, never per
    recipient.
    An address-keyed limit answers "is one client noisy"; it
    cannot answer "is one mailbox being flooded". An attacker spread across
    a botnet or a single IPv6 /64 stayed under every per-IP budget while
    filling one victim's inbox with password-reset mail, and nothing in the
    framework could express the limit that would have stopped it — a key
    function could read the path, headers, and query string, but not a
    form-encoded body, so the address was invisible on exactly the route
    that carries it.

    identity_key keys a bucket on the account being acted on. It reads the
    query string first and then a buffered form body, so one key function
    covers both shapes; the value is trimmed and lowercased, because
    Alice@Example.com reaches the same mailbox as alice@example.com and
    a limit bypassed by holding down shift is not a limit; and it is hashed,
    because a rate-limit backend is frequently a shared Redis with weaker
    access control than the primary database.

    Two new middleware builders support it. key_reads_body(cap) buffers
    the body before keying — opt-in, because buffering is work an
    unauthenticated caller gets to make you do, and a body over the cap is
    refused with 413 rather than passed through unkeyed. only_when(pred)
    skips a limiter entirely for requests it has nothing to say about,
    which is what keeps a stacked per-recipient budget from silently
    becoming the binding limit on routes that name no recipient.

    The dogfood app now stacks both on its issuance group: 10 per 5 minutes
    per address, 3 per 15 minutes per recipient.

A review of Torii's session, password, OAuth, and passkey paths turned up
eight defects, all fixed in the pinned fork (suprnova-torii-rs 968b0be).

  • Expired sessions could be refreshed back to life. The SeaORM session
    repository's refresh had no expiry predicate and unconditionally extended
    expires_at, and OpaqueSessionProvider::refresh_session skipped the
    is_expired() check that get_session performs. A token held past its
    expiry could be renewed indefinitely. Fixed at both layers. Not reachable
    through Suprnova's own surface — neither Torii nor the framework exposes
    session refresh — but it is public API of both crates.
  • The login form leaked which accounts exist, by timing. Authentication
    returned as soon as the email missed, skipping Argon2 entirely: measured at
    54µs for an unknown address against 719ms for a wrong password, a ~13,000x
    gap readable over a network. Both failure paths now verify against a dummy
    hash so they cost the same. This one was reachable through Suprnova's
    password login.
  • The JWT iss claim was written but never verified. Algorithm pinning
    was already correct — alg: none and HS/RS confusion were never possible —
    but the issuer was decoration, so two services sharing a signing key would
    accept each other's sessions. Now enforced when an issuer is configured.
  • A single-use PKCE verifier could be claimed twice. Consumption was a
    read followed by a delete, so two OAuth callbacks for the same csrf_state
    could both read it before either delete landed. Now claimed in one
    operation — DELETE ... RETURNING on Postgres, a primary-key delete whose
    affected-row count picks the winner on SeaORM.
  • Expired sessions were listed as active. find_by_user_id had no expiry
    filter, and expired rows survive until cleanup runs, so a "devices you're
    signed in on" screen offered users dead sessions to revoke while saying
    nothing about the live one.
  • A passkey lookup was named authenticate. Torii's
    PasskeyService::authenticate_credential took a credential ID and returned
    the owning user, and PasskeyAuth::authenticate minted a session from it.
    Torii stores passkeys — it carries no WebAuthn dependency and cannot verify
    an assertion, so the only thing those calls proved was that the caller knew
    a credential ID: a value the browser sends in the clear and
    allowCredentials hands to anyone who can start a ceremony. Renamed to
    find_user_by_credential and create_session_for_verified_credential, both
    documenting that verification is the caller's job. Not reachable through
    Suprnova, which drives webauthn-rs itself (see
    torii_integration::passkey) and reaches Torii only for credential storage.
  • A WebAuthn challenge was replayable for its whole TTL. Neither backend
    consumed a challenge on read, and the SeaORM get_challenge also ignored
    expires_at entirely, returning expired challenges as live. Reads now
    exclude expired rows on both backends, and a new take_challenge claims one
    exactly once — the same delete-decides-the-winner shape as the PKCE fix.

Breaking

  • Azure Blob Storage and Google Cloud Storage moved behind the new
    filesystem-azure and filesystem-gcs features.
    Storage::register_azblob,
    register_azblob_with, register_gcs, register_gcs_with, AzBlobConfig
    and GcsConfig no longer exist unless you enable the matching feature. If
    you use either backend, add it to your dependency:

    suprnova = { git = "", tag = "v…", features = ["filesystem-gcs"] }

    You get a compile error naming the missing item, not a runtime failure.

    Both opendal service crates pull rsa, which carries RUSTSEC-2023-0071
    (the Marvin timing attack) with no fixed release upstream. They were the
    only crates enabling reqsign-core/jwt, the feature reqsign-core's
    optional rsa sits behind, so gating them severs all three opendal paths
    to it at once. rsa is now avoidable: --no-default-features --features filesystem,database-postgres resolves without it and still has the
    storage subsystem. Previously no feature combination could shed it while
    keeping storage at all.

    A stock default build still carries rsadatabase-mysql is a default
    feature and sqlx-mysql 0.8.6 depends on it non-optionally — so the audit
    exception stays open. S3 is deliberately not gated: reqsign-aws-v4
    takes reqsign-core without jwt, so the S3 driver never contributed a
    path, and gating it would break the most-used cloud backend while removing
    nothing.

Added

  • suprnova --version, with -v as well as clap's default -V. Asking a
    CLI its version with the flag every other CLI uses should not print a usage
    error.

Fixed

  • Two Redis operations had no upper bound. The cache's tag flush read a
    tag's whole member set with SMEMBERS and deleted key by key, so a tag with
    a large membership stalled the connection and a concurrent write could be
    lost between the read and the delete; tags are now generation-based, flushed
    atomically, and scanned with a bounded SSCAN. The delayed-queue promotion
    pass moved every due job in one unbounded ZRANGEBYSCORE, so a backlog that
    came due together produced a single enormous script; it now promotes in
    batches.
  • Two shutdown drains waited forever. schedule:work on Ctrl-C and the
    workflow worker after cancellation both awaited every in-flight task with no
    deadline, so one task that never returned held the process open until
    SIGKILL — an operator sees a daemon that "doesn't stop". Both now wait a
    bounded grace, then abort what remains and report the count.
  • The release version-pin sweep only recognised one of the two pin
    syntaxes
    , so every file carrying a cargo install --tag vX.Y.Z line and
    no dependency snippet was never discovered. suprnova-cli/README.md had
    been telling readers to install v0.6.0 for three releases; manual/cli.md
    and manual/cli-new.md sat at v0.7.2; manual/installation.md carried
    both forms and had one bumped while the other froze. Discovery and rewrite
    now read from one pattern table, and a file's rules are derived from its
    content.
  • cargo doc failed for any build with filesystem but without
    testing
    — seven Storage::fake intra-doc links could not resolve, and
    lib.rs denies broken links. testing is a default feature, so no gate
    step had ever built that combination; check-feature-matrix.sh now does.
  • Torii's migrations could not be replayed over their own schema, so a
    database holding it without the torii_migrations tracking table — restored
    from a dump that skipped it, or migrated by hand — could not be brought under
    management. Every Table::create() carried .if_not_exists(); none of the 19
    Index::create() calls did, nor did the ADD COLUMN locked_at alter, so
    replay sailed through the tables and died on the first CREATE INDEX. Fixed
    in the pinned fork (suprnova-torii-rs a0f956d) via has_index /
    has_column rather than IF NOT EXISTS, which sea-query silently drops for
    MySQL — the syntactic fix would have left a default-featured build broken.
  • A failed Torii migration aborted the process instead of returning an
    error.
    SeaORMStorage::migrate unwrapped the migrator and returned
    Ok(()) unconditionally, so init_torii's mapping of the failure into a
    FrameworkError was unreachable code.
  • An app's own users table silently suppressed Torii's, because
    .if_not_exists() cannot tell "already mine" from "already somebody
    else's". The migration reported success and authentication failed later on
    a missing column — the reason the --api starter names its table
    app_users. Torii's migration now warns at migrate time when an existing
    users table lacks columns it requires, naming the columns and the remedy.
    It stays a warning rather than a hard failure so existing deployments keep
    booting.
  • The Railway and DigitalOcean deployment guides pointed the platform
    health check at a path that could probe Postgres.
    Both platforms restart
    the container when that check fails, so following the advice turned a
    database blip into a restart loop across every...
Read more

Suprnova v0.8.0

Choose a tag to compare

@eas4ai eas4ai released this 31 Jul 02:11

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 "...

Read more

Suprnova v0.7.2

Choose a tag to compare

@eas4ai eas4ai released this 28 Jul 15:47

Fixed

  • generate-types resolves nested prop structs without derives. 0.7.1's
    generator degraded any prop field whose type didn't derive
    InertiaProps/Data to unknown — so re-running the generator (or the
    suprnova serve watcher) over a project with a committed types file
    replaced real interfaces like Array<AdminArticleRow> with unknown and
    broke type-checking across the app. Plain structs defined anywhere in
    src/ now resolve to their real interfaces, transitively from the prop
    roots; unknown (with a warning) is reserved for types the project
    genuinely doesn't define — external crate types, enums, tuple structs.

Changed

  • routes.ts generation is opt-in. generate-types no longer drops
    frontend/src/types/routes.ts into every project unasked; pass
    --routes to generate it.

  • Frontend starter dependencies refreshed. New scaffolds from
    suprnova new now pin current versions: Vite ^8.1.5, Tailwind CSS ^4.3.3,
    Svelte ^5.56.8 (vite-plugin-svelte ^7.2.0, svelte-check ^4.7.4),
    React ^19.2.8 (plugin-react ^6.0.4), Vue ^3.5.40 (plugin-vue ^6.0.8,
    vue-tsc ^3.3.8), and @types/node ^24 (the Node 24 LTS types line).
    TypeScript stays at ^6.0.3 deliberately: it is the latest 6.x, and
    svelte-check's peer range (^5 || ^6) does not yet admit TypeScript 7.
    All three starters were verified end to end (npm install +
    npm run build) against the refreshed set.

Suprnova v0.7.1

Choose a tag to compare

@eas4ai eas4ai released this 27 Jul 14:46

A defect-fix pass over 0.7.0's queue routing, from a full post-release review.

Fixed

  • Chained jobs no longer lose their declared queue. ChainLink captured a
    job's max_tries, timeout, and backoff at chain-build time but not its
    Job::queue(), so a job that landed on its declared queue when pushed
    directly landed on default when dispatched as part of a chain — the "job"
    tier of the route → job → default resolution order silently vanished for
    chains. The declared queue is now captured on the link and resolved exactly
    like a direct push. Chain payloads written before this release decode
    unchanged (serde(default)), and a link with no declared queue serializes
    byte-identically to what 0.7.0 wrote.

  • Failed-job records carry the queue the job died on. The worker's
    dead-letter path hardcoded queue = "default" into every FailedJob
    record, so failures of a routed job were invisible to an operator filtering
    the failed store by the pool that owns them. The record now carries the
    envelope's queue (default for unrouted jobs).

  • The 0.7.0 upgrade note understated the jobs migration. It read
    "unfiltered workers are unaffected and need no migration", but
    DatabaseQueueDriver::push names the queue column in its INSERT
    whether or not the job is routed — a 0.7.0 binary against an un-migrated
    table fails every push, filtered or not. The 0.7.0 section below and
    manual/queues.md are corrected: on the database driver the ALTER TABLE
    is required for every deployment, and it must run before binaries roll
    (older binaries list their columns explicitly, so migrating first is safe).

  • README no longer advertises a #[job] macro. No such macro exists —
    jobs implement the Job trait. The queues row now describes the real
    surface, including 0.7.0's queue routing.

Changed

  • The release path now bumps README version references.
    bump-workspace-version.py rewrites the README's pinned install tag, the
    distribution-model example, and the MSRV line atomically with the
    manifests, and a reworded README that stops matching a pattern fails the
    release loudly. The README had advertised v0.6.0 since v0.7.0 shipped
    because nothing in the release path touched it.
  • Connection routing is documented as name-resolution only.
    Job::connection() and the connection field of Queue::route resolve the
    connection name carried on the JobQueueing / JobQueued lifecycle
    events; a single process-global driver still receives every push, so they
    do not select a different driver. The rustdoc and manual/queues.md
    previously implied driver selection that does not exist. The queue
    dimension is unaffected — it is honored end to end. Per-connection drivers
    remain future work.
  • ChainLink gained a public queue: Option<String> field, which breaks
    struct-literal construction of chain links. Links built through
    ChainLink::from_job — the normal path — are unaffected.

Upgrading

Coming from ≤ 0.6.x on the database queue driver, apply the 0.7.0 migration
below before rolling binaries; it is required for every deployment on
that driver, not just ones using --queue. 0.7.1 itself needs no migration.

Suprnova v0.7.0

Choose a tag to compare

@eas4ai eas4ai released this 27 Jul 03:16

Security

  • Upgraded ammonia to 4.1.4 (RUSTSEC-2026-0213). Versions through 4.1.3
    allow XSS via SVG animate and set animation tags. ammonia is the
    sanitizer at the end of Suprnova's markdown pipeline
    (comraksyntectammonia), so any app rendering user-supplied
    Markdown through content was exposed. The advisory was published
    2026-07-21 — after v0.6.5 shipped — so every release up to and including
    v0.6.5 is affected
    . Upgrading the framework is the fix; no application
    code changes are required.

Added

  • Queue routing. Jobs can be dispatched to a specific queue and connection,
    and workers can be dedicated to specific queues — the Laravel 13
    Queue::route(...) surface, typed. A job states its own home with
    Job::queue() / Job::connection(); an operator overrides it centrally with
    Queue::route::<SendInvoice>(Some("redis"), Some("billing")) in
    bootstrap::register(), without editing the job. Resolution is route, then
    job, then global default, and a None field in a route defers rather than
    clearing. queue:work --queue=billing,default drains only those queues.
    Unrouted jobs belong to default, so they are never stranded. Chained jobs
    resolve routes by name, since a chain link stores its job erased.
  • QueueDriver::pop_from. Filtering pop, with a default implementation that
    rejects a filter it cannot honor rather than silently draining every
    queue — a worker told to drain billing that quietly drains everything is
    indistinguishable from a working deployment until the wrong pool eats the
    wrong jobs. The memory and database drivers filter natively. Custom drivers
    keep compiling and inherit the loud default.
  • Documented the jobs table schema. manual/queues.md now carries the DDL
    DatabaseQueueDriver actually expects, which was previously only discoverable
    by reading the driver's SQL.
  • Documented Inertia's serverHead option. Server-driven <head> elements
    (Inertia 3.5.0) need no framework support: the client reads them from an
    ordinary prop, so any handler can already supply them. See
    manual/frontend-inertia-responses.md.

Changed

  • Envelope gained a queue: Option<String> field. It is serde(default) and
    skipped when absent, so an unrouted envelope serializes byte-identically to
    what previous versions wrote — the frozen wire-format test passes unchanged,
    there is no schema_version bump, and mixed-version fleets interoperate
    during a rolling upgrade.
  • WorkerConfig gained a queues: Vec<String> field (empty = drain everything,
    the previous behaviour).
  • Removed ROADMAP.md. Its design principles live in manual/introduction.md,
    the working agreement in manual/contributions.md, and the deployment and
    scale-out material in manual/deployment.md; the shipped/planned checklists
    had gone stale. README.md's pointer to it for "the relationship to upstream"
    was already dangling — that attribution lives in LICENSE.
  • Scaffold frontends now pin @inertiajs/{svelte,react,vue3} at ^3.6.1
    (from ^3.4.0). The 3.4.0 → 3.6.1 range is client-side only — audited against
    the upstream changelog and the Page contract in packages/core/src/types.ts,
    every X-Inertia-* header the 3.6.1 client sends was already handled.
  • scripts/release.sh now publishes the GitHub release itself, with notes taken
    from the version's CHANGELOG.md section. Previously this was a manual
    "next step" that got skipped, which is why v0.5.10 and v0.6.1–v0.6.3 are
    tag-only and the Releases page sat on a stale version. Preflight runs before
    the gate so a missing gh or changelog section fails in seconds, and
    publishing is skipped automatically unless origin is GitHub.

Upgrading

Existing jobs tables on the database queue driver must add the new
column — push names it in its INSERT whether or not the job is routed, so
an un-migrated table fails every push. Migrate first, then roll binaries
(older binaries list their columns explicitly and ignore the new one, so that
order is safe):

ALTER TABLE jobs ADD COLUMN queue TEXT NULL;
CREATE INDEX idx_jobs_queue ON jobs(queue);

(Corrected in 0.7.1 — this note originally claimed unfiltered deployments
needed no migration.)