|
1 | 1 | # Deployment |
2 | 2 |
|
3 | | -augment-it's single-tenant humain-vc instance runs on **Railway**, not the |
| 3 | +augment-it's instance runs on **Railway**, not the |
4 | 4 | DigitalOcean droplet originally prepped for it (see [Why Railway, not |
5 | 5 | DO](#why-railway-not-do)). This doc is the standing reference for how it's |
6 | 6 | deployed; the narrative of *how it got this way* — including every bug hit |
@@ -136,6 +136,82 @@ needing a CNAME + a one-time TXT ownership-verification record: |
136 | 136 | `id.didi.sh` had been silently failing since it first deployed. Add each |
137 | 137 | new `*.didi.sh` consumer (decks, memos, …) to that list as it goes live. |
138 | 138 |
|
| 139 | +## Multi-tenancy — the session carries the workspace |
| 140 | + |
| 141 | +As of the `feature/workspace-auth` run (2026-07-28, plan: |
| 142 | +[`context-v/plans/Open-Augment-Didi-Sh-To-Reach-Edu.md`](context-v/plans/Open-Augment-Didi-Sh-To-Reach-Edu.md)), |
| 143 | +ONE instance serves multiple client orgs. The identity spec's designed |
| 144 | +org ↔ workspace mapping is live: a session's `/api/me` memberships resolve |
| 145 | +to the workspaces it may touch, `workspace.activate` is per-user-session, |
| 146 | +and every capability frame is validated server-side against the session's |
| 147 | +allowed set (`services/workspace/src/tenancy.ts` + `enforceTenant` in |
| 148 | +`capabilities.ts`). Proof: `node scripts/prove-session-tenancy.mjs` |
| 149 | +(self-contained; needs only a NATS on localhost). |
| 150 | + |
| 151 | +**Workspace → org binding.** Each workspace declares its org in |
| 152 | +`clients/<id>/workspace.json` (`{ "org_id": "reach.edu" }`) — committed in |
| 153 | +each client repo. Because the deployed `workspace-service` keeps |
| 154 | +`/data/clients` on a volume (self-seeded stubs, not git), production uses |
| 155 | +the env fallback instead: |
| 156 | + |
| 157 | +``` |
| 158 | +WORKSPACE_ORG_MAP=humain-vc=humain.vc,reach-edu=reach.edu |
| 159 | +``` |
| 160 | + |
| 161 | +The file wins when both exist. A workspace with no org binding is |
| 162 | +invisible to client sessions (fails safe); superusers see everything. |
| 163 | + |
| 164 | +**Env changes vs the single-tenant era** (on `workspace-service`): |
| 165 | + |
| 166 | +| Var | Single-tenant (before) | Multi-tenant (now) | |
| 167 | +|---|---|---| |
| 168 | +| `DIDI_AUTH` | `required` | `required` (unchanged) | |
| 169 | +| `REQUIRED_ORG_ID` | `humain.vc` | **removed** — admission = memberships map onto ≥1 workspace, or superuser | |
| 170 | +| `ACTIVE_CLIENT_ID` | `humain-vc` | **removed** — active is per-session; the global default derives from the persisted pick / first slug | |
| 171 | +| `WORKSPACE_ORG_MAP` | — | `humain-vc=humain.vc,reach-edu=reach.edu` | |
| 172 | + |
| 173 | +The startCommand also seeds the second workspace stub beside humain-vc's: |
| 174 | + |
| 175 | +```sh |
| 176 | +sh -c 'mkdir -p /data/clients/humain-vc /data/clients/reach-edu && echo DEFAULT_DOMAIN_TYPE=thesis > /data/clients/humain-vc/.env && echo DEFAULT_DOMAIN_TYPE=strategy > /data/clients/reach-edu/.env && npm start' |
| 177 | +``` |
| 178 | + |
| 179 | +**The row-store caveat.** row-store (and the prompt/response stores behind |
| 180 | +the records surfaces) loads ONE `clients/<active>/rows.json` — the |
| 181 | +instance's *operator-active* workspace, moved only by superuser or |
| 182 | +anonymous switches. Client sessions get those capabilities only while the |
| 183 | +operator-active workspace is in their allowed set; otherwise dispatch |
| 184 | +refuses (`…operator-active workspace…`). SurrealDB-backed surfaces (the |
| 185 | +workbench family) are fully per-session. True per-session row-store |
| 186 | +scoping is a logged follow-up. |
| 187 | + |
| 188 | +**Onboarding the next client org** (the recipe reach-edu followed): |
| 189 | + |
| 190 | +1. id-didi-sh (Fly app `id-didi-sh` — `-C` splits on spaces / strips |
| 191 | + double quotes, hence `~s(...)` + `\x20`): |
| 192 | + |
| 193 | + ```bash |
| 194 | + fly ssh console -a id-didi-sh -C '/app/bin/id_didi_sh rpc IO.inspect(IdDidiSh.Accounts.upsert_org(~s(<org.domain>),~s(Display\x20Name)))' |
| 195 | + fly ssh console -a id-didi-sh -C '/app/bin/id_didi_sh rpc IO.inspect(IdDidiSh.Accounts.create_user(%{primary_email:~s(<email>),name:~s(First\x20Last)}))' |
| 196 | + fly ssh console -a id-didi-sh -C '/app/bin/id_didi_sh rpc (u=IdDidiSh.Accounts.get_user_by_email(~s(<email>));IO.inspect(IdDidiSh.Accounts.upsert_membership(u.didi_id,~s(<org.domain>),~s(editor))))' |
| 197 | + ``` |
| 198 | + |
| 199 | + Roles: `superuser | org_owner | org_admin | editor | viewer`. The user |
| 200 | + then self-serves a magic link at `id.didi.sh` (or you send one). |
| 201 | +2. `clients/<slug>/workspace.json` in the client repo + append to |
| 202 | + `WORKSPACE_ORG_MAP` + extend the startCommand's seeded stubs. |
| 203 | +3. Redeploy `workspace-service`. |
| 204 | + |
| 205 | +**The Augment-from-DB remotes** (`org-workbench`, `search-and-add`, |
| 206 | +`search-results`) deploy as three more static-asset services — same shape |
| 207 | +as `chat`/`strategy-curator`: repo-root build context, dockerfilePath |
| 208 | +`apps/<name>/Dockerfile`, no rootDirectory, build-time vars |
| 209 | +`PUBLIC_WS_URL` + `PUBLIC_<NAME>_ASSET_PREFIX=https://<own-domain>`, and |
| 210 | +three matching `PUBLIC_<NAME>_REMOTE=https://<domain>/remoteEntry.js` |
| 211 | +vars on `shell` (then rebuild shell — `PUBLIC_*` is baked at build). |
| 212 | +Person-* resolvers are deliberately NOT deployed: they ride the |
| 213 | +row-store-gated CSV flows. |
| 214 | + |
139 | 215 | ## Deploying / redeploying |
140 | 216 |
|
141 | 217 | ```bash |
|
0 commit comments