diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 00000000000..fe22bbfba9f --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,456 @@ +openapi: 3.1.0 +info: + title: Huly Self-Hosted API + version: 0.1.0 + description: | + REST API for Huly self-hosted instances. + + ## Authentication + + All data endpoints require a workspace-scoped JWT in the `Authorization: Bearer ` header. + Tokens can be minted using the CLI tool (`tools/mint-token/`) or the optional token service (`/_tokens`). + + ## Getting Started + + 1. Mint a token using the CLI tool or token service + 2. Use the token to query the transactor REST API + 3. The `find-all` endpoint queries documents by class + 4. The `tx` endpoint submits transactions (create, update, delete) + + license: + name: EPL-2.0 + url: https://www.eclipse.org/legal/epl-2.0/ + +servers: + - url: https://{host} + description: Your Huly self-hosted instance + variables: + host: + default: localhost:8083 + +security: + - bearerAuth: [] + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: | + Workspace-scoped JWT. Payload: `{ account: AccountUuid, workspace: WorkspaceUuid, exp: number }`. + Signed with `SERVER_SECRET` using HS256. + + serverSecret: + type: apiKey + in: header + name: X-Server-Secret + description: SERVER_SECRET value — used for admin-only token service endpoints. + + schemas: + TxOperation: + type: object + description: A Huly transaction object + properties: + _class: + type: string + description: Transaction class (e.g. `core:class:TxCreateDoc`) + example: 'core:class:TxCreateDoc' + objectClass: + type: string + description: Target document class + example: 'tracker:class:Issue' + objectSpace: + type: string + description: Target space + example: 'tracker:project:DefaultProject' + objectId: + type: string + description: Document ID (generated or existing) + attributes: + type: object + description: Document attributes to set + required: + - _class + + FindAllResponse: + type: object + properties: + result: + type: array + items: + type: object + description: Array of matching documents + total: + type: integer + description: Total count of matching documents + + TokenRequest: + type: object + properties: + email: + type: string + format: email + description: User email — resolved to account UUID via CockroachDB + example: user@example.com + workspace: + type: string + description: Workspace URL slug — resolved to workspace UUID via CockroachDB + example: my-workspace + expiryDays: + type: integer + minimum: 1 + maximum: 365 + default: 30 + description: Token lifetime in days + required: + - email + - workspace + + TokenResponse: + type: object + properties: + id: + type: string + description: Token metadata ID (for listing/revoking) + token: + type: string + description: Signed JWT + expiresAt: + type: string + format: date-time + description: Token expiration timestamp + + TokenMetadata: + type: object + properties: + id: + type: string + email: + type: string + workspace: + type: string + createdAt: + type: string + format: date-time + expiresAt: + type: string + format: date-time + revoked: + type: boolean + + JsonRpcRequest: + type: object + properties: + method: + type: string + description: RPC method name + params: + type: array + items: {} + description: Method parameters + required: + - method + - params + + JsonRpcResponse: + type: object + properties: + id: + type: string + result: + description: Method-specific result + + Error: + type: object + properties: + error: + type: string + +paths: + # ── Account Service (JSON-RPC) ────────────────────────────────────── + + /_accounts: + post: + operationId: accountRpc + tags: [Auth] + summary: Account service JSON-RPC + description: | + JSON-RPC endpoint for the account service. Key methods: + + - `login(params: [email, password])` → returns account JWT + - `selectWorkspace(params: [workspaceUrl, kind, allowAdmin])` → returns workspace JWT + endpoint + - `getUserWorkspaces()` → list workspaces for authenticated user + + Note: For API token-based access, you don't need these — mint a token directly. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/JsonRpcRequest' + examples: + login: + summary: Login + value: + method: login + params: ['user@example.com', 'password'] + selectWorkspace: + summary: Select workspace + value: + method: selectWorkspace + params: ['my-workspace', 'external', false] + responses: + '200': + description: JSON-RPC response + content: + application/json: + schema: + $ref: '#/components/schemas/JsonRpcResponse' + '400': + description: Malformed request + + # ── Transactor REST API ───────────────────────────────────────────── + + /_transactor/api/v1/find-all/{workspace}: + get: + operationId: findAll + tags: [Data] + summary: Query documents by class + description: | + Find all documents matching a class and optional query/options filters. + Returns an array of matching documents. + parameters: + - name: workspace + in: path + required: true + schema: + type: string + description: Workspace UUID + example: 'ws-abc-123' + - name: class + in: query + required: true + schema: + type: string + description: Huly class reference + example: 'contact:class:Person' + - name: query + in: query + schema: + type: string + description: JSON-encoded query filter + example: '{"name": "John"}' + - name: options + in: query + schema: + type: string + description: JSON-encoded options (limit, sort, etc.) + example: '{"limit": 10}' + responses: + '200': + description: Matching documents + content: + application/json: + schema: + $ref: '#/components/schemas/FindAllResponse' + '401': + description: Invalid or expired token + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /_transactor/api/v1/tx/{workspace}: + post: + operationId: submitTx + tags: [Data] + summary: Submit a transaction + description: | + Submit a transaction to create, update, or delete documents. + The transaction format follows the Huly platform transaction model. + parameters: + - name: workspace + in: path + required: true + schema: + type: string + description: Workspace UUID + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TxOperation' + examples: + createIssue: + summary: Create a tracker issue + value: + _class: 'core:class:TxCreateDoc' + objectClass: 'tracker:class:Issue' + objectSpace: 'tracker:project:DefaultProject' + attributes: + title: 'Fix login bug' + description: 'Users cannot log in with SSO' + priority: 1 + responses: + '200': + description: Transaction result + content: + application/json: + schema: + type: object + '401': + description: Invalid or expired token + + /_transactor/api/v1/load-model/{workspace}: + get: + operationId: loadModel + tags: [Data] + summary: Load data model / schema + description: | + Returns the full data model (class hierarchy, mixins, attributes) for the workspace. + Useful for discovering available classes and their fields. + parameters: + - name: workspace + in: path + required: true + schema: + type: string + description: Workspace UUID + responses: + '200': + description: Data model + content: + application/json: + schema: + type: object + '401': + description: Invalid or expired token + + /_transactor/api/v1/ping/{workspace}: + get: + operationId: ping + tags: [Data] + summary: Health check + description: | + Verifies the token is valid and the workspace is reachable. + Returns a simple status response. + parameters: + - name: workspace + in: path + required: true + schema: + type: string + description: Workspace UUID + responses: + '200': + description: Workspace is reachable + content: + application/json: + schema: + type: object + properties: + pong: + type: boolean + '401': + description: Invalid or expired token + + /_transactor/api/v1/account/{workspace}: + get: + operationId: getAccountInfo + tags: [Data] + summary: Get account info + description: | + Returns information about the authenticated account in the context of the given workspace, + including the account UUID and workspace role. + parameters: + - name: workspace + in: path + required: true + schema: + type: string + description: Workspace UUID + responses: + '200': + description: Account information + content: + application/json: + schema: + type: object + '401': + description: Invalid or expired token + + # ── Token Service (optional) ──────────────────────────────────────── + + /_tokens: + post: + operationId: mintToken + tags: [Tokens] + summary: Mint an API token + description: | + Mint a workspace-scoped JWT. Requires admin auth via `X-Server-Secret` header. + Resolves email → account UUID and workspace slug → workspace UUID automatically. + + Only available when `tokenService.enabled: true` in Helm values. + security: + - serverSecret: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TokenRequest' + responses: + '200': + description: Minted token + content: + application/json: + schema: + $ref: '#/components/schemas/TokenResponse' + '401': + description: Invalid server secret + '404': + description: Email or workspace not found + + get: + operationId: listTokens + tags: [Tokens] + summary: List minted tokens + description: Returns metadata for all minted tokens (token values are not stored). + security: + - serverSecret: [] + responses: + '200': + description: Token list + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TokenMetadata' + '401': + description: Invalid server secret + + /_tokens/{id}: + delete: + operationId: revokeToken + tags: [Tokens] + summary: Revoke a token + description: | + Soft-revoke a token by marking it as revoked in the database. + Note: The token will still be valid until it expires, as full revocation + requires a denylist check at the transactor level (future enhancement). + security: + - serverSecret: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Token revoked + '404': + description: Token not found diff --git a/foundations/core/packages/account-client/src/client.ts b/foundations/core/packages/account-client/src/client.ts index 1a8e6ea4308..00cf1453522 100644 --- a/foundations/core/packages/account-client/src/client.ts +++ b/foundations/core/packages/account-client/src/client.ts @@ -35,6 +35,8 @@ import { import platform, { PlatformError, Severity, Status } from '@hcengineering/platform' import type { AccountAggregatedInfo, + ApiTokenInfo, + ApiTokenResult, Integration, IntegrationKey, IntegrationSecret, @@ -255,6 +257,17 @@ export interface AccountClient { getWorkspaceUsersWithPermission: (params: { permission: string }) => Promise verify2fa: (code: string) => Promise + createApiToken: ( + name: string, + workspaceUuid: WorkspaceUuid, + expiryDays: number, + scopes?: string[] + ) => Promise + listApiTokens: () => Promise + revokeApiToken: (tokenId: string) => Promise + listWorkspaceApiTokens: (workspaceUuid: WorkspaceUuid) => Promise + revokeWorkspaceApiToken: (tokenId: string, workspaceUuid: WorkspaceUuid) => Promise + checkApiTokenRevoked: (apiTokenId: string) => Promise setCookie: () => Promise deleteCookie: () => Promise @@ -1224,6 +1237,65 @@ class AccountClientImpl implements AccountClient { await this.rpc(request) } + async createApiToken ( + name: string, + workspaceUuid: WorkspaceUuid, + expiryDays: number, + scopes?: string[] + ): Promise { + const request = { + method: 'createApiToken' as const, + params: { name, workspaceUuid, expiryDays, scopes } + } + + return await this.rpc(request) + } + + async listApiTokens (): Promise { + const request = { + method: 'listApiTokens' as const, + params: {} + } + + return await this.rpc(request) + } + + async revokeApiToken (tokenId: string): Promise { + const request = { + method: 'revokeApiToken' as const, + params: { tokenId } + } + + await this.rpc(request) + } + + async listWorkspaceApiTokens (workspaceUuid: WorkspaceUuid): Promise { + const request = { + method: 'listWorkspaceApiTokens' as const, + params: { workspaceUuid } + } + + return await this.rpc(request) + } + + async revokeWorkspaceApiToken (tokenId: string, workspaceUuid: WorkspaceUuid): Promise { + const request = { + method: 'revokeWorkspaceApiToken' as const, + params: { tokenId, workspaceUuid } + } + + await this.rpc(request) + } + + async checkApiTokenRevoked (apiTokenId: string): Promise { + const request = { + method: 'checkApiTokenRevoked' as const, + params: { apiTokenId } + } + + return await this.rpc(request) + } + async setCookie (): Promise { const url = concatLink(this.url, '/cookie') const response = await fetch(url, { ...this.request, method: 'PUT' }) diff --git a/foundations/core/packages/account-client/src/types.ts b/foundations/core/packages/account-client/src/types.ts index c9f791f3c21..82dfae11c05 100644 --- a/foundations/core/packages/account-client/src/types.ts +++ b/foundations/core/packages/account-client/src/types.ts @@ -112,6 +112,23 @@ export interface MailboxInfo { appPasswords: string[] } +export interface ApiTokenInfo { + id: string + name: string + workspaceUuid: WorkspaceUuid + workspaceName: string + createdOn: number + expiresOn: number + revoked: boolean + scopes?: string[] +} + +export interface ApiTokenResult { + id: string + token: string + expiresOn: number +} + export interface MailboxSecret { mailbox: string app?: string diff --git a/models/setting/src/index.ts b/models/setting/src/index.ts index 66ec8a9be99..528dbb67732 100644 --- a/models/setting/src/index.ts +++ b/models/setting/src/index.ts @@ -440,6 +440,19 @@ export function createModel (builder: Builder): void { setting.ids.OfficeSettings ) + builder.createDoc( + setting.class.WorkspaceSettingCategory, + core.space.Model, + { + name: 'apiTokens', + label: setting.string.ApiTokens, + icon: setting.icon.ApiToken, + component: setting.component.ApiTokens, + order: 1050, + role: AccountRole.Owner + }, + setting.ids.ApiTokens + ) // Currently remove Support item from settings // builder.createDoc( // setting.class.SettingsCategory, diff --git a/plugins/setting-assets/assets/icons.svg b/plugins/setting-assets/assets/icons.svg index 22429b72110..f96d4a989c4 100644 --- a/plugins/setting-assets/assets/icons.svg +++ b/plugins/setting-assets/assets/icons.svg @@ -98,4 +98,7 @@ + + + diff --git a/plugins/setting-assets/lang/cs.json b/plugins/setting-assets/lang/cs.json index 14aba538019..faa788292fb 100644 --- a/plugins/setting-assets/lang/cs.json +++ b/plugins/setting-assets/lang/cs.json @@ -234,6 +234,47 @@ "TwoFactorAuthEnabled": "Dvoufaktorové ověřování je povoleno", "TwoFactorAuthDisabled": "Dvoufaktorové ověřování je zakázáno", "ShowQRCode": "Zobrazit QR kód", - "EnterVerificationCode": "Zadejte ověřovací kód" + "EnterVerificationCode": "Zadejte ověřovací kód", + "ApiBaseUrl": "Base URL", + "ApiEndpointAccount": "Get account info", + "ApiEndpointFindAll": "Query documents by class", + "ApiEndpointFindAllPost": "Query with filters (JSON body)", + "ApiEndpointLoadModel": "Load the data model", + "ApiEndpointPing": "Health check", + "ApiEndpointTx": "Create or update documents", + "ApiTokenCopyWarning": "Copy this token now. You won't be able to see it again.", + "ApiTokenCreated": "Token created", + "ApiTokenExpiry": "Expiration", + "ApiTokenName": "Token name", + "ApiTokenNoTokens": "No API tokens yet", + "ApiTokenRevoke": "Revoke token", + "ApiTokenRevokeConfirm": "Are you sure you want to revoke this token? It will no longer be usable for API access.", + "ApiTokenWorkspace": "Workspace", + "ApiTokens": "API Tokens", + "ApiUsageDescription": "Use your API token with the built-in REST API to query and modify workspace data. Pass the token as a Bearer token in the Authorization header.", + "ApiUsageTitle": "Using the REST API", + "ApiWorkspaceId": "Your workspace ID (UUID) is included in the token. Pass it as :workspaceId in the URL.", + "CreateApiToken": "Create token", + "Created": "Created", + "Expires": "Expires", + "Login": "Login", + "Primary": "Primary", + "TokenStatus": "Status", + "ApiTokenStatusActive": "Active", + "ApiTokenStatusExpiring": "Expiring", + "ApiTokenStatusRevoked": "Revoked", + "ApiTokenStatusExpired": "Expired", + "ApiTokenExpiry7Days": "7 days", + "ApiTokenExpiry30Days": "30 days", + "ApiTokenExpiry90Days": "90 days", + "ApiTokenExpiry180Days": "180 days", + "ApiTokenExpiry365Days": "365 days", + "ApiTokenLoadError": "Failed to load API tokens", + "ApiTokenCreateError": "Failed to create token. Please try again.", + "ApiTokenPermissions": "Permissions", + "ApiTokenScopePreset": "Permissions", + "ApiTokenScopeReadOnly": "Read Only", + "ApiTokenScopeReadWrite": "Read & Write", + "ApiTokenScopeFullAccess": "Full Access" } } diff --git a/plugins/setting-assets/lang/de.json b/plugins/setting-assets/lang/de.json index d44044976dc..27ddce2187a 100644 --- a/plugins/setting-assets/lang/de.json +++ b/plugins/setting-assets/lang/de.json @@ -236,6 +236,46 @@ "TwoFactorAuthEnabled": "Zweistufige Authentifizierung ist aktiviert", "TwoFactorAuthDisabled": "Zweistufige Authentifizierung ist deaktiviert", "ShowQRCode": "QR-Code anzeigen", - "EnterVerificationCode": "Verifizierungscode eingeben" + "EnterVerificationCode": "Verifizierungscode eingeben", + "ApiBaseUrl": "Base URL", + "ApiEndpointAccount": "Get account info", + "ApiEndpointFindAll": "Query documents by class", + "ApiEndpointFindAllPost": "Query with filters (JSON body)", + "ApiEndpointLoadModel": "Load the data model", + "ApiEndpointPing": "Health check", + "ApiEndpointTx": "Create or update documents", + "ApiTokenCopyWarning": "Copy this token now. You won't be able to see it again.", + "ApiTokenCreated": "Token created", + "ApiTokenExpiry": "Expiration", + "ApiTokenName": "Token name", + "ApiTokenNoTokens": "No API tokens yet", + "ApiTokenRevoke": "Revoke token", + "ApiTokenRevokeConfirm": "Are you sure you want to revoke this token? It will no longer be usable for API access.", + "ApiTokenWorkspace": "Workspace", + "ApiTokens": "API Tokens", + "ApiUsageDescription": "Use your API token with the built-in REST API to query and modify workspace data. Pass the token as a Bearer token in the Authorization header.", + "ApiUsageTitle": "Using the REST API", + "ApiWorkspaceId": "Your workspace ID (UUID) is included in the token. Pass it as :workspaceId in the URL.", + "BetaWarning": "Modules labeled as beta are available for experimental purposes and may not be fully functional. We do not recommend relying on beta features for critical work at this time.", + "CreateApiToken": "Create token", + "Created": "Created", + "Expires": "Expires", + "TokenStatus": "Status", + "ApiTokenStatusActive": "Active", + "ApiTokenStatusExpiring": "Expiring", + "ApiTokenStatusRevoked": "Revoked", + "ApiTokenStatusExpired": "Expired", + "ApiTokenExpiry7Days": "7 days", + "ApiTokenExpiry30Days": "30 days", + "ApiTokenExpiry90Days": "90 days", + "ApiTokenExpiry180Days": "180 days", + "ApiTokenExpiry365Days": "365 days", + "ApiTokenLoadError": "Failed to load API tokens", + "ApiTokenCreateError": "Failed to create token. Please try again.", + "ApiTokenPermissions": "Permissions", + "ApiTokenScopePreset": "Permissions", + "ApiTokenScopeReadOnly": "Read Only", + "ApiTokenScopeReadWrite": "Read & Write", + "ApiTokenScopeFullAccess": "Full Access" } } diff --git a/plugins/setting-assets/lang/en.json b/plugins/setting-assets/lang/en.json index f867cf36b45..0007125f8b4 100644 --- a/plugins/setting-assets/lang/en.json +++ b/plugins/setting-assets/lang/en.json @@ -236,6 +236,45 @@ "TwoFactorAuthEnabled": "Two-factor authentication is enabled", "TwoFactorAuthDisabled": "Two-factor authentication is disabled", "ShowQRCode": "Show QR code", - "EnterVerificationCode": "Enter verification code" + "EnterVerificationCode": "Enter verification code", + "ApiTokenStatusActive": "Active", + "ApiTokenStatusExpiring": "Expiring", + "ApiTokenStatusRevoked": "Revoked", + "ApiTokenStatusExpired": "Expired", + "ApiTokenExpiry7Days": "7 days", + "ApiTokenExpiry30Days": "30 days", + "ApiTokenExpiry90Days": "90 days", + "ApiTokenExpiry180Days": "180 days", + "ApiTokenExpiry365Days": "365 days", + "ApiTokenLoadError": "Failed to load API tokens", + "ApiTokenCreateError": "Failed to create token. Please try again.", + "ApiTokens": "API Tokens", + "CreateApiToken": "Create token", + "ApiTokenName": "Token name", + "ApiTokenExpiry": "Expiration", + "ApiTokenCreated": "Token created", + "ApiTokenRevoke": "Revoke token", + "ApiTokenRevokeConfirm": "Are you sure you want to revoke this token? It will no longer be usable for API access.", + "ApiTokenCopyWarning": "Copy this token now. You won't be able to see it again.", + "ApiTokenNoTokens": "No API tokens yet", + "ApiTokenWorkspace": "Workspace", + "ApiTokenPermissions": "Permissions", + "ApiTokenScopePreset": "Permissions", + "ApiTokenScopeReadOnly": "Read Only", + "ApiTokenScopeReadWrite": "Read & Write", + "ApiTokenScopeFullAccess": "Full Access", + "Created": "Created", + "Expires": "Expires", + "TokenStatus": "Status", + "ApiUsageTitle": "Using the REST API", + "ApiUsageDescription": "Use your API token with the built-in REST API to query and modify workspace data. Pass the token as a Bearer token in the Authorization header.", + "ApiEndpointPing": "Health check", + "ApiEndpointFindAll": "Query documents by class", + "ApiEndpointFindAllPost": "Query with filters (JSON body)", + "ApiEndpointTx": "Create or update documents", + "ApiEndpointLoadModel": "Load the data model", + "ApiEndpointAccount": "Get account info", + "ApiBaseUrl": "Base URL", + "ApiWorkspaceId": "Your workspace ID (UUID) is included in the token. Pass it as :workspaceId in the URL." } } diff --git a/plugins/setting-assets/lang/es.json b/plugins/setting-assets/lang/es.json index a63b17d0eb5..8ac93fe22b3 100644 --- a/plugins/setting-assets/lang/es.json +++ b/plugins/setting-assets/lang/es.json @@ -227,6 +227,54 @@ "TwoFactorAuthEnabled": "La autenticación de dos factores está habilitada", "TwoFactorAuthDisabled": "La autenticación de dos factores está deshabilitada", "ShowQRCode": "Mostrar código QR", - "EnterVerificationCode": "Introducir código de verificación" + "EnterVerificationCode": "Introducir código de verificación", + "ApiBaseUrl": "Base URL", + "ApiEndpointAccount": "Get account info", + "ApiEndpointFindAll": "Query documents by class", + "ApiEndpointFindAllPost": "Query with filters (JSON body)", + "ApiEndpointLoadModel": "Load the data model", + "ApiEndpointPing": "Health check", + "ApiEndpointTx": "Create or update documents", + "ApiTokenCopyWarning": "Copy this token now. You won't be able to see it again.", + "ApiTokenCreated": "Token created", + "ApiTokenExpiry": "Expiration", + "ApiTokenName": "Token name", + "ApiTokenNoTokens": "No API tokens yet", + "ApiTokenRevoke": "Revoke token", + "ApiTokenRevokeConfirm": "Are you sure you want to revoke this token? It will no longer be usable for API access.", + "ApiTokenWorkspace": "Workspace", + "ApiTokens": "API Tokens", + "ApiUsageDescription": "Use your API token with the built-in REST API to query and modify workspace data. Pass the token as a Bearer token in the Authorization header.", + "ApiUsageTitle": "Using the REST API", + "ApiWorkspaceId": "Your workspace ID (UUID) is included in the token. Pass it as :workspaceId in the URL.", + "CountSpaces": "{count, plural, =0 {No spaces} =1 {# space} other {# spaces}}", + "CreateApiToken": "Create token", + "Created": "Created", + "Description": "Description", + "Expires": "Expires", + "General": "General", + "NewSpaceType": "New space type", + "Permissions": "Permissions", + "RoleName": "Role name", + "Roles": "Roles", + "SpaceTypeTitle": "Space type title", + "SpaceTypes": "Space types", + "TokenStatus": "Status", + "ApiTokenStatusActive": "Active", + "ApiTokenStatusExpiring": "Expiring", + "ApiTokenStatusRevoked": "Revoked", + "ApiTokenStatusExpired": "Expired", + "ApiTokenExpiry7Days": "7 days", + "ApiTokenExpiry30Days": "30 days", + "ApiTokenExpiry90Days": "90 days", + "ApiTokenExpiry180Days": "180 days", + "ApiTokenExpiry365Days": "365 days", + "ApiTokenLoadError": "Failed to load API tokens", + "ApiTokenCreateError": "Failed to create token. Please try again.", + "ApiTokenPermissions": "Permissions", + "ApiTokenScopePreset": "Permissions", + "ApiTokenScopeReadOnly": "Read Only", + "ApiTokenScopeReadWrite": "Read & Write", + "ApiTokenScopeFullAccess": "Full Access" } } diff --git a/plugins/setting-assets/lang/fr.json b/plugins/setting-assets/lang/fr.json index 8305bd751e6..39371d81281 100644 --- a/plugins/setting-assets/lang/fr.json +++ b/plugins/setting-assets/lang/fr.json @@ -236,6 +236,45 @@ "TwoFactorAuthEnabled": "L'authentification à deux facteurs est activée", "TwoFactorAuthDisabled": "L'authentification à deux facteurs est désactivée", "ShowQRCode": "Afficher le code QR", - "EnterVerificationCode": "Entrer le code de vérification" + "EnterVerificationCode": "Entrer le code de vérification", + "ApiBaseUrl": "Base URL", + "ApiEndpointAccount": "Get account info", + "ApiEndpointFindAll": "Query documents by class", + "ApiEndpointFindAllPost": "Query with filters (JSON body)", + "ApiEndpointLoadModel": "Load the data model", + "ApiEndpointPing": "Health check", + "ApiEndpointTx": "Create or update documents", + "ApiTokenCopyWarning": "Copy this token now. You won't be able to see it again.", + "ApiTokenCreated": "Token created", + "ApiTokenExpiry": "Expiration", + "ApiTokenName": "Token name", + "ApiTokenNoTokens": "No API tokens yet", + "ApiTokenRevoke": "Revoke token", + "ApiTokenRevokeConfirm": "Are you sure you want to revoke this token? It will no longer be usable for API access.", + "ApiTokenWorkspace": "Workspace", + "ApiTokens": "API Tokens", + "ApiUsageDescription": "Use your API token with the built-in REST API to query and modify workspace data. Pass the token as a Bearer token in the Authorization header.", + "ApiUsageTitle": "Using the REST API", + "ApiWorkspaceId": "Your workspace ID (UUID) is included in the token. Pass it as :workspaceId in the URL.", + "CreateApiToken": "Create token", + "Created": "Created", + "Expires": "Expires", + "TokenStatus": "Status", + "ApiTokenStatusActive": "Active", + "ApiTokenStatusExpiring": "Expiring", + "ApiTokenStatusRevoked": "Revoked", + "ApiTokenStatusExpired": "Expired", + "ApiTokenExpiry7Days": "7 days", + "ApiTokenExpiry30Days": "30 days", + "ApiTokenExpiry90Days": "90 days", + "ApiTokenExpiry180Days": "180 days", + "ApiTokenExpiry365Days": "365 days", + "ApiTokenLoadError": "Failed to load API tokens", + "ApiTokenCreateError": "Failed to create token. Please try again.", + "ApiTokenPermissions": "Permissions", + "ApiTokenScopePreset": "Permissions", + "ApiTokenScopeReadOnly": "Read Only", + "ApiTokenScopeReadWrite": "Read & Write", + "ApiTokenScopeFullAccess": "Full Access" } } diff --git a/plugins/setting-assets/lang/it.json b/plugins/setting-assets/lang/it.json index a2f4e354c57..6a6a6bf9b1e 100644 --- a/plugins/setting-assets/lang/it.json +++ b/plugins/setting-assets/lang/it.json @@ -236,6 +236,45 @@ "TwoFactorAuthEnabled": "L'autenticazione a due fattori è abilitata", "TwoFactorAuthDisabled": "L'autenticazione a due fattori è disabilitata", "ShowQRCode": "Mostra codice QR", - "EnterVerificationCode": "Inserisci codice di verifica" + "EnterVerificationCode": "Inserisci codice di verifica", + "ApiBaseUrl": "Base URL", + "ApiEndpointAccount": "Get account info", + "ApiEndpointFindAll": "Query documents by class", + "ApiEndpointFindAllPost": "Query with filters (JSON body)", + "ApiEndpointLoadModel": "Load the data model", + "ApiEndpointPing": "Health check", + "ApiEndpointTx": "Create or update documents", + "ApiTokenCopyWarning": "Copy this token now. You won't be able to see it again.", + "ApiTokenCreated": "Token created", + "ApiTokenExpiry": "Expiration", + "ApiTokenName": "Token name", + "ApiTokenNoTokens": "No API tokens yet", + "ApiTokenRevoke": "Revoke token", + "ApiTokenRevokeConfirm": "Are you sure you want to revoke this token? It will no longer be usable for API access.", + "ApiTokenWorkspace": "Workspace", + "ApiTokens": "API Tokens", + "ApiUsageDescription": "Use your API token with the built-in REST API to query and modify workspace data. Pass the token as a Bearer token in the Authorization header.", + "ApiUsageTitle": "Using the REST API", + "ApiWorkspaceId": "Your workspace ID (UUID) is included in the token. Pass it as :workspaceId in the URL.", + "CreateApiToken": "Create token", + "Created": "Created", + "Expires": "Expires", + "TokenStatus": "Status", + "ApiTokenStatusActive": "Active", + "ApiTokenStatusExpiring": "Expiring", + "ApiTokenStatusRevoked": "Revoked", + "ApiTokenStatusExpired": "Expired", + "ApiTokenExpiry7Days": "7 days", + "ApiTokenExpiry30Days": "30 days", + "ApiTokenExpiry90Days": "90 days", + "ApiTokenExpiry180Days": "180 days", + "ApiTokenExpiry365Days": "365 days", + "ApiTokenLoadError": "Failed to load API tokens", + "ApiTokenCreateError": "Failed to create token. Please try again.", + "ApiTokenPermissions": "Permissions", + "ApiTokenScopePreset": "Permissions", + "ApiTokenScopeReadOnly": "Read Only", + "ApiTokenScopeReadWrite": "Read & Write", + "ApiTokenScopeFullAccess": "Full Access" } } diff --git a/plugins/setting-assets/lang/ja.json b/plugins/setting-assets/lang/ja.json index 2abcffd54e4..28c15562bd0 100644 --- a/plugins/setting-assets/lang/ja.json +++ b/plugins/setting-assets/lang/ja.json @@ -236,6 +236,45 @@ "TwoFactorAuthEnabled": "二要素認証は有効です", "TwoFactorAuthDisabled": "二要素認証は無効です", "ShowQRCode": "QRコードを表示", - "EnterVerificationCode": "確認コードを入力" + "EnterVerificationCode": "確認コードを入力", + "ApiBaseUrl": "Base URL", + "ApiEndpointAccount": "Get account info", + "ApiEndpointFindAll": "Query documents by class", + "ApiEndpointFindAllPost": "Query with filters (JSON body)", + "ApiEndpointLoadModel": "Load the data model", + "ApiEndpointPing": "Health check", + "ApiEndpointTx": "Create or update documents", + "ApiTokenCopyWarning": "Copy this token now. You won't be able to see it again.", + "ApiTokenCreated": "Token created", + "ApiTokenExpiry": "Expiration", + "ApiTokenName": "Token name", + "ApiTokenNoTokens": "No API tokens yet", + "ApiTokenRevoke": "Revoke token", + "ApiTokenRevokeConfirm": "Are you sure you want to revoke this token? It will no longer be usable for API access.", + "ApiTokenWorkspace": "Workspace", + "ApiTokens": "API Tokens", + "ApiUsageDescription": "Use your API token with the built-in REST API to query and modify workspace data. Pass the token as a Bearer token in the Authorization header.", + "ApiUsageTitle": "Using the REST API", + "ApiWorkspaceId": "Your workspace ID (UUID) is included in the token. Pass it as :workspaceId in the URL.", + "CreateApiToken": "Create token", + "Created": "Created", + "Expires": "Expires", + "TokenStatus": "Status", + "ApiTokenStatusActive": "Active", + "ApiTokenStatusExpiring": "Expiring", + "ApiTokenStatusRevoked": "Revoked", + "ApiTokenStatusExpired": "Expired", + "ApiTokenExpiry7Days": "7 days", + "ApiTokenExpiry30Days": "30 days", + "ApiTokenExpiry90Days": "90 days", + "ApiTokenExpiry180Days": "180 days", + "ApiTokenExpiry365Days": "365 days", + "ApiTokenLoadError": "Failed to load API tokens", + "ApiTokenCreateError": "Failed to create token. Please try again.", + "ApiTokenPermissions": "Permissions", + "ApiTokenScopePreset": "Permissions", + "ApiTokenScopeReadOnly": "Read Only", + "ApiTokenScopeReadWrite": "Read & Write", + "ApiTokenScopeFullAccess": "Full Access" } } diff --git a/plugins/setting-assets/lang/pt-br.json b/plugins/setting-assets/lang/pt-br.json index 8550ff3875c..47f8f0fdb6c 100644 --- a/plugins/setting-assets/lang/pt-br.json +++ b/plugins/setting-assets/lang/pt-br.json @@ -227,6 +227,54 @@ "TwoFactorAuthEnabled": "Autenticação de dois fatores está ativada", "TwoFactorAuthDisabled": "Autenticação de dois fatores está desativada", "ShowQRCode": "Mostrar código QR", - "EnterVerificationCode": "Inserir código de verificação" + "EnterVerificationCode": "Inserir código de verificação", + "ApiBaseUrl": "Base URL", + "ApiEndpointAccount": "Get account info", + "ApiEndpointFindAll": "Query documents by class", + "ApiEndpointFindAllPost": "Query with filters (JSON body)", + "ApiEndpointLoadModel": "Load the data model", + "ApiEndpointPing": "Health check", + "ApiEndpointTx": "Create or update documents", + "ApiTokenCopyWarning": "Copy this token now. You won't be able to see it again.", + "ApiTokenCreated": "Token created", + "ApiTokenExpiry": "Expiration", + "ApiTokenName": "Token name", + "ApiTokenNoTokens": "No API tokens yet", + "ApiTokenRevoke": "Revoke token", + "ApiTokenRevokeConfirm": "Are you sure you want to revoke this token? It will no longer be usable for API access.", + "ApiTokenWorkspace": "Workspace", + "ApiTokens": "API Tokens", + "ApiUsageDescription": "Use your API token with the built-in REST API to query and modify workspace data. Pass the token as a Bearer token in the Authorization header.", + "ApiUsageTitle": "Using the REST API", + "ApiWorkspaceId": "Your workspace ID (UUID) is included in the token. Pass it as :workspaceId in the URL.", + "CountSpaces": "{count, plural, =0 {No spaces} =1 {# space} other {# spaces}}", + "CreateApiToken": "Create token", + "Created": "Created", + "Description": "Description", + "Expires": "Expires", + "General": "General", + "NewSpaceType": "New space type", + "Permissions": "Permissions", + "RoleName": "Role name", + "Roles": "Roles", + "SpaceTypeTitle": "Space type title", + "SpaceTypes": "Space types", + "TokenStatus": "Status", + "ApiTokenStatusActive": "Active", + "ApiTokenStatusExpiring": "Expiring", + "ApiTokenStatusRevoked": "Revoked", + "ApiTokenStatusExpired": "Expired", + "ApiTokenExpiry7Days": "7 days", + "ApiTokenExpiry30Days": "30 days", + "ApiTokenExpiry90Days": "90 days", + "ApiTokenExpiry180Days": "180 days", + "ApiTokenExpiry365Days": "365 days", + "ApiTokenLoadError": "Failed to load API tokens", + "ApiTokenCreateError": "Failed to create token. Please try again.", + "ApiTokenPermissions": "Permissions", + "ApiTokenScopePreset": "Permissions", + "ApiTokenScopeReadOnly": "Read Only", + "ApiTokenScopeReadWrite": "Read & Write", + "ApiTokenScopeFullAccess": "Full Access" } } diff --git a/plugins/setting-assets/lang/pt.json b/plugins/setting-assets/lang/pt.json index cd79967e81f..053b5933022 100644 --- a/plugins/setting-assets/lang/pt.json +++ b/plugins/setting-assets/lang/pt.json @@ -227,6 +227,54 @@ "TwoFactorAuthEnabled": "Autenticação de dois fatores está ativada", "TwoFactorAuthDisabled": "Autenticação de dois fatores está desativada", "ShowQRCode": "Mostrar código QR", - "EnterVerificationCode": "Inserir código de verificação" + "EnterVerificationCode": "Inserir código de verificação", + "ApiBaseUrl": "Base URL", + "ApiEndpointAccount": "Get account info", + "ApiEndpointFindAll": "Query documents by class", + "ApiEndpointFindAllPost": "Query with filters (JSON body)", + "ApiEndpointLoadModel": "Load the data model", + "ApiEndpointPing": "Health check", + "ApiEndpointTx": "Create or update documents", + "ApiTokenCopyWarning": "Copy this token now. You won't be able to see it again.", + "ApiTokenCreated": "Token created", + "ApiTokenExpiry": "Expiration", + "ApiTokenName": "Token name", + "ApiTokenNoTokens": "No API tokens yet", + "ApiTokenRevoke": "Revoke token", + "ApiTokenRevokeConfirm": "Are you sure you want to revoke this token? It will no longer be usable for API access.", + "ApiTokenWorkspace": "Workspace", + "ApiTokens": "API Tokens", + "ApiUsageDescription": "Use your API token with the built-in REST API to query and modify workspace data. Pass the token as a Bearer token in the Authorization header.", + "ApiUsageTitle": "Using the REST API", + "ApiWorkspaceId": "Your workspace ID (UUID) is included in the token. Pass it as :workspaceId in the URL.", + "CountSpaces": "{count, plural, =0 {No spaces} =1 {# space} other {# spaces}}", + "CreateApiToken": "Create token", + "Created": "Created", + "Description": "Description", + "Expires": "Expires", + "General": "General", + "NewSpaceType": "New space type", + "Permissions": "Permissions", + "RoleName": "Role name", + "Roles": "Roles", + "SpaceTypeTitle": "Space type title", + "SpaceTypes": "Space types", + "TokenStatus": "Status", + "ApiTokenStatusActive": "Active", + "ApiTokenStatusExpiring": "Expiring", + "ApiTokenStatusRevoked": "Revoked", + "ApiTokenStatusExpired": "Expired", + "ApiTokenExpiry7Days": "7 days", + "ApiTokenExpiry30Days": "30 days", + "ApiTokenExpiry90Days": "90 days", + "ApiTokenExpiry180Days": "180 days", + "ApiTokenExpiry365Days": "365 days", + "ApiTokenLoadError": "Failed to load API tokens", + "ApiTokenCreateError": "Failed to create token. Please try again.", + "ApiTokenPermissions": "Permissions", + "ApiTokenScopePreset": "Permissions", + "ApiTokenScopeReadOnly": "Read Only", + "ApiTokenScopeReadWrite": "Read & Write", + "ApiTokenScopeFullAccess": "Full Access" } } diff --git a/plugins/setting-assets/lang/ru.json b/plugins/setting-assets/lang/ru.json index f1b902d2ca2..226f3a6d382 100644 --- a/plugins/setting-assets/lang/ru.json +++ b/plugins/setting-assets/lang/ru.json @@ -236,6 +236,45 @@ "TwoFactorAuthEnabled": "Двухфакторная аутентификация включена", "TwoFactorAuthDisabled": "Двухфакторная аутентификация отключена", "ShowQRCode": "Показать QR-код", - "EnterVerificationCode": "Введите код подтверждения" + "EnterVerificationCode": "Введите код подтверждения", + "ApiBaseUrl": "Базовый URL", + "ApiEndpointAccount": "Информация об аккаунте", + "ApiEndpointFindAll": "Запрос документов по классу", + "ApiEndpointFindAllPost": "Запрос с фильтрами (JSON body)", + "ApiEndpointLoadModel": "Загрузка модели данных", + "ApiEndpointPing": "Проверка состояния", + "ApiEndpointTx": "Создание или обновление документов", + "ApiTokenCopyWarning": "Скопируйте этот токен сейчас. Вы не сможете увидеть его снова.", + "ApiTokenCreated": "Токен создан", + "ApiTokenExpiry": "Срок действия", + "ApiTokenName": "Название токена", + "ApiTokenNoTokens": "API токенов пока нет", + "ApiTokenRevoke": "Отозвать токен", + "ApiTokenRevokeConfirm": "Вы уверены, что хотите отозвать этот токен? Он больше не будет доступен для API-доступа.", + "ApiTokenWorkspace": "Рабочее пространство", + "ApiTokenStatusActive": "Активен", + "ApiTokenStatusExpiring": "Истекает", + "ApiTokenStatusRevoked": "Отозван", + "ApiTokenStatusExpired": "Истёк", + "ApiTokenExpiry7Days": "7 дней", + "ApiTokenExpiry30Days": "30 дней", + "ApiTokenExpiry90Days": "90 дней", + "ApiTokenExpiry180Days": "180 дней", + "ApiTokenExpiry365Days": "365 дней", + "ApiTokenLoadError": "Не удалось загрузить API токены", + "ApiTokenCreateError": "Не удалось создать токен. Попробуйте ещё раз.", + "ApiTokens": "API токены", + "ApiUsageDescription": "Используйте API-токен для работы с встроенным REST API для запроса и изменения данных рабочего пространства. Передайте токен как Bearer-токен в заголовке Authorization.", + "ApiUsageTitle": "Использование REST API", + "ApiWorkspaceId": "Идентификатор вашего рабочего пространства (UUID) включён в токен. Передайте его как :workspaceId в URL.", + "CreateApiToken": "Создать токен", + "Created": "Создан", + "Expires": "Истекает", + "TokenStatus": "Статус", + "ApiTokenPermissions": "Permissions", + "ApiTokenScopePreset": "Permissions", + "ApiTokenScopeReadOnly": "Read Only", + "ApiTokenScopeReadWrite": "Read & Write", + "ApiTokenScopeFullAccess": "Full Access" } } diff --git a/plugins/setting-assets/lang/tr.json b/plugins/setting-assets/lang/tr.json index a6823e15185..4569c77acb5 100644 --- a/plugins/setting-assets/lang/tr.json +++ b/plugins/setting-assets/lang/tr.json @@ -236,6 +236,45 @@ "TwoFactorAuthEnabled": "İki faktörlü kimlik doğrulama etkin", "TwoFactorAuthDisabled": "İki faktörlü kimlik doğrulama devre dışı", "ShowQRCode": "QR kodu göster", - "EnterVerificationCode": "Doğrulama kodunu gir" + "EnterVerificationCode": "Doğrulama kodunu gir", + "ApiBaseUrl": "Base URL", + "ApiEndpointAccount": "Get account info", + "ApiEndpointFindAll": "Query documents by class", + "ApiEndpointFindAllPost": "Query with filters (JSON body)", + "ApiEndpointLoadModel": "Load the data model", + "ApiEndpointPing": "Health check", + "ApiEndpointTx": "Create or update documents", + "ApiTokenCopyWarning": "Copy this token now. You won't be able to see it again.", + "ApiTokenCreated": "Token created", + "ApiTokenExpiry": "Expiration", + "ApiTokenName": "Token name", + "ApiTokenNoTokens": "No API tokens yet", + "ApiTokenRevoke": "Revoke token", + "ApiTokenRevokeConfirm": "Are you sure you want to revoke this token? It will no longer be usable for API access.", + "ApiTokenWorkspace": "Workspace", + "ApiTokens": "API Tokens", + "ApiUsageDescription": "Use your API token with the built-in REST API to query and modify workspace data. Pass the token as a Bearer token in the Authorization header.", + "ApiUsageTitle": "Using the REST API", + "ApiWorkspaceId": "Your workspace ID (UUID) is included in the token. Pass it as :workspaceId in the URL.", + "CreateApiToken": "Create token", + "Created": "Created", + "Expires": "Expires", + "TokenStatus": "Status", + "ApiTokenStatusActive": "Active", + "ApiTokenStatusExpiring": "Expiring", + "ApiTokenStatusRevoked": "Revoked", + "ApiTokenStatusExpired": "Expired", + "ApiTokenExpiry7Days": "7 days", + "ApiTokenExpiry30Days": "30 days", + "ApiTokenExpiry90Days": "90 days", + "ApiTokenExpiry180Days": "180 days", + "ApiTokenExpiry365Days": "365 days", + "ApiTokenLoadError": "Failed to load API tokens", + "ApiTokenCreateError": "Failed to create token. Please try again.", + "ApiTokenPermissions": "Permissions", + "ApiTokenScopePreset": "Permissions", + "ApiTokenScopeReadOnly": "Read Only", + "ApiTokenScopeReadWrite": "Read & Write", + "ApiTokenScopeFullAccess": "Full Access" } } diff --git a/plugins/setting-assets/lang/zh.json b/plugins/setting-assets/lang/zh.json index dbb836da062..6a7d9d313c0 100644 --- a/plugins/setting-assets/lang/zh.json +++ b/plugins/setting-assets/lang/zh.json @@ -236,6 +236,45 @@ "TwoFactorAuthEnabled": "双因素认证已启用", "TwoFactorAuthDisabled": "双因素认证已禁用", "ShowQRCode": "显示QR码", - "EnterVerificationCode": "输入验证码" + "EnterVerificationCode": "输入验证码", + "ApiBaseUrl": "Base URL", + "ApiEndpointAccount": "Get account info", + "ApiEndpointFindAll": "Query documents by class", + "ApiEndpointFindAllPost": "Query with filters (JSON body)", + "ApiEndpointLoadModel": "Load the data model", + "ApiEndpointPing": "Health check", + "ApiEndpointTx": "Create or update documents", + "ApiTokenCopyWarning": "Copy this token now. You won't be able to see it again.", + "ApiTokenCreated": "Token created", + "ApiTokenExpiry": "Expiration", + "ApiTokenName": "Token name", + "ApiTokenNoTokens": "No API tokens yet", + "ApiTokenRevoke": "Revoke token", + "ApiTokenRevokeConfirm": "Are you sure you want to revoke this token? It will no longer be usable for API access.", + "ApiTokenWorkspace": "Workspace", + "ApiTokens": "API Tokens", + "ApiUsageDescription": "Use your API token with the built-in REST API to query and modify workspace data. Pass the token as a Bearer token in the Authorization header.", + "ApiUsageTitle": "Using the REST API", + "ApiWorkspaceId": "Your workspace ID (UUID) is included in the token. Pass it as :workspaceId in the URL.", + "CreateApiToken": "Create token", + "Created": "Created", + "Expires": "Expires", + "TokenStatus": "Status", + "ApiTokenStatusActive": "Active", + "ApiTokenStatusExpiring": "Expiring", + "ApiTokenStatusRevoked": "Revoked", + "ApiTokenStatusExpired": "Expired", + "ApiTokenExpiry7Days": "7 days", + "ApiTokenExpiry30Days": "30 days", + "ApiTokenExpiry90Days": "90 days", + "ApiTokenExpiry180Days": "180 days", + "ApiTokenExpiry365Days": "365 days", + "ApiTokenLoadError": "Failed to load API tokens", + "ApiTokenCreateError": "Failed to create token. Please try again.", + "ApiTokenPermissions": "Permissions", + "ApiTokenScopePreset": "Permissions", + "ApiTokenScopeReadOnly": "Read Only", + "ApiTokenScopeReadWrite": "Read & Write", + "ApiTokenScopeFullAccess": "Full Access" } } diff --git a/plugins/setting-assets/src/index.ts b/plugins/setting-assets/src/index.ts index 3b19b1f4a17..a91292bd042 100644 --- a/plugins/setting-assets/src/index.ts +++ b/plugins/setting-assets/src/index.ts @@ -37,5 +37,6 @@ loadMetadata(setting.icon, { Relations: `${icons}#relation`, Mailbox: `${icons}#mailbox`, OfficeSettings: `${icons}#office`, - Reset: `${icons}#reset` + Reset: `${icons}#reset`, + ApiToken: `${icons}#apiToken` }) diff --git a/plugins/setting-resources/src/components/ApiDocsSection.svelte b/plugins/setting-resources/src/components/ApiDocsSection.svelte new file mode 100644 index 00000000000..34b8c168623 --- /dev/null +++ b/plugins/setting-resources/src/components/ApiDocsSection.svelte @@ -0,0 +1,241 @@ + + + +
+ + {#if showApiDocs} +
+

+ +
+ + copySnippet(baseApiUrl)} + on:keydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') copySnippet(baseApiUrl) + }}>{baseApiUrl} +
+ +

+ +
+
+
GET
+ /api/v1/ping/:workspaceId + +
+
+
GET
+ /api/v1/find-all/:workspaceId?class=... + +
+
+
POST
+ /api/v1/find-all/:workspaceId + +
+
+
POST
+ /api/v1/tx/:workspaceId + +
+
+
GET
+ /api/v1/load-model/:workspaceId + +
+
+
GET
+ /api/v1/account/:workspaceId + +
+
+ +
+ Example +
 copySnippet(curlExample)}
+          on:keydown={(e) => {
+            if (e.key === 'Enter' || e.key === ' ') copySnippet(curlExample)
+          }}>{curlExample}
+
+
+ {/if} +
+ + diff --git a/plugins/setting-resources/src/components/ApiTokenCreatePopup.svelte b/plugins/setting-resources/src/components/ApiTokenCreatePopup.svelte new file mode 100644 index 00000000000..f59d5105c4a --- /dev/null +++ b/plugins/setting-resources/src/components/ApiTokenCreatePopup.svelte @@ -0,0 +1,211 @@ + + + + { + dispatch('close', createdToken !== undefined) + }} +> + {#if createdToken !== undefined} +
+ +
{ + if (e.key === 'Enter' || e.key === ' ') copyToken() + }} + > + {createdToken} +
+
+ {:else} +
+ +
+
+ + +
+
+ + { + selectedScopePreset = e.detail + }} + /> +
+
+ + { + selectedExpiry = e.detail + }} + /> +
+ {#if error !== undefined} +
+ {/if} + {/if} +
+ + diff --git a/plugins/setting-resources/src/components/ApiTokens.svelte b/plugins/setting-resources/src/components/ApiTokens.svelte new file mode 100644 index 00000000000..1370ddf69dd --- /dev/null +++ b/plugins/setting-resources/src/components/ApiTokens.svelte @@ -0,0 +1,239 @@ + + + +
+
+ + + + +
+
+
+ {#if loading} + + {:else if loadError} +
+
+ {:else if tokens.length === 0} +
+
+ {:else} + + + + + + + + + + + + + + + {#each tokens as token} + {@const status = getStatus(token)} + + + + + + + + + + {/each} + +
{token.name}{token.workspaceName}{getScopeLabel(token)}{formatDate(token.createdOn)}{token.revoked ? '—' : formatDate(token.expiresOn)} + + + + {#if !token.revoked} + { + revoke(token) + }} + /> + {/if} +
+
+ {/if} + + +
+
+
+ + diff --git a/plugins/setting-resources/src/components/General.svelte b/plugins/setting-resources/src/components/General.svelte index 9f1e4bc367b..f4865baccc2 100644 --- a/plugins/setting-resources/src/components/General.svelte +++ b/plugins/setting-resources/src/components/General.svelte @@ -43,7 +43,6 @@ Toggle } from '@hcengineering/ui' import settingsRes from '../plugin' - import ApiTokenPopup from './ApiTokenPopup.svelte' import WorkspacePermissionEditor from './WorkspacePermissionEditor.svelte' let loading = true @@ -153,11 +152,6 @@ await accountClient.updatePasswordAgingRule(passwordAgingRule) } - async function handleGenerateApiToken (): Promise { - const { token } = await accountClient.selectWorkspace(workspaceUrl) - showPopup(ApiTokenPopup, { token }) - } - function handleTogglePermissions (): void { const newState = !arePermissionsDisabled showPopup(MessageBox, { @@ -318,19 +312,6 @@ allowGuests={true} /> -
-
-
-
-
-
diff --git a/plugins/setting-resources/src/index.ts b/plugins/setting-resources/src/index.ts index d11948b02c6..2fc33526718 100644 --- a/plugins/setting-resources/src/index.ts +++ b/plugins/setting-resources/src/index.ts @@ -73,6 +73,7 @@ import AddSocialId from './components/socialIds/AddSocialId.svelte' import AddEmailSocialId from './components/socialIds/AddEmailSocialId.svelte' import Mailboxes from './components/Mailboxes.svelte' import GuestPermissionsSettings from './components/GuestPermissionsSettings.svelte' +import ApiTokens from './components/ApiTokens.svelte' import OfficeSettings from './components/OfficeSettings.svelte' import BaseIntegrationState from './components/integrations/BaseIntegrationState.svelte' import IntegrationStateRow from './components/integrations/IntegrationStateRow.svelte' @@ -171,7 +172,8 @@ export default async (): Promise => ({ AddEmailSocialId, EmployeeRefEditor, UserRoleSelect, - TwoFactorSettings + TwoFactorSettings, + ApiTokens }, actionImpl: { DeleteMixin diff --git a/plugins/setting/src/index.ts b/plugins/setting/src/index.ts index 98c09a0e87d..aa3e3d779b2 100644 --- a/plugins/setting/src/index.ts +++ b/plugins/setting/src/index.ts @@ -200,7 +200,8 @@ export default plugin(settingId, { OfficeSettings: '' as Ref, DisablePermissionsConfiguration: '' as Ref, Mailboxes: '' as Ref, - Security: '' as Ref + Security: '' as Ref, + ApiTokens: '' as Ref }, mixin: { Editable: '' as Ref>, @@ -246,7 +247,8 @@ export default plugin(settingId, { AddEmailSocialId: '' as AnyComponent, OfficeSettings: '' as AnyComponent, UserRoleSelect: '' as AnyComponent, - TwoFactorSettings: '' as AnyComponent + TwoFactorSettings: '' as AnyComponent, + ApiTokens: '' as AnyComponent }, string: { Settings: '' as IntlString, @@ -353,7 +355,46 @@ export default plugin(settingId, { Disconnected: '' as IntlString, Available: '' as IntlString, NotConnectedIntegration: '' as IntlString, - IntegrationIsUnstable: '' as IntlString + IntegrationIsUnstable: '' as IntlString, + ApiTokenStatusActive: '' as IntlString, + ApiTokenStatusExpiring: '' as IntlString, + ApiTokenStatusRevoked: '' as IntlString, + ApiTokenStatusExpired: '' as IntlString, + ApiTokenExpiry7Days: '' as IntlString, + ApiTokenExpiry30Days: '' as IntlString, + ApiTokenExpiry90Days: '' as IntlString, + ApiTokenExpiry180Days: '' as IntlString, + ApiTokenExpiry365Days: '' as IntlString, + ApiTokenLoadError: '' as IntlString, + ApiTokenCreateError: '' as IntlString, + ApiTokens: '' as IntlString, + CreateApiToken: '' as IntlString, + ApiTokenName: '' as IntlString, + ApiTokenExpiry: '' as IntlString, + ApiTokenCreated: '' as IntlString, + ApiTokenRevoke: '' as IntlString, + ApiTokenRevokeConfirm: '' as IntlString, + ApiTokenCopyWarning: '' as IntlString, + ApiTokenNoTokens: '' as IntlString, + ApiTokenWorkspace: '' as IntlString, + ApiTokenPermissions: '' as IntlString, + ApiTokenScopePreset: '' as IntlString, + ApiTokenScopeReadOnly: '' as IntlString, + ApiTokenScopeReadWrite: '' as IntlString, + ApiTokenScopeFullAccess: '' as IntlString, + Created: '' as IntlString, + Expires: '' as IntlString, + TokenStatus: '' as IntlString, + ApiUsageTitle: '' as IntlString, + ApiUsageDescription: '' as IntlString, + ApiEndpointPing: '' as IntlString, + ApiEndpointFindAll: '' as IntlString, + ApiEndpointFindAllPost: '' as IntlString, + ApiEndpointTx: '' as IntlString, + ApiEndpointLoadModel: '' as IntlString, + ApiEndpointAccount: '' as IntlString, + ApiBaseUrl: '' as IntlString, + ApiWorkspaceId: '' as IntlString }, icon: { AccountSettings: '' as Asset, @@ -375,7 +416,8 @@ export default plugin(settingId, { Relations: '' as Asset, Mailbox: '' as Asset, OfficeSettings: '' as Asset, - Reset: '' as Asset + Reset: '' as Asset, + ApiToken: '' as Asset }, templateFieldCategory: { Integration: '' as Ref diff --git a/pods/server/src/__tests__/scopes.test.ts b/pods/server/src/__tests__/scopes.test.ts new file mode 100644 index 00000000000..49249f5b897 --- /dev/null +++ b/pods/server/src/__tests__/scopes.test.ts @@ -0,0 +1,61 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { hasScope, getRequiredScope } from '../rpc' + +describe('hasScope', () => { + test('returns true when scope is present', () => { + expect(hasScope(['read:*', 'write:*'], 'read:*')).toBe(true) + expect(hasScope(['read:*', 'write:*'], 'write:*')).toBe(true) + }) + + test('returns false when scope is missing', () => { + expect(hasScope(['read:*'], 'write:*')).toBe(false) + expect(hasScope(['read:*'], 'delete:*')).toBe(false) + }) + + test('returns false for empty scopes array', () => { + expect(hasScope([], 'read:*')).toBe(false) + }) + + test('requires exact match', () => { + expect(hasScope(['read:tracker'], 'read:*')).toBe(false) + expect(hasScope(['read:*'], 'read:tracker')).toBe(false) + }) +}) + +describe('getRequiredScope', () => { + test('ping and generateId require no scope', () => { + expect(getRequiredScope('ping')).toBeNull() + expect(getRequiredScope('generateId')).toBeNull() + }) + + test('read methods require read:*', () => { + expect(getRequiredScope('findAll')).toBe('read:*') + expect(getRequiredScope('searchFulltext')).toBe('read:*') + expect(getRequiredScope('loadModel')).toBe('read:*') + expect(getRequiredScope('account')).toBe('read:*') + }) + + test('write methods require write:*', () => { + expect(getRequiredScope('tx')).toBe('write:*') + expect(getRequiredScope('domainRequest')).toBe('write:*') + expect(getRequiredScope('ensurePerson')).toBe('write:*') + }) + + test('unknown methods default to read:*', () => { + expect(getRequiredScope('someUnknownMethod')).toBe('read:*') + }) +}) diff --git a/pods/server/src/rpc.ts b/pods/server/src/rpc.ts index 4c147a4c096..47917e56a63 100644 --- a/pods/server/src/rpc.ts +++ b/pods/server/src/rpc.ts @@ -27,7 +27,7 @@ import core, { } from '@hcengineering/core' import { rpcJSONReplacer, type RateLimitInfo } from '@hcengineering/rpc' import type { ClientSessionCtx, ConnectionSocket, Session, SessionManager } from '@hcengineering/server-core' -import { decodeToken } from '@hcengineering/server-token' +import { decodeToken, generateToken } from '@hcengineering/server-token' import { createHash } from 'crypto' import { type Express, type Response as ExpressResponse, type Request } from 'express' @@ -129,6 +129,63 @@ async function sendJson ( res.end(body) } +// ── API Token Revocation Cache ────────────────────────────────────── +// Per-token cache with 60s TTL. Once a token is confirmed revoked it +// stays cached permanently (revocation is irreversible). Non-revoked +// tokens are re-checked every TTL interval. +const REVOCATION_CACHE_TTL_MS = 60_000 +const revocationCache = new Map() + +async function isApiTokenRevoked (apiTokenId: string, accountClient: AccountClient): Promise { + const now = Date.now() + const cached = revocationCache.get(apiTokenId) + + // Permanently cached once revoked + if (cached?.revoked === true) return true + + // Re-check if stale or missing + if (cached == null || now - cached.checkedAt > REVOCATION_CACHE_TTL_MS) { + try { + const revoked = await accountClient.checkApiTokenRevoked(apiTokenId) + revocationCache.set(apiTokenId, { revoked, checkedAt: now }) + return revoked + } catch { + // If we can't reach the account service, use stale cache or allow + return cached?.revoked ?? false + } + } + + return cached.revoked +} + +// ── Token Scope Enforcement ───────────────────────────────────────── +// Phase 1: coarse scopes only (read:*, write:*, delete:*) + +export function hasScope (scopes: string[], required: string): boolean { + return scopes.includes(required) +} + +export function getRequiredScope (method: string): string | null { + switch (method) { + case 'ping': + case 'generateId': + return null // Always allowed + case 'findAll': + case 'searchFulltext': + case 'loadModel': + case 'account': + return 'read:*' + case 'tx': + // write:* checked here; delete:* checked after body parsing in the tx handler + return 'write:*' + case 'domainRequest': + case 'ensurePerson': + return 'write:*' + default: + return 'read:*' + } +} + export function registerRPC (app: Express, sessions: SessionManager, ctx: MeasureContext, accountsUrl: string): void { const rpcSessions = new Map() @@ -167,6 +224,31 @@ export function registerRPC (app: Express, sessions: SessionManager, ctx: Measur return } + // Reject revoked API tokens (cached check, ~60s TTL) + const apiTokenId = decodedToken.extra?.apiTokenId + if (apiTokenId !== undefined) { + if ( + await isApiTokenRevoked( + apiTokenId, + getAccountClient(generateToken(systemAccountUuid, undefined, { service: 'server' })) + ) + ) { + sendError(res, 401, { message: 'Token has been revoked' }) + return + } + } + + // Enforce token scopes (Phase 1: coarse scopes — read:*, write:*, delete:*) + const scopesRaw = decodedToken.extra?.scopes + if (scopesRaw !== undefined) { + const scopes: string[] = JSON.parse(scopesRaw) + const requiredScope = getRequiredScope(method) + if (requiredScope !== null && !hasScope(scopes, requiredScope)) { + sendError(res, 403, { message: 'Insufficient token scope', required: requiredScope }) + return + } + } + let transactorRpc = rpcSessions.get(token) if (transactorRpc === undefined) { @@ -267,9 +349,21 @@ export function registerRPC (app: Express, sessions: SessionManager, ctx: Measur }) app.post('/api/v1/tx/:workspaceId', (req, res) => { - void withSession(req, res, 'tx', async (ctx, session, rateLimit) => { + void withSession(req, res, 'tx', async (ctx, session, rateLimit, token) => { const tx: any = (await retrieveJson(req)) ?? {} + // Enforce delete:* scope for remove transactions + if (tx._class === core.class.TxRemoveDoc) { + const scopesStr = decodeToken(token).extra?.scopes + if (scopesStr !== undefined) { + const scopes: string[] = JSON.parse(scopesStr) + if (!scopes.includes('delete:*')) { + sendError(res, 403, { message: 'Insufficient token scope', required: 'delete:*' }) + return + } + } + } + if (tx._class === core.class.TxDomainEvent) { const domainTx = tx as TxDomainEvent const { result } = await session.domainRequestRaw(ctx, domainTx.domain, { diff --git a/pods/server/src/server_http.ts b/pods/server/src/server_http.ts index c1469b8ae8a..13b35d5ccc5 100644 --- a/pods/server/src/server_http.ts +++ b/pods/server/src/server_http.ts @@ -515,11 +515,10 @@ export function startHttpServer ( }, 1000) } if ('upgrade' in s) { - void cs - .send(ctx, { id: -1, result: { state: 'upgrading', stats: (s as any).upgradeInfo } }, false, false) - .then(() => { - cs.close() - }) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + void cs.send(ctx, { id: -1, result: { state: 'upgrading', stats: (s as any).upgradeInfo } }, false, false).then(() => { + cs.close() + }) } }) void webSocketData.session.catch((err) => { diff --git a/server/account/src/__tests__/apiTokenScopes.test.ts b/server/account/src/__tests__/apiTokenScopes.test.ts new file mode 100644 index 00000000000..34fa5f5647e --- /dev/null +++ b/server/account/src/__tests__/apiTokenScopes.test.ts @@ -0,0 +1,251 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { AccountRole, type MeasureContext, type PersonUuid, type WorkspaceUuid } from '@hcengineering/core' +import { decodeTokenVerbose, generateToken } from '@hcengineering/server-token' + +import { type AccountDB } from '../types' +import { getMethods } from '../operations' + +jest.mock('@hcengineering/platform', () => { + const actual = jest.requireActual('@hcengineering/platform') + return { + ...actual, + ...actual.default, + getMetadata: jest.fn(), + translate: jest.fn((id, params) => `${id} << ${JSON.stringify(params)}`) + } +}) + +jest.mock('@hcengineering/server-token', () => { + class TokenError extends Error { + constructor (msg: string) { + super(msg) + this.name = 'TokenError' + } + } + return { + decodeTokenVerbose: jest.fn(), + decodeToken: jest.fn(), + TokenError, + generateToken: jest.fn().mockImplementation((account: string, workspace: string, extra: any) => { + return `mocked-token-${account}-${workspace}-${JSON.stringify(extra)}` + }) + } +}) + +describe('createApiToken scopes', () => { + const mockCtx = { + error: jest.fn(), + info: jest.fn(), + warn: jest.fn() + } as unknown as MeasureContext + + const accountUuid = 'account-uuid' as PersonUuid + const workspaceUuid = 'workspace-uuid' as WorkspaceUuid + + const mockDb = { + account: { findOne: jest.fn() }, + workspace: { find: jest.fn().mockResolvedValue([]) }, + apiToken: { + find: jest.fn().mockResolvedValue([]), + findOne: jest.fn(), + insertOne: jest.fn(), + update: jest.fn() + }, + getWorkspaceRole: jest.fn() + } as unknown as AccountDB + + const methods = getMethods() + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const createApiToken = methods.createApiToken! + + beforeEach(() => { + jest.clearAllMocks() + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + account: accountUuid, + workspace: workspaceUuid, + extra: {} + }) + ;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Owner) + ;(mockDb.apiToken.find as jest.Mock).mockResolvedValue([]) + ;(mockDb.apiToken.insertOne as jest.Mock).mockResolvedValue(undefined) + }) + + test('creates token with valid scopes', async () => { + const result = await createApiToken( + mockCtx, + mockDb, + null, + { id: 1, params: { name: 'test', workspaceUuid, expiryDays: 30, scopes: ['read:*'] } }, + 'test-token' + ) + + expect(result.result).toBeDefined() + expect(result.result.id).toBeDefined() + expect(result.result.token).toContain('mocked-token') + + // Verify scopes were passed to generateToken + expect(generateToken).toHaveBeenCalledWith( + accountUuid, + workspaceUuid, + expect.objectContaining({ scopes: '["read:*"]' }), + undefined, + expect.any(Object) + ) + + // Verify scopes were persisted to DB + expect(mockDb.apiToken.insertOne).toHaveBeenCalledWith(expect.objectContaining({ scopes: ['read:*'] })) + }) + + test('creates token with multiple valid scopes', async () => { + const result = await createApiToken( + mockCtx, + mockDb, + null, + { id: 1, params: { name: 'test', workspaceUuid, expiryDays: 30, scopes: ['read:*', 'write:*', 'delete:*'] } }, + 'test-token' + ) + + expect(result.result).toBeDefined() + expect(mockDb.apiToken.insertOne).toHaveBeenCalledWith( + expect.objectContaining({ scopes: ['read:*', 'write:*', 'delete:*'] }) + ) + }) + + test('creates token without scopes (full access, backward compat)', async () => { + const result = await createApiToken( + mockCtx, + mockDb, + null, + { id: 1, params: { name: 'test', workspaceUuid, expiryDays: 30 } }, + 'test-token' + ) + + expect(result.result).toBeDefined() + + // No scopes in JWT extra + expect(generateToken).toHaveBeenCalledWith( + accountUuid, + workspaceUuid, + expect.not.objectContaining({ scopes: expect.anything() }), + undefined, + expect.any(Object) + ) + + // scopes field should be undefined in DB insert + const insertArg = (mockDb.apiToken.insertOne as jest.Mock).mock.calls[0][0] + expect(insertArg.scopes).toBeUndefined() + }) + + test('rejects invalid scope format', async () => { + const result = await createApiToken( + mockCtx, + mockDb, + null, + { id: 1, params: { name: 'test', workspaceUuid, expiryDays: 30, scopes: ['invalid'] } }, + 'test-token' + ) + + expect(result.error).toBeDefined() + }) + + test('rejects empty scopes array', async () => { + const result = await createApiToken( + mockCtx, + mockDb, + null, + { id: 1, params: { name: 'test', workspaceUuid, expiryDays: 30, scopes: [] } }, + 'test-token' + ) + + expect(result.error).toBeDefined() + }) + + test('rejects domain-scoped scopes in Phase 1', async () => { + const result = await createApiToken( + mockCtx, + mockDb, + null, + { id: 1, params: { name: 'test', workspaceUuid, expiryDays: 30, scopes: ['read:tracker'] } }, + 'test-token' + ) + + expect(result.error).toBeDefined() + }) +}) + +describe('listApiTokens includes scopes', () => { + const mockCtx = { + error: jest.fn(), + info: jest.fn(), + warn: jest.fn() + } as unknown as MeasureContext + + const accountUuid = 'account-uuid' as PersonUuid + const workspaceUuid = 'workspace-uuid' as WorkspaceUuid + + const mockDb = { + workspace: { + find: jest.fn().mockResolvedValue([{ uuid: workspaceUuid, name: 'Test' }]) + }, + apiToken: { + find: jest.fn().mockResolvedValue([ + { + id: 'token-1', + accountUuid, + name: 'Read Only', + workspaceUuid, + createdOn: 1000, + expiresOn: 2000, + revoked: false, + scopes: ['read:*'] + }, + { + id: 'token-2', + accountUuid, + name: 'Legacy', + workspaceUuid, + createdOn: 1000, + expiresOn: 2000, + revoked: false + // no scopes field (legacy) + } + ]) + } + } as unknown as AccountDB + + const methods = getMethods() + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const listApiTokens = methods.listApiTokens! + + beforeEach(() => { + jest.clearAllMocks() + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + account: accountUuid, + workspace: workspaceUuid, + extra: {} + }) + }) + + test('returns scopes for scoped tokens and undefined for legacy', async () => { + const result = await listApiTokens(mockCtx, mockDb, null, { id: 1, params: {} }, 'test-token') + + const tokens = result.result + expect(tokens).toHaveLength(2) + expect(tokens[0].scopes).toEqual(['read:*']) + expect(tokens[1].scopes).toBeUndefined() + }) +}) diff --git a/server/account/src/collections/mongo.ts b/server/account/src/collections/mongo.ts index b4e5e5e7afe..0da5322ff66 100644 --- a/server/account/src/collections/mongo.ts +++ b/server/account/src/collections/mongo.ts @@ -58,7 +58,8 @@ import type { WorkspaceOperation, WorkspaceStatus, WorkspaceStatusData, - WorkspacePermission + WorkspacePermission, + ApiToken } from '../types' import { isShallowEqual } from '../utils' @@ -411,6 +412,7 @@ export class MongoAccountDB implements AccountDB { workspaceMembers: MongoDbCollection workspacePermission: MongoDbCollection + apiToken: MongoDbCollection constructor (readonly db: Db) { this.migration = new MongoDbCollection('migration', db, 'key') @@ -431,6 +433,7 @@ export class MongoAccountDB implements AccountDB { this.workspaceMembers = new MongoDbCollection('workspaceMembers', db) this.workspacePermission = new MongoDbCollection('workspacePermissions', db) + this.apiToken = new MongoDbCollection('apiTokens', db, 'id') } async init (): Promise { @@ -865,6 +868,7 @@ export class MongoAccountDB implements AccountDB { } await this.mailbox.deleteMany({ accountUuid }) + await this.apiToken.deleteMany({ accountUuid }) await this.socialId.update({ personUuid: accountUuid }, { verifiedOn: undefined }) await this.workspaceMembers.deleteMany({ accountUuid }) diff --git a/server/account/src/collections/postgres/migrations.ts b/server/account/src/collections/postgres/migrations.ts index adaef2de9c5..0f4cc8238f7 100644 --- a/server/account/src/collections/postgres/migrations.ts +++ b/server/account/src/collections/postgres/migrations.ts @@ -82,7 +82,9 @@ export function getMigrations (ns: string, flavor: DBFlavor): [string, string][] getV22Migration(ns, flavor), getV23Migration(ns, flavor), getV24Migration(ns, flavor), - getV25Migration(ns, flavor) + getV25Migration(ns, flavor), + getV26Migration(ns, flavor), + getV27Migration(ns, flavor) ] } @@ -794,3 +796,44 @@ function getV25Migration (ns: string, flavor: DBFlavor): [string, string] { ` ] } + +function getV26Migration (ns: string, flavor: DBFlavor): [string, string] { + const types = dbTypes[flavor] + return [ + 'account_db_v26_add_api_tokens_table', + ` + /* ======= A P I T O K E N S ======= */ + CREATE TABLE IF NOT EXISTS ${ns}.api_tokens ( + id ${types.string} NOT NULL, + account_uuid UUID NOT NULL, + name ${types.string} NOT NULL, + workspace_uuid UUID NOT NULL, + created_on ${types.int8} NOT NULL DEFAULT current_epoch_ms(), + expires_on ${types.int8} NOT NULL, + revoked ${types.bool} NOT NULL DEFAULT false, + CONSTRAINT api_tokens_pk PRIMARY KEY (id), + CONSTRAINT api_tokens_account_fk FOREIGN KEY (account_uuid) REFERENCES ${ns}.person(uuid), + CONSTRAINT api_tokens_workspace_fk FOREIGN KEY (workspace_uuid) REFERENCES ${ns}.workspace(uuid) + ); + + CREATE INDEX IF NOT EXISTS api_tokens_account_idx + ON ${ns}.api_tokens (account_uuid); + + CREATE INDEX IF NOT EXISTS api_tokens_workspace_idx + ON ${ns}.api_tokens (workspace_uuid); + + CREATE INDEX IF NOT EXISTS api_tokens_expires_on_idx + ON ${ns}.api_tokens (expires_on); + ` + ] +} + +function getV27Migration (ns: string, flavor: DBFlavor): [string, string] { + return [ + 'account_db_v27_add_api_token_scopes', + ` + ALTER TABLE ${ns}.api_tokens + ADD COLUMN IF NOT EXISTS scopes TEXT[] DEFAULT NULL; + ` + ] +} diff --git a/server/account/src/collections/postgres/postgres.ts b/server/account/src/collections/postgres/postgres.ts index 4ab5aea2b14..84211c544d4 100644 --- a/server/account/src/collections/postgres/postgres.ts +++ b/server/account/src/collections/postgres/postgres.ts @@ -50,6 +50,7 @@ import type { UserProfile, Subscription, WorkspacePermission, + ApiToken, DBFlavor } from '../../types' @@ -540,6 +541,7 @@ export class PostgresAccountDB implements AccountDB { userProfile: PostgresDbCollection subscription: PostgresDbCollection workspacePermission: PostgresDbCollection + apiToken: PostgresDbCollection constructor ( readonly client: Sql, @@ -609,6 +611,12 @@ export class PostgresAccountDB implements AccountDB { timestampFields: ['createdOn'], withRetryClient }) + this.apiToken = new PostgresDbCollection('api_tokens', client, { + ns, + idKey: 'id', + timestampFields: ['createdOn', 'expiresOn'], + withRetryClient + }) } getWsMembersTableName (): string { @@ -1079,6 +1087,7 @@ export class PostgresAccountDB implements AccountDB { } await this.mailbox.deleteMany({ accountUuid }, rTx) + await this.apiToken.deleteMany({ accountUuid }, rTx) await this.socialId.update({ personUuid: accountUuid }, { verifiedOn: undefined }, rTx) diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index a1869b41c9b..4ef96b28e8d 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -35,9 +35,10 @@ import { type WorkspaceUuid, type IntegrationKind } from '@hcengineering/core' -import platform, { getMetadata, PlatformError, Severity, Status, translate } from '@hcengineering/platform' +import platform, { PlatformError, Severity, Status, getMetadata, translate } from '@hcengineering/platform' import { decodeToken, decodeTokenVerbose, generateToken, type PermissionsGrant } from '@hcengineering/server-token' +import { randomUUID } from 'crypto' import { isAdminEmail } from './admin' import { accountPlugin } from './plugin' import { type AccountServiceMethods, getServiceMethods } from './serviceOperations' @@ -417,7 +418,7 @@ export async function validateOtp ( throw new PlatformError(new Status(Severity.ERROR, platform.status.InvalidOtp, {})) } - let callerAccountUuid: AccountUuid | null = null + let callerAccountUuid: AccountUuid | undefined let callerAccount: Account | null = null if (action === 'verify') { @@ -1966,7 +1967,7 @@ export async function getLoginInfoByToken ( let extra: any let grant: PermissionsGrant | undefined let sub: AccountUuid | undefined - let account: AccountUuid | undefined + let account: AccountUuid let nbf: number | undefined let exp: number | undefined @@ -2566,6 +2567,261 @@ async function deleteMailbox ( ctx.info('Mailbox deleted', { mailbox, account }) } +// ── API Token Management ──────────────────────────────────────────── + +/** + * Creates a new API token for the authenticated user. + * @param params.name Human-readable token name (1–255 chars) + * @param params.workspaceUuid Target workspace — user must have access + * @param params.expiryDays Token validity period (1–365 days) + * @param params.scopes Optional scopes array. NULL/undefined = full access. e.g. ['read:*', 'write:*'] + * @returns Token ID, signed JWT, and expiration timestamp (ms) + * @throws BadRequest if validation fails + * @throws Forbidden if user lacks workspace access + */ +async function createApiToken ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { + name: string + workspaceUuid: WorkspaceUuid + expiryDays: number + scopes?: string[] + } +): Promise<{ id: string, token: string, expiresOn: number }> { + const { name, workspaceUuid, expiryDays, scopes } = params + + if ( + name == null || + typeof name !== 'string' || + name.trim() === '' || + name.trim().length > 255 || + workspaceUuid == null + ) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + } + + if (typeof expiryDays !== 'number' || !Number.isFinite(expiryDays)) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + } + + const days = Math.floor(expiryDays) + if (days < 1 || days > 365) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + } + + const { account, extra } = decodeTokenVerbose(ctx, token) + + // Verify the user has access to this workspace and is at least a User (not a guest) + const role = await db.getWorkspaceRole(account, workspaceUuid) + if (role == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + verifyAllowedRole(role, AccountRole.User, extra) + + // Enforce per-account token limit + const MAX_TOKENS_PER_ACCOUNT = 100 + const existingTokens = await db.apiToken.find({ accountUuid: account }) + if (existingTokens.length >= MAX_TOKENS_PER_ACCOUNT) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + } + + // Validate scopes if provided + const validScopePattern = /^(read|write|delete):\*$/ + if (scopes !== undefined) { + if (!Array.isArray(scopes) || scopes.length === 0) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + } + for (const scope of scopes) { + if (typeof scope !== 'string' || !validScopePattern.test(scope)) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + } + } + } + + const now = Date.now() + const expiresOn = now + days * 86400000 + const expSec = Math.floor(expiresOn / 1000) + + const id = randomUUID() + const tokenExtra: Record = { apiTokenId: id } + if (scopes !== undefined) { + tokenExtra.scopes = JSON.stringify(scopes) + } + const apiToken = generateToken(account, workspaceUuid, tokenExtra, undefined, { exp: expSec }) + + await db.apiToken.insertOne({ + id, + accountUuid: account, + name, + workspaceUuid, + createdOn: now, + expiresOn, + revoked: false, + scopes + }) + + ctx.info('API token created', { id, account, workspaceUuid, days, scopes }) + return { id, token: apiToken, expiresOn } +} + +/** + * Lists all API tokens for the authenticated user across all workspaces. + * Includes workspace names resolved from workspace UUIDs. + */ +async function listApiTokens ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string +): Promise< + Array<{ + id: string + name: string + workspaceUuid: WorkspaceUuid + workspaceName: string + createdOn: number + expiresOn: number + revoked: boolean + }> + > { + const { account } = decodeTokenVerbose(ctx, token) + + const tokens = await db.apiToken.find({ accountUuid: account }) + const wsUuids = [...new Set(tokens.map((t) => t.workspaceUuid))] + const workspaces = await db.workspace.find({ uuid: { $in: wsUuids } as any }) + const wsMap = new Map(workspaces.map((w) => [w.uuid, w.name ?? w.url])) + + return tokens.map((t) => ({ + id: t.id, + name: t.name, + workspaceUuid: t.workspaceUuid, + workspaceName: wsMap.get(t.workspaceUuid) ?? t.workspaceUuid, + createdOn: t.createdOn, + expiresOn: t.expiresOn, + revoked: t.revoked, + scopes: t.scopes ?? undefined + })) +} + +/** + * Soft-revoke: marks the token as revoked in the DB. + * The JWT itself remains valid until expiry — full revocation requires + * a denylist check at the transactor level (future enhancement). + */ +async function revokeApiToken ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { tokenId: string } +): Promise { + const { account, extra } = decodeTokenVerbose(ctx, token) + const { tokenId } = params + + const existing = await db.apiToken.findOne({ id: tokenId, accountUuid: account }) + if (existing == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + } + + // Verify the user is at least a User (not a guest) in the token's workspace + const role = await db.getWorkspaceRole(account, existing.workspaceUuid) + verifyAllowedRole(role, AccountRole.User, extra) + + await db.apiToken.update({ id: tokenId }, { revoked: true }) + ctx.info('API token revoked', { tokenId, account }) +} + +/** + * Checks if a specific API token has been revoked. + * Used by the transactor to enforce revocation at the request level. + */ +async function checkApiTokenRevoked ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { apiTokenId: string } +): Promise { + const { apiTokenId } = params + const existing = await db.apiToken.findOne({ id: apiTokenId }) + if (existing == null) { + return true // Unknown token treated as revoked + } + return existing.revoked +} + +/** + * Lists all API tokens for a workspace. Requires OWNER role. + * Returns tokens from all members, with account UUIDs for attribution. + */ +async function listWorkspaceApiTokens ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { workspaceUuid: WorkspaceUuid } +): Promise< + Array<{ + id: string + name: string + accountUuid: PersonUuid + workspaceUuid: WorkspaceUuid + createdOn: number + expiresOn: number + revoked: boolean + }> + > { + const { account } = decodeTokenVerbose(ctx, token) + const { workspaceUuid } = params + + const role = await db.getWorkspaceRole(account, workspaceUuid) + if (role == null || role !== AccountRole.Owner) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + + const tokens = await db.apiToken.find({ workspaceUuid }) + return tokens.map((t) => ({ + id: t.id, + name: t.name, + accountUuid: t.accountUuid, + workspaceUuid: t.workspaceUuid, + createdOn: t.createdOn, + expiresOn: t.expiresOn, + revoked: t.revoked, + scopes: t.scopes ?? undefined + })) +} + +/** + * Revoke any token in the workspace. Requires OWNER role. + */ +async function revokeWorkspaceApiToken ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { tokenId: string, workspaceUuid: WorkspaceUuid } +): Promise { + const { account } = decodeTokenVerbose(ctx, token) + const { tokenId, workspaceUuid } = params + + const role = await db.getWorkspaceRole(account, workspaceUuid) + if (role == null || role !== AccountRole.Owner) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + + const existing = await db.apiToken.findOne({ id: tokenId, workspaceUuid }) + if (existing == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + } + + await db.apiToken.update({ id: tokenId }, { revoked: true }) + ctx.info('Workspace API token revoked by owner', { tokenId, account, workspaceUuid }) +} + async function exchangeGuestToken ( ctx: MeasureContext, db: AccountDB, @@ -2981,7 +3237,7 @@ export async function getSubscriptions ( targetWorkspace = tokenWorkspace // Verify user has OWNER or MAINTAINER role - const role = await db.getWorkspaceRole(account, targetWorkspace) + const role = await db.getWorkspaceRole(account, tokenWorkspace) if (role === null) { throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) } @@ -3264,6 +3520,12 @@ export type AccountMethods = | 'hasWorkspacePermission' | 'getWorkspacePermissions' | 'getWorkspaceUsersWithPermission' + | 'createApiToken' + | 'listApiTokens' + | 'revokeApiToken' + | 'listWorkspaceApiTokens' + | 'revokeWorkspaceApiToken' + | 'checkApiTokenRevoked' /** * @public @@ -3331,6 +3593,14 @@ export function getMethods (hasSignUp: boolean = true): Partial subscription: DbCollection workspacePermission: DbCollection + apiToken: DbCollection init: () => Promise createWorkspace: (data: WorkspaceData, status: WorkspaceStatusData) => Promise