anbaric-cloud-hosting 1.60.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 +19 -0
- package/src/data-store/PostgresEntitlementStore.ts +40 -1
- package/src/hosting/HostingServer.ts +26 -4
- package/src/hosting/handlers/AppsHandler.ts +65 -6
- 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/main.ts +34 -1
- package/src/subdomains/HttpSubdomains.ts +91 -0
- package/src/subdomains/Subdomains.ts +30 -0
- package/src/usage/UsageReporter.ts +63 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anbaric-cloud-hosting",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.61.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.
|
|
22
|
-
"anbaric-plugins": "^1.
|
|
23
|
-
"anbaric-tsapi": "^1.
|
|
24
|
-
"anbaric-web": "^1.
|
|
21
|
+
"anbaric-data-store": "^1.61.0",
|
|
22
|
+
"anbaric-plugins": "^1.61.0",
|
|
23
|
+
"anbaric-tsapi": "^1.61.0",
|
|
24
|
+
"anbaric-web": "^1.61.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.
|
|
32
|
-
"anbaric-state-machine": "^1.
|
|
31
|
+
"anbaric-impl-cloud": "^1.61.0",
|
|
32
|
+
"anbaric-state-machine": "^1.61.0",
|
|
33
33
|
"tsx": "^4.20.0",
|
|
34
34
|
"typescript": "^7.0.2"
|
|
35
35
|
},
|
|
@@ -39,6 +39,8 @@ abstract class BaseBuildLayer implements BuildLayer {
|
|
|
39
39
|
protected deployments = new Map<string, Deployment>();
|
|
40
40
|
protected docGenerator? : DocGenerator;
|
|
41
41
|
consumerUrlsFor : (appName : string) => Array<string> = () => [];
|
|
42
|
+
|
|
43
|
+
appRemoved : (appName : string) => void = () => {};
|
|
42
44
|
private nextAppIndex = 0;
|
|
43
45
|
private hydration? : Promise<void>;
|
|
44
46
|
|
|
@@ -116,6 +118,7 @@ abstract class BaseBuildLayer implements BuildLayer {
|
|
|
116
118
|
deployment.status = "stopped";
|
|
117
119
|
await this.stop(deployment);
|
|
118
120
|
if (this.deployments.get(appName) === deployment) this.deployments.delete(appName);
|
|
121
|
+
this.appRemoved(appName);
|
|
119
122
|
};
|
|
120
123
|
|
|
121
124
|
if (deployment.status === "running" && this.consumerUrlsFor(appName).length > 0) {
|
|
@@ -37,6 +37,12 @@ interface BuildLayer {
|
|
|
37
37
|
// to drain; the hosting server supplies it from its consumer registry.
|
|
38
38
|
consumerUrlsFor : (appName : string) => Array<string>;
|
|
39
39
|
|
|
40
|
+
/* Called once an app is really gone, which for one that drained is some
|
|
41
|
+
minutes after the teardown returned. Whatever outlives the app - its
|
|
42
|
+
public address, in a hosted platform - is let go here, because this is
|
|
43
|
+
the only moment both paths out of teardown pass through. */
|
|
44
|
+
appRemoved : (appName : string) => void;
|
|
45
|
+
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
export type { BuildLayer, DeploymentStatus, DeploymentSummary, Draining };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/* Where an app is served, decided in one place because the console, the CLI
|
|
2
|
+
and anything else that shows a person a link must all show the same one.
|
|
3
|
+
|
|
4
|
+
Its own address, allocated by the control plane, which is the only form on
|
|
5
|
+
Anbaric Cloud. Then the app's name under the configured host suffix, which
|
|
6
|
+
is what a self-hoster gets who has pointed their own wildcard at the
|
|
7
|
+
platform and has no control plane to allocate anything - one platform serves
|
|
8
|
+
one tenant there, so the name alone is unambiguous. Then the path form,
|
|
9
|
+
which always works and is what a platform run locally uses.
|
|
10
|
+
|
|
11
|
+
An app's own hostname serves it at the root, so links, assets and fetches
|
|
12
|
+
inside it behave as they did on the machine it was built on. */
|
|
13
|
+
const appAddress = (appName : string, subdomain? : string, hostSuffix : string = process.env.ANBARIC_APP_HOST_SUFFIX ?? "") : string => {
|
|
14
|
+
if (! hostSuffix) return `/app/${appName}`;
|
|
15
|
+
|
|
16
|
+
return `https://${subdomain ?? appName}.${hostSuffix}`;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export { appAddress };
|
|
@@ -1,39 +1,32 @@
|
|
|
1
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.
|
|
3
|
-
will both have a hello-world, so the tenant is part of the name.
|
|
2
|
+
root of it and works exactly as it did when it was built locally.
|
|
4
3
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
The address itself is one DNS label and belongs to the control plane, which
|
|
5
|
+
is the only thing that can see every tenant and so the only thing that can
|
|
6
|
+
keep an address unique. What is checked here is narrower and local: that the
|
|
7
|
+
app's name could be a hostname label at all, since it is the first address
|
|
8
|
+
the control plane will be asked for.
|
|
10
9
|
|
|
11
|
-
|
|
10
|
+
A hosted app whose name is already taken is given a different address rather
|
|
11
|
+
than refused, so this is about shape, never about availability. */
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
/* A DNS label may not exceed 63 characters, and the control plane may have to
|
|
14
|
+
add a distinguishing word to a name two tenants both wanted - so a name is
|
|
15
|
+
held well short of the limit to leave room for one. */
|
|
16
|
+
const NAME_LIMIT = 48;
|
|
16
17
|
|
|
17
18
|
const SHAPE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
18
19
|
|
|
19
|
-
const hostLabelFor = (appName : string, tenant : string) : string => `${appName}${SEPARATOR}${tenant}`;
|
|
20
|
-
|
|
21
20
|
/* Why this name cannot have a hostname, or undefined when it can. Returned as a
|
|
22
21
|
sentence because it is shown to whoever ran the deploy. */
|
|
23
|
-
const hostnameObjection = (appName : string
|
|
22
|
+
const hostnameObjection = (appName : string) : string | undefined => {
|
|
24
23
|
if (! SHAPE.test(appName)) {
|
|
25
24
|
return `"${appName}" must be lower-case letters, numbers and hyphens, starting and ending with a letter or number`;
|
|
26
25
|
}
|
|
27
|
-
if (appName.
|
|
28
|
-
return `"${appName}"
|
|
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}`;
|
|
26
|
+
if (appName.length > NAME_LIMIT) {
|
|
27
|
+
return `"${appName}" is too long for an app name: ${appName.length} characters, and the limit is ${NAME_LIMIT}`;
|
|
35
28
|
}
|
|
36
29
|
return undefined;
|
|
37
30
|
};
|
|
38
31
|
|
|
39
|
-
export {
|
|
32
|
+
export { hostnameObjection, NAME_LIMIT };
|
|
@@ -31,8 +31,8 @@ class HttpMembershipService implements MembershipService {
|
|
|
31
31
|
return found;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
async invite(email : string, role : TenantRole, invitedBy : Inviter) : Promise<PendingInvitation> {
|
|
35
|
-
const response = await this.call("POST", "/invitations", { email, role, invitedBy });
|
|
34
|
+
async invite(email : string, role : TenantRole, invitedBy : Inviter, landingApp? : string) : Promise<PendingInvitation> {
|
|
35
|
+
const response = await this.call("POST", "/invitations", { email, role, invitedBy, landingApp });
|
|
36
36
|
return await response.json() as PendingInvitation;
|
|
37
37
|
}
|
|
38
38
|
|
|
@@ -17,13 +17,14 @@ class InMemoryMembershipService implements MembershipService {
|
|
|
17
17
|
return this.members.get(userId);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
async invite(email : string, role : TenantRole, invitedBy : Inviter) : Promise<PendingInvitation> {
|
|
20
|
+
async invite(email : string, role : TenantRole, invitedBy : Inviter, landingApp? : string) : Promise<PendingInvitation> {
|
|
21
21
|
const token = randomUUID();
|
|
22
22
|
const now = new Date();
|
|
23
23
|
const invitation : PendingInvitation = {
|
|
24
24
|
token,
|
|
25
25
|
email: email.trim().toLowerCase(),
|
|
26
26
|
role,
|
|
27
|
+
landingApp,
|
|
27
28
|
invitedBy: invitedBy.id,
|
|
28
29
|
invitedByName: invitedBy.name,
|
|
29
30
|
link: `${this.linkBase}/${token}`,
|
|
@@ -5,6 +5,7 @@ type PendingInvitation = {
|
|
|
5
5
|
token : string,
|
|
6
6
|
email : string,
|
|
7
7
|
role : TenantRole,
|
|
8
|
+
landingApp? : string,
|
|
8
9
|
invitedBy : string,
|
|
9
10
|
invitedByName? : string,
|
|
10
11
|
link : string,
|
|
@@ -29,7 +30,10 @@ interface MembershipService {
|
|
|
29
30
|
|
|
30
31
|
roleFor(userId : string) : Promise<TenantRole | undefined>;
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
/* landingApp is the app the invitation should open on rather than the
|
|
34
|
+
person's app directory. The control plane turns it into an address when
|
|
35
|
+
the invitation is accepted, so a later change of address still works. */
|
|
36
|
+
invite(email : string, role : TenantRole, invitedBy : Inviter, landingApp? : string) : Promise<PendingInvitation>;
|
|
33
37
|
|
|
34
38
|
pending(asking : Inviter) : Promise<Array<PendingInvitation>>;
|
|
35
39
|
|
|
@@ -22,6 +22,18 @@ type EntitlementGrant = {
|
|
|
22
22
|
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
+
/* An entitlement chosen for somebody who has not signed in yet. An invitation
|
|
26
|
+
names an email address; a grant names a user id, which that person does not
|
|
27
|
+
have until they arrive. This is the gap between the two. */
|
|
28
|
+
type EntitlementOffer = {
|
|
29
|
+
|
|
30
|
+
email : string,
|
|
31
|
+
appId : string | null,
|
|
32
|
+
entitlementId : string,
|
|
33
|
+
notes : string,
|
|
34
|
+
|
|
35
|
+
};
|
|
36
|
+
|
|
25
37
|
/* The platform's view of entitlements: what apps can do (register, has) plus
|
|
26
38
|
the management the console performs on their behalf. */
|
|
27
39
|
interface EntitlementStore extends Entitlements {
|
|
@@ -36,6 +48,16 @@ interface EntitlementStore extends Entitlements {
|
|
|
36
48
|
|
|
37
49
|
listGrants(userId? : string) : Promise<Array<EntitlementGrant>>;
|
|
38
50
|
|
|
51
|
+
// Held against an email address until whoever holds it signs in.
|
|
52
|
+
offer(offers : Array<EntitlementOffer>, offeredBy : string) : Promise<void>;
|
|
53
|
+
|
|
54
|
+
offersFor(email : string) : Promise<Array<EntitlementOffer>>;
|
|
55
|
+
|
|
56
|
+
/* Turns everything offered to this email into real grants and forgets the
|
|
57
|
+
offers. Returns how many were made, and is safe to call on every sign-in
|
|
58
|
+
because the second call has nothing left to claim. */
|
|
59
|
+
claim(email : string, userId : string) : Promise<number>;
|
|
60
|
+
|
|
39
61
|
}
|
|
40
62
|
|
|
41
|
-
export type { EntitlementStore, EntitlementDefinition, EntitlementGrant }
|
|
63
|
+
export type { EntitlementStore, EntitlementDefinition, EntitlementGrant, EntitlementOffer }
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import {randomUUID} from "node:crypto";
|
|
2
|
-
import {EntitlementDefinition, EntitlementGrant, EntitlementStore} from "./EntitlementStore";
|
|
2
|
+
import {EntitlementDefinition, EntitlementGrant, EntitlementOffer, EntitlementStore} from "./EntitlementStore";
|
|
3
3
|
|
|
4
4
|
class InMemoryEntitlementStore implements EntitlementStore {
|
|
5
5
|
|
|
6
6
|
private definitions = new Map<string, EntitlementDefinition>();
|
|
7
7
|
private grants = new Map<string, EntitlementGrant>();
|
|
8
|
+
private offers = new Map<string, { offer : EntitlementOffer, offeredBy : string }>();
|
|
8
9
|
|
|
9
10
|
constructor() {
|
|
10
11
|
this.definitions.set(this.key(null, "access"), { appId: null, entitlementId: "access", notes: "Global access" });
|
|
@@ -46,6 +47,29 @@ class InMemoryEntitlementStore implements EntitlementStore {
|
|
|
46
47
|
.map(grant => ({ ...grant }));
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
// Held against an email address; see PostgresEntitlementStore.
|
|
51
|
+
async offer(offers : Array<EntitlementOffer>, offeredBy : string) : Promise<void> {
|
|
52
|
+
for (const offer of offers) {
|
|
53
|
+
this.offers.set(`${offer.email} ${this.key(offer.appId, offer.entitlementId)}`, { offer, offeredBy });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async offersFor(email : string) : Promise<Array<EntitlementOffer>> {
|
|
58
|
+
return [...this.offers.values()]
|
|
59
|
+
.filter(held => held.offer.email === email)
|
|
60
|
+
.map(held => ({ ...held.offer }));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async claim(email : string, userId : string) : Promise<number> {
|
|
64
|
+
const owed = [...this.offers.entries()].filter(([, held]) => held.offer.email === email);
|
|
65
|
+
|
|
66
|
+
for (const [key, held] of owed) {
|
|
67
|
+
await this.grant(userId, held.offer.appId, held.offer.entitlementId, held.offer.notes, held.offeredBy);
|
|
68
|
+
this.offers.delete(key);
|
|
69
|
+
}
|
|
70
|
+
return owed.length;
|
|
71
|
+
}
|
|
72
|
+
|
|
49
73
|
private key(appId : string | null, entitlementId : string) : string {
|
|
50
74
|
return `${appId ?? ""}/${entitlementId}`;
|
|
51
75
|
}
|
|
@@ -237,6 +237,25 @@ const PLATFORM_MIGRATIONS : Array<Migration> = [
|
|
|
237
237
|
"CREATE INDEX jobs_heartbeat ON jobs (heartbeat_at) WHERE heartbeat_at IS NOT NULL",
|
|
238
238
|
),
|
|
239
239
|
|
|
240
|
+
/* Entitlements chosen for someone who has not arrived yet. An invitation
|
|
241
|
+
goes to an email address and a grant is made against a user id, which
|
|
242
|
+
that person does not have until the first time they sign in - so what
|
|
243
|
+
the inviter chose waits here under the email and is turned into real
|
|
244
|
+
grants at that moment. Rows are deleted as they are claimed, so this
|
|
245
|
+
table holds only what is still owed. */
|
|
246
|
+
statements("005-entitlement-invites",
|
|
247
|
+
`CREATE TABLE IF NOT EXISTS entitlement_invites (
|
|
248
|
+
email TEXT NOT NULL,
|
|
249
|
+
app_id TEXT,
|
|
250
|
+
entitlement_id TEXT NOT NULL,
|
|
251
|
+
notes TEXT NOT NULL DEFAULT '',
|
|
252
|
+
offered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
253
|
+
offered_by TEXT NOT NULL
|
|
254
|
+
)`,
|
|
255
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS entitlement_invites_key
|
|
256
|
+
ON entitlement_invites (email, COALESCE(app_id, ''), entitlement_id)`,
|
|
257
|
+
),
|
|
258
|
+
|
|
240
259
|
];
|
|
241
260
|
|
|
242
261
|
export { PLATFORM_MIGRATIONS };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {randomUUID} from "node:crypto";
|
|
2
2
|
import {Pool} from "pg";
|
|
3
|
-
import {EntitlementDefinition, EntitlementGrant, EntitlementStore} from "./EntitlementStore";
|
|
3
|
+
import {EntitlementDefinition, EntitlementGrant, EntitlementOffer, EntitlementStore} from "./EntitlementStore";
|
|
4
4
|
|
|
5
5
|
class PostgresEntitlementStore implements EntitlementStore {
|
|
6
6
|
|
|
@@ -64,6 +64,45 @@ class PostgresEntitlementStore implements EntitlementStore {
|
|
|
64
64
|
}));
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/* Held against an email address until whoever holds it signs in, because
|
|
68
|
+
an invitation names an email and a grant names a user id. */
|
|
69
|
+
async offer(offers : Array<EntitlementOffer>, offeredBy : string) : Promise<void> {
|
|
70
|
+
for (const offer of offers) {
|
|
71
|
+
await this.pool.query(
|
|
72
|
+
`INSERT INTO entitlement_invites (email, app_id, entitlement_id, notes, offered_by)
|
|
73
|
+
VALUES ($1, $2, $3, $4, $5)
|
|
74
|
+
ON CONFLICT (email, COALESCE(app_id, ''), entitlement_id)
|
|
75
|
+
DO UPDATE SET notes = EXCLUDED.notes, offered_by = EXCLUDED.offered_by`,
|
|
76
|
+
[offer.email, offer.appId, offer.entitlementId, offer.notes, offeredBy]);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async offersFor(email : string) : Promise<Array<EntitlementOffer>> {
|
|
81
|
+
const result = await this.pool.query(
|
|
82
|
+
`SELECT email, app_id, entitlement_id, notes FROM entitlement_invites
|
|
83
|
+
WHERE lower(email) = lower($1) ORDER BY app_id NULLS FIRST, entitlement_id`,
|
|
84
|
+
[email]);
|
|
85
|
+
return result.rows.map(row => ({
|
|
86
|
+
email: row.email, appId: row.app_id, entitlementId: row.entitlement_id, notes: row.notes,
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* Claimed in one statement so a second sign-in arriving at the same moment
|
|
91
|
+
cannot grant the same thing twice: the delete is what decides who owns
|
|
92
|
+
each row, and only rows it actually removed become grants. */
|
|
93
|
+
async claim(email : string, userId : string) : Promise<number> {
|
|
94
|
+
const result = await this.pool.query(
|
|
95
|
+
`WITH claimed AS (
|
|
96
|
+
DELETE FROM entitlement_invites WHERE lower(email) = lower($1)
|
|
97
|
+
RETURNING app_id, entitlement_id, notes, offered_by
|
|
98
|
+
)
|
|
99
|
+
INSERT INTO entitlement_grants (id, user_id, app_id, entitlement_id, notes, granted_by)
|
|
100
|
+
SELECT gen_random_uuid()::text, $2, app_id, entitlement_id, notes, offered_by FROM claimed
|
|
101
|
+
RETURNING id`,
|
|
102
|
+
[email, userId]);
|
|
103
|
+
return result.rowCount ?? 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
67
106
|
private async upsertDefinition(appId : string | null, entitlementId : string, notes : string) : Promise<void> {
|
|
68
107
|
await this.pool.query(
|
|
69
108
|
`INSERT INTO entitlements (app_id, entitlement_id, notes)
|
|
@@ -9,6 +9,8 @@ import {ConsumerRegistry} from "../queuing/ConsumerRegistry";
|
|
|
9
9
|
import {AppProxyHandler} from "./handlers/AppProxyHandler";
|
|
10
10
|
import {AppLinkFallbackHandler} from "./handlers/AppLinkFallbackHandler";
|
|
11
11
|
import {AppsHandler} from "./handlers/AppsHandler";
|
|
12
|
+
import {MyAppsHandler} from "./handlers/MyAppsHandler";
|
|
13
|
+
import {Subdomains} from "../subdomains/Subdomains";
|
|
12
14
|
import {AuditsHandler} from "./handlers/AuditsHandler";
|
|
13
15
|
import {AuthorizeCliHandler} from "./handlers/auth/AuthorizeCliHandler";
|
|
14
16
|
import {KeysHandler} from "./handlers/auth/KeysHandler";
|
|
@@ -74,7 +76,8 @@ class HostingServer {
|
|
|
74
76
|
userDirectory? : UserDirectory,
|
|
75
77
|
memberships? : MembershipService,
|
|
76
78
|
promptManagerFor? : (appId : string) => PromptManager,
|
|
77
|
-
fileStorageFor? : (appId : string) => FileStorage
|
|
79
|
+
fileStorageFor? : (appId : string) => FileStorage,
|
|
80
|
+
subdomains? : Subdomains) {
|
|
78
81
|
const pages = new PagesHandler();
|
|
79
82
|
const ping = new PingHandler(tenant);
|
|
80
83
|
const jobs = new JobsHandler(persistence);
|
|
@@ -107,7 +110,7 @@ class HostingServer {
|
|
|
107
110
|
if (notifier) publicRouter.registerApi("notifications", new NotificationsHandler(notifier));
|
|
108
111
|
if (entitlements) publicRouter.registerApi("entitlements", new EntitlementsAdminHandler(entitlements));
|
|
109
112
|
if (userDirectory) publicRouter.registerApi("users", new UsersHandler(userDirectory));
|
|
110
|
-
if (memberships) publicRouter.registerApi("invitations", new InvitationsHandler(memberships));
|
|
113
|
+
if (memberships) publicRouter.registerApi("invitations", new InvitationsHandler(memberships, entitlements));
|
|
111
114
|
const prompts = promptManagerFor && new PromptsHandler(promptManagerFor);
|
|
112
115
|
if (prompts) publicRouter.registerApi("prompts", prompts);
|
|
113
116
|
if (cliAuthorizer) {
|
|
@@ -120,8 +123,15 @@ class HostingServer {
|
|
|
120
123
|
// are wherever they registered themselves.
|
|
121
124
|
buildLayer.consumerUrlsFor = appName =>
|
|
122
125
|
[...new Set(registry.list().filter(consumer => consumer.appId === appName).map(consumer => consumer.url))];
|
|
123
|
-
|
|
126
|
+
buildLayer.appRemoved = appName => void subdomains?.release(appName);
|
|
127
|
+
publicRouter.registerApi("apps", new AppsHandler(buildLayer, tenant, subdomains));
|
|
124
128
|
publicRouter.register("app", appProxy);
|
|
129
|
+
|
|
130
|
+
// The directory a person who is not a builder sees instead of the
|
|
131
|
+
// console, so it only exists where entitlements decide access.
|
|
132
|
+
if (entitlements) {
|
|
133
|
+
publicRouter.registerApi("my-apps", new MyAppsHandler(buildLayer, entitlements, subdomains));
|
|
134
|
+
}
|
|
125
135
|
}
|
|
126
136
|
// An unrouted absolute path carrying an app Referer is an app-internal
|
|
127
137
|
// link the proxy's prefix-stripping left bare; send it back to its app.
|
|
@@ -151,10 +161,22 @@ class HostingServer {
|
|
|
151
161
|
request.url.pathname === "/favicon.ico" ||
|
|
152
162
|
(request.method === "GET" && CLI_KEY_POLL.test(request.url.pathname));
|
|
153
163
|
|
|
164
|
+
const authentication =
|
|
165
|
+
new AuthenticationMiddleware(authenticator, tokenAuthenticator, openRequests, undefined, userDirectory, memberships);
|
|
166
|
+
|
|
167
|
+
/* Entitlements an inviter chose before this person had a user id were
|
|
168
|
+
put aside under their email address; their first sign-in is when
|
|
169
|
+
they can finally be granted. */
|
|
170
|
+
if (entitlements) {
|
|
171
|
+
authentication.claimWhatIsOwed = async user => {
|
|
172
|
+
if (user.email) await entitlements.claim(user.email, user.id);
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
154
176
|
this.publicServer = new Server(publicRouter, [
|
|
155
177
|
new SessionMiddleware(),
|
|
156
178
|
new TenantRoutingMiddleware(tenant),
|
|
157
|
-
|
|
179
|
+
authentication,
|
|
158
180
|
/* After authentication and before routing: an app reached by its own
|
|
159
181
|
hostname owns every path on it, so nothing of the platform's would
|
|
160
182
|
match anyway - but a nicer address must not also be a way in
|
|
@@ -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 }
|
|
@@ -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 };
|