Skip to main content

Architecture and the path to an enterprise deployment

The app is built so that one organisation runs one engagement in its own deployment. Today it runs on Cloudflare. It is structured so the same code can later run on AWS or Azure for a single organisation.

Layers​

LayerToday (Cloudflare)File(s)Cloud-specific?
Browser appStatic filespublic/ (app), public/admin/ (admin)No
API and rulesCloudflare Workerlib/core/api.js, lib/core/rbac.js, lib/core/roles.jsNo – plain JavaScript
Identity (who is signed in)Cloudflare Access (email one-time PIN)lib/identity/cloudflare-access.jsYes – swap per cloud
StorageCloudflare D1 (SQLite)lib/store/d1.js, schema.sqlYes – swap per cloud
WiringWorker entry pointsrc/worker.js, wrangler.jsoncYes – swap per cloud

lib/core/api.js exposes one function, handle({ method, path, email, text }, store), which returns { status, body }. A new cloud only needs: an entry point that calls handle, an identity adapter that returns the signed-in email, and a storage adapter with the same methods as lib/store/d1.js.

Access control​

  • Authentication (proving who someone is) is done by the identity provider: Cloudflare Access today.
  • Authorisation (what they may do) is done by the app, from the users table managed in /admin:
    • Only active users can use the app at all. Others are refused and listed under "Waiting for access".
    • Administrator is a separate flag. Admins manage users and do the engagement set-up (in /admin). Being an admin does not by itself allow changing decisions; that needs an engagement role.
    • Engagement role (Counsel, Triodian, CRO, Insurer risk, Independent reviewer, Board record) decides what can be changed. Rules are in lib/core/roles.js and match the build specification.
  • Every save is checked on the server (lib/core/rbac.js): the stored engagement is compared with the one sent, and any change outside the user's role is refused. History is append-only and new entries are signed by the server with the user's name and role. Refusals are written to the audit log.
  • At least one active administrator must always exist; admins cannot remove their own admin access.
  • People change their own name, organisation and email on /profile. A new email only takes effect when the person signs in with it (so the identity provider has verified it); until then the old address keeps working.

Moving to AWS or Azure (one organisation)​

ConcernAWSAzure
Static appS3 + CloudFrontStatic Web Apps or App Service
APILambda behind API Gateway, or a container on ECS/FargateAzure Functions, or App Service / Container Apps
IdentityThe organisation's IdP via Cognito, or ALB OIDC authenticationMicrosoft Entra ID (App Service authentication or MSAL)
DatabaseRDS / Aurora PostgreSQLAzure Database for PostgreSQL, or Azure SQL
Audit and logsCloudWatch, forwarded to the organisation's SIEMAzure Monitor / Log Analytics, Sentinel
Secrets and keysKMS, Secrets ManagerKey Vault

The SQL in lib/store/d1.js is plain and ports to PostgreSQL with small changes (INSERT OR IGNORE → ON CONFLICT DO NOTHING, AUTOINCREMENT → identity column, COLLATE NOCASE → lower()).

Before an enterprise go-live (not yet done)​

  • Single sign-on with the organisation's identity provider; store the IdP's subject id as well as email.
  • Optional: map IdP groups to engagement roles, and SCIM provisioning so users are managed in one place.
  • Backups and point-in-time restore, retention policy, and data residency confirmed for the organisation.
  • Security headers (Content-Security-Policy), rate limiting, and an independent penetration test.
  • Self-host the web fonts Done: fonts are served from /fonts (SIL Open Font Licence; licences included).
  • Export of the audit log to the organisation's monitoring.

Two people editing at once​

The engagement is one record with a revision number. Each save says which revision it started from. If someone else has saved since, the server takes the saved version it started from (the last 200 are kept in engagement_revisions), the other person's version and this one, and merges them (public/shared/merge.js, also used by the browser). Changes to different positions, or different fields of one position, are combined. Only if both changed the same field to different values is the save refused, and the message names the item. Every merged save is still checked against the person's role, and the audit log records which save it was merged with.

Calibration set for the AI Use Assessment app​

GET /api/calibration-set returns the calibration set as it was issued: the latest version the board approved, or before any approval the latest the CRO agreed. It never returns positions being edited. The response gives version, status, issuedOn, issuedBy, minuteRef and calibrationSet (the same content as the downloadable calibration set file).

The endpoint sits behind the same Cloudflare Access sign-in as the app. The assessment app calls it from its server with a Cloudflare Access service token. A service token's sign-in has no email; its Client ID is in common_name. src/worker.js lets exactly one service token, the one whose Client ID is in the secret CALIBRATION_CLIENT_ID, read GET /api/calibration-set and nothing else (tests/service-token.test.mjs). Set-up steps are in the Use Assessment repo, docs/calibration-link.md. Each set is stored when it is issued, so versions issued before this was added are not available here; the next agreement or approval makes one available.