@oj-module/cli 0.2.5 → 0.2.8
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 +1 -1
- package/devkit/manual.md +19 -17
- package/devkit/sql-builder-missing-capabilities-pr.md +234 -0
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +2 -2
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/contract/__tests__/handler-scan.test.ts +29 -0
- package/src/contract/__tests__/market-client.integration.test.ts +167 -0
- package/src/contract/handler-scan.ts +35 -4
- package/src/dev.ts +10 -2
- package/src/index.ts +3 -3
- package/src/init.ts +44 -0
- package/src/preview-config.ts +163 -0
- package/src/preview.ts +105 -56
- package/src/usage.ts +3 -2
- package/templates/api/config.yaml +4 -2
- package/templates/api/src/_platform/README.md +31 -0
- package/templates/api/src/_platform/_lifecycle/atomic.ts +1 -1
- package/templates/api/src/_platform/_lifecycle/history.ts +10 -5
- package/templates/api/src/_platform/_lifecycle/installer.ts +4 -4
- package/templates/api/src/_platform/_lifecycle/sha384.ts +1 -1
- package/templates/api/src/_platform/_lifecycle/steps/download.ts +1 -1
- package/templates/api/src/_platform/_lifecycle/steps/register.ts +57 -29
- package/templates/api/src/_platform/_lifecycle/trust.ts +1 -1
- package/templates/api/src/_platform/_lifecycle/uninstall.ts +13 -8
- package/templates/api/src/_platform/_lifecycle/verify-checks.ts +2 -2
- package/templates/api/src/_platform/_shared/service/{contacts.ts → domain/contacts.ts} +8 -6
- package/templates/api/src/_platform/_shared/service/{roles.ts → domain/roles.ts} +18 -19
- package/templates/api/src/_platform/_shared/service/{settings.ts → domain/settings.ts} +6 -6
- package/templates/api/src/_platform/_shared/service/{solution-perms.ts → domain/solution-perms.ts} +18 -17
- package/templates/api/src/_platform/_shared/service/{users.ts → domain/users.ts} +20 -26
- package/templates/api/src/_platform/_shared/service/{access.ts → infra/access.ts} +5 -8
- package/templates/api/src/_platform/_shared/service/{audit.ts → infra/audit.ts} +26 -26
- package/templates/api/src/_platform/_shared/service/{rate.ts → infra/rate.ts} +12 -12
- package/templates/api/src/_platform/_shared/site-facade.ts +1 -1
- package/templates/api/src/_platform/contacts/api.ts +2 -2
- package/templates/api/src/_platform/contacts/detail/api.ts +2 -2
- package/templates/api/src/_platform/manifest.yaml +3 -1
- package/templates/api/src/_platform/market/api.ts +54 -16
- package/templates/api/src/_platform/schema.yaml +14 -0
- package/templates/api/src/_platform/solutions/api.ts +13 -12
- package/templates/api/src/auth/_shared/ldap-login.ts +11 -7
- package/templates/api/src/auth/_shared/login-throttle.ts +2 -2
- package/templates/api/src/auth/_shared/session.ts +25 -21
- package/templates/api/src/auth/api.ts +1 -1
- package/templates/api/src/demo/todos/api.ts +1 -0
- package/templates/api/src/home/line/api.ts +4 -4
- package/templates/api/src/home/pie/api.ts +4 -4
- package/templates/api/src/notification/_shared/notify.ts +40 -38
- package/templates/api/src/notifications/api.ts +1 -4
- package/templates/api/src/personal-center/upload/api.ts +1 -1
- package/templates/api/src/system/login-methods/api.ts +1 -1
- package/templates/api/src/system/oplog/api.ts +3 -3
- package/templates/api/src/system/permissions/api.ts +2 -2
- package/templates/api/src/system/roles/api.ts +2 -2
- package/templates/api/src/system/roles/bindings/api.ts +2 -2
- package/templates/api/src/system/settings/api.ts +2 -2
- package/templates/api/src/system/users/api.ts +2 -2
- package/templates/api/src/system/users/forbidden/api.ts +2 -2
- package/templates/api/src/system/users/password/api.ts +2 -2
- package/templates/api/src/system/users/roles/api.ts +2 -2
- package/templates/api/src/web/user-info/api.ts +2 -5
- package/templates/api/tests/auth-session.spec.ts +1 -1
- package/templates/api/tests/auth-throttle.spec.ts +1 -1
- package/templates/api/tests/guard.spec.ts +1 -1
- package/templates/api/tests/lifecycle/market-api.spec.ts +1 -1
- package/templates/api/tests/lifecycle/trust.spec.ts +1 -1
- package/templates/api/tests/lifecycle/uninstall.spec.ts +1 -1
- package/templates/api/tests/query-utils.spec.ts +1 -1
- package/templates/api/tests/rbac-audit.spec.ts +1 -1
- package/templates/api/tests/rbac-contacts.spec.ts +2 -2
- package/templates/api/tests/rbac-guard.spec.ts +1 -1
- package/templates/api/tests/rbac-roles.spec.ts +2 -2
- package/templates/api/tests/rbac-settings.spec.ts +2 -2
- package/templates/api/tests/rbac-solution-perms.spec.ts +1 -1
- package/templates/api/tests/rbac-users.spec.ts +1 -1
- package/templates/api/tsconfig.json +8 -0
- package/templates/env.d.ts +2 -0
- package/templates/global.d.ts +816 -0
- package/templates/init.include.yaml +18 -1
- package/templates/logs/task-events.jsonl +0 -0
- package/templates/openapi.json +525 -13
- package/templates/package.json +2 -1
- package/templates/web/src/_platform/__client/api.schemas.ts +97 -0
- package/templates/web/src/_platform/__client/api.ts +152 -39
- package/templates/web/src/oj-market/__tests__/CardGrid.test.tsx +19 -0
- package/templates/web/src/oj-market/__tests__/InstallDialog.test.tsx +3 -0
- package/templates/web/src/oj-market/__tests__/RollbackDialog.test.tsx +1 -0
- package/templates/web/src/oj-market/__tests__/marketClient.fake.ts +1 -0
- package/templates/web/src/oj-market/api/client.ts +5 -105
- package/templates/web/src/oj-market/api/defaultClient.ts +111 -5
- package/templates/web/src/oj-market/components/CardGrid.tsx +4 -1
- package/templates/web/src/oj-market/components/CardItem.tsx +9 -5
- package/templates/web/src/oj-market/components/DetailDrawer.tsx +7 -3
- package/templates/web/src/oj-market/components/InstallDialog.tsx +1 -1
- package/templates/web/src/oj-market/hooks/useMarket.ts +4 -1
- package/templates/web/src/oj-market/locales/en-US.json +2 -1
- package/templates/web/src/oj-market/locales/zh-CN.json +2 -1
- package/templates/web/src/oj-market/pages/MarketApp.tsx +16 -1
- package/templates/web.config.ts +3 -3
- package/vendor/host-versions.json +3 -3
- /package/templates/api/src/_platform/_shared/service/{guard.ts → infra/guard.ts} +0 -0
- /package/templates/api/src/_platform/_shared/service/{oplog.ts → infra/oplog.ts} +0 -0
- /package/templates/api/src/_platform/_shared/service/{query.ts → infra/query.ts} +0 -0
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// 禁止操作自己(AC1-10 注);最后 superadmin 兜底(服务端二次防线);
|
|
4
4
|
// 角色唯一权威源 sys_user_role + 写时失效(AC1-7/D10);禁用/重置口令级联删 refresh(§3.1)。
|
|
5
5
|
// bcrypt 经注入(Node 侧全局不可 stub,同 session.ts 约定)。
|
|
6
|
-
import { invalidateUser } from "
|
|
7
|
-
import { recordOp } from "
|
|
6
|
+
import { invalidateUser } from "../infra/access";
|
|
7
|
+
import { recordOp } from "../infra/audit";
|
|
8
8
|
|
|
9
9
|
export interface UserDeps {
|
|
10
10
|
bcryptHash: (pw: string) => Promise<string>
|
|
@@ -28,12 +28,12 @@ function fail(status: number, message: string): never {
|
|
|
28
28
|
const RESERVED_PREFIX = /^(?:ldap|sso):/i;
|
|
29
29
|
|
|
30
30
|
async function rolesOf(uid: number): Promise<string[]> {
|
|
31
|
-
const rows = await db.
|
|
31
|
+
const rows = await db.table("sys_user_role").select(["role_name"]).where({ field: "user_id", op: "eq", value: uid }).all() as Array<{ role_name: string }>;
|
|
32
32
|
return rows.map(r => r.role_name);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
async function usernameOf(uid: number): Promise<string> {
|
|
36
|
-
const rows = await db.
|
|
36
|
+
const rows = await db.table("users").select(["username"]).where({ field: "id", op: "eq", value: uid }).all() as Array<{ username: string }>;
|
|
37
37
|
return rows[0] ? String(rows[0].username) : `#${uid}`;
|
|
38
38
|
}
|
|
39
39
|
|
|
@@ -52,10 +52,8 @@ async function assertMembershipAllowed(actorId: number, nextRoles: string[]): Pr
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
export async function listUsers(): Promise<UserRow[]> {
|
|
55
|
-
const rows = await db.
|
|
56
|
-
|
|
57
|
-
) as Array<Record<string, unknown>>;
|
|
58
|
-
const roleRows = await db.query("select user_id, role_name from sys_user_role") as Array<{ user_id: number, role_name: string }>;
|
|
55
|
+
const rows = await db.table("users").select(["id", "username", "display_name", "email", "is_forbidden", "must_change"]).orderBy([{ field: "username" }]).all() as Array<Record<string, unknown>>;
|
|
56
|
+
const roleRows = await db.table("sys_user_role").select(["user_id", "role_name"]).all() as Array<{ user_id: number, role_name: string }>;
|
|
59
57
|
const roleMap = new Map<number, string[]>();
|
|
60
58
|
for (const r of roleRows)
|
|
61
59
|
(roleMap.get(Number(r.user_id)) ?? roleMap.set(Number(r.user_id), []).get(Number(r.user_id)))!.push(r.role_name);
|
|
@@ -79,14 +77,11 @@ export async function createUser(input: { username: string, password: string, di
|
|
|
79
77
|
fail(400, "username prefix reserved for external identity sources");
|
|
80
78
|
if (!input.password || String(input.password).length < 6)
|
|
81
79
|
fail(400, "password too short (min 6)");
|
|
82
|
-
const exists = await db.
|
|
80
|
+
const exists = await db.table("users").select(["id"]).where({ field: "username", op: "eq", value: username }).all();
|
|
83
81
|
if (exists.length)
|
|
84
82
|
fail(400, "username exists");
|
|
85
83
|
const hash = await deps.bcryptHash(String(input.password));
|
|
86
|
-
const rows = await db.
|
|
87
|
-
"insert into users (username, password_hash, display_name, email, must_change) values (?, ?, ?, ?, 1) returning id",
|
|
88
|
-
[username, hash, input.displayName ?? null, input.email ?? null],
|
|
89
|
-
) as Array<{ id: number }>;
|
|
84
|
+
const rows = await db.table("users").insert({ username, password_hash: hash, display_name: input.displayName ?? null, email: input.email ?? null, must_change: 1 }).returning(["id"]).run() as Array<{ id: number }>;
|
|
90
85
|
const uid = Number(rows[0].id);
|
|
91
86
|
await recordOp({ action: "user.create", module: "framework", target: username });
|
|
92
87
|
return uid;
|
|
@@ -95,14 +90,13 @@ export async function createUser(input: { username: string, password: string, di
|
|
|
95
90
|
/** AC1-7:整集替换角色(sys_user_role 权威源),写后失效持有者判权缓存。 */
|
|
96
91
|
export async function updateUserRoles(actorId: number, targetUid: number, roles: string[]): Promise<void> {
|
|
97
92
|
assertNotSelf(actorId, targetUid);
|
|
98
|
-
const target = await db.
|
|
93
|
+
const target = await db.table("users").select(["id"]).where({ field: "id", op: "eq", value: targetUid }).all();
|
|
99
94
|
if (!target.length)
|
|
100
95
|
fail(404, "user not found");
|
|
101
96
|
const next = [...new Set(roles.map(String))];
|
|
102
97
|
if (next.length) {
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
const knownSet = new Set(known.map((r: { name: string }) => r.name));
|
|
98
|
+
const known = await db.table("sys_role").select(["name"]).where({ field: "name", op: "in", value: next }).all();
|
|
99
|
+
const knownSet = new Set(known.map(r => (r as { name: string }).name));
|
|
106
100
|
const unknown = next.filter(r => !knownSet.has(r));
|
|
107
101
|
if (unknown.length)
|
|
108
102
|
fail(400, `unknown roles: ${unknown.join(", ")}`);
|
|
@@ -112,14 +106,14 @@ export async function updateUserRoles(actorId: number, targetUid: number, roles:
|
|
|
112
106
|
const beforeSet = new Set(before);
|
|
113
107
|
// AC1-10 兜底:撤掉最后一个 superadmin 拒绝(现实可达路径已被「禁止操作自己」拦截)
|
|
114
108
|
if (beforeSet.has("superadmin") && !next.includes("superadmin")) {
|
|
115
|
-
const supers = await db.
|
|
109
|
+
const supers = await db.table("sys_user_role").select([{ fn: "count", as: "c" }]).where({ field: "role_name", op: "eq", value: "superadmin" }).all() as Array<{ c: number }>;
|
|
116
110
|
if (Number(supers[0].c) <= 1)
|
|
117
111
|
fail(400, "cannot revoke the last superadmin");
|
|
118
112
|
}
|
|
119
113
|
await db.tx(async (t) => {
|
|
120
|
-
await t.
|
|
114
|
+
await t.table("sys_user_role").where({ field: "user_id", op: "eq", value: targetUid }).delete().run();
|
|
121
115
|
for (const role of next)
|
|
122
|
-
await t.
|
|
116
|
+
await t.table("sys_user_role").insert({ user_id: targetUid, role_name: role }).run();
|
|
123
117
|
});
|
|
124
118
|
invalidateUser(Number(targetUid));
|
|
125
119
|
await recordOp({
|
|
@@ -135,13 +129,13 @@ export async function changePasswordByAdmin(actorId: number, targetUid: number,
|
|
|
135
129
|
assertNotSelf(actorId, targetUid);
|
|
136
130
|
if (!newPassword || String(newPassword).length < 6)
|
|
137
131
|
fail(400, "password too short (min 6)");
|
|
138
|
-
const rows = await db.
|
|
132
|
+
const rows = await db.table("users").select(["id"]).where({ field: "id", op: "eq", value: targetUid }).all();
|
|
139
133
|
if (!rows.length)
|
|
140
134
|
fail(404, "user not found");
|
|
141
135
|
const hash = await deps.bcryptHash(String(newPassword));
|
|
142
136
|
await db.tx(async (t) => {
|
|
143
|
-
await t.
|
|
144
|
-
await t.
|
|
137
|
+
await t.table("users").update({ password_hash: hash, must_change: 1 }).where({ field: "id", op: "eq", value: targetUid }).run();
|
|
138
|
+
await t.table("sys_refresh_token").where({ field: "user_id", op: "eq", value: targetUid }).delete().run();
|
|
145
139
|
});
|
|
146
140
|
invalidateUser(Number(targetUid));
|
|
147
141
|
await recordOp({ action: "user.password.reset", module: "framework", target: await usernameOf(Number(targetUid)) });
|
|
@@ -150,13 +144,13 @@ export async function changePasswordByAdmin(actorId: number, targetUid: number,
|
|
|
150
144
|
/** B7:禁用/解禁——禁用级联删 refresh + 写时失效。 */
|
|
151
145
|
export async function setForbidden(actorId: number, targetUid: number, forbidden: boolean): Promise<void> {
|
|
152
146
|
assertNotSelf(actorId, targetUid);
|
|
153
|
-
const rows = await db.
|
|
147
|
+
const rows = await db.table("users").select(["id"]).where({ field: "id", op: "eq", value: targetUid }).all();
|
|
154
148
|
if (!rows.length)
|
|
155
149
|
fail(404, "user not found");
|
|
156
150
|
await db.tx(async (t) => {
|
|
157
|
-
await t.
|
|
151
|
+
await t.table("users").update({ is_forbidden: forbidden ? 1 : 0 }).where({ field: "id", op: "eq", value: targetUid }).run();
|
|
158
152
|
if (forbidden)
|
|
159
|
-
await t.
|
|
153
|
+
await t.table("sys_refresh_token").where({ field: "user_id", op: "eq", value: targetUid }).delete().run();
|
|
160
154
|
});
|
|
161
155
|
invalidateUser(Number(targetUid));
|
|
162
156
|
await recordOp({ action: "user.forbidden.update", module: "framework", target: await usernameOf(Number(targetUid)), detail: { forbidden } });
|
|
@@ -20,15 +20,12 @@ interface AccessSnapshot {
|
|
|
20
20
|
const cache = new Map<number, AccessSnapshot>();
|
|
21
21
|
|
|
22
22
|
async function loadSnapshot(userId: number): Promise<AccessSnapshot> {
|
|
23
|
-
const userRows = await db.
|
|
24
|
-
const roleRows = await db.
|
|
25
|
-
const roles = roleRows.map((r
|
|
23
|
+
const userRows = await db.table("users").select(["is_forbidden", "must_change"]).where({ field: "id", op: "eq", value: userId }).all();
|
|
24
|
+
const roleRows = await db.table("sys_user_role").select(["role_name"]).where({ field: "user_id", op: "eq", value: userId }).all();
|
|
25
|
+
const roles = roleRows.map(r => (r as { role_name: string }).role_name);
|
|
26
26
|
// 只捞该用户角色的绑定(参数化 IN,三方言纪律);deprecated 不过滤(判权职责,§5)
|
|
27
27
|
const bindRows = roles.length
|
|
28
|
-
? await db.
|
|
29
|
-
`select role_name, permission_code from sys_role_permission where role_name in (${roles.map(() => "?").join(",")})`,
|
|
30
|
-
roles,
|
|
31
|
-
)
|
|
28
|
+
? await db.table("sys_role_permission").select(["role_name", "permission_code"]).where({ field: "role_name", op: "in", value: roles }).all()
|
|
32
29
|
: [];
|
|
33
30
|
const bindings: Record<string, string[]> = {};
|
|
34
31
|
for (const r of bindRows as { role_name: string, permission_code: string }[])
|
|
@@ -69,7 +66,7 @@ export async function currentAccess(): Promise<AccessSnapshot> {
|
|
|
69
66
|
const uid = http.user?.id;
|
|
70
67
|
if (uid == null)
|
|
71
68
|
deny();
|
|
72
|
-
const snap = await snapshot(uid);
|
|
69
|
+
const snap = await snapshot(Number(uid));
|
|
73
70
|
if (snap.forbidden)
|
|
74
71
|
deny();
|
|
75
72
|
if (snap.mustChange)
|
|
@@ -22,27 +22,24 @@ export interface OpEntry {
|
|
|
22
22
|
async function usernameOf(userId: number): Promise<string> {
|
|
23
23
|
if (!userId)
|
|
24
24
|
return "system";
|
|
25
|
-
const rows = await db.
|
|
25
|
+
const rows = await db.table("users").select(["username"]).where({ field: "id", op: "eq", value: userId }).all();
|
|
26
26
|
return rows[0] ? String(rows[0].username) : "unknown";
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/** 同步落表。关键路径(如 A6 角色管理)直接 await;一般路径用 auditOp。 */
|
|
30
30
|
export async function recordOp(entry: OpEntry): Promise<void> {
|
|
31
|
-
const uid = http.user?.id ?? 0;
|
|
32
|
-
await db.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
entry.level ?? 6,
|
|
44
|
-
],
|
|
45
|
-
);
|
|
31
|
+
const uid = Number(http.user?.id ?? 0);
|
|
32
|
+
await db.table("sys_oplog").insert({
|
|
33
|
+
create_time: Date.now(),
|
|
34
|
+
user_id: uid,
|
|
35
|
+
username: await usernameOf(uid),
|
|
36
|
+
action: entry.action,
|
|
37
|
+
module: entry.module,
|
|
38
|
+
version: entry.version ?? null,
|
|
39
|
+
target: entry.target ?? null,
|
|
40
|
+
detail: entry.detail == null ? null : JSON.stringify(entry.detail),
|
|
41
|
+
level: entry.level ?? 6,
|
|
42
|
+
}).run();
|
|
46
43
|
}
|
|
47
44
|
|
|
48
45
|
/** fire-and-forget:审计失败静默(写日志也失败的极端场景下,业务正确性优先)。 */
|
|
@@ -61,7 +58,7 @@ let denyQueue: Promise<void> = Promise.resolve();
|
|
|
61
58
|
* uid 在调用瞬间同步捕获——http.user 是可变全局,异步队列执行时再读会被后续请求覆盖。
|
|
62
59
|
*/
|
|
63
60
|
export function auditDeny(code: string): void {
|
|
64
|
-
const uid = http.user?.id ?? 0;
|
|
61
|
+
const uid = Number(http.user?.id ?? 0);
|
|
65
62
|
denyQueue = denyQueue.then(() => recordDeny(uid, code)).catch(() => {});
|
|
66
63
|
}
|
|
67
64
|
|
|
@@ -73,18 +70,21 @@ export function resetDenyAudit(): void {
|
|
|
73
70
|
async function recordDeny(uid: number, code: string): Promise<void> {
|
|
74
71
|
const now = Date.now();
|
|
75
72
|
// 窗口锚点 = 分钟内最近一条同 (user, code) 的 auth.deny 行;命中只更新计数,否则落首条。
|
|
76
|
-
const rows = await db.
|
|
77
|
-
"select id, detail from sys_oplog where user_id = ? and action = 'auth.deny' and target = ? and create_time > ? order by id desc limit 1",
|
|
78
|
-
[uid, code, now - DENY_WINDOW_MS],
|
|
79
|
-
);
|
|
73
|
+
const rows = await db.table("sys_oplog").select(["id", "detail"]).where({ field: "user_id", op: "eq", value: uid }).where({ field: "action", op: "eq", value: "auth.deny" }).where({ field: "target", op: "eq", value: code }).where({ field: "create_time", op: "gt", value: now - DENY_WINDOW_MS }).orderBy([{ field: "id", dir: "desc" }]).limit(1).all();
|
|
80
74
|
const hit = rows[0] as { id: number, detail: string } | undefined;
|
|
81
75
|
if (hit) {
|
|
82
76
|
const count = (JSON.parse(hit.detail) as { count: number }).count + 1;
|
|
83
|
-
await db.
|
|
77
|
+
await db.table("sys_oplog").update({ detail: JSON.stringify({ code, count }) }).where({ field: "id", op: "eq", value: hit.id }).run();
|
|
84
78
|
return;
|
|
85
79
|
}
|
|
86
|
-
await db.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
80
|
+
await db.table("sys_oplog").insert({
|
|
81
|
+
create_time: now,
|
|
82
|
+
user_id: uid,
|
|
83
|
+
username: await usernameOf(uid),
|
|
84
|
+
action: "auth.deny",
|
|
85
|
+
module: moduleOf(code),
|
|
86
|
+
target: code,
|
|
87
|
+
detail: JSON.stringify({ code, count: 1 }),
|
|
88
|
+
level: 4,
|
|
89
|
+
}).run();
|
|
90
90
|
}
|
|
@@ -12,15 +12,15 @@ export interface RateLimitOpts {
|
|
|
12
12
|
/** key 维度(如 `contacts:<uid>` / `login:ip:<ip>`):窗口内超 limit → 429。 */
|
|
13
13
|
export async function rateLimit(key: string, limit: number, windowMs: number, opts: RateLimitOpts = {}): Promise<void> {
|
|
14
14
|
const now = opts.now?.() ?? Date.now();
|
|
15
|
-
const rows = await db.
|
|
15
|
+
const rows = await db.table("sys_rate_window").select(["window_start", "count"]).where({ field: "rate_key", op: "eq", value: key }).all();
|
|
16
16
|
const w = rows[0] as { window_start: number, count: number } | undefined;
|
|
17
17
|
if (!w || now - Number(w.window_start) >= windowMs) {
|
|
18
|
-
await db.
|
|
19
|
-
await db.
|
|
18
|
+
await db.table("sys_rate_window").where({ field: "rate_key", op: "eq", value: key }).delete().run();
|
|
19
|
+
await db.table("sys_rate_window").insert({ rate_key: key, window_start: now, count: 1 }).run();
|
|
20
20
|
return;
|
|
21
21
|
}
|
|
22
22
|
const count = Number(w.count) + 1;
|
|
23
|
-
await db.
|
|
23
|
+
await db.table("sys_rate_window").update({ count }).where({ field: "rate_key", op: "eq", value: key }).run();
|
|
24
24
|
if (count > limit)
|
|
25
25
|
throw Object.assign(new Error("rate limited"), { status: 429 });
|
|
26
26
|
}
|
|
@@ -44,12 +44,12 @@ export async function loginFailed(key: string, opts: LoginThrottleOpts = {}): Pr
|
|
|
44
44
|
const threshold = opts.threshold ?? 6;
|
|
45
45
|
const base = opts.baseBackoffMs ?? 15_000;
|
|
46
46
|
const now = opts.now?.() ?? Date.now();
|
|
47
|
-
const rows = await db.
|
|
48
|
-
const s = rows[0] as LoginThrottleRow | undefined;
|
|
47
|
+
const rows = await db.table("sys_login_throttle").select(["fail_count", "blocked_until"]).where({ field: "pair_key", op: "eq", value: key }).all();
|
|
48
|
+
const s = rows[0] as unknown as LoginThrottleRow | undefined;
|
|
49
49
|
const count = (s ? Number(s.fail_count) : 0) + 1;
|
|
50
50
|
const blockedUntil = count >= threshold ? now + base * 2 ** (count - threshold) : (s ? Number(s.blocked_until) : 0);
|
|
51
|
-
await db.
|
|
52
|
-
await db.
|
|
51
|
+
await db.table("sys_login_throttle").where({ field: "pair_key", op: "eq", value: key }).delete().run();
|
|
52
|
+
await db.table("sys_login_throttle").insert({ pair_key: key, fail_count: count, blocked_until: blockedUntil }).run();
|
|
53
53
|
return count;
|
|
54
54
|
}
|
|
55
55
|
|
|
@@ -58,19 +58,19 @@ export async function loginBlocked(key: string, opts: LoginThrottleOpts = {}): P
|
|
|
58
58
|
const threshold = opts.threshold ?? 6;
|
|
59
59
|
const base = opts.baseBackoffMs ?? 15_000;
|
|
60
60
|
const now = opts.now?.() ?? Date.now();
|
|
61
|
-
const rows = await db.
|
|
62
|
-
const s = rows[0] as LoginThrottleRow | undefined;
|
|
61
|
+
const rows = await db.table("sys_login_throttle").select(["fail_count", "blocked_until"]).where({ field: "pair_key", op: "eq", value: key }).all();
|
|
62
|
+
const s = rows[0] as unknown as LoginThrottleRow | undefined;
|
|
63
63
|
if (!s || Number(s.blocked_until) <= now)
|
|
64
64
|
return;
|
|
65
65
|
const count = Number(s.fail_count) + 1;
|
|
66
66
|
const blockedUntil = now + base * 2 ** (count - threshold);
|
|
67
|
-
await db.
|
|
67
|
+
await db.table("sys_login_throttle").update({ fail_count: count, blocked_until: blockedUntil }).where({ field: "pair_key", op: "eq", value: key }).run();
|
|
68
68
|
throw Object.assign(new Error("too many login attempts"), { status: 429, retryAfterMs: blockedUntil - now });
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
/** 登录成功清零连败。 */
|
|
72
72
|
export async function loginSucceeded(key: string): Promise<void> {
|
|
73
|
-
await db.
|
|
73
|
+
await db.table("sys_login_throttle").where({ field: "pair_key", op: "eq", value: key }).delete().run();
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
/** 清登录限速状态(测试夹具/运维排障用)。 */
|
|
@@ -34,7 +34,7 @@ export function withSiteOrigin(fetch: FetchInitFace): FetchFace {
|
|
|
34
34
|
/** 站点迁移账本 head(oj journal 真源 `_oj_migrations`;表缺失/查询失败 → null = 跳过禁降版判定,不猜) */
|
|
35
35
|
export async function journalHeadOf(name: string): Promise<number | bigint | null> {
|
|
36
36
|
try {
|
|
37
|
-
const rows = await db.
|
|
37
|
+
const rows = await db.table("_oj_migrations").select([{ fn: "max", field: "version", as: "head" }]).where({ field: "module", op: "eq", value: name }).all() as Array<{ head: unknown }>;
|
|
38
38
|
const head = rows[0]?.head;
|
|
39
39
|
return head == null ? null : Number(head);
|
|
40
40
|
}
|
|
@@ -2,8 +2,8 @@ import { respond } from "../_shared/respond";
|
|
|
2
2
|
// 平台共享服务域 /api/_platform/contacts(D207:登录即可,不挂权限点,H12)。
|
|
3
3
|
// 读操作不记审计(设计 §4B);接口级限速(设计 line 199)经 rate.ts(B5 复用同源)。
|
|
4
4
|
// 单条详情走子组 /api/_platform/contacts/detail?id=(detail/api.ts,含 email/avatar)。
|
|
5
|
-
import { searchContacts } from "../_shared/service/contacts";
|
|
6
|
-
import { rateLimit } from "../_shared/service/rate";
|
|
5
|
+
import { searchContacts } from "../_shared/service/domain/contacts";
|
|
6
|
+
import { rateLimit } from "../_shared/service/infra/rate";
|
|
7
7
|
|
|
8
8
|
export default {
|
|
9
9
|
// GET /api/_platform/contacts?keyword=&limit= —— 人员目录(AC1-22~24)。
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { respond } from "../../_shared/respond";
|
|
2
2
|
// GET /api/_platform/contacts/detail?id= —— 单条详情(含 email/avatar,设计 §4B)。
|
|
3
|
-
import { contactDetail } from "../../_shared/service/contacts";
|
|
4
|
-
import { rateLimit } from "../../_shared/service/rate";
|
|
3
|
+
import { contactDetail } from "../../_shared/service/domain/contacts";
|
|
4
|
+
import { rateLimit } from "../../_shared/service/infra/rate";
|
|
5
5
|
|
|
6
6
|
export default {
|
|
7
7
|
async get() {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
name: _platform
|
|
2
|
-
desc:
|
|
2
|
+
desc: 平台内核(非业务模块、无自身业务路由前缀)。含三类:① 跨模块服务 _shared/service(infra 基础设施 + domain 管理域);② 生命周期引擎 _lifecycle(方案安装/卸载/注册/验签/信任状态机);③ 平台编排 API 面 /api/_platform/{contacts,market,solutions}(由 market/api.ts、contacts/api.ts、solutions/api.ts 暴露,全 superadmin 门禁)。运行时安装的方案包不在此树。
|
|
3
3
|
version: 0.1.0
|
|
4
4
|
tables:
|
|
5
5
|
- users
|
|
@@ -12,3 +12,5 @@ tables:
|
|
|
12
12
|
- sys_user_role
|
|
13
13
|
- sys_refresh_token
|
|
14
14
|
- market_history
|
|
15
|
+
- sys_rate_window
|
|
16
|
+
- sys_login_throttle
|
|
@@ -27,7 +27,7 @@ import { validateTarListing } from "../_lifecycle/tgz-guard";
|
|
|
27
27
|
import { createTrustStore } from "../_lifecycle/trust";
|
|
28
28
|
import { computeUninstallImpact, runUninstall } from "../_lifecycle/uninstall";
|
|
29
29
|
import { respond } from "../_shared/respond";
|
|
30
|
-
import { assertSuperAdmin } from "../_shared/service/access";
|
|
30
|
+
import { assertSuperAdmin } from "../_shared/service/infra/access";
|
|
31
31
|
import { journalHeadOf, siteOrigin, withSiteOrigin } from "../_shared/site-facade";
|
|
32
32
|
import { BOOTSTRAP_KEY, seedBootstrapKey } from "./bootstrap";
|
|
33
33
|
|
|
@@ -182,7 +182,7 @@ async function cdnBaseOf(env: MarketEnv): Promise<string | null> {
|
|
|
182
182
|
return injected === "" ? null : injected.replace(/\/+$/, "");
|
|
183
183
|
}
|
|
184
184
|
try {
|
|
185
|
-
const rows = await db.
|
|
185
|
+
const rows = await db.table("sys_setting").select(["value", "default_value"]).where({ field: "key", op: "eq", value: "framework.market_cdn_url" }).all() as Array<Record<string, unknown>>;
|
|
186
186
|
const v = String(rows[0]?.value ?? rows[0]?.default_value ?? "").trim().replace(/\/+$/, "");
|
|
187
187
|
return v === "" ? null : v;
|
|
188
188
|
}
|
|
@@ -263,7 +263,7 @@ async function operatorOf(): Promise<string | null> {
|
|
|
263
263
|
return null;
|
|
264
264
|
}
|
|
265
265
|
try {
|
|
266
|
-
const u = await db.
|
|
266
|
+
const u = await db.table("users").select(["username"]).where({ field: "id", op: "eq", value: uid }).all() as Array<{ username: string }>;
|
|
267
267
|
return u[0]?.username ?? null;
|
|
268
268
|
}
|
|
269
269
|
catch {
|
|
@@ -274,7 +274,7 @@ async function operatorOf(): Promise<string | null> {
|
|
|
274
274
|
/** sys_solution_route enabled(reconcile route 一致性面;无行/查询失败 → null 不猜) */
|
|
275
275
|
async function routeEnabledOf(name: string): Promise<boolean | null> {
|
|
276
276
|
try {
|
|
277
|
-
const rows = await db.
|
|
277
|
+
const rows = await db.table("sys_solution_route").select(["enabled"]).where({ field: "module", op: "eq", value: name }).where({ field: "deprecated", op: "eq", value: 0 }).limit(1).all() as Array<{ enabled: unknown }>;
|
|
278
278
|
if (rows.length === 0) {
|
|
279
279
|
return null;
|
|
280
280
|
}
|
|
@@ -499,7 +499,7 @@ async function installBody(env: MarketEnv): Promise<{ task_id: string }> {
|
|
|
499
499
|
manifest,
|
|
500
500
|
name,
|
|
501
501
|
version,
|
|
502
|
-
operator: await operatorOf(),
|
|
502
|
+
operator: (await operatorOf()) ?? undefined,
|
|
503
503
|
state: await createStateStore(env.statePath),
|
|
504
504
|
archiveUrl,
|
|
505
505
|
sharedRoot: env.sharedRoot,
|
|
@@ -564,10 +564,13 @@ interface IndexRow {
|
|
|
564
564
|
/** 最近一次安装操作人(回落 market_history 行;表不存在/查询失败/无行 → null,观测面不阻断索引) */
|
|
565
565
|
async function lastInstallOperatorOf(name: string): Promise<string | null> {
|
|
566
566
|
try {
|
|
567
|
-
const rows = await db.
|
|
568
|
-
"
|
|
569
|
-
|
|
570
|
-
|
|
567
|
+
const rows = await db.table("market_history")
|
|
568
|
+
.select(["operator"])
|
|
569
|
+
.where({ field: "solution", op: "eq", value: name })
|
|
570
|
+
.where({ field: "action", op: "eq", value: "install" })
|
|
571
|
+
.orderBy([{ field: "created_at", dir: "desc" }, { field: "rowid", dir: "desc" }])
|
|
572
|
+
.limit(1)
|
|
573
|
+
.all() as Array<{ operator: unknown }>;
|
|
571
574
|
const op = rows[0]?.operator;
|
|
572
575
|
return typeof op === "string" ? op : null;
|
|
573
576
|
}
|
|
@@ -728,7 +731,7 @@ export function createMarketApi(env: MarketEnv) {
|
|
|
728
731
|
state: await createStateStore(env.statePath),
|
|
729
732
|
modulesJsonPath: env.modulesJsonPath,
|
|
730
733
|
trustPath: env.trustPath,
|
|
731
|
-
operator: await operatorOf(),
|
|
734
|
+
operator: (await operatorOf()) ?? undefined,
|
|
732
735
|
});
|
|
733
736
|
});
|
|
734
737
|
},
|
|
@@ -851,7 +854,7 @@ export function createMarketApi(env: MarketEnv) {
|
|
|
851
854
|
lockPath: `${env.sharedRoot}/manifests.yaml`,
|
|
852
855
|
state: await createStateStore(env.statePath),
|
|
853
856
|
modulesJsonPath: env.modulesJsonPath,
|
|
854
|
-
operator: await operatorOf(),
|
|
857
|
+
operator: (await operatorOf()) ?? undefined,
|
|
855
858
|
});
|
|
856
859
|
// P3-11 宿主打磨:方案已不存在 = 资源缺失语义 → 404(非 200 {ok:false});
|
|
857
860
|
// 其余领域拒绝(空锁拒启/级联失败等)维持 200 {ok:false} 人话在 message。
|
|
@@ -869,10 +872,10 @@ export function createMarketApi(env: MarketEnv) {
|
|
|
869
872
|
"history": {
|
|
870
873
|
async get() {
|
|
871
874
|
await respond(async () => {
|
|
872
|
-
const rows = (await db.
|
|
873
|
-
"
|
|
874
|
-
[]
|
|
875
|
-
|
|
875
|
+
const rows = (await db.table("market_history")
|
|
876
|
+
.select(["solution", "version", "action", "status", "operator", "message", "created_at"])
|
|
877
|
+
.orderBy([{ field: "created_at", dir: "desc" }])
|
|
878
|
+
.all()) as Array<Record<string, unknown>>;
|
|
876
879
|
return rows ?? [];
|
|
877
880
|
});
|
|
878
881
|
},
|
|
@@ -880,4 +883,39 @@ export function createMarketApi(env: MarketEnv) {
|
|
|
880
883
|
};
|
|
881
884
|
}
|
|
882
885
|
|
|
883
|
-
|
|
886
|
+
const marketApi = createMarketApi(marketEnv());
|
|
887
|
+
|
|
888
|
+
// —— 契约 I/O(schema-first,oj v0.1.72+):`.schema` 定义入参(body/query)、`.response`
|
|
889
|
+
// 定义出参,供 oj 在 handler 运行前校验 + 生成式 __client 推导有类型函数(与 notification/api.ts
|
|
890
|
+
// 同口径)。出参一律 additionalProperties:true 宽松,不约束运行期返回形状;入参对齐 handler 实际
|
|
891
|
+
// 收口(source 枚举 / 必填项),oj 先校验、handler 仍自校验(双保险,不放松运行期语义)。
|
|
892
|
+
marketApi.install.post.schema = {
|
|
893
|
+
body: {
|
|
894
|
+
type: "object",
|
|
895
|
+
required: ["source", "name"],
|
|
896
|
+
properties: { source: { type: "string", enum: ["cdn", "file"] }, name: { type: "string" }, version: { type: ["string", "null"] } },
|
|
897
|
+
},
|
|
898
|
+
};
|
|
899
|
+
marketApi.install.post.response = { type: "object", properties: { task_id: { type: "string" } }, additionalProperties: true };
|
|
900
|
+
marketApi.install.cancel.post.schema = { body: { type: "object", required: ["name"], properties: { name: { type: "string" } } } };
|
|
901
|
+
marketApi.install.cancel.post.response = { type: "object", properties: { ok: { type: "boolean" }, name: { type: "string" }, requested: { type: "boolean" } }, additionalProperties: true };
|
|
902
|
+
marketApi.task.get.schema = { query: { type: "object", required: ["id"], properties: { id: { type: "string" } } } };
|
|
903
|
+
marketApi.task.get.response = { type: "object", additionalProperties: true };
|
|
904
|
+
marketApi.enable.post.schema = { body: { type: "object", required: ["name", "enabled"], properties: { name: { type: "string" }, enabled: { type: "boolean" } } } };
|
|
905
|
+
marketApi.enable.post.response = { type: "object", additionalProperties: true };
|
|
906
|
+
marketApi.meta.get.response = { type: "object", properties: { apiVersion: { type: "string" } }, additionalProperties: true };
|
|
907
|
+
marketApi.trust.get.response = { type: "object", additionalProperties: true };
|
|
908
|
+
marketApi.trust.post.schema = { body: { type: "object", required: ["task_id"], properties: { task_id: { type: "string" } } } };
|
|
909
|
+
marketApi.trust.post.response = { type: "object", additionalProperties: true };
|
|
910
|
+
marketApi.trust.key.post.schema = { body: { type: "object", required: ["key_id", "public_key"], properties: { key_id: { type: "string" }, public_key: { type: "string" } } } };
|
|
911
|
+
marketApi.trust.key.post.response = { type: "object", additionalProperties: true };
|
|
912
|
+
marketApi.trust.key.del.schema = { query: { type: "object", required: ["key_id"], properties: { key_id: { type: "string" } } } };
|
|
913
|
+
marketApi.trust.key.del.response = { type: "object", additionalProperties: true };
|
|
914
|
+
marketApi.index.get.response = { type: "array", items: { type: "object", additionalProperties: true } };
|
|
915
|
+
marketApi["uninstall-impact"].get.schema = { query: { type: "object", required: ["name"], properties: { name: { type: "string" } } } };
|
|
916
|
+
marketApi["uninstall-impact"].get.response = { type: "object", additionalProperties: true };
|
|
917
|
+
marketApi.uninstall.post.schema = { body: { type: "object", required: ["name"], properties: { name: { type: "string" } } } };
|
|
918
|
+
marketApi.uninstall.post.response = { type: "object", additionalProperties: true };
|
|
919
|
+
marketApi.history.get.response = { type: "array", items: { type: "object", additionalProperties: true } };
|
|
920
|
+
|
|
921
|
+
export default marketApi;
|
|
@@ -88,6 +88,20 @@ tables:
|
|
|
88
88
|
expires_at: {type: integer, null: false}
|
|
89
89
|
revoked: {type: integer, null: false}
|
|
90
90
|
created_at: {type: integer, null: false}
|
|
91
|
+
# 限速/登录节流状态表(V9__sys_throttle 经迁移创建;rate.ts 经 db.table() 访问,
|
|
92
|
+
# 须在此声明 + 在 manifest.yaml tables 登记,否则 oj 表元数据校验报 unknown table)。
|
|
93
|
+
sys_rate_window:
|
|
94
|
+
pk: rate_key
|
|
95
|
+
columns:
|
|
96
|
+
rate_key: {type: text, null: false}
|
|
97
|
+
window_start: {type: integer, null: false}
|
|
98
|
+
count: {type: integer, null: false}
|
|
99
|
+
sys_login_throttle:
|
|
100
|
+
pk: pair_key
|
|
101
|
+
columns:
|
|
102
|
+
pair_key: {type: text, null: false}
|
|
103
|
+
fail_count: {type: integer, null: false}
|
|
104
|
+
blocked_until: {type: integer, null: false}
|
|
91
105
|
# 市场操作历史(P4 上收 _platform):install/enable/disable/uninstall/upgrade/rollback 六类动作留痕。
|
|
92
106
|
# 写入由平台编排面(_lifecycle/history.ts recordHistory)落到本表;市场页只读消费。
|
|
93
107
|
market_history:
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
} from "../_lifecycle/verify-checks";
|
|
25
25
|
import { getLastReport, persistReport, verifyReportsDirOf } from "../_lifecycle/verify-report";
|
|
26
26
|
import { respond } from "../_shared/respond";
|
|
27
|
-
import { assertSystemAdmin } from "../_shared/service/access";
|
|
27
|
+
import { assertSystemAdmin } from "../_shared/service/infra/access";
|
|
28
28
|
import { journalHeadOf, siteOrigin } from "../_shared/site-facade";
|
|
29
29
|
|
|
30
30
|
/** 线格式行(与 packages/runtime/src/api/rbac/types.ts 的 SolutionRouteRow 对齐) */
|
|
@@ -82,11 +82,11 @@ async function trustItemOf(env: SolutionsEnv, name: string): Promise<VerifyItem
|
|
|
82
82
|
/** 深检主体:四项检查编排 + 可信性项 + i18n 依赖项,组装 VerifyReport{ok,checks,advise}(persist 归端点) */
|
|
83
83
|
async function verifyBody(env: SolutionsEnv, name: string): Promise<VerifyReport> {
|
|
84
84
|
// 已装校验:sys_solution_route 有该 module 行(未装 → 404,端点门之一)
|
|
85
|
-
const routeRows = await db.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
85
|
+
const routeRows = await db.table("sys_solution_route")
|
|
86
|
+
.select(["path", "module", "category", "order", "title_key", "required_permission", "enabled"])
|
|
87
|
+
.where({ field: "deprecated", op: "eq", value: 0 })
|
|
88
|
+
.where({ field: "module", op: "eq", value: name })
|
|
89
|
+
.all() as Array<Record<string, unknown>>;
|
|
90
90
|
if (routeRows.length === 0)
|
|
91
91
|
throw Object.assign(new Error(`方案「${name}」未安装或无路由注册`), { status: 404 });
|
|
92
92
|
|
|
@@ -97,13 +97,13 @@ async function verifyBody(env: SolutionsEnv, name: string): Promise<VerifyReport
|
|
|
97
97
|
|
|
98
98
|
// ② RBAC 完整性:声明权限点 ↔ sys_permission 实有
|
|
99
99
|
const declaredPerms = [...new Set(routes.map(r => r.required_permission).filter((p): p is string => p != null))];
|
|
100
|
-
const permRows = await db.
|
|
100
|
+
const permRows = await db.table("sys_permission").select(["code"]).all() as Array<{ code: string }>;
|
|
101
101
|
const rbac = diffPermRows(declaredPerms, new Set(permRows.map(r => r.code)));
|
|
102
102
|
|
|
103
103
|
// ③ 设置完整性:manifest 声明设置 key ↔ sys_setting 实有(永不含值)
|
|
104
104
|
const manifest = await readSolutionManifest(env, name);
|
|
105
105
|
const declaredSettings = manifest ? declaredSettingsOf(manifest.manifest) : [];
|
|
106
|
-
const settingRows = await db.
|
|
106
|
+
const settingRows = await db.table("sys_setting").select(["key"]).where({ field: "module", op: "eq", value: name }).where({ field: "deprecated", op: "eq", value: 0 }).all() as Array<{ key: string }>;
|
|
107
107
|
const settings = diffSettingRows(declaredSettings, new Set(settingRows.map(r => r.key)));
|
|
108
108
|
|
|
109
109
|
// ④ 迁移账本:manifest 迁移目录最高版本 ↔ _oj_migrations 实有(journalHeadOf 复用,DRY)
|
|
@@ -167,10 +167,11 @@ export function createSolutionsApi(env: SolutionsEnv) {
|
|
|
167
167
|
return;
|
|
168
168
|
}
|
|
169
169
|
await respond(async () => {
|
|
170
|
-
const rows = await db.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
170
|
+
const rows = await db.table("sys_solution_route")
|
|
171
|
+
.select(["path", "module", "category", "order", "title_key", "required_permission", "enabled"])
|
|
172
|
+
.where({ field: "deprecated", op: "eq", value: 0 })
|
|
173
|
+
.orderBy([{ field: "module" }, { field: "order" }, { field: "path" }])
|
|
174
|
+
.all() as Array<Record<string, unknown>>;
|
|
174
175
|
return rows.map(rowOf);
|
|
175
176
|
});
|
|
176
177
|
},
|
|
@@ -25,7 +25,7 @@ function fail(status: number, message: string): never {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
async function setting(key: string): Promise<string> {
|
|
28
|
-
const rows = await db.
|
|
28
|
+
const rows = await db.table("sys_setting").select(["value"]).where({ field: "key", op: "eq", value: key }).all() as Array<{ value: string | null }>;
|
|
29
29
|
return rows[0]?.value == null ? "" : String(rows[0].value);
|
|
30
30
|
}
|
|
31
31
|
|
|
@@ -64,20 +64,24 @@ export function createLdapLogin(session: Pick<Session, "issueExternal">, gateway
|
|
|
64
64
|
// JIT find-or-create:键 "ldap:<uid>",占位 hash "!ldap" 恒 verify false(低信任来源不接管本地号);
|
|
65
65
|
// must_change=0——外来号无本地旧口令,悬挂期唯一可用写端(改密)必失败 = 号 brick(设计 §9.4)
|
|
66
66
|
const localName = `ldap:${username}`;
|
|
67
|
-
let rows = await db.
|
|
67
|
+
let rows = await db.table("users").select(["id", "is_forbidden"]).where({ field: "username", op: "eq", value: localName }).all() as Array<{ id: number, is_forbidden: number }>;
|
|
68
68
|
if (!rows.length) {
|
|
69
69
|
try {
|
|
70
|
-
await db.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
await db.table("users").insert({
|
|
71
|
+
username: localName,
|
|
72
|
+
password_hash: "!ldap",
|
|
73
|
+
source: "ldap",
|
|
74
|
+
email: firstAttr(entries[0].attrs, emailAttr) || null,
|
|
75
|
+
must_change: 0,
|
|
76
|
+
is_forbidden: 0,
|
|
77
|
+
}).run();
|
|
74
78
|
}
|
|
75
79
|
catch (e) {
|
|
76
80
|
// 唯一约束兜底(预查与插入间的竞态,同 createUser 模式):撞 username → 回查复用;其余照抛
|
|
77
81
|
if (!/unique|constraint/i.test(e instanceof Error ? e.message : String(e)))
|
|
78
82
|
throw e;
|
|
79
83
|
}
|
|
80
|
-
rows = await db.
|
|
84
|
+
rows = await db.table("users").select(["id", "is_forbidden"]).where({ field: "username", op: "eq", value: localName }).all() as Array<{ id: number, is_forbidden: number }>;
|
|
81
85
|
}
|
|
82
86
|
if (Number(rows[0].is_forbidden) === 1)
|
|
83
87
|
fail(403, "account disabled");
|