Estimate: 22m · Depends on: 1.2.2, 1.1.6
The server-side half of invites: an endpoint to send an invite email, an endpoint to validate an invite token (used by the acceptance UI on landing), and an endpoint to accept the invite (creates the WorkspaceMembership row, consumes the token). Token storage reuses the Verification table from Story 1.1.3 — no new Invite table. Email delivery reuses lib/email.ts from 1.1.6.
Why reuse Verification, not a new Invite table: the Verification table is Better-Auth's catch-all token primitive (identifier + value + expiresAt). The password-reset flow in 1.1.6 stores reset tokens there with identifier "reset-password:{token}". Adding workspace-invite tokens with identifier "workspace-invite:{token}" reuses the same single-use, expiry-tracking primitive — no new schema, no new cleanup job, no new index. The value column carries JSON: { workspaceId, email, role }. This is the durable shape: Verification IS the project's token primitive, and Story 1.2's invites fit it.
Why account-needed acceptance (not magic-link): magic-link invites would require a new Better-Auth surface (magic-link is its own primitive, not currently wired). Adding it would be shortcut shape — the project would carry two auth primitives (password / OAuth from 1.1, plus magic-link only used for invites). Account-needed acceptance reuses Story 1.1's complete auth surface: the invitee signs in (or signs up) through the existing flows, then lands on a one-click "Accept invite" screen. Decision recorded in MOTIR.md "Story 1.2 decisions baked in" (added in this Subtask's deepening pass).
Why 7-day expiry: matches the Linear / Slack / GitHub norm (longer than password-reset's 1-hour because invites have legitimate "I'll get to it later" delay; shorter than indefinite because expired tokens still need to be garbage-collected by the same cleanup the Verification table needs anyway).
Why 3/hour rate limit per workspace per email: mirrors Story 1.1.6's password-reset limit pattern, keyed by (workspaceId, recipientEmail) instead of by IP. Prevents accidental spam from a settings-page button mashing without blocking legitimate re-invites after an email goes to spam.
What you'll do: Create app/api/workspaces/[workspaceId]/invites/route.ts (POST: send invite; requires active membership in the workspace) and app/api/invites/[token]/route.ts (GET: validate token, returns { workspaceName, inviterName, email } for the acceptance UI; POST: accept invite, requires session whose email matches the invite's email, creates membership, deletes the verification row). Add lib/workspaces/invites.ts with the token-shape helpers (encodeInviteToken, decodeInviteToken, sendInviteEmail). The email body renders the design from 1.2.1's invite-email mockup — both plain-text (link unredacted) and HTML, sent via sendEmail from lib/email.ts. Rate limit implemented in-app (the Verification table is the source of truth; count rows with the same workspace+email identifier within the last hour). Comprehensive Vitest coverage: send invite happy path, send-to-already-member rejected, send-when-not-a-member rejected, accept with matching email succeeds, accept with non-matching email rejected, expired token rejected, single-use enforcement, rate limit triggers on 4th send.
POST /api/workspaces/[workspaceId]/invites (send), GET /api/invites/[token] (validate — used by the acceptance UI on page load to render the workspace name and inviter), POST /api/invites/[token]/accept (accept).workspaceId; returns 403 if the requesting user isn't a member. Returns 422 if the target email is already a member of the workspace (return the duplicate-detection error inline rather than silently no-op).Verification table: identifier workspace-invite:{base62-token}, value JSON.stringify({ workspaceId, email: lowercased, role: 'member' }), expiresAt now + 7 days.lib/email.ts's sendEmail() — subject "You're invited to join {Workspace} on Motir", plain-text body containing the accept link unredacted (mirroring the password-reset email's dev-console-readable contract from 1.1.6), HTML body matching 1.2.1's mockup.user.email matches the invite's email (case-insensitive — both stored and compared lowercase). Returns 403 with a clear "this invite is for a different email" error if mismatched (does NOT auto-link to a different account — the user must sign in with the invited email, or contact the inviter for a new invite).WorkspaceMembership row (role: 'member') and deletes the Verification row in a transaction. Idempotent: if the user is already a member, the second accept returns success without creating a duplicate (the unique constraint catches it).tests/workspace-invites.test.ts cover all 8 cases listed in the description (happy path send, already-member rejection, not-a-member rejection, accept happy path, email-mismatch rejection, expired token, single-use, rate limit).lib/email.ts + lib/auth/index.ts from Story 1.1 — the email abstraction and how Better-Auth uses Verification for reset tokens (the exact pattern to mirror)lib/workspaces/repo.ts + lib/workspaces/context.ts from 1.2.2/1.2.3tests/password-reset.test.ts from 1.1.6 — the rate-limit + token-lifecycle test pattern to mirror/design/workspaces/invite-email.png from 1.2.1 — the email body to rendernode_modules/better-auth/dist/api/routes/password.mjs) — the reference implementation of the Verification-based token pattern this Subtask mirrors for invites