diff --git a/.changeset/api-tokens.md b/.changeset/api-tokens.md new file mode 100644 index 0000000..0311ad5 --- /dev/null +++ b/.changeset/api-tokens.md @@ -0,0 +1,5 @@ +--- +'@thatopen/services': minor +--- + +PlatformClient manages the caller's API tokens: `listTokens`, `getTokenSecret`, `createToken`, `updateToken` and `revokeToken`. A token can be limited to some projects (`projectIds`); without them it acts on every project its owner can reach. New types `AccessToken`, `NewAccessToken` and `AccessTokenPermission`. diff --git a/.changeset/project-documents-export.md b/.changeset/project-documents-export.md new file mode 100644 index 0000000..1839f36 --- /dev/null +++ b/.changeset/project-documents-export.md @@ -0,0 +1,5 @@ +--- +'@thatopen/services': minor +--- + +`exportProjectDocuments(projectId, { format? })`: a project's documents as one flat table for BI and spreadsheets — a row per file with its folder path, live version and every metadata field as a `meta.` column; JSON rows by default, CSV text with `format: 'csv'`. Needs the platform's `GET /item/export`. diff --git a/.changeset/project-members-and-roles.md b/.changeset/project-members-and-roles.md new file mode 100644 index 0000000..62ac128 --- /dev/null +++ b/.changeset/project-members-and-roles.md @@ -0,0 +1,5 @@ +--- +'@thatopen/services': minor +--- + +PlatformClient administers a project's people and permissions: `listMembers`, `addMember`, `setMemberRole`, `removeMember`, `inviteMember`, `listInvitations`, `cancelInvitation`, `listRoles`, `createRole`, `updateRole`, `deleteRole` and `getPermissionMatrix` (every role, what it may do — folder grants included — and who has it). `Permission` gains `resourcePermissions` (a grant or removal on one file or folder, optionally for everything under it) and `PermissionSubject` gains `'API'`. diff --git a/src/core/client.export.test.ts b/src/core/client.export.test.ts new file mode 100644 index 0000000..9100d44 --- /dev/null +++ b/src/core/client.export.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest'; +import { EngineServicesClient } from './client'; + +// A project's documents as one flat table, for BI (APOGEA 21-10). +const API = 'https://api.example.com'; + +describe('exportProjectDocuments', () => { + let fetchMock: Mock; + beforeEach(() => { + fetchMock = vi.fn(); + globalThis.fetch = fetchMock as unknown as typeof fetch; + }); + afterEach(() => vi.restoreAllMocks()); + + it('asks for the JSON table by default', async () => { + const table = { columns: ['fileId'], value: [{ fileId: 'a' }] }; + fetchMock.mockResolvedValue({ + ok: true, status: 200, statusText: 'OK', + text: async () => JSON.stringify(table), json: async () => table, + }); + const client = new EngineServicesClient('token', API); + await expect(client.exportProjectDocuments('p1')).resolves.toEqual(table); + const url = new URL(fetchMock.mock.calls[0][0] as string); + expect(url.pathname).toBe('/api/item/export'); + expect(url.searchParams.get('projectId')).toBe('p1'); + expect(url.searchParams.get('format')).toBeNull(); + }); + + it('answers the CSV as text, without the BOM', async () => { + fetchMock.mockResolvedValue({ + ok: true, status: 200, statusText: 'OK', + blob: async () => new Blob(['fileId\r\na']), + headers: new Headers({ 'content-type': 'text/csv' }), + }); + const client = new EngineServicesClient('token', API); + await expect(client.exportProjectDocuments('p1', { format: 'csv' })).resolves.toBe('fileId\r\na'); + expect(new URL(fetchMock.mock.calls[0][0] as string).searchParams.get('format')).toBe('csv'); + }); +}); diff --git a/src/core/client.ts b/src/core/client.ts index 818d0cd..ad71927 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -625,6 +625,42 @@ export class EngineServicesClient { }>('GET', `${ITEM_PATH}/documents`, { query: { projectId } }); } + /** + * A project's documents as one flat table, for BI and spreadsheets + * (`GET /item/export`): a row per file with its folder path, its live + * version and every metadata field of that version as a `meta.` + * column. Only what the caller may read. As JSON rows by default; as CSV + * text (RFC 4180) with `format: 'csv'`, ready for Power BI or Excel. A + * platform from before the route answers 404. + * + * @example + * ```ts + * const { columns, value } = await client.exportProjectDocuments(projectId); + * const csv = await client.exportProjectDocuments(projectId, { format: 'csv' }); + * ``` + */ + async exportProjectDocuments( + projectId: string, + ): Promise<{ columns: string[]; value: Record[] }>; + async exportProjectDocuments( + projectId: string, + options: { format: 'csv' }, + ): Promise; + async exportProjectDocuments(projectId: string, options?: { format?: 'csv' | 'json' }) { + if (options?.format === 'csv') { + const blob = await this.#requestApi('GET', `${ITEM_PATH}/export`, { + query: { projectId, format: 'csv' }, + responseType: 'blob', + }); + // (The platform starts it with a BOM for Excel; a string has no use for it.) + return (await blob.text()).replace(/^/, ''); + } + return await this.#requestApi<{ + columns: string[]; + value: Record[]; + }>('GET', `${ITEM_PATH}/export`, { query: { projectId } }); + } + /** * Gets a single file by ID, optionally including version history. * @param fileId - The file's unique identifier. diff --git a/src/core/platform-client.members.test.ts b/src/core/platform-client.members.test.ts new file mode 100644 index 0000000..8582b87 --- /dev/null +++ b/src/core/platform-client.members.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest'; +import { PlatformClient } from './platform-client'; + +// Members, invitations and roles: the administration half (APOGEA 23-10). +const API = 'https://api.example.com'; + +function okResponse(data: unknown): Response { + return { + ok: true, + status: 200, + statusText: 'OK', + text: async () => JSON.stringify(data), + json: async () => data, + } as unknown as Response; +} + +describe('PlatformClient — members, invitations and roles', () => { + let fetchMock: Mock; + + beforeEach(() => { + fetchMock = vi.fn(); + globalThis.fetch = fetchMock as unknown as typeof fetch; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const call = (k = 0) => { + const [url, init] = fetchMock.mock.calls[k] as [string, RequestInit]; + return { + method: init.method, + path: new URL(url).pathname, + body: init.body ? JSON.parse(String(init.body)) : undefined, + }; + }; + + it('invites a person with a role', async () => { + fetchMock.mockResolvedValue(okResponse({ _id: 'i1' })); + await new PlatformClient('jwt', API).inviteMember('p1', 'a@b.com', 'r1'); + expect(call()).toEqual({ + method: 'POST', + path: '/api/project/p1/invite', + body: { email: 'a@b.com', projectRoleId: 'r1' }, + }); + }); + + it('lists and cancels invitations', async () => { + fetchMock.mockResolvedValue(okResponse([])); + const client = new PlatformClient('jwt', API); + await client.listInvitations('p1'); + await client.cancelInvitation('p1', 'i1'); + expect(call(0)).toMatchObject({ method: 'GET', path: '/api/project/p1/invites' }); + expect(call(1)).toMatchObject({ method: 'DELETE', path: '/api/project/p1/invite/i1' }); + }); + + it("sets a member's role, null going back to the default", async () => { + fetchMock.mockResolvedValue(okResponse({})); + await new PlatformClient('jwt', API).setMemberRole('p1', 'u1', null); + expect(call()).toEqual({ + method: 'PUT', + path: '/api/project/p1/user/u1', + body: { projectRoleId: null }, + }); + }); + + it('creates a role with a folder grant', async () => { + fetchMock.mockResolvedValue(okResponse({ _id: 'r2' })); + const permissions = [ + { + subject: 'STORAGE' as const, + action: 'READ' as const, + resourcePermissions: [{ resourceId: 'f1', appliesToDescendants: true }], + }, + ]; + await new PlatformClient('jwt', API).createRole('p1', { name: 'Observer', permissions }); + expect(call()).toEqual({ + method: 'POST', + path: '/api/project/p1/role', + body: { name: 'Observer', permissions }, + }); + }); + + it('puts roles, their permissions and their members in one matrix', async () => { + const roles = [ + { _id: 'r1', name: 'Admin', permissions: [{ subject: 'ALL', action: 'MANAGE' }] }, + { _id: 'r2', name: 'Observer', permissions: [] }, + ]; + const members = [ + { projectUserId: 'pu1', user: { _id: 'a', fullName: 'Ana', email: 'a@x' }, role: { _id: 'r1' } }, + { projectUserId: 'pu2', user: { _id: 'b', fullName: 'Luis', email: 'b@x' }, role: { _id: 'r2' } }, + ]; + fetchMock.mockImplementation(async (url: string) => + okResponse(new URL(url).pathname.endsWith('/roles') ? roles : members), + ); + const matrix = await new PlatformClient('jwt', API).getPermissionMatrix('p1'); + expect(matrix.map((row) => [row.role.name, row.members.map((m) => m.fullName)])).toEqual([ + ['Admin', ['Ana']], + ['Observer', ['Luis']], + ]); + }); +}); diff --git a/src/core/platform-client.tokens.test.ts b/src/core/platform-client.tokens.test.ts new file mode 100644 index 0000000..69884cc --- /dev/null +++ b/src/core/platform-client.tokens.test.ts @@ -0,0 +1,90 @@ +import { + describe, + it, + expect, + beforeEach, + afterEach, + vi, + type Mock, +} from 'vitest'; +import { PlatformClient } from './platform-client'; + +// API tokens limited to projects, revocable one by one (APOGEA 23-10, §2.5). +const API = 'https://api.example.com'; + +function okResponse(data: unknown, text = JSON.stringify(data)): Response { + return { + ok: true, + status: 200, + statusText: 'OK', + text: async () => text, + json: async () => data, + blob: async () => new Blob([text]), + } as unknown as Response; +} + +describe('PlatformClient — API tokens', () => { + let fetchMock: Mock; + + beforeEach(() => { + fetchMock = vi.fn(); + globalThis.fetch = fetchMock as unknown as typeof fetch; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const call = (k = 0) => { + const [url, init] = fetchMock.mock.calls[k] as [string, RequestInit]; + return { + method: init.method, + path: new URL(url).pathname, + body: init.body ? JSON.parse(String(init.body)) : undefined, + }; + }; + + it('creates a token limited to some projects', async () => { + fetchMock.mockResolvedValue(okResponse({ _id: 't1' })); + await new PlatformClient('jwt', API).createToken({ + name: 'BI', + permissions: ['STORAGE:READ'], + projectIds: ['p1', 'p2'], + }); + expect(call()).toEqual({ + method: 'POST', + path: '/api/tokens', + body: { + name: 'BI', + permissions: ['STORAGE:READ'], + urlRestrictions: [], + projectIds: ['p1', 'p2'], + }, + }); + }); + + it('changes, lists and revokes tokens', async () => { + fetchMock.mockResolvedValue(okResponse([])); + const client = new PlatformClient('jwt', API); + await client.updateToken('t1', { projectIds: [] }); + await client.listTokens(); + await client.revokeToken('t1'); + expect(call(0)).toEqual({ + method: 'PUT', + path: '/api/tokens/t1', + body: { projectIds: [] }, + }); + expect(call(1)).toMatchObject({ method: 'GET', path: '/api/tokens' }); + expect(call(2)).toMatchObject({ method: 'DELETE', path: '/api/tokens/t1' }); + }); + + it('reads a token secret, which the route answers as bare text', async () => { + fetchMock.mockResolvedValue(okResponse(undefined, 'secret-abc')); + const secret = await new PlatformClient('jwt', API).getTokenSecret('t1'); + expect(secret).toBe('secret-abc'); + expect(call()).toMatchObject({ + method: 'GET', + path: '/api/tokens/t1/token', + }); + }); +}); diff --git a/src/core/platform-client.ts b/src/core/platform-client.ts index d774bbe..b429273 100644 --- a/src/core/platform-client.ts +++ b/src/core/platform-client.ts @@ -3,7 +3,17 @@ import { EngineServicesClient, EngineServicesClientProps, } from './client'; -import { Project, ProjectData, ProjectWithRole } from '../types/projects'; +import { + Permission, + Project, + ProjectData, + ProjectDataMember, + ProjectInvitation, + ProjectPermissionMatrixRow, + ProjectRole, + ProjectWithRole, +} from '../types/projects'; +import { AccessToken, NewAccessToken } from '../types/tokens'; import { ThatOpenContext } from '../types/context'; import { ChannelClient, windowTransport } from './channel'; import { @@ -15,6 +25,7 @@ import { const PROJECT_PATH = 'project'; const NOTIFICATION_PATH = 'notifications'; +const TOKENS_PATH = 'tokens'; /** * What arrived on the socket. One callback for all three because a bell @@ -218,6 +229,222 @@ export class PlatformClient extends EngineServicesClient { ); } + // ─── Members, invitations and roles (JWT-only backend routes) ───── + // + // The administration half of a project, as the dashboard has it: who is in + // it, who is invited, which roles there are and what each may do — down to + // one folder (`Permission.resourcePermissions`). Each call is checked on the + // server against the caller's own role (MEMBERS / ROLES), exactly as the + // dashboard's are (APOGEA 23-10, «users and permissions»). + + /** The project's members, each with their role. */ + async listMembers(projectId: string) { + return await this.request( + 'GET', + `${PROJECT_PATH}/${projectId}/users`, + ); + } + + /** Adds an existing account to the project, with a role (the project's default when not given). */ + async addMember(projectId: string, accountId: string, roleId?: string) { + return await this.request( + 'POST', + `${PROJECT_PATH}/${projectId}/user`, + { + body: JSON.stringify({ + accountId, + ...(roleId && { projectRoleId: roleId }), + }), + contentType: 'application/json', + }, + ); + } + + /** Gives a member another role (`null`: the project's default). `projectUserId` is the member's, from {@link listMembers}. */ + async setMemberRole( + projectId: string, + projectUserId: string, + roleId: string | null, + ) { + return await this.request( + 'PUT', + `${PROJECT_PATH}/${projectId}/user/${projectUserId}`, + { + body: JSON.stringify({ projectRoleId: roleId }), + contentType: 'application/json', + }, + ); + } + + /** Takes a member out of the project. */ + async removeMember(projectId: string, projectUserId: string) { + return await this.request( + 'DELETE', + `${PROJECT_PATH}/${projectId}/user/${projectUserId}`, + ); + } + + /** + * Invites a person to the project by email, with a role. They get the + * platform's invitation; on accepting it they join with that role, whether + * or not they had an account. + */ + async inviteMember(projectId: string, email: string, roleId?: string) { + return await this.request( + 'POST', + `${PROJECT_PATH}/${projectId}/invite`, + { + body: JSON.stringify({ + email, + ...(roleId && { projectRoleId: roleId }), + }), + contentType: 'application/json', + }, + ); + } + + /** The project's invitations. */ + async listInvitations(projectId: string) { + return await this.request( + 'GET', + `${PROJECT_PATH}/${projectId}/invites`, + ); + } + + /** Withdraws an invitation that has not been accepted. */ + async cancelInvitation(projectId: string, invitationId: string) { + return await this.request( + 'DELETE', + `${PROJECT_PATH}/${projectId}/invite/${invitationId}`, + ); + } + + /** The project's roles, each with its permissions. */ + async listRoles(projectId: string) { + return await this.request( + 'GET', + `${PROJECT_PATH}/${projectId}/roles`, + ); + } + + /** + * Makes a role. Its `permissions` are subject × action, each optionally + * narrowed or widened on given files and folders (`resourcePermissions`): + * the grant a role has on one folder and what is under it. + * + * @example A role that only reads one folder and everything in it + * ```ts + * await client.createRole(projectId, { + * name: 'Observer — structure', + * permissions: [ + * { subject: 'STORAGE', action: 'READ', + * resourcePermissions: [{ resourceId: structureFolderId, appliesToDescendants: true }] }, + * ], + * }); + * ``` + */ + async createRole( + projectId: string, + role: { name: string; description?: string; permissions: Permission[] }, + ) { + return await this.request( + 'POST', + `${PROJECT_PATH}/${projectId}/role`, + { body: JSON.stringify(role), contentType: 'application/json' }, + ); + } + + /** Changes a role: its name, description or permissions (grants and revocations alike). */ + async updateRole( + roleId: string, + changes: { name?: string; description?: string; permissions?: Permission[] }, + ) { + return await this.request( + 'PUT', + `${PROJECT_PATH}/role/${roleId}`, + { body: JSON.stringify(changes), contentType: 'application/json' }, + ); + } + + /** Deletes a role. */ + async deleteRole(roleId: string) { + return await this.request( + 'DELETE', + `${PROJECT_PATH}/role/${roleId}`, + ); + } + + /** + * The project's permission matrix in one call: every role, what it may do + * (folder grants included) and who has it — for an admin screen that shows + * and changes it, instead of probing permission by permission. + */ + async getPermissionMatrix( + projectId: string, + ): Promise { + const [roles, members] = await Promise.all([ + this.listRoles(projectId), + this.listMembers(projectId), + ]); + return roles.map((role) => ({ + role, + permissions: role.permissions ?? [], + members: members + .filter((member) => String(member.role?._id) === String(role._id)) + .map((member) => member.user), + })); + } + + // ─── API tokens (JWT-only backend routes) ───────────────────────── + // + // The caller's own API tokens, as the dashboard's API Tokens card has them, + // so an integration can be given a credential of its own without leaving + // the product — limited to some projects (`projectIds`), split by + // permission, and revoked one by one (APOGEA 23-10, §2.5). + + /** The caller's API tokens (without their secret). */ + async listTokens() { + return await this.request('GET', TOKENS_PATH); + } + + /** A token's secret, to hand to the integration that will use it. */ + async getTokenSecret(tokenId: string) { + // (The route answers the bare secret, not JSON.) + const secret = await this.request( + 'GET', + `${TOKENS_PATH}/${tokenId}/token`, + { responseType: 'blob' }, + ); + return await secret.text(); + } + + /** + * Creates an API token. `projectIds` limits it to those projects; without + * them it may act on every project its owner can reach. + */ + async createToken(token: NewAccessToken) { + return await this.request('POST', TOKENS_PATH, { + body: JSON.stringify({ + urlRestrictions: [], + ...token, + }), + contentType: 'application/json', + }); + } + + /** Changes a token. `projectIds: []` lifts its project limit; leaving it out keeps it. */ + async updateToken(tokenId: string, changes: Partial) { + return await this.request('PUT', `${TOKENS_PATH}/${tokenId}`, { + body: JSON.stringify(changes), + contentType: 'application/json', + }); + } + + /** Revokes a token: it stops working at once. */ + async revokeToken(tokenId: string) { + return await this.request('DELETE', `${TOKENS_PATH}/${tokenId}`); + } + // ─── Account ────────────────────────────────────────────────────── /** diff --git a/src/index.ts b/src/index.ts index 784b3e7..251e396 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ export * from './types/batch'; export * from './types/response'; export * from './types/item.dto'; export * from './types/projects'; +export * from './types/tokens'; export * from './types/context'; export * from './types/npm'; export * from './types/notifications'; diff --git a/src/types/projects.ts b/src/types/projects.ts index ae5ee2a..2913cd5 100644 --- a/src/types/projects.ts +++ b/src/types/projects.ts @@ -7,6 +7,11 @@ export type PermissionSubject = | 'APP' | 'STORAGE' | 'EVENTS' + /** + * Not a resource, a channel: whether a member may act on the project with an + * API token at all. Checked on top of the subject the request is about. + */ + | 'API' | 'ALL'; export type PermissionAction = @@ -16,9 +21,23 @@ export type PermissionAction = | 'DELETE' | 'MANAGE'; +/** + * An exception to a role's permission on ONE resource (a file or a folder): the + * permission granted there only, or taken away there (`removePermission`). On a + * folder, `appliesToDescendants` extends it to everything under it — how a role + * sees one branch of the project and nothing else. + */ +export interface ResourcePermission { + resourceId: string; + removePermission?: boolean; + appliesToDescendants?: boolean; +} + export interface Permission { action: PermissionAction; subject?: PermissionSubject; + /** Per-resource grants or removals (see {@link ResourcePermission}). */ + resourcePermissions?: ResourcePermission[]; } export interface Project extends Base { @@ -48,6 +67,26 @@ export interface ProjectDataUser { email: string; } +/** Where a project invitation stands. */ +export type ProjectInvitationStatus = 'PENDING' | 'ACCEPTED' | 'REVOKED'; + +/** A pending (or past) invitation to a project, as the API lists it. */ +export interface ProjectInvitation { + _id: string; + email: string; + projectRoleId: string | null; + status: ProjectInvitationStatus; + createdAt: string; + expiresAt: string; +} + +/** A role's row of a project's permission matrix: what it may do, and who has it. */ +export interface ProjectPermissionMatrixRow { + role: ProjectRole; + permissions: Permission[]; + members: ProjectDataUser[]; +} + /** A project member with their role info. */ export interface ProjectDataMember { projectUserId: string; diff --git a/src/types/tokens.ts b/src/types/tokens.ts new file mode 100644 index 0000000..8e173cd --- /dev/null +++ b/src/types/tokens.ts @@ -0,0 +1,27 @@ +/** What an API token may do: `ALL`, or one action on one kind of resource. */ +export type AccessTokenPermission = + | 'ALL' + | 'COMPONENT:EXECUTE' + | `${'APP' | 'COMPONENT' | 'STORAGE'}:${'READ' | 'WRITE' | 'DOWNLOAD' | 'DELETE'}`; + +/** An API token as the platform lists it (its secret is fetched apart). */ +export interface AccessToken { + _id: string; + name: string; + permissions: AccessTokenPermission[]; + /** Where it may be used from; empty: anywhere. */ + urlRestrictions: string[]; + /** The projects it may act on; absent or empty: every project its owner can reach. */ + projectIds?: string[]; + requestCount: number; + lastUsedAt?: string; +} + +/** What is given to create a token. */ +export interface NewAccessToken { + name: string; + permissions: AccessTokenPermission[]; + urlRestrictions?: string[]; + /** Limits the token to these projects. */ + projectIds?: string[]; +}