Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/bright-permissions-unlocked.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'thatopen-services': minor
---

Align the client with the platform's new project-scoped permissions model and split the client surface for apps vs components.

**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).

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.

**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.

**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.

**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.

**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 })`.
73 changes: 73 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ npm run build:cli # CLI only
### Testing

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

## Two clients — components vs apps/FE

This package ships two clients with overlapping but intentionally different
surfaces. Pick the one that matches who's calling.

### `EngineServicesClient`
**For cloud components running inside the platform.** Authenticates via
API token by default (`?accessToken=`). Supports local-server execution,
WebSocket execution progress, built-in component runtime helpers, and the
low-level HTTP surface. This is what you get via
`EngineServicesClient.fromPlatformContext()` inside a component bundle.

### `PlatformClient`
**For apps, frontends, and any caller using a user JWT.** Extends
`EngineServicesClient`; the API-token-compatible surface is inherited and
the constructor forces `useBearer: true`. On top, it owns the JWT-only
routes — `getProject`, `getProjectData`, `checkPermission`,
`checkPermissionBatch` — which hit `ProjectController` in the backend
(guarded by JWT) and are not reachable with an access token.

The constructor accepts either a static JWT or a provider function
(sync or async) that returns the current JWT. The provider is called on
every request, so Auth0's `getAccessTokenSilently()` and similar
refreshing sources Just Work:

```ts
import { PlatformClient } from 'thatopen-services';
const client = new PlatformClient(
() => auth0.getAccessTokenSilently(),
'https://api.thatopen.com',
);
await client.getProjectData(projectId);
```

`PlatformClient.fromPlatformContext()` is available for apps running inside
the platform iframe — it pulls the JWT from
`window.__THATOPEN_CONTEXT__` and returns a ready-to-use client.

Choose by audience:
- Component code → `EngineServicesClient` (or `EngineServicesClient.fromPlatformContext()`).
- App / FE / integration with a user JWT → `PlatformClient`.

## Permissions contract (backend coupling)

The platform API enforces **project-scoped permission checks**: whenever a
request carries a `projectId` (URL param, query, or body), the backend
rejects the call if the resource does not belong to that project or the
caller lacks permission there — regardless of whether the caller has access
to the same resource in a different project.

Relevant client methods:

- `executeComponent(componentId, executionParams, versionTag?)`: include
`projectId` in `executionParams` to scope the execution. The backend
validates that the component is linked to that project. Without a
`projectId`, the execution runs in the user's personal/ownership scope.
- `listExecutions(componentId, projectId?)`: pass `projectId` to filter
executions to that project's context.
- `checkPermission({ resourceType, action, resourceId?, projectId? })`:
returns `{ hasPermission, scope }`. `scope` is `'global' | 'project' |
'entity' | 'none'` — `global` for admin/owner bypass, `project` for a
role broad grant, `entity` for a per-entity override, `none` for denied.
- `checkPermissionBatch(checks)`: evaluates a list of checks in one
round-trip. Useful for hydrating action visibility for many rows without
N+1 calls.

Per-entity permission overrides (`ResourcePermission.removePermission`,
`ResourcePermission.appliesToDescendants`) are applied automatically on the
server side when listing project files/folders — no client change needed.


### Publishing to npm

```bash
Expand Down
9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
"lint": "eslint src/",
"build": "eslint src/ && tsc && vite build && vite build --config vite.config.cli.mts",
"build:lib": "tsc && vite build",
"test": "vitest run",
"test:watch": "vitest",
"build:cli": "vite build --config vite.config.cli.mts && node scripts/copy-templates.mjs",
"preview": "vite preview",
"test:ui": "vite --config test/vite.config.mts",
Expand All @@ -35,9 +37,9 @@
},
"devDependencies": {
"@changesets/cli": "^2.27.7",
"@thatopen/fragments": "~3.4.0",
"@thatopen/components": "~3.4.0",
"@thatopen/components-front": "~3.4.0",
"@thatopen/fragments": "~3.4.0",
"@thatopen/ui": "~3.4.0",
"@thatopen/ui-obc": "~3.4.0",
"@types/node": "^20.12.12",
Expand All @@ -56,7 +58,8 @@
"typescript-eslint": "^7.10.0",
"vite": "^5.2.0",
"vite-plugin-dts": "^3.9.1",
"vite-plugin-eslint": "^1.8.1"
"vite-plugin-eslint": "^1.8.1",
"vitest": "^4.1.4"
},
"dependencies": {
"dotenv": "^16.4.5",
Expand All @@ -79,4 +82,4 @@
"optional": true
}
}
}
}
228 changes: 228 additions & 0 deletions src/core/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import {
describe,
it,
expect,
beforeEach,
afterEach,
vi,
type Mock,
} from 'vitest';
import { EngineServicesClient } from './client';

const API = 'https://api.example.com';
const TOKEN = 'test-token';

function okResponse(data: unknown): Response {
return {
ok: true,
status: 200,
statusText: 'OK',
text: async () => JSON.stringify(data),
json: async () => data,
} as unknown as Response;
}

function errorResponse(status: number, message = 'Bad Request'): Response {
return {
ok: false,
status,
statusText: message,
text: async () => message,
json: async () => ({ message }),
} as unknown as Response;
}

function getCall(
fetchMock: Mock,
index = 0,
): { url: string; init: RequestInit } {
const call = fetchMock.mock.calls[index];
return { url: call[0] as string, init: call[1] as RequestInit };
}

function parseUrl(url: string): { pathname: string; params: URLSearchParams } {
const u = new URL(url);
return { pathname: u.pathname, params: u.searchParams };
}

describe('EngineServicesClient — HTTP contract', () => {
let fetchMock: Mock;

beforeEach(() => {
fetchMock = vi.fn();
globalThis.fetch = fetchMock as unknown as typeof fetch;
});

afterEach(() => {
vi.restoreAllMocks();
});

describe('auth mode', () => {
it('access-token mode puts token in query string', async () => {
fetchMock.mockResolvedValue(okResponse([]));
const client = new EngineServicesClient(TOKEN, API);
await client.listFiles();
const { url, init } = getCall(fetchMock);
const { params } = parseUrl(url);
expect(params.get('accessToken')).toBe(TOKEN);
expect(
(init.headers as Record<string, string>).Authorization,
).toBeUndefined();
});

it('bearer mode sets Authorization header and omits accessToken query param', async () => {
fetchMock.mockResolvedValue(okResponse([]));
const client = new EngineServicesClient(TOKEN, API, { useBearer: true });
await client.listFiles();
const { url, init } = getCall(fetchMock);
const { params } = parseUrl(url);
expect(params.get('accessToken')).toBeNull();
expect((init.headers as Record<string, string>).Authorization).toBe(
`Bearer ${TOKEN}`,
);
});
});

describe('executeComponent', () => {
it('POSTs to /processor/:id/execute with JSON body including projectId when supplied', async () => {
fetchMock.mockResolvedValue(okResponse({ executionId: 'exec-1' }));
const client = new EngineServicesClient(TOKEN, API);
const result = await client.executeComponent(
'comp-42',
{ projectId: 'proj-99', foo: 'bar' },
'v1',
);
expect(result).toEqual({ executionId: 'exec-1' });
const { url, init } = getCall(fetchMock);
const { pathname, params } = parseUrl(url);
expect(pathname).toBe('/api/processor/comp-42/execute');
expect(init.method).toBe('POST');
expect(params.get('versionTag')).toBe('v1');
expect(init.body).toBe(
JSON.stringify({ projectId: 'proj-99', foo: 'bar' }),
);
});

it('omits versionTag from query when not supplied', async () => {
fetchMock.mockResolvedValue(okResponse({ executionId: 'exec-2' }));
const client = new EngineServicesClient(TOKEN, API);
await client.executeComponent('comp-42', {});
const { url } = getCall(fetchMock);
const { params } = parseUrl(url);
expect(params.get('versionTag')).toBeNull();
});
});

describe('listExecutions', () => {
it('passes projectId as a query parameter when provided', async () => {
fetchMock.mockResolvedValue(okResponse([]));
const client = new EngineServicesClient(TOKEN, API);
await client.listExecutions('comp-1', 'proj-1');
const { url } = getCall(fetchMock);
const { pathname, params } = parseUrl(url);
expect(pathname).toBe('/api/processor/comp-1/progress');
expect(params.get('projectId')).toBe('proj-1');
});

it('omits projectId when not supplied', async () => {
fetchMock.mockResolvedValue(okResponse([]));
const client = new EngineServicesClient(TOKEN, API);
await client.listExecutions('comp-1');
const { url } = getCall(fetchMock);
const { params } = parseUrl(url);
expect(params.get('projectId')).toBeNull();
});
});

// `checkPermission` and `checkPermissionBatch` live on `PlatformClient`
// (JWT-only routes) — their contract tests are in `platform-client.test.ts`.

describe('project-scoped list methods — via projectId query on /item and /item/folder', () => {
it('listFiles({ projectId }) forwards projectId on /item', async () => {
fetchMock.mockResolvedValue(okResponse([]));
const client = new EngineServicesClient(TOKEN, API);
await client.listFiles({ projectId: 'proj-1', archived: true });
const { url, init } = getCall(fetchMock);
const { pathname, params } = parseUrl(url);
expect(pathname).toBe('/api/item');
expect(init.method).toBe('GET');
expect(params.get('itemType')).toBe('FILE');
expect(params.get('projectId')).toBe('proj-1');
expect(params.get('archived')).toBe('true');
});

it('listFolders({ projectId }) forwards projectId on /item/folder', async () => {
fetchMock.mockResolvedValue(okResponse([]));
const client = new EngineServicesClient(TOKEN, API);
await client.listFolders({ projectId: 'proj-1' });
const { url, init } = getCall(fetchMock);
const { pathname, params } = parseUrl(url);
expect(pathname).toBe('/api/item/folder');
expect(init.method).toBe('GET');
expect(params.get('projectId')).toBe('proj-1');
});

it('listApps({ projectId }) forwards projectId on /item', async () => {
fetchMock.mockResolvedValue(okResponse([]));
const client = new EngineServicesClient(TOKEN, API);
await client.listApps({ projectId: 'proj-1' });
const { url, params } = {
...getCall(fetchMock),
...parseUrl(getCall(fetchMock).url),
};
expect(url).toMatch(/\/api\/item\b/);
expect(params.get('itemType')).toBe('APP');
expect(params.get('projectId')).toBe('proj-1');
});

it('listComponents({ projectId }) forwards projectId on /item', async () => {
fetchMock.mockResolvedValue(okResponse([]));
const client = new EngineServicesClient(TOKEN, API);
await client.listComponents({ projectId: 'proj-1' });
const { params } = parseUrl(getCall(fetchMock).url);
expect(params.get('itemType')).toBe('TOOL');
expect(params.get('projectId')).toBe('proj-1');
});
});

describe('createFile / createFolder / createComponent / createApp pass projectId', () => {
it('createFolder POSTs projectId in JSON body', async () => {
fetchMock.mockResolvedValue(okResponse({}));
const client = new EngineServicesClient(TOKEN, API);
await client.createFolder('My folder', undefined, 'proj-1');
const { url, init } = getCall(fetchMock);
const { pathname } = parseUrl(url);
expect(pathname).toBe('/api/item/folder');
expect(init.method).toBe('POST');
const body = JSON.parse(init.body as string);
expect(body).toMatchObject({ name: 'My folder', projectId: 'proj-1' });
});

it('createFile attaches projectId to the FormData body', async () => {
fetchMock.mockResolvedValue(okResponse({}));
const client = new EngineServicesClient(TOKEN, API);
const file = new Blob(['dummy']) as Blob;
await client.createFile({
file,
name: 'doc.ifc',
versionTag: 'v1',
projectId: 'proj-1',
});
const { init } = getCall(fetchMock);
const formData = init.body as FormData;
expect(formData).toBeInstanceOf(FormData);
expect(formData.get('projectId')).toBe('proj-1');
expect(formData.get('itemType')).toBe('FILE');
});
});

describe('error handling', () => {
it('throws when the server responds with a non-2xx status', async () => {
fetchMock.mockResolvedValue(errorResponse(403, 'Forbidden'));
const client = new EngineServicesClient(TOKEN, API);
await expect(
client.executeComponent('comp-1', { projectId: 'foreign' }),
).rejects.toThrow(/403/);
});
});
});
Loading
Loading