Skip to content

feat: add organization invitations - #23

Merged
Ferousco-dev merged 5 commits into
mainfrom
Feranmi_works
Sep 25, 2026
Merged

Ferousco-dev merged 5 commits into
mainfrom
Feranmi_works

Conversation

@Ferousco-dev

@Ferousco-dev Ferousco-dev commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Invite an organization member by email only (no invite-code flow), owner-only sending
  • 5-hour single-use invitation tokens, delivered through MailX's own outbound pipeline (same pattern as password reset)
  • A single combined accept endpoint handles both an already-logged-in invitee and a no-account-yet invitee (signup folded into acceptance)
  • Org logo / inviter avatar shown in the invite email when set (plain nullable URL fields, no upload pipeline)
  • Dedicated per-human rate limit on sending invitations
  • Migration 000030: org_invitations, tenants.logo_url, humans.avatar_url

Test plan

  • gofmt/go vet/go build clean
  • Full go test ./... and go test -race ./... clean against real Postgres+Redis
  • Docker rebuild + boot smoke clean
  • 5 new tests in internal/humanauth/org_invitation_test.go

RetriggerConfidence Score: 4/5

The PR is not ready to merge while the concurrent-resend test can race; equal invitation timestamps also need a deterministic ordering.

Fix All in Claude CodeFindings

  1. P1 Concurrent sends race in test ▶
  2. P2 Equal timestamps leave duplicate links ▶
Fix with agent prompt
### Issue 1
internal/humanauth/org_invitation_test.go:405-409
These five goroutines share one `fakeMailer`, but its `SendSystemEmail` method appends to `calls` without synchronization. Concurrent sends can lose recorded emails, making the link-count assertion unreliable, and `go test -race` can fail. Synchronize the shared test mailer.

### Issue 2
internal/database/org_invitations.go:85
If concurrent invitations receive the same `created_at` value, neither satisfies the new strict `<` comparison, so both links remain usable for the same organization and address. PostgreSQL timestamps can share a microsecond, and the pending-invitation index is not unique. This leaves the one-live-link policy unenforced in that case; use a deterministic tie-breaker.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Summary

The PR adds owner-issued organization invitations, email delivery, combined signup or existing-account acceptance, database-backed single-use tokens, and rate limits. The latest changes order concurrent resends by creation time and add a concurrency regression test.

Reviews (4) · Last reviewed commit: "fix: supersede org invitations by creati..."

Invite by email only, owner-only sending, 5-hour single-use tokens
delivered through MailX's own outbound pipeline (same pattern as
password reset). A single combined accept endpoint handles both an
already-logged-in invitee and a no-account-yet invitee (signup folded
into acceptance, always using the invitation's own email). Org
logo/inviter avatar are plain nullable URL fields shown in the invite
email when set. Dedicated per-human rate limit on sending.

Migration 000030 adds org_invitations, tenants.logo_url, humans.avatar_url.
@strix-security

Copy link
Copy Markdown

Strix is installed on this repository, but we couldn't run this PR security review because this workspace's trial has ended. Add a card to resume code reviews here.

So far, Strix has surfaced 1 security issue across this workspace.

Comment thread internal/humanauth/service.go Outdated
Comment thread internal/humanauth/service.go
Comment thread internal/api/routes.go Outdated
Comment thread internal/database/org_invitations.go Outdated
Comment thread internal/database/migrations/000030_org_invitations.up.sql
Comment thread internal/api/humanauth_handler.go
- escape owner/inviter-supplied names in the invitation HTML email
- refuse to send an invite when the mailer is configured but the
  dashboard base URL is not, matching password reset's guard
- rate-limit the public invite-accept endpoint (bcrypt per call)
- recheck expiry atomically at token-consume time, not just when
  the request started
- invalidate a prior pending invite when re-inviting the same
  address instead of stacking duplicate valid links
- validate the invitee email before it reaches the database
Comment thread internal/database/org_invitations.go Outdated
The previous dedup fix invalidated the old invitation and committed
the new one before attempting delivery, so a failed send left the
invitee with neither a working old link nor a delivered new one.
Invalidation now happens only after SendSystemEmail succeeds.
Comment thread internal/database/org_invitations.go Outdated
Two concurrent invites to the same address could each supersede the
other after both had already sent, since exclusion was only "not
this row's ID". Excluding by "created strictly before this row"
instead makes the operation commutative: the newest invitation can
never be invalidated by an older one racing behind it.
Comment thread internal/database/org_invitations.go Outdated
// which request's UPDATE happens to run last.
func (db *DB) SupersedeOtherPendingOrgInvitations(ctx context.Context, tenantID, email, keepID string, keepCreatedAt, now time.Time) error {
_, err := db.pool.Exec(ctx,
`UPDATE org_invitations SET expires_at = $5 WHERE tenant_id = $1 AND normalized_email = $2 AND id != $3 AND created_at < $4 AND accepted_at IS NULL AND expires_at > $5`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Equal timestamps leave duplicate links If concurrent invitations receive the same created_at value, neither satisfies the new strict < comparison, so both links remain usable for the same organization and address. PostgreSQL timestamps can share a microsecond, and the pending-invitation index is not unique. This leaves the one-live-link policy unenforced in that case; use a deterministic tie-breaker.

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/database/org_invitations.go
Line: 85

Comment:
**Equal timestamps leave duplicate links** If concurrent invitations receive the same `created_at` value, neither satisfies the new strict `<` comparison, so both links remain usable for the same organization and address. PostgreSQL timestamps can share a microsecond, and the pending-invitation index is not unique. This leaves the one-live-link policy unenforced in that case; use a deterministic tie-breaker.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

Comment on lines +405 to +409
for i := 0; i < n; i++ {
go func() {
done <- svc.InviteToOrganization(ctx, owner.Human.ID, tenant.ID, "invitee@example.com")
}()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Concurrent sends race in test These five goroutines share one fakeMailer, but its SendSystemEmail method appends to calls without synchronization. Concurrent sends can lose recorded emails, making the link-count assertion unreliable, and go test -race can fail. Synchronize the shared test mailer.

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/humanauth/org_invitation_test.go
Line: 405-409

Comment:
**Concurrent sends race in test** These five goroutines share one `fakeMailer`, but its `SendSystemEmail` method appends to `calls` without synchronization. Concurrent sends can lose recorded emails, making the link-count assertion unreliable, and `go test -race` can fail. Synchronize the shared test mailer.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

Two invitations sharing the same microsecond-precision created_at
had no defined winner under the strict-less-than comparison; now
compares (created_at, id) as a tuple for a genuine total order.
Also fixes an unsynchronized slice append in the test-only
fakeMailer that a concurrent regression test exposed under -race.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ferousco-dev has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@Ferousco-dev
Ferousco-dev merged commit 6affd11 into main Sep 25, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant