anbaric-cloud-hosting 1.59.0 → 1.61.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 +7 -7
- package/src/app-management/BaseBuildLayer.ts +3 -0
- package/src/app-management/BuildLayer.ts +6 -0
- package/src/app-management/appAddress.ts +19 -0
- package/src/app-management/appHostname.ts +16 -23
- package/src/auth/HttpMembershipService.ts +2 -2
- package/src/auth/InMemoryMembershipService.ts +2 -1
- package/src/auth/MembershipService.ts +5 -1
- package/src/data-store/EntitlementStore.ts +23 -1
- package/src/data-store/InMemoryEntitlementStore.ts +25 -1
- package/src/data-store/PlatformMigrations.ts +26 -0
- package/src/data-store/PostgresEntitlementStore.ts +40 -1
- package/src/data-store/PostgresJobPersistence.ts +29 -2
- package/src/data-store/StallSweep.ts +46 -0
- package/src/hosting/HostingServer.ts +26 -4
- package/src/hosting/handlers/AppsHandler.ts +65 -6
- package/src/hosting/handlers/JobsHandler.ts +6 -0
- package/src/hosting/handlers/MyAppsHandler.ts +48 -0
- package/src/hosting/handlers/auth/InvitationsHandler.ts +44 -4
- package/src/hosting/middleware/AuthenticationMiddleware.ts +20 -5
- package/src/hosting/pages/platform-ui.html +3 -3
- package/src/index.ts +1 -0
- package/src/main.ts +40 -2
- package/src/subdomains/HttpSubdomains.ts +91 -0
- package/src/subdomains/Subdomains.ts +30 -0
- package/src/usage/UsageReporter.ts +63 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }
|
|
@@ -22,6 +22,12 @@ class JobsHandler implements RequestHandler {
|
|
|
22
22
|
await this.persistence.kill(request.id, SystemActor.actor);
|
|
23
23
|
return request.reply(204);
|
|
24
24
|
}
|
|
25
|
+
if (request.id && request.subresource === "heartbeat") {
|
|
26
|
+
if (request.method !== "POST") return request.notFound();
|
|
27
|
+
const body = await request.body().catch(() => ({}));
|
|
28
|
+
await this.persistence.heartbeat(request.id, body?.running !== false);
|
|
29
|
+
return request.reply(204);
|
|
30
|
+
}
|
|
25
31
|
if (request.subresource) return request.notFound();
|
|
26
32
|
if (request.id) return this.handleJob(request, request.id);
|
|
27
33
|
return this.handleCollection(request);
|
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
123
|
+
await this.claimWhatIsOwed(user);
|
|
108
124
|
} catch (error) {
|
|
109
|
-
|
|
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
|
|