postgres— primary store (projects/members/topics/comments/viewpoints/attachments/audit).minio— object storage (source IFC,.fragtiles,props.json, attachments).api— FastAPI; image bundlesservices/dataso exports/clash/validate/drawings work.worker— the durable job queue, in its own process (python -m aec_api.worker). Not optional under this compose file: it setsAEC_JOB_WORKER=offon the API, so with no worker running every enqueue succeeds, every row is written, every caller is told the work is under way, and none of it happens. Nothing raises. Scale it independently —--scale worker=3— when conversions back up. Serves nothing, so it binds no port and its healthcheck is disabled.web— Vite build served by nginx (COOP/COEP headers for web-ifc threading).converter— Node IFC→Fragments, run as a job (docker compose run).
# core stack (api + worker + web + postgres + minio)
docker compose --profile full up --build
# web → http://localhost:8080 api → http://localhost:8000 minio console → :9001
# convert a model (drop it in ./data first)
docker compose --profile tools run --rm converter /data/model.ifc /data/model.frag
# enforce project roles
AEC_RBAC=1 docker compose --profile full up --build
# smoke test a running stack
API=http://localhost:8000 bash scripts/smoke-stack.sh samples/school_str.ifc| Var | Service | Purpose |
|---|---|---|
DATABASE_URL |
api | postgresql+psycopg://… (sqlite if unset, dev) |
S3_ENDPOINT / S3_ACCESS_KEY / S3_SECRET_KEY / S3_BUCKET |
api | MinIO/S3 object storage; unset → local STORAGE_DIR |
AEC_RBAC |
api | 1 enforces project-scoped roles |
AEC_API_KEY |
api | bearer treated as admin (service-to-service) |
AEC_AUTH_SECRET |
api | set in prod — signs auth tokens; unset = forgeable dev secret (logs a warning) |
AEC_RATE_LIMIT_RPM |
api | global per-IP request cap/min (0/unset = off); needs AEC_REDIS_URL to share across workers |
AEC_REDIS_URL |
api | Redis for the shared rate-limit + login-lockout counters (fail-open to in-process) |
AEC_THROTTLE_REVIEW_RPM |
api | per-caller cap on AI-review endpoints (default 30; 0 disables) |
AEC_THROTTLE_CONVERT_RPM |
api | per-caller cap on convert endpoints (default 12; 0 disables) |
AEC_PROPS_CACHE_PROJECTS |
api | in-process property-index LRU size (default 16 projects/worker) |
SPECKLE_SERVER / SPECKLE_TOKEN |
api | optional Speckle bridge (must be https://; see SSRF note below) |
SPECKLE_ALLOW_PRIVATE |
api | 1 allows a private/LAN Speckle host (otherwise SSRF guard blocks non-public IPs) |
APS_CLIENT_ID / APS_CLIENT_SECRET |
api | optional Autodesk APS bridge (RVT→IFC, paid, per-translation cost) |
SSRF note:
SPECKLE_SERVERis admin-settable from the Settings UI, so the bridge requireshttps://and refuses hosts that resolve to private/loopback/link-local/cloud-metadata addresses before making any request. Point it at a public Speckle host, or setSPECKLE_ALLOW_PRIVATE=1for a trusted self-hosted server on your LAN.
Project-scoped roles, least→most: viewer < reviewer < editor < admin (rbac.py).
- viewer: read models/properties/issues/drawings/exports
- reviewer: + create/comment topics & viewpoints, attachments (RFIs, markup)
- editor: + author IFC (
/edit,/publish), clash-with-topics, BCF import - admin: + project settings, manage members
The project creator becomes admin. Caller identified by
X-User(swap for your IdP/JWT in prod). Off by default (AEC_RBACunset) so local dev stays open. Verified:test_rbac.py.
Accounts & identity (independent of AEC_RBAC): the built-in auth issues signed bearer
tokens + an httpOnly cookie (auth.py). The first /auth/register bootstraps a global admin;
after that, admins manage accounts via /auth/users (create / list / set role / activate /
deactivate / reset password) — surfaced in the web app under the account menu → Manage users.
Users change their own password at /auth/password (account menu → Change password).
Deactivating an account blocks new logins and invalidates its existing tokens immediately;
the last active admin can't be removed. Verified: test_auth.py.
storage.py has Local and S3 (boto3) backends behind one interface incl. byte-range reads.
.frag tiles and attachments are served with HTTP range requests (serving.py): 206 Partial Content, Accept-Ranges, Content-Range, immutable cache headers — so the viewer/
CDN stream large models. Verified: test_serving.py (200 full / 206 ranged / 416).
Note:
/publishreconvert spawns the Node converter; in the container that step is best-effort (reindex always runs). For prod, run conversion via theconverterservice and write the.fragto MinIO under<project_id>/model.frag.
Every write records an AuditLog row (actor, action, method, path, topic, detail) — RFIs/
punchlist are contractual records. The DB + attachments + uploaded source IFCs are the system
of record; .frag tiles are reproducible from source IFC.
Backup/restore runbook (scripts/backup.sh / scripts/restore.sh, run from the repo root
with the stack up; Windows: Git Bash or WSL):
# back up DB (pg_dump) + MinIO objects + uploaded source IFCs → one timestamped tarball
./scripts/backup.sh # → ./backups/aec-backup-<ts>.tgz
# schedule it (crontab): nightly at 02:00, then prune backups older than 14 days
0 2 * * * cd /srv/massing && ./scripts/backup.sh >> /var/log/aec-backup.log 2>&1
0 3 * * * find /srv/massing/backups -name 'aec-backup-*.tgz' -mtime +14 -delete
# restore (DESTRUCTIVE — overwrites DB, objects, IFCs; stops the app while restoring)
./scripts/restore.sh ./backups/aec-backup-<ts>.tgzbackup.sh logically dumps Postgres (pg_dump --clean) and tars the MinIO + IFC volumes via a
throwaway alpine container (--volumes-from, so no volume-name or S3-credential coupling).
Verify a backup by restoring into a throwaway compose project and hitting /health + a known
project before trusting it. Keep backups off-box (e.g. aws s3 cp, rclone) for DR.
The base stack runs anywhere Docker does. For a public, TLS-secured demo, layer the
production overlay — it adds a Caddy reverse proxy that fetches + renews a Let's Encrypt
cert automatically, enforces auth (AEC_RBAC=1), and sets restart policies.
Before exposing anything, work through PRODUCTION_CHECKLIST.md and run the preflight:
docker compose … run --rm api python /app/scripts/validate_prod_config.py(exit 0 = go). The API also refuses to boot on Postgres without RBAC + a real auth secret.
# 1. a small VM (1–2 vCPU, 2–4 GB) with Docker + a DNS A record for your domain → the VM IP
# 2. firewall: allow only 80 + 443
sudo ufw allow OpenSSH && sudo ufw allow 80,443/tcp && sudo ufw enable
# 3. clone + configure secrets (REQUIRED in prod)
git clone https://github.com/ibuilder/massing.git && cd Massing
cp .env.example .env # set POSTGRES_PASSWORD, S3_ACCESS_KEY/SECRET, AEC_API_KEY, AEC_AUTH_SECRET
# 4. bring it up behind Caddy (auto-HTTPS for $DOMAIN)
DOMAIN=app.example.com docker compose -f docker-compose.yml -f docker-compose.prod.yml \
--profile full up -d --build
# 5. create the first admin (bootstraps as admin), then sign in at https://app.example.com
curl -s -X POST https://app.example.com/api/auth/register \
-H "Content-Type: application/json" -d '{"username":"admin","password":"<strong-password>"}'
# optional: seed a demo project across all relation chains
DOMAIN=app.example.com docker compose -f docker-compose.yml -f docker-compose.prod.yml \
--profile full --profile seed run --rm seedCaddy is the only public entrypoint (web/api/postgres/minio stay on the Docker network — keep 8000/8080/5432/9000 off the public firewall). Cookie auth + the SSE feed + downloads all work because everything is same-origin behind the proxy. Managed hosts (Fly.io, Render, Railway) work too — run the same images, swap MinIO for their S3-compatible bucket + managed Postgres, and front the web service with their TLS.
GitHub note: Pages is static-only and can't host this stack (it needs the API + Postgres
- MinIO). Pages can serve the marketing page (
docs/index.html) and, with extra setup, a viewer-only build; the full app needs a Docker host as above.
The whole platform runs in one process for a single operator — FastAPI serving both the API and
the web SPA on 127.0.0.1:8765, backed by SQLite + local files (no Docker / Postgres / MinIO),
in local mode (no login — the operator owns the one site). This is the free, single-seat desktop
build; projects are saved/opened as portable .mmproj bundles (Open/Save menu).
- Run from source:
python -m aec_api.desktop(fromservices/api, with the venv) — builds the web first withnpm run build:desktopso the SPA calls the same-origin API (.env.desktopsetsVITE_API_URL=). - Package the .exe:
services/api/build-desktop.ps1runs the desktop web build, then PyInstaller (desktop.spec) intoservices/api/dist_desktop/AEC-BIM/AEC-BIM.exe. The bundle includes the SPA (web/), the 68 module definitions (modules/), and ifcopenshell. Data lives under%LOCALAPPDATA%\AEC-BIM(override withAEC_DATA_DIR); uninstall = delete that folder + the app. - Env knobs:
AEC_PORT(8765),AEC_OPEN_BROWSER(1),AEC_DATA_DIR,AEC_HOST. The frozen build resolves the bundled SPA + module catalog via_MEIPASS(AEC_WEB_DIST/AEC_MODULES_DIR).
A native-window wrapper (Tauri, below) can host this same backend; the PyInstaller .exe already
ships a complete, double-clickable app on its own (it opens the system browser).
- Update check (built-in, no setup): on launch the app checks the project's GitHub Releases
for a newer version than the one baked in at build (
VITE_APP_VERSION←package.json), and shows a dismissible banner with a download link; there's also a manual "Check for updates" in Settings (apps/web/src/ui/update.ts). Works for the.exe, the Tauri build, and the browser. It links to the new installer rather than hot-swapping (an OS can't replace a running.exein place). - Silent self-install (Tauri, optional): for true in-app install, enable the Tauri updater —
it requires a signing keypair:
npx @tauri-apps/cli signer generate -w aec.key→ keep the public key, guard the private one.- Put the public key in
tauri.conf.jsonunderplugins.updater.pubkeyand setbundle.createUpdaterArtifacts: true+plugins.updater.endpointsto the releaselatest.json. - Add repo secrets
TAURI_SIGNING_PRIVATE_KEY+TAURI_SIGNING_PRIVATE_KEY_PASSWORDand pass them to thetauri-actionstep (tauri-action then emits the signed updater bundles +latest.json). Not enabled by default becausecreateUpdaterArtifactsmakes the build require the signing key — so flip it on only once the secret is configured (otherwise the release build fails).
The same web build (dist) wraps into native iOS/Android apps via Capacitor (capacitor.config.ts,
appId com.ibuilder.aecbim). A phone has no local Python backend, so a mobile build must target a
hosted API.
- Point at your API: edit
apps/web/.env.mobile→VITE_API_URL=https://your-cloud-api(blank = viewer-only offline build; the portal/proforma need the API). - Build the web + sync:
npm run build:mobilethennpx cap sync(or the one-shotnpm run mobile:android/npm run mobile:ios). - Add a platform once:
npx cap add android/npx cap add ios(folders are gitignored; regenerate anytime). - Build the binary (your toolchain): Android needs the Android SDK + JDK (
cap open android→ Gradle build/APK/AAB); iOS needs macOS + Xcode (cap open ios). - Validate on device: the model viewer uses threaded WASM (web-ifc, SharedArrayBuffer) — confirm it runs in the device WebView before relying on 3D; the portal/proforma/2D work everywhere.
- Store submission: Apple Developer + Google Play accounts (signing + listings are store-side).
Scaffolding is wired and verified (cap add android syncs the web build); the binary build,
on-device WASM validation, and store accounts are the external pieces.
.github/workflows/desktop.yml builds Windows / macOS (arm64) / Linux installers. Push a tag
(v0.1.0) for a draft Release; run it manually (Actions → Desktop release → Run workflow) for
artifact-only smoke builds. Without the secrets below, builds are unsigned (Gatekeeper /
SmartScreen warn on first launch) — everything still works; add the secrets to sign + notarize.
Native window + bundled backend (sidecar). The Tauri shell (src/lib.rs)
spawns the Python backend as a sidecar (binaries/aec-bim-server, declared in tauri.conf.json
externalBin), waits for 127.0.0.1:8765, then points the WebView at it — so the installed app is
the full platform (API + SPA + SQLite, local mode), same-origin, fully offline, in a native window.
CI builds that sidecar per-platform with services/api/build_sidecar.py (PyInstaller can't
cross-compile, so each runner builds its own; the binary is named with the Rust target triple).
The shell's beforeBuildCommand is npm run build:desktop so the bundled SPA targets the
same-origin API. To build locally: npm run build:desktop → python services/api/build_sidecar.py
→ npm --prefix apps/web run tauri build (needs the Rust toolchain).
| Platform | Repo secrets | Notes |
|---|---|---|
| macOS | APPLE_CERTIFICATE (base64 of a Developer ID .p12), APPLE_CERTIFICATE_PASSWORD, APPLE_SIGNING_IDENTITY (e.g. Developer ID Application: Name (TEAMID)), APPLE_ID, APPLE_PASSWORD (app-specific password), APPLE_TEAM_ID |
tauri-action imports the cert and notarizes automatically when all are set. |
| Windows | WINDOWS_CERTIFICATE (base64 of an Authenticode .pfx), WINDOWS_CERTIFICATE_PASSWORD |
The workflow imports the PFX and writes its thumbprint into tauri.conf.json before building. For an EV/HSM or Azure Trusted Signing cert, replace that step with the vendor's signtool flow. |
| Linux | — | .deb/.AppImage are not signed; distribute over HTTPS / via checksums. |
Generate APPLE_CERTIFICATE / WINDOWS_CERTIFICATE with base64 -w0 cert.p12 (Linux) or
[Convert]::ToBase64String([IO.File]::ReadAllBytes("cert.pfx")) (PowerShell), and add them under
Settings → Secrets and variables → Actions. The workflow then signs automatically on the next tag —
no code change needed (the import + thumbprint + notarize steps are already wired and guarded so
unsigned builds stay green until the secrets exist).
Where to get the certs (the only external piece):
- Windows — an Authenticode code-signing cert from a CA (DigiCert, Sectigo, SSL.com; OV ≈
$200–400/yr). OV still shows a SmartScreen prompt until it earns reputation; an EV cert (HSM/
token) or Azure Trusted Signing (~$10/mo, cloud, no physical token) clears SmartScreen
immediately — for those, swap the PFX-import step for the vendor's
signtool/Trusted-Signing action. - macOS — Apple Developer Program ($99/yr) → a Developer ID Application cert (export
.p12)- an app-specific password for notarization. Fills all six
APPLE_*secrets above.
- an app-specific password for notarization. Fills all six
- Linux —
.deb/.AppImagearen't OS-signed; publish SHA-256 checksums (GitHub shows asset digests) and/or GPG-detach-sign if your channel needs it.
GET /metrics exposes Prometheus text (request counts + latency summary by method/route
template, in-flight gauge, uptime) — point a Prometheus scrape at it. Each request also emits
a structured JSON access line on the aec.access logger (method/route/status/dur_ms) for log
aggregation. Metrics are per-process; with multiple uvicorn workers use a multiprocess
collector or scrape each worker. (Endpoint is unauthenticated — keep it on an internal network
or front it with auth at the proxy.)
web-ifc WASM + the Fragments worker are bundled into the web image; tiles serve from your own MinIO. No external CDN — the viewer runs fully offline.
Bonsai/Blender GPL (separate process), IfcOpenShell LGPL, That Open MIT-style.