@oj-module/cli 0.1.21 → 0.2.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 (107) hide show
  1. package/devkit/README.md +1 -1
  2. package/devkit/SKILL.md +17 -3
  3. package/devkit/manual.md +77 -0
  4. package/package.json +2 -2
  5. package/shell-dist/assets/{index-BUyoDvUl.js → index-CLdhl3Qd.js} +61 -12
  6. package/shell-dist/assets/runtime.js +226 -89
  7. package/shell-dist/index.html +3 -3
  8. package/shell-dist/versions.json +3 -3
  9. package/src/build.ts +25 -0
  10. package/src/init.ts +3 -0
  11. package/templates/README.md +15 -0
  12. package/templates/api/config.yaml +14 -0
  13. package/templates/api/seed.mysql.sql +5 -0
  14. package/templates/api/seed.pg.sql +5 -0
  15. package/templates/api/src/_platform/_shared/respond.ts +18 -0
  16. package/templates/api/src/_platform/_shared/service/access.ts +99 -0
  17. package/templates/api/src/_platform/_shared/service/audit.ts +90 -0
  18. package/templates/api/src/_platform/_shared/service/contacts.ts +36 -0
  19. package/templates/api/src/_platform/_shared/service/guard.ts +30 -0
  20. package/templates/api/src/_platform/_shared/service/oplog.ts +47 -0
  21. package/templates/api/src/_platform/_shared/service/query.ts +53 -0
  22. package/templates/api/src/_platform/_shared/service/rate.ts +79 -0
  23. package/templates/api/src/_platform/_shared/service/roles.ts +137 -0
  24. package/templates/api/src/_platform/_shared/service/settings.ts +67 -0
  25. package/templates/api/src/_platform/_shared/service/solution-perms.ts +94 -0
  26. package/templates/api/src/_platform/_shared/service/users.ts +163 -0
  27. package/templates/api/src/_platform/contacts/api.ts +22 -0
  28. package/templates/api/src/_platform/contacts/detail/api.ts +18 -0
  29. package/templates/api/src/_platform/manifest.yaml +9 -1
  30. package/templates/api/src/_platform/migrations/0001__create_users.mysql.sql +9 -0
  31. package/templates/api/src/_platform/migrations/0001__create_users.pg.sql +9 -0
  32. package/templates/api/src/_platform/migrations/0002__add_users_avatar_base64.mysql.sql +3 -0
  33. package/templates/api/src/_platform/migrations/0003__users_source_display_name.mysql.sql +5 -0
  34. package/templates/api/src/_platform/migrations/0003__users_source_display_name.sql +5 -0
  35. package/templates/api/src/_platform/migrations/0004__sys_rbac.mysql.sql +70 -0
  36. package/templates/api/src/_platform/migrations/0004__sys_rbac.pg.sql +67 -0
  37. package/templates/api/src/_platform/migrations/0004__sys_rbac.sql +67 -0
  38. package/templates/api/src/_platform/migrations/0005__users_must_change.mysql.sql +3 -0
  39. package/templates/api/src/_platform/migrations/0005__users_must_change.sql +2 -0
  40. package/templates/api/src/_platform/migrations/0006__users_is_forbidden.mysql.sql +3 -0
  41. package/templates/api/src/_platform/migrations/0006__users_is_forbidden.sql +3 -0
  42. package/templates/api/src/_platform/migrations/0007__users_email.sql +3 -0
  43. package/templates/api/src/_platform/migrations/0008__sys_refresh_token.mysql.sql +10 -0
  44. package/templates/api/src/_platform/migrations/0008__sys_refresh_token.pg.sql +10 -0
  45. package/templates/api/src/_platform/migrations/0008__sys_refresh_token.sql +11 -0
  46. package/templates/api/src/_platform/migrations/0009__sys_throttle.mysql.sql +12 -0
  47. package/templates/api/src/_platform/migrations/0009__sys_throttle.pg.sql +12 -0
  48. package/templates/api/src/_platform/migrations/0009__sys_throttle.sql +13 -0
  49. package/templates/api/src/_platform/schema.yaml +76 -1
  50. package/templates/api/src/_platform/seed.mysql.sql +8 -0
  51. package/templates/api/src/_platform/seed.pg.sql +8 -0
  52. package/templates/api/src/_platform/seed.sql +19 -2
  53. package/templates/api/src/auth/_shared/ldap-login.ts +86 -0
  54. package/templates/api/src/auth/_shared/ldap.ts +14 -0
  55. package/templates/api/src/auth/_shared/login-throttle.ts +29 -0
  56. package/templates/api/src/auth/_shared/session.ts +163 -18
  57. package/templates/api/src/auth/api.ts +54 -44
  58. package/templates/api/src/auth/ldap-login/api.ts +29 -0
  59. package/templates/api/src/system/login-methods/api.ts +19 -0
  60. package/templates/api/src/system/manifest.yaml +5 -0
  61. package/templates/api/src/system/oplog/api.ts +53 -0
  62. package/templates/api/src/system/permissions/api.ts +26 -0
  63. package/templates/api/src/system/roles/api.ts +75 -0
  64. package/templates/api/src/system/roles/bindings/api.ts +29 -0
  65. package/templates/api/src/system/settings/api.ts +60 -0
  66. package/templates/api/src/system/users/api.ts +58 -0
  67. package/templates/api/src/system/users/forbidden/api.ts +26 -0
  68. package/templates/api/src/system/users/password/api.ts +26 -0
  69. package/templates/api/src/system/users/roles/api.ts +27 -0
  70. package/templates/api/src/web/user-info/api.ts +9 -18
  71. package/templates/api/tests/auth-ldap.spec.ts +173 -0
  72. package/templates/api/tests/auth-session.spec.ts +194 -0
  73. package/templates/api/tests/auth-throttle.spec.ts +74 -0
  74. package/templates/api/tests/fixture.ts +50 -0
  75. package/templates/api/tests/guard.spec.ts +62 -0
  76. package/templates/api/tests/query-utils.spec.ts +68 -0
  77. package/templates/api/tests/rbac-audit.spec.ts +105 -0
  78. package/templates/api/tests/rbac-contacts.spec.ts +85 -0
  79. package/templates/api/tests/rbac-guard.spec.ts +192 -0
  80. package/templates/api/tests/rbac-roles.spec.ts +159 -0
  81. package/templates/api/tests/rbac-seed.spec.ts +54 -0
  82. package/templates/api/tests/rbac-settings.spec.ts +79 -0
  83. package/templates/api/tests/rbac-solution-perms.spec.ts +168 -0
  84. package/templates/api/tests/rbac-tables.spec.ts +63 -0
  85. package/templates/api/tests/rbac-users.spec.ts +164 -0
  86. package/templates/api/vitest.config.ts +12 -0
  87. package/templates/openapi.json +1060 -31
  88. package/templates/pnpm-workspace.yaml +9 -0
  89. package/templates/web/src/auth/__client/api.schemas.ts +6 -1
  90. package/templates/web/src/auth/__client/api.ts +33 -1
  91. package/templates/web/src/login/entry.ts +17 -0
  92. package/templates/web/src/login/locales/en-US.json +12 -2
  93. package/templates/web/src/login/locales/zh-CN.json +12 -2
  94. package/templates/web/src/login/pages/change-password.tsx +74 -0
  95. package/templates/web/src/system/entry.ts +90 -0
  96. package/templates/web/src/system/locales/en-US.json +98 -0
  97. package/templates/web/src/system/locales/zh-CN.json +98 -0
  98. package/templates/web/src/system/pages/errors.ts +51 -0
  99. package/templates/web/src/system/pages/forbidden.tsx +35 -0
  100. package/templates/web/src/system/pages/login-methods/index.tsx +29 -0
  101. package/templates/web/src/system/pages/oplog/index.tsx +91 -0
  102. package/templates/web/src/system/pages/roles/index.tsx +91 -0
  103. package/templates/web/src/system/pages/settings/index.tsx +106 -0
  104. package/templates/web/src/system/pages/users/index.tsx +168 -0
  105. package/templates/web/src/system/pages/users/roles.tsx +68 -0
  106. package/templates/web.config.ts +8 -0
  107. package/vendor/host-versions.json +3 -3
@@ -1,26 +1,171 @@
1
- // 会话与签发共享逻辑(移植 oj 官方 sample/src/auth/_shared/session.ts,
2
- // 对齐 devkit 手册 §8:access=jwt.sign,refresh=不透明随机串 + KV session)。
1
+ // 会话与签发领域服务(设计 §3.1 + D14 + AC1-12/13/27)。
2
+ // deps 注入:crypto/jwt/bcrypt 是 oj 运行时全局,Node 测试侧 crypto 只读不可 stub(设计 §9.1),
3
+ // 随机/哈希/签名/口令原语全部经构造注入,默认实现回落运行时全局(runtimeDeps)。
4
+ // B4:access JWT 仅载 sub(roles 走登录响应/资料接口,不进 claims)。
5
+ // B3:refresh = 不透明随机串 + DB 存 SHA-256 哈希(sys_refresh_token,迁移 0008);
6
+ // 轮换作旧 + 滑动窗口(绝对 TTL 封顶 min(旧 exp, now+30d));reuse 检测全族吊销 + 审计。
7
+ import { invalidateUser } from "../../_platform/_shared/service/access";
8
+ import { recordOp } from "../../_platform/_shared/service/audit";
3
9
 
4
- export function nowSecs(): number {
5
- return Math.floor(Date.now() / 1000);
10
+ export interface SessionDeps {
11
+ randomHex: (bytes: number) => string
12
+ sha256Hex: (s: string) => string
13
+ signAccess: (sub: string) => string | Promise<string>
14
+ accessTtlSecs: number
15
+ refreshTtlSecs: number
16
+ bcryptHash: (pw: string) => Promise<string>
17
+ bcryptVerify: (pw: string, hash: string) => Promise<boolean>
18
+ now?: () => number
6
19
  }
7
20
 
8
- export function sessionKey(refreshToken: string): string {
9
- return `AUTH-SESSION:${crypto.sha256Hex(refreshToken)}`;
21
+ /** 默认实现:回落 oj 运行时全局(jwt.sign 仅收 {sub}——B4 roles 不进 claims 的语法级保证)。 */
22
+ export function runtimeDeps(): SessionDeps {
23
+ const g = globalThis as any;
24
+ return {
25
+ randomHex: (n: number) => g.crypto.randomHex(n),
26
+ sha256Hex: (s: string) => g.crypto.sha256Hex(s),
27
+ signAccess: (sub: string) => g.jwt.sign({ sub }),
28
+ accessTtlSecs: g.jwt.accessDuration,
29
+ refreshTtlSecs: g.jwt.refreshDuration,
30
+ bcryptHash: (pw: string) => g.bcrypt.hash(pw),
31
+ bcryptVerify: (pw: string, h: string) => g.bcrypt.verify(pw, h),
32
+ };
10
33
  }
11
34
 
12
- // 签 access + 生成 refresh 并落 session(exp = now + refresh 时长,读取侧惰性判定)。
13
- export async function issueTokens(uid: string, roles: string[]) {
14
- const accessToken = await jwt.sign({ sub: uid, roles });
15
- const refreshToken = crypto.randomHex(32);
16
- await kv.set(
17
- sessionKey(refreshToken),
18
- JSON.stringify({ uid, exp: nowSecs() + jwt.refreshDuration }),
19
- );
35
+ export interface SessionUser {
36
+ id: number
37
+ username: string
38
+ display_name: string | null
39
+ avatar_base64: string | null
40
+ must_change: boolean
41
+ roles: string[]
42
+ }
43
+
44
+ export interface TokenBundle {
45
+ access_token: string
46
+ refresh_token: string
47
+ expires_in: number
48
+ /** AC1-27:must_change 悬挂期签发的限权标记(改密成功才换发正式 token) */
49
+ restricted: boolean
50
+ user: SessionUser
51
+ }
52
+
53
+ function fail(status: number, message: string): never {
54
+ throw Object.assign(new Error(message), { status });
55
+ }
56
+
57
+ export function createSession(deps: SessionDeps = runtimeDeps()) {
58
+ const now = deps.now ?? (() => Math.floor(Date.now() / 1000));
59
+ const hashOf = (token: string) => deps.sha256Hex(token);
60
+
61
+ async function profileOf(uid: number): Promise<SessionUser> {
62
+ const rows = await db.query(
63
+ "select id, username, display_name, avatar_base64, must_change from users where id = ?",
64
+ [uid],
65
+ ) as Array<Record<string, unknown>>;
66
+ if (!rows[0])
67
+ fail(404, "user not found");
68
+ const roleRows = await db.query("select role_name from sys_user_role where user_id = ?", [uid]) as Array<{ role_name: string }>;
69
+ return {
70
+ id: uid,
71
+ username: String(rows[0].username),
72
+ display_name: rows[0].display_name == null ? null : String(rows[0].display_name),
73
+ avatar_base64: rows[0].avatar_base64 == null ? null : String(rows[0].avatar_base64),
74
+ must_change: Number(rows[0].must_change) === 1,
75
+ roles: roleRows.map(r => r.role_name),
76
+ };
77
+ }
78
+
79
+ async function issueFor(uid: number): Promise<TokenBundle> {
80
+ const user = await profileOf(uid);
81
+ const refreshToken = deps.randomHex(32);
82
+ await db.exec(
83
+ "insert into sys_refresh_token (token_hash, user_id, expires_at, revoked, created_at) values (?, ?, ?, 0, ?)",
84
+ [hashOf(refreshToken), uid, now() + deps.refreshTtlSecs, now()],
85
+ );
86
+ return {
87
+ access_token: await deps.signAccess(String(uid)),
88
+ refresh_token: refreshToken,
89
+ expires_in: deps.accessTtlSecs,
90
+ restricted: user.must_change,
91
+ user,
92
+ };
93
+ }
94
+
95
+ /** 吊销联动(§3.1):禁用/重置口令/改密 → 删该用户全部 refresh 记录。 */
96
+ async function revokeAll(uid: number): Promise<void> {
97
+ await db.exec("delete from sys_refresh_token where user_id = ?", [uid]);
98
+ }
99
+
20
100
  return {
21
- access_token: accessToken,
22
- refresh_token: refreshToken,
23
- expires_in: jwt.accessDuration,
24
- user: { id: uid, roles },
101
+ /** B1+D14:本地登录——先验口令后查禁用;号不存在/口令错/禁用统一 401(不泄露账号状态)。 */
102
+ async localLogin(username: string, password: string): Promise<TokenBundle> {
103
+ const rows = await db.query("select id, password_hash, is_forbidden from users where username = ?", [String(username ?? "")]) as Array<Record<string, unknown>>;
104
+ const row = rows[0];
105
+ if (!row || !(await deps.bcryptVerify(String(password ?? ""), String(row.password_hash))))
106
+ fail(401, "invalid credentials");
107
+ if (Number(row.is_forbidden) === 1)
108
+ fail(401, "invalid credentials");
109
+ return issueFor(Number(row.id));
110
+ },
111
+
112
+ /** B3:刷新——轮换作旧;reuse 检测(已吊销串二次出现)全族吊销 + 审计。 */
113
+ async refresh(token: string): Promise<TokenBundle> {
114
+ const rows = await db.query("select * from sys_refresh_token where token_hash = ?", [hashOf(String(token ?? ""))]) as Array<Record<string, unknown>>;
115
+ const row = rows[0];
116
+ if (!row)
117
+ fail(401, "invalid or expired refresh token");
118
+ const uid = Number(row.user_id);
119
+ if (Number(row.revoked) === 1) {
120
+ await revokeAll(uid);
121
+ await recordOp({ action: "auth.refresh.reuse", module: "framework", target: `user:${uid}`, level: 4 });
122
+ fail(401, "invalid or expired refresh token");
123
+ }
124
+ if (Number(row.expires_at) <= now())
125
+ fail(401, "invalid or expired refresh token");
126
+ await db.exec("update sys_refresh_token set revoked = 1 where token_hash = ?", [row.token_hash]);
127
+ // 滑动窗口 + 绝对 30d 封顶(§3.1「绝对 TTL + 滑动」)
128
+ const newExp = Math.min(Number(row.expires_at), now() + deps.refreshTtlSecs);
129
+ const refreshToken = deps.randomHex(32);
130
+ await db.exec(
131
+ "insert into sys_refresh_token (token_hash, user_id, expires_at, revoked, created_at) values (?, ?, ?, 0, ?)",
132
+ [hashOf(refreshToken), uid, newExp, now()],
133
+ );
134
+ const user = await profileOf(uid);
135
+ return {
136
+ access_token: await deps.signAccess(String(uid)),
137
+ refresh_token: refreshToken,
138
+ expires_in: deps.accessTtlSecs,
139
+ restricted: user.must_change,
140
+ user,
141
+ };
142
+ },
143
+
144
+ /** AC1-27:自助改密——本人 + 旧口令校验;改密清 must_change、级联删 refresh、换发正式 token。 */
145
+ async changePassword(uid: number, oldPassword: string, newPassword: string): Promise<TokenBundle> {
146
+ const rows = await db.query("select password_hash from users where id = ?", [uid]) as Array<{ password_hash: string }>;
147
+ if (!rows[0])
148
+ fail(404, "user not found");
149
+ if (!(await deps.bcryptVerify(String(oldPassword ?? ""), String(rows[0].password_hash))))
150
+ fail(401, "invalid credentials");
151
+ if (!newPassword || String(newPassword).length < 6)
152
+ fail(400, "password too short (min 6)");
153
+ const hash = await deps.bcryptHash(String(newPassword));
154
+ await db.tx(async (t) => {
155
+ await t.exec("update users set password_hash = ?, must_change = 0 where id = ?", [hash, uid]);
156
+ await t.exec("delete from sys_refresh_token where user_id = ?", [uid]);
157
+ });
158
+ invalidateUser(uid);
159
+ return issueFor(uid);
160
+ },
161
+
162
+ revokeAll,
163
+
164
+ /** D1:外部登录(LDAP/未来 SSO)签发出口——JIT/禁号由登录链先行处理(D14:LDAP 先查号)。 */
165
+ async issueExternal(uid: number): Promise<TokenBundle> {
166
+ return issueFor(uid);
167
+ },
25
168
  };
26
169
  }
170
+
171
+ export type Session = ReturnType<typeof createSession>;
@@ -1,66 +1,76 @@
1
- // oj v0.1.64 嵌套 handler 组:组名 = 路径段(login → POST /auth/login,路由不变)。
2
- // DDD 判据:同聚合(会话生命周期)多动词收进单文件;
3
- // 未来 register/reset/oauth 属新聚合、各自开目录。共享签发逻辑仍在 _shared/session.ts。
4
- import { issueTokens, nowSecs, sessionKey } from "./_shared/session";
1
+ import { respond } from "../_platform/_shared/respond";
2
+ // oj 嵌套 handler 组:组名 = 路径段(login → POST /api/auth/login,路由不变)。
3
+ // 阶段 B 登录链(D14/AC1-12/13/27):领域逻辑全在 _shared/session.ts(createSession 注入 deps),
4
+ // handler 只编排:取参 → 调 service → json 信封;B5 限速收敛在 _shared/login-throttle.ts(与 ldap-login 共用)。
5
+ // refresh 存 DB(sys_refresh_token,§3.1),logout = 吊销当前串。
6
+ import { clientIp, guardLoginThrottle, noteLoginFailed, noteLoginSucceeded } from "./_shared/login-throttle";
7
+ import { createSession } from "./_shared/session";
8
+
9
+ // 模块级禁止 createSession():oj build 的 introspect 在无 jwt 的空沙箱里评估模块(§9.4),
10
+ // runtimeDeps() 读 g.jwt 会直接炸构建——session 在 handler 内按需构造(无状态,纯闭包)。
5
11
 
6
12
  const login = {
7
- // POST /api/auth/login —— 登录(devkit 手册 §8:auth 端点是业务路由)。
8
- // 用户不存在与密码错同报 invalid credentials(不泄露用户存在性)。
13
+ // POST /api/auth/login —— 本地登录(D14:先验口令后查禁用,三种失败统一 401)。
14
+ // B5/AC1-26:(IP, username) 连败阈值 → 429 + 指数 backoff;粗粒度 per-IP 窗口防撞库扫描。
9
15
  async post() {
10
16
  const body = http.body || {};
11
- const rows = await db.query(
12
- "select id, password_hash, roles from users where username = ?",
13
- [String(body.username ?? "")],
14
- );
15
- const row = rows[0];
16
- if (!row || !(await bcrypt.verify(String(body.password ?? ""), row.password_hash || ""))) {
17
- json.fail(401, "invalid credentials");
18
- return;
19
- }
20
- // roles 列按 JSON 数组串解析,失败回落空。
21
- let roles: string[] = [];
22
- try {
23
- roles = JSON.parse(row.roles || "[]");
24
- }
25
- catch {
26
- roles = [];
27
- }
28
- json.ok(await issueTokens(String(row.id), roles));
17
+ const username = String(body.username ?? "");
18
+ const ip = await clientIp();
19
+ await respond(async () => {
20
+ try {
21
+ await guardLoginThrottle(ip, username);
22
+ const r = await createSession().localLogin(username, String(body.password ?? ""));
23
+ await noteLoginSucceeded(ip, username);
24
+ return r;
25
+ }
26
+ catch (e) {
27
+ if ((e as Error & { status?: number }).status === 401)
28
+ await noteLoginFailed(ip, username);
29
+ throw e; // respond 收口:err.status/extra → json.fail 信封(oj 未捕获异常恒 500)
30
+ }
31
+ });
29
32
  },
30
33
  };
31
34
 
32
35
  const logout = {
33
- // POST /api/auth/logout —— 注销(删 refresh session;access 到期前仍有效)。
36
+ // POST /api/auth/logout —— 吊销当前 refresh 串(access 到期前仍有效,语义同旧版)。
34
37
  async post() {
35
38
  const token = String((http.body || {}).refresh_token ?? "");
36
- await kv.del(sessionKey(token));
39
+ await db.exec("delete from sys_refresh_token where token_hash = ?", [crypto.sha256Hex(token)]);
37
40
  json.ok(null);
38
41
  },
39
42
  };
40
43
 
41
44
  const refresh = {
42
- // POST /api/auth/refresh —— 刷新(轮换:旧 refresh 立即失效,一次一用)。
45
+ // POST /api/auth/refresh —— 轮换(旧串立即失效;reuse 检测在 service 内全族吊销+审计)。
43
46
  async post() {
44
47
  const token = String((http.body || {}).refresh_token ?? "");
45
- const key = sessionKey(token);
46
- const raw = await kv.get(key);
47
- const sess = raw ? JSON.parse(raw) : null;
48
- if (!sess || !(sess.exp > nowSecs())) {
49
- json.fail(401, "invalid or expired refresh token");
48
+ await respond(async () => createSession().refresh(token));
49
+ },
50
+ };
51
+
52
+ const password = {
53
+ // PUT /api/auth/password —— 自助改密(AC1-27:本人+旧口令;悬挂期唯一可用写端)。
54
+ async put() {
55
+ const uid = http.user?.id;
56
+ if (!uid) {
57
+ json.fail(401, "unauthorized");
50
58
  return;
51
59
  }
52
- // session 只存 uid——roles 重查库取最新。
53
- const rows = await db.query("select roles from users where id = ?", [sess.uid]);
54
- let roles: string[] = [];
55
- try {
56
- roles = JSON.parse((rows[0] || {}).roles || "[]");
57
- }
58
- catch {
59
- roles = [];
60
- }
61
- await kv.del(key);
62
- json.ok(await issueTokens(String(sess.uid), roles));
60
+ const body = http.body || {};
61
+ await respond(async () => createSession().changePassword(Number(uid), String(body.old_password ?? ""), String(body.new_password ?? "")));
62
+ },
63
+ };
64
+ // must_change 悬挂期唯一可用写端(AC1-27);契约供 login/system 模板的改密 UI 使用
65
+ password.put.schema = {
66
+ body: {
67
+ type: "object",
68
+ required: ["old_password", "new_password"],
69
+ properties: {
70
+ old_password: { type: "string", minLength: 1 },
71
+ new_password: { type: "string", minLength: 6 },
72
+ },
63
73
  },
64
74
  };
65
75
 
66
- export default { login, logout, refresh };
76
+ export default { login, logout, refresh, password };
@@ -0,0 +1,29 @@
1
+ // POST /api/auth/ldap-login —— LDAP 目录登录(D1/AC1-12;匿名端点,E6:anonymous_paths 写 /auth/ldap-login)。
2
+ // 编排:B5 限速守卫(与 auth/login 同维度)→ ldapLogin 九步 → respond 统一异常出口(§9.3-1)。
3
+ import { respond } from "../../_platform/_shared/respond";
4
+ import { createLdapLogin, runtimeLdapGateway } from "../_shared/ldap-login";
5
+ import { clientIp, guardLoginThrottle, noteLoginFailed, noteLoginSucceeded } from "../_shared/login-throttle";
6
+ import { createSession } from "../_shared/session";
7
+
8
+ // 模块级禁止 createSession()/runtimeLdapGateway():oj build introspect 的空沙箱无 jwt/axis(§9.4)。
9
+
10
+ export default {
11
+ async post() {
12
+ const body = http.body || {};
13
+ const username = String(body.username ?? "");
14
+ const ip = await clientIp();
15
+ await respond(async () => {
16
+ try {
17
+ await guardLoginThrottle(ip, username);
18
+ const r = await createLdapLogin(createSession(), runtimeLdapGateway())(username, String(body.password ?? ""));
19
+ await noteLoginSucceeded(ip, username);
20
+ return r;
21
+ }
22
+ catch (e) {
23
+ if ((e as Error & { status?: number }).status === 401)
24
+ await noteLoginFailed(ip, username);
25
+ throw e; // respond 收口:err.status/extra → json.fail 信封
26
+ }
27
+ });
28
+ },
29
+ };
@@ -0,0 +1,19 @@
1
+ import { respond } from "../../_platform/_shared/respond";
2
+ // GET /api/system/login-methods —— 登录方式发现(C5/D2;匿名端点:登录前无 token,
3
+ // 项目 config.yaml auth.anonymous_paths 必须列 /system/login-methods,漏列则握手 401 登录页必坏)。
4
+ import { listLoginMethods } from "../../_platform/_shared/service/settings";
5
+
6
+ async function get() {
7
+ await respond(async () => {
8
+ return listLoginMethods();
9
+ });
10
+ }
11
+ get.response = {
12
+ type: "object",
13
+ required: ["methods"],
14
+ properties: {
15
+ methods: { type: "array", items: { type: "string" } },
16
+ },
17
+ };
18
+
19
+ export default { get };
@@ -0,0 +1,5 @@
1
+ name: system
2
+ desc: 框架管理域端点(/api/system/*):角色/权限点管理(assertSystemAdmin 判权,H2)
3
+ version: 0.1.0
4
+ deps: # roles/bindings/permissions 读写 _platform 的 sys_* 表(ownership_guard: deny 下必须声明)
5
+ _platform: ^0.1.0
@@ -0,0 +1,53 @@
1
+ import { respond } from "../../_platform/_shared/respond";
2
+ // GET /api/system/oplog?action=&username=&module=&limit= —— 操作日志查询(AC1-29;D13 admin 可查)。
3
+ import { assertSystemAdmin } from "../../_platform/_shared/service/access";
4
+ import { searchOplog } from "../../_platform/_shared/service/oplog";
5
+
6
+ async function get() {
7
+ await respond(async () => {
8
+ await assertSystemAdmin();
9
+ return searchOplog(http.query as Record<string, unknown>);
10
+ });
11
+ }
12
+ get.schema = {
13
+ query: {
14
+ type: "object",
15
+ properties: {
16
+ action: { type: "string" },
17
+ // username 前缀匹配(service likePattern),非精确
18
+ username: { type: "string" },
19
+ module: { type: "string" },
20
+ // 分页大小,service paged 钳 200
21
+ limit: { type: "number", minimum: 1, maximum: 200 },
22
+ },
23
+ },
24
+ };
25
+ get.response = {
26
+ type: "object",
27
+ required: ["list", "total", "truncated"],
28
+ properties: {
29
+ list: {
30
+ type: "array",
31
+ items: {
32
+ type: "object",
33
+ required: ["id", "create_time", "user_id", "username", "action", "module", "version", "target", "detail", "level"],
34
+ properties: {
35
+ id: { type: "number" },
36
+ create_time: { type: "number" },
37
+ user_id: { type: "number" },
38
+ username: { type: "string" },
39
+ action: { type: "string" },
40
+ module: { type: "string" },
41
+ version: { type: ["string", "null"] },
42
+ target: { type: ["string", "null"] },
43
+ detail: { type: ["string", "null"] },
44
+ level: { type: "number" },
45
+ },
46
+ },
47
+ },
48
+ total: { type: "number" },
49
+ truncated: { type: "boolean" },
50
+ },
51
+ };
52
+
53
+ export default { get };
@@ -0,0 +1,26 @@
1
+ import { respond } from "../../_platform/_shared/respond";
2
+ // GET /api/system/permissions —— 权限点全量(C2 绑定编辑器数据源,deprecated 标灰)。
3
+ import { assertSystemAdmin } from "../../_platform/_shared/service/access";
4
+ import { listPermissions } from "../../_platform/_shared/service/roles";
5
+
6
+ async function get() {
7
+ await respond(async () => {
8
+ await assertSystemAdmin();
9
+ return listPermissions();
10
+ });
11
+ }
12
+ get.response = {
13
+ type: "array",
14
+ items: {
15
+ type: "object",
16
+ required: ["code", "module", "title_key", "deprecated"],
17
+ properties: {
18
+ code: { type: "string" },
19
+ module: { type: "string" },
20
+ title_key: { type: ["string", "null"] },
21
+ deprecated: { type: "boolean" },
22
+ },
23
+ },
24
+ };
25
+
26
+ export default { get };
@@ -0,0 +1,75 @@
1
+ import { respond } from "../../_platform/_shared/respond";
2
+ // 管理域端点 /api/system/roles(设计文档 D207:/api/system/* = 管理域,assertSystemAdmin 判权)。
3
+ // handler 层只编排:守卫 → service → json 信封;name 走 query(wiki 先例:静态段 + http.query)。
4
+ import { assertSystemAdmin } from "../../_platform/_shared/service/access";
5
+ import { createRole, deleteRole, listRoles } from "../../_platform/_shared/service/roles";
6
+
7
+ async function guard(): Promise<void> {
8
+ await assertSystemAdmin();
9
+ }
10
+
11
+ // GET /api/system/roles —— 角色列表(含持有计数与绑定,C2 页面数据源)。
12
+ async function get() {
13
+ await respond(async () => {
14
+ await guard();
15
+ return listRoles();
16
+ });
17
+ }
18
+ get.response = {
19
+ type: "array",
20
+ items: {
21
+ type: "object",
22
+ required: ["name", "module", "title", "description", "builtin", "deprecated", "holders", "permissions"],
23
+ properties: {
24
+ name: { type: "string" },
25
+ module: { type: "string" },
26
+ title: { type: ["string", "null"] },
27
+ description: { type: ["string", "null"] },
28
+ builtin: { type: "boolean" },
29
+ deprecated: { type: "boolean" },
30
+ holders: { type: "number" },
31
+ permissions: { type: "array", items: { type: "string" } },
32
+ },
33
+ },
34
+ };
35
+
36
+ // POST /api/system/roles —— 建角色(AC1-16:custom.<slug> 机器名 + 勾选绑定)。
37
+ async function post() {
38
+ await respond(async () => {
39
+ await guard();
40
+ return createRole(http.body || {});
41
+ });
42
+ }
43
+ post.schema = {
44
+ body: {
45
+ type: "object",
46
+ required: ["name", "title"],
47
+ properties: {
48
+ name: { type: "string", pattern: "^custom\\.[a-z0-9][a-z0-9-]*$" },
49
+ title: { type: "string", minLength: 1 },
50
+ permissions: { type: "array", items: { type: "string" } },
51
+ },
52
+ },
53
+ };
54
+ post.response = {
55
+ type: "object",
56
+ required: ["name", "module"],
57
+ properties: { name: { type: "string" }, module: { type: "string" } },
58
+ };
59
+
60
+ // DELETE /api/system/roles?name=xxx —— 删角色(AC1-20 占用禁删,409 带 holders)。
61
+ async function del() {
62
+ await respond(async () => {
63
+ await guard();
64
+ return deleteRole(String((http.query as Record<string, unknown>).name ?? ""));
65
+ });
66
+ }
67
+ del.schema = {
68
+ query: {
69
+ type: "object",
70
+ required: ["name"],
71
+ properties: { name: { type: "string", minLength: 1 } },
72
+ },
73
+ };
74
+
75
+ export default { get, post, del };
@@ -0,0 +1,29 @@
1
+ import { respond } from "../../../_platform/_shared/respond";
2
+ // PUT /api/system/roles/bindings?name=xxx —— 整集替换角色绑定。
3
+ // AC1-18 声明角色绑定可调整;AC1-19 builtin 硬拒(service 层);AC1-30 摘绑定 = 紧急吊销出口
4
+ // (service 内写后按持有者失效判权缓存)。
5
+ import { assertSystemAdmin } from "../../../_platform/_shared/service/access";
6
+ import { updateBindings } from "../../../_platform/_shared/service/roles";
7
+
8
+ async function put() {
9
+ await respond(async () => {
10
+ await assertSystemAdmin();
11
+ const name = String((http.query as Record<string, unknown>).name ?? "");
12
+ const codes = Array.isArray(http.body?.permissions) ? http.body.permissions.map(String) : [];
13
+ return updateBindings(name, codes);
14
+ });
15
+ }
16
+ put.schema = {
17
+ query: {
18
+ type: "object",
19
+ required: ["name"],
20
+ properties: { name: { type: "string", minLength: 1 } },
21
+ },
22
+ body: {
23
+ type: "object",
24
+ required: ["permissions"],
25
+ properties: { permissions: { type: "array", items: { type: "string" } } },
26
+ },
27
+ };
28
+
29
+ export default { put };
@@ -0,0 +1,60 @@
1
+ import { respond } from "../../_platform/_shared/respond";
2
+ // /api/system/settings —— 系统设置(PRD §4.3):GET 按组列出(secret 掩码),PUT 写值(写审计)。
3
+ import { assertSystemAdmin } from "../../_platform/_shared/service/access";
4
+ import { listSettings, updateSetting } from "../../_platform/_shared/service/settings";
5
+
6
+ const settingItemSchema = {
7
+ type: "object",
8
+ required: ["key", "module", "group", "type", "value", "default_value", "label_key", "options", "secret", "update_time"],
9
+ properties: {
10
+ key: { type: "string" },
11
+ module: { type: "string" },
12
+ group: { type: "string" },
13
+ type: { type: "string" },
14
+ // secret 项读取面恒为 "***"
15
+ value: { type: ["string", "null"] },
16
+ default_value: { type: ["string", "null"] },
17
+ label_key: { type: ["string", "null"] },
18
+ options: { type: ["string", "null"] },
19
+ secret: { type: "boolean" },
20
+ update_time: { type: "number" },
21
+ },
22
+ } as const;
23
+
24
+ async function get() {
25
+ await respond(async () => {
26
+ await assertSystemAdmin();
27
+ return listSettings();
28
+ });
29
+ }
30
+ get.response = {
31
+ type: "array",
32
+ items: {
33
+ type: "object",
34
+ required: ["group", "items"],
35
+ properties: {
36
+ group: { type: "string" },
37
+ items: { type: "array", items: settingItemSchema },
38
+ },
39
+ },
40
+ };
41
+
42
+ async function put() {
43
+ await respond(async () => {
44
+ await assertSystemAdmin();
45
+ const body = http.body || {};
46
+ return updateSetting(Number(http.user?.id), String(body.key ?? ""), String(body.value ?? ""));
47
+ });
48
+ }
49
+ put.schema = {
50
+ body: {
51
+ type: "object",
52
+ required: ["key", "value"],
53
+ properties: {
54
+ key: { type: "string", minLength: 1 },
55
+ value: { type: "string" },
56
+ },
57
+ },
58
+ };
59
+
60
+ export default { get, put };