anbaric-cloud-hosting 1.60.0 → 1.62.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.60.0",
3
+ "version": "1.62.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.60.0",
22
- "anbaric-plugins": "^1.60.0",
23
- "anbaric-tsapi": "^1.60.0",
24
- "anbaric-web": "^1.60.0",
21
+ "anbaric-data-store": "^1.62.0",
22
+ "anbaric-plugins": "^1.62.0",
23
+ "anbaric-tsapi": "^1.62.0",
24
+ "anbaric-web": "^1.62.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.60.0",
32
- "anbaric-state-machine": "^1.60.0",
31
+ "anbaric-impl-cloud": "^1.62.0",
32
+ "anbaric-state-machine": "^1.62.0",
33
33
  "tsx": "^4.20.0",
34
34
  "typescript": "^7.0.2"
35
35
  },
@@ -39,6 +39,8 @@ abstract class BaseBuildLayer implements BuildLayer {
39
39
  protected deployments = new Map<string, Deployment>();
40
40
  protected docGenerator? : DocGenerator;
41
41
  consumerUrlsFor : (appName : string) => Array<string> = () => [];
42
+
43
+ appRemoved : (appName : string) => void = () => {};
42
44
  private nextAppIndex = 0;
43
45
  private hydration? : Promise<void>;
44
46
 
@@ -116,6 +118,7 @@ abstract class BaseBuildLayer implements BuildLayer {
116
118
  deployment.status = "stopped";
117
119
  await this.stop(deployment);
118
120
  if (this.deployments.get(appName) === deployment) this.deployments.delete(appName);
121
+ this.appRemoved(appName);
119
122
  };
120
123
 
121
124
  if (deployment.status === "running" && this.consumerUrlsFor(appName).length > 0) {
@@ -37,6 +37,12 @@ interface BuildLayer {
37
37
  // to drain; the hosting server supplies it from its consumer registry.
38
38
  consumerUrlsFor : (appName : string) => Array<string>;
39
39
 
40
+ /* Called once an app is really gone, which for one that drained is some
41
+ minutes after the teardown returned. Whatever outlives the app - its
42
+ public address, in a hosted platform - is let go here, because this is
43
+ the only moment both paths out of teardown pass through. */
44
+ appRemoved : (appName : string) => void;
45
+
40
46
  }
41
47
 
42
48
  export type { BuildLayer, DeploymentStatus, DeploymentSummary, Draining };
@@ -0,0 +1,19 @@
1
+ /* Where an app is served, decided in one place because the console, the CLI
2
+ and anything else that shows a person a link must all show the same one.
3
+
4
+ Its own address, allocated by the control plane, which is the only form on
5
+ Anbaric Cloud. Then the app's name under the configured host suffix, which
6
+ is what a self-hoster gets who has pointed their own wildcard at the
7
+ platform and has no control plane to allocate anything - one platform serves
8
+ one tenant there, so the name alone is unambiguous. Then the path form,
9
+ which always works and is what a platform run locally uses.
10
+
11
+ An app's own hostname serves it at the root, so links, assets and fetches
12
+ inside it behave as they did on the machine it was built on. */
13
+ const appAddress = (appName : string, subdomain? : string, hostSuffix : string = process.env.ANBARIC_APP_HOST_SUFFIX ?? "") : string => {
14
+ if (! hostSuffix) return `/app/${appName}`;
15
+
16
+ return `https://${subdomain ?? appName}.${hostSuffix}`;
17
+ };
18
+
19
+ export { appAddress };
@@ -1,39 +1,32 @@
1
1
  /* An app is reachable at a hostname of its own, so that it is served at the
2
- root of it and works exactly as it did when it was built locally. Two tenants
3
- will both have a hello-world, so the tenant is part of the name.
2
+ root of it and works exactly as it did when it was built locally.
4
3
 
5
- One label, not two: a TLS wildcard matches a single label, so app.tenant.domain
6
- would need a certificate and a DNS record for every tenant, which would put a
7
- person in the middle of a self-serve signup. app--tenant.domain needs neither,
8
- ever. The separator is a double hyphen because an underscore is not legal in a
9
- hostname and would be rejected by certificates and by some browsers. */
4
+ The address itself is one DNS label and belongs to the control plane, which
5
+ is the only thing that can see every tenant and so the only thing that can
6
+ keep an address unique. What is checked here is narrower and local: that the
7
+ app's name could be a hostname label at all, since it is the first address
8
+ the control plane will be asked for.
10
9
 
11
- const SEPARATOR = "--";
10
+ A hosted app whose name is already taken is given a different address rather
11
+ than refused, so this is about shape, never about availability. */
12
12
 
13
- // A DNS label may not exceed 63 characters, and the whole label is the app and
14
- // the tenant together, so the limit is shared between them.
15
- const LABEL_LIMIT = 63;
13
+ /* A DNS label may not exceed 63 characters, and the control plane may have to
14
+ add a distinguishing word to a name two tenants both wanted - so a name is
15
+ held well short of the limit to leave room for one. */
16
+ const NAME_LIMIT = 48;
16
17
 
17
18
  const SHAPE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
18
19
 
19
- const hostLabelFor = (appName : string, tenant : string) : string => `${appName}${SEPARATOR}${tenant}`;
20
-
21
20
  /* Why this name cannot have a hostname, or undefined when it can. Returned as a
22
21
  sentence because it is shown to whoever ran the deploy. */
23
- const hostnameObjection = (appName : string, tenant : string) : string | undefined => {
22
+ const hostnameObjection = (appName : string) : string | undefined => {
24
23
  if (! SHAPE.test(appName)) {
25
24
  return `"${appName}" must be lower-case letters, numbers and hyphens, starting and ending with a letter or number`;
26
25
  }
27
- if (appName.includes(SEPARATOR)) {
28
- return `"${appName}" cannot contain "${SEPARATOR}" - it is what separates the app from the tenant in its hostname`;
29
- }
30
- if (! tenant) return undefined;
31
-
32
- const label = hostLabelFor(appName, tenant);
33
- if (label.length > LABEL_LIMIT) {
34
- return `"${appName}" is too long for a hostname alongside tenant "${tenant}": ${label.length} characters, and a hostname label allows ${LABEL_LIMIT}`;
26
+ if (appName.length > NAME_LIMIT) {
27
+ return `"${appName}" is too long for an app name: ${appName.length} characters, and the limit is ${NAME_LIMIT}`;
35
28
  }
36
29
  return undefined;
37
30
  };
38
31
 
39
- export { hostLabelFor, hostnameObjection, SEPARATOR, LABEL_LIMIT };
32
+ export { hostnameObjection, NAME_LIMIT };
@@ -16,6 +16,12 @@ interface AuditRecordStore {
16
16
  save(record : AuditRecord) : Promise<void>;
17
17
  list(filter : AuditFilter) : Promise<Array<AuditRecord>>;
18
18
 
19
+ /* Removes records older than the moment given, at most `limit` of them,
20
+ oldest first, and says how many went - so a backlog is cleared a batch
21
+ at a time rather than in one statement that holds a lock and leaves
22
+ every dead row behind at once. */
23
+ deleteBefore(before : Date, limit : number) : Promise<number>;
24
+
19
25
  }
20
26
 
21
27
  export type { AuditFilter, AuditRecordStore }
@@ -0,0 +1,67 @@
1
+ import {AuditRecordStore} from "./AuditRecordStore";
2
+
3
+ const SWEEP_INTERVAL_MS = 24 * 60 * 60_000;
4
+ const BATCH_SIZE = 5_000;
5
+ const BATCHES_PER_SWEEP = 200;
6
+
7
+ /* How long audit records are kept unless the platform is told otherwise. A
8
+ year: long enough that nobody is surprised by what has gone, short enough
9
+ that the table does not grow for ever. Zero means keep everything. */
10
+ const DEFAULT_RETENTION_DAYS = 365;
11
+
12
+ const DAY_MS = 24 * 60 * 60_000;
13
+
14
+ /* Removes audit records older than the retention, once a day and once at
15
+ start - a platform that restarts on every release would otherwise never
16
+ reach its first daily tick. Deletion is done a batch at a time rather than
17
+ in one statement, so clearing a backlog neither holds a lock for long nor
18
+ leaves more dead rows at once than autovacuum can keep up with, and each
19
+ sweep stops after a bounded number of batches so a very large backlog is
20
+ worked through over several days rather than in one sitting. */
21
+ class AuditSweep {
22
+
23
+ private ticker? : NodeJS.Timeout;
24
+ private sweeping = false;
25
+
26
+ constructor(private records : AuditRecordStore,
27
+ private retentionDays : number = DEFAULT_RETENTION_DAYS,
28
+ private intervalMs : number = SWEEP_INTERVAL_MS,
29
+ private batchSize : number = BATCH_SIZE) {}
30
+
31
+ start() : void {
32
+ if (this.ticker || this.retentionDays <= 0) return;
33
+ void this.sweep();
34
+ this.ticker = setInterval(() => void this.sweep(), this.intervalMs);
35
+ this.ticker.unref();
36
+ }
37
+
38
+ async sweep() : Promise<number> {
39
+ if (this.sweeping || this.retentionDays <= 0) return 0;
40
+ this.sweeping = true;
41
+
42
+ const before = new Date(Date.now() - this.retentionDays * DAY_MS);
43
+ let removed = 0;
44
+ try {
45
+ for (let batch = 0; batch < BATCHES_PER_SWEEP; batch++) {
46
+ const went = await this.records.deleteBefore(before, this.batchSize);
47
+ removed += went;
48
+ if (went < this.batchSize) break;
49
+ }
50
+ if (removed > 0) console.log(`[audit] removed ${removed} record(s) older than ${this.retentionDays} days`);
51
+ return removed;
52
+ } catch (error) {
53
+ console.error(`[audit] the retention sweep failed: ${error instanceof Error ? error.message : error}`);
54
+ return removed;
55
+ } finally {
56
+ this.sweeping = false;
57
+ }
58
+ }
59
+
60
+ async cleanUp() : Promise<void> {
61
+ if (this.ticker) clearInterval(this.ticker);
62
+ this.ticker = undefined;
63
+ }
64
+
65
+ }
66
+
67
+ export { AuditSweep, DEFAULT_RETENTION_DAYS };
@@ -26,6 +26,16 @@ class InMemoryAuditRecordStore implements AuditRecordStore {
26
26
  .slice(page * pageSize, (page + 1) * pageSize);
27
27
  }
28
28
 
29
+ async deleteBefore(before : Date, limit : number) : Promise<number> {
30
+ const stale = this.records
31
+ .filter(record => new Date(record.at ?? 0) < before)
32
+ .sort((left, right) => String(left.at).localeCompare(String(right.at)))
33
+ .slice(0, limit);
34
+
35
+ this.records = this.records.filter(record => ! stale.includes(record));
36
+ return stale.length;
37
+ }
38
+
29
39
  }
30
40
 
31
41
  export { InMemoryAuditRecordStore }
@@ -31,8 +31,8 @@ class HttpMembershipService implements MembershipService {
31
31
  return found;
32
32
  }
33
33
 
34
- async invite(email : string, role : TenantRole, invitedBy : Inviter) : Promise<PendingInvitation> {
35
- const response = await this.call("POST", "/invitations", { email, role, invitedBy });
34
+ async invite(email : string, role : TenantRole, invitedBy : Inviter, landingApp? : string) : Promise<PendingInvitation> {
35
+ const response = await this.call("POST", "/invitations", { email, role, invitedBy, landingApp });
36
36
  return await response.json() as PendingInvitation;
37
37
  }
38
38
 
@@ -17,13 +17,14 @@ class InMemoryMembershipService implements MembershipService {
17
17
  return this.members.get(userId);
18
18
  }
19
19
 
20
- async invite(email : string, role : TenantRole, invitedBy : Inviter) : Promise<PendingInvitation> {
20
+ async invite(email : string, role : TenantRole, invitedBy : Inviter, landingApp? : string) : Promise<PendingInvitation> {
21
21
  const token = randomUUID();
22
22
  const now = new Date();
23
23
  const invitation : PendingInvitation = {
24
24
  token,
25
25
  email: email.trim().toLowerCase(),
26
26
  role,
27
+ landingApp,
27
28
  invitedBy: invitedBy.id,
28
29
  invitedByName: invitedBy.name,
29
30
  link: `${this.linkBase}/${token}`,
@@ -5,6 +5,7 @@ type PendingInvitation = {
5
5
  token : string,
6
6
  email : string,
7
7
  role : TenantRole,
8
+ landingApp? : string,
8
9
  invitedBy : string,
9
10
  invitedByName? : string,
10
11
  link : string,
@@ -29,7 +30,10 @@ interface MembershipService {
29
30
 
30
31
  roleFor(userId : string) : Promise<TenantRole | undefined>;
31
32
 
32
- invite(email : string, role : TenantRole, invitedBy : Inviter) : Promise<PendingInvitation>;
33
+ /* landingApp is the app the invitation should open on rather than the
34
+ person's app directory. The control plane turns it into an address when
35
+ the invitation is accepted, so a later change of address still works. */
36
+ invite(email : string, role : TenantRole, invitedBy : Inviter, landingApp? : string) : Promise<PendingInvitation>;
33
37
 
34
38
  pending(asking : Inviter) : Promise<Array<PendingInvitation>>;
35
39
 
@@ -22,6 +22,18 @@ type EntitlementGrant = {
22
22
 
23
23
  };
24
24
 
25
+ /* An entitlement chosen for somebody who has not signed in yet. An invitation
26
+ names an email address; a grant names a user id, which that person does not
27
+ have until they arrive. This is the gap between the two. */
28
+ type EntitlementOffer = {
29
+
30
+ email : string,
31
+ appId : string | null,
32
+ entitlementId : string,
33
+ notes : string,
34
+
35
+ };
36
+
25
37
  /* The platform's view of entitlements: what apps can do (register, has) plus
26
38
  the management the console performs on their behalf. */
27
39
  interface EntitlementStore extends Entitlements {
@@ -36,6 +48,16 @@ interface EntitlementStore extends Entitlements {
36
48
 
37
49
  listGrants(userId? : string) : Promise<Array<EntitlementGrant>>;
38
50
 
51
+ // Held against an email address until whoever holds it signs in.
52
+ offer(offers : Array<EntitlementOffer>, offeredBy : string) : Promise<void>;
53
+
54
+ offersFor(email : string) : Promise<Array<EntitlementOffer>>;
55
+
56
+ /* Turns everything offered to this email into real grants and forgets the
57
+ offers. Returns how many were made, and is safe to call on every sign-in
58
+ because the second call has nothing left to claim. */
59
+ claim(email : string, userId : string) : Promise<number>;
60
+
39
61
  }
40
62
 
41
- export type { EntitlementStore, EntitlementDefinition, EntitlementGrant }
63
+ export type { EntitlementStore, EntitlementDefinition, EntitlementGrant, EntitlementOffer }
@@ -1,10 +1,11 @@
1
1
  import {randomUUID} from "node:crypto";
2
- import {EntitlementDefinition, EntitlementGrant, EntitlementStore} from "./EntitlementStore";
2
+ import {EntitlementDefinition, EntitlementGrant, EntitlementOffer, EntitlementStore} from "./EntitlementStore";
3
3
 
4
4
  class InMemoryEntitlementStore implements EntitlementStore {
5
5
 
6
6
  private definitions = new Map<string, EntitlementDefinition>();
7
7
  private grants = new Map<string, EntitlementGrant>();
8
+ private offers = new Map<string, { offer : EntitlementOffer, offeredBy : string }>();
8
9
 
9
10
  constructor() {
10
11
  this.definitions.set(this.key(null, "access"), { appId: null, entitlementId: "access", notes: "Global access" });
@@ -46,6 +47,29 @@ class InMemoryEntitlementStore implements EntitlementStore {
46
47
  .map(grant => ({ ...grant }));
47
48
  }
48
49
 
50
+ // Held against an email address; see PostgresEntitlementStore.
51
+ async offer(offers : Array<EntitlementOffer>, offeredBy : string) : Promise<void> {
52
+ for (const offer of offers) {
53
+ this.offers.set(`${offer.email} ${this.key(offer.appId, offer.entitlementId)}`, { offer, offeredBy });
54
+ }
55
+ }
56
+
57
+ async offersFor(email : string) : Promise<Array<EntitlementOffer>> {
58
+ return [...this.offers.values()]
59
+ .filter(held => held.offer.email === email)
60
+ .map(held => ({ ...held.offer }));
61
+ }
62
+
63
+ async claim(email : string, userId : string) : Promise<number> {
64
+ const owed = [...this.offers.entries()].filter(([, held]) => held.offer.email === email);
65
+
66
+ for (const [key, held] of owed) {
67
+ await this.grant(userId, held.offer.appId, held.offer.entitlementId, held.offer.notes, held.offeredBy);
68
+ this.offers.delete(key);
69
+ }
70
+ return owed.length;
71
+ }
72
+
49
73
  private key(appId : string | null, entitlementId : string) : string {
50
74
  return `${appId ?? ""}/${entitlementId}`;
51
75
  }
@@ -237,6 +237,31 @@ const PLATFORM_MIGRATIONS : Array<Migration> = [
237
237
  "CREATE INDEX jobs_heartbeat ON jobs (heartbeat_at) WHERE heartbeat_at IS NOT NULL",
238
238
  ),
239
239
 
240
+ /* Entitlements chosen for someone who has not arrived yet. An invitation
241
+ goes to an email address and a grant is made against a user id, which
242
+ that person does not have until the first time they sign in - so what
243
+ the inviter chose waits here under the email and is turned into real
244
+ grants at that moment. Rows are deleted as they are claimed, so this
245
+ table holds only what is still owed. */
246
+ statements("005-entitlement-invites",
247
+ `CREATE TABLE IF NOT EXISTS entitlement_invites (
248
+ email TEXT NOT NULL,
249
+ app_id TEXT,
250
+ entitlement_id TEXT NOT NULL,
251
+ notes TEXT NOT NULL DEFAULT '',
252
+ offered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
253
+ offered_by TEXT NOT NULL
254
+ )`,
255
+ `CREATE UNIQUE INDEX IF NOT EXISTS entitlement_invites_key
256
+ ON entitlement_invites (email, COALESCE(app_id, ''), entitlement_id)`,
257
+ ),
258
+
259
+ // Audit records are weeded by age, which without this is a scan of the
260
+ // whole table every day.
261
+ statements("006-audit-records-by-age",
262
+ "CREATE INDEX IF NOT EXISTS audit_records_at ON anbaric_system.audit_records (at)",
263
+ ),
264
+
240
265
  ];
241
266
 
242
267
  export { PLATFORM_MIGRATIONS };
@@ -92,6 +92,21 @@ class PostgresAuditRecordStore implements AuditRecordStore {
92
92
  }));
93
93
  }
94
94
 
95
+ /* Oldest first and bounded, so the sweep that calls this can work through
96
+ a large backlog a batch at a time. The subquery is what the index on
97
+ `at` serves; the delete itself goes by primary key. */
98
+ async deleteBefore(before : Date, limit : number) : Promise<number> {
99
+ const result = await this.pool.query(
100
+ `DELETE FROM anbaric_system.audit_records
101
+ WHERE id IN (
102
+ SELECT id FROM anbaric_system.audit_records
103
+ WHERE at < $1 ORDER BY at, id LIMIT $2
104
+ )`,
105
+ [before, limit],
106
+ );
107
+ return result.rowCount ?? 0;
108
+ }
109
+
95
110
  }
96
111
 
97
112
  export { PostgresAuditRecordStore }
@@ -1,6 +1,6 @@
1
1
  import {randomUUID} from "node:crypto";
2
2
  import {Pool} from "pg";
3
- import {EntitlementDefinition, EntitlementGrant, EntitlementStore} from "./EntitlementStore";
3
+ import {EntitlementDefinition, EntitlementGrant, EntitlementOffer, EntitlementStore} from "./EntitlementStore";
4
4
 
5
5
  class PostgresEntitlementStore implements EntitlementStore {
6
6
 
@@ -64,6 +64,45 @@ class PostgresEntitlementStore implements EntitlementStore {
64
64
  }));
65
65
  }
66
66
 
67
+ /* Held against an email address until whoever holds it signs in, because
68
+ an invitation names an email and a grant names a user id. */
69
+ async offer(offers : Array<EntitlementOffer>, offeredBy : string) : Promise<void> {
70
+ for (const offer of offers) {
71
+ await this.pool.query(
72
+ `INSERT INTO entitlement_invites (email, app_id, entitlement_id, notes, offered_by)
73
+ VALUES ($1, $2, $3, $4, $5)
74
+ ON CONFLICT (email, COALESCE(app_id, ''), entitlement_id)
75
+ DO UPDATE SET notes = EXCLUDED.notes, offered_by = EXCLUDED.offered_by`,
76
+ [offer.email, offer.appId, offer.entitlementId, offer.notes, offeredBy]);
77
+ }
78
+ }
79
+
80
+ async offersFor(email : string) : Promise<Array<EntitlementOffer>> {
81
+ const result = await this.pool.query(
82
+ `SELECT email, app_id, entitlement_id, notes FROM entitlement_invites
83
+ WHERE lower(email) = lower($1) ORDER BY app_id NULLS FIRST, entitlement_id`,
84
+ [email]);
85
+ return result.rows.map(row => ({
86
+ email: row.email, appId: row.app_id, entitlementId: row.entitlement_id, notes: row.notes,
87
+ }));
88
+ }
89
+
90
+ /* Claimed in one statement so a second sign-in arriving at the same moment
91
+ cannot grant the same thing twice: the delete is what decides who owns
92
+ each row, and only rows it actually removed become grants. */
93
+ async claim(email : string, userId : string) : Promise<number> {
94
+ const result = await this.pool.query(
95
+ `WITH claimed AS (
96
+ DELETE FROM entitlement_invites WHERE lower(email) = lower($1)
97
+ RETURNING app_id, entitlement_id, notes, offered_by
98
+ )
99
+ INSERT INTO entitlement_grants (id, user_id, app_id, entitlement_id, notes, granted_by)
100
+ SELECT gen_random_uuid()::text, $2, app_id, entitlement_id, notes, offered_by FROM claimed
101
+ RETURNING id`,
102
+ [email, userId]);
103
+ return result.rowCount ?? 0;
104
+ }
105
+
67
106
  private async upsertDefinition(appId : string | null, entitlementId : string, notes : string) : Promise<void> {
68
107
  await this.pool.query(
69
108
  `INSERT INTO entitlements (app_id, entitlement_id, notes)
@@ -9,6 +9,8 @@ import {ConsumerRegistry} from "../queuing/ConsumerRegistry";
9
9
  import {AppProxyHandler} from "./handlers/AppProxyHandler";
10
10
  import {AppLinkFallbackHandler} from "./handlers/AppLinkFallbackHandler";
11
11
  import {AppsHandler} from "./handlers/AppsHandler";
12
+ import {MyAppsHandler} from "./handlers/MyAppsHandler";
13
+ import {Subdomains} from "../subdomains/Subdomains";
12
14
  import {AuditsHandler} from "./handlers/AuditsHandler";
13
15
  import {AuthorizeCliHandler} from "./handlers/auth/AuthorizeCliHandler";
14
16
  import {KeysHandler} from "./handlers/auth/KeysHandler";
@@ -74,7 +76,8 @@ class HostingServer {
74
76
  userDirectory? : UserDirectory,
75
77
  memberships? : MembershipService,
76
78
  promptManagerFor? : (appId : string) => PromptManager,
77
- fileStorageFor? : (appId : string) => FileStorage) {
79
+ fileStorageFor? : (appId : string) => FileStorage,
80
+ subdomains? : Subdomains) {
78
81
  const pages = new PagesHandler();
79
82
  const ping = new PingHandler(tenant);
80
83
  const jobs = new JobsHandler(persistence);
@@ -107,7 +110,7 @@ class HostingServer {
107
110
  if (notifier) publicRouter.registerApi("notifications", new NotificationsHandler(notifier));
108
111
  if (entitlements) publicRouter.registerApi("entitlements", new EntitlementsAdminHandler(entitlements));
109
112
  if (userDirectory) publicRouter.registerApi("users", new UsersHandler(userDirectory));
110
- if (memberships) publicRouter.registerApi("invitations", new InvitationsHandler(memberships));
113
+ if (memberships) publicRouter.registerApi("invitations", new InvitationsHandler(memberships, entitlements));
111
114
  const prompts = promptManagerFor && new PromptsHandler(promptManagerFor);
112
115
  if (prompts) publicRouter.registerApi("prompts", prompts);
113
116
  if (cliAuthorizer) {
@@ -120,8 +123,15 @@ class HostingServer {
120
123
  // are wherever they registered themselves.
121
124
  buildLayer.consumerUrlsFor = appName =>
122
125
  [...new Set(registry.list().filter(consumer => consumer.appId === appName).map(consumer => consumer.url))];
123
- publicRouter.registerApi("apps", new AppsHandler(buildLayer, tenant));
126
+ buildLayer.appRemoved = appName => void subdomains?.release(appName);
127
+ publicRouter.registerApi("apps", new AppsHandler(buildLayer, tenant, subdomains));
124
128
  publicRouter.register("app", appProxy);
129
+
130
+ // The directory a person who is not a builder sees instead of the
131
+ // console, so it only exists where entitlements decide access.
132
+ if (entitlements) {
133
+ publicRouter.registerApi("my-apps", new MyAppsHandler(buildLayer, entitlements, subdomains));
134
+ }
125
135
  }
126
136
  // An unrouted absolute path carrying an app Referer is an app-internal
127
137
  // link the proxy's prefix-stripping left bare; send it back to its app.
@@ -151,10 +161,22 @@ class HostingServer {
151
161
  request.url.pathname === "/favicon.ico" ||
152
162
  (request.method === "GET" && CLI_KEY_POLL.test(request.url.pathname));
153
163
 
164
+ const authentication =
165
+ new AuthenticationMiddleware(authenticator, tokenAuthenticator, openRequests, undefined, userDirectory, memberships);
166
+
167
+ /* Entitlements an inviter chose before this person had a user id were
168
+ put aside under their email address; their first sign-in is when
169
+ they can finally be granted. */
170
+ if (entitlements) {
171
+ authentication.claimWhatIsOwed = async user => {
172
+ if (user.email) await entitlements.claim(user.email, user.id);
173
+ };
174
+ }
175
+
154
176
  this.publicServer = new Server(publicRouter, [
155
177
  new SessionMiddleware(),
156
178
  new TenantRoutingMiddleware(tenant),
157
- new AuthenticationMiddleware(authenticator, tokenAuthenticator, openRequests, undefined, userDirectory, memberships),
179
+ authentication,
158
180
  /* After authentication and before routing: an app reached by its own
159
181
  hostname owns every path on it, so nothing of the platform's would
160
182
  match anyway - but a nicer address must not also be a way in