anbaric-cloud-hosting 1.34.0 → 1.36.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/DockerBuildLayer.ts +1 -0
- package/src/app-management/FargateBuildLayer.ts +1 -0
- package/src/auth/HttpMembershipService.ts +44 -0
- package/src/auth/InMemoryMembershipService.ts +38 -0
- package/src/auth/MembershipService.ts +35 -0
- package/src/data-store/PostgresPromptManager.ts +87 -0
- package/src/data-store/Schema.ts +12 -0
- package/src/hosting/HostingServer.ts +11 -2
- package/src/hosting/handlers/PromptsHandler.ts +46 -0
- package/src/hosting/handlers/auth/InvitationsHandler.ts +34 -0
- package/src/index.ts +6 -0
- package/src/main.ts +10 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anbaric-cloud-hosting",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.36.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.36.0",
|
|
22
|
+
"anbaric-plugins": "^1.36.0",
|
|
23
|
+
"anbaric-tsapi": "^1.36.0",
|
|
24
|
+
"anbaric-web": "^1.36.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.36.0",
|
|
32
|
+
"anbaric-state-machine": "^1.36.0",
|
|
33
33
|
"tsx": "^4.20.0",
|
|
34
34
|
"typescript": "^7.0.2"
|
|
35
35
|
},
|
|
@@ -108,6 +108,7 @@ class DockerBuildLayer extends BaseBuildLayer {
|
|
|
108
108
|
"--env", "ANBARIC_AUDITOR_TYPE=cloud",
|
|
109
109
|
"--env", "ANBARIC_SESSION_RESOLVER_TYPE=cloud",
|
|
110
110
|
"--env", "ANBARIC_ENTITLEMENTS_TYPE=cloud",
|
|
111
|
+
"--env", "ANBARIC_PROMPT_MANAGER_TYPE=cloud",
|
|
111
112
|
"--env", "ANBARIC_NOTIFIER_TYPE=cloud",
|
|
112
113
|
"--env", `ANBARIC_CONSUMER_PORT=${deployment.consumerPort}`,
|
|
113
114
|
"--env", `ANBARIC_CONSUMER_URL=http://${container}:${deployment.consumerPort}`,
|
|
@@ -198,6 +198,7 @@ class FargateBuildLayer extends BaseBuildLayer {
|
|
|
198
198
|
{ name: "ANBARIC_AUDITOR_TYPE", value: "cloud" },
|
|
199
199
|
{ name: "ANBARIC_SESSION_RESOLVER_TYPE", value: "cloud" },
|
|
200
200
|
{ name: "ANBARIC_ENTITLEMENTS_TYPE", value: "cloud" },
|
|
201
|
+
{ name: "ANBARIC_PROMPT_MANAGER_TYPE", value: "cloud" },
|
|
201
202
|
{ name: "ANBARIC_NOTIFIER_TYPE", value: "cloud" },
|
|
202
203
|
{ name: "ANBARIC_CONSUMER_PORT", value: String(deployment.consumerPort) },
|
|
203
204
|
{ name: "ANBARIC_CONSUMER_URL", value: `http://${deployment.appHost}:${deployment.consumerPort}` },
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {Inviter, MembershipService, PendingInvitation} from "./MembershipService";
|
|
2
|
+
|
|
3
|
+
type FetchFn = (url : string, init : RequestInit) => Promise<Response>;
|
|
4
|
+
|
|
5
|
+
/* Talks to the central login's invitation endpoints for one tenant, with the
|
|
6
|
+
same central api key the platform already holds for CLI key lookups. */
|
|
7
|
+
class HttpMembershipService implements MembershipService {
|
|
8
|
+
|
|
9
|
+
constructor(private centralUrl : string, private secret : string, private tenantSlug : string,
|
|
10
|
+
private fetchFn : FetchFn = (url, init) => fetch(url, init)) {}
|
|
11
|
+
|
|
12
|
+
async invite(email : string, invitedBy : Inviter) : Promise<PendingInvitation> {
|
|
13
|
+
const response = await this.call("POST", "", { email, invitedBy });
|
|
14
|
+
return await response.json() as PendingInvitation;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async pending() : Promise<Array<PendingInvitation>> {
|
|
18
|
+
const response = await this.call("GET", "");
|
|
19
|
+
return await response.json() as Array<PendingInvitation>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async revoke(token : string) : Promise<boolean> {
|
|
23
|
+
const response = await this.call("DELETE", `/${encodeURIComponent(token)}`, undefined, [404]);
|
|
24
|
+
return response.status === 204;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private async call(method : string, path : string, body? : unknown, tolerated : Array<number> = []) : Promise<Response> {
|
|
28
|
+
const headers : Record<string, string> = { "x-anbaric-central-key": this.secret };
|
|
29
|
+
if (body !== undefined) headers["content-type"] = "application/json";
|
|
30
|
+
|
|
31
|
+
const response = await this.fetchFn(
|
|
32
|
+
`${this.centralUrl.replace(/\/$/, "")}/tenants/${encodeURIComponent(this.tenantSlug)}/invitations${path}`,
|
|
33
|
+
{ method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
34
|
+
|
|
35
|
+
if (! response.ok && ! tolerated.includes(response.status)) {
|
|
36
|
+
const problem = await response.json().catch(() => ({})) as { error? : string };
|
|
37
|
+
throw new Error(problem.error ?? `The invitation request failed with status ${response.status}`);
|
|
38
|
+
}
|
|
39
|
+
return response;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export { HttpMembershipService }
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {randomUUID} from "node:crypto";
|
|
2
|
+
import {Inviter, MembershipService, PendingInvitation} from "./MembershipService";
|
|
3
|
+
|
|
4
|
+
class InMemoryMembershipService implements MembershipService {
|
|
5
|
+
|
|
6
|
+
private invitations = new Map<string, PendingInvitation>();
|
|
7
|
+
|
|
8
|
+
constructor(private linkBase : string = "http://localhost/invitations") {}
|
|
9
|
+
|
|
10
|
+
async invite(email : string, invitedBy : Inviter) : Promise<PendingInvitation> {
|
|
11
|
+
const token = randomUUID();
|
|
12
|
+
const now = new Date();
|
|
13
|
+
const invitation : PendingInvitation = {
|
|
14
|
+
token,
|
|
15
|
+
email: email.trim().toLowerCase(),
|
|
16
|
+
role: "member",
|
|
17
|
+
invitedBy: invitedBy.id,
|
|
18
|
+
invitedByName: invitedBy.name,
|
|
19
|
+
link: `${this.linkBase}/${token}`,
|
|
20
|
+
createdAt: now.toISOString(),
|
|
21
|
+
expiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
|
22
|
+
emailed: false,
|
|
23
|
+
};
|
|
24
|
+
this.invitations.set(token, invitation);
|
|
25
|
+
return { ...invitation };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async pending() : Promise<Array<PendingInvitation>> {
|
|
29
|
+
return Array.from(this.invitations.values()).map(invitation => ({ ...invitation })).reverse();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async revoke(token : string) : Promise<boolean> {
|
|
33
|
+
return this.invitations.delete(token);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export { InMemoryMembershipService }
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
type PendingInvitation = {
|
|
2
|
+
|
|
3
|
+
token : string,
|
|
4
|
+
email : string,
|
|
5
|
+
role : string,
|
|
6
|
+
invitedBy : string,
|
|
7
|
+
invitedByName? : string,
|
|
8
|
+
link : string,
|
|
9
|
+
createdAt : string,
|
|
10
|
+
expiresAt : string,
|
|
11
|
+
emailed? : boolean,
|
|
12
|
+
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type Inviter = {
|
|
16
|
+
|
|
17
|
+
id : string,
|
|
18
|
+
name? : string,
|
|
19
|
+
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/* Invitations into this tenant. Membership itself is decided wherever people
|
|
23
|
+
sign in (Anbaric Cloud's central login), so a platform only asks that
|
|
24
|
+
service to invite, list and revoke on the tenant's behalf. */
|
|
25
|
+
interface MembershipService {
|
|
26
|
+
|
|
27
|
+
invite(email : string, invitedBy : Inviter) : Promise<PendingInvitation>;
|
|
28
|
+
|
|
29
|
+
pending() : Promise<Array<PendingInvitation>>;
|
|
30
|
+
|
|
31
|
+
revoke(token : string) : Promise<boolean>;
|
|
32
|
+
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type { MembershipService, PendingInvitation, Inviter }
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {Pool} from "pg";
|
|
2
|
+
import {JsonSchema, Prompt, PromptManager, samePromptContent} from "anbaric-tsapi";
|
|
3
|
+
|
|
4
|
+
const COLUMNS = "app_id, prompt_id, version, instructions, input_schema, output_schema, created_at";
|
|
5
|
+
|
|
6
|
+
const rowToPrompt = (row : any) : Prompt => ({
|
|
7
|
+
appId: row.app_id,
|
|
8
|
+
promptId: row.prompt_id,
|
|
9
|
+
version: row.version,
|
|
10
|
+
instructions: row.instructions,
|
|
11
|
+
inputSchema: row.input_schema ?? undefined,
|
|
12
|
+
outputSchema: row.output_schema ?? undefined,
|
|
13
|
+
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/* Saving reads the latest version and writes the next inside one transaction,
|
|
17
|
+
with the latest row locked, so two saves of the same prompt cannot both
|
|
18
|
+
claim the same version number. */
|
|
19
|
+
class PostgresPromptManager implements PromptManager {
|
|
20
|
+
|
|
21
|
+
constructor(private pool : Pool, private appId : string) {}
|
|
22
|
+
|
|
23
|
+
async save(promptId : string, instructions : string, inputSchema? : JsonSchema, outputSchema? : JsonSchema) : Promise<Prompt> {
|
|
24
|
+
const client = await this.pool.connect();
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
await client.query("BEGIN");
|
|
28
|
+
const latest = await client.query(
|
|
29
|
+
`SELECT ${COLUMNS} FROM prompts WHERE app_id = $1 AND prompt_id = $2
|
|
30
|
+
ORDER BY version DESC LIMIT 1 FOR UPDATE`, [this.appId, promptId]);
|
|
31
|
+
const current = latest.rows[0] && rowToPrompt(latest.rows[0]);
|
|
32
|
+
|
|
33
|
+
if (current && samePromptContent(current, instructions, inputSchema, outputSchema)) {
|
|
34
|
+
await client.query("COMMIT");
|
|
35
|
+
return current;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const inserted = await client.query(
|
|
39
|
+
`INSERT INTO prompts (app_id, prompt_id, version, instructions, input_schema, output_schema)
|
|
40
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
41
|
+
RETURNING ${COLUMNS}`,
|
|
42
|
+
[this.appId, promptId, (current?.version ?? 0) + 1, instructions,
|
|
43
|
+
inputSchema === undefined ? null : JSON.stringify(inputSchema),
|
|
44
|
+
outputSchema === undefined ? null : JSON.stringify(outputSchema)]);
|
|
45
|
+
await client.query("COMMIT");
|
|
46
|
+
return rowToPrompt(inserted.rows[0]);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
await client.query("ROLLBACK").catch(() => {});
|
|
49
|
+
throw error;
|
|
50
|
+
} finally {
|
|
51
|
+
client.release();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async retrieve(promptId : string, version? : number) : Promise<Prompt> {
|
|
56
|
+
const result = version === undefined
|
|
57
|
+
? await this.pool.query(
|
|
58
|
+
`SELECT ${COLUMNS} FROM prompts WHERE app_id = $1 AND prompt_id = $2 ORDER BY version DESC LIMIT 1`,
|
|
59
|
+
[this.appId, promptId])
|
|
60
|
+
: await this.pool.query(
|
|
61
|
+
`SELECT ${COLUMNS} FROM prompts WHERE app_id = $1 AND prompt_id = $2 AND version = $3`,
|
|
62
|
+
[this.appId, promptId, version]);
|
|
63
|
+
if (! result.rows[0]) {
|
|
64
|
+
throw new Error(version === undefined
|
|
65
|
+
? `No prompt found with id "${promptId}"`
|
|
66
|
+
: `No prompt found with id "${promptId}" at version ${version}`);
|
|
67
|
+
}
|
|
68
|
+
return rowToPrompt(result.rows[0]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async list() : Promise<Array<Prompt>> {
|
|
72
|
+
const result = await this.pool.query(
|
|
73
|
+
`SELECT DISTINCT ON (prompt_id) ${COLUMNS} FROM prompts
|
|
74
|
+
WHERE app_id = $1 ORDER BY prompt_id, version DESC`, [this.appId]);
|
|
75
|
+
return result.rows.map(rowToPrompt);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async history(promptId : string) : Promise<Array<Prompt>> {
|
|
79
|
+
const result = await this.pool.query(
|
|
80
|
+
`SELECT ${COLUMNS} FROM prompts WHERE app_id = $1 AND prompt_id = $2 ORDER BY version DESC`,
|
|
81
|
+
[this.appId, promptId]);
|
|
82
|
+
return result.rows.map(rowToPrompt);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export { PostgresPromptManager }
|
package/src/data-store/Schema.ts
CHANGED
|
@@ -144,6 +144,18 @@ const ensureSchema = async (pool : Pool) : Promise<void> => {
|
|
|
144
144
|
VALUES (NULL, 'access', 'Global access')
|
|
145
145
|
ON CONFLICT (COALESCE(app_id, ''), entitlement_id) DO NOTHING
|
|
146
146
|
`);
|
|
147
|
+
await pool.query(`
|
|
148
|
+
CREATE TABLE IF NOT EXISTS prompts (
|
|
149
|
+
app_id TEXT NOT NULL,
|
|
150
|
+
prompt_id TEXT NOT NULL,
|
|
151
|
+
version INTEGER NOT NULL,
|
|
152
|
+
instructions TEXT NOT NULL,
|
|
153
|
+
input_schema JSONB,
|
|
154
|
+
output_schema JSONB,
|
|
155
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
156
|
+
PRIMARY KEY (app_id, prompt_id, version)
|
|
157
|
+
)
|
|
158
|
+
`);
|
|
147
159
|
await pool.query("CREATE SCHEMA IF NOT EXISTS anbaric_system");
|
|
148
160
|
await pool.query(`
|
|
149
161
|
CREATE TABLE IF NOT EXISTS anbaric_system.cli_keys (
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {JobPersistence, JobRunSchedulePersistence, JsonStore, Notifier, SecretStore} from "anbaric-tsapi";
|
|
1
|
+
import {JobPersistence, JobRunSchedulePersistence, JsonStore, Notifier, PromptManager, SecretStore} from "anbaric-tsapi";
|
|
2
2
|
import {BuildLayer} from "../app-management/BuildLayer";
|
|
3
3
|
import {AuditRecordStore} from "../auditing/AuditRecordStore";
|
|
4
4
|
import {Authenticator} from "../auth/Authenticator";
|
|
@@ -14,6 +14,9 @@ import {AuthorizeCliHandler} from "./handlers/auth/AuthorizeCliHandler";
|
|
|
14
14
|
import {KeysHandler} from "./handlers/auth/KeysHandler";
|
|
15
15
|
import {SessionsHandler} from "./handlers/auth/SessionsHandler";
|
|
16
16
|
import {UsersHandler} from "./handlers/auth/UsersHandler";
|
|
17
|
+
import {InvitationsHandler} from "./handlers/auth/InvitationsHandler";
|
|
18
|
+
import {PromptsHandler} from "./handlers/PromptsHandler";
|
|
19
|
+
import {MembershipService} from "../auth/MembershipService";
|
|
17
20
|
import {WhoamiHandler} from "./handlers/auth/WhoamiHandler";
|
|
18
21
|
import {UserDirectory} from "../auth/UserDirectory";
|
|
19
22
|
import {ConsumersHandler} from "./handlers/ConsumersHandler";
|
|
@@ -65,7 +68,9 @@ class HostingServer {
|
|
|
65
68
|
jobRunSchedules? : JobRunSchedulePersistence,
|
|
66
69
|
notifier? : Notifier,
|
|
67
70
|
entitlements? : EntitlementStore,
|
|
68
|
-
userDirectory? : UserDirectory
|
|
71
|
+
userDirectory? : UserDirectory,
|
|
72
|
+
memberships? : MembershipService,
|
|
73
|
+
promptManagerFor? : (appId : string) => PromptManager) {
|
|
69
74
|
const pages = new PagesHandler();
|
|
70
75
|
const ping = new PingHandler(tenant);
|
|
71
76
|
const jobs = new JobsHandler(persistence);
|
|
@@ -96,6 +101,9 @@ class HostingServer {
|
|
|
96
101
|
if (notifier) publicRouter.registerApi("notifications", new NotificationsHandler(notifier));
|
|
97
102
|
if (entitlements) publicRouter.registerApi("entitlements", new EntitlementsAdminHandler(entitlements));
|
|
98
103
|
if (userDirectory) publicRouter.registerApi("users", new UsersHandler(userDirectory));
|
|
104
|
+
if (memberships) publicRouter.registerApi("invitations", new InvitationsHandler(memberships));
|
|
105
|
+
const prompts = promptManagerFor && new PromptsHandler(promptManagerFor);
|
|
106
|
+
if (prompts) publicRouter.registerApi("prompts", prompts);
|
|
99
107
|
if (cliAuthorizer) {
|
|
100
108
|
publicRouter.register("authorize-cli", new AuthorizeCliHandler(cliAuthorizer, pages, tenant));
|
|
101
109
|
publicRouter.registerApi("keys", new KeysHandler(cliAuthorizer));
|
|
@@ -121,6 +129,7 @@ class HostingServer {
|
|
|
121
129
|
if (jobRunSchedules) internalRouter.registerApi("job-run-schedules", new JobRunSchedulesHandler(jobRunSchedules));
|
|
122
130
|
if (notifier) internalRouter.registerApi("notifications", new NotificationsHandler(notifier));
|
|
123
131
|
if (entitlements) internalRouter.registerApi("entitlements", new EntitlementsHandler(entitlements));
|
|
132
|
+
if (prompts) internalRouter.registerApi("prompts", prompts);
|
|
124
133
|
|
|
125
134
|
// The favicon is requested by the browser before anyone has signed in,
|
|
126
135
|
// and by deployed apps' pages, so gating it behind a session would send
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {PromptManager} from "anbaric-tsapi";
|
|
2
|
+
import {Request} from "../Request";
|
|
3
|
+
import {RequestHandler} from "../RequestHandler";
|
|
4
|
+
|
|
5
|
+
/* Prompts belong to the calling app (the ambient app header): save a version,
|
|
6
|
+
fetch the latest or a named one, list, and read a prompt's history. */
|
|
7
|
+
class PromptsHandler implements RequestHandler {
|
|
8
|
+
|
|
9
|
+
constructor(private managerFor : (appId : string) => PromptManager) {}
|
|
10
|
+
|
|
11
|
+
async handle(request : Request) : Promise<void> {
|
|
12
|
+
const appId = request.appId;
|
|
13
|
+
if (! appId) return request.reply(400, { error: "Missing the x-anbaric-app header" });
|
|
14
|
+
|
|
15
|
+
const manager = this.managerFor(appId);
|
|
16
|
+
const { method, id, subresource } = request;
|
|
17
|
+
|
|
18
|
+
if (method === "GET" && ! id) return request.reply(200, await manager.list());
|
|
19
|
+
if (method === "PUT" && id && ! subresource) return this.save(request, manager, id);
|
|
20
|
+
if (method === "GET" && id && ! subresource) return this.retrieve(request, manager, id);
|
|
21
|
+
if (method === "GET" && id && subresource === "history") return request.reply(200, await manager.history(id));
|
|
22
|
+
request.notFound();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private async save(request : Request, manager : PromptManager, promptId : string) : Promise<void> {
|
|
26
|
+
const body = await request.body() as { instructions? : string, inputSchema? : object, outputSchema? : object } | undefined;
|
|
27
|
+
if (typeof body?.instructions !== "string") return request.reply(400, { error: "instructions is required" });
|
|
28
|
+
|
|
29
|
+
request.reply(200, await manager.save(promptId, body.instructions, body.inputSchema, body.outputSchema));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private async retrieve(request : Request, manager : PromptManager, promptId : string) : Promise<void> {
|
|
33
|
+
const raw = request.query("version");
|
|
34
|
+
const version = raw === undefined ? undefined : Number(raw);
|
|
35
|
+
if (version !== undefined && ! Number.isInteger(version)) return request.reply(400, { error: `"${raw}" is not a version` });
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
request.reply(200, await manager.retrieve(promptId, version));
|
|
39
|
+
} catch (error) {
|
|
40
|
+
request.reply(404, { error: error instanceof Error ? error.message : String(error) });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export { PromptsHandler }
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {MembershipService} from "../../../auth/MembershipService";
|
|
2
|
+
import {Request} from "../../Request";
|
|
3
|
+
import {RequestHandler} from "../../RequestHandler";
|
|
4
|
+
|
|
5
|
+
const EMAIL_SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
6
|
+
|
|
7
|
+
/* The console's side of inviting people into this tenant: list what is
|
|
8
|
+
pending, invite by email (attributed to the signed-in user), revoke. */
|
|
9
|
+
class InvitationsHandler implements RequestHandler {
|
|
10
|
+
|
|
11
|
+
constructor(private memberships : MembershipService) {}
|
|
12
|
+
|
|
13
|
+
async handle(request : Request) : Promise<void> {
|
|
14
|
+
if (request.method === "GET" && ! request.id) return request.reply(200, await this.memberships.pending());
|
|
15
|
+
if (request.method === "POST" && ! request.id) return this.invite(request);
|
|
16
|
+
if (request.method === "DELETE" && request.id && ! request.subresource) {
|
|
17
|
+
const revoked = await this.memberships.revoke(request.id);
|
|
18
|
+
return revoked ? request.reply(204) : request.notFound();
|
|
19
|
+
}
|
|
20
|
+
request.notFound();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private async invite(request : Request) : Promise<void> {
|
|
24
|
+
const body = await request.body() as { email? : string } | undefined;
|
|
25
|
+
const email = String(body?.email ?? "").trim();
|
|
26
|
+
if (! EMAIL_SHAPE.test(email)) return request.reply(400, { error: "A valid email address is required" });
|
|
27
|
+
|
|
28
|
+
const inviter = request.user ? { id: request.user.id, name: request.user.name } : { id: "system" };
|
|
29
|
+
request.reply(201, await this.memberships.invite(email, inviter));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export { InvitationsHandler }
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,12 @@ export * from "./auth/UserDirectory";
|
|
|
21
21
|
export * from "./auth/InMemoryUserDirectory";
|
|
22
22
|
export * from "./data-store/PostgresUserDirectory";
|
|
23
23
|
export * from "./hosting/handlers/auth/UsersHandler";
|
|
24
|
+
export * from "./auth/MembershipService";
|
|
25
|
+
export * from "./auth/HttpMembershipService";
|
|
26
|
+
export * from "./auth/InMemoryMembershipService";
|
|
27
|
+
export * from "./hosting/handlers/auth/InvitationsHandler";
|
|
28
|
+
export * from "./data-store/PostgresPromptManager";
|
|
29
|
+
export * from "./hosting/handlers/PromptsHandler";
|
|
24
30
|
export * from "./app-management/DockerBuildLayer";
|
|
25
31
|
export * from "./app-management/FargateBuildLayer";
|
|
26
32
|
export * from "./app-admin/AdminServer";
|
package/src/main.ts
CHANGED
|
@@ -22,6 +22,8 @@ import {GatewayDocModel} from "./docs/GatewayDocModel";
|
|
|
22
22
|
import {PostgresAppDocsStore} from "./data-store/PostgresAppDocsStore";
|
|
23
23
|
import {PostgresEntitlementStore} from "./data-store/PostgresEntitlementStore";
|
|
24
24
|
import {PostgresUserDirectory} from "./data-store/PostgresUserDirectory";
|
|
25
|
+
import {HttpMembershipService} from "./auth/HttpMembershipService";
|
|
26
|
+
import {PostgresPromptManager} from "./data-store/PostgresPromptManager";
|
|
25
27
|
import {ConsumerRegistry} from "./queuing/ConsumerRegistry";
|
|
26
28
|
import {Dispatcher} from "./queuing/Dispatcher";
|
|
27
29
|
import {HostingServer} from "./hosting/HostingServer";
|
|
@@ -93,11 +95,18 @@ const tokenAuthenticator = new TokenAuthenticator(cliKeyStore, process.env.ANBAR
|
|
|
93
95
|
|
|
94
96
|
const plugins = await new PluginLoader().load(process.env.ANBARIC_PLUGINS ?? "anbaric-plugins/state-machines");
|
|
95
97
|
|
|
98
|
+
// Invitations are managed by the central login that issued this tenant, which
|
|
99
|
+
// is the same service the CLI key lookup already points at.
|
|
100
|
+
const memberships = process.env.ANBARIC_CLI_KEY_LOOKUP_URL && process.env.ANBARIC_TENANT
|
|
101
|
+
? new HttpMembershipService(process.env.ANBARIC_CLI_KEY_LOOKUP_URL, process.env.ANBARIC_CLI_KEY_LOOKUP_SECRET ?? "", process.env.ANBARIC_TENANT)
|
|
102
|
+
: undefined;
|
|
103
|
+
|
|
96
104
|
const server = new HostingServer(new PostgresJobPersistence(pool), queue, registry, buildLayer,
|
|
97
105
|
(appId, collection) => new PostgresJsonStore(pool, appId, collection), secretStoreFor, authenticator, cliAuthorizer,
|
|
98
106
|
tokenAuthenticator, process.env.ANBARIC_TENANT, new PostgresAuditRecordStore(pool), plugins,
|
|
99
107
|
new PostgresJobRunSchedulePersistence(pool), await loadNotifier(process.env.ANBARIC_NOTIFIER_MODULE),
|
|
100
|
-
new PostgresEntitlementStore(pool), new PostgresUserDirectory(pool)
|
|
108
|
+
new PostgresEntitlementStore(pool), new PostgresUserDirectory(pool), memberships,
|
|
109
|
+
(appId) => new PostgresPromptManager(pool, appId));
|
|
101
110
|
const port = await server.listen(hostingPort);
|
|
102
111
|
const internal = await server.listenInternal(internalPort);
|
|
103
112
|
|