anbaric-cloud-hosting 1.31.0 → 1.33.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.31.0",
3
+ "version": "1.33.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.31.0",
22
- "anbaric-plugins": "^1.31.0",
23
- "anbaric-tsapi": "^1.31.0",
24
- "anbaric-web": "^1.31.0",
21
+ "anbaric-data-store": "^1.33.0",
22
+ "anbaric-plugins": "^1.33.0",
23
+ "anbaric-tsapi": "^1.33.0",
24
+ "anbaric-web": "^1.33.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.31.0",
32
- "anbaric-state-machine": "^1.31.0",
31
+ "anbaric-impl-cloud": "^1.33.0",
32
+ "anbaric-state-machine": "^1.33.0",
33
33
  "tsx": "^4.20.0",
34
34
  "typescript": "^7.0.2"
35
35
  },
@@ -114,10 +114,28 @@ abstract class BaseBuildLayer implements BuildLayer {
114
114
  }));
115
115
  }
116
116
 
117
+ // Regenerates an app's docs from its uploaded source without redeploying it:
118
+ // the source is materialised (from S3 for Fargate, from disk for Docker) and
119
+ // the same generator that runs on deploy is invoked against it.
120
+ async regenerateDocs(appName : string) : Promise<number> {
121
+ if (!this.deployments.has(appName)) throw new Error(`No app named "${appName}"`);
122
+ if (!this.docGenerator) throw new Error("Documentation generation is not configured on this platform");
123
+
124
+ const { dir, cleanup } = await this.sourceDir(appName);
125
+ try {
126
+ return await this.docGenerator.generate(appName, dir, { redeploy: true });
127
+ } finally {
128
+ await cleanup();
129
+ }
130
+ }
131
+
117
132
  protected abstract appHostFor(appName : string) : string;
118
133
  protected abstract start(deployment : Deployment, appDir : string, entryPoint : string) : Promise<void>;
119
134
  protected abstract stop(deployment : Deployment) : Promise<void>;
120
135
  protected abstract streamLogs(deployment : Deployment, signal : AbortSignal) : AsyncIterable<string>;
136
+ // Materialises the app's source into a directory for regeneration; the
137
+ // returned cleanup removes anything temporary the implementation created.
138
+ protected abstract sourceDir(appName : string) : Promise<{ dir : string, cleanup : () => Promise<void> }>;
121
139
 
122
140
  private async buildAndStart(deployment : Deployment, tarball : Buffer) : Promise<void> {
123
141
  const appDir = join(this.appsDir, deployment.appName);
@@ -16,6 +16,9 @@ interface BuildLayer {
16
16
  ping(appName : string) : Promise<boolean>;
17
17
  logs(appName : string, signal : AbortSignal) : AsyncIterable<string>;
18
18
  teardown(appName : string) : Promise<boolean>;
19
+ // Re-runs documentation generation for an already-deployed app from its
20
+ // uploaded source, without redeploying it. Returns the number of docs written.
21
+ regenerateDocs(appName : string) : Promise<number>;
19
22
  cleanUp() : Promise<void>;
20
23
 
21
24
  }
@@ -107,6 +107,7 @@ class DockerBuildLayer extends BaseBuildLayer {
107
107
  "--env", "ANBARIC_SECRET_STORE_TYPE=cloud",
108
108
  "--env", "ANBARIC_AUDITOR_TYPE=cloud",
109
109
  "--env", "ANBARIC_SESSION_RESOLVER_TYPE=cloud",
110
+ "--env", "ANBARIC_ENTITLEMENTS_TYPE=cloud",
110
111
  "--env", "ANBARIC_NOTIFIER_TYPE=cloud",
111
112
  "--env", `ANBARIC_CONSUMER_PORT=${deployment.consumerPort}`,
112
113
  "--env", `ANBARIC_CONSUMER_URL=http://${container}:${deployment.consumerPort}`,
@@ -120,6 +121,12 @@ class DockerBuildLayer extends BaseBuildLayer {
120
121
  await this.removeContainer(deployment, this.appHostFor(deployment.appName));
121
122
  }
122
123
 
124
+ // Locally the extracted source from the last deploy is still on disk, so
125
+ // regeneration reads it in place with nothing to clean up.
126
+ protected async sourceDir(appName : string) : Promise<{ dir : string, cleanup : () => Promise<void> }> {
127
+ return { dir: join(this.appsDir, appName), cleanup: async () => {} };
128
+ }
129
+
123
130
  private async removeContainer(deployment : Deployment, container : string) : Promise<void> {
124
131
  await this.docker(deployment, ["rm", "--force", container]).catch(() => {});
125
132
  }
@@ -1,10 +1,12 @@
1
- import {readFile, writeFile} from "node:fs/promises";
1
+ import {spawn} from "node:child_process";
2
+ import {mkdir, mkdtemp, readFile, rm, writeFile} from "node:fs/promises";
3
+ import {tmpdir} from "node:os";
2
4
  import {join} from "node:path";
3
5
  import {CodeBuildClient, BatchGetBuildsCommand, StartBuildCommand} from "@aws-sdk/client-codebuild";
4
6
  import {ECSClient, CreateServiceCommand, DeleteServiceCommand, DescribeServicesCommand,
5
7
  DescribeTaskDefinitionCommand, ListServicesCommand as ListEcsServicesCommand,
6
8
  RegisterTaskDefinitionCommand, UpdateServiceCommand} from "@aws-sdk/client-ecs";
7
- import {S3Client, PutObjectCommand} from "@aws-sdk/client-s3";
9
+ import {S3Client, GetObjectCommand, PutObjectCommand} from "@aws-sdk/client-s3";
8
10
  import {ServiceDiscoveryClient, CreateServiceCommand as CreateDiscoveryServiceCommand,
9
11
  ListServicesCommand} from "@aws-sdk/client-servicediscovery";
10
12
  import {CloudWatchLogsClient, FilterLogEventsCommand, StartLiveTailCommand} from "@aws-sdk/client-cloudwatch-logs";
@@ -195,6 +197,7 @@ class FargateBuildLayer extends BaseBuildLayer {
195
197
  { name: "ANBARIC_SECRET_STORE_TYPE", value: "cloud" },
196
198
  { name: "ANBARIC_AUDITOR_TYPE", value: "cloud" },
197
199
  { name: "ANBARIC_SESSION_RESOLVER_TYPE", value: "cloud" },
200
+ { name: "ANBARIC_ENTITLEMENTS_TYPE", value: "cloud" },
198
201
  { name: "ANBARIC_NOTIFIER_TYPE", value: "cloud" },
199
202
  { name: "ANBARIC_CONSUMER_PORT", value: String(deployment.consumerPort) },
200
203
  { name: "ANBARIC_CONSUMER_URL", value: `http://${deployment.appHost}:${deployment.consumerPort}` },
@@ -344,7 +347,38 @@ class FargateBuildLayer extends BaseBuildLayer {
344
347
  return `builds/${appName}.tar.gz`;
345
348
  }
346
349
 
350
+ protected async sourceDir(appName : string) : Promise<{ dir : string, cleanup : () => Promise<void> }> {
351
+ const work = await mkdtemp(join(tmpdir(), `anbaric-docs-${appName}-`));
352
+ const cleanup = () => rm(work, { recursive: true, force: true });
353
+
354
+ try {
355
+ const object = await this.aws.s3.send(new GetObjectCommand({
356
+ Bucket: this.options.buildBucket,
357
+ Key: this.buildKeyFor(appName),
358
+ }));
359
+ const bytes = await object.Body.transformToByteArray();
360
+
361
+ const tarballPath = join(work, "source.tar.gz");
362
+ await writeFile(tarballPath, Buffer.from(bytes));
363
+ const dir = join(work, "src");
364
+ await mkdir(dir, { recursive: true });
365
+ await extractTarball(tarballPath, dir);
366
+
367
+ return { dir, cleanup };
368
+ } catch (error) {
369
+ await cleanup();
370
+ throw error;
371
+ }
372
+ }
373
+
347
374
  }
348
375
 
376
+ const extractTarball = (tarballPath : string, dir : string) : Promise<void> =>
377
+ new Promise((resolve, reject) => {
378
+ const tar = spawn("tar", ["-xzf", tarballPath, "-C", dir]);
379
+ tar.on("error", reject);
380
+ tar.on("close", code => code === 0 ? resolve() : reject(new Error(`tar exited with code ${code}`)));
381
+ });
382
+
349
383
  export { FargateBuildLayer };
350
384
  export type { AwsClients, FargateBuildLayerOptions };
@@ -0,0 +1,41 @@
1
+ import {Entitlements} from "anbaric-tsapi";
2
+
3
+ /* An appId of null means global: a global definition is one no app owns
4
+ (console-created), a global grant satisfies the check from any app. */
5
+ type EntitlementDefinition = {
6
+
7
+ appId : string | null,
8
+ entitlementId : string,
9
+ notes : string,
10
+
11
+ };
12
+
13
+ type EntitlementGrant = {
14
+
15
+ id : string,
16
+ userId : string,
17
+ appId : string | null,
18
+ entitlementId : string,
19
+ notes : string,
20
+ grantedAt : string,
21
+ grantedBy : string,
22
+
23
+ };
24
+
25
+ /* The platform's view of entitlements: what apps can do (register, has) plus
26
+ the management the console performs on their behalf. */
27
+ interface EntitlementStore extends Entitlements {
28
+
29
+ defineGlobal(entitlementId : string, notes : string) : Promise<void>;
30
+
31
+ listDefinitions() : Promise<Array<EntitlementDefinition>>;
32
+
33
+ grant(userId : string, appId : string | null, entitlementId : string, notes : string, grantedBy : string) : Promise<EntitlementGrant>;
34
+
35
+ revoke(grantId : string) : Promise<boolean>;
36
+
37
+ listGrants(userId? : string) : Promise<Array<EntitlementGrant>>;
38
+
39
+ }
40
+
41
+ export type { EntitlementStore, EntitlementDefinition, EntitlementGrant }
@@ -0,0 +1,55 @@
1
+ import {randomUUID} from "node:crypto";
2
+ import {EntitlementDefinition, EntitlementGrant, EntitlementStore} from "./EntitlementStore";
3
+
4
+ class InMemoryEntitlementStore implements EntitlementStore {
5
+
6
+ private definitions = new Map<string, EntitlementDefinition>();
7
+ private grants = new Map<string, EntitlementGrant>();
8
+
9
+ constructor() {
10
+ this.definitions.set(this.key(null, "access"), { appId: null, entitlementId: "access", notes: "Global access" });
11
+ }
12
+
13
+ async register(appId : string, entitlementId : string, notes : string = "") : Promise<void> {
14
+ this.definitions.set(this.key(appId, entitlementId), { appId, entitlementId, notes });
15
+ }
16
+
17
+ async has(appId : string, userId : string, entitlementId : string) : Promise<boolean> {
18
+ return Array.from(this.grants.values()).some(grant =>
19
+ grant.userId === userId && grant.entitlementId === entitlementId &&
20
+ (grant.appId === null || grant.appId === appId));
21
+ }
22
+
23
+ async defineGlobal(entitlementId : string, notes : string) : Promise<void> {
24
+ this.definitions.set(this.key(null, entitlementId), { appId: null, entitlementId, notes });
25
+ }
26
+
27
+ async listDefinitions() : Promise<Array<EntitlementDefinition>> {
28
+ return Array.from(this.definitions.values()).map(definition => ({ ...definition }));
29
+ }
30
+
31
+ async grant(userId : string, appId : string | null, entitlementId : string, notes : string, grantedBy : string) : Promise<EntitlementGrant> {
32
+ const grant : EntitlementGrant = {
33
+ id: randomUUID(), userId, appId, entitlementId, notes, grantedBy, grantedAt: new Date().toISOString(),
34
+ };
35
+ this.grants.set(grant.id, grant);
36
+ return { ...grant };
37
+ }
38
+
39
+ async revoke(grantId : string) : Promise<boolean> {
40
+ return this.grants.delete(grantId);
41
+ }
42
+
43
+ async listGrants(userId? : string) : Promise<Array<EntitlementGrant>> {
44
+ return Array.from(this.grants.values())
45
+ .filter(grant => userId === undefined || grant.userId === userId)
46
+ .map(grant => ({ ...grant }));
47
+ }
48
+
49
+ private key(appId : string | null, entitlementId : string) : string {
50
+ return `${appId ?? ""}/${entitlementId}`;
51
+ }
52
+
53
+ }
54
+
55
+ export { InMemoryEntitlementStore }
@@ -0,0 +1,81 @@
1
+ import {randomUUID} from "node:crypto";
2
+ import {Pool} from "pg";
3
+ import {EntitlementDefinition, EntitlementGrant, EntitlementStore} from "./EntitlementStore";
4
+
5
+ class PostgresEntitlementStore implements EntitlementStore {
6
+
7
+ constructor(private pool : Pool) {}
8
+
9
+ async register(appId : string, entitlementId : string, notes : string = "") : Promise<void> {
10
+ await this.upsertDefinition(appId, entitlementId, notes);
11
+ }
12
+
13
+ async has(appId : string, userId : string, entitlementId : string) : Promise<boolean> {
14
+ const result = await this.pool.query(
15
+ `SELECT 1 FROM entitlement_grants
16
+ WHERE user_id = $1 AND entitlement_id = $2 AND (app_id = $3 OR app_id IS NULL)
17
+ LIMIT 1`,
18
+ [userId, entitlementId, appId]);
19
+ return (result.rowCount ?? 0) > 0;
20
+ }
21
+
22
+ async defineGlobal(entitlementId : string, notes : string) : Promise<void> {
23
+ await this.upsertDefinition(null, entitlementId, notes);
24
+ }
25
+
26
+ async listDefinitions() : Promise<Array<EntitlementDefinition>> {
27
+ const result = await this.pool.query(
28
+ `SELECT app_id, entitlement_id, notes FROM entitlements
29
+ ORDER BY app_id NULLS FIRST, entitlement_id`);
30
+ return result.rows.map(row => ({ appId: row.app_id, entitlementId: row.entitlement_id, notes: row.notes }));
31
+ }
32
+
33
+ async grant(userId : string, appId : string | null, entitlementId : string, notes : string, grantedBy : string) : Promise<EntitlementGrant> {
34
+ const id = randomUUID();
35
+ const result = await this.pool.query(
36
+ `INSERT INTO entitlement_grants (id, user_id, app_id, entitlement_id, notes, granted_by)
37
+ VALUES ($1, $2, $3, $4, $5, $6)
38
+ RETURNING granted_at`,
39
+ [id, userId, appId, entitlementId, notes, grantedBy]);
40
+ return { id, userId, appId, entitlementId, notes, grantedBy, grantedAt: this.iso(result.rows[0]?.granted_at) };
41
+ }
42
+
43
+ async revoke(grantId : string) : Promise<boolean> {
44
+ const result = await this.pool.query("DELETE FROM entitlement_grants WHERE id = $1", [grantId]);
45
+ return (result.rowCount ?? 0) > 0;
46
+ }
47
+
48
+ async listGrants(userId? : string) : Promise<Array<EntitlementGrant>> {
49
+ const result = userId === undefined
50
+ ? await this.pool.query(
51
+ `SELECT id, user_id, app_id, entitlement_id, notes, granted_at, granted_by
52
+ FROM entitlement_grants ORDER BY granted_at DESC`)
53
+ : await this.pool.query(
54
+ `SELECT id, user_id, app_id, entitlement_id, notes, granted_at, granted_by
55
+ FROM entitlement_grants WHERE user_id = $1 ORDER BY granted_at DESC`, [userId]);
56
+ return result.rows.map(row => ({
57
+ id: row.id,
58
+ userId: row.user_id,
59
+ appId: row.app_id,
60
+ entitlementId: row.entitlement_id,
61
+ notes: row.notes,
62
+ grantedAt: this.iso(row.granted_at),
63
+ grantedBy: row.granted_by,
64
+ }));
65
+ }
66
+
67
+ private async upsertDefinition(appId : string | null, entitlementId : string, notes : string) : Promise<void> {
68
+ await this.pool.query(
69
+ `INSERT INTO entitlements (app_id, entitlement_id, notes)
70
+ VALUES ($1, $2, $3)
71
+ ON CONFLICT (COALESCE(app_id, ''), entitlement_id) DO UPDATE SET notes = EXCLUDED.notes`,
72
+ [appId, entitlementId, notes]);
73
+ }
74
+
75
+ private iso(value : unknown) : string {
76
+ return value instanceof Date ? value.toISOString() : String(value ?? new Date().toISOString());
77
+ }
78
+
79
+ }
80
+
81
+ export { PostgresEntitlementStore }
@@ -112,6 +112,38 @@ const ensureSchema = async (pool : Pool) : Promise<void> => {
112
112
  `);
113
113
  await pool.query("ALTER TABLE app_docs ADD COLUMN IF NOT EXISTS parent_slug TEXT");
114
114
  await pool.query("ALTER TABLE app_docs ADD COLUMN IF NOT EXISTS position INTEGER NOT NULL DEFAULT 0");
115
+ await pool.query(`
116
+ CREATE TABLE IF NOT EXISTS entitlements (
117
+ app_id TEXT,
118
+ entitlement_id TEXT NOT NULL,
119
+ notes TEXT NOT NULL DEFAULT '',
120
+ registered_at TIMESTAMPTZ NOT NULL DEFAULT now()
121
+ )
122
+ `);
123
+ await pool.query(`
124
+ CREATE UNIQUE INDEX IF NOT EXISTS entitlements_key
125
+ ON entitlements (COALESCE(app_id, ''), entitlement_id)
126
+ `);
127
+ await pool.query(`
128
+ CREATE TABLE IF NOT EXISTS entitlement_grants (
129
+ id TEXT PRIMARY KEY,
130
+ user_id TEXT NOT NULL,
131
+ app_id TEXT,
132
+ entitlement_id TEXT NOT NULL,
133
+ notes TEXT NOT NULL DEFAULT '',
134
+ granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
135
+ granted_by TEXT NOT NULL
136
+ )
137
+ `);
138
+ await pool.query(`
139
+ CREATE INDEX IF NOT EXISTS entitlement_grants_lookup
140
+ ON entitlement_grants (user_id, entitlement_id)
141
+ `);
142
+ await pool.query(`
143
+ INSERT INTO entitlements (app_id, entitlement_id, notes)
144
+ VALUES (NULL, 'access', 'Global access')
145
+ ON CONFLICT (COALESCE(app_id, ''), entitlement_id) DO NOTHING
146
+ `);
115
147
  await pool.query("CREATE SCHEMA IF NOT EXISTS anbaric_system");
116
148
  await pool.query(`
117
149
  CREATE TABLE IF NOT EXISTS anbaric_system.cli_keys (
@@ -75,20 +75,22 @@ class DocGenerator {
75
75
 
76
76
  constructor(private model : DocModel, private store : AppDocsStore) {}
77
77
 
78
- async generate(appName : string, appDir : string, options : { redeploy : boolean }) : Promise<void> {
78
+ async generate(appName : string, appDir : string, options : { redeploy : boolean }) : Promise<number> {
79
79
  try {
80
80
  const { files, truncated } = await gatherSources(appDir);
81
- if (files.length === 0) return;
81
+ if (files.length === 0) return 0;
82
82
 
83
83
  const drafts = await this.model.writeDocs({ appName, files, truncated });
84
84
  const readme = files.find(file => README.test(file.path))?.content;
85
85
  const docs = buildDocTree(drafts, readme);
86
- if (docs.length === 0) return;
86
+ if (docs.length === 0) return 0;
87
87
 
88
88
  await this.store.replaceForApp(appName, docs);
89
89
  console.log(`[docs] ${options.redeploy ? "regenerated" : "generated"} ${docs.length} doc(s) for "${appName}"`);
90
+ return docs.length;
90
91
  } catch (error) {
91
92
  console.error(`[docs] could not generate documentation for "${appName}": ${error instanceof Error ? error.message : error}`);
93
+ return 0;
92
94
  }
93
95
  }
94
96
 
@@ -16,6 +16,9 @@ import {SessionsHandler} from "./handlers/auth/SessionsHandler";
16
16
  import {WhoamiHandler} from "./handlers/auth/WhoamiHandler";
17
17
  import {ConsumersHandler} from "./handlers/ConsumersHandler";
18
18
  import {DocumentsHandler} from "./handlers/DocumentsHandler";
19
+ import {EntitlementsAdminHandler} from "./handlers/EntitlementsAdminHandler";
20
+ import {EntitlementsHandler} from "./handlers/EntitlementsHandler";
21
+ import {EntitlementStore} from "../data-store/EntitlementStore";
19
22
  import {JobsHandler} from "./handlers/JobsHandler";
20
23
  import {FaviconHandler} from "./handlers/FaviconHandler";
21
24
  import {JobRunSchedulesHandler} from "./handlers/JobRunSchedulesHandler";
@@ -58,7 +61,8 @@ class HostingServer {
58
61
  auditRecords? : AuditRecordStore,
59
62
  plugins : Array<LoadedPlugin> = [],
60
63
  jobRunSchedules? : JobRunSchedulePersistence,
61
- notifier? : Notifier) {
64
+ notifier? : Notifier,
65
+ entitlements? : EntitlementStore) {
62
66
  const pages = new PagesHandler();
63
67
  const ping = new PingHandler(tenant);
64
68
  const jobs = new JobsHandler(persistence);
@@ -87,6 +91,7 @@ class HostingServer {
87
91
  if (audits) publicRouter.registerApi("audits", audits);
88
92
  if (jobRunSchedules) publicRouter.registerApi("job-run-schedules", new JobRunSchedulesHandler(jobRunSchedules));
89
93
  if (notifier) publicRouter.registerApi("notifications", new NotificationsHandler(notifier));
94
+ if (entitlements) publicRouter.registerApi("entitlements", new EntitlementsAdminHandler(entitlements));
90
95
  if (cliAuthorizer) {
91
96
  publicRouter.register("authorize-cli", new AuthorizeCliHandler(cliAuthorizer, pages, tenant));
92
97
  publicRouter.registerApi("keys", new KeysHandler(cliAuthorizer));
@@ -111,6 +116,7 @@ class HostingServer {
111
116
  if (audits) internalRouter.registerApi("audits", audits);
112
117
  if (jobRunSchedules) internalRouter.registerApi("job-run-schedules", new JobRunSchedulesHandler(jobRunSchedules));
113
118
  if (notifier) internalRouter.registerApi("notifications", new NotificationsHandler(notifier));
119
+ if (entitlements) internalRouter.registerApi("entitlements", new EntitlementsHandler(entitlements));
114
120
 
115
121
  // The favicon is requested by the browser before anyone has signed in,
116
122
  // and by deployed apps' pages, so gating it behind a session would send
@@ -15,6 +15,9 @@ class AppsHandler implements RequestHandler {
15
15
  case "logs":
16
16
  if (request.id) return this.handleLogs(request, request.id);
17
17
  break;
18
+ case "docs":
19
+ if (request.id) return this.handleDocs(request, request.id);
20
+ break;
18
21
  case undefined:
19
22
  if (request.id) return this.handleApp(request, request.id);
20
23
  return this.handleCollection(request);
@@ -22,6 +25,17 @@ class AppsHandler implements RequestHandler {
22
25
  request.notFound();
23
26
  }
24
27
 
28
+ private async handleDocs(request : Request, appName : string) : Promise<void> {
29
+ if (request.method !== "POST") return request.notFound();
30
+ try {
31
+ const docs = await this.buildLayer.regenerateDocs(appName);
32
+ return request.reply(200, { appName, docs });
33
+ } catch (error) {
34
+ const message = error instanceof Error ? error.message : "Could not regenerate documentation";
35
+ return request.reply(message.startsWith("No app") ? 404 : 400, { error: message });
36
+ }
37
+ }
38
+
25
39
  private async handleDeploy(request : Request, appName : string) : Promise<void> {
26
40
  switch (request.method) {
27
41
  case "POST": {
@@ -0,0 +1,52 @@
1
+ import {EntitlementStore} from "../../data-store/EntitlementStore";
2
+ import {Request} from "../Request";
3
+ import {RequestHandler} from "../RequestHandler";
4
+
5
+ /* The console-facing side, served on the authenticated public entry point:
6
+ list definitions, create global ones, and grant or revoke entitlements to
7
+ users. Grants record who made them from the signed-in user. */
8
+ class EntitlementsAdminHandler implements RequestHandler {
9
+
10
+ constructor(private store : EntitlementStore) {}
11
+
12
+ async handle(request : Request) : Promise<void> {
13
+ const { method, id, subresource } = request;
14
+
15
+ if (method === "GET" && ! id) return request.reply(200, await this.store.listDefinitions());
16
+ if (method === "POST" && id === "definitions" && ! subresource) return this.defineGlobal(request);
17
+ if (method === "GET" && id === "grants" && ! subresource) {
18
+ return request.reply(200, await this.store.listGrants(request.query("userId")));
19
+ }
20
+ if (method === "POST" && id === "grants" && ! subresource) return this.grant(request);
21
+ if (method === "DELETE" && id === "grants" && subresource) return this.revoke(request, subresource);
22
+ request.notFound();
23
+ }
24
+
25
+ private async defineGlobal(request : Request) : Promise<void> {
26
+ const body = await request.body() as { entitlementId? : string, notes? : string } | undefined;
27
+ if (! body?.entitlementId) return request.reply(400, { error: "entitlementId is required" });
28
+
29
+ await this.store.defineGlobal(body.entitlementId, body.notes ?? "");
30
+ request.reply(204);
31
+ }
32
+
33
+ private async grant(request : Request) : Promise<void> {
34
+ const body = await request.body() as
35
+ { userId? : string, appId? : string | null, entitlementId? : string, notes? : string } | undefined;
36
+ if (! body?.userId || ! body.entitlementId) {
37
+ return request.reply(400, { error: "userId and entitlementId are required" });
38
+ }
39
+
40
+ const grantedBy = request.user?.id ?? "system";
41
+ const grant = await this.store.grant(body.userId, body.appId || null, body.entitlementId, body.notes ?? "", grantedBy);
42
+ request.reply(201, grant);
43
+ }
44
+
45
+ private async revoke(request : Request, grantId : string) : Promise<void> {
46
+ const removed = await this.store.revoke(grantId);
47
+ removed ? request.reply(204) : request.notFound();
48
+ }
49
+
50
+ }
51
+
52
+ export { EntitlementsAdminHandler }
@@ -0,0 +1,39 @@
1
+ import {Entitlements} from "anbaric-tsapi";
2
+ import {Request} from "../Request";
3
+ import {RequestHandler} from "../RequestHandler";
4
+
5
+ /* The app-facing side, served on the internal entry point: register an
6
+ entitlement and check whether a user holds it. Both are scoped to the
7
+ calling app (the ambient app header), so an app can neither define a global
8
+ entitlement nor check as another app. */
9
+ class EntitlementsHandler implements RequestHandler {
10
+
11
+ constructor(private entitlements : Entitlements) {}
12
+
13
+ async handle(request : Request) : Promise<void> {
14
+ const appId = request.appId;
15
+ if (! appId) return request.reply(400, { error: "Missing the x-anbaric-app header" });
16
+
17
+ if (request.method === "POST" && ! request.id) return this.register(request, appId);
18
+ if (request.method === "GET" && request.id && request.subresource === "check") return this.check(request, appId);
19
+ request.notFound();
20
+ }
21
+
22
+ private async register(request : Request, appId : string) : Promise<void> {
23
+ const body = await request.body() as { entitlementId? : string, notes? : string } | undefined;
24
+ if (! body?.entitlementId) return request.reply(400, { error: "entitlementId is required" });
25
+
26
+ await this.entitlements.register(appId, body.entitlementId, body.notes ?? "");
27
+ request.reply(204);
28
+ }
29
+
30
+ private async check(request : Request, appId : string) : Promise<void> {
31
+ const userId = request.query("userId");
32
+ if (! userId) return request.reply(400, { error: "userId is required" });
33
+
34
+ request.reply(200, { has: await this.entitlements.has(appId, userId, request.id!) });
35
+ }
36
+
37
+ }
38
+
39
+ export { EntitlementsHandler }
package/src/index.ts CHANGED
@@ -27,6 +27,11 @@ export * from "./data-store/PostgresJobRunSchedulePersistence";
27
27
  export * from "./data-store/AppDocsStore";
28
28
  export * from "./data-store/PostgresAppDocsStore";
29
29
  export * from "./data-store/InMemoryAppDocsStore";
30
+ export * from "./data-store/EntitlementStore";
31
+ export * from "./data-store/PostgresEntitlementStore";
32
+ export * from "./data-store/InMemoryEntitlementStore";
33
+ export * from "./hosting/handlers/EntitlementsHandler";
34
+ export * from "./hosting/handlers/EntitlementsAdminHandler";
30
35
  export * from "./docs/DocModel";
31
36
  export * from "./docs/GatewayDocModel";
32
37
  export * from "./docs/DocGenerator";
package/src/main.ts CHANGED
@@ -20,6 +20,7 @@ import {FargateBuildLayer} from "./app-management/FargateBuildLayer";
20
20
  import {DocGenerator} from "./docs/DocGenerator";
21
21
  import {GatewayDocModel} from "./docs/GatewayDocModel";
22
22
  import {PostgresAppDocsStore} from "./data-store/PostgresAppDocsStore";
23
+ import {PostgresEntitlementStore} from "./data-store/PostgresEntitlementStore";
23
24
  import {ConsumerRegistry} from "./queuing/ConsumerRegistry";
24
25
  import {Dispatcher} from "./queuing/Dispatcher";
25
26
  import {HostingServer} from "./hosting/HostingServer";
@@ -94,7 +95,8 @@ const plugins = await new PluginLoader().load(process.env.ANBARIC_PLUGINS ?? "an
94
95
  const server = new HostingServer(new PostgresJobPersistence(pool), queue, registry, buildLayer,
95
96
  (appId, collection) => new PostgresJsonStore(pool, appId, collection), secretStoreFor, authenticator, cliAuthorizer,
96
97
  tokenAuthenticator, process.env.ANBARIC_TENANT, new PostgresAuditRecordStore(pool), plugins,
97
- new PostgresJobRunSchedulePersistence(pool), await loadNotifier(process.env.ANBARIC_NOTIFIER_MODULE));
98
+ new PostgresJobRunSchedulePersistence(pool), await loadNotifier(process.env.ANBARIC_NOTIFIER_MODULE),
99
+ new PostgresEntitlementStore(pool));
98
100
  const port = await server.listen(hostingPort);
99
101
  const internal = await server.listenInternal(internalPort);
100
102