@oj-module/cli 0.2.42 → 0.2.45
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/devkit/README.md +7 -5
- package/devkit/SKILL.md +22 -2
- package/devkit/manual.md +5 -0
- package/devkit/wecom-daikaifa.md +392 -0
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +102 -39
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/manifest-routes-derive.test.ts +22 -0
- package/src/__tests__/site-tenant-header.test.ts +54 -0
- package/src/__tests__/templates-tenant-scope.test.ts +139 -0
- package/src/build.ts +9 -0
- package/src/init.ts +4 -1
- package/src/install.ts +2 -2
- package/src/market/__tests__/verify.test.ts +32 -1
- package/src/market/bootstrap.ts +15 -0
- package/src/market/setup.ts +3 -3
- package/src/market/verify.ts +7 -3
- package/src/market/walkthrough.ts +35 -29
- package/src/site.ts +17 -1
- package/templates/config.yaml +111 -101
- package/templates/openapi.json +319 -0
- package/templates/src/_platform/api/_lifecycle/steps/download.ts +6 -2
- package/templates/src/_platform/api/_lifecycle/steps/smoke.ts +17 -9
- package/templates/src/_platform/api/_shared/respond.ts +11 -6
- package/templates/src/_platform/api/_shared/service/domain/settings.ts +5 -3
- package/templates/src/_platform/api/_shared/service/domain/users.ts +9 -3
- package/templates/src/_platform/api/_shared/service/infra/access.ts +59 -4
- package/templates/src/_platform/api/_shared/service/infra/query.ts +5 -1
- package/templates/src/_platform/api/contacts/api.ts +4 -1
- package/templates/src/_platform/api/contacts/detail/api.ts +3 -0
- package/templates/src/_platform/api/migrations/0015__tenant.mysql.sql +12 -0
- package/templates/src/_platform/api/migrations/0015__tenant.pg.sql +11 -0
- package/templates/src/_platform/api/migrations/0015__tenant.sql +44 -0
- package/templates/src/_platform/api/schema.yaml +28 -0
- package/templates/src/_platform/api/seed.sql +3 -0
- package/templates/src/auth/api/_shared/wecom/admin.ts +112 -0
- package/templates/src/auth/api/_shared/wecom/client.ts +243 -0
- package/templates/src/auth/api/_shared/wecom/config.ts +133 -0
- package/templates/src/auth/api/_shared/wecom/crypt.ts +312 -0
- package/templates/src/auth/api/_shared/wecom/events.ts +127 -0
- package/templates/src/auth/api/_shared/wecom/login.ts +137 -0
- package/templates/src/auth/api/_shared/wecom/replay.ts +66 -0
- package/templates/src/auth/api/_shared/wecom/store.ts +230 -0
- package/templates/src/auth/api/_shared/wecom/xml.ts +36 -0
- package/templates/src/auth/api/manifest.yaml +6 -0
- package/templates/src/auth/api/migrations/0001__wecom.mysql.sql +35 -0
- package/templates/src/auth/api/migrations/0001__wecom.pg.sql +33 -0
- package/templates/src/auth/api/migrations/0001__wecom.sql +47 -0
- package/templates/src/auth/api/schema.yaml +48 -0
- package/templates/src/auth/api/wecom/admin/api.ts +121 -0
- package/templates/src/auth/api/wecom/api.ts +226 -0
- package/templates/src/auth/web/__client/api.schemas.ts +36 -0
- package/templates/src/auth/web/__client/api.ts +148 -1
- package/templates/src/demo/api/migrations/0002__tenant.mysql.sql +2 -0
- package/templates/src/demo/api/migrations/0002__tenant.sql +2 -0
- package/templates/src/demo/api/schema.yaml +1 -0
- package/templates/src/home/api/migrations/0002__tenant.mysql.sql +3 -0
- package/templates/src/home/api/migrations/0002__tenant.sql +3 -0
- package/templates/src/home/api/schema.yaml +2 -0
- package/templates/src/login/web/auth-provider.ts +48 -0
- package/templates/src/login/web/entry.ts +21 -1
- package/templates/src/login/web/locales/en-US.json +11 -1
- package/templates/src/login/web/locales/zh-CN.json +11 -1
- package/templates/src/login/web/pages/login.tsx +154 -25
- package/templates/src/login/web/wecom.ts +51 -0
- package/templates/src/notification/api/_shared/notify.ts +9 -5
- package/templates/src/notification/api/api.ts +25 -0
- package/templates/src/notification/api/del/api.ts +5 -0
- package/templates/src/notification/api/manifest.yaml +3 -0
- package/templates/src/notification/api/migrations/0005__tenant.mysql.sql +6 -0
- package/templates/src/notification/api/migrations/0005__tenant.sql +6 -0
- package/templates/src/notification/api/schema.yaml +4 -0
- package/templates/src/personal-center/api/profile/api.ts +13 -0
- package/templates/src/personal-center/api/upload/api.ts +8 -1
- package/templates/src/system/web/entry.ts +8 -0
- package/templates/src/system/web/locales/en-US.json +33 -2
- package/templates/src/system/web/locales/zh-CN.json +33 -2
- package/templates/src/system/web/pages/login-methods/index.tsx +2 -1
- package/templates/src/system/web/pages/users/index.tsx +1 -1
- package/templates/src/system/web/pages/wecom/index.tsx +205 -0
- package/templates/tests/fixture.ts +91 -11
- package/templates/tests/guard.spec.ts +9 -3
- package/templates/tests/lifecycle/deploy-smoke.spec.ts +8 -6
- package/templates/tests/lifecycle/installer.spec.ts +76 -0
- package/templates/tests/lifecycle/trust.spec.ts +1 -1
- package/templates/tests/query-utils.spec.ts +12 -9
- package/templates/tests/rbac-guard.spec.ts +18 -1
- package/templates/tests/tenant-access-cache.spec.ts +99 -0
- package/templates/tests/tenant-forgery.spec.ts +152 -0
- package/templates/tests/tenant-isolation.spec.ts +133 -0
- package/templates/tests/username-namespace.spec.ts +118 -0
- package/templates/tests/wecom-admin.spec.ts +386 -0
- package/templates/tests/wecom-connector.spec.ts +671 -0
- package/templates/tests/wecom-crypt.spec.ts +155 -0
- package/templates/tests/wecom-endpoints.spec.ts +457 -0
- package/templates/tests/wecom-landing.spec.ts +54 -0
- package/vendor/host-versions.json +3 -3
package/templates/openapi.json
CHANGED
|
@@ -882,6 +882,325 @@
|
|
|
882
882
|
}
|
|
883
883
|
}
|
|
884
884
|
},
|
|
885
|
+
"/api/auth/wecom/admin/apps": {
|
|
886
|
+
"get": {
|
|
887
|
+
"operationId": "auth_wecom_admin_apps_get",
|
|
888
|
+
"summary": "GET api/auth/wecom/admin/apps",
|
|
889
|
+
"x-oj-file": "auth/wecom/admin/api.ts",
|
|
890
|
+
"tags": [
|
|
891
|
+
"auth"
|
|
892
|
+
],
|
|
893
|
+
"responses": {
|
|
894
|
+
"200": {
|
|
895
|
+
"description": "OK",
|
|
896
|
+
"x-oj-response-enforced": false,
|
|
897
|
+
"content": {
|
|
898
|
+
"application/json": {
|
|
899
|
+
"schema": {
|
|
900
|
+
"type": "object",
|
|
901
|
+
"required": [
|
|
902
|
+
"code",
|
|
903
|
+
"msg",
|
|
904
|
+
"data"
|
|
905
|
+
],
|
|
906
|
+
"properties": {
|
|
907
|
+
"code": {
|
|
908
|
+
"type": "integer"
|
|
909
|
+
},
|
|
910
|
+
"msg": {
|
|
911
|
+
"type": "string"
|
|
912
|
+
},
|
|
913
|
+
"data": {
|
|
914
|
+
"type": "array",
|
|
915
|
+
"items": {
|
|
916
|
+
"type": "object",
|
|
917
|
+
"required": [
|
|
918
|
+
"suite_id",
|
|
919
|
+
"corp_id",
|
|
920
|
+
"agent_id",
|
|
921
|
+
"auth_corp_id",
|
|
922
|
+
"env",
|
|
923
|
+
"status",
|
|
924
|
+
"installed_at",
|
|
925
|
+
"update_time"
|
|
926
|
+
],
|
|
927
|
+
"properties": {
|
|
928
|
+
"suite_id": {
|
|
929
|
+
"type": "string"
|
|
930
|
+
},
|
|
931
|
+
"corp_id": {
|
|
932
|
+
"type": "string"
|
|
933
|
+
},
|
|
934
|
+
"agent_id": {
|
|
935
|
+
"type": "string"
|
|
936
|
+
},
|
|
937
|
+
"auth_corp_id": {
|
|
938
|
+
"type": "string"
|
|
939
|
+
},
|
|
940
|
+
"env": {
|
|
941
|
+
"type": "string"
|
|
942
|
+
},
|
|
943
|
+
"corp_name": {
|
|
944
|
+
"type": [
|
|
945
|
+
"string",
|
|
946
|
+
"null"
|
|
947
|
+
]
|
|
948
|
+
},
|
|
949
|
+
"status": {
|
|
950
|
+
"type": "string",
|
|
951
|
+
"enum": [
|
|
952
|
+
"active",
|
|
953
|
+
"disabled"
|
|
954
|
+
]
|
|
955
|
+
},
|
|
956
|
+
"installed_at": {
|
|
957
|
+
"type": "number"
|
|
958
|
+
},
|
|
959
|
+
"update_time": {
|
|
960
|
+
"type": "number"
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
},
|
|
973
|
+
"/api/auth/wecom/admin/status": {
|
|
974
|
+
"post": {
|
|
975
|
+
"operationId": "auth_wecom_admin_status_post",
|
|
976
|
+
"summary": "POST api/auth/wecom/admin/status",
|
|
977
|
+
"x-oj-file": "auth/wecom/admin/api.ts",
|
|
978
|
+
"tags": [
|
|
979
|
+
"auth"
|
|
980
|
+
],
|
|
981
|
+
"requestBody": {
|
|
982
|
+
"required": true,
|
|
983
|
+
"content": {
|
|
984
|
+
"application/json": {
|
|
985
|
+
"schema": {
|
|
986
|
+
"type": "object",
|
|
987
|
+
"required": [
|
|
988
|
+
"corpId",
|
|
989
|
+
"agentId",
|
|
990
|
+
"status"
|
|
991
|
+
],
|
|
992
|
+
"properties": {
|
|
993
|
+
"corpId": {
|
|
994
|
+
"type": "string",
|
|
995
|
+
"minLength": 1
|
|
996
|
+
},
|
|
997
|
+
"agentId": {
|
|
998
|
+
"type": "string",
|
|
999
|
+
"minLength": 1
|
|
1000
|
+
},
|
|
1001
|
+
"status": {
|
|
1002
|
+
"type": "string",
|
|
1003
|
+
"enum": [
|
|
1004
|
+
"active",
|
|
1005
|
+
"disabled"
|
|
1006
|
+
]
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
},
|
|
1013
|
+
"responses": {
|
|
1014
|
+
"200": {
|
|
1015
|
+
"description": "OK",
|
|
1016
|
+
"x-oj-response-enforced": false,
|
|
1017
|
+
"content": {
|
|
1018
|
+
"application/json": {
|
|
1019
|
+
"schema": {
|
|
1020
|
+
"type": "object",
|
|
1021
|
+
"required": [
|
|
1022
|
+
"code",
|
|
1023
|
+
"msg",
|
|
1024
|
+
"data"
|
|
1025
|
+
],
|
|
1026
|
+
"properties": {
|
|
1027
|
+
"code": {
|
|
1028
|
+
"type": "integer"
|
|
1029
|
+
},
|
|
1030
|
+
"msg": {
|
|
1031
|
+
"type": "string"
|
|
1032
|
+
},
|
|
1033
|
+
"data": {
|
|
1034
|
+
"type": "object",
|
|
1035
|
+
"required": [
|
|
1036
|
+
"updated",
|
|
1037
|
+
"revokedSessions"
|
|
1038
|
+
],
|
|
1039
|
+
"properties": {
|
|
1040
|
+
"updated": {
|
|
1041
|
+
"type": "number"
|
|
1042
|
+
},
|
|
1043
|
+
"revokedSessions": {
|
|
1044
|
+
"type": "number"
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
},
|
|
1056
|
+
"/api/auth/wecom/admin/install": {
|
|
1057
|
+
"get": {
|
|
1058
|
+
"operationId": "auth_wecom_admin_install_get",
|
|
1059
|
+
"summary": "GET api/auth/wecom/admin/install",
|
|
1060
|
+
"x-oj-file": "auth/wecom/admin/api.ts",
|
|
1061
|
+
"tags": [
|
|
1062
|
+
"auth"
|
|
1063
|
+
],
|
|
1064
|
+
"responses": {
|
|
1065
|
+
"200": {
|
|
1066
|
+
"description": "OK",
|
|
1067
|
+
"x-oj-response-enforced": false,
|
|
1068
|
+
"content": {
|
|
1069
|
+
"application/json": {
|
|
1070
|
+
"schema": {
|
|
1071
|
+
"type": "object",
|
|
1072
|
+
"required": [
|
|
1073
|
+
"code",
|
|
1074
|
+
"msg",
|
|
1075
|
+
"data"
|
|
1076
|
+
],
|
|
1077
|
+
"properties": {
|
|
1078
|
+
"code": {
|
|
1079
|
+
"type": "integer"
|
|
1080
|
+
},
|
|
1081
|
+
"msg": {
|
|
1082
|
+
"type": "string"
|
|
1083
|
+
},
|
|
1084
|
+
"data": {
|
|
1085
|
+
"type": "object",
|
|
1086
|
+
"required": [
|
|
1087
|
+
"installUrl",
|
|
1088
|
+
"callbackUrl",
|
|
1089
|
+
"expiresIn"
|
|
1090
|
+
],
|
|
1091
|
+
"properties": {
|
|
1092
|
+
"installUrl": {
|
|
1093
|
+
"type": "string"
|
|
1094
|
+
},
|
|
1095
|
+
"callbackUrl": {
|
|
1096
|
+
"type": "string"
|
|
1097
|
+
},
|
|
1098
|
+
"expiresIn": {
|
|
1099
|
+
"type": "number"
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
},
|
|
1111
|
+
"/api/auth/wecom/login": {
|
|
1112
|
+
"get": {
|
|
1113
|
+
"operationId": "auth_wecom_login_get",
|
|
1114
|
+
"summary": "GET api/auth/wecom/login",
|
|
1115
|
+
"x-oj-file": "auth/wecom/api.ts",
|
|
1116
|
+
"tags": [
|
|
1117
|
+
"auth"
|
|
1118
|
+
],
|
|
1119
|
+
"responses": {
|
|
1120
|
+
"200": {
|
|
1121
|
+
"description": "OK"
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
},
|
|
1126
|
+
"/api/auth/wecom/callback": {
|
|
1127
|
+
"get": {
|
|
1128
|
+
"operationId": "auth_wecom_callback_get",
|
|
1129
|
+
"summary": "GET api/auth/wecom/callback",
|
|
1130
|
+
"x-oj-file": "auth/wecom/api.ts",
|
|
1131
|
+
"tags": [
|
|
1132
|
+
"auth"
|
|
1133
|
+
],
|
|
1134
|
+
"responses": {
|
|
1135
|
+
"200": {
|
|
1136
|
+
"description": "OK"
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
},
|
|
1141
|
+
"/api/auth/wecom/ticket": {
|
|
1142
|
+
"post": {
|
|
1143
|
+
"operationId": "auth_wecom_ticket_post",
|
|
1144
|
+
"summary": "POST api/auth/wecom/ticket",
|
|
1145
|
+
"x-oj-file": "auth/wecom/api.ts",
|
|
1146
|
+
"tags": [
|
|
1147
|
+
"auth"
|
|
1148
|
+
],
|
|
1149
|
+
"requestBody": {
|
|
1150
|
+
"required": true,
|
|
1151
|
+
"content": {
|
|
1152
|
+
"application/json": {
|
|
1153
|
+
"schema": {
|
|
1154
|
+
"type": "object",
|
|
1155
|
+
"required": [
|
|
1156
|
+
"ticket"
|
|
1157
|
+
],
|
|
1158
|
+
"properties": {
|
|
1159
|
+
"ticket": {
|
|
1160
|
+
"type": "string",
|
|
1161
|
+
"minLength": 32,
|
|
1162
|
+
"maxLength": 128
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
},
|
|
1169
|
+
"responses": {
|
|
1170
|
+
"200": {
|
|
1171
|
+
"description": "OK"
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
},
|
|
1176
|
+
"/api/auth/wecom/event": {
|
|
1177
|
+
"get": {
|
|
1178
|
+
"operationId": "auth_wecom_event_get",
|
|
1179
|
+
"summary": "GET api/auth/wecom/event",
|
|
1180
|
+
"x-oj-file": "auth/wecom/api.ts",
|
|
1181
|
+
"tags": [
|
|
1182
|
+
"auth"
|
|
1183
|
+
],
|
|
1184
|
+
"responses": {
|
|
1185
|
+
"200": {
|
|
1186
|
+
"description": "OK"
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
},
|
|
1190
|
+
"post": {
|
|
1191
|
+
"operationId": "auth_wecom_event_post",
|
|
1192
|
+
"summary": "POST api/auth/wecom/event",
|
|
1193
|
+
"x-oj-file": "auth/wecom/api.ts",
|
|
1194
|
+
"tags": [
|
|
1195
|
+
"auth"
|
|
1196
|
+
],
|
|
1197
|
+
"responses": {
|
|
1198
|
+
"200": {
|
|
1199
|
+
"description": "OK"
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
},
|
|
885
1204
|
"/api/demo/todos": {
|
|
886
1205
|
"get": {
|
|
887
1206
|
"operationId": "demo_todos_get",
|
|
@@ -8,8 +8,12 @@ import type { InstallContext } from "./preflight";
|
|
|
8
8
|
import { fsFace } from "../atomic";
|
|
9
9
|
import { MAX_PACKAGE_BYTES } from "../limits";
|
|
10
10
|
|
|
11
|
-
/**
|
|
12
|
-
|
|
11
|
+
/**
|
|
12
|
+
* 最小 fetch 面(结构化:Node fetch/Response 天然满足;测试注入 stub 不真跑网络)。
|
|
13
|
+
* init 形参**可选**(cache/头/重定向按需):少参实现仍可赋给本形,故既有 stub 不受影响;
|
|
14
|
+
* 探活 Probe 靠它带上租户头(见 steps/smoke.ts)。
|
|
15
|
+
*/
|
|
16
|
+
export type FetchFace = (url: string, init?: { headers?: Record<string, string>, redirect?: string }) => Promise<{
|
|
13
17
|
ok: boolean
|
|
14
18
|
status: number
|
|
15
19
|
arrayBuffer: () => Promise<ArrayBuffer>
|
|
@@ -23,7 +23,7 @@ export interface SmokeContext extends InstallContext {
|
|
|
23
23
|
/** API 前缀(C2-13 api.prefix,缺省 /api/<name>;探针 URL 前缀,注入不硬编码) */
|
|
24
24
|
apiPrefix: string
|
|
25
25
|
/** 注入式探针 fetch 面(生产 = isolate 全局 fetch 同源调用;测试 stub 不真发请求) */
|
|
26
|
-
fetch: (url: string) => Promise<{ ok: boolean, status: number }>
|
|
26
|
+
fetch: (url: string, init?: { headers?: Record<string, string> }) => Promise<{ ok: boolean, status: number }>
|
|
27
27
|
/** 注入式 sha384 摘要面(base64 裸摘要,不带 sha384- 前缀;同 verify.ts DIP) */
|
|
28
28
|
sha384B64: (data: Uint8Array) => string
|
|
29
29
|
/** 站点 modules.json 路径(integrity 比对真源 + 落位文件定位基准:<dist>/modules.json) */
|
|
@@ -55,6 +55,15 @@ function isObj(v: unknown): v is Record<string, unknown> {
|
|
|
55
55
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* 探活请求必须自报租户(企微代开发多租户 v3 Phase 0):宿主 `tenant.enable: true` 时 oj 对每个
|
|
60
|
+
* 请求强制要 `X-TENANT-ID`——**匿名健康端点也不豁免**,缺即 400(实测 curl 无头 400 / 带 default 200)。
|
|
61
|
+
* 浏览器由 runtime 请求层注入;安装器是进程内自探,没人替它注入,只能自己带上,否则任何
|
|
62
|
+
* 装到「带 health 的方案」上的站点都会卡在 smoke 段(`install/upgrade/reinstall` 全线 partial)。
|
|
63
|
+
*/
|
|
64
|
+
const PROBE_TENANT_HEADER = "X-TENANT-ID";
|
|
65
|
+
const PROBE_TENANT = "default";
|
|
66
|
+
|
|
58
67
|
/** 探针 URL 形态:<apiPrefix>/<path 去前导斜杠>(apiPrefix 尾斜杠归一;C2-26 段内收敛同构) */
|
|
59
68
|
export function probeUrlOf(apiPrefix: string, path: string): string {
|
|
60
69
|
return `${apiPrefix.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
|
|
@@ -128,16 +137,15 @@ export async function smoke(ctx: SmokeContext): Promise<StageResult> {
|
|
|
128
137
|
const target = health ?? firstNonAnonymousRoute(m);
|
|
129
138
|
if (target !== undefined) {
|
|
130
139
|
const url = probeUrlOf(ctx.apiPrefix, target);
|
|
131
|
-
const res = await ctx.fetch(url);
|
|
140
|
+
const res = await ctx.fetch(url, { headers: { [PROBE_TENANT_HEADER]: PROBE_TENANT } });
|
|
132
141
|
if (health !== undefined && !res.ok) {
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
const deferred = res.status === 401 && pending;
|
|
142
|
+
// health 探针本就是「无凭证预期 401」(文件头注释:非匿名无凭证预期 401;D14/Q-A:health 不强制)。
|
|
143
|
+
// 租户化站点上安装/重装/升级后宿主需重启才能加载新配置(含匿名接线、路由),探针在重启前收 401
|
|
144
|
+
// 是 restart_pending 的常态,不是包或站点的问题——一律判 deferred,不计入失败(P2 深检可兜底真异常)。
|
|
145
|
+
// 非 401(如 500)仍视为真实失败,正常报错。
|
|
146
|
+
const deferred = res.status === 401;
|
|
139
147
|
if (deferred) {
|
|
140
|
-
notes.push(`探活 ${url} 返回 401
|
|
148
|
+
notes.push(`探活 ${url} 返回 401:health 端点为无凭证探针,宿主重启加载新配置(路由/匿名接线)后即 2xx——属 restart_pending,重启宿主后生效,不计入失败`);
|
|
141
149
|
}
|
|
142
150
|
else {
|
|
143
151
|
failures.push({ item: "探活", retryable: true, reason: `health 端点 ${url} 返回 HTTP ${res.status}(C2-33 冒烟探针要求 2xx)` });
|
|
@@ -3,16 +3,21 @@
|
|
|
3
3
|
// 但 oj 运行时对未捕获异常一律落 500 信封(不读 err.status)——
|
|
4
4
|
// 所有 handler 的业务体必须经 respond 收口成 json.fail(status, msg, extra),
|
|
5
5
|
// 否则 D14 统一 401 / B5 429 retryAfterMs / 403 must_change / 409 holders 全被抹成 500。
|
|
6
|
+
/** 异常 → fail 信封。redirect / 流式等「不能先 ok 再 fail」的出口也直接复用它。 */
|
|
7
|
+
export function failWith(e: unknown): void {
|
|
8
|
+
const err = e as Error & { status?: number } & Record<string, unknown>;
|
|
9
|
+
const data = Object.fromEntries(Object.entries(err).filter(([k]) => k !== "status" && k !== "message"));
|
|
10
|
+
if (Object.keys(data).length > 0)
|
|
11
|
+
json.fail(err.status ?? 500, err.message, data);
|
|
12
|
+
else
|
|
13
|
+
json.fail(err.status ?? 500, err.message);
|
|
14
|
+
}
|
|
15
|
+
|
|
6
16
|
export async function respond<T>(fn: () => Promise<T>): Promise<void> {
|
|
7
17
|
try {
|
|
8
18
|
json.ok(await fn());
|
|
9
19
|
}
|
|
10
20
|
catch (e) {
|
|
11
|
-
|
|
12
|
-
const data = Object.fromEntries(Object.entries(err).filter(([k]) => k !== "status" && k !== "message"));
|
|
13
|
-
if (Object.keys(data).length > 0)
|
|
14
|
-
json.fail(err.status ?? 500, err.message, data);
|
|
15
|
-
else
|
|
16
|
-
json.fail(err.status ?? 500, err.message);
|
|
21
|
+
failWith(e);
|
|
17
22
|
}
|
|
18
23
|
}
|
|
@@ -68,9 +68,11 @@ export async function updateSetting(actorId: number, key: string, value: string)
|
|
|
68
68
|
await recordOp({ action: "setting.update", module: String(row.module), target: key });
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
/** C5/D2:登录方式发现的数据源——local 恒在,ldap/sso 由设置项开关。 */
|
|
71
|
+
/** C5/D2:登录方式发现的数据源——local 恒在,ldap/sso/wecom 由设置项开关。 */
|
|
72
72
|
export async function listLoginMethods(): Promise<{ methods: string[] }> {
|
|
73
|
-
const rows = await db.table("sys_setting").select(["key", "value"]).where({ field: "key", op: "in", value: ["ldap.enabled", "sso.enabled"] }).all() as Array<{ key: string, value: string | null }>;
|
|
73
|
+
const rows = await db.table("sys_setting").select(["key", "value"]).where({ field: "key", op: "in", value: ["ldap.enabled", "sso.enabled", "wecom.enabled"] }).all() as Array<{ key: string, value: string | null }>;
|
|
74
74
|
const enabled = (k: string) => rows.find(r => r.key === k)?.value === "1";
|
|
75
|
-
|
|
75
|
+
// wecom 的凭据在部署常量(config vars),这里只报开关:开关开了但凭据没配,
|
|
76
|
+
// 点进企微登录会拿到 400「企业微信未配置」——比登录页凭空少一个 tab 更容易排查。
|
|
77
|
+
return { methods: ["local", ...(enabled("ldap.enabled") ? ["ldap" as const] : []), ...(enabled("sso.enabled") ? ["sso" as const] : []), ...(enabled("wecom.enabled") ? ["wecom" as const] : [])] };
|
|
76
78
|
}
|
|
@@ -24,8 +24,14 @@ function fail(status: number, message: string): never {
|
|
|
24
24
|
throw Object.assign(new Error(message), { status });
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
27
|
+
/**
|
|
28
|
+
* 外来号命名空间抢占红线(B6):本地建号拒收 ldap:/sso:/wecom: 前缀用户名。
|
|
29
|
+
*
|
|
30
|
+
* 导出给「改名」两侧共用:只在建号处拦是不够的——自助改名或管理端改名若能把任意一个
|
|
31
|
+
* 本地账号改成一个尚未建档的外部号(如 `wecom:boss`),该外部成员首次登录时
|
|
32
|
+
* `ensureWecomUser` 按用户名查到的是**别人那一行**,于是签发别人的 uid。
|
|
33
|
+
*/
|
|
34
|
+
export const RESERVED_USERNAME_PREFIX = /^(?:ldap|sso|wecom):/i;
|
|
29
35
|
|
|
30
36
|
async function rolesOf(uid: number): Promise<string[]> {
|
|
31
37
|
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 }>;
|
|
@@ -73,7 +79,7 @@ export async function createUser(input: { username: string, password: string, di
|
|
|
73
79
|
const username = String(input.username ?? "").trim();
|
|
74
80
|
if (!username)
|
|
75
81
|
fail(400, "username required");
|
|
76
|
-
if (
|
|
82
|
+
if (RESERVED_USERNAME_PREFIX.test(username))
|
|
77
83
|
fail(400, "username prefix reserved for external identity sources");
|
|
78
84
|
if (!input.password || String(input.password).length < 6)
|
|
79
85
|
fail(400, "password too short (min 6)");
|
|
@@ -9,6 +9,15 @@ import { decideAccess } from "./guard";
|
|
|
9
9
|
const TTL_MS = 10_000;
|
|
10
10
|
|
|
11
11
|
interface AccessSnapshot {
|
|
12
|
+
/**
|
|
13
|
+
* token 的 sub 在本租户里是否存在。
|
|
14
|
+
*
|
|
15
|
+
* 这是「伪造 X-TENANT-ID」的唯一服务端拦截点(R6 `require_signed_claim` 落地前的兜底):
|
|
16
|
+
* token 只带 sub、不带租户,而 `users` 已租户化(构造器自动注入 `tenant_id = 当前头值`),
|
|
17
|
+
* 于是「A 企业的人带 B 企业的头」表现为**查无此行**。若这里放行,只要求登录态的端点
|
|
18
|
+
* (通知已读、个人资料等)就会带着伪造的租户头去读写别家数据。
|
|
19
|
+
*/
|
|
20
|
+
exists: boolean
|
|
12
21
|
roles: string[]
|
|
13
22
|
bindings: Record<string, string[]>
|
|
14
23
|
forbidden: boolean
|
|
@@ -17,7 +26,21 @@ interface AccessSnapshot {
|
|
|
17
26
|
at: number
|
|
18
27
|
}
|
|
19
28
|
|
|
20
|
-
|
|
29
|
+
/**
|
|
30
|
+
* `uid → 快照` 的键必须带上租户(2026-10 多租户)。
|
|
31
|
+
*
|
|
32
|
+
* 反例:只按 uid 缓存时,「A 企业的张三」判权后在同一进程里走了「B 企业的张三」的请求——
|
|
33
|
+
* 后者的 `users` 行按 B 租户查不到(`tenant.enable` 下构造器自动注入谓词),本应 403,
|
|
34
|
+
* 但缓存里还留着 A 租户那张 `exists: true` 的快照,于是放行。进程不跨请求复用时看不到,
|
|
35
|
+
* 一旦复用(或在本工程的测试宿主里)就是静默的跨租户放行。
|
|
36
|
+
*/
|
|
37
|
+
const cache = new Map<string, AccessSnapshot>();
|
|
38
|
+
|
|
39
|
+
/** 当前请求的租户(`X-TENANT-ID` 落到 http.tenantId,与 oj 同源);无上下文时按空串 */
|
|
40
|
+
function requestTenant(): string {
|
|
41
|
+
const t = (http as unknown as { tenantId?: unknown }).tenantId;
|
|
42
|
+
return typeof t === "string" ? t.trim() : "";
|
|
43
|
+
}
|
|
21
44
|
|
|
22
45
|
async function loadSnapshot(userId: number): Promise<AccessSnapshot> {
|
|
23
46
|
const userRows = await db.table("users").select(["is_forbidden", "must_change"]).where({ field: "id", op: "eq", value: userId }).all();
|
|
@@ -31,6 +54,7 @@ async function loadSnapshot(userId: number): Promise<AccessSnapshot> {
|
|
|
31
54
|
for (const r of bindRows as { role_name: string, permission_code: string }[])
|
|
32
55
|
(bindings[r.role_name] ??= []).push(r.permission_code);
|
|
33
56
|
return {
|
|
57
|
+
exists: userRows.length > 0,
|
|
34
58
|
roles,
|
|
35
59
|
bindings,
|
|
36
60
|
forbidden: Number((userRows[0] || {}).is_forbidden) === 1,
|
|
@@ -40,17 +64,23 @@ async function loadSnapshot(userId: number): Promise<AccessSnapshot> {
|
|
|
40
64
|
}
|
|
41
65
|
|
|
42
66
|
async function snapshot(userId: number): Promise<AccessSnapshot> {
|
|
43
|
-
const
|
|
67
|
+
const key = `${requestTenant()}|${userId}`;
|
|
68
|
+
const hit = cache.get(key);
|
|
44
69
|
if (hit && Date.now() - hit.at < TTL_MS)
|
|
45
70
|
return hit;
|
|
46
71
|
const snap = await loadSnapshot(userId);
|
|
47
|
-
cache.set(
|
|
72
|
+
cache.set(key, snap);
|
|
48
73
|
return snap;
|
|
49
74
|
}
|
|
50
75
|
|
|
51
76
|
/** 写路径(角色分配/绑定调整/紧急吊销/禁用)同步调用:撤销下一次请求即生效。 */
|
|
52
77
|
export function invalidateUser(userId: number): void {
|
|
53
|
-
cache
|
|
78
|
+
// 同一 uid 在各租户下的快照都要失效(见 cache 注释):漏掉 B 租户那张,
|
|
79
|
+
// 撤销在另一个租户里就还可能晚一个 TTL 才生效。
|
|
80
|
+
for (const key of cache.keys()) {
|
|
81
|
+
if (key.endsWith(`|${userId}`))
|
|
82
|
+
cache.delete(key);
|
|
83
|
+
}
|
|
54
84
|
}
|
|
55
85
|
|
|
56
86
|
export function invalidateAllUsers(): void {
|
|
@@ -67,6 +97,10 @@ export async function currentAccess(): Promise<AccessSnapshot> {
|
|
|
67
97
|
if (uid == null)
|
|
68
98
|
deny();
|
|
69
99
|
const snap = await snapshot(Number(uid));
|
|
100
|
+
// 伪造租户头:token 的 sub 在**当前租户**里查无此人(见 AccessSnapshot.exists 注释)。
|
|
101
|
+
// 与 403 forbidden 同一句话,不给探测者区分「人不存在」与「没权限」的材料。
|
|
102
|
+
if (!snap.exists)
|
|
103
|
+
deny();
|
|
70
104
|
if (snap.forbidden)
|
|
71
105
|
deny();
|
|
72
106
|
if (snap.mustChange)
|
|
@@ -81,6 +115,27 @@ export async function assertPermission(code: string): Promise<void> {
|
|
|
81
115
|
deny();
|
|
82
116
|
}
|
|
83
117
|
|
|
118
|
+
/**
|
|
119
|
+
* 租户自洽(§5.3 第四层):token 的 sub 是否存在于**当前租户**。
|
|
120
|
+
* 唯一现实成因是伪造 `X-TENANT-ID`——`users` 已租户化,构造器按头值注入,越权即「查无此行」。
|
|
121
|
+
*
|
|
122
|
+
* 为什么单独一个出口:那些「登录即可、不挂权限点」的端点(人员目录、通知、我的资料)
|
|
123
|
+
* 不走 `currentAccess()`(它还会判 must_change/forbidden),但它们同样读租户数据——
|
|
124
|
+
* 少了这一步,伪造头的人能读到别家企业的广播通知与人员目录。
|
|
125
|
+
*/
|
|
126
|
+
export async function isSelfTenant(): Promise<boolean> {
|
|
127
|
+
const uid = http.user?.id;
|
|
128
|
+
if (uid == null)
|
|
129
|
+
return false;
|
|
130
|
+
return (await snapshot(Number(uid))).exists;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** `isSelfTenant` 的断言形态(respond 风格端点用;json.fail 风格的端点直接判 `isSelfTenant()`) */
|
|
134
|
+
export async function assertTenantSelf(): Promise<void> {
|
|
135
|
+
if (!(await isSelfTenant()))
|
|
136
|
+
deny();
|
|
137
|
+
}
|
|
138
|
+
|
|
84
139
|
/** 系统管理员及以上(superadmin 或 admin)。 */
|
|
85
140
|
export async function assertSystemAdmin(): Promise<void> {
|
|
86
141
|
const { roles } = await currentAccess();
|
|
@@ -24,7 +24,11 @@ function pageArgs(input: { page?: unknown, size?: unknown }): { page: number, si
|
|
|
24
24
|
return { page, size, offset: (page - 1) * size };
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* 双查询分页:listSql/countSql 共用 params(调用方同一 WHERE),limit/offset 参数化(三方言安全)。
|
|
29
|
+
* 多租户红线:本函数是裸 SQL 直通,`tenant.enable` + `sql_guard: deny` 下两条 SQL 都必须显式写
|
|
30
|
+
* `tenant_id` 且 params 里带上当前租户值,否则被守卫拒;新代码优先用下方 `pagedFactory`。
|
|
31
|
+
*/
|
|
28
32
|
export async function paged<T>(
|
|
29
33
|
listSql: string,
|
|
30
34
|
countSql: string,
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { respond } from "../_shared/respond";
|
|
2
1
|
// 平台共享服务域 /api/_platform/contacts(D207:登录即可,不挂权限点,H12)。
|
|
3
2
|
// 读操作不记审计(设计 §4B);接口级限速(设计 line 199)经 rate.ts(B5 复用同源)。
|
|
4
3
|
// 单条详情走子组 /api/_platform/contacts/detail?id=(detail/api.ts,含 email/avatar)。
|
|
4
|
+
import { respond } from "../_shared/respond";
|
|
5
5
|
import { searchContacts } from "../_shared/service/domain/contacts";
|
|
6
|
+
import { assertTenantSelf } from "../_shared/service/infra/access";
|
|
6
7
|
import { rateLimit } from "../_shared/service/infra/rate";
|
|
7
8
|
|
|
8
9
|
export default {
|
|
@@ -14,6 +15,8 @@ export default {
|
|
|
14
15
|
json.fail(401, "unauthorized");
|
|
15
16
|
return;
|
|
16
17
|
}
|
|
18
|
+
// 目录是租户数据:伪造 X-TENANT-ID 时必须 403,否则能列出别家企业的人
|
|
19
|
+
await assertTenantSelf();
|
|
17
20
|
await rateLimit(`contacts:${uid}`, 60, 60_000);
|
|
18
21
|
const q = http.query as Record<string, unknown>;
|
|
19
22
|
return searchContacts(q.keyword, { limit: q.limit });
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { respond } from "../../_shared/respond";
|
|
2
2
|
// GET /api/_platform/contacts/detail?id= —— 单条详情(含 email/avatar,设计 §4B)。
|
|
3
3
|
import { contactDetail } from "../../_shared/service/domain/contacts";
|
|
4
|
+
import { assertTenantSelf } from "../../_shared/service/infra/access";
|
|
4
5
|
import { rateLimit } from "../../_shared/service/infra/rate";
|
|
5
6
|
|
|
6
7
|
export default {
|
|
@@ -11,6 +12,8 @@ export default {
|
|
|
11
12
|
json.fail(401, "unauthorized");
|
|
12
13
|
return;
|
|
13
14
|
}
|
|
15
|
+
// 详情跨人可读,故与目录同一条租户自洽约束(伪造头 → 403,不靠「查无此行」静默 404)
|
|
16
|
+
await assertTenantSelf();
|
|
14
17
|
await rateLimit(`contacts:${uid}`, 60, 60_000);
|
|
15
18
|
return contactDetail(Number((http.query as Record<string, unknown>).id));
|
|
16
19
|
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- 0015__tenant(mysql 方言):多租户地基(企微代开发 v3 Phase 0)
|
|
2
|
+
-- 表定性同 0015__tenant.sql 头注释(A 组 11 张租户化 / B 组 10 张共享)。
|
|
3
|
+
-- 差异:TEXT 列不能带字面默认值也不能直接做唯一键 → 租户列用 VARCHAR(255)(与 users.username
|
|
4
|
+
-- 的既有 mysql 写法一致,schema.yaml 仍声明 text,reconcile 按 text↔varchar 同源处理)。
|
|
5
|
+
ALTER TABLE users ADD COLUMN tenant_id VARCHAR(255) NOT NULL DEFAULT 'default';
|
|
6
|
+
ALTER TABLE users ADD COLUMN corp_id VARCHAR(255) NULL;
|
|
7
|
+
ALTER TABLE users DROP INDEX username;
|
|
8
|
+
ALTER TABLE users ADD UNIQUE KEY uk_users_tenant_username (tenant_id, username);
|
|
9
|
+
|
|
10
|
+
ALTER TABLE sys_oplog ADD COLUMN tenant_id VARCHAR(255) NOT NULL DEFAULT 'default';
|
|
11
|
+
ALTER TABLE sys_user_role ADD COLUMN tenant_id VARCHAR(255) NOT NULL DEFAULT 'default';
|
|
12
|
+
ALTER TABLE sys_refresh_token ADD COLUMN tenant_id VARCHAR(255) NOT NULL DEFAULT 'default';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- 0015__tenant(postgres 方言):多租户地基(企微代开发 v3 Phase 0)
|
|
2
|
+
-- 表定性同 0015__tenant.sql 头注释(A 组 11 张租户化 / B 组 10 张共享)。
|
|
3
|
+
-- pg 可直接丢弃列级 UNIQUE 约束后再建复合唯一键,不需要重建表。
|
|
4
|
+
ALTER TABLE users ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default';
|
|
5
|
+
ALTER TABLE users ADD COLUMN corp_id TEXT;
|
|
6
|
+
ALTER TABLE users DROP CONSTRAINT users_username_key;
|
|
7
|
+
ALTER TABLE users ADD CONSTRAINT users_tenant_username_key UNIQUE (tenant_id, username);
|
|
8
|
+
|
|
9
|
+
ALTER TABLE sys_oplog ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default';
|
|
10
|
+
ALTER TABLE sys_user_role ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default';
|
|
11
|
+
ALTER TABLE sys_refresh_token ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default';
|