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
3 changes: 2 additions & 1 deletion .ilana/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -491,4 +491,5 @@ Redis queue polling (200ms..2s), not blocking primitives (multi-condition wake-u
- New routes, registered directly on the API server's top-level mux (NOT inside the API-key-authenticated `/v1/` chain — `net/http.ServeMux` prefers the more specific `/v1/auth/*`/`/v1/orgs` patterns over the `/v1/` catch-all): `POST /v1/auth/signup`, `POST /v1/auth/login`, `POST /v1/auth/refresh`, `POST /v1/auth/logout` (public), `POST /v1/orgs`, `GET /v1/orgs` (require a human JWT via the new `humanAuthMiddleware`, distinct from `authenticateMiddleware`/`requireScope`). `api.Config.HumanAuth` is optional (nil disables these routes; existing API-key routes are unaffected either way).
- 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).
- 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).
- 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.
5 changes: 5 additions & 0 deletions .ilana/decisions.md

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions .ilana/ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,3 +302,18 @@ User flagged new Greptile comments as urgent right after the password-reset comm

## 2026-09-25 | v0.47 phase 1 fifth Greptile pass + CI fix | GATE PASS
User listed 5 named findings plus a failing Go CI check and asked to verify/fix all. Cross-referenced against DEC-214 (already fixed 2 of the 5 - the refresh/reset race and the missing-dashboard-URL guard) and found the other 3 were genuinely still open: erased-mail-stays-on-disk (materializeOne returned nil/success even when the disk cleanup itself failed, so the caller's error path and finishMaterialized both ran as if nothing was wrong - fixed by propagating the cleanup failure), a stale last-login API response (Login echoed its own timestamp instead of what the monotonic guard actually stored, so a client could be told an older time than what's in the database during a concurrent-login race - fixed by having the DB call return the true stored value and having Login use that instead), and a broadcast metric losing its phase label (erasure_cleanup wasn't in the metrics package's phase allowlist, so it silently fell into "other" and became useless for alerting - added it to the allowlist). Separately investigated the failing GitHub Actions "Go CI" run directly via gh run view --log-failed rather than guessing: 3 tests failed only in CI, never locally, because Postgres timestamptz truncates to microseconds while Go's time.Now() carries nanoseconds, so a round-tripped .Equal() comparison fails on environments where the timing happens to hit that boundary. Fixed at the source (truncate the production clock and the test helpers to microsecond precision) rather than patching the 3 symptomatic tests, since the same class of bug could resurface anywhere else a timestamp round-trips through Postgres. Verified: gofmt/go vet/go build clean, full go test ./... and go test -race ./... clean, Docker rebuild+boot smoke clean. Ilana updated: decisions.md (DEC-215), state.json (DEC counter).

## 2026-09-25 | v0.47 phase 1 follow-up: organization invitations | GATE PASS
Continued from a fresh-chat handoff per CLAUDE.md's Ilana-first rule (read ledger.md/state.json/architecture.md before touching code). Asked 4 scoped AskUserQuestion decisions before coding per the handoff's explicit instruction not to assume: avatar/logo as plain URL fields (no upload pipeline), a single combined accept-invite endpoint (not separate signup-then-join), owner-only sending, and a dedicated tighter rate-limit bucket - user accepted all 4 recommended defaults. Read the existing password-reset code (DEC-213) first as the pattern to mirror (hashed single-use tokens, own-pipeline mailer, collapsed anti-enumeration-style error) rather than designing from scratch. Built: migration 000030 (`org_invitations` table, `tenants.logo_url`, `humans.avatar_url`), `database.IsTenantOwner`/`CreateOrgInvitation`/`GetOrgInvitationByHash`/`AcceptOrgInvitationForExistingHuman`/`AcceptOrgInvitationWithSignup` (the last two atomic, RowsAffected-guarded single-use consumption matching `ResetPassword`'s race-safety pattern), `humanauth.InviteToOrganization`/`AcceptOrgInvitation` (owner-only server-side re-check, 5-hour TTL, combined existing-session-or-signup accept, invitation's own email always wins over any client-supplied one), new `POST /v1/orgs/{id}/invites` (owner-only, per-human rate-limited) and `POST /v1/orgs/invites/accept` (deliberately outside `humanAuthMiddleware` since the invitee may have no account) routes and handlers, a new `orgInviteLimitMiddleware` (keyed by human ID, not IP, since the caller here is already authenticated - a deliberate departure from the IP-keyed auth-surface buckets), and OpenAPI documentation for both endpoints. 5 new tests in `internal/humanauth/org_invitation_test.go` (owner-only enforcement, email-mismatch on an existing account, combined signup+join using the invitation's own email, TTL expiry, and a signup-conflict-rolls-back-the-whole-transaction case). Verified: gofmt/go vet/go build clean; full `go test ./...` and `go test -race ./...` clean against real Postgres+Redis (existing Docker stack); migration 000030 validated via a down/up/re-up round-trip against the dev Postgres container; Docker rebuild+boot smoke clean; live-curl-verified against the running server - owner-only 403 for a non-owner, an invitation row durably created despite "no mailer configured" (the same known dev-environment gap DEC-213 already hit, not a regression), 422 for a bogus accept token, and the new rate limiter observed live (9 charged calls then 429 exactly at burst 10). Ilana updated: milestones.md (v0.47 phase 1 section extended), architecture.md ("Human accounts & organizations" section extended with the invitations subsection), decisions.md (DEC-216), state.json (DEC counter, mailx status).

## 2026-09-25 | org invitations first Greptile pass | GATE PASS
User asked to check Greptile's comments on the new org-invitations PR (#23). Fetched via gh api and found 6 findings, all genuinely new (first review of that diff), 4 of them P1. Fixed all 6: HTML injection in the invite email (owner-supplied org/inviter names were interpolated unescaped, letting an org owner inject content into mail MailX itself sends - fixed with html.EscapeString), a missing-dashboard-URL guard identical to the one already fixed for password reset (DEC-215), no rate limiting at all on the public accept-invite endpoint despite it running bcrypt per call (added an IP-keyed limiter reusing the login-cost-class bucket), an expiry check that only ran at the service layer and not atomically at DB-consume time (a request could slip past its 5-hour deadline during processing and still be granted membership - fixed by rechecking expires_at inside the same UPDATE that consumes the token), a misleading index comment claiming deduplication support that was never actually enforced (fixed by invalidating any prior pending invite to the same address when a new one is sent, rather than just removing the comment), and unvalidated email input reaching a database CHECK constraint and leaking a raw Postgres error to the caller (added net/mail.ParseAddress validation before the DB call). Verified: gofmt/go vet/go build clean, full go test ./... and go test -race ./... clean including 3 new regression tests, Docker rebuild+boot smoke clean. Also discovered and corrected a process gap this session: PR #22 (human accounts/password reset) had already been merged to main by the time the org-invitations commit was made, so that commit was sitting unpushed on Feranmi_works with no PR - opened PR #23 to bring it into main properly rather than assuming a push to the feature branch alone was sufficient. Ilana updated: decisions.md (DEC-217), state.json (DEC counter).

## 2026-09-25 | org invitations second Greptile pass (self-caught regression) | GATE PASS
Greptile's re-review of the first fix-pass commit (DEC-217) on PR #23 caught a real regression in that pass's own dedup fix: invalidating the old pending invitation and creating the new one were committed together BEFORE the new invitation's email was ever sent, so a failed send left the invitee locked out entirely (old link dead, new link never delivered) — strictly worse than having no dedup at all. Fixed by moving the invalidation to run only after a confirmed successful send: split it into its own DB call (SupersedeOtherPendingOrgInvitations), called from InviteToOrganization after mailer.SendSystemEmail succeeds, never before. Also reordered the mailer/dashboard-URL configuration checks ahead of token creation, so a misconfigured deployment never creates an invitation row it can't deliver. Verified: gofmt/go vet/go build clean, full go test ./... and go test -race ./... clean including a new regression test that simulates a failed send and asserts the original invitation still works afterward, Docker rebuild+boot smoke clean. Ilana updated: decisions.md (DEC-218), state.json (DEC counter).

## 2026-09-25 | org invitations third Greptile pass (second self-caught regression) | GATE PASS
Greptile's re-review caught a second regression, this time in DEC-218's own fix: SupersedeOtherPendingOrgInvitations excluded only "not this exact row", so two concurrent invitations to the same address could each supersede the other after both had already been sent - a race where both requests report success but neither delivered link survives, depending purely on which UPDATE statement happens to commit last. Fixed by changing the exclusion from "not this ID" to "created strictly before this invitation's own created_at", which makes the operation commutative under concurrency: the newest invitation can never be superseded by an older one racing behind it. This is the second fix-on-a-fix in this invitation feature's review cycle (DEC-217 introduced the dedup logic, DEC-218 fixed its failed-send interaction, this fixes its concurrent-send interaction) - each pass genuinely caught something the previous one missed rather than re-flagging stale findings, confirmed by checking comment timestamps against each fix commit's landing time before treating anything as new. Verified: gofmt/go vet/go build clean, full go test ./... and go test -race ./... clean including a new 5-way concurrent regression test, Docker rebuild+boot smoke clean. Ilana updated: decisions.md (DEC-219), state.json (DEC counter).

## 2026-09-25 | org invitations fourth Greptile pass | GATE PASS
Final review pass on PR #23's org-invitations work: 2 more findings, both on the concurrency fix from the immediately prior pass. A P2 (two invitations sharing the same microsecond-precision created_at would neither supersede the other, since the comparison was strict-less-than on a value that isn't guaranteed unique) fixed by comparing the (created_at, id) tuple instead of created_at alone, giving a true total order. A P1 (the new concurrent regression test's shared fakeMailer had an unsynchronized slice append, a genuine data race under -race) fixed with a mutex. Re-ran the invitation test suite 3x with -race to confirm no flakiness before considering this settled. This was the fourth consecutive Greptile pass on the same feature, each one catching a real defect in the previous pass's own fix rather than repeating stale findings - a reminder that a "fix" for a concurrency bug needs to be checked for the SAME class of bug it was written to prevent. Verified: gofmt/go vet/go build clean, full go test ./... and go test -race ./... clean, Docker rebuild+boot smoke clean. Ilana updated: decisions.md (DEC-220), state.json (DEC counter).
2 changes: 2 additions & 0 deletions .ilana/milestones.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,5 @@ explicitly deferred to a later phase — v0.47 as a whole is NOT complete.
- `internal/api` gained `humanauth_handler.go` and `humanAuthMiddleware`, wired via `api.Config.HumanAuth`.
- `internal/api/openapi.go` documents `/auth/signup`, `/auth/login`, `/auth/refresh`, `/auth/logout`, `/orgs` (GET/POST) and a `HumanAuth` security scheme; `TestOpenAPIRoutesMatchRuntime`/`TestOpenAPISpecParses` still pass.
- Tests: `internal/database/humans_test.go` (7 cases, real Postgres) and `internal/humanauth/service_test.go` (7 cases, real Postgres), covering duplicate-email rejection, case-insensitive login, refresh rotation, reuse-of-revoked-token session-wide revocation, logout, atomic org creation (including the failure-leaves-no-orphan case), membership-scoped listing, and JWT round-trip/tamper/expiry.
- Follow-up: password reset (migration 000029, DEC-213/214/215) — 5-minute single-use tokens, delivered through MailX's own outbound pipeline, dedicated rate limit, all-refresh-tokens-revoked-on-reset.
- Follow-up: organization invitations (migration 000030, DEC-216) — invite by email only (no invite-code flow), owner-only sending, 5-hour single-use tokens, a single combined accept endpoint (existing-session or no-account-yet signup, both atomic), org logo/inviter avatar as plain nullable URL fields, own-pipeline email delivery, dedicated per-human rate limit. 5 new tests in `internal/humanauth/org_invitation_test.go`.
4 changes: 2 additions & 2 deletions .ilana/state.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"DEF": 27,
"CR": 24,
"RSK": 43,
"DEC": 215,
"DEC": 220,
"MET": 72,
"ETH": 1
},
Expand All @@ -37,7 +37,7 @@
"last_completed_milestone": "v0.46",
"ilana_current_through": "v0.47 (partial)",
"current_milestone": "v0.47 Human Accounts & Organizations",
"current_milestone_status": "v0.47 phase 1 complete (human auth + org membership) plus password reset (5-min single-use tokens, own-pipeline delivery, dedicated rate limit); billing/plans, OAuth, MFA still deferred",
"current_milestone_status": "v0.47 phase 1 complete (human auth + org membership) plus password reset and organization invitations (both 5-min/5-hour single-use tokens, own-pipeline delivery, dedicated rate limits); billing/plans, OAuth, MFA still deferred",
"next_milestone": "v0.47 phase 2 (billing/plans, dashboard backend) or v0.48 (TBD)"
}
}
2 changes: 2 additions & 0 deletions cmd/mailx/abuseconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ func loadAbusePolicy(get func(string) string) (policy ratelimit.Policy, enabled
n("MAILX_LIMIT_AUTH_IP_BURST", &policy.AuthIPBurst)
f("MAILX_LIMIT_PASSWORD_RESET_IP_RPS", &policy.PasswordResetIPRate)
n("MAILX_LIMIT_PASSWORD_RESET_IP_BURST", &policy.PasswordResetIPBurst)
f("MAILX_LIMIT_ORG_INVITE_RPS", &policy.OrgInviteRate)
n("MAILX_LIMIT_ORG_INVITE_BURST", &policy.OrgInviteBurst)
n("MAILX_RETRY_JITTER_PERCENT", &policy.RetryJitterPercent)
if raw := strings.TrimSpace(get("MAILX_LIMIT_PERMIT_TTL")); raw != "" {
d, e := time.ParseDuration(raw)
Expand Down
Loading
Loading