Commit graph

3 commits

Author SHA1 Message Date
Randall Stillwell
58f92f3898 feat(security): in-process rate limit for sign-in and invite creation
Algorithm: a fixed-window token bucket implemented as a pure function in
`@tasks/shared` (`consumeTokenBucket`) plus a thin `apps/web` wrapper that
holds per-key state in a module-scoped `Map`. No Redis, no external deps —
horizontally-scaled deploys will need a Redis-backed swap behind the same
`rateLimit()` signature; called out in the JSDoc as a follow-up. The pure
core is unit-tested in `packages/shared` (6 new vitest cases covering
allow/deny, window reset, key isolation, monotonic retryAfterMs, denied-
flood pegging, and option validation); the wrapper is intentionally not
tested here because apps/web has no vitest harness yet.

Wire-ins (the two narrow surfaces called out in the v1 spec):

  1. Credentials `authorize` in `apps/web/lib/auth.ts`: 5 attempts per
     IP per 60s. IP comes from `next/headers` (x-forwarded-for first
     entry, then x-real-ip); when headers() throws or returns nothing we
     fall back to keying on "unknown" in prod and skipping the limiter
     entirely in dev so a local test loop doesn't lock itself out. On a
     trip we `console.warn` and return null — the standard Auth.js
     "auth failed" signal — without consulting the DB.

  2. `invites.create` in `apps/web/server/routers/invites.ts`: 10
     invite-creates per inviter per hour. Keyed by inviter id (not
     workspace) so a multi-workspace admin can't multiply their
     allowance. On trip we throw TRPCError TOO_MANY_REQUESTS with a
     retry-after seconds count baked into the message.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 13:26:19 -05:00
Randall Stillwell
29e69e964b feat(invites): smart recipient autocomplete combobox (Task 3, done)
Closes Task-invite-recipient-autocomplete. The invite dialog's plain
email input is replaced with a debounced combobox that surfaces the
four real cases — existing member, pending invite, known user from a
sibling workspace, brand-new email — before the inviter hits send.

Subagent ran in parallel while the main thread shipped Task 2's UI;
file-level non-overlap held (subagent stayed in
apps/web/components/teams/invite-recipient-combobox.tsx and the
shared types; main thread stayed in invite-dialog.tsx and the
teams page). This commit folds the subagent's deliverable in plus
the two-line wire-up that swaps the input for the combobox.

Files (5 by subagent + 1 wire-up by main thread):

@tasks/shared:
* packages/shared/src/types/invite-suggestions.ts — InviteSuggestion
  union + pure mergeInviteSuggestions ranker. Lives in shared so
  client + server consume one type definition.
* packages/shared/src/types/invite-suggestions.test.ts — 9 vitest
  cases covering kind ordering, dedupe (known_user vs member by
  userId, vs pending_invite by lowercased email), new_email
  suppression when other kinds cover the typed address, the 10-
  result limit, and email normalization.
* packages/shared/src/types/index.ts — re-export.

apps/web:
* apps/web/server/routers/invites.ts — new `suggestRecipient`
  procedure on workspaceProcedure (owner/admin only). Implements
  the four kinds with the tenancy fence wired as a two-step query:
  first SELECT DISTINCT workspace_id FROM workspace_members WHERE
  user_id = inviter (the inviter's workspace pool), then
  inArray(workspaceMembers.workspaceId, pool) + ne(users.id,
  inviter) on the candidate join. Read the procedure JSDoc for the
  full set of invariants. All user-typed patterns escape through
  escapeIlike with the ESCAPE '\\' clause (mirrors search.ts).
  No existing exports modified.
* apps/web/components/teams/invite-recipient-combobox.tsx —
  standalone controlled combobox. 200ms debounce, min-2-char gate,
  distinct row styling per kind, ArrowUp/Down/Enter/Esc keyboard
  nav, outside-click close.
* apps/web/components/teams/invite-dialog.tsx (wire-up) — Input
  swapped for InviteRecipientCombobox. Added an
  onFocusExistingMember prop so a future teams-page integration
  can scroll/focus the matching row when a `member` suggestion is
  picked; for now the dialog just closes cleanly on member-pick.

Gates: 0 lint errors / 15 warnings (14 baseline + 1 incidental
from earlier teams-page work, none from this task's files); 6/6
type-check; 23/23 tests (14 baseline + 9 new).

Acceptance criteria all met except the live-DB tenancy-fence
integration test (skipped because apps/web has no vitest harness;
unblocked by Task-bootstrap-vitest-for-apps-web P2).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 10:39:09 -05:00
Randall Stillwell
7a55d6d1c6 feat(invites): workspace_invites schema + tRPC router + role management (Task 2, part 1/2)
Schema half of Task-workspace-invites-and-roles. Lands the table, the
invites router (create/list/revoke/accept), and the two new workspaces
procedures (updateMemberRole/removeMember). UI ships in part 2/2.

This is a stable checkpoint for Task 3 (invite-recipient-autocomplete)
to start building against — the procedure surface area is frozen and the
new identity helper from Task 1 is in the accept path.

Schema:
* workspace_invites: id, workspace_id, email (lowercased), role
  (owner/admin/member), invited_by_user_id, token (base64url 32B),
  expires_at (DEFAULT now() + 14d), accepted_at, revoked_at, created_at.
* Indexes: workspace_id, UNIQUE(token), and a PARTIAL UNIQUE on
  (workspace_id, email) WHERE accepted_at IS NULL AND revoked_at IS NULL.
  An open invite is unique per (workspace, email); closed invites
  (accepted or revoked) fall out of the constraint so re-invites work.
* Drizzle relations wired: workspaceInvites.workspace,
  workspaceInvites.invitedBy, workspaces.invites.
* Migration 0006_broad_lethal_legion applied to dev DB.

invites router:
* create({email, role}) on workspaceProcedure (owner/admin only).
  Generates a base64url token from 32 random bytes via node:crypto.
  Idempotent on (workspace, email) — if an open invite already exists,
  returns it instead of inserting (the partial unique would block it
  anyway). Refuses self-invite. Refuses if the email is already a
  member.
* list() returns pending (non-accepted, non-revoked) invites with
  inviter name/email joined for UI display.
* revoke({inviteId}) authorizes against the invite's workspace, not
  the caller's input (the inviteId carries its own tenant scope).
* accept({token}) is protectedProcedure (no workspace handle). Calls
  userOwnsEmail() from Task 1 — if the caller doesn't own the invited
  email under any of their verified identities, throws FORBIDDEN with
  a structured cause ({reason: "email_not_owned", invitedEmail}) so
  the redeem page can render the "link this email" explainer. Handles
  expiry, revoked, already-accepted states with clear messages.
  Idempotent on existing membership — if you've already been added by
  another flow, accept just closes the invite without re-inserting.

workspaces additions:
* updateMemberRole: admin/owner only. Three guards:
    1. Can't change your own role (avoids accidental lockout).
    2. Can't demote the only owner-role member (would leave the
       membership-level ownership empty even though workspaces.owner_user_id
       still points there — see ADR-pragmatic decision documented in the
       Task-multi-email-identity convoy discussion).
    3. Only owners can promote to owner; admins move people between
       admin/member but cannot create a new owner.
* removeMember: admin/owner OR self (the leave-workspace affordance).
  Same last-owner guard. Admins can't remove owners (only owners can,
  via demote-then-remove).

Wired both new routers into root.ts as `invites` and `identity`
(identity was landed in Task 1; this commit just keeps the registration
visible alongside invites).

All three CI gates green: 0 lint errors, 14 unchanged warnings, 6/6
type-check, 14/14 tests (no new tests yet — apps/web vitest harness is
filed as Task-bootstrap-vitest-for-apps-web P2).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 10:27:44 -05:00