@mars-stack/cli 0.2.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/dist/index.js +137 -12
  2. package/dist/index.js.map +1 -1
  3. package/package.json +4 -3
  4. package/template/.cursor/rules/composition-patterns.mdc +186 -0
  5. package/template/.cursor/rules/data-access.mdc +29 -0
  6. package/template/.cursor/rules/project-structure.mdc +34 -0
  7. package/template/.cursor/rules/security.mdc +25 -0
  8. package/template/.cursor/rules/testing.mdc +24 -0
  9. package/template/.cursor/rules/ui-conventions.mdc +29 -0
  10. package/template/.cursor/skills/add-api-route/SKILL.md +122 -0
  11. package/template/.cursor/skills/add-audit-log/SKILL.md +375 -0
  12. package/template/.cursor/skills/add-blog/SKILL.md +447 -0
  13. package/template/.cursor/skills/add-command-palette/SKILL.md +438 -0
  14. package/template/.cursor/skills/add-component/SKILL.md +158 -0
  15. package/template/.cursor/skills/add-crud-routes/SKILL.md +221 -0
  16. package/template/.cursor/skills/add-e2e-test/SKILL.md +227 -0
  17. package/template/.cursor/skills/add-error-boundary/SKILL.md +472 -0
  18. package/template/.cursor/skills/add-feature/SKILL.md +174 -0
  19. package/template/.cursor/skills/add-middleware/SKILL.md +135 -0
  20. package/template/.cursor/skills/add-page/SKILL.md +151 -0
  21. package/template/.cursor/skills/add-prisma-model/SKILL.md +148 -0
  22. package/template/.cursor/skills/add-protected-resource/SKILL.md +192 -0
  23. package/template/.cursor/skills/add-role/SKILL.md +156 -0
  24. package/template/.cursor/skills/add-server-action/SKILL.md +167 -0
  25. package/template/.cursor/skills/add-webhook/SKILL.md +192 -0
  26. package/template/.cursor/skills/build-complete-feature/SKILL.md +227 -0
  27. package/template/.cursor/skills/build-dashboard/SKILL.md +211 -0
  28. package/template/.cursor/skills/build-data-table/SKILL.md +283 -0
  29. package/template/.cursor/skills/build-form/SKILL.md +231 -0
  30. package/template/.cursor/skills/build-landing-page/SKILL.md +248 -0
  31. package/template/.cursor/skills/configure-ai/SKILL.md +617 -0
  32. package/template/.cursor/skills/configure-analytics/SKILL.md +413 -0
  33. package/template/.cursor/skills/configure-dark-mode/SKILL.md +309 -0
  34. package/template/.cursor/skills/configure-email/SKILL.md +170 -0
  35. package/template/.cursor/skills/configure-email-verification/SKILL.md +333 -0
  36. package/template/.cursor/skills/configure-feature-flags/SKILL.md +361 -0
  37. package/template/.cursor/skills/configure-i18n/SKILL.md +518 -0
  38. package/template/.cursor/skills/configure-jobs/SKILL.md +500 -0
  39. package/template/.cursor/skills/configure-magic-links/SKILL.md +385 -0
  40. package/template/.cursor/skills/configure-multi-tenancy/SKILL.md +611 -0
  41. package/template/.cursor/skills/configure-notifications/SKILL.md +569 -0
  42. package/template/.cursor/skills/configure-oauth/SKILL.md +217 -0
  43. package/template/.cursor/skills/configure-onboarding/SKILL.md +483 -0
  44. package/template/.cursor/skills/configure-payments/SKILL.md +243 -0
  45. package/template/.cursor/skills/configure-realtime/SKILL.md +733 -0
  46. package/template/.cursor/skills/configure-search/SKILL.md +581 -0
  47. package/template/.cursor/skills/configure-storage/SKILL.md +273 -0
  48. package/template/.cursor/skills/configure-two-factor/SKILL.md +518 -0
  49. package/template/.cursor/skills/create-execution-plan/SKILL.md +204 -0
  50. package/template/.cursor/skills/create-seed/SKILL.md +191 -0
  51. package/template/.cursor/skills/deploy-to-vercel/SKILL.md +300 -0
  52. package/template/.cursor/skills/design-tokens/SKILL.md +138 -0
  53. package/template/.cursor/skills/mars-capture-conversation-context/SKILL.md +119 -0
  54. package/template/.cursor/skills/setup-billing/SKILL.md +322 -0
  55. package/template/.cursor/skills/setup-project/SKILL.md +104 -0
  56. package/template/.cursor/skills/setup-teams/SKILL.md +682 -0
  57. package/template/.cursor/skills/test-api-route/SKILL.md +219 -0
  58. package/template/.cursor/skills/update-architecture-docs/SKILL.md +99 -0
  59. package/template/AGENTS.md +104 -0
  60. package/template/ARCHITECTURE.md +102 -0
  61. package/template/docs/QUALITY_SCORE.md +20 -0
  62. package/template/docs/design-docs/conversation-as-system-record.md +70 -0
  63. package/template/docs/design-docs/core-beliefs.md +43 -0
  64. package/template/docs/design-docs/index.md +8 -0
  65. package/template/docs/exec-plans/active/.gitkeep +0 -0
  66. package/template/docs/exec-plans/completed/.gitkeep +0 -0
  67. package/template/docs/exec-plans/tech-debt.md +7 -0
  68. package/template/docs/generated/.gitkeep +0 -0
  69. package/template/docs/product-specs/index.md +7 -0
  70. package/template/docs/references/index.md +18 -0
  71. package/template/e2e/api.spec.ts +20 -0
  72. package/template/e2e/auth.spec.ts +24 -0
  73. package/template/e2e/public.spec.ts +25 -0
  74. package/template/eslint.config.mjs +24 -0
  75. package/template/next-env.d.ts +6 -0
  76. package/template/next.config.ts +45 -0
  77. package/template/package.json +80 -0
  78. package/template/playwright.config.ts +31 -0
  79. package/template/postcss.config.mjs +8 -0
  80. package/template/prisma/generated/prisma/browser.ts +49 -0
  81. package/template/prisma/generated/prisma/client.ts +73 -0
  82. package/template/prisma/generated/prisma/commonInputTypes.ts +406 -0
  83. package/template/prisma/generated/prisma/enums.ts +15 -0
  84. package/template/prisma/generated/prisma/internal/class.ts +254 -0
  85. package/template/prisma/generated/prisma/internal/prismaNamespace.ts +1240 -0
  86. package/template/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
  87. package/template/prisma/generated/prisma/models/Account.ts +1543 -0
  88. package/template/prisma/generated/prisma/models/File.ts +1529 -0
  89. package/template/prisma/generated/prisma/models/Session.ts +1415 -0
  90. package/template/prisma/generated/prisma/models/Subscription.ts +1455 -0
  91. package/template/prisma/generated/prisma/models/User.ts +2235 -0
  92. package/template/prisma/generated/prisma/models/VerificationToken.ts +1099 -0
  93. package/template/prisma/generated/prisma/models.ts +17 -0
  94. package/template/prisma/schema/auth.prisma +69 -0
  95. package/template/prisma/schema/base.prisma +8 -0
  96. package/template/prisma/schema/file.prisma +15 -0
  97. package/template/prisma/schema/subscription.prisma +17 -0
  98. package/template/prisma.config.ts +13 -0
  99. package/template/scripts/check-architecture.ts +221 -0
  100. package/template/scripts/check-doc-freshness.ts +242 -0
  101. package/template/scripts/ensure-db.mjs +291 -0
  102. package/template/scripts/generate-docs.ts +143 -0
  103. package/template/scripts/generate-env-example.ts +89 -0
  104. package/template/scripts/seed.ts +56 -0
  105. package/template/scripts/update-quality-score.ts +263 -0
  106. package/template/src/__tests__/architecture.test.ts +114 -0
  107. package/template/src/app/(auth)/forgotten-password/page.tsx +92 -0
  108. package/template/src/app/(auth)/layout.tsx +11 -0
  109. package/template/src/app/(auth)/register/page.tsx +162 -0
  110. package/template/src/app/(auth)/reset-password/page.tsx +109 -0
  111. package/template/src/app/(auth)/sign-in/page.tsx +122 -0
  112. package/template/src/app/(auth)/verify/[token]/page.tsx +87 -0
  113. package/template/src/app/(auth)/verify/page.tsx +56 -0
  114. package/template/src/app/(protected)/admin/page.tsx +108 -0
  115. package/template/src/app/(protected)/dashboard/loading.tsx +20 -0
  116. package/template/src/app/(protected)/dashboard/page.tsx +22 -0
  117. package/template/src/app/(protected)/layout.tsx +262 -0
  118. package/template/src/app/(protected)/settings/page.tsx +370 -0
  119. package/template/src/app/api/auth/forgot/route.ts +63 -0
  120. package/template/src/app/api/auth/login/route.ts +121 -0
  121. package/template/src/app/api/auth/logout/route.ts +19 -0
  122. package/template/src/app/api/auth/me/route.ts +30 -0
  123. package/template/src/app/api/auth/reset/route.ts +45 -0
  124. package/template/src/app/api/auth/signup/route.ts +85 -0
  125. package/template/src/app/api/auth/verify/route.ts +46 -0
  126. package/template/src/app/api/csrf/route.ts +12 -0
  127. package/template/src/app/api/health/route.ts +10 -0
  128. package/template/src/app/api/protected/admin/users/route.ts +24 -0
  129. package/template/src/app/api/protected/billing/checkout/route.ts +83 -0
  130. package/template/src/app/api/protected/billing/portal/route.ts +39 -0
  131. package/template/src/app/api/protected/files/[fileId]/route.ts +86 -0
  132. package/template/src/app/api/protected/files/upload/route.ts +64 -0
  133. package/template/src/app/api/protected/user/password/route.ts +63 -0
  134. package/template/src/app/api/protected/user/profile/route.ts +35 -0
  135. package/template/src/app/api/protected/user/sessions/[sessionId]/route.ts +33 -0
  136. package/template/src/app/api/protected/user/sessions/route.ts +22 -0
  137. package/template/src/app/api/readiness/route.ts +15 -0
  138. package/template/src/app/api/webhooks/stripe/route.ts +166 -0
  139. package/template/src/app/error.tsx +33 -0
  140. package/template/src/app/layout.tsx +29 -0
  141. package/template/src/app/not-found.tsx +20 -0
  142. package/template/src/app/page.tsx +136 -0
  143. package/template/src/app/privacy/page.tsx +178 -0
  144. package/template/src/app/providers.tsx +8 -0
  145. package/template/src/app/terms/page.tsx +139 -0
  146. package/template/src/config/app.config.ts +70 -0
  147. package/template/src/config/routes.ts +17 -0
  148. package/template/src/features/admin/index.ts +11 -0
  149. package/template/src/features/admin/permissions.ts +64 -0
  150. package/template/src/features/auth/context/AuthContext.tsx +96 -0
  151. package/template/src/features/auth/context/index.ts +2 -0
  152. package/template/src/features/auth/index.ts +3 -0
  153. package/template/src/features/auth/server/consent.ts +66 -0
  154. package/template/src/features/auth/server/session-revocation.ts +20 -0
  155. package/template/src/features/auth/server/sessions.ts +66 -0
  156. package/template/src/features/auth/server/user.ts +166 -0
  157. package/template/src/features/auth/types.ts +19 -0
  158. package/template/src/features/auth/validators.ts +29 -0
  159. package/template/src/features/billing/server/index.ts +66 -0
  160. package/template/src/features/billing/types.ts +43 -0
  161. package/template/src/features/uploads/server/index.ts +49 -0
  162. package/template/src/features/uploads/types.ts +26 -0
  163. package/template/src/lib/core/email/templates/base-layout.ts +122 -0
  164. package/template/src/lib/core/email/templates/index.ts +4 -0
  165. package/template/src/lib/core/email/templates/password-reset-email.ts +42 -0
  166. package/template/src/lib/core/email/templates/verification-email.ts +41 -0
  167. package/template/src/lib/core/email/templates/welcome-email.ts +40 -0
  168. package/template/src/lib/mars.ts +56 -0
  169. package/template/src/lib/prisma.ts +19 -0
  170. package/template/src/proxy.ts +92 -0
  171. package/template/src/styles/brand.css +15 -0
  172. package/template/src/styles/globals.css +7 -0
  173. package/template/tsconfig.json +59 -0
  174. package/template/vitest.config.ts +41 -0
  175. package/template/vitest.setup.ts +24 -0
@@ -0,0 +1,611 @@
1
+ # Skill: Configure Multi-Tenancy
2
+
3
+ Add teams and organizations to a MARS application, enabling multi-tenant data isolation, role-based org access, and member invitation flows.
4
+
5
+ ## When to Use
6
+
7
+ Use this skill when the user asks to add organizations, teams, workspaces, multi-tenancy, or team-based access control (e.g., "add teams", "add organizations", "support multiple workspaces").
8
+
9
+ ## Prerequisites
10
+
11
+ - Auth feature is already working (users can sign up and log in).
12
+ - Read `src/config/app.config.ts` to check current feature flags.
13
+ - Decide on scoping strategy: **org-level** (simpler — all members share one flat namespace) vs. **team-level** (nested teams within an org for department-style isolation). Start with org-level unless the user explicitly needs teams.
14
+
15
+ ## Step 1: Prisma Schema
16
+
17
+ Create `prisma/schema/organization.prisma`:
18
+
19
+ ```prisma
20
+ model Organization {
21
+ id String @id @default(cuid())
22
+ name String
23
+ slug String @unique
24
+ plan String @default("free")
25
+ createdAt DateTime @default(now())
26
+ updatedAt DateTime @updatedAt
27
+
28
+ memberships Membership[]
29
+ invitations Invitation[]
30
+ teams Team[]
31
+
32
+ @@index([slug])
33
+ }
34
+
35
+ model Team {
36
+ id String @id @default(cuid())
37
+ name String
38
+ orgId String
39
+
40
+ organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
41
+
42
+ @@index([orgId])
43
+ }
44
+
45
+ model Membership {
46
+ id String @id @default(cuid())
47
+ userId String
48
+ orgId String
49
+ role String @default("member") // owner | admin | member | viewer
50
+ joinedAt DateTime @default(now())
51
+
52
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
53
+ organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
54
+
55
+ @@unique([userId, orgId])
56
+ @@index([orgId])
57
+ @@index([userId])
58
+ }
59
+
60
+ model Invitation {
61
+ id String @id @default(cuid())
62
+ orgId String
63
+ email String
64
+ role String @default("member")
65
+ token String @unique @default(cuid())
66
+ expiresAt DateTime
67
+ acceptedAt DateTime?
68
+ createdAt DateTime @default(now())
69
+
70
+ organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
71
+
72
+ @@index([orgId])
73
+ @@index([token])
74
+ @@index([email])
75
+ }
76
+ ```
77
+
78
+ Add the relation to the User model in `prisma/schema/auth.prisma`:
79
+
80
+ ```prisma
81
+ model User {
82
+ // ... existing fields
83
+ memberships Membership[]
84
+ }
85
+ ```
86
+
87
+ Run `yarn db:push` to sync.
88
+
89
+ ## Step 2: Types
90
+
91
+ Create `src/features/organizations/types.ts`:
92
+
93
+ ```typescript
94
+ export type OrgRole = 'owner' | 'admin' | 'member' | 'viewer';
95
+
96
+ export const ORG_ROLE_HIERARCHY: Record<OrgRole, number> = {
97
+ owner: 40,
98
+ admin: 30,
99
+ member: 20,
100
+ viewer: 10,
101
+ };
102
+
103
+ export interface OrgContext {
104
+ orgId: string;
105
+ orgSlug: string;
106
+ role: OrgRole;
107
+ }
108
+ ```
109
+
110
+ ## Step 3: Validation Schemas
111
+
112
+ Create `src/features/organizations/validation/schemas.ts`:
113
+
114
+ ```typescript
115
+ import { z } from 'zod';
116
+
117
+ export const organizationSchemas = {
118
+ create: z.object({
119
+ name: z.string().min(1, 'Name is required').max(100),
120
+ slug: z.string().min(2).max(50).regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens'),
121
+ }),
122
+ update: z.object({
123
+ name: z.string().min(1).max(100).optional(),
124
+ slug: z.string().min(2).max(50).regex(/^[a-z0-9-]+$/).optional(),
125
+ plan: z.string().max(50).optional(),
126
+ }),
127
+ };
128
+
129
+ export const membershipSchemas = {
130
+ updateRole: z.object({
131
+ role: z.enum(['admin', 'member', 'viewer']),
132
+ }),
133
+ };
134
+
135
+ export const invitationSchemas = {
136
+ create: z.object({
137
+ email: z.string().email('Valid email required'),
138
+ role: z.enum(['admin', 'member', 'viewer']).default('member'),
139
+ }),
140
+ };
141
+
142
+ export type CreateOrganizationInput = z.infer<typeof organizationSchemas.create>;
143
+ export type UpdateOrganizationInput = z.infer<typeof organizationSchemas.update>;
144
+ export type CreateInvitationInput = z.infer<typeof invitationSchemas.create>;
145
+ ```
146
+
147
+ ## Step 4: Server Functions
148
+
149
+ Create `src/features/organizations/server/index.ts`:
150
+
151
+ ```typescript
152
+ import 'server-only';
153
+
154
+ import { prisma } from '@/lib/prisma';
155
+ import type { CreateOrganizationInput, UpdateOrganizationInput, CreateInvitationInput } from '../validation/schemas';
156
+ import type { OrgRole } from '../types';
157
+
158
+ // --- Organization CRUD ---
159
+
160
+ export async function findOrganizationsByUserId(userId: string) {
161
+ return prisma.organization.findMany({
162
+ where: { memberships: { some: { userId } } },
163
+ include: {
164
+ memberships: { where: { userId }, select: { role: true } },
165
+ _count: { select: { memberships: true } },
166
+ },
167
+ orderBy: { createdAt: 'desc' },
168
+ });
169
+ }
170
+
171
+ export async function findOrganizationById(orgId: string) {
172
+ return prisma.organization.findUnique({
173
+ where: { id: orgId },
174
+ include: { _count: { select: { memberships: true, teams: true } } },
175
+ });
176
+ }
177
+
178
+ export async function createOrganization(userId: string, data: CreateOrganizationInput) {
179
+ return prisma.$transaction(async (tx) => {
180
+ const org = await tx.organization.create({ data });
181
+ await tx.membership.create({
182
+ data: { userId, orgId: org.id, role: 'owner' },
183
+ });
184
+ return org;
185
+ });
186
+ }
187
+
188
+ export async function updateOrganization(orgId: string, data: UpdateOrganizationInput) {
189
+ return prisma.organization.update({ where: { id: orgId }, data });
190
+ }
191
+
192
+ export async function deleteOrganization(orgId: string) {
193
+ return prisma.organization.delete({ where: { id: orgId } });
194
+ }
195
+
196
+ // --- Membership ---
197
+
198
+ export async function findMembershipsByOrgId(orgId: string) {
199
+ return prisma.membership.findMany({
200
+ where: { orgId },
201
+ include: { user: { select: { id: true, email: true, name: true } } },
202
+ orderBy: { joinedAt: 'asc' },
203
+ });
204
+ }
205
+
206
+ export async function getUserMembership(userId: string, orgId: string) {
207
+ return prisma.membership.findUnique({
208
+ where: { userId_orgId: { userId, orgId } },
209
+ });
210
+ }
211
+
212
+ export async function updateMemberRole(membershipId: string, role: OrgRole) {
213
+ return prisma.membership.update({ where: { id: membershipId }, data: { role } });
214
+ }
215
+
216
+ export async function removeMember(membershipId: string) {
217
+ return prisma.membership.delete({ where: { id: membershipId } });
218
+ }
219
+
220
+ // --- Invitations ---
221
+
222
+ export async function findInvitationsByOrgId(orgId: string) {
223
+ return prisma.invitation.findMany({
224
+ where: { orgId, acceptedAt: null, expiresAt: { gt: new Date() } },
225
+ orderBy: { createdAt: 'desc' },
226
+ });
227
+ }
228
+
229
+ export async function createInvitation(orgId: string, data: CreateInvitationInput) {
230
+ const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
231
+ return prisma.invitation.create({
232
+ data: { orgId, email: data.email, role: data.role, expiresAt },
233
+ });
234
+ }
235
+
236
+ export async function acceptInvitation(token: string, userId: string) {
237
+ return prisma.$transaction(async (tx) => {
238
+ const invitation = await tx.invitation.findUnique({ where: { token } });
239
+
240
+ if (!invitation || invitation.acceptedAt || invitation.expiresAt < new Date()) {
241
+ throw new Error('Invitation is invalid or expired');
242
+ }
243
+
244
+ await tx.membership.create({
245
+ data: { userId, orgId: invitation.orgId, role: invitation.role as OrgRole },
246
+ });
247
+
248
+ await tx.invitation.update({
249
+ where: { id: invitation.id },
250
+ data: { acceptedAt: new Date() },
251
+ });
252
+
253
+ return invitation;
254
+ });
255
+ }
256
+
257
+ export async function revokeInvitation(invitationId: string) {
258
+ return prisma.invitation.delete({ where: { id: invitationId } });
259
+ }
260
+ ```
261
+
262
+ ## Step 5: Auth Middleware — `withOrgAccess`
263
+
264
+ Create `src/features/organizations/server/middleware.ts`:
265
+
266
+ ```typescript
267
+ import 'server-only';
268
+
269
+ import { NextResponse, type NextRequest } from 'next/server';
270
+ import { verifySessionForAPI } from '@/lib/mars';
271
+ import { getUserMembership } from './index';
272
+ import { ORG_ROLE_HIERARCHY, type OrgRole } from '../types';
273
+
274
+ interface OrgAuthenticatedRequest extends NextRequest {
275
+ session: { userId: string; email: string };
276
+ org: { orgId: string; role: OrgRole };
277
+ }
278
+
279
+ type OrgRouteParams = { params: Promise<{ orgId: string }> };
280
+
281
+ /**
282
+ * Verifies the user has the required role (or higher) in the organization.
283
+ * Extracts orgId from route params.
284
+ */
285
+ export function withOrgAccess(
286
+ requiredRoles: OrgRole[],
287
+ handler: (request: OrgAuthenticatedRequest, context: OrgRouteParams) => Promise<NextResponse>,
288
+ ) {
289
+ return async (request: NextRequest, context: OrgRouteParams) => {
290
+ const session = await verifySessionForAPI(request);
291
+ if (!session) {
292
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
293
+ }
294
+
295
+ const { orgId } = await context.params;
296
+ const membership = await getUserMembership(session.userId, orgId);
297
+
298
+ if (!membership) {
299
+ return NextResponse.json({ error: 'Not a member of this organization' }, { status: 403 });
300
+ }
301
+
302
+ const userLevel = ORG_ROLE_HIERARCHY[membership.role as OrgRole];
303
+ const requiredLevel = Math.min(...requiredRoles.map((r) => ORG_ROLE_HIERARCHY[r]));
304
+
305
+ if (userLevel < requiredLevel) {
306
+ return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
307
+ }
308
+
309
+ const authedRequest = request as OrgAuthenticatedRequest;
310
+ authedRequest.session = session;
311
+ authedRequest.org = { orgId, role: membership.role as OrgRole };
312
+
313
+ return handler(authedRequest, context);
314
+ };
315
+ }
316
+ ```
317
+
318
+ ## Step 6: API Routes
319
+
320
+ ### List user's organizations — `src/app/api/protected/organizations/route.ts`
321
+
322
+ ```typescript
323
+ import { handleApiError, withAuthNoParams, type AuthenticatedRequest } from '@/lib/mars';
324
+ import { findOrganizationsByUserId, createOrganization } from '@/features/organizations/server';
325
+ import { organizationSchemas } from '@/features/organizations/validation/schemas';
326
+ import { NextResponse } from 'next/server';
327
+
328
+ export const GET = withAuthNoParams(async (request: AuthenticatedRequest) => {
329
+ try {
330
+ const orgs = await findOrganizationsByUserId(request.session.userId);
331
+ return NextResponse.json(orgs);
332
+ } catch (error) {
333
+ return handleApiError(error, { endpoint: '/api/protected/organizations' });
334
+ }
335
+ });
336
+
337
+ export const POST = withAuthNoParams(async (request: AuthenticatedRequest) => {
338
+ try {
339
+ const body = organizationSchemas.create.parse(await request.json());
340
+ const org = await createOrganization(request.session.userId, body);
341
+ return NextResponse.json(org, { status: 201 });
342
+ } catch (error) {
343
+ return handleApiError(error, { endpoint: '/api/protected/organizations' });
344
+ }
345
+ });
346
+ ```
347
+
348
+ ### Organization details — `src/app/api/protected/organizations/[orgId]/route.ts`
349
+
350
+ ```typescript
351
+ import { handleApiError } from '@/lib/mars';
352
+ import { findOrganizationById, updateOrganization, deleteOrganization } from '@/features/organizations/server';
353
+ import { withOrgAccess } from '@/features/organizations/server/middleware';
354
+ import { organizationSchemas } from '@/features/organizations/validation/schemas';
355
+ import { NextResponse } from 'next/server';
356
+
357
+ export const GET = withOrgAccess(['viewer'], async (request, context) => {
358
+ try {
359
+ const org = await findOrganizationById(request.org.orgId);
360
+ if (!org) return NextResponse.json({ error: 'Not found' }, { status: 404 });
361
+ return NextResponse.json(org);
362
+ } catch (error) {
363
+ return handleApiError(error, { endpoint: '/api/protected/organizations/[orgId]' });
364
+ }
365
+ });
366
+
367
+ export const PATCH = withOrgAccess(['admin'], async (request, context) => {
368
+ try {
369
+ const body = organizationSchemas.update.parse(await request.json());
370
+ const org = await updateOrganization(request.org.orgId, body);
371
+ return NextResponse.json(org);
372
+ } catch (error) {
373
+ return handleApiError(error, { endpoint: '/api/protected/organizations/[orgId]' });
374
+ }
375
+ });
376
+
377
+ export const DELETE = withOrgAccess(['owner'], async (request, context) => {
378
+ try {
379
+ await deleteOrganization(request.org.orgId);
380
+ return NextResponse.json({ success: true });
381
+ } catch (error) {
382
+ return handleApiError(error, { endpoint: '/api/protected/organizations/[orgId]' });
383
+ }
384
+ });
385
+ ```
386
+
387
+ ### Members — `src/app/api/protected/organizations/[orgId]/members/route.ts`
388
+
389
+ ```typescript
390
+ import { handleApiError } from '@/lib/mars';
391
+ import { findMembershipsByOrgId, updateMemberRole, removeMember } from '@/features/organizations/server';
392
+ import { withOrgAccess } from '@/features/organizations/server/middleware';
393
+ import { membershipSchemas } from '@/features/organizations/validation/schemas';
394
+ import { NextResponse } from 'next/server';
395
+
396
+ export const GET = withOrgAccess(['viewer'], async (request) => {
397
+ try {
398
+ const members = await findMembershipsByOrgId(request.org.orgId);
399
+ return NextResponse.json(members);
400
+ } catch (error) {
401
+ return handleApiError(error, { endpoint: '/api/protected/organizations/[orgId]/members' });
402
+ }
403
+ });
404
+ ```
405
+
406
+ ### Invitations — `src/app/api/protected/organizations/[orgId]/invitations/route.ts`
407
+
408
+ ```typescript
409
+ import { handleApiError } from '@/lib/mars';
410
+ import { findInvitationsByOrgId, createInvitation, revokeInvitation } from '@/features/organizations/server';
411
+ import { withOrgAccess } from '@/features/organizations/server/middleware';
412
+ import { invitationSchemas } from '@/features/organizations/validation/schemas';
413
+ import { NextResponse } from 'next/server';
414
+
415
+ export const GET = withOrgAccess(['admin'], async (request) => {
416
+ try {
417
+ const invitations = await findInvitationsByOrgId(request.org.orgId);
418
+ return NextResponse.json(invitations);
419
+ } catch (error) {
420
+ return handleApiError(error, { endpoint: '/api/protected/organizations/[orgId]/invitations' });
421
+ }
422
+ });
423
+
424
+ export const POST = withOrgAccess(['admin'], async (request) => {
425
+ try {
426
+ const body = invitationSchemas.create.parse(await request.json());
427
+ const invitation = await createInvitation(request.org.orgId, body);
428
+ return NextResponse.json(invitation, { status: 201 });
429
+ } catch (error) {
430
+ return handleApiError(error, { endpoint: '/api/protected/organizations/[orgId]/invitations' });
431
+ }
432
+ });
433
+ ```
434
+
435
+ ## Step 7: Add Org Context to Session
436
+
437
+ Extend the JWT payload to include the user's active organization. In `src/features/auth/server/session.ts` (or wherever sessions are built):
438
+
439
+ ```typescript
440
+ interface SessionPayload {
441
+ userId: string;
442
+ email: string;
443
+ role: string;
444
+ activeOrgId?: string; // Add this
445
+ activeOrgRole?: string; // Add this
446
+ }
447
+ ```
448
+
449
+ When the user switches orgs, call a dedicated endpoint to update the session cookie with the new `activeOrgId` and `activeOrgRole`. This keeps the JWT lean while letting middleware make fast decisions.
450
+
451
+ Alternatively, look up org membership on each request via the `withOrgAccess` middleware (no JWT change needed). This is safer for role changes that need to take effect immediately, but adds a DB query per request.
452
+
453
+ **Recommendation:** Use `withOrgAccess` middleware for API routes (always fresh from DB) and store `activeOrgId` in the JWT only for UI convenience (org switcher default selection).
454
+
455
+ ## Step 8: Migration Path — Single-User to Multi-Tenant
456
+
457
+ For existing projects that started as single-user:
458
+
459
+ 1. Run the Prisma migration to add the Organization, Team, Membership, and Invitation models.
460
+ 2. Create a backfill script:
461
+
462
+ ```typescript
463
+ // scripts/backfill-orgs.ts
464
+ import { prisma } from '@/lib/prisma';
465
+
466
+ async function backfill() {
467
+ const users = await prisma.user.findMany();
468
+
469
+ for (const user of users) {
470
+ const org = await prisma.organization.create({
471
+ data: {
472
+ name: `${user.name ?? user.email}'s Organization`,
473
+ slug: `user-${user.id.slice(0, 8)}`,
474
+ },
475
+ });
476
+
477
+ await prisma.membership.create({
478
+ data: { userId: user.id, orgId: org.id, role: 'owner' },
479
+ });
480
+ }
481
+
482
+ console.log(`Backfilled ${users.length} users into personal organizations.`);
483
+ }
484
+
485
+ backfill();
486
+ ```
487
+
488
+ 3. Add `orgId` to existing data models that need scoping, then backfill with the user's personal org ID.
489
+ 4. Update queries to scope by `orgId` instead of (or in addition to) `userId`.
490
+
491
+ ## Step 9: UI Patterns
492
+
493
+ ### Org Switcher Component
494
+
495
+ ```typescript
496
+ // src/features/organizations/components/org-switcher.tsx
497
+ 'use client';
498
+
499
+ import { Select } from '@mars-stack/ui';
500
+ import { useRouter } from 'next/navigation';
501
+
502
+ interface OrgSwitcherProps {
503
+ organizations: Array<{ id: string; name: string; slug: string }>;
504
+ activeOrgId: string;
505
+ }
506
+
507
+ export function OrgSwitcher({ organizations, activeOrgId }: OrgSwitcherProps) {
508
+ const router = useRouter();
509
+
510
+ async function handleSwitch(orgId: string) {
511
+ await fetch('/api/protected/organizations/switch', {
512
+ method: 'POST',
513
+ headers: { 'Content-Type': 'application/json' },
514
+ body: JSON.stringify({ orgId }),
515
+ });
516
+ router.refresh();
517
+ }
518
+
519
+ return (
520
+ <Select
521
+ value={activeOrgId}
522
+ onValueChange={handleSwitch}
523
+ options={organizations.map((org) => ({
524
+ label: org.name,
525
+ value: org.id,
526
+ }))}
527
+ />
528
+ );
529
+ }
530
+ ```
531
+
532
+ ### Invitation Flow
533
+
534
+ 1. Admin creates invitation via `POST /api/protected/organizations/[orgId]/invitations`.
535
+ 2. Send invitation email with a link: `{APP_URL}/invite/{token}`.
536
+ 3. Invite page calls `POST /api/invitations/accept` with the token and the authenticated user's session.
537
+ 4. On success, redirect to the organization dashboard.
538
+
539
+ ## Decision Notes
540
+
541
+ - **Org-level vs. team-level scoping:** Start with org-level scoping (all data belongs to the org, all members can access). Add teams only when the user needs sub-org isolation (e.g., departments that shouldn't see each other's data). Teams add a second join in every query.
542
+ - **Owner role is non-transferable by default.** Build a dedicated "transfer ownership" flow if needed rather than letting admins promote to owner.
543
+ - **Slug uniqueness** is enforced at the database level. Expose a `/api/protected/organizations/check-slug` endpoint so the UI can validate before submission.
544
+ - **Soft delete vs. hard delete:** The schema above uses hard delete (cascading). For audit trails, add a `deletedAt` column and filter with `where: { deletedAt: null }`.
545
+
546
+ ## Feature Flag
547
+
548
+ Add to `src/config/app.config.ts`:
549
+
550
+ ```typescript
551
+ features: {
552
+ // ... existing flags
553
+ organizations: true,
554
+ }
555
+ ```
556
+
557
+ Gate the org switcher, invitation routes, and org-scoped queries behind this flag.
558
+
559
+ ## Tests
560
+
561
+ Create `route.test.ts` next to each API route. Mock Prisma and auth:
562
+
563
+ ```typescript
564
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
565
+ import { GET, POST } from './route';
566
+ import { mockAuth } from '@mars-stack/core/test-utils';
567
+
568
+ vi.mock('@/lib/prisma', () => ({
569
+ prisma: {
570
+ organization: { findMany: vi.fn(), create: vi.fn() },
571
+ membership: { create: vi.fn(), findUnique: vi.fn() },
572
+ $transaction: vi.fn((fn) => fn({
573
+ organization: { create: vi.fn() },
574
+ membership: { create: vi.fn() },
575
+ })),
576
+ },
577
+ }));
578
+
579
+ vi.mock('@/lib/mars', () => ({
580
+ verifySessionForAPI: vi.fn(() => Promise.resolve(mockAuth)),
581
+ handleApiError: vi.fn((error) => {
582
+ return new Response(JSON.stringify({ error: 'Internal Server Error' }), { status: 500 });
583
+ }),
584
+ }));
585
+ ```
586
+
587
+ Test the `withOrgAccess` middleware specifically:
588
+
589
+ ```typescript
590
+ describe('withOrgAccess', () => {
591
+ it('returns 403 when user is not a member', async () => { /* ... */ });
592
+ it('returns 403 when user role is below required level', async () => { /* ... */ });
593
+ it('passes request with org context when authorized', async () => { /* ... */ });
594
+ });
595
+ ```
596
+
597
+ ## Checklist
598
+
599
+ - [ ] Prisma schema created (`organization.prisma`) with Organization, Team, Membership, Invitation models
600
+ - [ ] User model updated with `memberships` relation
601
+ - [ ] `yarn db:push` run to sync schema
602
+ - [ ] Types and validation schemas created
603
+ - [ ] Server functions for org CRUD, membership, and invitations
604
+ - [ ] `withOrgAccess` middleware created
605
+ - [ ] API routes created under `/api/protected/organizations/`
606
+ - [ ] Session/JWT extended with `activeOrgId` (or using middleware-only approach)
607
+ - [ ] Feature flag added to `app.config.ts`
608
+ - [ ] Org switcher component built
609
+ - [ ] Invitation flow (create, email, accept) implemented
610
+ - [ ] Backfill script for existing single-user data (if migrating)
611
+ - [ ] Tests written for API routes and `withOrgAccess` middleware