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
4 changes: 3 additions & 1 deletion .ilana/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -492,7 +492,8 @@ 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): 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.
- Email verification (DEC-241..244): migration 000034 adds nullable `humans.email_verified_at` (NULL = unverified) and `email_verification_tokens` (same shape as `password_reset_tokens`). `SignUp` issues a 15-minute token (`EmailVerificationTokenTTL`) and emails `{MAILX_DASHBOARD_BASE_URL}/verify-email?token=...` via `humanauth.Mailer`, bounded by a 10s timeout so a slow mail-accept can't stall account creation; send failure (or no mailer / no dashboard URL, DEC-215 guard) is logged and never fails signup. `database.CreateEmailVerificationToken` locks the `humans` row `FOR UPDATE` and inserts a fresh token WITHOUT touching any prior one; only after `sendVerificationEmail` confirms the new email actually sent does `SupersedeOtherEmailVerificationTokens` invalidate every other pending token strictly older (by `(created_at, id)`, `created_at` via `clock_timestamp()`) than the new one — a failed send therefore leaves the previous working link intact instead of orphaning the account (DEC-244, closing RSK-049; same fix shape as DEC-218/226 for org invitations). `POST /v1/auth/verify-email {token}` (public, `authIPLimitMiddleware`): one transaction, RowsAffected-checked consume + set `email_verified_at`; one collapsed `ErrEmailVerificationTokenInvalid` (422 `invalid_verification_token`). `POST /v1/auth/resend-verification {email}` (public): always the same 200 body; own bucket `Policy.EmailVerificationResendIPRate/Burst` (default 1/60 rps, burst 3; env `MAILX_LIMIT_EMAIL_VERIFICATION_RESEND_IP_RPS`/`_BURST`). `GET/PATCH /v1/me` expose `email_verified_at`. Verification gates nothing (DEC-243).
- Known limitation / explicitly deferred (not built): 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.

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

Expand Down Expand Up @@ -520,4 +521,5 @@ Redis queue polling (200ms..2s), not blocking primitives (multi-condition wake-u
- AuthZ: non-member -> 404 `organization_not_found`; member non-owner on owner-only route -> 403 `not_org_owner` (DEC-228).
- Invariant: an org always has >= 1 owner; removals are serialized by the tenant row lock (DEC-229). Owners cannot remove themselves (409 `cannot_remove_self`).
- DB: `internal/database/dashboard.go` (`UpdateHumanProfile`, `UpdateOrganization`, `ListTenantMembers`, `RemoveTenantMember`, `ListPendingOrgInvitations`, `RevokeOrgInvitation`). No migration.
- Org API keys (DEC-241..243, `internal/api/apikeys_handler.go`): `POST /v1/orgs/{id}/api-keys` (owner; `{name, scopes}` -> 201 key resource + raw `key` once), `GET .../api-keys` (member; `{data:[{id(=key_id), name, scopes, status active|revoked|expired, created_at, last_used_at, expires_at, revoked_at}]}`), `POST .../api-keys/{keyId}/rotate` (owner; 200 + new raw `key`, old key dead immediately, 409 if not active), `DELETE .../api-keys/{keyId}` (owner; 204, 404 if unknown/other tenant/already revoked). Same `auth.Service` as the CLI; per-human mint limit on create/rotate. This is how a web-signed-up org obtains the API key every `requireScope` route needs.
- Limitations: no leave-org / transfer-ownership / role-change flow; no email change; no org slug; `/v1/orgs/*` dashboard routes are not in `TestOpenAPIRoutesMatchRuntime` (its mux has no humanAuth), covered by `dashboard_handler_test.go` instead.
Loading
Loading