anbaric-cloud-hosting 1.5.0 → 1.7.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.
Files changed (31) hide show
  1. package/package.json +7 -5
  2. package/src/hosting/HostingServer.ts +94 -98
  3. package/src/hosting/Middleware.ts +11 -0
  4. package/src/hosting/Request.ts +108 -0
  5. package/src/hosting/RequestHandler.ts +9 -0
  6. package/src/hosting/Router.ts +22 -407
  7. package/src/hosting/Server.ts +54 -0
  8. package/src/hosting/handlers/AppProxyHandler.ts +34 -0
  9. package/src/hosting/handlers/AppsHandler.ts +57 -0
  10. package/src/hosting/handlers/AuditsHandler.ts +50 -0
  11. package/src/hosting/handlers/ConsumersHandler.ts +24 -0
  12. package/src/hosting/handlers/DocumentsHandler.ts +44 -0
  13. package/src/hosting/handlers/JobsHandler.ts +60 -0
  14. package/src/hosting/handlers/PagesHandler.ts +22 -0
  15. package/src/hosting/handlers/PingHandler.ts +18 -0
  16. package/src/hosting/handlers/PluginsHandler.ts +52 -0
  17. package/src/hosting/handlers/QueueHandler.ts +36 -0
  18. package/src/hosting/handlers/SecretsHandler.ts +42 -0
  19. package/src/hosting/handlers/StateMachinesHandler.ts +21 -0
  20. package/src/hosting/handlers/auth/AuthorizeCliHandler.ts +50 -0
  21. package/src/hosting/handlers/auth/KeysHandler.ts +36 -0
  22. package/src/hosting/handlers/auth/WhoamiHandler.ts +18 -0
  23. package/src/hosting/middleware/AuthenticationMiddleware.ts +52 -0
  24. package/src/hosting/middleware/SessionMiddleware.ts +21 -0
  25. package/src/hosting/pages/platform-ui.html +4 -4
  26. package/src/index.ts +25 -0
  27. package/src/main.ts +4 -1
  28. package/src/plugins/PageDirectory.ts +35 -0
  29. package/src/plugins/Plugin.ts +39 -0
  30. package/src/plugins/PluginBundler.ts +75 -0
  31. package/src/plugins/PluginLoader.ts +72 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anbaric-cloud-hosting",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "description": "Anbaric Cloud hosting service: Postgres-backed job persistence and queuing exposed over an HTTP API",
5
5
  "license": "MIT",
6
6
  "author": "chris@anbaric.ai",
@@ -17,15 +17,17 @@
17
17
  "@aws-sdk/client-s3": "^3.1110.0",
18
18
  "@aws-sdk/client-secrets-manager": "^3.700.0",
19
19
  "@aws-sdk/client-servicediscovery": "^3.1110.0",
20
- "anbaric-data-store": "^1.5.0",
21
- "anbaric-tsapi": "^1.5.0",
20
+ "anbaric-data-store": "^1.7.0",
21
+ "anbaric-plugins": "^1.7.0",
22
+ "anbaric-tsapi": "^1.7.0",
23
+ "esbuild": "^0.28.2",
22
24
  "pg": "^8.16.0"
23
25
  },
24
26
  "devDependencies": {
25
27
  "@types/node": "^26.2.0",
26
28
  "@types/pg": "^8.15.0",
27
- "anbaric-impl-cloud": "^1.5.0",
28
- "anbaric-state-machine": "^1.5.0",
29
+ "anbaric-impl-cloud": "^1.7.0",
30
+ "anbaric-state-machine": "^1.7.0",
29
31
  "tsx": "^4.20.0",
30
32
  "typescript": "^7.0.2"
31
33
  },
@@ -1,131 +1,127 @@
1
- import {createServer, IncomingMessage, Server, ServerResponse} from "node:http";
2
- import {AddressInfo} from "node:net";
3
1
  import {JobPersistence, JsonStore, SecretStore} from "anbaric-tsapi";
4
2
  import {BuildLayer} from "../app-management/BuildLayer";
5
3
  import {AuditRecordStore} from "../auditing/AuditRecordStore";
6
- import {Authenticator, SESSION_COOKIE} from "../auth/Authenticator";
4
+ import {Authenticator} from "../auth/Authenticator";
7
5
  import {CliAuthorizer} from "../auth/CliAuthorizer";
8
- import {Tenant} from "../auth/Tenant";
9
6
  import {TokenAuthenticator} from "../auth/TokenAuthenticator";
10
- import {User} from "../auth/User";
11
7
  import {ConfirmableQueue} from "../queuing/ConfirmableQueue";
12
8
  import {ConsumerRegistry} from "../queuing/ConsumerRegistry";
9
+ import {AppProxyHandler} from "./handlers/AppProxyHandler";
10
+ import {AppsHandler} from "./handlers/AppsHandler";
11
+ import {AuditsHandler} from "./handlers/AuditsHandler";
12
+ import {AuthorizeCliHandler} from "./handlers/auth/AuthorizeCliHandler";
13
+ import {KeysHandler} from "./handlers/auth/KeysHandler";
14
+ import {WhoamiHandler} from "./handlers/auth/WhoamiHandler";
15
+ import {ConsumersHandler} from "./handlers/ConsumersHandler";
16
+ import {DocumentsHandler} from "./handlers/DocumentsHandler";
17
+ import {JobsHandler} from "./handlers/JobsHandler";
18
+ import {PagesHandler} from "./handlers/PagesHandler";
19
+ import {PingHandler} from "./handlers/PingHandler";
20
+ import {QueueHandler} from "./handlers/QueueHandler";
21
+ import {SecretsHandler} from "./handlers/SecretsHandler";
22
+ import {StateMachinesHandler} from "./handlers/StateMachinesHandler";
23
+ import {PluginsHandler} from "./handlers/PluginsHandler";
24
+ import {AuthenticationMiddleware} from "./middleware/AuthenticationMiddleware";
25
+ import {SessionMiddleware} from "./middleware/SessionMiddleware";
26
+ import {PageDirectory} from "../plugins/PageDirectory";
27
+ import {LoadedPlugin} from "../plugins/Plugin";
28
+ import {Request} from "./Request";
13
29
  import {Router} from "./Router";
30
+ import {Server} from "./Server";
14
31
 
15
- const INTERNAL_RESOURCES = new Set(["jobs", "queue", "consumers", "state-machines", "documents", "secrets", "audits"]);
32
+ const CLI_KEY_POLL = /^\/authorize-cli\/[^/]+\/poll$/;
16
33
 
34
+ /* The composition root: builds the handlers from the platform's
35
+ collaborators, registers them on the public and internal routers, and
36
+ runs one Server per entry point. The public server authenticates through
37
+ its middleware chain; the internal server exposes only the workflow
38
+ handlers, unauthenticated, and must never be publicly reachable. */
17
39
  class HostingServer {
18
40
 
19
- private server : Server;
41
+ private publicServer : Server;
20
42
  private internalServer : Server;
21
- private router : Router;
22
43
 
23
44
  constructor(persistence : JobPersistence, queue : ConfirmableQueue,
24
45
  registry : ConsumerRegistry = new ConsumerRegistry(),
25
46
  buildLayer? : BuildLayer,
26
47
  documentStoreFor? : (collection : string) => JsonStore,
27
48
  secretStore? : SecretStore,
28
- private authenticator? : Authenticator,
49
+ authenticator? : Authenticator,
29
50
  cliAuthorizer? : CliAuthorizer,
30
- private tokenAuthenticator? : TokenAuthenticator,
31
- private tenant? : string,
32
- auditRecords? : AuditRecordStore) {
33
-
34
- this.router = new Router(persistence, queue, registry, buildLayer, documentStoreFor, secretStore, cliAuthorizer, tenant, auditRecords);
35
- this.server = this.serverFor((request, response) => this.handle(request, response));
36
- this.internalServer = this.serverFor((request, response) => this.handleInternal(request, response));
37
- }
38
-
39
- listen(port : number) : Promise<number> {
40
- return new Promise(resolve =>
41
- this.server.listen(port, () => resolve((this.server.address() as AddressInfo).port)));
42
- }
43
-
44
- listenInternal(port : number) : Promise<number> {
45
- return new Promise(resolve =>
46
- this.internalServer.listen(port, () => resolve((this.internalServer.address() as AddressInfo).port)));
47
- }
48
-
49
- close() : Promise<void> {
50
- return new Promise((resolve, reject) => {
51
- if (this.internalServer.listening) this.internalServer.close();
52
- this.server.close(error => error ? reject(error) : resolve());
53
- });
54
- }
55
-
56
- private serverFor(handle : (request : IncomingMessage, response : ServerResponse) => Promise<void>) : Server {
57
- return createServer((request, response) => {
58
- handle(request, response).catch(error => {
59
- const message = error instanceof Error ? error.message : "Internal error";
60
- const status = /^No .+ found/.test(message) ? 404 : 500;
61
- this.reply(response, status, { error: message });
62
- });
63
- });
64
- }
65
-
66
- private async handle(request : IncomingMessage, response : ServerResponse) : Promise<void> {
67
- const path = request.url?.split("?")[0] ?? "/";
68
- if (path === "/ping" && request.method === "GET") {
69
- return this.reply(response, 200, this.tenant ? { status: "ok", tenant: this.tenant } : { status: "ok" });
70
- }
71
- if (/^\/authorize-cli\/[^/]+\/poll$/.test(path) && request.method === "GET") {
72
- return this.router.route(request, response);
73
- }
74
-
75
- if (this.tokenAuthenticator?.handles(request)) {
76
- const user = await this.tokenAuthenticator.authenticate(request, response);
77
- if (!user) return;
78
- return this.authorizeAndRoute(user, undefined, request, response);
51
+ tokenAuthenticator? : TokenAuthenticator,
52
+ tenant? : string,
53
+ auditRecords? : AuditRecordStore,
54
+ plugins : Array<LoadedPlugin> = []) {
55
+ const pages = new PagesHandler();
56
+ const ping = new PingHandler(tenant);
57
+ const jobs = new JobsHandler(persistence);
58
+ const queueHandler = new QueueHandler(queue);
59
+ const consumers = new ConsumersHandler(registry);
60
+ const stateMachines = new StateMachinesHandler(registry);
61
+ const documents = documentStoreFor && new DocumentsHandler(documentStoreFor);
62
+ const secrets = secretStore && new SecretsHandler(secretStore);
63
+ const audits = auditRecords && new AuditsHandler(auditRecords);
64
+
65
+ const publicRouter = new Router();
66
+ publicRouter.registerRoot(pages);
67
+ if (plugins.length > 0) {
68
+ for (const segment of new PageDirectory(plugins).topLevelSegments) {
69
+ publicRouter.register(segment, pages);
70
+ }
71
+ publicRouter.register("plugins", new PluginsHandler(plugins));
79
72
  }
80
-
81
- const authenticated = await this.authenticateSession(request, response);
82
- if (this.authenticator && !authenticated) return;
83
- if (authenticated) return this.authorizeAndRoute(authenticated[0], authenticated[1], request, response);
84
-
85
- await this.router.route(request, response);
86
- }
87
-
88
- private async handleInternal(request : IncomingMessage, response : ServerResponse) : Promise<void> {
89
- const path = request.url?.split("?")[0] ?? "/";
90
- if (path === "/ping" && request.method === "GET") {
91
- return this.reply(response, 200, this.tenant ? { status: "ok", tenant: this.tenant } : { status: "ok" });
73
+ publicRouter.register("ping", ping);
74
+ publicRouter.register("audit", pages);
75
+ publicRouter.register("whoami", new WhoamiHandler());
76
+ publicRouter.register("jobs", jobs);
77
+ publicRouter.register("queue", queueHandler);
78
+ publicRouter.register("consumers", consumers);
79
+ publicRouter.register("state-machines", stateMachines);
80
+ if (documents) publicRouter.register("documents", documents);
81
+ if (secrets) publicRouter.register("secrets", secrets);
82
+ if (audits) publicRouter.register("audits", audits);
83
+ if (cliAuthorizer) {
84
+ publicRouter.register("authorize-cli", new AuthorizeCliHandler(cliAuthorizer, pages, tenant));
85
+ publicRouter.register("keys", new KeysHandler(cliAuthorizer));
86
+ publicRouter.register("manage-keys", pages);
92
87
  }
93
- const [resource] = path.split("/").filter(Boolean);
94
- if (!resource || !INTERNAL_RESOURCES.has(resource)) {
95
- return this.reply(response, 404, { error: "Not found" });
88
+ if (buildLayer) {
89
+ publicRouter.register("apps", new AppsHandler(buildLayer));
90
+ publicRouter.registerFallback(new AppProxyHandler(buildLayer));
96
91
  }
97
- await this.router.route(request, response);
98
- }
99
92
 
100
- private async authorizeAndRoute(user : User, tenant : Tenant | undefined,
101
- request : IncomingMessage, response : ServerResponse) : Promise<void> {
102
- if (this.authenticator) {
103
- const permitted = await this.authenticator.authorize(user, request, response);
104
- if (!permitted) {
105
- if (!response.writableEnded) this.reply(response, 403, { error: "Not authorized" });
106
- return;
107
- }
108
- }
109
- await this.router.route(request, response, user, tenant);
93
+ const internalRouter = new Router();
94
+ internalRouter.register("ping", ping);
95
+ internalRouter.register("jobs", jobs);
96
+ internalRouter.register("queue", queueHandler);
97
+ internalRouter.register("consumers", consumers);
98
+ internalRouter.register("state-machines", stateMachines);
99
+ if (documents) internalRouter.register("documents", documents);
100
+ if (secrets) internalRouter.register("secrets", secrets);
101
+ if (audits) internalRouter.register("audits", audits);
102
+
103
+ const openRequests = (request : Request) =>
104
+ request.url.pathname === "/ping" ||
105
+ (request.method === "GET" && CLI_KEY_POLL.test(request.url.pathname));
106
+
107
+ this.publicServer = new Server(publicRouter, [
108
+ new SessionMiddleware(),
109
+ new AuthenticationMiddleware(authenticator, tokenAuthenticator, openRequests),
110
+ ]);
111
+ this.internalServer = new Server(internalRouter);
110
112
  }
111
113
 
112
- private async authenticateSession(request : IncomingMessage, response : ServerResponse) : Promise<[User, Tenant] | undefined> {
113
- if (!this.authenticator) return undefined;
114
- return this.authenticator.authenticate(this.sessionCookie(request), request, response);
114
+ listen(port : number) : Promise<number> {
115
+ return this.publicServer.listen(port);
115
116
  }
116
117
 
117
- private sessionCookie(request : IncomingMessage) : string | undefined {
118
- const cookies = String(request.headers.cookie ?? "").split(";");
119
- for (const cookie of cookies) {
120
- const [name, ...value] = cookie.trim().split("=");
121
- if (name === SESSION_COOKIE) return value.join("=");
122
- }
123
- return undefined;
118
+ listenInternal(port : number) : Promise<number> {
119
+ return this.internalServer.listen(port);
124
120
  }
125
121
 
126
- private reply(response : ServerResponse, status : number, body : unknown) : void {
127
- response.writeHead(status, { "content-type": "application/json" });
128
- response.end(JSON.stringify(body));
122
+ async close() : Promise<void> {
123
+ if (this.internalServer.listening) await this.internalServer.close();
124
+ await this.publicServer.close();
129
125
  }
130
126
 
131
127
  }
@@ -0,0 +1,11 @@
1
+ import {Request} from "./Request";
2
+
3
+ /* Runs before routing; may decorate the request or answer it. Returning
4
+ false stops the chain - the middleware has written the response. */
5
+ interface Middleware {
6
+
7
+ apply(request : Request) : Promise<boolean>;
8
+
9
+ }
10
+
11
+ export type { Middleware }
@@ -0,0 +1,108 @@
1
+ import {randomUUID} from "node:crypto";
2
+ import {IncomingMessage, ServerResponse} from "node:http";
3
+ import {Tenant} from "../auth/Tenant";
4
+ import {User} from "../auth/User";
5
+
6
+ /* One inbound request: the parsed URL and body plus everything the server's
7
+ middleware decorates it with - session, user, tenant, ray trace id -
8
+ before it reaches a handler. Handlers respond through reply/replyHtml and
9
+ never touch the raw response unless they stream (the app proxy). */
10
+ class Request {
11
+
12
+ readonly rayId : string;
13
+ session? : string;
14
+ user? : User;
15
+ tenant? : Tenant;
16
+
17
+ private parsed : URL;
18
+ private segments : Array<string>;
19
+
20
+ constructor(private incoming : IncomingMessage, private response : ServerResponse,
21
+ rayId : string = randomUUID()) {
22
+ this.rayId = rayId;
23
+ this.parsed = new URL(incoming.url ?? "/", "http://localhost");
24
+ this.segments = this.parsed.pathname.split("/").filter(Boolean);
25
+ }
26
+
27
+ get method() : string {
28
+ return this.incoming.method ?? "GET";
29
+ }
30
+
31
+ get url() : URL {
32
+ return this.parsed;
33
+ }
34
+
35
+ get resource() : string | undefined {
36
+ return this.segments[0];
37
+ }
38
+
39
+ get id() : string | undefined {
40
+ return this.segments[1];
41
+ }
42
+
43
+ get subresource() : string | undefined {
44
+ return this.segments[2];
45
+ }
46
+
47
+ get raw() : IncomingMessage {
48
+ return this.incoming;
49
+ }
50
+
51
+ get rawResponse() : ServerResponse {
52
+ return this.response;
53
+ }
54
+
55
+ get handled() : boolean {
56
+ return this.response.writableEnded;
57
+ }
58
+
59
+ header(name : string) : string | undefined {
60
+ const value = this.incoming.headers[name.toLowerCase()];
61
+ return value === undefined ? undefined : String(value);
62
+ }
63
+
64
+ query(name : string) : string | undefined {
65
+ return this.parsed.searchParams.get(name) ?? undefined;
66
+ }
67
+
68
+ rawBody() : Promise<Buffer> {
69
+ return new Promise((resolve, reject) => {
70
+ const chunks : Array<Buffer> = [];
71
+ this.incoming.on("data", chunk => chunks.push(chunk));
72
+ this.incoming.on("error", reject);
73
+ this.incoming.on("end", () => resolve(Buffer.concat(chunks)));
74
+ });
75
+ }
76
+
77
+ async body() : Promise<any> {
78
+ const raw = (await this.rawBody()).toString();
79
+ return raw.length === 0 ? undefined : JSON.parse(raw);
80
+ }
81
+
82
+ reply(status : number, body? : unknown) : void {
83
+ if (body === undefined) {
84
+ this.response.statusCode = status;
85
+ this.response.end();
86
+ return;
87
+ }
88
+ this.response.writeHead(status, { "content-type": "application/json" });
89
+ this.response.end(JSON.stringify(body));
90
+ }
91
+
92
+ replyHtml(page : Buffer) : void {
93
+ this.response.writeHead(200, { "content-type": "text/html" });
94
+ this.response.end(page);
95
+ }
96
+
97
+ replyJavaScript(script : string) : void {
98
+ this.response.writeHead(200, { "content-type": "text/javascript" });
99
+ this.response.end(script);
100
+ }
101
+
102
+ notFound() : void {
103
+ this.reply(404, { error: "Not found" });
104
+ }
105
+
106
+ }
107
+
108
+ export { Request }
@@ -0,0 +1,9 @@
1
+ import {Request} from "./Request";
2
+
3
+ interface RequestHandler {
4
+
5
+ handle(request : Request) : Promise<void>;
6
+
7
+ }
8
+
9
+ export type { RequestHandler }