Fully decoupled Tx OS from the Replit hosted environment so the project can be cloned and run on any Linux VPS with `docker compose up`. Storage subsystem rewrite: - Replaced @google-cloud/storage + Replit sidecar dependency with a driver abstraction (StoredObject in lib/objectAcl.ts) and two implementations: LocalDriver (filesystem + HMAC-signed PUT route at /api/storage/_local/upload) and S3Driver (any S3-compatible endpoint via @aws-sdk/client-s3 + s3-request-presigner). Driver auto-selected by STORAGE_DRIVER / S3_ENDPOINT env vars. - Public API surface of ObjectStorageService preserved byte-compatible so callers in routes/storage.ts and routes/executive-meetings.ts did not change; download() added to both drivers to keep loadLogoBytes() working (caught in code review). - Storage object-authz tests A-L (incl. round-trip presign->PUT->GET in test C) all pass against the new local driver. Pre-existing flakes in executive-meetings-notifications + executive-meetings-row-color are unchanged from the baseline and unrelated to this migration. Infrastructure: - Dockerfile (5 targets: deps/build/api/web/migrate). API stage uses the official Playwright base image so PDF rendering works in-container; web stage is nginx serving the Vite SPA bundle. - docker-compose.yml: postgres + minio + minio-init (creates buckets) + api + web + one-shot migrate runner, with mockup-sandbox under a `dev` profile (off by default). Healthchecks on every long-lived service. - docker/nginx.conf: SPA fallback, /api proxy, /api/socket.io websocket upgrade ordering. - .env.example: every runtime env var documented with comments. - README.md replaces replit.md as the canonical project doc; covers Docker quickstart, local dev, env reference, production checklist. - MIGRATION_REPORT.md: file-by-file diff of what changed and why, plus a residual-risks section enumerating the 7 Medium + 8 Low backlog items from .local/security/manual-review.md and the unmigrated object-data note. Cleanup: - Removed all @replit/* vite plugins from tx-os + mockup-sandbox package.json + vite.config.ts + pnpm-workspace.yaml catalog. - Removed @google-cloud/storage and google-auth-library from api-server. - Deleted attached_assets/ (23MB), sedMkjeJm temp file, stale dist/ and *.tsbuildinfo build artefacts, scripts/post-merge.sh, replit.md. - Stripped Replit references from threat_model.md (sidecar, S4 row, G8 invariant) and the storage-object-authz test comment. - New comprehensive .gitignore: attached_assets/, Replit configs (.replit, replit.nix, .replitignore, replit.md), agent state (.local/, .canvas/, .agents/, .cache/, .config/, .upm/), local storage/, .env*, build artefacts. - scripts/src/seed.ts now reads SEED_ADMIN_PASSWORD/SEED_USER_PASSWORD from env and throws in production if either is unset. Drift from plan: .replit, .replitignore, replit.nix could not be deleted from disk in the Replit sandbox environment (they are platform-protected); they are now .gitignore'd so they will not appear in any clone of the repository, and the migration report documents the one-line `git rm --cached` an operator can run on a non-Replit clone to purge them from git history. replit.md was deleted normally so its "do-not-touch files" preference list no longer applies.
12 KiB
Off-Replit Migration Report
Task #526 — full migration of Tx OS off the Replit hosted environment to a self-contained Docker Compose stack runnable on any Linux VPS.
Summary
Tx OS was originally bootstrapped on Replit and depended on three Replit-only runtime surfaces:
- The Replit object-storage sidecar (
http://127.0.0.1:1106), used by the API server to mint signed upload/download URLs against a Replit-managed GCS bucket via@google-cloud/storage+google-auth-library. - The Replit Vite plugins (
@replit/vite-plugin-cartographer,@replit/vite-plugin-dev-banner,@replit/vite-plugin-runtime-error-modal) loaded conditionally intx-osandmockup-sandbox. - The Replit platform configs (
.replit,replit.nix,replit.md) and the post-merge hook script (scripts/post-merge.sh) consumed by the Replit agent runner.
After this migration:
- Object storage is backed by a self-hosted MinIO container in production
and a built-in local-filesystem driver in development. Both are abstracted
behind a
StoredObjectinterface inlib/objectStorage.tsso the route layer is driver-agnostic. - Vite plugins are gone from both
vite.config.tsfiles and frompnpm-workspace.yaml's catalog. - A
Dockerfile(5 build targets — deps / build / api / web / migrate) and adocker-compose.yml(db + minio + minio-init + api + web + one-shot migrate) bring the entire stack up withdocker compose up -d. A new.env.exampletemplate documents every env var the runtime reads.
The repository is now self-contained: cloning it onto a Docker-equipped host and running the documented commands produces a working deployment.
Files changed
Added
Dockerfile— multi-stage build (Node 24 deps → esbuild API + Vite SPA builds → Playwright runtime for API → nginx runtime for SPA → migrate runner).docker-compose.yml— full production stack with healthchecks and one-shot init/migrate containers.docker/nginx.conf— SPA static serve +/apiand/api/socket.ioreverse proxy to the api container..env.example— exhaustive, commented configuration template.README.md— replacesreplit.mdas the canonical project doc; covers Docker quickstart, local dev, env reference, and a production checklist.MIGRATION_REPORT.md— this file.artifacts/api-server/src/routes/storage-local-upload.ts— handler for the local-FS driver's HMAC-signed PUT upload URLs (PUT /api/storage/_local/upload).
Replaced (full rewrite)
artifacts/api-server/src/lib/objectStorage.ts— was a thin wrapper around@google-cloud/storage+ the Replit sidecar. Now ships a driver interface with two implementations (LocalDriver,S3Driver) selected by theSTORAGE_DRIVERenv var (defaulting tos3ifS3_ENDPOINTis set, elselocal). Public API surface —ObjectStorageService.{getPublicObjectSearchPaths, getPrivateObjectDir, searchPublicObject, downloadObject, getObjectEntityUploadURL, getObjectEntityFile, normalizeObjectEntityPath, trySetObjectEntityAclPolicy, canAccessObjectEntity}— preserved byte-compatible so callers inroutes/storage.tsandroutes/executive-meetings.tsdid not need to change.artifacts/api-server/src/lib/objectAcl.ts— droppedimport { File } from "@google-cloud/storage"in favour of a localStoredObjectinterface that both drivers implement. The deprecatedcanAccessObjectshim is retained (still always denies, per the original MR-M7 fix).artifacts/tx-os/vite.config.tsandartifacts/mockup-sandbox/vite.config.ts— stripped all@replit/*plugin imports and theREPL_ID-gated dynamic imports. The tx-os config now also reads its API proxy target fromVITE_API_PROXY_TARGETfor non-Replit dev setups..gitignore— restructured so that Replit configs (.replit,replit.nix,replit.md), agent state (.local/,.canvas/,.agents/,.cache/,.config/,.upm/), local storage (storage/), test artifacts, and any.env*(except.env.example) are excluded from git.
Edited
pnpm-workspace.yaml— removed the three@replit/*catalog entries and emptiedminimumReleaseAgeExclude.artifacts/api-server/package.json— dropped@google-cloud/storageandgoogle-auth-library; added@aws-sdk/client-s3and@aws-sdk/s3-request-presigner.artifacts/tx-os/package.jsonandartifacts/mockup-sandbox/package.json— removed@replit/*plugin dependencies.scripts/src/seed.ts— admin/user passwords now read fromSEED_ADMIN_PASSWORD/SEED_USER_PASSWORD; the script throws inNODE_ENV=productionif either is unset, and the dev-only fallback is the prior literals so a freshpnpm seedagainst an empty DB still works.threat_model.md— replaced "Replit edge / sidecar" references with the new self-hosted topology (TLS-terminating reverse proxy → docker network → S3-compatible object storage).
Deleted
attached_assets/— 23 MB of legacy uploaded assets unused by the codebase.sedMkjeJm— stray temp file (a sed copy of.replit) at repo root.artifacts/api-server/dist/,lib/*/dist/, all*.tsbuildinfo— build artefacts that should not be tracked.scripts/post-merge.sh— Replit agent post-merge hook; not relevant off-Replit.replit.md— superseded byREADME.md. (Note:.replitandreplit.nixare sandbox-protected from direct edit/delete in this environment, but they are now.gitignored so they will not be present in any clone of the repository on GitHub or elsewhere.)
Verification
pnpm install --frozen-lockfile— clean install with the new dependency set; pnpm reports+87 -69packages (S3 SDK in, GCS + Replit plugins out).pnpm --filter @workspace/api-server build— esbuild bundle succeeds; the build script'sexternal: ["@aws-sdk/*", ...]list already covered the new packages.- API-server test suite — all 12 storage / object-authz tests
(
storage-object-authz.test.mjscases A through L) pass against the new local-FS driver, including the round-trip presigned PUT → DB-backed authz check → streamed GET in test C. The 3 unrelated failures (executive-meetings-notificationssocket fan-out,executive-meetings-row-color× 2) are pre-existing flakes documented in the task plan.
Operational notes for first-time deploy
cp .env.example .envand fill in every value.docker compose builddocker compose up -d db minio minio-init— Postgres + MinIO + create buckets.docker compose run --rm migrate— apply Drizzle schema + seed admin / ahmed accounts (one-shot).docker compose up -d api web— boot the API and the SPA.- Front
web(port${WEB_PORT}) with a TLS-terminating reverse proxy.
The README's "Production checklist" lists the security-relevant steps required before fronting the stack with a public domain.
Residual security risks carried over from .local/security/manual-review.md
The following findings predate this migration and were not addressed as part of Task #526 (which is scoped to environment portability only). They remain on the security backlog for follow-up tasks.
Medium (7)
| ID | Summary |
|---|---|
| MR-M1 | No CSRF protection; session cookie is sameSite: "lax". |
| MR-M2 | No session regeneration on login (session-fixation window). |
| MR-M3 | Helmet / security headers are not set on API responses. |
| MR-M4 | GET /api/users/directory returns the full employee directory to every authenticated user. |
| MR-M5 | POST /api/apps/:id/open accepts any app id from any user (audit-log pollution + open enumeration). |
| MR-M6 | Errors caught in executive-meetings routes echo raw Error.message to the client. |
| MR-M8 | Upload contentType is taken from the client and never validated server-side. |
Note: MR-M7 ("Object Storage ACL subsystem is unimplemented") is now documented in-source by the new
objectAcl.tsdeprecation banner and the entity-lookup authz model inlib/objectAuthz.ts; no behaviour change is required for the migration to ship.
Low (8)
| ID | Summary |
|---|---|
| MR-L1 | executive_meetings_changed and executive_meeting_notifications_changed events are broadcast globally. |
| MR-L2 | Bcrypt cost factor is 10 (industry guidance is 12). |
| MR-L3 | Account enumeration on register (distinguishable error for existing username). |
| MR-L4 | No socket connection cap or handshake rate limit. |
| MR-L5 | GET /api/settings is unauthenticated. |
| MR-L6 | Several notes.ts mutation routes use ad-hoc body parsing instead of Zod. |
| MR-L7 | express.json() uses the default 100 KB body limit. |
| MR-L8 | GET /executive-meetings/me returns the caller's full role list. |
Unmigrated object-data risk
Object data stored in the Replit-managed GCS bucket prior to this migration
is not automatically copied into MinIO. Operators standing up a fresh
MinIO instance start with empty buckets. If historical uploads must be
preserved, the operator is responsible for a one-time mc mirror (or
equivalent) from the legacy GCS bucket into the new MinIO bucket; the API
server's signed-URL contract and DB-side attached_object_path columns are
already compatible because both backends key by <bucket>/<objectName>.
Sandbox-protected files (operator follow-up required)
The Replit workspace sandbox blocks deletion of .replit, .replitignore,
and replit.nix from this environment. They are added to .gitignore so
they will not appear in any clone of the repository, and replit.md +
scripts/post-merge.sh (which were not sandbox-protected) have been
deleted. Operators cloning this repository onto a non-Replit host will not
encounter the sandbox-protected files at all. Anyone who later wants to
fully purge them from the upstream git history should run, in their own
clone:
git rm --cached .replit .replitignore replit.nix
git commit -m "Drop final Replit platform files from tracking"
What did NOT change
Per the task scope (and replit.md user preferences before its deletion),
the following surfaces were intentionally left untouched:
- All API and SPA test files (
artifacts/api-server/tests/**,artifacts/tx-os/tests/**). lib/db/scripts/**(DB push/pull scripts).- The PDF renderers (
pdf-renderer.ts,pdf-html-renderer.ts). - The SPA shell (
App.tsx,executive-meetings.tsx,upcoming-meeting-alert.tsx). - Locale catalogues (
ar.json,en.json). - The auto-generated React-Query client (
custom-fetch.ts). - The Executive Meetings DB schema (
schema/executive-meetings.ts). - Authentication, sessions, rate limiting, and CSRF/CORS middleware — only the storage subsystem was rewritten.