2026-10-29 15:08:25 -04:00
2026-10-29 14:17:16 -04:00
2026-10-29 13:28:11 -04:00
UI
2026-10-29 15:08:25 -04:00
UI
2026-10-29 15:08:25 -04:00
2026-02-19 15:22:47 -05:00
2026-10-29 14:17:16 -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-29 14:17:16 -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 level-0 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 by default (see the explicit plaintext option below). This app never stores a password/hash.
  2. Sign-in uses the single Dex LDAP option with Authorization Code + PKCE, state and nonce. Authlib verifies the RS256 ID token, issuer, audience, signature and lifetime. The signed connector/UID must match the configured connector and username. Local account ownership is pinned to the verified Dex issuer and subject, never email alone. Membership makes no LDAP connection during login, including first login by an existing Dex user. The worker separately resolves and pins LDAP entryUUID before synchronizing paid groups.
  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.

Service access by membership level

Additional LDAP service benefits are configured separately from Stripe prices in membership/service-groups.eom.json. Set MEMBERSHIP_SERVICE_GROUPS_FILE to that file, or supply an equivalent object in MEMBERSHIP_SERVICE_GROUPS_JSON (which takes precedence). The default is empty; the shipped file contains empty lists for levels 0–5, granting nothing until you supply real, reviewed group DNs. Existing paid tiers and their group_dn continue working unchanged.

The shape is a level-to-list mapping, for example:

{
	"0": ["cn=community-chat,ou=membership-services,dc=example,dc=org"],
	"1": ["cn=community-chat,ou=membership-services,dc=example,dc=org"],
	"2": [
		"cn=community-chat,ou=membership-services,dc=example,dc=org",
		"cn=game-servers,ou=membership-services,dc=example,dc=org"
	]
}

Keys are canonical level strings: "0" or an existing paid tier's positive level. Values are lists of full LDAP group DNs. Omitted levels/empty lists grant no additional service access. The highest currently paid level selects exactly one list. There is no implicit inheritance: in this example level 2 gets chat and games, while levels 3–5 get neither unless explicitly listed. Level 0 means free accounts only, not a baseline automatically inherited by paid accounts. To offer a service to everyone, repeat its DN under every configured level. An active Stripe and SHKeeper combination still selects the highest paid level; it does not combine lower service lists. Normal per-tier group grants remain additive for each valid paid entitlement, as before.

The worker computes the level from paid subscription/invoice expiry on every pass, never from cached Dex groups or the account's stored level. Expiry falls back to level 0 and revokes paid-only service access without contacting payment providers. Editing/removing a DN, removing a tier or changing levels reconciles on the next sweep. Historical ManagedGroup records are retained so old grants are revoked even after configuration removal; management intent commits before LDAP changes so failed writes can be retried safely. Login/registration records intent but does not grant groups directly. Level 0 grants cover local membership accounts (including first-time Dex sign-ins), not every LDAP account that has never used this application.

Provision and secure the groups before enabling a mapping:

  • Targets must already be groupOfUniqueNames groups with the configured sentinel entry. Allow the worker narrowly scoped read/write of uniqueMember on these groups only. No LDAP schema, group or ACL is created automatically.
  • Mapping a DN hands this application control of that group's membership for every local member, including revocations of manual grants to noneligible members. Use dedicated groups for membership-controlled service access. Do not map administrator, staff, existing independently managed entitlement groups, the sentinel DN or groups where such removals would be unsafe. Configure services/Dex to authorize these dedicated groups and review any service-specific user provisioning requirements separately. Only administrators edit mappings; browser forms and Dex claims cannot choose entitlement groups.
  • Keep removed groups and ACL access available until the worker has successfully removed old grants. Missing groups/LDAP outages fail synchronization and delay grants/revocations; they must not be hidden by deleting historical tracking.
  • Group names/tokens already cached by Dex and relying services must refresh. Revocation is not an instantaneous logout across services.

For Kubernetes, use the role's www_membership_service_groups variable, not an image-baked file: the role mounts its own mapping next to its tiers configuration. Change the variable and rerun the role; its revision includes this mapping and rolls web/worker so they reload it. Directly changing a ConfigMap does not reload Django settings. Build/deploy the updated image and role once before using the new setting. No database migration is needed for service mappings. Changes to the benefit display text remain separate from actual authorization rules.

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.

Membership Monthly USD Existing live Price ID Proposed dedicated LDAP group
Level 0 · Open Lab Free — —
Level 1 · Bronze $4 price_1Rgv4xC5KPXqTvVLfMd6z7wn www-level-1
Level 2 · Silver $8 price_1Rgv5QC5KPXqTvVLv1PaHWb5 www-level-2
Level 3 · Gold $16 price_1Rgv5rC5KPXqTvVL8Ol8sMUf www-level-3
Level 4 · Platinum $32 price_1Rgv6FC5KPXqTvVL39mAxYjm www-level-4
Level 5 · Diamond $64 price_1Rgv6mC5KPXqTvVLkcmznWZa www-level-5

Every account has a non-null membership_level: new/free accounts start at 0, and synchronization records the highest currently valid paid level. The former paid levels 0–4 are shifted to 1–5; Stripe Price IDs remain unchanged. The migration also updates stored subscription and prepaid-invoice tier keys and backfills current account levels. Existing level-0 LDAP group grants are revoked by the worker after the migration; retain that legacy group until the first successful synchronization of existing accounts.

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 when TLS is used. To deliberately use plaintext on a trusted internal network, set LDAP_URL=ldap://openldap.openldap.svc.cluster.local:389 and LDAP_ALLOW_INSECURE=1 in both web and worker environments. This skips StartTLS and exposes bind passwords, registration passwords and directory data to the network. The default is 0 (StartTLS required for ldap://); TLS failures never trigger a plaintext fallback, and ldaps:// always verifies certificates.
  4. Add the website client to Dex and merge website account entries into its existing 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=ldap. Keep the connector ID and idAttr: uid unchanged to preserve subjects in other apps. Add the website inetOrgPerson branch to the LDAP filter; this allows those entries to authenticate to all Dex clients, so review that scope. Dex uses signed federated:id claims, while the app resolves UID from verified claims without querying LDAP during login. 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

Dex-only login and upgrade

Deploy the new membership image and apply migration 0004_dex_login_identity before serving it. Keep DEX_ISSUER unchanged during the upgrade: the migration pins existing non-null subjects to the currently configured issuer, because older records did not store their issuer. Back up the database and coordinate replacing web and worker together; older code cannot handle accounts whose directory UUID has not yet been resolved. Do not roll back to the old schema while unresolved accounts exist.

  • Returning users sign in using their existing issuer/subject binding, without LDAP or payment-provider calls. Signed verified email updates their contact address; it never identifies an account or attaches billing history.
  • First-time Dex users with no local username collision receive a free local account. The worker discovers their LDAP DN/UUID on its next successful group sync and thereafter uses only that UUID. Missing/ambiguous directory entries stop synchronization; a missing pinned UUID never falls back to username.
  • Website registration still provisions LDAP. Its browser session retains a one-hour, server-side proof permitting the first Dex login to bind only the just-created local account, with the same signed connector/username. Successful login consumes that proof. Without it, existing unbound accounts require an administrator mapping; matching email or username alone is insufficient.
  • For an existing unbound account, the management command bind_dex_identity requires --account (local account UUID), --uid and --subject (the exact Dex sub for this issuer/client/connector). Independently verify the identity with trusted Dex/directory administration before running it. Do not accept subjects asserted by users or decode an unverified token as proof. It makes no LDAP calls, preserves directory/billing data and refuses to replace an existing binding or reuse another account's subject. Lost registration proof can be recovered through this reviewed mapping, not by repeating registration.
  • Issuer, connector, username and subject changes require an explicit identity migration. The old automatic rebinding of retired www-ldap subjects is intentionally removed; review those bindings separately before deployment.

The existing Dex LDAP connector uses idAttr: uid. Do not recycle usernames: Dex itself cannot distinguish a recreated LDAP entry with the same connector/uid from the previous identity. Directory UUID pinning still protects group writes, but membership no longer checks directory recreation during login. Preserve the connector mapping for other services; migrating Dex to immutable user IDs is a separate coordinated change, not an automatic part of this release.

Self-service account profile

Signed-in members can choose Edit profile from their membership page. Deploy the new membership image, apply migration 0005_profile_email_change, and deploy the updated static CSS/proxy configuration. No new public settings or credentials are required. Login remains Dex-only: the profile editor reads LDAP only when opened, because the configured Dex LDAP connector exposes username/email/name/groups, not arbitrary LDAP attributes. Cached OIDC claims are neither a complete nor a current editable profile. Do not map the gamer tag to preferred_username or change idAttr to expose it.

Profile label LDAP attribute
About you description
Gamer tag (one value) displayName
Email address (verified separately) mail
First name / Last name givenName / sn
Profile photo jpegPhoto
City/town / State/province l / st
Websites labeledURI
Mobile numbers / Phone numbers mobile / telephoneNumber
Organizations / Job titles o / title
Postal addresses / Postal codes postalAddress / postalCode
Street addresses / Room numbers street / roomNumber
SSH public keys sshPublicKey
Password RFC 3062 Password Modify (never read back)

The form preserves existing multi-value attributes. Enter one value per line; separate multiline postal addresses with a blank line. Blank optional fields are removed on save; sn remains required by the directory schema. JPEGs are limited to 1 MiB and 4096 pixels per side/16 million pixels, resized to fit 512 pixels and reencoded without metadata. Existing photos are sanitized again before private, authenticated delivery. No arbitrary URLs are fetched. Supported SSH keys are Ed25519, RSA >=2048 bits and ECDSA NIST P-256/P-384/P-521; private keys, authorized_keys options and malformed values are rejected.

Directory prerequisites:

  • The service account needs read/search access to the profile attributes above, plus uid, entryUUID, objectClass and modifyTimestamp, across the intended account search tree. It never needs read access to userPassword. Accounts are pinned to immutable UUIDs; missing/deleted entries do not fall back to a reused username. LDAP unavailability disables editing rather than presenting an empty form that could erase values.
  • Every write binds with the member's current LDAP password and checks that entry's UUID again. Configure directory self-write ACLs only for these profile attributes and self Password Modify; do not grant writes to uid, entryUUID, account DNs, POSIX IDs, group membership or administrative fields. This supports existing Dex users outside the website registration OU without granting the membership service unrestricted directory write permissions.
  • Install the ldapPublicKey auxiliary schema before SSH editing. When a user supplies keys and the class is absent, the application adds only the fixed ldapPublicKey class in the same modify. Either pre-add that class to editable accounts or permit precisely that self-add with value-scoped objectClass ACLs. Never grant arbitrary self-write to objectClass. No schema/ACL changes are automatically applied by the website or OpenLDAP role.
  • OpenLDAP must support critical RFC 4528 assertion controls. Profile/email writes assert UUID and last-modified time, and profile forms carry a signed, one-hour revision to reject stale edits. A server rejecting the critical control must fail closed; do not silently retry without it.
  • Maintain directory-side unique mail and uid enforcement across all registrars. A client-side email preflight cannot prevent concurrent claims and may be restricted by self-read ACLs; the server uniqueness overlay is required. Review which services use mail as an account/recovery identifier.

Email is changed separately: the user enters their current password, receives a one-hour verification link at the new mailbox, then signs into the same local account and enters their password again. GET/scanner visits never modify LDAP. Tokens are stored only as hashes, bound to account/LDAP UUID, and consumed once. Confirming replaces all existing mail values with the verified address and updates the local contact address; it does not attach another user's billing history. Old Dex/downstream tokens may retain the prior email until refreshed.

Password changes send current/new passwords through Password Modify as the authenticated user, never a service-account reset or direct userPassword write. Hashing remains OpenLDAP's policy: no MD5-Crypt hash is generated by the application, no stored hash is exposed, and the application does not force or silently change the server's scheme. A legacy MD5-Crypt directory can still verify current passwords; do not introduce MD5-Crypt as a new application-side hashing scheme. Audit/migrate weak server hashing separately. The existing Argon2 gate for new registration remains unchanged. Password changes sign out the current membership browser; other membership/Dex/service sessions are not automatically revoked—use each service's session controls when necessary.

All writes are CSRF-protected and rate-limited per account/IP. Passwords and profile form bodies must never be logged by ingress/APM/LDAP debug logging. Profile data can be shared by directory-consuming services: the page is private, but it does not make phone/address/SSH fields private in the directory. Enforce directory read ACLs and tell users what other services can access. TLS defaults are unchanged; the existing explicit plaintext opt-in also exposes profile passwords/data and should not be used on untrusted networks.

The supplied Kubernetes ingress accepts up to 2 MiB; Apache allows that limit only under /members/profile/ and retains 256 KiB for other member routes. Mirror the limits at any additional edge proxy. Oversized or invalid uploads must be rejected before reaching image processing.

Database ProgrammingError during callback

Dex callback rejected kind=ProgrammingError in older images is a database error, not a password rejection. Updated images log a redacted sqlstate: 42703 means an undefined column, 42P01 an undefined table, and 42501 insufficient privileges. Raw SQL/exception messages are deliberately omitted because they may contain identity data.

For 42703 after the Dex-only release, check that members.0004_dex_login_identity is applied to the same database/schema used by web and worker. Using the release image and its production environment, inspect showmigrations members and migrate --plan, then run migrate --noinput through the deployment's migration service/Job. These are Django management commands. Do not use makemigrations or --fake as a production repair, or delete database volumes. Keep the original DEX_ISSUER while applying this migration.

On Kubernetes, publish a new immutable membership image tag/digest and rerun the role so it completes a new migration Job before deploying web/worker. Reusing an image tag can leave both an old cached migration image (IfNotPresent) and an already-completed Job: a rollout restart alone does not rerun migrations. Use www_rollout_revision to request a fresh Job when appropriate, but prefer a new immutable image reference. If the migration is marked applied yet columns are missing, investigate the database/schema and migration history before any manual DDL. For other SQLSTATEs, investigate the indicated permissions/schema problem rather than assuming an unapplied migration.

LDAP service credentials still required for directory writes

LDAP is still needed for registration and worker group synchronization. Configure the least-privilege LDAP_BIND_DN and its plaintext bind password in LDAP_BIND_PASSWORD, not a stored {CRYPT}$1$... hash or already-base64-encoded value. Kubernetes Secret data encodes original values once. Check service password rotation/account locks and redeploy web and worker after Secret updates; existing pods do not refresh environment credentials automatically. Never use root/admin credentials or disable TLS verification to bypass failures.

Older images may log LDAP service bind rejected during Dex callback or Dex callback rejected kind=LDAPInvalidCredentialsResult. These login-time service binds are removed by this release. OpenLDAP remains responsible for verifying users' existing password hashes through Dex. LDAP_PASSWORD_SCHEME only gates new registration; its Argon2 requirement is unchanged. Plan migration away from weak legacy password hashes separately.

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 the pending membership schema/data migrations, including membership/members/migrations/0003_membership_levels.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.
  • Level-0 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%