Industry portal implementation (WS1)
Routes, validation, storage, and scoping behind the /portal surface
This page describes how the industry portal is implemented. For instructions aimed at industry users, see the industry portal user guide.
The portal is the low-friction side of ROUNDTABLE: an organization describes itself without gaining access to internal government notes. It is designed for capability intake and engagement discovery, not proposal submission or source selection.
Surface map
| Route | Purpose | Session required |
|---|---|---|
/portal/register | Organization + first INDUSTRY user creation. | No |
/login | Credentials sign-in; /dashboard then redirects by role. | No |
/portal | Overview: organization card, recent submissions, open calls, announcements. | Yes |
/portal/profile | Organization profile mutation (name, status, and country excluded). | Yes |
/portal/submissions | Submission list scoped to session.user.organizationId. | Yes |
/portal/submissions/new | Submission form; accepts ?callId= to preselect a call. | Yes |
/portal/calls | Public open-call feed. | No |
/portal/announcements | Public published-announcement feed. | No |
/portal/notifications | Notification feed and per-type settings. | Yes |
/account/password | Password change; signs the user out on success. | Yes |
Every authenticated portal page calls requireIndustrySession() and redirects to /login when the session is missing or carries no organizationId.
Registration
POST /api/portal/register validates every field, applies a five-attempt-per-IP-per-hour limiter, hashes the password with bcrypt, and creates the organization plus the first INDUSTRY user in a single transaction. The session issued afterwards carries the new organizationId, which scopes all later profile and submission operations.
The schema requires organization and contact names, a valid email, and a 14–128 character password containing uppercase, lowercase, digit, and special character. Duplicate organization names and emails return 409. Rate-limit and conflict events are audited.
Profile mutation
The profile route accepts organization type, CAGE, UEI, small-business flag, website, city, state, description (2,000 characters), and tech tags. It does not allow the caller to change the organization name, status, or country, or to address another organization's row — the target is taken from the session, not the request body.
Tags are trimmed, lowercased, and stored as comma-separated text: UUV, Autonomy becomes uuv,autonomy. Those tags later join submission tags during matching.
Submission intake
Submission types are CAPABILITY_STATEMENT, WHITE_PAPER, PITCH_MATERIAL, EVENT_SUMMARY, and PROFILE_UPDATE.
Validation on POST /api/portal/submissions:
- title 3–255 characters, summary 10–5,000 characters, tags up to 500 characters,
- optional
callIdmust reference a call that is still open, - attachment extension limited to
.pdf,.docx,.pptx,.txt, size limited to 15 MB, and magic bytes checked in addition to the extension.
The route stores the object privately under uploads/ with the server-side encryption selected by UPLOADS_S3_SSE (AES256 by default), creates the Submission with status SUBMITTED, and calls routeSubmission. routeSubmission replaces prior SubmissionMatch rows, ranks portfolio nodes, and creates SUBMISSION_MATCH notifications for matching government interest profiles; the submission_routed audit record includes scorer, match count, and notification count. See matching and notifications.
If routing fails after creation, the submission remains for later rerouting. That is intentional: intake should not lose a valid capability statement because matching is temporarily unavailable.
Reads and file download
GET /api/portal/submissions returns only submissions belonging to the session organization. GET /api/portal/submissions/[id]/file permits any government role, or an industry user from the owning organization, before streaming the object. A non-owner industry user who guesses another organization's ID gets 403 or a generic not-found rather than another organization's file.
Edge cases
- Missing or invalid session: 401.
- Valid industry session with no organization assignment: rejected by the route.
- Invalid field, closed call, bad extension, oversized file, or failed magic-byte check: 400.
- S3/MinIO misconfiguration: generic 500; inspect server logs and environment settings.
- Another organization's submission: forbidden even when the caller knows the ID.
- Source-selection information: prohibited by product policy regardless of what validation accepts.
Diagnosing a new environment
Submit without a file first, then with a small .txt, then with a PDF or DOCX. The progression separates:
- authentication or organization scoping failures,
- Prisma and matching failures,
- S3 endpoint, bucket, credential, or encryption failures,
- magic-byte or extension validation failures.
Do not "fix" a 500 by making the upload bucket public. The intended design is private storage plus an authenticated download route.
Boundary the server enforces
The portal exposes no organization-status mutation, no internal dialogue, no enterprise search, no command administration, and no exports. A client may hide a button for usability, but the server still enforces the boundary — see the API reference for the response returned to a signed-in but wrong-audience session.