anbaric-cloud-hosting 1.5.0 → 1.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anbaric-cloud-hosting",
3
- "version": "1.5.0",
3
+ "version": "1.6.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,15 @@
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.6.0",
21
+ "anbaric-tsapi": "^1.6.0",
22
22
  "pg": "^8.16.0"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/node": "^26.2.0",
26
26
  "@types/pg": "^8.15.0",
27
- "anbaric-impl-cloud": "^1.5.0",
28
- "anbaric-state-machine": "^1.5.0",
27
+ "anbaric-impl-cloud": "^1.6.0",
28
+ "anbaric-state-machine": "^1.6.0",
29
29
  "tsx": "^4.20.0",
30
30
  "typescript": "^7.0.2"
31
31
  },
@@ -1,131 +1,117 @@
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 {AuthenticationMiddleware} from "./middleware/AuthenticationMiddleware";
24
+ import {SessionMiddleware} from "./middleware/SessionMiddleware";
25
+ import {Request} from "./Request";
13
26
  import {Router} from "./Router";
27
+ import {Server} from "./Server";
14
28
 
15
- const INTERNAL_RESOURCES = new Set(["jobs", "queue", "consumers", "state-machines", "documents", "secrets", "audits"]);
29
+ const CLI_KEY_POLL = /^\/authorize-cli\/[^/]+\/poll$/;
16
30
 
31
+ /* The composition root: builds the handlers from the platform's
32
+ collaborators, registers them on the public and internal routers, and
33
+ runs one Server per entry point. The public server authenticates through
34
+ its middleware chain; the internal server exposes only the workflow
35
+ handlers, unauthenticated, and must never be publicly reachable. */
17
36
  class HostingServer {
18
37
 
19
- private server : Server;
38
+ private publicServer : Server;
20
39
  private internalServer : Server;
21
- private router : Router;
22
40
 
23
41
  constructor(persistence : JobPersistence, queue : ConfirmableQueue,
24
42
  registry : ConsumerRegistry = new ConsumerRegistry(),
25
43
  buildLayer? : BuildLayer,
26
44
  documentStoreFor? : (collection : string) => JsonStore,
27
45
  secretStore? : SecretStore,
28
- private authenticator? : Authenticator,
46
+ authenticator? : Authenticator,
29
47
  cliAuthorizer? : CliAuthorizer,
30
- private tokenAuthenticator? : TokenAuthenticator,
31
- private tenant? : string,
48
+ tokenAuthenticator? : TokenAuthenticator,
49
+ tenant? : string,
32
50
  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" });
51
+ const pages = new PagesHandler();
52
+ const ping = new PingHandler(tenant);
53
+ const jobs = new JobsHandler(persistence);
54
+ const queueHandler = new QueueHandler(queue);
55
+ const consumers = new ConsumersHandler(registry);
56
+ const stateMachines = new StateMachinesHandler(registry);
57
+ const documents = documentStoreFor && new DocumentsHandler(documentStoreFor);
58
+ const secrets = secretStore && new SecretsHandler(secretStore);
59
+ const audits = auditRecords && new AuditsHandler(auditRecords);
60
+
61
+ const publicRouter = new Router();
62
+ publicRouter.registerRoot(pages);
63
+ publicRouter.register("ping", ping);
64
+ publicRouter.register("audit", pages);
65
+ publicRouter.register("whoami", new WhoamiHandler());
66
+ publicRouter.register("jobs", jobs);
67
+ publicRouter.register("queue", queueHandler);
68
+ publicRouter.register("consumers", consumers);
69
+ publicRouter.register("state-machines", stateMachines);
70
+ if (documents) publicRouter.register("documents", documents);
71
+ if (secrets) publicRouter.register("secrets", secrets);
72
+ if (audits) publicRouter.register("audits", audits);
73
+ if (cliAuthorizer) {
74
+ publicRouter.register("authorize-cli", new AuthorizeCliHandler(cliAuthorizer, pages, tenant));
75
+ publicRouter.register("keys", new KeysHandler(cliAuthorizer));
76
+ publicRouter.register("manage-keys", pages);
70
77
  }
71
- if (/^\/authorize-cli\/[^/]+\/poll$/.test(path) && request.method === "GET") {
72
- return this.router.route(request, response);
78
+ if (buildLayer) {
79
+ publicRouter.register("apps", new AppsHandler(buildLayer));
80
+ publicRouter.registerFallback(new AppProxyHandler(buildLayer));
73
81
  }
74
82
 
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);
79
- }
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" });
92
- }
93
- const [resource] = path.split("/").filter(Boolean);
94
- if (!resource || !INTERNAL_RESOURCES.has(resource)) {
95
- return this.reply(response, 404, { error: "Not found" });
96
- }
97
- await this.router.route(request, response);
83
+ const internalRouter = new Router();
84
+ internalRouter.register("ping", ping);
85
+ internalRouter.register("jobs", jobs);
86
+ internalRouter.register("queue", queueHandler);
87
+ internalRouter.register("consumers", consumers);
88
+ internalRouter.register("state-machines", stateMachines);
89
+ if (documents) internalRouter.register("documents", documents);
90
+ if (secrets) internalRouter.register("secrets", secrets);
91
+ if (audits) internalRouter.register("audits", audits);
92
+
93
+ const openRequests = (request : Request) =>
94
+ request.url.pathname === "/ping" ||
95
+ (request.method === "GET" && CLI_KEY_POLL.test(request.url.pathname));
96
+
97
+ this.publicServer = new Server(publicRouter, [
98
+ new SessionMiddleware(),
99
+ new AuthenticationMiddleware(authenticator, tokenAuthenticator, openRequests),
100
+ ]);
101
+ this.internalServer = new Server(internalRouter);
98
102
  }
99
103
 
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);
110
- }
111
-
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);
104
+ listen(port : number) : Promise<number> {
105
+ return this.publicServer.listen(port);
115
106
  }
116
107
 
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;
108
+ listenInternal(port : number) : Promise<number> {
109
+ return this.internalServer.listen(port);
124
110
  }
125
111
 
126
- private reply(response : ServerResponse, status : number, body : unknown) : void {
127
- response.writeHead(status, { "content-type": "application/json" });
128
- response.end(JSON.stringify(body));
112
+ async close() : Promise<void> {
113
+ if (this.internalServer.listening) await this.internalServer.close();
114
+ await this.publicServer.close();
129
115
  }
130
116
 
131
117
  }
@@ -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,103 @@
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
+ notFound() : void {
98
+ this.reply(404, { error: "Not found" });
99
+ }
100
+
101
+ }
102
+
103
+ 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 }