315 lines
20 KiB
Markdown
315 lines
20 KiB
Markdown
# 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 LDAP identity; account ownership is anchored to immutable `entryUUID`,
|
||
never 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.
|
||
|
||
| Membership | Monthly USD | Existing live Price ID | Proposed dedicated LDAP group |
|
||
| --- | ---: | --- | --- |
|
||
| Level 0 · Community | Free | — | — |
|
||
| Level 1 · Supporter | $4 | `price_1Rgv4xC5KPXqTvVLfMd6z7wn` | `www-level-1` |
|
||
| Level 2 · Insider | $8 | `price_1Rgv5QC5KPXqTvVLv1PaHWb5` | `www-level-2` |
|
||
| Level 3 · Collaborator | $16 | `price_1Rgv5rC5KPXqTvVL8Ol8sMUf` | `www-level-3` |
|
||
| Level 4 · Sustainer | $32 | `price_1Rgv6FC5KPXqTvVL39mAxYjm` | `www-level-4` |
|
||
| Level 5 · Patron | $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](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](.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](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](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 to LDAP and checks immutable `entryUUID`. 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](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](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](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.
|
||
|
||
|