Security & data handling

How Helmspur handles your uploaded IFC files.

A review-grade summary of how Helmspur handles uploaded IFC files — written so an infosec team can read it against the source tree in one sitting.

Every claim below is grounded in the code: the upload pipeline, the Prisma / Postgres data model, the auth surface, and the platform's transport and framing headers. If something here doesn't match what you see in the repo, that's a bug — please flag it.

See our current readiness posture by framework.

See who else handles the data — Helmspur's current sub-processor list.

Read the security FAQ for common vendor-review questions.

Read the product FAQ for EPCI procurement questions.

Read Helmspur's incident-response posture.

Storage

Where uploaded IFC files are stored.

The IFC upload pipeline runs at POST /api/projects/[projectId]/revisions (handler in src/app/api/projects/[projectId]/revisions/route.ts). For each accepted upload the route writes a single Revision row — the schema is in prisma/schema/projects.prisma.

Persisted columns include the original filename, byte size, element count, the disciplineCounts histogram, the per-element JSON elementData (expressID, GUID, discipline, AABB, triangles), the uploading user id, and the upload timestamp.

No raw .ifc bytes are stored server-side. The parser parseIfcBuffer() reads the upload in memory from req.formData(), extracts the geometry, and lets the buffer drop out of scope. The raw file is never written to disk or to object storage.

PersistsWhat it is
originalFilenameThe filename string the uploader sent.
fileSizeByte size at upload time — no media data.
elementCountTotal parseable elements found.
disciplineCountsPer-discipline histogram (JSON).
elementDataAABB-discipline-expressID-GUID-triangles JSON.
uploadedByIdThe signed-in user; the upload is accountable.
.ifc bytesNot stored. Parsed in memory and dropped — only the derived JSON above is kept.

Storage backs onto the Postgres provisioned by the platform — declared as [env] DATABASE_URL = { source = "rdbms" } in polsia.toml and consumed through Prisma (prisma/schema/_base.prisma, provider postgresql).

Project rows are linked to Revision and Clash with onDelete: Cascade — when a project is closed the joined rows are removed by the FK, not by a hand-rolled script.

Transport & at-rest

Encryption in transit and at rest.

In transit — the platform serves HTTPS-only. The Content-Security-Policy emitted per request in src/lib/csp.ts and applied by proxy.ts sets upgrade-insecure-requests, locks frame-ancestors 'none', and uses a per-request script-src nonce. The upload route is a same-origin multipart/form-data POST — there is no third-party upload URL.

At rest — the database is a Postgres provisioned by the platform (see polsia.toml above). Volume encryption for the provisioned database is a platform / hosting concern of the database tier, not an in-app crypto primitive; the app does not invent its own at-rest scheme.

Because the parser does not persist the raw .ifc bytes, there is no separate in-process payload that needs additional encryption — only the derived JSON columns above.

  • HTTPS-only via the platform's per-request CSP — including upgrade-insecure-requests.
  • Strict script-src with per-request nonce and strict-dynamic, set in src/lib/csp.ts.
  • Same-origin multipart/form-data upload — no off-host upload endpoint.
  • Raw .ifc byte buffer is parsed in memory and discarded; only the JSON geometry above is persisted.
Auth model

Identity, sessions, roles.

Sign-in and session state are provided by better-auth, the installed auth module. The framework-owned core lives in src/lib/auth.ts; the per-app knobs (currently just emailAndPassword.enabled = true) live in the user-owned companion src/lib/auth-config.ts.

Sessions are stored in the Postgres Session table (schema in prisma/schema/auth.prisma) via the Prisma adapter. The admin plugin is registered in src/lib/auth.ts as admin({ defaultRole: 'user', adminRoles: ['admin'] }) — every new user starts with role: 'user', and an owner grant (driven by the deploy-injected POLSIA_OWNER_EMAIL) elevates that one address to 'admin' at signup time.

Multi-host sign-in is handled explicitly: the trustedOrigin list accepts https://*.polsia.app, https://*.polsia.io, and any active custom domain routed in via BETTER_AUTH_TRUSTED_ORIGINS — so the cookie scope stays honest across the platform and brand-domain routes.

Per-route gating is done server-side with the two framework seams requireAuth() from src/lib/require-auth.ts and requireAdmin() from src/lib/require-admin.ts. The IFC upload route is gated with requireAuth(), and the uploader id is written to Revision.uploadedById so every revision carries an accountable uploader.

What we don't do

We don't run a second login surface, a shared admin password, or a custom session cookie — the only session machinery is better-auth via the Prisma Postgres adapter. The auth primitives are framework-owned; the page is documented here, not author-on-top-of.

Per-project isolation

How scans are scoped per project.

Every clash / revision / escalation route is parameterised by the project id in the URL — /api/projects/[projectId]/… — and each handler runs requireAuth() before any read. Lookups scope where: { projectId } on the model; there is no cross-project query path exercised in either the routes or the UI list.

The data model carries the project id on every row: Revision and Clash both have @@index([projectId]) plus a compound index (@@index([projectId, uploadedAt]) on Revision, @@index([projectId, createdAt]) and @@index([projectId, status]) on Clash) so project-scoped reads are index-anchored.

To re-run clash detection without re-uploading, each persisted Revision carries the parsed-element JSON on elementData — rehydrateRevision() in src/lib/ifc/parser.ts rebuilds the in-memory shape from the row alone.

Upload limits & route shape

  • 25 MB
    Hard cap on an upload — the route returns 413 above that. No streaming out, no off-host upload endpoint.
  • multipart
    Upload is a same-origin multipart/form-data POST. No third-party upload URL is involved.
  • per-project
    Every read scopes by projectId from the URL, after a session check.
Retention & deletion

What keeps, what goes when the project closes.

The data plane does not run a scheduled deletion job. There is no cron sweeping revisions out of the table — the Project row is the unit of deletion, and the FK cascade in projects.prisma removes its Revision and Clash rows together.

Closing a project — moving it to ProjectStatus.CLOSED — preserves history for audit. The lifecycle transitions are a status flip, not a delete. The day a project is actually deleted, the cascade removes the joined rows in the same transaction; the app code never has to remember the joined tables.

If you need a project removed sooner than the natural close, the engineering team can do it on request — pass the project id and the FK cascade takes care of the rest.

Access control

What an admin can and can't see.

Sign-in is the gate to everything data-bound. The landing, pricing, and security pages are public, but every project list, clash detail, and revision detail lives behind a session check. Admin-only views (such as waitlist signups) are gated with requireAdmin(); per-user routes use requireAuth() and scope every query by user id.

The IFC upload route — where revisions actually get written — is gated by requireAuth(req) and persists uploadedById from the session, so the model carries an accountable uploader rather than an anonymous blob.

The owner grant is a single email address, set at deploy time in POLSIA_OWNER_EMAIL; only that address is auto-elevated to 'admin' — every other signup is a plain user.

Public API

The per-project brief snapshot is also available as a JSON endpoint.

The same per-project state that powers the dashboard is exposed as a versioned JSON endpoint at GET /api/brief/v1, gated by a shared-secret bearer header. Enterprise procurement and project-control systems — Kongsberg, Aker, Subsea 7 and the wider EPC buyer side — poll the endpoint and project the snapshot onto their own programme dashboards, rather than round-tripping every revision through a session seam.

Auth, response shape, sample request/response, and the upcoming X-API-Key migration live on the dedicated reference page. The token model and per-route gating are documented in one place; this page is for the trust story.

Push back / report an issue

Where to send a finding.

If a claim on this page doesn't match the code, or if a reviewer wants a deeper walkthrough — schema exports, data-handling tests, audit logs — the engineering team responds same-day. The same address is what the landing page uses for the sales / pilot CTA, so the trust story and the conversion CTA share one owner.

Reviewer's checklist

Every claim above is enforced in code you can clone: the upload route at src/app/api/projects/[projectId]/revisions/route.ts, the data model in prisma/schema/projects.prisma, and the auth narrative in src/lib/auth.ts and src/lib/auth-config.ts. If a claim doesn't match what you read, treat that as a bug.