anbaric-cloud-hosting 1.70.0 → 1.71.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/auth/HttpMembershipService.ts +25 -8
- package/src/auth/InMemoryMembershipService.ts +30 -0
- package/src/auth/MembershipService.ts +11 -1
- package/src/data-store/EntitlementStore.ts +11 -1
- package/src/data-store/InMemoryEntitlementStore.ts +21 -0
- package/src/data-store/PlatformMigrations.ts +18 -0
- package/src/data-store/PostgresEntitlementStore.ts +29 -1
- package/src/data-store/PostgresSkillStore.ts +7 -5
- package/src/data-store/SkillStore.ts +3 -0
- package/src/hosting/HostingServer.ts +6 -2
- package/src/hosting/Request.ts +4 -0
- package/src/hosting/handlers/SkillsHandler.ts +23 -7
- package/src/hosting/handlers/auth/InvitationsHandler.ts +23 -8
- package/src/hosting/middleware/AuthenticationMiddleware.ts +10 -1
- package/src/main.ts +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anbaric-cloud-hosting",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.71.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.71.0",
|
|
22
|
+
"anbaric-plugins": "^1.71.0",
|
|
23
|
+
"anbaric-tsapi": "^1.71.0",
|
|
24
|
+
"anbaric-web": "^1.71.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.71.0",
|
|
32
|
+
"anbaric-state-machine": "^1.71.0",
|
|
33
33
|
"tsx": "^4.20.0",
|
|
34
34
|
"typescript": "^7.0.2"
|
|
35
35
|
},
|
|
@@ -11,23 +11,35 @@ const ROLE_CACHE_TTL_MS = 60_000;
|
|
|
11
11
|
role change is a fair trade for not calling central on each of them. */
|
|
12
12
|
class HttpMembershipService implements MembershipService {
|
|
13
13
|
|
|
14
|
-
private roles = new Map<string, { role : TenantRole | undefined, expires : number }>();
|
|
14
|
+
private roles = new Map<string, { role : TenantRole | undefined, invitationToken? : string, expires : number }>();
|
|
15
15
|
|
|
16
16
|
constructor(private centralUrl : string, private secret : string, private tenantSlug : string,
|
|
17
17
|
private fetchFn : FetchFn = (url, init) => fetch(url, init),
|
|
18
18
|
private cacheTtlMs : number = ROLE_CACHE_TTL_MS) {}
|
|
19
19
|
|
|
20
20
|
async roleFor(userId : string) : Promise<TenantRole | undefined> {
|
|
21
|
+
return (await this.membership(userId)).role;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async joinedVia(userId : string) : Promise<string | undefined> {
|
|
25
|
+
return (await this.membership(userId)).invitationToken;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private async membership(userId : string) : Promise<{ role : TenantRole | undefined, invitationToken? : string }> {
|
|
21
29
|
const cached = this.roles.get(userId);
|
|
22
|
-
if (cached && cached.expires > Date.now()) return cached
|
|
30
|
+
if (cached && cached.expires > Date.now()) return cached;
|
|
23
31
|
|
|
24
32
|
const response = await this.call("GET", `/members/${encodeURIComponent(userId)}`, undefined, [404]);
|
|
25
|
-
const
|
|
26
|
-
?
|
|
27
|
-
:
|
|
28
|
-
const found =
|
|
29
|
-
|
|
30
|
-
|
|
33
|
+
const answer = response.status === 404
|
|
34
|
+
? {}
|
|
35
|
+
: await response.json() as { role? : unknown, invitationToken? : unknown };
|
|
36
|
+
const found = {
|
|
37
|
+
role: isTenantRole(answer.role) ? answer.role : undefined,
|
|
38
|
+
invitationToken: typeof answer.invitationToken === "string" ? answer.invitationToken : undefined,
|
|
39
|
+
expires: Date.now() + this.cacheTtlMs,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
this.roles.set(userId, found);
|
|
31
43
|
return found;
|
|
32
44
|
}
|
|
33
45
|
|
|
@@ -36,6 +48,11 @@ class HttpMembershipService implements MembershipService {
|
|
|
36
48
|
return await response.json() as PendingInvitation;
|
|
37
49
|
}
|
|
38
50
|
|
|
51
|
+
async inviteLink(role : TenantRole, invitedBy : Inviter, uses : number, landingApp? : string) : Promise<PendingInvitation> {
|
|
52
|
+
const response = await this.call("POST", "/invitations", { link: true, uses, role, invitedBy, landingApp });
|
|
53
|
+
return await response.json() as PendingInvitation;
|
|
54
|
+
}
|
|
55
|
+
|
|
39
56
|
async pending(asking : Inviter) : Promise<Array<PendingInvitation>> {
|
|
40
57
|
const response = await this.call("GET", "/invitations", undefined, [], asking);
|
|
41
58
|
return await response.json() as Array<PendingInvitation>;
|
|
@@ -6,6 +6,7 @@ class InMemoryMembershipService implements MembershipService {
|
|
|
6
6
|
|
|
7
7
|
private invitations = new Map<string, PendingInvitation>();
|
|
8
8
|
private members = new Map<string, TenantRole>();
|
|
9
|
+
private joinedThrough = new Map<string, string>();
|
|
9
10
|
|
|
10
11
|
constructor(private linkBase : string = "http://localhost/invitations") {}
|
|
11
12
|
|
|
@@ -17,6 +18,35 @@ class InMemoryMembershipService implements MembershipService {
|
|
|
17
18
|
return this.members.get(userId);
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
// What a person who followed a link ends up as, for tests to arrange.
|
|
22
|
+
join(userId : string, role : TenantRole, token : string) : void {
|
|
23
|
+
this.members.set(userId, role);
|
|
24
|
+
this.joinedThrough.set(userId, token);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async joinedVia(userId : string) : Promise<string | undefined> {
|
|
28
|
+
return this.joinedThrough.get(userId);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async inviteLink(role : TenantRole, invitedBy : Inviter, uses : number, landingApp? : string) : Promise<PendingInvitation> {
|
|
32
|
+
const token = randomUUID();
|
|
33
|
+
const now = new Date();
|
|
34
|
+
const invitation : PendingInvitation = {
|
|
35
|
+
token,
|
|
36
|
+
role,
|
|
37
|
+
landingApp,
|
|
38
|
+
invitedBy: invitedBy.id,
|
|
39
|
+
invitedByName: invitedBy.name,
|
|
40
|
+
link: `${this.linkBase}/${token}`,
|
|
41
|
+
createdAt: now.toISOString(),
|
|
42
|
+
expiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
|
43
|
+
emailed: false,
|
|
44
|
+
usesRemaining: uses,
|
|
45
|
+
};
|
|
46
|
+
this.invitations.set(token, invitation);
|
|
47
|
+
return { ...invitation };
|
|
48
|
+
}
|
|
49
|
+
|
|
20
50
|
async remove(userId : string, _asking : Inviter) : Promise<boolean> {
|
|
21
51
|
return this.members.delete(userId);
|
|
22
52
|
}
|
|
@@ -3,7 +3,8 @@ import {TenantRole} from "./TenantRole";
|
|
|
3
3
|
type PendingInvitation = {
|
|
4
4
|
|
|
5
5
|
token : string,
|
|
6
|
-
|
|
6
|
+
// Absent for a link: nobody is named, whoever opens it may join.
|
|
7
|
+
email? : string,
|
|
7
8
|
role : TenantRole,
|
|
8
9
|
landingApp? : string,
|
|
9
10
|
invitedBy : string,
|
|
@@ -12,6 +13,8 @@ type PendingInvitation = {
|
|
|
12
13
|
createdAt : string,
|
|
13
14
|
expiresAt : string,
|
|
14
15
|
emailed? : boolean,
|
|
16
|
+
// Present for a link invitation: how many more people may join through it.
|
|
17
|
+
usesRemaining? : number,
|
|
15
18
|
|
|
16
19
|
};
|
|
17
20
|
|
|
@@ -35,6 +38,13 @@ interface MembershipService {
|
|
|
35
38
|
the invitation is accepted, so a later change of address still works. */
|
|
36
39
|
invite(email : string, role : TenantRole, invitedBy : Inviter, landingApp? : string) : Promise<PendingInvitation>;
|
|
37
40
|
|
|
41
|
+
/* An invitation with no name on it: a link that admits whoever opens it
|
|
42
|
+
and signs in, up to `uses` people. */
|
|
43
|
+
inviteLink(role : TenantRole, invitedBy : Inviter, uses : number, landingApp? : string) : Promise<PendingInvitation>;
|
|
44
|
+
|
|
45
|
+
// The invitation a member came in through, when it is known.
|
|
46
|
+
joinedVia(userId : string) : Promise<string | undefined>;
|
|
47
|
+
|
|
38
48
|
pending(asking : Inviter) : Promise<Array<PendingInvitation>>;
|
|
39
49
|
|
|
40
50
|
revoke(token : string, asking : Inviter) : Promise<boolean>;
|
|
@@ -27,7 +27,9 @@ type EntitlementGrant = {
|
|
|
27
27
|
have until they arrive. This is the gap between the two. */
|
|
28
28
|
type EntitlementOffer = {
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
// The address the offer is held against, or null when it is held against a link.
|
|
31
|
+
email : string | null,
|
|
32
|
+
linkToken? : string,
|
|
31
33
|
appId : string | null,
|
|
32
34
|
entitlementId : string,
|
|
33
35
|
notes : string,
|
|
@@ -58,6 +60,14 @@ interface EntitlementStore extends Entitlements {
|
|
|
58
60
|
because the second call has nothing left to claim. */
|
|
59
61
|
claim(email : string, userId : string) : Promise<number>;
|
|
60
62
|
|
|
63
|
+
/* Held against an invitation link instead of an address: granted to each
|
|
64
|
+
person who joins through the link, and kept, since the next person
|
|
65
|
+
through it is owed the same. Returns how many grants were made for this
|
|
66
|
+
person; none the second time, as they already hold them. */
|
|
67
|
+
offerForLink(linkToken : string, offers : Array<Omit<EntitlementOffer, "email" | "linkToken">>, offeredBy : string) : Promise<void>;
|
|
68
|
+
|
|
69
|
+
claimLink(linkToken : string, userId : string) : Promise<number>;
|
|
70
|
+
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
export type { EntitlementStore, EntitlementDefinition, EntitlementGrant, EntitlementOffer }
|
|
@@ -70,6 +70,27 @@ class InMemoryEntitlementStore implements EntitlementStore {
|
|
|
70
70
|
return owed.length;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
async offerForLink(linkToken : string, offers : Array<Omit<EntitlementOffer, "email" | "linkToken">>, offeredBy : string) : Promise<void> {
|
|
74
|
+
for (const offer of offers) {
|
|
75
|
+
this.offers.set(`link:${linkToken} ${this.key(offer.appId, offer.entitlementId)}`,
|
|
76
|
+
{ offer: { ...offer, email: null, linkToken }, offeredBy });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async claimLink(linkToken : string, userId : string) : Promise<number> {
|
|
81
|
+
const owed = [...this.offers.values()].filter(held => held.offer.linkToken === linkToken);
|
|
82
|
+
const already = await this.listGrants(userId);
|
|
83
|
+
let made = 0;
|
|
84
|
+
|
|
85
|
+
for (const held of owed) {
|
|
86
|
+
const holds = already.some(grant => grant.entitlementId === held.offer.entitlementId && (grant.appId ?? "") === (held.offer.appId ?? ""));
|
|
87
|
+
if (holds) continue;
|
|
88
|
+
await this.grant(userId, held.offer.appId, held.offer.entitlementId, held.offer.notes, held.offeredBy);
|
|
89
|
+
made++;
|
|
90
|
+
}
|
|
91
|
+
return made;
|
|
92
|
+
}
|
|
93
|
+
|
|
73
94
|
private key(appId : string | null, entitlementId : string) : string {
|
|
74
95
|
return `${appId ?? ""}/${entitlementId}`;
|
|
75
96
|
}
|
|
@@ -287,6 +287,24 @@ const PLATFORM_MIGRATIONS : Array<Migration> = [
|
|
|
287
287
|
)`,
|
|
288
288
|
),
|
|
289
289
|
|
|
290
|
+
// A managed skill is the operators' to change, not the tenant's.
|
|
291
|
+
statements("009-skills-locked",
|
|
292
|
+
"ALTER TABLE anbaric_system.skills ADD COLUMN IF NOT EXISTS locked BOOLEAN NOT NULL DEFAULT false",
|
|
293
|
+
),
|
|
294
|
+
|
|
295
|
+
/* An invitation may be a link rather than an email: anyone who opens it
|
|
296
|
+
and signs in becomes a member, as many times as it allows. The
|
|
297
|
+
entitlements chosen for it are held against the link, since there is
|
|
298
|
+
no address to hold them against, and granted to each person who joins
|
|
299
|
+
through it. */
|
|
300
|
+
statements("010-invite-links",
|
|
301
|
+
"ALTER TABLE entitlement_invites ALTER COLUMN email DROP NOT NULL",
|
|
302
|
+
"ALTER TABLE entitlement_invites ADD COLUMN IF NOT EXISTS link_token TEXT",
|
|
303
|
+
"DROP INDEX IF EXISTS entitlement_invites_key",
|
|
304
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS entitlement_invites_key
|
|
305
|
+
ON entitlement_invites (COALESCE(email, ''), COALESCE(link_token, ''), COALESCE(app_id, ''), entitlement_id)`,
|
|
306
|
+
),
|
|
307
|
+
|
|
290
308
|
];
|
|
291
309
|
|
|
292
310
|
export { PLATFORM_MIGRATIONS };
|
|
@@ -71,7 +71,7 @@ class PostgresEntitlementStore implements EntitlementStore {
|
|
|
71
71
|
await this.pool.query(
|
|
72
72
|
`INSERT INTO entitlement_invites (email, app_id, entitlement_id, notes, offered_by)
|
|
73
73
|
VALUES ($1, $2, $3, $4, $5)
|
|
74
|
-
ON CONFLICT (email, COALESCE(app_id, ''), entitlement_id)
|
|
74
|
+
ON CONFLICT (COALESCE(email, ''), COALESCE(link_token, ''), COALESCE(app_id, ''), entitlement_id)
|
|
75
75
|
DO UPDATE SET notes = EXCLUDED.notes, offered_by = EXCLUDED.offered_by`,
|
|
76
76
|
[offer.email, offer.appId, offer.entitlementId, offer.notes, offeredBy]);
|
|
77
77
|
}
|
|
@@ -87,6 +87,34 @@ class PostgresEntitlementStore implements EntitlementStore {
|
|
|
87
87
|
}));
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
async offerForLink(linkToken : string, offers : Array<Omit<EntitlementOffer, "email" | "linkToken">>, offeredBy : string) : Promise<void> {
|
|
91
|
+
for (const offer of offers) {
|
|
92
|
+
await this.pool.query(
|
|
93
|
+
`INSERT INTO entitlement_invites (email, link_token, app_id, entitlement_id, notes, offered_by)
|
|
94
|
+
VALUES (NULL, $1, $2, $3, $4, $5)
|
|
95
|
+
ON CONFLICT (COALESCE(email, ''), COALESCE(link_token, ''), COALESCE(app_id, ''), entitlement_id)
|
|
96
|
+
DO UPDATE SET notes = EXCLUDED.notes, offered_by = EXCLUDED.offered_by`,
|
|
97
|
+
[linkToken, offer.appId, offer.entitlementId, offer.notes, offeredBy]);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* The offers stay: the link may admit more people. The grant is made once
|
|
102
|
+
per person, so a sign-in that claims again finds nothing new. */
|
|
103
|
+
async claimLink(linkToken : string, userId : string) : Promise<number> {
|
|
104
|
+
const result = await this.pool.query(
|
|
105
|
+
`INSERT INTO entitlement_grants (id, user_id, app_id, entitlement_id, notes, granted_by)
|
|
106
|
+
SELECT gen_random_uuid()::text, $2, offered.app_id, offered.entitlement_id, offered.notes, offered.offered_by
|
|
107
|
+
FROM entitlement_invites offered
|
|
108
|
+
WHERE offered.link_token = $1
|
|
109
|
+
AND NOT EXISTS (
|
|
110
|
+
SELECT 1 FROM entitlement_grants held
|
|
111
|
+
WHERE held.user_id = $2 AND held.entitlement_id = offered.entitlement_id
|
|
112
|
+
AND COALESCE(held.app_id, '') = COALESCE(offered.app_id, ''))
|
|
113
|
+
RETURNING id`,
|
|
114
|
+
[linkToken, userId]);
|
|
115
|
+
return result.rowCount ?? 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
90
118
|
/* Claimed in one statement so a second sign-in arriving at the same moment
|
|
91
119
|
cannot grant the same thing twice: the delete is what decides who owns
|
|
92
120
|
each row, and only rows it actually removed become grants. */
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import {Pool} from "pg";
|
|
2
2
|
import {Skill, SkillDraft, SkillStore} from "./SkillStore";
|
|
3
3
|
|
|
4
|
-
const COLUMNS = "id, title, usage_hints, body, created_at, updated_at";
|
|
4
|
+
const COLUMNS = "id, title, usage_hints, body, locked, created_at, updated_at";
|
|
5
5
|
|
|
6
6
|
const rowToSkill = (row : any) : Skill => ({
|
|
7
7
|
id: row.id,
|
|
8
8
|
title: row.title,
|
|
9
9
|
usageHints: row.usage_hints,
|
|
10
10
|
body: row.body,
|
|
11
|
+
locked: row.locked,
|
|
11
12
|
createdAt: row.created_at,
|
|
12
13
|
updatedAt: row.updated_at,
|
|
13
14
|
});
|
|
@@ -28,12 +29,13 @@ class PostgresSkillStore implements SkillStore {
|
|
|
28
29
|
|
|
29
30
|
async save(draft : SkillDraft) : Promise<Skill> {
|
|
30
31
|
const result = await this.pool.query(
|
|
31
|
-
`INSERT INTO anbaric_system.skills (id, title, usage_hints, body)
|
|
32
|
-
VALUES ($1, $2, $3, $4)
|
|
32
|
+
`INSERT INTO anbaric_system.skills (id, title, usage_hints, body, locked)
|
|
33
|
+
VALUES ($1, $2, $3, $4, $5)
|
|
33
34
|
ON CONFLICT (id) DO UPDATE
|
|
34
|
-
SET title = EXCLUDED.title, usage_hints = EXCLUDED.usage_hints, body = EXCLUDED.body,
|
|
35
|
+
SET title = EXCLUDED.title, usage_hints = EXCLUDED.usage_hints, body = EXCLUDED.body,
|
|
36
|
+
locked = EXCLUDED.locked, updated_at = now()
|
|
35
37
|
RETURNING ${COLUMNS}`,
|
|
36
|
-
[draft.id, draft.title, draft.usageHints, draft.body]);
|
|
38
|
+
[draft.id, draft.title, draft.usageHints, draft.body, draft.locked]);
|
|
37
39
|
return rowToSkill(result.rows[0]);
|
|
38
40
|
}
|
|
39
41
|
|
|
@@ -7,6 +7,9 @@ type Skill = {
|
|
|
7
7
|
title : string,
|
|
8
8
|
usageHints : string,
|
|
9
9
|
body : string,
|
|
10
|
+
/* A managed skill: written by the platform's operators rather than the
|
|
11
|
+
tenant, shown as such, and not the tenant's to change or remove. */
|
|
12
|
+
locked : boolean,
|
|
10
13
|
createdAt : Date,
|
|
11
14
|
updatedAt : Date,
|
|
12
15
|
};
|
|
@@ -86,7 +86,8 @@ class HostingServer {
|
|
|
86
86
|
subdomains? : Subdomains,
|
|
87
87
|
billing? : CentralBilling,
|
|
88
88
|
appSettings? : AppSettingsStore,
|
|
89
|
-
skills? : SkillStore
|
|
89
|
+
skills? : SkillStore,
|
|
90
|
+
operatorKey? : string) {
|
|
90
91
|
const pages = new PagesHandler();
|
|
91
92
|
const ping = new PingHandler(tenant);
|
|
92
93
|
const jobs = new JobsHandler(persistence);
|
|
@@ -175,7 +176,7 @@ class HostingServer {
|
|
|
175
176
|
(request.method === "GET" && CLI_KEY_POLL.test(request.url.pathname));
|
|
176
177
|
|
|
177
178
|
const authentication =
|
|
178
|
-
new AuthenticationMiddleware(authenticator, tokenAuthenticator, openRequests, undefined, userDirectory, memberships);
|
|
179
|
+
new AuthenticationMiddleware(authenticator, tokenAuthenticator, openRequests, undefined, userDirectory, memberships, operatorKey);
|
|
179
180
|
|
|
180
181
|
/* Entitlements an inviter chose before this person had a user id were
|
|
181
182
|
put aside under their email address; their first sign-in is when
|
|
@@ -183,6 +184,9 @@ class HostingServer {
|
|
|
183
184
|
if (entitlements) {
|
|
184
185
|
authentication.claimWhatIsOwed = async user => {
|
|
185
186
|
if (user.email) await entitlements.claim(user.email, user.id);
|
|
187
|
+
// Someone who came in through a link is owed what the link carries.
|
|
188
|
+
const token = await memberships?.joinedVia(user.id);
|
|
189
|
+
if (token) await entitlements.claimLink(token, user.id);
|
|
186
190
|
};
|
|
187
191
|
}
|
|
188
192
|
|
package/src/hosting/Request.ts
CHANGED
|
@@ -16,6 +16,10 @@ class Request {
|
|
|
16
16
|
identity? : string;
|
|
17
17
|
user? : User;
|
|
18
18
|
tenant? : Tenant;
|
|
19
|
+
/* Set when the platform's operators are calling with the control plane's
|
|
20
|
+
key rather than anyone signing in: no user, no role, and admitted only
|
|
21
|
+
where a handler says operators belong. */
|
|
22
|
+
operator = false;
|
|
19
23
|
|
|
20
24
|
readonly api : boolean;
|
|
21
25
|
|
|
@@ -6,8 +6,10 @@ import {RequestHandler} from "../RequestHandler";
|
|
|
6
6
|
/* The tenant's skills: guidance its builders write for the agents that work
|
|
7
7
|
on it. Anyone in the tenant may read them - an agent acting for a user
|
|
8
8
|
needs them as much as one acting for a builder - but writing them is a
|
|
9
|
-
builder's job.
|
|
10
|
-
|
|
9
|
+
builder's job. A managed skill is the operators' instead: written with
|
|
10
|
+
the control plane's key, marked locked, and not the tenant's to change.
|
|
11
|
+
Served to people and their agents only, never to deployed apps, which is
|
|
12
|
+
why this is registered on the public entry point alone. */
|
|
11
13
|
class SkillsHandler implements RequestHandler {
|
|
12
14
|
|
|
13
15
|
constructor(private skills : SkillStore) {}
|
|
@@ -31,20 +33,29 @@ class SkillsHandler implements RequestHandler {
|
|
|
31
33
|
return request.reply(200, this.publicSkill(skill));
|
|
32
34
|
}
|
|
33
35
|
case "PUT": {
|
|
34
|
-
if (deniesBuild(request.user?.tenantRole)) return this.refuse(request);
|
|
36
|
+
if (! request.operator && deniesBuild(request.user?.tenantRole)) return this.refuse(request);
|
|
35
37
|
if (! isSkillId(id)) {
|
|
36
38
|
return request.reply(400, { error: `"${id}" is not a skill id: lower-case letters, digits and hyphens, up to 50 characters` });
|
|
37
39
|
}
|
|
40
|
+
const existing = await this.skills.get(id);
|
|
41
|
+
if (existing?.locked && ! request.operator) return this.managed(request);
|
|
42
|
+
|
|
38
43
|
const body = await request.body();
|
|
39
44
|
const draft = this.draftFrom(body);
|
|
40
45
|
if (! draft) return request.reply(400, { error: "Expected a body of { title : string, usageHints : string, body : string }, none empty" });
|
|
41
46
|
|
|
42
|
-
|
|
47
|
+
// Only an operator makes a managed skill, and says so each time.
|
|
48
|
+
const locked = request.operator && (body as { locked? : unknown })?.locked === true;
|
|
49
|
+
return request.reply(200, this.publicSkill(await this.skills.save({ id, ...draft, locked })));
|
|
43
50
|
}
|
|
44
51
|
case "DELETE": {
|
|
45
|
-
if (deniesBuild(request.user?.tenantRole)) return this.refuse(request);
|
|
46
|
-
const
|
|
47
|
-
|
|
52
|
+
if (! request.operator && deniesBuild(request.user?.tenantRole)) return this.refuse(request);
|
|
53
|
+
const existing = await this.skills.get(id);
|
|
54
|
+
if (! existing) return request.reply(404, { error: `No skill with id "${id}"` });
|
|
55
|
+
if (existing.locked && ! request.operator) return this.managed(request);
|
|
56
|
+
|
|
57
|
+
await this.skills.remove(id);
|
|
58
|
+
return request.reply(204);
|
|
48
59
|
}
|
|
49
60
|
}
|
|
50
61
|
request.notFound();
|
|
@@ -54,6 +65,10 @@ class SkillsHandler implements RequestHandler {
|
|
|
54
65
|
request.reply(403, { error: "Your role in this tenant cannot manage skills" });
|
|
55
66
|
}
|
|
56
67
|
|
|
68
|
+
private managed(request : Request) : void {
|
|
69
|
+
request.reply(403, { error: "This skill is a managed service and is not yours to change" });
|
|
70
|
+
}
|
|
71
|
+
|
|
57
72
|
private draftFrom(body : unknown) : { title : string, usageHints : string, body : string } | undefined {
|
|
58
73
|
const fields = body as Record<string, unknown> | undefined;
|
|
59
74
|
const text = (value : unknown) : string | undefined =>
|
|
@@ -71,6 +86,7 @@ class SkillsHandler implements RequestHandler {
|
|
|
71
86
|
title: skill.title,
|
|
72
87
|
usageHints: skill.usageHints,
|
|
73
88
|
body: skill.body,
|
|
89
|
+
locked: skill.locked,
|
|
74
90
|
createdAt: skill.createdAt.toISOString(),
|
|
75
91
|
updatedAt: skill.updatedAt.toISOString(),
|
|
76
92
|
};
|
|
@@ -5,6 +5,7 @@ import {Request} from "../../Request";
|
|
|
5
5
|
import {RequestHandler} from "../../RequestHandler";
|
|
6
6
|
|
|
7
7
|
const EMAIL_SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
8
|
+
const MOST_USES = 1000;
|
|
8
9
|
|
|
9
10
|
/* The console's side of inviting people into this tenant: list what is
|
|
10
11
|
pending, invite by email (attributed to the signed-in user), revoke.
|
|
@@ -41,11 +42,20 @@ class InvitationsHandler implements RequestHandler {
|
|
|
41
42
|
return request.user ? { id: request.user.id, name: request.user.name } : { id: "system" };
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
/* By email, or as a link. A link names nobody: whoever opens it and signs
|
|
46
|
+
in joins, as many times as it allows, and the entitlements chosen for
|
|
47
|
+
it are held against the link rather than an address. */
|
|
44
48
|
private async invite(request : Request) : Promise<void> {
|
|
45
49
|
const body = await request.body() as
|
|
46
|
-
{ email? : string, role? : string, landingApp? : string, entitlements? : Array<unknown> } | undefined;
|
|
50
|
+
{ email? : string, link? : unknown, uses? : unknown, role? : string, landingApp? : string, entitlements? : Array<unknown> } | undefined;
|
|
51
|
+
const asLink = body?.link === true;
|
|
47
52
|
const email = String(body?.email ?? "").trim();
|
|
48
|
-
if (! EMAIL_SHAPE.test(email)) return request.reply(400, { error: "A valid email address is required" });
|
|
53
|
+
if (! asLink && ! EMAIL_SHAPE.test(email)) return request.reply(400, { error: "A valid email address is required" });
|
|
54
|
+
|
|
55
|
+
const uses = asLink ? Number(body?.uses ?? 1) : 0;
|
|
56
|
+
if (asLink && (! Number.isInteger(uses) || uses < 1 || uses > MOST_USES)) {
|
|
57
|
+
return request.reply(400, { error: `A link may be used between 1 and ${MOST_USES} times` });
|
|
58
|
+
}
|
|
49
59
|
|
|
50
60
|
const role = body?.role ?? "USER";
|
|
51
61
|
if (! isTenantRole(role) || role === "OWNER") {
|
|
@@ -56,13 +66,19 @@ class InvitationsHandler implements RequestHandler {
|
|
|
56
66
|
? body.landingApp.trim()
|
|
57
67
|
: undefined;
|
|
58
68
|
|
|
59
|
-
const invitation =
|
|
69
|
+
const invitation = asLink
|
|
70
|
+
? await this.memberships.inviteLink(role, this.asking(request), uses, landingApp)
|
|
71
|
+
: await this.memberships.invite(email, role, this.asking(request), landingApp);
|
|
60
72
|
|
|
61
73
|
/* Put aside after the invitation exists, so nothing is held for an
|
|
62
|
-
email that was never actually
|
|
63
|
-
const chosen = this.chosenEntitlements(
|
|
74
|
+
email, or a link, that was never actually made. */
|
|
75
|
+
const chosen = this.chosenEntitlements(body?.entitlements);
|
|
64
76
|
if (chosen.length > 0 && this.entitlements) {
|
|
65
|
-
|
|
77
|
+
if (asLink) {
|
|
78
|
+
await this.entitlements.offerForLink(invitation.token, chosen, request.user?.id ?? "system");
|
|
79
|
+
} else {
|
|
80
|
+
await this.entitlements.offer(chosen.map(offer => ({ ...offer, email })), request.user?.id ?? "system");
|
|
81
|
+
}
|
|
66
82
|
}
|
|
67
83
|
|
|
68
84
|
request.reply(201, { ...invitation, entitlements: chosen.length });
|
|
@@ -70,7 +86,7 @@ class InvitationsHandler implements RequestHandler {
|
|
|
70
86
|
|
|
71
87
|
// Each is an app and an entitlement, with a null app meaning every app -
|
|
72
88
|
// the same scoping a grant already has.
|
|
73
|
-
private chosenEntitlements(
|
|
89
|
+
private chosenEntitlements(chosen : Array<unknown> | undefined) : Array<Omit<EntitlementOffer, "email" | "linkToken">> {
|
|
74
90
|
if (! Array.isArray(chosen)) return [];
|
|
75
91
|
|
|
76
92
|
return chosen.flatMap(each => {
|
|
@@ -78,7 +94,6 @@ class InvitationsHandler implements RequestHandler {
|
|
|
78
94
|
if (typeof offer?.entitlementId !== "string" || ! offer.entitlementId) return [];
|
|
79
95
|
|
|
80
96
|
return [{
|
|
81
|
-
email,
|
|
82
97
|
appId: typeof offer.appId === "string" && offer.appId ? offer.appId : null,
|
|
83
98
|
entitlementId: offer.entitlementId,
|
|
84
99
|
notes: "",
|
|
@@ -36,11 +36,20 @@ class AuthenticationMiddleware implements Middleware {
|
|
|
36
36
|
private isOpen : OpenRequestPredicate = () => false,
|
|
37
37
|
private sessionSigner : SessionSigner = new SessionSigner(),
|
|
38
38
|
private userDirectory? : UserDirectory,
|
|
39
|
-
private memberships? : MembershipService
|
|
39
|
+
private memberships? : MembershipService,
|
|
40
|
+
private operatorKey? : string) {}
|
|
40
41
|
|
|
41
42
|
async apply(request : Request) : Promise<boolean> {
|
|
42
43
|
if (this.isOpen(request)) return true;
|
|
43
44
|
|
|
45
|
+
/* The platform's operators, presenting the control plane's own key,
|
|
46
|
+
reach exactly one thing: the managed skills they look after. Nothing
|
|
47
|
+
else is open to the key, so holding it is not a way into a tenant. */
|
|
48
|
+
if (this.operatorKey && request.resource === "skills" && request.header("x-anbaric-central-key") === this.operatorKey) {
|
|
49
|
+
request.operator = true;
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
44
53
|
if (this.tokenAuthenticator?.handles(request.raw)) {
|
|
45
54
|
const user = await this.tokenAuthenticator.authenticate(request.raw, request.rawResponse);
|
|
46
55
|
if (!user) return false;
|
package/src/main.ts
CHANGED
|
@@ -157,7 +157,9 @@ const server = new HostingServer(jobs, queue, registry, buildLayer,
|
|
|
157
157
|
tokenAuthenticator, process.env.ANBARIC_TENANT, auditRecords, plugins,
|
|
158
158
|
new PostgresJobRunSchedulePersistence(pool), await loadNotifier(process.env.ANBARIC_NOTIFIER_MODULE),
|
|
159
159
|
new PostgresEntitlementStore(pool), new PostgresUserDirectory(pool), memberships,
|
|
160
|
-
(appId) => new PostgresPromptManager(pool, appId), fileStorageFor, subdomains, billing, appSettings, new PostgresSkillStore(pool)
|
|
160
|
+
(appId) => new PostgresPromptManager(pool, appId), fileStorageFor, subdomains, billing, appSettings, new PostgresSkillStore(pool),
|
|
161
|
+
// The control plane's key, which the operators hold, is what writes a managed skill.
|
|
162
|
+
process.env.ANBARIC_CLI_KEY_LOOKUP_SECRET || undefined);
|
|
161
163
|
const port = await server.listen(hostingPort);
|
|
162
164
|
const internal = await server.listenInternal(internalPort);
|
|
163
165
|
|