anbaric-cloud-hosting 1.69.0 → 1.71.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.69.0",
3
+ "version": "1.71.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.69.0",
22
- "anbaric-plugins": "^1.69.0",
23
- "anbaric-tsapi": "^1.69.0",
24
- "anbaric-web": "^1.69.0",
21
+ "anbaric-data-store": "^1.71.0",
22
+ "anbaric-plugins": "^1.71.0",
23
+ "anbaric-tsapi": "^1.71.0",
24
+ "anbaric-web": "^1.71.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.69.0",
32
- "anbaric-state-machine": "^1.69.0",
31
+ "anbaric-impl-cloud": "^1.71.0",
32
+ "anbaric-state-machine": "^1.71.0",
33
33
  "tsx": "^4.20.0",
34
34
  "typescript": "^7.0.2"
35
35
  },
@@ -11,23 +11,35 @@ const ROLE_CACHE_TTL_MS = 60_000;
11
11
  role change is a fair trade for not calling central on each of them. */
12
12
  class HttpMembershipService implements MembershipService {
13
13
 
14
- private roles = new Map<string, { role : TenantRole | undefined, expires : number }>();
14
+ private roles = new Map<string, { role : TenantRole | undefined, invitationToken? : string, expires : number }>();
15
15
 
16
16
  constructor(private centralUrl : string, private secret : string, private tenantSlug : string,
17
17
  private fetchFn : FetchFn = (url, init) => fetch(url, init),
18
18
  private cacheTtlMs : number = ROLE_CACHE_TTL_MS) {}
19
19
 
20
20
  async roleFor(userId : string) : Promise<TenantRole | undefined> {
21
+ return (await this.membership(userId)).role;
22
+ }
23
+
24
+ async joinedVia(userId : string) : Promise<string | undefined> {
25
+ return (await this.membership(userId)).invitationToken;
26
+ }
27
+
28
+ private async membership(userId : string) : Promise<{ role : TenantRole | undefined, invitationToken? : string }> {
21
29
  const cached = this.roles.get(userId);
22
- if (cached && cached.expires > Date.now()) return cached.role;
30
+ if (cached && cached.expires > Date.now()) return cached;
23
31
 
24
32
  const response = await this.call("GET", `/members/${encodeURIComponent(userId)}`, undefined, [404]);
25
- const role = response.status === 404
26
- ? undefined
27
- : (await response.json() as { role? : unknown }).role;
28
- const found = isTenantRole(role) ? role : undefined;
29
-
30
- this.roles.set(userId, { role: found, expires: Date.now() + this.cacheTtlMs });
33
+ const answer = response.status === 404
34
+ ? {}
35
+ : await response.json() as { role? : unknown, invitationToken? : unknown };
36
+ const found = {
37
+ role: isTenantRole(answer.role) ? answer.role : undefined,
38
+ invitationToken: typeof answer.invitationToken === "string" ? answer.invitationToken : undefined,
39
+ expires: Date.now() + this.cacheTtlMs,
40
+ };
41
+
42
+ this.roles.set(userId, found);
31
43
  return found;
32
44
  }
33
45
 
@@ -36,6 +48,11 @@ class HttpMembershipService implements MembershipService {
36
48
  return await response.json() as PendingInvitation;
37
49
  }
38
50
 
51
+ async inviteLink(role : TenantRole, invitedBy : Inviter, uses : number, landingApp? : string) : Promise<PendingInvitation> {
52
+ const response = await this.call("POST", "/invitations", { link: true, uses, role, invitedBy, landingApp });
53
+ return await response.json() as PendingInvitation;
54
+ }
55
+
39
56
  async pending(asking : Inviter) : Promise<Array<PendingInvitation>> {
40
57
  const response = await this.call("GET", "/invitations", undefined, [], asking);
41
58
  return await response.json() as Array<PendingInvitation>;
@@ -6,6 +6,7 @@ class InMemoryMembershipService implements MembershipService {
6
6
 
7
7
  private invitations = new Map<string, PendingInvitation>();
8
8
  private members = new Map<string, TenantRole>();
9
+ private joinedThrough = new Map<string, string>();
9
10
 
10
11
  constructor(private linkBase : string = "http://localhost/invitations") {}
11
12
 
@@ -17,6 +18,35 @@ class InMemoryMembershipService implements MembershipService {
17
18
  return this.members.get(userId);
18
19
  }
19
20
 
21
+ // What a person who followed a link ends up as, for tests to arrange.
22
+ join(userId : string, role : TenantRole, token : string) : void {
23
+ this.members.set(userId, role);
24
+ this.joinedThrough.set(userId, token);
25
+ }
26
+
27
+ async joinedVia(userId : string) : Promise<string | undefined> {
28
+ return this.joinedThrough.get(userId);
29
+ }
30
+
31
+ async inviteLink(role : TenantRole, invitedBy : Inviter, uses : number, landingApp? : string) : Promise<PendingInvitation> {
32
+ const token = randomUUID();
33
+ const now = new Date();
34
+ const invitation : PendingInvitation = {
35
+ token,
36
+ role,
37
+ landingApp,
38
+ invitedBy: invitedBy.id,
39
+ invitedByName: invitedBy.name,
40
+ link: `${this.linkBase}/${token}`,
41
+ createdAt: now.toISOString(),
42
+ expiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(),
43
+ emailed: false,
44
+ usesRemaining: uses,
45
+ };
46
+ this.invitations.set(token, invitation);
47
+ return { ...invitation };
48
+ }
49
+
20
50
  async remove(userId : string, _asking : Inviter) : Promise<boolean> {
21
51
  return this.members.delete(userId);
22
52
  }
@@ -3,7 +3,8 @@ import {TenantRole} from "./TenantRole";
3
3
  type PendingInvitation = {
4
4
 
5
5
  token : string,
6
- email : string,
6
+ // Absent for a link: nobody is named, whoever opens it may join.
7
+ email? : string,
7
8
  role : TenantRole,
8
9
  landingApp? : string,
9
10
  invitedBy : string,
@@ -12,6 +13,8 @@ type PendingInvitation = {
12
13
  createdAt : string,
13
14
  expiresAt : string,
14
15
  emailed? : boolean,
16
+ // Present for a link invitation: how many more people may join through it.
17
+ usesRemaining? : number,
15
18
 
16
19
  };
17
20
 
@@ -35,6 +38,13 @@ interface MembershipService {
35
38
  the invitation is accepted, so a later change of address still works. */
36
39
  invite(email : string, role : TenantRole, invitedBy : Inviter, landingApp? : string) : Promise<PendingInvitation>;
37
40
 
41
+ /* An invitation with no name on it: a link that admits whoever opens it
42
+ and signs in, up to `uses` people. */
43
+ inviteLink(role : TenantRole, invitedBy : Inviter, uses : number, landingApp? : string) : Promise<PendingInvitation>;
44
+
45
+ // The invitation a member came in through, when it is known.
46
+ joinedVia(userId : string) : Promise<string | undefined>;
47
+
38
48
  pending(asking : Inviter) : Promise<Array<PendingInvitation>>;
39
49
 
40
50
  revoke(token : string, asking : Inviter) : Promise<boolean>;
@@ -27,7 +27,9 @@ type EntitlementGrant = {
27
27
  have until they arrive. This is the gap between the two. */
28
28
  type EntitlementOffer = {
29
29
 
30
- email : string,
30
+ // The address the offer is held against, or null when it is held against a link.
31
+ email : string | null,
32
+ linkToken? : string,
31
33
  appId : string | null,
32
34
  entitlementId : string,
33
35
  notes : string,
@@ -58,6 +60,14 @@ interface EntitlementStore extends Entitlements {
58
60
  because the second call has nothing left to claim. */
59
61
  claim(email : string, userId : string) : Promise<number>;
60
62
 
63
+ /* Held against an invitation link instead of an address: granted to each
64
+ person who joins through the link, and kept, since the next person
65
+ through it is owed the same. Returns how many grants were made for this
66
+ person; none the second time, as they already hold them. */
67
+ offerForLink(linkToken : string, offers : Array<Omit<EntitlementOffer, "email" | "linkToken">>, offeredBy : string) : Promise<void>;
68
+
69
+ claimLink(linkToken : string, userId : string) : Promise<number>;
70
+
61
71
  }
62
72
 
63
73
  export type { EntitlementStore, EntitlementDefinition, EntitlementGrant, EntitlementOffer }
@@ -70,6 +70,27 @@ class InMemoryEntitlementStore implements EntitlementStore {
70
70
  return owed.length;
71
71
  }
72
72
 
73
+ async offerForLink(linkToken : string, offers : Array<Omit<EntitlementOffer, "email" | "linkToken">>, offeredBy : string) : Promise<void> {
74
+ for (const offer of offers) {
75
+ this.offers.set(`link:${linkToken} ${this.key(offer.appId, offer.entitlementId)}`,
76
+ { offer: { ...offer, email: null, linkToken }, offeredBy });
77
+ }
78
+ }
79
+
80
+ async claimLink(linkToken : string, userId : string) : Promise<number> {
81
+ const owed = [...this.offers.values()].filter(held => held.offer.linkToken === linkToken);
82
+ const already = await this.listGrants(userId);
83
+ let made = 0;
84
+
85
+ for (const held of owed) {
86
+ const holds = already.some(grant => grant.entitlementId === held.offer.entitlementId && (grant.appId ?? "") === (held.offer.appId ?? ""));
87
+ if (holds) continue;
88
+ await this.grant(userId, held.offer.appId, held.offer.entitlementId, held.offer.notes, held.offeredBy);
89
+ made++;
90
+ }
91
+ return made;
92
+ }
93
+
73
94
  private key(appId : string | null, entitlementId : string) : string {
74
95
  return `${appId ?? ""}/${entitlementId}`;
75
96
  }
@@ -0,0 +1,31 @@
1
+ import {Skill, SkillDraft, SkillStore} from "./SkillStore";
2
+
3
+ class InMemorySkillStore implements SkillStore {
4
+
5
+ private skills = new Map<string, Skill>();
6
+
7
+ async list() : Promise<Array<Skill>> {
8
+ return [...this.skills.values()]
9
+ .sort((left, right) => left.id.localeCompare(right.id))
10
+ .map(skill => ({ ...skill }));
11
+ }
12
+
13
+ async get(id : string) : Promise<Skill | undefined> {
14
+ const skill = this.skills.get(id);
15
+ return skill && { ...skill };
16
+ }
17
+
18
+ async save(draft : SkillDraft) : Promise<Skill> {
19
+ const now = new Date();
20
+ const saved : Skill = { ...draft, createdAt: this.skills.get(draft.id)?.createdAt ?? now, updatedAt: now };
21
+ this.skills.set(draft.id, saved);
22
+ return { ...saved };
23
+ }
24
+
25
+ async remove(id : string) : Promise<boolean> {
26
+ return this.skills.delete(id);
27
+ }
28
+
29
+ }
30
+
31
+ export { InMemorySkillStore };
@@ -273,6 +273,38 @@ const PLATFORM_MIGRATIONS : Array<Migration> = [
273
273
  )`,
274
274
  ),
275
275
 
276
+ /* Guidance the tenant's builders write for the agents that work on it:
277
+ usage hints say when to reach for a skill, the body says what to do.
278
+ A platform table rather than workflow data, since apps never see it. */
279
+ statements("008-skills",
280
+ `CREATE TABLE IF NOT EXISTS anbaric_system.skills (
281
+ id TEXT PRIMARY KEY,
282
+ title TEXT NOT NULL,
283
+ usage_hints TEXT NOT NULL,
284
+ body TEXT NOT NULL,
285
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
286
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
287
+ )`,
288
+ ),
289
+
290
+ // A managed skill is the operators' to change, not the tenant's.
291
+ statements("009-skills-locked",
292
+ "ALTER TABLE anbaric_system.skills ADD COLUMN IF NOT EXISTS locked BOOLEAN NOT NULL DEFAULT false",
293
+ ),
294
+
295
+ /* An invitation may be a link rather than an email: anyone who opens it
296
+ and signs in becomes a member, as many times as it allows. The
297
+ entitlements chosen for it are held against the link, since there is
298
+ no address to hold them against, and granted to each person who joins
299
+ through it. */
300
+ statements("010-invite-links",
301
+ "ALTER TABLE entitlement_invites ALTER COLUMN email DROP NOT NULL",
302
+ "ALTER TABLE entitlement_invites ADD COLUMN IF NOT EXISTS link_token TEXT",
303
+ "DROP INDEX IF EXISTS entitlement_invites_key",
304
+ `CREATE UNIQUE INDEX IF NOT EXISTS entitlement_invites_key
305
+ ON entitlement_invites (COALESCE(email, ''), COALESCE(link_token, ''), COALESCE(app_id, ''), entitlement_id)`,
306
+ ),
307
+
276
308
  ];
277
309
 
278
310
  export { PLATFORM_MIGRATIONS };
@@ -71,7 +71,7 @@ class PostgresEntitlementStore implements EntitlementStore {
71
71
  await this.pool.query(
72
72
  `INSERT INTO entitlement_invites (email, app_id, entitlement_id, notes, offered_by)
73
73
  VALUES ($1, $2, $3, $4, $5)
74
- ON CONFLICT (email, COALESCE(app_id, ''), entitlement_id)
74
+ ON CONFLICT (COALESCE(email, ''), COALESCE(link_token, ''), COALESCE(app_id, ''), entitlement_id)
75
75
  DO UPDATE SET notes = EXCLUDED.notes, offered_by = EXCLUDED.offered_by`,
76
76
  [offer.email, offer.appId, offer.entitlementId, offer.notes, offeredBy]);
77
77
  }
@@ -87,6 +87,34 @@ class PostgresEntitlementStore implements EntitlementStore {
87
87
  }));
88
88
  }
89
89
 
90
+ async offerForLink(linkToken : string, offers : Array<Omit<EntitlementOffer, "email" | "linkToken">>, offeredBy : string) : Promise<void> {
91
+ for (const offer of offers) {
92
+ await this.pool.query(
93
+ `INSERT INTO entitlement_invites (email, link_token, app_id, entitlement_id, notes, offered_by)
94
+ VALUES (NULL, $1, $2, $3, $4, $5)
95
+ ON CONFLICT (COALESCE(email, ''), COALESCE(link_token, ''), COALESCE(app_id, ''), entitlement_id)
96
+ DO UPDATE SET notes = EXCLUDED.notes, offered_by = EXCLUDED.offered_by`,
97
+ [linkToken, offer.appId, offer.entitlementId, offer.notes, offeredBy]);
98
+ }
99
+ }
100
+
101
+ /* The offers stay: the link may admit more people. The grant is made once
102
+ per person, so a sign-in that claims again finds nothing new. */
103
+ async claimLink(linkToken : string, userId : string) : Promise<number> {
104
+ const result = await this.pool.query(
105
+ `INSERT INTO entitlement_grants (id, user_id, app_id, entitlement_id, notes, granted_by)
106
+ SELECT gen_random_uuid()::text, $2, offered.app_id, offered.entitlement_id, offered.notes, offered.offered_by
107
+ FROM entitlement_invites offered
108
+ WHERE offered.link_token = $1
109
+ AND NOT EXISTS (
110
+ SELECT 1 FROM entitlement_grants held
111
+ WHERE held.user_id = $2 AND held.entitlement_id = offered.entitlement_id
112
+ AND COALESCE(held.app_id, '') = COALESCE(offered.app_id, ''))
113
+ RETURNING id`,
114
+ [linkToken, userId]);
115
+ return result.rowCount ?? 0;
116
+ }
117
+
90
118
  /* Claimed in one statement so a second sign-in arriving at the same moment
91
119
  cannot grant the same thing twice: the delete is what decides who owns
92
120
  each row, and only rows it actually removed become grants. */
@@ -0,0 +1,49 @@
1
+ import {Pool} from "pg";
2
+ import {Skill, SkillDraft, SkillStore} from "./SkillStore";
3
+
4
+ const COLUMNS = "id, title, usage_hints, body, locked, created_at, updated_at";
5
+
6
+ const rowToSkill = (row : any) : Skill => ({
7
+ id: row.id,
8
+ title: row.title,
9
+ usageHints: row.usage_hints,
10
+ body: row.body,
11
+ locked: row.locked,
12
+ createdAt: row.created_at,
13
+ updatedAt: row.updated_at,
14
+ });
15
+
16
+ class PostgresSkillStore implements SkillStore {
17
+
18
+ constructor(private pool : Pool) {}
19
+
20
+ async list() : Promise<Array<Skill>> {
21
+ const result = await this.pool.query(`SELECT ${COLUMNS} FROM anbaric_system.skills ORDER BY id`);
22
+ return result.rows.map(rowToSkill);
23
+ }
24
+
25
+ async get(id : string) : Promise<Skill | undefined> {
26
+ const result = await this.pool.query(`SELECT ${COLUMNS} FROM anbaric_system.skills WHERE id = $1`, [id]);
27
+ return result.rows[0] && rowToSkill(result.rows[0]);
28
+ }
29
+
30
+ async save(draft : SkillDraft) : Promise<Skill> {
31
+ const result = await this.pool.query(
32
+ `INSERT INTO anbaric_system.skills (id, title, usage_hints, body, locked)
33
+ VALUES ($1, $2, $3, $4, $5)
34
+ ON CONFLICT (id) DO UPDATE
35
+ SET title = EXCLUDED.title, usage_hints = EXCLUDED.usage_hints, body = EXCLUDED.body,
36
+ locked = EXCLUDED.locked, updated_at = now()
37
+ RETURNING ${COLUMNS}`,
38
+ [draft.id, draft.title, draft.usageHints, draft.body, draft.locked]);
39
+ return rowToSkill(result.rows[0]);
40
+ }
41
+
42
+ async remove(id : string) : Promise<boolean> {
43
+ const result = await this.pool.query("DELETE FROM anbaric_system.skills WHERE id = $1", [id]);
44
+ return (result.rowCount ?? 0) > 0;
45
+ }
46
+
47
+ }
48
+
49
+ export { PostgresSkillStore };
@@ -0,0 +1,41 @@
1
+ /* A skill is guidance a tenant writes for the agents that build on it: when to
2
+ reach for it (the usage hints, which an agent reads before deciding) and
3
+ what to do (the body, which it reads once it has decided). Kept by the
4
+ platform, managed from the console, and never handed to deployed apps. */
5
+ type Skill = {
6
+ id : string,
7
+ title : string,
8
+ usageHints : string,
9
+ body : string,
10
+ /* A managed skill: written by the platform's operators rather than the
11
+ tenant, shown as such, and not the tenant's to change or remove. */
12
+ locked : boolean,
13
+ createdAt : Date,
14
+ updatedAt : Date,
15
+ };
16
+
17
+ type SkillDraft = Omit<Skill, "createdAt" | "updatedAt">;
18
+
19
+ /* The id ends up as the tail of a tool name an agent sees, after a fixed
20
+ prefix, and tool names are capped at 64 characters: hence the shape and
21
+ the length. */
22
+ const SKILL_ID_SHAPE = /^[a-z0-9][a-z0-9-]{0,49}$/;
23
+
24
+ const isSkillId = (value : unknown) : value is string =>
25
+ typeof value === "string" && SKILL_ID_SHAPE.test(value);
26
+
27
+ interface SkillStore {
28
+
29
+ list() : Promise<Array<Skill>>;
30
+
31
+ get(id : string) : Promise<Skill | undefined>;
32
+
33
+ // Creates or replaces the skill with that id.
34
+ save(draft : SkillDraft) : Promise<Skill>;
35
+
36
+ remove(id : string) : Promise<boolean>;
37
+
38
+ }
39
+
40
+ export type { Skill, SkillDraft, SkillStore };
41
+ export { SKILL_ID_SHAPE, isSkillId };
@@ -14,6 +14,8 @@ import {MembersHandler} from "./handlers/auth/MembersHandler";
14
14
  import {BillingHandler} from "./handlers/BillingHandler";
15
15
  import {CentralBilling} from "../billing/CentralBilling";
16
16
  import {AppSettingsStore} from "../data-store/AppSettingsStore";
17
+ import {SkillStore} from "../data-store/SkillStore";
18
+ import {SkillsHandler} from "./handlers/SkillsHandler";
17
19
  import {Subdomains} from "../subdomains/Subdomains";
18
20
  import {AuditsHandler} from "./handlers/AuditsHandler";
19
21
  import {AuthorizeCliHandler} from "./handlers/auth/AuthorizeCliHandler";
@@ -83,7 +85,9 @@ class HostingServer {
83
85
  fileStorageFor? : (appId : string) => FileStorage,
84
86
  subdomains? : Subdomains,
85
87
  billing? : CentralBilling,
86
- appSettings? : AppSettingsStore) {
88
+ appSettings? : AppSettingsStore,
89
+ skills? : SkillStore,
90
+ operatorKey? : string) {
87
91
  const pages = new PagesHandler();
88
92
  const ping = new PingHandler(tenant);
89
93
  const jobs = new JobsHandler(persistence);
@@ -119,6 +123,8 @@ class HostingServer {
119
123
  if (memberships) publicRouter.registerApi("invitations", new InvitationsHandler(memberships, entitlements));
120
124
  if (memberships) publicRouter.registerApi("members", new MembersHandler(memberships, entitlements));
121
125
  if (billing) publicRouter.registerApi("billing", new BillingHandler(billing));
126
+ // People and their agents only: a deployed app has no business reading skills.
127
+ if (skills) publicRouter.registerApi("skills", new SkillsHandler(skills));
122
128
  const prompts = promptManagerFor && new PromptsHandler(promptManagerFor);
123
129
  if (prompts) publicRouter.registerApi("prompts", prompts);
124
130
  if (cliAuthorizer) {
@@ -170,7 +176,7 @@ class HostingServer {
170
176
  (request.method === "GET" && CLI_KEY_POLL.test(request.url.pathname));
171
177
 
172
178
  const authentication =
173
- new AuthenticationMiddleware(authenticator, tokenAuthenticator, openRequests, undefined, userDirectory, memberships);
179
+ new AuthenticationMiddleware(authenticator, tokenAuthenticator, openRequests, undefined, userDirectory, memberships, operatorKey);
174
180
 
175
181
  /* Entitlements an inviter chose before this person had a user id were
176
182
  put aside under their email address; their first sign-in is when
@@ -178,6 +184,9 @@ class HostingServer {
178
184
  if (entitlements) {
179
185
  authentication.claimWhatIsOwed = async user => {
180
186
  if (user.email) await entitlements.claim(user.email, user.id);
187
+ // Someone who came in through a link is owed what the link carries.
188
+ const token = await memberships?.joinedVia(user.id);
189
+ if (token) await entitlements.claimLink(token, user.id);
181
190
  };
182
191
  }
183
192
 
@@ -16,6 +16,10 @@ class Request {
16
16
  identity? : string;
17
17
  user? : User;
18
18
  tenant? : Tenant;
19
+ /* Set when the platform's operators are calling with the control plane's
20
+ key rather than anyone signing in: no user, no role, and admitted only
21
+ where a handler says operators belong. */
22
+ operator = false;
19
23
 
20
24
  readonly api : boolean;
21
25
 
@@ -0,0 +1,97 @@
1
+ import {deniesBuild} from "../../auth/TenantRole";
2
+ import {isSkillId, Skill, SkillStore} from "../../data-store/SkillStore";
3
+ import {Request} from "../Request";
4
+ import {RequestHandler} from "../RequestHandler";
5
+
6
+ /* The tenant's skills: guidance its builders write for the agents that work
7
+ on it. Anyone in the tenant may read them - an agent acting for a user
8
+ needs them as much as one acting for a builder - but writing them is a
9
+ builder's job. A managed skill is the operators' instead: written with
10
+ the control plane's key, marked locked, and not the tenant's to change.
11
+ Served to people and their agents only, never to deployed apps, which is
12
+ why this is registered on the public entry point alone. */
13
+ class SkillsHandler implements RequestHandler {
14
+
15
+ constructor(private skills : SkillStore) {}
16
+
17
+ async handle(request : Request) : Promise<void> {
18
+ if (request.subresource) return request.notFound();
19
+ if (request.id) return this.handleSkill(request, request.id);
20
+ return this.handleCollection(request);
21
+ }
22
+
23
+ private async handleCollection(request : Request) : Promise<void> {
24
+ if (request.method !== "GET") return request.notFound();
25
+ return request.reply(200, (await this.skills.list()).map(skill => this.publicSkill(skill)));
26
+ }
27
+
28
+ private async handleSkill(request : Request, id : string) : Promise<void> {
29
+ switch (request.method) {
30
+ case "GET": {
31
+ const skill = await this.skills.get(id);
32
+ if (! skill) return request.reply(404, { error: `No skill with id "${id}"` });
33
+ return request.reply(200, this.publicSkill(skill));
34
+ }
35
+ case "PUT": {
36
+ if (! request.operator && deniesBuild(request.user?.tenantRole)) return this.refuse(request);
37
+ if (! isSkillId(id)) {
38
+ return request.reply(400, { error: `"${id}" is not a skill id: lower-case letters, digits and hyphens, up to 50 characters` });
39
+ }
40
+ const existing = await this.skills.get(id);
41
+ if (existing?.locked && ! request.operator) return this.managed(request);
42
+
43
+ const body = await request.body();
44
+ const draft = this.draftFrom(body);
45
+ if (! draft) return request.reply(400, { error: "Expected a body of { title : string, usageHints : string, body : string }, none empty" });
46
+
47
+ // Only an operator makes a managed skill, and says so each time.
48
+ const locked = request.operator && (body as { locked? : unknown })?.locked === true;
49
+ return request.reply(200, this.publicSkill(await this.skills.save({ id, ...draft, locked })));
50
+ }
51
+ case "DELETE": {
52
+ if (! request.operator && deniesBuild(request.user?.tenantRole)) return this.refuse(request);
53
+ const existing = await this.skills.get(id);
54
+ if (! existing) return request.reply(404, { error: `No skill with id "${id}"` });
55
+ if (existing.locked && ! request.operator) return this.managed(request);
56
+
57
+ await this.skills.remove(id);
58
+ return request.reply(204);
59
+ }
60
+ }
61
+ request.notFound();
62
+ }
63
+
64
+ private refuse(request : Request) : void {
65
+ request.reply(403, { error: "Your role in this tenant cannot manage skills" });
66
+ }
67
+
68
+ private managed(request : Request) : void {
69
+ request.reply(403, { error: "This skill is a managed service and is not yours to change" });
70
+ }
71
+
72
+ private draftFrom(body : unknown) : { title : string, usageHints : string, body : string } | undefined {
73
+ const fields = body as Record<string, unknown> | undefined;
74
+ const text = (value : unknown) : string | undefined =>
75
+ typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
76
+
77
+ const title = text(fields?.title);
78
+ const usageHints = text(fields?.usageHints);
79
+ const content = text(fields?.body);
80
+ return title && usageHints && content ? { title, usageHints, body: content } : undefined;
81
+ }
82
+
83
+ private publicSkill(skill : Skill) {
84
+ return {
85
+ id: skill.id,
86
+ title: skill.title,
87
+ usageHints: skill.usageHints,
88
+ body: skill.body,
89
+ locked: skill.locked,
90
+ createdAt: skill.createdAt.toISOString(),
91
+ updatedAt: skill.updatedAt.toISOString(),
92
+ };
93
+ }
94
+
95
+ }
96
+
97
+ export { SkillsHandler };
@@ -5,6 +5,7 @@ import {Request} from "../../Request";
5
5
  import {RequestHandler} from "../../RequestHandler";
6
6
 
7
7
  const EMAIL_SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
8
+ const MOST_USES = 1000;
8
9
 
9
10
  /* The console's side of inviting people into this tenant: list what is
10
11
  pending, invite by email (attributed to the signed-in user), revoke.
@@ -41,11 +42,20 @@ class InvitationsHandler implements RequestHandler {
41
42
  return request.user ? { id: request.user.id, name: request.user.name } : { id: "system" };
42
43
  }
43
44
 
45
+ /* By email, or as a link. A link names nobody: whoever opens it and signs
46
+ in joins, as many times as it allows, and the entitlements chosen for
47
+ it are held against the link rather than an address. */
44
48
  private async invite(request : Request) : Promise<void> {
45
49
  const body = await request.body() as
46
- { email? : string, role? : string, landingApp? : string, entitlements? : Array<unknown> } | undefined;
50
+ { email? : string, link? : unknown, uses? : unknown, role? : string, landingApp? : string, entitlements? : Array<unknown> } | undefined;
51
+ const asLink = body?.link === true;
47
52
  const email = String(body?.email ?? "").trim();
48
- if (! EMAIL_SHAPE.test(email)) return request.reply(400, { error: "A valid email address is required" });
53
+ if (! asLink && ! EMAIL_SHAPE.test(email)) return request.reply(400, { error: "A valid email address is required" });
54
+
55
+ const uses = asLink ? Number(body?.uses ?? 1) : 0;
56
+ if (asLink && (! Number.isInteger(uses) || uses < 1 || uses > MOST_USES)) {
57
+ return request.reply(400, { error: `A link may be used between 1 and ${MOST_USES} times` });
58
+ }
49
59
 
50
60
  const role = body?.role ?? "USER";
51
61
  if (! isTenantRole(role) || role === "OWNER") {
@@ -56,13 +66,19 @@ class InvitationsHandler implements RequestHandler {
56
66
  ? body.landingApp.trim()
57
67
  : undefined;
58
68
 
59
- const invitation = await this.memberships.invite(email, role, this.asking(request), landingApp);
69
+ const invitation = asLink
70
+ ? await this.memberships.inviteLink(role, this.asking(request), uses, landingApp)
71
+ : await this.memberships.invite(email, role, this.asking(request), landingApp);
60
72
 
61
73
  /* Put aside after the invitation exists, so nothing is held for an
62
- email that was never actually invited. */
63
- const chosen = this.chosenEntitlements(email, body?.entitlements);
74
+ email, or a link, that was never actually made. */
75
+ const chosen = this.chosenEntitlements(body?.entitlements);
64
76
  if (chosen.length > 0 && this.entitlements) {
65
- await this.entitlements.offer(chosen, request.user?.id ?? "system");
77
+ if (asLink) {
78
+ await this.entitlements.offerForLink(invitation.token, chosen, request.user?.id ?? "system");
79
+ } else {
80
+ await this.entitlements.offer(chosen.map(offer => ({ ...offer, email })), request.user?.id ?? "system");
81
+ }
66
82
  }
67
83
 
68
84
  request.reply(201, { ...invitation, entitlements: chosen.length });
@@ -70,7 +86,7 @@ class InvitationsHandler implements RequestHandler {
70
86
 
71
87
  // Each is an app and an entitlement, with a null app meaning every app -
72
88
  // the same scoping a grant already has.
73
- private chosenEntitlements(email : string, chosen : Array<unknown> | undefined) : Array<EntitlementOffer> {
89
+ private chosenEntitlements(chosen : Array<unknown> | undefined) : Array<Omit<EntitlementOffer, "email" | "linkToken">> {
74
90
  if (! Array.isArray(chosen)) return [];
75
91
 
76
92
  return chosen.flatMap(each => {
@@ -78,7 +94,6 @@ class InvitationsHandler implements RequestHandler {
78
94
  if (typeof offer?.entitlementId !== "string" || ! offer.entitlementId) return [];
79
95
 
80
96
  return [{
81
- email,
82
97
  appId: typeof offer.appId === "string" && offer.appId ? offer.appId : null,
83
98
  entitlementId: offer.entitlementId,
84
99
  notes: "",
@@ -36,11 +36,20 @@ class AuthenticationMiddleware implements Middleware {
36
36
  private isOpen : OpenRequestPredicate = () => false,
37
37
  private sessionSigner : SessionSigner = new SessionSigner(),
38
38
  private userDirectory? : UserDirectory,
39
- private memberships? : MembershipService) {}
39
+ private memberships? : MembershipService,
40
+ private operatorKey? : string) {}
40
41
 
41
42
  async apply(request : Request) : Promise<boolean> {
42
43
  if (this.isOpen(request)) return true;
43
44
 
45
+ /* The platform's operators, presenting the control plane's own key,
46
+ reach exactly one thing: the managed skills they look after. Nothing
47
+ else is open to the key, so holding it is not a way into a tenant. */
48
+ if (this.operatorKey && request.resource === "skills" && request.header("x-anbaric-central-key") === this.operatorKey) {
49
+ request.operator = true;
50
+ return true;
51
+ }
52
+
44
53
  if (this.tokenAuthenticator?.handles(request.raw)) {
45
54
  const user = await this.tokenAuthenticator.authenticate(request.raw, request.rawResponse);
46
55
  if (!user) return false;
package/src/main.ts CHANGED
@@ -27,6 +27,7 @@ import {DocGenerator} from "./docs/DocGenerator";
27
27
  import {GatewayDocModel} from "./docs/GatewayDocModel";
28
28
  import {PostgresAppDocsStore} from "./data-store/PostgresAppDocsStore";
29
29
  import {PostgresAppSettingsStore} from "./data-store/PostgresAppSettingsStore";
30
+ import {PostgresSkillStore} from "./data-store/PostgresSkillStore";
30
31
  import {PostgresEntitlementStore} from "./data-store/PostgresEntitlementStore";
31
32
  import {PostgresUserDirectory} from "./data-store/PostgresUserDirectory";
32
33
  import {HttpMembershipService} from "./auth/HttpMembershipService";
@@ -156,7 +157,9 @@ const server = new HostingServer(jobs, queue, registry, buildLayer,
156
157
  tokenAuthenticator, process.env.ANBARIC_TENANT, auditRecords, plugins,
157
158
  new PostgresJobRunSchedulePersistence(pool), await loadNotifier(process.env.ANBARIC_NOTIFIER_MODULE),
158
159
  new PostgresEntitlementStore(pool), new PostgresUserDirectory(pool), memberships,
159
- (appId) => new PostgresPromptManager(pool, appId), fileStorageFor, subdomains, billing, appSettings);
160
+ (appId) => new PostgresPromptManager(pool, appId), fileStorageFor, subdomains, billing, appSettings, new PostgresSkillStore(pool),
161
+ // The control plane's key, which the operators hold, is what writes a managed skill.
162
+ process.env.ANBARIC_CLI_KEY_LOOKUP_SECRET || undefined);
160
163
  const port = await server.listen(hostingPort);
161
164
  const internal = await server.listenInternal(internalPort);
162
165