diff --git a/go/apps.go b/go/apps.go index 9ad68e5e..da6b0f20 100644 --- a/go/apps.go +++ b/go/apps.go @@ -91,3 +91,21 @@ func (c *Client) GetAppFilterOptions(ctx context.Context) (*AppFilterOptions, er } return &result, nil } + +// CheckAppIsAllowed checks if a deployment is allowed by an on-chain app contract. +func (c *Client) CheckAppIsAllowed(ctx context.Context, appID string, req *CheckAppIsAllowedRequest) (*IsAllowedResult, error) { + var result IsAllowedResult + if err := c.doJSON(ctx, "POST", fmt.Sprintf("/apps/%s/is-allowed", appID), req, &result); err != nil { + return nil, err + } + return &result, nil +} + +// CheckAppCvmsIsAllowed batch checks on-chain allowance for all CVMs under an app. +func (c *Client) CheckAppCvmsIsAllowed(ctx context.Context, appID string) (*AppCvmsBatchIsAllowedResponse, error) { + var result AppCvmsBatchIsAllowedResponse + if err := c.doJSON(ctx, "POST", fmt.Sprintf("/apps/%s/cvms/is-allowed", appID), struct{}{}, &result); err != nil { + return nil, err + } + return &result, nil +} diff --git a/go/cvms.go b/go/cvms.go index 80c1470b..a3f8aaf5 100644 --- a/go/cvms.go +++ b/go/cvms.go @@ -223,3 +223,12 @@ func (c *Client) ConfirmCVMPatch(ctx context.Context, cvmID string, req *Confirm } return &result, nil } + +// CheckCvmIsAllowed checks if a CVM deployment is allowed by its on-chain contract. +func (c *Client) CheckCvmIsAllowed(ctx context.Context, cvmID string, req *CheckCvmIsAllowedRequest) (*IsAllowedResult, error) { + var result IsAllowedResult + if err := c.doJSON(ctx, "POST", cvmPath(cvmID, "is-allowed"), req, &result); err != nil { + return nil, err + } + return &result, nil +} diff --git a/go/types_cvms.go b/go/types_cvms.go index 64f4ec2a..d87fa4ef 100644 --- a/go/types_cvms.go +++ b/go/types_cvms.go @@ -327,6 +327,45 @@ type CVMVisibility struct { PublicTcbinfo *bool `json:"public_tcbinfo,omitempty"` } +// IsAllowedResult represents the result of an on-chain allowance check. +type IsAllowedResult struct { + CvmID int `json:"cvm_id,omitempty"` + AppContractAddress string `json:"app_contract_address"` + ComposeHash string `json:"compose_hash"` + DeviceID string `json:"device_id"` + ComposeHashAllowed bool `json:"compose_hash_allowed"` + AllowAnyDevice bool `json:"allow_any_device"` + DeviceIDAllowed *bool `json:"device_id_allowed,omitempty"` + IsAllowed bool `json:"is_allowed"` + Error *string `json:"error,omitempty"` +} + +// CheckCvmIsAllowedRequest is the request for checking CVM on-chain allowance. +type CheckCvmIsAllowedRequest struct { + ComposeHash *string `json:"compose_hash,omitempty"` + NodeID *int `json:"node_id,omitempty"` + DeviceID *string `json:"device_id,omitempty"` +} + +// CheckAppIsAllowedRequest is the request for checking app contract allowance. +type CheckAppIsAllowedRequest struct { + ComposeHash string `json:"compose_hash"` + NodeID *int `json:"node_id,omitempty"` + DeviceID *string `json:"device_id,omitempty"` + ChainID *int `json:"chain_id,omitempty"` +} + +// AppCvmsBatchIsAllowedResponse is the batch response for app CVMs allowance check. +type AppCvmsBatchIsAllowedResponse struct { + IsOnchain bool `json:"is_onchain"` + Results []IsAllowedResult `json:"results"` + Total int `json:"total"` + AllowedCount int `json:"allowed_count"` + DeniedCount int `json:"denied_count"` + ErrorCount int `json:"error_count"` + SkippedCvmIDs []int `json:"skipped_cvm_ids"` +} + // OSImage represents an available OS image. type OSImage struct { Name string `json:"name"` diff --git a/js/src/actions/apps/check_app_cvms_is_allowed.ts b/js/src/actions/apps/check_app_cvms_is_allowed.ts new file mode 100644 index 00000000..6d78b210 --- /dev/null +++ b/js/src/actions/apps/check_app_cvms_is_allowed.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; +import { defineAction } from "../../utils/define-action"; +import { IsAllowedResultSchema } from "../cvms/check_cvm_is_allowed"; + +export const AppCvmsBatchIsAllowedResponseSchema = z.object({ + is_onchain: z.boolean(), + results: z.array(IsAllowedResultSchema.extend({ cvm_id: z.number() })).default([]), + total: z.number().default(0), + allowed_count: z.number().default(0), + denied_count: z.number().default(0), + error_count: z.number().default(0), + skipped_cvm_ids: z.array(z.number()).default([]), +}); + +export type AppCvmsBatchIsAllowedResponse = z.infer; + +export const CheckAppCvmsIsAllowedRequestSchema = z.object({ + appId: z.string().min(1), +}); + +export type CheckAppCvmsIsAllowedRequest = z.infer; + +/** + * Batch check on-chain deployment allowance for all CVMs under an app. + * + * For on-chain KMS apps, queries the blockchain via multicall3 to check + * compose hash and device allowance for each CVM. + * For offchain KMS apps, returns is_onchain=false with no results. + * + * @param client - The API client + * @param request - Request parameters + * @param request.appId - The hex app identifier + * @returns Batch allowance check results for all CVMs + */ +const { action: checkAppCvmsIsAllowed, safeAction: safeCheckAppCvmsIsAllowed } = defineAction< + CheckAppCvmsIsAllowedRequest, + typeof AppCvmsBatchIsAllowedResponseSchema, + AppCvmsBatchIsAllowedResponse +>(AppCvmsBatchIsAllowedResponseSchema, async (client, request) => { + const { appId } = CheckAppCvmsIsAllowedRequestSchema.parse(request); + return await client.post(`/apps/${appId}/cvms/is-allowed`, {}); +}); + +export { checkAppCvmsIsAllowed, safeCheckAppCvmsIsAllowed }; diff --git a/js/src/actions/apps/check_app_is_allowed.ts b/js/src/actions/apps/check_app_is_allowed.ts new file mode 100644 index 00000000..79d633fc --- /dev/null +++ b/js/src/actions/apps/check_app_is_allowed.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; +import { defineAction } from "../../utils/define-action"; +import { IsAllowedResultSchema, type IsAllowedResult } from "../cvms/check_cvm_is_allowed"; + +export const CheckAppIsAllowedRequestSchema = z.object({ + appId: z.string().min(1), + compose_hash: z.string(), + node_id: z.number().optional(), + device_id: z.string().optional(), + chain_id: z.number().optional(), +}); + +export type CheckAppIsAllowedRequest = z.infer; + +/** + * Check if a deployment is allowed by an on-chain DStack App contract. + * + * Supports pure contract query (no DB) when all parameters including chain_id are provided. + * + * @param client - The API client + * @param request - Request parameters + * @param request.appId - App contract address + * @param request.compose_hash - Compose hash to check + * @param request.node_id - Node ID (resolves device_id from DB) + * @param request.device_id - Device ID (hex, direct) + * @param request.chain_id - Chain ID for RPC URL resolution + * @returns On-chain allowance check result + */ +const { action: checkAppIsAllowed, safeAction: safeCheckAppIsAllowed } = defineAction< + CheckAppIsAllowedRequest, + typeof IsAllowedResultSchema, + IsAllowedResult +>(IsAllowedResultSchema, async (client, request) => { + const { appId, ...body } = CheckAppIsAllowedRequestSchema.parse(request); + return await client.post(`/apps/${appId}/is-allowed`, body); +}); + +export { checkAppIsAllowed, safeCheckAppIsAllowed }; diff --git a/js/src/actions/cvms/check_cvm_is_allowed.ts b/js/src/actions/cvms/check_cvm_is_allowed.ts new file mode 100644 index 00000000..dc84fc8f --- /dev/null +++ b/js/src/actions/cvms/check_cvm_is_allowed.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; +import { defineAction } from "../../utils/define-action"; +import { CvmIdSchema, type CvmIdInput } from "../../types/cvm_id"; + +export const IsAllowedResultSchema = z.object({ + cvm_id: z.number().optional(), + app_contract_address: z.string(), + compose_hash: z.string(), + device_id: z.string(), + compose_hash_allowed: z.boolean(), + allow_any_device: z.boolean(), + device_id_allowed: z.boolean().nullable().optional(), + is_allowed: z.boolean(), + error: z.string().nullable().optional(), +}); + +export type IsAllowedResult = z.infer; + +const CheckCvmIsAllowedInputSchema = z.object({ + cvmId: z.string().min(1), + compose_hash: z.string().optional(), + node_id: z.number().optional(), + device_id: z.string().optional(), +}); + +export const CheckCvmIsAllowedRequestSchema = CheckCvmIsAllowedInputSchema; + +export type CheckCvmIsAllowedRequest = z.infer; + +/** + * Check if a CVM deployment is allowed by its on-chain DStack App contract. + * + * @param client - The API client + * @param request - Request parameters + * @param request.cvmId - CVM identifier + * @param request.compose_hash - Optional compose hash override + * @param request.node_id - Optional node ID override (resolves device_id) + * @param request.device_id - Optional device ID override + * @returns On-chain allowance check result + */ +const { action: checkCvmIsAllowed, safeAction: safeCheckCvmIsAllowed } = defineAction< + CheckCvmIsAllowedRequest, + typeof IsAllowedResultSchema, + IsAllowedResult +>(IsAllowedResultSchema, async (client, request) => { + const { cvmId, ...body } = CheckCvmIsAllowedRequestSchema.parse(request); + return await client.post(`/cvms/${cvmId}/is-allowed`, body); +}); + +export { checkCvmIsAllowed, safeCheckCvmIsAllowed }; diff --git a/js/src/actions/index.ts b/js/src/actions/index.ts index 7b99ee28..0499cf7c 100644 --- a/js/src/actions/index.ts +++ b/js/src/actions/index.ts @@ -547,6 +547,32 @@ export { type SyncGithubSshKeysResponse, } from "./ssh_keys/sync_github_ssh_keys"; +// CVM Is-Allowed Check +export { + checkCvmIsAllowed, + safeCheckCvmIsAllowed, + IsAllowedResultSchema, + CheckCvmIsAllowedRequestSchema, + type IsAllowedResult, + type CheckCvmIsAllowedRequest, +} from "./cvms/check_cvm_is_allowed"; + +export { + checkAppIsAllowed, + safeCheckAppIsAllowed, + CheckAppIsAllowedRequestSchema, + type CheckAppIsAllowedRequest, +} from "./apps/check_app_is_allowed"; + +export { + checkAppCvmsIsAllowed, + safeCheckAppCvmsIsAllowed, + AppCvmsBatchIsAllowedResponseSchema, + CheckAppCvmsIsAllowedRequestSchema, + type AppCvmsBatchIsAllowedResponse, + type CheckAppCvmsIsAllowedRequest, +} from "./apps/check_app_cvms_is_allowed"; + // App Operations export { getAppList, diff --git a/python/src/phala_cloud/full_client.py b/python/src/phala_cloud/full_client.py index fc0ee61e..4938448a 100644 --- a/python/src/phala_cloud/full_client.py +++ b/python/src/phala_cloud/full_client.py @@ -50,7 +50,13 @@ from .models.apps import DeviceAllowlistResponse as _DeviceAllowlistResponse from .models.auth import CurrentUserV20251028, CurrentUserV20260121 from .models.base import CloudModel -from .models.cvms import PaginatedCvmInfosV20251028, PaginatedCvmInfosV20260121 +from .models.cvms import ( + CheckAppCvmsIsAllowedRequest, + CheckAppIsAllowedRequest, + CheckCvmIsAllowedRequest, + PaginatedCvmInfosV20251028, + PaginatedCvmInfosV20260121, +) from .models.kms import GetKmsListResponse, GetKmsOnChainDetailResponse, KmsInfo from .models.nodes import AvailableNodes from .models.os_images import GetOsImagesRequest, GetOsImagesResponse @@ -1140,6 +1146,37 @@ def safe_get_app_device_allowlist( ) -> SafeResult[Any]: return self.safe(self.get_app_device_allowlist, request) + def check_cvm_is_allowed(self, request: CheckCvmIsAllowedRequest | Mapping[str, Any]) -> Any: + req = CheckCvmIsAllowedRequest.model_validate(request) + body = req.model_dump(exclude={"cvm_id"}, exclude_none=True) + return self._loose_validate(self.post(f"/cvms/{req.cvm_id}/is-allowed", json=body)) + + def safe_check_cvm_is_allowed( + self, request: CheckCvmIsAllowedRequest | Mapping[str, Any] + ) -> SafeResult[Any]: + return self.safe(self.check_cvm_is_allowed, request) + + def check_app_is_allowed(self, request: CheckAppIsAllowedRequest | Mapping[str, Any]) -> Any: + req = CheckAppIsAllowedRequest.model_validate(request) + body = req.model_dump(exclude={"app_id"}, exclude_none=True) + return self._loose_validate(self.post(f"/apps/{req.app_id}/is-allowed", json=body)) + + def safe_check_app_is_allowed( + self, request: CheckAppIsAllowedRequest | Mapping[str, Any] + ) -> SafeResult[Any]: + return self.safe(self.check_app_is_allowed, request) + + def check_app_cvms_is_allowed( + self, request: CheckAppCvmsIsAllowedRequest | Mapping[str, Any] + ) -> Any: + req = CheckAppCvmsIsAllowedRequest.model_validate(request) + return self._loose_validate(self.post(f"/apps/{req.app_id}/cvms/is-allowed", json={})) + + def safe_check_app_cvms_is_allowed( + self, request: CheckAppCvmsIsAllowedRequest | Mapping[str, Any] + ) -> SafeResult[Any]: + return self.safe(self.check_app_cvms_is_allowed, request) + def add_compose_hash(self, *args: Any, **kwargs: Any) -> Any: return _add_compose_hash(*args, **kwargs) @@ -1937,6 +1974,41 @@ async def safe_get_app_device_allowlist( ) -> SafeResult[Any]: return await self.safe(self.get_app_device_allowlist, request) + async def check_cvm_is_allowed( + self, request: CheckCvmIsAllowedRequest | Mapping[str, Any] + ) -> Any: + req = CheckCvmIsAllowedRequest.model_validate(request) + body = req.model_dump(exclude={"cvm_id"}, exclude_none=True) + return self._loose_validate(await self.post(f"/cvms/{req.cvm_id}/is-allowed", json=body)) + + async def safe_check_cvm_is_allowed( + self, request: CheckCvmIsAllowedRequest | Mapping[str, Any] + ) -> SafeResult[Any]: + return await self.safe(self.check_cvm_is_allowed, request) + + async def check_app_is_allowed( + self, request: CheckAppIsAllowedRequest | Mapping[str, Any] + ) -> Any: + req = CheckAppIsAllowedRequest.model_validate(request) + body = req.model_dump(exclude={"app_id"}, exclude_none=True) + return self._loose_validate(await self.post(f"/apps/{req.app_id}/is-allowed", json=body)) + + async def safe_check_app_is_allowed( + self, request: CheckAppIsAllowedRequest | Mapping[str, Any] + ) -> SafeResult[Any]: + return await self.safe(self.check_app_is_allowed, request) + + async def check_app_cvms_is_allowed( + self, request: CheckAppCvmsIsAllowedRequest | Mapping[str, Any] + ) -> Any: + req = CheckAppCvmsIsAllowedRequest.model_validate(request) + return self._loose_validate(await self.post(f"/apps/{req.app_id}/cvms/is-allowed", json={})) + + async def safe_check_app_cvms_is_allowed( + self, request: CheckAppCvmsIsAllowedRequest | Mapping[str, Any] + ) -> SafeResult[Any]: + return await self.safe(self.check_app_cvms_is_allowed, request) + async def add_compose_hash(self, *args: Any, **kwargs: Any) -> Any: return _add_compose_hash(*args, **kwargs) diff --git a/python/src/phala_cloud/models/base.py b/python/src/phala_cloud/models/base.py index 1fbd0a84..292a74ca 100644 --- a/python/src/phala_cloud/models/base.py +++ b/python/src/phala_cloud/models/base.py @@ -7,3 +7,12 @@ class CloudModel(BaseModel): """Base model with forward-compatible parsing (allow unknown fields).""" model_config = ConfigDict(extra="allow") + + +class AliasModel(BaseModel): + """Base model for request types that use field aliases (e.g. alias='cvmId'). + + Enables population by both the alias and the Python field name. + """ + + model_config = ConfigDict(populate_by_name=True) diff --git a/python/src/phala_cloud/models/cvms.py b/python/src/phala_cloud/models/cvms.py index dfafa7bc..d48f8e5c 100644 --- a/python/src/phala_cloud/models/cvms.py +++ b/python/src/phala_cloud/models/cvms.py @@ -4,7 +4,7 @@ from pydantic import Field -from .base import CloudModel +from .base import AliasModel, CloudModel from .kms import KmsInfo BillingPeriod = Literal["skip", "hourly", "monthly"] @@ -223,3 +223,47 @@ class CvmAttestation(CloudModel): PaginatedCvmInfos = PaginatedCvmInfosV20260121 | PaginatedCvmInfosV20251028 + + +# Is-Allowed types + + +class CheckCvmIsAllowedRequest(AliasModel): + cvm_id: str = Field(..., alias="cvmId") + compose_hash: str | None = None + node_id: int | None = None + device_id: str | None = None + + +class IsAllowedResult(CloudModel): + cvm_id: int | None = None + app_contract_address: str + compose_hash: str + device_id: str + compose_hash_allowed: bool + allow_any_device: bool + device_id_allowed: bool | None = None + is_allowed: bool + error: str | None = None + + +class CheckAppIsAllowedRequest(AliasModel): + app_id: str = Field(..., alias="appId") + compose_hash: str + node_id: int | None = None + device_id: str | None = None + chain_id: int | None = None + + +class CheckAppCvmsIsAllowedRequest(AliasModel): + app_id: str = Field(..., alias="appId") + + +class AppCvmsBatchIsAllowedResponse(CloudModel): + is_onchain: bool + results: list[IsAllowedResult] = Field(default_factory=list) + total: int = 0 + allowed_count: int = 0 + denied_count: int = 0 + error_count: int = 0 + skipped_cvm_ids: list[int] = Field(default_factory=list)