# Deployment Migration Report

Migration of Tx OS from a hosted single-tenant runtime to a self-contained
Docker Compose stack runnable on any Linux host (or macOS workstation with
Docker Desktop).

## Summary

Tx OS previously depended on three runtime surfaces tied to its original
hosting environment:

1. A managed object-storage sidecar (`http://127.0.0.1:1106`) used by the API
   server to mint signed upload/download URLs against a managed GCS bucket
   via `@google-cloud/storage` + `google-auth-library`.
2. Three Vite plugins (`@*/vite-plugin-cartographer`,
   `@*/vite-plugin-dev-banner`, `@*/vite-plugin-runtime-error-modal`) loaded
   conditionally in the SPA and the component sandbox.
3. Hosted-platform configuration files consumed by the previous runtime.

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 `StoredObject` interface in
  `lib/objectStorage.ts` so the route layer is driver-agnostic.
- Hosted-platform Vite plugins are removed from both `vite.config.ts` files
  and from `pnpm-workspace.yaml`'s catalog.
- A `Dockerfile` (5 build targets — deps / build / api / web / migrate) and
  a `docker-compose.yml` (db + minio + minio-init + api + web + one-shot
  migrate) bring the entire stack up with `docker compose up -d`. A new
  `.env.example` template 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 + `/api` and `/api/socket.io`
  reverse proxy to the api container.
- `.env.example` — exhaustive, commented configuration template.
- `README.md` — canonical project doc; covers Docker quickstart, local dev,
  env reference, and a production checklist.
- `DEPLOYMENT_MIGRATION.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` and the legacy storage sidecar. Now ships
  a driver interface with two implementations (`LocalDriver`, `S3Driver`)
  selected by the `STORAGE_DRIVER` env var (defaulting to `s3` if
  `S3_ENDPOINT` is set, else `local`). Public API surface is preserved
  byte-compatible so callers in `routes/storage.ts` and
  `routes/executive-meetings.ts` did not need to change.
- `artifacts/api-server/src/lib/objectAcl.ts` — dropped the
  `@google-cloud/storage` File import in favour of a local `StoredObject`
  interface that both drivers implement. The deprecated `canAccessObject`
  shim is retained (still always denies, per the original safe default).
- `artifacts/tx-os/vite.config.ts` and
  `artifacts/mockup-sandbox/vite.config.ts` — stripped all hosted-platform
  plugin imports and gated dynamic imports. The tx-os config now reads its
  API proxy target from `VITE_API_PROXY_TARGET` for arbitrary dev setups.
- `.gitignore` — restructured so that hosted-platform configs, agent state,
  local storage, test artifacts, and any `.env*` (except `.env.example`)
  are excluded from git.

### Edited
- `pnpm-workspace.yaml` — removed the three hosted-platform catalog
  entries and emptied `minimumReleaseAgeExclude`.
- `artifacts/api-server/package.json` — dropped `@google-cloud/storage` and
  `google-auth-library`; added `@aws-sdk/client-s3` and
  `@aws-sdk/s3-request-presigner`.
- `artifacts/tx-os/package.json` and
  `artifacts/mockup-sandbox/package.json` — removed hosted-platform plugin
  dependencies.
- `scripts/src/seed.ts` — admin/user passwords now read from
  `SEED_ADMIN_PASSWORD` / `SEED_USER_PASSWORD`; the script throws in
  `NODE_ENV=production` if either is unset.
- `threat_model.md` — replaced legacy hosted-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.
- `artifacts/api-server/dist/`, `lib/*/dist/`, all `*.tsbuildinfo` — build
  artefacts that should not be tracked.
- Obsolete post-merge hook script consumed by the previous runtime.

## Verification

- `pnpm install --frozen-lockfile` — clean install with the new dependency
  set; pnpm reports `+87 -69` packages (S3 SDK in, GCS plugins out).
- `pnpm --filter @workspace/api-server build` — esbuild bundle succeeds.
- API-server test suite — all 12 storage / object-authz tests
  (`storage-object-authz.test.mjs` cases A through L) pass against the new
  local-FS driver, including the round-trip presigned PUT → DB-backed
  authz check → streamed GET.

## Operational notes for first-time deploy

> **Updating an existing deploy after `git pull`?** Use `./scripts/redeploy.sh` — it rebuilds the `api` and `web` images, runs migrations + seed, and restarts the stack in the right order. See `replit.md` → "Redeploying after `git pull`".

1. `cp .env.example .env` and fill in every value.
2. `docker compose build`
3. `docker compose up -d db minio minio-init` — Postgres + MinIO + create
   buckets.
4. `docker compose run --rm migrate` — apply Drizzle schema + seed admin /
   user accounts (one-shot).
5. `docker compose up -d api web` — boot the API and the SPA.
6. 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)

The following findings predate this migration and were **not** addressed
as part of this scope (which is environment portability only). They remain
on the security backlog.

### 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.ts` deprecation banner and
> the entity-lookup authz model in `lib/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 legacy 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 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>`.

## What did NOT change

Per the migration scope, 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.
