Skip to content
Merged
18 changes: 15 additions & 3 deletions .ilana/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -492,15 +492,27 @@ Redis queue polling (200ms..2s), not blocking primitives (multi-condition wake-u
- New env var: `MAILX_JWT_SECRET` (same convention as `MAILX_API_KEY_PEPPER`; unset falls back to an ephemeral per-process secret with a startup warning — sessions do not survive a restart in that mode).
- Password reset (DEC-213): migration 000029 adds `password_reset_tokens` (human_id FK CASCADE, `token_hash` unique-indexed, `expires_at`, `used_at` nullable, partial index on unresolved tokens per human). `Service.ForgotPassword`/`ResetPassword`: 5-minute single-use tokens (`PasswordResetTokenTTL`), same anti-enumeration response shape as `Login`, one collapsed `ErrPasswordResetTokenInvalid` for any unknown/expired/used/race-lost token. `database.ResetPassword` is one transaction: consume-token (RowsAffected-checked), update `password_hash`, revoke every refresh token the account has. Email delivery reuses `api.SubmissionAcceptor` (the v0.42 accept-a-message primitive) via a new `humanauth.Mailer` interface, configured by `MAILX_SYSTEM_TENANT_ID`/`MAILX_SYSTEM_FROM_ADDRESS`/`MAILX_DASHBOARD_BASE_URL`; unset means no mailer (token still created, logged not sent) rather than a startup failure. New routes `POST /v1/auth/forgot-password`/`reset-password`, public, behind their own `passwordResetIPLimitMiddleware` bucket (`Policy.PasswordResetIPRate`/`PasswordResetIPBurst`, default 1/60 rps burst 3 — much tighter than login's, since forgot-password sends a real email per call).
- Organization invitations (DEC-216): migration 000030 adds `org_invitations` (tenant_id FK CASCADE, invited_by FK CASCADE, `normalized_email`/`raw_email`, `token_hash` unique-indexed, `expires_at`, `accepted_at` nullable, partial index on `(tenant_id, normalized_email) WHERE accepted_at IS NULL`) plus plain nullable URL columns `tenants.logo_url` and `humans.avatar_url` (no upload pipeline — operator decision). `Service.InviteToOrganization(ctx, inviterHumanID, tenantID, email)`: owner-only (`database.IsTenantOwner` re-checked server-side, `ErrNotOrgOwner` otherwise — no broader RBAC), 5-hour single-use tokens (`OrgInvitationTTL`), same `humanauth.Mailer`/`WithDashboardBaseURL` delivery wiring as password reset, degrades the same way when no mailer is configured. `Service.AcceptOrgInvitation(ctx, rawToken, existingHumanID, signupName, signupPassword)` is a single combined accept path (operator decision, not separate signup-then-join calls): with an existing session, the invitation's email must match that account's own (`ErrOrgInvitationEmailMismatch` otherwise) and `database.AcceptOrgInvitationForExistingHuman` atomically consumes the token and inserts `tenant_members` (`ON CONFLICT DO NOTHING`); with no session, `database.AcceptOrgInvitationWithSignup` atomically consumes the token, creates the human account (ALWAYS using the invitation's own stored email, never client-supplied), and inserts membership, all in one transaction. Both failure paths collapse to `ErrOrgInvitationInvalid`. New routes: `POST /v1/orgs/{id}/invites` (owner-only, behind `humanAuthMiddleware` then a new per-human `orgInviteLimitMiddleware` — `Policy.OrgInviteRate`/`OrgInviteBurst`, default 1/30 rps burst 10, keyed by the inviting human's ID rather than IP since the caller is already authenticated) and `POST /v1/orgs/invites/accept` (deliberately NOT behind `humanAuthMiddleware` — the invitee may have no account yet; reads an optional bearer token directly).
- Known limitation / explicitly deferred (not built): Paystack/billing, a `plan` field, email verification, OAuth/social login, MFA, and any RBAC beyond owner/member. The frontend's `{name, slug}` org-create contract is accepted; `slug` is currently a no-op input (tenants has no slug column yet). Avatar/logo are URL-only fields with no upload/hosting pipeline.
- Known limitation / explicitly deferred (not built): email verification and any RBAC beyond owner/member. The frontend's `{name, slug}` org-create contract is accepted; `slug` is currently a no-op input (tenants has no slug column yet). Avatar/logo are URL-only fields with no upload/hosting pipeline.

## Billing & Plans (v0.47 phase 2; design decisions DEC-221..225)
## OAuth sign-in & TOTP MFA (v0.47 phase 3b; design decisions DEC-228..231)

- Migration 000032: `humans.mfa_enabled` + sealed `mfa_secret_*`/`mfa_pending_secret_*` (secretbox, AD `mfa:<human_id>`) + `mfa_last_used_step` (TOTP replay guard); `mfa_backup_codes` (SHA-256 hashed, single-use), `mfa_challenges` (hashed opaque token, 5-min TTL, single-use, burned after 5 wrong codes), `human_oauth_identities` (PK provider+provider_user_id -> human_id), `oauth_states` (hashed, 10-min TTL, consumed by DELETE).
- Routes (under the humanAuth block): `GET /v1/auth/oauth/{provider}/start|callback` (authIP bucket; 404 `oauth_provider_not_configured` per unconfigured provider), `POST /v1/auth/mfa/verify` (public, new `MFAVerifyIPRate` bucket 1/12s burst 5), `POST /v1/auth/mfa/enroll|confirm|disable` (human JWT; confirm on the MFA bucket, disable re-checks password on the authIP bucket).
- Invariant: a correct first factor on an MFA account (password Login OR OAuth callback) returns `*humanauth.MFARequiredError` (HTTP 200 `{mfa_required, mfa_token, expires_at}`), never a session. Every full session goes through `Service.completeLogin` (TouchLoginAndCreateRefreshToken).
- Invariant: OAuth links to an existing account by email only for a provider-VERIFIED email (Google `email_verified`, GitHub primary+verified from /user/emails). OAuth-created humans get an unusable password hash (password login impossible until a password reset).
- Config: `MAILX_GOOGLE_OAUTH_CLIENT_ID/SECRET`, `MAILX_GITHUB_OAUTH_CLIENT_ID/SECRET`, `MAILX_OAUTH_REDIRECT_BASE_URL` (each provider independent; half-config is a startup error), `MAILX_MFA_MASTER_KEY` (base64 32 bytes, must differ from DKIM/webhook keys; unset = enrollment 404, TOTP verify 503, backup codes still work), `MAILX_LIMIT_MFA_VERIFY_IP_RPS/BURST`. Wiring: `cmd/mailx/authconfig.go`.
- Limitations: RSK-046 (OAuth state not bound to the browser), RSK-047 (no per-account MFA lockout across challenges). Recovery/regeneration of backup codes requires disable + re-enroll.

## Billing & Plans (v0.47 phase 2 + phase 3c auto-renewal; design decisions DEC-221..225, DEC-235..238)

- Plans (`internal/billing/plans.go`, `billing.Plans`/`PlanFor`, unknown -> free; `billing.Unlimited` = 0, the ratelimit.Policy "zero = off" convention): free / plus ($6) / pro ($24), limits in milestones.md. Stored on `tenants.plan` (CHECK free|plus|pro, default free), `plan_status` (active|lapsed), `plan_current_period_end`, `paystack_customer_code` (migration 000031).
- Single enforcement switch: `database.DB.EnablePlanEnforcement()`, on iff `MAILX_PAYSTACK_SECRET_KEY` is set. Off = self-hosted = unlimited, billing routes unregistered. Plan-limit refusals wrap `database.ErrPlanLimit`; API maps them to 429 `daily_send_limit_reached` (volume) or 403 `plan_limit_reached` (caps/features).
- Enforcement points: `admitSend` (daily UTC-day message count), `domainHandler.handleCreate` (non-deleted domains), `humanauth.InviteToOrganization` (early) + `AcceptOrgInvitationForExistingHuman`/`WithSignup` (authoritative, tenant row `FOR UPDATE`), `broadcastHandler.handleCreate`, `webhookHandler.handleCreate`, retention purge default.
- Paystack: `POST /v1/billing/checkout` (owner-only, body `{tenant_id, plan}`, Initialize Transaction in USD cents with metadata `{tenant_id, plan}`, returns `authorization_url`); `GET /v1/billing/subscription?tenant_id=` (any member); `POST /v1/billing/webhook` (public, x-paystack-signature HMAC-SHA512 verified, charge.success applies plan for 30 days, `billing_payments.reference` PK makes it idempotent). `plan-lapse` hourly component downgrades expired paid plans to free/lapsed; no auto-renewal (RSK-044).
- Config: `MAILX_PAYSTACK_SECRET_KEY` (enables billing + enforcement), `MAILX_PAYSTACK_PUBLIC_KEY` (frontend only, unused server-side), optional `MAILX_PAYSTACK_CALLBACK_URL`, optional `MAILX_PAYSTACK_BASE_URL` (tests/tooling).
- Config: `MAILX_PAYSTACK_SECRET_KEY` (enables billing + enforcement), `MAILX_PAYSTACK_PUBLIC_KEY` (frontend only, unused server-side), optional `MAILX_PAYSTACK_CALLBACK_URL`, optional `MAILX_PAYSTACK_BASE_URL` (tests/tooling). `MAILX_BILLING_MASTER_KEY` (base64 32 bytes, own key): encrypts saved card authorizations; unset = auto-renew unavailable (PATCH on -> 503), invalid = startup fails.
- Auto-renewal (phase 3c, `internal/api/billing_renewal.go` `Renewer`, `internal/database/renewal.go`, migration 000033): opt-in per org (`tenants.auto_renew` default false), toggled by `PATCH /v1/billing/auto-renew` `{tenant_id, auto_renew}` (owner-only, human JWT, never charges, no amount accepted). A verified, applied `charge.success` with `authorization.reusable` stores the authorization code secretbox-encrypted (AD = tenant id) + payer email; plaintext is never stored or logged. Charges are MailX-initiated `POST /transaction/charge_authorization` at `billing.PlanFor(plan)` price (no Paystack Plans/Subscriptions, DEC-235).
- Hourly `plan-lapse` pass = `Renewer.RunOnce` (reminders -> reconcile pending -> new charges) then `DowngradeLapsedPlans` (unchanged, retention pinned). Timeline (DEC-237): reminder to all owners once per period at E-72h (auto-renew off: "renew manually"; on: "will charge $X"); charges only in (E-48h, E), only if an auto-renew reminder for this exact period was sent >= 24h earlier, max 3 attempts spaced >= 12h; success/failure emailed with Paystack's gateway reason. No system mailer => no reminders and therefore no charges.
- No double charge (DEC-238): `ClaimRenewalAttempt` takes tenant row `FOR UPDATE`, refuses if any pending/succeeded attempt exists for the period, inserts `billing_renewal_attempts` (PK tenant+period_end+attempt) as `pending` and commits BEFORE calling Paystack. Reference `mailx-renew-<tenant>-<period_end_us>-<n>` is deterministic and is the `billing_payments` key, so ticker and webhook applying the same charge collapse via `ApplyPlanPayment`'s replay protection. Unknown outcomes (network/5xx/non-final) stay pending, block further attempts, and are resolved by `GET /transaction/verify/:ref` after 1h; never retried under a new reference.

## Dashboard backend (v0.47 phase 3a; design decisions DEC-228..230)

Expand Down
Loading
Loading