@antasphere/chassis-server 1.0.2 → 1.1.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,403 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
6
+ import type { StartedPostgreSqlContainer } from '@testcontainers/postgresql';
7
+ import {
8
+ createDatabase,
9
+ createTestApp,
10
+ expectBootRefusal,
11
+ readJson,
12
+ startPostgres,
13
+ type TestApp,
14
+ host
15
+ } from './helpers.js';
16
+ import { FakeHub, type HubUserFixture } from '@antasphere/chassis-server/testing';
17
+ import * as sso from './sso-helpers.js';
18
+
19
+ /**
20
+ * The tool's own CLI package (PRDCT-3312): `POST /api/v1/cli/package`, cloud
21
+ * only, answers the tarball the release packed (`CLI_PACKAGE_DIR`) to the
22
+ * holder of a hub-minted exchange token, and discovery names the package.
23
+ * Pinned: the discovery field, the bytes and their headers, the one-time
24
+ * token shared with `/sso/cli-connect` (spent at one door, it opens neither),
25
+ * the uniform 401 for every bad token, the validation 400s, the 403 a machine
26
+ * credential meets before the handler, the 404 of an instance released
27
+ * without its package, the oss terminator, and the boot refusal of a folder
28
+ * that does not hold the pair.
29
+ *
30
+ * NOT modelled here: the hub's own refusal of a member without access to the
31
+ * tool (`tool_access_denied`, 403 on `POST /sso/tool-token`). The hub mints
32
+ * no token for that person, so nothing reaches this route; the refusal is the
33
+ * hub's suite's to pin, and the fake hub signs whatever it is asked to.
34
+ */
35
+
36
+ const OWNER = { email: 'owner@clipkg.test', name: 'Op Erator', password: 'op-erator-password-123' };
37
+ const ORG = '11111111-aaaa-4bbb-8ccc-0000000000c1';
38
+ /** The host's own resource URL, the audience every exchange token is minted for. */
39
+ const RESOURCE = 'http://localhost:3000/mcp';
40
+ const PACKAGE = { name: '@antasphere/yourtool', version: '1.2.3' };
41
+
42
+ let container: StartedPostgreSqlContainer;
43
+ let hub: FakeHub;
44
+ let fixtureDir = '';
45
+ let emptyDir = '';
46
+ const tarball = randomBytes(1024);
47
+
48
+ beforeAll(async () => {
49
+ [container, hub] = await Promise.all([startPostgres(), FakeHub.start({ clientId: host.hubClientId })]);
50
+ fixtureDir = await mkdtemp(join(tmpdir(), 'cli-package-fixture-'));
51
+ await writeFile(join(fixtureDir, 'package.json'), JSON.stringify(PACKAGE));
52
+ await writeFile(join(fixtureDir, 'package.tgz'), tarball);
53
+ emptyDir = await mkdtemp(join(tmpdir(), 'cli-package-empty-'));
54
+ });
55
+
56
+ afterAll(async () => {
57
+ await Promise.all([container?.stop(), hub?.stop()]);
58
+ await Promise.all([fixtureDir, emptyDir].map((d) => (d ? rm(d, { recursive: true, force: true }) : null)));
59
+ });
60
+
61
+ function cloudEnv(extra: Record<string, string> = {}) {
62
+ return {
63
+ EDITION: 'cloud',
64
+ HUB_ISSUER_URL: hub.issuer,
65
+ HUB_CLIENT_ID: host.hubClientId,
66
+ HUB_CLIENT_SECRET: 'integration-test-hub-secret-0001',
67
+ ...extra
68
+ };
69
+ }
70
+
71
+ async function setUp(app: TestApp, instanceName: string): Promise<void> {
72
+ const res = await app.app.request(
73
+ '/api/v1/setup',
74
+ sso.json({ setupToken: 'integration-test-setup-token', instanceName, owner: OWNER })
75
+ );
76
+ expect(res.status).toBe(201);
77
+ }
78
+
79
+ const download = (app: TestApp, body: unknown, headers: Record<string, string> = {}) =>
80
+ app.app.request('/api/v1/cli/package', {
81
+ method: 'POST',
82
+ headers: { 'content-type': 'application/json', ...headers },
83
+ ...(body === undefined ? {} : { body: JSON.stringify(body) })
84
+ });
85
+
86
+ async function expectError(res: Response, status: number, code: string): Promise<void> {
87
+ const body = await readJson(res);
88
+ expect({ status: res.status, code: body?.error?.code }).toEqual({ status, code });
89
+ }
90
+
91
+ let seq = 0;
92
+ const person = (): HubUserFixture => {
93
+ seq += 1;
94
+ return {
95
+ sub: `hub-clipkg-${seq}`,
96
+ email: `person${seq}@clipkg.test`,
97
+ name: `Person ${seq}`,
98
+ workspaceId: ORG,
99
+ role: 'member',
100
+ workspaceName: 'CLI Org'
101
+ };
102
+ };
103
+
104
+ describe('cloud edition, released with its CLI package', () => {
105
+ let app: TestApp;
106
+
107
+ beforeAll(async () => {
108
+ app = await createTestApp(
109
+ await createDatabase(container, 'cli_package'),
110
+ cloudEnv({ CLI_PACKAGE_DIR: fixtureDir })
111
+ );
112
+ await setUp(app, 'CLI package');
113
+ });
114
+
115
+ afterAll(async () => {
116
+ await app.stop();
117
+ });
118
+
119
+ it('discovery names the package and its version', async () => {
120
+ const res = await app.app.request('/api/v1/instance');
121
+ expect(res.status).toBe(200);
122
+ expect((await readJson(res)).cli).toEqual(PACKAGE);
123
+ });
124
+
125
+ it('a hub exchange token downloads the tarball, byte for byte, as npm names it', async () => {
126
+ const { token } = await hub.signConnectToken(person(), RESOURCE);
127
+ const res = await download(app, { token });
128
+ expect(res.status).toBe(200);
129
+ expect(res.headers.get('content-type')).toBe('application/gzip');
130
+ expect(res.headers.get('content-disposition')).toBe(
131
+ 'attachment; filename="antasphere-yourtool-1.2.3.tgz"'
132
+ );
133
+ expect(res.headers.get('content-length')).toBe('1024');
134
+ expect(res.headers.get('x-content-type-options')).toBe('nosniff');
135
+ expect(res.headers.get('cache-control')).toBe('no-store');
136
+ expect(Buffer.from(await res.arrayBuffer()).equals(tarball)).toBe(true);
137
+ });
138
+
139
+ it('the same token a second time is 401 invalid_token: one download per token', async () => {
140
+ const { token } = await hub.signConnectToken(person(), RESOURCE);
141
+ expect((await download(app, { token })).status).toBe(200);
142
+ await expectError(await download(app, { token }), 401, 'invalid_token');
143
+ });
144
+
145
+ it('one ledger with /sso/cli-connect: a token spent at one door opens neither', async () => {
146
+ const downloader = person();
147
+ const first = await hub.signConnectToken(downloader, RESOURCE);
148
+ expect((await download(app, { token: first.token })).status).toBe(200);
149
+ await expectError(
150
+ await app.app.request(
151
+ '/api/v1/sso/cli-connect',
152
+ sso.json({ token: first.token, hubRefreshToken: first.hubRefreshToken })
153
+ ),
154
+ 401,
155
+ 'invalid_token'
156
+ );
157
+
158
+ const connector = person();
159
+ const second = await hub.signConnectToken(connector, RESOURCE);
160
+ const connect = await app.app.request(
161
+ '/api/v1/sso/cli-connect',
162
+ sso.json({ token: second.token, hubRefreshToken: second.hubRefreshToken })
163
+ );
164
+ expect(connect.status).toBe(201);
165
+ await expectError(await download(app, { token: second.token }), 401, 'invalid_token');
166
+ });
167
+
168
+ it('every bad token is the same 401 invalid_token: no purpose, another purpose, another audience, expired', async () => {
169
+ const someone = person();
170
+ const tokens = [
171
+ (await hub.signConnectToken(someone, RESOURCE, { purpose: null })).token,
172
+ (await hub.signConnectToken(someone, RESOURCE, { purpose: 'other' })).token,
173
+ (
174
+ await hub.signConnectToken(
175
+ { ...someone, overrides: { accessAud: 'http://another-tool.test/mcp' } },
176
+ RESOURCE
177
+ )
178
+ ).token,
179
+ (await hub.signConnectToken({ ...someone, overrides: { expiresInSeconds: -10 } }, RESOURCE)).token,
180
+ 'garbage'
181
+ ];
182
+ for (const token of tokens) {
183
+ await expectError(await download(app, { token }), 401, 'invalid_token');
184
+ }
185
+ });
186
+
187
+ it('a replay and a bad token answer the very same error body: no oracle between them', async () => {
188
+ const { token } = await hub.signConnectToken(person(), RESOURCE);
189
+ expect((await download(app, { token })).status).toBe(200);
190
+ const replay = await download(app, { token });
191
+ const bad = await download(app, { token: 'garbage' });
192
+ expect(replay.status).toBe(401);
193
+ expect(bad.status).toBe(401);
194
+ const replayBody = await readJson(replay);
195
+ expect(replayBody).toEqual({
196
+ error: { code: 'invalid_token', message: 'The exchange token is invalid, expired, or already used' }
197
+ });
198
+ expect(await readJson(bad)).toEqual(replayBody);
199
+ });
200
+
201
+ it('a body without a token or with an empty one is 400 validation_error; no body at all is 400 invalid_json', async () => {
202
+ await expectError(await download(app, {}), 400, 'validation_error');
203
+ await expectError(await download(app, { token: '' }), 400, 'validation_error');
204
+ await expectError(await download(app, undefined), 400, 'invalid_json');
205
+ });
206
+
207
+ it('a machine credential is 403 endpoint_not_allowed before the handler, and the token is not spent', async () => {
208
+ const holder = person();
209
+ // A user-scoped API key, the one /sso/cli-connect mints.
210
+ const minted = await hub.signConnectToken(holder, RESOURCE);
211
+ const connect = await app.app.request(
212
+ '/api/v1/sso/cli-connect',
213
+ sso.json({ token: minted.token, hubRefreshToken: minted.hubRefreshToken })
214
+ );
215
+ expect(connect.status).toBe(201);
216
+ const key = (await readJson(connect)).key as string;
217
+ // An OAuth bearer this instance minted, through a browser session.
218
+ const cookie = await sso.ssoLogin(app, hub, holder);
219
+ const bearer = await sso.oauthBearer(app, cookie);
220
+
221
+ const { token } = await hub.signConnectToken(holder, RESOURCE);
222
+ await expectError(
223
+ await download(app, { token }, { authorization: `Bearer ${key}` }),
224
+ 403,
225
+ 'endpoint_not_allowed'
226
+ );
227
+ await expectError(
228
+ await download(app, { token }, { authorization: `Bearer ${bearer}` }),
229
+ 403,
230
+ 'endpoint_not_allowed'
231
+ );
232
+ // Refused before the handler: the token was never burned.
233
+ expect((await download(app, { token })).status).toBe(200);
234
+ });
235
+ });
236
+
237
+ describe('cloud edition, released without its CLI package', () => {
238
+ let app: TestApp;
239
+
240
+ beforeAll(async () => {
241
+ app = await createTestApp(await createDatabase(container, 'cli_package_none'), cloudEnv());
242
+ await setUp(app, 'No CLI package');
243
+ });
244
+
245
+ afterAll(async () => {
246
+ await app.stop();
247
+ });
248
+
249
+ it('discovery carries no cli key, and a valid token is 404 cli_not_packaged', async () => {
250
+ const instance = await readJson(await app.app.request('/api/v1/instance'));
251
+ expect('cli' in instance).toBe(false);
252
+ const { token } = await hub.signConnectToken(person(), RESOURCE);
253
+ await expectError(await download(app, { token }), 404, 'cli_not_packaged');
254
+ });
255
+
256
+ it('the token is burned before the 404: twice is 404 then 401, and /sso/cli-connect then refuses it', async () => {
257
+ const { token, hubRefreshToken } = await hub.signConnectToken(person(), RESOURCE);
258
+ await expectError(await download(app, { token }), 404, 'cli_not_packaged');
259
+ await expectError(await download(app, { token }), 401, 'invalid_token');
260
+ await expectError(
261
+ await app.app.request('/api/v1/sso/cli-connect', sso.json({ token, hubRefreshToken })),
262
+ 401,
263
+ 'invalid_token'
264
+ );
265
+ });
266
+
267
+ it('an unverified token learns nothing of the release: garbage and another purpose are 401, never 404', async () => {
268
+ const other = (await hub.signConnectToken(person(), RESOURCE, { purpose: 'other' })).token;
269
+ for (const token of ['garbage', other]) {
270
+ await expectError(await download(app, { token }), 401, 'invalid_token');
271
+ }
272
+ });
273
+ });
274
+
275
+ describe('the tarball, statted when it is served', () => {
276
+ let app: TestApp;
277
+ let dir = '';
278
+
279
+ beforeAll(async () => {
280
+ dir = await mkdtemp(join(tmpdir(), 'cli-package-live-'));
281
+ await writeFile(join(dir, 'package.json'), JSON.stringify(PACKAGE));
282
+ await writeFile(join(dir, 'package.tgz'), tarball);
283
+ app = await createTestApp(
284
+ await createDatabase(container, 'cli_package_live'),
285
+ cloudEnv({ CLI_PACKAGE_DIR: dir })
286
+ );
287
+ await setUp(app, 'Live tarball');
288
+ });
289
+
290
+ afterAll(async () => {
291
+ await app.stop();
292
+ await rm(dir, { recursive: true, force: true });
293
+ });
294
+
295
+ it('a tarball gone since the boot is 404 cli_not_packaged; a bigger one is served whole, with its own length', async () => {
296
+ await rm(join(dir, 'package.tgz'));
297
+ const gone = await hub.signConnectToken(person(), RESOURCE);
298
+ await expectError(await download(app, { token: gone.token }), 404, 'cli_not_packaged');
299
+
300
+ const bigger = randomBytes(4096);
301
+ await writeFile(join(dir, 'package.tgz'), bigger);
302
+ const { token } = await hub.signConnectToken(person(), RESOURCE);
303
+ const res = await download(app, { token });
304
+ expect(res.status).toBe(200);
305
+ expect(res.headers.get('content-length')).toBe('4096');
306
+ expect(Buffer.from(await res.arrayBuffer()).equals(bigger)).toBe(true);
307
+ // Discovery still names what the image packed.
308
+ expect((await readJson(await app.app.request('/api/v1/instance'))).cli).toEqual(PACKAGE);
309
+ });
310
+ });
311
+
312
+ describe('the rate wall (CLI_CONNECT_IP_LIMIT_PER_15_MIN)', () => {
313
+ let app: TestApp;
314
+
315
+ beforeAll(async () => {
316
+ app = await createTestApp(
317
+ await createDatabase(container, 'cli_package_wall'),
318
+ cloudEnv({ CLI_PACKAGE_DIR: fixtureDir, CLI_CONNECT_IP_LIMIT_PER_15_MIN: '2' })
319
+ );
320
+ await setUp(app, 'Wall');
321
+ });
322
+
323
+ afterAll(async () => {
324
+ await app.stop();
325
+ });
326
+
327
+ const from = (ip: string) => ({ 'x-forwarded-for': ip });
328
+
329
+ it('the third presentation from one address is 429, and the token it carried still downloads from another', async () => {
330
+ for (let i = 0; i < 2; i++) {
331
+ await expectError(await download(app, { token: 'garbage' }, from('10.91.0.1')), 401, 'invalid_token');
332
+ }
333
+ const { token } = await hub.signConnectToken(person(), RESOURCE);
334
+ expect((await download(app, { token }, from('10.91.0.1'))).status).toBe(429);
335
+ const elsewhere = await download(app, { token }, from('10.91.0.2'));
336
+ expect(elsewhere.status).toBe(200);
337
+ expect(Buffer.from(await elsewhere.arrayBuffer()).equals(tarball)).toBe(true);
338
+ });
339
+
340
+ it('the bucket is the one /sso/cli-connect spends: two connects, then the download is 429', async () => {
341
+ const connect = (token: string) =>
342
+ app.app.request('/api/v1/sso/cli-connect', {
343
+ method: 'POST',
344
+ headers: { 'content-type': 'application/json', ...from('10.91.0.3') },
345
+ body: JSON.stringify({ token })
346
+ });
347
+ for (let i = 0; i < 2; i++) {
348
+ await expectError(await connect('x'.repeat(24)), 401, 'invalid_token');
349
+ }
350
+ const { token } = await hub.signConnectToken(person(), RESOURCE);
351
+ expect((await download(app, { token }, from('10.91.0.3'))).status).toBe(429);
352
+ });
353
+ });
354
+
355
+ describe('oss edition', () => {
356
+ let app: TestApp;
357
+
358
+ beforeAll(async () => {
359
+ // Even with the folder set: the oss edition mounts zero hub surface.
360
+ app = await createTestApp(await createDatabase(container, 'cli_package_oss'), {
361
+ CLI_PACKAGE_DIR: fixtureDir
362
+ });
363
+ });
364
+
365
+ afterAll(async () => {
366
+ await app.stop();
367
+ });
368
+
369
+ it('the path is the JSON 404 terminator, and discovery carries no cli key', async () => {
370
+ const { token } = await hub.signConnectToken(person(), RESOURCE);
371
+ const res = await download(app, { token });
372
+ expect(res.status).toBe(404);
373
+ expect((await readJson(res)).error.code).toBe('not_found');
374
+ const instance = await readJson(await app.app.request('/api/v1/instance'));
375
+ expect('cli' in instance).toBe(false);
376
+ });
377
+ });
378
+
379
+ describe('the boot', () => {
380
+ it('refuses a CLI_PACKAGE_DIR that does not hold the package, naming the folder', async () => {
381
+ // The env parser's refusal is a printed table and process.exit(1): both
382
+ // are stubbed so the refusal surfaces as a rejection carrying the table.
383
+ let printed = '';
384
+ vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
385
+ printed += args.map(String).join(' ');
386
+ });
387
+ vi.spyOn(process, 'exit').mockImplementation((code) => {
388
+ throw new Error(`process.exit(${String(code)}): ${printed}`);
389
+ });
390
+ try {
391
+ const escaped = emptyDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
392
+ await expectBootRefusal(
393
+ createTestApp(
394
+ await createDatabase(container, 'cli_package_refused'),
395
+ cloudEnv({ CLI_PACKAGE_DIR: emptyDir })
396
+ ),
397
+ new RegExp(`CLI_PACKAGE_DIR\\s+CLI_PACKAGE_DIR=${escaped}: no package\\.tgz`)
398
+ );
399
+ } finally {
400
+ vi.restoreAllMocks();
401
+ }
402
+ });
403
+ });
@@ -0,0 +1,93 @@
1
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { afterEach, describe, expect, it } from 'vitest';
5
+ import { loadCliPackage, packFilename } from '@antasphere/chassis-server/cli-package';
6
+
7
+ /**
8
+ * PRDCT-3312: the folder `CLI_PACKAGE_DIR` names holds the tool's CLI package
9
+ * as the release packed it (`package.tgz` beside a copy of its
10
+ * `package.json`). Pinned: npm's own pack filename, the unset case, the four
11
+ * fields read off a good folder, and the refusals that stop the boot, each
12
+ * naming the folder.
13
+ */
14
+ describe('packFilename', () => {
15
+ it('drops the scope’s @ and turns its / into -', () => {
16
+ expect(packFilename('@antasphere/yourtool', '1.2.3')).toBe('antasphere-yourtool-1.2.3.tgz');
17
+ });
18
+
19
+ it('keeps an unscoped name as it is', () => {
20
+ expect(packFilename('yourtool', '0.4.0-beta.1')).toBe('yourtool-0.4.0-beta.1.tgz');
21
+ });
22
+ });
23
+
24
+ describe('loadCliPackage', () => {
25
+ const dirs: string[] = [];
26
+ const folder = async (files: Record<string, string | Buffer>): Promise<string> => {
27
+ const dir = await mkdtemp(join(tmpdir(), 'cli-package-'));
28
+ dirs.push(dir);
29
+ for (const [name, content] of Object.entries(files)) await writeFile(join(dir, name), content);
30
+ return dir;
31
+ };
32
+
33
+ afterEach(async () => {
34
+ await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
35
+ });
36
+
37
+ it('answers null when the variable is unset: the instance serves no CLI', () => {
38
+ expect(loadCliPackage(undefined)).toBeNull();
39
+ });
40
+
41
+ it('reads the name, the version, the tarball’s path and size, and the pack filename', async () => {
42
+ const dir = await folder({
43
+ 'package.json': JSON.stringify({ name: '@antasphere/yourtool', version: '1.2.3', bin: {} }),
44
+ 'package.tgz': Buffer.alloc(321, 7)
45
+ });
46
+ expect(loadCliPackage(dir)).toEqual({
47
+ name: '@antasphere/yourtool',
48
+ version: '1.2.3',
49
+ path: join(dir, 'package.tgz'),
50
+ sizeBytes: 321,
51
+ filename: 'antasphere-yourtool-1.2.3.tgz'
52
+ });
53
+ });
54
+
55
+ it('refuses a folder without the tarball, naming the folder', async () => {
56
+ const dir = await folder({ 'package.json': JSON.stringify({ name: 'yourtool', version: '1.0.0' }) });
57
+ expect(() => loadCliPackage(dir)).toThrow(`CLI_PACKAGE_DIR=${dir}: no package.tgz`);
58
+ });
59
+
60
+ it('refuses a folder without package.json, naming the folder', async () => {
61
+ const dir = await folder({ 'package.tgz': Buffer.alloc(8) });
62
+ expect(() => loadCliPackage(dir)).toThrow(`CLI_PACKAGE_DIR=${dir}: no package.json`);
63
+ });
64
+
65
+ it('refuses a package.json lacking a string version, naming the folder', async () => {
66
+ const dir = await folder({
67
+ 'package.json': JSON.stringify({ name: 'yourtool' }),
68
+ 'package.tgz': Buffer.alloc(8)
69
+ });
70
+ expect(() => loadCliPackage(dir)).toThrow(
71
+ `CLI_PACKAGE_DIR=${dir}: package.json carries no string "version"`
72
+ );
73
+ });
74
+
75
+ it('refuses a package.json lacking a string name, or not JSON at all', async () => {
76
+ const nameless = await folder({
77
+ 'package.json': JSON.stringify({ name: 7, version: '1.0.0' }),
78
+ 'package.tgz': Buffer.alloc(8)
79
+ });
80
+ expect(() => loadCliPackage(nameless)).toThrow(
81
+ `CLI_PACKAGE_DIR=${nameless}: package.json carries no string "name"`
82
+ );
83
+ const garbled = await folder({ 'package.json': '{ not json', 'package.tgz': Buffer.alloc(8) });
84
+ expect(() => loadCliPackage(garbled)).toThrow(
85
+ `CLI_PACKAGE_DIR=${garbled}: package.json is not valid JSON`
86
+ );
87
+ });
88
+
89
+ it('refuses a folder that does not exist, naming it', () => {
90
+ const dir = join(tmpdir(), 'cli-package-absent-folder');
91
+ expect(() => loadCliPackage(dir)).toThrow(`CLI_PACKAGE_DIR=${dir}: no package.tgz`);
92
+ });
93
+ });