---
title: "Host User Invitation"
slug: host-user-invitation
section: Portals
group: Host Portal
status: stable
description: "Frozen contract for inviting host users (host_admin, host_hiring_manager, host_finance, host_readonly), the activation token model, the first-login set-password flow, and the audit + notification surface that supports it."
last-updated: 2026-10-07
---

# Host User Invitation

Frozen contract for inviting host users (host_admin, host_hiring_manager, host_finance, host_readonly), the activation token model, the first-login set-password flow, and the audit + notification surface that supports it. Sibling to the Host Camp Overview and Host Contacts contracts.

## SUPERSEDED — Read This First

> **This contract was built, rejected, and removed — Auth0 owns host credentials**
>
> The Globus-stored host password/activation model this page freezes was implemented and then explicitly removed: migration 20260630000000_drop_host_activation_tokens.sql (MCP #3617/#3780, Task #433) dropped the host_activation_tokens table with the note that host users authenticate via Auth0 and Globus stores only partner_users.auth0_sub plus an invite token hash. There is no Globus password hash, no password policy, no set-password flow, and no password-reset token — Auth0 owns all of it. Everything below the As-Built section is the historical contract the code diverged from (the live hostActivation.ts even cites this page as its frozen origin) — do not build against it.

## As-Built Model (Auth0 + partner_users)

| Aspect | As built (verified 2026-07-04) |
| --- | --- |
| Identity | Host users live only in partner_users (no users row). Auth0 owns the password; Globus stores auth0_sub, set at accept. |
| Invite token | Single opaque token (generateAccessToken), SHA-256 hashed into partner_users.invitation_token_hash with invitation_expires_at (INVITE_TTL_DAYS = 7). No per-token table, no purpose enum, no id.secret composite. Source: server/services/hostUserInvite.ts (single primitive inviteHostUser()). |
| Accept URL | hostPortalBaseUrl/host-invitations/accept?token=rawToken |
| Roles | system_lookups group partner_user_role: admin, recruiter (label 'Hiring Manager'), readonly, finance (Task #573; label 'Finance Manager'; read-only, sharing the Read Only baseline of view capabilities) — not host_admin/host_hiring_manager/host_finance/host_readonly. |
| Invite endpoints | POST /api/partner/host/users (create+invite; partner API-key auth + requireHostCapability('host.users.manage') + requirePartnerScope('users.write')); staff-side POST /api/admin/partners/organizations/:orgId/users, .../invite-admin, and org-create auto-admin. Invite lists (read-only, one entry per `partner_users` row of the organization with `status` `pending`, `accepted` or `expired`, `invitedAt`, `acceptedAt` and `invitationExpiresAt`): `GET /api/admin/partners/host-companies/:id/invites` (`partners.read` scope plus one of `partners.view`, `job_orders.create`, `job_orders.manage`) and `GET /api/admin/partners/organizations/:orgId/invites` (`partners.view`); both share one handler. |
| Reissue | POST /api/partner/host/users/:id/reissue-invite and POST /api/admin/partners/host-users/:id/reissue-invite (Task #1251, partners.manage). Cooldown 60 seconds (Task #1633) → 429 INVITE_RESEND_COOLDOWN with Retry-After; 409 ALREADY_ACTIVATED only when acceptedAt AND auth0Sub are both set (accepted-but-unlinked rows are repairable, Task #1243). Reissue overwrites the same row's hash — no superseded-token rotation. Both reissue routes return 409 `INVITE_ON_HOLD` when the invite of the user was deferred (never sent) and the organization has no active agreement; activating an agreement releases it. `POST /api/partner/host/users` returns 409 `DUPLICATE_EMAIL` when the email already exists in the same organization, and the staff add-user route mirrors it. |
| Accept flow | POST /api/host-invitations/accept (canonical; also POST /api/host/activation/complete, Task #787) with body token, auth0_sub . auth0_sub REQUIRED — missing → 422 AUTH0_SUB_REQUIRED and the token is NOT consumed. Success sets acceptedAt, isActive=true, auth0Sub, clears token+expiry; response echoes host_admin_email + globus_id. Errors: 409 AUTH0_SUB_ALREADY_LINKED, 409 ALREADY_ACCEPTED, 410 INVALID_OR_EXPIRED_TOKEN. |
| Landing lookup | GET /api/portal/host-user/profile with X-Onboarding-Token header (non-consuming): 401 without the header, 410 invalid/expired; returns a FLAT profile incl. host_admin_email and status pending \| accepted \| expired. Source: portalHostUserFlows.ts. |
| Host Portal onboarding proxy | HostPortalPrototype serves the invite screen at `GET /api/v2/onboarding/invite?token=` (profile lookup) and `POST /api/v2/onboarding/accept` with body `token` and `password`. Accept creates (or reuses) the Auth0 user, assigns the `HostPortalUser` role (plus `HostPortalAdmin` when the invite role is admin), then accepts the invite on the Hub. Errors: 400 `MISSING_TOKEN` (no token, or the Hub rejected it as unauthenticated), 400 `INVALID_PASSWORD` (missing or under 8 characters), 400 `AUTH0_USER_CREATE_FAILED` (Auth0 refused the create with a 400; its message is returned), 410 `INVALID_OR_EXPIRED_TOKEN`, 409 `ALREADY_ACCEPTED` or `AUTH0_SUB_ALREADY_LINKED`, 503 when the Auth0 Management credentials are not configured, 502 for other upstream failures. |
| Legacy aliases | hostActivation.ts adds GET /api/host/activation/status, POST /verify, POST /complete\|activate\|set-password — set-password is an alias for complete taking only token and sets NO password. Errors on these routes: 400 `INVALID_TOKEN` (token missing), 410 `INVALID_OR_EXPIRED_TOKEN` (unknown, expired or consumed) and, on complete, activate and set-password only, 409 `ALREADY_ACTIVATED`. Verify and status do not consume the token and return `valid`, `email`, `firstName` and `lastName`. None of these routes takes or checks a password, so `WEAK_PASSWORD` does not occur here (it belongs to the staff activation flow). KNOWN DRIFT: POST /api/host/activation/complete is registered in BOTH hostInvitations.ts (requires auth0_sub) and hostActivation.ts (doesn't) — a duplicate-registration conflict to resolve. |
| Host admin email | `host_admin_email` (landing lookup and accept response) comes from `resolveHostOrgAdminEmail` in `server/services/hostOrgIdentity.ts`: the email of the organization's `admin` partner user, preferring an active user, then one who has accepted, then the earliest accepted or invited; if there is none, `partner_organizations.contact_email`; otherwise null. Per the code comments, the Auth0 post-login Action authorizes a host-portal user by matching `app_metadata.em` against this organization admin email, not the accepting user's own email, so the portal writes this value into `app_metadata.em` at accept time. |
| Legacy aliases | hostActivation.ts adds GET /api/host/activation/status, POST /verify, POST /complete\|activate\|set-password — set-password is an alias for complete taking only token and sets NO password. KNOWN DRIFT: POST /api/host/activation/complete is registered in BOTH hostInvitations.ts (requires auth0_sub) and hostActivation.ts (doesn't) — a duplicate-registration conflict to resolve. |
| Audit | Via auditMutation middleware: host_portal.user.create / .reissue_invite / .update / .delete, entityType partner_user. The host.user.* names below were never implemented. |
| Notification | Two channels, both best-effort and neither able to roll back the minted token. (1) In-app: notifyHost( templateKey: 'host_admin_invitation' ). (2) Email: inviteHostUser() emits the host.user_invited domain event (emitHostUserInvited, payload hostOrgId, inviteeEmail, inviteeName, invitedRole, activationUrl, expiresAt plus the resolved sponsor-brand fields; dedupeKey host.user_invited:<partnerUserId>:<rawToken>) and the n8n Dispatcher delivers the email (n8n → SendGrid). Globus no longer calls SendGrid directly for invites (removed to fix duplicate invite emails). If host.user_invited is not in the n8n Dispatcher's subscribed event list the email is suppressed; InviteHostUserResult.emailSent / emailSuppressed / emailError report the event handoff, not a SendGrid call. Every invite surface that funnels through inviteHostUser() (org-create bootstrap admin, reissue, add-user, invite-admin) emits the event. |
| Deactivation | PATCH/DELETE /api/partner/host/users/:id block/unblock the Auth0 user and enforce assertNotLastActiveAdmin. Password reset is an Auth0 flow — /api/host/activation/request-reset does not exist. |
| Staff permanent delete | DELETE /api/admin/partners/users/:id (partners.manage) permanently deletes a host user of any status, including accepted users. It returns 409 LAST_ADMIN (details.orgId) when the target is the organization's only active admin. The Auth0 account is deleted afterwards on a best-effort basis, so an Auth0 failure does not undo the database delete. Success returns `{ success: true, data: { success: true } }`. |
| Auth0 Management API config | Deactivation, reactivation, email edits and permanent removal call the Auth0 Management API through server/services/auth0Management.ts using an M2M client_credentials token. It needs three env vars: `AUTH0_DOMAIN`, `AUTH0_M2M_CLIENT_ID` and `AUTH0_M2M_CLIENT_SECRET`; the integration counts as configured only when all three are set. When not configured, every call is a logged no-op (block, unblock, email update, delete), so dev and test removals never fail. Calls are best-effort: an Auth0 outage does not roll back the Globus-side change, and a 404 from Auth0 is treated as already gone. |
| Auth0 Management env (HostPortalPrototype) | Used when the Host Portal creates the Auth0 user on accept. Required: `AUTH0_MGMT_CLIENT_ID`, `AUTH0_MGMT_CLIENT_SECRET` and `AUTH0_MGMT_AUDIENCE` (Management API audience; a trailing slash is added and the tenant origin is derived from it); if any is unset, accept returns 503. Optional: `AUTH0_DB_CONNECTION` (database connection for created users, default `Username-Password-Authentication`), `AUTH0_HOST_ROLE_NAME` (default `HostPortalUser`), `AUTH0_ADMIN_ROLE_NAME` (default `HostPortalAdmin`, assigned as well when the invite role is admin), and `AUTH0_HOST_ROLE_ID` / `AUTH0_ADMIN_ROLE_ID`, which pin role ids so the role-name lookup (needs the `read:roles` scope) is skipped. |

> **Host Portal app status**
>
> HostPortalPrototype has no accept/invite wiring yet — the accept URL contract above is served by Globus; the portal-side screen remains to be built (see Host Portal Endpoint Reference for the portal's current auth reality).

## Overview (historical contract)

Host company onboarding stops at "host org created" + "first admin user added." This contract picks up there. The product intent is straightforward:

When the first host_admin is added to a newly created host, the system emails them the platform URL and an activation link. / On first login the invitee sets a password. That password (hashed) is stored against their user account and used for every subsequent login. / Every additional host user (host_admin, host_hiring_manager, host_finance, host_readonly) added later from the Host Portal goes through the same invite → activation → set-password flow.
> **One flow, one token table, one set-password endpoint**
>
> The first-admin auto-provision path, the host-admin invites-someone-else path, and the password-reset path all share the same host_activation_tokens table and the same POST /api/host/activation/:token/set-password endpoint. Only the issuing trigger, the purpose column, and the expiry window differ. This keeps the activation UI a single surface in the Host Portal.

## Roles

The Host Portal recognises four end-user roles. The invitation flow, activation token model, and set-password endpoint are identical for all four — only the capability set behind the role differs (see the Host RBAC Permission Keys table for the per-key matrix).

> **host_finance is not the sponsor-side finance.* namespace**
>
> host_finance is a Host Portal end-user role only. It is not an alias for the sponsor-side finance.* staff permissions documented on RBAC → Finance Permissions . The two namespaces do not share capabilities, do not share users, and a host_finance invitee never gains any sponsor-side permission.

## Trigger Events

### First host_admin (Auto-Provisioned)

When sponsor staff creates a new host org (see Org Creation — Program-Type Field Capture ) and supplies a primary admin contact, the create-org transaction also inserts a users row with role host_admin and password_hash = null. A best-effort post-commit hook then mints an invite token and enqueues the email described below.

> **The host has no portal access until the password is set**
>
> The auto-provisioned user row exists from the moment the host org is created, but it has no usable credential. Login attempts before activation fail with a generic "account not activated — check your email" copy (no enumeration of which accounts are pending vs. unknown). Staff can re-issue an invite from the Sponsor Ops Console if the email is lost.

### Subsequent Host Users

Any host_admin with the host.users.manage permission may invite additional users from the Host Portal Users / Contacts surface. The invite endpoint:

- Creates a users row with the chosen role (host_admin, host_hiring_manager, host_finance, or host_readonly) and password_hash = null.
- Mints a token with purpose = "invite".
- Enqueues the host.user_invited notification.
- Writes an host.user.invited audit row via recordAudit().
> **No role-bypass**
>
> host_hiring_manager, host_finance, and host_readonly cannot invite users. Read-only, finance, and hiring-manager surfaces hide the "invite" affordance. The endpoint itself also enforces host.users.manage and returns 403 if a non-admin reaches it directly.

## Activation Token Model

### host_activation_tokens Table

New table owned by Globus core. The token id is opaque (UUID) and the secret is a cryptographically random 32-byte value, surfaced only in the emailed link as https:// /activate?token= . . The database stores only the SHA-256 hash of the secret.

| Column | Type | Description |
| --- | --- | --- |
| col | type | desc |

_(row above is a template, repeated for each data row)_

### Token Lifecycle

s email column is compared to users.email at consume time. If staff renamed the user after the invite was sent, the activation returns 409 email_mismatch and the inviter must re-issue.,
Re-issue is a new row / Re-sending an invite mints a fresh token row and emails a fresh link. Prior tokens for the same user with the same purpose are revoked (consumed_at = now() with resolution reason
### Resend Invitation

When the original activation email is lost, filtered to spam, expired, or simply never acted on, the inviting Host Admin (or sponsor-side staff with the same capability — see RBAC below) can re-issue the activation link without deleting and re-creating the user row. This is the Resend Invitation row action on the Users tab of the Host Portal Team / sponsor-side Host Management views .

Endpoint: POST /api/host/team/:userId/resend-invite. Required permission: host.users.manage (host_admin in the Host Portal) or the equivalent sponsor-side host-management capability for back-office staff.

invite / superseded / s expiry. The previous expiry does not carry over.,
Email re-sent / Reuses the existing host.user_invited template and pipeline (n8n → SendGrid). The template is rendered with the new activation URL; no separate \"resend\" copy is introduced. The payload carries a resend=true flag so n8n / SendGrid can apply a distinct subject-line variant or a brief \"this replaces our earlier invitation\" preamble — that styling decision is delivery-side, not contract-side.,
Visibility gate / The row action is surfaced only for users whose Portal Access state is \"Not invited\" or \"Linked — pending first sign-in\". It is hidden for activated users and for deactivated users; the endpoint defensively returns 409 user_already_activated or 410 user_deactivated if either is reached directly.,
Throttle / One resend per 10 minutes per user_id. Subsequent calls within the window return 429 resend_throttled. The throttle is per user, not per inviter — two different admins clicking Resend in quick succession still hit the same window. Throttle bypass for sponsor-side staff is explicitly out of scope.,
Bulk resend is out of scope / This endpoint is per-user only. A future bulk-resend surface, if introduced, would be a separate contract and a separate audit action.,
Not for password reset / Resend rotates an invite token (purpose=
> **Reuse host.user_invited, do not introduce a new event key**
>
> Resend reuses the existing host.user_invited event and template variant with resend=true on the payload. Rationale: the recipient (the individual invitee), the channel (n8n → SendGrid), the deliverability classification (transactional / authentication, never digestable), and the activation-link contract are identical. A second event key would force every downstream consumer (preference center, deliverability dashboard, n8n routing) to duplicate every rule for the same behavior. The resend flag is sufficient for any subject-line or copy variation.

> **Error taxonomy additions**
>
> The resend endpoint inherits the activation error vocabulary and adds three endpoint-specific reasons: 409 user_already_activated (target user has a non-null password_hash and last_login_at), 410 user_deactivated (target user is in the deactivated state — caller must Reactivate first), and 429 resend_throttled (within the 10-minute window). All three are well-formed-but-stateful failures and never produce a side effect.

## Invite Email

### Routing & Channel

The invite email is dispatched by the standard host notification pipeline (n8n → SendGrid), the same channel used by every other host.* event in Notification Model → Host Event Types . Event key: host.user_invited. Recipient: the invited user's email address (the individual being onboarded, not the org).

Brand: like every host-facing email, the invite (and its resend=true variant) renders under the brand resolved server-side from the host org's applicable host agreement — a Wild Packs-agreement host receives a Wild Packs-branded invite (sender identity, copy, visual treatment, footer, support details, and activation/portal links). The resolved brand and template version are snapshotted on the outbox row; delivery retries of that row reuse the recorded decision. A **resend is a new enqueue**: it creates a new outbox row and resolves the then-applicable agreement brand fresh, so a resend issued after the host gains a Wild Packs agreement renders under Wild Packs even if the original invite went out under the default brand. See Notification Model → Agreement-Derived Brand Resolution (Wild Packs).

Delivery-side note: in the n8n [Globus] Handler: Host Domain workflow, the `host.user_invited` email branch is currently built with a fixed WildPacks identity. It passes `from_email` `noreply@wildpacks.com` and `from_name` WildPacks to the SendGrid delivery workflow, uses WildPacks copy and `support@wildpacks.com`, and defaults the organization name to WildPacks; it does not read the agreement-resolved brand. The other `host.*` email branches in the same handler use Alliance Abroad copy and do not set a sender override.

### Required Content

s brand theming contract).,
Activation link / Of the form https:// /activate?token= . . Single use, expires in 7 days.,
Expiry timestamp / Human-readable expiry in the recipient
### Relationship to host.profile_created

> **Sibling event, not a replacement**
>
> host.user_invited targets the individual invitee and carries the activation link. host.profile_created (see Host Camp Overview — host.profile_created Event ) targets the sponsor partner-success team and the org-level contact when the host first publishes their Camp Overview. They are distinct events with different audiences, different templates, and different lifetimes (per-user vs once-per-org). A host_admin auto-provisioned during create-org receives host.user_invited immediately; host.profile_created only fires later, after they finish their Camp Overview tab.

## Activation Endpoints

Both endpoints are unauthenticated. Authorization is enforced entirely by possession of the opaque token. Both live under /api/host/activation/* — a sibling path to /api/partner/host/* — because the rest of the host portal API requires an API key the invitee does not yet have.

### GET /api/host/activation/:token

Resolves a token and returns enough context for the activation screen to render without revealing PII beyond the bound email. Idempotent — the token is not consumed.

// 200 OK

"email": "alex@pinewoodcamp.example",

"hostOrgName": "Pinewood Camp",

"role": "host_admin",

"status": "pending",

"expiresAt": "2026-05-27T14:00:00Z"

On a 410 the response body still includes code / token_expired / token_consumed so the portal can route the user to the "request a new invite" screen rather than a generic error.

### POST /api/host/activation/:token/set-password

Atomically validates the password against policy, hashes it, writes the hash to the user, stamps consumed_at on the token, and writes the two audit rows (host.user.activated + host.user.password_set). Single transaction — partial failure rolls back everything.

// Request body

password

// 200 OK

status / activated

The response intentionally does not echo the user id or email — those are already known to the portal from the prior GET. Minimizing the response surface keeps the endpoint useless as an enumeration oracle.

### Error Taxonomy

| HTTP | code | Meaning |
| --- | --- | --- |
| code | reason | desc |

_(row above is a template, repeated for each data row)_

## Password Policy

> **Failure response shape**
>
> On 422 the response body is code / password_policy / failures / min_length / complexity_classes / common_password / contains_email. Only the list of failing rule keys is returned — never any portion of the submitted password, never any hint about the common-password list other than whether a match occurred.

## Password Storage Contract

### Reset & Rotation

Password reset reuses this exact token machinery. A host_admin requesting a reset for themselves or another host user (or the user requesting their own reset from the login screen) mints a token with purpose = "password_reset" and expires_at = now() + 1h. The user lands on the same set-password screen the activation flow uses. On successful POST, the existing users.password_hash is overwritten and only host.user.password_set is written to audit (not host.user.activated — that event is first-set-only).

## RBAC

| Endpoint | Auth Mode | Required Permission |
| --- | --- | --- |
| POST /api/partner/host/users (invite) | Standard host portal auth — Direct or Host Proxy + User Attribution | host.users.manage |
| POST /api/partner/host/users/:id/reissue-invite | Standard host portal auth | host.users.manage |
| GET /api/host/activation/:token | Unauthenticated — token-gated | (none — possession of the token is the authorization) |
| POST /api/host/activation/:token/set-password | Unauthenticated — token-gated | (none — possession of the token is the authorization) |
| POST /api/host/activation/request-reset | Unauthenticated — email-gated (always responds 202 regardless of whether the email exists, to avoid enumeration) | (none) |

> **Unauthenticated, not unprotected**
>
> The activation and reset endpoints are unauthenticated because the user, by definition, has no credential yet. They are still rate-limited per IP and per token, the secret is hashed at rest, and lookups are constant-time to defeat timing attacks. See RBAC → External Portal Access for the broader pattern that these endpoints fit into.

## Audit

Three audit actions are written through the shared recordAudit() helper described in the Audit Logging Contract . Actor resolution follows the standard resolveActor(req) path. Audit writes are best-effort and never fail the underlying request.

| Action | Actor | When Written |
| --- | --- | --- |
| action | actor | whenWritten |

_(row above is a template, repeated for each data row)_

## Notifications

A single new host event is introduced: host.user_invited. It joins the host event catalog in Notification Model → Host Event Types and the Employer Notifications catalog. Channel: n8n → SendGrid. Recipient: the invited user's email (not the org-level distribution list). Template: host_user_invited — a single template variant used for both first-admin auto-provision and host-admin self-service invites; the "inviter context" block is conditionally rendered from template variables.

> **Not a digest event**
>
> Invites are per-user, transactional, and time-sensitive (7-day expiry). They are never rolled up into a digest. Hosts that opt out of recommended-list digests still receive host.user_invited — it is functionally part of authentication, not marketing.

## Acceptance Criteria (Gherkin)

## Implementation Follow-up

> **Cross-repo routing**
>
> Per the cross-repo ticket-routing rule in this site's contributor preferences, the items below are Host Portal repo tickets, not project tasks on this docs-site repo. They are listed here only so the Sponsor Ecosystem team can see what this contract unblocks downstream. The platform-side endpoint implementation (tables, hashing, the two endpoints, the notification template wiring) is a separate Globus core ticket also tracked outside this repo.

### Resend Invitation — Cross-Repo Tickets (House Asana Format)

> **Draft tickets — not project tasks on this repo**
>
> The three drafts below match the house standard documented in docs/asana-ticket-standard.md and the canonical reference ticket 1214212456520951. Per the cross-repo routing rule in replit.md, these are filed in the Globus-core and Host-Portal repos respectively — they are not proposed as project tasks on this docs-site repo.

Globus: host user invite resend endpoint with token rotation, throttle, and audit

FOR PRODUCT OWNERS / STAKEHOLDERS
Host Admins and sponsor-side staff frequently need to re-issue an activation invitation when the original email is lost, filtered to spam, expired, or simply ignored. Today the only workaround is to delete the user and re-create them, which loses audit history and risks a different user_id. This ticket adds a per-user "Resend Invitation" endpoint that rotates the invite token in-place, re-sends the existing host.user_invited email, and writes a clean audit trail — no user row destruction, no duplicate live tokens, no resend storms.
----------------------------------------
BACKGROUND
"host_activation_tokens" is the existing single-row-per-issuance table backing both invite and password-reset flows (see /docs/host-user-invitation). On the initial invite path, "POST /api/host/team/invite" mints a "purpose='invite'" row with a 7-day "expires_at" and dispatches the "host.user_invited" event via the standard n8n → SendGrid pipeline. There is currently no supported way to re-issue an invite for an existing user row: callers must "DELETE /api/host/team/:userId" and re-invite from scratch, which produces a new "user_id", breaks audit continuity, and silently strands any history that referenced the old id. The audit-logs vocabulary today covers "host.user.invited", "host.user.activated", and "host.user.password_set" but has no event for a re-issuance.
WHAT THIS TICKET DOES
Adds endpoint "POST /api/host/team/:userId/resend-invite" gated by the "host.users.manage" permission key (or the equivalent sponsor-side host-management capability). Behavior in one transaction: (1) reject with "409 user_already_activated" if "users.password_hash" is non-null AND "users.last_login_at" is non-null; (2) reject with "410 user_deactivated" if the user is in the deactivated state; (3) reject with "429 resend_throttled" with "retryAfterSec" if any "host.user.invite_resent" audit row for this "user_id" exists within the prior 10 minutes; (4) on success, stamp every outstanding "host_activation_tokens" row for this user with "purpose='invite'" and "consumed_at IS NULL" with "consumed_at = now()" and "resolution_reason = 'superseded'"; (5) insert a new "purpose='invite'" row with a fresh 7-day "expires_at"; (6) enqueue the existing "host.user_invited" notification with payload "resend = true"; (7) write an "host.user.invite_resent" audit row against "entityType: host_user" with payload "staff_resend". Adds the new audit action constant and updates the audit-vocabulary docs on /docs/audit-console and /docs/host-user-invitation. References "Globus: shared response-redaction layer for all outbound partner and host endpoints" for the response envelope conventions.
ACCEPTANCE CRITERIA (Gherkin)
Feature: Host user invite resend with token rotation, throttle, and audit
Scenario: Resend rotates outstanding invite tokens in a single transaction
Given a "users" row with "role = 'host_admin'" and "password_hash = null"
And one "host_activation_tokens" row with "purpose = 'invite'" and "consumed_at = null" for this user
When a caller with "host.users.manage" invokes "POST /api/host/team/:userId/resend-invite" outside the throttle window
Then the prior token row's "consumed_at" is stamped "now()" with "resolution_reason = 'superseded'"
And a fresh "host_activation_tokens" row with "purpose = 'invite'" and "expires_at = now() + 7 days" is inserted
And the "host.user_invited" event is enqueued with payload "resend = true"
And one "host.user.invite_resent" audit row is written against "entityType: host_user"
And the response is "200" with body "resent"
Scenario: Activated users cannot be re-invited
Given a "users" row with "password_hash" non-null AND "last_login_at" non-null
When a caller invokes "POST /api/host/team/:userId/resend-invite"
Then the response is "409" with body "user_already_activated"
And no "host_activation_tokens" row is rotated
And no email is enqueued
And no audit row is written
Scenario: Deactivated users cannot be re-invited
Given a "users" row in the deactivated state
When a caller invokes "POST /api/host/team/:userId/resend-invite"
Then the response is "410" with body "user_deactivated"
And no side effect occurs
Scenario: Throttle window is per-user, not per-actor
Given a successful resend for "user U" at time "T"
When any caller invokes "POST /api/host/team/:userId/resend-invite" for "user U" at "T + 5 minutes"
Then the response is "429" with body "resend_throttled"
And no token is rotated
And no email is enqueued
And no audit row is written
And the same outcome occurs whether the second caller is the original inviter or a different "host.users.manage" holder
Scenario: Permission gate
Given a caller without "host.users.manage"
When they invoke "POST /api/host/team/:userId/resend-invite"
Then the response is "403" and no side effect occurs
Scenario: Build-time guard against bypassing the throttle helper
Given a unit test asserting that "resend-invite" route handler imports the shared "throttleByUserId('host.user.invite_resent', 600)" helper
When a future commit removes that import
Then the test fails at CI time 
Host Portal: Team tab Resend Invitation row action with visibility gate and throttle tooltip

FOR PRODUCT OWNERS / STAKEHOLDERS
On the Host Portal "Team" tab, Host Admins manage the host org's user roster. Today the only row actions are Edit Role and Deactivate — there is no way to re-issue an activation invite when the original is lost, expired, or never acted on. This ticket adds a "Resend Invitation" row action that shows only for users who haven't activated yet, with a tooltip-surfaced throttle and a one-click confirm.
----------------------------------------
BACKGROUND
The Team tab list view currently reads "GET /api/host/team" and renders each row with an Edit Role and Deactivate action. The Portal Access column is computed client-side from "user.password_hash" and "user.last_login_at" and renders one of "Not invited", "Linked — pending first sign-in", "Linked — activated", or "Deactivated". The list does not currently call any endpoint other than the role-edit and deactivate endpoints.
WHAT THIS TICKET DOES
Adds a "Resend Invitation" row action on the Team tab that calls "POST /api/host/team/:userId/resend-invite" (see companion "Globus: host user invite resend endpoint with token rotation, throttle, and audit"). The action is rendered only when the row's computed Portal Access state is "Not invited" or "Linked — pending first sign-in" — it is omitted from the row menu for "Linked — activated" and "Deactivated" rows. The button enters a disabled state with a tooltip "You can resend again in m s" while the 10-minute throttle is in effect, sourced from the "retryAfterSec" field on the most recent "429" response (or from a successful response's local timestamp). On "200" the row's "lastInviteSentAt" is updated in place and a success toast confirms re-send. On "429", "409", "410", and "403" the UI surfaces the human-readable message from the response without re-throwing. References "Globus: host user invite resend endpoint with token rotation, throttle, and audit" for the endpoint contract.
ACCEPTANCE CRITERIA (Gherkin)
Feature: Team tab Resend Invitation row action
Scenario: Resend action is shown for users who have not activated
Given a row whose Portal Access state is "Not invited" or "Linked — pending first sign-in"
When the viewer renders the Team tab row menu
Then the "Resend Invitation" item is present and enabled
Scenario: Resend action is hidden for activated users
Given a row whose Portal Access state is "Linked — activated"
When the viewer renders the Team tab row menu
Then the "Resend Invitation" item is not rendered at all
Scenario: Resend action is hidden for deactivated users
Given a row whose Portal Access state is "Deactivated"
When the viewer renders the Team tab row menu
Then the "Resend Invitation" item is not rendered at all
And the row menu shows "Reactivate" instead
Scenario: Successful resend updates the row and confirms with a toast
Given a row in the "Not invited" state
When the viewer clicks "Resend Invitation" and the API returns "200 resent"
Then the row's "lastInviteSentAt" updates without a full refetch
And a success toast "Activation link re-sent" is shown
Scenario: Throttle is surfaced as a disabled state with a countdown tooltip
Given the API returns "429 resend_throttled"
When the row menu opens
Then the "Resend Invitation" item is disabled
And its tooltip reads "You can resend again in 9m 0s"
And the disabled state expires automatically when the countdown reaches zero without a page refresh
Scenario: Permission gate hides the action for non-admin host roles
Given the viewer's role is "host_hiring_manager", "host_finance", or "host_readonly"
When the Team tab renders
Then the "Resend Invitation" item is not rendered for any row
And the underlying endpoint is never called from this surface 
Globus: sponsor-side Host Management Users tab Resend Invitation row action

FOR PRODUCT OWNERS / STAKEHOLDERS
Sponsor-side back-office staff often need to re-issue a host user's activation invite on the host's behalf — typically when the host admin lost the email and reached out to their account manager. Today there's no UI to do this short of asking the host admin to log in themselves and click Resend. This ticket adds the same Resend Invitation row action on the sponsor-side Host Management Users tab so staff can perform the resend directly, with the same visibility rules and throttle as the Host Portal surface.
----------------------------------------
BACKGROUND
The sponsor-side Host Management screen is the staff back-office view of a single host org. Its Users tab lists every host user with a Portal Access column showing "Not invited" / "Linked — pending first sign-in" / "Linked — activated" / "Deactivated", and currently exposes Edit and Deactivate row actions only. The same "POST /api/host/team/:userId/resend-invite" endpoint introduced in "Globus: host user invite resend endpoint with token rotation, throttle, and audit" is the backing endpoint; the sponsor-side route handler resolves the host_user via the host-management capability, not via Host Portal session.
WHAT THIS TICKET DOES
Adds the "Resend Invitation" row action on the sponsor-side Host Management Users tab. Visibility rules are identical to the Host Portal Team tab (see "Host Portal: Team tab Resend Invitation row action with visibility gate and throttle tooltip") — hidden for "Linked — activated" and "Deactivated" rows. The throttle and tooltip behavior are identical. The audit row written by the endpoint resolves the actor to the sponsor-side staff user (not "system:unattended"), so the Audit Console at /docs/audit-console renders the resend with the staff user attribution. References "Globus: host user invite resend endpoint with token rotation, throttle, and audit" for the endpoint contract and "Host Portal: Team tab Resend Invitation row action with visibility gate and throttle tooltip" for the UX parity rules.
ACCEPTANCE CRITERIA (Gherkin)
Feature: Sponsor-side Host Management Users tab Resend Invitation
Scenario: Sponsor staff can resend for a host user in the "Not invited" state
Given a sponsor-side staff user with the host-management capability
And a host user row in the "Not invited" state on the Host Management Users tab
When the staff user clicks "Resend Invitation" outside the throttle window
Then the endpoint returns "200 resent"
And the audit row "host.user.invite_resent" is written with "userId" resolved to the sponsor-side staff user
And the host user receives the re-sent activation email
Scenario: Visibility parity with the Host Portal Team tab
Given a host user row in the "Linked — activated" state
When the staff user opens the row menu
Then "Resend Invitation" is not rendered
And the same hidden-state rule applies for "Deactivated" rows
Scenario: Throttle parity with the Host Portal Team tab
Given a successful sponsor-side resend at time "T"
When the staff user invokes Resend again at "T + 5 minutes"
Then the response is "429" with "retryAfterSec"
And the disabled tooltip on the sponsor-side button reads the same countdown as the Host Portal button would
Scenario: Permission gate
Given a sponsor-side staff user without the host-management capability
When they open the Host Management Users tab
Then the "Resend Invitation" row action is not rendered for any row
And direct calls to the endpoint are rejected with "403" 
## Cross-References
