anbaric-cloud-hosting 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import {readFile} from "node:fs/promises";
2
2
  import {IncomingMessage, ServerResponse} from "node:http";
3
- import {JobPersistence, JsonStore, SecretStore, deserializeJob, serializeJob} from "anbaric-tsapi";
3
+ import {AuditChange, JobPersistence, JsonStore, SecretStore, deserializeJob, serializeJob} from "anbaric-tsapi";
4
4
  import {BuildLayer} from "../app-management/BuildLayer";
5
5
  import {AuditRecordStore} from "../auditing/AuditRecordStore";
6
6
  import {CliAuthorizer} from "../auth/CliAuthorizer";
@@ -9,6 +9,8 @@ import {User} from "../auth/User";
9
9
  import {ConfirmableQueue} from "../queuing/ConfirmableQueue";
10
10
  import {ConsumerRegistry} from "../queuing/ConsumerRegistry";
11
11
 
12
+ const AUDIT_CHANGES = new Set(["CREATE", "UPDATE_PROPERTIES", "CHANGE_STATE", "DELETE"]);
13
+
12
14
  const readRawBody = (request : IncomingMessage) : Promise<Buffer> =>
13
15
  new Promise((resolve, reject) => {
14
16
  const chunks : Array<Buffer> = [];
@@ -22,6 +24,10 @@ const readBody = async (request : IncomingMessage) : Promise<any> => {
22
24
  return raw.length === 0 ? undefined : JSON.parse(raw);
23
25
  };
24
26
 
27
+ /* Routing is a tree: route() dispatches on the resource family (or the few
28
+ specific resources that fit no family), each family handler switches on
29
+ the specific resource shape, and the method switch sits underneath.
30
+ Unmatched requests fall through to the app proxy, then 404. */
25
31
  class Router {
26
32
 
27
33
  constructor(private persistence : JobPersistence, private queue : ConfirmableQueue,
@@ -38,64 +44,56 @@ class Router {
38
44
  const [resource, id, subresource] = url.pathname.split("/").filter(Boolean);
39
45
  const method = request.method ?? "GET";
40
46
 
41
- if (!resource && method === "GET") {
42
- return this.servePage(response);
43
- }
44
- if (resource === "authorize-cli" && id && this.cliAuthorizer) {
45
- return this.handleAuthorizeCli(method, id, subresource, request, response, user, sessionTenant);
46
- }
47
- if (resource === "manage-keys" && method === "GET" && !id && this.cliAuthorizer) {
48
- return this.servePage(response);
49
- }
50
- if (resource === "keys" && this.cliAuthorizer) {
51
- return this.handleKeys(method, id, response, user);
52
- }
53
- if (resource === "whoami" && method === "GET" && !id) {
54
- if (!user) return this.reply(response, 404, { error: "Not found" });
55
- return this.reply(response, 200, { id: user.id, roles: user.roles.map(role => role.id) });
56
- }
57
- if (resource === "audits" && this.auditRecords && !id) {
58
- if (method === "POST") {
59
- const record = await readBody(request);
60
- if (typeof record?.jobId !== "string" || typeof record?.description !== "string") {
61
- return this.reply(response, 400, { error: "Expected a body of { jobId, description, ... }" });
47
+ switch (resource) {
48
+ case undefined:
49
+ if (method === "GET") return this.servePage(response);
50
+ break;
51
+ case "audit":
52
+ if (method === "GET" && !id) return this.servePage(response);
53
+ break;
54
+ case "manage-keys":
55
+ if (method === "GET" && !id && this.cliAuthorizer) return this.servePage(response);
56
+ break;
57
+ case "whoami":
58
+ if (method === "GET" && !id && user) {
59
+ return this.reply(response, 200, { id: user.id, roles: user.roles.map(role => role.id) });
62
60
  }
63
- await this.auditRecords.save(record);
64
- return this.reply(response, 204);
65
- }
66
- if (method === "GET") {
67
- const records = await this.auditRecords.list({
68
- jobId: url.searchParams.get("jobId") ?? undefined,
69
- actorId: url.searchParams.get("actorId") ?? undefined,
70
- search: url.searchParams.get("search") ?? undefined,
71
- pageSize: url.searchParams.has("pageSize") ? Number(url.searchParams.get("pageSize")) : undefined,
72
- page: url.searchParams.has("page") ? Number(url.searchParams.get("page")) : undefined,
73
- });
74
- return this.reply(response, 200, records);
75
- }
76
- }
77
- if (resource === "audit" && method === "GET" && !id) {
78
- return this.servePage(response);
79
- }
80
- if (resource === "jobs") return this.handleJobs(method, id, subresource, url, request, response);
81
- if (resource === "queue" && method === "POST" && !subresource) return this.handleQueue(id, request, response);
82
- if (resource === "consumers" && method === "POST" && !id) {
83
- const { workflowId, url: consumerUrl } = await readBody(request);
84
- this.registry.register(workflowId, consumerUrl);
85
- return this.reply(response, 204);
86
- }
87
- if (resource === "apps" && this.buildLayer) {
88
- return this.handleApps(method, id, subresource, url, request, response);
89
- }
90
- if (resource === "state-machines" && method === "GET" && !id) {
91
- return this.reply(response, 200, this.registry.list());
92
- }
93
- if (resource === "documents" && id && this.documentStoreFor) {
94
- return this.handleDocuments(method, this.documentStoreFor(id), subresource, url, request, response);
95
- }
96
- if (resource === "secrets" && this.secretStore && !subresource) {
97
- return this.handleSecrets(method, id, request, response);
61
+ break;
62
+ case "state-machines":
63
+ if (method === "GET" && !id) return this.reply(response, 200, this.registry.list());
64
+ break;
65
+ case "authorize-cli":
66
+ if (this.cliAuthorizer && id) {
67
+ return this.handleAuthorizeCli(method, id, subresource, request, response, user, sessionTenant);
68
+ }
69
+ break;
70
+ case "keys":
71
+ if (this.cliAuthorizer) return this.handleKeys(method, id, response, user);
72
+ break;
73
+ case "audits":
74
+ if (this.auditRecords && !id) return this.handleAudits(method, url, request, response);
75
+ break;
76
+ case "jobs":
77
+ return this.handleJobs(method, id, subresource, url, request, response);
78
+ case "queue":
79
+ if (id && !subresource) return this.handleQueue(method, id, request, response);
80
+ break;
81
+ case "consumers":
82
+ if (!id) return this.handleConsumers(method, request, response);
83
+ break;
84
+ case "apps":
85
+ if (this.buildLayer) return this.handleApps(method, id, subresource, url, request, response);
86
+ break;
87
+ case "documents":
88
+ if (id && this.documentStoreFor) {
89
+ return this.handleDocuments(method, this.documentStoreFor(id), subresource, url, request, response);
90
+ }
91
+ break;
92
+ case "secrets":
93
+ if (!subresource && this.secretStore) return this.handleSecrets(method, id, request, response);
94
+ break;
98
95
  }
96
+
99
97
  if (resource && this.buildLayer) {
100
98
  const app = this.buildLayer.status(resource);
101
99
  if (app && app.status === "running") {
@@ -103,216 +101,312 @@ class Router {
103
101
  }
104
102
  }
105
103
 
106
- this.reply(response, 404, { error: "Not found" });
104
+ this.notFound(response);
107
105
  }
108
106
 
109
107
  private async handleAuthorizeCli(method : string, requestId : string, subresource : string | undefined,
110
108
  request : IncomingMessage, response : ServerResponse, user? : User,
111
109
  sessionTenant? : Tenant) : Promise<void> {
112
- if (method === "GET" && subresource === "poll") {
113
- const keyPair = this.cliAuthorizer!.collect(requestId);
114
- if (!keyPair) return this.reply(response, 202, { status: "pending" });
115
- return this.reply(response, 200, keyPair);
116
- }
117
-
118
- if (method === "GET" && !subresource) {
119
- return this.servePage(response);
120
- }
121
-
122
- if (method === "POST" && !subresource) {
123
- const { clientName } = await readBody(request);
124
- if (typeof clientName !== "string" || clientName.trim().length === 0) {
125
- return this.reply(response, 400, { error: "Expected a body of { clientName : string }" });
126
- }
127
- await this.cliAuthorizer!.approve(requestId, clientName.trim(), user ?? new User("local"),
128
- sessionTenant?.id ?? this.tenant);
129
- return this.reply(response, 204);
110
+ switch (subresource) {
111
+ case "poll":
112
+ switch (method) {
113
+ case "GET": {
114
+ const keyPair = this.cliAuthorizer!.collect(requestId);
115
+ if (!keyPair) return this.reply(response, 202, { status: "pending" });
116
+ return this.reply(response, 200, keyPair);
117
+ }
118
+ }
119
+ break;
120
+ case undefined:
121
+ switch (method) {
122
+ case "GET":
123
+ return this.servePage(response);
124
+ case "POST": {
125
+ const { clientName } = await readBody(request);
126
+ if (typeof clientName !== "string" || clientName.trim().length === 0) {
127
+ return this.reply(response, 400, { error: "Expected a body of { clientName : string }" });
128
+ }
129
+ await this.cliAuthorizer!.approve(requestId, clientName.trim(), user ?? new User("local"),
130
+ sessionTenant?.id ?? this.tenant);
131
+ return this.reply(response, 204);
132
+ }
133
+ }
134
+ break;
130
135
  }
131
136
 
132
- this.reply(response, 404, { error: "Not found" });
137
+ this.notFound(response);
133
138
  }
134
139
 
135
140
  private async handleKeys(method : string, id : string | undefined,
136
141
  response : ServerResponse, user? : User) : Promise<void> {
137
142
  const owner = user ?? new User("local");
138
143
 
139
- if (method === "GET" && !id) {
140
- const keys = await this.cliAuthorizer!.keysFor(owner.id);
141
- return this.reply(response, 200, keys.map(key => ({
142
- id: key.id,
143
- clientName: key.clientName,
144
- createdAt: key.createdAt.toISOString(),
145
- })));
146
- }
147
-
148
- if (method === "DELETE" && id) {
149
- await this.cliAuthorizer!.revoke(id, owner.id);
150
- return this.reply(response, 204);
144
+ if (id) {
145
+ switch (method) {
146
+ case "DELETE":
147
+ await this.cliAuthorizer!.revoke(id, owner.id);
148
+ return this.reply(response, 204);
149
+ }
150
+ } else {
151
+ switch (method) {
152
+ case "GET": {
153
+ const keys = await this.cliAuthorizer!.keysFor(owner.id);
154
+ return this.reply(response, 200, keys.map(key => ({
155
+ id: key.id,
156
+ clientName: key.clientName,
157
+ createdAt: key.createdAt.toISOString(),
158
+ })));
159
+ }
160
+ }
151
161
  }
152
162
 
153
- this.reply(response, 404, { error: "Not found" });
163
+ this.notFound(response);
154
164
  }
155
165
 
156
- private async servePage(response : ServerResponse) : Promise<void> {
157
- try {
158
- const page = await readFile(new URL("./pages/platform-ui.html", import.meta.url));
159
- response.writeHead(200, { "content-type": "text/html" });
160
- response.end(page);
161
- } catch {
162
- this.reply(response, 501, { error: "The platform UI has not been built - run npm run build in anbaric-cloud-hosting/ui" });
163
- }
164
- }
165
-
166
- private async forwardToApp(appHost : string, appPort : number, appName : string, method : string, url : URL,
166
+ private async handleAudits(method : string, url : URL,
167
167
  request : IncomingMessage, response : ServerResponse) : Promise<void> {
168
- const appPath = url.pathname.slice(`/${appName}`.length) || "/";
169
- const body = method === "GET" || method === "HEAD" ? undefined : await readRawBody(request);
170
-
171
- const upstream = await fetch(`http://${appHost}:${appPort}${appPath}${url.search}`, {
172
- method,
173
- headers: { "content-type": String(request.headers["content-type"] ?? "application/json") },
174
- body: body && body.length > 0 ? new Uint8Array(body) : undefined,
175
- });
176
-
177
- const payload = Buffer.from(await upstream.arrayBuffer());
178
- response.writeHead(upstream.status, { "content-type": upstream.headers.get("content-type") ?? "application/octet-stream" });
179
- response.end(payload);
180
- }
181
-
182
- private async handleSecrets(method : string, name : string | undefined,
183
- request : IncomingMessage, response : ServerResponse) : Promise<void> {
184
- if (!name && method === "GET") {
185
- return this.reply(response, 200, await this.secretStore!.list());
186
- }
187
-
188
- if (name) {
189
- if (method === "PUT") {
190
- const { value } = await readBody(request);
191
- if (typeof value !== "string") return this.reply(response, 400, { error: "Expected a body of { value : string }" });
192
- await this.secretStore!.save(name, value);
168
+ switch (method) {
169
+ case "POST": {
170
+ const record = await readBody(request);
171
+ if (typeof record?.jobId !== "string" || typeof record?.description !== "string"
172
+ || typeof record?.actorId !== "string" || typeof record?.actorType !== "string"
173
+ || !AUDIT_CHANGES.has(record?.change)) {
174
+ return this.reply(response, 400, { error: "Expected a body of { jobId, actorId, actorType, change, description, ... }" });
175
+ }
176
+ await this.auditRecords!.save(record);
193
177
  return this.reply(response, 204);
194
178
  }
195
- if (method === "GET") {
196
- return this.reply(response, 200, { value: await this.secretStore!.retrieve(name) });
197
- }
198
- if (method === "DELETE") {
199
- await this.secretStore!.delete(name);
200
- return this.reply(response, 204);
179
+ case "GET": {
180
+ const change = url.searchParams.get("change");
181
+ const records = await this.auditRecords!.list({
182
+ jobId: url.searchParams.get("jobId") ?? undefined,
183
+ actorId: url.searchParams.get("actorId") ?? undefined,
184
+ change: change && AUDIT_CHANGES.has(change) ? change as AuditChange : undefined,
185
+ search: url.searchParams.get("search") ?? undefined,
186
+ pageSize: url.searchParams.has("pageSize") ? Number(url.searchParams.get("pageSize")) : undefined,
187
+ page: url.searchParams.has("page") ? Number(url.searchParams.get("page")) : undefined,
188
+ });
189
+ return this.reply(response, 200, records);
201
190
  }
202
191
  }
203
192
 
204
- this.reply(response, 404, { error: "Not found" });
193
+ this.notFound(response);
205
194
  }
206
195
 
207
- private async handleDocuments(method : string, store : JsonStore, documentId : string | undefined,
208
- url : URL, request : IncomingMessage, response : ServerResponse) : Promise<void> {
209
- if (!documentId && method === "GET") {
210
- const pageSize = Number(url.searchParams.get("pageSize") ?? 100);
211
- const page = Number(url.searchParams.get("page") ?? 0);
212
- return this.reply(response, 200, await store.list(pageSize, page));
196
+ private async handleJobs(method : string, id : string | undefined, subresource : string | undefined,
197
+ url : URL, request : IncomingMessage, response : ServerResponse) : Promise<void> {
198
+ switch (subresource) {
199
+ case "properties":
200
+ if (id) {
201
+ switch (method) {
202
+ case "PATCH": {
203
+ const properties = new Map<string, any>(Object.entries(await readBody(request)));
204
+ await this.persistence.updateProperties(id, properties);
205
+ return this.reply(response, 204);
206
+ }
207
+ }
208
+ }
209
+ break;
210
+ case undefined:
211
+ if (id) {
212
+ switch (method) {
213
+ case "PUT":
214
+ await this.persistence.save(deserializeJob(await readBody(request)));
215
+ return this.reply(response, 204);
216
+ case "GET":
217
+ return this.reply(response, 200, serializeJob(await this.persistence.retrieve(id)));
218
+ case "DELETE":
219
+ await this.persistence.delete(id);
220
+ return this.reply(response, 204);
221
+ }
222
+ } else {
223
+ switch (method) {
224
+ case "GET": {
225
+ const pageSize = Number(url.searchParams.get("pageSize") ?? 100);
226
+ const page = Number(url.searchParams.get("page") ?? 0);
227
+ const jobs = await this.persistence.list(pageSize, page);
228
+ return this.reply(response, 200, jobs.map(serializeJob));
229
+ }
230
+ }
231
+ }
232
+ break;
213
233
  }
214
234
 
215
- if (documentId) {
216
- if (method === "PUT") {
217
- await store.save(documentId, await readBody(request));
218
- return this.reply(response, 204);
219
- }
220
- if (method === "GET") {
221
- return this.reply(response, 200, await store.retrieve(documentId));
222
- }
223
- if (method === "DELETE") {
224
- await store.delete(documentId);
235
+ this.notFound(response);
236
+ }
237
+
238
+ private async handleQueue(method : string, operation : string,
239
+ request : IncomingMessage, response : ServerResponse) : Promise<void> {
240
+ switch (operation) {
241
+ case "enqueue":
242
+ switch (method) {
243
+ case "POST": {
244
+ const { jobId, workflowId } = await readBody(request);
245
+ await this.queue.enqueue(jobId, workflowId);
246
+ return this.reply(response, 204);
247
+ }
248
+ }
249
+ break;
250
+ case "schedule":
251
+ switch (method) {
252
+ case "POST": {
253
+ const { jobId, workflowId, due } = await readBody(request);
254
+ await this.queue.schedule(jobId, workflowId, new Date(due));
255
+ return this.reply(response, 204);
256
+ }
257
+ }
258
+ break;
259
+ case "dequeue":
260
+ switch (method) {
261
+ case "POST":
262
+ return this.reply(response, 200, { messages: await this.queue.dequeueSome() });
263
+ }
264
+ break;
265
+ case "confirm":
266
+ switch (method) {
267
+ case "POST": {
268
+ const { jobId, workflowId } = await readBody(request);
269
+ await this.queue.confirm({ jobId, workflowId });
270
+ return this.reply(response, 204);
271
+ }
272
+ }
273
+ break;
274
+ }
275
+
276
+ this.notFound(response);
277
+ }
278
+
279
+ private async handleConsumers(method : string, request : IncomingMessage,
280
+ response : ServerResponse) : Promise<void> {
281
+ switch (method) {
282
+ case "POST": {
283
+ const { workflowId, url: consumerUrl } = await readBody(request);
284
+ this.registry.register(workflowId, consumerUrl);
225
285
  return this.reply(response, 204);
226
286
  }
227
287
  }
228
288
 
229
- this.reply(response, 404, { error: "Not found" });
289
+ this.notFound(response);
230
290
  }
231
291
 
232
292
  private async handleApps(method : string, appName : string | undefined, subresource : string | undefined,
233
293
  url : URL, request : IncomingMessage, response : ServerResponse) : Promise<void> {
234
- if (method === "GET" && !appName) {
235
- return this.reply(response, 200, this.buildLayer!.list());
294
+ switch (subresource) {
295
+ case "deploy":
296
+ if (appName) {
297
+ switch (method) {
298
+ case "POST": {
299
+ const appPort = Number(url.searchParams.get("port"));
300
+ if (!Number.isInteger(appPort) || appPort <= 0) {
301
+ return this.reply(response, 400, { error: "Expected a numeric port query parameter" });
302
+ }
303
+ const tarball = await readRawBody(request);
304
+ if (tarball.length === 0) return this.reply(response, 400, { error: "Expected a gzipped tarball body" });
305
+ return this.reply(response, 202, this.buildLayer!.deploy(appName, appPort, tarball));
306
+ }
307
+ }
308
+ }
309
+ break;
310
+ case undefined:
311
+ if (appName) {
312
+ switch (method) {
313
+ case "GET": {
314
+ const status = this.buildLayer!.status(appName);
315
+ if (!status) return this.reply(response, 404, { error: `No app named "${appName}"` });
316
+ return this.reply(response, 200, status);
317
+ }
318
+ }
319
+ } else {
320
+ switch (method) {
321
+ case "GET":
322
+ return this.reply(response, 200, this.buildLayer!.list());
323
+ }
324
+ }
325
+ break;
236
326
  }
237
327
 
238
- if (!appName) return this.reply(response, 404, { error: "Not found" });
328
+ this.notFound(response);
329
+ }
239
330
 
240
- if (method === "POST" && subresource === "deploy") {
241
- const appPort = Number(url.searchParams.get("port"));
242
- if (!Number.isInteger(appPort) || appPort <= 0) {
243
- return this.reply(response, 400, { error: "Expected a numeric port query parameter" });
331
+ private async handleDocuments(method : string, store : JsonStore, documentId : string | undefined,
332
+ url : URL, request : IncomingMessage, response : ServerResponse) : Promise<void> {
333
+ if (documentId) {
334
+ switch (method) {
335
+ case "PUT":
336
+ await store.save(documentId, await readBody(request));
337
+ return this.reply(response, 204);
338
+ case "GET":
339
+ return this.reply(response, 200, await store.retrieve(documentId));
340
+ case "DELETE":
341
+ await store.delete(documentId);
342
+ return this.reply(response, 204);
343
+ }
344
+ } else {
345
+ switch (method) {
346
+ case "GET": {
347
+ const pageSize = Number(url.searchParams.get("pageSize") ?? 100);
348
+ const page = Number(url.searchParams.get("page") ?? 0);
349
+ return this.reply(response, 200, await store.list(pageSize, page));
350
+ }
244
351
  }
245
- const tarball = await readRawBody(request);
246
- if (tarball.length === 0) return this.reply(response, 400, { error: "Expected a gzipped tarball body" });
247
- return this.reply(response, 202, this.buildLayer!.deploy(appName, appPort, tarball));
248
- }
249
-
250
- if (method === "GET" && !subresource) {
251
- const status = this.buildLayer!.status(appName);
252
- if (!status) return this.reply(response, 404, { error: `No app named "${appName}"` });
253
- return this.reply(response, 200, status);
254
352
  }
255
353
 
256
- this.reply(response, 404, { error: "Not found" });
354
+ this.notFound(response);
257
355
  }
258
356
 
259
- private async handleJobs(method : string, id : string | undefined, subresource : string | undefined,
260
- url : URL, request : IncomingMessage, response : ServerResponse) : Promise<void> {
261
- if (!id && method === "GET") {
262
- const pageSize = Number(url.searchParams.get("pageSize") ?? 100);
263
- const page = Number(url.searchParams.get("page") ?? 0);
264
- const jobs = await this.persistence.list(pageSize, page);
265
- return this.reply(response, 200, jobs.map(serializeJob));
266
- }
267
-
268
- if (id && !subresource) {
269
- if (method === "PUT") {
270
- await this.persistence.save(deserializeJob(await readBody(request)));
271
- return this.reply(response, 204);
357
+ private async handleSecrets(method : string, name : string | undefined,
358
+ request : IncomingMessage, response : ServerResponse) : Promise<void> {
359
+ if (name) {
360
+ switch (method) {
361
+ case "PUT": {
362
+ const { value } = await readBody(request);
363
+ if (typeof value !== "string") return this.reply(response, 400, { error: "Expected a body of { value : string }" });
364
+ await this.secretStore!.save(name, value);
365
+ return this.reply(response, 204);
366
+ }
367
+ case "GET":
368
+ return this.reply(response, 200, { value: await this.secretStore!.retrieve(name) });
369
+ case "DELETE":
370
+ await this.secretStore!.delete(name);
371
+ return this.reply(response, 204);
272
372
  }
273
- if (method === "GET") {
274
- const job = await this.persistence.retrieve(id);
275
- return this.reply(response, 200, serializeJob(job));
373
+ } else {
374
+ switch (method) {
375
+ case "GET":
376
+ return this.reply(response, 200, await this.secretStore!.list());
276
377
  }
277
- if (method === "DELETE") {
278
- await this.persistence.delete(id);
279
- return this.reply(response, 204);
280
- }
281
- }
282
-
283
- if (id && subresource === "properties" && method === "PATCH") {
284
- const properties = new Map<string, any>(Object.entries(await readBody(request)));
285
- await this.persistence.updateProperties(id, properties);
286
- return this.reply(response, 204);
287
378
  }
288
379
 
289
- this.reply(response, 404, { error: "Not found" });
380
+ this.notFound(response);
290
381
  }
291
382
 
292
- private async handleQueue(operation : string | undefined, request : IncomingMessage,
293
- response : ServerResponse) : Promise<void> {
294
- if (operation === "enqueue") {
295
- const { jobId, workflowId } = await readBody(request);
296
- await this.queue.enqueue(jobId, workflowId);
297
- return this.reply(response, 204);
383
+ private async servePage(response : ServerResponse) : Promise<void> {
384
+ try {
385
+ const page = await readFile(new URL("./pages/platform-ui.html", import.meta.url));
386
+ response.writeHead(200, { "content-type": "text/html" });
387
+ response.end(page);
388
+ } catch {
389
+ this.reply(response, 501, { error: "The platform UI has not been built - run npm run build in anbaric-cloud-hosting/ui" });
298
390
  }
391
+ }
299
392
 
300
- if (operation === "schedule") {
301
- const { jobId, workflowId, due } = await readBody(request);
302
- await this.queue.schedule(jobId, workflowId, new Date(due));
303
- return this.reply(response, 204);
304
- }
393
+ private async forwardToApp(appHost : string, appPort : number, appName : string, method : string, url : URL,
394
+ request : IncomingMessage, response : ServerResponse) : Promise<void> {
395
+ const appPath = url.pathname.slice(`/${appName}`.length) || "/";
396
+ const body = method === "GET" || method === "HEAD" ? undefined : await readRawBody(request);
305
397
 
306
- if (operation === "dequeue") {
307
- return this.reply(response, 200, { messages: await this.queue.dequeueSome() });
308
- }
398
+ const upstream = await fetch(`http://${appHost}:${appPort}${appPath}${url.search}`, {
399
+ method,
400
+ headers: { "content-type": String(request.headers["content-type"] ?? "application/json") },
401
+ body: body && body.length > 0 ? new Uint8Array(body) : undefined,
402
+ });
309
403
 
310
- if (operation === "confirm") {
311
- const { jobId, workflowId } = await readBody(request);
312
- await this.queue.confirm({ jobId, workflowId });
313
- return this.reply(response, 204);
314
- }
404
+ const payload = Buffer.from(await upstream.arrayBuffer());
405
+ response.writeHead(upstream.status, { "content-type": upstream.headers.get("content-type") ?? "application/octet-stream" });
406
+ response.end(payload);
407
+ }
315
408
 
409
+ private notFound(response : ServerResponse) : void {
316
410
  this.reply(response, 404, { error: "Not found" });
317
411
  }
318
412