@zhin.js/gateway 0.0.1-bootstrap.0 → 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.
Files changed (181) 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 +18 -0
  14. package/lib/config/configuration-redaction.js +75 -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 +223 -0
  19. package/lib/config/environment-files.d.ts +3 -0
  20. package/lib/config/environment-files.js +14 -0
  21. package/lib/config/literal-secrets.d.ts +34 -0
  22. package/lib/config/literal-secrets.js +136 -0
  23. package/lib/config/plugin-package-map.d.ts +2 -0
  24. package/lib/config/plugin-package-map.js +14 -0
  25. package/lib/config/plugin-schema-catalog.d.ts +22 -0
  26. package/lib/config/plugin-schema-catalog.js +262 -0
  27. package/lib/config.d.ts +13 -0
  28. package/lib/config.js +13 -0
  29. package/lib/database/config.d.ts +22 -0
  30. package/lib/database/config.js +159 -0
  31. package/lib/database/index.d.ts +2 -0
  32. package/lib/database/index.js +2 -0
  33. package/lib/database/project-database.d.ts +31 -0
  34. package/lib/database/project-database.js +130 -0
  35. package/lib/database.d.ts +2 -0
  36. package/lib/database.js +2 -0
  37. package/lib/http/console-rpc.d.ts +27 -0
  38. package/lib/http/console-rpc.js +51 -0
  39. package/lib/http/gateway-console.d.ts +63 -0
  40. package/lib/http/gateway-console.js +309 -0
  41. package/lib/http/gateway-events.d.ts +40 -0
  42. package/lib/http/gateway-events.js +185 -0
  43. package/lib/http/gateway-http-server.d.ts +95 -0
  44. package/lib/http/gateway-http-server.js +522 -0
  45. package/lib/http/local-principal.d.ts +2 -0
  46. package/lib/http/local-principal.js +16 -0
  47. package/lib/http/plugin-switch-rpc.d.ts +19 -0
  48. package/lib/http/plugin-switch-rpc.js +214 -0
  49. package/lib/http/run-gateway.d.ts +43 -0
  50. package/lib/http/run-gateway.js +268 -0
  51. package/lib/http/start-gateway.d.ts +114 -0
  52. package/lib/http/start-gateway.js +226 -0
  53. package/lib/http-response.d.ts +6 -0
  54. package/lib/http-response.js +25 -0
  55. package/lib/http.d.ts +6 -0
  56. package/lib/http.js +6 -0
  57. package/lib/iam/crypto.d.ts +12 -0
  58. package/lib/iam/crypto.js +58 -0
  59. package/lib/iam/index.d.ts +8 -0
  60. package/lib/iam/index.js +8 -0
  61. package/lib/iam/install.d.ts +36 -0
  62. package/lib/iam/install.js +74 -0
  63. package/lib/iam/local-admin.d.ts +27 -0
  64. package/lib/iam/local-admin.js +102 -0
  65. package/lib/iam/rate-limit.d.ts +15 -0
  66. package/lib/iam/rate-limit.js +52 -0
  67. package/lib/iam/routes.d.ts +23 -0
  68. package/lib/iam/routes.js +157 -0
  69. package/lib/iam/rpc.d.ts +14 -0
  70. package/lib/iam/rpc.js +100 -0
  71. package/lib/iam/service.d.ts +203 -0
  72. package/lib/iam/service.js +784 -0
  73. package/lib/iam/setup-service.d.ts +83 -0
  74. package/lib/iam/setup-service.js +206 -0
  75. package/lib/iam/tables.d.ts +20 -0
  76. package/lib/iam/tables.js +82 -0
  77. package/lib/iam.d.ts +2 -0
  78. package/lib/iam.js +2 -0
  79. package/lib/index.d.ts +22 -0
  80. package/lib/index.js +22 -0
  81. package/lib/local/client.d.ts +71 -0
  82. package/lib/local/client.js +296 -0
  83. package/lib/local/handler.d.ts +30 -0
  84. package/lib/local/handler.js +86 -0
  85. package/lib/local/local-socket.d.ts +55 -0
  86. package/lib/local/local-socket.js +232 -0
  87. package/lib/local/paths.d.ts +34 -0
  88. package/lib/local/paths.js +58 -0
  89. package/lib/local/protocol.d.ts +140 -0
  90. package/lib/local/protocol.js +90 -0
  91. package/lib/local/state-file.d.ts +8 -0
  92. package/lib/local/state-file.js +45 -0
  93. package/lib/local.d.ts +10 -0
  94. package/lib/local.js +10 -0
  95. package/lib/onboarding/allowlist.d.ts +54 -0
  96. package/lib/onboarding/allowlist.js +106 -0
  97. package/lib/onboarding/errors.d.ts +7 -0
  98. package/lib/onboarding/errors.js +17 -0
  99. package/lib/onboarding/index.d.ts +6 -0
  100. package/lib/onboarding/index.js +6 -0
  101. package/lib/onboarding/install.d.ts +99 -0
  102. package/lib/onboarding/install.js +132 -0
  103. package/lib/onboarding/package-manager.d.ts +47 -0
  104. package/lib/onboarding/package-manager.js +179 -0
  105. package/lib/onboarding/service.d.ts +53 -0
  106. package/lib/onboarding/service.js +144 -0
  107. package/lib/onboarding/state-store.d.ts +26 -0
  108. package/lib/onboarding/state-store.js +97 -0
  109. package/lib/onboarding.d.ts +2 -0
  110. package/lib/onboarding.js +2 -0
  111. package/lib/packages/authorization.d.ts +24 -0
  112. package/lib/packages/authorization.js +128 -0
  113. package/lib/packages/errors.d.ts +11 -0
  114. package/lib/packages/errors.js +26 -0
  115. package/lib/packages/index.d.ts +5 -0
  116. package/lib/packages/index.js +5 -0
  117. package/lib/packages/rpc.d.ts +30 -0
  118. package/lib/packages/rpc.js +160 -0
  119. package/lib/packages/service.d.ts +139 -0
  120. package/lib/packages/service.js +504 -0
  121. package/lib/packages/snapshot.d.ts +14 -0
  122. package/lib/packages/snapshot.js +73 -0
  123. package/lib/packages.d.ts +2 -0
  124. package/lib/packages.js +2 -0
  125. package/lib/plugin-management/activity.d.ts +6 -0
  126. package/lib/plugin-management/activity.js +14 -0
  127. package/lib/plugin-management/config-explanation.d.ts +6 -0
  128. package/lib/plugin-management/config-explanation.js +113 -0
  129. package/lib/plugin-management/use-cases.d.ts +15 -0
  130. package/lib/plugin-management/use-cases.js +120 -0
  131. package/lib/plugin-management/workflow.d.ts +136 -0
  132. package/lib/plugin-management/workflow.js +878 -0
  133. package/lib/plugin-management.d.ts +5 -0
  134. package/lib/plugin-management.js +5 -0
  135. package/lib/project/config-file.d.ts +6 -0
  136. package/lib/project/config-file.js +58 -0
  137. package/lib/project/environment.d.ts +13 -0
  138. package/lib/project/environment.js +52 -0
  139. package/lib/project/instance-declarations.d.ts +55 -0
  140. package/lib/project/instance-declarations.js +542 -0
  141. package/lib/project/legacy-config.d.ts +25 -0
  142. package/lib/project/legacy-config.js +70 -0
  143. package/lib/project/node-requirements.d.ts +7 -0
  144. package/lib/project/node-requirements.js +20 -0
  145. package/lib/project/tsx-module-loader.d.ts +7 -0
  146. package/lib/project/tsx-module-loader.js +29 -0
  147. package/lib/project.d.ts +7 -0
  148. package/lib/project.js +7 -0
  149. package/lib/server.d.ts +11 -0
  150. package/lib/server.js +11 -0
  151. package/lib/supervisor/instance-logs.d.ts +39 -0
  152. package/lib/supervisor/instance-logs.js +152 -0
  153. package/lib/supervisor/instance-supervisor.d.ts +140 -0
  154. package/lib/supervisor/instance-supervisor.js +408 -0
  155. package/lib/supervisor/run-instance.d.ts +35 -0
  156. package/lib/supervisor/run-instance.js +161 -0
  157. package/lib/supervisor/terminal-state.d.ts +30 -0
  158. package/lib/supervisor/terminal-state.js +57 -0
  159. package/lib/supervisor.d.ts +4 -0
  160. package/lib/supervisor.js +4 -0
  161. package/lib/terminal/backends.d.ts +38 -0
  162. package/lib/terminal/backends.js +193 -0
  163. package/lib/terminal/config.d.ts +63 -0
  164. package/lib/terminal/config.js +237 -0
  165. package/lib/terminal/host.d.ts +26 -0
  166. package/lib/terminal/host.js +76 -0
  167. package/lib/terminal/index.d.ts +60 -0
  168. package/lib/terminal/index.js +135 -0
  169. package/lib/terminal/modules.d.ts +87 -0
  170. package/lib/terminal/modules.js +44 -0
  171. package/lib/terminal/service.d.ts +101 -0
  172. package/lib/terminal/service.js +455 -0
  173. package/lib/terminal/tickets.d.ts +46 -0
  174. package/lib/terminal/tickets.js +76 -0
  175. package/lib/terminal.d.ts +3 -0
  176. package/lib/terminal.js +3 -0
  177. package/lib/usage/aggregate.d.ts +87 -0
  178. package/lib/usage/aggregate.js +212 -0
  179. package/lib/usage.d.ts +2 -0
  180. package/lib/usage.js +2 -0
  181. package/package.json +117 -4
@@ -0,0 +1,522 @@
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 { authorizeConsoleWebSocket, auditActor, createHttpRouter, extractBearerToken, jsonResponse, normalizePath, requestMeta, TokenRegistry, } from '@zhin.js/host-http';
18
+ import { InstanceTransportError, } from '@zhin.js/host-http/bridge';
19
+ import { createNodeHttpListener } from '@zhin.js/host-http/node';
20
+ import { formatCompact, getLogger } from '@zhin.js/logger';
21
+ import { databaseBusyResponse, isDatabaseBusyError } from '../database/project-database.js';
22
+ import { GATEWAY_RPC_PREFIX } from './console-rpc.js';
23
+ import { LOCAL_OWNER_PRINCIPAL } from './local-principal.js';
24
+ const logger = getLogger('gateway');
25
+ export const DEFAULT_GATEWAY_RETRY_AFTER_SECONDS = 5;
26
+ /** Default cap on a forwarded request body (uploads go through the instance). */
27
+ export const DEFAULT_GATEWAY_MAX_REQUEST_BODY_BYTES = 64 * 1024 * 1024;
28
+ /** Client frames kept while the instance side of a forwarded socket is being opened. */
29
+ const WS_PENDING_FRAME_LIMIT = 256;
30
+ const WS_PENDING_BYTE_LIMIT = 4 * 1024 * 1024;
31
+ const HOP_BY_HOP_HEADERS = Object.freeze([
32
+ 'connection',
33
+ 'keep-alive',
34
+ 'proxy-authenticate',
35
+ 'proxy-authorization',
36
+ 'proxy-connection',
37
+ 'te',
38
+ 'trailer',
39
+ 'transfer-encoding',
40
+ 'upgrade',
41
+ ]);
42
+ /** One router, many listeners: every listener serves the same surface. */
43
+ export class GatewayHttpServer {
44
+ tokenRegistry;
45
+ apiBase;
46
+ #router;
47
+ /**
48
+ * The same surface for `gateway.http.request` without a credential: a `forwarded` router that
49
+ * takes {@link LOCAL_OWNER_PRINCIPAL} as authenticated and still authorizes every route against
50
+ * the Console permission map, like the HTTP router does.
51
+ */
52
+ #localRouter;
53
+ #listeners;
54
+ #instance;
55
+ #retryAfter;
56
+ #maxBody;
57
+ #consoleRpc;
58
+ #rpcHandlers = new Set();
59
+ #closed = false;
60
+ #closeResult;
61
+ constructor(options) {
62
+ const { listeners: extra = [], ...primary } = options.http ?? {};
63
+ const listenerOptions = [primary, ...extra];
64
+ this.#instance = options.instance;
65
+ this.#retryAfter = String(Math.max(1, Math.round(options.retryAfterSeconds ?? DEFAULT_GATEWAY_RETRY_AFTER_SECONDS)));
66
+ this.#maxBody = options.maxRequestBodyBytes ?? DEFAULT_GATEWAY_MAX_REQUEST_BODY_BYTES;
67
+ this.#consoleRpc = options.consoleRpc;
68
+ this.tokenRegistry = options.tokenRegistry ?? new TokenRegistry({ primaryToken: primary.token });
69
+ this.apiBase = normalizePath(primary.apiBase ?? '/api');
70
+ const routerOptions = {
71
+ corsOrigins: primary.corsOrigins,
72
+ apiBase: this.apiBase,
73
+ authExemptPaths: primary.authExemptPaths,
74
+ tokenRegistry: this.tokenRegistry,
75
+ secure: primary.tls !== undefined,
76
+ address: () => this.address,
77
+ builtinRoutes: false,
78
+ };
79
+ this.#router = createHttpRouter(routerOptions);
80
+ this.#localRouter = createHttpRouter({ ...routerOptions, authentication: 'forwarded' });
81
+ for (const router of [this.#router, this.#localRouter]) {
82
+ router.route('GET', '/pub/health', () => this.#health(), { summary: 'Gateway health probe', tags: ['pub'] });
83
+ router.route('POST', `${this.apiBase}/console/request`, (request, context) => this.#consoleRequest(request, context), {
84
+ summary: 'Console RPC (gateway-owned types answered by the gateway, the rest forwarded)',
85
+ tags: ['console'],
86
+ });
87
+ // Lowest precedence: every path no gateway route claims goes to the instance.
88
+ router.route('ALL', '/*', (request, context) => this.#forward(request, context));
89
+ }
90
+ this.#listeners = listenerOptions.map((entry) => createNodeHttpListener({
91
+ host: entry.host,
92
+ port: entry.port,
93
+ tls: entry.tls,
94
+ trustProxy: entry.trustProxy,
95
+ handle: (request, info) => this.#router.handle(request, info),
96
+ upgrade: (request) => this.#upgrade(request),
97
+ onListening: (address) => {
98
+ logger.info(formatCompact({ op: 'gateway_listening', origin: address.origin, base: this.apiBase }));
99
+ },
100
+ }));
101
+ }
102
+ /** Registers a gateway-owned route; it takes precedence over the instance's routes. */
103
+ route(method, path, handler, meta) {
104
+ const wrapped = withDatabaseBusy(handler);
105
+ const unregister = [this.#router.route(method, path, wrapped, meta), this.#localRouter.route(method, path, wrapped, meta)];
106
+ return () => { for (const release of unregister)
107
+ release(); };
108
+ }
109
+ /** Registers a gateway-owned WebSocket route. */
110
+ ws(path, options) {
111
+ return this.#router.ws(path, options);
112
+ }
113
+ /**
114
+ * Answers further RPC types on `POST {base}/console/request` (e.g. `iam.*`); the handler checks
115
+ * the per-RPC permission itself. Returns an unregister function.
116
+ */
117
+ useConsoleRpc(handler) {
118
+ this.#rpcHandlers.add(handler);
119
+ return () => { this.#rpcHandlers.delete(handler); };
120
+ }
121
+ /** Whether an instance is connected and serving its route table. */
122
+ get instanceReady() {
123
+ return this.#readyTransport() !== undefined;
124
+ }
125
+ /**
126
+ * Forwards one request to the instance (the router already authenticated and authorized it):
127
+ * the instance's response, or the documented 502 / 503 / 504 / 413 gateway responses.
128
+ */
129
+ forward(request, context) {
130
+ return this.#forward(request, context);
131
+ }
132
+ /** Handles one request without a socket (tests, local socket bridge). */
133
+ handle(request, info = {}) {
134
+ return this.#router.handle(request, info);
135
+ }
136
+ /** Primary listener address. */
137
+ get address() {
138
+ return this.#listeners[0]?.address;
139
+ }
140
+ /** Addresses of every bound listener, primary first. */
141
+ get addresses() {
142
+ return this.#listeners.flatMap((listener) => listener.address ? [listener.address] : []);
143
+ }
144
+ async listen() {
145
+ if (this.#closed)
146
+ throw new Error('Gateway HTTP server is closed');
147
+ try {
148
+ for (const listener of this.#listeners)
149
+ await listener.listen();
150
+ }
151
+ catch (error) {
152
+ await Promise.allSettled([...this.#listeners].reverse().map((listener) => listener.close()));
153
+ throw error;
154
+ }
155
+ return this.#listeners[0].address;
156
+ }
157
+ close() {
158
+ this.#closeResult ??= (async () => {
159
+ this.#closed = true;
160
+ this.#router.close();
161
+ this.#localRouter.close();
162
+ const results = await Promise.allSettled([...this.#listeners].reverse().map((listener) => listener.close()));
163
+ const errors = results.flatMap((result) => result.status === 'rejected' ? [result.reason] : []);
164
+ if (errors.length === 1)
165
+ throw errors[0];
166
+ if (errors.length > 1)
167
+ throw new AggregateError(errors, 'multiple gateway listeners failed to close');
168
+ })();
169
+ return this.#closeResult;
170
+ }
171
+ #readyTransport() {
172
+ const transport = this.#instance.transport();
173
+ return transport?.ready ? transport : undefined;
174
+ }
175
+ #health() {
176
+ const body = {
177
+ success: true,
178
+ status: 'ok',
179
+ timestamp: new Date().toISOString(),
180
+ instance: { state: this.#instance.state(), ready: this.#readyTransport() !== undefined },
181
+ };
182
+ // Polled by the Console's instance banner: never cached.
183
+ return jsonResponse(200, body, { 'cache-control': 'no-store' });
184
+ }
185
+ #unavailable() {
186
+ return this.#instanceError(503, 'instance_unavailable', 'not-dispatched', 'The Zhin instance is not running', true);
187
+ }
188
+ /** One body shape for every forward failure (`GatewayInstanceErrorReply`, ADR 0054 §3). */
189
+ #instanceError(status, code, requestOutcome, error, retry) {
190
+ const body = { success: false, code, requestOutcome, state: this.#instance.state(), error };
191
+ return jsonResponse(status, body, { ...(retry ? { 'retry-after': this.#retryAfter } : {}), 'cache-control': 'no-store' });
192
+ }
193
+ async #forward(request, context) {
194
+ const transport = this.#readyTransport();
195
+ if (!transport)
196
+ return this.#unavailable();
197
+ const declared = request.headers.get('content-length');
198
+ if (declared !== null && Number(declared) > this.#maxBody)
199
+ return tooLarge();
200
+ const headers = new Headers(request.headers);
201
+ for (const name of HOP_BY_HOP_HEADERS)
202
+ headers.delete(name);
203
+ const body = request.body ? limitBody(request.body, this.#maxBody) : null;
204
+ const forwarded = new Request(context.url, {
205
+ method: request.method,
206
+ headers,
207
+ signal: request.signal,
208
+ ...(body ? { body, duplex: 'half' } : {}),
209
+ });
210
+ let response;
211
+ try {
212
+ response = await transport.fetch(forwarded, {
213
+ ...(context.principal ? { principal: context.principal } : {}),
214
+ ...(context.remoteAddress ? { remoteAddress: context.remoteAddress } : {}),
215
+ });
216
+ }
217
+ catch (error) {
218
+ if (request.signal.aborted)
219
+ return new Response(null, { status: 499 });
220
+ if (error instanceof BodyLimitError || (error instanceof Error && error.cause instanceof BodyLimitError))
221
+ return tooLarge();
222
+ return this.#transportFailure(error, transport, context.url.pathname);
223
+ }
224
+ return stripHopByHop(response);
225
+ }
226
+ /** Maps a failed forward onto the documented gateway error responses (ADR 0054 §3). */
227
+ #transportFailure(error, transport, path) {
228
+ const code = error instanceof InstanceTransportError ? error.code : undefined;
229
+ // A transport that is no longer ready failed because the instance went away.
230
+ if (code === 'instance-offline' || (code === undefined && !transport.ready))
231
+ return this.#unavailable();
232
+ if (code === 'timeout') {
233
+ return this.#instanceError(504, 'instance_timeout', 'unknown', 'The instance did not answer in time', false);
234
+ }
235
+ if (code === 'overloaded') {
236
+ return this.#instanceError(502, 'instance_error', 'not-dispatched', 'The instance is overloaded', true);
237
+ }
238
+ logger.warn(formatCompact({
239
+ op: 'gateway_forward_failed',
240
+ path,
241
+ ...(code ? { code } : {}),
242
+ error: error instanceof Error ? error.message : String(error),
243
+ }));
244
+ return this.#instanceError(502, 'instance_error', 'unknown', 'The instance failed to answer', false);
245
+ }
246
+ /**
247
+ * `POST {base}/console/request`: the router authenticated and authorized the route; `gateway.*`
248
+ * types are dispatched here (the dispatcher checks the per-RPC permission), the rest are
249
+ * forwarded to the instance with the body as received.
250
+ */
251
+ async #consoleRequest(request, context) {
252
+ const declared = request.headers.get('content-length');
253
+ if (declared !== null && Number(declared) > this.#maxBody)
254
+ return tooLarge();
255
+ let bytes;
256
+ try {
257
+ bytes = request.body
258
+ ? await new Response(limitBody(request.body, this.#maxBody)).arrayBuffer()
259
+ : new ArrayBuffer(0);
260
+ }
261
+ catch (error) {
262
+ if (request.signal.aborted)
263
+ return new Response(null, { status: 499 });
264
+ if (error instanceof BodyLimitError)
265
+ return tooLarge();
266
+ return jsonResponse(400, { success: false, code: 'invalid_body', error: 'Invalid request body' });
267
+ }
268
+ const type = peekRpcType(bytes);
269
+ const owner = type === undefined
270
+ ? undefined
271
+ : type.startsWith(GATEWAY_RPC_PREFIX) && this.#consoleRpc
272
+ ? this.#consoleRpc
273
+ : [...this.#rpcHandlers].find((handler) => handler.owns(type));
274
+ if (owner) {
275
+ let message;
276
+ try {
277
+ message = JSON.parse(new TextDecoder().decode(bytes));
278
+ }
279
+ catch {
280
+ return jsonResponse(400, { success: false, code: 'invalid_body', error: 'Invalid JSON body' });
281
+ }
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
+ }
293
+ }
294
+ }
295
+ const headers = new Headers(request.headers);
296
+ for (const name of HOP_BY_HOP_HEADERS)
297
+ headers.delete(name);
298
+ headers.delete('content-length');
299
+ return this.#forward(new Request(context.url, {
300
+ method: request.method,
301
+ headers,
302
+ signal: request.signal,
303
+ body: bytes,
304
+ }), context);
305
+ }
306
+ /**
307
+ * A `{base}/…` request from the local control socket (`gateway.http.request`, ADR 0054 §6).
308
+ * Without `token` the socket's owner check is the authentication: the request runs as
309
+ * {@link LOCAL_OWNER_PRINCIPAL}. With `token` (an explicit `ZHIN_CONSOLE_TOKEN`) it is
310
+ * authenticated like an HTTP request with that Bearer credential and runs — and is audited — as
311
+ * its principal. Either way the request takes the HTTP route table: gateway-owned routes (e.g.
312
+ * `{base}/auth/me`), the `POST {base}/console/request` split, then the instance. Event streams
313
+ * cannot be carried in one socket reply; the socket handler refuses them. Only the path and query
314
+ * of `path` are used; paths outside `{base}` are refused.
315
+ */
316
+ async localRequest(input) {
317
+ const method = input.method.toUpperCase();
318
+ // `\` is a path separator to the URL parser: `/\evil.com/x` would name another host.
319
+ if (!input.path.startsWith('/') || input.path.startsWith('//') || input.path.includes('\\')) {
320
+ return jsonResponse(400, { success: false, code: 'invalid_input', error: 'path must be an absolute path without a host' });
321
+ }
322
+ if (input.body !== undefined && (method === 'GET' || method === 'HEAD')) {
323
+ return jsonResponse(400, { success: false, code: 'invalid_input', error: `${method} requests cannot have a body` });
324
+ }
325
+ const parsed = new URL(input.path, LOCAL_REQUEST_ORIGIN);
326
+ const url = new URL(`${parsed.pathname}${parsed.search}`, LOCAL_REQUEST_ORIGIN);
327
+ const pathname = normalizePath(url.pathname);
328
+ if (!isUnderBase(pathname, this.apiBase)) {
329
+ return jsonResponse(404, { success: false, code: 'not_found', error: `Only ${this.apiBase === '/' ? '/' : `${this.apiBase}/`}… is reachable over the local socket` });
330
+ }
331
+ const headers = new Headers();
332
+ if (input.body !== undefined)
333
+ headers.set('content-type', 'application/json');
334
+ if (input.token !== undefined)
335
+ headers.set('authorization', `Bearer ${input.token}`);
336
+ const request = new Request(url, { method, headers, ...(input.body === undefined ? {} : { body: input.body }) });
337
+ return input.token !== undefined
338
+ ? this.#router.handle(request)
339
+ : this.#localRouter.handle(request, { principal: LOCAL_OWNER_PRINCIPAL });
340
+ }
341
+ async #upgrade(request) {
342
+ const own = await this.#router.upgrade(request);
343
+ if (own.accepted || own.status !== 404 || this.#closed)
344
+ return own;
345
+ const transport = this.#readyTransport();
346
+ if (!transport)
347
+ return Object.freeze({ accepted: false, status: 503, headers: { 'Retry-After': this.#retryAfter } });
348
+ const pathname = normalizePath(request.url.pathname);
349
+ const routes = transport.routes().ws.filter((route) => normalizePath(route.path) === pathname);
350
+ if (routes.length === 0)
351
+ return Object.freeze({ accepted: false, status: 404 });
352
+ let principal;
353
+ if (!routes.every((route) => route.auth === 'protocol')) {
354
+ principal = await this.#authenticateUpgrade(request, pathname).catch(() => undefined);
355
+ if (!principal)
356
+ return Object.freeze({ accepted: false, status: 401 });
357
+ }
358
+ const info = Object.freeze({
359
+ url: request.url,
360
+ headers: request.headers,
361
+ ...(principal ? { principal } : {}),
362
+ ...(request.remoteAddress ? { remoteAddress: request.remoteAddress } : {}),
363
+ });
364
+ return Object.freeze({
365
+ accepted: true,
366
+ reject: () => undefined,
367
+ accept: (socket) => {
368
+ const peer = bufferedConnection(info, socket);
369
+ transport.openWebSocket(info, peer).then((opened) => {
370
+ if (!opened)
371
+ peer.close(1013, 'Instance unavailable');
372
+ }, (error) => {
373
+ logger.warn(formatCompact({
374
+ op: 'gateway_ws_forward_failed',
375
+ path: pathname,
376
+ error: error instanceof Error ? error.message : String(error),
377
+ }));
378
+ peer.close(1011, 'Instance unavailable');
379
+ });
380
+ },
381
+ });
382
+ }
383
+ async #authenticateUpgrade(request, pathname) {
384
+ const token = extractBearerToken(request.headers.get('authorization') ?? undefined, request.url.searchParams.get('token'));
385
+ const principal = await this.tokenRegistry.authenticate(token, requestMeta(request, request.remoteAddress));
386
+ if (!principal)
387
+ return undefined;
388
+ if (!authorizeConsoleWebSocket(principal, pathname)) {
389
+ const provider = this.tokenRegistry.accessProvider;
390
+ try {
391
+ provider?.audit?.({
392
+ actor: auditActor(principal),
393
+ action: `ws:${pathname}`,
394
+ outcome: 'denied',
395
+ ...(request.remoteAddress ? { ip: request.remoteAddress } : {}),
396
+ });
397
+ }
398
+ catch { /* audit never breaks a request */ }
399
+ return undefined;
400
+ }
401
+ return principal;
402
+ }
403
+ }
404
+ /** A gateway route whose database stayed locked past the retry budget answers 503 `database_busy`. */
405
+ /** Origin of the URLs `localRequest` builds (never contacted). */
406
+ const LOCAL_REQUEST_ORIGIN = 'http://gateway.local';
407
+ /** `pathname` is `base` or below it (`base` may be `/`). */
408
+ function isUnderBase(pathname, base) {
409
+ if (base === '/')
410
+ return pathname.startsWith('/');
411
+ return pathname === base || pathname.startsWith(`${base}/`);
412
+ }
413
+ function withDatabaseBusy(handler) {
414
+ return async (request, context) => {
415
+ try {
416
+ return await handler(request, context);
417
+ }
418
+ catch (error) {
419
+ if (isDatabaseBusyError(error))
420
+ return databaseBusyResponse();
421
+ throw error;
422
+ }
423
+ };
424
+ }
425
+ class BodyLimitError extends Error {
426
+ }
427
+ function tooLarge() {
428
+ return jsonResponse(413, { success: false, code: 'payload_too_large', requestOutcome: 'not-dispatched', error: 'Request body too large' });
429
+ }
430
+ function limitBody(body, max) {
431
+ let total = 0;
432
+ return body.pipeThrough(new TransformStream({
433
+ transform(chunk, controller) {
434
+ total += chunk.byteLength;
435
+ if (total > max)
436
+ controller.error(new BodyLimitError('Request body too large'));
437
+ else
438
+ controller.enqueue(chunk);
439
+ },
440
+ }));
441
+ }
442
+ function stripHopByHop(response) {
443
+ if (!HOP_BY_HOP_HEADERS.some((name) => response.headers.has(name)))
444
+ return response;
445
+ const headers = new Headers(response.headers);
446
+ for (const name of HOP_BY_HOP_HEADERS)
447
+ headers.delete(name);
448
+ return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
449
+ }
450
+ /**
451
+ * A {@link WsConnection} over the client socket that keeps client frames until the instance side
452
+ * subscribes (opening it is asynchronous); too many early frames close the socket (1009).
453
+ */
454
+ function bufferedConnection(info, socket) {
455
+ const listeners = new Set();
456
+ let pending = [];
457
+ let pendingBytes = 0;
458
+ socket.onMessage((data) => {
459
+ if (pending) {
460
+ pending.push(data);
461
+ pendingBytes += typeof data === 'string' ? data.length : data.byteLength;
462
+ if (pending.length > WS_PENDING_FRAME_LIMIT || pendingBytes > WS_PENDING_BYTE_LIMIT) {
463
+ pending = [];
464
+ socket.close(1009, 'Too many frames before the instance accepted the socket');
465
+ }
466
+ return;
467
+ }
468
+ for (const listener of [...listeners]) {
469
+ try {
470
+ listener(data);
471
+ }
472
+ catch { /* isolate listener failures */ }
473
+ }
474
+ });
475
+ socket.onClose(() => {
476
+ pending = undefined;
477
+ listeners.clear();
478
+ });
479
+ return Object.freeze({
480
+ url: info.url,
481
+ headers: info.headers,
482
+ ...(info.principal ? { principal: info.principal } : {}),
483
+ ...(info.remoteAddress ? { remoteAddress: info.remoteAddress } : {}),
484
+ send: (data) => socket.send(data),
485
+ close: (code, reason) => socket.close(code, reason),
486
+ onMessage(listener) {
487
+ listeners.add(listener);
488
+ if (pending) {
489
+ const queued = pending;
490
+ pending = undefined;
491
+ queueMicrotask(() => {
492
+ for (const data of queued) {
493
+ for (const current of [...listeners]) {
494
+ try {
495
+ current(data);
496
+ }
497
+ catch { /* isolate listener failures */ }
498
+ }
499
+ }
500
+ });
501
+ }
502
+ return () => { listeners.delete(listener); };
503
+ },
504
+ onClose: (listener) => socket.onClose(listener),
505
+ get bufferedAmount() { return socket.bufferedAmount; },
506
+ });
507
+ }
508
+ /** Reads `type` from a JSON RPC envelope without failing on malformed input. */
509
+ function peekRpcType(bytes) {
510
+ if (bytes.byteLength === 0)
511
+ return undefined;
512
+ try {
513
+ const parsed = JSON.parse(new TextDecoder().decode(bytes));
514
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
515
+ return undefined;
516
+ const type = parsed.type;
517
+ return typeof type === 'string' ? type : undefined;
518
+ }
519
+ catch {
520
+ return undefined;
521
+ }
522
+ }
@@ -0,0 +1,2 @@
1
+ import type { ConsoleUserPrincipal } from '@zhin.js/host-http';
2
+ export declare const LOCAL_OWNER_PRINCIPAL: ConsoleUserPrincipal;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The principal of requests from the local control socket (`gateway.http.request`, ADR 0054 §6).
3
+ * Connecting to `.zhin/gateway.sock` requires the project owner's OS account (the socket's owner
4
+ * check), which the gateway treats as the Console owner. The instance sees a global owner; audit
5
+ * entries name it `user:local`.
6
+ */
7
+ import { CONSOLE_PERMISSION_LIST } from '@zhin.js/console-protocol';
8
+ export const LOCAL_OWNER_PRINCIPAL = Object.freeze({
9
+ kind: 'user',
10
+ userId: 'local',
11
+ username: 'local',
12
+ displayName: '本机 CLI(网关控制套接字)',
13
+ credentialId: 'local-socket',
14
+ grants: Object.freeze([Object.freeze({ role: 'owner', scope: Object.freeze({ kind: 'global' }), permissions: CONSOLE_PERMISSION_LIST })]),
15
+ roles: Object.freeze(['owner']),
16
+ });
@@ -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;