Skip to content

Commit 755e6d5

Browse files
authored
Merge pull request #74 from lossless-group/feature/workspace-auth
feat(workspace-auth): multi-tenant workspace auth — org-mapped admission, per-session tenancy, and the zombie-session fix
2 parents aa5e0e5 + 67430ec commit 755e6d5

39 files changed

Lines changed: 1431 additions & 88 deletions

File tree

DEPLOYMENT.md

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Deployment
22

3-
augment-it's single-tenant humain-vc instance runs on **Railway**, not the
3+
augment-it's instance runs on **Railway**, not the
44
DigitalOcean droplet originally prepped for it (see [Why Railway, not
55
DO](#why-railway-not-do)). This doc is the standing reference for how it's
66
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:
136136
`id.didi.sh` had been silently failing since it first deployed. Add each
137137
new `*.didi.sh` consumer (decks, memos, …) to that list as it goes live.
138138

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+
139215
## Deploying / redeploying
140216

141217
```bash

apps/affiliation-rating-resolver/src/App.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
const ACTIVE_RECORD_SET_KEY = 'augment-it:active-record-set';
2828
const MAPPING_KEY_PREFIX = 'augment-it:affiliation-rating-resolver:mapping:';
2929
30-
let status = $state<'connecting' | 'open' | 'closed' | 'error'>('connecting');
30+
let status = $state<'connecting' | 'open' | 'closed' | 'error' | 'auth_required'>('connecting');
3131
let client = $state<string>('reach-edu');
3232
3333
let recordSets = $state<RecordSet[]>([]);

apps/chat/Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ COPY shell ./shell
1818
RUN pnpm install --frozen-lockfile
1919

2020
ARG PUBLIC_WS_URL
21+
ARG PUBLIC_ID_BASE
2122
ARG PUBLIC_CHAT_ASSET_PREFIX
2223
ENV PUBLIC_WS_URL=$PUBLIC_WS_URL
24+
ENV PUBLIC_ID_BASE=$PUBLIC_ID_BASE
2325
ENV PUBLIC_CHAT_ASSET_PREFIX=$PUBLIC_CHAT_ASSET_PREFIX
2426
RUN pnpm --filter @augment-it/chat build
2527

apps/chat/src/App.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
((import.meta as { env?: Record<string, string> }).env?.PUBLIC_WS_URL as string | undefined) ||
2020
'ws://localhost:3001/ws';
2121
22-
let connectionStatus = $state<'connecting' | 'open' | 'closed' | 'error'>('connecting');
22+
let connectionStatus = $state<'connecting' | 'open' | 'closed' | 'error' | 'auth_required'>('connecting');
2323
2424
onMount(() => {
2525
const TOKEN_KEY = 'augment_it_session_token';

apps/enhanced-records-list/src/App.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
type EnhancedRecord,
2222
} from './state.svelte';
2323
24-
let connectionStatus = $state<'connecting' | 'open' | 'closed' | 'error'>('connecting');
24+
let connectionStatus = $state<'connecting' | 'open' | 'closed' | 'error' | 'auth_required'>('connecting');
2525
let loadedOnce = $state<boolean>(false);
2626
2727
// ---- Connect + bootstrap data load ----

apps/org-workbench/Dockerfile

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Production build for the org-workbench federated remote — the first
2+
# surface of the "Augment from DB" flow (Stephenie's workbench). Same
3+
# rationale as apps/chat/Dockerfile: direct pnpm --filter build, no
4+
# Railpack auto-detection.
5+
#
6+
# Build context is the repo root (Railway: no rootDirectory set for this
7+
# service, dockerfilePath: apps/org-workbench/Dockerfile).
8+
9+
FROM node:22-alpine AS build
10+
WORKDIR /monorepo
11+
RUN corepack enable
12+
13+
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
14+
COPY packages ./packages
15+
COPY apps ./apps
16+
COPY shell ./shell
17+
RUN pnpm install --frozen-lockfile
18+
19+
ARG PUBLIC_WS_URL
20+
ARG PUBLIC_ID_BASE
21+
ARG PUBLIC_ORG_WORKBENCH_ASSET_PREFIX
22+
ENV PUBLIC_WS_URL=$PUBLIC_WS_URL
23+
ENV PUBLIC_ID_BASE=$PUBLIC_ID_BASE
24+
ENV PUBLIC_ORG_WORKBENCH_ASSET_PREFIX=$PUBLIC_ORG_WORKBENCH_ASSET_PREFIX
25+
RUN pnpm --filter @augment-it/org-workbench build
26+
27+
FROM node:22-alpine
28+
WORKDIR /app
29+
RUN npm install -g serve
30+
COPY --from=build /monorepo/apps/org-workbench/dist ./dist
31+
EXPOSE 3014
32+
CMD ["serve", "-s", "dist", "-l", "3014"]

apps/org-workbench/rsbuild.config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ import { pluginModuleFederation } from '@module-federation/rsbuild-plugin';
99
// by design: every read/write rides workspace.invoke → NATS →
1010
// record-surrealdb-resolver (spec decision D1).
1111
// See context-v/specs/Augment-From-DB-Flow.md.
12+
// Own-origin asset prefix for production (chat/strategy-curator pattern):
13+
// a remote's sub-chunks resolve against the assetPrefix BAKED AT BUILD, not
14+
// the host page's origin — without output.assetPrefix they 404 into the
15+
// shell's SPA-fallback HTML in production. dev.assetPrefix alone only
16+
// covers the local dev server.
17+
const ASSET_PREFIX = process.env.PUBLIC_ORG_WORKBENCH_ASSET_PREFIX || 'http://localhost:3014';
18+
1219
export default defineConfig({
1320
plugins: [
1421
pluginSvelte(),
@@ -27,6 +34,7 @@ export default defineConfig({
2734
output: {
2835
target: 'web',
2936
overrideBrowserslist: ['last 2 Chrome versions', 'last 2 Firefox versions', 'last 2 Safari versions'],
37+
assetPrefix: ASSET_PREFIX,
3038
},
3139
tools: {
3240
swc: {

apps/org-workbench/src/App.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
window.dispatchEvent(new CustomEvent('augment-it:active-entity', { detail }));
3636
}
3737
38-
let status = $state<'connecting' | 'open' | 'closed' | 'error'>('connecting');
38+
let status = $state<'connecting' | 'open' | 'closed' | 'error' | 'auth_required'>('connecting');
3939
let client = $state<string>('reach-edu');
4040
4141
let org = $state<OrgDetail | null>(null);

apps/pack-runner/src/App.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@
8484
const initialBundleId = readStored(BUNDLE_ID_KEY) ?? BUNDLES[0].bundle_id;
8585
const initialBundle = getBundle(initialBundleId) ?? BUNDLES[0];
8686
87-
let status = $state<'connecting' | 'open' | 'closed' | 'error'>('connecting');
87+
let status = $state<'connecting' | 'open' | 'closed' | 'error' | 'auth_required'>('connecting');
8888
let recordSets = $state<RecordSet[]>([]);
8989
let selectedRecordSetId = $state<string | null>(
9090
readStored(ACTIVE_RECORD_SET_KEY) ?? readStored(LEGACY_RECORD_SET_KEY),

apps/person-db-resolver/src/App.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
// on explicit jumps.
4545
const IDX_KEY_PREFIX = 'augment-it:person-db-resolver:idx:';
4646
47-
let status = $state<'connecting' | 'open' | 'closed' | 'error'>('connecting');
47+
let status = $state<'connecting' | 'open' | 'closed' | 'error' | 'auth_required'>('connecting');
4848
let client = $state<string>('reach-edu');
4949
5050
let recordSets = $state<RecordSet[]>([]);

0 commit comments

Comments
 (0)