@zhin.js/gateway 1.1.1 → 1.1.2
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/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/http/console-rpc.d.ts +9 -1
- 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 +214 -0
- package/lib/http/start-gateway.js +6 -0
- package/lib/local/handler.js +1 -1
- 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 +5 -5
|
@@ -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';
|
|
@@ -12,8 +12,16 @@ export declare const GATEWAY_RPC_PREFIX = "gateway.";
|
|
|
12
12
|
export interface GatewayConsoleRpc {
|
|
13
13
|
handle(message: Readonly<Record<string, unknown>>, context: RouteContext, tokenRegistry: TokenRegistry): Promise<Response>;
|
|
14
14
|
}
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Further gateway-owned RPC types (`iam.*`, `onboarding.update`, the global plugin switch), see
|
|
17
|
+
* `GatewayHttpServer.useConsoleRpc`.
|
|
18
|
+
*/
|
|
16
19
|
export interface GatewayRpcHandler extends GatewayConsoleRpc {
|
|
17
20
|
owns(type: string): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Narrows {@link owns} by payload: when present and false for the parsed message, the request
|
|
23
|
+
* is forwarded to the instance untouched (e.g. `feature.set-enabled` below the global level).
|
|
24
|
+
*/
|
|
25
|
+
claims?(message: Readonly<Record<string, unknown>>): boolean;
|
|
18
26
|
}
|
|
19
27
|
export declare function createGatewayConsoleRpc(dispatch: GatewayRpcDispatcher): GatewayConsoleRpc;
|
|
@@ -10,10 +10,15 @@
|
|
|
10
10
|
* Console reconnects to the merged stream. `GET {base}/events/history` then answers an empty
|
|
11
11
|
* page that keeps the caller's cursor.
|
|
12
12
|
*
|
|
13
|
+
* A succeeded plugin enable / disable that changed `disable_plugins` also sends
|
|
14
|
+
* `plugin:lifecycle-updated` on `{base}/events` (the Console reloads its entries on it).
|
|
15
|
+
*
|
|
13
16
|
* `GET {base}/gateway/packages/progress` serves the progress frames alone.
|
|
14
17
|
*/
|
|
15
18
|
import type { GatewayPackageEvent } from '@zhin.js/console-protocol';
|
|
16
19
|
import type { GatewayHttpServer } from './gateway-http-server.js';
|
|
20
|
+
/** Console event the client reloads its entries on (`CONSOLE_ENTRY_LIFECYCLE_EVENTS`). */
|
|
21
|
+
export declare const PLUGIN_LIFECYCLE_UPDATED = "plugin:lifecycle-updated";
|
|
17
22
|
export interface GatewayEventSource {
|
|
18
23
|
/** Package transaction events (`GatewayPackageService.subscribe`). */
|
|
19
24
|
subscribe(listener: (event: GatewayPackageEvent) => void): () => void;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { GATEWAY_PACKAGES_PROGRESS, GATEWAY_PACKAGES_PROGRESS_PATH } from '@zhin.js/console-protocol';
|
|
2
2
|
import { jsonResponse } from '@zhin.js/host-http';
|
|
3
3
|
const KEEPALIVE_MS = 15_000;
|
|
4
|
+
/** Console event the client reloads its entries on (`CONSOLE_ENTRY_LIFECYCLE_EVENTS`). */
|
|
5
|
+
export const PLUGIN_LIFECYCLE_UPDATED = 'plugin:lifecycle-updated';
|
|
4
6
|
/** Gateway frames held while an instance frame is half written. */
|
|
5
7
|
const MAX_PENDING_FRAMES = 1_024;
|
|
6
8
|
/** Chunks queued for a client that does not read; beyond it the stream ends (the client resumes). */
|
|
@@ -63,7 +65,18 @@ export function registerGatewayEventRoutes(options) {
|
|
|
63
65
|
cleanups.push(() => clearInterval(timer));
|
|
64
66
|
return { stream, write, finish, cleanups, get finished() { return finished; } };
|
|
65
67
|
};
|
|
66
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Progress frames; on the Console event stream (`lifecycle`) a succeeded enable / disable that
|
|
70
|
+
* changed `disable_plugins` is followed by `plugin:lifecycle-updated` `{ instanceKey, enabled }`,
|
|
71
|
+
* the event the instance used to publish for the Console switch (the Console reloads its entries).
|
|
72
|
+
*/
|
|
73
|
+
const subscribeProgress = (deliver, lifecycle = false) => options.packages?.subscribe((event) => {
|
|
74
|
+
deliver(gatewayEventFrame(GATEWAY_PACKAGES_PROGRESS, event));
|
|
75
|
+
const plugin = event.type === 'result' && event.result.ok ? event.result.plugin : undefined;
|
|
76
|
+
if (lifecycle && plugin?.changed) {
|
|
77
|
+
deliver(gatewayEventFrame(PLUGIN_LIFECYCLE_UPDATED, { instanceKey: plugin.instanceKey, enabled: plugin.enabled }));
|
|
78
|
+
}
|
|
79
|
+
});
|
|
67
80
|
const events = async (request, context) => {
|
|
68
81
|
if (!server.instanceReady)
|
|
69
82
|
return offlineStream(request);
|
|
@@ -95,7 +108,7 @@ export function registerGatewayEventRoutes(options) {
|
|
|
95
108
|
return;
|
|
96
109
|
}
|
|
97
110
|
client.write(frame);
|
|
98
|
-
});
|
|
111
|
+
}, true);
|
|
99
112
|
if (stop)
|
|
100
113
|
client.cleanups.push(stop);
|
|
101
114
|
void (async () => {
|
|
@@ -121,7 +134,7 @@ export function registerGatewayEventRoutes(options) {
|
|
|
121
134
|
const offlineStream = (request) => {
|
|
122
135
|
const client = open(request);
|
|
123
136
|
client.write(': gateway\n\n');
|
|
124
|
-
const stop = subscribeProgress((frame) => client.write(frame));
|
|
137
|
+
const stop = subscribeProgress((frame) => client.write(frame), true);
|
|
125
138
|
if (stop)
|
|
126
139
|
client.cleanups.push(stop);
|
|
127
140
|
// Reconnect to the merged stream once the instance is back.
|
|
@@ -279,13 +279,17 @@ export class GatewayHttpServer {
|
|
|
279
279
|
catch {
|
|
280
280
|
return jsonResponse(400, { success: false, code: 'invalid_body', error: 'Invalid JSON body' });
|
|
281
281
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
282
|
+
const claimed = typeof message === 'object' && message !== null && !Array.isArray(message)
|
|
283
|
+
&& (!('claims' in owner) || typeof owner.claims !== 'function' || owner.claims(message));
|
|
284
|
+
if (claimed) {
|
|
285
|
+
try {
|
|
286
|
+
return await owner.handle(message, context, this.tokenRegistry);
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
if (isDatabaseBusyError(error))
|
|
290
|
+
return databaseBusyResponse({ requestId: message.requestId });
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
289
293
|
}
|
|
290
294
|
}
|
|
291
295
|
const headers = new Headers(request.headers);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { GatewayPackageService } from '../packages/service.js';
|
|
2
|
+
import type { GatewayRpcHandler } from './console-rpc.js';
|
|
3
|
+
/** What the switch needs from the package service. */
|
|
4
|
+
export type GatewayPluginSwitchPackages = Pick<GatewayPackageService, 'setEnabled'>;
|
|
5
|
+
/** The root configuration as written after the transaction. */
|
|
6
|
+
export interface GatewayPluginSwitchConfigRead {
|
|
7
|
+
readonly revision: string;
|
|
8
|
+
readonly document: Readonly<Record<string, unknown>>;
|
|
9
|
+
}
|
|
10
|
+
export interface GatewayPluginSwitchRpcOptions {
|
|
11
|
+
readonly projectRoot: string;
|
|
12
|
+
/** Undefined when the gateway manages no packages: the switch then answers 503 (never forwarded). */
|
|
13
|
+
readonly packages: GatewayPluginSwitchPackages | undefined;
|
|
14
|
+
/** Reads the root configuration (revision and document). Defaults to the project's config file. */
|
|
15
|
+
readonly readConfig?: () => Promise<GatewayPluginSwitchConfigRead>;
|
|
16
|
+
}
|
|
17
|
+
/** Whether the gateway answers this Console RPC message itself (the global plugin switch). */
|
|
18
|
+
export declare function isGatewayPluginSwitch(message: Readonly<Record<string, unknown>>): boolean;
|
|
19
|
+
export declare function createGatewayPluginSwitchRpc(options: GatewayPluginSwitchRpcOptions): GatewayRpcHandler;
|