@antasphere/chassis-server 1.4.0 → 1.5.1
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/dist/api/create-api.d.ts.map +1 -1
- package/dist/api/create-api.js +17 -0
- package/dist/api/create-api.js.map +1 -1
- package/dist/apps/index.d.ts +11 -0
- package/dist/apps/index.d.ts.map +1 -0
- package/dist/apps/index.js +11 -0
- package/dist/apps/index.js.map +1 -0
- package/dist/apps/routes.d.ts +14 -0
- package/dist/apps/routes.d.ts.map +1 -0
- package/dist/apps/routes.js +23 -0
- package/dist/apps/routes.js.map +1 -0
- package/dist/apps/select.d.ts +64 -0
- package/dist/apps/select.d.ts.map +1 -0
- package/dist/apps/select.js +168 -0
- package/dist/apps/select.js.map +1 -0
- package/dist/apps/service.d.ts +74 -0
- package/dist/apps/service.d.ts.map +1 -0
- package/dist/apps/service.js +165 -0
- package/dist/apps/service.js.map +1 -0
- package/dist/env.d.ts +2 -0
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +2 -0
- package/dist/env.js.map +1 -1
- package/dist/testing/fake-hub.d.ts +20 -0
- package/dist/testing/fake-hub.d.ts.map +1 -1
- package/dist/testing/fake-hub.js +73 -1
- package/dist/testing/fake-hub.js.map +1 -1
- package/package.json +7 -3
- package/src/api/create-api.ts +21 -0
- package/src/apps/index.ts +17 -0
- package/src/apps/routes.ts +30 -0
- package/src/apps/select.ts +175 -0
- package/src/apps/service.ts +198 -0
- package/src/env.ts +2 -0
- package/src/testing/fake-hub.ts +83 -1
- package/test/integration/apps-route.test.ts +239 -0
- package/test/unit/apps.test.ts +544 -0
- package/test/unit/fake-hub-me-apps.test.ts +71 -0
|
@@ -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. */
|
package/src/testing/fake-hub.ts
CHANGED
|
@@ -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,239 @@
|
|
|
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. The suite runs in every
|
|
22
|
+
* tool against its own host, so the other tools it lists carry invented slugs
|
|
23
|
+
* (ledger-tool, people-tool) that no real tool has: a real slug would be the
|
|
24
|
+
* host's own in that tool and read as "this tool" (chassis 1.5.0 named finance
|
|
25
|
+
* and hiring, and the finance and hiring tools' suites went red on it).
|
|
26
|
+
*
|
|
27
|
+
* - self-hosted, with an operator's file: a browser session reads the list
|
|
28
|
+
* (this tool marked current, the account from the file's `account` entry,
|
|
29
|
+
* a non-http entry left out, the descriptor keys read), and a machine
|
|
30
|
+
* credential is refused before the handler, since no rule of the scope
|
|
31
|
+
* allowlist opens the path (fail-closed);
|
|
32
|
+
* - cloud, against the fake hub: the list is the hub's `GET /me/apps` as
|
|
33
|
+
* the signed-in person, asked for the workspace's hub organization, the
|
|
34
|
+
* tools the organization does not open to them left out, the descriptor
|
|
35
|
+
* carried; closing a tool at the hub takes it out of the list once the
|
|
36
|
+
* five-minute hold has passed (a fresh person reads it at once); a person
|
|
37
|
+
* whose organization the hub suspended reads an empty list that names the
|
|
38
|
+
* organization and its status, never the tools.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
const OWNER = { email: 'owner@apps.test', name: 'Apps Owner', password: 'apps-owner-password-1' };
|
|
42
|
+
const DESCRIPTOR = {
|
|
43
|
+
description: 'Where the company keeps its figures.',
|
|
44
|
+
icon: 'landmark',
|
|
45
|
+
drawing: 'ledger',
|
|
46
|
+
accent: '#B5542A'
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
let container: StartedPostgreSqlContainer;
|
|
50
|
+
|
|
51
|
+
const send = (app: TestApp, method: string, path: string, headers: Record<string, string>, body?: unknown) =>
|
|
52
|
+
app.app.request(`/api/v1${path}`, {
|
|
53
|
+
method,
|
|
54
|
+
headers: {
|
|
55
|
+
'x-forwarded-for': nextIp(),
|
|
56
|
+
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
57
|
+
...headers
|
|
58
|
+
},
|
|
59
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {})
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
beforeAll(async () => {
|
|
63
|
+
container = await startPostgres();
|
|
64
|
+
}, 180_000);
|
|
65
|
+
|
|
66
|
+
afterAll(async () => {
|
|
67
|
+
await container?.stop();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('GET /apps on a self-hosted instance with an operator file', () => {
|
|
71
|
+
let app: TestApp;
|
|
72
|
+
let cookie = '';
|
|
73
|
+
|
|
74
|
+
beforeAll(async () => {
|
|
75
|
+
const dir = mkdtempSync(join(tmpdir(), 'apps-route-'));
|
|
76
|
+
const appsFile = join(dir, 'apps.json');
|
|
77
|
+
writeFileSync(
|
|
78
|
+
appsFile,
|
|
79
|
+
JSON.stringify([
|
|
80
|
+
{ slug: 'account', name: 'Antasphere account', launchUrl: 'https://account.example.com' },
|
|
81
|
+
{
|
|
82
|
+
slug: 'ledger-tool',
|
|
83
|
+
name: 'Antasphere Ledger Tool',
|
|
84
|
+
launchUrl: 'https://ledger-tool.example.com',
|
|
85
|
+
...DESCRIPTOR
|
|
86
|
+
},
|
|
87
|
+
{ slug: host.identity.slug, name: 'This Tool', launchUrl: 'https://thistool.example.com' },
|
|
88
|
+
{ slug: 'bad-url-tool', name: 'Antasphere Bad URL Tool', launchUrl: 'javascript:alert(1)' }
|
|
89
|
+
])
|
|
90
|
+
);
|
|
91
|
+
app = await createTestApp(await createDatabase(container, 'apps_route_oss'), { APPS_FILE: appsFile });
|
|
92
|
+
const setup = await send(
|
|
93
|
+
app,
|
|
94
|
+
'POST',
|
|
95
|
+
'/setup',
|
|
96
|
+
{},
|
|
97
|
+
{ setupToken: SETUP_TOKEN, instanceName: 'Apps', owner: OWNER }
|
|
98
|
+
);
|
|
99
|
+
expect(setup.status).toBe(201);
|
|
100
|
+
const signIn = await send(
|
|
101
|
+
app,
|
|
102
|
+
'POST',
|
|
103
|
+
'/auth/sign-in/email',
|
|
104
|
+
{},
|
|
105
|
+
{ email: OWNER.email, password: OWNER.password }
|
|
106
|
+
);
|
|
107
|
+
expect(signIn.status).toBe(200);
|
|
108
|
+
cookie = extractCookie(signIn);
|
|
109
|
+
}, 120_000);
|
|
110
|
+
|
|
111
|
+
afterAll(async () => {
|
|
112
|
+
await app?.stop();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('a session reads the file: the account, the tools with their descriptor, this tool current, the bad URL left out', async () => {
|
|
116
|
+
const res = await send(app, 'GET', '/apps', { cookie });
|
|
117
|
+
expect(res.status).toBe(200);
|
|
118
|
+
const body = await readJson(res);
|
|
119
|
+
expect(body.source).toBe('file');
|
|
120
|
+
expect(body.hub).toEqual({ url: 'https://account.example.com/', name: 'Antasphere account' });
|
|
121
|
+
expect(body.apps.map((a: { slug: string; current: boolean }) => [a.slug, a.current])).toEqual([
|
|
122
|
+
['ledger-tool', false],
|
|
123
|
+
[host.identity.slug, true]
|
|
124
|
+
]);
|
|
125
|
+
expect(body.apps[0].descriptor).toEqual(DESCRIPTOR);
|
|
126
|
+
expect(body.apps[1]).not.toHaveProperty('descriptor');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('a machine credential is refused before the handler, and no credential answers 401', async () => {
|
|
130
|
+
const minted = await send(
|
|
131
|
+
app,
|
|
132
|
+
'POST',
|
|
133
|
+
'/api-keys',
|
|
134
|
+
{ cookie },
|
|
135
|
+
{ name: 'reader', scopes: [host.scopes.read] }
|
|
136
|
+
);
|
|
137
|
+
expect(minted.status).toBe(201);
|
|
138
|
+
const key = (await readJson(minted)).key as string;
|
|
139
|
+
const asKey = await send(app, 'GET', '/apps', { authorization: `Bearer ${key}` });
|
|
140
|
+
expect(asKey.status).toBe(403);
|
|
141
|
+
expect((await readJson(asKey)).error.code).toBe('endpoint_not_allowed');
|
|
142
|
+
const anon = await send(app, 'GET', '/apps', {});
|
|
143
|
+
expect(anon.status).toBe(401);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('GET /apps on the cloud edition, against the hub', () => {
|
|
148
|
+
let hub: FakeHub;
|
|
149
|
+
let app: TestApp;
|
|
150
|
+
const ORG = '11111111-2222-4333-8444-555555555555';
|
|
151
|
+
const mira: HubUserFixture = {
|
|
152
|
+
sub: 'hub-user-mira',
|
|
153
|
+
email: 'mira@apps.test',
|
|
154
|
+
name: 'Mira Lund',
|
|
155
|
+
workspaceId: ORG,
|
|
156
|
+
role: 'member',
|
|
157
|
+
workspaceName: 'Acme'
|
|
158
|
+
};
|
|
159
|
+
const omar: HubUserFixture = {
|
|
160
|
+
...mira,
|
|
161
|
+
sub: 'hub-user-omar',
|
|
162
|
+
email: 'omar@apps.test',
|
|
163
|
+
name: 'Omar Diallo'
|
|
164
|
+
};
|
|
165
|
+
const lea: HubUserFixture = {
|
|
166
|
+
...mira,
|
|
167
|
+
sub: 'hub-user-lea',
|
|
168
|
+
email: 'lea@apps.test',
|
|
169
|
+
name: 'Léa Martin'
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
beforeAll(async () => {
|
|
173
|
+
hub = await FakeHub.start({ clientId: host.hubClientId });
|
|
174
|
+
hub.setTools([
|
|
175
|
+
{
|
|
176
|
+
slug: 'ledger-tool',
|
|
177
|
+
name: 'Antasphere Ledger Tool',
|
|
178
|
+
launchUrl: 'https://ledger-tool.example.com',
|
|
179
|
+
descriptor: DESCRIPTOR
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
slug: 'people-tool',
|
|
183
|
+
name: 'Antasphere People Tool',
|
|
184
|
+
launchUrl: 'https://people-tool.example.com',
|
|
185
|
+
descriptor: { icon: 'users' }
|
|
186
|
+
},
|
|
187
|
+
{ slug: host.identity.slug, name: 'This Tool', launchUrl: 'https://thistool.example.com' }
|
|
188
|
+
]);
|
|
189
|
+
hub.setToolClosed(omar.sub, 'people-tool', ORG);
|
|
190
|
+
app = await createTestApp(await createDatabase(container, 'apps_route_cloud'), {
|
|
191
|
+
EDITION: 'cloud',
|
|
192
|
+
HUB_ISSUER_URL: hub.issuer,
|
|
193
|
+
HUB_CLIENT_ID: host.hubClientId,
|
|
194
|
+
HUB_CLIENT_SECRET: 'integration-test-hub-secret-0001'
|
|
195
|
+
});
|
|
196
|
+
}, 180_000);
|
|
197
|
+
|
|
198
|
+
afterAll(async () => {
|
|
199
|
+
await app?.stop();
|
|
200
|
+
await hub?.stop();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('a person signed in through the hub reads the catalogue for her organization, with the descriptors, this tool current', async () => {
|
|
204
|
+
const cookie = await ssoLogin(app, hub, mira);
|
|
205
|
+
const res = await send(app, 'GET', '/apps', { cookie });
|
|
206
|
+
expect(res.status).toBe(200);
|
|
207
|
+
const body = await readJson(res);
|
|
208
|
+
expect(body.source).toBe('hub');
|
|
209
|
+
expect(body.hub).toEqual({ url: `${hub.issuer}/`, name: 'Antasphere Account' });
|
|
210
|
+
expect(body.apps.map((a: { slug: string; current: boolean }) => [a.slug, a.current])).toEqual([
|
|
211
|
+
['ledger-tool', false],
|
|
212
|
+
['people-tool', false],
|
|
213
|
+
[host.identity.slug, true]
|
|
214
|
+
]);
|
|
215
|
+
expect(body.apps[0].descriptor).toEqual(DESCRIPTOR);
|
|
216
|
+
expect(body.apps[1].descriptor).toEqual({ icon: 'users' });
|
|
217
|
+
// asked as the person, for the workspace's hub organization
|
|
218
|
+
const last = hub.meAppsRequests.at(-1)!;
|
|
219
|
+
expect(last.orgId).toBe(ORG);
|
|
220
|
+
expect(last.auth).toMatch(/^Bearer /);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('a tool the organization does not open to the person is left out of her list', async () => {
|
|
224
|
+
const cookie = await ssoLogin(app, hub, omar);
|
|
225
|
+
const body = await readJson(await send(app, 'GET', '/apps', { cookie }));
|
|
226
|
+
expect(body.source).toBe('hub');
|
|
227
|
+
expect(body.apps.map((a: { slug: string }) => a.slug)).toEqual(['ledger-tool', host.identity.slug]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('a person whose organization the hub suspended reads an empty list that says so, never the tools', async () => {
|
|
231
|
+
const cookie = await ssoLogin(app, hub, lea);
|
|
232
|
+
hub.setUserOrg(lea.sub, ORG, { name: 'Acme', role: 'member', status: 'suspended' });
|
|
233
|
+
const body = await readJson(await send(app, 'GET', '/apps', { cookie }));
|
|
234
|
+
expect(body.source).toBe('hub');
|
|
235
|
+
expect(body.apps).toEqual([]);
|
|
236
|
+
expect(body.organization).toEqual({ name: 'Acme', status: 'suspended' });
|
|
237
|
+
expect(body.hub).toEqual({ url: `${hub.issuer}/`, name: 'Antasphere Account' });
|
|
238
|
+
});
|
|
239
|
+
});
|