@antasphere/chassis-server 1.4.0 → 1.5.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.
@@ -0,0 +1,198 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { eq } from 'drizzle-orm';
3
+ import { workspaces, type Db } from '@antasphere/chassis-db';
4
+ import type { AppsList, Principal } from '@antasphere/chassis-contract';
5
+ import type { GrantAccess } from '../identity/hub-grant.js';
6
+ import type { Logger } from '../logger.js';
7
+ import { HUB_APP_NAME, appsFromFile, readOrganization, safeHttpUrl, selectHubApps } from './select.js';
8
+
9
+ /**
10
+ * The Apps page's list (`GET /apps`, PRDCT-3493): the other tools of the
11
+ * person's organization, and the account that signs them in.
12
+ *
13
+ * On the cloud edition the hub is asked AS THE PERSON, with their own grant
14
+ * (the chassis's `HubGrantService`, the same stored grant and the same
15
+ * cross-replica refresh lock the reconciler uses): ONE call, `GET
16
+ * <hub>/api/v1/me/apps` with `X-Workspace-Id` naming the workspace's hub
17
+ * organization (`workspaces.central_account_id`), answers the catalogue as
18
+ * the person sees it there (the restricted tools they may not see already
19
+ * left out by the hub) with the access verdict per tool; the tools not open
20
+ * are left out here. A SUSPENDED organization (the hub's `organization.status`)
21
+ * is served as an empty list carrying the organization and its status, never
22
+ * as the tools: the hub's contract says a tool's Apps service never serves
23
+ * such a list, and the dashboard says why. The answer is held five minutes
24
+ * per person and workspace. A grant that is missing or dead, a hub that does
25
+ * not answer, a malformed body, or a workspace that is no hub organization
26
+ * falls back to the operator's file, never to an error: the page is a
27
+ * convenience, not an access decision (each tool still asks the hub when it
28
+ * is opened).
29
+ *
30
+ * On a self-hosted instance the list is the operator's file (`APPS_FILE`),
31
+ * read on every call; without one the page lists nothing.
32
+ */
33
+
34
+ /** How long a person's hub answer is held. */
35
+ export const APPS_CACHE_TTL_MS = 5 * 60_000;
36
+ /** At most this many answers are held; the oldest goes first. */
37
+ const APPS_CACHE_MAX = 5_000;
38
+ /** The hub read's budget: the page waits on it. */
39
+ const HUB_TIMEOUT_MS = 3_000;
40
+
41
+ /** The as-the-person token source: what the page needs of the chassis's `HubGrantService`. */
42
+ export interface HubTokens {
43
+ accessToken(userId: string): Promise<GrantAccess>;
44
+ refresh(userId: string): Promise<GrantAccess>;
45
+ invalidateAccess(userId: string): void;
46
+ }
47
+
48
+ export interface AppsServiceOptions {
49
+ db: Db;
50
+ logger: Logger;
51
+ edition: 'oss' | 'cloud';
52
+ /** HUB_ISSUER_URL (cloud). */
53
+ hubIssuerUrl?: string | undefined;
54
+ /** APPS_FILE. */
55
+ appsFile?: string | undefined;
56
+ /** This tool's own slug (the identity's): its card says "You are here". */
57
+ selfSlug: string;
58
+ /** The person's hub grant (cloud); null on a self-hosted instance. */
59
+ tokens: HubTokens | null;
60
+ /** Test seams. */
61
+ fetchImpl?: typeof fetch;
62
+ now?: () => number;
63
+ }
64
+
65
+ type HubRead = { kind: 'ok'; status: number; body: unknown } | { kind: 'fail' };
66
+
67
+ export class AppsService {
68
+ private readonly cache = new Map<string, { at: number; value: AppsList }>();
69
+ private readonly fetchImpl: typeof fetch;
70
+ private readonly now: () => number;
71
+ private readonly hubBase: string | null;
72
+
73
+ constructor(private readonly opts: AppsServiceOptions) {
74
+ this.fetchImpl = opts.fetchImpl ?? fetch;
75
+ this.now = opts.now ?? Date.now;
76
+ this.hubBase =
77
+ opts.edition === 'cloud' && opts.hubIssuerUrl ? opts.hubIssuerUrl.replace(/\/+$/, '') : null;
78
+ }
79
+
80
+ /** The list for one person in one workspace. */
81
+ async list(principal: Pick<Principal, 'userId' | 'workspaceId'>): Promise<AppsList> {
82
+ const key = `${principal.userId}:${principal.workspaceId}`;
83
+ const held = this.cache.get(key);
84
+ if (held && this.now() - held.at < APPS_CACHE_TTL_MS) return held.value;
85
+
86
+ const fromHub = await this.fromHub(principal);
87
+ if (!fromHub) return this.fromFile();
88
+ this.cache.delete(key);
89
+ this.cache.set(key, { at: this.now(), value: fromHub });
90
+ if (this.cache.size > APPS_CACHE_MAX) {
91
+ const oldest = this.cache.keys().next().value;
92
+ if (oldest !== undefined) this.cache.delete(oldest);
93
+ }
94
+ return fromHub;
95
+ }
96
+
97
+ /** The hub's card: the issuer on the cloud edition. */
98
+ private cloudHub(): AppsList['hub'] {
99
+ const url = this.hubBase ? safeHttpUrl(this.hubBase) : null;
100
+ return url ? { url, name: HUB_APP_NAME } : null;
101
+ }
102
+
103
+ /** The operator's file; `none` without one, or when it cannot be read. */
104
+ async fromFile(): Promise<AppsList> {
105
+ const path = this.opts.appsFile;
106
+ if (!path) return { apps: [], hub: this.cloudHub(), source: 'none' };
107
+ let raw: unknown;
108
+ try {
109
+ raw = JSON.parse(await readFile(path, 'utf8'));
110
+ } catch (err) {
111
+ this.opts.logger.warn(
112
+ { err, path },
113
+ 'APPS_FILE could not be read as JSON — the Apps page lists nothing'
114
+ );
115
+ return { apps: [], hub: this.cloudHub(), source: 'none' };
116
+ }
117
+ const file = appsFromFile(raw, this.opts.selfSlug);
118
+ if (file.rejected.length > 0) {
119
+ this.opts.logger.warn({ path, rejected: file.rejected }, 'APPS_FILE entries left out of the Apps page');
120
+ }
121
+ return { apps: file.apps, hub: file.hub ?? this.cloudHub(), source: 'file' };
122
+ }
123
+
124
+ /**
125
+ * The hub's answer, or null when the hub cannot be asked for this person
126
+ * (no grant, a dead one, a workspace that is no hub organization, a hub
127
+ * that does not answer or answers another shape): the file is read instead.
128
+ */
129
+ private async fromHub(principal: Pick<Principal, 'userId' | 'workspaceId'>): Promise<AppsList | null> {
130
+ const tokens = this.opts.tokens;
131
+ if (!this.hubBase || !tokens) return null;
132
+ const [row] = await this.opts.db
133
+ .select({ hubOrgId: workspaces.centralAccountId })
134
+ .from(workspaces)
135
+ .where(eq(workspaces.id, principal.workspaceId))
136
+ .limit(1);
137
+ const hubOrgId = row?.hubOrgId ?? null;
138
+ if (!hubOrgId) return null;
139
+
140
+ const access = await tokens.accessToken(principal.userId);
141
+ if (access.kind !== 'ok') return null;
142
+
143
+ // One forced refresh and one retry on a 401/403 (the stored token may be
144
+ // stale): the chassis's own posture (hub-user-client.ts).
145
+ let res = await this.get(access.accessToken, hubOrgId);
146
+ if (res.kind === 'ok' && (res.status === 401 || res.status === 403)) {
147
+ tokens.invalidateAccess(principal.userId);
148
+ const refreshed = await tokens.refresh(principal.userId);
149
+ if (refreshed.kind !== 'ok') return null;
150
+ res = await this.get(refreshed.accessToken, hubOrgId);
151
+ }
152
+ if (res.kind !== 'ok' || res.status !== 200) {
153
+ if (res.kind === 'ok') {
154
+ this.opts.logger.warn(
155
+ { status: res.status },
156
+ 'hub /me/apps answered non-200 — the Apps page reads the file'
157
+ );
158
+ }
159
+ return null;
160
+ }
161
+ // The organization's status first: a suspended one empties the list
162
+ // whatever the body's tools say (or fail to say).
163
+ const organization = readOrganization(res.body);
164
+ if (organization?.status === 'suspended') {
165
+ this.opts.logger.info({ hubOrgId }, 'hub organization suspended — the Apps page lists nothing');
166
+ return { apps: [], hub: this.cloudHub(), source: 'hub', organization };
167
+ }
168
+ const apps = selectHubApps(res.body, this.opts.selfSlug);
169
+ if (!apps) {
170
+ this.opts.logger.warn('hub /me/apps carried no tools list — the Apps page reads the file');
171
+ return null;
172
+ }
173
+ return { apps, hub: this.cloudHub(), source: 'hub', ...(organization ? { organization } : {}) };
174
+ }
175
+
176
+ private async get(token: string, hubOrgId: string): Promise<HubRead> {
177
+ try {
178
+ const response = await this.fetchImpl(`${this.hubBase}/api/v1/me/apps`, {
179
+ headers: {
180
+ authorization: `Bearer ${token}`,
181
+ accept: 'application/json',
182
+ 'x-workspace-id': hubOrgId
183
+ },
184
+ signal: AbortSignal.timeout(HUB_TIMEOUT_MS)
185
+ });
186
+ let body: unknown = null;
187
+ try {
188
+ body = await response.json();
189
+ } catch {
190
+ body = null;
191
+ }
192
+ return { kind: 'ok', status: response.status, body };
193
+ } catch (err) {
194
+ this.opts.logger.warn({ err }, 'hub read for the Apps page failed');
195
+ return { kind: 'fail' };
196
+ }
197
+ }
198
+ }
package/src/env.ts CHANGED
@@ -222,6 +222,8 @@ const chassisEnvShape = (version: string) => ({
222
222
  HSTS_MAX_AGE: numeric(z.coerce.number().int().min(0).default(15552000)),
223
223
  /** Instance-level cap read by the default AllowAllEntitlements. */
224
224
  MAX_FILE_SIZE_MB: numeric(z.coerce.number().int().min(1).default(100)),
225
+ /** A JSON file listing the other tools of the organization for the Apps page (PRDCT-3493): `[{ "slug": "finance", "name": "Antasphere Finance", "launchUrl": "https://finance.example.com", "description": "optional sentence", "icon": "landmark", "drawing": "ledger", "accent": "#B5542A" }]`, the last four the hub's descriptor keys (icon and drawing from the contract's closed lists; an invalid descriptor is dropped, the entry kept). Read on a self-hosted instance (`EDITION=oss`), and on the cloud edition when the hub cannot be asked (the cloud edition otherwise asks the hub as the person, `GET /me/apps`). Every `launchUrl` must be http(s); an entry that is not is left out, with a warning in the log. Absent: the Apps page lists no other tool. */
226
+ APPS_FILE: optionalString(z.string().min(1).max(1000)),
225
227
  /** How many workspaces ONE person may own on this instance — the cap on creating another workspace from the dashboard (the first one comes from setup). Any signed-in member who is not a guest may create one until they own this many. 0 closes workspace creation for everyone. On the cloud edition the count is Antasphere's own organization limit; only the 0 switch applies here. */
226
228
  MAX_WORKSPACES_PER_USER: numeric(z.coerce.number().int().min(0).max(1000).default(10)),
227
229
  /** General per-principal API quota: sustained requests/minute allowed to every authenticated /api/v1 principal (API key, OAuth token, session). 0 disables the general limiter. */
@@ -5,7 +5,8 @@ import {
5
5
  usageCheckSchema,
6
6
  usageEventSchema,
7
7
  usageEventsBatchSchema,
8
- usageIngestResultSchema
8
+ usageIngestResultSchema,
9
+ meAppsSchema
9
10
  } from '@antasphere/chassis-contract';
10
11
  import { randomUUID } from 'node:crypto';
11
12
  import { exportJWK, generateKeyPair, SignJWT, type JWK } from 'jose';
@@ -111,6 +112,14 @@ export interface HubUserFixture {
111
112
  overrides?: HubTokenOverrides | undefined;
112
113
  }
113
114
 
115
+ /** One tool of the fake's catalogue (what GET /me/apps serves, PRDCT-3493). */
116
+ export interface FakeHubTool {
117
+ slug: string;
118
+ name: string;
119
+ launchUrl: string;
120
+ descriptor?: Record<string, unknown> | undefined;
121
+ }
122
+
114
123
  /** One entry of a user's org registry (what GET /orgs serves). */
115
124
  export interface HubOrgEntry {
116
125
  name: string | null;
@@ -180,6 +189,11 @@ export class FakeHub {
180
189
  private readonly orgTeamRegistry = new Map<string, Array<{ id: string; slug: string; name: string }>>();
181
190
  /** sub → the orgs that do NOT open the tool to them (GET /orgs' top-level `denied`). */
182
191
  private readonly userDenied = new Map<string, Array<{ id: string; name: string }>>();
192
+ /** The catalogue GET /me/apps serves (PRDCT-3493): the registry's tools, in order, with their descriptor. */
193
+ private tools: FakeHubTool[] = [];
194
+ /** Per user and tool, the organizations that do NOT open the tool to them (absent = open everywhere). */
195
+ private readonly userToolClosed = new Map<string, Set<string>>();
196
+ readonly meAppsRequests: Array<{ auth: string | null; orgId: string | null }> = [];
183
197
  /** Every JWT access token this fake minted (bearer lookup for /api/v1). */
184
198
  private readonly accessTokens = new Map<string, AccessTokenRecord>();
185
199
  /** Live + rotated-out refresh tokens (rotation families). */
@@ -550,6 +564,9 @@ export class FakeHub {
550
564
  if (req.method === 'GET' && url.pathname === '/api/v1/teams') {
551
565
  return this.handleTeams(req, res, url);
552
566
  }
567
+ if (req.method === 'GET' && url.pathname === '/api/v1/me/apps') {
568
+ return this.handleMeApps(req, res);
569
+ }
553
570
  if (req.method === 'POST' && url.pathname === '/api/v1/orgs') {
554
571
  return this.handleOrgCreate(req, res);
555
572
  }
@@ -880,6 +897,71 @@ export class FakeHub {
880
897
  }
881
898
 
882
899
  // ── GET /api/v1/orgs: the caller-scoped org list ──────────────────────
900
+ /** The tools GET /me/apps lists, in this order. */
901
+ setTools(tools: FakeHubTool[]): void {
902
+ this.tools = tools;
903
+ }
904
+
905
+ /** Close one tool to one user in one organization (the real hub's teams rule, reduced to its verdict). */
906
+ setToolClosed(sub: string, slug: string, orgId: string, closed = true): void {
907
+ const key = `${sub}:${slug}`;
908
+ const set = this.userToolClosed.get(key) ?? new Set<string>();
909
+ if (closed) set.add(orgId);
910
+ else set.delete(orgId);
911
+ this.userToolClosed.set(key, set);
912
+ }
913
+
914
+ // ── GET /api/v1/me/apps: the catalogue as one person sees it (PRDCT-3493) ─
915
+ // Mirrors the hub's api/me-apps.ts: the same bearer rule as /orgs, the
916
+ // organization the `X-Workspace-Id` header names (else the user's first org,
917
+ // the real hub's default), every registered tool with `open` from the
918
+ // per-user closures, `connected` false (the fake keeps no connection facts).
919
+ private handleMeApps(req: IncomingMessage, res: ServerResponse): void {
920
+ const header = req.headers['x-workspace-id'];
921
+ const orgId = (Array.isArray(header) ? header[0] : header) ?? null;
922
+ this.meAppsRequests.push({ auth: req.headers.authorization ?? null, orgId });
923
+ const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1] ?? null;
924
+ const record = bearer ? this.accessTokens.get(bearer) : undefined;
925
+ if (!record || record.expMs <= Date.now() || !record.aud.includes(this.apiResource)) {
926
+ return sendJson(res, 401, { error: { code: 'invalid_token', message: 'Unauthorized' } });
927
+ }
928
+ const orgs = this.userOrgs.get(record.sub) ?? new Map<string, HubOrgEntry>();
929
+ const resolved = orgId ?? orgs.keys().next().value ?? null;
930
+ if (!resolved || !orgs.has(resolved)) {
931
+ return sendJson(res, 401, { error: { code: 'unauthenticated', message: 'Authentication required' } });
932
+ }
933
+ const tools = this.tools.map((tool) => ({
934
+ slug: tool.slug,
935
+ clientId: `tool-${tool.slug}`,
936
+ name: tool.name,
937
+ launchUrl: tool.launchUrl,
938
+ returnOrigins: [new URL(tool.launchUrl).origin],
939
+ connected: false,
940
+ connectedAt: null,
941
+ lastUsedAt: null,
942
+ ...(tool.descriptor ? { descriptor: tool.descriptor } : {}),
943
+ open: !(this.userToolClosed.get(`${record.sub}:${tool.slug}`)?.has(resolved) ?? false)
944
+ }));
945
+ const entry = orgs.get(resolved);
946
+ const body = {
947
+ organization: { id: resolved, name: entry?.name ?? '', status: entry?.status ?? 'active' },
948
+ tools
949
+ };
950
+ // The fake answers the hub's published shape and nothing else: a body the
951
+ // real hub would never send is a test proving nothing, so it is refused
952
+ // here, as a 500 naming the drift (a thrown error would only hang the socket).
953
+ const checked = meAppsSchema.safeParse(body);
954
+ if (!checked.success) {
955
+ return sendJson(res, 500, {
956
+ error: {
957
+ code: 'fake_hub_drift',
958
+ message: `/me/apps body is off the hub contract: ${checked.error.message}`
959
+ }
960
+ });
961
+ }
962
+ return sendJson(res, 200, body);
963
+ }
964
+
883
965
  private async handleOrgs(req: IncomingMessage, res: ServerResponse): Promise<void> {
884
966
  this.orgsRequests.push({ auth: req.headers.authorization ?? null });
885
967
  if (this.orgsDelayMs > 0) await new Promise((r) => setTimeout(r, this.orgsDelayMs));
@@ -0,0 +1,235 @@
1
+ import { mkdtempSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
5
+ import type { StartedPostgreSqlContainer } from '@testcontainers/postgresql';
6
+ import {
7
+ createDatabase,
8
+ createTestApp,
9
+ extractCookie,
10
+ host,
11
+ readJson,
12
+ startPostgres,
13
+ SETUP_TOKEN,
14
+ type TestApp
15
+ } from './helpers.js';
16
+ import { FakeHub, type HubUserFixture } from '@antasphere/chassis-server/testing';
17
+ import { nextIp, ssoLogin } from './sso-helpers.js';
18
+
19
+ /**
20
+ * The Apps page's route (`GET /apps`, `src/apps/`, PRDCT-3493) through the
21
+ * REAL boot of the minimal tool, on both editions:
22
+ *
23
+ * - self-hosted, with an operator's file: a browser session reads the list
24
+ * (this tool marked current, the account from the file's `account` entry,
25
+ * a non-http entry left out, the descriptor keys read), and a machine
26
+ * credential is refused before the handler, since no rule of the scope
27
+ * allowlist opens the path (fail-closed);
28
+ * - cloud, against the fake hub: the list is the hub's `GET /me/apps` as
29
+ * the signed-in person, asked for the workspace's hub organization, the
30
+ * tools the organization does not open to them left out, the descriptor
31
+ * carried; closing a tool at the hub takes it out of the list once the
32
+ * five-minute hold has passed (a fresh person reads it at once); a person
33
+ * whose organization the hub suspended reads an empty list that names the
34
+ * organization and its status, never the tools.
35
+ */
36
+
37
+ const OWNER = { email: 'owner@apps.test', name: 'Apps Owner', password: 'apps-owner-password-1' };
38
+ const DESCRIPTOR = {
39
+ description: 'Where the company keeps its figures.',
40
+ icon: 'landmark',
41
+ drawing: 'ledger',
42
+ accent: '#B5542A'
43
+ };
44
+
45
+ let container: StartedPostgreSqlContainer;
46
+
47
+ const send = (app: TestApp, method: string, path: string, headers: Record<string, string>, body?: unknown) =>
48
+ app.app.request(`/api/v1${path}`, {
49
+ method,
50
+ headers: {
51
+ 'x-forwarded-for': nextIp(),
52
+ ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
53
+ ...headers
54
+ },
55
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {})
56
+ });
57
+
58
+ beforeAll(async () => {
59
+ container = await startPostgres();
60
+ }, 180_000);
61
+
62
+ afterAll(async () => {
63
+ await container?.stop();
64
+ });
65
+
66
+ describe('GET /apps on a self-hosted instance with an operator file', () => {
67
+ let app: TestApp;
68
+ let cookie = '';
69
+
70
+ beforeAll(async () => {
71
+ const dir = mkdtempSync(join(tmpdir(), 'apps-route-'));
72
+ const appsFile = join(dir, 'apps.json');
73
+ writeFileSync(
74
+ appsFile,
75
+ JSON.stringify([
76
+ { slug: 'account', name: 'Antasphere account', launchUrl: 'https://account.example.com' },
77
+ {
78
+ slug: 'finance',
79
+ name: 'Antasphere Finance',
80
+ launchUrl: 'https://finance.example.com',
81
+ ...DESCRIPTOR
82
+ },
83
+ { slug: host.identity.slug, name: 'This Tool', launchUrl: 'https://thistool.example.com' },
84
+ { slug: 'crm', name: 'Antasphere CRM', launchUrl: 'javascript:alert(1)' }
85
+ ])
86
+ );
87
+ app = await createTestApp(await createDatabase(container, 'apps_route_oss'), { APPS_FILE: appsFile });
88
+ const setup = await send(
89
+ app,
90
+ 'POST',
91
+ '/setup',
92
+ {},
93
+ { setupToken: SETUP_TOKEN, instanceName: 'Apps', owner: OWNER }
94
+ );
95
+ expect(setup.status).toBe(201);
96
+ const signIn = await send(
97
+ app,
98
+ 'POST',
99
+ '/auth/sign-in/email',
100
+ {},
101
+ { email: OWNER.email, password: OWNER.password }
102
+ );
103
+ expect(signIn.status).toBe(200);
104
+ cookie = extractCookie(signIn);
105
+ }, 120_000);
106
+
107
+ afterAll(async () => {
108
+ await app?.stop();
109
+ });
110
+
111
+ it('a session reads the file: the account, the tools with their descriptor, this tool current, the bad URL left out', async () => {
112
+ const res = await send(app, 'GET', '/apps', { cookie });
113
+ expect(res.status).toBe(200);
114
+ const body = await readJson(res);
115
+ expect(body.source).toBe('file');
116
+ expect(body.hub).toEqual({ url: 'https://account.example.com/', name: 'Antasphere account' });
117
+ expect(body.apps.map((a: { slug: string; current: boolean }) => [a.slug, a.current])).toEqual([
118
+ ['finance', false],
119
+ [host.identity.slug, true]
120
+ ]);
121
+ expect(body.apps[0].descriptor).toEqual(DESCRIPTOR);
122
+ expect(body.apps[1]).not.toHaveProperty('descriptor');
123
+ });
124
+
125
+ it('a machine credential is refused before the handler, and no credential answers 401', async () => {
126
+ const minted = await send(
127
+ app,
128
+ 'POST',
129
+ '/api-keys',
130
+ { cookie },
131
+ { name: 'reader', scopes: [host.scopes.read] }
132
+ );
133
+ expect(minted.status).toBe(201);
134
+ const key = (await readJson(minted)).key as string;
135
+ const asKey = await send(app, 'GET', '/apps', { authorization: `Bearer ${key}` });
136
+ expect(asKey.status).toBe(403);
137
+ expect((await readJson(asKey)).error.code).toBe('endpoint_not_allowed');
138
+ const anon = await send(app, 'GET', '/apps', {});
139
+ expect(anon.status).toBe(401);
140
+ });
141
+ });
142
+
143
+ describe('GET /apps on the cloud edition, against the hub', () => {
144
+ let hub: FakeHub;
145
+ let app: TestApp;
146
+ const ORG = '11111111-2222-4333-8444-555555555555';
147
+ const mira: HubUserFixture = {
148
+ sub: 'hub-user-mira',
149
+ email: 'mira@apps.test',
150
+ name: 'Mira Lund',
151
+ workspaceId: ORG,
152
+ role: 'member',
153
+ workspaceName: 'Acme'
154
+ };
155
+ const omar: HubUserFixture = {
156
+ ...mira,
157
+ sub: 'hub-user-omar',
158
+ email: 'omar@apps.test',
159
+ name: 'Omar Diallo'
160
+ };
161
+ const lea: HubUserFixture = {
162
+ ...mira,
163
+ sub: 'hub-user-lea',
164
+ email: 'lea@apps.test',
165
+ name: 'Léa Martin'
166
+ };
167
+
168
+ beforeAll(async () => {
169
+ hub = await FakeHub.start({ clientId: host.hubClientId });
170
+ hub.setTools([
171
+ {
172
+ slug: 'finance',
173
+ name: 'Antasphere Finance',
174
+ launchUrl: 'https://finance.example.com',
175
+ descriptor: DESCRIPTOR
176
+ },
177
+ {
178
+ slug: 'hiring',
179
+ name: 'Antasphere Hiring',
180
+ launchUrl: 'https://hiring.example.com',
181
+ descriptor: { icon: 'users' }
182
+ },
183
+ { slug: host.identity.slug, name: 'This Tool', launchUrl: 'https://thistool.example.com' }
184
+ ]);
185
+ hub.setToolClosed(omar.sub, 'hiring', ORG);
186
+ app = await createTestApp(await createDatabase(container, 'apps_route_cloud'), {
187
+ EDITION: 'cloud',
188
+ HUB_ISSUER_URL: hub.issuer,
189
+ HUB_CLIENT_ID: host.hubClientId,
190
+ HUB_CLIENT_SECRET: 'integration-test-hub-secret-0001'
191
+ });
192
+ }, 180_000);
193
+
194
+ afterAll(async () => {
195
+ await app?.stop();
196
+ await hub?.stop();
197
+ });
198
+
199
+ it('a person signed in through the hub reads the catalogue for her organization, with the descriptors, this tool current', async () => {
200
+ const cookie = await ssoLogin(app, hub, mira);
201
+ const res = await send(app, 'GET', '/apps', { cookie });
202
+ expect(res.status).toBe(200);
203
+ const body = await readJson(res);
204
+ expect(body.source).toBe('hub');
205
+ expect(body.hub).toEqual({ url: `${hub.issuer}/`, name: 'Antasphere Account' });
206
+ expect(body.apps.map((a: { slug: string; current: boolean }) => [a.slug, a.current])).toEqual([
207
+ ['finance', false],
208
+ ['hiring', false],
209
+ [host.identity.slug, true]
210
+ ]);
211
+ expect(body.apps[0].descriptor).toEqual(DESCRIPTOR);
212
+ expect(body.apps[1].descriptor).toEqual({ icon: 'users' });
213
+ // asked as the person, for the workspace's hub organization
214
+ const last = hub.meAppsRequests.at(-1)!;
215
+ expect(last.orgId).toBe(ORG);
216
+ expect(last.auth).toMatch(/^Bearer /);
217
+ });
218
+
219
+ it('a tool the organization does not open to the person is left out of her list', async () => {
220
+ const cookie = await ssoLogin(app, hub, omar);
221
+ const body = await readJson(await send(app, 'GET', '/apps', { cookie }));
222
+ expect(body.source).toBe('hub');
223
+ expect(body.apps.map((a: { slug: string }) => a.slug)).toEqual(['finance', host.identity.slug]);
224
+ });
225
+
226
+ it('a person whose organization the hub suspended reads an empty list that says so, never the tools', async () => {
227
+ const cookie = await ssoLogin(app, hub, lea);
228
+ hub.setUserOrg(lea.sub, ORG, { name: 'Acme', role: 'member', status: 'suspended' });
229
+ const body = await readJson(await send(app, 'GET', '/apps', { cookie }));
230
+ expect(body.source).toBe('hub');
231
+ expect(body.apps).toEqual([]);
232
+ expect(body.organization).toEqual({ name: 'Acme', status: 'suspended' });
233
+ expect(body.hub).toEqual({ url: `${hub.issuer}/`, name: 'Antasphere Account' });
234
+ });
235
+ });