create-fullstack-scaffold 0.5.7 → 0.6.1

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 (53) hide show
  1. package/dist/cli/index.js +65 -2
  2. package/dist/cli/index.js.map +1 -1
  3. package/package.json +12 -12
  4. package/template/CLAUDE.md +3 -3
  5. package/template/PROJECT_SUMMARY.md +1 -1
  6. package/template/QUICKSTART.md +3 -3
  7. package/template/README.md +2 -2
  8. package/template/drizzle/0000_init_full_schema.sql +390 -0
  9. package/template/drizzle/meta/0000_snapshot.json +2487 -0
  10. package/template/drizzle/meta/_journal.json +13 -0
  11. package/template/package.json +10 -10
  12. package/template/src/cli/modules/tenant/index.ts +89 -0
  13. package/template/src/server/db/init.ts +0 -52
  14. package/template/src/server/db/schema/index.ts +3 -0
  15. package/template/src/server/db/schema/tenant-invitations.ts +33 -0
  16. package/template/src/server/db/schema/tenant-members.ts +38 -0
  17. package/template/src/server/db/schema/tenant-roles.ts +32 -0
  18. package/template/src/server/db/schema/todos.ts +4 -0
  19. package/template/src/server/db/test-setup.ts +128 -0
  20. package/template/src/server/entries/node.ts +13 -0
  21. package/template/src/server/index.ts +12 -0
  22. package/template/src/server/middleware/__tests__/tenant-isolation.test.ts +55 -9
  23. package/template/src/server/middleware/auth.ts +17 -19
  24. package/template/src/server/middleware/tenant-isolation.ts +4 -2
  25. package/template/src/server/module-auth/module.ts +2 -1
  26. package/template/src/server/module-auth/services/auth-service.ts +39 -0
  27. package/template/src/server/module-tenant/__tests__/tenant-routes.test.ts +97 -0
  28. package/template/src/server/module-tenant/__tests__/tenant-service.test.ts +201 -22
  29. package/template/src/server/module-tenant/module.ts +1 -1
  30. package/template/src/server/module-tenant/routes/tenant-routes.ts +339 -2
  31. package/template/src/server/module-tenant/services/tenant-service.ts +581 -17
  32. package/template/src/server/module-todos/routes/todos-routes.ts +10 -2
  33. package/template/src/server/module-todos/services/todo-service.ts +12 -2
  34. package/template/src/server/utils/__tests__/captcha.test.ts +6 -4
  35. package/template/src/server/utils/id-helpers.ts +13 -0
  36. package/template/src/shared/modules/index.ts +29 -6
  37. package/template/src/shared/modules/tenant/index.ts +30 -0
  38. package/template/src/shared/modules/tenant/permissions.ts +54 -0
  39. package/template/src/shared/modules/tenant/role-templates.ts +66 -0
  40. package/template/src/shared/modules/tenant/schemas.ts +113 -1
  41. package/template/src/shared/modules/todos/schemas.ts +1 -0
  42. package/template/src/shared/schemas/index.ts +28 -0
  43. package/template/src/tenant/App.tsx +30 -23
  44. package/template/src/tenant/components/TenantGuard.tsx +12 -2
  45. package/template/src/tenant/layouts/Header.tsx +6 -2
  46. package/template/src/tenant/pages/InviteAcceptPage.tsx +97 -0
  47. package/template/src/tenant/pages/LoginPage.tsx +82 -0
  48. package/template/src/tenant/pages/SubscriptionPage.tsx +32 -55
  49. package/template/src/tenant/pages/UsersPage.tsx +134 -88
  50. package/template/src/tenant/services/tenantApi.ts +61 -0
  51. package/template/src/tenant/stores/tenantStore.ts +172 -49
  52. package/template/src/test/setup-db-path.ts +20 -0
  53. package/template/vitest.config.ts +3 -1
@@ -58,23 +58,37 @@ describe('tenantIsolationMiddleware', () => {
58
58
  return app
59
59
  }
60
60
 
61
- it('should return error when no tenant header or subdomain', async () => {
61
+ // P2 起中间件为"可选上下文"语义:无租户标识 放行且不设上下文
62
+ //(全局模式:dev token / 平台级 API / 无 tenant 模块 preset 不受影响)
63
+ it('should pass through without context when no tenant header or subdomain', async () => {
62
64
  const app = createApp()
63
65
  const res = await app.request('/api/test')
64
- expect(res.status).toBe(404)
66
+ // /api/test 无路由 → 404 来自路由缺失而非中间件;断言响应不是
67
+ // tenant-not-found 错误体即可证明中间件放行
68
+ const body = (await res.json()) as { error?: string }
69
+ expect(String(body.error)).not.toContain('Tenant')
65
70
  })
66
71
 
67
- it('should return error when tenant header is empty string', async () => {
72
+ it('should pass through without context when tenant header is empty string', async () => {
68
73
  const app = createApp()
69
74
  const res = await app.request('/api/test', {
70
75
  headers: { 'X-Tenant-Slug': '' },
71
76
  })
72
- expect(res.status).toBe(404)
77
+ const body = (await res.json()) as { error?: string }
78
+ expect(String(body.error)).not.toContain('Tenant')
73
79
  })
74
80
 
75
81
  it('should pass with valid tenant from header', async () => {
76
82
  mockDbResult([
77
- { id: 1, slug: 'test-tenant', name: 'Test', status: 'active', plan: 'free', maxUsers: 10, settings: null },
83
+ {
84
+ id: 1,
85
+ slug: 'test-tenant',
86
+ name: 'Test',
87
+ status: 'active',
88
+ plan: 'free',
89
+ maxUsers: 10,
90
+ settings: null,
91
+ },
78
92
  ])
79
93
  const app = createApp()
80
94
  const res = await app.request('/api/test', {
@@ -98,7 +112,15 @@ describe('tenantIsolationMiddleware', () => {
98
112
 
99
113
  it('should extract tenant from subdomain when no header', async () => {
100
114
  mockDbResult([
101
- { id: 2, slug: 'acme', name: 'Acme', status: 'active', plan: 'pro', maxUsers: 100, settings: null },
115
+ {
116
+ id: 2,
117
+ slug: 'acme',
118
+ name: 'Acme',
119
+ status: 'active',
120
+ plan: 'pro',
121
+ maxUsers: 100,
122
+ settings: null,
123
+ },
102
124
  ])
103
125
  const app = createApp()
104
126
  const res = await app.request('/api/test', {
@@ -111,7 +133,15 @@ describe('tenantIsolationMiddleware', () => {
111
133
 
112
134
  it('should prefer X-Tenant-Slug header over subdomain', async () => {
113
135
  mockDbResult([
114
- { id: 3, slug: 'header-tenant', name: 'Header', status: 'active', plan: 'free', maxUsers: 5, settings: null },
136
+ {
137
+ id: 3,
138
+ slug: 'header-tenant',
139
+ name: 'Header',
140
+ status: 'active',
141
+ plan: 'free',
142
+ maxUsers: 5,
143
+ settings: null,
144
+ },
115
145
  ])
116
146
  const app = createApp()
117
147
  const res = await app.request('/api/test', {
@@ -124,7 +154,15 @@ describe('tenantIsolationMiddleware', () => {
124
154
 
125
155
  it('should parse tenant settings JSON', async () => {
126
156
  mockDbResult([
127
- { id: 4, slug: 'with-settings', name: 'Settings', status: 'active', plan: 'pro', maxUsers: 50, settings: '{"theme":"dark"}' },
157
+ {
158
+ id: 4,
159
+ slug: 'with-settings',
160
+ name: 'Settings',
161
+ status: 'active',
162
+ plan: 'pro',
163
+ maxUsers: 50,
164
+ settings: '{"theme":"dark"}',
165
+ },
128
166
  ])
129
167
  const app = createApp()
130
168
  const res = await app.request('/api/test', {
@@ -137,7 +175,15 @@ describe('tenantIsolationMiddleware', () => {
137
175
 
138
176
  it('should handle null settings gracefully', async () => {
139
177
  mockDbResult([
140
- { id: 5, slug: 'no-settings', name: 'NoSettings', status: 'active', plan: 'free', maxUsers: 5, settings: null },
178
+ {
179
+ id: 5,
180
+ slug: 'no-settings',
181
+ name: 'NoSettings',
182
+ status: 'active',
183
+ plan: 'free',
184
+ maxUsers: 5,
185
+ settings: null,
186
+ },
141
187
  ])
142
188
  const app = createApp()
143
189
  const res = await app.request('/api/test', {
@@ -131,27 +131,25 @@ function verifyToken(token: string, key: string): AuthUser | null {
131
131
  }
132
132
  }
133
133
 
134
- if (key !== defaultSecretKey) {
135
- try {
136
- const decoded = jwt.verify(token, key) as {
137
- userId: string
138
- role: string
139
- username: string
140
- email: string
141
- }
142
- return {
143
- id: decoded.userId,
144
- username: decoded.username,
145
- email: decoded.email,
146
- role: decoded.role as UserRole,
147
- permissions: getPermissionsByRole(decoded.role as UserRole),
148
- }
149
- } catch {
150
- return null
134
+ // 真实 JWT 验证对默认/自定义 key 均启用——此前仅非默认 key 才走此分支,
135
+ // 导致 dev 未设 AUTH_SECRET_KEY 时所有注册账号的登录 token 恒 401
136
+ try {
137
+ const decoded = jwt.verify(token, key) as {
138
+ userId: string
139
+ role: string
140
+ username: string
141
+ email: string
142
+ }
143
+ return {
144
+ id: decoded.userId,
145
+ username: decoded.username,
146
+ email: decoded.email,
147
+ role: decoded.role as UserRole,
148
+ permissions: getPermissionsByRole(decoded.role as UserRole),
151
149
  }
150
+ } catch {
151
+ return null
152
152
  }
153
-
154
- return null
155
153
  }
156
154
 
157
155
  export function authMiddleware(options: AuthMiddlewareOptions = {}): MiddlewareHandler {
@@ -41,9 +41,11 @@ export function tenantIsolationMiddleware(): MiddlewareHandler {
41
41
  const hostname = c.req.header('host') || ''
42
42
  const tenantSlug = extractTenantSlug(hostname, c.req.header('X-Tenant-Slug'))
43
43
 
44
+ // 无租户标识 → 不设上下文继续(全局模式:dev token、平台级 API、
45
+ // 无 tenant 模块 preset 的既有行为都不受影响)
44
46
  if (!tenantSlug) {
45
- log.warn({ hostname, path: c.req.path }, 'No tenant slug found')
46
- throw NotFoundError.tenant()
47
+ await next()
48
+ return
47
49
  }
48
50
 
49
51
  const db = await getDb()
@@ -33,7 +33,8 @@ const authManifest: ModuleManifest = {
33
33
 
34
34
  dbSchemas: {
35
35
  files: ['developers'],
36
- hasSeed: false,
36
+ hasSeed: true,
37
+ seed: { serviceFile: 'auth-service', functionName: 'seedDevelopersIfEmpty' },
37
38
  },
38
39
 
39
40
  cliModule: { dir: 'auth', registerFunction: 'registerAuthCommands' },
@@ -6,6 +6,7 @@ import { developers, type DeveloperTable } from '@server/db/schema'
6
6
  import { ConflictError, AuthenticationError } from '@server/utils/app-error'
7
7
  import { generateUUID } from '@server/utils/uuid'
8
8
  import { toISOString } from '@server/utils/date'
9
+ import { createModuleLoggerSync } from '@server/utils/logger'
9
10
 
10
11
  // eslint-disable-next-line local-rules/no-util-functions-in-service -- module-specific row-to-profile mapping
11
12
  function toProfile(row: DeveloperTable): DeveloperProfile {
@@ -98,3 +99,41 @@ export async function getDeveloperById(id: string): Promise<DeveloperProfile | n
98
99
 
99
100
  return toProfile(rows[0])
100
101
  }
102
+
103
+ /**
104
+ * 认证种子(首启空库):demo 开发者 + 平台超管。
105
+ * superadmin 必须是 super_admin 角色——requireSuperAdminMiddleware 只认这个值。
106
+ * 凭据 superadmin/admin123,首启日志打印一次,上线前必须改密。
107
+ */
108
+ export async function seedDevelopersIfEmpty(): Promise<void> {
109
+ const db = await getDb()
110
+ const existing = await db.select().from(developers)
111
+ if (existing.length > 0) return
112
+
113
+ const log = createModuleLoggerSync('auth-service')
114
+ log.info({}, 'Seeding developers...')
115
+ const demoPasswordHash = hashSync('demo123', 10)
116
+ const adminPasswordHash = hashSync('admin123', 10)
117
+ await db.insert(developers).values([
118
+ {
119
+ id: generateUUID(),
120
+ username: 'demo',
121
+ email: 'demo@biomimic.app',
122
+ passwordHash: demoPasswordHash,
123
+ role: 'developer' as const,
124
+ apiKey: generateUUID(),
125
+ },
126
+ {
127
+ id: generateUUID(),
128
+ username: 'superadmin',
129
+ email: 'admin@biomimic.app',
130
+ passwordHash: adminPasswordHash,
131
+ role: 'super_admin' as const,
132
+ apiKey: generateUUID(),
133
+ },
134
+ ])
135
+ log.warn(
136
+ { account: 'superadmin', password: 'admin123', note: 'CHANGE IMMEDIATELY' },
137
+ 'Platform super admin seeded (first boot only) — change the password before any real deployment'
138
+ )
139
+ }
@@ -140,3 +140,100 @@ describe('Tenant Routes', () => {
140
140
  })
141
141
  })
142
142
  })
143
+
144
+ describe('membership & invitation routes (P1)', () => {
145
+ const authHeaders = { Authorization: 'Bearer test-super-admin-1' }
146
+
147
+ beforeAll(async () => {
148
+ await setupTestDatabase()
149
+ })
150
+
151
+ afterAll(async () => {
152
+ await cleanupTestDatabase()
153
+ })
154
+
155
+ it('GET /api/tenants/mine 返回当前用户租户', async () => {
156
+ const client = createTestClient(undefined, { headers: authHeaders })
157
+ const res = await client.api.tenants.mine.$get()
158
+ expect(res.status).toBe(200)
159
+ const data = await res.json()
160
+ if (data.success) {
161
+ expect(Array.isArray(data.data)).toBe(true)
162
+ expect(data.data.length).toBeGreaterThanOrEqual(1) // demo 种子 owner
163
+ }
164
+ })
165
+
166
+ it('非成员访问租户成员列表 403', async () => {
167
+ const client = createTestClient(undefined, {
168
+ headers: { Authorization: 'Bearer test-user-2' },
169
+ })
170
+ const res = await client.api.tenants[':tenantId'].members.$get({ param: { tenantId: '1' } })
171
+ expect(res.status).toBe(403)
172
+ })
173
+
174
+ it('owner 邀请→公开查详情→接受→列表可见→移除', async () => {
175
+ const owner = createTestClient(undefined, { headers: authHeaders })
176
+
177
+ // demo 租户(id=1,seed owner=test-super-admin-1)
178
+ const rolesRes = await owner.api.tenants[':tenantId'].roles.$get({ param: { tenantId: '1' } })
179
+ expect(rolesRes.status).toBe(200)
180
+ const rolesData = await rolesRes.json()
181
+ if (!rolesData.success) throw new Error('roles failed')
182
+ const guestRole = rolesData.data.find(r => r.code === 'tenant_guest')!
183
+
184
+ const inviteRes = await owner.api.tenants[':tenantId'].members.invite.$post({
185
+ param: { tenantId: '1' },
186
+ json: { email: 'route-newbie@example.com', roleId: guestRole.id },
187
+ })
188
+ expect(inviteRes.status).toBe(201)
189
+ const inviteData = await inviteRes.json()
190
+ if (!inviteData.success) throw new Error('invite failed')
191
+ const token = inviteData.data.token
192
+
193
+ // 公开接口(无认证)可查脱敏详情
194
+ const pub = createTestClient(undefined)
195
+ const pubRes = await pub.api.tenants.invitations[':token'].$get({ param: { token } })
196
+ expect(pubRes.status).toBe(200)
197
+ const pubData = await pubRes.json()
198
+ if (pubData.success) {
199
+ expect(pubData.data.tenantSlug).toBe('demo')
200
+ expect(pubData.data.roleLabel).toBe('访客')
201
+ expect(pubData.data).not.toHaveProperty('inviterId')
202
+ }
203
+
204
+ // 被邀人接受
205
+ const newbie = createTestClient(undefined, {
206
+ headers: { Authorization: 'Bearer test-user-2' },
207
+ })
208
+ const acceptRes = await newbie.api.tenants.invitations[':token'].accept.$post({
209
+ param: { token },
210
+ })
211
+ expect(acceptRes.status).toBe(200)
212
+
213
+ // owner 视角成员列表可见新人
214
+ const membersRes = await owner.api.tenants[':tenantId'].members.$get({
215
+ param: { tenantId: '1' },
216
+ })
217
+ const membersData = await membersRes.json()
218
+ if (membersData.success) {
219
+ const newbieRow = membersData.data.find((m: { userId: string }) => m.userId === 'test-user-2')
220
+ expect(newbieRow).toBeDefined()
221
+ expect(newbieRow!.role?.code).toBe('tenant_guest')
222
+ }
223
+
224
+ // 移除后新人 403
225
+ if (membersData.success) {
226
+ const newbieRow = membersData.data.find(
227
+ (m: { userId: string }) => m.userId === 'test-user-2'
228
+ )!
229
+ const delRes = await owner.api.tenants[':tenantId'].members[':memberId'].$delete({
230
+ param: { tenantId: '1', memberId: newbieRow.id },
231
+ })
232
+ expect(delRes.status).toBe(200)
233
+ const denied = await newbie.api.tenants[':tenantId'].members.$get({
234
+ param: { tenantId: '1' },
235
+ })
236
+ expect(denied.status).toBe(403)
237
+ }
238
+ })
239
+ })
@@ -7,7 +7,19 @@ import {
7
7
  createTenant,
8
8
  updateTenant,
9
9
  deleteTenant,
10
+ getTenantRoles,
11
+ createTenantRole,
12
+ getTenantMembers,
13
+ getUserTenants,
14
+ getUserTenantPermissions,
15
+ hasTenantPermission,
16
+ requireTenantMembership,
17
+ inviteMember,
18
+ getInvitationByToken,
19
+ acceptInvitation,
20
+ removeMember,
10
21
  } from '../services/tenant-service'
22
+ import { TenantPermission } from '@shared/schemas'
11
23
  import type { CreateTenantInput } from '@shared/schemas'
12
24
 
13
25
  describe('Tenant Service', () => {
@@ -84,7 +96,7 @@ describe('Tenant Service', () => {
84
96
  maxUsers: 5,
85
97
  settings: null,
86
98
  }
87
- const result = await createTenant(input)
99
+ const result = await createTenant(input, 'test-super-admin-1')
88
100
  expect(result.name).toBe('New Corp')
89
101
  expect(result.slug).toBe('new-corp')
90
102
  expect(result.status).toBe('trial')
@@ -93,13 +105,16 @@ describe('Tenant Service', () => {
93
105
 
94
106
  it('should reject duplicate slug', async () => {
95
107
  await expect(
96
- createTenant({
97
- name: 'Duplicate',
98
- slug: 'demo',
99
- plan: 'free',
100
- maxUsers: 5,
101
- settings: null,
102
- })
108
+ createTenant(
109
+ {
110
+ name: 'Duplicate',
111
+ slug: 'demo',
112
+ plan: 'free',
113
+ maxUsers: 5,
114
+ settings: null,
115
+ },
116
+ 'test-super-admin-1'
117
+ )
103
118
  ).rejects.toThrow(/already exists/)
104
119
  })
105
120
  })
@@ -112,13 +127,16 @@ describe('Tenant Service', () => {
112
127
 
113
128
  it('should update tenant fields', async () => {
114
129
  // Create a tenant to update
115
- const created = await createTenant({
116
- name: 'Update Test',
117
- slug: 'update-test',
118
- plan: 'free',
119
- maxUsers: 5,
120
- settings: null,
121
- })
130
+ const created = await createTenant(
131
+ {
132
+ name: 'Update Test',
133
+ slug: 'update-test',
134
+ plan: 'free',
135
+ maxUsers: 5,
136
+ settings: null,
137
+ },
138
+ 'test-super-admin-1'
139
+ )
122
140
 
123
141
  const result = await updateTenant(created.id, { name: 'Updated Name' })
124
142
  expect(result).not.toBeNull()
@@ -133,13 +151,16 @@ describe('Tenant Service', () => {
133
151
  })
134
152
 
135
153
  it('should delete and return true', async () => {
136
- const created = await createTenant({
137
- name: 'Delete Test',
138
- slug: 'delete-test',
139
- plan: 'free',
140
- maxUsers: 5,
141
- settings: null,
142
- })
154
+ const created = await createTenant(
155
+ {
156
+ name: 'Delete Test',
157
+ slug: 'delete-test',
158
+ plan: 'free',
159
+ maxUsers: 5,
160
+ settings: null,
161
+ },
162
+ 'test-super-admin-1'
163
+ )
143
164
 
144
165
  const result = await deleteTenant(created.id)
145
166
  expect(result).toBe(true)
@@ -150,3 +171,161 @@ describe('Tenant Service', () => {
150
171
  })
151
172
  })
152
173
  })
174
+
175
+ describe('provisioning & membership (P1)', () => {
176
+ beforeAll(async () => {
177
+ await setupTestDatabase()
178
+ })
179
+
180
+ afterAll(async () => {
181
+ await cleanupTestDatabase()
182
+ })
183
+
184
+ it('createTenant 事务播种 3 个系统角色 + owner 为租户管理员', async () => {
185
+ const tenant = await createTenant(
186
+ { name: '开通验证', slug: 'prov-check', plan: 'pro', maxUsers: 20, settings: {} },
187
+ 'test-super-admin-1'
188
+ )
189
+ const roles = await getTenantRoles(tenant.id)
190
+ expect(roles.length).toBe(3)
191
+ expect(roles.map(r => r.code).sort()).toEqual(['tenant_admin', 'tenant_guest', 'tenant_member'])
192
+ expect(roles.every(r => r.isSystem)).toBe(true)
193
+
194
+ const members = await getTenantMembers(tenant.id)
195
+ expect(members.length).toBe(1)
196
+ expect(members[0].userId).toBe('test-super-admin-1')
197
+ expect(members[0].role?.code).toBe('tenant_admin')
198
+ expect(members[0].status).toBe('active')
199
+ })
200
+
201
+ it('owner 拥有全部租户权限,陌生人无任何权限', async () => {
202
+ const tenant = await createTenant(
203
+ { name: '权限验证', slug: 'perm-check', plan: 'free', maxUsers: 5, settings: {} },
204
+ 'test-super-admin-1'
205
+ )
206
+ const ownerPerms = await getUserTenantPermissions('test-super-admin-1', tenant.id)
207
+ expect(ownerPerms.length).toBeGreaterThan(10)
208
+ expect(
209
+ await hasTenantPermission('test-super-admin-1', tenant.id, TenantPermission.MEMBER_INVITE)
210
+ ).toBe(true)
211
+ expect(await hasTenantPermission('test-user-2', tenant.id, TenantPermission.DATA_VIEW)).toBe(
212
+ false
213
+ )
214
+ await expect(requireTenantMembership('test-user-2', tenant.id)).rejects.toThrow()
215
+ })
216
+
217
+ it('邀请→接受→成员可见→移除后失权 全链路', async () => {
218
+ const tenant = await createTenant(
219
+ { name: '邀请验证', slug: 'invite-check', plan: 'free', maxUsers: 5, settings: {} },
220
+ 'test-super-admin-1'
221
+ )
222
+ const guestRole = (await getTenantRoles(tenant.id)).find(r => r.code === 'tenant_guest')!
223
+
224
+ const invitation = await inviteMember(
225
+ tenant.id,
226
+ 'newbie@example.com',
227
+ guestRole.id,
228
+ 'test-super-admin-1'
229
+ )
230
+ expect(invitation.status).toBe('pending')
231
+ expect(invitation.token.length).toBeGreaterThanOrEqual(32)
232
+
233
+ // 公开详情可查(带租户名/角色名)
234
+ const detail = await getInvitationByToken(invitation.token)
235
+ expect(detail?.tenantName).toBe('邀请验证')
236
+ expect(detail?.roleLabel).toBe('访客')
237
+
238
+ // 陌生用户接受邀请后成为 guest
239
+ const member = await acceptInvitation(invitation.token, 'test-user-2')
240
+ expect(member?.status).toBe('active')
241
+ expect(await hasTenantPermission('test-user-2', tenant.id, TenantPermission.DATA_VIEW)).toBe(
242
+ true
243
+ )
244
+ expect(
245
+ await hasTenantPermission('test-user-2', tenant.id, TenantPermission.MEMBER_INVITE)
246
+ ).toBe(false)
247
+
248
+ // 二次接受同一 token 失效
249
+ const again = await acceptInvitation(invitation.token, 'test-user-3')
250
+ expect(again).toBeNull()
251
+
252
+ // 移除后立即失权
253
+ await removeMember(tenant.id, member!.id)
254
+ expect(await hasTenantPermission('test-user-2', tenant.id, TenantPermission.DATA_VIEW)).toBe(
255
+ false
256
+ )
257
+ await expect(requireTenantMembership('test-user-2', tenant.id)).rejects.toThrow()
258
+ })
259
+
260
+ it('套餐角色数配额:free 上限 3,创建第 4 个被拦截', async () => {
261
+ const tenant = await createTenant(
262
+ { name: '配额验证', slug: 'quota-check', plan: 'free', maxUsers: 5, settings: {} },
263
+ 'test-super-admin-1'
264
+ )
265
+ // 3 个系统角色已占满 free 上限
266
+ await expect(
267
+ createTenantRole(tenant.id, {
268
+ code: 'custom-a',
269
+ name: 'custom-a',
270
+ label: '自定义A',
271
+ description: null,
272
+ permissions: [TenantPermission.DATA_VIEW],
273
+ })
274
+ ).rejects.toThrow(/上限/)
275
+
276
+ // 升级 pro 后可继续创建
277
+ await updateTenant(tenant.id, { plan: 'pro' })
278
+ const role = await createTenantRole(tenant.id, {
279
+ code: 'custom-a',
280
+ name: 'custom-a',
281
+ label: '自定义A',
282
+ description: null,
283
+ permissions: [TenantPermission.DATA_VIEW],
284
+ })
285
+ expect(role.isSystem).toBe(false)
286
+ })
287
+
288
+ it('P4:套餐成员配额——满员后邀请被拦截,升级后放行', async () => {
289
+ // maxUsers=1 的租户:owner 已占 1 席
290
+ const tiny = await createTenant(
291
+ { name: '配额租户', slug: 'quota-member', plan: 'free', maxUsers: 1, settings: {} },
292
+ 'test-super-admin-1'
293
+ )
294
+ const guestRole = (await getTenantRoles(tiny.id)).find(r => r.code === 'tenant_guest')!
295
+
296
+ await expect(
297
+ inviteMember(tiny.id, 'full@quota.io', guestRole.id, 'test-super-admin-1')
298
+ ).rejects.toThrow(/上限/)
299
+
300
+ // 升级 maxUsers=2 后放行;接受时也过配额
301
+ await updateTenant(tiny.id, { maxUsers: 2 })
302
+ const inv = await inviteMember(tiny.id, 'full@quota.io', guestRole.id, 'test-super-admin-1')
303
+ const member = await acceptInvitation(inv.token, 'test-user-4')
304
+ expect(member?.status).toBe('active')
305
+
306
+ // 满员(2/2)后再邀新邮箱 → 拦截
307
+ await expect(
308
+ inviteMember(tiny.id, 'overflow@quota.io', guestRole.id, 'test-super-admin-1')
309
+ ).rejects.toThrow(/上限/)
310
+ })
311
+
312
+ it('P4:接受时二次校验——邀请有效期内满员则接受被拦截', async () => {
313
+ const t = await createTenant(
314
+ { name: '二次校验', slug: 'quota-accept', plan: 'free', maxUsers: 2, settings: {} },
315
+ 'test-super-admin-1'
316
+ )
317
+ const guestRole = (await getTenantRoles(t.id)).find(r => r.code === 'tenant_guest')!
318
+ const inv = await inviteMember(t.id, 'late@quota.io', guestRole.id, 'test-super-admin-1')
319
+
320
+ // 邀请发出后把 maxUsers 降到 1(owner 已满员)
321
+ await updateTenant(t.id, { maxUsers: 1 })
322
+ await expect(acceptInvitation(inv.token, 'test-user-5')).rejects.toThrow(/上限/)
323
+ })
324
+
325
+ it('getUserTenants 只返回本人 active 成员的租户', async () => {
326
+ const mine = await getUserTenants('test-super-admin-1')
327
+ expect(mine.length).toBeGreaterThanOrEqual(2) // demo 种子 + 本组新建
328
+ expect(mine.every(t => t.slug)).toBe(true)
329
+ expect((await getUserTenants('test-user-2')).length).toBe(0)
330
+ })
331
+ })
@@ -22,7 +22,7 @@ const tenantManifest: ModuleManifest = {
22
22
  clientPages: [],
23
23
 
24
24
  dbSchemas: {
25
- files: ['tenants'],
25
+ files: ['tenants', 'tenant-roles', 'tenant-members', 'tenant-invitations'],
26
26
  hasSeed: true,
27
27
  seed: { serviceFile: 'tenant-service', functionName: 'seedTenantsIfEmpty' },
28
28
  },