2026-10-23 14:25:25 -04:00
2026-10-23 12:19:40 -04:00
2026-10-23 12:02:50 -04:00
2026-10-23 13:25:45 -04:00
2026-10-23 14:25:25 -04:00
2026-02-19 15:22:47 -05:00
2026-10-23 12:19:40 -04:00
2026-10-23 12:02:50 -04:00
2026-10-23 12:02:50 -04:00
2026-04-09 14:23:57 -04:00
2026-02-19 19:10:13 -05:00
2026-10-23 12:19:40 -04:00

WWW

Apache serves the static eom.dev website. A separate Django service provides public accounts, Dex sign-in, and Stripe membership under /members/.

Optional SHKeeper support adds prepaid crypto membership alongside Stripe. Both payment methods feed the same LDAP group reconciliation and local expiry.

Membership flow

  1. Anyone can request a free account. A one-hour, single-use email link allows them to choose a password. The account is created as an inetOrgPerson in OpenLDAP; the password goes only through RFC 3062 Password Modify over certificate-verified LDAPS/StartTLS. This app never stores a password/hash.
  2. Sign-in uses Dex Authorization Code + PKCE, state and nonce. Authlib verifies the RS256 ID token, issuer, audience, signature and lifetime. A signed federated_claims connector/user ID must match the configured LDAP connector and LDAP entryUUID; accounts are not linked using email alone.
  3. Authenticated users choose a server-allowlisted recurring Stripe Price. Stripe hosts Checkout and the billing portal. Return URLs never grant access.
  4. Stripe webhooks are signature-verified and durably deduplicated. The worker rereads canonical subscriptions and paid invoice lines, so duplicate, missing, or reordered webhooks cannot restore expired access.
  5. The worker first sweeps local expiries for all accounts, then reconciles Stripe state. An account belongs to the configured tier group only while it has paid service time. A failed renewal does not extend that time. Immediate cancellation, pause, unpaid status or unknown prices revoke access. Cancel at period end retains access until the paid expiry. Trialing does not grant paid access. A zero-total invoice marked paid (e.g. a coupon) counts as paid.
  6. Expiry removes group membership, not the LDAP account. LDAP writes are idempotent, and failed synchronization is retried on every sweep. Old group mappings remain in the database so deleting/changing a tier also revokes its former group. Account row locks serialize Checkout and reconciliation.

The worker interval is 60 seconds after a complete sweep, not a hard one-minute expiry SLA. LDAP/network outages delay changes until recovery. Stripe outages do not extend locally recorded paid expiry. Already-issued Dex tokens and downstream sessions do not instantly lose their cached claims; set short token lifetimes and enforce reauthorization in each relying application.

Existing Stripe product

The live Stripe dashboard was inspected on 2026-10-23. This integration reuses Subscription, product prod_Sc9N5J0vNAdLCn, without modifying its prices, metadata, Discourse integration, or existing subscriptions.

Tier Monthly USD Existing live Price ID Proposed dedicated LDAP group
Level 0 $4 price_1Rgv4xC5KPXqTvVLfMd6z7wn www-level-0
Level 1 $8 price_1Rgv5QC5KPXqTvVLv1PaHWb5 www-level-1
Level 2 $16 price_1Rgv5rC5KPXqTvVL8Ol8sMUf www-level-2
Level 3 $32 price_1Rgv6FC5KPXqTvVL39mAxYjm www-level-3
Level 4 $64 price_1Rgv6mC5KPXqTvVLkcmznWZa www-level-4

The deployable mapping is in membership/tiers.eom.json. The group DNs are deployment proposals, not verified existing groups: create them or edit the mapping to your dedicated groups before starting the worker. Higher tiers do not implicitly inherit lower-tier groups. Map your applications' authorization accordingly. Never use administrator or unrelated service groups. Display prices are informational; the configured Stripe Price is authoritative. For tax obligations, review the product tax code and Stripe settings separately.

Existing Discourse customers/subscriptions are not automatically imported. New website subscriptions carry metadata.membership_account and are scoped to the signed-in account's stored customer ID. Never attach a pre-existing customer or subscription based only on matching email; a separately reviewed ownership migration is required. The dashboard showed zero active subscriptions on each of these five prices when inspected.

Production setup

  1. Copy .env.example to the ignored local environment file. Fill secrets directly through your secret manager/editor, not chat. Generate independent, high-entropy Django, PostgreSQL, Dex and LDAP credentials. Fill SMTP settings and a real sender address for verification mail.
  2. Create the account OU and sentinel/groups after reviewing membership/deploy/groups.example.ldif. Configure a least-privilege LDAP service account allowed to:
    • search the account tree for uid, mail, entryUUID and DN;
    • add/delete entries only within the website account OU;
    • perform Password Modify only on those newly provisioned entries;
    • read and add/delete uniqueMember only on the dedicated paid groups;
    • read the sentinel, which must never be a login identity. Do not use the directory root/admin bind. Permit Dex a separate read/search service identity. Prevent users from writing their own paid group membership.
  3. Verify OpenLDAP's Argon2 module/password policy (olcPasswordHash: {ARGON2}), require suitable password strength/history, reject unauthenticated binds, and enforce directory-side unique uid and mail across the full search tree, including other account registrars. Only then set LDAP_PASSWORD_SCHEME={ARGON2}. The app does not alter slapd configuration. If a private CA is used, mount its certificate read-only into both membership and worker services and set LDAP_CA_CERT_FILE. TLS verification is mandatory.
  4. Add a dedicated Dex client/LDAP connector using membership/deploy/dex-membership.example.yaml. The exact callback is https://eom.dev/members/oidc/callback/ (trailing slash). Set the same client secret in Dex and DEX_CLIENT_SECRET, and set DEX_CONNECTOR_ID=www-ldap. This connector uses idAttr: entryUUID, preferredUsernameAttr: uid, emailAttr: mail; scopes include federated:id. Do not change the existing connector's idAttr blindly: that would change subjects in other apps. Use the additional connector instead. Dex must search the account OU without requiring membership in a paid group. RS256 and all discovery endpoints must be HTTPS on the issuer's origin. Existing lowercase LDAP usernames matching the registration syntax can sign in without signup.
  5. The supplied tier file contains live Price IDs. Set STRIPE_LIVE_MODE=1 only with the matching live Stripe API key. For sandbox testing, override MEMBERSHIP_TIERS_JSON with test-mode recurring prices and set live mode to 0. Set the JSON object to {} to disable paid tiers. Grant the API key only required customer, Checkout, subscription, invoice/line read, Price read, and billing portal permissions; the app never creates Products or Prices.
  6. Create/configure a Stripe billing portal with only these five allowed prices, payment method updates and cancellation. Set its configuration ID in STRIPE_PORTAL_CONFIGURATION. For tier changes, use immediate paid proration for upgrades and period-end downgrades. Access always follows the current price's paid invoice lines; an unpaid upgrade does not grant the higher group. An arbitrary instant downgrade without a paid line for the new price can suspend access until Stripe invoices it; use scheduled downgrades instead.
  7. Add a Stripe webhook endpoint at https://eom.dev/members/stripe/webhook/. Subscribe to checkout.session.completed, checkout.session.async_payment_succeeded, checkout.session.async_payment_failed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.paid, invoice.payment_failed. Use API version 2025-03-31.basil and set the endpoint signing secret in STRIPE_WEBHOOK_SECRET. Do not edit/delete the Discourse webhook. The worker's full sweep recovers missing events without relying on payload arrival order.
  8. Deploy with Docker Compose. A one-shot migration service runs before the web and worker services. PostgreSQL has no published port. Its Compose network is private; use POSTGRES_SSLMODE=verify-full with a mounted root CA for remote PostgreSQL, and use encrypted disks/backups. Do not use the SQLite setting in production: real PostgreSQL row locks are required.
  9. Terminate TLS at your existing edge and forward only to Apache's private listener. The host port now defaults to loopback (127.0.0.1:8080); if your TLS proxy is on another host/container, explicitly set a private bind address and firewall access. Apache overwrites, rather than trusts, forwarded scheme and client-IP headers. Do not expose the membership service port. Do not enable MEMBERSHIP_TRUST_PROXY outside this isolated topology. For actual IP throttling behind another proxy, configure Apache's trusted mod_remoteip proxies; otherwise the default conservatively throttles by the TLS edge IP.
  10. Disable/redact access/error tracing for verification URLs and OIDC callback codes at every edge, CDN, APM and log collector. Apache suppresses member access logs; Gunicorn access logs are disabled. Do not enable request/body debug logging. Sessions are server-side, rotate on login, and expire in one hour. CSRF protects state-changing forms; only signed Stripe webhooks are CSRF-exempt. Backups contain identity/billing metadata; restrict access, encrypt them, and define a retention/deletion policy.

Configuration does not automatically provision remote LDAP/Dex/Stripe resources. No production credentials or live account changes are made by the test suite.

Operations and limitations

SHKeeper prepaid crypto

Stripe remains the automatically recurring payment method. SHKeeper accepts one-time payments for one calendar month of the same tiers, at the USD amounts in membership/tiers.eom.json. It does not debit a wallet or automatically renew. The user must create and pay a new invoice for each month. Amounts are server-side configuration, never supplied by the browser and never parsed from the display-price label. Keep usd_amount aligned with your Stripe prices when changing prices; existing orders retain the original amount.

To enable:

  1. Set SHKEEPER_URL to your actual HTTPS origin, SHKEEPER_API_KEY to its invoice API key, and SHKEEPER_CURRENCIES to a comma-separated allowlist of the API currency identifiers enabled on your instance (e.g. BTC or XMR). These identifiers are network-specific for tokens (e.g. ETH-USDT is not interchangeable with TRX-USDT). No instance URL, wallets or keys are assumed.
  2. For an internal CA, mount the trusted CA file read-only into both web and worker containers and set SHKEEPER_CA_CERT_FILE. TLS verification is always enabled. API requests have timeouts, disallow redirects, and cap response size; the API key never reaches the browser. No payout/UI administrator credentials or wallet private keys are needed. Restrict network/API access because SHKeeper's API key may expose more than invoice endpoints.
  3. Review SHKeeper fee, exchange-rate recalculation, payment-tolerance and confirmation settings. This integration requires a full invoice amount and confirmed transactions; it intentionally does not grant access for partial, underpaid or unconfirmed transfers, even if a provider tolerance says paid. The payment page identifies its initial quote as such; a partial/late payment may require administrator assistance with the updated remainder.
  4. Each new invoice supplies the callback URL https://eom.dev/members/shkeeper/callback/. Permit SHKeeper to reach it. SHKeeper's documented callbacks have no signature. This endpoint only acknowledges them: it never trusts amounts/status/IDs, writes payment state, or triggers an API request. The worker polls known local invoice UUIDs over authenticated HTTPS, so forged, repeated, missing or reordered callbacks cannot grant access or extend a term.
  5. Redeploy the image and run the migration service, which applies membership/members/migrations/0002_crypto_invoices.py. Keep the membership worker running; it independently refreshes Stripe and SHKeeper, and performs a local-only expiry sweep before remote requests.
  6. Smoke-test a low-value invoice on your actual deployment before offering public crypto payments. The adapter follows the documented SHKeeper 2.1 API: POST /api/v1/{crypto}/payment_request and GET /api/v1/invoices/{external_id}. Require the GET response to include external_id, fiat, amount_fiat, balance_fiat, status and txs (transaction status, asset identifier, address and txid). Unsupported or ambiguous responses do not grant access. Different SHKeeper versions must be verified against these fields, especially transaction crypto identifiers.

Payment lifecycle:

  • An authenticated user selects the currency, then sees a private invoice with its deposit address and quote. One unfinished invoice is allowed per account. Repeated submissions reopen that same invoice, even if a second tab requests another tier or currency. The UUID and USD amount are committed before contacting SHKeeper; retrying after a timeout reuses SHKeeper's documented create/update-by-external-ID operation with the same parameters.
  • There is no fabricated invoice deadline: SHKeeper deposit addresses can receive late transfers. Unpaid/partial orders remain polled and cannot be abandoned/switched from the UI. Contact the administrator if the wrong network was selected; do not delete orders with potentially in-flight funds.
  • Access begins when the worker first verifies a fully paid invoice with matching USD order, amount, receiving address and asset and confirmed txs. A renewal of the same tier starts at the later of now or that tier's previous prepaid end and adds one calendar month in UTC. Month-end dates clamp to the last day of the next month. Queued future terms do not grant early access. Delayed worker verification starts a new term at verification, not at an untrusted callback timestamp.
  • Each invoice can grant exactly one term. Overpayments, repeated callbacks and additional transfers to a paid address never buy extra months. Create a new invoice for renewal. Refund/overpayment handling is manual; this app does not issue cryptocurrency payouts.
  • Active/queued paid crypto invoices are rechecked until their end. If an invoice reverts to unpaid/partial/unconfirmed (e.g. a chain reorganization), its grant is removed. Recovery restores only its original term, not another month. During API outages, the last verified term remains valid only until its stored expiry; local expiry never waits for provider recovery. Alert on stale last_shkeeper_sync, failed polls and worker errors.
  • LDAP desired groups are the union of all currently valid Stripe and SHKeeper terms. One provider's failure/cancellation cannot revoke a still valid grant from another. Removed tiers are revoked regardless of provider.
  • To avoid accidental double payments, a current Stripe subscription or open Stripe Checkout blocks a new crypto invoice. Unfinished or unexpired crypto orders block a new Stripe subscription; another crypto tier must expire before switching tiers. Same-tier crypto renewals remain available. This does not cancel existing Stripe recurring charges: finish/cancel them through Stripe's billing portal before changing methods. Provider-side/manual changes can still create overlapping payments; grants remain a safe union.
  • Leaving SHKeeper configuration empty hides new crypto checkouts. Previously verified prepaid grants still expire locally, but remote status cannot be rechecked while the provider is disabled. Do not delete payment history or change your SHKeeper server/key to an unrelated instance while orders exist. CryptoInvoice protects its parent Account from accidental deletion.

General operations

  • Keep membership-worker running. Alert on its errors and on accounts whose last_ldap_sync or last_billing_sync is stale. A one-shot sync_memberships command exits nonzero on a failed sweep. Logs record account IDs/error classes, not passwords/tokens or raw webhook payloads.
  • The worker removes expired registration/session/rate-limit records and retains processed webhook IDs for 30 days. A later replay remains harmless because grants are always recalculated from canonical Stripe data.
  • Do not delete Account/ManagedGroup rows before removing their LDAP grants. Do not manually remove an LDAP account without first revoking paid groups. Directory renames are reconciled using entryUUID; the old DN is removed too.
  • Registration cannot atomically commit LDAP and PostgreSQL together. If LDAP creation succeeds and the database write fails, the UI directs the user to Dex; successful login safely recovers the directory account by its UUID. Failed LDAP rollback may require administrator cleanup. Never reset an existing account's password merely because someone verified its email.
  • Full Stripe invoice history is paginated for correctness. For a large member base, add a durable incremental invoice cache and shard workers before relying on a tight revocation latency. No refunds/dispute policy is inferred: the current implementation follows paid invoices plus subscription state. If a refund/dispute should revoke access, cancel/suspend the subscription as part of your reviewed refund/dispute process.
  • Free accounts are inetOrgPerson, not automatically POSIX/shell accounts and not automatically members of any service group. Password resets remain your existing LDAP/Dex account recovery process.

Verification

Offline tests use an isolated SQLite database, mocked Stripe/SMTP/LDAP network calls, real signed Stripe webhook verification, and real RSA-signed Authlib state/PKCE/nonce/issuer/audience/signature tests. PostgreSQL tests run the same suite against a disposable container using membership/compose.test.yaml.

The suite covers verification expiry/replay, password validation, CSRF, directory identity linking, allowed checkout prices, customer scoping, duplicate/reordered webhooks, paid invoice periods, cancellation, failed renewals, unavailable Stripe, LDAP retries, tier removal, multiple subscriptions, and the last member's removal from a groupOfUniqueNames using its sentinel. Live Stripe, Dex, SMTP and OpenLDAP must still be smoke-tested together in a non-production environment before enabling public registration/payment. SHKeeper regressions additionally cover API-key/TLS transport, immutable prices and account-scoped invoices, callback spoofing, partial/unconfirmed/underpaid transfers, duplicate confirmation, calendar-month renewals, overpayments, reorg recovery, provider outages, and cross-provider overlap. PostgreSQL also verifies that concurrent workers credit one crypto invoice only once.

S
Description
Homepage for eom.dev
Readme
1.4 MiB
Languages
Python 55.5%
JavaScript 26%
HTML 10.7%
CSS 7.7%
Dockerfile 0.1%