Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .ilana/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -461,8 +461,8 @@ Redis queue polling (200ms..2s), not blocking primitives (multi-condition wake-u

## Data Retention & GDPR Tooling (v0.46; design decision DEC-205)

- `tenants.retention_days` (nullable INTEGER, migration 000025): per-tenant retention window override. NULL means "use `database.DefaultRetentionDays`" (90), not "retain forever" — set via `mailx set-retention -tenant <id> -days <n|default>`, read via `mailx show-retention -tenant <id>`.
- `database.PurgeExpiredMessages`: one tenant-joined query finds every message older than `COALESCE(tenants.retention_days, 90)` days, then hard-deletes them in a transaction — recipients/delivery_attempts/events cascade via existing FKs; `broadcast_recipients.message_id` (which has no FK) is explicitly nulled first. Runs as the `retention-purge` component (hourly ticker, same shape as `idempotency-cleanup`) and via `mailx purge-expired` for manual/cron use. Callers must also call `storage.FileStore.Delete(id)` for each purged id — the database function only owns the DB side.
- `tenants.retention_days` (nullable INTEGER, migration 000025): per-tenant retention window override. NULL means "use the default window", not "retain forever": the flat `database.DefaultRetentionDays` (90) when billing is not configured, or the tenant plan's RetentionDays (7/30/90) when plan enforcement is on (DEC-224) — set via `mailx set-retention -tenant <id> -days <n|default>`, read via `mailx show-retention -tenant <id>`.
- `database.PurgeExpiredMessages`: one tenant-joined query finds every message older than `COALESCE(tenants.retention_days, <default above>)` days, then hard-deletes them in a transaction — recipients/delivery_attempts/events cascade via existing FKs; `broadcast_recipients.message_id` (which has no FK) is explicitly nulled first. Runs as the `retention-purge` component (hourly ticker, same shape as `idempotency-cleanup`) and via `mailx purge-expired` for manual/cron use. Callers must also call `storage.FileStore.Delete(id)` for each purged id — the database function only owns the DB side.
- GDPR subject requests are operator-only CLI, not an API endpoint (matches v0.19's tenant/key-management precedent): `mailx gdpr-export -tenant -email` (read-only: contact record, audience memberships, recipient history) and `mailx gdpr-delete -tenant -email -confirm` (deletes the contact row + matching recipient rows only — never the parent message, other recipients on it, or suppressions).
- Recipient-address matching (both retention-adjacent GDPR lookups) decodes bracket/bare/display-name forms via `net/mail.ParseAddress` before running the result through `contact.Normalize` — `contact.Normalize` alone rejects display-name input, so it can't be called directly on a raw stored recipient address.

Expand Down Expand Up @@ -493,3 +493,11 @@ Redis queue polling (200ms..2s), not blocking primitives (multi-condition wake-u
- 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.

## Billing & Plans (v0.47 phase 2; design decisions DEC-221..225)

- 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).
Loading
Loading