anbaric-cloud-hosting 1.18.1 → 1.20.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.18.1",
3
+ "version": "1.20.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,17 +18,17 @@
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.18.1",
22
- "anbaric-plugins": "^1.18.1",
23
- "anbaric-tsapi": "^1.18.1",
21
+ "anbaric-data-store": "^1.20.0",
22
+ "anbaric-plugins": "^1.20.0",
23
+ "anbaric-tsapi": "^1.20.0",
24
24
  "esbuild": "^0.28.2",
25
25
  "pg": "^8.16.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^26.2.0",
29
29
  "@types/pg": "^8.15.0",
30
- "anbaric-impl-cloud": "^1.18.1",
31
- "anbaric-state-machine": "^1.18.1",
30
+ "anbaric-impl-cloud": "^1.20.0",
31
+ "anbaric-state-machine": "^1.20.0",
32
32
  "tsx": "^4.20.0",
33
33
  "typescript": "^7.0.2"
34
34
  },
@@ -89,6 +89,7 @@ class DockerBuildLayer extends BaseBuildLayer {
89
89
  "--env", "ANBARIC_JSON_STORE_TYPE=cloud",
90
90
  "--env", "ANBARIC_SECRET_STORE_TYPE=cloud",
91
91
  "--env", "ANBARIC_AUDITOR_TYPE=cloud",
92
+ "--env", "ANBARIC_SESSION_RESOLVER_TYPE=cloud",
92
93
  "--env", `ANBARIC_CONSUMER_PORT=${deployment.consumerPort}`,
93
94
  "--env", `ANBARIC_CONSUMER_URL=http://${container}:${deployment.consumerPort}`,
94
95
  ...sqlEnv,
@@ -178,6 +178,7 @@ class FargateBuildLayer extends BaseBuildLayer {
178
178
  { name: "ANBARIC_JSON_STORE_TYPE", value: "cloud" },
179
179
  { name: "ANBARIC_SECRET_STORE_TYPE", value: "cloud" },
180
180
  { name: "ANBARIC_AUDITOR_TYPE", value: "cloud" },
181
+ { name: "ANBARIC_SESSION_RESOLVER_TYPE", value: "cloud" },
181
182
  { name: "ANBARIC_CONSUMER_PORT", value: String(deployment.consumerPort) },
182
183
  { name: "ANBARIC_CONSUMER_URL", value: `http://${deployment.appHost}:${deployment.consumerPort}` },
183
184
  ...(this.options.servicesUrl ? [{ name: "ANBARIC_SERVICES_URL", value: this.options.servicesUrl }] : []),
@@ -4,7 +4,7 @@ import {AuditFilter, AuditRecordStore} from "../auditing/AuditRecordStore";
4
4
 
5
5
  /* The writes worth keeping in the durable trail; reads (READ, LIST, QUERY) are
6
6
  dropped by default. Interactions are plain strings chosen by each store. */
7
- const DEFAULT_WRITE_MASK : Array<string> = ["INITIALIZE", "CREATE", "SAVE", "UPDATE_PROPERTIES", "CHANGE_STATE", "DELETE", "EXECUTE", "KILL"];
7
+ const DEFAULT_WRITE_MASK : Array<string> = ["INITIALIZE", "CREATE", "SAVE", "UPDATE_PROPERTIES", "CHANGE_STATE", "AWAIT", "DELETE", "EXECUTE", "KILL"];
8
8
 
9
9
  /* Persists audit records, subject to a write mask: only the interactions in
10
10
  the mask are stored, so high-volume reads can be audited at the call site
@@ -1,4 +1,4 @@
1
- import {Auditor, Job, JobPersistence, NoOpAuditor, deserializeJob} from "anbaric-tsapi";
1
+ import {Auditor, Job, JobPersistence, NoOpAuditor, SerializedWaitForInput, deserializeJob, serializeWaitForInput} from "anbaric-tsapi";
2
2
  import {Pool} from "pg";
3
3
 
4
4
  type JobRow = {
@@ -10,9 +10,18 @@ type JobRow = {
10
10
  started_by : string,
11
11
  last_updated : Date,
12
12
  killed : boolean,
13
+ status : string,
14
+ waiting_for? : string,
15
+ await_metadata? : SerializedWaitForInput,
13
16
  };
14
17
 
15
- const JOB_COLUMNS = "id, state, properties, workflow_id, started_at, started_by, last_updated, killed";
18
+ /* The await a job is parked on is normalised into the awaits table: jobs
19
+ carry a waiting_for foreign key, the metadata lives once in awaits, and the
20
+ job's awaitMetadata is rejoined on read. */
21
+ const JOB_SELECT =
22
+ `SELECT j.id, j.state, j.properties, j.workflow_id, j.started_at, j.started_by, j.last_updated,
23
+ j.killed, j.status, j.waiting_for, a.metadata AS await_metadata
24
+ FROM jobs j LEFT JOIN awaits a ON a.id = j.waiting_for`;
16
25
 
17
26
  class PostgresJobPersistence extends JobPersistence {
18
27
 
@@ -21,21 +30,47 @@ class PostgresJobPersistence extends JobPersistence {
21
30
  }
22
31
 
23
32
  protected async saveInternal(job : Job) : Promise<void> {
24
- await this.pool.query(
25
- `INSERT INTO jobs (id, state, properties, workflow_id, started_at, started_by, last_updated, killed)
26
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
27
- ON CONFLICT (id) DO UPDATE SET state = EXCLUDED.state, properties = EXCLUDED.properties,
28
- workflow_id = EXCLUDED.workflow_id, last_updated = EXCLUDED.last_updated, killed = EXCLUDED.killed`,
29
- [job.id, job.state, Object.fromEntries(job.properties), job.workflowId,
30
- job.startedAt, job.startedBy, job.lastUpdated, job.killed],
31
- );
33
+ const client = await this.pool.connect();
34
+ try {
35
+ await client.query("BEGIN");
36
+
37
+ const previous = await client.query("SELECT waiting_for FROM jobs WHERE id = $1", [job.id]);
38
+ const previousAwait : string | null = previous.rows[0]?.waiting_for ?? null;
39
+
40
+ if (job.waitingFor) {
41
+ await client.query(
42
+ `INSERT INTO awaits (id, metadata) VALUES ($1, $2)
43
+ ON CONFLICT (id) DO UPDATE SET metadata = EXCLUDED.metadata`,
44
+ [job.waitingFor, job.awaitMetadata ? serializeWaitForInput(job.awaitMetadata) : {}],
45
+ );
46
+ }
47
+
48
+ await client.query(
49
+ `INSERT INTO jobs (id, state, properties, workflow_id, started_at, started_by, last_updated, killed, status, waiting_for)
50
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
51
+ ON CONFLICT (id) DO UPDATE SET state = EXCLUDED.state, properties = EXCLUDED.properties,
52
+ workflow_id = EXCLUDED.workflow_id, last_updated = EXCLUDED.last_updated, killed = EXCLUDED.killed,
53
+ status = EXCLUDED.status, waiting_for = EXCLUDED.waiting_for`,
54
+ [job.id, job.state, Object.fromEntries(job.properties), job.workflowId,
55
+ job.startedAt, job.startedBy, job.lastUpdated, job.killed, job.status, job.waitingFor ?? null],
56
+ );
57
+
58
+ // The await the job has left is no longer referenced - drop its metadata.
59
+ if (previousAwait && previousAwait !== job.waitingFor) {
60
+ await client.query("DELETE FROM awaits WHERE id = $1", [previousAwait]);
61
+ }
62
+
63
+ await client.query("COMMIT");
64
+ } catch (error) {
65
+ await client.query("ROLLBACK");
66
+ throw error;
67
+ } finally {
68
+ client.release();
69
+ }
32
70
  }
33
71
 
34
72
  protected async retrieveInternal(id : string) : Promise<Job> {
35
- const result = await this.pool.query(
36
- `SELECT ${JOB_COLUMNS} FROM jobs WHERE id = $1`,
37
- [id],
38
- );
73
+ const result = await this.pool.query(`${JOB_SELECT} WHERE j.id = $1`, [id]);
39
74
  if (result.rowCount === 0) throw new Error(`No job found with id "${id}"`);
40
75
 
41
76
  return this.deserializeRow(result.rows[0]);
@@ -47,7 +82,7 @@ class PostgresJobPersistence extends JobPersistence {
47
82
 
48
83
  protected async listInternal(pageSize : number = 100, page : number = 0) : Promise<Array<Job>> {
49
84
  const result = await this.pool.query(
50
- `SELECT ${JOB_COLUMNS} FROM jobs ORDER BY inserted_at LIMIT $1 OFFSET $2`,
85
+ `${JOB_SELECT} ORDER BY j.inserted_at LIMIT $1 OFFSET $2`,
51
86
  [pageSize, page * pageSize],
52
87
  );
53
88
 
@@ -81,6 +116,9 @@ class PostgresJobPersistence extends JobPersistence {
81
116
  startedBy: row.started_by,
82
117
  lastUpdated: row.last_updated.toISOString(),
83
118
  killed: row.killed,
119
+ status: row.status,
120
+ waitingFor: row.waiting_for ?? undefined,
121
+ awaitMetadata: row.await_metadata ?? undefined,
84
122
  });
85
123
  }
86
124
 
@@ -14,6 +14,7 @@ const ensureSchema = async (pool : Pool) : Promise<void> => {
14
14
  started_by TEXT NOT NULL DEFAULT 'system',
15
15
  last_updated TIMESTAMPTZ NOT NULL DEFAULT now(),
16
16
  transitions JSONB NOT NULL DEFAULT '[]',
17
+ status TEXT NOT NULL DEFAULT 'active',
17
18
  inserted_at BIGINT GENERATED ALWAYS AS IDENTITY
18
19
  )
19
20
  `);
@@ -23,6 +24,16 @@ const ensureSchema = async (pool : Pool) : Promise<void> => {
23
24
  await pool.query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS last_updated TIMESTAMPTZ NOT NULL DEFAULT now()");
24
25
  await pool.query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS transitions JSONB NOT NULL DEFAULT '[]'");
25
26
  await pool.query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS killed BOOLEAN NOT NULL DEFAULT false");
27
+ await pool.query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active'");
28
+ // The await a job is parked on; its metadata is normalised here rather than on the job.
29
+ await pool.query(`
30
+ CREATE TABLE IF NOT EXISTS awaits (
31
+ id UUID PRIMARY KEY,
32
+ metadata JSONB NOT NULL DEFAULT '{}',
33
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
34
+ )
35
+ `);
36
+ await pool.query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS waiting_for UUID REFERENCES awaits(id) ON DELETE SET NULL");
26
37
  await pool.query(`
27
38
  CREATE TABLE IF NOT EXISTS documents (
28
39
  collection TEXT NOT NULL,
@@ -11,6 +11,7 @@ import {AppsHandler} from "./handlers/AppsHandler";
11
11
  import {AuditsHandler} from "./handlers/AuditsHandler";
12
12
  import {AuthorizeCliHandler} from "./handlers/auth/AuthorizeCliHandler";
13
13
  import {KeysHandler} from "./handlers/auth/KeysHandler";
14
+ import {SessionsHandler} from "./handlers/auth/SessionsHandler";
14
15
  import {WhoamiHandler} from "./handlers/auth/WhoamiHandler";
15
16
  import {ConsumersHandler} from "./handlers/ConsumersHandler";
16
17
  import {DocumentsHandler} from "./handlers/DocumentsHandler";
@@ -60,12 +61,14 @@ class HostingServer {
60
61
  const documents = documentStoreFor && new DocumentsHandler(documentStoreFor);
61
62
  const secrets = secretStore && new SecretsHandler(secretStore);
62
63
  const audits = auditRecords && new AuditsHandler(auditRecords);
64
+ const sessions = new SessionsHandler();
63
65
 
64
66
  const publicRouter = new Router();
65
67
  publicRouter.registerRoot(pages);
66
68
  publicRouter.register("ping", ping);
67
69
  if (plugins.length > 0) publicRouter.registerApi("plugins", new PluginsHandler(plugins));
68
70
  publicRouter.registerApi("whoami", new WhoamiHandler());
71
+ publicRouter.registerApi("sessions", sessions);
69
72
  publicRouter.registerApi("jobs", jobs);
70
73
  publicRouter.registerApi("queue", queueHandler);
71
74
  publicRouter.registerApi("consumers", consumers);
@@ -84,6 +87,7 @@ class HostingServer {
84
87
 
85
88
  const internalRouter = new Router();
86
89
  internalRouter.register("ping", ping);
90
+ internalRouter.registerApi("sessions", sessions);
87
91
  internalRouter.registerApi("jobs", jobs);
88
92
  internalRouter.registerApi("queue", queueHandler);
89
93
  internalRouter.registerApi("consumers", consumers);
@@ -113,7 +117,7 @@ class HostingServer {
113
117
 
114
118
  async close() : Promise<void> {
115
119
  if (this.internalServer.listening) await this.internalServer.close();
116
- await this.publicServer.close();
120
+ if (this.publicServer.listening) await this.publicServer.close();
117
121
  }
118
122
 
119
123
  }
@@ -0,0 +1,35 @@
1
+ import {SessionSigner} from "../../../auth/SessionSigner";
2
+ import {Request} from "../../Request";
3
+ import {RequestHandler} from "../../RequestHandler";
4
+
5
+ /* Resolves a platform session token into the user's identity, for apps that
6
+ cannot verify it themselves (the signing secret is platform only). The token
7
+ travels in the body - it is the credential - so this needs no session
8
+ middleware and is safe on the internal, app-facing router. */
9
+ class SessionsHandler implements RequestHandler {
10
+
11
+ constructor(private sessionSigner : SessionSigner = new SessionSigner()) {}
12
+
13
+ async handle(request : Request) : Promise<void> {
14
+ if (request.method === "POST" && request.id === "resolve" && !request.subresource) {
15
+ return this.resolve(request);
16
+ }
17
+ request.notFound();
18
+ }
19
+
20
+ private async resolve(request : Request) : Promise<void> {
21
+ const body = await request.body() as { session? : string } | undefined;
22
+ const resolved = this.sessionSigner.verify(body?.session);
23
+ if (!resolved) return request.reply(401, { error: "Invalid or expired session" });
24
+
25
+ const [user, tenant] = resolved;
26
+ request.reply(200, {
27
+ id: user.id,
28
+ roles: user.roles.map(role => role.id),
29
+ tenant: tenant?.id,
30
+ });
31
+ }
32
+
33
+ }
34
+
35
+ export { SessionsHandler }