Skip to content

v0.27.0: production hardening and billing on payforge - #71

Merged
grokify merged 33 commits into
mainfrom
release/v0.27.0
Oct 29, 2026
Merged

grokify merged 33 commits into
mainfrom
release/v0.27.0

Conversation

@grokify

@grokify grokify commented Oct 29, 2026

Copy link
Copy Markdown
Member

Hardening for running SystemAuth and the relying-party stack with outside users, and billing moved onto payforge. Release notes: docs/releases/v0.27.0.md; roadmap: docs/specs/initiatives/INIT-SYSTEMFORGE-015/ROADMAP.md (Phases 1–3).

Security

  • Codes and refresh tokens are single-use under concurrency: invalidation and rotation are conditional updates, so a second concurrent request gets invalid_grant/invalid_request and no tokens. The memory store now returns the request for a used code, so a replayed code revokes its tokens.
  • Email linking: a social sign-in links to an existing account by email only when the provider is authoritative for the address (Google consumer domains, or a Workspace account's hd domain). GitHub uses the primary email only and never auto-links.
  • Production checks fail closed: tokens.hmac_secret, static allow_bearer_tokens clients, and a per-instance replay cache without dpop.single_instance or Redis stop startup. The token endpoint refuses client_credentials with a shared secret and allow_bearer_tokens clients. Waivers are listed under production.allow and logged.
  • Security headers on every SystemAuth response: CSP with frame-ancestors 'none', X-Frame-Options, nosniff, Referrer-Policy: same-origin, and HSTS on https issuers.
  • Billing on payforge: billing replaces marketplace/stripe. It has a Postgres store and cross-instance advisory lock, a verified, size-limited, idempotent webhook handler, sinks that re-sync authorization on every apply, and checkout/portal handlers that take the organization from the session and the redirect URLs from config. Loopback redirects are allowed only with a test-mode key.

Also

  • relyingparty: cookie writes with an allowed Origin accept any body (file uploads); BFF.SignOut; LoginErrorPath redirects failed sign-ins to the app's sign-in page.
  • A past-due subscription keeps its plan while the provider retries the payment.
  • SECURITY.md and docs/security/production-checklist.md.

Breaking

marketplace/stripe removed; production startup needs tokens.hmac_secret and dpop.single_instance (or Redis); client_credentials with a client secret refused in production; narrower email linking; Referrer-Policy changed to same-origin. See the upgrade guide in the release notes.

Tests and lint pass locally. The Postgres-backed billing and lock tests run only in CI (SF_TEST_PG_DSN).

🤖 Generated with Claude Code

grokify and others added 30 commits October 28, 2026 14:43
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fosite reads a code or refresh token before it writes, and this storage
does not run the two in one transaction, so two concurrent requests with
the same code or refresh token could both read it as valid and both
receive tokens.

Invalidating a code now only matches an unused code, and rotating a
refresh token first retires the presented token only while it is
active. The request that loses gets invalid_grant or invalid_request
and no tokens. A later reuse of a rotated refresh token still revokes
the family through the read path.

The memory store also returned no request for a used code, so a
replayed code answered server_error and left the tokens it had issued
active; it now returns the request and Fosite revokes them.

Refs: RMI-SYSTEMFORGE-200
Refs: RMI-SYSTEMFORGE-201
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he address

A new GitHub or Google identity was linked to any existing account whose
verified email matched the upstream one. A provider can keep an address
marked verified after the person loses the mailbox, such as a former
work address, so whoever held that stale verification could sign in to
the account of the person who now owns the address.

An identity now links by email only when the login is authoritative for
the address: Google for gmail.com and googlemail.com, and for a
Workspace account's own domain (the hd claim, which the Google connector
now reads). Other verified emails create an account as before, and an
email that already belongs to an account is refused with
ErrEmailConflict; the person signs in the way they signed in before.

GitHub sign-in uses the primary email only, never another verified
address on the account.

Refs: RMI-SYSTEMFORGE-202
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Report vulnerabilities through GitHub private vulnerability reporting,
with response targets, the supported-versions policy and how fixes are
disclosed. Link it from the README and the documentation site.

Refs: RMI-SYSTEMFORGE-210
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A checklist an operator walks before an application that embeds
SystemAuth goes live: keys and secrets, the fail-closed production
checks, shared stores, the edge and origin, logging, the database,
billing, monitoring and response, and branch protection. Link it from
the security policy and the documentation site.

Refs: RMI-SYSTEMFORGE-211
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… policy

production.allow names the production checks a deployment waives
(ephemeral_hmac_secret, client_secret_client_credentials,
bearer_token_clients); an unknown name is a configuration error.
dpop.single_instance declares that one instance serves SystemAuth.

WithProductionPolicy makes the token endpoint answer unauthorized_client,
once the client is authenticated, to the client_credentials grant from a
client using a shared secret and to clients registered with
allow_bearer_tokens. Checking per request covers clients registered at
runtime. Confidential clients keep using their secret for the
authorization_code and refresh_token grants, and discovery stops listing
client_credentials when it is refused.

Refs: RMI-SYSTEMFORGE-203
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd replay cache

Outside development mode the service now refuses to start without
tokens.hmac_secret, with a statically configured client that sets
allow_bearer_tokens, or without a DPoP replay cache every instance shares
(dpop.replay_cache_redis_url or Options.DPoPReplayCache) or
dpop.single_instance. It applies the token endpoint's production policy
unless production.allow waives a check, and logs every waiver and the
single-instance setting as a warning at startup.

Refs: RMI-SYSTEMFORGE-203
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New logs a warning when it falls back to the in-memory replay cache for a
public URL. Options.SingleInstance declares that one instance serves the
API and silences it.

Refs: RMI-SYSTEMFORGE-203
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs: RMI-SYSTEMFORGE-203
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs: RMI-SYSTEMFORGE-203
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One middleware sets a content security policy (default-src 'self',
base-uri 'none', frame-ancestors 'none', object-src 'none', form-action
'self'), X-Frame-Options DENY, X-Content-Type-Options nosniff,
Referrer-Policy same-origin, and HSTS when the issuer is https, on the
login, consent, sign-up, sign-up code, waitlist, age question and logout
pages, their error pages, and the JSON and redirect responses.

Browsers check form-action on the redirects that follow a submission, so
the consent page adds its client's redirect URI origins, the logout page
the allowed redirect origins, and the age question https:. The pages need
no inline script or style.

same-origin replaces no-referrer on the pages: page URLs still never reach
another origin as a referrer, and a same-origin form post keeps the Origin
header the form handlers check, which no-referrer turns into null.

Refs: RMI-SYSTEMFORGE-204
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs: RMI-SYSTEMFORGE-204
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs: RMI-SYSTEMFORGE-204
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A provisioned user signing in through the organization's Google
Workspace still links to the provisioned principal; a GitHub sign-in
with the same verified address is refused with ErrEmailConflict.

Refs: RMI-SYSTEMFORGE-202
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs: RMI-SYSTEMFORGE-211
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The billing package supplies the ports payforge leaves to the host.
Store implements payforge.Store on the Ent client: processed event IDs
in sf_billing_events, whose unique event_id makes processing once hold
across instances, and the customer-to-organization mapping in
sf_billing_customers. Both tables are created by the Ent schema
migration.

billing/pglock implements payforge.Locker with a PostgreSQL
transaction-scoped advisory lock held on its own connection until
unlock, so it cannot outlive its connection and is never left held on a
pooled one. It is a package of its own so the rest of billing runs on
any Ent dialect.

payforge is not yet tagged, so it resolves through a relative replace
until a release is available.

Refs: RMI-SYSTEMFORGE-205
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NewWebhookHandler verifies a request with payforge's reader, keeping its
body limit, and passes the event to the lifecycle, which holds the
organization's lock. It answers 400 to a signature that does not
verify, 413 to an oversized body, 500 only when reading or handling
fails, a lock included, so the provider redelivers, and 200 to
duplicate, ignored, other-mode and foreign events, which are logged
rather than retried. Response bodies are fixed words; causes are logged
through the configured slog.Logger.

A one-time checkout, which the lifecycle leaves to the host, is passed
to an optional CheckoutCompleter under the same per-organization lock
and recorded as processed.

Refs: RMI-SYSTEMFORGE-206
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sink implements payforge's EntitlementSink, SaleSink, SellerSink and
DisputeSink, and the webhook's CheckoutCompleter, on the Ent client.
The subject is the buying organization.

- A plan entitlement sets the organization's platform subscription:
  plan_tier is the plan it is on, the spec's default when nothing
  grants. OnEntitlement and WithPlanSyncer run after every apply.
- A paid one-time checkout grants a license, matched to the listing,
  organization and purchaser through the order recorded at checkout
  (sf_billing_orders), never through provider metadata.
- A full refund (sf_billing_refunds), or an open or lost dispute
  (sf_billing_disputes), suspends that license; a dispute that is won
  restores it. Refunds only grow and closed disputes stay closed, so
  events may arrive in any order, before the checkout included.
- A seller's connected account (sf_billing_sellers) records whether it
  can take charges. SellerGate implements the new
  marketplace.PublishGate, which EntListingService.WithPublishGate uses
  to refuse publishing a paid listing with ErrSellerNotReady.

Each apply changes the database in one transaction, then writes the
authorization graph again from the stored state whether or not anything
changed, so a sync that failed is repaired when the provider redelivers.
A subject that is not a known organization is logged and acknowledged.

Refs: RMI-SYSTEMFORGE-207
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Checkout serves, on plain net/http, a plan checkout, a one-time listing
checkout and the customer portal for the caller's organization. The
organization comes from the caller's credential (configurable), the
caller must pass a marketplace.PurchaseAuthorizer for it, which
NewCheckout requires, and managed principals are refused. The provider
customer is found from the organization on the server and created and
linked the first time.

Redirect URLs are configured, never read from the request, and must be
https on a host in AllowedURLHosts. A listing checkout runs under the
organization's lock and records its order before the buyer sees it.
Errors are fixed JSON messages; causes are logged.

NewStripe builds payforge's Stripe client and webhook reader with what
production needs: the startup checks, the allowed redirect hosts, the
shared Locker, and the reader's mode set from the key.

Refs: RMI-SYSTEMFORGE-208
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Payments are taken by package billing on payforge. marketplace/stripe,
marketplace.CheckoutService with its request and session types,
EntService.WithCheckout, Service.Checkout, the marketplace API's
POST /checkout endpoint and the WithPurchaseAuthorizer option that only
it read are removed, with the stripe-go v84 dependency.
PurchaseAuthorizer and OrgRolePurchaseAuthorizer stay and are passed to
billing.CheckoutConfig.

docs/marketplace/billing.md describes the wiring, the endpoints and
tables, and how to upgrade from marketplace/stripe.

BREAKING CHANGE: marketplace/stripe, marketplace.CheckoutService and
POST /checkout are removed; use billing.Checkout and its endpoints.
Refs: RMI-SYSTEMFORGE-209
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…retries

Billing applies payforge's rule that a past-due subscription still
grants its plan while the provider retries the payment, but
Subscription.IsActive treated past due as inactive, so the
organization's entitlements dropped the paid tier during the retries.
IsActive now grants while active, trialing or past due.

Refs: RMI-SYSTEMFORGE-207
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs: RMI-SYSTEMFORGE-212
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs: RMI-SYSTEMFORGE-202
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Checkout accepted redirect URLs on localhost and loopback addresses
with any key, so a live configuration pointing at a developer's machine
started. Loopback hosts, and http with them, now pass only when the
payment provider reports a test-mode key; a provider that reports no
mode counts as live, so NewCheckout refuses such URLs at startup.

Refs: RMI-SYSTEMFORGE-208
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cookie-write check required a JSON body on every state-changing
request, so a signed-in browser could not upload a file with
multipart/form-data. A browser sends Origin on such requests and a page
cannot forge it, so when Origin is present and allowed, any body is now
accepted; a cross-site form still arrives with its own site's Origin and
is refused. Without an Origin, the JSON body is still required, with an
allowed Referer when one is sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An application that mounts the organization console with its BFF
session needs the console's SignOut: end the session, revoke its
SystemAuth refresh token and clear the cookie, without the logout
endpoint's response. BFF.SignOut does that, and /bff/auth/logout now
calls it after its cross-origin check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A browser whose sign-in failed at the BFF's login or callback routes
saw a raw JSON error. With BFFConfig.LoginErrorPath (also
appauth.Options.LoginErrorPath) set, it is redirected there with
?error=<CODE> instead; requests that ask for JSON still get JSON, and
the upstream's error text is never copied into the URL. The path must
pass the return_to checks. Without it the errors stay JSON.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…v0.27.0

Refs: RMI-SYSTEMFORGE-212
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Too many files!

This PR contains 156 files, which is 56 over the limit of 100.

To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch.

Upgrade to a paid plan to raise the limit.

This review couldn't start because sufficient usage credits or metered capacity aren't available. Add credits or update usage-based reviews in the billing tab, then retry.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 63cdd65c-4756-44f3-b532-268e2cef48f2
Commits

Reviewing files that changed from the base of the PR and between aaad4cb and e3ceb4d.

Files ignored due to path filters: 1
  • go.sum is excluded by !**/*.sum
Files selected for processing: 156
  • CHANGELOG.json
  • CHANGELOG.md
  • README.md
  • SECURITY.md
  • billing/checkout.go
  • billing/checkout_test.go
  • billing/doc.go
  • billing/orders.go
  • billing/pglock/pglock.go
  • billing/pglock/pglock_test.go
  • billing/sink.go
  • billing/sink_test.go
  • billing/store.go
  • billing/store_test.go
  • billing/stripe.go
  • billing/stripe_test.go
  • billing/testdata/pricing.json
  • billing/webhook.go
  • billing/webhook_test.go
  • cmd/systemauth/config.example.yaml
  • cmd/systemauth/main_test.go
  • cmd/systemauth/postgres_test.go
  • docs/design/architecture/organizations.md
  • docs/guides/app-authentication.md
  • docs/marketplace/billing.md
  • docs/marketplace/overview.md
  • docs/releases/changelog.md
  • docs/releases/index.md
  • docs/releases/v0.27.0.md
  • docs/security/production-checklist.md
  • docs/session/dpop.md
  • docs/specs/initiatives/INIT-SYSTEMFORGE-015/ROADMAP.md
  • docs/systemauth/deployment.md
  • docs/systemauth/embedding.md
  • docs/systemauth/relying-party.md
  • docs/systemauth/social-login.md
  • go.mod
  • identity/appauth/appauth.go
  • identity/appauth/authenticator_internal_test.go
  • identity/ent/billingcustomer.go
  • identity/ent/billingcustomer/billingcustomer.go
  • identity/ent/billingcustomer/where.go
  • identity/ent/billingcustomer_create.go
  • identity/ent/billingcustomer_delete.go
  • identity/ent/billingcustomer_query.go
  • identity/ent/billingcustomer_update.go
  • identity/ent/billingdispute.go
  • identity/ent/billingdispute/billingdispute.go
  • identity/ent/billingdispute/where.go
  • identity/ent/billingdispute_create.go
  • identity/ent/billingdispute_delete.go
  • identity/ent/billingdispute_query.go
  • identity/ent/billingdispute_update.go
  • identity/ent/billingevent.go
  • identity/ent/billingevent/billingevent.go
  • identity/ent/billingevent/where.go
  • identity/ent/billingevent_create.go
  • identity/ent/billingevent_delete.go
  • identity/ent/billingevent_query.go
  • identity/ent/billingevent_update.go
  • identity/ent/billingorder.go
  • identity/ent/billingorder/billingorder.go
  • identity/ent/billingorder/where.go
  • identity/ent/billingorder_create.go
  • identity/ent/billingorder_delete.go
  • identity/ent/billingorder_query.go
  • identity/ent/billingorder_update.go
  • identity/ent/billingrefund.go
  • identity/ent/billingrefund/billingrefund.go
  • identity/ent/billingrefund/where.go
  • identity/ent/billingrefund_create.go
  • identity/ent/billingrefund_delete.go
  • identity/ent/billingrefund_query.go
  • identity/ent/billingrefund_update.go
  • identity/ent/billingseller.go
  • identity/ent/billingseller/billingseller.go
  • identity/ent/billingseller/where.go
  • identity/ent/billingseller_create.go
  • identity/ent/billingseller_delete.go
  • identity/ent/billingseller_query.go
  • identity/ent/billingseller_update.go
  • identity/ent/client.go
  • identity/ent/ent.go
  • identity/ent/entql.go
  • identity/ent/hook/hook.go
  • identity/ent/internal/schema.go
  • identity/ent/migrate/schema.go
  • identity/ent/mutation.go
  • identity/ent/predicate/predicate.go
  • identity/ent/privacy/privacy.go
  • identity/ent/runtime.go
  • identity/ent/schema/billing_customer.go
  • identity/ent/schema/billing_dispute.go
  • identity/ent/schema/billing_event.go
  • identity/ent/schema/billing_order.go
  • identity/ent/schema/billing_refund.go
  • identity/ent/schema/billing_seller.go
  • identity/ent/tx.go
  • identity/oauthclient/client.go
  • identity/oauthclient/connector.go
  • identity/oauthclient/github_email_test.go
  • identity/relyingparty/bff.go
  • identity/relyingparty/bff_test.go
  • identity/relyingparty/csrf_test.go
  • identity/relyingparty/login_error_test.go
  • identity/scim/store/trust_test.go
  • identity/systemauth/config.go
  • identity/systemauth/handler_discovery.go
  • identity/systemauth/handler_fosite.go
  • identity/systemauth/production.go
  • identity/systemauth/production_test.go
  • identity/systemauth/schema/config.schema.json
  • identity/systemauth/security_headers.go
  • identity/systemauth/security_headers_test.go
  • identity/systemauth/server.go
  • identity/systemauth/signup_age.go
  • identity/systemauth/signup_code_page.go
  • identity/systemauth/signup_code_page_test.go
  • identity/systemauth/signup_waitlist.go
  • identity/systemauth/social_authoritative_test.go
  • identity/systemauth/social_consent.go
  • identity/systemauth/social_directory.go
  • identity/systemauth/social_login.go
  • identity/systemauth/social_login_test.go
  • identity/systemauth/social_logout.go
  • identity/systemauth/social_redirect.go
  • identity/systemauth/social_signup_test.go
  • identity/systemauth/storage_ent.go
  • identity/systemauth/storage_memory.go
  • identity/systemauth/token_race_test.go
  • identity/systemauthsvc/bootstrap_test.go
  • identity/systemauthsvc/config.go
  • identity/systemauthsvc/connections_test.go
  • identity/systemauthsvc/console_test.go
  • identity/systemauthsvc/production.go
  • identity/systemauthsvc/production_test.go
  • identity/systemauthsvc/service.go
  • identity/systemauthsvc/service_test.go
  • identity/systemauthsvc/signup_test.go
  • marketplace/access_test.go
  • marketplace/caller.go
  • marketplace/caller_test.go
  • marketplace/checkout_api_test.go
  • marketplace/errors.go
  • marketplace/handlers.go
  • marketplace/listing_service.go
  • marketplace/marketplace.go
  • marketplace/service.go
  • marketplace/stripe/checkout.go
  • marketplace/stripe/checkout_test.go
  • marketplace/stripe/connect.go
  • marketplace/stripe/webhook_test.go
  • marketplace/stripe/webhooks.go
  • marketplace/types.go
  • marketplace/types_test.go
  • mkdocs.yml

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread billing/checkout.go Fixed
grokify and others added 2 commits October 28, 2026 17:27
…estart test

The test starts the server outside dev mode, which now requires the
token HMAC secret and a declared replay cache.

Refs: RMI-SYSTEMFORGE-203
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A failed billing request logged r.URL.Path, which the client controls;
it now logs the route pattern the request matched.

Refs: RMI-SYSTEMFORGE-208
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@grokify
grokify merged commit dd86507 into main Oct 29, 2026
10 checks passed
@grokify
grokify deleted the release/v0.27.0 branch October 29, 2026 11:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants