Skip to content

Releases: Perseus-Computing-LLC/ledger

v1.1.1

Choose a tag to compare

@tcconnally tcconnally released this 01 Aug 17:44
3369051

Canonicalize the public Ledger container image namespace as ghcr.io/perseus-computing-llc/ledger. The plutus-agent package and plutus CLI remain supported compatibility surfaces.

What's Changed

Full Changelog: v1.1.0...v1.1.1

Plutus v1.0.1

Choose a tag to compare

@tcconnally tcconnally released this 05 Jul 18:46
4bbc145

[1.0.1] — 2026-07-05

Security-hardening release. A four-part follow-up to the internal pre-1.0
review, from a fresh 2026-07-05 audit (money/ledger, auth/OIDC, HTTP surface,
deploy posture). No API/contract changes; the /v1 surface is unchanged. Every
finding was traced to source and covered by tests. Highlights: closed an OIDC
login-CSRF, made all money side effects atomic with the markers guarding them
(no webhook credit-loss / idempotency double-debit under crash+retry), added
security headers + CSV-injection defense, and made the server fail closed rather
than expose an unauthenticated dashboard on a public interface.

Security

  • Fail closed instead of exposing an unauthenticated dashboard. The server now
    refuses to bind a non-loopback host (e.g. --host 0.0.0.0) when authentication
    is disabled — previously the shipped Docker/compose default (serve --demo --host 0.0.0.0) and the off-by-default / fail-off-on-misconfig auth could
    publish an open billing console on the network. Override for a trusted network
    with --allow-insecure / PLUTUS_ALLOW_INSECURE=1; the disposable demo image
    sets it explicitly. New docs/deploy-hardening.md documents the safe config.
  • Config backups no longer escape the ignore rules. .gitignore/.dockerignore
    matched only *.plutus-bak-* but the real backup name is config.yaml.bak-<ts>,
    which could carry file-sourced secrets into a commit or image; now config.yaml*
    and *.bak-* are ignored.
  • SMTP alerts.require_tls (opt-in) refuses to send alerts over an
    unencrypted connection (protects alert bodies, not just credentials, from a
    STARTTLS downgrade). PyYAML is now a hard requirement at server start
    (fail fast) rather than silently degrading config parsing.

Changed

  • Dependency floors gained upper bounds (stripe<14, reportlab<5, PyYAML<7,
    pytest<10) for reproducible builds; the Docker image now runs as a non-root
    user.
  • Ledger atomicity: every money side effect now commits in the same transaction
    as the marker guarding it.
    Two crash-window bugs are closed:
    (1) Webhook silent credit loss / double-reversemark_stripe_event committed
    the event claim in a separate transaction from the credit add_ledger, so a
    crash between them plus Stripe's retry left the event marked "duplicate" with the
    credit never applied (customer paid, no credit); concurrent refund/dispute events
    for one charge could also double-reverse. handle_webhook_event now wraps the
    claim and all side effects in one db.immediate (BEGIN IMMEDIATE) transaction
    with commit=False threaded through mark_stripe_event/add_ledger/
    set_org_tier; any error rolls the whole thing back for a clean retry.
    (2) /v1/usage idempotency double-debit — the batch committed, but the
    idempotency-key status was flipped in a later separate commit, so a crash in
    between left committed events behind a NULL-status claim that the 120s reclaim
    deleted, letting a retry re-record the batch. The response is now stored inside
    the same db.immediate block as the debits. (2026-07-05 security review)
  • Prepaid hard-stop decided in integer micro-dollars. The block_over_balance
    check compared float USD (balance - cost_usd < 0); it now uses
    get_balance_micros/usd_to_micros so sub-micro float error can't let a debit
    slip past zero or wrongly block one. (2026-07-05 security review)
  • OIDC login-CSRF: bind the OAuth flow to the browser that started it. The
    authorization state was held only in a process-global pool, so an attacker
    could complete their own Google sign-in, capture their code+state, and feed
    a victim's browser a /auth/callback?code=…&state=… link — planting an
    attacker-owned session in the victim's browser (the victim would then operate
    inside, and leak API keys to, the attacker's tenant). /auth/login now sets a
    short-lived HttpOnly; SameSite=Lax plutus_oauth_state cookie and
    handle_callback requires the callback state to match it (constant-time),
    in addition to the existing _pending nonce check. (2026-07-05 security review)
  • Security headers on every response. _send now emits X-Frame-Options: DENY
    (+ CSP frame-ancestors 'none') so the dashboard can't be framed/clickjacked,
    X-Content-Type-Options: nosniff, Referrer-Policy: same-origin, and a CSP that
    locks default-src/object-src/base-uri/form-action to self. Script/style
    keep 'unsafe-inline' (the dashboard is inline-only); a nonce-based script-src
    is a follow-up. (2026-07-05 security review)
  • CSV export formula-injection neutralized. Tenant-controlled
    provider/model/workspace/task_type cells beginning with = + - @ (or a
    leading tab/CR) are now quote-prefixed in export.csv, so a crafted value like
    =HYPERLINK(...) can't execute when a teammate opens the export. (2026-07-05)
  • Webhook error/log hardening. /webhook/stripe no longer echoes the raw
    exception text to the caller on a bad signature (returns a generic message, logs
    detail server-side), and the success path logs only the event id/type instead of
    the applied result (org_id/amount/balance). (2026-07-05 security review)

Changed

  • views.simple_page's body parameter renamed to body_html with a docstring
    making explicit that it is inserted as raw HTML and callers must pre-escape
    untrusted data (removes a latent XSS foot-gun; no live path today). (2026-07-05)

Plutus v1.0.0

Choose a tag to compare

@tcconnally tcconnally released this 05 Jul 15:22
a0f3b12

[1.0.0] — 2026-06-27

Plutus 1.0 — the billing loop is closed and the contract is frozen. The
ledger is now an auditable mirror of Stripe (refunds, disputes, and failed
payments reverse it idempotently); every money- and quota-bearing input is
guarded; ingest and auth are hardened; self-serve export and a token-scoped admin
API are in; and the /v1 OpenAPI spec plus the DB forward-compatibility policy
are published as the frozen contract Perseus and Perseus Vault build against. An internal
security review (documented in docs/security-review-2026-06-27.md) cleared the
money/auth/tenant surfaces; an external review remains the gate before any public
launch.

Fixed

  • Orphaned in-flight Idempotency-Key no longer 409s forever (review F3, #80).
    If a request crashed between claiming an Idempotency-Key and storing its
    response, the row stayed status=NULL and every retry got 409 permanently.
    A claimed-but-unanswered row older than a 2-minute grace window is now
    reclaimable (re-processed), while a completed claim is never reclaimed
    (replay preserved). Added db.purge_idempotency() to bound the table.

Added

  • OpenAPI 3.1 spec for /v1/* + the forward-compatibility contract (#67).
    openapi.yaml documents the frozen /v1 surface (usage ingest,
    spend export, admin) that Perseus/Perseus Vault build against. docs/schema.md
    states the database forward-compat policy (additive-only within 1.x; breaking
    changes need a new major), and docs/postgres.md records the
    ADR keeping the single-file SQLite backend for 1.0 while documenting the
    Postgres migration shape. db.SCHEMA_VERSION bumped to 5 (the
    ingest_idempotency table); init_schema now refuses to open a database
    written by a newer Plutus, and db.get_schema_version() reads the stamped
    version.

Security

  • Negative token counts can no longer rewind the free-tier meter (#80). The
    /v1/usage boundary validated only that token fields coerce to int, so a
    negative input_tokens (with a non-negative cost_usd, dodging the #61 guard)
    rewound tracked_tokens_mtd — bypassing the Free-tier quota — and corrupted
    SUM(tokens) aggregates. Negatives are now rejected with a 400 at the
    boundary and a ValueError in record_usage.
  • CSRF synchronizer token as defense-in-depth (#58). State-changing
    dashboard POSTs now accept a per-session CSRF token in addition to the existing
    fail-closed Origin/Referer check: a request passes if it is same-origin or
    carries a valid token. This lets through legitimate requests whose
    Origin/Referer a privacy proxy stripped (which the origin check rejects), while
    a forgery — which can't know the token — is still blocked. The token is
    HMAC-SHA256(session_token, "plutus-csrf-v1"), derivable only by the cookie
    holder and never leaking the cookie; it's embedded as a hidden _csrf field in
    every dashboard/pricing form. The origin check remains the first gate.
  • Per-IP self-serve signup throttle (#59). The existing global hourly limiter
    and DB-backed daily org cap (#33) are both global, so one abuser could drain
    the whole daily budget and lock out legitimate signups. A new per-IP cap
    (auth.max_signups_per_ip_per_day, default 3; in-memory 24h ring) is checked
    before the global limiter. The client IP is the socket peer by default, or the
    first X-Forwarded-For hop when auth.trust_forwarded_for is set (for running
    behind a trusted reverse proxy). Existing members signing in are never
    throttled — only new-org self-serve signups.

Fixed

  • Dashboard "Sign out" chip used an undefined --muted CSS var (#56). The
    signed-in user chip and its Sign-out button now use var(--dim), so the text
    renders in the intended dim gray instead of falling back to the inherited color.

Changed

  • Package version is single-sourced (#57). pyproject.toml now declares
    dynamic = ["version"] reading from plutus_agent.__version__, so the wheel
    metadata and plutus version can no longer drift apart.

Tests

  • High-risk auth/tenant coverage (#66, part 3 — closes #66). Added tests for
    the previously-untested money/auth paths: the hand-rolled OIDC RS256 verifier
    itself (a real pure-Python RSA-signed token verifies; a tampered payload and a
    non-RS256 alg are rejected — every other auth test had set
    allow_unsigned_tokens, so the signature math was never exercised); the
    _authz_org cross-tenant PermissionError path; and the
    allow_negative_balance exemption end-to-end over HTTP. (The #60/#61/#62
    coverage landed with those fixes.)

Added

  • Token-scoped admin API (#66, part 2). A new /v1/admin/* surface lets an
    operator script tenant management instead of using the CLI/dashboard only:
    GET/POST /v1/admin/orgs (list / create), POST /v1/admin/credits
    (grant/adjust ledger entries), and GET/POST /v1/admin/keys (list /
    mint — the secret is returned once). Gated by a single admin.token
    (env PLUTUS_ADMIN_TOKEN, masked from saved config, constant-time compared);
    with no token configured the API is disabled and returns 404.
  • Self-serve spend export + cursor pagination (#66, part 1). New
    GET /v1/usage/export.csv and export.json (Bearer-authenticated, org-scoped,
    optional ?since/?until epoch bounds) let a customer pull their own usage
    for their books. List endpoints now paginate with a ?limit&before=<_rowid>
    cursor: new GET /api/ledger and GET /api/events return {items, next_before, limit}, and GET /api/orgs accepts ?limit&offset. The
    underlying db.ledger_history / metering.recent_events gained a before
    cursor.

Security

  • /v1/usage ingest hardening (#65).
    • Idempotency-Key. A retried or duplicated POST used to double-count usage
      and double-debit credit (the inverse of the webhook idempotency from #26).
      The endpoint now accepts an Idempotency-Key header, claims it atomically
      with the recording (per-org ingest_idempotency table), and replays the
      stored response on a duplicate instead of re-recording.
    • Per-key rate limit. A leaked/abusive key could fire unbounded batches; a
      per-key token-bucket limiter (config ingest.rate_per_min / burst) now
      returns 429 when exceeded.
    • Monitor-bridge lock-down. The bridge subprocess now requires the command
      to be an absolute path present in monitor.allowed_binaries (fail-closed,
      structured argv, shell=False), and when auth is on it only shells out for
      an authenticated request — an unauthenticated dashboard hit no longer
      triggers it.

Added

  • Estimated costs are flagged unpriced when no exact model price exists
    (#64).
    Whenever a usage event is metered without an exact cost_usd and the
    (provider, model) isn't in the price table, the cost falls back to a
    provider/global default — previously with no signal, so a coarse estimate
    looked authoritative. MeterResult.unpriced now carries that signal and it is
    surfaced per-event in the /v1/usage response. The price table is expanded to
    current 2026 models (adds claude-fable-5, the GPT-5 family, more Gemini, and
    xAI / Mistral / Cohere / Meta providers), carries a dated PRICE_TABLE_AS_OF
    stamp shown on the pricing page, and ModelPrice can now price reasoning
    tokens separately (defaults to the output rate, so existing estimates are
    unchanged). Deferred: persisting unpriced onto historical dashboard rows
    (needs a usage_events column) and cache-write token pricing (needs a new
    event token field) — both noted for a follow-up.

Fixed

  • Money-correctness cluster (#63) — four independent fixes:
    • USD-only is enforced. The credit ledger stores plain USD micro-dollars
      with no currency dimension, so a non-USD top-up was recorded as the wrong
      number of dollars. A configured billing.currency other than usd now
      raises a clear BillingError instead of silently mis-billing.
    • past_due no longer counts as active Pro. A subscription in dunning
      used to retain full Pro for the whole retry window; Pro is now kept only
      through active/trialing (Stripe restores Pro on the next active).
    • Credit checkout amounts are bounded to a finite $1–$10,000 at the form
      boundary — inf/nan/a 9-figure typo previously passed straight to Stripe.
    • Month boundaries are computed in UTC, matching the UTC-epoch event store,
      so the free-tier quota reset and MTD reports no longer shift by the server's
      UTC offset on a non-UTC host.
  • Batch /v1/usage no longer hides prepaid-hard-stop rejections (#62). The
    multi-event summary reported only the free-tier blocked count;
    over_balance rejections were absent, so a prepaid org past zero credit could
    get a 200 with events silently dropped. The summary now carries
    over_balance_blocked, free_limit_blocked, and a blocked total covering
    both reasons, and the endpoint returns 402 whenever nothing landed —
    including a batch split across both rejection reasons (previously it only 402'd
    when a single reason accounted for the whole batch).

Added

  • Stripe refunds, disputes, and failed payments now reverse the ledger
    (#60).
    The webhook handler previously ignored every reversal event, so a
    refunded or charged-back prepaid top-up left the credit on Plutus's
    append-only ledger forever. New handlers: charge.refunded posts a negative
    refund entry (converging to the charge's cumulative amount_refunded, so
    partial/repeat refunds reverse exactly once); charge.dispute.created /
    charge.dispute.funds_withdrawn post a negative adjust for the disputed
    amount (both events for one dispute converge to a single reversal); and
    invoice.payment_failed is recorded as a dunning alert. Top-ups are now keyed
    on the PaymentIntent so a dispute (...
Read more

v0.3.0 — Google OIDC sign-in

Choose a tag to compare

@tcconnally tcconnally released this 22 Jun 21:42
1d03ef7

App-native Google OIDC sign-in for the dashboard and billing endpoints — Plutus no longer needs an external proxy to be private. Off by default; enable with auth.enabled + a Google OAuth client. Revocable server-side sessions, allow-listed access, per-user org scoping. See docs/auth.md.

Schema v2 (adds sessions, auto-applied). Existing behavior unchanged until configured.

🤖 Generated with Claude Code