anbaric-cloud-hosting 1.69.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/InMemorySkillStore.ts +31 -0
- package/src/data-store/PlatformMigrations.ts +32 -0
- package/src/data-store/PostgresEntitlementStore.ts +29 -1
- package/src/data-store/PostgresSkillStore.ts +49 -0
- package/src/data-store/SkillStore.ts +41 -0
- package/src/hosting/HostingServer.ts +11 -2
- package/src/hosting/Request.ts +4 -0
- package/src/hosting/handlers/SkillsHandler.ts +97 -0
- package/src/hosting/handlers/auth/InvitationsHandler.ts +23 -8
- package/src/hosting/middleware/AuthenticationMiddleware.ts +10 -1
- package/src/main.ts +4 -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
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {Skill, SkillDraft, SkillStore} from "./SkillStore";
|
|
2
|
+
|
|
3
|
+
class InMemorySkillStore implements SkillStore {
|
|
4
|
+
|
|
5
|
+
private skills = new Map<string, Skill>();
|
|
6
|
+
|
|
7
|
+
async list() : Promise<Array<Skill>> {
|
|
8
|
+
return [...this.skills.values()]
|
|
9
|
+
.sort((left, right) => left.id.localeCompare(right.id))
|
|
10
|
+
.map(skill => ({ ...skill }));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async get(id : string) : Promise<Skill | undefined> {
|
|
14
|
+
const skill = this.skills.get(id);
|
|
15
|
+
return skill && { ...skill };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async save(draft : SkillDraft) : Promise<Skill> {
|
|
19
|
+
const now = new Date();
|
|
20
|
+
const saved : Skill = { ...draft, createdAt: this.skills.get(draft.id)?.createdAt ?? now, updatedAt: now };
|
|
21
|
+
this.skills.set(draft.id, saved);
|
|
22
|
+
return { ...saved };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async remove(id : string) : Promise<boolean> {
|
|
26
|
+
return this.skills.delete(id);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export { InMemorySkillStore };
|
|
@@ -273,6 +273,38 @@ const PLATFORM_MIGRATIONS : Array<Migration> = [
|
|
|
273
273
|
)`,
|
|
274
274
|
),
|
|
275
275
|
|
|
276
|
+
/* Guidance the tenant's builders write for the agents that work on it:
|
|
277
|
+
usage hints say when to reach for a skill, the body says what to do.
|
|
278
|
+
A platform table rather than workflow data, since apps never see it. */
|
|
279
|
+
statements("008-skills",
|
|
280
|
+
`CREATE TABLE IF NOT EXISTS anbaric_system.skills (
|
|
281
|
+
id TEXT PRIMARY KEY,
|
|
282
|
+
title TEXT NOT NULL,
|
|
283
|
+
usage_hints TEXT NOT NULL,
|
|
284
|
+
body TEXT NOT NULL,
|
|
285
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
286
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
287
|
+
)`,
|
|
288
|
+
),
|
|
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
|
+
|
|
276
308
|
];
|
|
277
309
|
|
|
278
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. */
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {Pool} from "pg";
|
|
2
|
+
import {Skill, SkillDraft, SkillStore} from "./SkillStore";
|
|
3
|
+
|
|
4
|
+
const COLUMNS = "id, title, usage_hints, body, locked, created_at, updated_at";
|
|
5
|
+
|
|
6
|
+
const rowToSkill = (row : any) : Skill => ({
|
|
7
|
+
id: row.id,
|
|
8
|
+
title: row.title,
|
|
9
|
+
usageHints: row.usage_hints,
|
|
10
|
+
body: row.body,
|
|
11
|
+
locked: row.locked,
|
|
12
|
+
createdAt: row.created_at,
|
|
13
|
+
updatedAt: row.updated_at,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
class PostgresSkillStore implements SkillStore {
|
|
17
|
+
|
|
18
|
+
constructor(private pool : Pool) {}
|
|
19
|
+
|
|
20
|
+
async list() : Promise<Array<Skill>> {
|
|
21
|
+
const result = await this.pool.query(`SELECT ${COLUMNS} FROM anbaric_system.skills ORDER BY id`);
|
|
22
|
+
return result.rows.map(rowToSkill);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async get(id : string) : Promise<Skill | undefined> {
|
|
26
|
+
const result = await this.pool.query(`SELECT ${COLUMNS} FROM anbaric_system.skills WHERE id = $1`, [id]);
|
|
27
|
+
return result.rows[0] && rowToSkill(result.rows[0]);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async save(draft : SkillDraft) : Promise<Skill> {
|
|
31
|
+
const result = await this.pool.query(
|
|
32
|
+
`INSERT INTO anbaric_system.skills (id, title, usage_hints, body, locked)
|
|
33
|
+
VALUES ($1, $2, $3, $4, $5)
|
|
34
|
+
ON CONFLICT (id) DO UPDATE
|
|
35
|
+
SET title = EXCLUDED.title, usage_hints = EXCLUDED.usage_hints, body = EXCLUDED.body,
|
|
36
|
+
locked = EXCLUDED.locked, updated_at = now()
|
|
37
|
+
RETURNING ${COLUMNS}`,
|
|
38
|
+
[draft.id, draft.title, draft.usageHints, draft.body, draft.locked]);
|
|
39
|
+
return rowToSkill(result.rows[0]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async remove(id : string) : Promise<boolean> {
|
|
43
|
+
const result = await this.pool.query("DELETE FROM anbaric_system.skills WHERE id = $1", [id]);
|
|
44
|
+
return (result.rowCount ?? 0) > 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export { PostgresSkillStore };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/* A skill is guidance a tenant writes for the agents that build on it: when to
|
|
2
|
+
reach for it (the usage hints, which an agent reads before deciding) and
|
|
3
|
+
what to do (the body, which it reads once it has decided). Kept by the
|
|
4
|
+
platform, managed from the console, and never handed to deployed apps. */
|
|
5
|
+
type Skill = {
|
|
6
|
+
id : string,
|
|
7
|
+
title : string,
|
|
8
|
+
usageHints : string,
|
|
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,
|
|
13
|
+
createdAt : Date,
|
|
14
|
+
updatedAt : Date,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type SkillDraft = Omit<Skill, "createdAt" | "updatedAt">;
|
|
18
|
+
|
|
19
|
+
/* The id ends up as the tail of a tool name an agent sees, after a fixed
|
|
20
|
+
prefix, and tool names are capped at 64 characters: hence the shape and
|
|
21
|
+
the length. */
|
|
22
|
+
const SKILL_ID_SHAPE = /^[a-z0-9][a-z0-9-]{0,49}$/;
|
|
23
|
+
|
|
24
|
+
const isSkillId = (value : unknown) : value is string =>
|
|
25
|
+
typeof value === "string" && SKILL_ID_SHAPE.test(value);
|
|
26
|
+
|
|
27
|
+
interface SkillStore {
|
|
28
|
+
|
|
29
|
+
list() : Promise<Array<Skill>>;
|
|
30
|
+
|
|
31
|
+
get(id : string) : Promise<Skill | undefined>;
|
|
32
|
+
|
|
33
|
+
// Creates or replaces the skill with that id.
|
|
34
|
+
save(draft : SkillDraft) : Promise<Skill>;
|
|
35
|
+
|
|
36
|
+
remove(id : string) : Promise<boolean>;
|
|
37
|
+
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type { Skill, SkillDraft, SkillStore };
|
|
41
|
+
export { SKILL_ID_SHAPE, isSkillId };
|
|
@@ -14,6 +14,8 @@ import {MembersHandler} from "./handlers/auth/MembersHandler";
|
|
|
14
14
|
import {BillingHandler} from "./handlers/BillingHandler";
|
|
15
15
|
import {CentralBilling} from "../billing/CentralBilling";
|
|
16
16
|
import {AppSettingsStore} from "../data-store/AppSettingsStore";
|
|
17
|
+
import {SkillStore} from "../data-store/SkillStore";
|
|
18
|
+
import {SkillsHandler} from "./handlers/SkillsHandler";
|
|
17
19
|
import {Subdomains} from "../subdomains/Subdomains";
|
|
18
20
|
import {AuditsHandler} from "./handlers/AuditsHandler";
|
|
19
21
|
import {AuthorizeCliHandler} from "./handlers/auth/AuthorizeCliHandler";
|
|
@@ -83,7 +85,9 @@ class HostingServer {
|
|
|
83
85
|
fileStorageFor? : (appId : string) => FileStorage,
|
|
84
86
|
subdomains? : Subdomains,
|
|
85
87
|
billing? : CentralBilling,
|
|
86
|
-
appSettings? : AppSettingsStore
|
|
88
|
+
appSettings? : AppSettingsStore,
|
|
89
|
+
skills? : SkillStore,
|
|
90
|
+
operatorKey? : string) {
|
|
87
91
|
const pages = new PagesHandler();
|
|
88
92
|
const ping = new PingHandler(tenant);
|
|
89
93
|
const jobs = new JobsHandler(persistence);
|
|
@@ -119,6 +123,8 @@ class HostingServer {
|
|
|
119
123
|
if (memberships) publicRouter.registerApi("invitations", new InvitationsHandler(memberships, entitlements));
|
|
120
124
|
if (memberships) publicRouter.registerApi("members", new MembersHandler(memberships, entitlements));
|
|
121
125
|
if (billing) publicRouter.registerApi("billing", new BillingHandler(billing));
|
|
126
|
+
// People and their agents only: a deployed app has no business reading skills.
|
|
127
|
+
if (skills) publicRouter.registerApi("skills", new SkillsHandler(skills));
|
|
122
128
|
const prompts = promptManagerFor && new PromptsHandler(promptManagerFor);
|
|
123
129
|
if (prompts) publicRouter.registerApi("prompts", prompts);
|
|
124
130
|
if (cliAuthorizer) {
|
|
@@ -170,7 +176,7 @@ class HostingServer {
|
|
|
170
176
|
(request.method === "GET" && CLI_KEY_POLL.test(request.url.pathname));
|
|
171
177
|
|
|
172
178
|
const authentication =
|
|
173
|
-
new AuthenticationMiddleware(authenticator, tokenAuthenticator, openRequests, undefined, userDirectory, memberships);
|
|
179
|
+
new AuthenticationMiddleware(authenticator, tokenAuthenticator, openRequests, undefined, userDirectory, memberships, operatorKey);
|
|
174
180
|
|
|
175
181
|
/* Entitlements an inviter chose before this person had a user id were
|
|
176
182
|
put aside under their email address; their first sign-in is when
|
|
@@ -178,6 +184,9 @@ class HostingServer {
|
|
|
178
184
|
if (entitlements) {
|
|
179
185
|
authentication.claimWhatIsOwed = async user => {
|
|
180
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);
|
|
181
190
|
};
|
|
182
191
|
}
|
|
183
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
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {deniesBuild} from "../../auth/TenantRole";
|
|
2
|
+
import {isSkillId, Skill, SkillStore} from "../../data-store/SkillStore";
|
|
3
|
+
import {Request} from "../Request";
|
|
4
|
+
import {RequestHandler} from "../RequestHandler";
|
|
5
|
+
|
|
6
|
+
/* The tenant's skills: guidance its builders write for the agents that work
|
|
7
|
+
on it. Anyone in the tenant may read them - an agent acting for a user
|
|
8
|
+
needs them as much as one acting for a builder - but writing them is a
|
|
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. */
|
|
13
|
+
class SkillsHandler implements RequestHandler {
|
|
14
|
+
|
|
15
|
+
constructor(private skills : SkillStore) {}
|
|
16
|
+
|
|
17
|
+
async handle(request : Request) : Promise<void> {
|
|
18
|
+
if (request.subresource) return request.notFound();
|
|
19
|
+
if (request.id) return this.handleSkill(request, request.id);
|
|
20
|
+
return this.handleCollection(request);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private async handleCollection(request : Request) : Promise<void> {
|
|
24
|
+
if (request.method !== "GET") return request.notFound();
|
|
25
|
+
return request.reply(200, (await this.skills.list()).map(skill => this.publicSkill(skill)));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private async handleSkill(request : Request, id : string) : Promise<void> {
|
|
29
|
+
switch (request.method) {
|
|
30
|
+
case "GET": {
|
|
31
|
+
const skill = await this.skills.get(id);
|
|
32
|
+
if (! skill) return request.reply(404, { error: `No skill with id "${id}"` });
|
|
33
|
+
return request.reply(200, this.publicSkill(skill));
|
|
34
|
+
}
|
|
35
|
+
case "PUT": {
|
|
36
|
+
if (! request.operator && deniesBuild(request.user?.tenantRole)) return this.refuse(request);
|
|
37
|
+
if (! isSkillId(id)) {
|
|
38
|
+
return request.reply(400, { error: `"${id}" is not a skill id: lower-case letters, digits and hyphens, up to 50 characters` });
|
|
39
|
+
}
|
|
40
|
+
const existing = await this.skills.get(id);
|
|
41
|
+
if (existing?.locked && ! request.operator) return this.managed(request);
|
|
42
|
+
|
|
43
|
+
const body = await request.body();
|
|
44
|
+
const draft = this.draftFrom(body);
|
|
45
|
+
if (! draft) return request.reply(400, { error: "Expected a body of { title : string, usageHints : string, body : string }, none empty" });
|
|
46
|
+
|
|
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 })));
|
|
50
|
+
}
|
|
51
|
+
case "DELETE": {
|
|
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);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
request.notFound();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private refuse(request : Request) : void {
|
|
65
|
+
request.reply(403, { error: "Your role in this tenant cannot manage skills" });
|
|
66
|
+
}
|
|
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
|
+
|
|
72
|
+
private draftFrom(body : unknown) : { title : string, usageHints : string, body : string } | undefined {
|
|
73
|
+
const fields = body as Record<string, unknown> | undefined;
|
|
74
|
+
const text = (value : unknown) : string | undefined =>
|
|
75
|
+
typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
76
|
+
|
|
77
|
+
const title = text(fields?.title);
|
|
78
|
+
const usageHints = text(fields?.usageHints);
|
|
79
|
+
const content = text(fields?.body);
|
|
80
|
+
return title && usageHints && content ? { title, usageHints, body: content } : undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private publicSkill(skill : Skill) {
|
|
84
|
+
return {
|
|
85
|
+
id: skill.id,
|
|
86
|
+
title: skill.title,
|
|
87
|
+
usageHints: skill.usageHints,
|
|
88
|
+
body: skill.body,
|
|
89
|
+
locked: skill.locked,
|
|
90
|
+
createdAt: skill.createdAt.toISOString(),
|
|
91
|
+
updatedAt: skill.updatedAt.toISOString(),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export { SkillsHandler };
|
|
@@ -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
|
@@ -27,6 +27,7 @@ import {DocGenerator} from "./docs/DocGenerator";
|
|
|
27
27
|
import {GatewayDocModel} from "./docs/GatewayDocModel";
|
|
28
28
|
import {PostgresAppDocsStore} from "./data-store/PostgresAppDocsStore";
|
|
29
29
|
import {PostgresAppSettingsStore} from "./data-store/PostgresAppSettingsStore";
|
|
30
|
+
import {PostgresSkillStore} from "./data-store/PostgresSkillStore";
|
|
30
31
|
import {PostgresEntitlementStore} from "./data-store/PostgresEntitlementStore";
|
|
31
32
|
import {PostgresUserDirectory} from "./data-store/PostgresUserDirectory";
|
|
32
33
|
import {HttpMembershipService} from "./auth/HttpMembershipService";
|
|
@@ -156,7 +157,9 @@ const server = new HostingServer(jobs, queue, registry, buildLayer,
|
|
|
156
157
|
tokenAuthenticator, process.env.ANBARIC_TENANT, auditRecords, plugins,
|
|
157
158
|
new PostgresJobRunSchedulePersistence(pool), await loadNotifier(process.env.ANBARIC_NOTIFIER_MODULE),
|
|
158
159
|
new PostgresEntitlementStore(pool), new PostgresUserDirectory(pool), memberships,
|
|
159
|
-
(appId) => new PostgresPromptManager(pool, appId), fileStorageFor, subdomains, billing, appSettings)
|
|
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);
|
|
160
163
|
const port = await server.listen(hostingPort);
|
|
161
164
|
const internal = await server.listenInternal(internalPort);
|
|
162
165
|
|