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 | Trimmed, max 20, or empty string. |
uei | string | No | Trimmed, max 20, or empty string. |
smallBusiness | boolean | No | Defaults false. |
website | URL string | No | Max 255, or empty string. |
city | string | No | Max 100, or empty string. |
state | string | No | Max 50, 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. |
curl -X POST http://localhost:3000/api/portal/register \
-H 'content-type: application/json' \
-d '{"orgName":"Pelagic Dynamics","orgType":"COMPANY","smallBusiness":true,"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, 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,"smallBusiness":true,"nontraditional":true,"website":null,"city":"San Diego","state":"CA","country":"USA","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; updates only the session user's organization.
| Field | Type | Required? | Constraints |
|---|---|---|---|
orgType | enum | Yes | Six OrgType values. |
cageCode | string | No | Max 20 or empty. |
uei | string | No | Max 20 or empty. |
smallBusiness | boolean | Yes | Explicit boolean. |
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":"","smallBusiness":true,"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, 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.
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 | Own organization update | Profile-update audit. |
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.