Skip to content

Commit b108648

Browse files
authored
feat: align client with project-scoped permissions API (#11)
* feat: align client with project-scoped permissions API Mirrors backend changes from thatopen/backend-api feat/permissions-checks-improvements. Clients that assert a projectId now match what the backend expects; new project-scoped list helpers unlock use cases where components want to enumerate resources inside a specific project. - executeComponent: typed projectId as a reserved key on executionParams. The backend now rejects foreign projectIds with 403. - listExecutions(componentId, projectId?) forwards the projectId query param to the scoped endpoint. - checkPermission returns { hasPermission, scope }; add checkPermissionBatch for batch checks. - New listProjectFiles / listProjectFolders / listProjectApps / listProjectComponents wrappers. - Vitest harness for the HTTP client with contract coverage of the methods above. - CONTEXT.md gains a "Permissions contract" section. * feat: split client surface — add PlatformClient for JWT; projectId on listFiles/Folders/Apps/Components - New PlatformClient (bearer-only) for apps / frontends / JWT users. Composes EngineServicesClient under the hood — zero duplication. - EngineServicesClient now supports projectId on listFiles / listFolders / listApps / listComponents, hitting the new public routes GET /item?projectId and GET /item/folder?projectId. - Removed the v1 listProjectFiles / listProjectFolders / listProjectApps / listProjectComponents helpers that pointed at JWT-only /project routes; those were the wrong target for a component client. - CONTEXT.md: documents the two-client split (components vs apps/FE). - Vitest: surface-contract tests for PlatformClient (authoritative list of what it exposes and what it doesn't) plus query-string checks for the new projectId path. * refactor: PlatformClient extends EngineServicesClient; add changeset Instead of wrapping EngineServicesClient behind a narrowed surface, PlatformClient now subclasses it and only swaps the constructor to force Bearer auth. The full method vocabulary is inherited — apps and frontends that need executeComponent or other methods can use them from the same client, with the user's JWT. - PlatformClient constructor: (bearerToken, apiUrl, props?) with useBearer removed from the prop surface. - Vitest: replace "does not expose" assertions with instanceof + ts-expect-error contract tests that lock the constructor shape. - CONTEXT.md: simplify the two-client split description. - Add .changeset/bright-permissions-unlocked.md — minor bump with breaking-note on the removed listProject* helpers. * refactor: move JWT-only routes to PlatformClient; accept token provider - getProject / getProjectData / checkPermission / checkPermissionBatch cannot be reached with an API token (ProjectController is JWT-guarded on the backend). Moved from EngineServicesClient to PlatformClient so the types reflect that reality — API-token callers get a compile error instead of a runtime 401. - PlatformClient constructor accepts string | () => string | Promise<string>. When a function is passed, it's called on every request — Auth0's getAccessTokenSilently() and similar refreshing sources Just Work, so an expired JWT no longer sticks. - Added PlatformClient.fromPlatformContext() for apps running inside the platform iframe. - EngineServicesClient now exposes a protected resolveAccessToken() hook; #requestApi and #requestFile route through it.
1 parent 2653ee7 commit b108648

10 files changed

Lines changed: 1379 additions & 109 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'thatopen-services': minor
3+
---
4+
5+
Align the client with the platform's new project-scoped permissions model and split the client surface for apps vs components.
6+
7+
**New: `PlatformClient`.** Extends `EngineServicesClient` with a bearer-only constructor. Use it from apps, frontends, and any caller authenticating with a user JWT. On top of the inherited API-token-compatible surface, `PlatformClient` owns the JWT-only routes `getProject`, `getProjectData`, `checkPermission`, and `checkPermissionBatch` — those hit `ProjectController` on the backend which is guarded by JWT, so they're not reachable from an access token. `EngineServicesClient` remains the right choice for components (API-token auth, local server, WebSocket progress).
8+
9+
The `PlatformClient` constructor accepts either a static JWT string **or a provider function** (`() => string | Promise<string>`) that's called on every request — so Auth0's `getAccessTokenSilently()` and similar refreshing sources can be passed directly and expired tokens never stick. `PlatformClient.fromPlatformContext()` is available as a static factory for apps running inside the platform iframe.
10+
11+
**Project-scoped listings on the main list methods.** `listFiles`, `listFolders`, `listApps`, and `listComponents` now accept an optional `projectId` and forward it to the new public `GET /item?projectId=X` / `GET /item/folder?projectId=X` routes. Per-entity role overrides are applied server-side; callers without project role permission get 403 (not an empty list). Pass `itemType: 'APP' | 'TOOL' | 'FILE'` to switch what comes back.
12+
13+
**Updated permission checks.** `checkPermission` now returns `{ hasPermission, scope }` where `scope` is `'global' | 'project' | 'entity' | 'none'`. New `checkPermissionBatch(checks)` evaluates multiple checks in one round-trip.
14+
15+
**Execution scoping.** `executeComponent` accepts `projectId` as a reserved key on `executionParams`; foreign project ids are rejected by the backend. `listExecutions(componentId, projectId?)` forwards the query param.
16+
17+
**Breaking.** The v1 convenience helpers `listProjectFiles`, `listProjectFolders`, `listProjectApps`, `listProjectComponents` are removed. They pointed at JWT-only `/project/:id/*` routes, which was the wrong target for an API-token client. Replace with `listFiles({ projectId })` / `listFolders({ projectId })` / `listApps({ projectId })` / `listComponents({ projectId })`.

CONTEXT.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ npm run build:cli # CLI only
4545
### Testing
4646

4747
```bash
48+
npm run test # Run vitest unit tests (HTTP client contract)
49+
npm run test:watch # Vitest watch mode
4850
npm run test:ui # Interactive browser test page
4951
npm run test:cli-build-app # Scaffold + build a test app
5052
npm run test:cli-build-component # Scaffold + build a test cloud component
@@ -53,6 +55,77 @@ npm run test:cli-build-tests # Build CLI + scaffold test app & test compone
5355
npm run test:cli-serve-tests # Serve the test app and test component's local server in parallel
5456
```
5557

58+
## Two clients — components vs apps/FE
59+
60+
This package ships two clients with overlapping but intentionally different
61+
surfaces. Pick the one that matches who's calling.
62+
63+
### `EngineServicesClient`
64+
**For cloud components running inside the platform.** Authenticates via
65+
API token by default (`?accessToken=`). Supports local-server execution,
66+
WebSocket execution progress, built-in component runtime helpers, and the
67+
low-level HTTP surface. This is what you get via
68+
`EngineServicesClient.fromPlatformContext()` inside a component bundle.
69+
70+
### `PlatformClient`
71+
**For apps, frontends, and any caller using a user JWT.** Extends
72+
`EngineServicesClient`; the API-token-compatible surface is inherited and
73+
the constructor forces `useBearer: true`. On top, it owns the JWT-only
74+
routes — `getProject`, `getProjectData`, `checkPermission`,
75+
`checkPermissionBatch` — which hit `ProjectController` in the backend
76+
(guarded by JWT) and are not reachable with an access token.
77+
78+
The constructor accepts either a static JWT or a provider function
79+
(sync or async) that returns the current JWT. The provider is called on
80+
every request, so Auth0's `getAccessTokenSilently()` and similar
81+
refreshing sources Just Work:
82+
83+
```ts
84+
import { PlatformClient } from 'thatopen-services';
85+
const client = new PlatformClient(
86+
() => auth0.getAccessTokenSilently(),
87+
'https://api.thatopen.com',
88+
);
89+
await client.getProjectData(projectId);
90+
```
91+
92+
`PlatformClient.fromPlatformContext()` is available for apps running inside
93+
the platform iframe — it pulls the JWT from
94+
`window.__THATOPEN_CONTEXT__` and returns a ready-to-use client.
95+
96+
Choose by audience:
97+
- Component code → `EngineServicesClient` (or `EngineServicesClient.fromPlatformContext()`).
98+
- App / FE / integration with a user JWT → `PlatformClient`.
99+
100+
## Permissions contract (backend coupling)
101+
102+
The platform API enforces **project-scoped permission checks**: whenever a
103+
request carries a `projectId` (URL param, query, or body), the backend
104+
rejects the call if the resource does not belong to that project or the
105+
caller lacks permission there — regardless of whether the caller has access
106+
to the same resource in a different project.
107+
108+
Relevant client methods:
109+
110+
- `executeComponent(componentId, executionParams, versionTag?)`: include
111+
`projectId` in `executionParams` to scope the execution. The backend
112+
validates that the component is linked to that project. Without a
113+
`projectId`, the execution runs in the user's personal/ownership scope.
114+
- `listExecutions(componentId, projectId?)`: pass `projectId` to filter
115+
executions to that project's context.
116+
- `checkPermission({ resourceType, action, resourceId?, projectId? })`:
117+
returns `{ hasPermission, scope }`. `scope` is `'global' | 'project' |
118+
'entity' | 'none'``global` for admin/owner bypass, `project` for a
119+
role broad grant, `entity` for a per-entity override, `none` for denied.
120+
- `checkPermissionBatch(checks)`: evaluates a list of checks in one
121+
round-trip. Useful for hydrating action visibility for many rows without
122+
N+1 calls.
123+
124+
Per-entity permission overrides (`ResourcePermission.removePermission`,
125+
`ResourcePermission.appliesToDescendants`) are applied automatically on the
126+
server side when listing project files/folders — no client change needed.
127+
128+
56129
### Publishing to npm
57130

58131
```bash

package.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
"lint": "eslint src/",
1919
"build": "eslint src/ && tsc && vite build && vite build --config vite.config.cli.mts",
2020
"build:lib": "tsc && vite build",
21+
"test": "vitest run",
22+
"test:watch": "vitest",
2123
"build:cli": "vite build --config vite.config.cli.mts && node scripts/copy-templates.mjs",
2224
"preview": "vite preview",
2325
"test:ui": "vite --config test/vite.config.mts",
@@ -35,9 +37,9 @@
3537
},
3638
"devDependencies": {
3739
"@changesets/cli": "^2.27.7",
38-
"@thatopen/fragments": "~3.4.0",
3940
"@thatopen/components": "~3.4.0",
4041
"@thatopen/components-front": "~3.4.0",
42+
"@thatopen/fragments": "~3.4.0",
4143
"@thatopen/ui": "~3.4.0",
4244
"@thatopen/ui-obc": "~3.4.0",
4345
"@types/node": "^20.12.12",
@@ -56,7 +58,8 @@
5658
"typescript-eslint": "^7.10.0",
5759
"vite": "^5.2.0",
5860
"vite-plugin-dts": "^3.9.1",
59-
"vite-plugin-eslint": "^1.8.1"
61+
"vite-plugin-eslint": "^1.8.1",
62+
"vitest": "^4.1.4"
6063
},
6164
"dependencies": {
6265
"dotenv": "^16.4.5",
@@ -79,4 +82,4 @@
7982
"optional": true
8083
}
8184
}
82-
}
85+
}

src/core/client.test.ts

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import {
2+
describe,
3+
it,
4+
expect,
5+
beforeEach,
6+
afterEach,
7+
vi,
8+
type Mock,
9+
} from 'vitest';
10+
import { EngineServicesClient } from './client';
11+
12+
const API = 'https://api.example.com';
13+
const TOKEN = 'test-token';
14+
15+
function okResponse(data: unknown): Response {
16+
return {
17+
ok: true,
18+
status: 200,
19+
statusText: 'OK',
20+
text: async () => JSON.stringify(data),
21+
json: async () => data,
22+
} as unknown as Response;
23+
}
24+
25+
function errorResponse(status: number, message = 'Bad Request'): Response {
26+
return {
27+
ok: false,
28+
status,
29+
statusText: message,
30+
text: async () => message,
31+
json: async () => ({ message }),
32+
} as unknown as Response;
33+
}
34+
35+
function getCall(
36+
fetchMock: Mock,
37+
index = 0,
38+
): { url: string; init: RequestInit } {
39+
const call = fetchMock.mock.calls[index];
40+
return { url: call[0] as string, init: call[1] as RequestInit };
41+
}
42+
43+
function parseUrl(url: string): { pathname: string; params: URLSearchParams } {
44+
const u = new URL(url);
45+
return { pathname: u.pathname, params: u.searchParams };
46+
}
47+
48+
describe('EngineServicesClient — HTTP contract', () => {
49+
let fetchMock: Mock;
50+
51+
beforeEach(() => {
52+
fetchMock = vi.fn();
53+
globalThis.fetch = fetchMock as unknown as typeof fetch;
54+
});
55+
56+
afterEach(() => {
57+
vi.restoreAllMocks();
58+
});
59+
60+
describe('auth mode', () => {
61+
it('access-token mode puts token in query string', async () => {
62+
fetchMock.mockResolvedValue(okResponse([]));
63+
const client = new EngineServicesClient(TOKEN, API);
64+
await client.listFiles();
65+
const { url, init } = getCall(fetchMock);
66+
const { params } = parseUrl(url);
67+
expect(params.get('accessToken')).toBe(TOKEN);
68+
expect(
69+
(init.headers as Record<string, string>).Authorization,
70+
).toBeUndefined();
71+
});
72+
73+
it('bearer mode sets Authorization header and omits accessToken query param', async () => {
74+
fetchMock.mockResolvedValue(okResponse([]));
75+
const client = new EngineServicesClient(TOKEN, API, { useBearer: true });
76+
await client.listFiles();
77+
const { url, init } = getCall(fetchMock);
78+
const { params } = parseUrl(url);
79+
expect(params.get('accessToken')).toBeNull();
80+
expect((init.headers as Record<string, string>).Authorization).toBe(
81+
`Bearer ${TOKEN}`,
82+
);
83+
});
84+
});
85+
86+
describe('executeComponent', () => {
87+
it('POSTs to /processor/:id/execute with JSON body including projectId when supplied', async () => {
88+
fetchMock.mockResolvedValue(okResponse({ executionId: 'exec-1' }));
89+
const client = new EngineServicesClient(TOKEN, API);
90+
const result = await client.executeComponent(
91+
'comp-42',
92+
{ projectId: 'proj-99', foo: 'bar' },
93+
'v1',
94+
);
95+
expect(result).toEqual({ executionId: 'exec-1' });
96+
const { url, init } = getCall(fetchMock);
97+
const { pathname, params } = parseUrl(url);
98+
expect(pathname).toBe('/api/processor/comp-42/execute');
99+
expect(init.method).toBe('POST');
100+
expect(params.get('versionTag')).toBe('v1');
101+
expect(init.body).toBe(
102+
JSON.stringify({ projectId: 'proj-99', foo: 'bar' }),
103+
);
104+
});
105+
106+
it('omits versionTag from query when not supplied', async () => {
107+
fetchMock.mockResolvedValue(okResponse({ executionId: 'exec-2' }));
108+
const client = new EngineServicesClient(TOKEN, API);
109+
await client.executeComponent('comp-42', {});
110+
const { url } = getCall(fetchMock);
111+
const { params } = parseUrl(url);
112+
expect(params.get('versionTag')).toBeNull();
113+
});
114+
});
115+
116+
describe('listExecutions', () => {
117+
it('passes projectId as a query parameter when provided', async () => {
118+
fetchMock.mockResolvedValue(okResponse([]));
119+
const client = new EngineServicesClient(TOKEN, API);
120+
await client.listExecutions('comp-1', 'proj-1');
121+
const { url } = getCall(fetchMock);
122+
const { pathname, params } = parseUrl(url);
123+
expect(pathname).toBe('/api/processor/comp-1/progress');
124+
expect(params.get('projectId')).toBe('proj-1');
125+
});
126+
127+
it('omits projectId when not supplied', async () => {
128+
fetchMock.mockResolvedValue(okResponse([]));
129+
const client = new EngineServicesClient(TOKEN, API);
130+
await client.listExecutions('comp-1');
131+
const { url } = getCall(fetchMock);
132+
const { params } = parseUrl(url);
133+
expect(params.get('projectId')).toBeNull();
134+
});
135+
});
136+
137+
// `checkPermission` and `checkPermissionBatch` live on `PlatformClient`
138+
// (JWT-only routes) — their contract tests are in `platform-client.test.ts`.
139+
140+
describe('project-scoped list methods — via projectId query on /item and /item/folder', () => {
141+
it('listFiles({ projectId }) forwards projectId on /item', async () => {
142+
fetchMock.mockResolvedValue(okResponse([]));
143+
const client = new EngineServicesClient(TOKEN, API);
144+
await client.listFiles({ projectId: 'proj-1', archived: true });
145+
const { url, init } = getCall(fetchMock);
146+
const { pathname, params } = parseUrl(url);
147+
expect(pathname).toBe('/api/item');
148+
expect(init.method).toBe('GET');
149+
expect(params.get('itemType')).toBe('FILE');
150+
expect(params.get('projectId')).toBe('proj-1');
151+
expect(params.get('archived')).toBe('true');
152+
});
153+
154+
it('listFolders({ projectId }) forwards projectId on /item/folder', async () => {
155+
fetchMock.mockResolvedValue(okResponse([]));
156+
const client = new EngineServicesClient(TOKEN, API);
157+
await client.listFolders({ projectId: 'proj-1' });
158+
const { url, init } = getCall(fetchMock);
159+
const { pathname, params } = parseUrl(url);
160+
expect(pathname).toBe('/api/item/folder');
161+
expect(init.method).toBe('GET');
162+
expect(params.get('projectId')).toBe('proj-1');
163+
});
164+
165+
it('listApps({ projectId }) forwards projectId on /item', async () => {
166+
fetchMock.mockResolvedValue(okResponse([]));
167+
const client = new EngineServicesClient(TOKEN, API);
168+
await client.listApps({ projectId: 'proj-1' });
169+
const { url, params } = {
170+
...getCall(fetchMock),
171+
...parseUrl(getCall(fetchMock).url),
172+
};
173+
expect(url).toMatch(/\/api\/item\b/);
174+
expect(params.get('itemType')).toBe('APP');
175+
expect(params.get('projectId')).toBe('proj-1');
176+
});
177+
178+
it('listComponents({ projectId }) forwards projectId on /item', async () => {
179+
fetchMock.mockResolvedValue(okResponse([]));
180+
const client = new EngineServicesClient(TOKEN, API);
181+
await client.listComponents({ projectId: 'proj-1' });
182+
const { params } = parseUrl(getCall(fetchMock).url);
183+
expect(params.get('itemType')).toBe('TOOL');
184+
expect(params.get('projectId')).toBe('proj-1');
185+
});
186+
});
187+
188+
describe('createFile / createFolder / createComponent / createApp pass projectId', () => {
189+
it('createFolder POSTs projectId in JSON body', async () => {
190+
fetchMock.mockResolvedValue(okResponse({}));
191+
const client = new EngineServicesClient(TOKEN, API);
192+
await client.createFolder('My folder', undefined, 'proj-1');
193+
const { url, init } = getCall(fetchMock);
194+
const { pathname } = parseUrl(url);
195+
expect(pathname).toBe('/api/item/folder');
196+
expect(init.method).toBe('POST');
197+
const body = JSON.parse(init.body as string);
198+
expect(body).toMatchObject({ name: 'My folder', projectId: 'proj-1' });
199+
});
200+
201+
it('createFile attaches projectId to the FormData body', async () => {
202+
fetchMock.mockResolvedValue(okResponse({}));
203+
const client = new EngineServicesClient(TOKEN, API);
204+
const file = new Blob(['dummy']) as Blob;
205+
await client.createFile({
206+
file,
207+
name: 'doc.ifc',
208+
versionTag: 'v1',
209+
projectId: 'proj-1',
210+
});
211+
const { init } = getCall(fetchMock);
212+
const formData = init.body as FormData;
213+
expect(formData).toBeInstanceOf(FormData);
214+
expect(formData.get('projectId')).toBe('proj-1');
215+
expect(formData.get('itemType')).toBe('FILE');
216+
});
217+
});
218+
219+
describe('error handling', () => {
220+
it('throws when the server responds with a non-2xx status', async () => {
221+
fetchMock.mockResolvedValue(errorResponse(403, 'Forbidden'));
222+
const client = new EngineServicesClient(TOKEN, API);
223+
await expect(
224+
client.executeComponent('comp-1', { projectId: 'foreign' }),
225+
).rejects.toThrow(/403/);
226+
});
227+
});
228+
});

0 commit comments

Comments
 (0)