anbaric-cloud-hosting 1.51.0 → 1.52.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.51.0",
3
+ "version": "1.52.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",
@@ -18,18 +18,18 @@
18
18
  "@aws-sdk/client-s3": "^3.1110.0",
19
19
  "@aws-sdk/client-secrets-manager": "^3.700.0",
20
20
  "@aws-sdk/client-servicediscovery": "^3.1110.0",
21
- "anbaric-data-store": "^1.51.0",
22
- "anbaric-plugins": "^1.51.0",
23
- "anbaric-tsapi": "^1.51.0",
24
- "anbaric-web": "^1.51.0",
21
+ "anbaric-data-store": "^1.52.0",
22
+ "anbaric-plugins": "^1.52.0",
23
+ "anbaric-tsapi": "^1.52.0",
24
+ "anbaric-web": "^1.52.0",
25
25
  "esbuild": "^0.28.2",
26
26
  "pg": "^8.16.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^26.2.0",
30
30
  "@types/pg": "^8.15.0",
31
- "anbaric-impl-cloud": "^1.51.0",
32
- "anbaric-state-machine": "^1.51.0",
31
+ "anbaric-impl-cloud": "^1.52.0",
32
+ "anbaric-state-machine": "^1.52.0",
33
33
  "tsx": "^4.20.0",
34
34
  "typescript": "^7.0.2"
35
35
  },
@@ -0,0 +1,39 @@
1
+ /* An app is reachable at a hostname of its own, so that it is served at the
2
+ root of it and works exactly as it did when it was built locally. Two tenants
3
+ will both have a hello-world, so the tenant is part of the name.
4
+
5
+ One label, not two: a TLS wildcard matches a single label, so app.tenant.domain
6
+ would need a certificate and a DNS record for every tenant, which would put a
7
+ person in the middle of a self-serve signup. app--tenant.domain needs neither,
8
+ ever. The separator is a double hyphen because an underscore is not legal in a
9
+ hostname and would be rejected by certificates and by some browsers. */
10
+
11
+ const SEPARATOR = "--";
12
+
13
+ // A DNS label may not exceed 63 characters, and the whole label is the app and
14
+ // the tenant together, so the limit is shared between them.
15
+ const LABEL_LIMIT = 63;
16
+
17
+ const SHAPE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
18
+
19
+ const hostLabelFor = (appName : string, tenant : string) : string => `${appName}${SEPARATOR}${tenant}`;
20
+
21
+ /* Why this name cannot have a hostname, or undefined when it can. Returned as a
22
+ sentence because it is shown to whoever ran the deploy. */
23
+ const hostnameObjection = (appName : string, tenant : string) : string | undefined => {
24
+ if (! SHAPE.test(appName)) {
25
+ return `"${appName}" must be lower-case letters, numbers and hyphens, starting and ending with a letter or number`;
26
+ }
27
+ if (appName.includes(SEPARATOR)) {
28
+ return `"${appName}" cannot contain "${SEPARATOR}" - it is what separates the app from the tenant in its hostname`;
29
+ }
30
+ if (! tenant) return undefined;
31
+
32
+ const label = hostLabelFor(appName, tenant);
33
+ if (label.length > LABEL_LIMIT) {
34
+ return `"${appName}" is too long for a hostname alongside tenant "${tenant}": ${label.length} characters, and a hostname label allows ${LABEL_LIMIT}`;
35
+ }
36
+ return undefined;
37
+ };
38
+
39
+ export { hostLabelFor, hostnameObjection, SEPARATOR, LABEL_LIMIT };
@@ -38,6 +38,7 @@ import {PluginsHandler} from "./handlers/PluginsHandler";
38
38
  import {AuthenticationMiddleware} from "./middleware/AuthenticationMiddleware";
39
39
  import {SessionMiddleware} from "./middleware/SessionMiddleware";
40
40
  import {TenantRoutingMiddleware} from "./middleware/TenantRoutingMiddleware";
41
+ import {AppHostMiddleware} from "./middleware/AppHostMiddleware";
41
42
  import {LoadedPlugin} from "../plugins/Plugin";
42
43
  import {Request} from "./Request";
43
44
  import {Router} from "./Router";
@@ -109,9 +110,10 @@ class HostingServer {
109
110
  publicRouter.register("authorize-cli", new AuthorizeCliHandler(cliAuthorizer, pages, tenant));
110
111
  publicRouter.registerApi("keys", new KeysHandler(cliAuthorizer));
111
112
  }
112
- if (buildLayer) {
113
- publicRouter.registerApi("apps", new AppsHandler(buildLayer));
114
- publicRouter.register("app", new AppProxyHandler(buildLayer));
113
+ const appProxy = buildLayer ? new AppProxyHandler(buildLayer) : undefined;
114
+ if (buildLayer && appProxy) {
115
+ publicRouter.registerApi("apps", new AppsHandler(buildLayer, tenant));
116
+ publicRouter.register("app", appProxy);
115
117
  }
116
118
  // An unrouted absolute path carrying an app Referer is an app-internal
117
119
  // link the proxy's prefix-stripping left bare; send it back to its app.
@@ -143,6 +145,9 @@ class HostingServer {
143
145
  this.publicServer = new Server(publicRouter, [
144
146
  new SessionMiddleware(),
145
147
  new TenantRoutingMiddleware(tenant),
148
+ // Before routing: an app on its own hostname owns every path, so
149
+ // there is nothing for the platform's own routes to match against.
150
+ new AppHostMiddleware(appProxy),
146
151
  new AuthenticationMiddleware(authenticator, tokenAuthenticator, openRequests, undefined, userDirectory, memberships),
147
152
  ]);
148
153
  this.internalServer = new Server(internalRouter);
@@ -2,6 +2,7 @@ import {randomUUID} from "node:crypto";
2
2
  import {IncomingMessage, ServerResponse} from "node:http";
3
3
  import {Tenant} from "../auth/Tenant";
4
4
  import {User} from "../auth/User";
5
+ import {ErrorPage, NOT_FOUND, render} from "./pages/ErrorPage";
5
6
 
6
7
  /* One inbound request: the parsed URL and body plus everything the server's
7
8
  middleware decorates it with - session, user, tenant, ray trace id -
@@ -125,7 +126,32 @@ class Request {
125
126
  }
126
127
 
127
128
  notFound() : void {
128
- this.reply(404, { error: "Not found" });
129
+ this.replyProblem(NOT_FOUND);
130
+ }
131
+
132
+ /* Answers in whatever the caller can read: a person gets a page that says
133
+ what happened, a client gets the json it was expecting. Deciding by the
134
+ Accept header rather than by the route means an api call never has html
135
+ pushed at it, and a browser never sees a bare error object. */
136
+ replyProblem(page : ErrorPage) : void {
137
+ const retry = page.retryAfter ? { "retry-after": String(page.retryAfter) } : {};
138
+
139
+ if (! this.wantsHtml()) {
140
+ this.response.writeHead(page.status, { "content-type": "application/json", ...retry });
141
+ this.response.end(JSON.stringify({ error: page.heading, detail: page.detail }));
142
+ return;
143
+ }
144
+
145
+ this.response.writeHead(page.status, {
146
+ "content-type": "text/html; charset=utf-8",
147
+ "cache-control": "no-store",
148
+ ...retry,
149
+ });
150
+ this.response.end(render(page));
151
+ }
152
+
153
+ private wantsHtml() : boolean {
154
+ return String(this.header("accept") ?? "").includes("text/html");
129
155
  }
130
156
 
131
157
  }
@@ -2,6 +2,7 @@ import {IncomingHttpHeaders, OutgoingHttpHeaders, request as httpRequest} from "
2
2
  import {BuildLayer} from "../../app-management/BuildLayer";
3
3
  import {Request} from "../Request";
4
4
  import {RequestHandler} from "../RequestHandler";
5
+ import {DEPLOYING, FAILED, NOT_RUNNING} from "../pages/ErrorPage";
5
6
 
6
7
  /* Headers that describe a single hop and must not be forwarded across the
7
8
  proxy in either direction. */
@@ -21,13 +22,36 @@ class AppProxyHandler implements RequestHandler {
21
22
  constructor(private buildLayer : BuildLayer) {}
22
23
 
23
24
  async handle(request : Request) : Promise<void> {
24
- await this.buildLayer.ensureHydrated();
25
25
  const appName = request.id;
26
- if (!appName) return request.notFound();
26
+ if (! appName) return request.notFound();
27
+
28
+ // Under /app/<name> the app is mounted on a sub-path, so it is told
29
+ // where it really lives and the prefix is taken off what it receives.
30
+ await this.serve(request, appName, request.url.pathname.slice(`/app/${appName}`.length) || "/", `/app/${appName}`);
31
+ }
32
+
33
+ /* The same app, reached by its own hostname. Nothing is stripped and there
34
+ is no prefix to announce, because the app is at the root of that host -
35
+ exactly where it was when it was built and run locally. */
36
+ async serveAtRoot(request : Request, appName : string) : Promise<void> {
37
+ await this.serve(request, appName, request.url.pathname, "");
38
+ }
39
+
40
+ private async serve(request : Request, appName : string, appPath : string, prefix : string) : Promise<void> {
41
+ await this.buildLayer.ensureHydrated();
27
42
  const app = this.buildLayer.status(appName);
28
- if (!app || app.status !== "running") return request.notFound();
43
+ if (! app) return request.notFound();
44
+
45
+ /* An app is torn down while its replacement builds, so a visitor who
46
+ arrives mid-deploy would otherwise be told the app does not exist.
47
+ It does; it is just between versions, and saying so - with a page
48
+ that comes back on its own - is the difference between a blip and an
49
+ outage as far as anyone watching is concerned. */
50
+ if (app.status !== "running") {
51
+ return request.replyProblem(
52
+ app.status === "building" ? DEPLOYING : app.status === "failed" ? FAILED : NOT_RUNNING);
53
+ }
29
54
 
30
- const appPath = request.url.pathname.slice(`/app/${appName}`.length) || "/";
31
55
  const body = request.method === "GET" || request.method === "HEAD" ? undefined : await request.rawBody();
32
56
 
33
57
  await new Promise<void>((resolve, reject) => {
@@ -36,7 +60,7 @@ class AppProxyHandler implements RequestHandler {
36
60
  port: app.appPort,
37
61
  method: request.method,
38
62
  path: `${appPath}${request.url.search}`,
39
- headers: this.forwardHeaders(request, appName),
63
+ headers: this.forwardHeaders(request, prefix),
40
64
  }, response => {
41
65
  request.rawResponse.writeHead(response.statusCode ?? 502, this.passThrough(response.headers));
42
66
  response.pipe(request.rawResponse);
@@ -49,10 +73,10 @@ class AppProxyHandler implements RequestHandler {
49
73
  });
50
74
  }
51
75
 
52
- private forwardHeaders(request : Request, appName : string) : OutgoingHttpHeaders {
76
+ private forwardHeaders(request : Request, prefix : string) : OutgoingHttpHeaders {
53
77
  const headers = this.passThrough(request.raw.headers);
54
78
  delete headers.host;
55
- headers["x-forwarded-prefix"] = `/app/${appName}`;
79
+ headers["x-forwarded-prefix"] = prefix;
56
80
  headers["x-forwarded-host"] = request.raw.headers.host;
57
81
  headers["x-forwarded-proto"] = "https";
58
82
  return headers;
@@ -1,11 +1,12 @@
1
1
  import {BuildLayer} from "../../app-management/BuildLayer";
2
2
  import {deniesBuild} from "../../auth/TenantRole";
3
3
  import {Request} from "../Request";
4
+ import {hostnameObjection} from "../../app-management/appHostname";
4
5
  import {RequestHandler} from "../RequestHandler";
5
6
 
6
7
  class AppsHandler implements RequestHandler {
7
8
 
8
- constructor(private buildLayer : BuildLayer) {}
9
+ constructor(private buildLayer : BuildLayer, private tenant? : string) {}
9
10
 
10
11
  async handle(request : Request) : Promise<void> {
11
12
  // Changing what is deployed is a builder's job; a USER may look.
@@ -50,6 +51,13 @@ class AppsHandler implements RequestHandler {
50
51
  private async handleDeploy(request : Request, appName : string) : Promise<void> {
51
52
  switch (request.method) {
52
53
  case "POST": {
54
+ /* Refused here rather than at the edge: a name that cannot be
55
+ part of a hostname would deploy happily and then simply not
56
+ be reachable by its own address, which is a worse thing to
57
+ discover later. */
58
+ const objection = hostnameObjection(appName, this.tenant ?? "");
59
+ if (objection) return request.reply(400, { error: objection });
60
+
53
61
  const appPort = Number(request.query("port"));
54
62
  if (!Number.isInteger(appPort) || appPort <= 0) {
55
63
  return request.reply(400, { error: "Expected a numeric ?port query parameter - the app's internal port (\"internalPort\" in .anbaric/app-config.json)" });
@@ -0,0 +1,31 @@
1
+ import {AppProxyHandler} from "../handlers/AppProxyHandler";
2
+ import {Middleware} from "../Middleware";
3
+ import {Request} from "../Request";
4
+
5
+ /* An app reached by its own hostname is served at the root of it, so a page
6
+ that asks for /styles.css or calls fetch("/api/thing") works exactly as it
7
+ did on the author's machine. Under /app/<name> those same requests escape the
8
+ app and land on the platform, which is why an app built locally has to be
9
+ rewritten before it can be deployed - the thing this removes.
10
+
11
+ The edge resolves the hostname and names the app in a header; it overwrites
12
+ whatever the client sent, so this is not something a caller can choose. Even
13
+ if it were, naming an app is not a privilege: the same app is reachable at
14
+ /app/<name> anyway, and the platform authenticates either way. */
15
+ const APP_HOST_HEADER = "x-anbaric-app-host";
16
+
17
+ class AppHostMiddleware implements Middleware {
18
+
19
+ constructor(private proxy : AppProxyHandler | undefined) {}
20
+
21
+ async apply(request : Request) : Promise<boolean> {
22
+ const appName = String(request.header(APP_HOST_HEADER) ?? "").trim();
23
+ if (! this.proxy || ! appName) return true;
24
+
25
+ await this.proxy.serveAtRoot(request, appName);
26
+ return false;
27
+ }
28
+
29
+ }
30
+
31
+ export { AppHostMiddleware, APP_HOST_HEADER };
@@ -0,0 +1,112 @@
1
+ /* Pages for the moments when there is nothing to show. These are served by the
2
+ platform itself rather than the console bundle, so they carry no assets and
3
+ no stylesheet: the values below are the design system's own tokens, copied
4
+ because the platform has no way to read that package at runtime. Keep them in
5
+ step with anbaric-design-system/tokens.css.
6
+
7
+ A page that says "back in a moment" is worth more than a stack trace to
8
+ someone who only wanted to look at an app while it happened to be mid-deploy. */
9
+
10
+ const TOKENS = {
11
+ background: "#f7f8fa",
12
+ surface: "#ffffff",
13
+ edge: "#eaebef",
14
+ foreground: "#14131c",
15
+ muted: "#6f6f7c",
16
+ primary: "#e4637b",
17
+ title: '"Founders Grotesk", "Inter", system-ui, -apple-system, sans-serif',
18
+ };
19
+
20
+ type ErrorPage = {
21
+ status : number,
22
+ heading : string,
23
+ detail : string,
24
+
25
+ /* Seconds until the page reloads itself. Only for states that are known to
26
+ be temporary: a page that retries something permanently broken is just a
27
+ flicker, and it hides the fact that nothing is going to change. */
28
+ retryAfter? : number,
29
+ };
30
+
31
+ const escapeHtml = (text : string) : string =>
32
+ text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
33
+
34
+ const render = (page : ErrorPage) : string => `<!doctype html>
35
+ <html lang="en">
36
+ <head>
37
+ <meta charset="utf-8">
38
+ <meta name="viewport" content="width=device-width, initial-scale=1">
39
+ <title>${escapeHtml(page.heading)}</title>
40
+ ${page.retryAfter ? `<meta http-equiv="refresh" content="${page.retryAfter}">` : ""}
41
+ <style>
42
+ :root { color-scheme: light }
43
+ body {
44
+ margin: 0;
45
+ min-height: 100dvh;
46
+ display: flex;
47
+ align-items: center;
48
+ justify-content: center;
49
+ background: ${TOKENS.background};
50
+ color: ${TOKENS.foreground};
51
+ font-family: ${TOKENS.title};
52
+ padding: 1.5rem;
53
+ box-sizing: border-box;
54
+ }
55
+ main {
56
+ max-width: 30rem;
57
+ width: 100%;
58
+ background: ${TOKENS.surface};
59
+ border: 1px solid ${TOKENS.edge};
60
+ border-radius: 1.125rem;
61
+ padding: 2rem;
62
+ }
63
+ .brand {
64
+ font-size: 0.8rem;
65
+ font-weight: 700;
66
+ letter-spacing: 0.12em;
67
+ text-transform: uppercase;
68
+ color: ${TOKENS.primary};
69
+ margin: 0 0 1.5rem;
70
+ }
71
+ h1 { margin: 0 0 0.5rem; font-size: 1.4rem; line-height: 1.25; text-transform: uppercase }
72
+ p { margin: 0; color: ${TOKENS.muted}; font-size: 0.95rem; line-height: 1.6 }
73
+ .waiting { margin-top: 1.5rem; font-size: 0.85rem; color: ${TOKENS.muted} }
74
+ </style>
75
+ </head>
76
+ <body>
77
+ <main>
78
+ <p class="brand">Anbaric</p>
79
+ <h1>${escapeHtml(page.heading)}</h1>
80
+ <p>${escapeHtml(page.detail)}</p>
81
+ ${page.retryAfter ? `<p class="waiting">This page checks again every ${page.retryAfter} seconds.</p>` : ""}
82
+ </main>
83
+ </body>
84
+ </html>`;
85
+
86
+ const NOT_FOUND : ErrorPage = {
87
+ status: 404,
88
+ heading: "Nothing here",
89
+ detail: "That address does not match anything on this platform. If you followed a link, it may be out of date.",
90
+ };
91
+
92
+ const DEPLOYING : ErrorPage = {
93
+ status: 503,
94
+ heading: "Just a moment",
95
+ detail: "This app is being deployed. It will be back as soon as the new version is running.",
96
+ retryAfter: 5,
97
+ };
98
+
99
+ const NOT_RUNNING : ErrorPage = {
100
+ status: 503,
101
+ heading: "Not running",
102
+ detail: "This app is not running at the moment. Someone with access can start it again from the console.",
103
+ };
104
+
105
+ const FAILED : ErrorPage = {
106
+ status: 503,
107
+ heading: "This app did not start",
108
+ detail: "Its last deployment failed, so there is nothing to serve yet. The console has the build log.",
109
+ };
110
+
111
+ export { render, NOT_FOUND, DEPLOYING, NOT_RUNNING, FAILED };
112
+ export type { ErrorPage };
package/src/index.ts CHANGED
@@ -58,6 +58,8 @@ export * from "./hosting/Server";
58
58
  export * from "./hosting/middleware/AuthenticationMiddleware";
59
59
  export * from "./hosting/middleware/SessionMiddleware";
60
60
  export * from "./hosting/middleware/TenantRoutingMiddleware";
61
+ export * from "./hosting/middleware/AppHostMiddleware";
62
+ export * from "./app-management/appHostname";
61
63
  export * from "./hosting/handlers/AppProxyHandler";
62
64
  export * from "./hosting/handlers/AppsHandler";
63
65
  export * from "./hosting/handlers/AuditsHandler";