create-windy 0.3.0 → 0.3.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.
- package/README.md +30 -3
- package/dist/cli.js +7640 -3255
- package/package.json +3 -3
- package/template/.windy-template.json +2 -2
- package/template/README.md +25 -3
- package/template/_gitignore +4 -0
- package/template/apps/agent-server/tests/__init__.py +1 -0
- package/template/apps/agent-server/tests/openai_provider_test_support.py +131 -0
- package/template/apps/agent-server/tests/test_openai_provider.py +7 -123
- package/template/apps/server/src/auth/routes.test-support.ts +94 -0
- package/template/apps/server/src/auth/routes.test.ts +20 -88
- package/template/apps/server/src/index.ts +17 -34
- package/template/apps/server/src/mcp/adapter.test.ts +3 -2
- package/template/apps/server/src/mcp/adapter.ts +9 -9
- package/template/apps/server/src/mcp/routes.ts +4 -3
- package/template/apps/server/src/platform-http-runtime.ts +87 -0
- package/template/apps/server/src/system/drizzle-system-configs.ts +319 -0
- package/template/apps/server/src/system/drizzle-system.ts +35 -315
- package/template/apps/server/src/system/resource-routes.ts +270 -0
- package/template/apps/server/src/system/routes.ts +3 -269
- package/template/apps/web/src/pages/system/components/SystemResourceLoadError.vue +21 -0
- package/template/apps/web/src/pages/system/components/SystemResourceLoadError.webtest.ts +17 -0
- package/template/apps/web/src/pages/system/components/SystemResourcePage.vue +5 -10
- package/template/docs/architecture/mcp-business-capabilities.md +107 -0
- package/template/docs/development/custom-login-page.md +3 -3
- package/template/docs/platform/agent-runtime.md +5 -5
- package/template/docs/platform/agent-tools.md +4 -0
- package/template/packages/modules/package.json +1 -1
- package/template/packages/modules/src/composition-fixtures.test-support.ts +164 -0
- package/template/packages/modules/src/composition.test.ts +5 -158
- package/template/packages/modules/src/system-api-operational.ts +234 -0
- package/template/packages/modules/src/system-api-permissions.ts +8 -229
- package/template/packages/modules/src/system-module-tasks.ts +86 -0
- package/template/packages/modules/src/system-modules-access.test.ts +60 -0
- package/template/packages/modules/src/system-modules.test.ts +0 -53
- package/template/packages/modules/src/system-modules.ts +2 -84
- package/template/packages/shared/index.ts +1 -0
- package/template/packages/shared/package.json +1 -1
- package/template/packages/shared/src/mcp.test.ts +12 -0
- package/template/packages/shared/src/mcp.ts +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-windy",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "创建单组织、单租户、私有部署优先的 Windy 企业 Dashboard Starter",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,14 +13,14 @@
|
|
|
13
13
|
"README.md"
|
|
14
14
|
],
|
|
15
15
|
"scripts": {
|
|
16
|
-
"build": "bun build src/cli.ts --target=
|
|
16
|
+
"build": "bun build src/cli.ts --target=bun --format=esm --outdir=dist",
|
|
17
17
|
"template:sync": "bun run src/sync-template.ts",
|
|
18
18
|
"prepack": "bun run template:sync && bun run build",
|
|
19
19
|
"postpack": "bun run src/clean-template.ts",
|
|
20
20
|
"typecheck": "tsc --noEmit --project tsconfig.json"
|
|
21
21
|
},
|
|
22
22
|
"engines": {
|
|
23
|
-
"
|
|
23
|
+
"bun": ">=1.3.14"
|
|
24
24
|
},
|
|
25
25
|
"keywords": [
|
|
26
26
|
"dashboard",
|
package/template/README.md
CHANGED
|
@@ -52,6 +52,19 @@ bun run dev:server
|
|
|
52
52
|
以终端输出为准。`dev:server` 不会自动启动 PostgreSQL 或执行 migration;未配置
|
|
53
53
|
`DATABASE_URL` 时使用内存模式。
|
|
54
54
|
|
|
55
|
+
## Agent、MCP 与仓库 Skill
|
|
56
|
+
|
|
57
|
+
仓库始终包含 `.agents/skills/windy-business-module/SKILL.md`。新增或修改业务模块、
|
|
58
|
+
Agent Tool 或 MCP 暴露时,可以明确要求代码 Agent“使用 `windy-business-module`
|
|
59
|
+
Skill 完成该能力”。Skill 只指导开发流程;Feature、Permission、Schema 和 Tool 的
|
|
60
|
+
运行时事实仍以 Module Manifest 与平台 Registry 为准。
|
|
61
|
+
|
|
62
|
+
当前组合未包含 `system.agent`,MCP Server、Agent Tool Host 和相关文档已被
|
|
63
|
+
物理裁剪。若项目尚可重新生成,请在创建时增加 `--module system.agent` 或使用
|
|
64
|
+
`--all-modules`;部署后的通用 `module add` 尚未交付,不要手工复制 MCP Adapter
|
|
65
|
+
或另建授权链。仓库 Skill 仍可用于普通业务模块开发。
|
|
66
|
+
|
|
67
|
+
|
|
55
68
|
|
|
56
69
|
|
|
57
70
|
## 页面与登录
|
|
@@ -129,6 +142,7 @@ bun run build
|
|
|
129
142
|
bun run windy doctor
|
|
130
143
|
bun run windy update --check
|
|
131
144
|
bun run windy update --dry-run
|
|
145
|
+
bun run windy reconcile --to <精确目标版本>
|
|
132
146
|
bun run lint
|
|
133
147
|
bun run format
|
|
134
148
|
```
|
|
@@ -137,7 +151,14 @@ bun run format
|
|
|
137
151
|
|
|
138
152
|
更新前必须保持 Git 工作区干净。`--dry-run` 只生成计划,不写项目;正式更新先备份,
|
|
139
153
|
再做 package 字段级合并、共享脚手架三方合并与完整验证。任何冲突都会在写入前中止,
|
|
140
|
-
|
|
154
|
+
无并发变化的验证失败会自动回滚;中断恢复使用
|
|
155
|
+
`bun run windy doctor --repair`。该命令在提交点前保留并发编辑并回滚其余路径,
|
|
156
|
+
在 committed 提交点后只完成事务状态与备份清理,不回滚项目文件。
|
|
157
|
+
|
|
158
|
+
仅当 Windy 发布说明提供确定性 proof 时使用
|
|
159
|
+
`reconcile --to <精确目标版本>`。它默认只演练;显式增加 `--apply` 后也只更新
|
|
160
|
+
受管清单元数据与无正文报告,不修改项目源码或推进 Project Lock。project-owned、
|
|
161
|
+
缺失和未知语义差异始终保守未决;shared-scaffold 只有精确命中已发布 proof 才可对账。
|
|
141
162
|
|
|
142
163
|
## 开发约束
|
|
143
164
|
|
|
@@ -156,8 +177,9 @@ bun run format
|
|
|
156
177
|
create-windy 默认建立 `main` 分支并创建 `chore: initialize project` 首次提交。若当时
|
|
157
178
|
没有配置 Git 用户身份,文件会保留在暂存区;配置身份后手动提交即可。
|
|
158
179
|
|
|
159
|
-
|
|
160
|
-
|
|
180
|
+
当前已交付的 Windy 更新器会根据 Project Lock 和文件所有权,从 npm 历史 artifact
|
|
181
|
+
重建 Base 并执行三方合并;它不连接私有源仓库,也不会静默覆盖项目代码。部署期通用
|
|
182
|
+
`module add/update/remove` 尚未交付。
|
|
161
183
|
|
|
162
184
|
## License
|
|
163
185
|
|
package/template/_gitignore
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Agent Server test package."""
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import tempfile
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from southwind_agent_server.config import (
|
|
9
|
+
EnvironmentSecretResolver,
|
|
10
|
+
OpenAiCompatibleConfig,
|
|
11
|
+
SecretRef,
|
|
12
|
+
)
|
|
13
|
+
from southwind_agent_server.contracts import (
|
|
14
|
+
ProviderDataPolicy,
|
|
15
|
+
ProviderEgress,
|
|
16
|
+
ProviderFieldPolicy,
|
|
17
|
+
ProviderInputFieldPolicy,
|
|
18
|
+
RuntimeLimits,
|
|
19
|
+
RuntimeToolDefinition,
|
|
20
|
+
ToolNetworkPolicy,
|
|
21
|
+
)
|
|
22
|
+
from southwind_agent_server.engine import EngineRunRequest
|
|
23
|
+
from southwind_agent_server.openai_provider import OpenAiCompatibleProvider
|
|
24
|
+
from southwind_agent_server.provider_audit import ProviderAuditStore
|
|
25
|
+
from southwind_agent_server.provider_governance import ProviderRequestGovernance
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def create_provider(
|
|
29
|
+
transport: httpx.AsyncBaseTransport,
|
|
30
|
+
) -> OpenAiCompatibleProvider:
|
|
31
|
+
environment = {"QWEN_API_KEY": "server-secret"}
|
|
32
|
+
return OpenAiCompatibleProvider(
|
|
33
|
+
OpenAiCompatibleConfig(
|
|
34
|
+
base_url="https://provider.test/v1",
|
|
35
|
+
model="qwen-compatible",
|
|
36
|
+
api_key_ref=SecretRef("env", "QWEN_API_KEY"),
|
|
37
|
+
https_host_allowlist=("provider.test",),
|
|
38
|
+
),
|
|
39
|
+
EnvironmentSecretResolver(environment),
|
|
40
|
+
transport=transport,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def runtime_request(tool_key: str) -> EngineRunRequest:
|
|
45
|
+
return EngineRunRequest(
|
|
46
|
+
run_id="run_1",
|
|
47
|
+
operation_key="business.answer",
|
|
48
|
+
input={"question": "记录"},
|
|
49
|
+
instructions="回答业务问题。",
|
|
50
|
+
user_prompt="有多少记录?",
|
|
51
|
+
allowed_tools=(
|
|
52
|
+
RuntimeToolDefinition(
|
|
53
|
+
key=tool_key,
|
|
54
|
+
label="查询记录",
|
|
55
|
+
description="查询当前数据范围内的记录。",
|
|
56
|
+
),
|
|
57
|
+
),
|
|
58
|
+
limits=RuntimeLimits(
|
|
59
|
+
max_duration_seconds=60,
|
|
60
|
+
max_output_bytes=4096,
|
|
61
|
+
),
|
|
62
|
+
provider_egress=ProviderEgress(
|
|
63
|
+
mode="configured-endpoint",
|
|
64
|
+
deployment_key="ai.openai-compatible",
|
|
65
|
+
),
|
|
66
|
+
provider_data_policy=provider_data_policy(),
|
|
67
|
+
tool_network_policy=ToolNetworkPolicy(mode="forbidden"),
|
|
68
|
+
execution_grant="grant_1",
|
|
69
|
+
tool_host_url="http://server:9747",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def provider_data_policy() -> ProviderDataPolicy:
|
|
74
|
+
return ProviderDataPolicy(
|
|
75
|
+
input_fields=[
|
|
76
|
+
ProviderInputFieldPolicy(
|
|
77
|
+
json_pointer="/question",
|
|
78
|
+
classification="internal",
|
|
79
|
+
dlp_action="redact",
|
|
80
|
+
)
|
|
81
|
+
],
|
|
82
|
+
envelope_fields=[
|
|
83
|
+
ProviderFieldPolicy(
|
|
84
|
+
field="instructions",
|
|
85
|
+
classification="internal",
|
|
86
|
+
dlp_action="reject",
|
|
87
|
+
),
|
|
88
|
+
ProviderFieldPolicy(
|
|
89
|
+
field="user-prompt",
|
|
90
|
+
classification="internal",
|
|
91
|
+
dlp_action="redact",
|
|
92
|
+
),
|
|
93
|
+
ProviderFieldPolicy(
|
|
94
|
+
field="tool-definitions",
|
|
95
|
+
classification="internal",
|
|
96
|
+
dlp_action="reject",
|
|
97
|
+
),
|
|
98
|
+
ProviderFieldPolicy(
|
|
99
|
+
field="provider-output",
|
|
100
|
+
classification="internal",
|
|
101
|
+
dlp_action="redact",
|
|
102
|
+
),
|
|
103
|
+
ProviderFieldPolicy(
|
|
104
|
+
field="tool-results",
|
|
105
|
+
classification="sensitive",
|
|
106
|
+
dlp_action="redact",
|
|
107
|
+
),
|
|
108
|
+
],
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def governance(path: str | None = None) -> ProviderRequestGovernance:
|
|
113
|
+
path = (
|
|
114
|
+
path
|
|
115
|
+
or tempfile.NamedTemporaryFile(
|
|
116
|
+
suffix=".sqlite3",
|
|
117
|
+
delete=False,
|
|
118
|
+
).name
|
|
119
|
+
)
|
|
120
|
+
return ProviderRequestGovernance(ProviderAuditStore(path, retention_days=90))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def sse_response(chunks: list[object]) -> httpx.Response:
|
|
124
|
+
body = "".join(
|
|
125
|
+
f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n" for chunk in chunks
|
|
126
|
+
)
|
|
127
|
+
return httpx.Response(
|
|
128
|
+
200,
|
|
129
|
+
headers={"content-type": "text/event-stream"},
|
|
130
|
+
content=f"{body}data: [DONE]\n\n",
|
|
131
|
+
)
|
|
@@ -7,20 +7,7 @@ import tempfile
|
|
|
7
7
|
import httpx
|
|
8
8
|
import pytest
|
|
9
9
|
|
|
10
|
-
from southwind_agent_server.
|
|
11
|
-
EnvironmentSecretResolver,
|
|
12
|
-
OpenAiCompatibleConfig,
|
|
13
|
-
SecretRef,
|
|
14
|
-
)
|
|
15
|
-
from southwind_agent_server.contracts import (
|
|
16
|
-
ProviderDataPolicy,
|
|
17
|
-
ProviderEgress,
|
|
18
|
-
ProviderFieldPolicy,
|
|
19
|
-
ProviderInputFieldPolicy,
|
|
20
|
-
RuntimeLimits,
|
|
21
|
-
RuntimeToolDefinition,
|
|
22
|
-
ToolNetworkPolicy,
|
|
23
|
-
)
|
|
10
|
+
from southwind_agent_server.contracts import ProviderEgress
|
|
24
11
|
from southwind_agent_server.engine import (
|
|
25
12
|
EngineRunRequest,
|
|
26
13
|
SafeEngineError,
|
|
@@ -28,10 +15,13 @@ from southwind_agent_server.engine import (
|
|
|
28
15
|
ToolRequested,
|
|
29
16
|
)
|
|
30
17
|
from southwind_agent_server.openai_engine import OpenAiCompatibleEngineAdapter
|
|
31
|
-
from southwind_agent_server.openai_provider import OpenAiCompatibleProvider
|
|
32
|
-
from southwind_agent_server.provider_audit import ProviderAuditStore
|
|
33
|
-
from southwind_agent_server.provider_governance import ProviderRequestGovernance
|
|
34
18
|
from southwind_agent_server.tool_host import ToolHostClient
|
|
19
|
+
from tests.openai_provider_test_support import (
|
|
20
|
+
create_provider,
|
|
21
|
+
governance,
|
|
22
|
+
runtime_request,
|
|
23
|
+
sse_response,
|
|
24
|
+
)
|
|
35
25
|
|
|
36
26
|
|
|
37
27
|
async def test_provider_stream_and_health_use_bounded_safe_protocol() -> None:
|
|
@@ -255,109 +245,3 @@ async def test_engine_denies_mismatched_provider_deployment() -> None:
|
|
|
255
245
|
async for _signal in engine.execute(request):
|
|
256
246
|
pass
|
|
257
247
|
assert captured.value.code == "FORBIDDEN"
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
def create_provider(
|
|
261
|
-
transport: httpx.AsyncBaseTransport,
|
|
262
|
-
) -> OpenAiCompatibleProvider:
|
|
263
|
-
environment = {"QWEN_API_KEY": "server-secret"}
|
|
264
|
-
return OpenAiCompatibleProvider(
|
|
265
|
-
OpenAiCompatibleConfig(
|
|
266
|
-
base_url="https://provider.test/v1",
|
|
267
|
-
model="qwen-compatible",
|
|
268
|
-
api_key_ref=SecretRef("env", "QWEN_API_KEY"),
|
|
269
|
-
https_host_allowlist=("provider.test",),
|
|
270
|
-
),
|
|
271
|
-
EnvironmentSecretResolver(environment),
|
|
272
|
-
transport=transport,
|
|
273
|
-
)
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
def runtime_request(tool_key: str) -> EngineRunRequest:
|
|
277
|
-
return EngineRunRequest(
|
|
278
|
-
run_id="run_1",
|
|
279
|
-
operation_key="business.answer",
|
|
280
|
-
input={"question": "记录"},
|
|
281
|
-
instructions="回答业务问题。",
|
|
282
|
-
user_prompt="有多少记录?",
|
|
283
|
-
allowed_tools=(
|
|
284
|
-
RuntimeToolDefinition(
|
|
285
|
-
key=tool_key,
|
|
286
|
-
label="查询记录",
|
|
287
|
-
description="查询当前数据范围内的记录。",
|
|
288
|
-
),
|
|
289
|
-
),
|
|
290
|
-
limits=RuntimeLimits(
|
|
291
|
-
max_duration_seconds=60,
|
|
292
|
-
max_output_bytes=4096,
|
|
293
|
-
),
|
|
294
|
-
provider_egress=ProviderEgress(
|
|
295
|
-
mode="configured-endpoint",
|
|
296
|
-
deployment_key="ai.openai-compatible",
|
|
297
|
-
),
|
|
298
|
-
provider_data_policy=provider_data_policy(),
|
|
299
|
-
tool_network_policy=ToolNetworkPolicy(mode="forbidden"),
|
|
300
|
-
execution_grant="grant_1",
|
|
301
|
-
tool_host_url="http://server:9747",
|
|
302
|
-
)
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
def provider_data_policy() -> ProviderDataPolicy:
|
|
306
|
-
return ProviderDataPolicy(
|
|
307
|
-
input_fields=[
|
|
308
|
-
ProviderInputFieldPolicy(
|
|
309
|
-
json_pointer="/question",
|
|
310
|
-
classification="internal",
|
|
311
|
-
dlp_action="redact",
|
|
312
|
-
)
|
|
313
|
-
],
|
|
314
|
-
envelope_fields=[
|
|
315
|
-
ProviderFieldPolicy(
|
|
316
|
-
field="instructions",
|
|
317
|
-
classification="internal",
|
|
318
|
-
dlp_action="reject",
|
|
319
|
-
),
|
|
320
|
-
ProviderFieldPolicy(
|
|
321
|
-
field="user-prompt",
|
|
322
|
-
classification="internal",
|
|
323
|
-
dlp_action="redact",
|
|
324
|
-
),
|
|
325
|
-
ProviderFieldPolicy(
|
|
326
|
-
field="tool-definitions",
|
|
327
|
-
classification="internal",
|
|
328
|
-
dlp_action="reject",
|
|
329
|
-
),
|
|
330
|
-
ProviderFieldPolicy(
|
|
331
|
-
field="provider-output",
|
|
332
|
-
classification="internal",
|
|
333
|
-
dlp_action="redact",
|
|
334
|
-
),
|
|
335
|
-
ProviderFieldPolicy(
|
|
336
|
-
field="tool-results",
|
|
337
|
-
classification="sensitive",
|
|
338
|
-
dlp_action="redact",
|
|
339
|
-
),
|
|
340
|
-
],
|
|
341
|
-
)
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
def governance(path: str | None = None) -> ProviderRequestGovernance:
|
|
345
|
-
path = (
|
|
346
|
-
path
|
|
347
|
-
or tempfile.NamedTemporaryFile(
|
|
348
|
-
suffix=".sqlite3",
|
|
349
|
-
delete=False,
|
|
350
|
-
).name
|
|
351
|
-
)
|
|
352
|
-
return ProviderRequestGovernance(ProviderAuditStore(path, retention_days=90))
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
def sse_response(chunks: list[object]) -> httpx.Response:
|
|
356
|
-
body = "".join(
|
|
357
|
-
f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n" for chunk in chunks
|
|
358
|
-
)
|
|
359
|
-
return httpx.Response(
|
|
360
|
-
200,
|
|
361
|
-
headers={"content-type": "text/event-stream"},
|
|
362
|
-
content=f"{body}data: [DONE]\n\n",
|
|
363
|
-
)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { expect } from "bun:test";
|
|
2
|
+
import type { AuditEvent, PasswordPolicy } from "@southwind-ai/shared";
|
|
3
|
+
import { Elysia } from "elysia";
|
|
4
|
+
import { createServerRuntime } from "../runtime.js";
|
|
5
|
+
import { RepositoryActorResolver } from "./actor-resolver.js";
|
|
6
|
+
import { InMemoryAuthRepository, type AuthUserRecord } from "./repository.js";
|
|
7
|
+
import { registerAuthRoutes, registerAuthSessionRoute } from "./routes.js";
|
|
8
|
+
import { AuthService } from "./service.js";
|
|
9
|
+
|
|
10
|
+
export function authFixture(
|
|
11
|
+
users: AuthUserRecord[],
|
|
12
|
+
passwordPolicy?: () => PasswordPolicy,
|
|
13
|
+
sessionTtlSeconds?: () => number,
|
|
14
|
+
) {
|
|
15
|
+
const repository = new InMemoryAuthRepository(users);
|
|
16
|
+
const resolver = new RepositoryActorResolver(repository, {
|
|
17
|
+
sessionCookieName: "windy.sid",
|
|
18
|
+
});
|
|
19
|
+
const events: AuditEvent[] = [];
|
|
20
|
+
const runtime = createServerRuntime({}, { actorResolver: resolver });
|
|
21
|
+
const service = new AuthService(repository, {
|
|
22
|
+
passwordPolicy,
|
|
23
|
+
sessionTtlSeconds,
|
|
24
|
+
auditWriter: {
|
|
25
|
+
write(event) {
|
|
26
|
+
events.push(event);
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
const app = new Elysia({ prefix: "/api" }).derive(async ({ request }) => ({
|
|
31
|
+
guardContext: await runtime.createGuardContext(request),
|
|
32
|
+
}));
|
|
33
|
+
registerAuthSessionRoute(app);
|
|
34
|
+
registerAuthRoutes(app, service, { secureCookie: false });
|
|
35
|
+
return { app, events, repository, runtime };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createActiveUser(passwordHash: string): AuthUserRecord {
|
|
39
|
+
return {
|
|
40
|
+
id: "user_admin",
|
|
41
|
+
username: "admin",
|
|
42
|
+
displayName: "平台管理员",
|
|
43
|
+
email: "admin@windy.local",
|
|
44
|
+
phone: "13800138000",
|
|
45
|
+
passwordHash,
|
|
46
|
+
credentialState: "active",
|
|
47
|
+
status: "active",
|
|
48
|
+
roleCodes: ["developer"],
|
|
49
|
+
permissionKeys: ["system.user.manage", "system.audit.read"],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function loginWithPassword(
|
|
54
|
+
app: { handle(request: Request): Response | Promise<Response> },
|
|
55
|
+
username: string,
|
|
56
|
+
password: string,
|
|
57
|
+
) {
|
|
58
|
+
const response = await app.handle(
|
|
59
|
+
jsonRequest("/api/auth/api/login", {
|
|
60
|
+
username,
|
|
61
|
+
password,
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
expect(response.status).toBe(200);
|
|
65
|
+
return (await response.json()) as {
|
|
66
|
+
token: string;
|
|
67
|
+
sessionId: string;
|
|
68
|
+
expiresAt: string;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function readCookieValue(header: string, name: string): string {
|
|
73
|
+
const value = header.split(";")[0]?.slice(`${name}=`.length);
|
|
74
|
+
return value ? decodeURIComponent(value) : "";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function jsonRequest(path: string, body: unknown): Request {
|
|
78
|
+
return new Request(`http://localhost${path}`, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: { "content-type": "application/json" },
|
|
81
|
+
body: JSON.stringify(body),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function apiRequest(
|
|
86
|
+
path: string,
|
|
87
|
+
token: string,
|
|
88
|
+
method = "GET",
|
|
89
|
+
): Request {
|
|
90
|
+
return new Request(`http://localhost${path}`, {
|
|
91
|
+
method,
|
|
92
|
+
headers: { authorization: `Bearer ${token}` },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
-
import type {
|
|
3
|
-
import { Elysia } from "elysia";
|
|
4
|
-
import { createServerRuntime } from "../runtime.js";
|
|
5
|
-
import { RepositoryActorResolver } from "./actor-resolver.js";
|
|
2
|
+
import type { PasswordPolicy } from "@southwind-ai/shared";
|
|
6
3
|
import { argon2PasswordHasher } from "./password.js";
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
import {
|
|
5
|
+
apiRequest,
|
|
6
|
+
authFixture,
|
|
7
|
+
createActiveUser,
|
|
8
|
+
jsonRequest,
|
|
9
|
+
loginWithPassword,
|
|
10
|
+
readCookieValue,
|
|
11
|
+
} from "./routes.test-support.js";
|
|
10
12
|
|
|
11
13
|
let passwordHash = "";
|
|
12
14
|
|
|
@@ -14,6 +16,17 @@ beforeAll(async () => {
|
|
|
14
16
|
passwordHash = await argon2PasswordHasher.hash("test-only-password");
|
|
15
17
|
});
|
|
16
18
|
|
|
19
|
+
function activeUser() {
|
|
20
|
+
return createActiveUser(passwordHash);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function login(
|
|
24
|
+
app: { handle(request: Request): Response | Promise<Response> },
|
|
25
|
+
username: string,
|
|
26
|
+
) {
|
|
27
|
+
return loginWithPassword(app, username, "test-only-password");
|
|
28
|
+
}
|
|
29
|
+
|
|
17
30
|
describe("platform auth API", () => {
|
|
18
31
|
test("密码策略端点返回当前运行时策略且禁止缓存", async () => {
|
|
19
32
|
let policy: PasswordPolicy = { minLength: 16, maxLength: 64 };
|
|
@@ -308,84 +321,3 @@ describe("platform auth API", () => {
|
|
|
308
321
|
);
|
|
309
322
|
});
|
|
310
323
|
});
|
|
311
|
-
|
|
312
|
-
function authFixture(
|
|
313
|
-
users: AuthUserRecord[],
|
|
314
|
-
passwordPolicy?: () => PasswordPolicy,
|
|
315
|
-
sessionTtlSeconds?: () => number,
|
|
316
|
-
) {
|
|
317
|
-
const repository = new InMemoryAuthRepository(users);
|
|
318
|
-
const resolver = new RepositoryActorResolver(repository, {
|
|
319
|
-
sessionCookieName: "windy.sid",
|
|
320
|
-
});
|
|
321
|
-
const events: AuditEvent[] = [];
|
|
322
|
-
const runtime = createServerRuntime({}, { actorResolver: resolver });
|
|
323
|
-
const service = new AuthService(repository, {
|
|
324
|
-
passwordPolicy,
|
|
325
|
-
sessionTtlSeconds,
|
|
326
|
-
auditWriter: {
|
|
327
|
-
write(event) {
|
|
328
|
-
events.push(event);
|
|
329
|
-
},
|
|
330
|
-
},
|
|
331
|
-
});
|
|
332
|
-
const app = new Elysia({ prefix: "/api" }).derive(async ({ request }) => ({
|
|
333
|
-
guardContext: await runtime.createGuardContext(request),
|
|
334
|
-
}));
|
|
335
|
-
registerAuthSessionRoute(app);
|
|
336
|
-
registerAuthRoutes(app, service, { secureCookie: false });
|
|
337
|
-
return { app, events, repository, runtime };
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
function activeUser(): AuthUserRecord {
|
|
341
|
-
return {
|
|
342
|
-
id: "user_admin",
|
|
343
|
-
username: "admin",
|
|
344
|
-
displayName: "平台管理员",
|
|
345
|
-
email: "admin@windy.local",
|
|
346
|
-
phone: "13800138000",
|
|
347
|
-
passwordHash,
|
|
348
|
-
credentialState: "active",
|
|
349
|
-
status: "active",
|
|
350
|
-
roleCodes: ["developer"],
|
|
351
|
-
permissionKeys: ["system.user.manage", "system.audit.read"],
|
|
352
|
-
};
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
async function login(
|
|
356
|
-
app: { handle(request: Request): Response | Promise<Response> },
|
|
357
|
-
username: string,
|
|
358
|
-
) {
|
|
359
|
-
const response = await app.handle(
|
|
360
|
-
jsonRequest("/api/auth/api/login", {
|
|
361
|
-
username,
|
|
362
|
-
password: "test-only-password",
|
|
363
|
-
}),
|
|
364
|
-
);
|
|
365
|
-
expect(response.status).toBe(200);
|
|
366
|
-
return (await response.json()) as {
|
|
367
|
-
token: string;
|
|
368
|
-
sessionId: string;
|
|
369
|
-
expiresAt: string;
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
function readCookieValue(header: string, name: string): string {
|
|
374
|
-
const value = header.split(";")[0]?.slice(`${name}=`.length);
|
|
375
|
-
return value ? decodeURIComponent(value) : "";
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
function jsonRequest(path: string, body: unknown): Request {
|
|
379
|
-
return new Request(`http://localhost${path}`, {
|
|
380
|
-
method: "POST",
|
|
381
|
-
headers: { "content-type": "application/json" },
|
|
382
|
-
body: JSON.stringify(body),
|
|
383
|
-
});
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
function apiRequest(path: string, token: string, method = "GET"): Request {
|
|
387
|
-
return new Request(`http://localhost${path}`, {
|
|
388
|
-
method,
|
|
389
|
-
headers: { authorization: `Bearer ${token}` },
|
|
390
|
-
});
|
|
391
|
-
}
|