create-fullstack-scaffold 0.5.6 → 0.6.0

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 (54) hide show
  1. package/dist/cli/index.js +80 -22
  2. package/dist/cli/index.js.map +1 -1
  3. package/package.json +12 -12
  4. package/template/package.json +10 -14
  5. package/template/src/cli/modules/tenant/index.ts +89 -0
  6. package/template/src/client/entry-server.tsx +7 -1
  7. package/template/src/client/main.tsx +0 -8
  8. package/template/src/client/pages/ContentDetailPage.tsx +1 -1
  9. package/template/src/client/pages/ContentListPage.tsx +1 -1
  10. package/template/src/client/pages/TodoPage.tsx +20 -4
  11. package/template/src/client/ssr-pages.ts +10 -21
  12. package/template/src/server/db/init.ts +0 -52
  13. package/template/src/server/db/schema/index.ts +3 -0
  14. package/template/src/server/db/schema/tenant-invitations.ts +33 -0
  15. package/template/src/server/db/schema/tenant-members.ts +38 -0
  16. package/template/src/server/db/schema/tenant-roles.ts +32 -0
  17. package/template/src/server/db/schema/todos.ts +4 -0
  18. package/template/src/server/db/test-setup.ts +128 -0
  19. package/template/src/server/entries/node.ts +38 -19
  20. package/template/src/server/index.ts +12 -0
  21. package/template/src/server/middleware/__tests__/tenant-isolation.test.ts +55 -9
  22. package/template/src/server/middleware/auth.ts +17 -19
  23. package/template/src/server/middleware/tenant-isolation.ts +4 -2
  24. package/template/src/server/module-auth/module.ts +2 -1
  25. package/template/src/server/module-auth/services/auth-service.ts +39 -0
  26. package/template/src/server/module-tenant/__tests__/tenant-routes.test.ts +97 -0
  27. package/template/src/server/module-tenant/__tests__/tenant-service.test.ts +201 -22
  28. package/template/src/server/module-tenant/module.ts +1 -1
  29. package/template/src/server/module-tenant/routes/tenant-routes.ts +339 -2
  30. package/template/src/server/module-tenant/services/tenant-service.ts +581 -17
  31. package/template/src/server/module-todos/routes/todos-routes.ts +10 -2
  32. package/template/src/server/module-todos/services/todo-service.ts +12 -2
  33. package/template/src/server/ssr-bridge.ts +10 -0
  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/vite.config.ts +4 -73
  54. package/template/vitest.config.ts +3 -1
@@ -11,6 +11,7 @@ export async function setupTestDatabase(): Promise<void> {
11
11
  const migrationSQL = `
12
12
  CREATE TABLE IF NOT EXISTS todos (
13
13
  id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
14
+ tenant_id INTEGER,
14
15
  title TEXT NOT NULL,
15
16
  description TEXT,
16
17
  status TEXT DEFAULT 'pending' NOT NULL,
@@ -298,6 +299,43 @@ export async function setupTestDatabase(): Promise<void> {
298
299
  updated_at INTEGER DEFAULT (unixepoch() * 1000) NOT NULL
299
300
  );
300
301
 
302
+ CREATE TABLE IF NOT EXISTS tenant_roles (
303
+ id TEXT PRIMARY KEY NOT NULL,
304
+ tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
305
+ code TEXT NOT NULL,
306
+ name TEXT NOT NULL,
307
+ label TEXT NOT NULL,
308
+ description TEXT,
309
+ permissions TEXT NOT NULL,
310
+ is_system INTEGER NOT NULL DEFAULT 0,
311
+ is_active INTEGER NOT NULL DEFAULT 1,
312
+ sort_order INTEGER NOT NULL DEFAULT 0,
313
+ created_at TEXT NOT NULL,
314
+ updated_at TEXT NOT NULL
315
+ );
316
+ CREATE TABLE IF NOT EXISTS tenant_members (
317
+ id TEXT PRIMARY KEY NOT NULL,
318
+ tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
319
+ user_id TEXT NOT NULL,
320
+ role_id TEXT NOT NULL REFERENCES tenant_roles(id) ON DELETE CASCADE,
321
+ status TEXT NOT NULL DEFAULT 'active',
322
+ invited_by TEXT,
323
+ invited_at TEXT,
324
+ joined_at TEXT NOT NULL,
325
+ last_active_at TEXT
326
+ );
327
+ CREATE TABLE IF NOT EXISTS tenant_invitations (
328
+ id TEXT PRIMARY KEY NOT NULL,
329
+ tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
330
+ email TEXT NOT NULL,
331
+ role_id TEXT NOT NULL REFERENCES tenant_roles(id) ON DELETE CASCADE,
332
+ inviter_id TEXT NOT NULL,
333
+ token TEXT NOT NULL UNIQUE,
334
+ status TEXT NOT NULL DEFAULT 'pending',
335
+ expires_at TEXT NOT NULL,
336
+ accepted_at TEXT,
337
+ created_at TEXT NOT NULL
338
+ );
301
339
  CREATE TABLE IF NOT EXISTS merchants (
302
340
  id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
303
341
  user_id TEXT NOT NULL,
@@ -799,6 +837,93 @@ async function seedTestData(client: Client): Promise<void> {
799
837
  }
800
838
  }
801
839
 
840
+ // Seed tenant roles + owner member(与 app 启动种子 seedTenantsIfEmpty 对齐,demo 租户 id=1)
841
+ const tenantNowIso = new Date().toISOString()
842
+ const tenantRoleSeed = [
843
+ {
844
+ id: 'tr_seed_admin',
845
+ code: 'tenant_admin',
846
+ label: '租户管理员',
847
+ perms: JSON.stringify([
848
+ 'tenant:member:view',
849
+ 'tenant:member:invite',
850
+ 'tenant:member:remove',
851
+ 'tenant:member:role:assign',
852
+ 'tenant:role:view',
853
+ 'tenant:role:create',
854
+ 'tenant:role:edit',
855
+ 'tenant:role:delete',
856
+ 'tenant:settings:view',
857
+ 'tenant:settings:edit',
858
+ 'tenant:data:view',
859
+ 'tenant:data:create',
860
+ 'tenant:data:edit',
861
+ 'tenant:data:delete',
862
+ 'tenant:data:export',
863
+ 'tenant:data:import',
864
+ 'tenant:billing:view',
865
+ 'tenant:billing:manage',
866
+ 'tenant:audit:view',
867
+ ]),
868
+ sortOrder: 0,
869
+ },
870
+ {
871
+ id: 'tr_seed_member',
872
+ code: 'tenant_member',
873
+ label: '普通成员',
874
+ perms: JSON.stringify([
875
+ 'tenant:member:view',
876
+ 'tenant:role:view',
877
+ 'tenant:settings:view',
878
+ 'tenant:data:view',
879
+ 'tenant:data:create',
880
+ 'tenant:data:edit',
881
+ 'tenant:data:delete',
882
+ 'tenant:data:export',
883
+ ]),
884
+ sortOrder: 1,
885
+ },
886
+ {
887
+ id: 'tr_seed_guest',
888
+ code: 'tenant_guest',
889
+ label: '访客',
890
+ perms: JSON.stringify([
891
+ 'tenant:member:view',
892
+ 'tenant:role:view',
893
+ 'tenant:settings:view',
894
+ 'tenant:data:view',
895
+ ]),
896
+ sortOrder: 2,
897
+ },
898
+ ]
899
+ for (const role of tenantRoleSeed) {
900
+ try {
901
+ await client.execute({
902
+ sql: `INSERT OR IGNORE INTO tenant_roles (id, tenant_id, code, name, label, description, permissions, is_system, is_active, sort_order, created_at, updated_at) VALUES (?, 1, ?, ?, ?, NULL, ?, 1, 1, ?, ?, ?)`,
903
+ args: [
904
+ role.id,
905
+ role.code,
906
+ role.code,
907
+ role.label,
908
+ role.perms,
909
+ role.sortOrder,
910
+ tenantNowIso,
911
+ tenantNowIso,
912
+ ],
913
+ })
914
+ } catch {
915
+ // Ignore duplicate errors
916
+ }
917
+ }
918
+ try {
919
+ await client.execute({
920
+ sql: `INSERT OR IGNORE INTO tenant_members (id, tenant_id, user_id, role_id, status, invited_by, invited_at, joined_at, last_active_at) VALUES ('tm_seed_owner', 1, 'test-super-admin-1', 'tr_seed_admin', 'active', NULL, NULL, ?, ?)`,
921
+ args: [tenantNowIso, tenantNowIso],
922
+ })
923
+ } catch {
924
+ // Ignore duplicate errors
925
+ }
926
+
802
927
  // Seed products for testing
803
928
  const products = [
804
929
  {
@@ -968,6 +1093,9 @@ export async function cleanupTestDatabase(): Promise<void> {
968
1093
  } catch {
969
1094
  // Plugin tables may not exist in all test environments
970
1095
  }
1096
+ await client.execute('DELETE FROM tenant_invitations')
1097
+ await client.execute('DELETE FROM tenant_members')
1098
+ await client.execute('DELETE FROM tenant_roles')
971
1099
  await client.execute('DELETE FROM tenants')
972
1100
  await client.execute('DELETE FROM merchants')
973
1101
  await client.execute('DELETE FROM products')
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @framework-baseline 159d5e23f19e9793
2
+ * @framework-baseline 91c7b6f83b7c9913
3
3
  * @framework-modify
4
4
  * @reason 添加 SPA 前端路由处理,区分开发/生产环境
5
5
  * @impact 新增前端路由处理逻辑,/admin/* 返回 admin.html,其他路由返回 index.html
@@ -17,22 +17,10 @@ import { logger } from '../utils/logger'
17
17
  import { createApp } from '../app'
18
18
  import { getDb, runMigrations } from '../db'
19
19
  import { createISRCache, isISRRoute } from '@server/core/isr-cache'
20
+ // Side-effect: 注册 ISR 路由到全局 registry(Node 入口此前漏了这一行——
21
+ // registry 空 → isISRRoute 恒 false → ISR 永远不触发,SSR 壳照旧)
22
+ import '@server/isr-modules'
20
23
  import { renderISRPage } from '@server/core/isr-renderer'
21
- // @client/entry-server 仅含 client 的 preset 存在;cli-only 会因静态
22
- // 导入悬空(verify 链已拦过一次)。运行时按需加载,缺省回退壳渲染。
23
- type RenderSSRFn = (pathname: string, data: never) => { html: string }
24
-
25
- async function loadRenderSSR(): Promise<RenderSSRFn | null> {
26
- try {
27
- // 变量说明符:tsc 不做模块解析(cli-only 无 client 目录时字面量
28
- // 动态导入也会被 tsc 拦——与 vitest.config 的 react 插件同法)
29
- const mod = '@client/entry-server'
30
- const m = (await import(/* @vite-ignore */ mod)) as { renderSSR: RenderSSRFn }
31
- return m.renderSSR
32
- } catch {
33
- return null
34
- }
35
- }
36
24
  import { isrRegistry, type ISRRouterContext } from '@server/core/isr-registry'
37
25
  import { setISRCache } from '@server/core/isr-invalidation'
38
26
  import { setRuntimeAdapter } from '@server/core/runtime'
@@ -50,6 +38,9 @@ const indexHtmlPath = hasDist
50
38
  const adminHtmlPath = hasDist
51
39
  ? resolve(distPath, 'admin.html')
52
40
  : resolve(process.cwd(), 'admin.html')
41
+ const tenantHtmlPath = hasDist
42
+ ? resolve(distPath, 'tenant.html')
43
+ : resolve(process.cwd(), 'tenant.html')
53
44
 
54
45
  const indexHtml = existsSync(indexHtmlPath)
55
46
  ? readFileSync(indexHtmlPath, 'utf-8')
@@ -57,6 +48,8 @@ const indexHtml = existsSync(indexHtmlPath)
57
48
  const adminHtml = existsSync(adminHtmlPath)
58
49
  ? readFileSync(adminHtmlPath, 'utf-8')
59
50
  : '<html><body>admin.html not found</body></html>'
51
+ // saas 等含 tenant 模块的 preset 才有 tenant.html;缺失回落 index
52
+ const tenantHtml = existsSync(tenantHtmlPath) ? readFileSync(tenantHtmlPath, 'utf-8') : indexHtml
60
53
 
61
54
  const log = logger.api()
62
55
 
@@ -130,6 +123,14 @@ app.get('/admin/*', c => {
130
123
  return c.html(adminHtml)
131
124
  })
132
125
 
126
+ // 租户控制台独立 SPA(basename=/tenant)
127
+ app.get('/tenant', c => {
128
+ return c.html(tenantHtml)
129
+ })
130
+ app.get('/tenant/*', c => {
131
+ return c.html(tenantHtml)
132
+ })
133
+
133
134
  // 其他非 API 路由返回 index.html(ISR 路由尝试缓存)
134
135
  app.get('*', async c => {
135
136
  if (c.req.path.startsWith('/api/') || c.req.path.startsWith('/files/')) {
@@ -217,9 +218,28 @@ export async function createServer() {
217
218
  * 页面级渲染 → renderISRPage 注入 body + meta + __SSR_DATA__。
218
219
  * 返回 null 表示无注册路由/渲染失败,调用方回退 SPA index.html。
219
220
  */
221
+ type SSRRenderer = (pathname: string, data: unknown) => { html: string } | null
222
+
223
+ // 渲染桥按需加载:静态 import 会把整个 React SSR 图(react-router-dom 等
224
+ // CJS 依赖)拉进 vite dev 的 SSR 模块图,dev server 全路由 500;动态加载
225
+ // 只在生产渲染 ISR 路由时触发,dev 下失败则回退 SPA 壳。
226
+ let cachedRenderer: SSRRenderer | null | undefined
227
+ async function loadSSRRenderer(): Promise<SSRRenderer | null> {
228
+ if (cachedRenderer !== undefined) return cachedRenderer
229
+ try {
230
+ const mod = await import('@server/ssr-bridge')
231
+ cachedRenderer = mod.renderSSR
232
+ } catch {
233
+ cachedRenderer = null
234
+ }
235
+ return cachedRenderer
236
+ }
237
+
220
238
  async function renderISRForRoute(pathname: string): Promise<string | null> {
221
239
  const entry = isrRegistry.match(pathname)
222
240
  if (!entry) return null
241
+ const renderSSR = await loadSSRRenderer()
242
+ if (!renderSSR) return null // 无 client 的 preset(cli-only 等)回退 SPA 壳
223
243
  const ctx: ISRRouterContext = { db: await getDb(), env: {} }
224
244
  let data: unknown = {}
225
245
  let meta = { title: 'App', description: '' }
@@ -229,10 +249,9 @@ async function renderISRForRoute(pathname: string): Promise<string | null> {
229
249
  } catch {
230
250
  // DB 错误——回退默认 meta 继续渲染壳
231
251
  }
232
- const renderSSR = await loadRenderSSR()
233
- if (!renderSSR) return null // 无 client(cli-only 等)——调用方回退 SPA
234
252
  try {
235
- const ssr = renderSSR(pathname, data as never)
253
+ const ssr = renderSSR(pathname, data)
254
+ if (!ssr) return null // 渲染桥返回 null(无 client 变体)
236
255
  return renderISRPage({ template: indexHtml, body: ssr.html, meta, data })
237
256
  } catch (e) {
238
257
  console.warn('ISR render failed:', e)
@@ -24,6 +24,7 @@ setRuntimeAdapter(runtimeAdapter)
24
24
  // HTML 文件路径(开发环境直接读取根目录)
25
25
  const indexHtmlPath = resolve(process.cwd(), 'index.html')
26
26
  const adminHtmlPath = resolve(process.cwd(), 'admin.html')
27
+ const tenantHtmlPath = resolve(process.cwd(), 'tenant.html')
27
28
 
28
29
  const indexHtml = existsSync(indexHtmlPath)
29
30
  ? readFileSync(indexHtmlPath, 'utf-8')
@@ -31,6 +32,8 @@ const indexHtml = existsSync(indexHtmlPath)
31
32
  const adminHtml = existsSync(adminHtmlPath)
32
33
  ? readFileSync(adminHtmlPath, 'utf-8')
33
34
  : '<html><body>admin.html not found</body></html>'
35
+ // saas 等含 tenant 模块的 preset 才有 tenant.html;缺失时回落 index
36
+ const tenantHtml = existsSync(tenantHtmlPath) ? readFileSync(tenantHtmlPath, 'utf-8') : indexHtml
34
37
 
35
38
  // 创建 Hono 应用
36
39
  const app = createApp()
@@ -40,6 +43,15 @@ app.get('/admin/*', c => {
40
43
  return c.html(adminHtml)
41
44
  })
42
45
 
46
+ // 租户控制台(basename=/tenant 的独立 SPA)——无此入口时 /tenant/* 会
47
+ // 落进客户端 SPA fallback 渲染成 404
48
+ app.get('/tenant/*', c => {
49
+ return c.html(tenantHtml)
50
+ })
51
+ app.get('/tenant', c => {
52
+ return c.html(tenantHtml)
53
+ })
54
+
43
55
  // SPA fallback - 使用中间件方式,确保 API 路由已经注册后才添加
44
56
  app.use('*', async (c, next) => {
45
57
  await next()
@@ -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
+ })