anbaric-cloud-hosting 1.70.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.70.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.70.0",
22
- "anbaric-plugins": "^1.70.0",
23
- "anbaric-tsapi": "^1.70.0",
24
- "anbaric-web": "^1.70.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.70.0",
32
- "anbaric-state-machine": "^1.70.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
  }
@@ -287,6 +287,24 @@ const PLATFORM_MIGRATIONS : Array<Migration> = [
287
287
  )`,
288
288
  ),
289
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
+
290
308
  ];
291
309
 
292
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. */
@@ -1,13 +1,14 @@
1
1
  import {Pool} from "pg";
2
2
  import {Skill, SkillDraft, SkillStore} from "./SkillStore";
3
3
 
4
- const COLUMNS = "id, title, usage_hints, body, created_at, updated_at";
4
+ const COLUMNS = "id, title, usage_hints, body, locked, created_at, updated_at";
5
5
 
6
6
  const rowToSkill = (row : any) : Skill => ({
7
7
  id: row.id,
8
8
  title: row.title,
9
9
  usageHints: row.usage_hints,
10
10
  body: row.body,
11
+ locked: row.locked,
11
12
  createdAt: row.created_at,
12
13
  updatedAt: row.updated_at,
13
14
  });
@@ -28,12 +29,13 @@ class PostgresSkillStore implements SkillStore {
28
29
 
29
30
  async save(draft : SkillDraft) : Promise<Skill> {
30
31
  const result = await this.pool.query(
31
- `INSERT INTO anbaric_system.skills (id, title, usage_hints, body)
32
- VALUES ($1, $2, $3, $4)
32
+ `INSERT INTO anbaric_system.skills (id, title, usage_hints, body, locked)
33
+ VALUES ($1, $2, $3, $4, $5)
33
34
  ON CONFLICT (id) DO UPDATE
34
- SET title = EXCLUDED.title, usage_hints = EXCLUDED.usage_hints, body = EXCLUDED.body, updated_at = now()
35
+ SET title = EXCLUDED.title, usage_hints = EXCLUDED.usage_hints, body = EXCLUDED.body,
36
+ locked = EXCLUDED.locked, updated_at = now()
35
37
  RETURNING ${COLUMNS}`,
36
- [draft.id, draft.title, draft.usageHints, draft.body]);
38
+ [draft.id, draft.title, draft.usageHints, draft.body, draft.locked]);
37
39
  return rowToSkill(result.rows[0]);
38
40
  }
39
41
 
@@ -7,6 +7,9 @@ type Skill = {
7
7
  title : string,
8
8
  usageHints : string,
9
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,
10
13
  createdAt : Date,
11
14
  updatedAt : Date,
12
15
  };
@@ -86,7 +86,8 @@ class HostingServer {
86
86
  subdomains? : Subdomains,
87
87
  billing? : CentralBilling,
88
88
  appSettings? : AppSettingsStore,
89
- skills? : SkillStore) {
89
+ skills? : SkillStore,
90
+ operatorKey? : string) {
90
91
  const pages = new PagesHandler();
91
92
  const ping = new PingHandler(tenant);
92
93
  const jobs = new JobsHandler(persistence);
@@ -175,7 +176,7 @@ class HostingServer {
175
176
  (request.method === "GET" && CLI_KEY_POLL.test(request.url.pathname));
176
177
 
177
178
  const authentication =
178
- new AuthenticationMiddleware(authenticator, tokenAuthenticator, openRequests, undefined, userDirectory, memberships);
179
+ new AuthenticationMiddleware(authenticator, tokenAuthenticator, openRequests, undefined, userDirectory, memberships, operatorKey);
179
180
 
180
181
  /* Entitlements an inviter chose before this person had a user id were
181
182
  put aside under their email address; their first sign-in is when
@@ -183,6 +184,9 @@ class HostingServer {
183
184
  if (entitlements) {
184
185
  authentication.claimWhatIsOwed = async user => {
185
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);
186
190
  };
187
191
  }
188
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
 
@@ -6,8 +6,10 @@ import {RequestHandler} from "../RequestHandler";
6
6
  /* The tenant's skills: guidance its builders write for the agents that work
7
7
  on it. Anyone in the tenant may read them - an agent acting for a user
8
8
  needs them as much as one acting for a builder - but writing them is a
9
- builder's job. Served to people and their agents only, never to deployed
10
- apps, which is why this is registered on the public entry point alone. */
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. */
11
13
  class SkillsHandler implements RequestHandler {
12
14
 
13
15
  constructor(private skills : SkillStore) {}
@@ -31,20 +33,29 @@ class SkillsHandler implements RequestHandler {
31
33
  return request.reply(200, this.publicSkill(skill));
32
34
  }
33
35
  case "PUT": {
34
- if (deniesBuild(request.user?.tenantRole)) return this.refuse(request);
36
+ if (! request.operator && deniesBuild(request.user?.tenantRole)) return this.refuse(request);
35
37
  if (! isSkillId(id)) {
36
38
  return request.reply(400, { error: `"${id}" is not a skill id: lower-case letters, digits and hyphens, up to 50 characters` });
37
39
  }
40
+ const existing = await this.skills.get(id);
41
+ if (existing?.locked && ! request.operator) return this.managed(request);
42
+
38
43
  const body = await request.body();
39
44
  const draft = this.draftFrom(body);
40
45
  if (! draft) return request.reply(400, { error: "Expected a body of { title : string, usageHints : string, body : string }, none empty" });
41
46
 
42
- return request.reply(200, this.publicSkill(await this.skills.save({ id, ...draft })));
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 })));
43
50
  }
44
51
  case "DELETE": {
45
- if (deniesBuild(request.user?.tenantRole)) return this.refuse(request);
46
- const removed = await this.skills.remove(id);
47
- return removed ? request.reply(204) : request.reply(404, { error: `No skill with id "${id}"` });
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);
48
59
  }
49
60
  }
50
61
  request.notFound();
@@ -54,6 +65,10 @@ class SkillsHandler implements RequestHandler {
54
65
  request.reply(403, { error: "Your role in this tenant cannot manage skills" });
55
66
  }
56
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
+
57
72
  private draftFrom(body : unknown) : { title : string, usageHints : string, body : string } | undefined {
58
73
  const fields = body as Record<string, unknown> | undefined;
59
74
  const text = (value : unknown) : string | undefined =>
@@ -71,6 +86,7 @@ class SkillsHandler implements RequestHandler {
71
86
  title: skill.title,
72
87
  usageHints: skill.usageHints,
73
88
  body: skill.body,
89
+ locked: skill.locked,
74
90
  createdAt: skill.createdAt.toISOString(),
75
91
  updatedAt: skill.updatedAt.toISOString(),
76
92
  };
@@ -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
@@ -157,7 +157,9 @@ const server = new HostingServer(jobs, queue, registry, buildLayer,
157
157
  tokenAuthenticator, process.env.ANBARIC_TENANT, auditRecords, plugins,
158
158
  new PostgresJobRunSchedulePersistence(pool), await loadNotifier(process.env.ANBARIC_NOTIFIER_MODULE),
159
159
  new PostgresEntitlementStore(pool), new PostgresUserDirectory(pool), memberships,
160
- (appId) => new PostgresPromptManager(pool, appId), fileStorageFor, subdomains, billing, appSettings, new PostgresSkillStore(pool));
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);
161
163
  const port = await server.listen(hostingPort);
162
164
  const internal = await server.listenInternal(internalPort);
163
165