anbaric-cloud-hosting 1.60.0 → 1.62.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.
@@ -2,11 +2,14 @@ import {BuildLayer} from "../../app-management/BuildLayer";
2
2
  import {deniesBuild} from "../../auth/TenantRole";
3
3
  import {Request} from "../Request";
4
4
  import {hostnameObjection} from "../../app-management/appHostname";
5
+ import {appAddress} from "../../app-management/appAddress";
5
6
  import {RequestHandler} from "../RequestHandler";
7
+ import {Subdomains} from "../../subdomains/Subdomains";
6
8
 
7
9
  class AppsHandler implements RequestHandler {
8
10
 
9
- constructor(private buildLayer : BuildLayer, private tenant? : string) {}
11
+ constructor(private buildLayer : BuildLayer, private tenant? : string,
12
+ private subdomains? : Subdomains) {}
10
13
 
11
14
  async handle(request : Request) : Promise<void> {
12
15
  // Changing what is deployed is a builder's job; a USER may look.
@@ -25,6 +28,9 @@ class AppsHandler implements RequestHandler {
25
28
  case "docs":
26
29
  if (request.id) return this.handleDocs(request, request.id);
27
30
  break;
31
+ case "subdomain":
32
+ if (request.id) return this.handleSubdomain(request, request.id);
33
+ break;
28
34
  case undefined:
29
35
  if (request.id) return this.handleApp(request, request.id);
30
36
  return this.handleCollection(request);
@@ -34,9 +40,30 @@ class AppsHandler implements RequestHandler {
34
40
 
35
41
  private changesApps(request : Request) : boolean {
36
42
  if (request.subresource === "deploy" || request.subresource === "docs") return true;
43
+ if (request.subresource === "subdomain") return request.method === "PUT";
37
44
  return request.subresource === undefined && request.method === "DELETE";
38
45
  }
39
46
 
47
+ /* Where an app answers, which is a builder's to change. The address itself
48
+ is the control plane's to agree, so a name already taken comes back from
49
+ there as a refusal and is passed on as one. */
50
+ private async handleSubdomain(request : Request, appName : string) : Promise<void> {
51
+ if (request.method !== "PUT") return request.notFound();
52
+ if (! this.subdomains) {
53
+ return request.reply(409, { error: "This platform does not give its apps addresses of their own" });
54
+ }
55
+ if (! this.buildLayer.status(appName)) return request.reply(404, { error: `No app named "${appName}"` });
56
+
57
+ const body = await request.body() as { subdomain? : string } | undefined;
58
+ const wanted = String(body?.subdomain ?? "").trim().toLowerCase();
59
+
60
+ try {
61
+ return request.reply(200, await this.subdomains.set(appName, wanted));
62
+ } catch (error) {
63
+ return request.reply(409, { error: error instanceof Error ? error.message : "That address is not available" });
64
+ }
65
+ }
66
+
40
67
  private async handleDocs(request : Request, appName : string) : Promise<void> {
41
68
  if (request.method !== "POST") return request.notFound();
42
69
  try {
@@ -55,7 +82,7 @@ class AppsHandler implements RequestHandler {
55
82
  part of a hostname would deploy happily and then simply not
56
83
  be reachable by its own address, which is a worse thing to
57
84
  discover later. */
58
- const objection = hostnameObjection(appName, this.tenant ?? "");
85
+ const objection = hostnameObjection(appName);
59
86
  if (objection) return request.reply(400, { error: objection });
60
87
 
61
88
  const appPort = Number(request.query("port"));
@@ -64,7 +91,19 @@ class AppsHandler implements RequestHandler {
64
91
  }
65
92
  const tarball = await request.rawBody();
66
93
  if (tarball.length === 0) return request.reply(400, { error: "Expected a gzipped tarball body" });
67
- return request.reply(202, this.buildLayer.deploy(appName, appPort, tarball));
94
+
95
+ const summary = this.buildLayer.deploy(appName, appPort, tarball);
96
+
97
+ /* An address is asked for alongside the build rather than
98
+ after it: allocation is idempotent, so a redeploy costs
99
+ nothing, and a first deploy has its address ready by the
100
+ time the app is up. */
101
+ const address = await this.subdomains?.allocate(appName);
102
+ return request.reply(202, {
103
+ ...summary,
104
+ subdomain: address?.subdomain,
105
+ url: appAddress(appName, address?.subdomain),
106
+ });
68
107
  }
69
108
  }
70
109
  request.notFound();
@@ -76,11 +115,15 @@ class AppsHandler implements RequestHandler {
76
115
  const status = this.buildLayer.status(appName);
77
116
  if (!status) return request.reply(404, { error: `No app named "${appName}"` });
78
117
  const live = await this.buildLayer.ping(appName);
79
- return request.reply(200, { ...status, live });
118
+ const subdomain = await this.addressOf(appName);
119
+ return request.reply(200, { ...status, live, subdomain, url: appAddress(appName, subdomain) });
80
120
  }
81
121
  case "DELETE": {
82
122
  const outcome = await this.buildLayer.teardown(appName);
83
123
  if (!outcome) return request.reply(404, { error: `No app named "${appName}"` });
124
+
125
+ // The address is let go by the build layer's appRemoved hook,
126
+ // which an app that drains reaches minutes after this returns.
84
127
  return request.reply(200, outcome);
85
128
  }
86
129
  }
@@ -129,12 +172,28 @@ class AppsHandler implements RequestHandler {
129
172
 
130
173
  private async handleCollection(request : Request) : Promise<void> {
131
174
  switch (request.method) {
132
- case "GET":
133
- return request.reply(200, this.buildLayer.list());
175
+ case "GET": {
176
+ const addresses = new Map((await this.addresses()).map(held => [held.appName, held.subdomain]));
177
+ return request.reply(200, this.buildLayer.list().map(app => {
178
+ const subdomain = addresses.get(app.appName);
179
+ return { ...app, subdomain, url: appAddress(app.appName, subdomain) };
180
+ }));
181
+ }
134
182
  }
135
183
  request.notFound();
136
184
  }
137
185
 
186
+ private async addressOf(appName : string) : Promise<string | undefined> {
187
+ return (await this.addresses()).find(held => held.appName === appName)?.subdomain;
188
+ }
189
+
190
+ /* An address is the control plane's to know, so a platform without one -
191
+ self-hosted, or local - simply has none, and the console falls back to
192
+ serving its apps by path. */
193
+ private async addresses() {
194
+ return this.subdomains ? this.subdomains.all() : [];
195
+ }
196
+
138
197
  }
139
198
 
140
199
  export { AppsHandler }
@@ -0,0 +1,48 @@
1
+ import {BuildLayer} from "../../app-management/BuildLayer";
2
+ import {appAddress} from "../../app-management/appAddress";
3
+ import {EntitlementStore} from "../../data-store/EntitlementStore";
4
+ import {Subdomains} from "../../subdomains/Subdomains";
5
+ import {Request} from "../Request";
6
+ import {RequestHandler} from "../RequestHandler";
7
+
8
+ /* The entitlement that says a person may open an app at all. The platform
9
+ seeds it as a global definition, so it exists in every tenant from the first
10
+ migration and nobody has to create it. Granted globally it means every app;
11
+ granted against one app it means that app. */
12
+ const ACCESS = "access";
13
+
14
+ /* The apps this person may open, which is what the directory shows someone who
15
+ is not a builder. The same question Entitlements.has answers one app at a
16
+ time, asked once for the whole list. */
17
+ class MyAppsHandler implements RequestHandler {
18
+
19
+ constructor(private buildLayer : BuildLayer, private entitlements : EntitlementStore,
20
+ private subdomains? : Subdomains) {}
21
+
22
+ async handle(request : Request) : Promise<void> {
23
+ if (request.method !== "GET" || request.id) return request.notFound();
24
+ if (! request.user) return request.reply(401, { error: "You are not signed in" });
25
+
26
+ await this.buildLayer.ensureHydrated();
27
+
28
+ const grants = (await this.entitlements.listGrants(request.user.id))
29
+ .filter(grant => grant.entitlementId === ACCESS);
30
+ const everyApp = grants.some(grant => grant.appId === null);
31
+ const named = new Set(grants.map(grant => grant.appId));
32
+
33
+ const addresses = new Map(
34
+ (this.subdomains ? await this.subdomains.all() : []).map(held => [held.appName, held.subdomain]));
35
+
36
+ request.reply(200, this.buildLayer.list()
37
+ .filter(app => everyApp || named.has(app.appName))
38
+ .map(app => ({
39
+ appName: app.appName,
40
+ status: app.status,
41
+ subdomain: addresses.get(app.appName),
42
+ url: appAddress(app.appName, addresses.get(app.appName)),
43
+ })));
44
+ }
45
+
46
+ }
47
+
48
+ export { MyAppsHandler, ACCESS };
@@ -1,4 +1,5 @@
1
1
  import {MembershipService} from "../../../auth/MembershipService";
2
+ import {EntitlementOffer, EntitlementStore} from "../../../data-store/EntitlementStore";
2
3
  import {canInvite, isTenantRole} from "../../../auth/TenantRole";
3
4
  import {Request} from "../../Request";
4
5
  import {RequestHandler} from "../../RequestHandler";
@@ -6,10 +7,17 @@ import {RequestHandler} from "../../RequestHandler";
6
7
  const EMAIL_SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
7
8
 
8
9
  /* The console's side of inviting people into this tenant: list what is
9
- pending, invite by email (attributed to the signed-in user), revoke. */
10
+ pending, invite by email (attributed to the signed-in user), revoke.
11
+
12
+ An invitation may also carry the app it should open on and the entitlements
13
+ the inviter picked out for whoever accepts it. The two are kept apart on
14
+ purpose: the landing app travels to the control plane, which owns the
15
+ invitation and the addresses, while the entitlements stay here, because
16
+ grants are the platform's and are held against the email address until
17
+ that person first signs in. */
10
18
  class InvitationsHandler implements RequestHandler {
11
19
 
12
- constructor(private memberships : MembershipService) {}
20
+ constructor(private memberships : MembershipService, private entitlements? : EntitlementStore) {}
13
21
 
14
22
  async handle(request : Request) : Promise<void> {
15
23
  // Only a tenant's owner or admin administers its membership. Central
@@ -34,7 +42,8 @@ class InvitationsHandler implements RequestHandler {
34
42
  }
35
43
 
36
44
  private async invite(request : Request) : Promise<void> {
37
- const body = await request.body() as { email? : string, role? : string } | undefined;
45
+ const body = await request.body() as
46
+ { email? : string, role? : string, landingApp? : string, entitlements? : Array<unknown> } | undefined;
38
47
  const email = String(body?.email ?? "").trim();
39
48
  if (! EMAIL_SHAPE.test(email)) return request.reply(400, { error: "A valid email address is required" });
40
49
 
@@ -43,7 +52,38 @@ class InvitationsHandler implements RequestHandler {
43
52
  return request.reply(400, { error: "Invite someone as an ADMIN, BUILDER or USER" });
44
53
  }
45
54
 
46
- request.reply(201, await this.memberships.invite(email, role, this.asking(request)));
55
+ const landingApp = typeof body?.landingApp === "string" && body.landingApp.trim()
56
+ ? body.landingApp.trim()
57
+ : undefined;
58
+
59
+ const invitation = await this.memberships.invite(email, role, this.asking(request), landingApp);
60
+
61
+ /* Put aside after the invitation exists, so nothing is held for an
62
+ email that was never actually invited. */
63
+ const chosen = this.chosenEntitlements(email, body?.entitlements);
64
+ if (chosen.length > 0 && this.entitlements) {
65
+ await this.entitlements.offer(chosen, request.user?.id ?? "system");
66
+ }
67
+
68
+ request.reply(201, { ...invitation, entitlements: chosen.length });
69
+ }
70
+
71
+ // Each is an app and an entitlement, with a null app meaning every app -
72
+ // the same scoping a grant already has.
73
+ private chosenEntitlements(email : string, chosen : Array<unknown> | undefined) : Array<EntitlementOffer> {
74
+ if (! Array.isArray(chosen)) return [];
75
+
76
+ return chosen.flatMap(each => {
77
+ const offer = each as { appId? : unknown, entitlementId? : unknown };
78
+ if (typeof offer?.entitlementId !== "string" || ! offer.entitlementId) return [];
79
+
80
+ return [{
81
+ email,
82
+ appId: typeof offer.appId === "string" && offer.appId ? offer.appId : null,
83
+ entitlementId: offer.entitlementId,
84
+ notes: "",
85
+ }];
86
+ });
47
87
  }
48
88
 
49
89
  }
@@ -23,6 +23,14 @@ class AuthenticationMiddleware implements Middleware {
23
23
 
24
24
  private recorded = new Map<string, number>();
25
25
 
26
+ /* Anything owed to this person that could only be arranged against their
27
+ email address, because until now they had no user id - entitlements an
28
+ inviter chose for them before they ever arrived. Injected rather than
29
+ named here: the middleware's business is who someone is, not what they
30
+ are owed. Called where the directory first records them, which is that
31
+ moment, and safe to call again because claiming twice claims nothing. */
32
+ claimWhatIsOwed : (user : User) => Promise<void> = async () => {};
33
+
26
34
  constructor(private authenticator? : Authenticator,
27
35
  private tokenAuthenticator? : TokenAuthenticator,
28
36
  private isOpen : OpenRequestPredicate = () => false,
@@ -96,18 +104,25 @@ class AuthenticationMiddleware implements Middleware {
96
104
  }
97
105
 
98
106
  private async remember(user : User) : Promise<void> {
99
- if (! this.userDirectory) return;
100
-
101
107
  const now = Date.now();
102
108
  const last = this.recorded.get(user.id) ?? 0;
103
109
  if (now - last < DIRECTORY_REFRESH_MS) return;
104
110
 
105
111
  this.recorded.set(user.id, now);
112
+
113
+ if (this.userDirectory) {
114
+ try {
115
+ await this.userDirectory.record(user);
116
+ } catch (error) {
117
+ this.recorded.delete(user.id);
118
+ console.error(`Could not record user "${user.id}" in the directory:`, error);
119
+ }
120
+ }
121
+
106
122
  try {
107
- await this.userDirectory.record(user);
123
+ await this.claimWhatIsOwed(user);
108
124
  } catch (error) {
109
- this.recorded.delete(user.id);
110
- console.error(`Could not record user "${user.id}" in the directory:`, error);
125
+ console.error(`Could not claim what is owed to "${user.id}":`, error);
111
126
  }
112
127
  }
113
128