---
title: "Endpoint Reference"
slug: host-portal-endpoints
section: Portals
group: Host Portal
status: stable
description: "The current HostPortalPrototype `development` branch uses a React/Vite client and an Express backend."
last-updated: 2026-10-07
---

# Endpoint Reference

The current HostPortalPrototype `development` branch uses a React/Vite client and an Express backend. The active server composition mounts modular `/api/v2` facade domains, supplies a public health endpoint, and includes Auth0/JWT authorization code. This page supersedes an earlier endpoint reference that described a different, effectively unauthenticated route set. [1] [2]

> **Scope of this reference**
>
> This page records the code-mounted service boundaries and representative authentication behavior. It is not a replacement for the Hub’s cross-service Host Proxy contract, nor does it claim that every facade method has identical authorization or upstream behavior. Check the corresponding route module and Hub contract before integrating a specific endpoint. [2] [3]

## Current Authentication Boundary

The Host Portal has two distinct layers of access control:

| Layer | Code-backed behavior | Documentation consequence |
| --- | --- | --- |
| Client route protection | The React client reads the roles claim through the same shared, tolerant extractor the server uses (see the server role gates row). Ordinary portal routes require `ROLES.USER`; `/user-management` requires `ROLES.ADMIN`. [4] | The client is not an unauthenticated demo shell. |
| Server JWT validation | `middlewareJwtCheck` first verifies the Auth0 token, then authorizes the identity it carries: the token must include the `read:hostcompanyportal:basic` permission and a host identity. The host identity is read from the configured `JWT_PAYLOAD_HO_USER_ID_KEYNAME` claim, then `<namespace>/ho_user_id`, `<namespace>/globus_id`, `<namespace>/hanover_id`, `globus_id`, or `hanover_id`; Auth0’s `sub` is deliberately never a fallback. A missing, malformed or expired token is answered `401` with code `AUTHENTICATION_REQUIRED` (so the portal can prompt for sign-in); a verified token lacking the permission or a host identity gets a generic `403`. [5] | Server-side authorization is implemented; it must not be described as absent. |
| Server role gates | `requireAdminRole` checks the validated portal role claim (`HostPortalAdmin`, versus `HostPortalUser`) server-side, deny-by-default. The server and the client share one tolerant roles-claim extractor: it accepts the canonical `<namespace>/roles` key, the trailing-slash variant `<namespace>//roles`, or a bare `roles` key, as an array or a single string; values are matched exactly, and a missing or malformed claim yields no roles. It is applied to all `/api/v2/user-management` routes, the job-order authoring writes (`PATCH /:id/section`, `POST /:id/submit`, `POST /:id/restore`), and contact create/update/delete under `/api/v2/profile`. [5] [6] [7] | Hiding a button in the client is not a control; these routes refuse non-admins themselves. |
| Verified v2 module usage | The identity and job-order v2 routers explicitly apply `middlewareJwtCheck` to their mounted handlers. [6] [7] | Treat endpoint-level middleware as a route-level fact, not an inference from a UI route. |
| Rate limiting | Every route under `/api` is behind a shared `apiRateLimiter` (1000 requests per 15 minutes per client IP; over the limit returns `429` code `RATE_LIMITED`). [2] | A per-IP flood cap, not a substitute for authorization. |
| Non-production auth bypass | When `NODE_ENV` is not `production` and `DEV_AUTH_BYPASS=true`, JWT verification is skipped and a configured dev identity (admin and user roles) is injected. [5] | Local development only; it must never be enabled in a deployed environment. |
| Auth0 environment configuration | Server: `AUTH0_NAMESPACE` (falling back to `VITE_AUTH0_NAMESPACE`, then the default `https://aa-host-portal-app.com`; a trailing slash is stripped) is the custom-claim namespace for the roles and host-identity claims. When `AUTH0_SECRET` is set the token is verified as HS256 with that secret plus `AUTH0_AUDIENCE` and `AUTH0_ISSUER`; otherwise it is verified as RS256 through JWKS using `AUTH0_AUDIENCE` and `AUTH0_ISSUER_BASE_URL`. `JWT_PAYLOAD_HO_USER_ID_KEYNAME` overrides the host-identity claim name. Client: the build-time variable `VITE_AUTH0_NAMESPACE` (set to `https://aa-host-portal-app.com` in the CI deploy workflows) is passed to `hasPortalRole` in `ProtectedComponent` to read the roles claim. [5] | Client and server must resolve the same namespace, otherwise the roles claim is not found and the role gates find no roles. |
| Client Auth0 configuration | `AuthProvider.tsx` passes `VITE_AUTH0_DOMAIN`, `VITE_AUTH0_CLIENT_ID` and `VITE_AUTH0_AUDIENCE` (read from `import.meta.env`, so compiled into the client bundle at build time) to `Auth0Provider`, with refresh tokens on and an in-memory token cache; `VITE_AUTH0_NAMESPACE` (the roles-claim namespace) is read from `import.meta.env` too. The `deploy-development.yml` and `deploy-qa.yml` workflows supply them as build variables. | There is no in-code fallback for the domain, client ID or audience: an unset value is passed through as undefined and sign-in cannot be configured. The namespace does fall back to `https://aa-host-portal-app.com` (`DEFAULT_AUTH0_NAMESPACE` in `shared/const/roles.ts`) when unset or blank. A separate client-only bypass provider replaces Auth0 only in dev builds with `VITE_DEV_AUTH_BYPASS=true`. |
| Health probe | `/api/health` is registered before modular routes and is intentionally available without JWT middleware. [1] | Monitoring health is a narrowly documented public exception, not a statement that business routes are public. |
| CORS allow-list | `CORS_ALLOWED_ORIGINS` is an optional comma-separated list of allowed origins. When it is non-empty the server enables CORS with credentials for exactly those origins and rejects other origins (requests with no `Origin` header are allowed); when unset or empty no CORS middleware is installed. The CORS options set `maxAge: 600`, letting browsers cache the preflight answer for 10 minutes. | Entries are split on commas without trimming, so each must be an exact origin with no surrounding spaces. |
| Client API base URL | `client/src/lib/config.ts` sets `API_BASE_URL` from `window.__env.VITE_API_BASE_URL`, then from the build-time `import.meta.env.VITE_API_BASE_URL`, then to an empty string. `entrypoint.sh` writes `window.__env` into `env-config.js` at container start from the container's `VITE_API_BASE_URL` (and the Auth0 and PostHog client variables). | The API base can be changed per container without rebuilding the client; the build-time value is only the local-development fallback. |
| Hub service-to-service access | The Host Proxy pattern uses a server-side scoped API key plus host-org/user-attribution headers when a portal service calls the Hub. [3] | Do not conflate the browser’s Auth0 session with the Host Proxy API-key contract. |
| Hub host-org status gate | On Hub host-proxy routes the resolved host org must have a host type (otherwise `400` `NOT_HOST_ORG`). An org with status `suspended` or `terminated` gets `403` `HOST_ORG_INACTIVE` on every method; an org on `probation` gets `403` `HOST_ORG_PROBATION` on `POST`, `PUT`, `PATCH` and `DELETE` while reads pass. When `X-Host-Org-Id` is absent and the org cannot be resolved to exactly one from `X-Host-User-Email` (none found, or several), the answer is `400` `MISSING_HOST_ORG`. | These come from the Hub, not from the portal JWT check, so do not confuse them with the Auth0 `403` responses above. |

## Current API Surface

The Express server mounts the following current facade prefixes under `/api`. These modules are a concise map of the development branch’s service boundary; each facade delegates to a corresponding endpoint implementation and may have its own endpoint-level behavior. [2]

| Prefix | Domain | Current route-composition evidence |
| --- | --- | --- |
| `/api/health` | Service health | Health response registered in `server/index.ts` before route composition. [1] |
| `/api/version` | Build identity | `GET /api/version` serves the build identity. The server image sets the `GIT_SHA` environment variable from a build argument that defaults to `unknown`. `scripts/deploy-plain.sh` (the production deploy) uses it as a build-identity probe: after traffic moves to the new revision, the deploy is rolled back unless the response carries the exact commit just built. |
| `/api/v2/dashboard` | Host dashboard | Modular v2 facade registration. [2] |
| `/api/v2/user` | Caller identity and related hosts | `GET /me` and `GET /related-hosts` are explicitly JWT-gated. [2] [6] |
| `/api/v2/reservations` | Reservations | `GET /`, `GET /stats`, `GET /:id`, `GET /:id/hold-window`, `GET /extension-requests`, plus the four mutations `POST /`, `POST /:id/cancel`, `POST /:id/hire` and `POST /extension-requests`. Only those four mutations additionally run the trial-agreement guard (`requireFullAgreement`). An extension request requires `applicationId` (with `jobOrderId` optional). A `requestType` other than `reservation_extension` is refused with 422 before the Hub is called; the code is the prefix of the message text (`INVALID_REQUEST_TYPE: Invalid requestType ...`), not a separate `code` field. Create maps the Hub’s `MARKET_NOT_READY` or `APPLICANT_NOT_MARKET_READY` refusal to `422 MARKET_NOT_READY` and any other readiness refusal to `422 PARTICIPANT_NOT_READY`; hire and cancel map a readiness refusal to `422 PARTICIPANT_NOT_READY` as well. An extension request the Hub refuses with code `EXTENSIONS_NOT_ALLOWED` (or a message saying extensions are not allowed) is returned as `422 EXTENSIONS_NOT_ALLOWED`; a Hub `403` becomes `403 FORBIDDEN` (applicant not in the host org); any other Hub 4xx keeps its code and status (a `400` becomes `422`), defaulting to `EXTENSION_REQUEST_REJECTED`. On the Hub, `GET /api/partner/host/reservations/counts` (host role `admin`, `recruiter` or `readonly`; partner scope `reservations.read`) returns the organisation's reservation holds counted by status as `active`, `confirmed`, `expired`, `declined`, `cancelled` and `total`, optionally narrowed by `jobOrderId`, `name` (applicant first, last or full name) and `maxDaysRemaining` (holds expiring within that many days); it runs the expired-hold release sweep before counting. The remaining create rejection codes on `POST /api/v2/reservations` are `409 RESERVATION_CONFLICT` (upstream code containing `ALREADY_RESERVED` or `RESERVATION_CONFLICT`, or any other Hub `409`), `422 RESERVATION_LIMIT_REACHED` (upstream code containing `QUOTA` or `LIMIT`), `404 NOT_FOUND` (any other Hub `404`), `403 AGREEMENT_NOT_SIGNED` (upstream code naming an unsigned operating agreement), `403 RESERVATION_SCOPE_REFUSED` (any other Hub `401` or `403`) and `422 RESERVATION_VALIDATION_FAILED` for any other Hub 4xx. On the Hub, the reservation list and detail reads (`GET /api/partner/host/reservations` and `GET /api/partner/host/reservations/:id`) carry the same `offer` block as job-order reads; a staff host-level hold with no job order gets an all-null offer with `is_complete` false. |
| `/api/v2/participants` | Participant information | `GET /:applicationId`, `/:participantId/photo`, `/:participantId/step-media/:mediaId`, `/:applicationId/video-intro`, and an authenticated same-origin document relay `GET /:applicationId/documents/:documentId/download` that re-authorizes against the host’s visible participants on every call. When the Hub detail route 404s and the participant is resolved from a fallback feed (search, recommended, reservation, placement), the local documentId pre-check is skipped (those records carry no document list) and the Hub download call authorizes the document. Any document denial (failed pre-check, or Hub 403/404/422 on the download call) returns 404 `DOCUMENT_UNAVAILABLE` ("Document not found"), distinct from 404 `PARTICIPANT_UNAVAILABLE` ("Participant not found") for a participant the host cannot see. |
| `/api/v2/activity` | Activity feed | Modular v2 facade registration. [2] |
| `/api/v2/talent-pool` | Talent-pool views | `GET /filters`, `/recommended`, `/search`, and `POST /recommended/dismiss`. Dismiss accepts any of `matchId`, `recommendationId` or `participant_id` (the server injects `host_id` from `hostOrgId`; clients never send it) and returns 422 `VALIDATION_ERROR` if none is present. |
| `/api/v2/placements` | Placement workflow | Modular v2 facade registration. [2] |
| `/api/v2/host` | Host context and brand | `GET /programs`, `GET /context`, `GET /brands/:brandId` (the Globus brand record for the brand on the host’s agreement) and `GET /brands/:brandId/logo` (authenticated proxy for the official logo). |
| `/api/v2/profile` | Host profile (My Camp) | Reads and writes for overview, contacts, arrivals, cultural activities, activity sites (with address lookup), housing (including `POST /housing/:id/withdraw`; a new housing record must carry a `programId` that is a UUID, otherwise `400`, and on update a supplied `programId` must still be a UUID; the id is forwarded to the Hub housing-lead surface, which stores it), offer details (and position types), `PUT /seasonal-address`, `GET /completeness`, and `GET /context`. The overview read also carries a read-only `agreementSeasonDates` object (`startDate`, `endDate` and `source`, which is `season` or `agreement`) that the BFF derives from the host context's active agreements: the linked season's dates first (a running season is preferred, then the nearest upcoming one, then the most recently ended one), otherwise the agreement's own dates, and `null` when there is no active agreement or no date. Operating-agreement routes: `GET /operating-agreement/status`, `GET /operating-agreement/content`, `GET /operating-agreement/signed-pdf` (streams the signed PDF), `POST /operating-agreement/accept`, `POST /operating-agreement/dismiss` and `POST /operating-agreement/upgrade` (trial to full). The status response includes a `currentRevision` object if a revised version of the agreement exists. The `accept` call is now gated by agreement revisions: if a revised version of the agreement has been issued, the call must include the expected revision details. A missing or mismatched revision is refused with `409` and code `HOST_AGREEMENT_REVISION_REQUIRED` or `HOST_AGREEMENT_REVISION_STALE`; if the revision's legal terms are unavailable, the refusal is `409` with code `OPERATING_TERMS_UNAVAILABLE`. An already-accepted revision is returned as a success with `alreadyAccepted: true` and a `revisionAcceptance` object. After a successful new acceptance, `accept` does not trust the Hub’s echo: after the call it re-reads the gate-exempt status and only reports success when the Hub positively shows the agreement as signed (otherwise `502 ACCEPTANCE_STATE_UNVERIFIED`). The one reconciliation is a recorded-acceptance partial success: when the accept call itself fails with a `503` (`INTERNAL_ERROR`, the signed-document archive delay) or a `409` (`CONFLICT`, the signed copy cannot be produced), the service re-reads the gate-exempt status up to 3 times (750 ms apart by default) and, only if the status positively shows the intended agreement as signed, returns a normal success envelope (`alreadyAccepted: true`, `reconciledFromArchiveDelay: true`) without re-submitting the acceptance; if signed state cannot be confirmed, or a different agreement shows as signed, the original upstream error is re-thrown unchanged. Profile writes (overview, seasonal address, contacts, arrivals, cultural activities) do not trust a resolved Hub response either: a failure or malformed echo (`success: false`, no data object, or no identifiable persisted record) is surfaced as `502` with code `INVALID_HUB_RESPONSE` (or the Hub’s own code when it supplies one). Contact create/update carrying any field outside the supported contact fields is refused `422 VALIDATION_ERROR` (`UnsupportedContactFieldsError`) before it reaches the Hub. Only contact create/update/delete are admin-role gated; profile writes (including offer details, activity sites and arrivals) carry no trial guard. [2] The overview PATCH is verified rather than trusted: `numberOfJ1Counselors` and `numberOfJ1SupportStaff` must be `null` or a non-negative whole number (otherwise `422 VALIDATION_ERROR` naming the field, before the Hub is called); any submitted key missing from the Hub's echo was not persisted and fails `422` with code `FIELDS_NOT_SUPPORTED` (`OverviewFieldsNotPersistedError`); and the Staff Info fields (which include `uniform` and `requiredPayment`) and the EIN are re-read from the Hub after the write and must match. `uniform` and `requiredPayment` accept `null` to clear, which the Hub represents by omitting the key, so an absent key there counts as success. A saved uniform is compared through the Hub's own normalization (when `provided` is false the cost, currency and due date read back as `null`; an omitted currency is filled in by the Hub, so only a currency the caller asserted must match), and a saved required payment by its rule (`details` reads back as `null` when `required` is false or the details are blank). Housing create and update accept the Hub's `costIsFree` spelling of the free-housing flag as an alias of `isFreeHousing` (a conflicting pair is `400`); whenever the flag is supplied the Hub-bound lead body carries `costIsFree`, and `true` nulls the cost per bed and cost frequency. On the Hub, housing and housing-lead create and update (`POST` and `PATCH` on `/api/partner/host/housing` and `/api/partner/host/housing-leads`, host role `admin` or `recruiter`, scope `housing.write`) check the program scope (on update, only when the body supplies a `programId` or `seasonId`) and refuse with 400 `VALIDATION_ERROR` carrying `details.code` `HOUSING_SEASON_REQUIRES_PROGRAM` (a `seasonId` without a `programId`) or `HOUSING_PROGRAM_OUT_OF_SCOPE` (the host has no active agreement covering that program and season). Housing reads use the Hub's `GET /api/partner/host/housing-leads`; create and update use `POST` and `PATCH /api/partner/host/housing-leads`; withdrawal uses `POST /api/partner/host/housing-leads/:id/withdraw`; withdrawn leads are filtered out of the list. Facade errors: `502 HOUSING_LIST_MALFORMED` when the Hub list response has no recognizable list envelope (the client shows a load error instead of an empty table); `502 HOUSING_LEAD_WITHDRAWAL_UNVERIFIED` when the Hub accepted a withdrawal but neither its response nor a read of withdrawn leads shows the lead as `withdrawn`; `422 HOUSING_DELETE_NOOP` when, after `DELETE /api/v2/profile/housing/:id`, the record is still present in the re-read housing list. |
| `/api/v2/user-management` | Host user management | `GET/POST /users`, `PATCH/DELETE /users/:id`, all gated by `middlewareJwtCheck` plus `requireAdminRole` server-side (the client page is also admin-gated). A role must be one of `admin`, `hiring_manager`, `finance` or `read_only` (anything else is a 400); the recruiter role is no longer accepted. [2] [4] |
| `/api/v2/notifications` | Notifications | Modular v2 facade registration. [2] |
| `/api/v2/positions` | Positions | Modular v2 facade registration. [2] |
| `/api/v2/onboarding` | Host self-service onboarding | The route composition labels this facade as public onboarding. Its router mounts `GET /password-policy` (handled by the onboarding endpoint's `getPasswordPolicy`). Consult its route module for exact paths and validation. [2] |
| `/api/v2/job-orders` | Job-order lifecycle | List, pending, active, detail, applications, create, update, status, approve, reject and activate, plus the host authoring routes (see below) are mounted with JWT middleware. [7] |
| `/api/v2/company-documents` | Company documents | `GET /`, `GET /categories`, `GET /:id`, `GET /:id/download`, `POST /uploads/initiate`, `POST /uploads/finalize`, `POST /uploads/relay` (server-side transfer fallback, raw bytes), and `PATCH /:id/expiry`, which proxies the Hub’s `PATCH /api/partner/host/documents/:id/expiry` (the Hub owns authorization, org scoping and validation; it never touches review state). `PATCH /:id/status` and `DELETE /:id` are marked gaps that return 501. The Hub also serves `GET /api/partner/host/documents/:id/review-history` (deprecated alias `/api/host/documents/:id/review-history`, announced sunset 2026-10-31): host role `admin`, `recruiter` or `readonly` and partner scope `host.documents.view`; it returns `history` from `host_document_review_audit_logs` (entries carry `fromState`, `toState`, `action`, `reviewerEmail`, `reviewerName`, `notes` and `createdAt`) and 404 for a document that is not visible to the host. No route in this repo calls it. |
| `/api/v2/job-offer-contracts` | Job offer contract uploads (My Camp, Offer Details) | `GET /`, `GET /:id/download`, and `POST /uploads/initiate`, `/uploads/finalize`, `/uploads/relay`. Same Hub surface as company documents, scoped to the `job_offer_contract` document type; contract uploads carry no trial guard. |
| `/api/v2/documents` and `/api/v2/document-expiration` | Application document and document-expiration domains | Modular v2 facade registration. [2] |
| `/download` | Download helpers | Registered outside `/api`. [2] |

## Representative Route Detail

The current job-order module illustrates the v2 design: it mounts a direct facade on `/api/v2/job-orders`, applies `middlewareJwtCheck` to reads and mutations, and delegates handlers to `jobOrdersGlobusEndpoint`. [7]

The list routes (`/`, `/pending`, `/active`) require a `hostOrgId` query parameter and answer `400` without it. The list reads the Hub’s host-scoped `GET /api/partner/host/job-orders`, whose rows carry an `offer` object (the saved offer terms, plus `is_complete`, which is true only when `position_title`, `salary`, `salary_frequency`, `contract_start_date` and `contract_end_date` are all present, and `missing_fields`, which lists the absent ones); its optional `status` filter must be one of the canonical job-order statuses (any other value is a `400`, not silently mapped), and `archived` and `limit` are validated too. `/active` is the canonical `active` filter on the same listing, and `/pending` merges `submitted` and `in_review`. The offer `salary` is a two-decimal dollar string derived from `wage_amount`, which the Hub stores as an integer number of cents (1550 reads back as `15.50`). Separately, placed participants are exclusive per applicant, not per application: reservation create, the host direct placement create, the staff placement create and the hold-to-placement confirmation all refuse a participant who already has a live placement with 409 `CONFLICT` and `details.code` `PARTICIPANT_ALREADY_PLACED`. `details.reasonCode` is `PLACED_WITH_YOUR_ORGANIZATION` (the placement id, job order id and status are included) or `PLACED_WITH_ANOTHER_HOST` (nothing about the other placement is disclosed; a staff caller always receives this withholding form), and a cancelled, ended or completed placement releases the participant.

Hub-side reservation and placement guards (host roles `admin` or `recruiter`): `POST /api/partner/host/placements` with a `reservationId` refuses with `409 RESERVATION_PLACEMENT_MISMATCH` when the reservation's job order or application differs from the placement's, and with `409 STAFF_HOLD_NOT_CONFIRMABLE` for a staff host-level hold; the transaction rolls back and the hold is restored. `PATCH /api/partner/host/reservations/:id` on a staff-managed hold (`holdType` `staff_host`) returns `409 STAFF_HOLD_NOT_EDITABLE`. `POST /api/partner/host/reservations/:id/convert` returns `409 CONFLICT` with `details.code` `HOST_LEVEL_HOLD_NOT_CONVERTIBLE` unless the hold is an active host-level hold with no job order.

Staff approval of a host extension request (`POST /api/admin/extension-requests/:id/transition` with `action` `approve`, permission `extension_requests.manage`) extends the underlying reservation hold first and only then marks the request approved. When the hold cannot be advanced the route answers `409 RESERVATION_EXTENSION_FAILED` with `details.reason` set to one of `MISSING_APPLICATION_LINK` (the request has no `applicationId`), `MISSING_DURATION` (`extensionDurationDays` is not a positive integer; not checked for `program_extension` requests, which skip the hold extension), `NO_ACTIVE_HOLD` (no active reservation hold for that application at that host), `HOLD_UPDATE_FAILED` (the hold update threw) or `HOLD_NO_LONGER_ACTIVE` (the hold stopped being active mid-approval, for example expired by the auto-release sweeper). In every case the request is not marked approved.

On the Hub, `POST /api/partner/host/reservations` (host role `admin` or `recruiter`, scope `reservations.write`) answers with an `error.code` envelope. After body and hold-window validation, its pre-flight checks return 404 `APPLICATION_NOT_FOUND` when `applicationId` does not exist, 400 `APPLICATION_APPLICANT_MISMATCH` (`details` has `applicationId`, `expectedApplicantId` and `providedApplicantId`) when the application belongs to a different applicant than `applicantId`, and 404 `JOB_ORDER_NOT_FOUND_FOR_PARTNER` when the job order does not exist or belongs to another host organisation.

| Method | Path | Auth behavior confirmed in code | Handler domain |
| --- | --- | --- | --- |
| `GET` | `/api/v2/job-orders` | `middlewareJwtCheck` | Job orders (host-scoped list) |
| `GET` | `/api/v2/job-orders/pending` | `middlewareJwtCheck` | Pending job orders |
| `GET` | `/api/v2/job-orders/active` | `middlewareJwtCheck` | Active job orders |
| `GET` | `/api/v2/job-orders/:id` | `middlewareJwtCheck` | Job-order detail |
| `GET` | `/api/v2/job-orders/:id/applications` | `middlewareJwtCheck` | Job-order applications |
| `GET` | `/api/v2/job-orders/:id/workflow-schema` | `middlewareJwtCheck` (open to view-only hosts) | Published host job-order form |
| `GET` | `/api/v2/job-orders/:id/decisions` | `middlewareJwtCheck` (open to view-only hosts) | Review decision history |
| `PATCH` | `/api/v2/job-orders/:id/section` | `middlewareJwtCheck` + `requireAdminRole` | Save one authoring section (draft only) |
| `POST` | `/api/v2/job-orders/:id/submit` | `middlewareJwtCheck` + `requireAdminRole` | Submit for review |
| `POST` | `/api/v2/job-orders/:id/restore` | `middlewareJwtCheck` + `requireAdminRole` | Restore an archived draft |
| `POST` | `/api/v2/job-orders` | `middlewareJwtCheck` | Legacy create (proxies the admin create, which the portal credential cannot reach; not an authoring path) |
| `PATCH` | `/api/v2/job-orders/:id` | `middlewareJwtCheck` | Update job order |
| `PATCH` | `/api/v2/job-orders/:id/status` | `middlewareJwtCheck` | Update job-order status |
| `POST` | `/api/v2/job-orders/:id/approve`, `/reject`, `/activate` | `middlewareJwtCheck` | Approval and activation actions |

The authoring routes are all scoped to the signed-in host (`hostOrgId` is required, never inferred). Hub failures are relayed with their machine-readable code and per-field `details.fields`: a per-field validation refusal becomes 422, and a row that is no longer a draft stays a 409 carrying its own code, never presented as a permissions problem.

**Hub host-side mutations (audit registry).** The Hub's mutation registry (`server/middleware/mutationRegistry.ts`) includes these host writes (not an exhaustive list of the registry). Auth type `portal`, no staff permission key, category `host_portal`: `PATCH` and `DELETE /api/partner/host/reservations/:id` (`host.reservation.update`, `host.reservation.delete`), `POST /api/partner/host/addresses` with `PATCH` and `DELETE /api/partner/host/addresses/:id`, `POST /api/partner/host/activity-sites` with `PATCH` and `DELETE /api/partner/host/activity-sites/:id`, `POST /api/partner/host/contacts` with `PATCH` and `DELETE /api/partner/host/contacts/:id`, and `POST /api/partner/host/cultural-activities` with `PATCH` and `DELETE /api/partner/host/cultural-activities/:id`. `POST /api/partner/host/extension-requests` is auth type `system`, no permission key, audit action `extension_request.created`. When a host extension request is created, the create handler in `server/routes/hostPortal/extensions.ts` calls `notifyExtensionRequestSubmitted` to notify the host's account manager (with a role route fallback); it sends one email per recipient, is not awaited so a slow mail provider does not hold up the Host Portal's request, and never throws. Host documents (auth type `system`) require `host.documents.manage`: `POST /api/partner/host/documents/initiate-upload`, `POST .../finalize-upload`, `POST .../:id/review`, `PATCH .../:id/expiry` and `DELETE .../:id`. The legacy `/api/host/documents/*` paths (initiate-upload, finalize-upload, review, delete) are registered as deprecated aliases of the same handlers; the registry comment gives a sunset of 2026-10-31.

**Hub-side host routes.** These `/api/partner/host/*` routes are served by the Hub (BackOfficePrototype), not by this portal's server.

- `GET /api/partner/host/participant-profile/:participantId` serves a participant profile PDF (`200`, `application/pdf`, inline, `Cache-Control: no-store`, rendered from the current record at fetch time). It takes no session or API key: access is a signed link carrying `exp` and `sig` query parameters (HMAC-SHA256 over the participant id and `exp`, secret `PARTICIPANT_PROFILE_PDF_SECRET`; default lifetime 24 hours, set with `PARTICIPANT_PROFILE_PDF_TTL_SECONDS` and clamped to 60 seconds to 7 days). A missing or invalid signature is `401 INVALID_SIGNATURE`; a validly signed link past `exp` is `410 URL_EXPIRED`; an unknown participant is `404 PARTICIPANT_NOT_FOUND`; a render failure is `500 RENDER_FAILED`.
- `GET /api/partner/host/agreements/summary` returns `hasActiveAgreement`, `activeAgreementCount`, `totalAgreementCount`, a per-program `programs` breakdown (with seasons) and the `agreements` list for the resolved host org; host roles `admin`, `recruiter` or `readonly`. `GET /api/partner/host/agreements` and `GET /api/partner/host/agreements/:id` are the list and detail companions.
- `GET /api/partner/host/documents/:id/review-history` (alias `/api/host/documents/:id/review-history`) returns `history`, the review audit log of one host document; host roles `admin`, `recruiter` or `readonly` plus the documents view scope. `404` when the document is not in the host's org or is not visible to the host.
- `GET` and `POST /api/partner/host/housing`, and `GET`, `PATCH` and `DELETE /api/partner/host/housing/:id`, are deprecated compatibility routes over housing leads: every response carries `Deprecation: true` and a `Link` header naming `/api/partner/host/housing-leads` as the successor. Reads need host role `admin`, `recruiter` or `readonly` and scope `housing.read`; writes need `admin` or `recruiter` and `housing.write`. `DELETE` withdraws rather than deletes (status `withdrawn`, `204`) and withdrawn leads are hidden from these reads. `PATCH` on a `withdrawn` or `rejected` lead is refused with `INVALID_STATE`. A `seasonId` without a `programId` is refused with `HOUSING_SEASON_REQUIRES_PROGRAM`, a `programId` outside the host's active agreement scope with `HOUSING_PROGRAM_OUT_OF_SCOPE`, and a changed value for a field this surface cannot persist (including status-derived fields) with `HOUSING_FIELD_NOT_WRITABLE`.
- `GET` and `PATCH /api/partner/host/settings` read and update the host org's portal settings row (created on first read): `emailNotificationsEnabled`, `activityDigestFrequency` (`daily`, `weekly`, `monthly` or `never`), `privacyPolicyAcceptedAt`, `dataProcessingConsentAt`, `locale`, `timezone`. `GET` needs capability `host.org.view` and scope `settings.read`; `PATCH` needs `host.org.manage` and `settings.write`. Responses include a `twoFactorNote` stating that 2FA is managed in Auth0.
- `GET /api/partner/host/programs/:programId/seasons` (host roles `admin`, `recruiter` or `readonly`) returns the seasons of one program covered by the host's active agreements. A program no active agreement covers is refused `403 SCOPE_VIOLATION` ("No active agreement for this program") with `details.scopeEmpty` and `details.scopeEmptyReason`: `no_active_agreement` or `agreement_scope_missing_programs` when the host has no authorized programs at all, `program_not_in_agreement_scope` when it has scope but not for this program. If any agreement covers the program without naming a season, every season of the program is returned; otherwise the named seasons across agreements are combined.
- `POST /api/partner-orgs/:id/transition` (staff route, API key or session with `partners.read` plus permission `partners.manage`) moves a host organisation along the lifecycle `pending_verification`, `active`, `probation`, `suspended`, `terminated`. The strict body is `targetState`, `reason`, `reasonCategory`, `notes` and `confirmation_token`; `probation`, `suspended` and `terminated` need a `reason` of at least 10 characters, and `suspended` or `terminated` also need a `reasonCategory` (otherwise `400 VALIDATION_ERROR`); an unknown organisation is `404 NOT_FOUND`. `pending_verification` to `active` is not served here (it goes through `POST /api/sponsor/host-orgs/:orgId/activate`, the Host Activation Gate) and is refused, as is any other edge the lifecycle engine rejects, with `422 INVALID_LIFECYCLE_TRANSITION`. `GET /api/partner-orgs/:id/lifecycle` (permission `partners.view`) returns `hostOrgId`, `currentState`, `terminatedAt` and the `audit` history. Host lifecycle errors map to these statuses (before BackOfficePrototype#2677 they all fell through to a `500`): `INVALID_LIFECYCLE_TRANSITION` is 422; `PLACEMENT_FROZEN_HOST_SUSPENDED` is 409 (a placement mutation on a placement frozen by the suspend or terminate cascade; reads stay unblocked); `DOCUMENT_LOCKED_HOST_SUSPENDED` is 409 (a host-document edit, replace or delete when the document is locked or the host organisation is suspended or terminated); `TERMINATION_CONFIRMATION_REQUIRED` is 400 (the status-change route, when `newStatus` is `terminated` and no `confirmation_token` is on the request body); `TERMINATION_IS_PERMANENT` is 422 (an attempt to move an organisation off `terminated`, which is a sink state).

**Hosts with no data yet.** When the Hub answers `404` for a host org that has no data yet, three portal facades return an empty result instead of failing: the activity log returns an empty page (`data` empty, `total` 0, honouring the requested `page` and `pageSize`, defaults 1 and 20), the reservation count returns `activeCount` 0, and the job-offer-contracts list returns an empty array. Any non-404 error still propagates unchanged.

**Globus client configuration.** The server resolves its Globus connection in `server/services/globus/index.ts`. When `NODE_ENV` or `ENVIRONMENT` is `production` it reads `GLOBUS_BASE_URL_PRODUCTION`, `GLOBUS_API_KEY_PRODUCTION` and `GLOBUS_WEBHOOK_SECRET_PRODUCTION`, each falling back to the unsuffixed `GLOBUS_BASE_URL`, `GLOBUS_API_KEY` and `GLOBUS_WEBHOOK_SECRET`; in any other environment only the unsuffixed variables are read. The base URL defaults to `https://development.globus.allianceabroad.com` when neither is set. With no API key the Globus client is not created and a warning is logged.

**Hub inbound host receivers (BackOfficePrototype).** `POST /api/webhooks/globus` receives `host.*` events from Globus, directly or through the n8n Host Domain relay. When `GLOBUS_WEBHOOK_SECRET` is set the request must carry `X-Globus-Signature`, an HMAC-SHA256 of the raw body; a missing header or a mismatch is `401`, and with no secret configured the route answers `503` outside `NODE_ENV=test`. The envelope must carry `eventType`, `eventId` and a `payload` containing `hostOrgId` (each missing field is `400`, as is a malformed envelope). Non-`host.*` and unknown `host.*` events are acknowledged `200` with `recognized: false`. A recognized event creates a host notification for `hostOrgId` and returns `200` with `notificationsCreated`; a persistence failure is `503`, and an `eventId` already seen within 10 minutes returns `200` with `duplicate: true`. `POST /api/integrations/host-portal/events` is the Host Portal callback for `host.user.first_login` (strict body `eventType` fixed to `host.user.first_login`, `hostUserId`, `hostOrgId`, `email`). It sits behind the portal sync gate (`503` when `PORTAL_SYNC_DISABLED=true`) and integration auth with scope `integrations.portal`. If the user is not an active, accepted member of that organization with a linked Auth0 identity and a matching email, it answers `404` `HOST_USER_NOT_FOUND`; otherwise it emits the first-login event, writes an audit log entry and returns success.

## User Experience Surface

The client provides the user-facing composition over these API domains. It mounts dashboard, applications/reserved participants, talent pool, participants, profile/business/camp views, housing, contacts and addresses, company and arrival details, offers, cultural activities, documents, resources, notifications, settings, job offers, activities, and user management. The job surfaces (`/jobs`, `/job-approvals`, `/job-orders`, `/job-order-approvals`) are hidden for now — deliberately not registered, so they fall through to Not Found — and the Transportation & Safety page has been retired (no route is registered for it). [4]

Several remaining job-oriented client routes are deliberately redirected when the selected program is **Camp**. This is an implementation-specific UI condition, not a general statement that all host capabilities are unavailable for camps. [4]

## Retired Reference Claims

The prior version of this page stated that the deployed Host Portal had no authentication middleware, used plaintext login/hard-coded demo identity, mounted a different legacy `/api/jobs` and `/api/h2b` route set as canonical, and had only a log-only inbound Globus webhook. Those statements are not safe current-reference claims for the development branch reviewed here. The current code contains Auth0 client-route protection, a JWT permission middleware, and modular v2 facade registration. [1] [2] [4] [5]

This change deliberately does **not** erase host lifecycle, agreement, document, placement, or governance specifications elsewhere in Nexus. Those remain in [Host Portal Governance](/docs/host-governance), [Portal Integration Contract](/docs/integration-contract), and the [Globus Portal API Reference](/docs/globus-portal-api); the purpose of this page is to provide the code-current Host Portal service map.

## References

[1]: https://github.com/Alliance-Strategies/HostPortalPrototype/blob/development/server/index.ts "HostPortalPrototype development server entry point"
[2]: https://github.com/Alliance-Strategies/HostPortalPrototype/blob/development/server/routes-modular.ts "HostPortalPrototype modular route composition"
[3]: /docs/integration-contract "Portal Integration Contract"
[4]: https://github.com/Alliance-Strategies/HostPortalPrototype/blob/development/client/src/App.tsx "HostPortalPrototype development client route registry"
[5]: https://github.com/Alliance-Strategies/HostPortalPrototype/blob/development/server/middleware/jwt-auth.ts "HostPortalPrototype JWT middleware"
[6]: https://github.com/Alliance-Strategies/HostPortalPrototype/blob/development/server/routes/v2/identity.ts "HostPortalPrototype v2 identity router"
[7]: https://github.com/Alliance-Strategies/HostPortalPrototype/blob/development/server/routes/v2/job-orders.ts "HostPortalPrototype v2 job-orders router"
