@zhin.js/gateway 0.0.1-bootstrap.0 → 1.1.1

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.
Files changed (177) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/lib/config/ai-config-loader.d.ts +22 -0
  4. package/lib/config/ai-config-loader.js +96 -0
  5. package/lib/config/configuration-chat-policy.d.ts +57 -0
  6. package/lib/config/configuration-chat-policy.js +423 -0
  7. package/lib/config/configuration-patch.d.ts +16 -0
  8. package/lib/config/configuration-patch.js +105 -0
  9. package/lib/config/configuration-path.d.ts +6 -0
  10. package/lib/config/configuration-path.js +40 -0
  11. package/lib/config/configuration-projection.d.ts +6 -0
  12. package/lib/config/configuration-projection.js +28 -0
  13. package/lib/config/configuration-redaction.d.ts +24 -0
  14. package/lib/config/configuration-redaction.js +128 -0
  15. package/lib/config/configuration.d.ts +81 -0
  16. package/lib/config/configuration.js +686 -0
  17. package/lib/config/console-schema.d.ts +11 -0
  18. package/lib/config/console-schema.js +222 -0
  19. package/lib/config/environment-files.d.ts +2 -0
  20. package/lib/config/environment-files.js +21 -0
  21. package/lib/config/plugin-package-map.d.ts +2 -0
  22. package/lib/config/plugin-package-map.js +14 -0
  23. package/lib/config/plugin-schema-catalog.d.ts +22 -0
  24. package/lib/config/plugin-schema-catalog.js +262 -0
  25. package/lib/config.d.ts +12 -0
  26. package/lib/config.js +12 -0
  27. package/lib/database/config.d.ts +22 -0
  28. package/lib/database/config.js +159 -0
  29. package/lib/database/index.d.ts +2 -0
  30. package/lib/database/index.js +2 -0
  31. package/lib/database/project-database.d.ts +31 -0
  32. package/lib/database/project-database.js +130 -0
  33. package/lib/database.d.ts +2 -0
  34. package/lib/database.js +2 -0
  35. package/lib/http/console-rpc.d.ts +19 -0
  36. package/lib/http/console-rpc.js +51 -0
  37. package/lib/http/gateway-console.d.ts +63 -0
  38. package/lib/http/gateway-console.js +309 -0
  39. package/lib/http/gateway-events.d.ts +35 -0
  40. package/lib/http/gateway-events.js +172 -0
  41. package/lib/http/gateway-http-server.d.ts +95 -0
  42. package/lib/http/gateway-http-server.js +518 -0
  43. package/lib/http/local-principal.d.ts +2 -0
  44. package/lib/http/local-principal.js +16 -0
  45. package/lib/http/run-gateway.d.ts +43 -0
  46. package/lib/http/run-gateway.js +268 -0
  47. package/lib/http/start-gateway.d.ts +114 -0
  48. package/lib/http/start-gateway.js +220 -0
  49. package/lib/http-response.d.ts +6 -0
  50. package/lib/http-response.js +25 -0
  51. package/lib/http.d.ts +6 -0
  52. package/lib/http.js +6 -0
  53. package/lib/iam/crypto.d.ts +12 -0
  54. package/lib/iam/crypto.js +58 -0
  55. package/lib/iam/index.d.ts +8 -0
  56. package/lib/iam/index.js +8 -0
  57. package/lib/iam/install.d.ts +36 -0
  58. package/lib/iam/install.js +74 -0
  59. package/lib/iam/local-admin.d.ts +27 -0
  60. package/lib/iam/local-admin.js +102 -0
  61. package/lib/iam/rate-limit.d.ts +15 -0
  62. package/lib/iam/rate-limit.js +52 -0
  63. package/lib/iam/routes.d.ts +23 -0
  64. package/lib/iam/routes.js +157 -0
  65. package/lib/iam/rpc.d.ts +14 -0
  66. package/lib/iam/rpc.js +100 -0
  67. package/lib/iam/service.d.ts +203 -0
  68. package/lib/iam/service.js +784 -0
  69. package/lib/iam/setup-service.d.ts +83 -0
  70. package/lib/iam/setup-service.js +206 -0
  71. package/lib/iam/tables.d.ts +20 -0
  72. package/lib/iam/tables.js +82 -0
  73. package/lib/iam.d.ts +2 -0
  74. package/lib/iam.js +2 -0
  75. package/lib/index.d.ts +22 -0
  76. package/lib/index.js +22 -0
  77. package/lib/local/client.d.ts +71 -0
  78. package/lib/local/client.js +296 -0
  79. package/lib/local/handler.d.ts +30 -0
  80. package/lib/local/handler.js +86 -0
  81. package/lib/local/local-socket.d.ts +55 -0
  82. package/lib/local/local-socket.js +232 -0
  83. package/lib/local/paths.d.ts +34 -0
  84. package/lib/local/paths.js +58 -0
  85. package/lib/local/protocol.d.ts +140 -0
  86. package/lib/local/protocol.js +90 -0
  87. package/lib/local/state-file.d.ts +8 -0
  88. package/lib/local/state-file.js +45 -0
  89. package/lib/local.d.ts +10 -0
  90. package/lib/local.js +10 -0
  91. package/lib/onboarding/allowlist.d.ts +54 -0
  92. package/lib/onboarding/allowlist.js +106 -0
  93. package/lib/onboarding/errors.d.ts +7 -0
  94. package/lib/onboarding/errors.js +17 -0
  95. package/lib/onboarding/index.d.ts +6 -0
  96. package/lib/onboarding/index.js +6 -0
  97. package/lib/onboarding/install.d.ts +99 -0
  98. package/lib/onboarding/install.js +132 -0
  99. package/lib/onboarding/package-manager.d.ts +47 -0
  100. package/lib/onboarding/package-manager.js +179 -0
  101. package/lib/onboarding/service.d.ts +53 -0
  102. package/lib/onboarding/service.js +144 -0
  103. package/lib/onboarding/state-store.d.ts +26 -0
  104. package/lib/onboarding/state-store.js +97 -0
  105. package/lib/onboarding.d.ts +2 -0
  106. package/lib/onboarding.js +2 -0
  107. package/lib/packages/authorization.d.ts +23 -0
  108. package/lib/packages/authorization.js +109 -0
  109. package/lib/packages/errors.d.ts +11 -0
  110. package/lib/packages/errors.js +26 -0
  111. package/lib/packages/index.d.ts +5 -0
  112. package/lib/packages/index.js +5 -0
  113. package/lib/packages/rpc.d.ts +30 -0
  114. package/lib/packages/rpc.js +155 -0
  115. package/lib/packages/service.d.ts +121 -0
  116. package/lib/packages/service.js +441 -0
  117. package/lib/packages/snapshot.d.ts +14 -0
  118. package/lib/packages/snapshot.js +73 -0
  119. package/lib/packages.d.ts +2 -0
  120. package/lib/packages.js +2 -0
  121. package/lib/plugin-management/activity.d.ts +6 -0
  122. package/lib/plugin-management/activity.js +14 -0
  123. package/lib/plugin-management/config-explanation.d.ts +6 -0
  124. package/lib/plugin-management/config-explanation.js +113 -0
  125. package/lib/plugin-management/use-cases.d.ts +15 -0
  126. package/lib/plugin-management/use-cases.js +120 -0
  127. package/lib/plugin-management/workflow.d.ts +92 -0
  128. package/lib/plugin-management/workflow.js +772 -0
  129. package/lib/plugin-management.d.ts +5 -0
  130. package/lib/plugin-management.js +5 -0
  131. package/lib/project/config-file.d.ts +6 -0
  132. package/lib/project/config-file.js +58 -0
  133. package/lib/project/environment.d.ts +13 -0
  134. package/lib/project/environment.js +52 -0
  135. package/lib/project/instance-declarations.d.ts +55 -0
  136. package/lib/project/instance-declarations.js +542 -0
  137. package/lib/project/legacy-config.d.ts +25 -0
  138. package/lib/project/legacy-config.js +70 -0
  139. package/lib/project/node-requirements.d.ts +7 -0
  140. package/lib/project/node-requirements.js +20 -0
  141. package/lib/project/tsx-module-loader.d.ts +7 -0
  142. package/lib/project/tsx-module-loader.js +29 -0
  143. package/lib/project.d.ts +7 -0
  144. package/lib/project.js +7 -0
  145. package/lib/server.d.ts +11 -0
  146. package/lib/server.js +11 -0
  147. package/lib/supervisor/instance-logs.d.ts +39 -0
  148. package/lib/supervisor/instance-logs.js +152 -0
  149. package/lib/supervisor/instance-supervisor.d.ts +140 -0
  150. package/lib/supervisor/instance-supervisor.js +408 -0
  151. package/lib/supervisor/run-instance.d.ts +35 -0
  152. package/lib/supervisor/run-instance.js +161 -0
  153. package/lib/supervisor/terminal-state.d.ts +30 -0
  154. package/lib/supervisor/terminal-state.js +57 -0
  155. package/lib/supervisor.d.ts +4 -0
  156. package/lib/supervisor.js +4 -0
  157. package/lib/terminal/backends.d.ts +38 -0
  158. package/lib/terminal/backends.js +193 -0
  159. package/lib/terminal/config.d.ts +63 -0
  160. package/lib/terminal/config.js +237 -0
  161. package/lib/terminal/host.d.ts +26 -0
  162. package/lib/terminal/host.js +76 -0
  163. package/lib/terminal/index.d.ts +60 -0
  164. package/lib/terminal/index.js +135 -0
  165. package/lib/terminal/modules.d.ts +87 -0
  166. package/lib/terminal/modules.js +44 -0
  167. package/lib/terminal/service.d.ts +101 -0
  168. package/lib/terminal/service.js +455 -0
  169. package/lib/terminal/tickets.d.ts +46 -0
  170. package/lib/terminal/tickets.js +76 -0
  171. package/lib/terminal.d.ts +3 -0
  172. package/lib/terminal.js +3 -0
  173. package/lib/usage/aggregate.d.ts +87 -0
  174. package/lib/usage/aggregate.js +212 -0
  175. package/lib/usage.d.ts +2 -0
  176. package/lib/usage.js +2 -0
  177. package/package.json +117 -4
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Console access control in the gateway (ADR 0054 §3 / §7, ADR 0049, ADR 0053 §4): the gateway
3
+ * opens the project database itself, owns the `console_*` tables, authenticates sessions / API
4
+ * tokens / the bootstrap token locally, and serves `/auth/*`, `iam.*`, `onboarding.update`, the
5
+ * setup wizard, the terminal and the audit log — all without an instance.
6
+ *
7
+ * The instance contributes what only it knows over the bridge: the plugin permission catalog
8
+ * (`catalog`) and its own audit entries (`audit`); the gateway tells it when a user's credentials
9
+ * were revoked (`principal-revoked`) so it closes that user's SSE / WebSocket connections.
10
+ */
11
+ import { join } from 'node:path';
12
+ import { auditActor, authorizeConsoleRpc, jsonResponse, rpcAuditDetail, rpcAuditTarget, } from '@zhin.js/host-http';
13
+ import { ONBOARDING_RPC } from '@zhin.js/console-protocol';
14
+ import { DEFAULT_ROOT_CONFIG_FILE_NAME } from '@zhin.js/plugin-runtime';
15
+ import { createConfigDocument } from '@zhin.js/runtime';
16
+ import { formatCompact, getLogger } from '@zhin.js/logger';
17
+ import { ConsoleConfigurationStore } from '../config/configuration.js';
18
+ import { readEnvFile, writeEnvFile } from '../config/environment-files.js';
19
+ import { loadExpandedProjectConfig, resolveProjectDatabaseConfig } from '../database/config.js';
20
+ import { ProjectDatabase } from '../database/project-database.js';
21
+ import { projectEnvironment } from '../iam/install.js';
22
+ import { createLocalConsoleAdmin } from '../iam/local-admin.js';
23
+ import { registerConsoleAuthRoutes } from '../iam/routes.js';
24
+ import { dispatchIamRpc, isIamRpc } from '../iam/rpc.js';
25
+ import { ConsoleIamService } from '../iam/service.js';
26
+ import { ConsoleSetupService } from '../iam/setup-service.js';
27
+ import { defineConsoleIamTables } from '../iam/tables.js';
28
+ import { OnboardingError } from '../onboarding/errors.js';
29
+ import { OnboardingService } from '../onboarding/service.js';
30
+ import { findConfigFile } from '../project/config-file.js';
31
+ import { installHostConsoleTerminal } from '../terminal/host.js';
32
+ const logger = getLogger('gateway');
33
+ const DEFAULT_RETRY_MS = 10_000;
34
+ /** A database that never starts: the IAM service then fails closed on its setup marker. */
35
+ const UNAVAILABLE_DATABASE = Object.freeze({ started: false, models: Object.freeze({ get: () => undefined }) });
36
+ export async function startGatewayConsole(options) {
37
+ const { server, projectRoot } = options;
38
+ const failClosed = () => ({
39
+ service: new ConsoleIamService({ database: UNAVAILABLE_DATABASE, projectRoot }),
40
+ supported: true,
41
+ dispose: [],
42
+ });
43
+ const environment = options.environment ?? 'development';
44
+ const retryMs = options.retryMs ?? DEFAULT_RETRY_MS;
45
+ const registry = server.tokenRegistry;
46
+ const settings = await loadExpandedProjectConfig(projectRoot, environment).catch((error) => {
47
+ logger.warn(formatCompact({ op: 'gateway_config_read_failed', error: errorText(error) }));
48
+ return Object.freeze({});
49
+ });
50
+ // Until (and while re-) connecting, fail closed: the owner state comes from the setup marker.
51
+ let connection = failClosed();
52
+ let closed = false;
53
+ let retryTimer;
54
+ let opening;
55
+ const iam = () => (connection.supported ? connection.service : undefined);
56
+ const disposers = [];
57
+ // Level grants need the catalog of the instance's loaded plugins (kept across restarts).
58
+ disposers.push(registry.usePluginPermissions(() => options.instance?.pluginPermissions?.() ?? []));
59
+ const access = Object.freeze({
60
+ initialized: () => connection.service?.initialized() ?? false,
61
+ authenticate: async (token, meta) => (await iam()?.authenticate(token, meta)) ?? null,
62
+ audit: (entry) => { iam()?.audit(entry); },
63
+ });
64
+ disposers.push(registry.useAccessProvider(access));
65
+ const stopInstanceAudit = options.instance?.onAudit?.((entry) => { iam()?.audit(entry); });
66
+ if (stopInstanceAudit)
67
+ disposers.push(stopInstanceAudit);
68
+ const terminal = installHostConsoleTerminal(server, {
69
+ config: options.terminal !== undefined ? options.terminal : readTerminalBlock(settings),
70
+ projectRoot,
71
+ base: server.apiBase,
72
+ iam,
73
+ logger: { warn: (message) => logger.warn(message), info: (message) => logger.info(message) },
74
+ });
75
+ const revoke = (userId, reason) => {
76
+ terminal.terminatePrincipal(`user:${userId}`, 'revoked');
77
+ try {
78
+ options.instance?.revokePrincipal?.(userId, reason);
79
+ }
80
+ catch { /* bridge gone */ }
81
+ };
82
+ async function connect() {
83
+ let config;
84
+ try {
85
+ config = options.database ?? await resolveProjectDatabaseConfig(projectRoot, await loadExpandedProjectConfig(projectRoot, environment));
86
+ }
87
+ catch (error) {
88
+ logger.warn(formatCompact({ op: 'gateway_database_config_invalid', error: errorText(error) }));
89
+ connection = failClosed();
90
+ return;
91
+ }
92
+ let database;
93
+ try {
94
+ database = new ProjectDatabase(config);
95
+ }
96
+ catch (error) {
97
+ logger.warn(formatCompact({ op: 'gateway_database_driver_failed', dialect: config.dialect, error: errorText(error) }));
98
+ connection = failClosed();
99
+ return;
100
+ }
101
+ const supported = defineConsoleIamTables(database);
102
+ if (!supported) {
103
+ logger.warn('console iam: the configured database cannot store Console users; run the setup wizard to choose sqlite/mysql/pg/mongodb');
104
+ connection = { database, supported: false, dispose: [] };
105
+ return;
106
+ }
107
+ const service = new ConsoleIamService({
108
+ database,
109
+ projectRoot,
110
+ onError: (error) => logger.warn(`console iam: ${errorText(error)}`),
111
+ pluginPermissions: () => registry.pluginPermissions(),
112
+ });
113
+ const dispose = [service.onCredentialsRevoked(({ userId, reason }) => revoke(userId, reason))];
114
+ connection = { database, service, supported: true, dispose };
115
+ try {
116
+ await database.start();
117
+ await service.start();
118
+ logger.info(formatCompact({ op: 'gateway_database_open', dialect: config.dialect }));
119
+ }
120
+ catch (error) {
121
+ // Not ready: `/auth/*` answers 503 and the owner state comes from the setup marker.
122
+ logger.warn(formatCompact({ op: 'gateway_database_open_failed', dialect: config.dialect, error: errorText(error) }));
123
+ scheduleRetry();
124
+ }
125
+ }
126
+ function scheduleRetry() {
127
+ if (closed || retryTimer)
128
+ return;
129
+ retryTimer = setTimeout(() => {
130
+ retryTimer = undefined;
131
+ if (closed)
132
+ return;
133
+ void reopen();
134
+ }, retryMs);
135
+ retryTimer.unref?.();
136
+ }
137
+ async function disconnect() {
138
+ const current = connection;
139
+ connection = failClosed();
140
+ for (const dispose of current.dispose.splice(0))
141
+ dispose();
142
+ current.service?.stop();
143
+ await current.database?.stop().catch((error) => {
144
+ logger.warn(formatCompact({ op: 'gateway_database_close_failed', error: errorText(error) }));
145
+ });
146
+ }
147
+ function reopen() {
148
+ opening = (opening ?? Promise.resolve()).then(async () => {
149
+ if (closed)
150
+ return;
151
+ clearTimeout(retryTimer);
152
+ retryTimer = undefined;
153
+ await disconnect();
154
+ if (!closed)
155
+ await connect();
156
+ });
157
+ return opening;
158
+ }
159
+ const configFile = join(projectRoot, findConfigFile(projectRoot) ?? DEFAULT_ROOT_CONFIG_FILE_NAME);
160
+ const setup = new ConsoleSetupService({
161
+ projectRoot,
162
+ config: new ConsoleConfigurationStore({ projectRoot, document: createConfigDocument(configFile) }),
163
+ envFile: {
164
+ read: () => readEnvFile(projectRoot, '.env'),
165
+ write: (content) => writeEnvFile(projectRoot, '.env', content),
166
+ },
167
+ iam,
168
+ environment: () => projectEnvironment(projectRoot),
169
+ });
170
+ const onboarding = options.onboarding ?? new OnboardingService({
171
+ projectRoot,
172
+ hasOwner: () => connection.service?.initialized() ?? false,
173
+ });
174
+ const restartInstance = () => {
175
+ void options.restartInstance?.().catch((error) => {
176
+ logger.warn(formatCompact({ op: 'gateway_database_switch_failed', error: errorText(error) }));
177
+ });
178
+ };
179
+ const localAdmin = createLocalConsoleAdmin({
180
+ setup,
181
+ iam,
182
+ // ADR 0054 §7, as for the web wizard: the gateway uses the new database before it answers.
183
+ switchDatabase: async () => {
184
+ await reopen();
185
+ restartInstance();
186
+ return { restarting: options.restartInstance !== undefined };
187
+ },
188
+ });
189
+ disposers.push(...registerConsoleAuthRoutes({
190
+ http: server,
191
+ base: server.apiBase,
192
+ iam,
193
+ setup,
194
+ onboarding: () => onboarding.status(),
195
+ // ADR 0054 §7: close the gateway's connection, open the new database, restart the instance.
196
+ requestRestart: () => {
197
+ void reopen()
198
+ .then(() => options.restartInstance?.())
199
+ .catch((error) => logger.warn(formatCompact({ op: 'gateway_database_switch_failed', error: errorText(error) })));
200
+ },
201
+ }));
202
+ // `/auth/*` belongs to the gateway alone: nothing under it is forwarded to the instance.
203
+ disposers.push(server.route('ALL', `${server.apiBase}/auth/*`, () => jsonResponse(404, { success: false, code: 'not_found', error: 'Not found' }), { summary: 'Unknown /auth route', tags: ['auth'] }));
204
+ disposers.push(server.useConsoleRpc(createGatewayAccountRpc({ iam, onboarding })));
205
+ const ready = reopen().catch((error) => {
206
+ logger.warn(formatCompact({ op: 'gateway_database_open_failed', error: errorText(error) }));
207
+ });
208
+ let closeResult;
209
+ return Object.freeze({
210
+ ready,
211
+ iam,
212
+ database: () => connection.database,
213
+ setup,
214
+ onboarding,
215
+ terminal,
216
+ reopenDatabase: reopen,
217
+ localAdmin,
218
+ close() {
219
+ closeResult ??= (async () => {
220
+ closed = true;
221
+ clearTimeout(retryTimer);
222
+ terminal.close();
223
+ for (const dispose of disposers.splice(0).reverse()) {
224
+ try {
225
+ dispose();
226
+ }
227
+ catch { /* keep closing */ }
228
+ }
229
+ await opening?.catch(() => undefined);
230
+ await disconnect();
231
+ })();
232
+ return closeResult;
233
+ },
234
+ });
235
+ }
236
+ /** `console.terminal` of the expanded root config. */
237
+ function readTerminalBlock(document) {
238
+ const block = document.console;
239
+ return block && typeof block === 'object' && !Array.isArray(block) ? block.terminal : undefined;
240
+ }
241
+ const ONBOARDING_STEPS = new Set(['ai', 'adapters', 'finish']);
242
+ /**
243
+ * `iam.*` (ADR 0049 §5) and `onboarding.update` (ADR 0053 §4) on the gateway's
244
+ * `POST {base}/console/request`. Authorization uses the shared permission map
245
+ * (`CONSOLE_RPC_PERMISSIONS`); denials and writes are audited like in the instance dispatcher.
246
+ */
247
+ export function createGatewayAccountRpc(options) {
248
+ return Object.freeze({
249
+ owns: (type) => isIamRpc(type) || type === ONBOARDING_RPC.UPDATE,
250
+ async handle(message, context, registry) {
251
+ const noStore = { 'cache-control': 'no-store' };
252
+ const requestId = message.requestId;
253
+ const principal = context.principal;
254
+ if (!principal) {
255
+ return jsonResponse(401, { success: false, code: 'unauthorized', error: 'Invalid or missing token', requestId }, noStore);
256
+ }
257
+ const decision = authorizeConsoleRpc(principal, message);
258
+ const audit = (outcome) => {
259
+ try {
260
+ registry.accessProvider?.audit?.({
261
+ actor: auditActor(principal),
262
+ action: `rpc:${String(message.type ?? '')}`.slice(0, 256),
263
+ ...(decision.allowed && rpcAuditTarget(decision.rule) ? { target: rpcAuditTarget(decision.rule) } : {}),
264
+ outcome,
265
+ detail: rpcAuditDetail(message),
266
+ ...(context.remoteAddress ? { ip: context.remoteAddress } : {}),
267
+ });
268
+ }
269
+ catch { /* audit never breaks a request */ }
270
+ };
271
+ if (!decision.allowed) {
272
+ audit('denied');
273
+ return jsonResponse(403, { success: false, code: decision.code, error: decision.error, requestId }, noStore);
274
+ }
275
+ const result = isIamRpc(message.type)
276
+ ? await dispatchIamRpc(message, options.iam(), principal)
277
+ : await updateOnboarding(options.onboarding, message, principal);
278
+ if (decision.write)
279
+ audit(result.status < 400 && result.body.success !== false ? 'ok' : 'error');
280
+ else if (result.status === 403)
281
+ audit('denied');
282
+ return jsonResponse(result.status, result.body, { ...noStore, ...result.headers });
283
+ },
284
+ });
285
+ }
286
+ async function updateOnboarding(onboarding, message, principal) {
287
+ const type = String(message.type ?? '');
288
+ const requestId = message.requestId;
289
+ // The Console sends `{ type, step, status }`; `data` is accepted as well.
290
+ const payload = (message.data && typeof message.data === 'object' && !Array.isArray(message.data) ? message.data : message);
291
+ try {
292
+ const step = payload.step;
293
+ if (typeof step !== 'string' || !ONBOARDING_STEPS.has(step))
294
+ throw new OnboardingError('invalid_input', 'step 只能是 ai / adapters / finish');
295
+ const status = step === 'finish'
296
+ ? await onboarding.complete({ principal })
297
+ : await onboarding.markStep(step, payload.status, { principal });
298
+ return { status: 200, body: { success: true, data: status, type, requestId } };
299
+ }
300
+ catch (error) {
301
+ if (error instanceof OnboardingError) {
302
+ return { status: error.status, body: { success: false, code: error.code, error: error.message, requestId } };
303
+ }
304
+ return { status: 500, body: { success: false, code: 'failed', error: '引导状态更新失败', requestId } };
305
+ }
306
+ }
307
+ function errorText(error) {
308
+ return error instanceof Error ? error.message : String(error);
309
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Gateway events on the Console event stream (ADR 0054 §4): package transaction progress
3
+ * (`gateway.packages.progress`) must reach the Console while the instance is stopped for the
4
+ * transaction, so the gateway owns `GET {base}/events`:
5
+ *
6
+ * - instance ready: the instance's SSE stream is forwarded and gateway frames are spliced in
7
+ * between its frames (gateway frames carry no `id:`, so the resumable cursor stays the
8
+ * instance's); the stream ends with the instance's;
9
+ * - instance offline: a gateway-only stream, closed as soon as the instance is ready again so the
10
+ * Console reconnects to the merged stream. `GET {base}/events/history` then answers an empty
11
+ * page that keeps the caller's cursor.
12
+ *
13
+ * `GET {base}/gateway/packages/progress` serves the progress frames alone.
14
+ */
15
+ import type { GatewayPackageEvent } from '@zhin.js/console-protocol';
16
+ import type { GatewayHttpServer } from './gateway-http-server.js';
17
+ export interface GatewayEventSource {
18
+ /** Package transaction events (`GatewayPackageService.subscribe`). */
19
+ subscribe(listener: (event: GatewayPackageEvent) => void): () => void;
20
+ }
21
+ export interface GatewayInstanceReadiness {
22
+ /** Called whenever the instance's route table changes (ready, reload, gone). */
23
+ onChange(listener: () => void): () => void;
24
+ }
25
+ export interface RegisterGatewayEventRoutesOptions {
26
+ readonly server: GatewayHttpServer;
27
+ readonly packages?: GatewayEventSource;
28
+ readonly instance?: GatewayInstanceReadiness;
29
+ readonly keepAliveMs?: number;
30
+ }
31
+ export declare function gatewayEventFrame(type: string, data: unknown): string;
32
+ /** Registers the gateway's event routes; returns the unregister function. */
33
+ export declare function registerGatewayEventRoutes(options: RegisterGatewayEventRoutesOptions): () => void;
34
+ /** The text after the last complete SSE frame (empty when the buffer ends on a frame boundary). */
35
+ export declare function trailingPartialFrame(buffer: string): string;
@@ -0,0 +1,172 @@
1
+ import { GATEWAY_PACKAGES_PROGRESS, GATEWAY_PACKAGES_PROGRESS_PATH } from '@zhin.js/console-protocol';
2
+ import { jsonResponse } from '@zhin.js/host-http';
3
+ const KEEPALIVE_MS = 15_000;
4
+ /** Gateway frames held while an instance frame is half written. */
5
+ const MAX_PENDING_FRAMES = 1_024;
6
+ /** Chunks queued for a client that does not read; beyond it the stream ends (the client resumes). */
7
+ const MAX_QUEUED_CHUNKS = 4_096;
8
+ const SSE_HEADERS = Object.freeze({
9
+ 'content-type': 'text/event-stream; charset=utf-8',
10
+ 'cache-control': 'no-cache, no-transform',
11
+ 'x-accel-buffering': 'no',
12
+ });
13
+ export function gatewayEventFrame(type, data) {
14
+ return `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
15
+ }
16
+ /** Registers the gateway's event routes; returns the unregister function. */
17
+ export function registerGatewayEventRoutes(options) {
18
+ const { server } = options;
19
+ const base = server.apiBase;
20
+ const keepAliveMs = options.keepAliveMs ?? KEEPALIVE_MS;
21
+ const encoder = new TextEncoder();
22
+ /** A client stream fed with gateway frames (plus, when merging, the instance's). */
23
+ const open = (request) => {
24
+ let controller;
25
+ let finished = false;
26
+ const cleanups = [];
27
+ const finish = () => {
28
+ if (finished)
29
+ return;
30
+ finished = true;
31
+ for (const cleanup of cleanups.splice(0)) {
32
+ try {
33
+ cleanup();
34
+ }
35
+ catch { /* keep closing */ }
36
+ }
37
+ try {
38
+ controller.close();
39
+ }
40
+ catch { /* already closed */ }
41
+ };
42
+ const stream = new ReadableStream({
43
+ start(value) { controller = value; },
44
+ cancel() { finish(); },
45
+ });
46
+ const write = (text) => {
47
+ if (finished)
48
+ return;
49
+ try {
50
+ if ((controller.desiredSize ?? 1) < -MAX_QUEUED_CHUNKS) {
51
+ finish();
52
+ return;
53
+ }
54
+ controller.enqueue(encoder.encode(text));
55
+ }
56
+ catch {
57
+ finish();
58
+ }
59
+ };
60
+ request.signal.addEventListener('abort', finish, { once: true });
61
+ const timer = setInterval(() => write(': keepalive\n\n'), keepAliveMs);
62
+ timer.unref?.();
63
+ cleanups.push(() => clearInterval(timer));
64
+ return { stream, write, finish, cleanups, get finished() { return finished; } };
65
+ };
66
+ const subscribeProgress = (deliver) => options.packages?.subscribe((event) => deliver(gatewayEventFrame(GATEWAY_PACKAGES_PROGRESS, event)));
67
+ const events = async (request, context) => {
68
+ if (!server.instanceReady)
69
+ return offlineStream(request);
70
+ const upstream = await server.forward(request, context);
71
+ const type = upstream.headers.get('content-type') ?? '';
72
+ // Errors (401 / 403 / 503 …) and non-SSE answers pass through unchanged.
73
+ if (!upstream.ok || !upstream.body || !type.includes('text/event-stream')) {
74
+ if (upstream.status === 503 && !server.instanceReady) {
75
+ await upstream.body?.cancel().catch(() => undefined);
76
+ return offlineStream(request);
77
+ }
78
+ return upstream;
79
+ }
80
+ const client = open(request);
81
+ const reader = upstream.body.getReader();
82
+ client.cleanups.push(() => { void reader.cancel().catch(() => undefined); });
83
+ // Gateway frames wait while an instance frame is half written.
84
+ const pending = [];
85
+ let partial = '';
86
+ const decoder = new TextDecoder();
87
+ const flushPending = () => {
88
+ for (const frame of pending.splice(0))
89
+ client.write(frame);
90
+ };
91
+ const stop = subscribeProgress((frame) => {
92
+ if (partial) {
93
+ if (pending.length < MAX_PENDING_FRAMES)
94
+ pending.push(frame);
95
+ return;
96
+ }
97
+ client.write(frame);
98
+ });
99
+ if (stop)
100
+ client.cleanups.push(stop);
101
+ void (async () => {
102
+ try {
103
+ for (;;) {
104
+ const { done, value } = await reader.read();
105
+ if (done || client.finished)
106
+ break;
107
+ const text = decoder.decode(value, { stream: true });
108
+ client.write(text);
109
+ partial = trailingPartialFrame(partial + text);
110
+ if (!partial)
111
+ flushPending();
112
+ }
113
+ }
114
+ catch { /* instance side ended */ }
115
+ client.finish();
116
+ })();
117
+ const headers = new Headers(upstream.headers);
118
+ headers.delete('content-length');
119
+ return new Response(client.stream, { status: upstream.status, headers });
120
+ };
121
+ const offlineStream = (request) => {
122
+ const client = open(request);
123
+ client.write(': gateway\n\n');
124
+ const stop = subscribeProgress((frame) => client.write(frame));
125
+ if (stop)
126
+ client.cleanups.push(stop);
127
+ // Reconnect to the merged stream once the instance is back.
128
+ const unwatch = options.instance?.onChange(() => { if (server.instanceReady)
129
+ client.finish(); });
130
+ if (unwatch)
131
+ client.cleanups.push(unwatch);
132
+ return new Response(client.stream, { status: 200, headers: SSE_HEADERS });
133
+ };
134
+ const history = async (request, context) => {
135
+ if (server.instanceReady)
136
+ return server.forward(request, context);
137
+ const runtimeId = context.url.searchParams.get('runtimeId') ?? 'gateway';
138
+ const after = Number(context.url.searchParams.get('after') ?? 0);
139
+ const cursor = Number.isSafeInteger(after) && after >= 0 ? after : 0;
140
+ const page = Object.freeze({
141
+ runtimeId,
142
+ items: Object.freeze([]),
143
+ oldestAvailableEventId: null,
144
+ latestEventId: cursor,
145
+ nextAfter: cursor,
146
+ hasMore: false,
147
+ gap: false,
148
+ });
149
+ return jsonResponse(200, { success: true, data: page }, { 'cache-control': 'no-store' });
150
+ };
151
+ const progress = (request) => {
152
+ const client = open(request);
153
+ client.write(': gateway packages\n\n');
154
+ const stop = subscribeProgress((frame) => client.write(frame));
155
+ if (stop)
156
+ client.cleanups.push(stop);
157
+ return new Response(client.stream, { status: 200, headers: SSE_HEADERS });
158
+ };
159
+ const registrations = [
160
+ server.route('GET', `${base}/events`, events, { summary: 'Console SSE stream (instance events merged with gateway events)', tags: ['console'] }),
161
+ server.route('GET', `${base}/events/history`, history, { summary: 'Console event history (empty while the instance is offline)', tags: ['console'] }),
162
+ server.route('GET', `${base}${GATEWAY_PACKAGES_PROGRESS_PATH}`, progress, { summary: 'Package transaction progress (SSE)', tags: ['gateway'] }),
163
+ ];
164
+ return () => { for (const unregister of registrations)
165
+ unregister(); };
166
+ }
167
+ /** The text after the last complete SSE frame (empty when the buffer ends on a frame boundary). */
168
+ export function trailingPartialFrame(buffer) {
169
+ const normalized = buffer.replace(/\r\n/gu, '\n');
170
+ const boundary = normalized.lastIndexOf('\n\n');
171
+ return boundary < 0 ? normalized : normalized.slice(boundary + 2);
172
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The gateway's HTTP server (ADR 0054 §2 / §3): the project's only listener.
3
+ *
4
+ * Request flow:
5
+ * 1. gateway-owned routes ({@link GatewayHttpServer.route} / `ws`) — health, `/auth/*`,
6
+ * terminal, the merged Console event stream, package progress; gateway-owned Console RPC
7
+ * (`gateway.*`, `iam.*`, `onboarding.update`) inside `POST {base}/console/request`;
8
+ * 2. `{base}/…` — authenticated and authorized here, then forwarded with the principal;
9
+ * 3. anything else (plugin webhooks, adapter reverse WebSockets, static pages) — forwarded as-is;
10
+ * those routes authenticate themselves.
11
+ *
12
+ * The instance being offline never takes gateway-owned routes down: forwarded requests get
13
+ * `503 { code: 'instance_unavailable', state }` with `Retry-After`; an instance that does not
14
+ * answer in time gives `504 { code: 'instance_timeout' }`, any other bridge failure
15
+ * `502 { code: 'instance_error' }` (mapped from {@link InstanceTransportError}).
16
+ */
17
+ import { TokenRegistry, type HttpHandler, type HttpHostAddress, type HttpRouteRegistration, type ResolvedHttpConfig, type RouteContext, type RouteMeta, type WsHandle, type WsRouteOptions } from '@zhin.js/host-http';
18
+ import { type InstanceTransport } from '@zhin.js/host-http/bridge';
19
+ import { type GatewayConsoleRpc, type GatewayRpcHandler } from './console-rpc.js';
20
+ export declare const DEFAULT_GATEWAY_RETRY_AFTER_SECONDS = 5;
21
+ /** Default cap on a forwarded request body (uploads go through the instance). */
22
+ export declare const DEFAULT_GATEWAY_MAX_REQUEST_BODY_BYTES: number;
23
+ /** Where the server finds the instance; both are read per request. */
24
+ export interface GatewayInstanceSource {
25
+ /** Current transport; undefined while no instance process exists. */
26
+ transport(): InstanceTransport | undefined;
27
+ /** Supervisor state reported in 503 bodies and `/pub/health` (e.g. `starting`, `crashed`). */
28
+ state(): string;
29
+ }
30
+ export interface GatewayHttpServerOptions {
31
+ /** Resolved `http.*` config: the primary listener plus `listeners` (extra ports, TLS). */
32
+ readonly http?: ResolvedHttpConfig;
33
+ readonly instance: GatewayInstanceSource;
34
+ /**
35
+ * Console authentication. The access provider (users, sessions, API tokens) is installed on it
36
+ * by the IAM module; defaults to a registry over `http.token`.
37
+ */
38
+ readonly tokenRegistry?: TokenRegistry;
39
+ readonly retryAfterSeconds?: number;
40
+ readonly maxRequestBodyBytes?: number;
41
+ /**
42
+ * Gateway-owned Console RPC (`gateway.*`) answered on `POST {base}/console/request`; every other
43
+ * RPC type on that route is forwarded to the instance unchanged.
44
+ */
45
+ readonly consoleRpc?: GatewayConsoleRpc;
46
+ }
47
+ /** One router, many listeners: every listener serves the same surface. */
48
+ export declare class GatewayHttpServer {
49
+ #private;
50
+ readonly tokenRegistry: TokenRegistry;
51
+ readonly apiBase: string;
52
+ constructor(options: GatewayHttpServerOptions);
53
+ /** Registers a gateway-owned route; it takes precedence over the instance's routes. */
54
+ route(method: string, path: string, handler: HttpHandler, meta?: RouteMeta): HttpRouteRegistration;
55
+ /** Registers a gateway-owned WebSocket route. */
56
+ ws(path: string, options?: WsRouteOptions): WsHandle;
57
+ /**
58
+ * Answers further RPC types on `POST {base}/console/request` (e.g. `iam.*`); the handler checks
59
+ * the per-RPC permission itself. Returns an unregister function.
60
+ */
61
+ useConsoleRpc(handler: GatewayRpcHandler): () => void;
62
+ /** Whether an instance is connected and serving its route table. */
63
+ get instanceReady(): boolean;
64
+ /**
65
+ * Forwards one request to the instance (the router already authenticated and authorized it):
66
+ * the instance's response, or the documented 502 / 503 / 504 / 413 gateway responses.
67
+ */
68
+ forward(request: Request, context: RouteContext): Promise<Response>;
69
+ /** Handles one request without a socket (tests, local socket bridge). */
70
+ handle(request: Request, info?: {
71
+ readonly remoteAddress?: string;
72
+ }): Promise<Response>;
73
+ /** Primary listener address. */
74
+ get address(): HttpHostAddress | undefined;
75
+ /** Addresses of every bound listener, primary first. */
76
+ get addresses(): readonly HttpHostAddress[];
77
+ listen(): Promise<HttpHostAddress>;
78
+ close(): Promise<void>;
79
+ /**
80
+ * A `{base}/…` request from the local control socket (`gateway.http.request`, ADR 0054 §6).
81
+ * Without `token` the socket's owner check is the authentication: the request runs as
82
+ * {@link LOCAL_OWNER_PRINCIPAL}. With `token` (an explicit `ZHIN_CONSOLE_TOKEN`) it is
83
+ * authenticated like an HTTP request with that Bearer credential and runs — and is audited — as
84
+ * its principal. Either way the request takes the HTTP route table: gateway-owned routes (e.g.
85
+ * `{base}/auth/me`), the `POST {base}/console/request` split, then the instance. Event streams
86
+ * cannot be carried in one socket reply; the socket handler refuses them. Only the path and query
87
+ * of `path` are used; paths outside `{base}` are refused.
88
+ */
89
+ localRequest(input: {
90
+ readonly method: string;
91
+ readonly path: string;
92
+ readonly body?: string;
93
+ readonly token?: string;
94
+ }): Promise<Response>;
95
+ }