Portal API
Public feeds, industry registration, profiles, submissions, and private file access
Portal routes serve industry users and public discovery. A signed-in industry session carries organizationId; every private read and write is scoped to that value. Public announcement and call feeds intentionally have no session requirement.
GET /api/portal/announcements
Auth: Public. Success: 200.
curl http://localhost:3000/api/portal/announcementsExample shape:
{"data":[{"id":"clx-announcement","title":"ROUNDTABLE pilot update","body":"Industry engagement dates are available.","published":true,"createdAt":"2026-01-15T12:00:00.000Z","author":{"name":"CDR Dana Whitfield"}}]}The route returns published announcements ordered newest first. There are no request fields. Unexpected database failures return { "error": "An error occurred." } with 500.
GET /api/portal/calls
Auth: Public. Success: 200.
curl http://localhost:3000/api/portal/callsExample shape:
{"data":[{"id":"clx-call","title":"Maritime AI for Predictive Maintenance","description":"Share approaches for naval maintenance analytics.","techTags":"ai/ml,predictive maintenance","status":"OPEN","closesAt":"2026-04-30T00:00:00.000Z"}]}The route selects open calls and sorts them by deadline using sortCallsByDeadline. A database failure returns 500. The feed does not let a caller create, close, or edit a call.
POST /api/portal/register
Auth: Public. Success: 201. Rate limit: five attempts per IP per hour.
| Field | Type | Required? | Constraints/default |
|---|---|---|---|
orgName | string | Yes | Trimmed, 2–255. |
orgType | enum | Yes | COMPANY, ACADEMIA, FFRDC, UARC, FOREIGN_PARTNER, OTHER. |
cageCode | string | No | Exactly 5 alphanumeric characters, or empty string. |
uei | string | No | Trimmed, max 20, or empty string. |
dunsNumber | string | No | Exactly 9 digits, or empty string. |
naicsCodes | string | No | Comma/space-separated six-digit codes, at most 10, or empty string. |
addressLine1 / addressLine2 | string | No | Max 255, or empty string. |
city | string | No | Max 100, or empty string. |
state | string | No | Max 50, or empty string. |
postalCode | string | No | 3–20 characters (letters, digits, space, hyphen), or empty string. |
setAsides | enum array | No | Up to 6 of SMALL_BUSINESS, EIGHT_A, WOSB, EDWOSB, SDVOSB, HUBZONE; defaults []. |
pocName / pocEmail / pocPhone | string | No | Name max 255; valid email; phone 7–25 permitted characters. |
contractsPocName / contractsPocEmail / contractsPocPhone | string | No | Same constraints as the primary POC fields. |
employeeCount | digit string | No | Whole number up to 2,000,000. |
annualRevenueUsd | digit string | No | Whole dollars, up to 12 digits. |
businessStartYear | digit string | No | 1800 through the current year. |
samRegistrationStatus | enum | No | ACTIVE, PENDING, EXPIRED, NOT_REGISTERED. |
samExpirationDate | date string | No | YYYY-MM-DD. |
website | URL string | No | Max 255, or empty string. |
description | string | No | Max 2,000, or empty string. |
techTags | string | No | Max 500, or empty string. |
contactName | string | Yes | Trimmed, 2–255. |
email | email string | Yes | Lowercased, max 255. |
password | string | Yes | 14–128; uppercase, lowercase, digit, special character. |
There is no smallBusiness field: the stored smallBusiness flag is derived — it is true when setAsides contains any SBA set-aside category.
curl -X POST http://localhost:3000/api/portal/register \
-H 'content-type: application/json' \
-d '{"orgName":"Pelagic Dynamics","orgType":"COMPANY","setAsides":["SMALL_BUSINESS"],"naicsCodes":"334511","city":"San Diego","state":"CA","description":"Autonomous undersea vehicles","techTags":"uuv,autonomy,acoustics","contactName":"Pelagic Demo Contact","email":"demo-pelagic@example.com","password":"Roundtable2026!Demo"}'Success:
{"data":{"organizationId":"clx-organization"}}The transaction creates an organization and first INDUSTRY user (with industryRole: INDUSTRY_ADMIN), hashes the password, audits the registration, and returns the organization ID. Errors: 400 invalid data, 409 duplicate organization/email, 429 rate limit, 500 unexpected failure.
GET /api/portal/profile
Auth: INDUSTRY. Success: 200.
curl http://localhost:3000/api/portal/profile -H 'cookie: next-auth.session-token=SESSION'Example shape:
{"data":{"id":"clx-org","name":"Pelagic Dynamics","orgType":"COMPANY","status":"ACTIVE","cageCode":null,"uei":null,"dunsNumber":null,"naicsCodes":["334511"],"setAsides":["SMALL_BUSINESS"],"smallBusiness":true,"nontraditional":true,"website":null,"addressLine1":null,"addressLine2":null,"city":"San Diego","state":"CA","postalCode":null,"country":"USA","pocName":null,"pocEmail":null,"pocPhone":null,"contractsPocName":null,"contractsPocEmail":null,"contractsPocPhone":null,"employeeCount":null,"annualRevenueUsd":null,"businessStartYear":null,"samRegistrationStatus":null,"samExpirationDate":null,"description":"Autonomous undersea vehicles","techTags":"uuv,autonomy,acoustics"}}No request fields. 401 means no industry session; 404 means the session has no organization or the organization cannot be found; 500 is unexpected failure.
PUT /api/portal/profile
Auth: INDUSTRY with industryRole: INDUSTRY_ADMIN; updates only the session user's organization. An ordinary INDUSTRY_USER receives 403.
| Field | Type | Required? | Constraints |
|---|---|---|---|
orgType | enum | Yes | Six OrgType values. |
cageCode | string | No | Five alphanumeric characters or empty. |
uei | string | No | Max 20 or empty. |
dunsNumber | string | No | Nine digits or empty. |
naicsCodes | string | No | Up to 10 six-digit codes or empty. |
addressLine1 / addressLine2 / postalCode | string | No | Same constraints as registration. |
setAsides | enum array | No | Up to 6 set-aside values; the stored smallBusiness flag is re-derived from this list on every save. |
| POC and contracts-POC fields | string | No | Same constraints as registration. |
employeeCount / annualRevenueUsd / businessStartYear | digit string | No | Same constraints as registration. |
samRegistrationStatus / samExpirationDate | enum / date | No | Same constraints as registration. |
website | URL string | No | Max 255 or empty. |
city | string | No | Max 100 or empty. |
state | string | No | Max 50 or empty. |
description | string | No | Max 2,000 or empty. |
techTags | string | No | Max 500 or empty. |
curl -X PUT http://localhost:3000/api/portal/profile \
-H 'content-type: application/json' -H 'cookie: next-auth.session-token=SESSION' \
-d '{"orgType":"COMPANY","cageCode":"","uei":"","setAsides":["SMALL_BUSINESS"],"naicsCodes":"334511","website":"","city":"San Diego","state":"CA","description":"Updated UUV capability","techTags":"uuv,autonomy"}'Success returns { "data": <updated organization> }. Errors: 401, 400 invalid profile data, 404 missing organization, 500. The route normalizes tags and audits the update.
GET /api/portal/submissions
Auth: INDUSTRY; own organization only.
curl http://localhost:3000/api/portal/submissions -H 'cookie: next-auth.session-token=SESSION'Example shape:
{"data":[{"id":"clx-submission","type":"WHITE_PAPER","status":"ROUTED","title":"Autonomous UUV navigation","summary":"...","fileName":"uuv-paper.pdf","techTags":"uuv,autonomy","callId":null,"createdAt":"2026-02-01T12:00:00.000Z","updatedAt":"2026-02-01T12:00:01.000Z"}]}No request fields. Errors: 401 or 500.
POST /api/portal/submissions
Auth: INDUSTRY. Content type: multipart/form-data. The text fields are validated with the submission schema.
| Field | Type | Required? | Constraints |
|---|---|---|---|
type | enum | Yes | CAPABILITY_STATEMENT, WHITE_PAPER, PITCH_MATERIAL, EVENT_SUMMARY, PROFILE_UPDATE. |
title | string | Yes | Trimmed, 3–255. |
summary | string | Yes | Trimmed, 10–5,000. |
techTags | string | No | Max 500 or empty. |
callId | string | No | Max 64 or empty; referenced call must be eligible. |
file | file | No | PDF/DOCX/PPTX/TXT, max 15 MB, valid magic bytes. |
curl -X POST http://localhost:3000/api/portal/submissions \
-H 'cookie: next-auth.session-token=SESSION' \
-F 'type=WHITE_PAPER' -F 'title=Autonomous UUV navigation' \
-F 'summary=Pelagic Dynamics describes an acoustic navigation approach for UUV operations.' \
-F 'techTags=uuv,autonomy,acoustics' -F 'callId=clx-call' \
-F 'file=@uuv-paper.pdf'Success is 201 with { "data": { "id": "clx-submission" } }. Errors: 401, 400 invalid fields, invalid file type, oversize, bad magic bytes, invalid call, 413 when the request body exceeds 15 MB plus a 64 KB field allowance (checked against Content-Length and again by a streaming byte counter, before the multipart body is parsed), 429 above twenty submissions per user per hour, 500 storage/database failure. The route creates the row, calls routeSubmission, writes matches/notifications/audit, and never returns a public object URL.
GET /api/portal/submissions/[id]/file
Auth: Any government role, or an INDUSTRY user of the owning organization. Success: 200 file stream.
curl -OJ http://localhost:3000/api/portal/submissions/clx-submission/file \
-H 'cookie: next-auth.session-token=SESSION'The route checks session, submission ownership, file metadata, and S3 retrieval. It returns 401 without a session, 404 for no record/no file/storage not-found, 403 for another organization's record, and 500 for an unexpected storage error.
PATCH /api/portal/users/[id]
Auth: INDUSTRY with industryRole: INDUSTRY_ADMIN; the target must be an INDUSTRY user of the same organization.
| Field | Type | Required? | Constraints |
|---|---|---|---|
industryRole | enum | Yes | INDUSTRY_ADMIN or INDUSTRY_USER. |
curl -X PATCH http://localhost:3000/api/portal/users/clx-user \
-H 'content-type: application/json' -H 'cookie: next-auth.session-token=SESSION' \
-d '{"industryRole":"INDUSTRY_ADMIN"}'Success returns { "data": { "id": "clx-user", "email": "...", "industryRole": "INDUSTRY_ADMIN" } }. Two guarded cases return 400 with a generic message and write an authorization_failure audit event: self-demotion, and demoting the organization's last administrator (checked inside a serializable transaction). Other errors: 400 invalid body, 401, 403 non-admin or no organization, 404 target outside the organization, 500. No session bump is needed — the JWT callback refreshes industryRole from the database.
Portal endpoint decision table
| Endpoint | Public/session | Main write or read | Audit or side effect |
|---|---|---|---|
GET /api/portal/announcements | Public | Published feed read | None. |
GET /api/portal/calls | Public | Open call feed read | None. |
POST /api/portal/register | Public | Organization and user create | Registration audit and rate-limit events. |
GET /api/portal/profile | Industry | Own organization read | None. |
PUT /api/portal/profile | Industry (organization admin) | Own organization update | Profile-update audit. |
PATCH /api/portal/users/[id] | Industry (organization admin) | Same-organization role change | Role-change audit; last-admin and self-demotion guards. |
GET /api/portal/submissions | Industry | Own submissions read | None. |
POST /api/portal/submissions | Industry | Submission and optional object create | Submission audit, matches, notifications. |
GET /api/portal/submissions/[id]/file | Any government role or industry owner | Private object read | No public URL creation. |
Why the API repeats ownership checks
The browser can send any submission ID or organization ID. The server does not assume a link came from a trusted page. It obtains the organization ID from the session, applies it to Prisma's where clause, and only then serializes data. This is why a UI-only “My submissions” filter is not sufficient security.
Multipart debugging
Inspect the request as multipart rather than JSON. The route reads type, title, summary, techTags, and callId from FormData, then reads a file part. A request with Content-Type: application/json cannot supply a binary file to this handler. A request with a zero-byte file is treated as no file; a nonempty file must pass all validators.
Portal request checklist
Before sending a submission request, confirm all of the following:
- The session is an industry session and belongs to the intended organization.
- The request is
multipart/form-data; do not manually set a boundary header. typeis an exactSubmissionTypeenum value.titleandsummarysatisfy their trim and length constraints.techTagsis a comma-separated string rather than a JSON array.callId, when present, identifies an open call.- The file extension matches the file signature and the file is no larger than 15 MB.
Example response interpretation
{
"data": {
"id": "cmexample-submission"
}
}The short response is intentional. The client can fetch the newly created row through the authenticated submissions list, where organization scoping is re-applied. It should not assume the returned ID grants access to a government detail route or to another organization's records.
Failure triage
| Symptom | Likely status | Interpretation |
|---|---|---|
| No session | 401 | Sign in or inspect cookie/origin configuration. |
| Government account posts | 403 | Use the industry portal account. |
| Missing title/file field | 400 | Inspect multipart names and Zod constraints. |
| Closed call ID | 400 | Choose an open call or omit callId. |
| Invalid magic bytes | 400 | The content is not the claimed supported file type. |
| Storage failure | 500 | Check S3/MinIO configuration and server logs; do not make the bucket public. |
Status transitions
Creation starts at SUBMITTED. A routing match moves it to ROUTED; a government reviewer may later move it to REVIEWED; archival is a separate lifecycle decision. A failure in routing is logged but does not roll back the already-created submission, so operators can use reroute later.
