Context
Derived from the audit in #205. Depends on #228 (schema — team_link_invitations table) and #231 (magic link acceptance flow).
A second invite mode alongside email invites: a role-scoped link that any admin can copy and share. Anyone who receives the link can join — but must prove email ownership first (two-step flow). The link can be revoked and expires after 7 days.
What's needed
New entity + model action
TeamLinkInvitation entity mapped to team_link_invitations (created in #228).
TeamLinkInvitationModelAction with:
findActiveByTokenHash(hash) — active, non-expired row
findActiveByTeam(teamId) — current active link for a team
revokeLink(linkId) — sets status = 'revoked'
New endpoints
POST /admin/teams/:teamId/invite-link — generate a shareable link
- If an active non-expired link already exists for the team, revoke it first
- Generate 32-byte token, store SHA-256 hash in
team_link_invitations with 7-day expiry
- Returns
{ link: "${FRONTEND_URL}/join?token=<rawToken>", expiresAt }
GET /admin/teams/:teamId/invite-link — get the current active link
- Returns the active link if one exists, 404 if none
DELETE /admin/teams/:teamId/invite-link/:linkId — revoke and delete a link
- Sets
status = 'revoked', returns 200
POST /admin/teams/invite-link/verify-email — step 1 of shareable link acceptance
- Accepts
{ token: string, email: string } (public endpoint, no JWT)
- Hashes token, looks up active non-expired
team_link_invitations row
- Checks the email is not already a team member
- Generates a short-lived (15 min) email-invite token, stores hash in
team_invitations with via = 'link' and the link_invitation_id reference
- Sends magic link email to the provided address:
${FRONTEND_URL}/accept-invite?token=<rawToken>&via=link
- Silent 200 on invalid token (no enumeration)
POST /admin/teams/invitations/accept — update to handle via=link
Acceptance criteria
Depends on
#228, #231
Context
Derived from the audit in #205. Depends on #228 (schema —
team_link_invitationstable) and #231 (magic link acceptance flow).A second invite mode alongside email invites: a role-scoped link that any admin can copy and share. Anyone who receives the link can join — but must prove email ownership first (two-step flow). The link can be revoked and expires after 7 days.
What's needed
New entity + model action
TeamLinkInvitationentity mapped toteam_link_invitations(created in #228).TeamLinkInvitationModelActionwith:findActiveByTokenHash(hash)— active, non-expired rowfindActiveByTeam(teamId)— current active link for a teamrevokeLink(linkId)— setsstatus = 'revoked'New endpoints
POST /admin/teams/:teamId/invite-link— generate a shareable linkteam_link_invitationswith 7-day expiry{ link: "${FRONTEND_URL}/join?token=<rawToken>", expiresAt }GET /admin/teams/:teamId/invite-link— get the current active linkDELETE /admin/teams/:teamId/invite-link/:linkId— revoke and delete a linkstatus = 'revoked', returns 200POST /admin/teams/invite-link/verify-email— step 1 of shareable link acceptance{ token: string, email: string }(public endpoint, no JWT)team_link_invitationsrowteam_invitationswithvia = 'link'and thelink_invitation_idreference${FRONTEND_URL}/accept-invite?token=<rawToken>&via=linkPOST /admin/teams/invitations/accept— update to handlevia=linkvia=linkis present, resolve theteam_invitationsrow that was created byverify-emailAcceptance criteria
verify-emaildoes not reveal whether the shareable token is valid or invalid in its responseforce_password_changeDepends on
#228, #231