@zhin.js/gateway 1.1.1 → 1.2.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/README.md +4 -4
- package/lib/config/configuration-chat-policy.js +2 -2
- package/lib/config/configuration-redaction.d.ts +2 -8
- package/lib/config/configuration-redaction.js +15 -68
- package/lib/config/console-schema.js +4 -3
- package/lib/config/environment-files.d.ts +1 -0
- package/lib/config/environment-files.js +4 -11
- package/lib/config/literal-secrets.d.ts +34 -0
- package/lib/config/literal-secrets.js +136 -0
- package/lib/config.d.ts +1 -0
- package/lib/config.js +1 -0
- package/lib/database/config.js +31 -0
- package/lib/database/console-role.d.ts +35 -0
- package/lib/database/console-role.js +108 -0
- package/lib/database/gateway-store.d.ts +43 -0
- package/lib/database/gateway-store.js +90 -0
- package/lib/database/index.d.ts +2 -0
- package/lib/database/index.js +2 -0
- package/lib/database/project-database.d.ts +45 -0
- package/lib/database/project-database.js +212 -6
- package/lib/http/console-rpc.d.ts +9 -1
- package/lib/http/gateway-console.d.ts +15 -8
- package/lib/http/gateway-console.js +128 -45
- package/lib/http/gateway-events.d.ts +5 -0
- package/lib/http/gateway-events.js +16 -3
- package/lib/http/gateway-http-server.js +11 -7
- package/lib/http/plugin-switch-rpc.d.ts +19 -0
- package/lib/http/plugin-switch-rpc.js +218 -0
- package/lib/http/start-gateway.js +15 -0
- package/lib/iam/index.d.ts +4 -3
- package/lib/iam/index.js +4 -3
- package/lib/iam/install.d.ts +17 -11
- package/lib/iam/install.js +39 -18
- package/lib/iam/legacy-import.d.ts +151 -0
- package/lib/iam/legacy-import.js +600 -0
- package/lib/iam/local-admin.d.ts +2 -0
- package/lib/iam/local-admin.js +6 -1
- package/lib/iam/routes.js +10 -2
- package/lib/iam/rpc.js +1 -1
- package/lib/iam/service.d.ts +31 -0
- package/lib/iam/service.js +49 -4
- package/lib/iam/setup-service.d.ts +2 -0
- package/lib/iam/setup-service.js +11 -4
- package/lib/iam/tables.d.ts +6 -6
- package/lib/iam/tables.js +6 -8
- package/lib/local/handler.js +1 -1
- package/lib/local/protocol.d.ts +6 -0
- package/lib/local/protocol.js +6 -0
- package/lib/packages/authorization.d.ts +2 -1
- package/lib/packages/authorization.js +20 -1
- package/lib/packages/rpc.js +5 -0
- package/lib/packages/service.d.ts +18 -0
- package/lib/packages/service.js +66 -3
- package/lib/plugin-management/config-explanation.js +3 -3
- package/lib/plugin-management/workflow.d.ts +44 -0
- package/lib/plugin-management/workflow.js +109 -3
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Zhin.js 管理网关([ADR 0054](../../../docs/adr/0054-management-gateway.md))。网关负责宿主层面的事:Console 账号与权限、终端、
|
|
4
4
|
项目配置与 `.env`、依赖事务、实例监管。它**不依赖 `zhin.js` 与 IM 层**,不加载插件代码(`pnpm check:architecture` 强制)。
|
|
5
5
|
|
|
6
|
-
网关进程(`zhin start`;前台运行用 `zhin start --foreground
|
|
6
|
+
网关进程(`zhin start`;前台运行用 `zhin start --foreground`)把 `console_*` 账号表放在自己的 `.zhin/gateway.db`(ADR 0059,与项目数据库方言无关;升级时自动从项目数据库迁入):登录、初始化、`iam.*`、`onboarding.update`、终端与审计
|
|
7
7
|
都在网关里,没有实例时 Console 照样可用;受管实例只按转发的主体做授权。`--standalone`(开发 / 测试,无网关)的实例仍在进程内
|
|
8
8
|
装配 `installConsoleIam` / `installHostConsoleTerminal`。
|
|
9
9
|
|
|
@@ -27,7 +27,7 @@ zhin console init # 首次创建 Console 所有者;之后 http.token
|
|
|
27
27
|
|
|
28
28
|
| 子路径 | 内容 |
|
|
29
29
|
|---|---|
|
|
30
|
-
| `@zhin.js/gateway/iam` | Console 账号、角色、会话、API 令牌、审计与初始化(ADR 0049):`ConsoleIamService`、`ConsoleSetupService`、`installConsoleIam`、`registerConsoleAuthRoutes`、`dispatchIamRpc` |
|
|
30
|
+
| `@zhin.js/gateway/iam` | Console 账号、角色、会话、API 令牌、审计与初始化(ADR 0049):`ConsoleIamService`、`ConsoleSetupService`、`installConsoleIam`、`registerConsoleAuthRoutes`、`dispatchIamRpc`、`importLegacyIamTables` / `revertLegacyIamImport` / `inspectLegacyIamImport`(旧版项目库账号表迁入 gateway.db 与回退,ADR 0059) |
|
|
31
31
|
| `@zhin.js/gateway/terminal` | Console 终端(ADR 0051):`TerminalService`、`installHostConsoleTerminal`、`parseTerminalConfig` |
|
|
32
32
|
| `@zhin.js/gateway/config` | 原始配置文件与 `.env` 读写:`ConsoleConfigurationStore`、`readEnvFile` / `writeEnvFile`、配置路径与补丁、Console Schema |
|
|
33
33
|
| `@zhin.js/gateway/plugin-management` | 项目级依赖事务:`PluginManagementWorkflow`、`PluginManagementActivity`、插件用例 |
|
|
@@ -36,8 +36,8 @@ zhin console init # 首次创建 Console 所有者;之后 http.token
|
|
|
36
36
|
| `@zhin.js/gateway/usage` | 用量统计聚合 |
|
|
37
37
|
| `@zhin.js/gateway/onboarding` | Console 引导(ADR 0053):`.zhin/onboarding.json` 状态(`OnboardingStateStore`、自动识别已配置步骤)、`OnboardingService`、安装白名单与 `authorizeInstall`、流式 `installPackages` |
|
|
38
38
|
| `@zhin.js/gateway/http` | HTML / SSE 响应工具(实例也导入,不加载 Node HTTP 栈) |
|
|
39
|
-
| `@zhin.js/gateway/server` | 网关 HTTP 服务器(`GatewayHttpServer`:自有路由、`{base}` 认证后转发、其他路由原样转发、实例离线 503 / 超时 504 / 其他 502;`useConsoleRpc` 登记更多网关 RPC)、`gateway.*` Console RPC 路由、`startGatewayConsole
|
|
40
|
-
| `@zhin.js/gateway/database` |
|
|
39
|
+
| `@zhin.js/gateway/server` | 网关 HTTP 服务器(`GatewayHttpServer`:自有路由、`{base}` 认证后转发、其他路由原样转发、实例离线 503 / 超时 504 / 其他 502;`useConsoleRpc` 登记更多网关 RPC)、`gateway.*` Console RPC 路由、`startGatewayConsole`(gateway.db、旧表迁移、本地认证、`/auth/*`、`iam.*`、`onboarding.update`、初始化向导、终端、审计)、`registerGatewayEventRoutes`(合并 `{base}/events`、`{base}/gateway/packages/progress`)、`startGateway`(服务器 + Console + 实例监管 + `@zhin.js/host-http/bridge` 实例传输)、`runGateway`(`zhin start` 的监管 + 网关入口) |
|
|
40
|
+
| `@zhin.js/gateway/database` | 网关自己的存储与临时项目库连接(ADR 0054 §7、ADR 0059):`openGatewayStore`(`.zhin/gateway.db`,`0600`,`gateway_meta`)、`loadProjectDatabaseConfig` / `resolveProjectDatabaseConfig`(与实例 `resolveDatabaseConfig` 同一规则,含 `database.console`)、`ProjectDatabase`(SQLite WAL;不用同步的 `busy_timeout`,`SQLITE_BUSY` 以异步退避重试,默认 5 秒预算,超时返回 `503 database_busy` 与 `Retry-After`;`tableExists` / `readSnapshot` / `dropTables` 供迁移使用)、`setupConsoleRole`(`zhin db console-role`:PostgreSQL / MySQL 的 Console SQL 受限账号) |
|
|
41
41
|
| `@zhin.js/gateway` | 以上全部 |
|
|
42
42
|
|
|
43
43
|
```ts
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { expandEnvironmentValue } from '@zhin.js/runtime';
|
|
2
|
+
import { isSecretConfigSchema } from '@zhin.js/runtime';
|
|
2
3
|
import { isUnknownConfigurationSchemaBoundary, sensitiveConfigKey as sensitiveKey } from './configuration-redaction.js';
|
|
3
4
|
const accessMessages = Object.freeze({
|
|
4
5
|
sensitive: '敏感配置不能通过聊天读取或修改,请使用 CLI 或 Console。',
|
|
@@ -88,8 +89,7 @@ function record(value) {
|
|
|
88
89
|
}
|
|
89
90
|
function own(value, key) { return Object.hasOwn(value, key); }
|
|
90
91
|
function sensitive(schema) {
|
|
91
|
-
return schema
|
|
92
|
-
|| record(schema['x-zhin-config']) && (schema['x-zhin-config'].secret === true || schema['x-zhin-config'].component === 'password');
|
|
92
|
+
return isSecretConfigSchema(schema);
|
|
93
93
|
}
|
|
94
94
|
function unknown() { throw new ConfigChatAccessError('unknown'); }
|
|
95
95
|
function assertSafe(nodes) {
|
|
@@ -1,14 +1,8 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Masks stored configuration values before they leave the store in a response that the
|
|
3
|
-
* caller did not ask to read (e.g. a trusted project patch conflict). Conservative: a
|
|
4
|
-
* schema secret/writeOnly/password/hidden annotation, a secret-like key, or an unexpanded
|
|
5
|
-
* environment reference (`${…}`, `env://…`) is replaced as a whole and never echoed.
|
|
6
|
-
*/
|
|
7
1
|
type Schema = Readonly<Record<string, unknown>>;
|
|
8
2
|
/** Shared proof limit: these branches/resources need a resolver we do not provide. */
|
|
9
3
|
export declare function isUnknownConfigurationSchemaBoundary(schema: Schema, root: Schema): boolean;
|
|
10
|
-
/** Credential names, excluding ordinary budgets such as maxTokens and tokenLimit. */
|
|
11
|
-
export declare
|
|
4
|
+
/** Credential names, excluding ordinary budgets such as maxTokens and tokenLimit (the ADR 0057 predicate). */
|
|
5
|
+
export declare const sensitiveConfigKey: (key: string) => boolean;
|
|
12
6
|
export declare const REDACTED_CONFIG_VALUE = "[redacted]";
|
|
13
7
|
/**
|
|
14
8
|
* Masks `value`, stored at `path` (first segment is the section) under `sectionSchema`.
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Masks stored configuration values before they leave the store in a response that the
|
|
3
|
+
* caller did not ask to read (e.g. a trusted project patch conflict). Conservative: a
|
|
4
|
+
* schema secret/writeOnly/password/hidden annotation, a secret-like key, or an unexpanded
|
|
5
|
+
* environment reference (`${…}`, `env://…`) is replaced as a whole and never echoed.
|
|
6
|
+
*/
|
|
7
|
+
import { UNRESOLVED_CONFIG_SCHEMA, configSchemaChildren, expandConfigSchema, isConfigEnvironmentReference, isSecretConfigKey, isSecretConfigSchema, } from '@zhin.js/runtime';
|
|
1
8
|
const unsupportedApplicators = Object.freeze([
|
|
2
9
|
'if', 'then', 'else', 'not', 'contains', 'dependencies', 'dependentSchemas',
|
|
3
10
|
'$dynamicRef', '$recursiveRef', '$dynamicAnchor', 'unevaluatedProperties', 'unevaluatedItems',
|
|
@@ -8,88 +15,28 @@ export function isUnknownConfigurationSchemaBoundary(schema, root) {
|
|
|
8
15
|
|| unsupportedApplicators.some(keyword => Object.hasOwn(schema, keyword))
|
|
9
16
|
|| schema.$ref !== undefined && (typeof schema.$ref !== 'string' || !schema.$ref.startsWith('#/') || /~(?![01])/u.test(schema.$ref));
|
|
10
17
|
}
|
|
11
|
-
/** Credential names, excluding ordinary budgets such as maxTokens and tokenLimit. */
|
|
12
|
-
export
|
|
13
|
-
const normalized = key.replace(/[^a-z0-9]/giu, '').toLowerCase();
|
|
14
|
-
return /(?:password|passwd|secret|apikey|accesskey|privatekey|encrypt(?:ion)?key|encodingaeskey|credential|authorization|cookie)/u.test(normalized)
|
|
15
|
-
|| normalized === 'pwd' || /(?:^|api|access|refresh|auth|bearer|bot|oauth|session|private|client|app|verify|verification)tokens?(?:v[0-9]+)?$/u.test(normalized);
|
|
16
|
-
}
|
|
18
|
+
/** Credential names, excluding ordinary budgets such as maxTokens and tokenLimit (the ADR 0057 predicate). */
|
|
19
|
+
export const sensitiveConfigKey = isSecretConfigKey;
|
|
17
20
|
export const REDACTED_CONFIG_VALUE = '[redacted]';
|
|
18
21
|
const MAX_DEPTH = 32;
|
|
19
22
|
// Retain uncertainty without changing the non-strict masker's best-effort behavior.
|
|
20
|
-
const unresolvedSchema =
|
|
23
|
+
const unresolvedSchema = UNRESOLVED_CONFIG_SCHEMA;
|
|
24
|
+
const expand = expandConfigSchema;
|
|
25
|
+
const children = configSchemaChildren;
|
|
21
26
|
function record(value) {
|
|
22
27
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
23
28
|
}
|
|
24
29
|
function sensitiveSchema(schema) {
|
|
25
30
|
const metadata = schema['x-zhin-config'];
|
|
26
31
|
// `hidden` mirrors the configuration explanation contract: hidden fields are never echoed.
|
|
27
|
-
return schema
|
|
28
|
-
||
|
|
29
|
-
|| record(metadata) && (metadata.secret === true || metadata.hidden === true || metadata.component === 'password');
|
|
30
|
-
}
|
|
31
|
-
/** Expands local `$ref` and every combinator branch; any branch's annotation applies. */
|
|
32
|
-
function expand(node, root, seen = new Set()) {
|
|
33
|
-
if (!record(node) || seen.has(node) || seen.size > MAX_DEPTH)
|
|
34
|
-
return [unresolvedSchema];
|
|
35
|
-
const nextSeen = new Set([...seen, node]);
|
|
36
|
-
const nodes = [node];
|
|
37
|
-
if (typeof node.$ref === 'string' && node.$ref.startsWith('#/')) {
|
|
38
|
-
let referenced = root;
|
|
39
|
-
for (const raw of node.$ref.slice(2).split('/')) {
|
|
40
|
-
const key = raw.replaceAll('~1', '/').replaceAll('~0', '~');
|
|
41
|
-
referenced = record(referenced) && Object.hasOwn(referenced, key) ? referenced[key] : undefined;
|
|
42
|
-
}
|
|
43
|
-
nodes.push(...expand(referenced, root, nextSeen));
|
|
44
|
-
}
|
|
45
|
-
for (const keyword of ['allOf', 'anyOf', 'oneOf']) {
|
|
46
|
-
const branches = node[keyword];
|
|
47
|
-
if (Array.isArray(branches))
|
|
48
|
-
for (const branch of branches)
|
|
49
|
-
nodes.push(...expand(branch, root, nextSeen));
|
|
50
|
-
}
|
|
51
|
-
return nodes;
|
|
52
|
-
}
|
|
53
|
-
function children(nodes, key, root) {
|
|
54
|
-
const next = [];
|
|
55
|
-
for (const node of nodes) {
|
|
56
|
-
let matched = false;
|
|
57
|
-
if (record(node.properties) && Object.hasOwn(node.properties, key)) {
|
|
58
|
-
next.push(...expand(node.properties[key], root));
|
|
59
|
-
matched = true;
|
|
60
|
-
}
|
|
61
|
-
if (record(node.patternProperties)) {
|
|
62
|
-
for (const [pattern, child] of Object.entries(node.patternProperties)) {
|
|
63
|
-
let test = false;
|
|
64
|
-
try {
|
|
65
|
-
test = new RegExp(pattern, 'u').test(key);
|
|
66
|
-
}
|
|
67
|
-
catch {
|
|
68
|
-
test = false;
|
|
69
|
-
}
|
|
70
|
-
if (test) {
|
|
71
|
-
next.push(...expand(child, root));
|
|
72
|
-
matched = true;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
if (!matched && record(node.additionalProperties))
|
|
77
|
-
next.push(...expand(node.additionalProperties, root));
|
|
78
|
-
if (/^(?:0|[1-9][0-9]*)$/u.test(key)) {
|
|
79
|
-
const index = Number(key);
|
|
80
|
-
if (Array.isArray(node.prefixItems) && index < node.prefixItems.length)
|
|
81
|
-
next.push(...expand(node.prefixItems[index], root));
|
|
82
|
-
else if (record(node.items))
|
|
83
|
-
next.push(...expand(node.items, root));
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
return next;
|
|
32
|
+
return isSecretConfigSchema(schema) || schema.hidden === true
|
|
33
|
+
|| record(metadata) && metadata.hidden === true;
|
|
87
34
|
}
|
|
88
35
|
function maskTree(value, nodes, root, depth, hidden) {
|
|
89
36
|
if (nodes.some(hidden))
|
|
90
37
|
return REDACTED_CONFIG_VALUE;
|
|
91
38
|
if (typeof value === 'string')
|
|
92
|
-
return
|
|
39
|
+
return isConfigEnvironmentReference(value) ? REDACTED_CONFIG_VALUE : value;
|
|
93
40
|
if (value === null || typeof value !== 'object')
|
|
94
41
|
return value;
|
|
95
42
|
if (depth >= MAX_DEPTH)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isSecretConfigSchema } from '@zhin.js/runtime';
|
|
1
2
|
const record = (value) => Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
2
3
|
const own = (value, key) => Object.hasOwn(value, key);
|
|
3
4
|
const jsonTypes = new Set(['object', 'array', 'string', 'number', 'integer', 'boolean', 'null']);
|
|
@@ -7,7 +8,7 @@ export function readStringLiteralOptions(input) {
|
|
|
7
8
|
if (!Array.isArray(input.anyOf) || input.anyOf.length === 0 || input.anyOf.length > 64)
|
|
8
9
|
return null;
|
|
9
10
|
const annotations = ['title', 'description', 'x-descriptionZh', 'x-i18n', '$comment'];
|
|
10
|
-
const parentKeys = ['anyOf', 'type', 'default', 'writeOnly', 'readOnly', 'deprecated', 'x-zhin-config', ...annotations];
|
|
11
|
+
const parentKeys = ['anyOf', 'type', 'default', 'writeOnly', 'readOnly', 'deprecated', 'x-zhin-config', 'x-zhin-secret', ...annotations];
|
|
11
12
|
if (Object.keys(input).some(key => !parentKeys.includes(key))
|
|
12
13
|
|| (input.type !== undefined && input.type !== 'string'))
|
|
13
14
|
return null;
|
|
@@ -148,7 +149,7 @@ function convert(input, key, context) {
|
|
|
148
149
|
const inferred = type ?? (Array.isArray(schema.enum) && schema.enum.length ? (schema.enum[0] === null ? 'null' : typeof schema.enum[0]) : 'any');
|
|
149
150
|
return finish({ type: inferred === 'integer' ? 'number' : jsonTypes.has(inferred) ? inferred : 'any', ...(inferred === 'integer' ? { integer: true } : {}) });
|
|
150
151
|
}
|
|
151
|
-
const annotationKeys = new Set(['title', 'description', 'x-descriptionZh', 'x-i18n', 'default', 'readOnly', 'writeOnly', 'examples', 'deprecated', 'x-zhin-config', 'key', 'hidden', 'component', 'options', 'effect', 'secret']);
|
|
152
|
+
const annotationKeys = new Set(['title', 'description', 'x-descriptionZh', 'x-i18n', 'default', 'readOnly', 'writeOnly', 'examples', 'deprecated', 'x-zhin-config', 'x-zhin-secret', 'key', 'hidden', 'component', 'options', 'effect', 'secret']);
|
|
152
153
|
function annotate(result, schema, key) {
|
|
153
154
|
const output = { ...result };
|
|
154
155
|
if (record(schema['x-i18n']))
|
|
@@ -180,7 +181,7 @@ function annotate(result, schema, key) {
|
|
|
180
181
|
output[name] = metadata[name];
|
|
181
182
|
if (output.secret !== true && typeof schema.secret === 'boolean')
|
|
182
183
|
output.secret = schema.secret;
|
|
183
|
-
if (output.secret === true || schema
|
|
184
|
+
if (output.secret === true || isSecretConfigSchema(schema)) {
|
|
184
185
|
output.secret = true;
|
|
185
186
|
if (output.component !== 'textarea')
|
|
186
187
|
output.component = 'password';
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export declare function readEnvFile(projectRoot: string, filename: string): Promise<string>;
|
|
2
|
+
/** Locked, atomic replacement that keeps the file mode (`0600` when new); ADR 0057 §4. */
|
|
2
3
|
export declare function writeEnvFile(projectRoot: string, filename: string, content: string): Promise<void>;
|
|
@@ -1,21 +1,14 @@
|
|
|
1
|
-
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
1
|
import { join } from 'node:path';
|
|
2
|
+
import { readDotenvFile, writeDotenvFile } from '@zhin.js/runtime';
|
|
3
3
|
const ENV_FILES = new Set(['.env', '.env.development', '.env.production']);
|
|
4
4
|
export async function readEnvFile(projectRoot, filename) {
|
|
5
5
|
if (!ENV_FILES.has(filename))
|
|
6
6
|
throw new Error(`Invalid env file: ${filename}`);
|
|
7
|
-
|
|
8
|
-
try {
|
|
9
|
-
return await readFile(file, 'utf8');
|
|
10
|
-
}
|
|
11
|
-
catch (error) {
|
|
12
|
-
if (error.code === 'ENOENT')
|
|
13
|
-
return '';
|
|
14
|
-
throw error;
|
|
15
|
-
}
|
|
7
|
+
return readDotenvFile(join(projectRoot, filename));
|
|
16
8
|
}
|
|
9
|
+
/** Locked, atomic replacement that keeps the file mode (`0600` when new); ADR 0057 §4. */
|
|
17
10
|
export async function writeEnvFile(projectRoot, filename, content) {
|
|
18
11
|
if (!ENV_FILES.has(filename))
|
|
19
12
|
throw new Error(`Invalid env file: ${filename}`);
|
|
20
|
-
await
|
|
13
|
+
await writeDotenvFile(join(projectRoot, filename), content);
|
|
21
14
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type LiteralConfigSecret } from '@zhin.js/runtime';
|
|
2
|
+
import type { RuntimeConfigDocument } from '@zhin.js/plugin-runtime';
|
|
3
|
+
/**
|
|
4
|
+
* Secret paths holding literals in `document`, using the Host schema, the root `schema.json`
|
|
5
|
+
* and each declared instance's `schema.json` (annotations), plus the field-name fallback.
|
|
6
|
+
* Plugins whose schema is only inline (`definePlugin({ config })`) use the name fallback here.
|
|
7
|
+
*/
|
|
8
|
+
export declare function findProjectLiteralSecrets(projectRoot: string, document: RuntimeConfigDocument): Promise<readonly LiteralConfigSecret[]>;
|
|
9
|
+
/** `ZHIN_<PATH_UPPER_SNAKE>`: `http.token` → `ZHIN_HTTP_TOKEN`, `qq.endpoints[0].appSecret` → `ZHIN_QQ_ENDPOINTS_0_APP_SECRET`. */
|
|
10
|
+
export declare function literalSecretEnvironmentName(path: readonly string[]): string;
|
|
11
|
+
export interface LiteralSecretMove {
|
|
12
|
+
readonly path: readonly string[];
|
|
13
|
+
readonly variable: string;
|
|
14
|
+
/** The variable already held this exact value; `.env` was not changed for it. */
|
|
15
|
+
readonly reused: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface LiteralSecretSkip {
|
|
18
|
+
readonly path: readonly string[];
|
|
19
|
+
readonly reason: string;
|
|
20
|
+
}
|
|
21
|
+
export interface LiteralSecretFixResult {
|
|
22
|
+
readonly moved: readonly LiteralSecretMove[];
|
|
23
|
+
readonly skipped: readonly LiteralSecretSkip[];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Moves every literal secret of `configFile` into `<projectRoot>/.env`.
|
|
27
|
+
*
|
|
28
|
+
* Order: `.env` first (locked, atomic; a variable that already holds the same value is reused,
|
|
29
|
+
* a name taken by another value gets `_2`, `_3`, …), then the config file through
|
|
30
|
+
* ConfigFileDocument (revision check, lock, atomic). If the config changed in between, the
|
|
31
|
+
* write is refused with a conflict and nothing in the config changes; the next run reuses the
|
|
32
|
+
* variables already written, so the operation is idempotent.
|
|
33
|
+
*/
|
|
34
|
+
export declare function moveLiteralSecretsToEnv(projectRoot: string, configFile: string): Promise<LiteralSecretFixResult>;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR 0057 §4: literal secrets in the YAML/JSON config move to `.env`.
|
|
3
|
+
* `zhin doctor` reports them; `zhin doctor --fix` writes each literal into `.env` as
|
|
4
|
+
* `ZHIN_<PATH>` and replaces the config value with `${ZHIN_<PATH>}`.
|
|
5
|
+
*/
|
|
6
|
+
import { readFile } from 'node:fs/promises';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { parse as parseDotenv } from 'dotenv';
|
|
9
|
+
import { HOST_CONFIG_SCHEMA, createConfigDocument, findLiteralConfigSecrets, readApplicationPluginDeclaration, updateDotenvFile, } from '@zhin.js/runtime';
|
|
10
|
+
import { PluginSchemaCatalog } from './plugin-schema-catalog.js';
|
|
11
|
+
/**
|
|
12
|
+
* Secret paths holding literals in `document`, using the Host schema, the root `schema.json`
|
|
13
|
+
* and each declared instance's `schema.json` (annotations), plus the field-name fallback.
|
|
14
|
+
* Plugins whose schema is only inline (`definePlugin({ config })`) use the name fallback here.
|
|
15
|
+
*/
|
|
16
|
+
export async function findProjectLiteralSecrets(projectRoot, document) {
|
|
17
|
+
let instanceKeys = [];
|
|
18
|
+
try {
|
|
19
|
+
instanceKeys = readApplicationPluginDeclaration(document).instances.map(item => item.instanceKey);
|
|
20
|
+
}
|
|
21
|
+
catch { /* Invalid declarations are reported elsewhere; still scan with the name fallback. */ }
|
|
22
|
+
const schemas = await new PluginSchemaCatalog(projectRoot).readJsonMany(['plugin', ...instanceKeys], document);
|
|
23
|
+
const plugins = [];
|
|
24
|
+
for (const [key, schema] of schemas) {
|
|
25
|
+
if (schema && !(schema instanceof Error))
|
|
26
|
+
plugins.push([[key], schema]);
|
|
27
|
+
}
|
|
28
|
+
return findLiteralConfigSecrets(document, { host: HOST_CONFIG_SCHEMA, plugins });
|
|
29
|
+
}
|
|
30
|
+
/** `ZHIN_<PATH_UPPER_SNAKE>`: `http.token` → `ZHIN_HTTP_TOKEN`, `qq.endpoints[0].appSecret` → `ZHIN_QQ_ENDPOINTS_0_APP_SECRET`. */
|
|
31
|
+
export function literalSecretEnvironmentName(path) {
|
|
32
|
+
const body = path
|
|
33
|
+
.map(segment => segment.replace(/([a-z0-9])([A-Z])/gu, '$1_$2'))
|
|
34
|
+
.join('_')
|
|
35
|
+
.replace(/[^A-Za-z0-9]+/gu, '_')
|
|
36
|
+
.replace(/^_+|_+$/gu, '')
|
|
37
|
+
.toUpperCase();
|
|
38
|
+
return `ZHIN_${body || 'SECRET'}`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Moves every literal secret of `configFile` into `<projectRoot>/.env`.
|
|
42
|
+
*
|
|
43
|
+
* Order: `.env` first (locked, atomic; a variable that already holds the same value is reused,
|
|
44
|
+
* a name taken by another value gets `_2`, `_3`, …), then the config file through
|
|
45
|
+
* ConfigFileDocument (revision check, lock, atomic). If the config changed in between, the
|
|
46
|
+
* write is refused with a conflict and nothing in the config changes; the next run reuses the
|
|
47
|
+
* variables already written, so the operation is idempotent.
|
|
48
|
+
*/
|
|
49
|
+
export async function moveLiteralSecretsToEnv(projectRoot, configFile) {
|
|
50
|
+
const config = createConfigDocument(configFile);
|
|
51
|
+
const snapshot = await config.read();
|
|
52
|
+
const secrets = await findProjectLiteralSecrets(projectRoot, snapshot.document);
|
|
53
|
+
if (secrets.length === 0)
|
|
54
|
+
return Object.freeze({ moved: Object.freeze([]), skipped: Object.freeze([]) });
|
|
55
|
+
// `.env.<environment>` overrides `.env`: a name used there with another value is taken.
|
|
56
|
+
const overlays = await Promise.all(['.env.development', '.env.production'].map(name => readOptional(join(projectRoot, name))));
|
|
57
|
+
const shadowing = overlays.map(source => parseDotenv(source));
|
|
58
|
+
const moved = [];
|
|
59
|
+
const skipped = [];
|
|
60
|
+
await updateDotenvFile(join(projectRoot, '.env'), current => {
|
|
61
|
+
moved.length = 0;
|
|
62
|
+
skipped.length = 0;
|
|
63
|
+
const existing = { ...parseDotenv(current) };
|
|
64
|
+
const lines = [];
|
|
65
|
+
for (const secret of secrets) {
|
|
66
|
+
const value = readPath(snapshot.document, secret.path);
|
|
67
|
+
if (typeof value !== 'string')
|
|
68
|
+
continue;
|
|
69
|
+
const line = (name) => dotenvLine(name, value);
|
|
70
|
+
if (!line('ZHIN_PROBE')) {
|
|
71
|
+
skipped.push(Object.freeze({ path: secret.path, reason: '值含换行或无法安全写入 .env,请手动迁移' }));
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const base = literalSecretEnvironmentName(secret.path);
|
|
75
|
+
let variable = base;
|
|
76
|
+
for (let suffix = 2;; suffix += 1) {
|
|
77
|
+
const taken = existing[variable] !== undefined && existing[variable] !== value
|
|
78
|
+
|| shadowing.some(layer => layer[variable] !== undefined && layer[variable] !== value);
|
|
79
|
+
if (!taken)
|
|
80
|
+
break;
|
|
81
|
+
variable = `${base}_${suffix}`;
|
|
82
|
+
}
|
|
83
|
+
const reused = existing[variable] === value;
|
|
84
|
+
if (!reused) {
|
|
85
|
+
lines.push(line(variable));
|
|
86
|
+
existing[variable] = value;
|
|
87
|
+
}
|
|
88
|
+
moved.push(Object.freeze({ path: secret.path, variable, reused }));
|
|
89
|
+
}
|
|
90
|
+
if (lines.length === 0)
|
|
91
|
+
return undefined;
|
|
92
|
+
const separator = current === '' || current.endsWith('\n') ? '' : '\n';
|
|
93
|
+
return `${current}${separator}${current.trim() ? '\n' : ''}# zhin doctor --fix:从 ${configFileLabel(configFile)} 移入的密钥(ADR 0057)\n${lines.join('\n')}\n`;
|
|
94
|
+
});
|
|
95
|
+
if (moved.length) {
|
|
96
|
+
const patches = moved.map(item => ({ op: 'set', path: item.path, value: `\${${item.variable}}` }));
|
|
97
|
+
const prepared = await config.prepare(snapshot, patches);
|
|
98
|
+
await prepared.commit();
|
|
99
|
+
}
|
|
100
|
+
return Object.freeze({ moved: Object.freeze(moved), skipped: Object.freeze(skipped) });
|
|
101
|
+
}
|
|
102
|
+
/** A `NAME=value` dotenv line that parses back to exactly `value`, or undefined when none does. */
|
|
103
|
+
function dotenvLine(name, value) {
|
|
104
|
+
if (/[\r\n]/u.test(value))
|
|
105
|
+
return undefined;
|
|
106
|
+
for (const quote of ["'", '`', '"']) {
|
|
107
|
+
if (value.includes(quote))
|
|
108
|
+
continue;
|
|
109
|
+
const line = `${name}=${quote}${value}${quote}`;
|
|
110
|
+
if (parseDotenv(line)[name] === value)
|
|
111
|
+
return line;
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
function readPath(document, path) {
|
|
116
|
+
let current = document;
|
|
117
|
+
for (const segment of path) {
|
|
118
|
+
if (!current || typeof current !== 'object' || !Object.hasOwn(current, segment))
|
|
119
|
+
return undefined;
|
|
120
|
+
current = current[segment];
|
|
121
|
+
}
|
|
122
|
+
return current;
|
|
123
|
+
}
|
|
124
|
+
function configFileLabel(file) {
|
|
125
|
+
return file.split(/[\\/]/u).pop() ?? file;
|
|
126
|
+
}
|
|
127
|
+
async function readOptional(file) {
|
|
128
|
+
try {
|
|
129
|
+
return await readFile(file, 'utf8');
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
|
|
133
|
+
return '';
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
package/lib/config.d.ts
CHANGED
|
@@ -8,5 +8,6 @@ export * from './config/configuration-projection.js';
|
|
|
8
8
|
export * from './config/configuration-redaction.js';
|
|
9
9
|
export * from './config/console-schema.js';
|
|
10
10
|
export * from './config/environment-files.js';
|
|
11
|
+
export * from './config/literal-secrets.js';
|
|
11
12
|
export * from './config/plugin-package-map.js';
|
|
12
13
|
export * from './config/plugin-schema-catalog.js';
|
package/lib/config.js
CHANGED
|
@@ -8,5 +8,6 @@ export * from './config/configuration-projection.js';
|
|
|
8
8
|
export * from './config/configuration-redaction.js';
|
|
9
9
|
export * from './config/console-schema.js';
|
|
10
10
|
export * from './config/environment-files.js';
|
|
11
|
+
export * from './config/literal-secrets.js';
|
|
11
12
|
export * from './config/plugin-package-map.js';
|
|
12
13
|
export * from './config/plugin-schema-catalog.js';
|
package/lib/database/config.js
CHANGED
|
@@ -53,6 +53,32 @@ function normalizeRedisDatabase(value) {
|
|
|
53
53
|
}
|
|
54
54
|
return normalized;
|
|
55
55
|
}
|
|
56
|
+
/** `database.console` (ADR 0059 §4; port of the instance's check): PostgreSQL / MySQL only. */
|
|
57
|
+
function normalizeConsoleCredential(config) {
|
|
58
|
+
if (!('console' in config) || config.console === undefined)
|
|
59
|
+
return;
|
|
60
|
+
const value = config.console;
|
|
61
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
62
|
+
throw configurationError('database.console', '必须是对象 { user, password }');
|
|
63
|
+
const block = { ...value };
|
|
64
|
+
// `${ZHIN_DB_CONSOLE_*}` not set (yet): no restricted account — raw SQL stays off, the rest works.
|
|
65
|
+
if ((block.user === undefined || block.user === '') && (block.password === undefined || block.password === '')) {
|
|
66
|
+
delete config.console;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
normalizeRequiredText(block, 'user', 'database.console.user', 'ZHIN_DB_CONSOLE_USER=zhin_console_xxxxxxxx');
|
|
70
|
+
if (typeof block.password !== 'string' || block.password === '') {
|
|
71
|
+
throw configurationError('database.console.password', '不能为空', 'ZHIN_DB_CONSOLE_PASSWORD=…(zhin db console-role 生成)');
|
|
72
|
+
}
|
|
73
|
+
if (String(block.user) === String(config.user ?? '')) {
|
|
74
|
+
throw configurationError('database.console.user', '不能与主账号 database.user 相同(原始 SQL 只在受限账号上执行)');
|
|
75
|
+
}
|
|
76
|
+
for (const key of Object.keys(block)) {
|
|
77
|
+
if (key !== 'user' && key !== 'password')
|
|
78
|
+
throw configurationError(`database.console.${key}`, '不支持(只有 user 与 password)');
|
|
79
|
+
}
|
|
80
|
+
config.console = Object.freeze({ user: block.user, password: block.password });
|
|
81
|
+
}
|
|
56
82
|
/** Validates an expanded `database` block (port of `normalizeDatabaseHostConfig`). */
|
|
57
83
|
export function normalizeProjectDatabaseConfig(input) {
|
|
58
84
|
const dialect = input.dialect;
|
|
@@ -60,6 +86,9 @@ export function normalizeProjectDatabaseConfig(input) {
|
|
|
60
86
|
throw new TypeError(`数据库配置 database.dialect 不支持 ${JSON.stringify(dialect)};可选值为 sqlite、mysql、pg、mongodb、redis、memory。`);
|
|
61
87
|
}
|
|
62
88
|
const config = { ...input, dialect };
|
|
89
|
+
if (dialect !== 'pg' && dialect !== 'mysql')
|
|
90
|
+
delete config.console;
|
|
91
|
+
delete config.denyAttach;
|
|
63
92
|
switch (dialect) {
|
|
64
93
|
case 'mysql':
|
|
65
94
|
normalizeOptionalText(config, 'host', 'database.host', 'DB_HOST=127.0.0.1');
|
|
@@ -69,6 +98,7 @@ export function normalizeProjectDatabaseConfig(input) {
|
|
|
69
98
|
config.port = normalizePort(config.port, 'database.port', 'DB_PORT=3306');
|
|
70
99
|
if (config.port === undefined)
|
|
71
100
|
delete config.port;
|
|
101
|
+
normalizeConsoleCredential(config);
|
|
72
102
|
break;
|
|
73
103
|
case 'pg':
|
|
74
104
|
normalizeOptionalText(config, 'host', 'database.host', 'DB_HOST=127.0.0.1');
|
|
@@ -78,6 +108,7 @@ export function normalizeProjectDatabaseConfig(input) {
|
|
|
78
108
|
config.port = normalizePort(config.port, 'database.port', 'DB_PORT=5432');
|
|
79
109
|
if (config.port === undefined)
|
|
80
110
|
delete config.port;
|
|
111
|
+
normalizeConsoleCredential(config);
|
|
81
112
|
break;
|
|
82
113
|
case 'mongodb':
|
|
83
114
|
normalizeRequiredText(config, 'url', 'database.url', 'DB_URL=mongodb://127.0.0.1:27017');
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type ConsoleCredentialDialect } from '@zhin.js/database';
|
|
2
|
+
export declare const CONSOLE_ROLE_USER_ENV = "ZHIN_DB_CONSOLE_USER";
|
|
3
|
+
export declare const CONSOLE_ROLE_PASSWORD_ENV = "ZHIN_DB_CONSOLE_PASSWORD";
|
|
4
|
+
/** Stands for the password in `--print-sql` output (the real one is only written to `.env`). */
|
|
5
|
+
export declare const CONSOLE_ROLE_PASSWORD_PLACEHOLDER = "<ZHIN_DB_CONSOLE_PASSWORD>";
|
|
6
|
+
export declare class ConsoleRoleError extends Error {
|
|
7
|
+
readonly code: 'unsupported' | 'unavailable' | 'pending_migration' | 'denied';
|
|
8
|
+
constructor(code: 'unsupported' | 'unavailable' | 'pending_migration' | 'denied', message: string, options?: {
|
|
9
|
+
cause?: unknown;
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
export interface SetupConsoleRoleOptions {
|
|
13
|
+
readonly projectRoot: string;
|
|
14
|
+
/** Selects `.env.<environment>` for `${…}` (default `development`). */
|
|
15
|
+
readonly environment?: string;
|
|
16
|
+
/** Role name (default `zhin_console_<hash of the project root>`). */
|
|
17
|
+
readonly role?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Do not create the account: write the password to `.env` and the reference to the config,
|
|
20
|
+
* and return the statements for a DBA (password shown as {@link CONSOLE_ROLE_PASSWORD_PLACEHOLDER}).
|
|
21
|
+
*/
|
|
22
|
+
readonly printSql?: boolean;
|
|
23
|
+
}
|
|
24
|
+
export interface SetupConsoleRoleResult {
|
|
25
|
+
readonly dialect: ConsoleCredentialDialect;
|
|
26
|
+
readonly user: string;
|
|
27
|
+
/** Created / rotated by this run (false with `printSql`). */
|
|
28
|
+
readonly provisioned: boolean;
|
|
29
|
+
/** The statements (password replaced by the placeholder). */
|
|
30
|
+
readonly statements: readonly string[];
|
|
31
|
+
readonly envFile: string;
|
|
32
|
+
}
|
|
33
|
+
export declare function setupConsoleRole(options: SetupConsoleRoleOptions): Promise<SetupConsoleRoleResult>;
|
|
34
|
+
/** The default role name of a project (what `zhin db console-role` creates). */
|
|
35
|
+
export declare function defaultConsoleRoleName(projectRoot: string): string;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `zhin db console-role` and `zhin doctor --fix` (ADR 0059 §4): create the restricted database
|
|
3
|
+
* account raw Console SQL runs on (PostgreSQL / MySQL), with the main account, store its password
|
|
4
|
+
* in `.env` and reference it from `database.console`. Never run at instance start: the instance
|
|
5
|
+
* only probes the account (and refreshes PostgreSQL grants).
|
|
6
|
+
*/
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { createConfigDocument } from '@zhin.js/runtime';
|
|
9
|
+
import { DEFAULT_ROOT_CONFIG_FILE_NAME } from '@zhin.js/plugin-runtime';
|
|
10
|
+
import { consoleRoleName, generateConsolePassword, planConsoleRole, provisionConsoleRole, scrubSecret, } from '@zhin.js/database';
|
|
11
|
+
import { ConsoleConfigurationStore } from '../config/configuration.js';
|
|
12
|
+
import { readEnvFile, writeEnvFile } from '../config/environment-files.js';
|
|
13
|
+
import { upsertDotenv } from '../iam/setup-service.js';
|
|
14
|
+
import { IAM_TABLES } from '../iam/tables.js';
|
|
15
|
+
import { findConfigFile } from '../project/config-file.js';
|
|
16
|
+
import { loadExpandedProjectConfig, resolveProjectDatabaseConfig } from './config.js';
|
|
17
|
+
import { ProjectDatabase } from './project-database.js';
|
|
18
|
+
export const CONSOLE_ROLE_USER_ENV = 'ZHIN_DB_CONSOLE_USER';
|
|
19
|
+
export const CONSOLE_ROLE_PASSWORD_ENV = 'ZHIN_DB_CONSOLE_PASSWORD';
|
|
20
|
+
/** Stands for the password in `--print-sql` output (the real one is only written to `.env`). */
|
|
21
|
+
export const CONSOLE_ROLE_PASSWORD_PLACEHOLDER = `<${CONSOLE_ROLE_PASSWORD_ENV}>`;
|
|
22
|
+
export class ConsoleRoleError extends Error {
|
|
23
|
+
code;
|
|
24
|
+
constructor(code, message, options) {
|
|
25
|
+
super(message, options);
|
|
26
|
+
this.code = code;
|
|
27
|
+
this.name = 'ConsoleRoleError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const ENV_REFERENCE = { user: `\${${CONSOLE_ROLE_USER_ENV}}`, password: `\${${CONSOLE_ROLE_PASSWORD_ENV}}` };
|
|
31
|
+
export async function setupConsoleRole(options) {
|
|
32
|
+
const { projectRoot } = options;
|
|
33
|
+
const expanded = await loadExpandedProjectConfig(projectRoot, options.environment ?? 'development');
|
|
34
|
+
const config = await resolveProjectDatabaseConfig(projectRoot, expanded);
|
|
35
|
+
if (config.dialect !== 'pg' && config.dialect !== 'mysql') {
|
|
36
|
+
throw new ConsoleRoleError('unsupported', `项目数据库是 ${config.dialect}:只有 PostgreSQL / MySQL 需要受限账号(SQLite 的 Console 账号在另一个文件里,且实例连接拒绝 ATTACH)`);
|
|
37
|
+
}
|
|
38
|
+
const dialect = config.dialect;
|
|
39
|
+
const main = new ProjectDatabase(config);
|
|
40
|
+
try {
|
|
41
|
+
await main.start();
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
throw new ConsoleRoleError('unavailable', `无法用主账号连接项目数据库:${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
45
|
+
}
|
|
46
|
+
const password = generateConsolePassword();
|
|
47
|
+
let statements;
|
|
48
|
+
let user;
|
|
49
|
+
try {
|
|
50
|
+
// The old console_* tables would be inside a MySQL database-wide grant: import them first.
|
|
51
|
+
const legacy = await main.tableExists(Object.values(IAM_TABLES));
|
|
52
|
+
if (legacy.length) {
|
|
53
|
+
throw new ConsoleRoleError('pending_migration', `项目数据库里还有旧版的 Console 账号表(${legacy.join(', ')}):先运行 zhin start 或 zhin console migrate 完成迁移`);
|
|
54
|
+
}
|
|
55
|
+
const run = (sql, params) => main.query(sql, params);
|
|
56
|
+
const request = { dialect, projectKey: projectRoot, password, ...(options.role ? { role: options.role } : {}) };
|
|
57
|
+
if (options.printSql) {
|
|
58
|
+
const plan = await planConsoleRole(run, request);
|
|
59
|
+
statements = plan.statements;
|
|
60
|
+
user = plan.user;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
try {
|
|
64
|
+
const plan = await provisionConsoleRole(run, request);
|
|
65
|
+
statements = plan.statements;
|
|
66
|
+
user = plan.user;
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
// The message never carries the new password (provisionConsoleRole scrubs; scrub again here).
|
|
70
|
+
throw new ConsoleRoleError('denied', scrubSecret(`主账号无法创建受限账号(${error instanceof Error ? error.message : String(error)});改用 zhin db console-role --print-sql 把 SQL 交给 DBA 执行`, [password]));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (error instanceof ConsoleRoleError)
|
|
76
|
+
throw error;
|
|
77
|
+
throw new ConsoleRoleError('unavailable', scrubSecret(error instanceof Error ? error.message : String(error), [password]));
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
await main.stop().catch(() => undefined);
|
|
81
|
+
}
|
|
82
|
+
// The password lives only in .env (ADR 0057); the config references it.
|
|
83
|
+
await writeEnvFile(projectRoot, '.env', upsertDotenv(await readEnvFile(projectRoot, '.env'), {
|
|
84
|
+
[CONSOLE_ROLE_USER_ENV]: user,
|
|
85
|
+
[CONSOLE_ROLE_PASSWORD_ENV]: password,
|
|
86
|
+
}));
|
|
87
|
+
const store = new ConsoleConfigurationStore({
|
|
88
|
+
projectRoot,
|
|
89
|
+
document: createConfigDocument(join(projectRoot, findConfigFile(projectRoot) ?? DEFAULT_ROOT_CONFIG_FILE_NAME)),
|
|
90
|
+
});
|
|
91
|
+
const document = await store.readDocument();
|
|
92
|
+
const block = document.database && typeof document.database === 'object' && !Array.isArray(document.database)
|
|
93
|
+
? document.database
|
|
94
|
+
: { dialect };
|
|
95
|
+
await store.setKey('database', { ...block, console: { ...ENV_REFERENCE } });
|
|
96
|
+
return Object.freeze({
|
|
97
|
+
dialect,
|
|
98
|
+
user,
|
|
99
|
+
provisioned: !options.printSql,
|
|
100
|
+
// PostgreSQL statements carry a SCRAM verifier, never the password; MySQL's are shown with a placeholder.
|
|
101
|
+
statements: Object.freeze(statements.map(statement => scrubSecret(statement, [password]).split('[redacted]').join(CONSOLE_ROLE_PASSWORD_PLACEHOLDER))),
|
|
102
|
+
envFile: '.env',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/** The default role name of a project (what `zhin db console-role` creates). */
|
|
106
|
+
export function defaultConsoleRoleName(projectRoot) {
|
|
107
|
+
return consoleRoleName(projectRoot);
|
|
108
|
+
}
|