@southwind-ai/config 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.ts +4 -0
- package/package.json +24 -0
- package/src/definition.ts +109 -0
- package/src/environment-loader.ts +240 -0
- package/src/platform.ts +185 -0
- package/src/secrets.ts +69 -0
package/index.ts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@southwind-ai/config",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "UNLICENSED",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"bun": ">=1.3.0"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.ts",
|
|
11
|
+
"src/**/*.ts",
|
|
12
|
+
"!src/**/*.test.ts"
|
|
13
|
+
],
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@southwind-ai/shared": "workspace:*",
|
|
16
|
+
"zod": "^4.4.3"
|
|
17
|
+
},
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./index.ts"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { ZodType } from "zod";
|
|
2
|
+
import type { SecretProviderKey } from "./secrets.js";
|
|
3
|
+
|
|
4
|
+
export type EnvironmentValueDecoder = (value: string) => unknown;
|
|
5
|
+
|
|
6
|
+
export interface ModuleConfigEnvironmentField {
|
|
7
|
+
key: string;
|
|
8
|
+
path: string;
|
|
9
|
+
defaultValue?: unknown;
|
|
10
|
+
decode?: EnvironmentValueDecoder;
|
|
11
|
+
secret?: {
|
|
12
|
+
provider: SecretProviderKey;
|
|
13
|
+
required?: boolean;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ModuleConfigDefinition<T> {
|
|
18
|
+
key: string;
|
|
19
|
+
label: string;
|
|
20
|
+
schema: ZodType<T>;
|
|
21
|
+
environment: readonly ModuleConfigEnvironmentField[];
|
|
22
|
+
ownedEnvironmentPrefixes?: readonly string[];
|
|
23
|
+
ownedEnvironmentKeys?: readonly string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ModuleConfigDescriptor {
|
|
27
|
+
key: string;
|
|
28
|
+
label: string;
|
|
29
|
+
fields: Array<{
|
|
30
|
+
path: string;
|
|
31
|
+
secret: boolean;
|
|
32
|
+
required: boolean;
|
|
33
|
+
}>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function defineModuleConfig<T>(
|
|
37
|
+
definition: ModuleConfigDefinition<T>,
|
|
38
|
+
): ModuleConfigDefinition<T> {
|
|
39
|
+
validateDefinition(definition);
|
|
40
|
+
return Object.freeze({
|
|
41
|
+
...definition,
|
|
42
|
+
environment: Object.freeze([...definition.environment]),
|
|
43
|
+
ownedEnvironmentPrefixes: Object.freeze([
|
|
44
|
+
...(definition.ownedEnvironmentPrefixes || []),
|
|
45
|
+
]),
|
|
46
|
+
ownedEnvironmentKeys: Object.freeze([
|
|
47
|
+
...(definition.ownedEnvironmentKeys || []),
|
|
48
|
+
]),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class ModuleConfigRegistry {
|
|
53
|
+
private readonly definitions = new Map<
|
|
54
|
+
string,
|
|
55
|
+
ModuleConfigDefinition<unknown>
|
|
56
|
+
>();
|
|
57
|
+
|
|
58
|
+
register<T>(definition: ModuleConfigDefinition<T>): void {
|
|
59
|
+
validateDefinition(definition);
|
|
60
|
+
if (this.definitions.has(definition.key)) {
|
|
61
|
+
throw new Error(`配置模块重复注册:${definition.key}`);
|
|
62
|
+
}
|
|
63
|
+
this.definitions.set(
|
|
64
|
+
definition.key,
|
|
65
|
+
definition as ModuleConfigDefinition<unknown>,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get<T>(key: string): ModuleConfigDefinition<T> {
|
|
70
|
+
const definition = this.definitions.get(key);
|
|
71
|
+
if (!definition) throw new Error(`未知配置模块:${key}`);
|
|
72
|
+
return definition as ModuleConfigDefinition<T>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe(): ModuleConfigDescriptor[] {
|
|
76
|
+
return [...this.definitions.values()].map((definition) => ({
|
|
77
|
+
key: definition.key,
|
|
78
|
+
label: definition.label,
|
|
79
|
+
fields: definition.environment.map((field) => ({
|
|
80
|
+
path: field.path,
|
|
81
|
+
secret: Boolean(field.secret),
|
|
82
|
+
required: Boolean(field.secret?.required),
|
|
83
|
+
})),
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function validateDefinition<T>(definition: ModuleConfigDefinition<T>): void {
|
|
89
|
+
if (!/^[a-z][a-z0-9.-]{1,79}$/.test(definition.key)) {
|
|
90
|
+
throw new Error("配置模块 Key 格式无效");
|
|
91
|
+
}
|
|
92
|
+
if (!definition.label.trim()) throw new Error("配置模块名称必填");
|
|
93
|
+
|
|
94
|
+
const environmentKeys = new Set<string>();
|
|
95
|
+
const paths = new Set<string>();
|
|
96
|
+
for (const field of definition.environment) {
|
|
97
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(field.key)) {
|
|
98
|
+
throw new Error(`配置模块 ${definition.key} 包含无效环境变量名`);
|
|
99
|
+
}
|
|
100
|
+
if (!/^[a-z][A-Za-z0-9]*(\.[a-z][A-Za-z0-9]*)*$/.test(field.path)) {
|
|
101
|
+
throw new Error(`配置模块 ${definition.key} 包含无效字段路径`);
|
|
102
|
+
}
|
|
103
|
+
if (environmentKeys.has(field.key) || paths.has(field.path)) {
|
|
104
|
+
throw new Error(`配置模块 ${definition.key} 包含重复字段`);
|
|
105
|
+
}
|
|
106
|
+
environmentKeys.add(field.key);
|
|
107
|
+
paths.add(field.path);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ModuleConfigDefinition,
|
|
3
|
+
ModuleConfigEnvironmentField,
|
|
4
|
+
} from "./definition.js";
|
|
5
|
+
import { SecretRef, type SecretResolver } from "./secrets.js";
|
|
6
|
+
|
|
7
|
+
export type ConfigEnvironment = Readonly<Record<string, string | undefined>>;
|
|
8
|
+
|
|
9
|
+
export type ConfigLoadErrorCode =
|
|
10
|
+
| "CONFIG_UNKNOWN_ENV"
|
|
11
|
+
| "CONFIG_INVALID_VALUE"
|
|
12
|
+
| "CONFIG_SECRET_REQUIRED"
|
|
13
|
+
| "CONFIG_SECRET_UNAVAILABLE"
|
|
14
|
+
| "CONFIG_ASYNC_SECRET_RESOLVER";
|
|
15
|
+
|
|
16
|
+
export class ConfigLoadError extends Error {
|
|
17
|
+
constructor(
|
|
18
|
+
readonly code: ConfigLoadErrorCode,
|
|
19
|
+
readonly moduleKey: string,
|
|
20
|
+
readonly fieldPath?: string,
|
|
21
|
+
) {
|
|
22
|
+
super(errorMessage(code, moduleKey, fieldPath));
|
|
23
|
+
this.name = "ConfigLoadError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function loadModuleConfig<T>(
|
|
28
|
+
definition: ModuleConfigDefinition<T>,
|
|
29
|
+
environment: ConfigEnvironment,
|
|
30
|
+
resolver: SecretResolver,
|
|
31
|
+
): Promise<T> {
|
|
32
|
+
assertKnownEnvironment(definition, environment);
|
|
33
|
+
const input: Record<string, unknown> = {};
|
|
34
|
+
for (const field of definition.environment) {
|
|
35
|
+
const value = await readField(definition.key, field, environment, resolver);
|
|
36
|
+
if (value !== undefined) setPath(input, field.path, value);
|
|
37
|
+
}
|
|
38
|
+
return parseConfig(definition, input);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function loadModuleConfigSync<T>(
|
|
42
|
+
definition: ModuleConfigDefinition<T>,
|
|
43
|
+
environment: ConfigEnvironment,
|
|
44
|
+
resolver: SecretResolver,
|
|
45
|
+
): T {
|
|
46
|
+
assertKnownEnvironment(definition, environment);
|
|
47
|
+
const input: Record<string, unknown> = {};
|
|
48
|
+
for (const field of definition.environment) {
|
|
49
|
+
const value = readFieldSync(definition.key, field, environment, resolver);
|
|
50
|
+
if (value !== undefined) setPath(input, field.path, value);
|
|
51
|
+
}
|
|
52
|
+
return parseConfig(definition, input);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function strictBoolean(value: string): boolean {
|
|
56
|
+
if (value === "true" || value === "1") return true;
|
|
57
|
+
if (value === "false" || value === "0") return false;
|
|
58
|
+
throw new Error("布尔值只接受 true、false、1 或 0");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function strictInteger(value: string): number {
|
|
62
|
+
if (!/^-?\d+$/.test(value.trim())) throw new Error("必须是整数");
|
|
63
|
+
const parsed = Number(value);
|
|
64
|
+
if (!Number.isSafeInteger(parsed)) throw new Error("整数超出安全范围");
|
|
65
|
+
return parsed;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function commaSeparated(value: string): string[] {
|
|
69
|
+
return value
|
|
70
|
+
.split(",")
|
|
71
|
+
.map((item) => item.trim())
|
|
72
|
+
.filter(Boolean);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function readField(
|
|
76
|
+
moduleKey: string,
|
|
77
|
+
field: ModuleConfigEnvironmentField,
|
|
78
|
+
environment: ConfigEnvironment,
|
|
79
|
+
resolver: SecretResolver,
|
|
80
|
+
): Promise<unknown> {
|
|
81
|
+
const raw = environment[field.key];
|
|
82
|
+
if (!field.secret) return decodeField(moduleKey, field, raw);
|
|
83
|
+
const ref = createSecretRef(field, raw);
|
|
84
|
+
if (!ref) return missingSecret(moduleKey, field);
|
|
85
|
+
try {
|
|
86
|
+
const value = await resolver.resolve(ref);
|
|
87
|
+
if (!value?.trim()) return missingSecret(moduleKey, field);
|
|
88
|
+
return value;
|
|
89
|
+
} catch {
|
|
90
|
+
throw new ConfigLoadError(
|
|
91
|
+
"CONFIG_SECRET_UNAVAILABLE",
|
|
92
|
+
moduleKey,
|
|
93
|
+
field.path,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function readFieldSync(
|
|
99
|
+
moduleKey: string,
|
|
100
|
+
field: ModuleConfigEnvironmentField,
|
|
101
|
+
environment: ConfigEnvironment,
|
|
102
|
+
resolver: SecretResolver,
|
|
103
|
+
): unknown {
|
|
104
|
+
const raw = environment[field.key];
|
|
105
|
+
if (!field.secret) return decodeField(moduleKey, field, raw);
|
|
106
|
+
const ref = createSecretRef(field, raw);
|
|
107
|
+
if (!ref) return missingSecret(moduleKey, field);
|
|
108
|
+
try {
|
|
109
|
+
const value = resolver.resolve(ref);
|
|
110
|
+
if (isPromiseLike(value)) {
|
|
111
|
+
throw new ConfigLoadError(
|
|
112
|
+
"CONFIG_ASYNC_SECRET_RESOLVER",
|
|
113
|
+
moduleKey,
|
|
114
|
+
field.path,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (!value?.trim()) return missingSecret(moduleKey, field);
|
|
118
|
+
return value;
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (error instanceof ConfigLoadError) throw error;
|
|
121
|
+
throw new ConfigLoadError(
|
|
122
|
+
"CONFIG_SECRET_UNAVAILABLE",
|
|
123
|
+
moduleKey,
|
|
124
|
+
field.path,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function createSecretRef(
|
|
130
|
+
field: ModuleConfigEnvironmentField,
|
|
131
|
+
raw: string | undefined,
|
|
132
|
+
): SecretRef | undefined {
|
|
133
|
+
if (!field.secret) return undefined;
|
|
134
|
+
if (field.secret.provider === "env") {
|
|
135
|
+
return raw === undefined || raw === ""
|
|
136
|
+
? undefined
|
|
137
|
+
: new SecretRef("env", field.key);
|
|
138
|
+
}
|
|
139
|
+
const locator = raw?.trim();
|
|
140
|
+
return locator ? new SecretRef("file", locator) : undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function missingSecret(
|
|
144
|
+
moduleKey: string,
|
|
145
|
+
field: ModuleConfigEnvironmentField,
|
|
146
|
+
): undefined {
|
|
147
|
+
if (field.secret?.required) {
|
|
148
|
+
throw new ConfigLoadError("CONFIG_SECRET_REQUIRED", moduleKey, field.path);
|
|
149
|
+
}
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function decodeField(
|
|
154
|
+
moduleKey: string,
|
|
155
|
+
field: ModuleConfigEnvironmentField,
|
|
156
|
+
raw: string | undefined,
|
|
157
|
+
): unknown {
|
|
158
|
+
if (raw === undefined) return field.defaultValue;
|
|
159
|
+
try {
|
|
160
|
+
return field.decode ? field.decode(raw) : raw;
|
|
161
|
+
} catch {
|
|
162
|
+
throw new ConfigLoadError("CONFIG_INVALID_VALUE", moduleKey, field.path);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function parseConfig<T>(
|
|
167
|
+
definition: ModuleConfigDefinition<T>,
|
|
168
|
+
input: Record<string, unknown>,
|
|
169
|
+
): T {
|
|
170
|
+
const result = definition.schema.safeParse(input);
|
|
171
|
+
if (result.success) return result.data;
|
|
172
|
+
const firstIssue = result.error.issues[0];
|
|
173
|
+
throw new ConfigLoadError(
|
|
174
|
+
"CONFIG_INVALID_VALUE",
|
|
175
|
+
definition.key,
|
|
176
|
+
firstIssue?.path.map(String).join(".") || undefined,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function assertKnownEnvironment<T>(
|
|
181
|
+
definition: ModuleConfigDefinition<T>,
|
|
182
|
+
environment: ConfigEnvironment,
|
|
183
|
+
): void {
|
|
184
|
+
const known = new Set(definition.environment.map((field) => field.key));
|
|
185
|
+
const exact = new Set(definition.ownedEnvironmentKeys || []);
|
|
186
|
+
const prefixes = definition.ownedEnvironmentPrefixes || [];
|
|
187
|
+
const unknown = Object.keys(environment).filter(
|
|
188
|
+
(key) =>
|
|
189
|
+
environment[key] !== undefined &&
|
|
190
|
+
!known.has(key) &&
|
|
191
|
+
(exact.has(key) || prefixes.some((prefix) => key.startsWith(prefix))),
|
|
192
|
+
);
|
|
193
|
+
if (unknown.length > 0) {
|
|
194
|
+
throw new ConfigLoadError("CONFIG_UNKNOWN_ENV", definition.key);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function setPath(
|
|
199
|
+
target: Record<string, unknown>,
|
|
200
|
+
path: string,
|
|
201
|
+
value: unknown,
|
|
202
|
+
): void {
|
|
203
|
+
const segments = path.split(".");
|
|
204
|
+
let cursor = target;
|
|
205
|
+
for (const segment of segments.slice(0, -1)) {
|
|
206
|
+
const existing = cursor[segment];
|
|
207
|
+
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
|
|
208
|
+
cursor[segment] = {};
|
|
209
|
+
}
|
|
210
|
+
cursor = cursor[segment] as Record<string, unknown>;
|
|
211
|
+
}
|
|
212
|
+
const leaf = segments.at(-1);
|
|
213
|
+
if (leaf) cursor[leaf] = value;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
|
217
|
+
return (
|
|
218
|
+
typeof value === "object" &&
|
|
219
|
+
value !== null &&
|
|
220
|
+
"then" in value &&
|
|
221
|
+
typeof value.then === "function"
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function errorMessage(
|
|
226
|
+
code: ConfigLoadErrorCode,
|
|
227
|
+
moduleKey: string,
|
|
228
|
+
fieldPath?: string,
|
|
229
|
+
): string {
|
|
230
|
+
const field = fieldPath ? `,字段 ${fieldPath}` : "";
|
|
231
|
+
if (code === "CONFIG_UNKNOWN_ENV")
|
|
232
|
+
return `配置模块 ${moduleKey} 含未知环境变量`;
|
|
233
|
+
if (code === "CONFIG_SECRET_REQUIRED")
|
|
234
|
+
return `配置模块 ${moduleKey} 缺少必填 Secret${field}`;
|
|
235
|
+
if (code === "CONFIG_SECRET_UNAVAILABLE")
|
|
236
|
+
return `配置模块 ${moduleKey} 无法解析 Secret${field}`;
|
|
237
|
+
if (code === "CONFIG_ASYNC_SECRET_RESOLVER")
|
|
238
|
+
return `配置模块 ${moduleKey} 需要异步 SecretResolver${field}`;
|
|
239
|
+
return `配置模块 ${moduleKey} 的值无效${field}`;
|
|
240
|
+
}
|
package/src/platform.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type { DatabaseDialect } from "@southwind-ai/shared";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { defineModuleConfig, ModuleConfigRegistry } from "./definition.js";
|
|
4
|
+
import {
|
|
5
|
+
commaSeparated,
|
|
6
|
+
loadModuleConfigSync,
|
|
7
|
+
strictBoolean,
|
|
8
|
+
strictInteger,
|
|
9
|
+
type ConfigEnvironment,
|
|
10
|
+
} from "./environment-loader.js";
|
|
11
|
+
import { EnvironmentSecretResolver } from "./secrets.js";
|
|
12
|
+
|
|
13
|
+
export interface DatabaseConfig {
|
|
14
|
+
dialect: DatabaseDialect;
|
|
15
|
+
url?: string;
|
|
16
|
+
legacyRuoYiMysqlUrl?: string;
|
|
17
|
+
legacyRuoYiRedisUrl?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PlatformConfig {
|
|
21
|
+
name: string;
|
|
22
|
+
logoUrl?: string;
|
|
23
|
+
enableAI: boolean;
|
|
24
|
+
database: DatabaseConfig;
|
|
25
|
+
cache: { redisUrl?: string; healthTimeoutMs: number };
|
|
26
|
+
security: {
|
|
27
|
+
sessionCookieName: string;
|
|
28
|
+
sessionTtlSeconds: number;
|
|
29
|
+
passwordHash: "argon2id";
|
|
30
|
+
};
|
|
31
|
+
license: {
|
|
32
|
+
enabled: boolean;
|
|
33
|
+
publicKeyId?: string;
|
|
34
|
+
publicKey?: string;
|
|
35
|
+
machineCode?: string;
|
|
36
|
+
};
|
|
37
|
+
audit: { enabled: boolean; redactFields: string[] };
|
|
38
|
+
compatibility: {
|
|
39
|
+
ruoyiVue: { enabled: boolean; runtimeReadonly: boolean };
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface WorkspaceConfig {
|
|
44
|
+
name: string;
|
|
45
|
+
description?: string;
|
|
46
|
+
features: Record<string, boolean>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ProjectConfig {
|
|
50
|
+
name: string;
|
|
51
|
+
modules: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const platformSchema: z.ZodType<PlatformConfig> = z.strictObject({
|
|
55
|
+
name: z.string().trim().min(1).max(120),
|
|
56
|
+
logoUrl: z.string().trim().url().optional(),
|
|
57
|
+
enableAI: z.boolean(),
|
|
58
|
+
database: z.strictObject({
|
|
59
|
+
dialect: z.enum(["postgresql", "mysql", "dm", "kingbase"]),
|
|
60
|
+
url: z.string().trim().min(1).optional(),
|
|
61
|
+
legacyRuoYiMysqlUrl: z.string().trim().min(1).optional(),
|
|
62
|
+
legacyRuoYiRedisUrl: z.string().trim().min(1).optional(),
|
|
63
|
+
}),
|
|
64
|
+
cache: z.strictObject({
|
|
65
|
+
redisUrl: z.string().trim().min(1).optional(),
|
|
66
|
+
healthTimeoutMs: z.number().int().min(50).max(10_000),
|
|
67
|
+
}),
|
|
68
|
+
security: z.strictObject({
|
|
69
|
+
sessionCookieName: z.string().trim().min(1).max(120),
|
|
70
|
+
sessionTtlSeconds: z.number().int().min(60).max(2_592_000),
|
|
71
|
+
passwordHash: z.literal("argon2id"),
|
|
72
|
+
}),
|
|
73
|
+
license: z.strictObject({
|
|
74
|
+
enabled: z.boolean(),
|
|
75
|
+
publicKeyId: z.string().trim().min(1).optional(),
|
|
76
|
+
publicKey: z.string().trim().min(1).optional(),
|
|
77
|
+
machineCode: z.string().trim().min(1).optional(),
|
|
78
|
+
}),
|
|
79
|
+
audit: z.strictObject({
|
|
80
|
+
enabled: z.boolean(),
|
|
81
|
+
redactFields: z.array(z.string().min(1)).min(1),
|
|
82
|
+
}),
|
|
83
|
+
compatibility: z.strictObject({
|
|
84
|
+
ruoyiVue: z.strictObject({
|
|
85
|
+
enabled: z.boolean(),
|
|
86
|
+
runtimeReadonly: z.boolean(),
|
|
87
|
+
}),
|
|
88
|
+
}),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
export const platformConfigDefinition = defineModuleConfig<PlatformConfig>({
|
|
92
|
+
key: "platform.runtime",
|
|
93
|
+
label: "平台运行配置",
|
|
94
|
+
schema: platformSchema,
|
|
95
|
+
ownedEnvironmentPrefixes: [
|
|
96
|
+
"PLATFORM_",
|
|
97
|
+
"DATABASE_",
|
|
98
|
+
"REDIS_",
|
|
99
|
+
"RUOYI_",
|
|
100
|
+
"SESSION_",
|
|
101
|
+
"AUDIT_",
|
|
102
|
+
],
|
|
103
|
+
ownedEnvironmentKeys: [
|
|
104
|
+
"LICENSE_ENABLED",
|
|
105
|
+
"LICENSE_PUBLIC_KEY_ID",
|
|
106
|
+
"LICENSE_PUBLIC_KEY",
|
|
107
|
+
"LICENSE_MACHINE_CODE",
|
|
108
|
+
],
|
|
109
|
+
environment: [
|
|
110
|
+
value("PLATFORM_NAME", "name", "Windy Platform"),
|
|
111
|
+
value("PLATFORM_LOGO_URL", "logoUrl"),
|
|
112
|
+
value("PLATFORM_ENABLE_AI", "enableAI", false, strictBoolean),
|
|
113
|
+
value("DATABASE_DIALECT", "database.dialect", "postgresql"),
|
|
114
|
+
secret("DATABASE_URL", "database.url"),
|
|
115
|
+
secret("RUOYI_MYSQL_URL", "database.legacyRuoYiMysqlUrl"),
|
|
116
|
+
secret("RUOYI_REDIS_URL", "database.legacyRuoYiRedisUrl"),
|
|
117
|
+
secret("REDIS_URL", "cache.redisUrl"),
|
|
118
|
+
value(
|
|
119
|
+
"REDIS_HEALTH_TIMEOUT_MS",
|
|
120
|
+
"cache.healthTimeoutMs",
|
|
121
|
+
1_500,
|
|
122
|
+
strictInteger,
|
|
123
|
+
),
|
|
124
|
+
value("SESSION_COOKIE_NAME", "security.sessionCookieName", "windy.sid"),
|
|
125
|
+
value(
|
|
126
|
+
"SESSION_TTL_SECONDS",
|
|
127
|
+
"security.sessionTtlSeconds",
|
|
128
|
+
28_800,
|
|
129
|
+
strictInteger,
|
|
130
|
+
),
|
|
131
|
+
value("PLATFORM_PASSWORD_HASH", "security.passwordHash", "argon2id"),
|
|
132
|
+
value("LICENSE_ENABLED", "license.enabled", true, strictBoolean),
|
|
133
|
+
value("LICENSE_PUBLIC_KEY_ID", "license.publicKeyId"),
|
|
134
|
+
value("LICENSE_PUBLIC_KEY", "license.publicKey"),
|
|
135
|
+
secret("LICENSE_MACHINE_CODE", "license.machineCode"),
|
|
136
|
+
value("AUDIT_ENABLED", "audit.enabled", true, strictBoolean),
|
|
137
|
+
value(
|
|
138
|
+
"AUDIT_REDACT_FIELDS",
|
|
139
|
+
"audit.redactFields",
|
|
140
|
+
["password", "token", "secret"],
|
|
141
|
+
commaSeparated,
|
|
142
|
+
),
|
|
143
|
+
value(
|
|
144
|
+
"RUOYI_COMPAT_ENABLED",
|
|
145
|
+
"compatibility.ruoyiVue.enabled",
|
|
146
|
+
false,
|
|
147
|
+
strictBoolean,
|
|
148
|
+
),
|
|
149
|
+
value(
|
|
150
|
+
"RUOYI_RUNTIME_READONLY",
|
|
151
|
+
"compatibility.ruoyiVue.runtimeReadonly",
|
|
152
|
+
true,
|
|
153
|
+
strictBoolean,
|
|
154
|
+
),
|
|
155
|
+
],
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
export function createPlatformConfigRegistry(): ModuleConfigRegistry {
|
|
159
|
+
const registry = new ModuleConfigRegistry();
|
|
160
|
+
registry.register(platformConfigDefinition);
|
|
161
|
+
return registry;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function loadPlatformConfig(
|
|
165
|
+
environment: ConfigEnvironment = process.env,
|
|
166
|
+
): PlatformConfig {
|
|
167
|
+
return loadModuleConfigSync(
|
|
168
|
+
platformConfigDefinition,
|
|
169
|
+
environment,
|
|
170
|
+
new EnvironmentSecretResolver(environment),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function value(
|
|
175
|
+
key: string,
|
|
176
|
+
path: string,
|
|
177
|
+
defaultValue?: unknown,
|
|
178
|
+
decode?: (value: string) => unknown,
|
|
179
|
+
) {
|
|
180
|
+
return { key, path, defaultValue, decode };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function secret(key: string, path: string) {
|
|
184
|
+
return { key, path, secret: { provider: "env" as const } };
|
|
185
|
+
}
|
package/src/secrets.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export type SecretProviderKey = "env" | "file";
|
|
2
|
+
|
|
3
|
+
export class SecretRef {
|
|
4
|
+
readonly #provider: SecretProviderKey;
|
|
5
|
+
readonly #locator: string;
|
|
6
|
+
|
|
7
|
+
constructor(provider: SecretProviderKey, locator: string) {
|
|
8
|
+
this.#provider = provider;
|
|
9
|
+
this.#locator = locator;
|
|
10
|
+
Object.freeze(this);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
locatorFor(provider: SecretProviderKey): string {
|
|
14
|
+
if (provider !== this.#provider) {
|
|
15
|
+
throw new Error("SecretResolver 与 SecretRef Provider 不匹配");
|
|
16
|
+
}
|
|
17
|
+
return this.#locator;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
toJSON(): undefined {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
toString(): string {
|
|
25
|
+
return "[SecretRef]";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SecretResolver {
|
|
30
|
+
resolve(ref: SecretRef): string | undefined | Promise<string | undefined>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class EnvironmentSecretResolver implements SecretResolver {
|
|
34
|
+
constructor(
|
|
35
|
+
private readonly environment: Readonly<Record<string, string | undefined>>,
|
|
36
|
+
) {}
|
|
37
|
+
|
|
38
|
+
resolve(ref: SecretRef): string | undefined {
|
|
39
|
+
return this.environment[ref.locatorFor("env")];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class CompositeSecretResolver implements SecretResolver {
|
|
44
|
+
constructor(
|
|
45
|
+
private readonly resolvers: Readonly<
|
|
46
|
+
Partial<Record<SecretProviderKey, SecretResolver>>
|
|
47
|
+
>,
|
|
48
|
+
) {}
|
|
49
|
+
|
|
50
|
+
resolve(ref: SecretRef): string | undefined | Promise<string | undefined> {
|
|
51
|
+
for (const provider of ["env", "file"] as const) {
|
|
52
|
+
try {
|
|
53
|
+
ref.locatorFor(provider);
|
|
54
|
+
const resolver = this.resolvers[provider];
|
|
55
|
+
if (!resolver) throw new Error("Secret Provider 未配置");
|
|
56
|
+
return resolver.resolve(ref);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (
|
|
59
|
+
error instanceof Error &&
|
|
60
|
+
error.message === "SecretResolver 与 SecretRef Provider 不匹配"
|
|
61
|
+
) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
throw new Error("未知 Secret Provider");
|
|
68
|
+
}
|
|
69
|
+
}
|