anbaric-cloud-hosting 1.64.0 → 1.65.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.64.0",
3
+ "version": "1.65.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.64.0",
22
- "anbaric-plugins": "^1.64.0",
23
- "anbaric-tsapi": "^1.64.0",
24
- "anbaric-web": "^1.64.0",
21
+ "anbaric-data-store": "^1.65.0",
22
+ "anbaric-plugins": "^1.65.0",
23
+ "anbaric-tsapi": "^1.65.0",
24
+ "anbaric-web": "^1.65.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.64.0",
32
- "anbaric-state-machine": "^1.64.0",
31
+ "anbaric-impl-cloud": "^1.65.0",
32
+ "anbaric-state-machine": "^1.65.0",
33
33
  "tsx": "^4.20.0",
34
34
  "typescript": "^7.0.2"
35
35
  },
@@ -52,6 +52,15 @@ class HttpMembershipService implements MembershipService {
52
52
  return response.status === 204;
53
53
  }
54
54
 
55
+ async changeRole(userId : string, role : TenantRole, asking : Inviter) : Promise<TenantRole | undefined> {
56
+ const response = await this.call("PATCH", `/members/${encodeURIComponent(userId)}`, { role }, [404], asking);
57
+ this.roles.delete(userId);
58
+ if (response.status === 404) return undefined;
59
+
60
+ const changed = (await response.json() as { role? : unknown }).role;
61
+ return isTenantRole(changed) ? changed : undefined;
62
+ }
63
+
55
64
  private async call(method : string, path : string, body? : unknown,
56
65
  tolerated : Array<number> = [], asking? : Inviter) : Promise<Response> {
57
66
  const headers : Record<string, string> = { "x-anbaric-central-key": this.secret };
@@ -21,6 +21,12 @@ class InMemoryMembershipService implements MembershipService {
21
21
  return this.members.delete(userId);
22
22
  }
23
23
 
24
+ async changeRole(userId : string, role : TenantRole, _asking : Inviter) : Promise<TenantRole | undefined> {
25
+ if (! this.members.has(userId)) return undefined;
26
+ this.members.set(userId, role);
27
+ return role;
28
+ }
29
+
24
30
  async invite(email : string, role : TenantRole, invitedBy : Inviter, landingApp? : string) : Promise<PendingInvitation> {
25
31
  const token = randomUUID();
26
32
  const now = new Date();
@@ -42,6 +42,9 @@ interface MembershipService {
42
42
  // Ends someone's membership. False when they were not a member.
43
43
  remove(userId : string, asking : Inviter) : Promise<boolean>;
44
44
 
45
+ // Changes someone's role. Undefined when they were not a member.
46
+ changeRole(userId : string, role : TenantRole, asking : Inviter) : Promise<TenantRole | undefined>;
47
+
45
48
  }
46
49
 
47
50
  export type { MembershipService, PendingInvitation, Inviter }
@@ -0,0 +1,38 @@
1
+ type FetchFn = (url : string, init : RequestInit) => Promise<Response>;
2
+
3
+ /* What this tenant is spending and where its owner pays. The numbers are the
4
+ control plane's - it holds the metering, the prices and the allowance - so
5
+ the platform asks and passes the answer on without interpreting it, the
6
+ same way it asks about memberships. */
7
+ class CentralBilling {
8
+
9
+ constructor(private centralUrl : string, private secret : string, private tenantSlug : string,
10
+ private fetchFn : FetchFn = (url, init) => fetch(url, init)) {}
11
+
12
+ async summary() : Promise<Record<string, unknown>> {
13
+ const response = await this.call("GET", `/usage/${encodeURIComponent(this.tenantSlug)}`);
14
+ return await response.json() as Record<string, unknown>;
15
+ }
16
+
17
+ async portalUrl(returnUrl : string) : Promise<string> {
18
+ const response = await this.call("POST", `/tenants/${encodeURIComponent(this.tenantSlug)}/billing/portal`, { returnUrl });
19
+ return String((await response.json() as { url : string }).url);
20
+ }
21
+
22
+ private async call(method : string, path : string, body? : unknown) : Promise<Response> {
23
+ const headers : Record<string, string> = { "x-anbaric-central-key": this.secret };
24
+ if (body !== undefined) headers["content-type"] = "application/json";
25
+
26
+ const response = await this.fetchFn(`${this.centralUrl.replace(/\/$/, "")}${path}`,
27
+ { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
28
+
29
+ if (! response.ok) {
30
+ const problem = await response.json().catch(() => ({})) as { error? : string };
31
+ throw new Error(problem.error ?? `The request to the central login failed with status ${response.status}`);
32
+ }
33
+ return response;
34
+ }
35
+
36
+ }
37
+
38
+ export { CentralBilling };
@@ -11,6 +11,8 @@ import {AppLinkFallbackHandler} from "./handlers/AppLinkFallbackHandler";
11
11
  import {AppsHandler} from "./handlers/AppsHandler";
12
12
  import {MyAppsHandler} from "./handlers/MyAppsHandler";
13
13
  import {MembersHandler} from "./handlers/auth/MembersHandler";
14
+ import {BillingHandler} from "./handlers/BillingHandler";
15
+ import {CentralBilling} from "../billing/CentralBilling";
14
16
  import {Subdomains} from "../subdomains/Subdomains";
15
17
  import {AuditsHandler} from "./handlers/AuditsHandler";
16
18
  import {AuthorizeCliHandler} from "./handlers/auth/AuthorizeCliHandler";
@@ -78,7 +80,8 @@ class HostingServer {
78
80
  memberships? : MembershipService,
79
81
  promptManagerFor? : (appId : string) => PromptManager,
80
82
  fileStorageFor? : (appId : string) => FileStorage,
81
- subdomains? : Subdomains) {
83
+ subdomains? : Subdomains,
84
+ billing? : CentralBilling) {
82
85
  const pages = new PagesHandler();
83
86
  const ping = new PingHandler(tenant);
84
87
  const jobs = new JobsHandler(persistence);
@@ -113,6 +116,7 @@ class HostingServer {
113
116
  if (userDirectory) publicRouter.registerApi("users", new UsersHandler(userDirectory));
114
117
  if (memberships) publicRouter.registerApi("invitations", new InvitationsHandler(memberships, entitlements));
115
118
  if (memberships) publicRouter.registerApi("members", new MembersHandler(memberships, entitlements));
119
+ if (billing) publicRouter.registerApi("billing", new BillingHandler(billing));
116
120
  const prompts = promptManagerFor && new PromptsHandler(promptManagerFor);
117
121
  if (prompts) publicRouter.registerApi("prompts", prompts);
118
122
  if (cliAuthorizer) {
@@ -0,0 +1,46 @@
1
+ import {CentralBilling} from "../../billing/CentralBilling";
2
+ import {canInvite, deniesBuild} from "../../auth/TenantRole";
3
+ import {Request} from "../Request";
4
+ import {RequestHandler} from "../RequestHandler";
5
+
6
+ /* The console's costs page. A builder may see what the tenant is spending;
7
+ only an owner or admin may go and change how it pays. */
8
+ class BillingHandler implements RequestHandler {
9
+
10
+ constructor(private billing : CentralBilling, private publicUrl : string = process.env.ANBARIC_PLATFORM_PUBLIC_URL ?? "") {}
11
+
12
+ async handle(request : Request) : Promise<void> {
13
+ if (request.method === "GET" && request.id === "summary") return this.summary(request);
14
+ if (request.method === "POST" && request.id === "portal") return this.portal(request);
15
+ request.notFound();
16
+ }
17
+
18
+ private async summary(request : Request) : Promise<void> {
19
+ if (deniesBuild(request.user?.tenantRole)) {
20
+ return request.reply(403, { error: "Your role in this tenant cannot see its costs" });
21
+ }
22
+ try {
23
+ request.reply(200, await this.billing.summary());
24
+ } catch (error) {
25
+ request.reply(502, { error: error instanceof Error ? error.message : "Could not read this tenant's costs" });
26
+ }
27
+ }
28
+
29
+ private async portal(request : Request) : Promise<void> {
30
+ if (! canInvite(request.user?.tenantRole)) {
31
+ return request.reply(403, { error: "Your role in this tenant cannot change its payment details" });
32
+ }
33
+
34
+ const body = await request.body() as { returnTo? : string } | undefined;
35
+ const returnTo = typeof body?.returnTo === "string" && body.returnTo.startsWith("/") ? body.returnTo : "/";
36
+
37
+ try {
38
+ request.reply(200, { url: await this.billing.portalUrl(`${this.publicUrl.replace(/\/$/, "")}${returnTo}`) });
39
+ } catch (error) {
40
+ request.reply(409, { error: error instanceof Error ? error.message : "Could not open the payment details page" });
41
+ }
42
+ }
43
+
44
+ }
45
+
46
+ export { BillingHandler };
@@ -1,18 +1,35 @@
1
- import {Authenticator} from "../../auth/Authenticator";
1
+ import {Authenticator, IDENTITY_COOKIE} from "../../auth/Authenticator";
2
2
  import {SessionSigner} from "../../auth/SessionSigner";
3
3
  import {Request} from "../Request";
4
4
  import {RequestHandler} from "../RequestHandler";
5
5
 
6
- /* Ends the platform session. The cookie is cleared here; where the browser
7
- goes next is the authenticator's business, because an identity provider
8
- holding its own session has to be signed out of as well - otherwise the
9
- redirect home just signs the person straight back in. */
6
+ const TENANT_COOKIE = "anbaric_tenant";
7
+
8
+ /* Ends the platform session. Every cookie that could sign the person back in
9
+ or route them somewhere goes: the platform's own session, the identity the
10
+ central login left, and the tenant the edge was routing them to - both the
11
+ domain-scoped cookie and any host-only one from before the domain was set,
12
+ because a browser keeps the one whose attributes you did not match. Where
13
+ the browser goes next is the authenticator's business, because an identity
14
+ provider holding its own session has to be signed out of as well - otherwise
15
+ the redirect home just signs the person straight back in. */
10
16
  class LogoutHandler implements RequestHandler {
11
17
 
12
- constructor(private authenticator? : Authenticator, private sessionSigner : SessionSigner = new SessionSigner()) {}
18
+ constructor(private authenticator? : Authenticator,
19
+ private sessionSigner : SessionSigner = new SessionSigner(),
20
+ private domain : string | undefined = process.env.ANBARIC_COOKIE_DOMAIN || undefined) {}
13
21
 
14
22
  async handle(request : Request) : Promise<void> {
15
23
  this.sessionSigner.clear(request.rawResponse);
24
+
25
+ const expired = [IDENTITY_COOKIE, TENANT_COOKIE].flatMap(name => [
26
+ `${name}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`,
27
+ ...(this.domain ? [`${name}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0; Domain=${this.domain}`] : []),
28
+ ]);
29
+ const existing = request.rawResponse.getHeader("Set-Cookie");
30
+ const before = existing === undefined ? [] : Array.isArray(existing) ? existing : [String(existing)];
31
+ request.rawResponse.setHeader("Set-Cookie", [...before, ...expired]);
32
+
16
33
  request.redirect(this.authenticator?.logoutUrl() ?? "/");
17
34
  }
18
35
 
@@ -1,12 +1,16 @@
1
1
  import {MembershipService} from "../../../auth/MembershipService";
2
- import {canInvite} from "../../../auth/TenantRole";
2
+ import {canInvite, isTenantRole} from "../../../auth/TenantRole";
3
3
  import {EntitlementStore} from "../../../data-store/EntitlementStore";
4
4
  import {Request} from "../../Request";
5
5
  import {RequestHandler} from "../../RequestHandler";
6
6
 
7
- /* The console's side of removing someone from this tenant. Membership is the
8
- control plane's to end - it is what admits a session at all - and it is
9
- asked to; what is this platform's own is the entitlements that person held
7
+ /* The console's side of administering who is in this tenant and as what:
8
+ removing someone, and moving them between admin, builder and user.
9
+ Membership is the control plane's - it is what admits a session at all -
10
+ and it is asked to make each change; it also refuses the changes that are
11
+ nobody's to make, such as the owner's role or your own.
12
+
13
+ What is this platform's own is the entitlements a removed person held
10
14
  here, which go with them so nothing is left granted to somebody who can no
11
15
  longer sign in. Their directory entry stays: it is a record of who was here. */
12
16
  class MembersHandler implements RequestHandler {
@@ -14,30 +18,57 @@ class MembersHandler implements RequestHandler {
14
18
  constructor(private memberships : MembershipService, private entitlements? : EntitlementStore) {}
15
19
 
16
20
  async handle(request : Request) : Promise<void> {
17
- if (request.method !== "DELETE" || ! request.id || request.subresource) return request.notFound();
21
+ if (! request.id || request.subresource) return request.notFound();
18
22
 
19
23
  if (! canInvite(request.user?.tenantRole)) {
20
- return request.reply(403, { error: "Your role in this tenant cannot remove members" });
24
+ return request.reply(403, { error: "Your role in this tenant cannot manage members" });
21
25
  }
22
26
  if (request.user?.id === request.id) {
23
- return request.reply(400, { error: "You cannot remove yourself from a tenant" });
27
+ return request.reply(400, { error: "You cannot remove yourself or change your own role" });
24
28
  }
25
29
 
26
- const asking = request.user ? { id: request.user.id, name: request.user.name } : { id: "system" };
30
+ if (request.method === "DELETE") return this.remove(request, request.id);
31
+ if (request.method === "PATCH") return this.changeRole(request, request.id);
32
+ request.notFound();
33
+ }
34
+
35
+ private asking(request : Request) {
36
+ return request.user ? { id: request.user.id, name: request.user.name } : { id: "system" };
37
+ }
38
+
39
+ private async remove(request : Request, userId : string) : Promise<void> {
27
40
  let removed : boolean;
28
41
  try {
29
- removed = await this.memberships.remove(request.id, asking);
42
+ removed = await this.memberships.remove(userId, this.asking(request));
30
43
  } catch (error) {
31
44
  return request.reply(403, { error: error instanceof Error ? error.message : "Could not remove that member" });
32
45
  }
33
46
  if (! removed) return request.notFound();
34
47
 
35
- for (const grant of await this.entitlements?.listGrants(request.id) ?? []) {
48
+ for (const grant of await this.entitlements?.listGrants(userId) ?? []) {
36
49
  await this.entitlements?.revoke(grant.id);
37
50
  }
38
51
  request.reply(204);
39
52
  }
40
53
 
54
+ private async changeRole(request : Request, userId : string) : Promise<void> {
55
+ const body = await request.body() as { role? : unknown } | undefined;
56
+ const role = body?.role;
57
+ if (! isTenantRole(role) || role === "OWNER") {
58
+ return request.reply(400, { error: "A member may be made an ADMIN, BUILDER or USER" });
59
+ }
60
+
61
+ let changed : string | undefined;
62
+ try {
63
+ changed = await this.memberships.changeRole(userId, role, this.asking(request));
64
+ } catch (error) {
65
+ return request.reply(403, { error: error instanceof Error ? error.message : "Could not change that role" });
66
+ }
67
+ if (! changed) return request.notFound();
68
+
69
+ request.reply(200, { userId, role: changed });
70
+ }
71
+
41
72
  }
42
73
 
43
74
  export { MembersHandler }