Project ROUNDTABLE Docs
API reference

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/announcements

Example 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/calls

Example 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.

FieldTypeRequired?Constraints/default
orgNamestringYesTrimmed, 2–255.
orgTypeenumYesCOMPANY, ACADEMIA, FFRDC, UARC, FOREIGN_PARTNER, OTHER.
cageCodestringNoTrimmed, max 20, or empty string.
ueistringNoTrimmed, max 20, or empty string.
smallBusinessbooleanNoDefaults false.
websiteURL stringNoMax 255, or empty string.
citystringNoMax 100, or empty string.
statestringNoMax 50, or empty string.
descriptionstringNoMax 2,000, or empty string.
techTagsstringNoMax 500, or empty string.
contactNamestringYesTrimmed, 2–255.
emailemail stringYesLowercased, max 255.
passwordstringYes14–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.

FieldTypeRequired?Constraints
orgTypeenumYesSix OrgType values.
cageCodestringNoMax 20 or empty.
ueistringNoMax 20 or empty.
smallBusinessbooleanYesExplicit boolean.
websiteURL stringNoMax 255 or empty.
citystringNoMax 100 or empty.
statestringNoMax 50 or empty.
descriptionstringNoMax 2,000 or empty.
techTagsstringNoMax 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.

FieldTypeRequired?Constraints
typeenumYesCAPABILITY_STATEMENT, WHITE_PAPER, PITCH_MATERIAL, EVENT_SUMMARY, PROFILE_UPDATE.
titlestringYesTrimmed, 3–255.
summarystringYesTrimmed, 10–5,000.
techTagsstringNoMax 500 or empty.
callIdstringNoMax 64 or empty; referenced call must be eligible.
filefileNoPDF/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

EndpointPublic/sessionMain write or readAudit or side effect
GET /api/portal/announcementsPublicPublished feed readNone.
GET /api/portal/callsPublicOpen call feed readNone.
POST /api/portal/registerPublicOrganization and user createRegistration audit and rate-limit events.
GET /api/portal/profileIndustryOwn organization readNone.
PUT /api/portal/profileIndustryOwn organization updateProfile-update audit.
GET /api/portal/submissionsIndustryOwn submissions readNone.
POST /api/portal/submissionsIndustrySubmission and optional object createSubmission audit, matches, notifications.
GET /api/portal/submissions/[id]/fileAny government role or industry ownerPrivate object readNo 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.
  • type is an exact SubmissionType enum value.
  • title and summary satisfy their trim and length constraints.
  • techTags is 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

SymptomLikely statusInterpretation
No session401Sign in or inspect cookie/origin configuration.
Government account posts403Use the industry portal account.
Missing title/file field400Inspect multipart names and Zod constraints.
Closed call ID400Choose an open call or omit callId.
Invalid magic bytes400The content is not the claimed supported file type.
Storage failure500Check 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.