Files
www/README.md
T
2026-10-29 14:17:16 -04:00

570 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](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:
```json
{
"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](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 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](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.