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.
- package/dist/cli/index.js +80 -22
- package/dist/cli/index.js.map +1 -1
- package/package.json +12 -12
- package/template/package.json +10 -14
- package/template/src/cli/modules/tenant/index.ts +89 -0
- package/template/src/client/entry-server.tsx +7 -1
- package/template/src/client/main.tsx +0 -8
- package/template/src/client/pages/ContentDetailPage.tsx +1 -1
- package/template/src/client/pages/ContentListPage.tsx +1 -1
- package/template/src/client/pages/TodoPage.tsx +20 -4
- package/template/src/client/ssr-pages.ts +10 -21
- package/template/src/server/db/init.ts +0 -52
- package/template/src/server/db/schema/index.ts +3 -0
- package/template/src/server/db/schema/tenant-invitations.ts +33 -0
- package/template/src/server/db/schema/tenant-members.ts +38 -0
- package/template/src/server/db/schema/tenant-roles.ts +32 -0
- package/template/src/server/db/schema/todos.ts +4 -0
- package/template/src/server/db/test-setup.ts +128 -0
- package/template/src/server/entries/node.ts +38 -19
- package/template/src/server/index.ts +12 -0
- package/template/src/server/middleware/__tests__/tenant-isolation.test.ts +55 -9
- package/template/src/server/middleware/auth.ts +17 -19
- package/template/src/server/middleware/tenant-isolation.ts +4 -2
- package/template/src/server/module-auth/module.ts +2 -1
- package/template/src/server/module-auth/services/auth-service.ts +39 -0
- package/template/src/server/module-tenant/__tests__/tenant-routes.test.ts +97 -0
- package/template/src/server/module-tenant/__tests__/tenant-service.test.ts +201 -22
- package/template/src/server/module-tenant/module.ts +1 -1
- package/template/src/server/module-tenant/routes/tenant-routes.ts +339 -2
- package/template/src/server/module-tenant/services/tenant-service.ts +581 -17
- package/template/src/server/module-todos/routes/todos-routes.ts +10 -2
- package/template/src/server/module-todos/services/todo-service.ts +12 -2
- package/template/src/server/ssr-bridge.ts +10 -0
- package/template/src/server/utils/__tests__/captcha.test.ts +6 -4
- package/template/src/server/utils/id-helpers.ts +13 -0
- package/template/src/shared/modules/index.ts +29 -6
- package/template/src/shared/modules/tenant/index.ts +30 -0
- package/template/src/shared/modules/tenant/permissions.ts +54 -0
- package/template/src/shared/modules/tenant/role-templates.ts +66 -0
- package/template/src/shared/modules/tenant/schemas.ts +113 -1
- package/template/src/shared/modules/todos/schemas.ts +1 -0
- package/template/src/shared/schemas/index.ts +28 -0
- package/template/src/tenant/App.tsx +30 -23
- package/template/src/tenant/components/TenantGuard.tsx +12 -2
- package/template/src/tenant/layouts/Header.tsx +6 -2
- package/template/src/tenant/pages/InviteAcceptPage.tsx +97 -0
- package/template/src/tenant/pages/LoginPage.tsx +82 -0
- package/template/src/tenant/pages/SubscriptionPage.tsx +32 -55
- package/template/src/tenant/pages/UsersPage.tsx +134 -88
- package/template/src/tenant/services/tenantApi.ts +61 -0
- package/template/src/tenant/stores/tenantStore.ts +172 -49
- package/template/src/test/setup-db-path.ts +20 -0
- package/template/vite.config.ts +4 -73
- 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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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:
|
|
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
|
+
})
|