feat: multi-workspace support — one login, several separate businesses #4041
7 changed files+32−18
ModifiedCLAUDE.md+3−2View fileUnifiedSplit
@@ -681,6 +681,7 @@ larger storage, unlimited accounts, dedicated support. Billing wiring pending
681681| 61 | **Inline markdown parsing unimplemented** in `packages/email-parser/src/document-model.ts:575` — bold/italic/link/code not handled, affects how some emails render in the UI | LOW | 2026-07-01 | OPEN |
682682| 62 | **MTA OTel metrics not wired** — `services/mta/src/health.ts:207` `/metrics` returns a placeholder 404 ("not_implemented") instead of real OpenTelemetry export | LOW | 2026-07-01 | OPEN — blocks real MTA health metrics in monitoring. |
683683| 63 | **Only one workspace account could ever be created** — reported as "admin section only lets me create one workspace account." Root cause was two-layered: (a) frontend bug, `apps/web/app/(dashboard)/workspace/page.tsx` Team tab only kept `res.data[0]` from `GET /v1/organizations` and hid the create form once any org existed; (b) deeper architectural gap — `users.accountId` was a hard 1:1 (one login = exactly one tenant), so even fixing the UI wouldn't have let one login manage more than one business. | HIGH | 2026-07-03 | FIXED 2026-07-03 (Craig-authorized architecture change) — new `workspace_members` table (`packages/db/src/schema/workspace-members.ts`, migration `0004_legal_lady_bullseye.sql` with backfill) decouples identity (`users`) from per-workspace role; `refresh_tokens.accountId` added so token rotation stays in the active workspace instead of reverting to the home account. New `POST/GET /v1/workspaces` + `POST /v1/auth/switch-workspace` (mints a fresh token pair after verifying membership). `organizations.ts` members/invitations rewritten against `workspace_members` (invitation-accept no longer re-parents an existing identity into a new account — it grants membership without touching their other workspaces). `/v1/auth/me`, login, register, Google OAuth, passkey, and SSO all resolve role from `workspace_members` (self-healing fallback to legacy `users.role` for pre-migration rows). Frontend: new `WorkspaceSwitcher` in the sidebar (list/switch/create), Team tab bug fixed. **Run `bun run db:migrate` on the box before this works in prod.** |
684| 64 | **Pro/Enterprise features stayed gated for paying accounts, including the owner's own top-tier account** — `apps/web/components/plan-gate.tsx` fetched `GET /v1/auth/me` and read `data.planTier` directly off the response root, but (a) the endpoint wraps its payload in `{ data: {...} }` (should have been `data.data.planTier`), and (b) `/v1/auth/me` never selected/returned a `planTier` field at all — so `PlanGate` always fell back to `"free"` regardless of the account's real plan. DB (`accounts.planTier`) and the JWT `tier` claim were correct the whole time; only this one read path was broken. | HIGH | 2026-07-03 | FIXED 2026-07-03 — `GET /v1/auth/me` (`apps/api/src/routes/auth.ts`) now selects `accounts.planTier` for the active workspace and returns it as `data.planTier`; `plan-gate.tsx` fixed to read `body.data.planTier`. Bundled into PR #90 (same endpoint already touched for the multi-workspace fix, and plan tier is naturally per-workspace under that model). |
684685
685686---
686687
@@ -840,9 +841,9 @@ legacy with no customers. Do not add domains to Vercel or propose CNAMEs back.
840841 surface exists in this repo).
841842
842843
843**Last updated:** 2026-07-03 08:56 UTC
844**Last updated:** 2026-07-03 09:05 UTC
844845**Current phase:** Phase 1 — Beta Launch in Progress. Infra migrating from Vapron box (149.28.119.158) to Jarvis box (66.42.121.161) — see Known Issue #55 and Next Actions.
845**Current focus:** Shipped multi-workspace support (issue #63, Craig-authorized) — one login can now own/join several separate workspaces via a `workspace_members` table + `/v1/workspaces` + `/v1/auth/switch-workspace`; **run `bun run db:migrate` on the box for this to take effect in prod.** Previously: full ground-truth code audit run 2026-07-01 (see Known Issues #58-62 + Product Decisions Log above) — found `services/imap` non-bootable (decided: OAuth-only, not a blocker) and `.env.production`/`.env.test` were git-tracked (fixed + pushed). Vapron Type 2 migration COMPLETE, `mail.vapron.ai` fully verified (SPF ✅ DKIM ✅ DMARC ✅ MX ✅ Return-Path ✅), smoke test passed. Outstanding Craig tasks: (1) finish 158→161 infra migration (copy `.env`, stand up `alecrae-api`/`alecrae-mta`/`alecrae-web` systemd units on 161); (2) set PTR record on 161 to `mail.alecrae.com` (currently resolves to `mail.vapron.ai`, which stays correct for 158); (3) confirm email arrives in Gmail inbox; (4) run MTA idempotency check (same message_id → no duplicate).
846**Current focus:** Shipped multi-workspace support (issue #63, Craig-authorized) — one login can now own/join several separate workspaces via a `workspace_members` table + `/v1/workspaces` + `/v1/auth/switch-workspace`; **run `bun run db:migrate` on the box for this to take effect in prod.** Also fixed issue #64 (pro/enterprise plan tier not recognized — `/v1/auth/me` never returned `planTier`). Both in PR #90. Previously: full ground-truth code audit run 2026-07-01 (see Known Issues #58-62 + Product Decisions Log above) — found `services/imap` non-bootable (decided: OAuth-only, not a blocker) and `.env.production`/`.env.test` were git-tracked (fixed + pushed). Vapron Type 2 migration COMPLETE, `mail.vapron.ai` fully verified (SPF ✅ DKIM ✅ DMARC ✅ MX ✅ Return-Path ✅), smoke test passed. Outstanding Craig tasks: (1) finish 158→161 infra migration (copy `.env`, stand up `alecrae-api`/`alecrae-mta`/`alecrae-web` systemd units on 161); (2) set PTR record on 161 to `mail.alecrae.com` (currently resolves to `mail.vapron.ai`, which stays correct for 158); (3) confirm email arrives in Gmail inbox; (4) run MTA idempotency check (same message_id → no duplicate).
846847**Build completion:** TIER 1-4 (36/36) + 7 bonus + 31 advanced (S10/10 + A7/7 + B8/8 + C6/10) + 20 expansion (Tier 5) + 9 platform (Tier 6) + 6 intelligence (Tier 7) + 6 deep AI (Tier 8)
847848
848849**Next review:** Before any major architectural change, before any production deployment, at the start of every session.
Modifiedapps/api/src/lib/dns-providers/cloudflare.ts+12−7View fileUnifiedSplit
@@ -12,7 +12,7 @@ const CF_API = "https://api.cloudflare.com/client/v4";
1212interface CfResponse<T> {
1313 success: boolean;
1414 result: T;
15 errors: Array<{ code: number; message: string }>;
15 errors: { code: number; message: string }[];
1616}
1717
1818interface CfZone {
@@ -53,8 +53,9 @@ async function findZoneId(token: string, domain: string): Promise<string | null>
5353 token,
5454 `/zones?name=${encodeURIComponent(candidate)}&status=active`,
5555 );
56 if (data.success && data.result?.length > 0) {
57 return data.result[0].id;
56 const zone = data.result?.[0];
57 if (data.success && zone) {
58 return zone.id;
5859 }
5960 }
6061 return null;
@@ -86,10 +87,10 @@ async function upsertRecord(
8687 ttl: 1, // auto
8788 proxied: false,
8889 };
89 if (record.priority != null) payload.priority = record.priority;
90 if (record.priority !== undefined && record.priority !== null) payload.priority = record.priority;
9091
91 if (existing.result?.length > 0) {
92 const hit = existing.result[0];
92 const hit = existing.result?.[0];
93 if (hit) {
9394 if (hit.content === record.value && !hit.proxied) {
9495 return { type: record.type, name: record.name, status: "existed" };
9596 }
@@ -133,7 +134,11 @@ export async function configureCloudflare(
133134 }
134135
135136 // Derive zone from the first record (all records share the same zone)
136 const zoneId = await findZoneId(apiToken, records[0].name);
137 const firstRecord = records[0];
138 if (!firstRecord) {
139 return { success: false, records: [], error: "No DNS records to configure" };
140 }
141 const zoneId = await findZoneId(apiToken, firstRecord.name);
137142 if (!zoneId) {
138143 return {
139144 success: false,
Modifiedapps/api/src/lib/dns-providers/godaddy.ts+1−1View fileUnifiedSplit
@@ -52,7 +52,7 @@ export async function configureGodaddy(
5252 {
5353 data: rec.value,
5454 ttl: 600,
55 ...(rec.priority != null ? { priority: rec.priority } : {}),
55 ...(rec.priority !== undefined && rec.priority !== null ? { priority: rec.priority } : {}),
5656 },
5757 ];
5858
Modifiedapps/api/src/lib/dns-providers/porkbun.ts+4−4View fileUnifiedSplit
@@ -81,10 +81,10 @@ export async function configurePorkbun(
8181 name: relName,
8282 content: rec.value,
8383 ttl: "600",
84 ...(rec.priority != null ? { prio: String(rec.priority) } : {}),
84 ...(rec.priority !== undefined && rec.priority !== null ? { prio: String(rec.priority) } : {}),
8585 });
8686 if (editRes.status !== "SUCCESS") {
87 return { type: rec.type, name: rec.name, status: "failed", error: editRes.message };
87 return { type: rec.type, name: rec.name, status: "failed", error: editRes.message ?? "Update failed" };
8888 }
8989 return { type: rec.type, name: rec.name, status: "updated" };
9090 }
@@ -94,10 +94,10 @@ export async function configurePorkbun(
9494 name: relName,
9595 content: rec.value,
9696 ttl: "600",
97 ...(rec.priority != null ? { prio: String(rec.priority) } : {}),
97 ...(rec.priority !== undefined && rec.priority !== null ? { prio: String(rec.priority) } : {}),
9898 });
9999 if (createRes.status !== "SUCCESS") {
100 return { type: rec.type, name: rec.name, status: "failed", error: createRes.message };
100 return { type: rec.type, name: rec.name, status: "failed", error: createRes.message ?? "Create failed" };
101101 }
102102 return { type: rec.type, name: rec.name, status: "created" };
103103 }),
Modifiedapps/api/src/routes/agent.ts+1−1View fileUnifiedSplit
@@ -31,7 +31,7 @@
3131
3232import { Hono } from "hono";
3333import { z } from "zod";
34import { eq, and, desc, gte, sql } from "drizzle-orm";
34import { eq, and, desc, gte } from "drizzle-orm";
3535import { randomUUID } from "node:crypto";
3636
3737import { requireScope } from "../middleware/auth.js";
Modifiedapps/api/src/routes/auth.ts+9−1View fileUnifiedSplit
@@ -653,8 +653,16 @@ auth.get("/me", async (c) => {
653653 const role = await getWorkspaceRole(user.id, session.accountId);
654654 if (!role) return c.json(unauthenticatedResponse(), 401);
655655
656 // planTier was never returned here, so the frontend's PlanGate always fell
657 // back to "free" regardless of the account's actual plan.
658 const [account] = await db
659 .select({ planTier: accounts.planTier })
660 .from(accounts)
661 .where(eq(accounts.id, session.accountId))
662 .limit(1);
663
656664 return c.json({
657 data: { ...user, accountId: session.accountId, role },
665 data: { ...user, accountId: session.accountId, role, planTier: account?.planTier ?? "free" },
658666 });
659667});
660668
Modifiedapps/web/components/plan-gate.tsx+2−2View fileUnifiedSplit
@@ -22,8 +22,8 @@ export function PlanGate({ feature: _feature, required, children, showUpgrade =
2222 headers: { Authorization: `Bearer ${token}` },
2323 });
2424 if (res.ok) {
25 const data = await res.json() as { planTier?: string };
26 setPlan(normalizeApiPlanTier(data.planTier));
25 const body = await res.json() as { data?: { planTier?: string } };
26 setPlan(normalizeApiPlanTier(body.data?.planTier));
2727 } else {
2828 setPlan("free");
2929 }
3030
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts