A security and access release. Every account is now provisioned with an active free plan, so nothing gates the product behind a payment wall that moves no money. Around that, the account surface was hardened on four axes it previously left open — where the session credential lives, how often an account can be guessed at, how many identities one mailbox can hold, and how much of the platform one account can hold at once. A self-contained watchdog means a default deploy is no longer silent when it breaks.
Every account starts free
Registration provisions an active free subscription with an unlimited token quota. POST /tasks no longer answers 402 to a new account, and the plan grid leads with free rather than presenting a paywall.
Paid plans are parked, not removed: BILLING_ENABLED (backend) and BILLING_LIVE (frontend) are both false while no real processor is integrated, so /billing/subscribe and /billing/cancel answer 403 for everyone except admins — who keep the live flow so the operator can test it — and the billing surfaces render "coming soon". The two flags flip together when a processor lands.
Migration 0018_free_plan backfills every existing account onto the new plan.
EMAIL_VERIFICATION_REQUIRED now ships false. That is one decision with EMAIL_PROVIDER=console, not two: the console sender writes verification mail to the server log, so an enforced gate locked every account on a fresh install with no way through. Nothing was removed — /verify-email, the resend endpoint and the new code flow all keep working. Enable the gate alongside a real sender.
The refresh token never reaches JavaScript
The refresh token is now set and read as an httpOnly, Secure, SameSite=Strict cookie scoped to /api/v1/auth. No endpoint accepts one in a request body, none returns one in a response body, and the access token lives in a module-level variable that is never persisted. Neither localStorage nor sessionStorage holds a credential, so an XSS foothold can spend the current access token for at most its 30-minute life and cannot exfiltrate a 7-day session — which rotation alone could never prevent, since a thief holding both halves just rotates like a normal user and trips no detection. tests/test_auth_cookie.py fails on any of the three.
SameSite is the whole CSRF control on the two cookie-authenticated routes, and it is sufficient because both are POST-only. SameSite=None is deliberately not offered and is rejected at parse time: a split-domain deployment routes the API through the frontend's BACKEND_ORIGIN rewrite rather than deleting the control.
Because memory starts empty, every document load spends one rotation, and several tabs restoring at once would replay one cookie into reuse detection and burn the session family. navigator.locks serializes rotation across the origin's documents, so a waiting tab presents the successor rather than a token already spent. That per-load rotation is also why /refresh now carries its own rate-limit tier instead of the shared auth bucket: routine page loads must not be throttled alongside credential stuffing.
REFRESH_COOKIE_SECURE / REFRESH_COOKIE_SAMESITE / REFRESH_COOKIE_DOMAIN are new. SECURE is deliberately unset rather than true — Safari has historically refused a Secure cookie over http://localhost — and resolves to "on outside development", with production refusing a boot that turns it off.
Sign-in throttling counts the right axis
A rate-limit bucket keys by caller identity, which on an unauthenticated route is the client IP — so a credential-stuffing run spread over a botnet spends one or two attempts per address against a target account and never approaches the limit, leaving guesses against any single account effectively unbounded.
utils/login_throttle counts the other axis: failures per account, whatever address they arrive from (10 per 15 minutes, and 5 for the second factor, which is only 10⁶ and sits behind an already-known password). It runs over the limiter's own buckets, so Redis, the in-memory fallback and the circuit breaker are shared rather than duplicated. Only failures are recorded and a success clears the record. The check runs before the password is verified, which is what makes it a block rather than a counter — and keeps a blocked account from costing an Argon2 hash. The subject is hashed into the bucket key, because an address is PII and a key travels through monitoring output that the address should not.
The accepted trade-off is the classic one, and it is bounded three ways: the block is temporary rather than an administrative lock, it cannot be extended past one window, and password reset stays open throughout because it is keyed by an emailed token rather than by these counters.
Signup abuse, on the two axes a limiter cannot see
utils/mail_budgetcounts sends per recipient address — the axis a caller-keyed limiter is blind to, since the endpoints that send mail all let the caller pick or trigger who receives it.POST /users/me/emailis the sharpest of them: it takes an arbitrary address behind a session, so without this one account is a mail cannon aimed at anyone.utils/human_checkadds a honeypot, a server-signed challenge nonce and an optional CAPTCHA to the two unauthenticated endpoints only.CAPTCHA_PROVIDERdefaults tononeand that is not "unprotected" — the other two layers still run and no third party is contacted, keeping the zero-egress default. Production refusesturnstilewith a missing key, because a provider that fails closed on every call stops registration silently.
Every rejection is silent: the endpoint answers with its normal fixed body. A 429 or a "captcha failed" would break the byte-identical response /register guarantees and tell an automated client which layer caught it. The cost is that a false positive is invisible to the user, which is why each rejection increments maestro_abuse_rejected_total{reason} — that counter is the only place the false-positive rate can be seen.
At /register an exhausted budget suppresses the mail and still creates the account. Refusing would hand back a better weapon than the one removed: keeping one address's bucket full would deny that address registration indefinitely.
One mailbox, one account
mail_budget bounds how much mail an inbox takes and human_check bounds how mechanically a form is submitted, but neither notices that you+1@gmail.com, you+2@gmail.com and y.o.u@gmail.com are one mailbox. users.canonical_email carries a unique index over the canonical form, so an account is bounded per mailbox rather than per typed string. The rule applies only to known providers — + is a legal local-part character whose meaning belongs to the receiving server, and stripping it from an unknown domain would merge unrelated accounts.
Enforcement is the index, never a SELECT before the INSERT: the collision surfaces as an IntegrityError and falls into the silent-duplicate branch /register already had, which keeps the endpoint free of both a TOCTOU window and a timing oracle. Migration 0019 grandfathers accounts that already collide as NULL rather than merging them or picking a winner, and CI now downgrades one revision, seeds a colliding pair and re-runs the backfill to assert that behaviour — the one code path whose only production run is its first real one.
Two domain-level checks sit beside it and answer visibly with a 400, unlike every other control here: a disposable-provider blocklist (vendored and curated rather than fetched, so the zero-egress default holds) and an MX check. Neither answer can be turned into an account-existence oracle, because both are properties of the submitted domain alone. Privacy relays are allowlisted ahead of the blocklist — Apple Private Relay and its peers are permanent addresses belonging to precisely the users most careful about their data. The MX check is not an abuse control and does not pretend to be one; every burner provider has good MX records. It exists so hard bounces do not damage sender reputation, and it fails open on every DNS error.
DISPOSABLE_EMAIL_BLOCK_ENABLED, DISPOSABLE_DOMAINS_EXTRA and EMAIL_MX_CHECK_ENABLED are the per-control rollbacks. All ship on, because neither widens what the platform reaches out to.
Email ownership
An account's address is now only ever set by proving the inbox is reachable. PATCH /users/me cannot touch email at all; the change goes through POST /users/me/email, which requires the current password, leaves the account on its old address, mails a link plus code to the new one and an actionable-nothing notice to the old one, and revokes other sessions when it lands. The pending address rides on the token row rather than on users, so it expires and rotates with the token and a stale link can never apply an older address.
- Every verification and email-change mail now carries a 6-digit code beside the link. The two are not interchangeable: a 256-bit token is globally unique, which is what lets the link endpoints stay unauthenticated, while 10⁶ is guessable — so the code carries a short TTL, an attempt cap that burns the row, and a lookup scoped to one
user_id. Password reset deliberately has no code: it grants account takeover and must not gain a guessable second credential. - Single-use tokens are claimed with one conditional
UPDATE … RETURNING, never a read-then-write, so two concurrent redemptions of the same link cannot both succeed. /registerno longer reveals whether an address is known. A duplicate registration creates nothing, leaves the existing account's credentials untouched, and notifies the real owner instead.POST /users/me/emaillikewise never reports that a target address is taken — a collision surfaces only at confirm time, once the caller has proven they can read that inbox.python -m app.scripts.purge_email_tokenssweeps rows past their retention window, and an expiry index backs it.
Two more account ceilings: simultaneity and storage
Token quota bounds monthly spend and says nothing about the other two axes.
Concurrency. PLAN_MAX_CONCURRENT_TASKS (free 1, starter 1, pro 3, scale 5) caps how many non-terminal tasks an account may hold at once. Without it the only ceiling was the expensive-route rate limit multiplied by the task timeout — hundreds of concurrent runs per account, each fanning out to subagents, outbound fetches and, where enabled, sandbox containers. Enforcement lives at task_run_store.create_run, the single insert point for a run header, inside the transaction that writes it and serialized per user by a row lock. A count in the route handler would be a TOCTOU window several awaits wide, and a burst of simultaneous starts would each observe the pre-insert count and all pass — the one case the cap exists to stop. AWAITING_ANSWER holds a slot, because a paused task still owns a lease and an in-process runner; nothing gets stuck, because the reconciliation sweep finalizes runs whose worker died.
Storage — the only axis that costs the platform after a run ends. Uploaded documents are per-plan (free 10 files / 10 MB up to scale 300 / 300 MB), enforced after the body is read and before it is chunked, so a refusal costs no embedding calls. The byte cap is the load-bearing half: a plan's allowance costs roughly 3× itself in resident Qdrant memory, and the file count is only the legible face of it. Custom agents carry a flat cap at the single insert point, so a one-click marketplace install cannot route around the limit the wizard respects. Conversation memory — one point per completed task, growing even for an account that never uploads — became a ring buffer: the newest write always lands and the oldest is pruned, best-effort and logged, because failing a stored memory over a trim is the worse trade.
Admins are unmetered on all three axes, as they already were for quota, so the operator can still load-test. A None ceiling means exactly that and is never read as zero.
A default deploy is no longer silent when it breaks
A self-contained watchdog now alerts on two conditions with no monitoring service to run:
- Readiness. Alerts fire on a state transition, never on a tick, so a dependency that stays down pages once. Two failing ticks to declare degraded — a restarting Postgres finishes well inside that, and a backend that boots ahead of its dependencies must not wake anyone — and one good tick to declare recovery. Degraded and recovered carry different dedupe keys, so a recovery is never swallowed by the outage's cooldown. With Redis configured the send right is claimed with
SET NX EXso N workers page once; on a Redis error the claim falls back to process-local, deliberately, because N workers each reporting a Redis outage beats an outage that silences its own alert. - 5xx rate.
ALERT_ERROR_RATE_THRESHOLDis a ratio, not a count, and that is the point: each worker serves roughly 1/N of the traffic, so a raw count would silently become N times stricter per worker while a ratio is topology-invariant. Probe traffic is excluded, because/health/readyanswers 503 while degraded and counting it would double-page for one fault.
Channels are a webhook and email, behind the same adapter seam as the email and payment providers. Both empty makes alerting a no-op with zero egress, and there is deliberately no ALERTING_ENABLED switch — configuring a channel is the enable. The webhook body carries text and content in one payload, which is what makes a single URL work for both Slack and Discord with no per-platform setting. ALERT_WEBHOOK_URL is the one outbound URL that is deliberately not passed through url_guard: it is an operator value from .env.prod, and guarding it would break the common self-hosted case (an internal notifier on the compose network is exactly the non-routable address url_guard rejects) while defending only against an attacker who can already rewrite the env file. What is enforced: scheme validation at boot, redirects never followed, a hard timeout, and the URL never reaching a log line or an exception message — a Slack/Discord webhook URL is itself the credential.
GET /metrics exposes Prometheus text over in-process counters, hand-rolled with no new dependency. It is unlocked by METRICS_TOKEN and answers 404 rather than 401 when unset, so an install that never configured it is indistinguishable from one where the route does not exist. It is a separate token from HEALTH_DETAIL_TOKEN on purpose — this one lives in a long-lived scraper config and exposes traffic volume, latency distribution and error rate, so their rotations should not be coupled. Caddy deliberately does not route it. No path label is emitted: path cardinality is unbounded, and label-exploding a hand-rolled registry is how "lightweight" becomes an OOM.
An optional external uptime probe ships as a compose profile, for the failure the in-process watchdog structurally cannot report: the process being gone.
Production refuses a boot it cannot make safe
POSTGRES_URL/MONGODB_URL/REDIS_URL/QDRANT_API_KEYnow reject the dev defaults, aCHANGE_MEleft over from the example file, and a guessable password (one equal to its username, or a compose default). It deliberately does not require credentials at all — a datastore reachable only on the compose network legitimately runs without auth, and demanding one would refuse a working deploy.REDIS_URLmost needs this: a wrong password there boots fine and silently degrades every throttle to per-process buckets. The error names the variable and never the value.TRUST_PROXY_HEADERSbecamebool | Noneand production refuses to boot with it unset. Exposed directly, a client forgesX-Forwarded-Forand opens a fresh bucket per request; left false behind a proxy, every user shares the proxy's bucket. There is no safe default to guess, so the operator has to choose.
A silent RAG outage, and the gate that would not have caught it
qdrant-client removed AsyncQdrantClient.search. retrieve_memories degrades to [] on any exception, so RAG returned "no results" for every user in production with nothing but a WARNING to show for it — while every CI gate stayed green, because each in-memory Qdrant double hand-defines the methods it answers to and kept replying.
Fixed by moving to query_points, raising the client floor so a lock can never resolve a version without it, and pointing the semgrep user-scope rule at the methods that now exist — a rule matching a removed method guards nothing. Two new coverage tiers close the class of drift rather than the instance: AsyncQdrantClient(":memory:") in the default suite, which is qdrant-client's own implementation and needs no infrastructure, and an integration gate in CI running the service code against the real Mongo and Qdrant the smoke job already starts. REQUIRE_INTEGRATION_SERVERS=1 turns the fixtures' "no server, skip" path into a failure, because a skip there is a green job that asserted nothing.
CI and code quality
@typescript-eslint/no-explicit-anyis now an error.tsc --noEmitacceptsanyby definition, so lint is the only gate that can see one — and four hand-writtenanys sat in the tree with every check green. All of them were in an unused demo component, whose deletion removes 1,300 lines with them.- The smoke job now asserts the metrics endpoint 404s without a token and serves a well-formed exposition body with one. pytest drives the app over an ASGI transport, which never runs lifespan events, so a watchdog that raises at startup would otherwise surface first on deploy.
SECURITY.mddocuments commit provenance and signing;CONTRIBUTING.mdcovers the merge policy.
Interface
- A dedicated change-email page and dialog, and a reusable OTP input for the 6-digit codes.
- The quota meter and subscription card render the free plan's unlimited allowance instead of a division by a missing limit.
- The cookie notice now describes the refresh cookie honestly, since the session credential is no longer in browser storage.
Full changelog: v0.1.2...v0.1.3