anbaric-cloud-hosting 1.20.0 → 1.21.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.20.0",
3
+ "version": "1.21.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,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.20.0",
22
- "anbaric-plugins": "^1.20.0",
23
- "anbaric-tsapi": "^1.20.0",
21
+ "anbaric-data-store": "^1.21.0",
22
+ "anbaric-plugins": "^1.21.0",
23
+ "anbaric-tsapi": "^1.21.0",
24
+ "anbaric-web": "^1.21.0",
24
25
  "esbuild": "^0.28.2",
25
26
  "pg": "^8.16.0"
26
27
  },
27
28
  "devDependencies": {
28
29
  "@types/node": "^26.2.0",
29
30
  "@types/pg": "^8.15.0",
30
- "anbaric-impl-cloud": "^1.20.0",
31
- "anbaric-state-machine": "^1.20.0",
31
+ "anbaric-impl-cloud": "^1.21.0",
32
+ "anbaric-state-machine": "^1.21.0",
32
33
  "tsx": "^4.20.0",
33
34
  "typescript": "^7.0.2"
34
35
  },
@@ -1,6 +1,7 @@
1
1
  import {AuditRecord} from "anbaric-tsapi";
2
2
 
3
3
  type AuditFilter = {
4
+ appId? : string,
4
5
  resourceType? : string,
5
6
  resourceId? : string,
6
7
  actorId? : string,
@@ -15,6 +15,7 @@ class InMemoryAuditRecordStore implements AuditRecordStore {
15
15
  const page = filter.page ?? 0;
16
16
 
17
17
  return this.records
18
+ .filter(record => !filter.appId || record.appId === filter.appId)
18
19
  .filter(record => !filter.resourceType || record.resourceType === filter.resourceType)
19
20
  .filter(record => !filter.resourceId || record.resourceId === filter.resourceId)
20
21
  .filter(record => !filter.actorId || record.actorId === filter.actorId)
@@ -13,6 +13,14 @@ abstract class Authenticator {
13
13
  return true;
14
14
  }
15
15
 
16
+ // True for authenticators that answer an unauthenticated request with a
17
+ // redirect to an interactive login (an identity provider). The platform
18
+ // uses this to avoid discarding the body of a state-changing request that
19
+ // hits an expired session - it fails such a request cleanly instead.
20
+ redirectsToLoginOnFailure() : boolean {
21
+ return false;
22
+ }
23
+
16
24
  }
17
25
 
18
26
  export { Authenticator, SESSION_COOKIE }
@@ -7,6 +7,11 @@ import {User} from "./User";
7
7
 
8
8
  const DEFAULT_TTL_SECONDS = 30 * 24 * 60 * 60;
9
9
 
10
+ const configuredTtlSeconds = () => {
11
+ const seconds = Number(process.env.ANBARIC_SESSION_TTL_SECONDS);
12
+ return Number.isFinite(seconds) && seconds > 0 ? seconds : DEFAULT_TTL_SECONDS;
13
+ };
14
+
10
15
  const nowSeconds = () => Math.floor(Date.now() / 1000);
11
16
 
12
17
  const base64url = (input : Buffer | string) => Buffer.from(input).toString("base64url");
@@ -18,11 +23,13 @@ const base64url = (input : Buffer | string) => Buffer.from(input).toString("base
18
23
  store. The signing secret lives in the environment
19
24
  (ANBARIC_SESSION_SIGNING_SECRET, sourced from a secret manager); with none
20
25
  set the signer is inert and the platform falls back to the authenticator.
21
- Sessions slide: a valid one is re-issued with a fresh expiry on each request. */
26
+ Sessions slide: a valid one is re-issued with a fresh expiry on each request.
27
+ The lifetime defaults to 30 days and can be tuned with
28
+ ANBARIC_SESSION_TTL_SECONDS. */
22
29
  class SessionSigner {
23
30
 
24
31
  constructor(private secret : string = process.env.ANBARIC_SESSION_SIGNING_SECRET ?? "",
25
- private ttlSeconds : number = DEFAULT_TTL_SECONDS) {}
32
+ private ttlSeconds : number = configuredTtlSeconds()) {}
26
33
 
27
34
  get configured() : boolean {
28
35
  return this.secret.length > 0;
@@ -29,10 +29,10 @@ class PostgresAuditRecordStore implements AuditRecordStore {
29
29
 
30
30
  await this.pool.query(
31
31
  `INSERT INTO anbaric_system.audit_records
32
- (resource_type, resource_id, actor_id, actor_type, interaction, description, details)
33
- VALUES ($1, $2, $3, $4, $5::text[], $6, $7)`,
34
- [record.resourceType, record.resourceId, record.actorId, record.actorType, record.interaction,
35
- record.description, JSON.stringify(record.details ?? null)],
32
+ (app_id, resource_type, resource_id, actor_id, actor_type, interaction, description, details)
33
+ VALUES ($1, $2, $3, $4, $5, $6::text[], $7, $8)`,
34
+ [record.appId || null, record.resourceType, record.resourceId, record.actorId, record.actorType,
35
+ record.interaction, record.description, JSON.stringify(record.details ?? null)],
36
36
  );
37
37
  }
38
38
 
@@ -40,6 +40,10 @@ class PostgresAuditRecordStore implements AuditRecordStore {
40
40
  const conditions : Array<string> = [];
41
41
  const parameters : Array<any> = [];
42
42
 
43
+ if (filter.appId) {
44
+ parameters.push(filter.appId);
45
+ conditions.push(`app_id = $${parameters.length}`);
46
+ }
43
47
  if (filter.resourceType) {
44
48
  parameters.push(filter.resourceType);
45
49
  conditions.push(`resource_type = $${parameters.length}`);
@@ -68,7 +72,7 @@ class PostgresAuditRecordStore implements AuditRecordStore {
68
72
  const offset = `OFFSET $${parameters.length}`;
69
73
 
70
74
  const result = await this.pool.query(
71
- `SELECT id, resource_type, resource_id, actor_id, actor_type, interaction, description, details, at
75
+ `SELECT id, app_id, resource_type, resource_id, actor_id, actor_type, interaction, description, details, at
72
76
  FROM anbaric_system.audit_records ${where}
73
77
  ORDER BY at DESC, id DESC ${limit} ${offset}`,
74
78
  parameters,
@@ -76,6 +80,7 @@ class PostgresAuditRecordStore implements AuditRecordStore {
76
80
 
77
81
  return result.rows.map(row => ({
78
82
  id: String(row.id),
83
+ appId: row.app_id ?? undefined,
79
84
  resourceType: row.resource_type,
80
85
  resourceId: row.resource_id,
81
86
  actorId: row.actor_id,
@@ -6,6 +6,7 @@ type JobRow = {
6
6
  state : string,
7
7
  properties : Record<string, any>,
8
8
  workflow_id? : string,
9
+ app_id? : string,
9
10
  started_at : Date,
10
11
  started_by : string,
11
12
  last_updated : Date,
@@ -19,7 +20,7 @@ type JobRow = {
19
20
  carry a waiting_for foreign key, the metadata lives once in awaits, and the
20
21
  job's awaitMetadata is rejoined on read. */
21
22
  const JOB_SELECT =
22
- `SELECT j.id, j.state, j.properties, j.workflow_id, j.started_at, j.started_by, j.last_updated,
23
+ `SELECT j.id, j.state, j.properties, j.workflow_id, j.app_id, j.started_at, j.started_by, j.last_updated,
23
24
  j.killed, j.status, j.waiting_for, a.metadata AS await_metadata
24
25
  FROM jobs j LEFT JOIN awaits a ON a.id = j.waiting_for`;
25
26
 
@@ -46,12 +47,12 @@ class PostgresJobPersistence extends JobPersistence {
46
47
  }
47
48
 
48
49
  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)
50
+ `INSERT INTO jobs (id, state, properties, workflow_id, app_id, started_at, started_by, last_updated, killed, status, waiting_for)
51
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
51
52
  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,
53
+ workflow_id = EXCLUDED.workflow_id, app_id = EXCLUDED.app_id, last_updated = EXCLUDED.last_updated,
54
+ killed = EXCLUDED.killed, status = EXCLUDED.status, waiting_for = EXCLUDED.waiting_for`,
55
+ [job.id, job.state, Object.fromEntries(job.properties), job.workflowId, job.appId || null,
55
56
  job.startedAt, job.startedBy, job.lastUpdated, job.killed, job.status, job.waitingFor ?? null],
56
57
  );
57
58
 
@@ -112,6 +113,7 @@ class PostgresJobPersistence extends JobPersistence {
112
113
  return deserializeJob({
113
114
  ...row,
114
115
  workflowId: row.workflow_id ?? undefined,
116
+ appId: row.app_id ?? undefined,
115
117
  startedAt: row.started_at.toISOString(),
116
118
  startedBy: row.started_by,
117
119
  lastUpdated: row.last_updated.toISOString(),
@@ -1,24 +1,26 @@
1
1
  import {Auditor, JsonStore, NoOpAuditor} from "anbaric-tsapi";
2
2
  import {Pool} from "pg";
3
3
 
4
+ /* Documents are keyed by the composite (app_id, collection, id): the owning app,
5
+ the collection and the document id, so ids never collide across apps. */
4
6
  class PostgresJsonStore extends JsonStore {
5
7
 
6
- constructor(private pool : Pool, collection : string, auditor : Auditor = new NoOpAuditor()) {
8
+ constructor(private pool : Pool, private appId : string, collection : string, auditor : Auditor = new NoOpAuditor()) {
7
9
  super(auditor, collection);
8
10
  }
9
11
 
10
12
  protected async saveInternal(id : string, document : any) : Promise<void> {
11
13
  await this.pool.query(
12
- `INSERT INTO documents (collection, id, document) VALUES ($1, $2, $3)
13
- ON CONFLICT (collection, id) DO UPDATE SET document = EXCLUDED.document`,
14
- [this.collection, id, document],
14
+ `INSERT INTO documents (app_id, collection, id, document) VALUES ($1, $2, $3, $4)
15
+ ON CONFLICT (app_id, collection, id) DO UPDATE SET document = EXCLUDED.document`,
16
+ [this.appId, this.collection, id, document],
15
17
  );
16
18
  }
17
19
 
18
20
  protected async retrieveInternal(id : string) : Promise<any> {
19
21
  const result = await this.pool.query(
20
- "SELECT document FROM documents WHERE collection = $1 AND id = $2",
21
- [this.collection, id],
22
+ "SELECT document FROM documents WHERE app_id = $1 AND collection = $2 AND id = $3",
23
+ [this.appId, this.collection, id],
22
24
  );
23
25
  if (result.rowCount === 0) throw new Error(`No document found with id "${id}"`);
24
26
 
@@ -26,13 +28,14 @@ class PostgresJsonStore extends JsonStore {
26
28
  }
27
29
 
28
30
  protected async deleteInternal(id : string) : Promise<void> {
29
- await this.pool.query("DELETE FROM documents WHERE collection = $1 AND id = $2", [this.collection, id]);
31
+ await this.pool.query("DELETE FROM documents WHERE app_id = $1 AND collection = $2 AND id = $3",
32
+ [this.appId, this.collection, id]);
30
33
  }
31
34
 
32
35
  protected async listInternal(pageSize : number = 100, page : number = 0) : Promise<Array<any>> {
33
36
  const result = await this.pool.query(
34
- "SELECT document FROM documents WHERE collection = $1 ORDER BY inserted_at LIMIT $2 OFFSET $3",
35
- [this.collection, pageSize, page * pageSize],
37
+ "SELECT document FROM documents WHERE app_id = $1 AND collection = $2 ORDER BY inserted_at LIMIT $3 OFFSET $4",
38
+ [this.appId, this.collection, pageSize, page * pageSize],
36
39
  );
37
40
 
38
41
  return result.rows.map(row => row.document);
@@ -25,6 +25,14 @@ const ensureSchema = async (pool : Pool) : Promise<void> => {
25
25
  await pool.query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS transitions JSONB NOT NULL DEFAULT '[]'");
26
26
  await pool.query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS killed BOOLEAN NOT NULL DEFAULT false");
27
27
  await pool.query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active'");
28
+ // A workflow is keyed by the composite (app_id, workflow_id). Older rows
29
+ // stored "appId/workflowId" in workflow_id; split that once into the two
30
+ // columns (best effort - a bare workflow id with no app is left as-is).
31
+ await pool.query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS app_id TEXT");
32
+ await pool.query(`UPDATE jobs
33
+ SET app_id = split_part(workflow_id, '/', 1),
34
+ workflow_id = substring(workflow_id from position('/' in workflow_id) + 1)
35
+ WHERE app_id IS NULL AND workflow_id LIKE '%/%'`);
28
36
  // The await a job is parked on; its metadata is normalised here rather than on the job.
29
37
  await pool.query(`
30
38
  CREATE TABLE IF NOT EXISTS awaits (
@@ -36,13 +44,24 @@ const ensureSchema = async (pool : Pool) : Promise<void> => {
36
44
  await pool.query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS waiting_for UUID REFERENCES awaits(id) ON DELETE SET NULL");
37
45
  await pool.query(`
38
46
  CREATE TABLE IF NOT EXISTS documents (
47
+ app_id TEXT NOT NULL DEFAULT '',
39
48
  collection TEXT NOT NULL,
40
49
  id TEXT NOT NULL,
41
50
  document JSONB NOT NULL,
42
51
  inserted_at BIGINT GENERATED ALWAYS AS IDENTITY,
43
- PRIMARY KEY (collection, id)
52
+ PRIMARY KEY (app_id, collection, id)
44
53
  )
45
54
  `);
55
+ // Documents are owned by an app: widen the key to (app_id, collection, id).
56
+ await pool.query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS app_id TEXT NOT NULL DEFAULT ''");
57
+ await pool.query(`DO $$ BEGIN
58
+ IF NOT EXISTS (SELECT 1 FROM information_schema.key_column_usage
59
+ WHERE table_name = 'documents' AND constraint_name = 'documents_pkey'
60
+ AND column_name = 'app_id') THEN
61
+ ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_pkey;
62
+ ALTER TABLE documents ADD PRIMARY KEY (app_id, collection, id);
63
+ END IF;
64
+ END $$`);
46
65
  await pool.query(`
47
66
  CREATE TABLE IF NOT EXISTS queue (
48
67
  position BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
@@ -52,6 +71,11 @@ const ensureSchema = async (pool : Pool) : Promise<void> => {
52
71
  leased_until TIMESTAMPTZ
53
72
  )
54
73
  `);
74
+ await pool.query("ALTER TABLE queue ADD COLUMN IF NOT EXISTS app_id TEXT");
75
+ await pool.query(`UPDATE queue
76
+ SET app_id = split_part(workflow_id, '/', 1),
77
+ workflow_id = substring(workflow_id from position('/' in workflow_id) + 1)
78
+ WHERE app_id IS NULL AND workflow_id LIKE '%/%'`);
55
79
  await pool.query("CREATE SCHEMA IF NOT EXISTS anbaric_system");
56
80
  await pool.query(`
57
81
  CREATE TABLE IF NOT EXISTS anbaric_system.cli_keys (
@@ -86,6 +110,8 @@ const ensureSchema = async (pool : Pool) : Promise<void> => {
86
110
  `);
87
111
  await pool.query("ALTER TABLE anbaric_system.audit_records ADD COLUMN IF NOT EXISTS resource_type TEXT");
88
112
  await pool.query("ALTER TABLE anbaric_system.audit_records ADD COLUMN IF NOT EXISTS resource_id TEXT");
113
+ // The app that owns the audited resource; part of every resource's composite identity.
114
+ await pool.query("ALTER TABLE anbaric_system.audit_records ADD COLUMN IF NOT EXISTS app_id TEXT");
89
115
  await pool.query("ALTER TABLE anbaric_system.audit_records ADD COLUMN IF NOT EXISTS interaction TEXT[]");
90
116
  await pool.query(`
91
117
  DO $$ BEGIN
@@ -7,6 +7,7 @@ import {TokenAuthenticator} from "../auth/TokenAuthenticator";
7
7
  import {ConfirmableQueue} from "../queuing/ConfirmableQueue";
8
8
  import {ConsumerRegistry} from "../queuing/ConsumerRegistry";
9
9
  import {AppProxyHandler} from "./handlers/AppProxyHandler";
10
+ import {AppLinkFallbackHandler} from "./handlers/AppLinkFallbackHandler";
10
11
  import {AppsHandler} from "./handlers/AppsHandler";
11
12
  import {AuditsHandler} from "./handlers/AuditsHandler";
12
13
  import {AuthorizeCliHandler} from "./handlers/auth/AuthorizeCliHandler";
@@ -44,8 +45,8 @@ class HostingServer {
44
45
  constructor(persistence : JobPersistence, queue : ConfirmableQueue,
45
46
  registry : ConsumerRegistry = new ConsumerRegistry(),
46
47
  buildLayer? : BuildLayer,
47
- documentStoreFor? : (collection : string) => JsonStore,
48
- secretStore? : SecretStore,
48
+ documentStoreFor? : (appId : string, collection : string) => JsonStore,
49
+ secretStoreFor? : (appId : string) => SecretStore,
49
50
  authenticator? : Authenticator,
50
51
  cliAuthorizer? : CliAuthorizer,
51
52
  tokenAuthenticator? : TokenAuthenticator,
@@ -59,7 +60,7 @@ class HostingServer {
59
60
  const consumers = new ConsumersHandler(registry);
60
61
  const stateMachines = new StateMachinesHandler(registry);
61
62
  const documents = documentStoreFor && new DocumentsHandler(documentStoreFor);
62
- const secrets = secretStore && new SecretsHandler(secretStore);
63
+ const secrets = secretStoreFor && new SecretsHandler(secretStoreFor);
63
64
  const audits = auditRecords && new AuditsHandler(auditRecords);
64
65
  const sessions = new SessionsHandler();
65
66
 
@@ -84,6 +85,9 @@ class HostingServer {
84
85
  publicRouter.registerApi("apps", new AppsHandler(buildLayer));
85
86
  publicRouter.register("app", new AppProxyHandler(buildLayer));
86
87
  }
88
+ // An unrouted absolute path carrying an app Referer is an app-internal
89
+ // link the proxy's prefix-stripping left bare; send it back to its app.
90
+ publicRouter.registerFallback(new AppLinkFallbackHandler());
87
91
 
88
92
  const internalRouter = new Router();
89
93
  internalRouter.register("ping", ping);
@@ -67,6 +67,12 @@ class Request {
67
67
  return value === undefined ? undefined : String(value);
68
68
  }
69
69
 
70
+ // The calling app's id, sent as an ambient header by the cloud clients, used
71
+ // to scope app-owned resources (documents, secrets) server-side.
72
+ get appId() : string | undefined {
73
+ return this.header("x-anbaric-app");
74
+ }
75
+
70
76
  query(name : string) : string | undefined {
71
77
  return this.parsed.searchParams.get(name) ?? undefined;
72
78
  }
@@ -105,6 +111,11 @@ class Request {
105
111
  this.response.end(script);
106
112
  }
107
113
 
114
+ redirect(location : string, status : number = 302, headers : Record<string, string> = {}) : void {
115
+ this.response.writeHead(status, { location, ...headers });
116
+ this.response.end();
117
+ }
118
+
108
119
  notFound() : void {
109
120
  this.reply(404, { error: "Not found" });
110
121
  }
@@ -0,0 +1,26 @@
1
+ import {appInternalRedirect} from "anbaric-web";
2
+ import {Request} from "../Request";
3
+ import {RequestHandler} from "../RequestHandler";
4
+
5
+ /* The public router's fallback for an otherwise-unrouted path. When the request
6
+ is an app-internal absolute link (identified by its Referer), it is redirected
7
+ back onto its app; anything else is a genuine 404. A 307 is used so a form
8
+ POST to an absolute action keeps its method and body across the redirect.
9
+
10
+ The redirect must never be cached: the same root path (e.g. "/thing") maps to
11
+ different apps depending on the Referer, so a stored mapping would send one
12
+ app's request to another. `no-store` forbids caching outright and
13
+ `Vary: Referer` states the dependency for any cache that stores it anyway. */
14
+ class AppLinkFallbackHandler implements RequestHandler {
15
+
16
+ async handle(request : Request) : Promise<void> {
17
+ const target = appInternalRedirect(request.url.pathname, request.url.search, request.header("referer"));
18
+ if (target !== undefined) {
19
+ return request.redirect(target, 307, { "cache-control": "no-store", "vary": "Referer" });
20
+ }
21
+ request.notFound();
22
+ }
23
+
24
+ }
25
+
26
+ export { AppLinkFallbackHandler };
@@ -34,6 +34,7 @@ class AuditsHandler implements RequestHandler {
34
34
  private async handleQuery(request : Request) : Promise<void> {
35
35
  const interaction = request.query("interaction");
36
36
  const records = await this.auditRecords.list({
37
+ appId: request.query("appId"),
37
38
  resourceType: request.query("resourceType"),
38
39
  resourceId: request.query("resourceId"),
39
40
  actorId: request.query("actorId"),
@@ -11,8 +11,8 @@ class ConsumersHandler implements RequestHandler {
11
11
 
12
12
  switch (request.method) {
13
13
  case "POST": {
14
- const { workflowId, url } = await request.body();
15
- this.registry.register(workflowId, url);
14
+ const { appId, workflowId, url } = await request.body();
15
+ this.registry.register(appId, workflowId, url);
16
16
  return request.reply(204);
17
17
  }
18
18
  }
@@ -4,12 +4,14 @@ import {RequestHandler} from "../RequestHandler";
4
4
 
5
5
  class DocumentsHandler implements RequestHandler {
6
6
 
7
- constructor(private storeFor : (collection : string) => JsonStore) {}
7
+ constructor(private storeFor : (appId : string, collection : string) => JsonStore) {}
8
8
 
9
9
  async handle(request : Request) : Promise<void> {
10
10
  if (!request.id) return request.notFound();
11
11
 
12
- const store = this.storeFor(request.id);
12
+ // Documents are owned by the calling app (the ambient app header); the
13
+ // store is scoped to (appId, collection) so ids never collide across apps.
14
+ const store = this.storeFor(request.appId ?? "", request.id);
13
15
  if (request.subresource) return this.handleDocument(request, store, request.subresource);
14
16
  return this.handleCollection(request, store);
15
17
  }
@@ -17,20 +17,20 @@ class QueueHandler implements RequestHandler {
17
17
 
18
18
  switch (request.id) {
19
19
  case "enqueue": {
20
- const { jobId, workflowId } = await request.body();
21
- await this.queue.enqueue(jobId, workflowId);
20
+ const { jobId, appId, workflowId } = await request.body();
21
+ await this.queue.enqueue(jobId, appId, workflowId);
22
22
  return request.reply(204);
23
23
  }
24
24
  case "schedule": {
25
- const { jobId, workflowId, due } = await request.body();
26
- await this.queue.schedule(jobId, workflowId, new Date(due));
25
+ const { jobId, appId, workflowId, due } = await request.body();
26
+ await this.queue.schedule(jobId, appId, workflowId, new Date(due));
27
27
  return request.reply(204);
28
28
  }
29
29
  case "dequeue":
30
30
  return request.reply(200, { messages: await this.queue.dequeueSome() });
31
31
  case "confirm": {
32
- const { jobId, workflowId, position } = await request.body();
33
- await this.queue.confirm({ jobId, workflowId, position });
32
+ const { jobId, appId, workflowId, position } = await request.body();
33
+ await this.queue.confirm({ jobId, appId, workflowId, position });
34
34
  return request.reply(204);
35
35
  }
36
36
  }
@@ -4,35 +4,37 @@ import {RequestHandler} from "../RequestHandler";
4
4
 
5
5
  class SecretsHandler implements RequestHandler {
6
6
 
7
- constructor(private secretStore : SecretStore) {}
7
+ constructor(private storeFor : (appId : string) => SecretStore) {}
8
8
 
9
9
  async handle(request : Request) : Promise<void> {
10
10
  if (request.subresource) return request.notFound();
11
- if (request.id) return this.handleSecret(request, request.id);
12
- return this.handleCollection(request);
11
+ // Secrets are owned by the calling app (the ambient app header).
12
+ const store = this.storeFor(request.appId ?? "");
13
+ if (request.id) return this.handleSecret(request, store, request.id);
14
+ return this.handleCollection(request, store);
13
15
  }
14
16
 
15
- private async handleSecret(request : Request, name : string) : Promise<void> {
17
+ private async handleSecret(request : Request, store : SecretStore, name : string) : Promise<void> {
16
18
  switch (request.method) {
17
19
  case "PUT": {
18
20
  const { value } = await request.body();
19
21
  if (typeof value !== "string") return request.reply(400, { error: "Expected a body of { value : string }" });
20
- await this.secretStore.create(SystemActor.actor, name, value);
22
+ await store.create(SystemActor.actor, name, value);
21
23
  return request.reply(204);
22
24
  }
23
25
  case "GET":
24
- return request.reply(200, { value: await this.secretStore.retrieve(name, SystemActor.actor) });
26
+ return request.reply(200, { value: await store.retrieve(name, SystemActor.actor) });
25
27
  case "DELETE":
26
- await this.secretStore.delete(name, SystemActor.actor);
28
+ await store.delete(name, SystemActor.actor);
27
29
  return request.reply(204);
28
30
  }
29
31
  request.notFound();
30
32
  }
31
33
 
32
- private async handleCollection(request : Request) : Promise<void> {
34
+ private async handleCollection(request : Request, store : SecretStore) : Promise<void> {
33
35
  switch (request.method) {
34
36
  case "GET":
35
- return request.reply(200, await this.secretStore.list(SystemActor.actor));
37
+ return request.reply(200, await store.list(SystemActor.actor));
36
38
  }
37
39
  request.notFound();
38
40
  }
@@ -39,6 +39,15 @@ class AuthenticationMiddleware implements Middleware {
39
39
  }
40
40
  }
41
41
 
42
+ // A state-changing request with no valid session, bound for an
43
+ // authenticator that redirects to an interactive login, would have its
44
+ // body silently discarded across that redirect. Fail it loudly instead
45
+ // so the client can re-authenticate (a fresh GET) and retry.
46
+ if (this.isStateChanging(request.method) && this.authenticator?.redirectsToLoginOnFailure() && !request.handled) {
47
+ request.reply(401, { error: "Your session has expired or you are not signed in. Reload the page and try again." });
48
+ return false;
49
+ }
50
+
42
51
  if (!this.authenticator) return true;
43
52
 
44
53
  const authenticated = await this.authenticator.authenticate(request.session, request.raw, request.rawResponse);
@@ -51,6 +60,10 @@ class AuthenticationMiddleware implements Middleware {
51
60
  return this.authorized(request);
52
61
  }
53
62
 
63
+ private isStateChanging(method : string) : boolean {
64
+ return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
65
+ }
66
+
54
67
  private async authorized(request : Request) : Promise<boolean> {
55
68
  if (!this.authenticator || !request.user) return true;
56
69
 
package/src/main.ts CHANGED
@@ -61,9 +61,15 @@ const buildLayer = process.env.ANBARIC_BUILD_LAYER === "docker"
61
61
  })
62
62
  : undefined;
63
63
 
64
- const secretStore : SecretStore = process.env.AWS_REGION
65
- ? new SecretsManagerSecretStore(new SecretsManagerClient({}))
66
- : new InMemorySecretStore();
64
+ // Secrets are owned by an app: each app gets its own namespace. AWS-backed
65
+ // stores fold the app into their name prefix (stateless, built per request);
66
+ // the in-memory local store is cached per app so its data persists.
67
+ const secretsManagerClient = process.env.AWS_REGION ? new SecretsManagerClient({}) : undefined;
68
+ const localSecretStores = new Map<string, InMemorySecretStore>();
69
+ const secretStoreFor = (appId : string) : SecretStore =>
70
+ secretsManagerClient
71
+ ? new SecretsManagerSecretStore(secretsManagerClient, `anbaric/${appId}/`)
72
+ : localSecretStores.get(appId) ?? localSecretStores.set(appId, new InMemorySecretStore()).get(appId)!;
67
73
 
68
74
  const authenticator = await loadAuthenticator(process.env.ANBARIC_AUTHENTICATOR);
69
75
  const cliKeyStore = process.env.ANBARIC_CLI_KEY_LOOKUP_URL
@@ -75,7 +81,7 @@ const tokenAuthenticator = new TokenAuthenticator(cliKeyStore, process.env.ANBAR
75
81
  const plugins = await new PluginLoader().load(process.env.ANBARIC_PLUGINS ?? "anbaric-plugins/state-machines");
76
82
 
77
83
  const server = new HostingServer(new PostgresJobPersistence(pool), queue, registry, buildLayer,
78
- (collection) => new PostgresJsonStore(pool, collection), secretStore, authenticator, cliAuthorizer,
84
+ (appId, collection) => new PostgresJsonStore(pool, appId, collection), secretStoreFor, authenticator, cliAuthorizer,
79
85
  tokenAuthenticator, process.env.ANBARIC_TENANT, new PostgresAuditRecordStore(pool), plugins);
80
86
  const port = await server.listen(hostingPort);
81
87
  const internal = await server.listenInternal(internalPort);
@@ -1,17 +1,23 @@
1
1
  class ConsumerRegistry {
2
2
 
3
- private consumers = new Map<string, string>();
3
+ private consumers = new Map<string, { appId? : string, workflowId : string, url : string }>();
4
4
 
5
- register(workflowId : string, url : string) : void {
6
- this.consumers.set(workflowId, url);
5
+ // Consumers are keyed by the (appId, workflowId) composite, encoded as a
6
+ // tuple so an app and a machine id can never collide.
7
+ private key(appId : string | undefined, workflowId : string) : string {
8
+ return JSON.stringify([appId || null, workflowId]);
7
9
  }
8
10
 
9
- lookup(workflowId : string) : string | undefined {
10
- return this.consumers.get(workflowId);
11
+ register(appId : string | undefined, workflowId : string, url : string) : void {
12
+ this.consumers.set(this.key(appId, workflowId), { appId, workflowId, url });
11
13
  }
12
14
 
13
- list() : Array<{ workflowId : string, url : string }> {
14
- return Array.from(this.consumers, ([workflowId, url]) => ({ workflowId, url }));
15
+ lookup(appId : string | undefined, workflowId : string) : string | undefined {
16
+ return this.consumers.get(this.key(appId, workflowId))?.url;
17
+ }
18
+
19
+ list() : Array<{ appId? : string, workflowId : string, url : string }> {
20
+ return Array.from(this.consumers.values());
15
21
  }
16
22
 
17
23
  }
@@ -28,9 +28,9 @@ class Dispatcher {
28
28
  const byConsumerUrl = new Map<string, Array<QueueMessage>>();
29
29
 
30
30
  for (const message of messages) {
31
- const url = this.registry.lookup(message.workflowId);
31
+ const url = this.registry.lookup(message.appId, message.workflowId);
32
32
  if (!url) {
33
- await this.queue.enqueue(message.jobId, message.workflowId);
33
+ await this.queue.enqueue(message.jobId, message.appId, message.workflowId);
34
34
  continue;
35
35
  }
36
36
  byConsumerUrl.set(url, [...(byConsumerUrl.get(url) ?? []), message]);
@@ -54,7 +54,7 @@ class Dispatcher {
54
54
  if (!response.ok) throw new Error(`Consumer at ${url} responded with status ${response.status}`);
55
55
  } catch {
56
56
  for (const message of batch) {
57
- await this.queue.enqueue(message.jobId, message.workflowId);
57
+ await this.queue.enqueue(message.jobId, message.appId, message.workflowId);
58
58
  }
59
59
  }
60
60
  }
@@ -9,12 +9,12 @@ class PostgresQueue implements ConfirmableQueue {
9
9
 
10
10
  constructor(private pool : Pool) {}
11
11
 
12
- async enqueue(jobId : string, workflowId : string) : Promise<void> {
13
- await this.pool.query("INSERT INTO queue (job_id, workflow_id) VALUES ($1, $2)", [jobId, workflowId]);
12
+ async enqueue(jobId : string, appId : string | undefined, workflowId : string) : Promise<void> {
13
+ await this.pool.query("INSERT INTO queue (job_id, app_id, workflow_id) VALUES ($1, $2, $3)", [jobId, appId || null, workflowId]);
14
14
  }
15
15
 
16
- async schedule(jobId : string, workflowId : string, due : Date) : Promise<void> {
17
- await this.pool.query("INSERT INTO queue (job_id, workflow_id, due) VALUES ($1, $2, $3)", [jobId, workflowId, due]);
16
+ async schedule(jobId : string, appId : string | undefined, workflowId : string, due : Date) : Promise<void> {
17
+ await this.pool.query("INSERT INTO queue (job_id, app_id, workflow_id, due) VALUES ($1, $2, $3, $4)", [jobId, appId || null, workflowId, due]);
18
18
  }
19
19
 
20
20
  async dequeueSome() : Promise<Array<QueueMessage>> {
@@ -28,7 +28,7 @@ class PostgresQueue implements ConfirmableQueue {
28
28
  LIMIT $1
29
29
  FOR UPDATE SKIP LOCKED
30
30
  )
31
- RETURNING job_id, workflow_id, due, position`,
31
+ RETURNING job_id, app_id, workflow_id, due, position`,
32
32
  [DEQUEUE_BATCH_SIZE],
33
33
  );
34
34
 
@@ -39,7 +39,7 @@ class PostgresQueue implements ConfirmableQueue {
39
39
  if (!b.due) return 1;
40
40
  return a.due.getTime() - b.due.getTime() || a.position - b.position;
41
41
  })
42
- .map(row => ({ jobId: row.job_id, workflowId: row.workflow_id, position: Number(row.position) }));
42
+ .map(row => ({ jobId: row.job_id, appId: row.app_id ?? undefined, workflowId: row.workflow_id, position: Number(row.position) }));
43
43
  }
44
44
 
45
45
  async confirm(message : QueueMessage) : Promise<void> {