anbaric-cloud-hosting 1.34.0 → 1.35.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.34.0",
3
+ "version": "1.35.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.34.0",
22
- "anbaric-plugins": "^1.34.0",
23
- "anbaric-tsapi": "^1.34.0",
24
- "anbaric-web": "^1.34.0",
21
+ "anbaric-data-store": "^1.35.0",
22
+ "anbaric-plugins": "^1.35.0",
23
+ "anbaric-tsapi": "^1.35.0",
24
+ "anbaric-web": "^1.35.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.34.0",
32
- "anbaric-state-machine": "^1.34.0",
31
+ "anbaric-impl-cloud": "^1.35.0",
32
+ "anbaric-state-machine": "^1.35.0",
33
33
  "tsx": "^4.20.0",
34
34
  "typescript": "^7.0.2"
35
35
  },
@@ -0,0 +1,44 @@
1
+ import {Inviter, MembershipService, PendingInvitation} from "./MembershipService";
2
+
3
+ type FetchFn = (url : string, init : RequestInit) => Promise<Response>;
4
+
5
+ /* Talks to the central login's invitation endpoints for one tenant, with the
6
+ same central api key the platform already holds for CLI key lookups. */
7
+ class HttpMembershipService implements MembershipService {
8
+
9
+ constructor(private centralUrl : string, private secret : string, private tenantSlug : string,
10
+ private fetchFn : FetchFn = (url, init) => fetch(url, init)) {}
11
+
12
+ async invite(email : string, invitedBy : Inviter) : Promise<PendingInvitation> {
13
+ const response = await this.call("POST", "", { email, invitedBy });
14
+ return await response.json() as PendingInvitation;
15
+ }
16
+
17
+ async pending() : Promise<Array<PendingInvitation>> {
18
+ const response = await this.call("GET", "");
19
+ return await response.json() as Array<PendingInvitation>;
20
+ }
21
+
22
+ async revoke(token : string) : Promise<boolean> {
23
+ const response = await this.call("DELETE", `/${encodeURIComponent(token)}`, undefined, [404]);
24
+ return response.status === 204;
25
+ }
26
+
27
+ private async call(method : string, path : string, body? : unknown, tolerated : Array<number> = []) : Promise<Response> {
28
+ const headers : Record<string, string> = { "x-anbaric-central-key": this.secret };
29
+ if (body !== undefined) headers["content-type"] = "application/json";
30
+
31
+ const response = await this.fetchFn(
32
+ `${this.centralUrl.replace(/\/$/, "")}/tenants/${encodeURIComponent(this.tenantSlug)}/invitations${path}`,
33
+ { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
34
+
35
+ if (! response.ok && ! tolerated.includes(response.status)) {
36
+ const problem = await response.json().catch(() => ({})) as { error? : string };
37
+ throw new Error(problem.error ?? `The invitation request failed with status ${response.status}`);
38
+ }
39
+ return response;
40
+ }
41
+
42
+ }
43
+
44
+ export { HttpMembershipService }
@@ -0,0 +1,38 @@
1
+ import {randomUUID} from "node:crypto";
2
+ import {Inviter, MembershipService, PendingInvitation} from "./MembershipService";
3
+
4
+ class InMemoryMembershipService implements MembershipService {
5
+
6
+ private invitations = new Map<string, PendingInvitation>();
7
+
8
+ constructor(private linkBase : string = "http://localhost/invitations") {}
9
+
10
+ async invite(email : string, invitedBy : Inviter) : Promise<PendingInvitation> {
11
+ const token = randomUUID();
12
+ const now = new Date();
13
+ const invitation : PendingInvitation = {
14
+ token,
15
+ email: email.trim().toLowerCase(),
16
+ role: "member",
17
+ invitedBy: invitedBy.id,
18
+ invitedByName: invitedBy.name,
19
+ link: `${this.linkBase}/${token}`,
20
+ createdAt: now.toISOString(),
21
+ expiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(),
22
+ emailed: false,
23
+ };
24
+ this.invitations.set(token, invitation);
25
+ return { ...invitation };
26
+ }
27
+
28
+ async pending() : Promise<Array<PendingInvitation>> {
29
+ return Array.from(this.invitations.values()).map(invitation => ({ ...invitation })).reverse();
30
+ }
31
+
32
+ async revoke(token : string) : Promise<boolean> {
33
+ return this.invitations.delete(token);
34
+ }
35
+
36
+ }
37
+
38
+ export { InMemoryMembershipService }
@@ -0,0 +1,35 @@
1
+ type PendingInvitation = {
2
+
3
+ token : string,
4
+ email : string,
5
+ role : string,
6
+ invitedBy : string,
7
+ invitedByName? : string,
8
+ link : string,
9
+ createdAt : string,
10
+ expiresAt : string,
11
+ emailed? : boolean,
12
+
13
+ };
14
+
15
+ type Inviter = {
16
+
17
+ id : string,
18
+ name? : string,
19
+
20
+ };
21
+
22
+ /* Invitations into this tenant. Membership itself is decided wherever people
23
+ sign in (Anbaric Cloud's central login), so a platform only asks that
24
+ service to invite, list and revoke on the tenant's behalf. */
25
+ interface MembershipService {
26
+
27
+ invite(email : string, invitedBy : Inviter) : Promise<PendingInvitation>;
28
+
29
+ pending() : Promise<Array<PendingInvitation>>;
30
+
31
+ revoke(token : string) : Promise<boolean>;
32
+
33
+ }
34
+
35
+ export type { MembershipService, PendingInvitation, Inviter }
@@ -14,6 +14,8 @@ import {AuthorizeCliHandler} from "./handlers/auth/AuthorizeCliHandler";
14
14
  import {KeysHandler} from "./handlers/auth/KeysHandler";
15
15
  import {SessionsHandler} from "./handlers/auth/SessionsHandler";
16
16
  import {UsersHandler} from "./handlers/auth/UsersHandler";
17
+ import {InvitationsHandler} from "./handlers/auth/InvitationsHandler";
18
+ import {MembershipService} from "../auth/MembershipService";
17
19
  import {WhoamiHandler} from "./handlers/auth/WhoamiHandler";
18
20
  import {UserDirectory} from "../auth/UserDirectory";
19
21
  import {ConsumersHandler} from "./handlers/ConsumersHandler";
@@ -65,7 +67,8 @@ class HostingServer {
65
67
  jobRunSchedules? : JobRunSchedulePersistence,
66
68
  notifier? : Notifier,
67
69
  entitlements? : EntitlementStore,
68
- userDirectory? : UserDirectory) {
70
+ userDirectory? : UserDirectory,
71
+ memberships? : MembershipService) {
69
72
  const pages = new PagesHandler();
70
73
  const ping = new PingHandler(tenant);
71
74
  const jobs = new JobsHandler(persistence);
@@ -96,6 +99,7 @@ class HostingServer {
96
99
  if (notifier) publicRouter.registerApi("notifications", new NotificationsHandler(notifier));
97
100
  if (entitlements) publicRouter.registerApi("entitlements", new EntitlementsAdminHandler(entitlements));
98
101
  if (userDirectory) publicRouter.registerApi("users", new UsersHandler(userDirectory));
102
+ if (memberships) publicRouter.registerApi("invitations", new InvitationsHandler(memberships));
99
103
  if (cliAuthorizer) {
100
104
  publicRouter.register("authorize-cli", new AuthorizeCliHandler(cliAuthorizer, pages, tenant));
101
105
  publicRouter.registerApi("keys", new KeysHandler(cliAuthorizer));
@@ -0,0 +1,34 @@
1
+ import {MembershipService} from "../../../auth/MembershipService";
2
+ import {Request} from "../../Request";
3
+ import {RequestHandler} from "../../RequestHandler";
4
+
5
+ const EMAIL_SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
6
+
7
+ /* The console's side of inviting people into this tenant: list what is
8
+ pending, invite by email (attributed to the signed-in user), revoke. */
9
+ class InvitationsHandler implements RequestHandler {
10
+
11
+ constructor(private memberships : MembershipService) {}
12
+
13
+ async handle(request : Request) : Promise<void> {
14
+ if (request.method === "GET" && ! request.id) return request.reply(200, await this.memberships.pending());
15
+ if (request.method === "POST" && ! request.id) return this.invite(request);
16
+ if (request.method === "DELETE" && request.id && ! request.subresource) {
17
+ const revoked = await this.memberships.revoke(request.id);
18
+ return revoked ? request.reply(204) : request.notFound();
19
+ }
20
+ request.notFound();
21
+ }
22
+
23
+ private async invite(request : Request) : Promise<void> {
24
+ const body = await request.body() as { email? : string } | undefined;
25
+ const email = String(body?.email ?? "").trim();
26
+ if (! EMAIL_SHAPE.test(email)) return request.reply(400, { error: "A valid email address is required" });
27
+
28
+ const inviter = request.user ? { id: request.user.id, name: request.user.name } : { id: "system" };
29
+ request.reply(201, await this.memberships.invite(email, inviter));
30
+ }
31
+
32
+ }
33
+
34
+ export { InvitationsHandler }
package/src/index.ts CHANGED
@@ -21,6 +21,10 @@ export * from "./auth/UserDirectory";
21
21
  export * from "./auth/InMemoryUserDirectory";
22
22
  export * from "./data-store/PostgresUserDirectory";
23
23
  export * from "./hosting/handlers/auth/UsersHandler";
24
+ export * from "./auth/MembershipService";
25
+ export * from "./auth/HttpMembershipService";
26
+ export * from "./auth/InMemoryMembershipService";
27
+ export * from "./hosting/handlers/auth/InvitationsHandler";
24
28
  export * from "./app-management/DockerBuildLayer";
25
29
  export * from "./app-management/FargateBuildLayer";
26
30
  export * from "./app-admin/AdminServer";
package/src/main.ts CHANGED
@@ -22,6 +22,7 @@ import {GatewayDocModel} from "./docs/GatewayDocModel";
22
22
  import {PostgresAppDocsStore} from "./data-store/PostgresAppDocsStore";
23
23
  import {PostgresEntitlementStore} from "./data-store/PostgresEntitlementStore";
24
24
  import {PostgresUserDirectory} from "./data-store/PostgresUserDirectory";
25
+ import {HttpMembershipService} from "./auth/HttpMembershipService";
25
26
  import {ConsumerRegistry} from "./queuing/ConsumerRegistry";
26
27
  import {Dispatcher} from "./queuing/Dispatcher";
27
28
  import {HostingServer} from "./hosting/HostingServer";
@@ -93,11 +94,17 @@ const tokenAuthenticator = new TokenAuthenticator(cliKeyStore, process.env.ANBAR
93
94
 
94
95
  const plugins = await new PluginLoader().load(process.env.ANBARIC_PLUGINS ?? "anbaric-plugins/state-machines");
95
96
 
97
+ // Invitations are managed by the central login that issued this tenant, which
98
+ // is the same service the CLI key lookup already points at.
99
+ const memberships = process.env.ANBARIC_CLI_KEY_LOOKUP_URL && process.env.ANBARIC_TENANT
100
+ ? new HttpMembershipService(process.env.ANBARIC_CLI_KEY_LOOKUP_URL, process.env.ANBARIC_CLI_KEY_LOOKUP_SECRET ?? "", process.env.ANBARIC_TENANT)
101
+ : undefined;
102
+
96
103
  const server = new HostingServer(new PostgresJobPersistence(pool), queue, registry, buildLayer,
97
104
  (appId, collection) => new PostgresJsonStore(pool, appId, collection), secretStoreFor, authenticator, cliAuthorizer,
98
105
  tokenAuthenticator, process.env.ANBARIC_TENANT, new PostgresAuditRecordStore(pool), plugins,
99
106
  new PostgresJobRunSchedulePersistence(pool), await loadNotifier(process.env.ANBARIC_NOTIFIER_MODULE),
100
- new PostgresEntitlementStore(pool), new PostgresUserDirectory(pool));
107
+ new PostgresEntitlementStore(pool), new PostgresUserDirectory(pool), memberships);
101
108
  const port = await server.listen(hostingPort);
102
109
  const internal = await server.listenInternal(internalPort);
103
110