anbaric-cloud-hosting 1.35.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anbaric-cloud-hosting",
3
- "version": "1.35.0",
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.35.0",
22
- "anbaric-plugins": "^1.35.0",
23
- "anbaric-tsapi": "^1.35.0",
24
- "anbaric-web": "^1.35.0",
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.35.0",
32
- "anbaric-state-machine": "^1.35.0",
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,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 }
@@ -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";
@@ -15,6 +15,7 @@ import {KeysHandler} from "./handlers/auth/KeysHandler";
15
15
  import {SessionsHandler} from "./handlers/auth/SessionsHandler";
16
16
  import {UsersHandler} from "./handlers/auth/UsersHandler";
17
17
  import {InvitationsHandler} from "./handlers/auth/InvitationsHandler";
18
+ import {PromptsHandler} from "./handlers/PromptsHandler";
18
19
  import {MembershipService} from "../auth/MembershipService";
19
20
  import {WhoamiHandler} from "./handlers/auth/WhoamiHandler";
20
21
  import {UserDirectory} from "../auth/UserDirectory";
@@ -68,7 +69,8 @@ class HostingServer {
68
69
  notifier? : Notifier,
69
70
  entitlements? : EntitlementStore,
70
71
  userDirectory? : UserDirectory,
71
- memberships? : MembershipService) {
72
+ memberships? : MembershipService,
73
+ promptManagerFor? : (appId : string) => PromptManager) {
72
74
  const pages = new PagesHandler();
73
75
  const ping = new PingHandler(tenant);
74
76
  const jobs = new JobsHandler(persistence);
@@ -100,6 +102,8 @@ class HostingServer {
100
102
  if (entitlements) publicRouter.registerApi("entitlements", new EntitlementsAdminHandler(entitlements));
101
103
  if (userDirectory) publicRouter.registerApi("users", new UsersHandler(userDirectory));
102
104
  if (memberships) publicRouter.registerApi("invitations", new InvitationsHandler(memberships));
105
+ const prompts = promptManagerFor && new PromptsHandler(promptManagerFor);
106
+ if (prompts) publicRouter.registerApi("prompts", prompts);
103
107
  if (cliAuthorizer) {
104
108
  publicRouter.register("authorize-cli", new AuthorizeCliHandler(cliAuthorizer, pages, tenant));
105
109
  publicRouter.registerApi("keys", new KeysHandler(cliAuthorizer));
@@ -125,6 +129,7 @@ class HostingServer {
125
129
  if (jobRunSchedules) internalRouter.registerApi("job-run-schedules", new JobRunSchedulesHandler(jobRunSchedules));
126
130
  if (notifier) internalRouter.registerApi("notifications", new NotificationsHandler(notifier));
127
131
  if (entitlements) internalRouter.registerApi("entitlements", new EntitlementsHandler(entitlements));
132
+ if (prompts) internalRouter.registerApi("prompts", prompts);
128
133
 
129
134
  // The favicon is requested by the browser before anyone has signed in,
130
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 }
package/src/index.ts CHANGED
@@ -25,6 +25,8 @@ export * from "./auth/MembershipService";
25
25
  export * from "./auth/HttpMembershipService";
26
26
  export * from "./auth/InMemoryMembershipService";
27
27
  export * from "./hosting/handlers/auth/InvitationsHandler";
28
+ export * from "./data-store/PostgresPromptManager";
29
+ export * from "./hosting/handlers/PromptsHandler";
28
30
  export * from "./app-management/DockerBuildLayer";
29
31
  export * from "./app-management/FargateBuildLayer";
30
32
  export * from "./app-admin/AdminServer";
package/src/main.ts CHANGED
@@ -23,6 +23,7 @@ import {PostgresAppDocsStore} from "./data-store/PostgresAppDocsStore";
23
23
  import {PostgresEntitlementStore} from "./data-store/PostgresEntitlementStore";
24
24
  import {PostgresUserDirectory} from "./data-store/PostgresUserDirectory";
25
25
  import {HttpMembershipService} from "./auth/HttpMembershipService";
26
+ import {PostgresPromptManager} from "./data-store/PostgresPromptManager";
26
27
  import {ConsumerRegistry} from "./queuing/ConsumerRegistry";
27
28
  import {Dispatcher} from "./queuing/Dispatcher";
28
29
  import {HostingServer} from "./hosting/HostingServer";
@@ -104,7 +105,8 @@ const server = new HostingServer(new PostgresJobPersistence(pool), queue, regist
104
105
  (appId, collection) => new PostgresJsonStore(pool, appId, collection), secretStoreFor, authenticator, cliAuthorizer,
105
106
  tokenAuthenticator, process.env.ANBARIC_TENANT, new PostgresAuditRecordStore(pool), plugins,
106
107
  new PostgresJobRunSchedulePersistence(pool), await loadNotifier(process.env.ANBARIC_NOTIFIER_MODULE),
107
- new PostgresEntitlementStore(pool), new PostgresUserDirectory(pool), memberships);
108
+ new PostgresEntitlementStore(pool), new PostgresUserDirectory(pool), memberships,
109
+ (appId) => new PostgresPromptManager(pool, appId));
108
110
  const port = await server.listen(hostingPort);
109
111
  const internal = await server.listenInternal(internalPort);
110
112