@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,682 @@
1
+ # Skill: Setup Teams (Meta-Skill)
2
+
3
+ Orchestrate multi-tenancy with teams/organizations: data models, invitation flow, role hierarchy, org switching, team settings UI, and documentation updates.
4
+
5
+ ## When to Use
6
+
7
+ Use this skill when the user asks to:
8
+ - Add teams or organizations
9
+ - Set up workspaces or multi-tenancy
10
+ - Add team invitations or member management
11
+ - Build org switching functionality
12
+
13
+ This meta-skill chains sub-skills in the correct order. For just the multi-tenancy data model, use `configure-multi-tenancy` instead.
14
+
15
+ ## Prerequisites
16
+
17
+ - Read `src/config/app.config.ts` to check if multi-tenancy features exist
18
+ - Read `prisma/schema/auth.prisma` to understand the current User model
19
+ - Read `src/lib/mars.ts` to check available auth wrappers
20
+
21
+ ## Execution Order
22
+
23
+ ```
24
+ ┌─────────────────────────────────────────────────────┐
25
+ │ Phase 0: Plan │
26
+ │ create-execution-plan │
27
+ ├─────────────────────────────────────────────────────┤
28
+ │ Phase 1: Multi-Tenancy Foundation │
29
+ │ configure-multi-tenancy (tenant model, scoping) │
30
+ ├─────────────────────────────────────────────────────┤
31
+ │ Phase 2: Data Layer │
32
+ │ add-prisma-model (Team, Membership, Invitation) │
33
+ ├─────────────────────────────────────────────────────┤
34
+ │ Phase 3: Feature Module │
35
+ │ add-feature (teams service, invitation logic) │
36
+ ├─────────────────────────────────────────────────────┤
37
+ │ Phase 4: API Routes │
38
+ │ add-crud-routes (teams, members, invitations) │
39
+ ├─────────────────────────────────────────────────────┤
40
+ │ Phase 5: UI │
41
+ │ add-page (team settings, invite, org switcher) │
42
+ ├─────────────────────────────────────────────────────┤
43
+ │ Phase 6: Auth Integration │
44
+ │ Update session, middleware, and context │
45
+ ├─────────────────────────────────────────────────────┤
46
+ │ Phase 7: Testing & Docs │
47
+ │ test-api-route → update-architecture-docs │
48
+ └─────────────────────────────────────────────────────┘
49
+ ```
50
+
51
+ ## Phase 0: Create Execution Plan
52
+
53
+ **Skill:** `create-execution-plan`
54
+
55
+ Create `docs/exec-plans/active/setup-teams.md` with all tasks listed below.
56
+
57
+ ## Phase 1: Multi-Tenancy Foundation
58
+
59
+ **Skill:** `configure-multi-tenancy`
60
+
61
+ 1. Enable feature flag:
62
+
63
+ ```typescript
64
+ // src/config/app.config.ts
65
+ features: {
66
+ teams: true,
67
+ }
68
+ ```
69
+
70
+ 2. Decide on tenancy model:
71
+
72
+ | Model | Description | Use when |
73
+ |-------|-------------|----------|
74
+ | **Team-based** | Users belong to one or more teams | B2B SaaS, collaboration tools |
75
+ | **Org-based** | Hierarchical: Org → Team → User | Enterprise, large organizations |
76
+ | **Workspace-based** | Flat: Users switch between workspaces | Project management, dev tools |
77
+
78
+ This skill assumes **Team-based** tenancy. Adjust models for org/workspace variants.
79
+
80
+ ## Phase 2: Data Layer
81
+
82
+ **Skill:** `add-prisma-model`
83
+
84
+ ### Team model
85
+
86
+ ```prisma
87
+ // prisma/schema/teams.prisma
88
+ model Team {
89
+ id String @id @default(cuid())
90
+ name String
91
+ slug String @unique
92
+ avatarUrl String?
93
+ createdAt DateTime @default(now())
94
+ updatedAt DateTime @updatedAt
95
+
96
+ memberships Membership[]
97
+ invitations Invitation[]
98
+
99
+ @@index([slug])
100
+ }
101
+
102
+ model Membership {
103
+ id String @id @default(cuid())
104
+ userId String
105
+ teamId String
106
+ role MembershipRole @default(MEMBER)
107
+ createdAt DateTime @default(now())
108
+ updatedAt DateTime @updatedAt
109
+
110
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
111
+ team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
112
+
113
+ @@unique([userId, teamId])
114
+ @@index([userId])
115
+ @@index([teamId])
116
+ @@index([role])
117
+ }
118
+
119
+ model Invitation {
120
+ id String @id @default(cuid())
121
+ email String
122
+ teamId String
123
+ role MembershipRole @default(MEMBER)
124
+ token String @unique @default(cuid())
125
+ status InvitationStatus @default(PENDING)
126
+ invitedBy String
127
+ expiresAt DateTime
128
+ createdAt DateTime @default(now())
129
+
130
+ team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
131
+ inviter User @relation("InvitationsSent", fields: [invitedBy], references: [id])
132
+
133
+ @@index([email])
134
+ @@index([teamId])
135
+ @@index([token])
136
+ @@index([status])
137
+ }
138
+
139
+ enum MembershipRole {
140
+ OWNER
141
+ ADMIN
142
+ MEMBER
143
+ VIEWER
144
+ }
145
+
146
+ enum InvitationStatus {
147
+ PENDING
148
+ ACCEPTED
149
+ EXPIRED
150
+ REVOKED
151
+ }
152
+ ```
153
+
154
+ ### Update User model
155
+
156
+ ```prisma
157
+ // In prisma/schema/auth.prisma
158
+ model User {
159
+ // ... existing fields
160
+ memberships Membership[]
161
+ invitationsSent Invitation[] @relation("InvitationsSent")
162
+ activeTeamId String?
163
+ }
164
+ ```
165
+
166
+ Run `yarn db:push`.
167
+
168
+ ## Phase 3: Feature Module
169
+
170
+ **Skill:** `add-feature`
171
+
172
+ Create `src/features/teams/`:
173
+
174
+ ```
175
+ src/features/teams/
176
+ ├── components/
177
+ │ ├── TeamSwitcher.tsx # Org/team switcher dropdown
178
+ │ ├── MemberList.tsx # Team member list with roles
179
+ │ ├── InviteForm.tsx # Invite new member form
180
+ │ ├── InvitationList.tsx # Pending invitations
181
+ │ ├── TeamSettingsForm.tsx # Edit team name, avatar
182
+ │ └── index.ts
183
+ ├── server/
184
+ │ ├── index.ts # Team queries and mutations
185
+ │ ├── invitations.ts # Invitation logic
186
+ │ └── membership.ts # Membership management
187
+ ├── hooks/
188
+ │ ├── useActiveTeam.ts # Current team context hook
189
+ │ └── index.ts
190
+ ├── validation/
191
+ │ └── schemas.ts # Zod schemas
192
+ └── types.ts # Team-related types
193
+ ```
194
+
195
+ ### Server module: Teams
196
+
197
+ ```typescript
198
+ // src/features/teams/server/index.ts
199
+ import 'server-only';
200
+
201
+ import { prisma } from '@/lib/prisma';
202
+
203
+ export async function getUserTeams(userId: string) {
204
+ return prisma.membership.findMany({
205
+ where: { userId },
206
+ include: { team: true },
207
+ orderBy: { createdAt: 'asc' },
208
+ });
209
+ }
210
+
211
+ export async function getTeamById(teamId: string, userId: string) {
212
+ return prisma.team.findFirst({
213
+ where: {
214
+ id: teamId,
215
+ memberships: { some: { userId } },
216
+ },
217
+ include: {
218
+ memberships: {
219
+ include: { user: { select: { id: true, email: true, name: true } } },
220
+ orderBy: { createdAt: 'asc' },
221
+ },
222
+ },
223
+ });
224
+ }
225
+
226
+ export async function createTeam(userId: string, name: string) {
227
+ const slug = generateSlug(name);
228
+
229
+ return prisma.$transaction(async (tx) => {
230
+ const team = await tx.team.create({
231
+ data: { name, slug },
232
+ });
233
+
234
+ await tx.membership.create({
235
+ data: { userId, teamId: team.id, role: 'OWNER' },
236
+ });
237
+
238
+ await tx.user.update({
239
+ where: { id: userId },
240
+ data: { activeTeamId: team.id },
241
+ });
242
+
243
+ return team;
244
+ });
245
+ }
246
+
247
+ function generateSlug(name: string): string {
248
+ return name
249
+ .toLowerCase()
250
+ .replace(/[^a-z0-9]+/g, '-')
251
+ .replace(/^-|-$/g, '')
252
+ + '-' + Math.random().toString(36).slice(2, 6);
253
+ }
254
+ ```
255
+
256
+ ### Server module: Invitations
257
+
258
+ ```typescript
259
+ // src/features/teams/server/invitations.ts
260
+ import 'server-only';
261
+
262
+ import { prisma } from '@/lib/prisma';
263
+ import { apiLogger } from '@/lib/mars';
264
+ import type { MembershipRole } from '@db';
265
+
266
+ export async function createInvitation(
267
+ teamId: string,
268
+ email: string,
269
+ role: MembershipRole,
270
+ invitedBy: string,
271
+ ) {
272
+ const existingMember = await prisma.membership.findFirst({
273
+ where: {
274
+ teamId,
275
+ user: { email },
276
+ },
277
+ });
278
+
279
+ if (existingMember) {
280
+ throw new Error('User is already a member of this team');
281
+ }
282
+
283
+ const existingInvite = await prisma.invitation.findFirst({
284
+ where: { teamId, email, status: 'PENDING' },
285
+ });
286
+
287
+ if (existingInvite) {
288
+ throw new Error('An invitation is already pending for this email');
289
+ }
290
+
291
+ const expiresAt = new Date();
292
+ expiresAt.setDate(expiresAt.getDate() + 7);
293
+
294
+ const invitation = await prisma.invitation.create({
295
+ data: { teamId, email, role, invitedBy, expiresAt },
296
+ include: { team: true },
297
+ });
298
+
299
+ apiLogger.info({ teamId, email, role }, 'Team invitation created');
300
+
301
+ return invitation;
302
+ }
303
+
304
+ export async function acceptInvitation(token: string, userId: string) {
305
+ const invitation = await prisma.invitation.findUnique({
306
+ where: { token },
307
+ });
308
+
309
+ if (!invitation) throw new Error('Invitation not found');
310
+ if (invitation.status !== 'PENDING') throw new Error('Invitation is no longer valid');
311
+ if (invitation.expiresAt < new Date()) {
312
+ await prisma.invitation.update({
313
+ where: { id: invitation.id },
314
+ data: { status: 'EXPIRED' },
315
+ });
316
+ throw new Error('Invitation has expired');
317
+ }
318
+
319
+ return prisma.$transaction(async (tx) => {
320
+ await tx.invitation.update({
321
+ where: { id: invitation.id },
322
+ data: { status: 'ACCEPTED' },
323
+ });
324
+
325
+ const membership = await tx.membership.create({
326
+ data: {
327
+ userId,
328
+ teamId: invitation.teamId,
329
+ role: invitation.role,
330
+ },
331
+ });
332
+
333
+ return membership;
334
+ });
335
+ }
336
+
337
+ export async function revokeInvitation(invitationId: string, teamId: string) {
338
+ return prisma.invitation.update({
339
+ where: { id: invitationId, teamId },
340
+ data: { status: 'REVOKED' },
341
+ });
342
+ }
343
+ ```
344
+
345
+ ### Role hierarchy
346
+
347
+ ```typescript
348
+ // src/features/teams/server/membership.ts
349
+ import 'server-only';
350
+
351
+ import { prisma } from '@/lib/prisma';
352
+ import type { MembershipRole } from '@db';
353
+
354
+ const ROLE_HIERARCHY: Record<MembershipRole, number> = {
355
+ OWNER: 4,
356
+ ADMIN: 3,
357
+ MEMBER: 2,
358
+ VIEWER: 1,
359
+ };
360
+
361
+ export function canManageRole(actorRole: MembershipRole, targetRole: MembershipRole): boolean {
362
+ return ROLE_HIERARCHY[actorRole] > ROLE_HIERARCHY[targetRole];
363
+ }
364
+
365
+ export async function updateMemberRole(
366
+ teamId: string,
367
+ targetUserId: string,
368
+ newRole: MembershipRole,
369
+ actorUserId: string,
370
+ ) {
371
+ const actorMembership = await prisma.membership.findUnique({
372
+ where: { userId_teamId: { userId: actorUserId, teamId } },
373
+ });
374
+
375
+ if (!actorMembership || !canManageRole(actorMembership.role, newRole)) {
376
+ throw new Error('Insufficient permissions to assign this role');
377
+ }
378
+
379
+ return prisma.membership.update({
380
+ where: { userId_teamId: { userId: targetUserId, teamId } },
381
+ data: { role: newRole },
382
+ });
383
+ }
384
+
385
+ export async function removeMember(
386
+ teamId: string,
387
+ targetUserId: string,
388
+ actorUserId: string,
389
+ ) {
390
+ if (targetUserId === actorUserId) {
391
+ throw new Error('Cannot remove yourself. Transfer ownership first.');
392
+ }
393
+
394
+ const actorMembership = await prisma.membership.findUnique({
395
+ where: { userId_teamId: { userId: actorUserId, teamId } },
396
+ });
397
+
398
+ const targetMembership = await prisma.membership.findUnique({
399
+ where: { userId_teamId: { userId: targetUserId, teamId } },
400
+ });
401
+
402
+ if (!actorMembership || !targetMembership) {
403
+ throw new Error('Membership not found');
404
+ }
405
+
406
+ if (!canManageRole(actorMembership.role, targetMembership.role)) {
407
+ throw new Error('Insufficient permissions');
408
+ }
409
+
410
+ return prisma.membership.delete({
411
+ where: { userId_teamId: { userId: targetUserId, teamId } },
412
+ });
413
+ }
414
+ ```
415
+
416
+ ## Phase 4: API Routes
417
+
418
+ **Skill:** `add-crud-routes`
419
+
420
+ ### Team routes
421
+
422
+ | Route | Method | Auth | Purpose |
423
+ |-------|--------|------|---------|
424
+ | `/api/protected/teams` | GET | `withAuthNoParams` | List user's teams |
425
+ | `/api/protected/teams` | POST | `withAuthNoParams` | Create new team |
426
+ | `/api/protected/teams/[teamId]` | GET | `withAuth` | Get team details |
427
+ | `/api/protected/teams/[teamId]` | PUT | `withAuth` | Update team settings |
428
+ | `/api/protected/teams/[teamId]` | DELETE | `withAuth` | Delete team (owner only) |
429
+
430
+ ### Member routes
431
+
432
+ | Route | Method | Auth | Purpose |
433
+ |-------|--------|------|---------|
434
+ | `/api/protected/teams/[teamId]/members` | GET | `withAuth` | List team members |
435
+ | `/api/protected/teams/[teamId]/members/[userId]` | PUT | `withAuth` | Update member role |
436
+ | `/api/protected/teams/[teamId]/members/[userId]` | DELETE | `withAuth` | Remove member |
437
+
438
+ ### Invitation routes
439
+
440
+ | Route | Method | Auth | Purpose |
441
+ |-------|--------|------|---------|
442
+ | `/api/protected/teams/[teamId]/invitations` | GET | `withAuth` | List pending invitations |
443
+ | `/api/protected/teams/[teamId]/invitations` | POST | `withAuth` | Send invitation |
444
+ | `/api/protected/teams/[teamId]/invitations/[id]` | DELETE | `withAuth` | Revoke invitation |
445
+ | `/api/protected/invitations/accept` | POST | `withAuthNoParams` | Accept invitation by token |
446
+
447
+ ### Active team route
448
+
449
+ | Route | Method | Auth | Purpose |
450
+ |-------|--------|------|---------|
451
+ | `/api/protected/user/active-team` | PUT | `withAuthNoParams` | Switch active team |
452
+
453
+ **All team-scoped routes must verify membership:**
454
+
455
+ ```typescript
456
+ export const GET = withAuth(async (request, { params }) => {
457
+ const { teamId } = await params;
458
+ const team = await getTeamById(teamId, request.session.userId);
459
+ if (!team) {
460
+ return NextResponse.json({ error: 'Team not found' }, { status: 404 });
461
+ }
462
+ // ...
463
+ });
464
+ ```
465
+
466
+ **Role-gated operations (admin+ for invitations, owner for delete):**
467
+
468
+ ```typescript
469
+ const membership = team.memberships.find(m => m.userId === request.session.userId);
470
+ if (!membership || ROLE_HIERARCHY[membership.role] < ROLE_HIERARCHY.ADMIN) {
471
+ return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
472
+ }
473
+ ```
474
+
475
+ ## Phase 5: UI
476
+
477
+ **Skill:** `add-page`
478
+
479
+ ### Team settings page
480
+
481
+ Create `src/app/(protected)/settings/team/page.tsx`:
482
+ - Team name and slug editor
483
+ - Avatar upload (if storage configured)
484
+ - Member list with role badges
485
+ - Invite member form
486
+ - Pending invitations list
487
+ - Danger zone: delete team (owner only)
488
+
489
+ ### Team switcher component
490
+
491
+ ```tsx
492
+ // src/features/teams/components/TeamSwitcher.tsx
493
+ 'use client';
494
+
495
+ import { useActiveTeam } from '@/features/teams/hooks/useActiveTeam';
496
+ import { Select } from '@mars-stack/ui';
497
+
498
+ export function TeamSwitcher() {
499
+ const { teams, activeTeam, switchTeam, loading } = useActiveTeam();
500
+
501
+ if (loading || teams.length <= 1) return null;
502
+
503
+ return (
504
+ <Select
505
+ value={activeTeam?.id}
506
+ onChange={(teamId) => switchTeam(teamId)}
507
+ options={teams.map(t => ({ value: t.team.id, label: t.team.name }))}
508
+ />
509
+ );
510
+ }
511
+ ```
512
+
513
+ ### Active team hook
514
+
515
+ ```tsx
516
+ // src/features/teams/hooks/useActiveTeam.ts
517
+ 'use client';
518
+
519
+ import { useEffect, useState, useCallback } from 'react';
520
+
521
+ interface TeamMembership {
522
+ id: string;
523
+ role: string;
524
+ team: { id: string; name: string; slug: string };
525
+ }
526
+
527
+ export function useActiveTeam() {
528
+ const [teams, setTeams] = useState<TeamMembership[]>([]);
529
+ const [activeTeam, setActiveTeam] = useState<TeamMembership | null>(null);
530
+ const [loading, setLoading] = useState(true);
531
+
532
+ useEffect(() => {
533
+ fetch('/api/protected/teams')
534
+ .then(res => res.json())
535
+ .then((data: TeamMembership[]) => {
536
+ setTeams(data);
537
+ const active = data.find(t => t.team.id === localStorage.getItem('activeTeamId'));
538
+ setActiveTeam(active ?? data[0] ?? null);
539
+ })
540
+ .finally(() => setLoading(false));
541
+ }, []);
542
+
543
+ const switchTeam = useCallback(async (teamId: string) => {
544
+ await fetch('/api/protected/user/active-team', {
545
+ method: 'PUT',
546
+ headers: { 'Content-Type': 'application/json' },
547
+ body: JSON.stringify({ teamId }),
548
+ });
549
+ localStorage.setItem('activeTeamId', teamId);
550
+ const team = teams.find(t => t.team.id === teamId);
551
+ if (team) setActiveTeam(team);
552
+ }, [teams]);
553
+
554
+ return { teams, activeTeam, switchTeam, loading };
555
+ }
556
+ ```
557
+
558
+ ### Invitation acceptance page
559
+
560
+ Create `src/app/(protected)/invitations/accept/page.tsx`:
561
+ - Reads `?token=` from search params
562
+ - Calls accept invitation API
563
+ - Redirects to the team on success
564
+
565
+ ### Invitation email
566
+
567
+ If email is configured, send invitation emails using the `configure-email` skill pattern:
568
+
569
+ ```typescript
570
+ await sendEmail({
571
+ to: email,
572
+ subject: `You've been invited to join ${team.name}`,
573
+ html: invitationEmailTemplate({ team, inviterName, acceptUrl }),
574
+ });
575
+ ```
576
+
577
+ ## Phase 6: Auth Integration
578
+
579
+ ### Extend session with team context
580
+
581
+ Add `activeTeamId` to the JWT session claims so team-scoped queries can use it:
582
+
583
+ ```typescript
584
+ // When creating/refreshing session, include activeTeamId
585
+ const sessionData = {
586
+ userId: user.id,
587
+ email: user.email,
588
+ role: user.role,
589
+ activeTeamId: user.activeTeamId,
590
+ };
591
+ ```
592
+
593
+ ### Team-scoped queries
594
+
595
+ For features that are team-scoped, queries should filter by `teamId`:
596
+
597
+ ```typescript
598
+ export async function findProjectsByTeam(teamId: string) {
599
+ return prisma.project.findMany({
600
+ where: { teamId },
601
+ orderBy: { createdAt: 'desc' },
602
+ });
603
+ }
604
+ ```
605
+
606
+ ### Middleware consideration
607
+
608
+ If certain routes should only be accessible to team members, add team verification in the route handler (not middleware, to keep middleware thin).
609
+
610
+ ## Phase 7: Testing & Documentation
611
+
612
+ **Skill:** `test-api-route` + `update-architecture-docs`
613
+
614
+ ### Tests
615
+
616
+ 1. Unit test team CRUD routes
617
+ 2. Unit test invitation flow (create, accept, revoke, expiry)
618
+ 3. Unit test role hierarchy (canManageRole, permission checks)
619
+ 4. Unit test membership management (add, remove, update role)
620
+ 5. E2E test: create team → invite member → accept → verify access
621
+
622
+ ### Documentation updates
623
+
624
+ 1. Update `docs/QUALITY_SCORE.md`:
625
+
626
+ ```markdown
627
+ | Teams | B | Team CRUD, invitations, role hierarchy, org switching |
628
+ ```
629
+
630
+ 2. Update `AGENTS.md`:
631
+ - Add teams feature to directory listing
632
+ - Document team-scoped query pattern
633
+
634
+ 3. Mark execution plan as complete
635
+
636
+ ## Data Scoping Decision
637
+
638
+ **When to scope data by team vs. by user:**
639
+
640
+ | Scope by Team | Scope by User |
641
+ |---------------|---------------|
642
+ | Projects, documents, shared resources | Personal settings, profile |
643
+ | Billing and subscriptions | Notification preferences |
644
+ | API keys and integrations | Security settings |
645
+ | Audit logs (team actions) | Auth sessions |
646
+
647
+ For team-scoped models, add `teamId` field and index:
648
+
649
+ ```prisma
650
+ model Project {
651
+ // ...
652
+ teamId String
653
+ team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
654
+
655
+ @@index([teamId])
656
+ }
657
+ ```
658
+
659
+ ## Checklist
660
+
661
+ - [ ] Execution plan created
662
+ - [ ] Multi-tenancy feature flag enabled
663
+ - [ ] Team, Membership, Invitation models in Prisma
664
+ - [ ] Membership role enum (OWNER, ADMIN, MEMBER, VIEWER)
665
+ - [ ] Invitation status enum (PENDING, ACCEPTED, EXPIRED, REVOKED)
666
+ - [ ] User model extended with `activeTeamId`
667
+ - [ ] Team CRUD server module
668
+ - [ ] Invitation server module (create, accept, revoke)
669
+ - [ ] Role hierarchy with permission checks
670
+ - [ ] Team CRUD API routes
671
+ - [ ] Member management API routes
672
+ - [ ] Invitation API routes
673
+ - [ ] Active team switching API route
674
+ - [ ] Team settings page with member management
675
+ - [ ] Team switcher component
676
+ - [ ] Invitation acceptance flow
677
+ - [ ] Session extended with activeTeamId
678
+ - [ ] Invitation emails (if email configured)
679
+ - [ ] Unit tests for all API routes
680
+ - [ ] E2E test for invitation flow
681
+ - [ ] QUALITY_SCORE.md updated
682
+ - [ ] Execution plan completed