diff --git a/.gitattributes b/.gitattributes index ffcc4445fe8a..24860868b59b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,7 @@ # Batch files need CRLF for cmd.exe to parse them reliably. *.cmd text eol=crlf *.bat text eol=crlf -# GitHub's GraphQL schema and the operations graphqlgen writes from it. +# GitHub's and Expo's GraphQL schemas and the operations graphqlgen writes from them. packages/source-control-github/schema/** linguist-generated +apps/server/schema/** linguist-generated **/*.graphql.ts linguist-generated diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6217858a06ce..45b333973261 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,9 @@ jobs: - name: Check generated GitHub GraphQL operations run: vp run --filter @t3tools/source-control-github generate --check + - name: Check generated Expo GraphQL operations + run: vp run --filter t3 generate --check + - name: Check run: vp check diff --git a/apps/server/graphql.config.ts b/apps/server/graphql.config.ts new file mode 100644 index 000000000000..f042d039410d --- /dev/null +++ b/apps/server/graphql.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "@effect/graphql-generator/Config"; + +// `schema/expo.graphql` is api.expo.dev's schema, read by introspection +// (`vp run generate:expo-schema`). The EAS Simulator operations are the ones +// eas-cli's `simulator:*` commands send; Expo does not document them. +export default defineConfig({ + schema: "./schema/expo.graphql", + documents: ["src/device/**/*.graphql"], + scalars: { + // Feature gates arrive as an object of gate name to boolean. + JSONObject: "effect/Schema#Json", + }, +}); diff --git a/apps/server/package.json b/apps/server/package.json index b474b7012d6a..b13957b82b7d 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -26,7 +26,9 @@ "record:cursor-replay": "node --env-file-if-exists=../../.env --experimental-strip-types scripts/record-cursor-agent-sdk-replay-fixture.ts", "record:pi-replay": "node scripts/record-pi-rpc-replay-fixture.ts", "record:grok-replay": "node scripts/record-grok-acp-replay-fixture.ts", - "record:muse-replay": "node scripts/record-muse-msp-replay-fixture.ts" + "record:muse-replay": "node scripts/record-muse-msp-replay-fixture.ts", + "generate": "graphqlgen", + "generate:expo-schema": "node scripts/fetch-expo-schema.ts" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.276", @@ -64,6 +66,7 @@ "yauzl": "^3.4.0" }, "devDependencies": { + "@effect/graphql-generator": "catalog:", "@effect/vitest": "catalog:", "@modelcontextprotocol/sdk": "1.29.0", "@t3tools/contracts": "workspace:*", @@ -78,6 +81,7 @@ "@types/yauzl": "^3.4.0", "effect-acp": "workspace:*", "effect-codex-app-server": "workspace:*", + "graphql": "16.14.1", "vite-plus": "catalog:" }, "engines": { diff --git a/apps/server/schema/expo.graphql b/apps/server/schema/expo.graphql new file mode 100644 index 000000000000..93cbceeb517b --- /dev/null +++ b/apps/server/schema/expo.graphql @@ -0,0 +1,11587 @@ +schema { + query: RootQuery + mutation: RootMutation +} + +type AcceptUserInvitationResult { + success: Boolean! +} + +"""A method of authentication for an Actor""" +type AccessToken { + createdAt: DateTime! + id: ID! + lastUsedAt: DateTime + note: String + owner: Actor! + revokedAt: DateTime + + """ + Scopes this token is restricted to. Null for a full token that authenticates as the owner + with all of their permissions. + """ + scopes: [AccessTokenScope!] + updatedAt: DateTime! + visibleTokenPrefix: String! +} + +type AccessTokenMutation { + """Create an AccessToken for an Actor""" + createAccessToken(createAccessTokenData: CreateAccessTokenInput!): CreateAccessTokenResponse! + + """Delete an AccessToken""" + deleteAccessToken(id: ID!): DeleteAccessTokenResult! + + """Revoke an AccessToken""" + setAccessTokenRevoked(id: ID!, revoked: Boolean): AccessToken! +} + +"""One scope narrowing an AccessToken below its owner's full access""" +type AccessTokenScope { + """The account this scope applies to. Only present for ACCOUNT scopes.""" + accountID: ID + permissions: [Permission!]! + type: AccessTokenScopeType! +} + +input AccessTokenScopeInput { + """ + The account this scope applies to. Required for ACCOUNT scopes and must be omitted for + ALL_ACCOUNTS scopes. + """ + accountID: ID + permissions: [Permission!]! + type: AccessTokenScopeType! +} + +enum AccessTokenScopeType { + """One account, named by accountID, narrowed to the scope's permissions.""" + ACCOUNT + + """ + Every account the token's owner has permissions on at request time, narrowed to the + scope's permissions. + """ + ALL_ACCOUNTS +} + +""" +A GitHub App installation visible to a viewer, not necessarily linked yet. +""" +type AccessibleGitHubAppInstallation { + account: AccessibleGitHubAppInstallationAccount! + installationIdentifier: Int! +} + +type AccessibleGitHubAppInstallationAccount { + avatarUrl: String! + login: String! + type: GitHubAppInstallationAccountType! +} + +type AccessibleGitHubRepository { + id: Int! + name: String! + nodeId: String! + owner: AccessibleGitHubRepositoryOwner! + private: Boolean! +} + +type AccessibleGitHubRepositoryOwner { + login: String! +} + +""" +An account is a container owning projects, credentials, billing and other organization +data and settings. Actors may own and be members of accounts. +""" +type Account { + """ + Server account feature gate values for this account, optionally filtering by desired gates. + """ + accountFeatureGates(filter: [String!]): JSONObject! + + """Coalesced project activity for all apps belonging to this account.""" + activityTimelineProjectActivities( + """ Offset the query """ + createdBefore: DateTime + + """ Types of objects to filter """ + filterTypes: [ActivityTimelineProjectActivityType!] + limit: Int! + ): [ActivityTimelineProjectActivity!]! + + """AI provider connections available to agent sandbox sessions.""" + agentProviderConnections: [AgentProviderConnection!]! + appCount: Int! + appStoreConnectApiKeys: [AppStoreConnectApiKey!]! @deprecated(reason: "Use appStoreConnectApiKeysPaginated") + appStoreConnectApiKeysPaginated(after: String, before: String, first: Int, last: Int): AccountAppStoreConnectApiKeysConnection! + appleAppIdentifiers(bundleIdentifier: String): [AppleAppIdentifier!]! + appleDevicesPaginated(after: String, before: String, filter: AppleDeviceFilterInput, first: Int, last: Int): AccountAppleDevicesConnection! + appleDistributionCertificates: [AppleDistributionCertificate!]! @deprecated(reason: "Use appleDistributionCertificatesPaginated") + appleDistributionCertificatesPaginated(after: String, before: String, first: Int, last: Int): AccountAppleDistributionCertificatesConnection! + appleProvisioningProfiles(appleAppIdentifierId: ID): [AppleProvisioningProfile!]! @deprecated(reason: "Use appleProvisioningProfilesPaginated") + appleProvisioningProfilesPaginated(after: String, before: String, first: Int, last: Int): AccountAppleProvisioningProfilesConnection! + applePushKeys: [ApplePushKey!]! @deprecated(reason: "Use applePushKeysPaginated") + applePushKeysPaginated(after: String, before: String, first: Int, last: Int): AccountApplePushKeysConnection! + appleTeams(appleTeamIdentifier: String, limit: Int, offset: Int): [AppleTeam!]! @deprecated(reason: "Use appleTeamsPaginated") + + """iOS credentials for account""" + appleTeamsPaginated(after: String, before: String, filter: AppleTeamFilterInput, first: Int, last: Int): AccountAppleTeamsConnection! + + """Apps associated with this account""" + apps(includeUnpublished: Boolean, limit: Int!, offset: Int!): [App!]! @deprecated(reason: "Use appsPaginated") + + """ + Paginated list of apps associated with this account. By default sorted by name. Use filter to adjust the sorting order. + """ + appsPaginated(after: String, before: String, filter: AccountAppsFilterInput, first: Int, last: Int): AccountAppsConnection! + + """Audit logs for account""" + auditLogsPaginated(after: String, before: String, filter: AuditLogFilterInput, first: Int, last: Int): AuditLogConnection! + + """ + Billing information. Only visible to members with the ADMIN or OWNER role. + """ + billing: Billing + billingPeriod(date: DateTime!): BillingPeriod! + concurrencyConsumers(limit: Int! = 500): [ConcurrencyConsumer!]! + + """Convex team connections for this account""" + convexTeamConnections: [ConvexTeamConnection!]! + createdAt: DateTime! + + """ + Simulator sessions for all apps associated with this account, sorted newest first. + """ + deviceRunSessionsPaginated(after: String, before: String, filter: AccountDeviceRunSessionFilterInput, first: Int, last: Int): AccountDeviceRunSessionsConnection! + displayName: String + + """Environment secrets for an account""" + environmentSecrets(filterNames: [String!]): [EnvironmentSecret!]! + environmentVariableEnvironments: [EnvironmentVariableEnvironment!]! + + """ + Whether the account turned on environment variable protection. When protection is on, using, + creating, changing, or deleting a SECRET environment variable needs the PUBLISH_PROTECTED + permission, in every project of the account. Expo can also turn protection on for an + account; this field does not show that. + """ + environmentVariableProtectionEnabled: Boolean! + + """Environment variables for an account""" + environmentVariables(environment: EnvironmentVariableEnvironment, filterNames: [String!]): [EnvironmentVariable!]! + + """Environment variables for an account with decrypted secret values""" + environmentVariablesIncludingSensitive(environment: EnvironmentVariableEnvironment, filterNames: [String!]): [EnvironmentVariableWithSecret!]! + + """GitHub App installations for an account""" + githubAppInstallations: [GitHubAppInstallation!]! + + """GitHub Enterprise app registration for an account""" + githubAppRegistration: GitHubAppRegistration + googleServiceAccountKeys: [GoogleServiceAccountKey!]! @deprecated(reason: "Use googleServiceAccountKeysPaginated") + + """Android credentials for account""" + googleServiceAccountKeysPaginated(after: String, before: String, first: Int, last: Int): AccountGoogleServiceAccountKeysConnection! + id: ID! + isCurrent: Boolean! + isDisabled: Boolean! + + """ + Whether an Account plan falls into AppDevDomainName's free or paid tier + """ + isFreeAppDevDomainTier: Boolean! + + """Whether this account has SSO enabled. Can be queried by all members.""" + isSSOEnabled: Boolean! + lastDeletionAttemptTime: DateTime + logRocketOrganization: LogRocketOrganization + + """Aggregate statistics about account members.""" + memberStats: AccountMemberStats! + + """ + Paginated list of members (actors) associated with this account and their permissions + """ + membersPaginated( + after: String + before: String + filter: AccountMembersFilterInput + first: Int + last: Int + + """Filter members by type. Defaults to ALL.""" + memberType: AccountMemberType + ): AccountMembersConnection! + name: String! + + """ + Whether EAS Observe ingestion is paused for this account. Accounts that have not configured + the setting are treated as false (ingestion enabled). + """ + observeIngestionPaused: Boolean! + + """Offers set on this account""" + offers: [Offer!] + + """Onboarding milestone stats for this account""" + onboardingStats: AccountOnboardingStats! + + """Owning User of this account if personal account""" + owner: User @deprecated(reason: "Deprecated in favor of ownerUserActor") + + """Owning UserActor of this account if personal account""" + ownerUserActor: UserActor + pendingSentryInstallation: PendingSentryInstallation + + """PostHog organization connection for this account""" + posthogOrganizationConnection: PostHogOrganizationConnection + profileImageUrl: String! + pushSecurityEnabled: Boolean! + requireTwoFactor: Boolean! + sentryInstallation: SentryInstallation + + """Snacks associated with this account""" + snacks(limit: Int!, offset: Int!): [Snack!]! @deprecated(reason: "Use snacksPaginated") + + """ + Paginated list of Snacks associated with this account, sorted by most recent activity. + """ + snacksPaginated(after: String, before: String, first: Int, last: Int): AccountSnacksConnection! + + """Allowed SSO providers for this account""" + ssoAllowedAuthProviders: [AuthProviderIdentifier!]! + + """SSO configuration for this account""" + ssoConfiguration: AccountSSOConfiguration + + """Subscription info visible to members that have VIEWER role""" + subscription: SubscriptionDetails + + """Supabase connection for this account""" + supabaseConnection: SupabaseConnection + + """Coalesced project activity for an app using pagination""" + timelineActivity(after: String, before: String, filter: TimelineActivityFilterInput, first: Int, last: Int): TimelineActivityConnection! + updatedAt: DateTime! + + """Account query object for querying EAS usage metrics""" + usageMetrics: AccountUsageMetrics! + + """Pending user invitations for this account""" + userInvitations: [UserInvitation!]! + userSpecifiedAccountUsage: UserSpecifiedAccountUsage + + """Actors associated with this account and permissions they hold""" + users: [UserPermission!]! @deprecated(reason: "Deprecated in favor of membersPaginated and memberStats") + + """Vexo account connection for this account""" + vexoAccountConnection: VexoAccountConnection + + """Notification preferences of the viewer for this account""" + viewerNotificationPreferences: [NotificationPreferenceItem!]! + + """Permission info for the viewer on this account""" + viewerUserPermission: UserPermission! @deprecated(reason: "Use viewerUserPermission2 instead") + + """ + Permission info for the viewer on this account, including their effective permissions + """ + viewerUserPermission2: ViewerUserPermission! +} + +type AccountAppStoreConnectApiKeysConnection { + edges: [AccountAppStoreConnectApiKeysEdge!]! + pageInfo: PageInfo! +} + +type AccountAppStoreConnectApiKeysEdge { + cursor: String! + node: AppStoreConnectApiKey! +} + +type AccountAppleDevicesConnection { + edges: [AccountAppleDevicesEdge!]! + pageInfo: PageInfo! +} + +type AccountAppleDevicesEdge { + cursor: String! + node: AppleDevice! +} + +type AccountAppleDistributionCertificatesConnection { + edges: [AccountAppleDistributionCertificatesEdge!]! + pageInfo: PageInfo! +} + +type AccountAppleDistributionCertificatesEdge { + cursor: String! + node: AppleDistributionCertificate! +} + +type AccountAppleProvisioningProfilesConnection { + edges: [AccountAppleProvisioningProfilesEdge!]! + pageInfo: PageInfo! +} + +type AccountAppleProvisioningProfilesEdge { + cursor: String! + node: AppleProvisioningProfile! +} + +type AccountApplePushKeysConnection { + edges: [AccountApplePushKeysEdge!]! + pageInfo: PageInfo! +} + +type AccountApplePushKeysEdge { + cursor: String! + node: ApplePushKey! +} + +type AccountAppleTeamsConnection { + edges: [AccountAppleTeamsEdge!]! + pageInfo: PageInfo! +} + +type AccountAppleTeamsEdge { + cursor: String! + node: AppleTeam! +} + +type AccountAppsConnection { + edges: [AccountAppsEdge!]! + pageInfo: PageInfo! +} + +type AccountAppsEdge { + cursor: String! + node: App! +} + +input AccountAppsFilterInput { + searchTerm: String + sortByField: AccountAppsSortByField! +} + +enum AccountAppsSortByField { + LATEST_ACTIVITY_TIME + + """ + Name prefers the display name but falls back to full_name with @account/ + part stripped. + """ + NAME +} + +input AccountDataInput { + displayName: String + name: String! + userSpecifiedAccountUsage: UserSpecifiedAccountUsage +} + +input AccountDeviceRunSessionFilterInput { + statuses: [DeviceRunSessionStatus!] +} + +type AccountDeviceRunSessionsConnection { + edges: [AccountDeviceRunSessionsEdge!]! + pageInfo: PageInfo! +} + +type AccountDeviceRunSessionsEdge { + cursor: String! + node: DeviceRunSession! +} + +type AccountGoogleServiceAccountKeysConnection { + edges: [AccountGoogleServiceAccountKeysEdge!]! + pageInfo: PageInfo! +} + +type AccountGoogleServiceAccountKeysEdge { + cursor: String! + node: GoogleServiceAccountKey! +} + +"""Aggregate statistics about account members""" +type AccountMemberStats { + """ + Whether all human members (Users only, not SSO) have 2FA enabled. + Also checks the implicit account owner for personal accounts. + """ + allHave2FAEnabled: Boolean! + + """Count of human members (Users + SSOUsers)""" + humanCount: Int! + + """Count of members with OWN permission""" + ownerCount: Int! + + """Count of robot members""" + robotCount: Int! + + """Count of SSO users only""" + ssoUserCount: Int! + + """Total count of all members""" + totalCount: Int! +} + +"""Filter for account member types in membersPaginated query""" +enum AccountMemberType { + """All members (default)""" + ALL + + """Human members (Users + SSOUsers)""" + HUMAN + + """Robot members only""" + ROBOT + + """SSO users only""" + SSO_USER +} + +type AccountMembersConnection { + edges: [AccountMembersEdge!]! + pageInfo: PageInfo! +} + +type AccountMembersEdge { + cursor: String! + node: UserPermission! +} + +input AccountMembersFilterInput { + """Filter by members containing all of the specified permissions.""" + exactPermissions: [Permission!] + + """ + Filter members by 2FA status. Only regular users are returned when set, since SSO users + and robots don't have 2FA. Requires admin permission on the account. + """ + isSecondFactorAuthenticationEnabled: Boolean + + """Case-insensitive substring match on username, email, and name""" + searchTerm: String +} + +type AccountMutation { + """Cancels all subscriptions immediately""" + cancelAllSubscriptionsImmediately(accountID: ID!): Account! + + """Cancel scheduled subscription change""" + cancelScheduledSubscriptionChange(accountID: ID!): Account! + + """ + Buys or revokes account's additional concurrencies, charging the account the appropriate amount if needed. + """ + changeAdditionalConcurrenciesCount(accountID: ID!, newAdditionalConcurrenciesCount: Int!): Account! + + """ + Upgrades or downgrades the active subscription to the newPlanIdentifier, which must be one of the EAS plans (i.e., Production or Enterprise). + """ + changePlan(accountID: ID!, couponCode: String, newPlanIdentifier: String!): Account! + + """ + Add specified account Permissions for Actor. Actor must already have at least one permission on the account. + """ + grantActorPermissions(accountID: ID!, actorID: ID!, permissions: [Permission]): Account! + + """ + Remove profile image for the account. Do nothing if there's no profile image associated. + """ + removeProfileImage(accountID: ID!): Account! + + """ + Rename this account and the primary user's username if this account is a personal account + """ + rename(accountID: ID!, newName: String!): Account! + + """ + Requests a refund for the specified charge by requesting a manual refund from support + """ + requestRefund(accountID: ID!, chargeID: ID!, description: String, reason: String): Boolean + + """ + Revoke specified Permissions for Actor. Actor must already have at least one permission on the account. + """ + revokeActorPermissions(accountID: ID!, actorID: ID!, permissions: [Permission]): Account! + + """ + Set the display name for the account. Pass null to clear it and fall back to the account identifier. + """ + setDisplayName(accountID: ID!, displayName: String): Account! + + """ + Set whether environment variable protection is on for this account. When it is on, using, + creating, changing, or deleting a SECRET environment variable needs the PUBLISH_PROTECTED + permission on the account, in every project of the account. Requires the ADMIN permission on + the account. Turning protection off also requires a sudo session. + """ + setEnvironmentVariableProtectionEnabled(accountID: ID!, environmentVariableProtectionEnabled: Boolean!): Account! + + """Set whether EAS Observe ingestion is paused for this account.""" + setObserveIngestionPaused(accountID: ID!, paused: Boolean!): Account! + + """ + Require authorization to send push notifications for experiences owned by this account + """ + setPushSecurityEnabled(accountID: ID!, pushSecurityEnabled: Boolean!): Account! + + """ + Set whether two-factor authentication is required for this account. When enabled: + - Existing members with 2FA cannot disable it + - New members must enable 2FA before joining + - Existing members without 2FA are not affected + """ + setRequireTwoFactor(accountID: ID!, requireTwoFactor: Boolean!): Account! + + """Set the user specified account usage for the account.""" + setUserSpecifiedAccountUsage(accountID: ID!, userSpecifiedAccountUsage: UserSpecifiedAccountUsage!): Account! +} + +"""Account-level notification preference""" +type AccountNotificationPreference implements NotificationPreference { + account: Account! + enabled: Boolean! + event: NotificationEvent! + type: NotificationType! +} + +input AccountNotificationPreferenceInput { + accountId: ID! + enabled: Boolean! + event: NotificationEvent! + type: NotificationType! +} + +"""Onboarding milestone stats for an account""" +type AccountOnboardingStats { + firstBuildCompletedAt: DateTime + firstProjectCreatedAt: DateTime + firstSubmissionCompletedAt: DateTime + firstUpdateCreatedAt: DateTime + hasConfiguredUpdate: Boolean! @deprecated(reason: "Onboarding widget experiment removed") + hasConfiguredWorkflow: Boolean! + hasTeamMembers: Boolean! @deprecated(reason: "Onboarding widget experiment removed") +} + +type AccountQuery { + """Query an Account by ID""" + byId( + """Account id to use to look up account""" + accountId: String! + ): Account! + + """Query an Account by name""" + byName( + """Account name to use to look up account""" + accountName: String! + ): Account! +} + +"""Auth configuration data for an SSO account.""" +type AccountSSOConfiguration { + authProtocol: AuthProtocolType! + authProviderIdentifier: AuthProviderIdentifier! + clientIdentifier: String! + clientSecret: String! + createdAt: DateTime! + id: ID! + issuer: String! + updatedAt: DateTime! +} + +input AccountSSOConfigurationData { + authProtocol: AuthProtocolType! + authProviderIdentifier: AuthProviderIdentifier! + clientIdentifier: String! + clientSecret: String! + issuer: String! +} + +type AccountSSOConfigurationMutation { + """Create an AccountSSOConfiguration for an Account""" + createAccountSSOConfiguration(accountId: ID!, accountSSOConfigurationData: AccountSSOConfigurationData!): AccountSSOConfiguration! + + """Delete an AccountSSOConfiguration""" + deleteAccountSSOConfiguration(id: ID!): DeleteAccountSSOConfigurationResult! + + """Update an AccountSSOConfiguration""" + updateAccountSSOConfiguration(accountSSOConfigurationData: AccountSSOConfigurationData!, id: ID!): AccountSSOConfiguration! + + """Update just the client secret of an AccountSSOConfiguration""" + updateAccountSSOConfigurationClientSecret(clientSecret: String!, id: ID!): AccountSSOConfiguration! +} + +"""Public auth configuration data for an SSO account.""" +type AccountSSOConfigurationPublicData { + authProtocol: AuthProtocolType! + authProviderIdentifier: AuthProviderIdentifier! + authorizationUrl: String! + id: ID! + issuer: String! +} + +type AccountSSOConfigurationPublicDataQuery { + """Get AccountSSOConfiguration public data by account name""" + publicDataByAccountName( + """Account name to use to look up account SSO configuration""" + accountName: String! + ): AccountSSOConfigurationPublicData! +} + +type AccountSnacksConnection { + edges: [AccountSnacksEdge!]! + pageInfo: PageInfo! +} + +type AccountSnacksEdge { + cursor: String! + node: Snack! +} + +enum AccountUploadSessionType { + PROFILE_IMAGE_UPLOAD + WORKFLOWS_PROJECT_SOURCES +} + +type AccountUsageEASBuildMetadata { + billingResourceClass: EASBuildBillingResourceClass + platform: AppPlatform + waiverType: EASBuildWaiverType +} + +union AccountUsageMetadata = AccountUsageEASBuildMetadata + +type AccountUsageMetric { + id: ID! + metricType: UsageMetricType! + serviceMetric: EASServiceMetric! + timestamp: DateTime! + value: Float! +} + +type AccountUsageMetrics { + byBillingPeriod(date: DateTime!, service: EASService): UsageMetricTotal! + metricsForServiceMetric(filterParams: JSONObject, granularity: UsageMetricsGranularity!, serviceMetric: EASServiceMetric!, timespan: UsageMetricsTimespan!): [AccountUsageMetric!]! +} + +interface ActivityTimelineProjectActivity { + activityTimestamp: DateTime! + actor: Actor + id: ID! +} + +enum ActivityTimelineProjectActivityType { + BUILD + SUBMISSION + UPDATE + WORKER + WORKFLOW_RUN +} + +""" +A regular user, SSO user, or robot that can authenticate with Expo services and be a member of accounts. +""" +interface Actor { + """Access Tokens belonging to this actor""" + accessTokens: [AccessToken!]! + + """Associated accounts""" + accounts: [Account!]! + created: DateTime! + + """ + Best-effort human readable name for this actor for use in user interfaces during action attribution. + For example, when displaying a sentence indicating that actor X created a build or published an update. + """ + displayName: String! + + """Experiments associated with this actor""" + experiments: [ActorExperiment!]! + + """ + Server feature gate values for this actor, optionally filtering by desired gates. + Only resolves for the viewer. + """ + featureGates(filter: [String!]): JSONObject! + firstName: String + id: ID! + isExpoAdmin: Boolean! + lastDeletionAttemptTime: DateTime +} + +type ActorExperiment { + createdAt: DateTime! + enabled: Boolean! + experiment: Experiment! + id: ID! + updatedAt: DateTime! +} + +type ActorExperimentMutation { + """Create or update the value of a User Experiment""" + createOrUpdateActorExperiment(enabled: Boolean!, experiment: Experiment!): ActorExperiment! +} + +type AddonDetails { + id: ID! + name: String! + nextInvoice: DateTime + planId: String! + quantity: Int + willCancel: Boolean +} + +type AgentDeviceRunSessionRemoteConfig { + agentDeviceRemoteSessionToken: String! + agentDeviceRemoteSessionUrl: String! + + """ + Fingerprint of the reverse-tunnel server key for the client to pin. Null when + the session does not use local egress. + """ + egressFingerprint: String + + """ + Loopback port on the device host that the local egress client must serve + through the tunnel. Null when the session does not use local egress. + """ + egressPort: Int + + """ + Secret the local egress client presents to the reverse-tunnel endpoint. Null + when the session does not use local egress. + """ + egressToken: String + + """ + Reverse-tunnel endpoint the local egress client connects to. Null when the + session does not use local egress. + """ + egressUrl: String + + """URL of the preview server, for reaching its API rather than its page.""" + previewApiUrl: String + + """ + Session token gating the web preview. Null when the preview runs ungated. + """ + webPreviewToken: String + + """ + URL of the web preview surface for the session. Null when a web preview is + not available for the session. + """ + webPreviewUrl: String +} + +enum AgentProvider { + ANTHROPIC + OPENAI +} + +type AgentProviderConnection { + account: Account! + createdAt: DateTime! + createdByActor: Actor + expiresAt: DateTime + id: ID! + name: String + provider: AgentProvider! + providerAccountEmail: String + providerAccountId: String + providerPlan: String + status: AgentProviderConnectionStatus! + updatedAt: DateTime! +} + +enum AgentProviderConnectionAuthorizationStatus { + EXPIRED + PENDING + SUCCEEDED +} + +type AgentProviderConnectionMutation { + completeClaudeConnection(input: CompleteClaudeConnectionInput!): AgentProviderConnection! + deleteAgentProviderConnection(id: ID!): AgentProviderConnection! + pollCodexConnection(requestId: ID!): CodexConnectionPollResult! + startClaudeConnection(input: StartClaudeConnectionInput!): ClaudeAuthorization! + startCodexConnection(accountId: ID!): CodexDeviceAuthorization! + updateAgentProviderConnection(id: ID!, input: UpdateAgentProviderConnectionInput!): AgentProviderConnection! +} + +enum AgentProviderConnectionStatus { + INVALID + UNABLE_TO_VERIFY + VALID +} + +type AndroidAppBuildCredentials { + androidKeystore: AndroidKeystore + id: ID! + isDefault: Boolean! + isLegacy: Boolean! + name: String! +} + +""" +@isDefault: if set, these build credentials will become the default for the Android app. All other build credentials will have their default status set to false. +""" +input AndroidAppBuildCredentialsInput { + isDefault: Boolean! + keystoreId: ID! + name: String! +} + +type AndroidAppBuildCredentialsMutation { + """Create a set of build credentials for an Android app""" + createAndroidAppBuildCredentials(androidAppBuildCredentialsInput: AndroidAppBuildCredentialsInput!, androidAppCredentialsId: ID!): AndroidAppBuildCredentials! + + """delete a set of build credentials for an Android app""" + deleteAndroidAppBuildCredentials(id: ID!): deleteAndroidAppBuildCredentialsResult! + + """Set the build credentials to be the default for the Android app""" + setDefault(id: ID!, isDefault: Boolean!): AndroidAppBuildCredentials! + + """Set the keystore to be used for an Android app""" + setKeystore(id: ID!, keystoreId: ID!): AndroidAppBuildCredentials! + + """ + Set the name of a set of build credentials to be used for an Android app + """ + setName(id: ID!, name: String!): AndroidAppBuildCredentials! +} + +type AndroidAppCredentials { + androidAppBuildCredentialsArray: [AndroidAppBuildCredentials!]! @deprecated(reason: "use androidAppBuildCredentialsList instead") + androidAppBuildCredentialsList: [AndroidAppBuildCredentials!]! + androidFcm: AndroidFcm + app: App! + applicationIdentifier: String + googleServiceAccountKeyForFcmV1: GoogleServiceAccountKey + googleServiceAccountKeyForSubmissions: GoogleServiceAccountKey + id: ID! + isLegacy: Boolean! +} + +input AndroidAppCredentialsFilter { + applicationIdentifier: String + legacyOnly: Boolean +} + +input AndroidAppCredentialsInput { + fcmId: ID + googleServiceAccountKeyForFcmV1Id: ID + googleServiceAccountKeyForSubmissionsId: ID +} + +type AndroidAppCredentialsMutation { + """Create a set of credentials for an Android app""" + createAndroidAppCredentials(androidAppCredentialsInput: AndroidAppCredentialsInput!, appId: ID!, applicationIdentifier: String!): AndroidAppCredentials! + + """ + Create a GoogleServiceAccountKeyEntity to store credential and + connect it with an edge from AndroidAppCredential + """ + createFcmV1Credential(androidAppCredentialsId: String!, credential: String!): AndroidAppCredentials! + + """Delete a set of credentials for an Android app""" + deleteAndroidAppCredentials(id: ID!): DeleteAndroidAppCredentialsResult! + + """Set the FCM push key to be used in an Android app""" + setFcm(fcmId: ID!, id: ID!): AndroidAppCredentials! + + """ + Set the Google Service Account Key to be used for Firebase Cloud Messaging V1 + """ + setGoogleServiceAccountKeyForFcmV1(googleServiceAccountKeyId: ID!, id: ID!): AndroidAppCredentials! + + """ + Set the Google Service Account Key to be used for submitting an Android app + """ + setGoogleServiceAccountKeyForSubmissions(googleServiceAccountKeyId: ID!, id: ID!): AndroidAppCredentials! +} + +enum AndroidBuildType { + APK + APP_BUNDLE + + """@deprecated Use developmentClient option instead.""" + DEVELOPMENT_CLIENT +} + +input AndroidBuilderEnvironmentInput { + bun: String + corepack: Boolean + env: JSONObject + expoCli: String + image: String + ndk: String + node: String + pnpm: String + yarn: String +} + +type AndroidDeviceRunSessionDevice { + """Human-readable device name to show to users (e.g. "Pixel 9").""" + displayName: String! + + """ + AVD hardware profile id accepted by android.deviceIdentifier (e.g. "pixel_9"). + """ + identifier: String! +} + +type AndroidDeviceRunSessionProfiles { + devices: [AndroidDeviceRunSessionDevice!]! +} + +type AndroidFcm { + account: Account! + createdAt: DateTime! + + """ + Legacy FCM: returns the Cloud Messaging token, parses to a String + FCM v1: returns the Service Account Key file, parses to an Object + """ + credential: JSON! + id: ID! + snippet: FcmSnippet! + updatedAt: DateTime! + version: AndroidFcmVersion! +} + +input AndroidFcmInput { + credential: String! + version: AndroidFcmVersion! +} + +type AndroidFcmMutation { + """Create an FCM V0/Legacy credential""" + createAndroidFcm(accountId: ID!, androidFcmInput: AndroidFcmInput!): AndroidFcm! @deprecated(reason: "FCM Legacy credentials are no longer supported by Google. Use createFcmV1Credential instead.") + + """Delete an FCM V0/Legacy credential""" + deleteAndroidFcm(id: ID!): deleteAndroidFcmResult! +} + +enum AndroidFcmVersion { + LEGACY + V1 +} + +input AndroidJobBuildCredentialsInput { + keystore: AndroidJobKeystoreInput! +} + +input AndroidJobInput { + applicationArchivePath: String + + """@deprecated""" + artifactPath: String + buildArtifactPaths: [String!] + buildProfile: String + buildType: AndroidBuildType + builderEnvironment: AndroidBuilderEnvironmentInput + cache: BuildCacheInput + customBuildConfig: CustomBuildConfigInput + developmentClient: Boolean + environment: EnvironmentVariableEnvironment + experimental: JSONObject + gradleCommand: String + loggerLevel: WorkerLoggerLevel + mode: BuildMode + projectArchive: ProjectArchiveSourceInput! + projectRootDirectory: String! + releaseChannel: String + secrets: AndroidJobSecretsInput + triggeredBy: BuildTrigger + type: BuildWorkflow! + updates: BuildUpdatesInput + username: String + version: AndroidJobVersionInput +} + +input AndroidJobKeystoreInput { + dataBase64: String! + keyAlias: String! + keyPassword: String + keystorePassword: String! +} + +input AndroidJobOverridesInput { + applicationArchivePath: String + + """@deprecated""" + artifactPath: String + buildArtifactPaths: [String!] + buildProfile: String + buildType: AndroidBuildType + builderEnvironment: AndroidBuilderEnvironmentInput + cache: BuildCacheInput + customBuildConfig: CustomBuildConfigInput + developmentClient: Boolean + experimental: JSONObject + gradleCommand: String + loggerLevel: WorkerLoggerLevel + mode: BuildMode + releaseChannel: String + secrets: AndroidJobSecretsInput + updates: BuildUpdatesInput + username: String + version: AndroidJobVersionInput +} + +input AndroidJobSecretsInput { + buildCredentials: AndroidJobBuildCredentialsInput +} + +input AndroidJobVersionInput { + versionCode: String! +} + +type AndroidKeystore { + account: Account! + createdAt: DateTime! + id: ID! + keyAlias: String! + keyPassword: String + keystore: String! + keystorePassword: String! + md5CertificateFingerprint: String + sha1CertificateFingerprint: String + sha256CertificateFingerprint: String + type: AndroidKeystoreType! + updatedAt: DateTime! +} + +input AndroidKeystoreInput { + base64EncodedKeystore: String! + keyAlias: String! + keyPassword: String + keystorePassword: String! +} + +type AndroidKeystoreMutation { + """Create a Keystore""" + createAndroidKeystore(accountId: ID!, androidKeystoreInput: AndroidKeystoreInput!): AndroidKeystore + + """Delete a Keystore""" + deleteAndroidKeystore(id: ID!): DeleteAndroidKeystoreResult! +} + +enum AndroidKeystoreType { + JKS + PKCS12 + UNKNOWN +} + +type AndroidSubmissionConfig { + applicationIdentifier: String @deprecated(reason: "applicationIdentifier is deprecated and will be auto-detected on submit") + releaseStatus: SubmissionAndroidReleaseStatus + rollout: Float + track: String! +} + +input AndroidSubmissionConfigInput { + applicationIdentifier: String + changelog: String + changesNotSentForReview: Boolean + googleServiceAccountKeyId: String + googleServiceAccountKeyJson: String + isVerboseFastlaneEnabled: Boolean + releaseStatus: SubmissionAndroidReleaseStatus + rollout: Float + track: SubmissionAndroidTrack! +} + +"""Represents an Exponent App (or Experience in legacy terms)""" +type App implements Project { + """Coalesced project activity for an app""" + activityTimelineProjectActivities( + """ Offset the query """ + createdBefore: DateTime + filterChannels: [String!] + filterPlatforms: [AppPlatform!] + + """ Types of objects to filter """ + filterTypes: [ActivityTimelineProjectActivityType!] + limit: Int! + ): [ActivityTimelineProjectActivity!]! + + """Android app credentials for the project""" + androidAppCredentials(filter: AndroidAppCredentialsFilter): [AndroidAppCredentials!]! + appStoreConnectApp: AppStoreConnectApp + + """ + Connection status for this project's App Store Connect-triggered workflows. + """ + appStoreConnectWorkflowConnectionStatus: AppStoreConnectWorkflowConnectionStatus! + assetLimitPerUpdateGroup: Int! + branchesPaginated(after: String, before: String, filter: BranchFilterInput, first: Int, last: Int): AppBranchesConnection! + buildProfiles: [String!]! + + """(EAS Build) Builds associated with this app""" + builds( + filter: BuildFilter + limit: Int! + offset: Int! + + """Deprecated, use filter instead""" + platform: AppPlatform + + """Deprecated, use filter instead""" + status: BuildStatus + ): [Build!]! + buildsPaginated(after: String, before: String, filter: BuildFilterInput, first: Int, last: Int): AppBuildsConnection! + channelsPaginated(after: String, before: String, filter: ChannelFilterInput, first: Int, last: Int): AppChannelsConnection! + convexProject: ConvexProject + deployment(channel: String!, runtimeVersion: String!): Deployment + + """Deployments associated with this app""" + deployments(after: String, before: String, filter: DeploymentFilterInput, first: Int, last: Int): DeploymentsConnection! + description: String! @deprecated(reason: "Classic updates have been deprecated.") + devDomainName: AppDevDomainName + + """ + Every tag used by this app's device run sessions, sorted alphabetically. + """ + deviceRunSessionTags: [String!]! + deviceRunSessionsPaginated(after: String, before: String, filter: DeviceRunSessionFilterInput, first: Int, last: Int): AppDeviceRunSessionsConnection! + embeddedUpdatesPaginated(after: String, before: String, filter: EmbeddedUpdateFilterInput, first: Int, last: Int): AppEmbeddedUpdatesConnection! + + """Environment secrets for an app""" + environmentSecrets(filterNames: [String!]): [EnvironmentSecret!]! + environmentVariableEnvironments: [EnvironmentVariableEnvironment!]! + + """Environment variables for an app""" + environmentVariables(environment: EnvironmentVariableEnvironment, filterNames: [String!]): [EnvironmentVariable!]! + + """Environment variables for an app with decrypted secret values""" + environmentVariablesIncludingSensitive(environment: EnvironmentVariableEnvironment, filterNames: [String!]): [EnvironmentVariableWithSecret!]! + fingerprintsPaginated(after: String, before: String, filter: FingerprintFilterInput, first: Int, last: Int): AppFingerprintsConnection! + fullName: String! + githubBuildTriggers: [GitHubBuildTrigger!]! + githubJobRunTriggers: [GitHubJobRunTrigger!]! + githubRepository: GitHubRepository + githubRepositorySettings: GitHubRepositorySettings + + """ + Info about the icon specified in the most recent classic update manifest + """ + icon: AppIcon @deprecated(reason: "Classic updates have been deprecated.") + iconUrl: String @deprecated + id: ID! + + """App query field for querying EAS Insights about this app""" + insights: AppInsights! + internalDistributionBuildPrivacy: AppInternalDistributionBuildPrivacy! + + """iOS app credentials for the project""" + iosAppCredentials(filter: IosAppCredentialsFilter): [IosAppCredentials!]! + + """ + Whether this app qualifies for the observe-notification-lapsed-paid experiment + """ + isEligibleForObserveLapsedPaidNotification: Boolean! + + """ + Tells you if the project can show the Observe promotional notification. This field is part of + the observe-notification experiment. + """ + isEligibleForObserveNotice: Boolean! @deprecated(reason: "This field is deprecated from its first release. It supplies data for the observe-notification promotion on the project page. It will be removed when the promotion ends. Do not rely on it.") + lastDeletionAttemptTime: DateTime + + """Time of the last user activity (update, branch, submission).""" + latestActivity: DateTime! + latestAppVersionByPlatformAndApplicationIdentifier(applicationIdentifier: String!, platform: AppPlatform!): AppVersion + logRocketProject: LogRocketProject + name: String! + observe: AppObserve! + + """ + Whether EAS Observe ingestion is paused for this app. Apps that have not configured the setting + are treated as false (ingestion enabled). An account-level pause takes precedence. + """ + observeIngestionPaused: Boolean! + ownerAccount: Account! + packageName: String! @deprecated + posthogProject: PostHogProject + privacy: String! @deprecated + profileImageUrl(width: AppProfileImageWidth): String + + """Whether there have been any classic update publishes""" + published: Boolean! @deprecated(reason: "Classic updates have been deprecated.") + + """App query field for querying details about an app's push notifications""" + pushNotifications: AppPushNotifications! + pushSecurityEnabled: Boolean! + resourceClassExperiment: ResourceClassExperiment + + """Runtimes associated with this app""" + runtimes(after: String, before: String, filter: RuntimeFilterInput, first: Int, last: Int): RuntimesConnection! + scopeKey: String! + + """SDK version of the latest classic update publish, 0.0.0 otherwise""" + sdkVersion: String! @deprecated(reason: "Classic updates have been deprecated.") + sentryProject: SentryProject + slug: String! + + """EAS Submissions associated with this app""" + submissions(filter: SubmissionFilter!, limit: Int!, offset: Int!): [Submission!]! + submissionsPaginated(after: String, before: String, first: Int, last: Int): AppSubmissionsConnection! + suggestedDevDomainName: String! + supabaseProject: SupabaseProject + + """Coalesced project activity for an app using pagination""" + timelineActivity(after: String, before: String, filter: TimelineActivityFilterInput, first: Int, last: Int): TimelineActivityConnection! + turtleBrownfieldArtifactsPaginated(after: String, before: String, filter: BrownfieldArtifactFilterInput, first: Int, last: Int): BrownfieldArtifactsConnection! + + """get an EAS branch owned by the app by name""" + updateBranchByName(name: String!): UpdateBranch + + """EAS branches owned by an app""" + updateBranches(limit: Int!, offset: Int!): [UpdateBranch!]! + + """get an EAS channel owned by the app by name""" + updateChannelByName(name: String!): UpdateChannel + + """EAS channels owned by an app""" + updateChannels(limit: Int!, offset: Int!): [UpdateChannel!]! + + """EAS updates owned by an app grouped by update group""" + updateGroups(filter: UpdatesFilter, limit: Int!, offset: Int!): [[Update!]!]! + + """Time of last classic update publish""" + updated: DateTime! @deprecated(reason: "Classic updates have been deprecated.") + + """EAS updates owned by an app""" + updates(limit: Int!, offset: Int!): [Update!]! + updatesPaginated(after: String, before: String, filter: UpdateFilterInput, first: Int, last: Int): AppUpdatesConnection! + updatesTimeline(after: String, before: String, filter: UpdatesTimelineFilter, first: Int, last: Int): UpdatesTimelineConnection! + + """Project query object for querying EAS usage metrics""" + usageMetrics: AppUsageMetrics! + username: String! @deprecated(reason: "Use ownerAccount.name instead") + vexoApp: VexoApp + + """Notification preferences of the viewer for this app""" + viewerNotificationPreferences: [NotificationPreferenceItem!]! + + """Webhooks for an app""" + webhooks(filter: WebhookFilter): [Webhook!]! + workerCustomDomain: WorkerCustomDomain + workerDeployment(deploymentIdentifier: WorkerDeploymentIdentifier!): WorkerDeployment + workerDeploymentAlias(aliasName: WorkerDeploymentIdentifier): WorkerDeploymentAlias + workerDeploymentAliases(after: String, before: String, first: Int, last: Int): WorkerDeploymentAliasesConnection! + workerDeployments(after: String, before: String, first: Int, last: Int): WorkerDeploymentsConnection! + workerDeploymentsCrash(crashKey: ID!, sampleFor: CrashSampleFor): WorkerDeploymentCrashEdge! + workerDeploymentsCrashes(filters: CrashesFilters, timespan: DatasetTimespan!): WorkerDeploymentCrashes + workerDeploymentsRequest(requestKey: ID!): WorkerDeploymentRequestEdge! + workerDeploymentsRequests(filters: RequestsFilters, timespan: DatasetTimespan!): WorkerDeploymentRequests + + """ + Caches associated with this app, ordered by last access time (most recent first). + """ + workflowCachesPaginated(after: String, before: String, first: Int, last: Int): AppWorkflowCachesConnection! + + """Per-type caching configuration for this app.""" + workflowCachingConfig: WorkflowCachingConfig! + workflowDeviceTestCaseHistory(filters: WorkflowDeviceTestCaseHistoryFiltersInput, path: String!, timespan: WorkflowDeviceTestCaseInsightsTimespanInput!): WorkflowDeviceTestCaseHistory! + workflowDeviceTestCaseInsights(filters: WorkflowDeviceTestCaseInsightsFiltersInput, timespan: WorkflowDeviceTestCaseInsightsTimespanInput!): WorkflowDeviceTestCaseInsights! + workflowRunGitBranchesPaginated(after: String, before: String, filter: WorkflowRunGitBranchFilterInput, first: Int, last: Int): AppWorkflowRunGitBranchesConnection! + workflowRunsPaginated(after: String, before: String, filter: AppWorkflowRunFilterInput, first: Int, last: Int): AppWorkflowRunsConnection! + workflows(filter: AppWorkflowFilterInput): [Workflow!]! + workflowsInsights: AppWorkflowsInsights! +} + +type AppBranchEdge { + cursor: String! + node: UpdateBranch! +} + +type AppBranchesConnection { + edges: [AppBranchEdge!]! + pageInfo: PageInfo! +} + +type AppBuildEdge { + cursor: String! + node: BuildOrBuildJob! +} + +type AppBuildsConnection { + edges: [AppBuildEdge!]! + pageInfo: PageInfo! +} + +type AppChannelEdge { + cursor: String! + node: UpdateChannel! +} + +type AppChannelsConnection { + edges: [AppChannelEdge!]! + pageInfo: PageInfo! +} + +input AppDataInput { + id: ID! + internalDistributionBuildPrivacy: AppInternalDistributionBuildPrivacy + privacy: String +} + +type AppDevDomainName { + app: App + id: ID! + name: DevDomainName! +} + +type AppDevDomainNameMutation { + """Creates a DevDomainName assigning it to an app""" + assignDevDomainName(appId: ID!, name: DevDomainName!): AppDevDomainName! + + """Updates a DevDomainName for a given app""" + changeDevDomainName(appId: ID!, name: DevDomainName!): AppDevDomainName! +} + +type AppDeviceRunSessionEdge { + cursor: String! + node: DeviceRunSession! +} + +type AppDeviceRunSessionsConnection { + edges: [AppDeviceRunSessionEdge!]! + pageInfo: PageInfo! +} + +type AppEmbeddedUpdateEdge { + cursor: String! + node: EmbeddedUpdate! +} + +type AppEmbeddedUpdatesConnection { + edges: [AppEmbeddedUpdateEdge!]! + pageInfo: PageInfo! +} + +type AppFingerprintEdge { + cursor: String! + node: Fingerprint! +} + +type AppFingerprintsConnection { + edges: [AppFingerprintEdge!]! + pageInfo: PageInfo! +} + +type AppIcon { + originalUrl: String! + primaryColor: String + url: String! +} + +input AppInfoInput { + displayName: String +} + +input AppInput { + accountId: ID! + appInfo: AppInfoInput + projectName: String! +} + +type AppInsights { + hasEventsFromExpoInsightsClientModule: Boolean! + totalUniqueUsers(timespan: InsightsTimespan!): Int + uniqueUsersByAppVersionOverTime(timespan: InsightsTimespan!): UniqueUsersOverTimeData! + uniqueUsersByPlatformOverTime(timespan: InsightsTimespan!): UniqueUsersOverTimeData! +} + +enum AppInternalDistributionBuildPrivacy { + PRIVATE + PUBLIC +} + +type AppMutation { + """Create an app""" + createApp(appInput: AppInput!): App! + + """ + Remove profile image (icon) for the app. Do nothing if there's no profile image associated. + """ + removeProfileImage(appId: ID!): App! + + """ + Delete an App. Returns the ID of the background job receipt. Use BackgroundJobReceiptQuery to get the status of the job. + """ + scheduleAppDeletion(appId: ID!): BackgroundJobReceipt! + + """Set display info for app""" + setAppInfo(appId: ID!, appInfo: AppInfoInput!): App! + + """ + Set whether EAS Observe ingestion is paused for this app. Requires the ADMIN permission on + the owning account. An account-level pause takes precedence over this setting. + """ + setObserveIngestionPaused(appId: ID!, paused: Boolean!): App! + + """Require api token to send push notifs for experience""" + setPushSecurityEnabled(appId: ID!, pushSecurityEnabled: Boolean!): App! + + """Set resource class experiment for app""" + setResourceClassExperiment(appId: ID!, resourceClassExperiment: ResourceClassExperiment): App! + + """Set per-type caching configuration for the app.""" + setWorkflowCachingConfig(appId: ID!, config: WorkflowCachingConfigInput!): App! +} + +"""App-level notification preference""" +type AppNotificationPreference implements NotificationPreference { + app: App! + enabled: Boolean! + event: NotificationEvent! + type: NotificationType! +} + +input AppNotificationPreferenceInput { + appId: ID! + enabled: Boolean! + event: NotificationEvent! + type: NotificationType! +} + +type AppObserve { + appVersions(input: AppObserveReleasesInput!): [AppObserveAppVersion!]! + customEventList(after: String, before: String, filter: AppObserveCustomEventListFilter, first: Int, last: Int, orderBy: AppObserveCustomEventListOrderBy): AppObserveCustomEventListConnection! @deprecated(reason: "Use userEvents.list (or errors.occurrences for exceptions) instead.") + customEventNames( + appBuildNumber: String + appEasBuildId: String + appUpdateId: String + appVersion: String + endTime: DateTime! + environment: String + isEmbeddedUpdate: Boolean + orderBy: AppObserveCustomEventNamesOrderBy + platform: AppObservePlatform + + """Filter to these platforms. Cannot be set together with platform.""" + platforms: [AppObservePlatform!] + startTime: DateTime! + ): AppObserveCustomEventNames! @deprecated(reason: "Use userEvents.names instead.") + + """ + Namespaced successor API (ENG-26160). Prefer over the flat `updates` field. + """ + easUpdates: AppObserveEasUpdates! + environments( + endTime: DateTime + platform: AppObservePlatform + + """Filter to these platforms. Cannot be set together with platform.""" + platforms: [AppObservePlatform!] + startTime: DateTime + ): [String!]! + + """ + Distinct unhandled-JS-error groups (the issues list), grouped by fingerprint. + """ + errorGroups(input: AppObserveErrorGroupsInput!): AppObserveErrorGroups! @deprecated(reason: "Use errors.groups instead.") + + """ + Headline error stats for the overview: crash-free rates and error/affected counts. + """ + errorStats(input: AppObserveErrorStatsInput!): AppObserveErrorStats! @deprecated(reason: "Use errors.stats instead.") + + """ + Namespaced successor API (ENG-26160). Prefer over the flat `errorGroups`/`errorStats` fields. + """ + errors: AppObserveErrors! + events(after: String, before: String, filter: AppObserveEventsFilter, first: Int, last: Int, orderBy: AppObserveEventsOrderBy): AppObserveEventsConnection! @deprecated(reason: "Use metrics.list instead.") + + """ + Highest Expo SDK version seen in telemetry over the trailing 30 days; null when none was reported. + """ + latestExpoSdkVersion: String + + """ + A single log (user-defined event or error) by `event_id`. Null when missing or aged out of retention. + """ + log(id: ID!): AppObserveLog + + """ + Namespaced successor API (ENG-26160). Prefer over the flat `timeSeries`/`events` fields. + """ + metrics: AppObserveMetrics! + + """ + Namespaced successor API (ENG-26160). Prefer over the flat `navigationRoutes` field. + """ + navigation: AppObserveNavigation! + navigationRoutes(after: String, before: String, filter: AppObserveNavigationRoutesFilter!, first: Int, last: Int, orderBy: AppObserveNavigationRoutesOrderBy): AppObserveNavigationRoutesConnection! @deprecated(reason: "Use navigation.routes instead.") + + """ + Namespaced successor API (ENG-26160). Prefer over the flat `overview*` fields. + """ + overview: AppObserveOverview! + + """ + Active users and sessions for the Overview engagement band, across all versions. + """ + overviewEngagement(input: AppObserveOverviewEngagementInput!): AppObserveOverviewEngagement! @deprecated(reason: "Use overview.engagement instead.") + + """ + Crash-free rates with previous-period comparison for the Overview stability tiles. + """ + overviewStability(input: AppObserveOverviewStabilityInput!): AppObserveOverviewStability! @deprecated(reason: "Use overview.stability instead.") + + """ + Per-version, per-platform metric summaries for the Overview version-comparison matrix. + """ + overviewVersionComparison(input: AppObserveOverviewVersionComparisonInput!): AppObserveOverviewVersionComparison! @deprecated(reason: "Use overview.versionComparison instead.") + + """ + One session's metrics and logs for the timeline (ENG-26160). Unknown session ids yield empty lists. + """ + session(id: ID!): AppObserveSession! + timeSeries(input: AppObserveTimeSeriesInput!): AppObserveTimeSeries! @deprecated(reason: "Use metrics.timeSeries instead.") + totalEventCount: Int! + + """ + Approximate count of unique users (`eas_client_id`) with at least one + supported-metric event in `[startTime, endTime)`. Uses a ClickHouse + HyperLogLog sketch, so the value can drift by a small percent on apps + with very large user bases. `metricNames` on the input is currently + ignored: the count is always over all supported metrics, matching the + universe used by `appVersions` headline counts. + """ + uniqueActiveUserCount(input: AppObserveReleasesInput!): Int! + + """ + Update download activity (the Recent updates list). Not available on the free tier or legacy plans. + """ + updates(input: AppObserveUpdatesInput!): AppObserveUpdatesConnection! @deprecated(reason: "Use easUpdates.list instead.") + + """ + Namespaced successor API (ENG-26160). Prefer over the flat `customEventNames`/`customEventList` fields. + """ + userEvents: AppObserveUserEvents! +} + +type AppObserveAppBuildEmbeddedSummary { + eventCount: Int! + firstSeenAt: DateTime! + + """ + Unique users whose most recent supported-metric event in the queried + range ran this build's embedded bundle. + """ + lastSeenUserCount: Int! + uniqueUserCount: Int! +} + +type AppObserveAppBuildNumber { + appBuildNumber: String! + easBuilds: [AppObserveAppEasBuild!]! + + """ + Summary restricted to events running this build's embedded bundle + (no OTA update applied). Null when the build has no such events + in the queried time range. + """ + embedded: AppObserveAppBuildEmbeddedSummary + eventCount: Int! + firstSeenAt: DateTime! + + """ + Unique users whose most recent supported-metric event in the queried + range was on this (appVersion, appBuildNumber) tuple. + """ + lastSeenUserCount: Int! + uniqueUserCount: Int! + + """ + Updates seen on this build number in the queried time range. Each + entry's counts and EAS builds are scoped to this (appVersion, + appBuildNumber, appUpdateId) tuple. + """ + updates: [AppObserveAppUpdate!]! +} + +type AppObserveAppEasBuild { + easBuildId: String! + eventCount: Int! + firstSeenAt: DateTime! + uniqueUserCount: Int! +} + +type AppObserveAppUpdate { + appUpdateId: String! + appUpdateMessage: String + easBuilds: [AppObserveAppEasBuild!]! + eventCount: Int! + firstSeenAt: DateTime! + + """ + Unique users whose most recent supported-metric event in the queried + range matched this update at its current nesting level. + """ + lastSeenUserCount: Int! + uniqueUserCount: Int! +} + +type AppObserveAppVersion { + appVersion: String! + buildNumbers: [AppObserveAppBuildNumber!]! + eventCount: Int! + firstSeenAt: DateTime! + + """ + Unique users whose most recent supported-metric event in + `[startTime, endTime)` was on this version. Each active user + contributes to exactly one version, so summing this field across the + returned versions yields the total active users in the window (equal + to `AppObserve.uniqueActiveUserCount` up to HyperLogLog noise). + A version may be returned with `lastSeenUserCount = 0` when + every user who touched it later moved to a newer version. Users with + no events in the window are not counted on any version. + """ + lastSeenUserCount: Int! + metrics: [AppObserveAppVersionMetric!]! + uniqueUserCount: Int! + updates: [AppObserveAppUpdate!]! +} + +type AppObserveAppVersionMetric { + eventCount: Int! + metricName: String! + statistics: AppObserveVersionMarkerStatistics! +} + +type AppObserveCustomEvent { + appBuildNumber: String! + appEasBuildId: String + appIdentifier: String! + appUpdateId: String + appUpdateMessage: String + appVersion: String! + body: String + clientVersion: String + countryCode: String + deviceLanguageTag: String + deviceModel: String! + deviceOs: String! + deviceOsVersion: String! + + """ + Human-friendly label from the expo.log.display_name attribute; null when the event was logged without one. + """ + displayName: String + easClientId: String! + environment: String + errorFingerprint: String + errorSource: String + eventName: String! + exceptionMessage: String + + """ + Symbolicated when a build or update with a matching source map exists; otherwise as reported. + """ + exceptionStacktrace: String + + """ + Error fields below are populated only for exception events (eventName: exception). + """ + exceptionType: String + expoSdkVersion: String + id: ID! + ingestedAt: DateTime + isFatal: Boolean + properties: [AppObserveEventProperty!]! + reactNativeVersion: String + sessionId: String + severityNumber: Int + severityText: String + + """ + Build whose uploaded source map symbolicated exceptionStacktrace; null when the stack trace is shown as reported or was symbolicated from an update. + """ + symbolicationBuild: Build + + """ + Update whose uploaded source map symbolicated exceptionStacktrace; null when the stack trace is shown as reported or was symbolicated from a build. + """ + symbolicationUpdate: Update + timestamp: DateTime! +} + +type AppObserveCustomEventEdge { + cursor: String! + node: AppObserveCustomEvent! +} + +type AppObserveCustomEventListConnection { + edges: [AppObserveCustomEventEdge!]! + pageInfo: PageInfo! +} + +input AppObserveCustomEventListFilter { + appBuildNumber: String + appEasBuildId: String + appUpdateId: String + appVersion: String + countryCode: String + deviceModel: String + deviceOsVersion: String + easClientId: String + endTime: DateTime + environment: String + + """ + Restrict to one error group. Combine with the exception eventName to list a group's occurrences. + """ + errorFingerprint: String + eventName: String + isEmbeddedUpdate: Boolean + platform: AppObservePlatform + + """Filter to these platforms. Cannot be set together with platform.""" + platforms: [AppObservePlatform!] + propertyFilters: [AppObserveCustomEventPropertyFilter!] + sessionId: String + startTime: DateTime +} + +input AppObserveCustomEventListOrderBy { + direction: AppObserveEventsOrderByDirection! + field: AppObserveCustomEventListOrderByField! +} + +enum AppObserveCustomEventListOrderByField { + TIMESTAMP +} + +type AppObserveCustomEventName { + count: Int! + eventName: String! +} + +type AppObserveCustomEventNames { + isTruncated: Boolean! + names: [AppObserveCustomEventName!]! +} + +input AppObserveCustomEventNamesOrderBy { + direction: AppObserveCustomEventNamesOrderByDirection! + field: AppObserveCustomEventNamesOrderByField! +} + +enum AppObserveCustomEventNamesOrderByDirection { + ASC + DESC +} + +enum AppObserveCustomEventNamesOrderByField { + COUNT + EVENT_NAME +} + +input AppObserveCustomEventPropertyFilter { + key: String! + value: String! +} + +""" +EAS Update download activity (app_metrics download samples plus update metadata). +""" +type AppObserveEasUpdates { + """ + Updates with download statistics. Forward pagination only. Defaults to newest first. + """ + list(after: String, filter: AppObserveEasUpdatesFilter!, first: Int, orderBy: AppObserveEasUpdatesOrderBy): AppObserveUpdatesConnection! +} + +input AppObserveEasUpdatesFilter { + appBuildNumber: String + appUpdateId: String + appVersion: String + endTime: DateTime! + environment: String + + """Filter to these platforms.""" + platforms: [AppObservePlatform!] + startTime: DateTime! +} + +input AppObserveEasUpdatesOrderBy { + direction: AppObserveOrderDirection! + field: AppObserveUpdatesOrderByField! +} + +input AppObserveEngagementInput { + """Series bucket size. Defaults to one day.""" + bucketIntervalMinutes: Int + endTime: DateTime! + environment: String + + """Filter to these platforms.""" + platforms: [AppObservePlatform!] + startTime: DateTime! +} + +"""One error or crash (an app_events *.exception row).""" +type AppObserveError implements AppObserveLog { + appBuildNumber: String! + appEasBuildId: String + appIdentifier: String! + appUpdateId: String + appUpdateMessage: String + appVersion: String! + body: String + clientVersion: String + countryCode: String + deviceLanguageTag: String + deviceModel: String! + deviceOs: String! + deviceOsVersion: String! + easClientId: String! + environment: String + + """Raw log event name, e.g. exception, js.exception, native.exception.""" + eventName: String! + expoSdkVersion: String + fingerprint: String + id: ID! + ingestedAt: DateTime + isFatal: Boolean + kind: AppObserveErrorKind! + message: String + properties: [AppObserveEventProperty!]! + reactNativeVersion: String + sessionId: String + severityNumber: Int + severityText: String + source: String + + """ + Symbolicated when a build or update with a matching source map exists; otherwise as reported. + """ + stacktrace: String + + """ + Build whose uploaded source map symbolicated stacktrace; null when the stack trace is shown as reported or was symbolicated from an update. + """ + symbolicationBuild: Build + + """ + Update whose uploaded source map symbolicated stacktrace; null when the stack trace is shown as reported or was symbolicated from a build. + """ + symbolicationUpdate: Update + timestamp: DateTime! + type: String +} + +type AppObserveErrorBreakdownBucket { + count: Int! + + """ + Dimension value (app version, OS, device model, or country code). Empty string when unknown. + """ + key: String! + uniqueUserCount: Int! +} + +enum AppObserveErrorBreakdownDimension { + APP_VERSION + COUNTRY + DEVICE + OS +} + +type AppObserveErrorConnection { + edges: [AppObserveErrorEdge!]! + pageInfo: PageInfo! +} + +type AppObserveErrorEdge { + cursor: String! + node: AppObserveError! +} + +type AppObserveErrorGroup { + affectedSessionCount: Int! + + """ + How the error was captured, from the most recent occurrence (e.g. global). + """ + errorSource: String + eventCount: Int! + + """Error message, from the most recent occurrence.""" + exceptionMessage: String + + """Error class/type, from the most recent occurrence (e.g. TypeError).""" + exceptionType: String + + """ + Stable grouping key (sha256 over error kind + error type + entropy-normalized message). + """ + fingerprint: String! + firstSeenAt: DateTime! + isFatal: Boolean! + + """Derived from the event name of the most recent occurrence.""" + kind: AppObserveErrorKind! + lastSeenAt: DateTime! + + """ + Per-platform occurrence and unique-user counts, sorted by eventCount descending. Covers every device OS seen (iOS, Android, iPadOS, ...), so the counts sum to the group totals. + """ + platformCounts: [AppObserveErrorGroupPlatformCount!]! + + """Distinct device OS values seen for this group (e.g. Android, iOS).""" + platforms: [String!]! + + """FATAL if any occurrence in the group was fatal, otherwise ERROR.""" + severity: AppObserveErrorSeverity! + + """ + Occurrences over the query window, bucketed ascending. Sparse (empty buckets omitted); the client fills gaps. Renders as the list sparkline. Bucket width is set via AppObserveErrorGroupsInput.bucketIntervalMinutes. + """ + timeSeries: [AppObserveErrorTimeSeriesBucket!]! + uniqueUserCount: Int! +} + +type AppObserveErrorGroupBreakdown { + """Buckets ordered by count descending.""" + buckets: [AppObserveErrorBreakdownBucket!]! + + """True when more buckets exist than were returned (top-N truncation).""" + isTruncated: Boolean! +} + +""" +Filters for the per-group breakdown. Same release/time/platform filters used across Observe. +""" +input AppObserveErrorGroupBreakdownInput { + appBuildNumber: String + appEasBuildId: String + appUpdateId: String + appVersion: String + dimension: AppObserveErrorBreakdownDimension! + endTime: DateTime! + environment: String + fingerprint: String! + isEmbeddedUpdate: Boolean + + """ + Restrict to one error kind (JS errors, native crashes, or other *.exception events). + """ + kind: AppObserveErrorKind + platform: AppObservePlatform + + """Filter to these platforms. Cannot be set together with platform.""" + platforms: [AppObservePlatform!] + startTime: DateTime! +} + +type AppObserveErrorGroupPlatformCount { + eventCount: Int! + + """Device OS value, e.g. iOS, Android, iPadOS.""" + platform: String! + uniqueUserCount: Int! +} + +type AppObserveErrorGroups { + groups: [AppObserveErrorGroup!]! + + """True when more groups exist than were returned (top-N truncation).""" + isTruncated: Boolean! +} + +input AppObserveErrorGroupsInput { + appBuildNumber: String + appEasBuildId: String + appUpdateId: String + appVersion: String + + """ + Bucket width in minutes for the per-group timeSeries field. Only used when timeSeries is selected. Defaults to 1440 (daily). + """ + bucketIntervalMinutes: Int + endTime: DateTime! + environment: String + + """ + Restrict to a single group, e.g. to fetch one group's header on the detail page. + """ + fingerprint: String + isEmbeddedUpdate: Boolean + + """ + Restrict to one error kind (JS errors, native crashes, or other *.exception events). + """ + kind: AppObserveErrorKind + orderBy: AppObserveErrorGroupsOrderBy + platform: AppObservePlatform + + """Filter to these platforms. Cannot be set together with platform.""" + platforms: [AppObservePlatform!] + + """Restrict to fatal-only or non-fatal-only errors.""" + severity: AppObserveErrorSeverity + + """Restrict to a capture source (e.g. global).""" + source: String + startTime: DateTime! +} + +enum AppObserveErrorGroupsOrderBy { + FIRST_SEEN + LAST_SEEN + MOST_FREQUENT + MOST_USERS +} + +""" +JS covers both the legacy exception name and js.exception. NATIVE is native.exception. OTHER is any other *.exception event. +""" +enum AppObserveErrorKind { + JS + NATIVE + OTHER +} + +input AppObserveErrorOccurrencesFilter { + appBuildNumber: String + appEasBuildId: String + appUpdateId: String + appVersion: String + countryCode: String + deviceModel: String + easClientId: String + endTime: DateTime + environment: String + + """Restrict to one error group.""" + fingerprint: String + isEmbeddedUpdate: Boolean + + """ + Restrict to one error kind (JS errors, native crashes, or other *.exception events). + """ + kind: AppObserveErrorKind + + """Filter to these platforms.""" + platforms: [AppObservePlatform!] + sessionId: String + startTime: DateTime +} + +input AppObserveErrorOccurrencesOrderBy { + direction: AppObserveOrderDirection! + field: AppObserveErrorOccurrencesOrderByField! +} + +enum AppObserveErrorOccurrencesOrderByField { + TIMESTAMP +} + +"""AppObserveErrorStats for one platform; iOS includes iPadOS and tvOS.""" +type AppObserveErrorPlatformStats { + """Distinct sessions on this platform that hit any exception.""" + affectedSessions: Int! + + """Distinct users on this platform that hit any exception.""" + affectedUsers: Int! + + """ + Fraction (0..1) of this platform's active sessions without a fatal error. + """ + crashFreeSessions: Float! + + """Fraction (0..1) of this platform's active users without a fatal error.""" + crashFreeUsers: Float! + fatalCount: Int! + nonFatalCount: Int! + platform: AppObservePlatform! + + """Total exception events in range on this platform.""" + totalErrors: Int! +} + +enum AppObserveErrorSeverity { + ERROR + FATAL +} + +type AppObserveErrorStats { + """Distinct sessions that hit any exception.""" + affectedSessions: Int! + + """Distinct users that hit any exception.""" + affectedUsers: Int! + + """ + Fraction (0..1) of active sessions without a fatal error. Denominator spans app_metrics and app_events. + """ + crashFreeSessions: Float! + + """Fraction (0..1) of active users without a fatal error.""" + crashFreeUsers: Float! + fatalCount: Int! + nonFatalCount: Int! + + """Total exception events in range.""" + totalErrors: Int! +} + +""" +Filters for the error-stats header. Same release/time/platform filters used across Observe. +""" +input AppObserveErrorStatsInput { + appBuildNumber: String + appEasBuildId: String + appUpdateId: String + appVersion: String + endTime: DateTime! + environment: String + isEmbeddedUpdate: Boolean + + """ + Restrict to one error kind (JS errors, native crashes, or other *.exception events). + """ + kind: AppObserveErrorKind + platform: AppObservePlatform + + """Filter to these platforms. Cannot be set together with platform.""" + platforms: [AppObservePlatform!] + startTime: DateTime! +} + +type AppObserveErrorTimeSeries { + buckets: [AppObserveErrorTimeSeriesBucket!]! + + """Sum of fatalCount across all buckets.""" + totalFatalCount: Int! + + """Sum of nonFatalCount across all buckets.""" + totalNonFatalCount: Int! +} + +type AppObserveErrorTimeSeriesBucket { + """ + Approximate count of unique affected users in this bucket. Not summable across buckets. + """ + affectedUsers: Int! + + """Start of the bucket interval.""" + bucket: DateTime! + fatalCount: Int! + nonFatalCount: Int! +} + +""" +Filters for the error time series. Same release/time/platform filters used across Observe, plus the bucket size. +""" +input AppObserveErrorTimeSeriesInput { + appBuildNumber: String + appEasBuildId: String + appUpdateId: String + appVersion: String + + """Bucket width in minutes. Defaults to 60.""" + bucketIntervalMinutes: Int + endTime: DateTime! + environment: String + + """ + Restrict to one error group for the detail page's occurrences-over-time chart. Omit for the overview chart. + """ + fingerprint: String + isEmbeddedUpdate: Boolean + + """ + Restrict to one error kind (JS errors, native crashes, or other *.exception events). + """ + kind: AppObserveErrorKind + platform: AppObservePlatform + + """Filter to these platforms. Cannot be set together with platform.""" + platforms: [AppObservePlatform!] + startTime: DateTime! +} + +"""JS errors and native crashes: app_events *.exception rows.""" +type AppObserveErrors { + """Breaks one error group down by app version, OS, device, or country.""" + breakdown(input: AppObserveErrorsBreakdownInput!): AppObserveErrorGroupBreakdown! + + """ + A single error or crash by id. Null when missing, aged out, or the id belongs to a user-defined event. + """ + error(id: ID!): AppObserveError + + """Distinct error groups (the issues list), grouped by fingerprint.""" + groups(input: AppObserveErrorsGroupsInput!): AppObserveErrorGroups! + + """Individual error occurrences. Defaults to newest first.""" + occurrences(after: String, before: String, filter: AppObserveErrorOccurrencesFilter, first: Int, last: Int, orderBy: AppObserveErrorOccurrencesOrderBy): AppObserveErrorConnection! + + """Headline error stats: crash-free rates and error/affected counts.""" + stats(input: AppObserveErrorsStatsInput!): AppObserveErrorStats! + + """Time-bucketed exception counts split into fatal vs non-fatal.""" + timeSeries(input: AppObserveErrorsTimeSeriesInput!): AppObserveErrorTimeSeries! +} + +input AppObserveErrorsBreakdownInput { + appBuildNumber: String + appEasBuildId: String + appUpdateId: String + appVersion: String + countryCode: String + deviceModel: String + dimension: AppObserveErrorBreakdownDimension! + endTime: DateTime! + environment: String + fingerprint: String! + isEmbeddedUpdate: Boolean + + """ + Restrict to one error kind (JS errors, native crashes, or other *.exception events). + """ + kind: AppObserveErrorKind + + """Filter to these platforms.""" + platforms: [AppObservePlatform!] + startTime: DateTime! +} + +input AppObserveErrorsGroupsInput { + appBuildNumber: String + appEasBuildId: String + appUpdateId: String + appVersion: String + + """Bucket size for each group's timeSeries. Defaults to daily.""" + bucketIntervalMinutes: Int + countryCode: String + deviceModel: String + endTime: DateTime! + environment: String + + """Restrict to one error group.""" + fingerprint: String + isEmbeddedUpdate: Boolean + + """ + Restrict to one error kind (JS errors, native crashes, or other *.exception events). + """ + kind: AppObserveErrorKind + orderBy: AppObserveErrorGroupsOrderBy + + """Filter to these platforms.""" + platforms: [AppObservePlatform!] + severity: AppObserveErrorSeverity + source: String + startTime: DateTime! +} + +input AppObserveErrorsStatsInput { + appBuildNumber: String + appEasBuildId: String + appUpdateId: String + appVersion: String + countryCode: String + deviceModel: String + endTime: DateTime! + environment: String + isEmbeddedUpdate: Boolean + + """ + Restrict to one error kind (JS errors, native crashes, or other *.exception events). + """ + kind: AppObserveErrorKind + + """Filter to these platforms.""" + platforms: [AppObservePlatform!] + startTime: DateTime! +} + +input AppObserveErrorsTimeSeriesInput { + appBuildNumber: String + appEasBuildId: String + appUpdateId: String + appVersion: String + bucketIntervalMinutes: Int + countryCode: String + deviceModel: String + endTime: DateTime! + environment: String + + """Restrict to one error group.""" + fingerprint: String + isEmbeddedUpdate: Boolean + + """ + Restrict to one error kind (JS errors, native crashes, or other *.exception events). + """ + kind: AppObserveErrorKind + + """Filter to these platforms.""" + platforms: [AppObservePlatform!] + startTime: DateTime! +} + +type AppObserveEvent { + appBuildNumber: String! + appEasBuildId: String + appIdentifier: String! + appName: String! + appUpdateId: String + appUpdateMessage: String + appVersion: String! + clientVersion: String + countryCode: String + customParams: JSON + deviceLanguageTag: String + deviceModel: String! + deviceName: String + deviceOs: String! + deviceOsVersion: String! + easClientId: String! + environment: String + eventBatchId: ID! + expoSdkVersion: String + id: ID! + ingestedAt: DateTime + metricName: String! + metricValue: Float! + parentSessionId: String + reactNativeVersion: String + routeName: String + sessionEventCount: Int + sessionId: String + tags: JSON! + timestamp: DateTime! + userEventCount: Int +} + +type AppObserveEventEdge { + cursor: String! + node: AppObserveEvent! +} + +type AppObserveEventProperty { + key: String! + type: AppObservePropertyType! + value: String! +} + +type AppObserveEventsConnection { + edges: [AppObserveEventEdge!]! + pageInfo: PageInfo! +} + +input AppObserveEventsFilter { + appBuildNumber: String + appEasBuildId: String + + """ + Filter by the update the device was *running* at event time (the app_update_id column). + """ + appUpdateId: String + appVersion: String + + """ + Filter by the update that was *downloaded* (the expo.update_id tag), as surfaced by the EAS Update Recent updates download drill-down. Distinct from appUpdateId (running update); the two are not interchangeable. + """ + downloadedUpdateId: String + easClientId: String + endTime: DateTime + environment: String + isEmbeddedUpdate: Boolean + metricName: String + platform: AppObservePlatform + + """Filter to these platforms. Cannot be set together with platform.""" + platforms: [AppObservePlatform!] + routeName: String + sessionId: String + startTime: DateTime +} + +input AppObserveEventsOrderBy { + direction: AppObserveEventsOrderByDirection! + field: AppObserveEventsOrderByField! +} + +enum AppObserveEventsOrderByDirection { + ASC + DESC +} + +enum AppObserveEventsOrderByField { + METRIC_VALUE + TIMESTAMP +} + +"""Any app_events log row: a user-defined event or an error.""" +interface AppObserveLog { + appBuildNumber: String! + appEasBuildId: String + appIdentifier: String! + appUpdateId: String + appUpdateMessage: String + appVersion: String! + body: String + clientVersion: String + countryCode: String + deviceLanguageTag: String + deviceModel: String! + deviceOs: String! + deviceOsVersion: String! + easClientId: String! + environment: String + expoSdkVersion: String + id: ID! + ingestedAt: DateTime + properties: [AppObserveEventProperty!]! + reactNativeVersion: String + sessionId: String + severityNumber: Int + severityText: String + timestamp: DateTime! +} + +type AppObserveLogConnection { + edges: [AppObserveLogEdge!]! + pageInfo: PageInfo! +} + +type AppObserveLogEdge { + cursor: String! + node: AppObserveLog! +} + +input AppObserveLogsOrderBy { + direction: AppObserveOrderDirection! + field: AppObserveLogsOrderByField! +} + +enum AppObserveLogsOrderByField { + TIMESTAMP +} + +"""One performance-metric sample (a row in app_metrics).""" +type AppObserveMetric { + appBuildNumber: String! + appEasBuildId: String + appIdentifier: String! + appName: String! + appUpdateId: String + appUpdateMessage: String + appVersion: String! + clientVersion: String + countryCode: String + customParams: JSON + deviceLanguageTag: String + deviceModel: String! + deviceName: String + deviceOs: String! + deviceOsVersion: String! + easClientId: String! + environment: String + eventBatchId: ID! + expoSdkVersion: String + id: ID! + ingestedAt: DateTime + name: String! + parentSessionId: String + reactNativeVersion: String + routeName: String + sessionEventCount: Int + sessionId: String + tags: JSON! + timestamp: DateTime! + userEventCount: Int + value: Float! +} + +type AppObserveMetricConnection { + edges: [AppObserveMetricEdge!]! + pageInfo: PageInfo! +} + +type AppObserveMetricEdge { + cursor: String! + node: AppObserveMetric! +} + +input AppObserveMetricTimeSeriesInput { + appBuildNumber: String + appEasBuildId: String + appUpdateId: String + appVersion: String + bucketIntervalMinutes: Int + endTime: DateTime! + environment: String + isEmbeddedUpdate: Boolean + + """Metric name, e.g. `expo.app_startup.tti`.""" + name: String! + + """Filter to these platforms.""" + platforms: [AppObservePlatform!] + routeName: String + startTime: DateTime! +} + +""" +Performance metrics (the app_metrics table): startup, navigation, and update-download samples. +""" +type AppObserveMetrics { + """Individual metric samples. Defaults to newest first.""" + list(after: String, before: String, filter: AppObserveMetricsListFilter, first: Int, last: Int, orderBy: AppObserveMetricsListOrderBy): AppObserveMetricConnection! + + """ + A single metric sample by id. Null when missing or aged out; sessionEventCount/userEventCount are always null here. + """ + metric(id: ID!): AppObserveMetric + + """Time-bucketed aggregates for one metric, with app-version markers.""" + timeSeries(input: AppObserveMetricTimeSeriesInput!): AppObserveTimeSeries! +} + +input AppObserveMetricsListFilter { + appBuildNumber: String + appEasBuildId: String + + """ + Filter by the update the device was *running* at sample time (the app_update_id column). + """ + appUpdateId: String + appVersion: String + + """ + Filter by the update that was *downloaded* (the expo.update_id tag). Distinct from appUpdateId (running update); the two are not interchangeable. + """ + downloadedUpdateId: String + easClientId: String + endTime: DateTime + environment: String + isEmbeddedUpdate: Boolean + + """Metric name, e.g. `expo.app_startup.tti`.""" + name: String + + """Filter to these platforms.""" + platforms: [AppObservePlatform!] + routeName: String + sessionId: String + startTime: DateTime +} + +input AppObserveMetricsListOrderBy { + direction: AppObserveOrderDirection! + field: AppObserveMetricsListOrderByField! +} + +enum AppObserveMetricsListOrderByField { + TIMESTAMP + VALUE +} + +"""Navigation performance per route (app_metrics navigation samples).""" +type AppObserveNavigation { + """Per-route navigation summaries.""" + routes(after: String, before: String, filter: AppObserveNavigationFilter!, first: Int, last: Int, orderBy: AppObserveNavigationOrderBy): AppObserveNavigationRoutesConnection! +} + +input AppObserveNavigationFilter { + appBuildNumber: String + appEasBuildId: String + appUpdateId: String + appVersion: String + endTime: DateTime! + environment: String + + """Filter to these platforms.""" + platforms: [AppObservePlatform!] + routeNames: [String!] + startTime: DateTime! +} + +input AppObserveNavigationOrderBy { + direction: AppObserveOrderDirection! + field: AppObserveNavigationRoutesOrderByField! +} + +""" +Per-route navigation timing breakdown. The "Navigations" count shown in the +dashboard is `coldTtr.count` (one cold-TTR event per user navigation). +Per-metric `median`/`p90` are null when `count` is 0 so the client can +distinguish "no data" from "0ms". +""" +type AppObserveNavigationRoute { + coldTtr: AppObserveNavigationStat! + routeName: String! + tti: AppObserveNavigationStat! + warmTtr: AppObserveNavigationStat! +} + +type AppObserveNavigationRouteEdge { + cursor: String! + node: AppObserveNavigationRoute! +} + +type AppObserveNavigationRoutesConnection { + edges: [AppObserveNavigationRouteEdge!]! + pageInfo: PageInfo! +} + +input AppObserveNavigationRoutesFilter { + appBuildNumber: String + appEasBuildId: String + appUpdateId: String + appVersion: String + endTime: DateTime! + environment: String + platform: AppObservePlatform + + """Filter to these platforms. Cannot be set together with platform.""" + platforms: [AppObservePlatform!] + routeNames: [String!] + startTime: DateTime! +} + +input AppObserveNavigationRoutesOrderBy { + direction: AppObserveEventsOrderByDirection! + field: AppObserveNavigationRoutesOrderByField! +} + +enum AppObserveNavigationRoutesOrderByField { + MEDIAN_COLD_TTR + MEDIAN_TTI + MEDIAN_WARM_TTR + NAVIGATION_COUNT + P90_COLD_TTR + P90_TTI + P90_WARM_TTR + ROUTE_NAME +} + +type AppObserveNavigationStat { + count: Int! + median: Float + p90: Float +} + +enum AppObserveOrderDirection { + ASC + DESC +} + +""" +Overview-tab aggregates, across all versions unless a comparison narrows them. +""" +type AppObserveOverview { + """Active users and sessions for the engagement band.""" + engagement(input: AppObserveEngagementInput!): AppObserveOverviewEngagement! + + """ + Crash-free rates with previous-period comparison for the stability tiles. + """ + stability(input: AppObserveStabilityInput!): AppObserveOverviewStability! + + """ + Per-update comparison within one app version: the embedded bundle plus each OTA update. + """ + updateComparison(input: AppObserveOverviewUpdateComparisonInput!): AppObserveOverviewUpdateComparison! + + """ + Per-version, per-platform metric summaries for the version-comparison matrix. + """ + versionComparison(input: AppObserveOverviewVersionComparisonInput!): AppObserveOverviewVersionComparison! +} + +type AppObserveOverviewAllReleases { + """ + One entry per (metric, platform) with data in range; one entry per exact device platform. + """ + metrics: [AppObserveOverviewVersionMetric!]! + uniqueUserCount: Int! +} + +type AppObserveOverviewEngagement { + activeUsers: AppObserveOverviewEngagementStat! + sessions: AppObserveOverviewEngagementStat! +} + +type AppObserveOverviewEngagementBucket { + bucketStart: DateTime! + count: Int! +} + +""" +Engagement-band filters. No release filters: the band always spans all versions. +""" +input AppObserveOverviewEngagementInput { + """Series bucket size. Defaults to one day.""" + bucketIntervalMinutes: Int + endTime: DateTime! + environment: String + platform: AppObservePlatform + + """Filter to these platforms. Cannot be set together with platform.""" + platforms: [AppObservePlatform!] + startTime: DateTime! +} + +type AppObserveOverviewEngagementStat { + """ + Total for the equal-length window before startTime. Null when the window is longer than 45 days, since the previous period would fall outside the 90-day retention. + """ + previousPeriodTotal: Int + + """Per-bucket approximate uniques; buckets do not sum to `total`.""" + series: [AppObserveOverviewEngagementBucket!]! + total: Int! +} + +type AppObserveOverviewStability { + """Fatal exception events in range.""" + crashCount: Int! + + """Fraction (0..1) of active sessions without a fatal error.""" + crashFreeSessions: Float! + + """Fraction (0..1) of active users without a fatal error.""" + crashFreeUsers: Float! + + """Distinct users that hit a fatal error in range.""" + crashedUsers: Int! + + """ + Fatal exception events in the equal-length window before startTime. Null when the window is longer than 45 days. + """ + previousPeriodCrashCount: Int + + """ + Same fractions for the equal-length window before startTime; null when it had no activity, or when the window is longer than 45 days and the previous period falls outside the 90-day retention. + """ + previousPeriodCrashFreeSessions: Float + previousPeriodCrashFreeUsers: Float + + """Null when the window is longer than 45 days.""" + previousPeriodCrashedUsers: Int + series: [AppObserveOverviewStabilityBucket!]! + + """ + All exception events in range, for distinguishing quiet apps from crash-free ones. + """ + totalErrors: Int! +} + +type AppObserveOverviewStabilityBucket { + """ + Approximate unique users that hit any exception in this bucket. Not summable across buckets. + """ + affectedUsers: Int! + bucketStart: DateTime! + crashCount: Int! +} + +""" +Stability-tile filters. No release filters: the tiles always span all versions. +""" +input AppObserveOverviewStabilityInput { + """Series bucket size. Defaults to one day.""" + bucketIntervalMinutes: Int + endTime: DateTime! + environment: String + platform: AppObservePlatform + + """Filter to these platforms. Cannot be set together with platform.""" + platforms: [AppObservePlatform!] + startTime: DateTime! +} + +type AppObserveOverviewUpdate { + """Null for the embedded bundle.""" + appUpdateId: ID + + """ + Downloads recorded in range across platforms; null when none were recorded + (and always for the embedded bundle). Download data is paid-only: on the + free tier and legacy plans this is always null. + """ + downloadCount: Int + + """ + One entry per platform with downloads in range; empty for the embedded bundle and on the free tier and legacy plans. + """ + downloads: [AppObserveOverviewUpdateDownload!]! + eventCount: Int! + firstSeenAt: DateTime! + isEmbedded: Boolean! + + """ + EAS update message; null for the embedded bundle or when the update row no longer exists. + """ + message: String + + """One entry per (metric, platform) with data in range.""" + metrics: [AppObserveOverviewUpdateMetric!]! + + """When the update was published; null for the embedded bundle.""" + publishedAt: DateTime + + """ + Error stats for this update, one entry per platform with activity; iOS includes iPadOS and tvOS. + """ + stability: [AppObserveErrorPlatformStats!]! + uniqueUserCount: Int! +} + +type AppObserveOverviewUpdateComparison { + """ + Embedded bundle first, then OTA updates oldest first. Only updates with runtime data appear. + """ + updates: [AppObserveOverviewUpdate!]! +} + +input AppObserveOverviewUpdateComparisonInput { + appVersion: String! + endTime: DateTime! + environment: String + + """ + Metric names to aggregate. Defaults to all metrics available on the account's plan. + """ + metricNames: [String!] + startTime: DateTime! +} + +type AppObserveOverviewUpdateDownload { + downloadCount: Int! + medianDownloadTime: Float! + platform: AppObservePlatform! +} + +type AppObserveOverviewUpdateMetric { + eventCount: Int! + metricName: String! + platform: AppObservePlatform! + statistics: AppObserveVersionMarkerStatistics! +} + +type AppObserveOverviewVersion { + appVersion: String! + firstSeenAt: DateTime! + + """ + One entry per (metric, platform) with data in range; each device OS (iOS, iPadOS, tvOS, macOS, Android) is a separate platform. + """ + metrics: [AppObserveOverviewVersionMetric!]! + + """ + Error stats for this version, one entry per platform with activity; iOS includes iPadOS and tvOS. + """ + stability: [AppObserveErrorPlatformStats!]! + uniqueUserCount: Int! +} + +type AppObserveOverviewVersionComparison { + """Aggregate across every release in range; the comparison baseline.""" + allReleases: AppObserveOverviewAllReleases! + + """ + Every version with data in range, highest version first, for the version picker. + """ + allVersions: [AppObserveOverviewVersionSummary!]! + + """ + Compared versions, lowest version first. Requested versions with no data are omitted. + """ + versions: [AppObserveOverviewVersion!]! +} + +input AppObserveOverviewVersionComparisonInput { + """ + Versions to compare (max 10, any order). Defaults to the two highest versions. + """ + appVersions: [String!] + endTime: DateTime! + environment: String + + """ + Metric names to aggregate. Defaults to all metrics available on the account's plan. + """ + metricNames: [String!] + startTime: DateTime! +} + +type AppObserveOverviewVersionMetric { + eventCount: Int! + metricName: String! + platform: AppObservePlatform! + statistics: AppObserveVersionMarkerStatistics! +} + +type AppObserveOverviewVersionSummary { + appVersion: String! + firstSeenAt: DateTime! + uniqueUserCount: Int! +} + +enum AppObservePlatform { + ANDROID + IOS + IPADOS + MACOS + TVOS +} + +enum AppObservePropertyType { + BOOLEAN + JSON + NUMBER + STRING +} + +input AppObserveReleasesInput { + endTime: DateTime! + environment: String + metricNames: [String!] + platform: AppObservePlatform + + """Filter to these platforms. Cannot be set together with platform.""" + platforms: [AppObservePlatform!] + startTime: DateTime! +} + +type AppObserveSession { + id: ID! + + """Logs in the session: user-defined events and errors interleaved.""" + logs(after: String, before: String, first: Int, last: Int, orderBy: AppObserveLogsOrderBy): AppObserveLogConnection! + + """Metric samples in the session.""" + metrics(after: String, before: String, first: Int, last: Int, orderBy: AppObserveMetricsListOrderBy): AppObserveMetricConnection! +} + +""" +Stability-tile filters. No release filters: the tiles always span all versions. +""" +input AppObserveStabilityInput { + """Series bucket size. Defaults to one day.""" + bucketIntervalMinutes: Int + endTime: DateTime! + environment: String + + """Filter to these platforms.""" + platforms: [AppObservePlatform!] + startTime: DateTime! +} + +type AppObserveTimeSeries { + appVersionMarkers: [AppObserveAppVersion!]! + buckets: [AppObserveTimeSeriesBucket!]! + eventCount: Int! + latestVersionStatistics: AppObserveVersionStatistics + previousVersionStatistics: AppObserveVersionStatistics + statistics: AppObserveTimeSeriesStatistics! +} + +type AppObserveTimeSeriesBucket { + average: Float + bucket: DateTime! + count: Int! + max: Float + median: Float + min: Float + p80: Float + p90: Float + p99: Float +} + +input AppObserveTimeSeriesInput { + appBuildNumber: String + appEasBuildId: String + appUpdateId: String + appVersion: String + bucketIntervalMinutes: Int + endTime: DateTime! + environment: String + isEmbeddedUpdate: Boolean + metricName: String! + platform: AppObservePlatform + + """Filter to these platforms. Cannot be set together with platform.""" + platforms: [AppObservePlatform!] + routeName: String + startTime: DateTime! +} + +type AppObserveTimeSeriesStatistics { + average: Float + count: Int! + max: Float + median: Float + min: Float + p80: Float + p90: Float + p99: Float +} + +type AppObserveUpdate { + appUpdateId: String! + appUpdateMessage: String + appVersion: String! + + """Branch the update was published to.""" + branchName: String + downloadCount: Int! + firstSeenAt: DateTime! + + """Git commit the update was published from, when it was recorded.""" + gitCommitHash: String + medianDownloadTime: Float! + p90DownloadTime: Float! + + """Platform the update was published for: ios, android, or web.""" + platform: String + + """When the update was published, from the EAS Update record.""" + publishedAt: DateTime + + """Runtime version the update is compatible with.""" + runtimeVersion: String + + """ID of the update group this update belongs to.""" + updateGroupId: String +} + +type AppObserveUpdateEdge { + cursor: String! + node: AppObserveUpdate! +} + +type AppObserveUpdatesConnection { + edges: [AppObserveUpdateEdge!]! + pageInfo: PageInfo! + totalCount: Int! + totalDownloads: Int! +} + +input AppObserveUpdatesInput { + after: String + appBuildNumber: String + appUpdateId: String + appVersion: String + endTime: DateTime! + environment: String + first: Int + orderBy: AppObserveUpdatesOrderBy + platform: AppObservePlatform + + """Filter to these platforms. Cannot be set together with platform.""" + platforms: [AppObservePlatform!] + startTime: DateTime! +} + +input AppObserveUpdatesOrderBy { + direction: AppObserveUpdatesOrderByDirection! + field: AppObserveUpdatesOrderByField! +} + +enum AppObserveUpdatesOrderByDirection { + ASC + DESC +} + +enum AppObserveUpdatesOrderByField { + DOWNLOAD_COUNT + FIRST_SEEN_AT + MEDIAN_DOWNLOAD_TIME + P90_DOWNLOAD_TIME +} + +"""One user-defined event (an app_events row that is not an exception).""" +type AppObserveUserEvent implements AppObserveLog { + appBuildNumber: String! + appEasBuildId: String + appIdentifier: String! + appUpdateId: String + appUpdateMessage: String + appVersion: String! + body: String + clientVersion: String + countryCode: String + deviceLanguageTag: String + deviceModel: String! + deviceOs: String! + deviceOsVersion: String! + + """ + Human-friendly label from the expo.log.display_name attribute; null when the event was logged without one. + """ + displayName: String + easClientId: String! + environment: String + expoSdkVersion: String + id: ID! + ingestedAt: DateTime + name: String! + properties: [AppObserveEventProperty!]! + reactNativeVersion: String + sessionId: String + severityNumber: Int + severityText: String + timestamp: DateTime! +} + +type AppObserveUserEventBreakdown { + """Buckets ordered by count descending.""" + buckets: [AppObserveUserEventBreakdownBucket!]! + + """True when more buckets exist than were returned (top-N truncation).""" + isTruncated: Boolean! + + """Number of distinct dimension values before truncation.""" + totalBucketCount: Int! +} + +type AppObserveUserEventBreakdownBucket { + count: Int! + + """ + Dimension value (app version, OS, device model, country code, or EAS client id). Empty string when unknown. + """ + key: String! + uniqueUserCount: Int! +} + +enum AppObserveUserEventBreakdownDimension { + APP_VERSION + COUNTRY + DEVICE + OS + OS_VERSION + USER +} + +input AppObserveUserEventBreakdownInput { + appBuildNumber: String + appEasBuildId: String + appUpdateId: String + appVersion: String + countryCode: String + deviceModel: String + deviceOsVersion: String + dimension: AppObserveUserEventBreakdownDimension! + endTime: DateTime! + environment: String + isEmbeddedUpdate: Boolean + + """Maximum buckets to return, 1 to 100. Defaults to 100.""" + limit: Int + + """Event name. The reserved `exception` name is rejected.""" + name: String! + + """Filter to these platforms.""" + platforms: [AppObservePlatform!] + startTime: DateTime! +} + +type AppObserveUserEventConnection { + edges: [AppObserveUserEventEdge!]! + pageInfo: PageInfo! +} + +type AppObserveUserEventCountBucket { + bucket: DateTime! + count: Int! +} + +type AppObserveUserEventCountSeries { + """ + Buckets with at least one occurrence, ascending. Empty buckets are omitted. + """ + buckets: [AppObserveUserEventCountBucket!]! + name: String! + + """Sum of count across all buckets.""" + totalCount: Int! +} + +type AppObserveUserEventCounts { + """One series per requested name, in request order.""" + series: [AppObserveUserEventCountSeries!]! +} + +input AppObserveUserEventCountsInput { + appBuildNumber: String + appEasBuildId: String + appUpdateId: String + appVersion: String + + """Bucket width. Defaults to 60. The range must not exceed 2000 buckets.""" + bucketIntervalMinutes: Int + countryCode: String + deviceModel: String + deviceOsVersion: String + endTime: DateTime! + environment: String + isEmbeddedUpdate: Boolean + + """ + Event names to count. At most 10; the reserved `exception` name is rejected. + """ + names: [String!]! + + """Filter to these platforms.""" + platforms: [AppObservePlatform!] + startTime: DateTime! +} + +type AppObserveUserEventEdge { + cursor: String! + node: AppObserveUserEvent! +} + +input AppObserveUserEventListFilter { + appBuildNumber: String + appEasBuildId: String + appUpdateId: String + appVersion: String + countryCode: String + deviceModel: String + deviceOsVersion: String + easClientId: String + endTime: DateTime + environment: String + isEmbeddedUpdate: Boolean + + """ + Event name. The reserved `exception` name is rejected; errors live in the errors namespace. + """ + name: String + + """Filter to these platforms.""" + platforms: [AppObservePlatform!] + propertyFilters: [AppObserveUserEventPropertyFilter!] + sessionId: String + startTime: DateTime +} + +input AppObserveUserEventListOrderBy { + direction: AppObserveOrderDirection! + field: AppObserveUserEventListOrderByField! +} + +enum AppObserveUserEventListOrderByField { + TIMESTAMP +} + +type AppObserveUserEventName { + count: Int! + firstSeenAt: DateTime! + lastSeenAt: DateTime! + name: String! +} + +type AppObserveUserEventNames { + isTruncated: Boolean! + names: [AppObserveUserEventName!]! +} + +input AppObserveUserEventNamesInput { + appBuildNumber: String + appEasBuildId: String + appUpdateId: String + appVersion: String + endTime: DateTime! + environment: String + isEmbeddedUpdate: Boolean + orderBy: AppObserveUserEventNamesOrderBy + + """Filter to these platforms.""" + platforms: [AppObservePlatform!] + startTime: DateTime! +} + +input AppObserveUserEventNamesOrderBy { + direction: AppObserveOrderDirection! + field: AppObserveUserEventNamesOrderByField! +} + +enum AppObserveUserEventNamesOrderByField { + COUNT + FIRST_SEEN + LAST_SEEN + NAME +} + +input AppObserveUserEventPropertyFilter { + key: String! + value: String! +} + +""" +User-defined events (`Observe.logEvent`): app_events log rows, excluding errors. +""" +type AppObserveUserEvents { + """ + Top values of one dimension (app version, OS, device, user, ...) for a single event name. + """ + breakdown(input: AppObserveUserEventBreakdownInput!): AppObserveUserEventBreakdown! + + """Time-bucketed occurrence counts, one series per requested event name.""" + counts(input: AppObserveUserEventCountsInput!): AppObserveUserEventCounts! + + """ + A single user-defined event by id. Null when missing, aged out, or the id belongs to an error. + """ + event(id: ID!): AppObserveUserEvent + + """Individual user-defined events. Defaults to newest first.""" + list(after: String, before: String, filter: AppObserveUserEventListFilter, first: Int, last: Int, orderBy: AppObserveUserEventListOrderBy): AppObserveUserEventConnection! + + """Distinct event names with counts over the time range.""" + names(input: AppObserveUserEventNamesInput!): AppObserveUserEventNames! +} + +type AppObserveVersionMarkerStatistics { + average: Float + max: Float + median: Float + min: Float + p80: Float + p90: Float + p99: Float +} + +type AppObserveVersionStatistics { + appVersion: String! + statistics: AppObserveVersionMarkerStatistics! +} + +enum AppPlatform { + ANDROID + IOS +} + +enum AppPrivacy { + HIDDEN + PUBLIC + UNLISTED +} + +enum AppProfileImageWidth { + EXTRA_LARGE + LARGE + MEDIUM + SMALL +} + +type AppPushNotifications { + id: ID! + insights: AppPushNotificationsInsights! +} + +type AppPushNotificationsInsights { + id: ID! + notificationsSentOverTime(timespan: InsightsTimespan!): NotificationsSentOverTimeData! + successFailureOverTime(timespan: InsightsTimespan!): NotificationsSentOverTimeData! + totalNotificationsSent(filters: [JSON!] = [], timespan: InsightsTimespan!): Int! +} + +type AppQuery { + """Look up app by dev domain name, if one has been created""" + byDevDomainName(name: DevDomainName!): App! + byFullName( + """App full name used to look up app""" + fullName: String! + ): App! + + """Look up app by app id""" + byId(appId: String!): App! +} + +type AppStoreConnectApiKey { + account: Account! + appleTeam: AppleTeam + createdAt: DateTime! + id: ID! + + """ + Null for individual (user-scoped) keys, which only support submissions. + """ + issuerIdentifier: String + keyIdentifier: String! + keyP8: String! + name: String + remoteAppStoreConnectApps(bundleIdentifier: String): [RemoteAppStoreConnectApp!]! + roles: [AppStoreConnectUserRole!] + updatedAt: DateTime! +} + +input AppStoreConnectApiKeyInput { + appleTeamId: ID + + """ + Omit for individual (user-scoped) keys, which have no issuer ID and only + support submissions. + """ + issuerIdentifier: String + keyIdentifier: String! + keyP8: String! + name: String + roles: [AppStoreConnectUserRole!] +} + +type AppStoreConnectApiKeyMutation { + """Create an App Store Connect Api Key for an Apple Team""" + createAppStoreConnectApiKey(accountId: ID!, appStoreConnectApiKeyInput: AppStoreConnectApiKeyInput!): AppStoreConnectApiKey! + + """Delete an App Store Connect Api Key""" + deleteAppStoreConnectApiKey(id: ID!): deleteAppStoreConnectApiKeyResult! + + """Update an App Store Connect Api Key for an Apple Team""" + updateAppStoreConnectApiKey(appStoreConnectApiKeyUpdateInput: AppStoreConnectApiKeyUpdateInput!, id: ID!): AppStoreConnectApiKey! +} + +type AppStoreConnectApiKeyQuery { + byId(id: ID!): AppStoreConnectApiKey! +} + +input AppStoreConnectApiKeyUpdateInput { + appleTeamId: ID +} + +type AppStoreConnectApp { + app: App! + appStoreConnectApiKey: AppStoreConnectApiKey! + ascAppIdentifier: String! + createdAt: DateTime! + id: ID! + remoteAppStoreConnectApp: RemoteAppStoreConnectApp + updatedAt: DateTime! + webhookEventTypes: [String!]! + webhookIdentifier: ID! +} + +input AppStoreConnectAppInput { + appId: ID! + appStoreConnectApiKeyId: ID! + ascAppIdentifier: String! +} + +type AppStoreConnectAppMutation { + """Create an App Store Connect app for an Expo app.""" + createAppStoreConnectApp(appStoreConnectAppInput: AppStoreConnectAppInput!): AppStoreConnectApp! + + """Delete an App Store Connect app by ID.""" + deleteAppStoreConnectApp(appStoreConnectAppId: ID!): DeleteAppStoreConnectAppResult! +} + +type AppStoreConnectBuild { + ascBuildIdentifier: String! + buildNumber: String + expirationDate: DateTime + minOsVersion: String + processingState: AppStoreConnectBuildProcessingState! + uploadedDate: DateTime +} + +enum AppStoreConnectBuildProcessingState { + FAILED + INVALID + PROCESSING + VALID +} + +type AppStoreConnectBuildUpload { + appStoreConnectBuild: AppStoreConnectBuild + ascBuildUploadIdentifier: String! + buildNumber: String + createdDate: DateTime + uploadState: AppStoreConnectBuildUploadState! + uploadedDate: DateTime + version: String +} + +enum AppStoreConnectBuildUploadState { + AWAITING_UPLOAD + COMPLETE + FAILED + PROCESSING +} + +enum AppStoreConnectUserRole { + ACCESS_TO_REPORTS + ACCOUNT_HOLDER + ADMIN + APP_MANAGER + CLOUD_MANAGED_APP_DISTRIBUTION + CLOUD_MANAGED_DEVELOPER_ID + CREATE_APPS + CUSTOMER_SUPPORT + DEVELOPER + FINANCE + IMAGE_MANAGER + MARKETING + READ_ONLY + SALES + TECHNICAL + UNKNOWN +} + +""" +Whether a project has an App Store Connect connection for its ASC-triggered workflows. +""" +enum AppStoreConnectWorkflowConnectionStatus { + """An App Store Connect app connection is linked to this project.""" + HAS_WORKFLOWS_IS_CONNECTED + + """ + The project has App Store Connect-triggered workflows but no connection. + """ + HAS_WORKFLOWS_MISSING_CONNECTION + + """The project has no workflows with an on.app_store_connect trigger.""" + NO_APP_STORE_CONNECT_WORKFLOWS +} + +type AppSubmissionEdge { + cursor: String! + node: Submission! +} + +type AppSubmissionsConnection { + edges: [AppSubmissionEdge!]! + pageInfo: PageInfo! +} + +type AppUpdateEdge { + cursor: String! + node: Update! +} + +type AppUpdatesConnection { + edges: [AppUpdateEdge!]! + pageInfo: PageInfo! +} + +enum AppUploadSessionType { + PROFILE_IMAGE_UPLOAD +} + +type AppUsageMetric { + id: ID! + metricType: UsageMetricType! + serviceMetric: EASServiceMetric! + timestamp: DateTime! + value: Float! +} + +type AppUsageMetricTotal { + billingPeriod: BillingPeriod! + id: ID! + planMetrics: [EstimatedUsage!]! + + """Total cost of overages, in cents""" + totalCost: Float! + + """ + Services whose estimates could not be computed and are omitted from the other fields. + """ + unavailableServiceUsageMetrics: [UnavailableServiceUsageMetric!]! +} + +type AppUsageMetrics { + byBillingPeriod(date: DateTime!, service: EASService): AppUsageMetricTotal! + metricsForServiceMetric(filterParams: JSONObject, granularity: UsageMetricsGranularity!, serviceMetric: EASServiceMetric!, timespan: UsageMetricsTimespan!): [AppUsageMetric!]! +} + +"""Represents Play Store/App Store version of an application""" +type AppVersion { + """ + Store identifier for an application + - Android - applicationId + - iOS - bundle identifier + """ + applicationIdentifier: String! + + """ + Value that identifies build in a store (it's visible to developers, but not to end users) + - Android - versionCode in build.gradle ("android.versionCode" field in app.json) + - iOS - CFBundleVersion in Info.plist ("ios.buildNumber" field in app.json) + """ + buildVersion: String! + id: ID! + platform: AppPlatform! + runtimeVersion: String + + """ + User-facing version in a store + - Android - versionName in build.gradle ("version" field in app.json) + - iOS - CFBundleShortVersionString in Info.plist ("version" field in app.json) + """ + storeVersion: String! +} + +input AppVersionInput { + appId: ID! + applicationIdentifier: String! + buildVersion: String! + platform: AppPlatform! + runtimeVersion: String + storeVersion: String! +} + +type AppVersionMutation { + """Create an app version""" + createAppVersion(appVersionInput: AppVersionInput!): AppVersion! +} + +input AppWithGithubRepositoryInput { + accountId: ID! + appInfo: AppInfoInput + installationIdentifier: String + projectName: String! +} + +type AppWorkflowCacheEdge { + cursor: String! + node: WorkflowCache! +} + +type AppWorkflowCachesConnection { + edges: [AppWorkflowCacheEdge!]! + pageInfo: PageInfo! +} + +input AppWorkflowFilterInput { + requestedGitRef: String +} + +type AppWorkflowRunEdge { + cursor: String! + node: WorkflowRun! +} + +input AppWorkflowRunFilterInput { + requestedGitRef: String + searchTerm: String + status: WorkflowRunStatus + timeRange: WorkflowRunTimeRangeInput + workflowId: ID +} + +type AppWorkflowRunGitBranchEdge { + cursor: String! + node: AppWorkflowRunGitBranchNode! +} + +type AppWorkflowRunGitBranchNode { + lastRunAt: DateTime! + name: String! +} + +type AppWorkflowRunGitBranchesConnection { + edges: [AppWorkflowRunGitBranchEdge!]! + pageInfo: PageInfo! +} + +input AppWorkflowRunTimeRangeInput { + earliest: DateTime + latest: DateTime +} + +type AppWorkflowRunsConnection { + edges: [AppWorkflowRunEdge!]! + pageInfo: PageInfo! +} + +type AppWorkflowsInsights { + overviewMetrics(filters: WorkflowsInsightsFiltersInput, timespan: WorkflowsInsightsTimespanInput!): WorkflowsInsightsOverviewMetrics! + + """ + Buckets start at the requested timespan start and advance by the granularity interval, so + their boundaries follow the requested range. The last bucket is shorter when the timespan is + not a whole number of intervals. + """ + runsOverTime(filters: WorkflowsInsightsFiltersInput, granularity: WorkflowsInsightsRunsOverTimeGranularity!, timespan: WorkflowsInsightsTimespanInput!): WorkflowsInsightsRunsOverTimeData! + workflows(after: String, filters: WorkflowsInsightsFiltersInput, first: Int!, timespan: WorkflowsInsightsTimespanInput!): WorkflowsInsightsWorkflowConnection! +} + +type AppiumRunSessionRemoteConfig { + """Appium server URL for the remote device.""" + appiumUrl: String! + + """W3C capabilities for the device that backs this session.""" + capabilities: JSONObject! + + """ + Fingerprint of the reverse-tunnel server key for the client to pin. Null when + the session does not use local egress. + """ + egressFingerprint: String + + """ + Loopback port on the device host that the local egress client must serve + through the tunnel. Null when the session does not use local egress. + """ + egressPort: Int + + """ + Secret the local egress client presents to the reverse-tunnel endpoint. Null + when the session does not use local egress. + """ + egressToken: String + + """ + Reverse-tunnel endpoint the local egress client connects to. Null when the + session does not use local egress. + """ + egressUrl: String + + """URL of the preview server, for reaching its API rather than its page.""" + previewApiUrl: String + + """ + Session token gating the web preview. Null when the preview runs ungated. + """ + webPreviewToken: String + + """ + URL of the web preview surface for the session. Null when a web preview is + not available for the session. + """ + webPreviewUrl: String +} + +type AppleAppIdentifier { + account: Account! + appleTeam: AppleTeam + bundleIdentifier: String! + id: ID! + parentAppleAppIdentifier: AppleAppIdentifier +} + +input AppleAppIdentifierInput { + appleTeamId: ID + bundleIdentifier: String! + parentAppleAppId: ID +} + +type AppleAppIdentifierMutation { + """Create an Identifier for an iOS App""" + createAppleAppIdentifier(accountId: ID!, appleAppIdentifierInput: AppleAppIdentifierInput!): AppleAppIdentifier! +} + +type AppleDevice { + account: Account! + appleTeam: AppleTeam! + createdAt: DateTime! + deviceClass: AppleDeviceClass + enabled: Boolean + id: ID! + identifier: String! + model: String + name: String + softwareVersion: String +} + +enum AppleDeviceClass { + IPAD + IPHONE + MAC + UNKNOWN +} + +input AppleDeviceFilterInput { + appleTeamIdentifier: String + class: AppleDeviceClass + identifier: String +} + +input AppleDeviceInput { + appleTeamId: ID! + deviceClass: AppleDeviceClass + enabled: Boolean + identifier: String! + model: String + name: String + softwareVersion: String +} + +type AppleDeviceMutation { + """Create an Apple Device""" + createAppleDevice(accountId: ID!, appleDeviceInput: AppleDeviceInput!): AppleDevice! + + """Delete an Apple Device""" + deleteAppleDevice(id: ID!): DeleteAppleDeviceResult! + + """Update an Apple Device""" + updateAppleDevice(appleDeviceUpdateInput: AppleDeviceUpdateInput!, id: ID!): AppleDevice! +} + +type AppleDeviceRegistrationRequest { + account: Account! + appleTeam: AppleTeam! + closedAt: DateTime + id: ID! + + """ + Device that satisfied this request, including when enrollment matched a pre-existing device. + """ + resolvedAppleDevice: AppleDevice + + """ + Workflow job waiting for registration review when this request was created from a workflow run. + """ + workflowJob: WorkflowJob +} + +type AppleDeviceRegistrationRequestMutation { + """ + Create an Apple Device registration request. + Pass singleUse to create a fresh request that closes after the first device is registered. + """ + createAppleDeviceRegistrationRequest(accountId: ID!, appleTeamId: ID!, singleUse: Boolean): AppleDeviceRegistrationRequest! +} + +"""Publicly visible data for an AppleDeviceRegistrationRequest.""" +type AppleDeviceRegistrationRequestPublicData { + id: ID! +} + +type AppleDeviceRegistrationRequestPublicDataQuery { + """Get AppleDeviceRegistrationRequest public data by ID""" + byId(id: ID!): AppleDeviceRegistrationRequestPublicData +} + +type AppleDeviceRegistrationRequestQuery { + byId(id: ID!): AppleDeviceRegistrationRequest! +} + +input AppleDeviceUpdateInput { + name: String +} + +type AppleDistributionCertificate { + account: Account! + appleTeam: AppleTeam + certificateP12: String + certificatePassword: String + certificatePrivateSigningKey: String + createdAt: DateTime! + developerPortalIdentifier: String + id: ID! + iosAppBuildCredentialsList: [IosAppBuildCredentials!]! + serialNumber: String! + updatedAt: DateTime! + validityNotAfter: DateTime! + validityNotBefore: DateTime! +} + +input AppleDistributionCertificateInput { + appleTeamId: ID + certP12: String! + certPassword: String! + certPrivateSigningKey: String + developerPortalIdentifier: String +} + +type AppleDistributionCertificateMutation { + """Create a Distribution Certificate""" + createAppleDistributionCertificate(accountId: ID!, appleDistributionCertificateInput: AppleDistributionCertificateInput!): AppleDistributionCertificate + + """Delete a Distribution Certificate""" + deleteAppleDistributionCertificate(id: ID!): DeleteAppleDistributionCertificateResult! +} + +type AppleDistributionCertificateQuery { + byId(id: ID!): AppleDistributionCertificate! +} + +type AppleProvisioningProfile { + account: Account! + appleAppIdentifier: AppleAppIdentifier! + appleDevices: [AppleDevice!]! + appleTeam: AppleTeam + appleUUID: String! + createdAt: DateTime! + developerPortalIdentifier: String + expiration: DateTime! + id: ID! + provisioningProfile: String + status: String! + updatedAt: DateTime! +} + +input AppleProvisioningProfileInput { + appleProvisioningProfile: String! + developerPortalIdentifier: String +} + +type AppleProvisioningProfileMutation { + """Create a Provisioning Profile""" + createAppleProvisioningProfile(accountId: ID!, appleAppIdentifierId: ID!, appleProvisioningProfileInput: AppleProvisioningProfileInput!): AppleProvisioningProfile! + + """Delete a Provisioning Profile""" + deleteAppleProvisioningProfile(id: ID!): DeleteAppleProvisioningProfileResult! + + """Delete Provisioning Profiles""" + deleteAppleProvisioningProfiles(ids: [ID!]!): [DeleteAppleProvisioningProfileResult!]! + + """Update a Provisioning Profile""" + updateAppleProvisioningProfile(appleProvisioningProfileInput: AppleProvisioningProfileInput!, id: ID!): AppleProvisioningProfile! +} + +type AppleProvisioningProfileQuery { + byId(id: ID!): AppleProvisioningProfile! +} + +type ApplePushKey { + account: Account! + appleTeam: AppleTeam + createdAt: DateTime! + id: ID! + iosAppCredentialsList: [IosAppCredentials!]! + keyIdentifier: String! + keyP8: String! + updatedAt: DateTime! +} + +input ApplePushKeyInput { + appleTeamId: ID! + keyIdentifier: String! + keyP8: String! +} + +type ApplePushKeyMutation { + """Create an Apple Push Notification key""" + createApplePushKey(accountId: ID!, applePushKeyInput: ApplePushKeyInput!): ApplePushKey! + + """Delete an Apple Push Notification key""" + deleteApplePushKey(id: ID!): deleteApplePushKeyResult! +} + +type AppleTeam { + account: Account! + appleAppIdentifiers(bundleIdentifier: String): [AppleAppIdentifier!]! + appleDevices(limit: Int, offset: Int): [AppleDevice!]! + appleDistributionCertificates: [AppleDistributionCertificate!]! + appleProvisioningProfiles(appleAppIdentifierId: ID): [AppleProvisioningProfile!]! + applePushKeys: [ApplePushKey!]! + appleTeamIdentifier: String! + appleTeamName: String + appleTeamType: AppleTeamType + id: ID! + lastDeletionAttemptTime: DateTime +} + +input AppleTeamFilterInput { + appleTeamIdentifier: String +} + +input AppleTeamInput { + appleTeamIdentifier: String! + appleTeamName: String + appleTeamType: AppleTeamType +} + +type AppleTeamMutation { + """Create an Apple Team""" + createAppleTeam(accountId: ID!, appleTeamInput: AppleTeamInput!): AppleTeam! + + """Delete an Apple Team in the background""" + scheduleAppleTeamDeletion(id: ID!): BackgroundJobReceipt! + + """Update an Apple Team""" + updateAppleTeam(appleTeamUpdateInput: AppleTeamUpdateInput!, id: ID!): AppleTeam! +} + +type AppleTeamQuery { + byAppleTeamIdentifier(accountId: ID!, identifier: String!): AppleTeam +} + +enum AppleTeamType { + COMPANY_OR_ORGANIZATION + INDIVIDUAL + IN_HOUSE +} + +input AppleTeamUpdateInput { + appleTeamName: String + appleTeamType: AppleTeamType +} + +type ArgentRunSessionRemoteConfig { + """ + Fingerprint of the reverse-tunnel server key for the client to pin. Null when + the session does not use local egress. + """ + egressFingerprint: String + + """ + Loopback port on the device host that the local egress client must serve + through the tunnel. Null when the session does not use local egress. + """ + egressPort: Int + + """ + Secret the local egress client presents to the reverse-tunnel endpoint. Null + when the session does not use local egress. + """ + egressToken: String + + """ + Reverse-tunnel endpoint the local egress client connects to. Null when the + session does not use local egress. + """ + egressUrl: String + + """URL of the preview server, for reaching its API rather than its page.""" + previewApiUrl: String + toolsAuthToken: String + toolsUrl: String! + + """ + Session token gating the web preview. Null when the preview runs ungated. + """ + webPreviewToken: String + + """ + URL of the web preview surface for the session. Null when a web preview is + not available for the session. + """ + webPreviewUrl: String +} + +input AscApiKeyInput { + """Omit for individual (user-scoped) keys, which have no issuer ID.""" + issuerIdentifier: String + keyIdentifier: String! + keyP8: String! +} + +type Asset { + contentType: String! + fileSHA256: String! + fileSize: Int! + finalFileSize: Int + id: ID! + storageKey: String! +} + +input AssetMapGroup { + android: AssetMapSourceInput + ios: AssetMapSourceInput + web: AssetMapSourceInput +} + +input AssetMapSourceInput { + bucketKey: String + type: AssetMapSourceType +} + +enum AssetMapSourceType { + GCS +} + +type AssetMetadataResult { + status: AssetMetadataStatus! + storageKey: String! +} + +enum AssetMetadataStatus { + DOES_NOT_EXIST + EXISTS +} + +type AssetMutation { + """ + Returns an array of specifications for upload. Each URL is valid for an hour. + The content type of the asset you wish to upload must be specified. + """ + getSignedAssetUploadSpecifications( + """max 1400""" + assetContentTypes: [String]! + ): GetSignedAssetUploadSpecificationsResult! +} + +"""Check to see if assets with given storageKeys exist""" +type AssetQuery { + byStorageKeys(storageKeys: [String!]!): [Asset!]! + metadata(storageKeys: [String!]!): [AssetMetadataResult!]! + signedUrls(storageKeys: [String!]!, updateId: ID!): [AssetSignedUrlResult!]! +} + +type AssetSignedUrlResult { + headers: JSON + storageKey: String! + url: String! +} + +type AuditLog { + account: Account + actor: Actor + createdAt: DateTime! + id: ID! + metadata: JSONObject + targetEntityId: ID! + targetEntityMutationType: TargetEntityMutationType! + targetEntityTypeName: EntityTypeName! + targetEntityTypePublicName: String! + websiteMessage: String! +} + +type AuditLogConnection { + edges: [AuditLogEdge!]! + pageInfo: PageInfo! +} + +type AuditLogEdge { + cursor: String! + node: AuditLog! +} + +input AuditLogExportInput { + accountId: ID! + createdAfter: String! + createdBefore: String! + format: AuditLogsExportFormat! + targetEntityMutationType: [TargetEntityMutationType!] + targetEntityTypeName: [EntityTypeName!] +} + +input AuditLogFilterInput { + entityTypes: [EntityTypeName!] + mutationTypes: [TargetEntityMutationType!] +} + +type AuditLogMutation { + """ + Exports Audit Logs for an account. Returns the ID of the background job receipt. Use BackgroundJobReceiptQuery to get the status of the job. + """ + exportAuditLogs(exportInput: AuditLogExportInput!): BackgroundJobReceipt! +} + +type AuditLogQuery { + """Audit logs for account""" + byId(auditLogId: ID!): AuditLog! + typeNamesMap: [LogNameTypeMapping!]! +} + +enum AuditLogsExportFormat { + CSV + JSON + JSONL +} + +enum AuthProtocolType { + OIDC +} + +enum AuthProviderIdentifier { + AMAZON_FEDERATE + GENERIC + GOOGLE_WS + MS_ENTRA_ID + OKTA + ONE_LOGIN + STUB_IDP +} + +type AverageAssetMetrics { + averageDownloadSizeBytes: Int! + baseUpdate: Update + baseUpdateDownloadUrl: String + baseUpdateId: ID + count: Int! + storageKey: String! +} + +type BackgroundJobReceipt { + account: Account! + createdAt: DateTime! + errorCode: String + errorMessage: String + id: ID! + resultData: JSONObject + resultId: ID + resultType: BackgroundJobResultType! + state: BackgroundJobState! + tries: Int! + updatedAt: DateTime! + willRetry: Boolean! +} + +type BackgroundJobReceiptQuery { + """Look up background job receipt by ID""" + byId(id: ID!): BackgroundJobReceipt! +} + +enum BackgroundJobResultType { + AUDIT_LOGS_EXPORT + GITHUB_BUILD + USER_AUDIT_LOGS_EXPORT + VOID + WORKFLOWS_INSIGHTS_EXPORT +} + +enum BackgroundJobState { + FAILURE + IN_PROGRESS + QUEUED + SUCCESS +} + +input BeginSupabaseOAuthInput { + accountId: ID! +} + +type Billing { + """History of invoices""" + charges: [Charge] + id: ID! + subscription: SubscriptionDetails +} + +type BillingPeriod { + anchor: DateTime! + end: DateTime! + id: ID! + start: DateTime! +} + +input BranchFilterInput { + searchTerm: String +} + +type BranchQuery { + """Query a Branch by ID""" + byId( + """Branch ID to use to look up branch""" + branchId: ID! + ): UpdateBranch! +} + +type BrownfieldArtifact { + bundleName: String! + createdAt: DateTime! + downloadUrl: String + id: ID! + platform: AppPlatform! + sizeBytes: Int + updatedAt: DateTime! + version: String! +} + +type BrownfieldArtifactEdge { + cursor: String! + node: BrownfieldArtifact! +} + +input BrownfieldArtifactFilterInput { + bundleName: String + platform: AppPlatform + version: String +} + +type BrownfieldArtifactsConnection { + edges: [BrownfieldArtifactEdge!]! + pageInfo: PageInfo! +} + +"""Represents an EAS Build""" +type Build implements ActivityTimelineProjectActivity & BuildOrBuildJob { + activityTimestamp: DateTime! + actor: Actor + app: App! + appBuildVersion: String + appIdentifier: String + appVersion: String + artifacts: BuildArtifacts + buildMode: BuildMode + buildProfile: String + canRetry(newMode: BuildMode): Boolean! + cancelingActor: Actor + channel: String @deprecated(reason: "Use 'updateChannel' field instead.") + childBuild: Build + cliVersion: String + completedAt: DateTime + createdAt: DateTime! + customNodeVersion: String + customWorkflowName: String + deployment: Deployment + developmentClient: Boolean + distribution: DistributionType + embeddedUpdate: EmbeddedUpdate + enqueuedAt: DateTime + error: BuildError + estimatedWaitTimeLeftSeconds: Int + expirationDate: DateTime + fingerprint: Fingerprint + gitCommitHash: String + gitCommitMessage: String + gitRef: String + id: ID! + + """Queue position is 1-indexed""" + initialQueuePosition: Int + initiatingActor: Actor + iosEnterpriseProvisioning: BuildIosEnterpriseProvisioning + isForIosSimulator: Boolean! + isGitWorkingTreeDirty: Boolean + isWaived: Boolean! + logFileUrls: [String!]! + logFiles: [String!]! @deprecated(reason: "Use logFileUrls instead") + maxBuildTimeSeconds: Int! + + """Retry time starts after completedAt""" + maxRetryTimeMinutes: Int + message: String + metrics: BuildMetrics + parentBuild: Build + platform: AppPlatform! + priority: BuildPriority! + project: Project! @deprecated(reason: "Use app field instead") + projectMetadataFileUrl: String + projectRootDirectory: String + provisioningStartedAt: DateTime + + """Queue position is 1-indexed""" + queuePosition: Int + reactNativeVersion: String + releaseChannel: String + requiredPackageManager: String + resolvedEnvironment: EnvironmentVariableEnvironment + resolvedImage: String + + """The builder resource class requested by the developer""" + resourceClass: BuildResourceClass! @deprecated(reason: "Use resourceClassDisplayName instead") + + """String describing the resource class used to run the build""" + resourceClassDisplayName: String! + retryDisabledReason(newMode: BuildMode): BuildRetryDisabledReason + runFromCI: Boolean + runtime: Runtime + runtimeVersion: String @deprecated(reason: "Use 'runtime' field instead.") + sdkVersion: String + selectedImage: String @deprecated(reason: "Use 'resolvedImage' for the concrete image the build runs on.") + + """The active ssh session for this build, if any.""" + sshSession: TurtleSshSession + status: BuildStatus! + submissions: [Submission!]! + updateChannel: UpdateChannel + updatedAt: DateTime! + waiverType: EASBuildWaiverType + workerStartedAt: DateTime + workflowJob: WorkflowJob +} + +type BuildAnnotation { + authorUsername: String + buildPhase: String! + exampleBuildLog: String + id: ID! + internalNotes: String + message: String! + regexFlags: String + regexString: String! + title: String! + type: BuildAnnotationType! +} + +input BuildAnnotationDataInput { + buildPhase: String! + exampleBuildLog: String + internalNotes: String + message: String! + regexFlags: String + regexString: String! + title: String! + type: BuildAnnotationType! +} + +input BuildAnnotationFiltersInput { + buildPhases: [String!]! +} + +type BuildAnnotationMutation { + """Create a Build Annotation""" + createBuildAnnotation(buildAnnotationData: BuildAnnotationDataInput!): BuildAnnotation! + + """Delete a Build Annotation""" + deleteBuildAnnotation(buildAnnotationId: ID!): DeleteBuildAnnotationResult! + + """Update a Build Annotation""" + updateBuildAnnotation(buildAnnotationData: BuildAnnotationDataInput!, buildAnnotationId: ID!): BuildAnnotation! +} + +enum BuildAnnotationType { + ERROR + INFO + WARNING +} + +type BuildAnnotationsQuery { + """View build annotations""" + all(filters: BuildAnnotationFiltersInput): [BuildAnnotation!]! + + """Find a build annotation by ID""" + byId(buildAnnotationId: ID!): BuildAnnotation! +} + +type BuildArtifacts { + applicationArchiveUrl: String + buildArtifactsUrl: String + buildUrl: String + xcodeBuildLogsUrl: String +} + +input BuildCacheInput { + clear: Boolean + disabled: Boolean + key: String + paths: [String!] +} + +enum BuildCredentialsSource { + LOCAL + REMOTE +} + +type BuildError { + buildPhase: BuildPhase + docsUrl: String + errorCode: String! + message: String! +} + +input BuildFilter { + appBuildVersion: String + appIdentifier: String + appVersion: String + buildProfile: String + channel: String + developmentClient: Boolean + distribution: DistributionType + fingerprintHash: String + gitCommitHash: String + hasFingerprint: Boolean + platform: AppPlatform + runtimeVersion: String + sdkVersion: String + simulator: Boolean + status: BuildStatus +} + +input BuildFilterInput { + buildModes: [BuildMode!] + buildProfile: String + channel: String + developmentClient: Boolean + distributions: [DistributionType!] + expired: Boolean + fingerprintHash: String + hasFingerprint: Boolean + platforms: [AppPlatform!] + releaseChannel: String + runtimeVersion: String + simulator: Boolean + statuses: [BuildStatus!] +} + +enum BuildIosEnterpriseProvisioning { + ADHOC + UNIVERSAL +} + +type BuildLimitThresholdExceededMetadata { + account: Account! + thresholdsExceeded: [NotificationThresholdExceeded!]! +} + +enum BuildLimitThresholdExceededMetadataType { + IOS + TOTAL +} + +input BuildMetadataInput { + appBuildVersion: String + appIdentifier: String + appName: String + appVersion: String + buildProfile: String + channel: String + cliVersion: String + credentialsSource: BuildCredentialsSource + customNodeVersion: String + customWorkflowName: String + developmentClient: Boolean + distribution: DistributionType + environment: String + expoPackageVersion: String + fingerprintHash: String + fingerprintSource: FingerprintSourceInput + gitCommitHash: String + gitCommitMessage: String + iosEnterpriseProvisioning: BuildIosEnterpriseProvisioning + isGitWorkingTreeDirty: Boolean + message: String + projectMetadataFile: ProjectMetadataFileInput + reactNativeVersion: String + releaseChannel: String + requiredPackageManager: String + runFromCI: Boolean + runWithNoWaitFlag: Boolean + runtimeVersion: String + sdkVersion: String + simulator: Boolean + trackingContext: JSONObject + username: String + workflow: BuildWorkflow +} + +type BuildMetrics { + buildDuration: Int + buildQueueTime: Int + buildWaitTime: Int +} + +enum BuildMode { + BUILD + CUSTOM + LOCAL + REPACK + RESIGN +} + +type BuildMutation { + """Cancel an EAS Build build""" + cancel: Build! @deprecated(reason: "Use cancelBuild instead") + + """Cancel an EAS Build build""" + cancelBuild(buildId: ID!): Build! + + """Create an Android build""" + createAndroidBuild(appId: ID!, buildParams: BuildParamsInput, job: AndroidJobInput!, metadata: BuildMetadataInput): CreateBuildResult! + + """Create an iOS build""" + createIosBuild(appId: ID!, buildParams: BuildParamsInput, job: IosJobInput!, metadata: BuildMetadataInput): CreateBuildResult! + + """Create a local build""" + createLocalBuild(appId: ID!, artifactSource: LocalBuildArchiveSourceInput!, job: LocalBuildJobInput!, metadata: BuildMetadataInput): CreateBuildResult! + + """Delete an EAS Build build""" + deleteBuild(buildId: ID!): Build! + + """Retry an Android EAS Build""" + retryAndroidBuild(buildId: ID!, jobOverrides: AndroidJobOverridesInput): Build! + + """Retry an iOS EAS Build""" + retryIosBuild(buildId: ID!, jobOverrides: IosJobOverridesInput): Build! + + """Update metadata for EAS Build build""" + updateBuildMetadata(buildId: ID!, metadata: BuildMetadataInput!): Build! +} + +interface BuildOrBuildJob { + id: ID! +} + +input BuildParamsInput { + reactNativeVersion: String + resourceClass: BuildResourceClass! + sdkVersion: String +} + +enum BuildPhase { + BUILDER_INFO + CACHE_STATS + CALCULATE_EXPO_UPDATES_RUNTIME_VERSION + CLEAN_UP_CREDENTIALS + COMPLETE_BUILD + COMPLETE_JOB + CONFIGURE_ANDROID_VERSION + CONFIGURE_EXPO_UPDATES + CONFIGURE_XCODE_PROJECT + CUSTOM + DOWNLOAD_APPLICATION_ARCHIVE + EAGER_BUNDLE + EAS_BUILD_INTERNAL + FAIL_BUILD + FIX_GRADLEW + GRADLE_BUILD_PROFILE + INSTALL_CUSTOM_TOOLS + INSTALL_DEPENDENCIES + INSTALL_PODS + ON_BUILD_CANCEL_HOOK + ON_BUILD_COMPLETE_HOOK + ON_BUILD_ERROR_HOOK + ON_BUILD_SUCCESS_HOOK + PARSE_CUSTOM_WORKFLOW_CONFIG + PARSE_XCACTIVITYLOG + POST_INSTALL_HOOK + PREBUILD + PREPARE_ARTIFACTS + PREPARE_CREDENTIALS + PREPARE_PROJECT + PRE_INSTALL_HOOK + PRE_UPLOAD_ARTIFACTS_HOOK + QUEUE + READ_APP_CONFIG + READ_EAS_JSON + READ_PACKAGE_JSON + RESTORE_CACHE + RUN_EXPO_DOCTOR + RUN_FASTLANE + RUN_GRADLEW + SAVE_CACHE + SET_UP_BUILD_ENVIRONMENT + SPIN_UP_BUILDER + SSH_SESSION + START_BUILD + UNKNOWN + UPLOAD_APPLICATION_ARCHIVE + UPLOAD_ARTIFACTS @deprecated + UPLOAD_BUILD_ARTIFACTS + UPLOAD_EMBEDDED_BUNDLE +} + +type BuildPlanCreditThresholdExceededMetadata { + account: Account! + buildCreditUsage: Int! + planLimit: Int! + threshold: Int! +} + +enum BuildPriority { + HIGH + NORMAL + NORMAL_PLUS +} + +"""Publicly visible data for a Build.""" +type BuildPublicData { + artifacts: PublicArtifacts! + buildMode: BuildMode + distribution: DistributionType + id: ID! + isForIosSimulator: Boolean! + platform: AppPlatform! + project: ProjectPublicData! + status: BuildStatus! +} + +type BuildPublicDataQuery { + """Get BuildPublicData by ID""" + byId(id: ID!): BuildPublicData +} + +type BuildQuery { + """ + Get all builds for a specific app. + They are sorted from latest to oldest. + """ + allForApp(appId: String!, limit: Int, offset: Int, platform: AppPlatform, status: BuildStatus): [Build]! @deprecated(reason: "Use App.builds instead") + + """Look up EAS Build by build ID""" + byId(buildId: ID!): Build! +} + +input BuildResignInput { + applicationArchiveSource: ProjectArchiveSourceInput +} + +enum BuildResourceClass { + ANDROID_DEFAULT + ANDROID_LARGE + ANDROID_MEDIUM + IOS_DEFAULT + IOS_INTEL_LARGE @deprecated(reason: "No longer available. Use IOS_M_LARGE instead.") + IOS_INTEL_MEDIUM @deprecated(reason: "No longer available. Use IOS_M_MEDIUM instead.") + IOS_LARGE + IOS_M1_LARGE @deprecated(reason: "Use IOS_M_MEDIUM instead") + IOS_M1_MEDIUM @deprecated(reason: "Use IOS_M_MEDIUM instead") + IOS_MEDIUM + IOS_M_LARGE + IOS_M_MEDIUM + LEGACY + LINUX_LARGE + LINUX_MEDIUM +} + +enum BuildRetryDisabledReason { + ALREADY_RETRIED + INVALID_STATUS + IS_GITHUB_BUILD + IS_WORKFLOW_BUILD + LOCAL_BUILDS_NOT_RETRYABLE + NOT_COMPLETED_YET + TOO_MUCH_TIME_ELAPSED +} + +enum BuildStatus { + CANCELED + ERRORED + FINISHED + IN_PROGRESS + IN_QUEUE + NEW + PENDING_CANCEL +} + +enum BuildTrigger { + EAS_CLI + GIT_BASED_INTEGRATION +} + +input BuildUpdatesInput { + channel: String +} + +enum BuildWorkflow { + GENERIC + MANAGED + UNKNOWN +} + +"""A non-negative integer number of bytes.""" +scalar ByteSize + +union ChannelBuildOrEmbeddedUpdate = Build | EmbeddedUpdate + +type ChannelBuildOrEmbeddedUpdateEdge { + cursor: String! + node: ChannelBuildOrEmbeddedUpdate! +} + +type ChannelBuildsAndEmbeddedUpdatesConnection { + edges: [ChannelBuildOrEmbeddedUpdateEdge!]! + pageInfo: PageInfo! +} + +input ChannelFilterInput { + searchTerm: String +} + +type ChannelQuery { + """Query a Channel by ID""" + byId( + """Channel ID to use to look up channel""" + channelId: ID! + ): UpdateChannel! +} + +type ChannelRuntimeCumulativeMetricsOverTimeData { + data: LineChartData! + metricsAtLastTimestamp: [LineDatapoint!]! + mostPopularUpdates: [Update!]! +} + +type ChannelRuntimeEdge { + cursor: String! + node: RuntimeWithLastActivity! +} + +type ChannelRuntimesConnection { + edges: [ChannelRuntimeEdge!]! + pageInfo: PageInfo! +} + +type Charge { + amount: Int! + createdAt: DateTime! + id: ID! + invoiceId: String + paid: Boolean! + receiptUrl: String + wasRefunded: Boolean! +} + +type ClaudeAuthorization { + authorizationUrl: String! + expiresAt: DateTime! + requestId: ID! +} + +type CodeSigningInfo { + alg: String! + keyid: String! + sig: String! +} + +input CodeSigningInfoInput { + alg: String! + keyid: String! + sig: String! +} + +type CodexConnectionPollResult { + connection: AgentProviderConnection + status: AgentProviderConnectionAuthorizationStatus! +} + +type CodexDeviceAuthorization { + expiresAt: DateTime! + pollingIntervalSeconds: Int! + requestId: ID! + userCode: String! + verificationUrl: String! +} + +input CompleteClaudeConnectionInput { + authorizationCode: String! + requestId: ID! +} + +input CompletePostHogConnectionInput { + code: String! + state: ID! +} + +input CompleteSupabaseOAuthInput { + code: String! + state: ID! +} + +type Concurrencies { + android: Int! + ios: Int! + total: Int! +} + +union ConcurrencyConsumer = Build | JobRun + +enum ContinentCode { + AF + AN + AS + EU + NA + OC + SA + T1 +} + +type ConvexIntegrationQuery { + clientIdentifier: String! +} + +type ConvexProject { + app: App! + convexProjectIdentifier: String! + convexProjectName: String! + convexProjectSlug: String! + convexTeamConnection: ConvexTeamConnection! + createdAt: DateTime! + id: ID! + updatedAt: DateTime! +} + +type ConvexProjectMutation { + deleteConvexProject(convexProjectId: ID!): ID! + setupConvexProject(input: SetupConvexProjectInput!): SetupConvexProjectResult! +} + +type ConvexTeamConnection { + account: Account! + convexProjects: [ConvexProject!]! + convexTeamIdentifier: String! + convexTeamName: String! + convexTeamSlug: String! + createdAt: DateTime! + hasBeenClaimed: Boolean! + id: ID! + invitedAt: DateTime + invitedEmail: String + updatedAt: DateTime! +} + +type ConvexTeamConnectionMutation { + createConvexTeamConnection(convexTeamConnectionData: CreateConvexTeamConnectionInput!): ConvexTeamConnection! + deleteConvexTeamConnection(convexTeamConnectionId: ID!): ConvexTeamConnection! + sendConvexTeamInviteToVerifiedEmail(input: SendConvexTeamInviteToVerifiedEmailInput!): Boolean! +} + +enum CrashSampleFor { + NEWEST + OLDEST +} + +input CrashesFilters { + crashKind: [WorkerDeploymentCrashKind!] + name: [String!] +} + +input CreateAccessTokenInput { + actorID: ID! + note: String + + """ + When given, requests authenticated with the new token are restricted to these scopes instead + of the actor's full access. Either a single ALL_ACCOUNTS scope or one ACCOUNT scope per + account. + """ + scopes: [AccessTokenScopeInput!] +} + +type CreateAccessTokenResponse { + """AccessToken created""" + accessToken: AccessToken! + + """Full token string to be used for authentication""" + token: String! +} + +input CreateAndConfigureRepositoryInput { + appId: ID! + installationIdentifier: Int! +} + +input CreateAndroidDeviceRunSessionInput { + """AVD hardware profile id to use for the emulator (e.g. "pixel_9").""" + deviceIdentifier: String + + """ + Android SDK system image package to use for the emulator (e.g. + "system-images;android-35;google_apis;x86_64"). + """ + systemImagePackage: String +} + +input CreateAndroidSubmissionInput { + appId: ID! + archiveSource: SubmissionArchiveSourceInput + config: AndroidSubmissionConfigInput! + submittedBuildId: ID +} + +input CreateBrownfieldArtifactInput { + appId: ID! + bundleName: String! + + """Filename for the artifact (e.g. "MyFramework.xcframework.tar.gz").""" + filename: String! + platform: AppPlatform! + + """The ID of the turtle job run that is producing this artifact.""" + producingTurtleJobRunId: ID! + + """ + Size of the artifact in bytes. Required for generating the signed upload URL. + """ + size: Int! + + """ + Optional. If omitted, the version is auto-incremented from the latest artifact for the same platform and bundle name. + """ + version: String +} + +type CreateBrownfieldArtifactResult { + artifact: BrownfieldArtifact! + + """Headers to include with the upload request.""" + uploadHeaders: JSONObject! + + """Signed URL to upload the artifact to.""" + uploadUrl: String! +} + +type CreateBuildResult { + build: Build! + deprecationInfo: EASBuildDeprecationInfo +} + +input CreateConvexTeamConnectionInput { + accountId: ID! + convexTeamName: String + deploymentRegion: String! +} + +input CreateDeviceRunSessionInput { + """ + The coding agent sending this request, recorded for analytics only and never used for + authorization. Surrounding whitespace is trimmed, and a blank value or one over 200 characters + is treated as omitted. + """ + agentId: String + + """ + The coding agent's own session identifier, recorded for analytics only and never used for + authorization. Surrounding whitespace is trimmed, and a blank value or one over 200 characters + is treated as omitted. + """ + agentSessionId: String + + """Android emulator options. Only supported when platform is ANDROID.""" + android: CreateAndroidDeviceRunSessionInput + appId: ID! + + """ + Application archive URL to download, install, and launch before the simulator session + becomes available. Mutually exclusive with buildId, buildFingerprint, and expoGo. + """ + applicationArchiveUrl: String + + """ + Fingerprint hash of an EAS Build to install and launch before the simulator session becomes + available; the server resolves it to the most recent installable build with this fingerprint. + Mutually exclusive with buildId, applicationArchiveUrl, and expoGo. + """ + buildFingerprint: String + + """ + EAS Build to install and launch before the simulator session becomes available. + Mutually exclusive with buildFingerprint, applicationArchiveUrl, and expoGo. + """ + buildId: ID + + """ + Origins of pages that may call the session's preview host API cross-origin, for example to + render the device with @expo/hub-client. Framing the preview does not need this. Same value + rules as frameAncestors. At most 10. The page still needs the preview token from remoteConfig. + If omitted, only expo.dev may call the preview API cross-origin. + """ + corsOrigins: [String!] + + """ + Where the virtual device's proxied network traffic exits to the internet. If + omitted, all traffic exits from EAS infrastructure. + """ + egress: DeviceRunSessionEgress + + """ + Install and launch Expo Go before the simulator session becomes available. The server resolves + the platform-specific application archive. Mutually exclusive with buildId, buildFingerprint, + and applicationArchiveUrl. + """ + expoGo: Boolean + + """ + Origins of pages that may embed the session's preview host in a frame, next to expo.dev. + Each value is an https origin such as https://app.example.com or a subdomain wildcard such + as https://*.example.com; plain http is accepted for localhost only. At most 10. The + embedding page still needs the preview token from remoteConfig, and whoever embeds the + preview can also control the device. If omitted, only expo.dev may embed the preview. + """ + frameAncestors: [String!] + + """iOS Simulator options. Only supported when platform is IOS.""" + ios: CreateIosDeviceRunSessionInput + + """ + Arguments passed to the installed application when it is launched. Requires buildId, + applicationArchiveUrl, or expoGo. + """ + launchArgs: [String!] + + """ + Stop the session automatically after this many minutes without observed + session activity. Must be positive and smaller than the session's maximum + duration (maxRunTimeMinutes when set). Only supported for + agent-device, argent, and Appium sessions. If omitted, the session has no idle + timeout. + """ + maxIdleTimeMinutes: Int + + """ + Maximum usable session duration, in minutes, starting when the remote session + is ready. Must be non-negative and no greater than 40 for normal-priority + accounts or 115 for high-priority accounts. The backing job receives an + additional five minutes for cleanup. + """ + maxRunTimeMinutes: Int + + """ + Human-readable label for the session, at most 255 characters after leading and + trailing whitespace is trimmed. If omitted, the session is unnamed and clients + fall back to identifying it by id. + """ + name: String + + """ + Record HTTP(S) traffic from apps on the device for the whole session. iOS only. + HTTPS is decrypted, so recordings contain credentials and cookies in cleartext. + If omitted, no traffic is recorded. + """ + networkCapture: Boolean + + """ + What network capture may keep beyond method, URL, status, timing and size: header, query, + request-body, response-body. Each can carry credentials, so nothing here is recorded unless + it is asked for. Requires networkCapture. + """ + networkCaptureFields: [String!] + + """ + Expo or development-client URL to open after launching the installed application. Requires + buildId, applicationArchiveUrl, or expoGo. + """ + openUrl: String + + """ + The version of the package backing the device run session (e.g. "0.1.3-alpha.3"). + If omitted, consumers treat the session as pinned to "latest". + """ + packageVersion: String + platform: AppPlatform! + + """ + Prefer public preview links and show the preview host's Share button. Defaults to false. + This is a presentation preference and does not change access to bearer links. + """ + publicPreview: Boolean + + """ + The client sending this request, recorded for analytics only. Omit it when the + client is not one of the listed values; the session is then reported as unknown. + """ + requestOrigin: DeviceRunSessionRequestOrigin + resourceClass: DeviceRunSessionResourceClass + + """ + Expo SDK version used to select an Expo Go application archive. Only supported when expoGo is + true. If omitted, the current Expo Go archive for the platform is used. + """ + sdkVersion: String + + """ + Free-form labels for grouping sessions, for example one per app variant. + """ + tags: [String!] + type: DeviceRunSessionType! +} + +input CreateEnvironmentSecretInput { + name: String! + type: EnvironmentSecretType + value: String! +} + +input CreateEnvironmentVariableInput { + environments: [EnvironmentVariableEnvironment!] + fileName: String + name: String! + overwrite: Boolean + type: EnvironmentSecretType + value: String! + visibility: EnvironmentVariableVisibility! +} + +input CreateFingerprintInput { + hash: String! + source: FingerprintSourceInput +} + +input CreateGitHubAppInstallationInput { + accountId: ID! + githubAppRegistrationId: ID + installationIdentifier: Int! +} + +input CreateGitHubBuildTriggerInput { + appId: ID! + autoSubmit: Boolean! + buildProfile: String! + environment: EnvironmentVariableEnvironment + executionBehavior: GitHubBuildTriggerExecutionBehavior! + isActive: Boolean! + platform: AppPlatform! + + """ + A branch or tag name, or a wildcard pattern where the code change originates from. For example, `main` or `release/*`. + """ + sourcePattern: String! + submitProfile: String + + """ + A branch name or a wildcard pattern that the pull request targets. For example, `main` or `release/*`. + """ + targetPattern: String + type: GitHubBuildTriggerType! +} + +input CreateGitHubJobRunTriggerInput { + appId: ID! + isActive: Boolean! + jobType: GitHubJobRunJobType! + sourcePattern: String! + targetPattern: String + triggerType: GitHubJobRunTriggerType! +} + +input CreateGitHubRepositoryInput { + appId: ID! + githubAppInstallationId: ID! + githubRepositoryIdentifier: Int! + nodeIdentifier: String! +} + +input CreateGitHubRepositorySettingsInput { + appId: ID! + + """ + The base directory is the directory to change to before starting a build. This string should be a properly formatted POSIX path starting with '/', './', or the name of the directory relative to the root of the repository. Valid examples include: '/apps/expo-app', './apps/expo-app', and 'apps/expo-app'. This is intended for monorepos or apps that live in a subdirectory of a repository. + """ + baseDirectory: String! +} + +input CreateIosDeviceRunSessionInput { + """ + Simulator device name or UDID to use for the session (e.g. "iPhone 16 Pro"). + """ + deviceIdentifier: String +} + +input CreateIosSubmissionInput { + appId: ID! + archiveSource: SubmissionArchiveSourceInput + config: IosSubmissionConfigInput! + submittedBuildId: ID +} + +input CreatePostHogAccountRequestInput { + accountId: ID! + region: PostHogRegion! +} + +input CreatePostHogDeepLinkInput { + """ + When set, deep-links to this app's linked PostHog project; otherwise lands on the organization home. + """ + appId: ID + posthogOrganizationConnectionId: ID! + + """ + Which EAS surface opened the link; omitted lets PostHog apply its own default. + """ + purpose: PostHogDeepLinkPurpose +} + +input CreateSandboxArtifactUploadSessionInput { + filename: String! + name: String! + size: ByteSize! +} + +type CreateSandboxArtifactUploadSessionResult { + artifact: SandboxArtifact! + uploadSession: SandboxArtifactUploadSession! +} + +input CreateSandboxInput { + agentRunId: ID + appId: ID! + image: String + operatingSystem: SandboxOperatingSystem! + projectArchive: SandboxProjectArchiveInput + resourceClass: SandboxResourceClass! +} + +input CreateSentryProjectInput { + appId: ID! + sentryProjectId: String! + sentryProjectSlug: String! +} + +input CreateSharedEnvironmentVariableInput { + environments: [EnvironmentVariableEnvironment!] + fileName: String + name: String! + overwrite: Boolean + type: EnvironmentSecretType + value: String! + visibility: EnvironmentVariableVisibility! +} + +type CreateSubmissionResult { + """Created submission""" + submission: Submission! +} + +input CreateVexoAccountConnectionInput { + accountId: ID! + code: String! +} + +input CreateVexoAppInput { + appId: ID! + domain: String + iconUrl: String + name: String! + owner: String! + slug: String! + vexoIdentifier: String! +} + +input CreateWorkflowDeviceTestCaseResultsInput { + """Test case results to create. (path, retryCount) pairs must be unique.""" + testCaseResults: [WorkflowDeviceTestCaseResultInput!]! + + """ID of the workflow job to associate results with.""" + workflowJobId: ID! +} + +type CumulativeAverageMetrics { + averageUpdatePayloadBytes: Int! + launchAssetCount: Int! +} + +type CumulativeMetrics { + data: UpdatesMetricsData! + metricsAtLastTimestamp: CumulativeMetricsTotals! +} + +type CumulativeMetricsOverTimeData { + data: LineChartData! + metricsAtLastTimestamp: [LineDatapoint!]! +} + +type CumulativeMetricsTotals { + totalFailedInstalls: Int! + totalInstalls: Int! +} + +type CumulativeUpdatesDataset { + cumulative: [Int!]! + difference: [Int!]! + id: String! + label: String! +} + +input CustomBuildConfigInput { + path: String! +} + +type CustomDomainDNSRecord { + dnsContent: String! + dnsName: String! + dnsType: CustomDomainDNSRecordType! + isConfigured: Boolean! +} + +enum CustomDomainDNSRecordType { + A + CNAME + TXT +} + +type CustomDomainMutation { + deleteCustomDomain(customDomainId: ID!): DeleteCustomDomainResult! + refreshCustomDomain(customDomainId: ID!): WorkerCustomDomain! + registerCustomDomain(aliasName: WorkerDeploymentIdentifier, appId: ID!, hostname: String!): WorkerCustomDomain! +} + +type CustomDomainSetup { + sslErrors: [String!] + sslStatus: CustomDomainStatus + status: CustomDomainStatus! + verificationErrors: [String!] + verificationStatus: CustomDomainStatus +} + +enum CustomDomainStatus { + ACTIVE + ERROR + PENDING + TIMED_OUT +} + +enum DashboardViewPin { + ACTIVITY + OVERVIEW +} + +input DatasetTimespan { + end: DateTime! + start: DateTime! +} + +"""Date custom scalar type""" +scalar DateTime + +type DeleteAccessTokenResult { + id: ID! +} + +type DeleteAccountResult { + id: ID! +} + +type DeleteAccountSSOConfigurationResult { + id: ID! +} + +type DeleteAliasResult { + aliasName: WorkerDeploymentIdentifier + id: ID! +} + +type DeleteAndroidAppCredentialsResult { + id: ID! +} + +type DeleteAndroidKeystoreResult { + id: ID! +} + +type DeleteAppStoreConnectAppResult { + id: ID! +} + +type DeleteAppleDeviceResult { + id: ID! +} + +type DeleteAppleDistributionCertificateResult { + id: ID! +} + +type DeleteAppleProvisioningProfileResult { + id: ID! +} + +type DeleteBuildAnnotationResult { + buildAnnotationId: ID! +} + +type DeleteCustomDomainResult { + appId: ID! + hostname: String! + id: ID! +} + +type DeleteDeviceRunSessionResult { + id: ID! +} + +type DeleteDiscordUserResult { + id: ID! +} + +type DeleteEmbeddedUpdateResult { + id: ID! +} + +type DeleteEnvironmentSecretResult { + id: ID! +} + +type DeleteEnvironmentVariableResult { + id: ID! +} + +type DeleteGitHubUserResult { + id: ID! +} + +type DeleteGoogleServiceAccountKeyResult { + id: ID! +} + +type DeleteIosAppBuildCredentialsResult { + id: ID! +} + +type DeleteIosAppCredentialsResult { + id: ID! +} + +type DeleteLogRocketOrganizationResult { + accountId: ID! +} + +type DeleteLogRocketProjectResult { + id: ID! +} + +type DeleteRobotResult { + id: ID! +} + +type DeleteSSOUserResult { + id: ID! +} + +type DeleteSentryInstallationResult { + id: ID! +} + +type DeleteSentryProjectResult { + id: ID! +} + +type DeleteUpdateBranchResult { + id: ID! +} + +type DeleteUpdateChannelResult { + id: ID! +} + +type DeleteWebhookResult { + id: ID! +} + +type DeleteWorkerDeploymentResult { + deploymentIdentifier: WorkerDeploymentIdentifier! + id: ID! +} + +""" +Represents a Deployment - a set of Builds with the same Runtime Version and Channel +""" +type Deployment { + buildCount(statuses: [BuildStatus!]): Int! + builds(after: String, before: String, filter: DeploymentBuildFilterInput, first: Int, last: Int): DeploymentBuildsConnection! + channel: UpdateChannel! + id: ID! + + """Ordered the same way as 'updateBranches' in UpdateChannel""" + latestUpdatesPerBranch(limit: Int!, offset: Int!): [LatestUpdateOnBranch!]! + runtime: Runtime! +} + +type DeploymentBuildEdge { + cursor: String! + node: Build! +} + +input DeploymentBuildFilterInput { + status: BuildStatus +} + +"""Represents the connection over the builds edge of a Deployment""" +type DeploymentBuildsConnection { + edges: [DeploymentBuildEdge!]! + pageInfo: PageInfo! +} + +type DeploymentCumulativeMetricsOverTimeData { + data: LineChartData! + metricsAtLastTimestamp: [LineDatapoint!]! + mostPopularUpdates: [Update!]! +} + +type DeploymentEdge { + cursor: String! + node: Deployment! +} + +input DeploymentFilterInput { + channel: String + runtimeVersion: String +} + +type DeploymentQuery { + """Query a Deployment by ID""" + byId( + """Deployment ID to use to look up deployment""" + deploymentId: ID! + ): Deployment! +} + +type DeploymentResult { + data: UpdateDeploymentsConnection + error: String + success: Boolean! +} + +type DeploymentSignedUrlResult { + deploymentIdentifier: ID! + pendingWorkerDeploymentId: ID! + url: String! +} + +"""Represents the connection over the deployments edge of an App""" +type DeploymentsConnection { + edges: [DeploymentEdge!]! + pageInfo: PageInfo! +} + +type DeploymentsMutation { + assignAlias(aliasName: WorkerDeploymentIdentifier, appId: ID!, deploymentIdentifier: ID!): WorkerDeploymentAlias! + + """Create a signed deployment URL""" + createSignedDeploymentUrl(appId: ID!, deploymentIdentifier: ID): DeploymentSignedUrlResult! + deleteAlias(aliasName: WorkerDeploymentIdentifier, appId: ID!): DeleteAliasResult! + deleteWorkerDeployment(workerDeploymentId: ID!): DeleteWorkerDeploymentResult! + deleteWorkerDeploymentByIdentifier(appId: ID!, deploymentIdentifier: ID!): DeleteWorkerDeploymentResult! +} + +"""A DevDomainName for a deployed serverless app""" +scalar DevDomainName + +type DeviceRunSession { + app: App! + + """ + Direct application archive URL installed and launched for this session. Null when + the session uses an EAS Build or starts without an app. + """ + applicationArchiveUrl: String + artifacts: [DeviceRunSessionArtifact!]! + + """ + EAS Build installed and launched for this session. Null when the session uses + a direct application archive URL or starts without an app. + """ + build: Build + createdAt: DateTime! + finishedAt: DateTime + id: ID! + initiatingActor: Actor + + """ + Number of minutes without observed session activity after which the session + is stopped automatically. Null when the session has no idle timeout. + """ + maxIdleTimeMinutes: Int + + """ + Human-readable label set when the session is created or changed later with + updateDeviceRunSession. Null when the session has no name. + """ + name: String + + """ + The version of the package backing the device run session. Null means the session is + pinned to "latest" at the consumer side. + """ + packageVersion: String + platform: AppPlatform! + + """ + Whether this session prefers public preview links and the preview host's Share button. + This preference does not change access to bearer links. + """ + publicPreview: Boolean! + remoteConfig: DeviceRunSessionRemoteConfig + startedAt: DateTime + status: DeviceRunSessionStatus! + + """ + Free-form labels for grouping sessions, for example one per app variant. + """ + tags: [String!]! + turtleJobRun: JobRun + type: DeviceRunSessionType! + updatedAt: DateTime! +} + +type DeviceRunSessionArtifact { + createdAt: DateTime! + deviceRunSession: DeviceRunSession! + downloadUrl: String! + fileSizeBytes: Float + filename: String! + id: ID! + kind: String + metadata: JSONObject + name: String! + updatedAt: DateTime! +} + +type DeviceRunSessionArtifactQuery { + byId(deviceRunSessionArtifactId: ID!): DeviceRunSessionArtifact! +} + +""" +Where the virtual device's proxied network traffic exits to the internet. When +unset, all traffic exits from EAS infrastructure. +""" +enum DeviceRunSessionEgress { + """ + HTTP(S) requests that honor the device's system proxy exit through a reverse + tunnel to the machine running the EAS CLI egress client, so third parties see + that machine's public IP. Those requests fail while the client is disconnected. + Requests from libraries that bypass the system proxy are not covered and exit + from EAS infrastructure. The client must stay connected for the life of the + session. + """ + LOCAL +} + +input DeviceRunSessionFilterInput { + """Case-insensitive match anywhere in the session name.""" + name: String + platforms: [AppPlatform!] + statuses: [DeviceRunSessionStatus!] + + """Matches sessions carrying every listed tag. Case-insensitive.""" + tags: [String!] + types: [DeviceRunSessionType!] +} + +type DeviceRunSessionMutation { + """Create a device run session""" + createDeviceRunSession(deviceRunSessionInput: CreateDeviceRunSessionInput!): DeviceRunSession! + + """ + Permanently delete a finished session and its artifacts. The backing job, + if present, must also have finished. Returns the deleted session ID. + """ + deleteDeviceRunSession(deviceRunSessionId: ID!): DeleteDeviceRunSessionResult! + + """ + Ensure a device run session is stopped. Idempotent: if the session has already + finished, the existing session is returned unchanged (an ERRORED session stays + ERRORED). + """ + ensureDeviceRunSessionStopped(deviceRunSessionId: ID!): DeviceRunSession! + + """ + Update a device run session name and tags, including on sessions + that have already finished. + """ + updateDeviceRunSession(deviceRunSessionId: ID!, input: UpdateDeviceRunSessionInput!): DeviceRunSession! +} + +"""Virtual device profiles available for new device run sessions.""" +type DeviceRunSessionProfiles { + android: AndroidDeviceRunSessionProfiles! + ios: IosDeviceRunSessionProfiles! +} + +type DeviceRunSessionQuery { + byId(deviceRunSessionId: ID!): DeviceRunSession! + profiles: DeviceRunSessionProfiles! +} + +union DeviceRunSessionRemoteConfig = AgentDeviceRunSessionRemoteConfig | AppiumRunSessionRemoteConfig | ArgentRunSessionRemoteConfig | ServeSimRunSessionRemoteConfig | WebPreviewOnlyRunSessionRemoteConfig + +""" +The client that sent a session creation request, as reported by that client. Recorded +for analytics only and never used for authorization. +""" +enum DeviceRunSessionRequestOrigin { + EAS_CLI + MCP + WEBSITE +} + +enum DeviceRunSessionResourceClass { + LARGE + MEDIUM +} + +enum DeviceRunSessionStatus { + ERRORED + IN_PROGRESS + NEW + + """The session's job run waits in the queue for a worker.""" + QUEUED + + """A worker runs the session's job and prepares the virtual device.""" + STARTING + STOPPED +} + +enum DeviceRunSessionType { + AGENT_DEVICE + APPIUM + ARGENT + SERVE_SIM @deprecated(reason: "Use WEB_PREVIEW_ONLY instead.") + + """A session accessed only through its web preview.""" + WEB_PREVIEW_ONLY +} + +type DiscordUser { + discordIdentifier: String! + id: ID! + metadata: DiscordUserMetadata + userActor: UserActor! +} + +type DiscordUserMetadata { + discordAvatarUrl: String! + discordDiscriminator: String! + discordUsername: String! +} + +type DiscordUserMutation { + """Delete a Discord User by ID""" + deleteDiscordUser(id: ID!): DeleteDiscordUserResult! +} + +enum DistributionType { + INTERNAL + SIMULATOR + STORE +} + +enum EASBuildBillingResourceClass { + LARGE + MEDIUM +} + +type EASBuildDeprecationInfo { + message: String! + type: EASBuildDeprecationInfoType! +} + +enum EASBuildDeprecationInfoType { + INTERNAL + USER_FACING +} + +enum EASBuildWaiverType { + FAST_FAILED_BUILD + SYSTEM_ERROR +} + +enum EASService { + BUILDS + JOBS + MCP + OBSERVE + SIMULATOR + UPDATES +} + +enum EASServiceMetric { + ASSETS_REQUESTS + BANDWIDTH_USAGE + BUILDS + LOCAL_BUILDS + MANIFEST_REQUESTS + MCP_REQUESTS + OBSERVE_EVENTS + RUN_TIME + SIMULATOR_USAGE + UNIQUE_UPDATERS + UNIQUE_USERS +} + +type EASTotalPlanEnablement { + total: Int! + unit: EASTotalPlanEnablementUnit +} + +enum EASTotalPlanEnablementUnit { + BUILD + BYTE + CONCURRENCY + EVENT + MINUTE + REQUEST + UPDATER + USER +} + +input EditUpdateBranchInput { + appId: ID + id: ID + name: String + newName: String! +} + +type EmbeddedUpdate { + build: Build + channel: String! + createdAt: DateTime! + + """The manifest UUID baked into the binary by expo-updates at build time.""" + id: ID! + launchAsset: EmbeddedUpdateAsset! + platform: AppPlatform! + runtime: Runtime + runtimeVersion: String! + + """ + A short-lived signed URL for downloading this bundle's launch asset. Minted on access, + so only select it when the user is actually downloading. + """ + signedAssetUrl: String! +} + +type EmbeddedUpdateAsset { + contentType: String! + fileSHA256: String! + fileSize: Int! + finalFileSize: Int + id: ID! + storageKey: String! +} + +type EmbeddedUpdateAssetMutation { + """ + Returns a presigned POST URL for uploading an embedded update bundle. + Requires PUBLISH permission on the app. + """ + getSignedEmbeddedUpdateAssetUploadSpecifications(appId: ID!, contentType: String!, embeddedUpdateId: ID!): EmbeddedUpdateAssetUploadSpec! +} + +type EmbeddedUpdateAssetUploadSpec { + """ + Form fields that must be included with the POST request alongside the file. + """ + fields: JSONObject! + + """Presigned POST URL targeting the upload bucket. Valid for one hour.""" + presignedUrl: String! + + """ + Storage key (`{appId}/{embeddedUpdateId}`). Same key in both upload and destination buckets. + """ + storageKey: String! +} + +input EmbeddedUpdateFilterInput { + channel: String + platform: AppPlatform + runtimeVersion: String +} + +type EmbeddedUpdateMutation { + """ + Delete an embedded update by id. Best-effort: deleting an unknown id succeeds + (mirrors background deletion jobs). The linked asset row and underlying GCS + object are cleaned up via the entity's afterDelete trigger chain. + """ + deleteEmbeddedUpdate(id: ID!): DeleteEmbeddedUpdateResult! + + """ + Register an embedded bundle as the launch asset for a given app/platform/channel. + Returns EMBEDDED_UPDATE_ASSET_NOT_AVAILABLE if the asset has not been finalized yet, + or EMBEDDED_UPDATE_ALREADY_EXISTS if an embedded update with this id is already registered. + """ + uploadEmbeddedUpdate(input: UploadEmbeddedUpdateInput!): EmbeddedUpdate! +} + +type EmbeddedUpdateQuery { + """ + Look up an embedded update by its id and the owning app's id. + Throws EMBEDDED_UPDATE_NOT_FOUND when no embedded update with this id exists on + the given app within the caller's account. + """ + byId(appId: ID!, embeddedUpdateId: ID!): EmbeddedUpdate! +} + +enum EntityTypeName { + AccountEntity + AccountSSOConfigurationEntity + AgentProviderConnectionEntity + AndroidAppCredentialsEntity + AndroidKeystoreEntity + AppEntity + AppStoreConnectApiKeyEntity + AppStoreConnectAppEntity + AppleDeviceEntity + AppleDistributionCertificateEntity + AppleProvisioningProfileEntity + AppleTeamEntity + BillingContractEntity + BranchEntity + ChannelEntity + ConvexProjectEntity + ConvexTeamConnectionEntity + CustomerEntity + EchoProjectEntity @deprecated(reason: "This entity type was deleted. Existing audit logs can still reference it.") + EchoVersionEntity @deprecated(reason: "This entity type was deleted. Existing audit logs can still reference it.") + EnvironmentVariableEntity + GoogleServiceAccountKeyEntity + IosAppCredentialsEntity + LogRocketOrganizationEntity + LogRocketProjectEntity + PostHogOrganizationConnectionEntity + PostHogProjectEntity + SupabaseConnectionEntity + SupabaseProjectEntity + UserInvitationEntity + UserPermissionEntity + VexoAccountConnectionEntity + VexoAppEntity + WorkerCustomDomainEntity + WorkerDeploymentAliasEntity + WorkerEntity + WorkflowEntity + WorkflowRevisionEntity + WorkflowScheduleEntity +} + +type EnvironmentSecret { + createdAt: DateTime! + id: ID! + name: String! + type: EnvironmentSecretType! + updatedAt: DateTime! +} + +type EnvironmentSecretMutation { + """Create an environment secret for an Account""" + createEnvironmentSecretForAccount(accountId: String!, environmentSecretData: CreateEnvironmentSecretInput!): EnvironmentSecret! + + """Create an environment secret for an App""" + createEnvironmentSecretForApp(appId: String!, environmentSecretData: CreateEnvironmentSecretInput!): EnvironmentSecret! + + """Delete an environment secret""" + deleteEnvironmentSecret(id: String!): DeleteEnvironmentSecretResult! +} + +enum EnvironmentSecretType { + FILE_BASE64 + STRING +} + +type EnvironmentVariable { + createdAt: DateTime! + environments: [EnvironmentVariableEnvironment!] + fileName: String + id: ID! + isGlobal: Boolean + linkedEnvironments(appFullName: String, appId: String): [EnvironmentVariableEnvironment!] + name: String! + scope: EnvironmentVariableScope! + type: EnvironmentSecretType! + updatedAt: DateTime! + value(includeFileContent: Boolean): String + visibility: EnvironmentVariableVisibility +} + +"""A name of an environment variable environment""" +scalar EnvironmentVariableEnvironment + +type EnvironmentVariableMutation { + """Create bulk env variables for an Account""" + createBulkEnvironmentVariablesForAccount(accountId: ID!, environmentVariablesData: [CreateSharedEnvironmentVariableInput!]!): [EnvironmentVariable!]! + + """Create bulk env variables for an App""" + createBulkEnvironmentVariablesForApp(appId: ID!, environmentVariablesData: [CreateEnvironmentVariableInput!]!): [EnvironmentVariable!]! + + """Create an environment variable for an Account""" + createEnvironmentVariableForAccount(accountId: ID!, environmentVariableData: CreateSharedEnvironmentVariableInput!): EnvironmentVariable! + + """Create an environment variable for an App""" + createEnvironmentVariableForApp(appId: ID!, environmentVariableData: CreateEnvironmentVariableInput!): EnvironmentVariable! + + """Bulk delete environment variables""" + deleteBulkEnvironmentVariables(ids: [ID!]!): [DeleteEnvironmentVariableResult!]! + + """Delete an environment variable""" + deleteEnvironmentVariable(id: ID!): DeleteEnvironmentVariableResult! + + """Bulk update environment variables""" + updateBulkEnvironmentVariables(environmentVariablesData: [UpdateEnvironmentVariableInput!]!): [EnvironmentVariable!]! + + """Update an environment variable""" + updateEnvironmentVariable(environmentVariableData: UpdateEnvironmentVariableInput!): EnvironmentVariable! +} + +enum EnvironmentVariableScope { + PROJECT + SHARED +} + +enum EnvironmentVariableVisibility { + PUBLIC + SECRET + SENSITIVE +} + +type EnvironmentVariableWithSecret { + apps: [App!]! + createdAt: DateTime! + environments: [EnvironmentVariableEnvironment!] + fileName: String + id: ID! + isGlobal: Boolean! + linkedEnvironments(appFullName: String, appId: String): [EnvironmentVariableEnvironment!] + name: String! + scope: EnvironmentVariableScope! + sensitive: Boolean! + type: EnvironmentSecretType! + updatedAt: DateTime! + value(includeFileContent: Boolean): String + visibility: EnvironmentVariableVisibility! +} + +type EstimatedOverageAndCost { + id: ID! + + """The limit, in units, allowed by this plan""" + limit: Float! + metadata: AccountUsageMetadata + metricType: UsageMetricType! + service: EASService! + serviceMetric: EASServiceMetric! + + """Total cost of this particular metric, in cents""" + totalCost: Int! + value: Float! +} + +type EstimatedUsage { + id: ID! + jobTypeBreakdown: EstimatedUsageJobTypeBreakdown + limit: Float! + metricType: UsageMetricType! + platformBreakdown: EstimatedUsagePlatformBreakdown + service: EASService! + serviceMetric: EASServiceMetric! + value: Float! +} + +type EstimatedUsageJobTypeBreakdown { + other: Float! + simulator: Float! + workflows: Float! +} + +type EstimatedUsagePlatformBreakdown { + android: EstimatedUsagePlatformDetail! + ios: EstimatedUsagePlatformDetail! +} + +type EstimatedUsagePlatformDetail { + limit: Float! + value: Float! +} + +input ExitInterviewBeginChatTurnInput { + accountId: ID! +} + +type ExitInterviewBeginChatTurnResult { + ok: Boolean! +} + +input ExitInterviewCompleteInput { + accountId: ID! + conversationId: ID! + messages: [ExitInterviewMessageInput!]! + outcome: ExitInterviewOutcome! + + """ + Optional classification tags. Present when the website's tagging call + succeeded; omitted when it failed or when the user dismissed without + engaging. When present, drives the conversation-tagged event and the + tagged Slack notification. + """ + tags: ExitInterviewTagsInput +} + +type ExitInterviewCompleteResult { + success: Boolean! +} + +input ExitInterviewFeedbackInput { + accountId: ID! + conversationId: ID! + feedback: String! + outcome: ExitInterviewOutcome! +} + +type ExitInterviewFeedbackResult { + success: Boolean! +} + +input ExitInterviewMessageInput { + content: String! + role: ExitInterviewMessageRole! +} + +enum ExitInterviewMessageRole { + ASSISTANT + USER +} + +type ExitInterviewMutation { + """ + Gate a chat turn before the website route calls OpenAI. Enforces + authorization (account admin), eligibility (active paid subscription, + not cancelling), and a per-user rate limit (30 turns/hour). The + website's /api/exit-interview/chat route calls this server-to-server + on every turn; failures map to HTTP 4xx and abort the stream before + any OpenAI tokens are spent. + """ + beginChatTurn(input: ExitInterviewBeginChatTurnInput!): ExitInterviewBeginChatTurnResult! + + """ + Mark a treatment-variant exit-interview chat conversation complete. Emits + the conversation-completed event to RudderStack with the full transcript. + If the transcript carries at least one server-signed assistant turn (i.e. + the user actually engaged the bot), fans out the raw transcript to Slack. + If the client also provides tags, emits a separate conversation-tagged + event and a tagged Slack notification. Control-variant one-shot feedback + goes through submitFeedback. + """ + complete(input: ExitInterviewCompleteInput!): ExitInterviewCompleteResult! + + """ + Record a control-variant one-shot feedback submission. Emits the + feedback-submitted event to RudderStack. No Slack notification, no + tagging — those pipelines are scoped to the treatment chat flow via + complete. + """ + submitFeedback(input: ExitInterviewFeedbackInput!): ExitInterviewFeedbackResult! +} + +enum ExitInterviewOutcome { + CANCELLED_IMMEDIATELY + CONTINUED_TO_STRIPE + DISMISSED + KEPT_PLAN +} + +""" +Tags produced by the website's churn-classification step. The website runs +this synchronously before calling complete, then forwards the structured +result. Allowed category / sentiment values are enforced by the resolver. +""" +input ExitInterviewTagsInput { + actionableInsight: Boolean! + category: String! + competitorMention: String + confidence: String + productArea: String + sentiment: String! + summary: String +} + +enum Experiment { + ORBIT +} + +type ExperimentationQuery { + """ + Get account experimentation config for account-level A/B testing. + All users in the same account will be bucketed together. + """ + accountConfig: JSONObject! + + """Get device experimentation config""" + deviceConfig: JSONObject! + + """ + Get experimentation unit to use for device experiments. In this case, it is the IP address. + """ + deviceExperimentationUnit: ID! + + """Get user experimentation config""" + userConfig: JSONObject! +} + +type ExpoGoBuildQuery { + repackConfiguration(input: ExpoGoRepackInput!): ExpoGoProjectConfiguration! + supportedSdkVersionKeys: [String!]! + supportedSdkVersions: [ExpoGoSdkVersion!]! +} + +type ExpoGoProjectConfiguration { + files: [ExpoGoProjectFile!]! + sdkVersion: String! +} + +type ExpoGoProjectFile { + fileContents: String! + fileName: String! +} + +input ExpoGoRepackInput { + appId: ID! + appName: String + ascAppId: String! + bundleId: String! + sdkVersion: String +} + +type ExpoGoSdkVersion { + isBeta: Boolean! + isDeprecated: Boolean! + isLatest: Boolean! + sdkVersion: String! + sourceIpaUrl: String! +} + +union FcmSnippet = FcmSnippetLegacy | FcmSnippetV1 + +type FcmSnippetLegacy { + firstFourCharacters: String! + lastFourCharacters: String! +} + +type FcmSnippetV1 { + clientId: String + keyId: String! + projectId: String! + serviceAccountEmail: String! +} + +enum Feature { + """Priority Builds""" + BUILDS + + """Funds support for open source development""" + OPEN_SOURCE + + """Top Tier Support""" + SUPPORT + + """Share access to projects""" + TEAMS +} + +input FinalizeGitHubAppRegistrationInput { + code: String! + state: String! +} + +type Fingerprint { + app: App! + buildCount: Int! + builds(after: String, before: String, filter: FingerprintBuildsFilterInput, first: Int, last: Int): AppBuildsConnection! + createdAt: DateTime! + debugInfoUrl: String + hash: String! + id: ID! + source: FingerprintSource + updateCount: Int! + updatedAt: DateTime! + updates(after: String, before: String, first: Int, last: Int): AppUpdatesConnection! +} + +input FingerprintBuildsFilterInput { + channel: String + developmentClient: Boolean + distributions: [DistributionType!] + platforms: [AppPlatform!] + releaseChannel: String + simulator: Boolean +} + +input FingerprintFilterInput { + hashes: [String!] +} + +input FingerprintInfo { + fingerprintHash: String! + fingerprintSource: FingerprintSourceInput! +} + +input FingerprintInfoGroup { + android: FingerprintInfo + ios: FingerprintInfo + web: FingerprintInfo +} + +type FingerprintMutation { + """Create or get an existing fingerprint for an App""" + createOrGetExistingFingerprint(appId: ID!, fingerprintData: CreateFingerprintInput!): Fingerprint! +} + +type FingerprintSource { + bucketKey: String! + isDebugFingerprint: Boolean + type: FingerprintSourceType! +} + +input FingerprintSourceInput { + bucketKey: String + isDebugFingerprint: Boolean + type: FingerprintSourceType +} + +enum FingerprintSourceType { + GCS +} + +type FutureSubscription { + createdAt: DateTime! + id: ID! + meteredBillingStatus: MeteredBillingStatus! + planId: String! + recurringCents: Int + startDate: DateTime! +} + +type GenerateLogRocketOrganizationLinkResult { + url: String! +} + +input GenerateLogRocketOrganizationLinkingURLInput { + accountId: ID! + callbackUrl: String! +} + +type GenerateLogRocketReplayTokenResult { + orgSlug: String! + replayToken: String! +} + +type GenerateSentryTokenResult { + installationId: ID! + orgSlug: String! + token: String! +} + +type GetSignedAssetUploadSpecificationsResult { + specifications: [String!]! +} + +enum GitHubAppEnvironment { + DEVELOPMENT + PRODUCTION + STAGING +} + +type GitHubAppInstallation { + """The Expo account that owns the installation entity.""" + account: Account! + actor: Actor + id: ID! + installationIdentifier: Int! + metadata: GitHubAppInstallationMetadata! + + """ + The GitHub Enterprise registration this installation belongs to, or null for the default + github.com app. + """ + registration: GitHubAppRegistration +} + +enum GitHubAppInstallationAccountType { + ORGANIZATION + USER +} + +type GitHubAppInstallationMetadata { + githubAccountAvatarUrl: String + + """ + The login of the GitHub account that owns the installation. Not the display name. + """ + githubAccountName: String + githubAccountType: GitHubAppInstallationAccountType + installationStatus: GitHubAppInstallationStatus! +} + +type GitHubAppInstallationMutation { + """Create a GitHub App installation for an Account""" + createGitHubAppInstallationForAccount(githubAppInstallationData: CreateGitHubAppInstallationInput!): GitHubAppInstallation! + + """Delete a GitHub App installation by ID""" + deleteGitHubAppInstallation(githubAppInstallationId: ID!): GitHubAppInstallation! +} + +enum GitHubAppInstallationStatus { + ACTIVE + NOT_INSTALLED + SUSPENDED +} + +type GitHubAppMutation { + """ + Create a GitHub build for an app. Returns the ID of the background job receipt. Use BackgroundJobReceiptQuery to get the status of the job. + """ + createGitHubBuild(buildInput: GitHubBuildInput!): BackgroundJobReceipt! +} + +type GitHubAppQuery { + appIdentifier: String! + clientIdentifier: String! + environment: GitHubAppEnvironment! + installation(id: ID!): GitHubAppInstallation! + name: String! +} + +"""A GitHub App Expo can act through.""" +type GitHubAppRegistration { + """ + The Expo account that owns this registration, or null for the default github.com app. + """ + account: Account + + """ + Installations visible to the viewer's GitHub user, or null when the viewer's GitHub + authorization for this registration could not be used. The accompanying error says why. + """ + appInstallationsForViewer: [AccessibleGitHubAppInstallation!] + + """ + URL of the app's page on its GitHub instance, e.g. https://github.example.com/github-apps/expo. + """ + githubAppUrl: String! + + """Public OAuth client id of the GHE app.""" + githubClientIdentifier: String! + id: ID! + + """Origin of the GitHub instance.""" + origin: String! + + """ + Repositories the viewer's GitHub user can reach through the given installation. + """ + repositoriesForViewer(after: String, first: Int!, installationIdentifier: Int!): GitHubAppRegistrationRepositoriesConnection! + + """The GHE app slug.""" + slug: String! +} + +"""Registration data for the GitHub App manifest flow.""" +type GitHubAppRegistrationManifest { + githubManifestCreateUrl: String! + manifestJson: String! + state: String! +} + +type GitHubAppRegistrationMutation { + """ + Deletes a GitHub Enterprise app registration. Installations of the app and repository links + made through it are removed as well. The app itself remains on the GitHub Enterprise instance + and must be deleted there separately. + """ + deleteGitHubAppRegistration(githubAppRegistrationId: ID!): GitHubAppRegistration! + + """ + Finalizes a GitHub App registration after the GHE instance redirects back with a temporary + code. Exchanges the code for the app's credentials and stores the registration. + """ + finalizeGitHubAppRegistration(input: FinalizeGitHubAppRegistrationInput!): GitHubAppRegistration! + + """ + Starts a GitHub App registration on a GHE instance via the app manifest flow. Returns the + manifest for the browser to submit. + """ + startGitHubAppRegistration(input: StartGitHubAppRegistrationInput!): GitHubAppRegistrationManifest! +} + +type GitHubAppRegistrationQuery { + byId(githubAppRegistrationId: ID!): GitHubAppRegistration! +} + +type GitHubAppRegistrationRepositoriesConnection { + edges: [GitHubAppRegistrationRepositoryEdge!]! + pageInfo: PageInfo! +} + +type GitHubAppRegistrationRepositoryEdge { + cursor: String! + node: AccessibleGitHubRepository! +} + +input GitHubBuildInput { + appId: ID! + autoSubmit: Boolean + baseDirectory: String + buildProfile: String! + environment: EnvironmentVariableEnvironment + gitRef: String! + platform: AppPlatform! + + """ + Refresh the ad hoc provisioning profile before building. Supported for iOS builds with a build profile that uses internal distribution and remote credentials. + """ + refreshAdHocProvisioningProfile: Boolean + submitProfile: String +} + +type GitHubBuildTrigger { + app: App! + autoSubmit: Boolean! + buildProfile: String! + createdAt: DateTime! + environment: EnvironmentVariableEnvironment + executionBehavior: GitHubBuildTriggerExecutionBehavior! + id: ID! + isActive: Boolean! + lastRunAt: DateTime + lastRunBuild: Build + lastRunErrorCode: String + lastRunErrorMessage: String + lastRunStatus: GitHubBuildTriggerRunStatus + platform: AppPlatform! + sourcePattern: String! + submitProfile: String + targetPattern: String + type: GitHubBuildTriggerType! + updatedAt: DateTime! +} + +enum GitHubBuildTriggerExecutionBehavior { + ALWAYS + BASE_DIRECTORY_CHANGED +} + +type GitHubBuildTriggerMutation { + """Create GitHub build trigger for an App""" + createGitHubBuildTrigger(githubBuildTriggerData: CreateGitHubBuildTriggerInput!): GitHubBuildTrigger! + + """Delete GitHub build trigger by ID""" + deleteGitHubBuildTrigger(githubBuildTriggerId: ID!): GitHubBuildTrigger! + + """Update a GitHub build trigger by ID""" + updateGitHubBuildTrigger(githubBuildTriggerData: UpdateGitHubBuildTriggerInput!, githubBuildTriggerId: ID!): GitHubBuildTrigger! +} + +enum GitHubBuildTriggerRunStatus { + ERRORED + SUCCESS +} + +enum GitHubBuildTriggerType { + PULL_REQUEST_UPDATED + PUSH_TO_BRANCH + TAG_UPDATED +} + +enum GitHubJobRunJobType { + PUBLISH_UPDATE +} + +type GitHubJobRunTrigger { + app: App! + createdAt: DateTime! + id: ID! + isActive: Boolean! + jobType: GitHubJobRunJobType + lastRunAt: DateTime + lastRunErrorCode: String + lastRunErrorMessage: String + lastRunStatus: GitHubJobRunTriggerRunStatus + sourcePattern: String! + targetPattern: String + triggerType: GitHubJobRunTriggerType! +} + +type GitHubJobRunTriggerMutation { + createGitHubJobRunTrigger(gitHubJobRunTriggerData: CreateGitHubJobRunTriggerInput!): GitHubJobRunTrigger! + deleteGitHubJobRunTrigger(gitHubJobRunTriggerId: ID!): GitHubJobRunTrigger! + updateGitHubJobRunTrigger(gitHubJobRunTriggerData: UpdateGitHubJobRunTriggerInput!, gitHubJobRunTriggerId: ID!): GitHubJobRunTrigger! +} + +enum GitHubJobRunTriggerRunStatus { + ERRORED + SUCCESS +} + +enum GitHubJobRunTriggerType { + PULL_REQUEST_UPDATED + PUSH_TO_BRANCH +} + +type GitHubRepository { + app: App! + branchesForViewer: [String!]! + createdAt: DateTime! + githubAppInstallation: GitHubAppInstallation! + githubRepositoryIdentifier: Int! + githubRepositoryUrl: String + id: ID! + lastDeletionAttemptTime: DateTime + metadata: GitHubRepositoryMetadata! + nodeIdentifier: String! +} + +type GitHubRepositoryMetadata { + defaultBranch: String + githubRepoDescription: String + githubRepoName: String! + githubRepoOwnerName: String! + githubRepoUrl: String! + id: ID! + lastPushed: DateTime! + lastUpdated: DateTime! + private: Boolean! +} + +type GitHubRepositoryMutation { + """ + Configure EAS by pushing a commit to the default branch which updates or creates app.json, eas.json, and installs necessary dependencies. + """ + configureEAS(githubRepositoryId: ID!): BackgroundJobReceipt! + createAndConfigureRepository(input: CreateAndConfigureRepositoryInput!): BackgroundJobReceipt! + + """Create a GitHub repository for an App""" + createGitHubRepository(githubRepositoryData: CreateGitHubRepositoryInput!): GitHubRepository! + + """Delete a GitHub repository by ID in the background""" + scheduleGitHubRepositoryDeletion(githubRepositoryId: ID!): BackgroundJobReceipt! +} + +type GitHubRepositorySettings { + app: App! + baseDirectory: String! + id: ID! +} + +type GitHubRepositorySettingsMutation { + """Create GitHub repository settings for an App""" + createGitHubRepositorySettings(githubRepositorySettingsData: CreateGitHubRepositorySettingsInput!): GitHubRepositorySettings! + + """Delete GitHub repository settings by ID""" + deleteGitHubRepositorySettings(githubRepositorySettingsId: ID!): GitHubRepositorySettings! + + """Update GitHub repository settings""" + updateGitHubRepositorySettings(githubRepositorySettingsData: UpdateGitHubRepositorySettingsInput!, githubRepositorySettingsId: ID!): GitHubRepositorySettings! +} + +type GitHubUser { + githubUserIdentifier: String! + id: ID! + metadata: GitHubUserMetadata + registration: GitHubAppRegistration + userActor: UserActor! +} + +type GitHubUserMetadata { + avatarUrl: String! + login: String! + name: String + url: String! +} + +type GitHubUserMutation { + """Delete a GitHub User by ID""" + deleteGitHubUser(id: ID!): DeleteGitHubUserResult! + + """Generate a GitHub User Access Token""" + generateGitHubUserAccessToken(githubAppRegistrationId: ID): String @deprecated(reason: "This mutation is scheduled for removal.") +} + +type GoogleServiceAccountKey { + account: Account! + clientEmail: String! + clientIdentifier: String! + createdAt: DateTime! + id: ID! + keyJson: String! + privateKeyIdentifier: String! + projectIdentifier: String! + updatedAt: DateTime! +} + +input GoogleServiceAccountKeyInput { + jsonKey: JSONObject! +} + +type GoogleServiceAccountKeyMutation { + """Create a Google Service Account Key""" + createGoogleServiceAccountKey(accountId: ID!, googleServiceAccountKeyInput: GoogleServiceAccountKeyInput!): GoogleServiceAccountKey! + + """Delete a Google Service Account Key""" + deleteGoogleServiceAccountKey(id: ID!): DeleteGoogleServiceAccountKeyResult! +} + +type GoogleServiceAccountKeyQuery { + byId(id: ID!): GoogleServiceAccountKey! +} + +""" +The value field is always sent from the client as a string, +and then it's parsed server-side according to the filterType +""" +input InsightsFilter { + filterType: InsightsFilterType! + value: String! +} + +enum InsightsFilterType { + PLATFORM +} + +input InsightsTimespan { + end: DateTime! + start: DateTime! +} + +type Invoice { + """The total amount due for the invoice, in cents""" + amountDue: Int! + + """ + The total amount that has been paid, considering any discounts or account credit. Value is in cents. + """ + amountPaid: Int! + + """ + The total amount that needs to be paid, considering any discounts or account credit. Value is in cents. + """ + amountRemaining: Int! + discount: InvoiceDiscount + id: ID! + lineItems: [InvoiceLineItem!]! + period: InvoicePeriod! + startingBalance: Int! + subtotal: Int! + total: Int! + totalDiscountedAmount: Int! +} + +type InvoiceDiscount { + """The coupon's discount value, in percentage or in dollar amount""" + amount: Int! + duration: String! + durationInMonths: Int + id: ID! + name: String! + type: InvoiceDiscountType! +} + +enum InvoiceDiscountType { + AMOUNT + PERCENTAGE +} + +type InvoiceLineItem { + """Line-item amount in cents""" + amount: Int! + description: String! + id: ID! + metadata: JSONObject! + period: InvoicePeriod! + price: StripePrice + proration: Boolean! + quantity: Int! + + """The unit amount excluding tax, in cents""" + unitAmountExcludingTax: Float +} + +type InvoicePeriod { + end: DateTime! + start: DateTime! +} + +type InvoiceQuery { + """ + Previews the invoice for the specified number of additional concurrencies. + This is the total number of concurrencies the customer wishes to purchase + on top of their base plan, not the relative change in concurrencies + the customer wishes to make. For example, specify "3" if the customer has + two add-on concurrencies and wishes to purchase one more. + """ + previewInvoiceForAdditionalConcurrenciesCountUpdate(accountID: ID!, additionalConcurrenciesCount: Int!): Invoice + + """Preview an upgrade subscription invoice, with proration""" + previewInvoiceForSubscriptionUpdate(accountId: String!, couponCode: String, newPlanIdentifier: String!): Invoice! +} + +type IosAppBuildCredentials { + distributionCertificate: AppleDistributionCertificate + id: ID! + iosAppCredentials: IosAppCredentials! + iosDistributionType: IosDistributionType! + provisioningProfile: AppleProvisioningProfile +} + +input IosAppBuildCredentialsFilter { + iosDistributionType: IosDistributionType +} + +input IosAppBuildCredentialsInput { + distributionCertificateId: ID! + iosDistributionType: IosDistributionType! + provisioningProfileId: ID! +} + +type IosAppBuildCredentialsMutation { + """Create a set of build credentials for an iOS app""" + createIosAppBuildCredentials(iosAppBuildCredentialsInput: IosAppBuildCredentialsInput!, iosAppCredentialsId: ID!): IosAppBuildCredentials! + + """Disassociate the build credentials from an iOS app""" + deleteIosAppBuildCredentials(id: ID!): DeleteIosAppBuildCredentialsResult! + + """Set the distribution certificate to be used for an iOS app""" + setDistributionCertificate(distributionCertificateId: ID!, id: ID!): IosAppBuildCredentials! + + """Set the provisioning profile to be used for an iOS app""" + setProvisioningProfile(id: ID!, provisioningProfileId: ID!): IosAppBuildCredentials! +} + +type IosAppCredentials { + app: App! + appStoreConnectApiKeyForBuilds: AppStoreConnectApiKey + appStoreConnectApiKeyForSubmissions: AppStoreConnectApiKey + appleAppIdentifier: AppleAppIdentifier! + appleTeam: AppleTeam + id: ID! + iosAppBuildCredentialsArray(filter: IosAppBuildCredentialsFilter): [IosAppBuildCredentials!]! @deprecated(reason: "use iosAppBuildCredentialsList instead") + iosAppBuildCredentialsList(filter: IosAppBuildCredentialsFilter): [IosAppBuildCredentials!]! + pushKey: ApplePushKey +} + +input IosAppCredentialsFilter { + appleAppIdentifierId: String +} + +input IosAppCredentialsInput { + appStoreConnectApiKeyForBuildsId: ID + appStoreConnectApiKeyForSubmissionsId: ID + appleTeamId: ID + pushKeyId: ID +} + +type IosAppCredentialsMutation { + """Create a set of credentials for an iOS app""" + createIosAppCredentials(appId: ID!, appleAppIdentifierId: ID!, iosAppCredentialsInput: IosAppCredentialsInput!): IosAppCredentials! + + """Delete a set of credentials for an iOS app""" + deleteIosAppCredentials(id: ID!): DeleteIosAppCredentialsResult! + + """Set the App Store Connect Api Key to be used for submitting an iOS app""" + setAppStoreConnectApiKeyForSubmissions(ascApiKeyId: ID!, id: ID!): IosAppCredentials! + + """Set the push key to be used in an iOS app""" + setPushKey(id: ID!, pushKeyId: ID!): IosAppCredentials! + + """Update a set of credentials for an iOS app""" + updateIosAppCredentials(id: ID!, iosAppCredentialsInput: IosAppCredentialsInput!): IosAppCredentials! +} + +"""@deprecated Use developmentClient option instead.""" +enum IosBuildType { + DEVELOPMENT_CLIENT + RELEASE +} + +input IosBuilderEnvironmentInput { + bun: String + bundler: String + cocoapods: String + corepack: Boolean + env: JSONObject + expoCli: String + fastlane: String + image: String + node: String + pnpm: String + yarn: String +} + +type IosDeviceRunSessionDevice { + """Human-readable device name to show to users (e.g. "iPhone 17 Pro").""" + displayName: String! + + """ + Simulator device name accepted by ios.deviceIdentifier (e.g. "iPhone 17 Pro"). + """ + identifier: String! +} + +type IosDeviceRunSessionProfiles { + devices: [IosDeviceRunSessionDevice!]! +} + +enum IosDistributionType { + AD_HOC + APP_STORE + DEVELOPMENT + ENTERPRISE +} + +input IosJobDistributionCertificateInput { + dataBase64: String! + password: String! +} + +input IosJobInput { + applicationArchivePath: String + + """@deprecated""" + artifactPath: String + buildArtifactPaths: [String!] + buildConfiguration: String + buildProfile: String + + """@deprecated""" + buildType: IosBuildType + builderEnvironment: IosBuilderEnvironmentInput + cache: BuildCacheInput + customBuildConfig: CustomBuildConfigInput + developmentClient: Boolean + + """@deprecated""" + distribution: DistributionType + environment: EnvironmentVariableEnvironment + experimental: JSONObject + loggerLevel: WorkerLoggerLevel + mode: BuildMode + projectArchive: ProjectArchiveSourceInput! + projectRootDirectory: String! + releaseChannel: String + scheme: String + secrets: IosJobSecretsInput + simulator: Boolean + triggeredBy: BuildTrigger + type: BuildWorkflow! + updates: BuildUpdatesInput + username: String + version: IosJobVersionInput +} + +input IosJobOverridesInput { + applicationArchivePath: String + + """@deprecated""" + artifactPath: String + buildArtifactPaths: [String!] + buildConfiguration: String + buildProfile: String + + """@deprecated""" + buildType: IosBuildType + builderEnvironment: IosBuilderEnvironmentInput + cache: BuildCacheInput + customBuildConfig: CustomBuildConfigInput + developmentClient: Boolean + + """@deprecated""" + distribution: DistributionType + experimental: JSONObject + loggerLevel: WorkerLoggerLevel + mode: BuildMode + releaseChannel: String + resign: BuildResignInput + scheme: String + secrets: IosJobSecretsInput + simulator: Boolean + type: BuildWorkflow + updates: BuildUpdatesInput + username: String + version: IosJobVersionInput +} + +input IosJobSecretsInput { + buildCredentials: [IosJobTargetCredentialsInput] +} + +input IosJobTargetCredentialsInput { + distributionCertificate: IosJobDistributionCertificateInput! + provisioningProfileBase64: String! + targetName: String! +} + +input IosJobVersionInput { + buildNumber: String! +} + +"""@deprecated Use developmentClient option instead.""" +enum IosManagedBuildType { + DEVELOPMENT_CLIENT + RELEASE +} + +enum IosSchemeBuildConfiguration { + DEBUG + RELEASE +} + +type IosSubmissionConfig { + appleIdUsername: String + ascApiKeyId: String + ascAppIdentifier: String! +} + +input IosSubmissionConfigInput { + appleAppSpecificPassword: String + appleIdUsername: String + ascApiKey: AscApiKeyInput + ascApiKeyId: String + ascAppIdentifier: String! + changelog: String + groups: [String!] + isVerboseFastlaneEnabled: Boolean +} + +""" +The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +""" +scalar JSON + +""" +The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +""" +scalar JSONObject + +"""Represents a Turtle Job Run""" +type JobRun { + app: App! + artifacts: [WorkflowArtifact!]! + createdAt: DateTime! + displayName: String + endedAt: DateTime + enqueuedAt: DateTime + errors: [JobRunError!]! + expiresAt: DateTime! + gitCommitHash: String + gitCommitMessage: String + gitRef: String + id: ID! + initiatingActor: Actor + isWaived: Boolean! + logFileUrls: [String!]! + + """Max run time in seconds for this job run.""" + maxRunTimeSeconds: Int! + name: String! + priority: JobRunPriority! + + """String describing the worker profile used to run this job run.""" + resourceClassDisplayName: String! + + """The active ssh session for this job run, if any.""" + sshSession: TurtleSshSession + startedAt: DateTime + status: JobRunStatus! + updateGroups: [[Update!]!]! + workflowJob: WorkflowJob +} + +type JobRunError { + buildPhase: String + docsUrl: String + errorCode: String! + message: String! +} + +type JobRunMutation { + """Cancel an EAS Job Run""" + cancelJobRun(jobRunId: ID!): JobRun! +} + +enum JobRunPriority { + HIGH + NORMAL +} + +type JobRunQuery { + """Look up EAS Job Run by ID""" + byId(jobRunId: ID!): JobRun! +} + +enum JobRunStatus { + CANCELED + ERRORED + FINISHED + IN_PROGRESS + IN_QUEUE + NEW + PENDING_CANCEL +} + +type KeystoreGenerationUrl { + id: ID! + url: String! +} + +type KeystoreGenerationUrlMutation { + """Create a Keystore Generation URL""" + createKeystoreGenerationUrl: KeystoreGenerationUrl! +} + +type LatestUpdateOnBranch { + branchId: String! + update: Update +} + +type LeaveAccountResult { + success: Boolean! +} + +type LineChartData { + datasets: [LineDataset!]! + labels: [String!]! +} + +type LineDatapoint { + data: Int! + id: ID! + label: String! +} + +type LineDataset { + data: [Int]! + id: ID! + label: String! +} + +input LinkLogRocketOrganizationToExpoAccountInput { + accountId: ID! + client_id: String! + client_secret: String! + orgName: String! + orgSlug: String! + state: String! +} + +input LinkSentryInstallationToExpoAccountInput { + accountId: ID! + code: String! + installationId: ID! + sentryOrgSlug: String! +} + +input LinkSupabaseProjectInput { + appId: ID! + supabaseProjectRef: String! +} + +input LocalBuildArchiveSourceInput { + bucketKey: String! + type: LocalBuildArchiveSourceType! +} + +enum LocalBuildArchiveSourceType { + GCS +} + +input LocalBuildJobInput { + developmentClient: Boolean + platform: AppPlatform! + simulator: Boolean +} + +type LogNameTypeMapping { + publicName: String! + typeName: EntityTypeName! +} + +type LogRocketOrganization { + account: Account! + createdAt: DateTime! + id: ID! + orgName: String! + orgSlug: String! +} + +type LogRocketOrganizationMutation { + """Delete a LogRocket organization by ID""" + deleteLogRocketOrganization(accountId: ID!): DeleteLogRocketOrganizationResult! + + """Generate a LogRocket linking URL""" + generateLogRocketOrganizationLinkingURL(input: GenerateLogRocketOrganizationLinkingURLInput!): GenerateLogRocketOrganizationLinkResult! + + """Generate a LogRocket replay token for an organization""" + generateLogRocketReplayToken(accountId: ID!): GenerateLogRocketReplayTokenResult! + + """Link a LogRocket organization to an Expo account""" + linkLogRocketOrganizationToExpoAccount(input: LinkLogRocketOrganizationToExpoAccountInput!): LogRocketOrganization! +} + +type LogRocketProject { + app: App! + createdAt: DateTime! + id: ID! + logRocketOrgId: ID! + logRocketProjectSlug: String! + updatedAt: DateTime! +} + +type LogRocketProjectMutation { + """Create a LogRocket project""" + createLogRocketProject(appId: ID!, logRocketProjectSlug: String!): LogRocketProject! + + """Delete a LogRocket project by ID""" + deleteLogRocketProject(logRocketProjectId: ID!): DeleteLogRocketProjectResult! +} + +input LogsTimespan { + end: DateTime! + start: DateTime +} + +type MeMutation { + """Add an additional second factor device""" + addSecondFactorDevice(deviceConfiguration: SecondFactorDeviceConfiguration!, otp: String): SecondFactorDeviceConfigurationResult! + + """ + Certify an initiated second factor authentication method for the current user + """ + certifySecondFactorDevice(otp: String!): SecondFactorBooleanResult! + + """Create a new Account and grant this User the owner Role""" + createAccount(accountData: AccountDataInput!): Account! + + """Delete a second factor device""" + deleteSecondFactorDevice(otp: String, userSecondFactorDeviceId: ID!): SecondFactorBooleanResult! + + """Delete a Snack that the current user owns""" + deleteSnack(snackId: ID!): Snack! + + """Disable all second factor authentication for the current user""" + disableSecondFactorAuthentication(otp: String): SecondFactorBooleanResult! + + """Initiate setup of two-factor authentication for the current user""" + initiateSecondFactorAuthentication(deviceConfigurations: [SecondFactorDeviceConfiguration!]!, recaptchaResponseToken: String): SecondFactorInitiationResult! + + """Leave an Account (revoke own permissions on Account)""" + leaveAccount(accountId: ID!): LeaveAccountResult! + + """ + Purge unfinished two-factor authentication setup for the current user if not fully-set-up + """ + purgeUnfinishedSecondFactorAuthentication: SecondFactorBooleanResult! + + """Regenerate backup codes for the current user""" + regenerateSecondFactorBackupCodes(otp: String): SecondFactorRegenerateBackupCodesResult! + + """Schedule deletion for Account created via createAccount""" + scheduleAccountDeletion(accountId: ID!): BackgroundJobReceipt! + + """Schedule deletion of the current regular user""" + scheduleCurrentUserDeletion: BackgroundJobReceipt! + + """ + Schedule deletion of a SSO user. Actor must be an owner on the SSO user's SSO account. + """ + scheduleSSOUserDeletionAsSSOAccountOwner(ssoUserId: ID!): BackgroundJobReceipt! + + """Set the user's primary second factor device""" + setPrimarySecondFactorDevice(userSecondFactorDeviceId: ID!): SecondFactorBooleanResult! + + """Transfer project to a different Account""" + transferApp(appId: ID!, destinationAccountId: ID!): App! + + """Update an App that the current user owns""" + updateApp(appData: AppDataInput!): App! + + """Update the current regular user's data""" + updateProfile(userData: UserDataInput!): User! + + """Update the current SSO user's data""" + updateSSOProfile(userData: SSOUserDataInput!): SSOUser! +} + +type MeteredBillingStatus { + EAS_BUILD: Boolean! + EAS_UPDATE: Boolean! +} + +type Notification { + accountName: String! + createdAt: DateTime! + event: NotificationEvent! + id: ID! + isRead: Boolean! + metadata: NotificationMetadata + type: NotificationType! + updatedAt: DateTime! + websiteMessage: String! +} + +enum NotificationEvent { + BUILD_COMPLETE + BUILD_ERRORED + BUILD_LIMIT_THRESHOLD_EXCEEDED + BUILD_PLAN_CREDIT_THRESHOLD_EXCEEDED + SUBMISSION_COMPLETE + SUBMISSION_ERRORED + TEST +} + +union NotificationMetadata = BuildLimitThresholdExceededMetadata | BuildPlanCreditThresholdExceededMetadata | TestNotificationMetadata + +"""Base interface for notification preferences""" +interface NotificationPreference { + enabled: Boolean! + event: NotificationEvent! + type: NotificationType! +} + +""" +Union type representing either account-level or app-level notification preferences +""" +union NotificationPreferenceItem = AccountNotificationPreference | AppNotificationPreference + +type NotificationPreferenceMutation { + setAccountNotificationPreference(input: AccountNotificationPreferenceInput!): NotificationPreferenceItem! + setAppNotificationPreference(input: AppNotificationPreferenceInput!): NotificationPreferenceItem! +} + +type NotificationThresholdExceeded { + count: Int! + limit: Int! + threshold: Int! + type: BuildLimitThresholdExceededMetadataType! +} + +enum NotificationType { + EMAIL + WEB +} + +type NotificationsSentOverTimeData { + data: LineChartData! +} + +type OAuthIdentity { + email: String! + id: ID! + login: String + provider: OAuthProvider! +} + +enum OAuthProvider { + apple + github + google +} + +type Offer { + features: [Feature] + id: ID! + prerequisite: OfferPrerequisite + price: Int! + quantity: Int + stripeId: ID! + trialLength: Int + type: OfferType! +} + +type OfferPrerequisite { + stripeIds: [String!]! + type: String! +} + +enum OfferType { + """Addon, or supplementary subscription""" + ADDON + + """Advanced Purchase of Paid Resource""" + PREPAID + + """Term subscription""" + SUBSCRIPTION +} + +enum OnboardingDeviceType { + DEVICE + SIMULATOR +} + +enum OnboardingEnvironment { + DEV_BUILD + EXPO_GO +} + +enum Order { + ASC + DESC +} + +type PageInfo { + endCursor: String + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String +} + +input PartialManifest { + assets: [PartialManifestAsset]! + extra: JSONObject + launchAsset: PartialManifestAsset! +} + +input PartialManifestAsset { + bundleKey: String! + contentType: String! + fileExtension: String + fileSHA256: String! + storageKey: String! +} + +"""Represents a partner-provisioned actor.""" +type PartnerActor implements Actor { + """Access Tokens belonging to this actor""" + accessTokens: [AccessToken!]! + + """Associated accounts""" + accounts: [Account!]! + created: DateTime! + displayName: String! + email: String! + + """Experiments associated with this actor""" + experiments: [ActorExperiment!]! + + """ + Server feature gate values for this actor, optionally filtering by desired gates. + Only resolves for the viewer. + """ + featureGates(filter: [String!]): JSONObject! + firstName: String + id: ID! + isExpoAdmin: Boolean! + lastDeletionAttemptTime: DateTime + + """Primary account""" + primaryAccount: Account! + username: String! +} + +type PendingSentryInstallation { + account: Account! + createdAt: DateTime! + id: ID! + installationId: String! + orgSlug: String! +} + +enum Permission { + ADMIN + OWN + PUBLISH + PUBLISH_PROTECTED + RUN + VIEW +} + +type PinnedDashboardView { + account: Account! + view: DashboardViewPin! +} + +union PlanEnablement = Concurrencies | EASTotalPlanEnablement + +""" +A one-time, signed-in PostHog link. Expires in 10 minutes and opens once. +""" +type PostHogDeepLink { + expiresAt: DateTime! + url: String! +} + +""" +Which EAS surface a deep link was opened from; PostHog records it as an analytics label. +""" +enum PostHogDeepLinkPurpose { + DASHBOARD + OBSERVABILITY + PROJECT +} + +type PostHogIntegrationQuery { + clientIdentifier: String! +} + +type PostHogOrganizationConnection { + account: Account! + createdAt: DateTime! + id: ID! + posthogOrganizationIdentifier: String! + posthogOrganizationName: String! + posthogProjects: [PostHogProject!]! + posthogRegion: PostHogRegion! + updatedAt: DateTime! +} + +type PostHogOrganizationConnectionMutation { + """ + Completes an existing-user connection from the browser callback: exchanges the + authorization code with the stored PKCE verifier and persists the connection. + """ + completePostHogConnection(input: CompletePostHogConnectionInput!): PostHogOrganizationConnection! + createPostHogAccountRequest(input: CreatePostHogAccountRequestInput!): PostHogOrganizationConnection! + + """ + Mints a one-time, signed-in PostHog link. The URL expires in 10 minutes and can be + opened once, so request it at click time rather than pre-rendering it. + """ + createPostHogDeepLink(input: CreatePostHogDeepLinkInput!): PostHogDeepLink! + + """ + Removes the Expo-side connection only; the PostHog organization is preserved. + """ + deletePostHogOrganizationConnection(id: ID!): ID! + + """ + Starts a PostHog connection. Returns the connection directly for a new PostHog + user, or a pending browser-auth handoff for an existing PostHog user. + """ + startPostHogConnection(input: CreatePostHogAccountRequestInput!): StartPostHogConnectionResult! +} + +""" +Returned when the account's email already belongs to a PostHog account. The user +must approve the connection in their browser at `url`; the CLI then polls for +completion with `state`. +""" +type PostHogPendingConnection { + state: ID! + url: String! +} + +type PostHogProject { + app: App! + createdAt: DateTime! + id: ID! + posthogHost: String! + posthogOrganizationConnection: PostHogOrganizationConnection! + posthogProjectIdentifier: String! + posthogProjectName: String! + posthogProjectToken: String! + updatedAt: DateTime! +} + +type PostHogProjectMutation { + """ + Removes the Expo-side project link only; the PostHog project is preserved. + """ + deletePostHogProject(id: ID!): ID! + + """ + Provisions a PostHog project for the app; the project name is derived from the app. + """ + setupPostHogProject(input: SetupPostHogProjectInput!): PostHogProject! +} + +enum PostHogRegion { + EU + US +} + +interface Project { + description: String! + fullName: String! + iconUrl: String @deprecated + id: ID! + name: String! + published: Boolean! + slug: String! + updated: DateTime! + username: String! +} + +input ProjectArchiveSourceInput { + bucketKey: String + gitRef: String + metadataLocation: String + type: ProjectArchiveSourceType! + url: String +} + +enum ProjectArchiveSourceType { + GCS + GIT + NONE + URL +} + +input ProjectMetadataFileInput { + bucketKey: String! + type: ProjectArchiveSourceType! +} + +type ProjectPublicData { + fullName: String! + id: ID! +} + +input ProvisionAdditionalSupabaseProjectInput { + appId: ID! + + """ + Suffix appended to the app name for the new Supabase project (e.g. preview). + """ + projectNameSuffix: String! + + """ + A Supabase region code (e.g. us-east-1) or smart-group (americas, emea, apac). + """ + region: String! +} + +input ProvisionSupabaseProjectInput { + appId: ID! + + """ + A Supabase region code (e.g. us-east-1) or smart-group (americas, emea, apac). + """ + region: String! +} + +type PublicArtifacts { + applicationArchiveUrl: String + buildUrl: String +} + +input PublishUpdateGroupInput { + assetHostOverride: String + assetMapGroup: AssetMapGroup + awaitingCodeSigningInfo: Boolean + branchId: String! + environment: EnvironmentVariableEnvironment + excludedAssets: [PartialManifestAsset!] + fingerprintInfoGroup: FingerprintInfoGroup + gitCommitHash: String + isGitWorkingTreeDirty: Boolean + manifestHostOverride: String + message: String + previousRolloutUpdateToClobberIdGroup: UpdateIdGroup + rollBackToEmbeddedInfoGroup: UpdateRollBackToEmbeddedGroup + rolloutInfoGroup: UpdateRolloutInfoGroup + runtimeVersion: String! + sourceMapGroup: SourceMapGroup + turtleJobRunId: String + updateInfoGroup: UpdateInfoGroup +} + +type RealtimeLogsCentrifugoConnectionToken { + token: String! +} + +type RealtimeLogsCentrifugoSubscriptionToken { + channel: String! + token: String! +} + +type RealtimeLogsMutation { + """Generate a token for connecting to EAS Logs Centrifugo""" + generateCentrifugoConnectionToken: RealtimeLogsCentrifugoConnectionToken! + + """Generate a token for subscribing to an EAS Logs channel""" + generateCentrifugoSubscriptionToken(target: RealtimeLogsTargetInput!, thread: String): RealtimeLogsCentrifugoSubscriptionToken! +} + +input RealtimeLogsTargetInput { + id: ID! + type: RealtimeLogsTargetType! +} + +enum RealtimeLogsTargetType { + BUILD + JOB_RUN +} + +type RemoteAppStoreConnectApp { + appStoreIconUrl: String + ascAppIdentifier: String! + bundleIdentifier: String! + name: String +} + +enum RequestMethod { + DELETE + GET + HEAD + OPTIONS + PATCH + POST + PUT +} + +input RequestsFilters { + cacheStatus: [ResponseCacheStatus!] + continent: [ContinentCode!] + country: [String!] + hasCustomDomainOrigin: Boolean + isAsset: Boolean + isCrash: Boolean + isLimitExceeded: Boolean + isVerifiedBot: Boolean + method: [RequestMethod!] + os: [UserAgentOS!] + pathname: String + platform: [UserAgentPlatform!] + requestId: [WorkerDeploymentRequestID!] + responseType: [ResponseType!] + status: [Int!] + statusType: [ResponseStatusType!] +} + +input RequestsOrderBy { + direction: RequestsOrderByDirection + field: RequestsOrderByField! +} + +enum RequestsOrderByDirection { + ASC + DESC +} + +enum RequestsOrderByField { + ASSETS_SUM + CACHE_HIT_RATIO + CACHE_PASS_RATIO + CRASHES_SUM + DURATION + REQUESTS_SUM +} + +type RescindUserInvitationResult { + id: ID! +} + +enum ResourceClassExperiment { + C3D + N2 +} + +enum ResponseCacheStatus { + HIT + MISS + PASS +} + +enum ResponseStatusType { + CLIENT_ERROR + NONE + REDIRECT + SERVER_ERROR + SUCCESSFUL +} + +enum ResponseType { + ASSET + CRASH + REJECTED + ROUTE +} + +"""Represents a robot (not human) actor.""" +type Robot implements Actor { + """Access Tokens belonging to this actor""" + accessTokens: [AccessToken!]! + + """Associated accounts""" + accounts: [Account!]! + created: DateTime! + displayName: String! + + """Experiments associated with this actor""" + experiments: [ActorExperiment!]! + + """ + Server feature gate values for this actor, optionally filtering by desired gates. + Only resolves for the viewer. + """ + featureGates(filter: [String!]): JSONObject! + firstName: String + + """GitHub App Installations that manage this actor""" + githubAppInstallations: [GitHubAppInstallation!]! + id: ID! + isExpoAdmin: Boolean! + isManagedByGitHubApp: Boolean! + lastDeletionAttemptTime: DateTime +} + +input RobotDataInput { + name: String +} + +type RobotMutation { + """Create a Robot and grant it Permissions on an Account""" + createRobotForAccount(accountID: String!, permissions: [Permission]!, robotData: RobotDataInput): Robot! + + """Schedule deletion of a Robot""" + scheduleRobotDeletion(id: ID!): BackgroundJobReceipt! + + """Update a Robot""" + updateRobot(id: String!, robotData: RobotDataInput!): Robot! +} + +enum Role { + ADMIN + CUSTOM + DEVELOPER + HAS_ADMIN @deprecated + NOT_ADMIN @deprecated + OWNER + RELEASE_MANAGER + RUNNER + VIEW_ONLY +} + +type RootMutation { + """This is a placeholder field""" + _doNotUse: String @deprecated(reason: "Not used.") + + """ + Mutations that create, read, update, and delete AccessTokens for Actors + """ + accessToken: AccessTokenMutation! + + """Mutations that modify an Account""" + account(accountName: ID): AccountMutation! + + """Mutations that create, update, and delete an AccountSSOConfiguration""" + accountSSOConfiguration: AccountSSOConfigurationMutation! + + """Mutations for Actor experiments""" + actorExperiment: ActorExperimentMutation! + agentProviderConnection: AgentProviderConnectionMutation! + + """Mutations that modify the build credentials for an Android app""" + androidAppBuildCredentials: AndroidAppBuildCredentialsMutation! + + """Mutations that modify the credentials for an Android app""" + androidAppCredentials: AndroidAppCredentialsMutation! + + """Mutations that modify an FCM V0/Legacy credential""" + androidFcm: AndroidFcmMutation! + + """Mutations that modify a Keystore""" + androidKeystore: AndroidKeystoreMutation! + + """Mutations that modify an App""" + app(appId: ID): AppMutation + + """Mutations that modify an App Store Connect Api Key""" + appStoreConnectApiKey: AppStoreConnectApiKeyMutation! + + """Mutations for App Store Connect apps.""" + appStoreConnectApp: AppStoreConnectAppMutation! + + """Mutations that modify an AppVersion""" + appVersion: AppVersionMutation! + + """Mutations that modify an Identifier for an iOS App""" + appleAppIdentifier: AppleAppIdentifierMutation! + + """Mutations that modify an Apple Device""" + appleDevice: AppleDeviceMutation! + + """Mutations that modify an Apple Device registration request""" + appleDeviceRegistrationRequest: AppleDeviceRegistrationRequestMutation! + + """Mutations that modify a Distribution Certificate""" + appleDistributionCertificate: AppleDistributionCertificateMutation! + + """Mutations that modify a Provisioning Profile""" + appleProvisioningProfile: AppleProvisioningProfileMutation! + + """Mutations that modify an Apple Push Notification key""" + applePushKey: ApplePushKeyMutation! + + """Mutations that modify an Apple Team""" + appleTeam: AppleTeamMutation! + asset: AssetMutation! + auditLog: AuditLogMutation! + + """Mutations that modify an EAS Build""" + build(buildId: ID): BuildMutation! + + """Mutations that create, update, and delete Build Annotations""" + buildAnnotation: BuildAnnotationMutation! + convexProject: ConvexProjectMutation! + convexTeamConnection: ConvexTeamConnectionMutation! + customDomain: CustomDomainMutation! + deployments: DeploymentsMutation! + + """Mutations that assign or modify DevDomainNames for apps""" + devDomainName: AppDevDomainNameMutation! + + """Mutations that create, stop, update, and delete device run sessions""" + deviceRunSession: DeviceRunSessionMutation! + + """Mutations for Discord users""" + discordUser: DiscordUserMutation! + + """Mutations that register embedded update bundles for bundle diffing.""" + embeddedUpdate: EmbeddedUpdateMutation! + embeddedUpdateAsset: EmbeddedUpdateAssetMutation! + + """Mutations that create and delete EnvironmentSecrets""" + environmentSecret: EnvironmentSecretMutation! + + """Mutations that create and delete EnvironmentVariables""" + environmentVariable: EnvironmentVariableMutation! + + """ + Mutations for the exit-interview chat shown when a user cancels their plan. + """ + exitInterview: ExitInterviewMutation! + + """Mutations that modify App fingerprints""" + fingerprint: FingerprintMutation! + + """Mutations that utilize services facilitated by the GitHub App""" + githubApp: GitHubAppMutation! + + """Mutations for GitHub App installations""" + githubAppInstallation: GitHubAppInstallationMutation! + githubAppRegistration: GitHubAppRegistrationMutation! + + """Mutations for GitHub build triggers""" + githubBuildTrigger: GitHubBuildTriggerMutation! + githubJobRunTrigger: GitHubJobRunTriggerMutation! + + """Mutations for GitHub repositories""" + githubRepository: GitHubRepositoryMutation! + + """Mutations for GitHub repository settings""" + githubRepositorySettings: GitHubRepositorySettingsMutation! + + """Mutations for GitHub users""" + githubUser: GitHubUserMutation! + + """Mutations that modify a Google Service Account Key""" + googleServiceAccountKey: GoogleServiceAccountKeyMutation! + + """Mutations that modify the build credentials for an iOS app""" + iosAppBuildCredentials: IosAppBuildCredentialsMutation! + + """Mutations that modify the credentials for an iOS app""" + iosAppCredentials: IosAppCredentialsMutation! + + """Mutations that modify an EAS Build""" + jobRun: JobRunMutation! + keystoreGenerationUrl: KeystoreGenerationUrlMutation! + + """Mutations for LogRocket organizations""" + logRocketOrganization: LogRocketOrganizationMutation! + + """Mutations for LogRocket projects""" + logRocketProject: LogRocketProjectMutation! + + """Mutations that modify the currently authenticated User""" + me: MeMutation! + + """Notification preference management""" + notificationPreference: NotificationPreferenceMutation! + posthogOrganizationConnection: PostHogOrganizationConnectionMutation! + posthogProject: PostHogProjectMutation! + realtimeLogs: RealtimeLogsMutation! + + """Mutations that create, update, and delete Robots""" + robot: RobotMutation! + sandbox: SandboxMutation! + + """Mutations for Sentry installations""" + sentryInstallation: SentryInstallationMutation! + + """Mutations for Sentry projects""" + sentryProject: SentryProjectMutation! + + """Mutations that modify an EAS Submit submission""" + submission: SubmissionMutation! + supabaseConnection: SupabaseConnectionMutation! + supabaseProject: SupabaseProjectMutation! + tunnels: TunnelsMutation! + turtleBrownfieldArtifacts: TurtleBrownfieldArtifactMutation! + turtleSshSession: TurtleSshSessionMutation! + update: UpdateMutation! + updateBranch: UpdateBranchMutation! + updateChannel: UpdateChannelMutation! + uploadSession: UploadSession! + usageBudget: UsageBudgetMutation! + + """Mutations that create, update, and delete pinned apps""" + userAppPins: UserAppPinMutation! + userAuditLog: UserAuditLogMutation! + + """Mutations that create, update, and delete pinned dashboard views""" + userDashboardViewPins: UserDashboardViewPinMutation! + + """Mutations that create, delete, and accept UserInvitations""" + userInvitation: UserInvitationMutation! + + """Mutation interface for user preferences""" + userPreference: UserPreferenceMutation! + + """Mutations for Vexo account connections""" + vexoAccountConnection: VexoAccountConnectionMutation! + + """Mutations for Vexo apps""" + vexoApp: VexoAppMutation! + + """Mutations that create, delete, update Webhooks""" + webhook: WebhookMutation! + + """Mutations that modify a websiteNotification""" + websiteNotifications: WebsiteNotificationMutation! + workflowCache: WorkflowCacheMutation! + workflowDeviceTestCaseResult: WorkflowDeviceTestCaseResultMutation! + workflowJobAppleDeviceRegistrationRequest: WorkflowJobAppleDeviceRegistrationRequestMutation! + workflowJobApproval: WorkflowJobApprovalMutation! + workflowRevision: WorkflowRevisionMutation! + workflowRun: WorkflowRunMutation! + workflowsInsights: WorkflowsInsightsMutation! +} + +type RootQuery { + """This is a placeholder field""" + _doNotUse: String @deprecated(reason: "Not used.") + + """Top-level query object for querying Accounts.""" + account: AccountQuery! + + """Top-level query object for querying AccountSSOConfigurationPublicData""" + accountSSOConfigurationPublicData: AccountSSOConfigurationPublicDataQuery! + app: AppQuery! + + """Look up app by app id""" + appByAppId(appId: String!): App @deprecated(reason: "Use 'byId' field under 'app'.") + + """Top-level query object for querying App Store Connect API Keys.""" + appStoreConnectApiKey: AppStoreConnectApiKeyQuery! + + """ + Top-level query object for querying Apple Device registration requests. + """ + appleDeviceRegistrationRequest: AppleDeviceRegistrationRequestQuery! + + """ + Top-level query object for querying AppleDeviceRegistrationRequest publicly. + """ + appleDeviceRegistrationRequestPublicData: AppleDeviceRegistrationRequestPublicDataQuery! + + """Top-level query object for querying Apple distribution certificates.""" + appleDistributionCertificate: AppleDistributionCertificateQuery + + """Top-level query object for querying Apple provisioning profiles.""" + appleProvisioningProfile: AppleProvisioningProfileQuery + + """Top-level query object for querying Apple Teams.""" + appleTeam: AppleTeamQuery! + asset: AssetQuery! + + """Top-level query object for querying Account Audit Logs.""" + auditLogs: AuditLogQuery! + backgroundJobReceipt: BackgroundJobReceiptQuery! + + """Top-level query object for querying Branchs.""" + branches: BranchQuery! + + """Top-level query object for querying annotations.""" + buildAnnotations: BuildAnnotationsQuery! + + """Top-level query object for querying BuildPublicData publicly.""" + buildPublicData: BuildPublicDataQuery! + builds: BuildQuery! + + """Top-level query object for querying Channels.""" + channels: ChannelQuery! + + """Top-level query object for querying Convex Integration information.""" + convexIntegration: ConvexIntegrationQuery! + + """Top-level query object for querying Deployments.""" + deployments: DeploymentQuery! + deviceRunSessionArtifacts: DeviceRunSessionArtifactQuery! + deviceRunSessions: DeviceRunSessionQuery! + embeddedUpdates: EmbeddedUpdateQuery! + + """Top-level query object for querying Experimentation configuration.""" + experimentation: ExperimentationQuery! + expoGoBuild: ExpoGoBuildQuery! + + """ + Top-level query object for querying GitHub App information and resources it has access to. + """ + githubApp: GitHubAppQuery! + githubAppRegistrations: GitHubAppRegistrationQuery! + + """Top-level query object for querying Google Service Account Keys.""" + googleServiceAccountKey: GoogleServiceAccountKeyQuery! + + """Top-level query object for querying Stripe Invoices.""" + invoice: InvoiceQuery! + jobRun: JobRunQuery! + + """ + If authenticated as a typical end user, this is the appropriate top-level + query object + """ + me: User + + """ + If authenticated as any type of Actor, this is the appropriate top-level + query object + """ + meActor: Actor + + """ + If authenticated as any type of human end user (Actor types User or SSOUser), + this is the appropriate top-level query object + """ + meUserActor: UserActor + + """Top-level query object for querying PostHog Integration information.""" + posthogIntegration: PostHogIntegrationQuery! + + """Top-level query object for querying Runtimes.""" + runtimes: RuntimeQuery! + sandboxArtifacts: SandboxArtifactQuery! + sandboxes: SandboxQuery! + snack: SnackQuery! + + """Top-level query object for querying Expo status page services.""" + statuspageService: StatuspageServiceQuery! + submissions: SubmissionQuery! + turtleBrownfieldArtifacts: TurtleBrownfieldArtifactQuery! + + """Top-level query object for querying Updates.""" + updates: UpdateQuery! + + """fetch all updates in a group""" + updatesByGroup(group: ID!, platform: String): [Update!]! + usageBudget: UsageBudgetQuery! + + """Top-level query object for querying UserActorPublicData publicly.""" + userActorPublicData: UserActorPublicDataQuery! + + """Top-level query object for querying User Audit Logs.""" + userAuditLogs: UserAuditLogQuery! + + """Top-level query object for querying UserInvitationPublicData publicly.""" + userInvitationPublicData: UserInvitationPublicDataQuery! + + """Query interface for user preferences""" + userPreference: UserPreferenceQuery! + + """Top-level query object for querying Vexo Integration information.""" + vexoIntegration: VexoIntegrationQuery! + + """ + If authenticated as a typical end user, this is the appropriate top-level + query object + """ + viewer: User + + """Top-level query object for querying Webhooks.""" + webhook: WebhookQuery! + workerDeployment: WorkerDeploymentQuery! + workflowJobs: WorkflowJobQuery! + workflowRevisions: WorkflowRevisionQuery! + workflowRuns: WorkflowRunQuery! + workflows: WorkflowQuery! +} + +type Runtime { + app: App! + buildCount(channel: String, statuses: [BuildStatus!]): Int! + builds(after: String, before: String, filter: RuntimeBuildsFilterInput, first: Int, last: Int): AppBuildsConnection! + createdAt: DateTime! + deployments(after: String, before: String, filter: RuntimeDeploymentsFilterInput, first: Int, last: Int): DeploymentsConnection! + embeddedUpdateCount(channel: String): Int! + fingerprint: Fingerprint + firstBuildCreatedAt: DateTime + id: ID! + isFingerprint: Boolean! + updatedAt: DateTime! + updates(after: String, before: String, filter: RuntimeUpdatesFilterInput, first: Int, last: Int): AppUpdatesConnection! + version: String! +} + +input RuntimeBuildsFilterInput { + channel: String + developmentClient: Boolean + distributions: [DistributionType!] + platforms: [AppPlatform!] + releaseChannel: String + simulator: Boolean +} + +input RuntimeDeploymentsFilterInput { + channel: String +} + +type RuntimeEdge { + cursor: String! + node: Runtime! +} + +input RuntimeFilterInput { + """Only return runtimes shared with this branch""" + branchId: String + runtimeVersions: [String!] + searchTerm: String +} + +type RuntimeQuery { + """Query a Runtime by ID""" + byId( + """Runtime ID to use to look up runtime""" + runtimeId: ID! + ): Runtime! +} + +input RuntimeUpdatesFilterInput { + channel: String +} + +type RuntimeWithLastActivity { + lastActivityPostgresSerialized: String! + runtime: Runtime! +} + +"""Represents the connection over the runtime edge of an App""" +type RuntimesConnection { + edges: [RuntimeEdge!]! + pageInfo: PageInfo! +} + +"""Represents a human SSO (not robot) actor.""" +type SSOUser implements Actor & UserActor { + """Access Tokens belonging to this actor, none at present""" + accessTokens: [AccessToken!]! + accounts: [Account!]! + + """ + Coalesced project activity for all apps belonging to all accounts this user belongs to. Only resolves for the viewer. + """ + activityTimelineProjectActivities( + """ Offset the query """ + createdBefore: DateTime + + """ Types of objects to filter """ + filterTypes: [ActivityTimelineProjectActivityType!] + limit: Int! + ): [ActivityTimelineProjectActivity!]! + appCount: Int! + bestContactEmail: String + created: DateTime! + + """Discord account linked to a user""" + discordUser: DiscordUser + displayName: String! + + """Experiments associated with this actor""" + experiments: [ActorExperiment!]! + + """ + Server feature gate values for this actor, optionally filtering by desired gates. + Only resolves for the viewer. + """ + featureGates(filter: [String!]): JSONObject! + firstName: String + fullName: String + + """ + Every GitHub App registration this actor can link a GitHub user through. Includes the default + github.com app. An entry is null when that registration failed to resolve; the accompanying + error says why. + """ + githubAppRegistrations: [GitHubAppRegistration]! + + """GitHub.com account linked to a user""" + githubUser: GitHubUser + + """ + Every GitHub account linked to a user, one per GitHub App registration they have linked + through. Includes githubUser. + """ + githubUsers: [GitHubUser!]! + id: ID! + isExpoAdmin: Boolean! + isStaffModeEnabled: Boolean! + lastDeletionAttemptTime: DateTime + lastName: String + pinnedApps: [App!]! + pinnedDashboardViews: [PinnedDashboardView!]! + preferences: UserPreferences! @deprecated(reason: "No longer stored. Use userPreference instead.") + + """Associated accounts""" + primaryAccount: Account! + primaryAccountProfileImageUrl: String! + profilePhoto: String! @deprecated(reason: "Use primaryAccountProfileImageUrl instead") + + """Snacks associated with this account""" + snacks(limit: Int!, offset: Int!): [Snack!]! + username: String! + websiteNotificationsPaginated(after: String, before: String, first: Int, last: Int): WebsiteNotificationsConnection! +} + +input SSOUserDataInput { + firstName: String + lastName: String +} + +type Sandbox { + app: App! + artifacts: [SandboxArtifact!]! + createdAt: DateTime! + finishedAt: DateTime + id: ID! + lastUsedAt: DateTime! + startedAt: DateTime + status: SandboxStatus! + turtleJobRun: JobRun! + updatedAt: DateTime! +} + +type SandboxArtifact { + createdAt: DateTime! + downloadUrl: String + fileSizeBytes: ByteSize + filename: String! + id: ID! + name: String! + sandbox: Sandbox! + updatedAt: DateTime! +} + +type SandboxArtifactQuery { + byId(sandboxArtifactId: ID!): SandboxArtifact! +} + +type SandboxArtifactUploadSession { + headers: JSONObject! + url: String! +} + +type SandboxConnection { + edges: [SandboxEdge!]! + pageInfo: PageInfo! +} + +type SandboxEdge { + cursor: String! + node: Sandbox! +} + +input SandboxFilterInput { + statuses: [SandboxStatus!] +} + +type SandboxMutation { + createArtifactUploadSession(input: CreateSandboxArtifactUploadSessionInput!, sandboxId: ID!): CreateSandboxArtifactUploadSessionResult! + createSandbox(input: CreateSandboxInput!): Sandbox! + finalizeArtifact(artifactId: ID!): SandboxArtifact! + markSandboxReady(sandboxId: ID!): Sandbox! + stopSandbox(sandboxId: ID!): Sandbox! + touchSandbox(sandboxId: ID!): Sandbox! +} + +enum SandboxOperatingSystem { + LINUX + MACOS +} + +input SandboxProjectArchiveInput { + gitRef: String + url: String +} + +type SandboxQuery { + """ + Project sandboxes, newest first. An omitted or empty status filter includes all statuses. + """ + byAppIdPaginated(after: String, appId: ID!, before: String, filter: SandboxFilterInput, first: Int, last: Int): SandboxConnection! + byId(sandboxId: ID!): Sandbox! +} + +enum SandboxResourceClass { + LARGE + MEDIUM +} + +enum SandboxStatus { + ERRORED + RUNNING + STARTING + STOPPED +} + +type SecondFactorBooleanResult { + success: Boolean! +} + +input SecondFactorDeviceConfiguration { + isPrimary: Boolean! + method: SecondFactorMethod! + name: String! +} + +type SecondFactorDeviceConfigurationResult { + keyURI: String! + secondFactorDevice: UserSecondFactorDevice! + secret: String! +} + +type SecondFactorInitiationResult { + configurationResults: [SecondFactorDeviceConfigurationResult!]! + plaintextBackupCodes: [String!]! +} + +enum SecondFactorMethod { + """Google Authenticator (TOTP)""" + AUTHENTICATOR + + """SMS""" + SMS @deprecated +} + +type SecondFactorRegenerateBackupCodesResult { + plaintextBackupCodes: [String!]! +} + +input SendConvexTeamInviteToVerifiedEmailInput { + convexTeamConnectionId: ID! +} + +type SentryInstallation { + account: Account! + createdAt: DateTime! + id: ID! + installationId: String! + orgSlug: String! +} + +type SentryInstallationMutation { + """Confirm a pending Sentry installation""" + confirmPendingSentryInstallation(installationId: ID!): SentryInstallation! + + """Force delete a Sentry installation from Expo's side""" + deleteSentryInstallation(accountId: ID!): DeleteSentryInstallationResult! + + """Generate a Sentry token for an installation""" + generateSentryToken(accountId: ID!): GenerateSentryTokenResult! + + """Link a Sentry installation to an Expo account""" + linkSentryInstallationToExpoAccount(input: LinkSentryInstallationToExpoAccountInput!): PendingSentryInstallation! +} + +type SentryProject { + app: App! + createdAt: DateTime! + id: ID! + sentryInstallationId: ID! + sentryProjectId: String! + sentryProjectSlug: String! + updatedAt: DateTime! +} + +type SentryProjectMutation { + """Create a Sentry project""" + createSentryProject(input: CreateSentryProjectInput!): SentryProject! + + """Delete a Sentry project by ID""" + deleteSentryProject(sentryProjectId: ID!): DeleteSentryProjectResult! +} + +""" +Legacy remote config for SERVE_SIM sessions. Use WEB_PREVIEW_ONLY sessions and +WebPreviewOnlyRunSessionRemoteConfig instead. +""" +type ServeSimRunSessionRemoteConfig { + """ + Fingerprint of the reverse-tunnel server key for the client to pin. Null when + the session does not use local egress. + """ + egressFingerprint: String + + """ + Loopback port on the device host that the local egress client must serve + through the tunnel. Null when the session does not use local egress. + """ + egressPort: Int + + """ + Secret the local egress client presents to the reverse-tunnel endpoint. Null + when the session does not use local egress. + """ + egressToken: String + + """ + Reverse-tunnel endpoint the local egress client connects to. Null when the + session does not use local egress. + """ + egressUrl: String + + """URL of the preview server, for reaching its API rather than its page.""" + previewApiUrl: String + + """Session token gating the preview. Null when the preview runs ungated.""" + previewToken: String + previewUrl: String! + streamUrl: String @deprecated(reason: "Use previewUrl instead.") +} + +input SetSupabaseConnectionOrganizationInput { + organizationSlug: String! + supabaseConnectionId: ID! +} + +input SetupConvexProjectInput { + appId: ID! + convexTeamConnectionId: ID! + deploymentRegion: String + projectName: String! +} + +type SetupConvexProjectResult { + convexDeploymentName: String! + convexDeploymentUrl: String! + convexProject: ConvexProject! + deployKey: String! +} + +input SetupPostHogProjectInput { + appId: ID! + posthogOrganizationConnectionId: ID! +} + +type SizeBreakdownCategory { + assetCount: Int! + category: String! + totalBytes: Float! +} + +type Snack implements Project { + """Description of the Snack""" + description: String! + + """ + Full name of the Snack, e.g. "@john/mysnack", "@snack/245631" + """ + fullName: String! + + """Has the Snack been run without errors""" + hasBeenRunSuccessfully: Boolean + hashId: String! + iconUrl: String @deprecated + id: ID! + + """ + Draft status, which is true when the Snack was not saved explicitly, but auto-saved + """ + isDraft: Boolean! + + """ + Name of the Snack, e.g. "My Snack" + """ + name: String! + + """Preview image of the running snack""" + previewImage: String + published: Boolean! + + """SDK version of the snack""" + sdkVersion: String! + + """ + Slug name, e.g. "mysnack", "245631" + """ + slug: String! + + """Date and time the Snack was last updated""" + updated: DateTime! + + """ + Name of the user that created the Snack, or "snack" when the Snack was saved anonymously + """ + username: String! +} + +type SnackQuery { + """Get snack by hashId""" + byHashId(hashId: ID!): Snack! +} + +input SourceMapGroup { + android: SourceMapSourceInput + ios: SourceMapSourceInput +} + +input SourceMapSourceInput { + bucketKey: String! + type: SourceMapSourceType! +} + +enum SourceMapSourceType { + GCS +} + +enum StandardOffer { + """$29 USD per month, 30 day trial""" + DEFAULT + + """$800 USD per month""" + SUPPORT + + """$29 USD per month, 1 year trial""" + YC_DEALS + + """$348 USD per year, 30 day trial""" + YEARLY_SUB +} + +input StartClaudeConnectionInput { + accountId: ID! +} + +input StartGitHubAppRegistrationInput { + accountId: ID! + + """ + Organization to register the app under; omit to register under the signed-in user's account. + """ + org: String + + """ + URL of the GitHub Enterprise instance, e.g. https://github.example.com or https://example.ghe.com. + """ + origin: String! +} + +union StartPostHogConnectionResult = PostHogOrganizationConnection | PostHogPendingConnection + +"""Incident for a given component from Expo status page API.""" +type StatuspageIncident { + createdAt: DateTime! + id: ID! + + """Impact of an incident from Expo status page.""" + impact: StatuspageIncidentImpact! + name: String! + resolvedAt: DateTime + + """Shortlink to the incident from Expo status page.""" + shortlink: String! + + """Current status of an incident from Expo status page.""" + status: StatuspageIncidentStatus! + updatedAt: DateTime! + + """List of all updates for an incident from Expo status page.""" + updates: [StatuspageIncidentUpdate!]! +} + +"""Possible Incident impact values from Expo status page API.""" +enum StatuspageIncidentImpact { + CRITICAL + MAINTENANCE + MAJOR + MINOR + NONE +} + +"""Possible Incident statuses from Expo status page API.""" +enum StatuspageIncidentStatus { + COMPLETED + IDENTIFIED + INVESTIGATING + IN_PROGRESS + MONITORING + RESOLVED + SCHEDULED + VERIFYING +} + +"""Update for an Incident from Expo status page API.""" +type StatuspageIncidentUpdate { + """Text of an update from Expo status page.""" + body: String! + createdAt: DateTime! + id: ID! + + """Status set at the moment of update.""" + status: StatuspageIncidentStatus! +} + +"""Service monitored by Expo status page.""" +type StatuspageService { + """Description of a service from Expo status page.""" + description: String + id: ID! + + """ + List of last inicdents for a service from Expo status page (we always query for 50 latest incidents for all services) + sorted by createdAt field in descending order. + """ + incidents: [StatuspageIncident!]! + + """Name of a service monitored by Expo status page.""" + name: StatuspageServiceName! + + """Current status of a service from Expo status page.""" + status: StatuspageServiceStatus! +} + +"""Name of a service monitored by Expo status page.""" +enum StatuspageServiceName { + EAS_BUILD + EAS_SUBMIT + EAS_UPDATE + EAS_WORKFLOWS + GITHUB_API_REQUESTS + GITHUB_WEBHOOKS +} + +type StatuspageServiceQuery { + """Query services from Expo status page by names.""" + byServiceNames( + """Service names to use to look up components.""" + serviceNames: [StatuspageServiceName!]! + ): [StatuspageService!]! +} + +"""Possible statuses for a service.""" +enum StatuspageServiceStatus { + DEGRADED_PERFORMANCE + MAJOR_OUTAGE + OPERATIONAL + PARTIAL_OUTAGE + UNDER_MAINTENANCE +} + +type StripeCoupon { + amountOff: String + appliesTo: String + id: ID! + name: String! + percentOff: Float + valid: Boolean! +} + +type StripePrice { + id: ID! +} + +"""Represents an EAS Submission""" +type Submission implements ActivityTimelineProjectActivity { + activityTimestamp: DateTime! + actor: Actor + androidConfig: AndroidSubmissionConfig + app: App! + appStoreConnectBuildUpload: AppStoreConnectBuildUpload + archiveUrl: String + canRetry: Boolean! + cancelingActor: Actor + childSubmission: Submission + completedAt: DateTime + createdAt: DateTime! + error: SubmissionError + id: ID! + initiatingActor: Actor + iosConfig: IosSubmissionConfig + jobRun: JobRun + logFiles: [String!]! + logsUrl: String @deprecated(reason: "Use logFiles instead") + + """Retry time starts after completedAt""" + maxRetryTimeMinutes: Int! + parentSubmission: Submission + platform: AppPlatform! + priority: SubmissionPriority + status: SubmissionStatus! + submittedBuild: Build + updatedAt: DateTime! + workflowJob: WorkflowJob +} + +enum SubmissionAndroidReleaseStatus { + COMPLETED + DRAFT + HALTED + IN_PROGRESS +} + +scalar SubmissionAndroidTrack + +input SubmissionArchiveSourceInput { + """ + Required if the archive source type is GCS_BUILD_APPLICATION_ARCHIVE, GCS_BUILD_APPLICATION_ARCHIVE_ORCHESTRATOR or GCS_SUBMIT_ARCHIVE + """ + bucketKey: String + type: SubmissionArchiveSourceType! + + """Required if the archive source type is URL""" + url: String +} + +enum SubmissionArchiveSourceType { + GCS_BUILD_APPLICATION_ARCHIVE @deprecated(reason: "Submit with the build ID instead.") + GCS_BUILD_APPLICATION_ARCHIVE_ORCHESTRATOR @deprecated(reason: "Submit with the build ID instead.") + GCS_SUBMIT_ARCHIVE + URL +} + +type SubmissionError { + errorCode: String + message: String +} + +input SubmissionFilter { + platform: AppPlatform + status: SubmissionStatus +} + +type SubmissionMutation { + """Cancel an EAS Submit submission""" + cancelSubmission(submissionId: ID!): Submission! + + """Create an Android EAS Submit submission""" + createAndroidSubmission(input: CreateAndroidSubmissionInput!): CreateSubmissionResult! + + """Create an iOS EAS Submit submission""" + createIosSubmission(input: CreateIosSubmissionInput!): CreateSubmissionResult! + generateSubmissionArchiveDownloadUrl(submissionId: ID!): String! + + """Retry an EAS Submit submission""" + retrySubmission(parentSubmissionId: ID!): CreateSubmissionResult! +} + +enum SubmissionPriority { + HIGH + NORMAL +} + +type SubmissionQuery { + """Look up EAS Submission by submission ID""" + byId(submissionId: ID!): Submission! +} + +enum SubmissionStatus { + AWAITING_BUILD + CANCELED + ERRORED + FINISHED + IN_PROGRESS + IN_QUEUE +} + +type SubscriptionDetails { + addons: [AddonDetails!]! + cancelAt: DateTime + concurrencies: Concurrencies + coupon: StripeCoupon + endedAt: DateTime + futureSubscription: FutureSubscription + id: ID! + isDowngrading: Boolean + meteredBillingStatus: MeteredBillingStatus! + name: String + nextInvoice: DateTime + nextInvoiceAmountDueCents: Int + paymentFailedAt: DateTime + planEnablement(serviceMetric: EASServiceMetric!): PlanEnablement + planId: String + price: Int! + recurringCents: Int + status: String + trialEnd: DateTime + upcomingInvoice: Invoice + willCancel: Boolean +} + +""" +An unresolved finding from Supabase's Security or Performance Advisor (database linter). +""" +type SupabaseAdvisorLint { + """Stable identifier for this finding on this project.""" + cacheKey: String! + description: String! + + """Project-specific explanation naming the affected schema object.""" + detail: String! + + """Affected schema object, e.g. public.todos, when the lint names one.""" + entity: String + level: SupabaseAdvisorLintLevel! + + """Lint rule identifier, e.g. rls_disabled_in_public.""" + name: String! + + """Link to the remediation guide, when Supabase provides one.""" + remediation: String + title: String! +} + +enum SupabaseAdvisorLintLevel { + ERROR + INFO + WARN +} + +enum SupabaseAdvisorType { + PERFORMANCE + SECURITY +} + +type SupabaseConnection { + account: Account! + createdAt: DateTime! + id: ID! + supabaseOrganizationName: String! + + """ + The organization new projects are provisioned under. Chosen at connect time; defaults to the first. + """ + supabaseOrganizationSlug: String! + supabaseProjects: [SupabaseProject!]! + updatedAt: DateTime! +} + +type SupabaseConnectionMutation { + """ + Begins a Supabase connection: creates the PKCE handoff and returns the authorize URL. + """ + beginSupabaseOAuth(input: BeginSupabaseOAuthInput!): SupabaseOAuthStart! + + """ + Completes a connection from the browser callback: exchanges the authorization code with the + stored PKCE verifier and persists the connection. + """ + completeSupabaseOAuth(input: CompleteSupabaseOAuthInput!): SupabaseConnection! + + """ + Removes the Expo-side connection only; the Supabase organization and its projects are preserved. + """ + disconnectSupabase(id: ID!): ID! + + """ + Live list of Supabase organizations this connection can access (Management API). Used once at + connect-time for the org picker — not a type field, so casual connection reads stay cheap. + """ + listSupabaseOrganizations(accountId: ID!): [SupabaseOrganization!]! + + """ + Sets which Supabase organization new projects are provisioned under (the connect-time picker). + """ + setSupabaseConnectionOrganization(input: SetSupabaseConnectionOrganizationInput!): SupabaseConnection! +} + +""" +Handoff for the browser OAuth step. The CLI opens `url`, and once the user authorizes, +the website callback completes the connection; the CLI polls for it with `state`. +""" +type SupabaseOAuthStart { + state: ID! + url: String! +} + +type SupabaseOrganization { + id: ID! + name: String! + slug: String! +} + +type SupabaseProject { + """ + Live unresolved lints from the project's Security or Performance Advisor, ordered by severity. + Readable with view permission; a token refresh triggered by a read is persisted with elevated + privileges. Null when Supabase cannot be reached. + """ + advisorLints(type: SupabaseAdvisorType!): [SupabaseAdvisorLint!] + app: App! + createdAt: DateTime! + id: ID! + supabaseConnection: SupabaseConnection! + supabaseProjectName: String! + supabaseProjectRef: String! + supabaseProjectUrl: String! + supabaseRegion: String! + updatedAt: DateTime! +} + +type SupabaseProjectMutation { + """ + Removes the Expo-side project link only; the Supabase project is preserved. + """ + deleteSupabaseProject(id: ID!): ID! + + """ + Live publishable (anon) key for the app's linked Supabase project (Management API). Never + stored — null while the project is still provisioning. Used by the CLI to write EAS env vars. + """ + fetchSupabasePublishableKey(appId: ID!): String + + """Links an existing Supabase project (by ref) to the app.""" + linkSupabaseProject(input: LinkSupabaseProjectInput!): SupabaseProject! + + """ + Schedules an additional hosted Supabase project for selected EAS environments. Does not replace + the app's primary linked project — poll the receipt for ref/url/publishableKey in resultData. + """ + provisionAdditionalSupabaseProject(input: ProvisionAdditionalSupabaseProjectInput!): BackgroundJobReceipt! + + """ + Schedules provisioning of a new Supabase project for the app. Poll the returned receipt until success, then read app.supabaseProject. + """ + provisionSupabaseProject(input: ProvisionSupabaseProjectInput!): BackgroundJobReceipt! +} + +enum TargetEntityMutationType { + CREATE + DELETE + UPDATE +} + +type TestNotificationMetadata { + message: String! +} + +type TimelineActivityConnection { + edges: [TimelineActivityEdge!]! + pageInfo: PageInfo! +} + +type TimelineActivityEdge { + cursor: String! + node: ActivityTimelineProjectActivity! +} + +input TimelineActivityFilterInput { + channels: [String!] + platforms: [AppPlatform!] + releaseChannels: [String!] + types: [ActivityTimelineProjectActivityType!] +} + +type TunnelSignedUrlResult { + label: ID! + url: String! +} + +type TunnelsMutation { + """Create a signed tunnel URL for an account""" + createSignedTunnelUrl(accountId: ID!): TunnelSignedUrlResult! +} + +type TurtleBrownfieldArtifactMutation { + createTurtleBrownfieldArtifact(input: CreateBrownfieldArtifactInput!): CreateBrownfieldArtifactResult! +} + +type TurtleBrownfieldArtifactQuery { + byId(turtleBrownfieldArtifactId: ID!): BrownfieldArtifact! + latestForApp(appId: ID!, bundleName: String!, platform: AppPlatform!): BrownfieldArtifact +} + +""" +Everything a client needs to open the ssh connection, reported by the worker once it has dialed +the relay. Reading this decrypts the connection secret, so it is gated at account PUBLISH. +""" +type TurtleSshConnectionConfig { + host: String! + reconnecting: Boolean! + secret: String! + type: TurtleSshTransportType! +} + +input TurtleSshConnectionConfigInput { + reconnecting: Boolean + secret: String! + type: TurtleSshTransportType! +} + +type TurtleSshSession { + build: Build + connectionConfig: TurtleSshConnectionConfig! + createdAt: DateTime! + id: ID! + initiatingActor: Actor + jobRun: JobRun + sessionSettings: TurtleSshSessionSettings! + updatedAt: DateTime! +} + +type TurtleSshSessionMutation { + createOrUpdateTurtleSshSession(connectionConfig: TurtleSshConnectionConfigInput!, sessionSettings: TurtleSshSessionSettingsInput!, target: TurtleSshTargetInput!): TurtleSshSession! +} + +type TurtleSshSessionSettings { + idleTimeoutSeconds: Int! +} + +input TurtleSshSessionSettingsInput { + idleTimeoutSeconds: Int! +} + +input TurtleSshTargetInput { + id: ID! + type: TurtleSshTargetType! +} + +enum TurtleSshTargetType { + BUILD + JOB_RUN +} + +enum TurtleSshTransportType { + UPTERM_V1 +} + +type UnavailableServiceUsageMetric { + """User-displayable explanation of why the service's estimate is missing.""" + message: String! + service: EASService! +} + +type UniqueUsersOverTimeData { + data: LineChartData! +} + +type Update implements ActivityTimelineProjectActivity { + activityTimestamp: DateTime! + actor: Actor + app: App! + assetHostOverride: String + assetMapUrl: String + awaitingCodeSigningInfo: Boolean! + branch: UpdateBranch! + branchId: ID! + codeSigningInfo: CodeSigningInfo + createdAt: DateTime! + deployments(after: String, before: String, first: Int, last: Int): DeploymentResult! + diffReceipts(after: String, filter: UpdateDiffReceiptFilterInput, first: Int!, orderBy: [UpdateDiffReceiptOrderByClause!]): UpdateDiffReceiptConnection! + environment: EnvironmentVariableEnvironment + expoGoSDKVersion: String + fingerprint: Fingerprint + gitCommitHash: String + group: String! + id: ID! + + """Update query field""" + insights: UpdateInsights! + isGitWorkingTreeDirty: Boolean! + isRollBackToEmbedded: Boolean! + jobRun: JobRun + manifestFragment: String! + manifestHostOverride: String + manifestPermalink: String! + message: String + platform: String! + rolloutControlUpdate: Update + rolloutPercentage: Int + runtime: Runtime! + runtimeVersion: String! @deprecated(reason: "Use 'runtime' field .") + sourceMapUrl: String + updatedAt: DateTime! + workflowJob: WorkflowJob +} + +input UpdateAgentProviderConnectionInput { + name: String +} + +type UpdateBranch { + app: App! + appId: ID! + createdAt: DateTime! + id: ID! + lastDeletionAttemptTime: DateTime + latestActivity: DateTime! + name: String! + runtimes(after: String, before: String, filter: RuntimeFilterInput, first: Int, last: Int): RuntimesConnection! + updateGroups(filter: UpdatesFilter, limit: Int!, offset: Int!): [[Update!]!]! + updateGroupsPaginated(after: String, before: String, filter: UpdatesFilterV2, first: Int, last: Int): UpdateGroupsConnection! + updatedAt: DateTime! + updates(filter: UpdatesFilter, limit: Int!, offset: Int!): [Update!]! +} + +type UpdateBranchMutation { + """Create an EAS branch for an app""" + createUpdateBranchForApp(appId: ID!, name: String!): UpdateBranch! + + """ + Delete an EAS branch and all of its updates as long as the branch is not being used by any channels + """ + deleteUpdateBranch(branchId: ID!): DeleteUpdateBranchResult! @deprecated(reason: "Use scheduleUpdateBranchDeletion instead") + + """ + Edit an EAS branch. The branch can be specified either by its ID or + with the combination of (appId, name). + """ + editUpdateBranch(input: EditUpdateBranchInput!): UpdateBranch! + + """Publish an update group to a branch""" + publishUpdateGroups(publishUpdateGroupsInput: [PublishUpdateGroupInput!]!): [Update!]! + + """Delete an EAS branch and all of its updates in the background""" + scheduleUpdateBranchDeletion(branchId: ID!): BackgroundJobReceipt! +} + +type UpdateChannel { + app: App! + appId: ID! + branchMapping: String! + buildCount: Int! + buildsAndEmbeddedUpdatesPaginated(after: String, before: String, first: Int, last: Int): ChannelBuildsAndEmbeddedUpdatesConnection! + createdAt: DateTime! + embeddedUpdateCount: Int! + id: ID! + isPaused: Boolean! + + """ + Only allowed publishers (Release Managers, Admins, Owners) may release to a protected channel. + """ + isProtected: Boolean! + lastDeletionAttemptTime: DateTime + latestRuntimes(after: String, before: String, first: Int, last: Int): ChannelRuntimesConnection! + name: String! + runtimeInsights: UpdateChannelRuntimeInsights! + updateBranches(limit: Int!, offset: Int!): [UpdateBranch!]! + updatedAt: DateTime! +} + +type UpdateChannelMutation { + """ + Create an EAS channel for an app. + + In order to work with GraphQL formatting, the branchMapping should be a + stringified JSON supplied to the mutation as a variable. + """ + createUpdateChannelForApp(appId: ID!, branchMapping: String, name: String!): UpdateChannel! + + """delete an EAS channel that doesn't point to any branches""" + deleteUpdateChannel(channelId: ID!): DeleteUpdateChannelResult! @deprecated(reason: "Use scheduleUpdateChannelDeletion instead") + + """ + Edit an EAS channel. + + In order to work with GraphQL formatting, the branchMapping should be a + stringified JSON supplied to the mutation as a variable. + """ + editUpdateChannel(branchMapping: String!, channelId: ID!): UpdateChannel! + + """Pause updates for an EAS channel.""" + pauseUpdateChannel(channelId: ID!): UpdateChannel! + + """ + Protect an EAS channel, so only allowed publishers (Release Managers, Admins, Owners) may release to it. + """ + protectUpdateChannel(channelId: ID!): UpdateChannel! + + """Resume updates for an EAS channel.""" + resumeUpdateChannel(channelId: ID!): UpdateChannel! + + """Delete an EAS channel and all of its builds in the background""" + scheduleUpdateChannelDeletion(channelId: ID!): BackgroundJobReceipt! + + """Remove protection from an EAS channel.""" + unprotectUpdateChannel(channelId: ID!): UpdateChannel! +} + +type UpdateChannelRuntimeInsights { + cumulativeMetricsOverTime(runtimeVersion: String!, timespan: InsightsTimespan!): ChannelRuntimeCumulativeMetricsOverTimeData! + embeddedUpdateTotalUniqueUsers(runtimeVersion: String!, timespan: InsightsTimespan!): Int! + id: ID! + mostPopularUpdates(runtimeVersion: String!, timespan: InsightsTimespan!): [Update!]! + uniqueUsersOverTime(runtimeVersion: String!, timespan: InsightsTimespan!): UniqueUsersOverTimeData! +} + +type UpdateDeploymentEdge { + cursor: String! + node: Deployment! +} + +type UpdateDeploymentsConnection { + edges: [UpdateDeploymentEdge!]! + pageInfo: PageInfo! +} + +input UpdateDeviceRunSessionInput { + """ + New human-readable label for the session, at most 255 characters after leading + and trailing whitespace is trimmed. Pass null to clear the name so clients fall + back to identifying the session by id. + """ + name: String + + """ + Replace the session tags. Tags are trimmed, lowercased and deduplicated. + Pass an empty list or null to clear them. Omit to leave tags unchanged. + """ + tags: [String!] +} + +type UpdateDiffReceipt { + appId: ID! + baseUpdate: Update + baseUpdateId: ID! + createdAt: DateTime! + downloadUrl: String + errorCode: String + errorMessage: String + fileSize: Int + id: ID! + state: String! + updateId: ID! + updatedAt: DateTime! +} + +type UpdateDiffReceiptConnection { + edges: [UpdateDiffReceiptEdge!]! + pageInfo: PageInfo! +} + +type UpdateDiffReceiptEdge { + cursor: String! + node: UpdateDiffReceipt! +} + +input UpdateDiffReceiptFilterInput { + """ + Excludes diff receipts whose diffs have been downloaded. + Downloads are determined from ClickHouse metrics. Results are + approximate when more than 1000 downloaded diffs exist. + """ + omitDownloadedDiffs: Boolean +} + +input UpdateDiffReceiptOrderByClause { + direction: UpdateDiffReceiptOrderByDirection + field: UpdateDiffReceiptOrderByField! + stateOrder: [UpdateDiffReceiptStateValue!] +} + +enum UpdateDiffReceiptOrderByDirection { + ASC + DESC +} + +enum UpdateDiffReceiptOrderByField { + CREATED_AT + STATE +} + +enum UpdateDiffReceiptStateValue { + FAILURE + IN_PROGRESS + QUEUED + SUCCESS + WONT_COMPUTE +} + +input UpdateEnvironmentVariableInput { + environments: [EnvironmentVariableEnvironment!] + fileName: String + id: ID! + name: String + type: EnvironmentSecretType + value: String + visibility: EnvironmentVariableVisibility +} + +input UpdateFilterInput { + fingerprintHash: String + hasFingerprint: Boolean + runtimeVersion: String +} + +input UpdateGitHubBuildTriggerInput { + autoSubmit: Boolean! + buildProfile: String! + environment: EnvironmentVariableEnvironment + executionBehavior: GitHubBuildTriggerExecutionBehavior! + isActive: Boolean! + platform: AppPlatform! + sourcePattern: String! + submitProfile: String + targetPattern: String + type: GitHubBuildTriggerType! +} + +input UpdateGitHubJobRunTriggerInput { + isActive: Boolean! + sourcePattern: String! + targetPattern: String +} + +input UpdateGitHubRepositorySettingsInput { + baseDirectory: String! +} + +type UpdateGroup { + createdAt: DateTime! + + """The shared update_group UUID of the member updates.""" + id: ID! + updates: [Update!]! +} + +type UpdateGroupEdge { + cursor: String! + node: [Update!]! +} + +type UpdateGroupsConnection { + edges: [UpdateGroupEdge!]! + pageInfo: PageInfo! +} + +input UpdateIdGroup { + android: ID + ios: ID + web: ID +} + +input UpdateInfoGroup { + android: PartialManifest + ios: PartialManifest + web: PartialManifest +} + +type UpdateInsights { + averageAssetMetrics: [AverageAssetMetrics!]! + cumulativeAverageMetrics: CumulativeAverageMetrics! + cumulativeMetrics(timespan: InsightsTimespan!): CumulativeMetrics! + id: ID! + sizeBreakdownByCategory: [SizeBreakdownCategory!]! + totalUniqueUsers(timespan: InsightsTimespan!): Int! +} + +type UpdateMutation { + """Delete an EAS update group in the background""" + scheduleUpdateGroupDeletion(group: ID!): BackgroundJobReceipt! + + """Set code signing info for an update""" + setCodeSigningInfo(codeSigningInfo: CodeSigningInfoInput!, updateId: ID!): Update! + + """Set rollout percentage for an update""" + setRolloutPercentage(percentage: Int!, updateId: ID!): Update! +} + +type UpdateQuery { + """Query an Update by ID""" + byId( + """Update ID to use to look up update""" + updateId: ID! + ): Update! +} + +input UpdateRollBackToEmbeddedGroup { + android: Boolean + ios: Boolean + web: Boolean +} + +input UpdateRolloutInfo { + rolloutControlUpdateId: ID + rolloutPercentage: Int! +} + +input UpdateRolloutInfoGroup { + android: UpdateRolloutInfo + ios: UpdateRolloutInfo + web: UpdateRolloutInfo +} + +input UpdateVexoAppInput { + domain: String + iconUrl: String + name: String + owner: String + slug: String +} + +input UpdatesFilter { + platform: AppPlatform + runtimeVersions: [String!] +} + +input UpdatesFilterV2 { + platform: AppPlatform + runtimeVersions: [String!] +} + +type UpdatesMetricsData { + failedInstallsDataset: CumulativeUpdatesDataset! + installsDataset: CumulativeUpdatesDataset! + labels: [String!]! +} + +type UpdatesTimelineConnection { + edges: [UpdatesTimelineEdge!]! + pageInfo: PageInfo! +} + +type UpdatesTimelineEdge { + cursor: String! + node: UpdatesTimelineItem! +} + +input UpdatesTimelineFilter { + channel: String + platform: AppPlatform + runtimeVersions: [String!] + + """ + Case-insensitive substring match on update group message, branch name, and runtime version, + plus prefix match on update ID, update group ID, and git commit hash. + Embedded updates are matched on channel, runtime version, and ID prefix. + """ + searchTerm: String + types: [UpdatesTimelineItemType!] +} + +union UpdatesTimelineItem = EmbeddedUpdate | UpdateGroup + +enum UpdatesTimelineItemType { + EMBEDDED_UPDATE + UPDATE_GROUP +} + +input UploadEmbeddedUpdateInput { + appId: ID! + channel: String! + + """ + UUID baked into the binary by expo-updates at build time (from app.manifest id field). + """ + embeddedUpdateId: ID! + platform: AppPlatform! + runtimeVersion: String! + turtleBuildId: ID +} + +type UploadSession { + """Create an Upload Session for a specific account""" + createAccountScopedUploadSession(accountID: ID!, type: AccountUploadSessionType!): JSONObject! + + """Create an Upload Session for a specific app""" + createAppScopedUploadSession(appID: ID!, type: AppUploadSessionType!): JSONObject! + + """Create an Upload Session""" + createUploadSession(filename: String, type: UploadSessionType!): JSONObject! +} + +enum UploadSessionType { + EAS_BUILD_GCS_PROJECT_METADATA + EAS_BUILD_GCS_PROJECT_SOURCES + EAS_BUILD_PROJECT_SOURCES @deprecated(reason: "Use EAS_BUILD_GCS_PROJECT_SOURCES instead.") + EAS_SHARE_GCS_APP_ARCHIVE + EAS_SUBMIT_APP_ARCHIVE @deprecated(reason: "Use EAS_SUBMIT_GCS_APP_ARCHIVE instead.") + EAS_SUBMIT_GCS_APP_ARCHIVE + EAS_UPDATE_ASSETS_METADATA + EAS_UPDATE_FINGERPRINT + EAS_UPDATE_SOURCE_MAPS +} + +type UsageBudget { + id: ID! + limits: UsageBudgetLimits! + remaining: UsageBudgetRemaining! +} + +type UsageBudgetLimits { + builds: Int + simulatorMinutes: Int +} + +input UsageBudgetLimitsInput { + builds: Int + simulatorMinutes: Int +} + +type UsageBudgetMutation { + createUsageBudget(limits: UsageBudgetLimitsInput!): UsageBudget! +} + +type UsageBudgetQuery { + byId(id: ID!): UsageBudget! +} + +type UsageBudgetRemaining { + """Available builds, or null when there is no build limit.""" + builds: Int + + """ + Available minutes after completed usage and active reservations, or null when unlimited. + """ + simulatorMinutes: Float +} + +type UsageMetricTotal { + billingPeriod: BillingPeriod! + id: ID! + overageMetrics: [EstimatedOverageAndCost!]! + planMetrics: [EstimatedUsage!]! + + """Total cost of overages, in cents""" + totalCost: Float! + + """ + Services whose estimates could not be computed and are omitted from the other fields. + """ + unavailableServiceUsageMetrics: [UnavailableServiceUsageMetric!]! +} + +enum UsageMetricType { + BANDWIDTH + BUILD + EVENT + MINUTE + REQUEST + UPDATE + USER +} + +enum UsageMetricsGranularity { + DAY + HOUR + MINUTE + TOTAL +} + +input UsageMetricsTimespan { + end: DateTime! + start: DateTime! +} + +"""Represents a human (not robot) actor.""" +type User implements Actor & UserActor { + """Access Tokens belonging to this actor""" + accessTokens: [AccessToken!]! + accounts: [Account!]! + + """ + Coalesced project activity for all apps belonging to all accounts this user belongs to. Only resolves for the viewer. + """ + activityTimelineProjectActivities( + """ Offset the query """ + createdBefore: DateTime + + """ Types of objects to filter """ + filterTypes: [ActivityTimelineProjectActivityType!] + limit: Int! + ): [ActivityTimelineProjectActivity!]! + appCount: Int! + bestContactEmail: String + created: DateTime! + + """Discord account linked to a user""" + discordUser: DiscordUser + displayName: String! + email: String! + emailVerified: Boolean! + + """Experiments associated with this actor""" + experiments: [ActorExperiment!]! + + """ + Server feature gate values for this actor, optionally filtering by desired gates. + Only resolves for the viewer. + """ + featureGates(filter: [String!]): JSONObject! + firstName: String + fullName: String + + """ + Every GitHub App registration this actor can link a GitHub user through. Includes the default + github.com app. An entry is null when that registration failed to resolve; the accompanying + error says why. + """ + githubAppRegistrations: [GitHubAppRegistration]! + + """GitHub.com account linked to a user""" + githubUser: GitHubUser + + """ + Every GitHub account linked to a user, one per GitHub App registration they have linked + through. Includes githubUser. + """ + githubUsers: [GitHubUser!]! + hasPassword: Boolean! + + """ + Whether this user has any pending user invitations. Only resolves for the viewer. + """ + hasPendingUserInvitations: Boolean! + id: ID! + isExpoAdmin: Boolean! + isSecondFactorAuthenticationEnabled: Boolean! + isStaffModeEnabled: Boolean! + lastDeletionAttemptTime: DateTime + lastName: String + newEmailPendingVerification: String + oAuthIdentities: [OAuthIdentity!]! + + """ + The organization created during signup onboarding: the most recently + created organization this user owns. Only resolves for the viewer. + """ + onboardingOrganization: Account + + """Registered passkey credentials""" + passkeyCredentials: [UserPasskeyCredential!]! + + """Pending UserInvitations for this user. Only resolves for the viewer.""" + pendingUserInvitations: [UserInvitation!]! + pinnedApps: [App!]! + pinnedDashboardViews: [PinnedDashboardView!]! + preferences: UserPreferences! @deprecated(reason: "No longer stored. Use userPreference instead.") + + """Associated accounts""" + primaryAccount: Account! + primaryAccountProfileImageUrl: String! + profilePhoto: String! @deprecated(reason: "Use primaryAccountProfileImageUrl instead") + + """Get all certified second factor authentication methods""" + secondFactorDevices: [UserSecondFactorDevice!]! + + """Snacks associated with this account""" + snacks(limit: Int!, offset: Int!): [Snack!]! + username: String! + websiteNotificationsPaginated(after: String, before: String, first: Int, last: Int): WebsiteNotificationsConnection! +} + +""" +A human user (type User or SSOUser) that can login to the Expo website, use Expo services, and be a member of accounts. +""" +interface UserActor implements Actor { + """Access Tokens belonging to this user actor""" + accessTokens: [AccessToken!]! + accounts: [Account!]! + + """ + Coalesced project activity for all apps belonging to all accounts this user actor belongs to. + Only resolves for the viewer. + """ + activityTimelineProjectActivities( + """ Offset the query """ + createdBefore: DateTime + + """ Types of objects to filter """ + filterTypes: [ActivityTimelineProjectActivityType!] + limit: Int! + ): [ActivityTimelineProjectActivity!]! + appCount: Int! + bestContactEmail: String + created: DateTime! + + """Discord account linked to a user""" + discordUser: DiscordUser + + """ + Best-effort human readable name for this human actor for use in user interfaces during action attribution. + For example, when displaying a sentence indicating that actor X created a build or published an update. + """ + displayName: String! + + """Experiments associated with this actor""" + experiments: [ActorExperiment!]! + + """ + Server feature gate values for this user actor, optionally filtering by desired gates. + Only resolves for the viewer. + """ + featureGates(filter: [String!]): JSONObject! + firstName: String + fullName: String + + """ + Every GitHub App registration this actor can link a GitHub user through. Includes the default + github.com app. An entry is null when that registration failed to resolve; the accompanying + error says why. + """ + githubAppRegistrations: [GitHubAppRegistration]! + + """GitHub.com account linked to a user""" + githubUser: GitHubUser + + """ + Every GitHub account linked to a user, one per GitHub App registration they have linked + through. Includes githubUser. + """ + githubUsers: [GitHubUser!]! + id: ID! + isExpoAdmin: Boolean! + isStaffModeEnabled: Boolean! + lastDeletionAttemptTime: DateTime + lastName: String + pinnedApps: [App!]! + preferences: UserPreferences! @deprecated(reason: "No longer stored. Use userPreference instead.") + + """Associated accounts""" + primaryAccount: Account! + primaryAccountProfileImageUrl: String! + profilePhoto: String! @deprecated(reason: "Use primaryAccountProfileImageUrl instead") + + """Snacks associated with this user's personal account""" + snacks(limit: Int!, offset: Int!): [Snack!]! + username: String! + websiteNotificationsPaginated(after: String, before: String, first: Int, last: Int): WebsiteNotificationsConnection! +} + +""" +A human user (type User or SSOUser) that can login to the Expo website, use Expo services, and be a member of accounts. +""" +type UserActorPublicData { + firstName: String + id: ID! + lastName: String + primaryAccountProfileImageUrl: String! + + """Snacks associated with this user's personal account""" + snacks(limit: Int!, offset: Int!): [Snack!]! + username: String! +} + +type UserActorPublicDataQuery { + """Get UserActorPublicData by username""" + byUsername(username: String!): UserActorPublicData! +} + +enum UserAgentBrowser { + ANDROID_MOBILE + CHROME + CHROME_IOS + EDGE + FACEBOOK_MOBILE + FIREFOX + FIREFOX_IOS + INTERNET_EXPLORER + KONQUEROR + MOZILLA + OPERA + SAFARI + SAFARI_MOBILE + SAMSUNG_INTERNET + UC_BROWSER +} + +enum UserAgentOS { + ANDROID + CHROME_OS + IOS + IPAD_OS + LINUX + MAC_OS + WINDOWS +} + +enum UserAgentPlatform { + ANDROID + APPLE + UNKNOWN + WEB +} + +type UserAppPinMutation { + pinApp(appId: ID!): ID! + unpinApp(appId: ID!): ID +} + +type UserAuditLog { + actor: Actor! + createdAt: DateTime! + id: ID! + ip: String + metadata: JSONObject + targetEntityId: ID! + targetEntityMutationType: TargetEntityMutationType! + targetEntityTypeName: UserEntityTypeName! + targetEntityTypePublicName: String! + userActor: UserActor! + websiteMessage: String! +} + +type UserAuditLogConnection { + edges: [UserAuditLogEdge!]! + pageInfo: PageInfo! +} + +type UserAuditLogEdge { + cursor: String! + node: UserAuditLog! +} + +input UserAuditLogExportInput { + createdAfter: String! + createdBefore: String! + format: AuditLogsExportFormat! + targetEntityMutationType: [TargetEntityMutationType!] + targetEntityTypeName: [UserEntityTypeName!] + userId: ID! +} + +input UserAuditLogFilterInput { + entityTypes: [UserEntityTypeName!] + mutationTypes: [TargetEntityMutationType!] +} + +type UserAuditLogMutation { + """ + Exports User Audit Logs for an user. Returns the ID of the background job receipt. Use BackgroundJobReceiptQuery to get the status of the job. + """ + exportUserAuditLogs(exportInput: UserAuditLogExportInput!): BackgroundJobReceipt! +} + +type UserAuditLogQuery { + """Audit logs for user""" + byId(auditLogId: ID!): UserAuditLog! + byUserIdPaginated(after: String, before: String, filter: UserAuditLogFilterInput, first: Int, last: Int, userId: ID!): UserAuditLogConnection! + typeNamesMap: [UserLogNameTypeMapping!]! +} + +type UserDashboardViewPinMutation { + pinDashboardView(accountId: ID!, view: DashboardViewPin!): PinnedDashboardView! + unpinDashboardView(accountId: ID!): PinnedDashboardView +} + +input UserDataInput { + email: String + firstName: String + fullName: String + id: ID + lastName: String + profilePhoto: String + username: String +} + +enum UserEntityTypeName { + AccessTokenEntity + DiscordUserEntity + GitHubUserEntity + OAuthIdentityEntity + PartnerProvisionedActorEntity + PasswordEntity + SSOUserEntity + UserEntity + UserPasskeyCredentialEntity + UserPermissionEntity + UserSecondFactorBackupCodesEntity + UserSecondFactorDeviceEntity +} + +""" +An pending invitation sent to an email granting membership on an Account. +""" +type UserInvitation { + accountName: String! + + """The profile image URL of the account owner""" + accountProfileImageUrl: String! + + """Whether the account requires two-factor authentication""" + accountRequiresTwoFactor: Boolean! + created: DateTime! + + """Email to which this invitation was sent""" + email: String! + expires: DateTime! + id: ID! + + """If the invite is for an organization or a personal team""" + isForOrganization: Boolean! + + """Account permissions to be granted upon acceptance of this invitation""" + permissions: [Permission!]! + + """Role to be granted upon acceptance of this invitation""" + role: Role! @deprecated(reason: "Derive role from permissions instead") +} + +type UserInvitationMutation { + """ + Accept UserInvitation by ID. Viewer must have matching email and email must be verified. + """ + acceptUserInvitationAsViewer(id: ID!): AcceptUserInvitationResult! + + """ + Accept UserInvitation by token. Note that the viewer's email is not required to match + the email on the invitation. If viewer's email does match that of the invitation, + their email will also be verified. + """ + acceptUserInvitationByTokenAsViewer(token: ID!): AcceptUserInvitationResult! + + """ + Create a UserInvitation for an email that when accepted grants + the specified permissions on an Account + """ + createUserInvitationForAccount(accountID: ID!, email: String!, permissions: [Permission]!): UserInvitation! + + """Rescind UserInvitation by ID""" + deleteUserInvitation(id: ID!): RescindUserInvitationResult! + + """ + Delete UserInvitation by token. Note that the viewer's email is not required to match + the email on the invitation. + """ + deleteUserInvitationByToken(token: ID!): RescindUserInvitationResult! + + """Re-send UserInivitation by ID""" + resendUserInvitation(id: ID!): UserInvitation! + + """Update UserInvitation permissions by ID""" + updateUserInvitationPermissions(invitationID: ID!, permissions: [Permission]!): UserInvitation! +} + +"""Publicly visible data for a UserInvitation.""" +type UserInvitationPublicData { + accountName: String! + accountProfileImageUrl: String! + accountProfilePhoto: String + accountRequiresTwoFactor: Boolean! + created: DateTime! + email: String! + expires: DateTime! + + """Email to which this invitation was sent""" + id: ID! + isForOrganization: Boolean! +} + +type UserInvitationPublicDataQuery { + """Get UserInvitationPublicData by token""" + byToken(token: ID!): UserInvitationPublicData! +} + +type UserLogNameTypeMapping { + publicName: String! + typeName: UserEntityTypeName! +} + +"""A passkey credential belonging to a User""" +type UserPasskeyCredential { + """ + Authenticator Attestation Globally Unique Identifier — identifies the authenticator model + (e.g., 1Password, iCloud Keychain). See https://passkeydeveloper.github.io/passkey-authenticator-aaguids/explorer/ + """ + aaguid: ID + createdAt: DateTime! + credentialDeviceType: String! + id: ID! + lastUsedAt: DateTime + name: String! +} + +type UserPermission { + actor: Actor! + + """Composite identifier for this account membership (accountId:actorId)""" + id: ID! + permissions: [Permission!]! + role: Role! @deprecated(reason: "Derive role from permissions instead") + userActor: UserActor +} + +"""A single user preference key-value pair""" +type UserPreference { + createdAt: DateTime! + id: ID! + key: String! + updatedAt: DateTime! + value: JSON! +} + +"""Mutation interface for user preferences""" +type UserPreferenceMutation { + """Delete a user preference by key""" + delete(key: String!): UserPreference + + """Set a user preference value by key""" + set(key: String!, value: JSON!): UserPreference! +} + +"""Query interface for user preferences""" +type UserPreferenceQuery { + """Get a user preference by key""" + get(key: String!): UserPreference +} + +type UserPreferences { + onboarding: UserPreferencesOnboarding @deprecated(reason: "No longer stored; always null.") +} + +""" +Set by website. Used by CLI to continue onboarding process on user's machine - clone repository, +install dependencies etc. +""" +type UserPreferencesOnboarding { + appId: ID! + deviceType: OnboardingDeviceType + environment: OnboardingEnvironment + isCLIDone: Boolean + lastUsed: String! + platform: AppPlatform +} + +"""A second factor device belonging to a User""" +type UserSecondFactorDevice { + createdAt: DateTime! + id: ID! + isCertified: Boolean! + isPrimary: Boolean! + method: SecondFactorMethod! + name: String! + smsPhoneNumber: String + updatedAt: DateTime! + user: User! +} + +enum UserSpecifiedAccountUsage { + COMPANY + PERSONAL +} + +type VexoAccountAccessToken { + access_token: String! + expires_at: String + token_type: String! +} + +type VexoAccountConnection { + accessToken: VexoAccountAccessToken! + account: Account! + id: ID! +} + +type VexoAccountConnectionMutation { + """Create a Vexo account connection for an Account""" + createVexoAccountConnection(vexoAccountConnectionData: CreateVexoAccountConnectionInput!): VexoAccountConnection! + + """Delete a Vexo account connection by ID and revoke the access token""" + revokeAndDeleteVexoAccountConnection(vexoAccountConnectionId: ID!): VexoAccountConnection! +} + +type VexoApp { + app: App! + domain: String + iconUrl: String + id: ID! + name: String! + owner: String! + slug: String! + vexoIdentifier: String! +} + +type VexoAppMutation { + """Create a Vexo app for an App""" + createVexoApp(vexoAppData: CreateVexoAppInput!): VexoApp! + + """Delete a Vexo app by ID""" + deleteVexoApp(vexoAppId: ID!): VexoApp! + + """Update a Vexo app by ID""" + updateVexoApp(vexoAppData: UpdateVexoAppInput!, vexoAppId: ID!): VexoApp! +} + +type VexoIntegrationQuery { + clientIdentifier: String! +} + +""" +Permission info for the viewer on this account, including their effective permissions +""" +type ViewerUserPermission { + actor: Actor! + + """ + Permissions the viewer can use on this account with the credential used for the request. This + includes the implicit permissions of the owner of a personal account. For a scoped credential + (scoped session, scoped access token, or scoped JWT), the credential's scopes cap these. + """ + effectivePermissions: [Permission!]! + + """ + Composite identifier for this viewer account membership (accountId:actorId) + """ + id: ID! + + """ + Permissions the viewer's actor holds on this account, regardless of the scopes of the + credential used for the request. This includes the implicit permissions of the owner of a + personal account, unlike UserPermission.permissions. A permission in this list but not in + effectivePermissions is held by the actor but not allowed by the credential's scopes. + """ + permissions: [Permission!]! + userActor: UserActor +} + +input WebNotificationUpdateReadStateInput { + id: ID! + isRead: Boolean! +} + +type WebPreviewOnlyRunSessionRemoteConfig { + """ + Fingerprint of the reverse-tunnel server key for the client to pin. Null when + the session does not use local egress. + """ + egressFingerprint: String + + """ + Loopback port on the device host that the local egress client must serve + through the tunnel. Null when the session does not use local egress. + """ + egressPort: Int + + """ + Secret the local egress client presents to the reverse-tunnel endpoint. Null + when the session does not use local egress. + """ + egressToken: String + + """ + Reverse-tunnel endpoint the local egress client connects to. Null when the + session does not use local egress. + """ + egressUrl: String + + """URL of the preview server, for reaching its API rather than its page.""" + previewApiUrl: String + + """ + Session token gating the web preview. Null when the preview runs ungated. + """ + webPreviewToken: String + webPreviewUrl: String! +} + +type Webhook { + appId: ID! + createdAt: DateTime! + event: WebhookType! + id: ID! + updatedAt: DateTime! + url: String! +} + +input WebhookFilter { + event: WebhookType +} + +input WebhookInput { + event: WebhookType! + secret: String! + url: String! +} + +type WebhookMutation { + """Create a Webhook""" + createWebhook(appId: String!, webhookInput: WebhookInput!): Webhook! + + """Delete a Webhook""" + deleteWebhook(webhookId: ID!): DeleteWebhookResult! + + """Update a Webhook""" + updateWebhook(webhookId: ID!, webhookInput: WebhookInput!): Webhook! +} + +type WebhookQuery { + byId(id: ID!): Webhook! +} + +enum WebhookType { + BUILD + SUBMIT +} + +type WebsiteNotificationEdge { + cursor: String! + node: Notification! +} + +type WebsiteNotificationMutation { + updateAllWebsiteNotificationReadStateAsRead: Boolean! + updateNotificationReadState(input: WebNotificationUpdateReadStateInput!): Notification! +} + +type WebsiteNotificationsConnection { + edges: [WebsiteNotificationEdge!]! + pageInfo: PageInfo! +} + +type WorkerCustomDomain { + alias: WorkerDeploymentAlias! + createdAt: DateTime! + dcvDelegationRecord: CustomDomainDNSRecord + devDomainName: DevDomainName! + dnsRecord: CustomDomainDNSRecord! + hostname: String! + id: ID! + setup: CustomDomainSetup + updatedAt: DateTime! + verificationRecord: CustomDomainDNSRecord +} + +type WorkerDeployment implements ActivityTimelineProjectActivity { + activityTimestamp: DateTime! + actor: Actor + aliases: [WorkerDeploymentAlias!] + app: App! + crashes(filters: CrashesFilters, timespan: DatasetTimespan!): WorkerDeploymentCrashes + createdAt: DateTime! + deploymentDomain: String! + deploymentIdentifier: WorkerDeploymentIdentifier! + devDomainName: DevDomainName! + id: ID! + initiatingActor: Actor + logs(limit: Int = 500, timespan: LogsTimespan!): WorkerDeploymentLogs + requests(filters: RequestsFilters, timespan: DatasetTimespan!): WorkerDeploymentRequests + signedAssetsURL: String! + signedDeploymentURL: String! + subdomain: String! + url: String! +} + +type WorkerDeploymentAlias { + aliasName: WorkerDeploymentIdentifier + createdAt: DateTime! + deploymentDomain: String! + devDomainName: DevDomainName! + id: ID! + subdomain: String! + updatedAt: DateTime! + url: String! + workerDeployment: WorkerDeployment! +} + +type WorkerDeploymentAliasEdge { + cursor: String! + node: WorkerDeploymentAlias! +} + +type WorkerDeploymentAliasesConnection { + edges: [WorkerDeploymentAliasEdge!]! + pageInfo: PageInfo! +} + +type WorkerDeploymentCrashEdge { + logs: [WorkerDeploymentLogNode!]! + node: WorkerDeploymentCrashNode! + request: WorkerDeploymentRequestNode +} + +enum WorkerDeploymentCrashKind { + EXCEEDED_CPU + EXCEEDED_MEMORY + EXCEEDED_SUBREQUESTS + GENERIC + INTERNAL + RESPONSE_STREAM_DISCONNECTED +} + +type WorkerDeploymentCrashNode { + crashHash: ID! + crashKind: WorkerDeploymentCrashKind! + crashTimestamp: DateTime! + deploymentIdentifier: String! + firstStackLine: String + key: ID! + message: String! + name: String! + requestTimestamp: DateTime! + scriptName: String! + stack: [String!] +} + +type WorkerDeploymentCrashes { + byCrashHash: [WorkerDeploymentCrashesHashEdge!]! + byName: [WorkerDeploymentCrashesNameEdge!]! + interval: Int! + minRowsWithoutLimit: Int! + nodes: [WorkerDeploymentCrashNode!]! + summary: WorkerDeploymentCrashesAggregationNode! + timeseries: [WorkerDeploymentCrashesTimeseriesEdge!]! +} + +type WorkerDeploymentCrashesAggregationNode { + crashesPerMs: Float + crashesSum: Int! + distinctCrashes: Int! + firstOccurredAt: DateTime! + mostRecentlyOccurredAt: DateTime! + sampleRate: Float +} + +type WorkerDeploymentCrashesHashEdge { + crashHash: ID! + node: WorkerDeploymentCrashesAggregationNode! + sample: WorkerDeploymentCrashNode + timeseries: [WorkerDeploymentCrashesTimeseriesEdge!]! +} + +type WorkerDeploymentCrashesNameEdge { + name: String! + node: WorkerDeploymentCrashesAggregationNode! + sample: WorkerDeploymentCrashNode + timeseries: [WorkerDeploymentCrashesTimeseriesEdge!]! +} + +type WorkerDeploymentCrashesTimeseriesEdge { + node: WorkerDeploymentCrashesAggregationNode + timestamp: DateTime! +} + +type WorkerDeploymentEdge { + cursor: String! + node: WorkerDeployment! +} + +"""A alias name for a serverless deployment""" +scalar WorkerDeploymentIdentifier + +enum WorkerDeploymentLogLevel { + DEBUG + ERROR + FATAL + INFO + LOG + WARN +} + +type WorkerDeploymentLogNode { + level: WorkerDeploymentLogLevel! + message: String! + timestamp: DateTime! +} + +type WorkerDeploymentLogs { + minRowsWithoutLimit: Int + nodes: [WorkerDeploymentLogNode!]! +} + +type WorkerDeploymentQuery { + byId(id: ID!): WorkerDeployment! +} + +type WorkerDeploymentRequestEdge { + crash: WorkerDeploymentCrashNode + logs: [WorkerDeploymentLogNode!]! + node: WorkerDeploymentRequestNode! +} + +"""A Worker Deployment Request ID (also known as the CF Ray ID)""" +scalar WorkerDeploymentRequestID + +type WorkerDeploymentRequestNode { + browserKind: UserAgentBrowser + browserVersion: String + cacheStatus: ResponseCacheStatus + continent: ContinentCode + country: String + deploymentIdentifier: String! + duration: Int! + hasCustomDomainOrigin: Boolean! + isAsset: Boolean! + isCrash: Boolean! + isLimitExceeded: Boolean! + isRejected: Boolean! + isStaleIfError: Boolean! + isStaleWhileRevalidate: Boolean! + isVerifiedBot: Boolean! + key: ID! + method: String! + os: UserAgentOS + pathname: String! + platform: UserAgentPlatform! + region: String + requestId: WorkerDeploymentRequestID! + requestTimestamp: DateTime! + responseType: ResponseType! + routerPath: String + scriptName: String! + search: String + status: Int! + statusType: ResponseStatusType +} + +type WorkerDeploymentRequests { + byBrowser(limit: Int, orderBy: RequestsOrderBy): [WorkerDeploymentRequestsBrowserEdge!]! + byCacheStatus(limit: Int, orderBy: RequestsOrderBy): [WorkerDeploymentRequestsCacheStatusEdge!]! + byContinent(limit: Int, orderBy: RequestsOrderBy): [WorkerDeploymentRequestsContinentEdge!]! + byCountry(limit: Int, orderBy: RequestsOrderBy): [WorkerDeploymentRequestsCountryEdge!]! + byMethod(limit: Int, orderBy: RequestsOrderBy): [WorkerDeploymentRequestsMethodEdge!]! + byOS(limit: Int, orderBy: RequestsOrderBy): [WorkerDeploymentRequestsOperatingSystemEdge!]! + byPathname(limit: Int, orderBy: RequestsOrderBy): [WorkerDeploymentRequestsPathnameEdge!]! + byPlatform(limit: Int, orderBy: RequestsOrderBy): [WorkerDeploymentRequestsPlatformEdge!]! + byResponseType(limit: Int, orderBy: RequestsOrderBy): [WorkerDeploymentRequestsResponseTypeEdge!]! + byStatusType(limit: Int, orderBy: RequestsOrderBy): [WorkerDeploymentRequestsStatusTypeEdge!]! + interval: Int! + minRowsWithoutLimit: Int! + nodes: [WorkerDeploymentRequestNode!]! + summary: WorkerDeploymentRequestsAggregationNode! + timeseries: [WorkerDeploymentRequestsTimeseriesEdge!]! +} + +type WorkerDeploymentRequestsAggregationNode { + assetsPerMs: Float + assetsSum: Int! + cacheHitRatio: Float! + cacheHitRatioP50: Float! + cacheHitRatioP90: Float! + cacheHitRatioP99: Float! + cacheHitsPerMs: Float + cacheHitsSum: Int! + cachePassRatio: Float! + cachePassRatioP50: Float! + cachePassRatioP90: Float! + cachePassRatioP99: Float! + clientErrorRatio: Float! + clientErrorRatioP50: Float! + clientErrorRatioP90: Float! + clientErrorRatioP99: Float! + crashRatio: Float! + crashRatioP50: Float! + crashRatioP90: Float! + crashRatioP99: Float! + crashesPerMs: Float + crashesSum: Int! + duration: Float! + durationP50: Float! + durationP90: Float! + durationP99: Float! + limitExceededPerMs: Float + limitExceededSum: Int! + requestsPerMs: Float + requestsSum: Int! + sampleRate: Float + serverErrorRatio: Float! + serverErrorRatioP50: Float! + serverErrorRatioP90: Float! + serverErrorRatioP99: Float! + staleIfErrorPerMs: Float + staleIfErrorSum: Int! + staleWhileRevalidatePerMs: Float + staleWhileRevalidateSum: Int! +} + +type WorkerDeploymentRequestsBrowserEdge { + browser: UserAgentBrowser + node: WorkerDeploymentRequestsAggregationNode! +} + +type WorkerDeploymentRequestsCacheStatusEdge { + cacheStatus: ResponseCacheStatus + node: WorkerDeploymentRequestsAggregationNode! +} + +type WorkerDeploymentRequestsContinentEdge { + continent: ContinentCode + node: WorkerDeploymentRequestsAggregationNode! +} + +type WorkerDeploymentRequestsCountryEdge { + country: String + node: WorkerDeploymentRequestsAggregationNode! +} + +type WorkerDeploymentRequestsMethodEdge { + method: String! + node: WorkerDeploymentRequestsAggregationNode! +} + +type WorkerDeploymentRequestsOperatingSystemEdge { + node: WorkerDeploymentRequestsAggregationNode! + os: UserAgentOS +} + +type WorkerDeploymentRequestsPathnameEdge { + node: WorkerDeploymentRequestsAggregationNode! + pathname: String! +} + +type WorkerDeploymentRequestsPlatformEdge { + node: WorkerDeploymentRequestsAggregationNode! + platform: UserAgentPlatform! +} + +type WorkerDeploymentRequestsResponseTypeEdge { + node: WorkerDeploymentRequestsAggregationNode! + responseType: ResponseType! +} + +type WorkerDeploymentRequestsStatusTypeEdge { + node: WorkerDeploymentRequestsAggregationNode! + statusType: ResponseStatusType +} + +type WorkerDeploymentRequestsTimeseriesEdge { + byBrowser: [WorkerDeploymentRequestsBrowserEdge!]! + byCacheStatus: [WorkerDeploymentRequestsCacheStatusEdge!]! + byContinent: [WorkerDeploymentRequestsContinentEdge!]! + byCountry: [WorkerDeploymentRequestsCountryEdge!]! + byMethod: [WorkerDeploymentRequestsMethodEdge!]! + byOS: [WorkerDeploymentRequestsOperatingSystemEdge!]! + byPathname: [WorkerDeploymentRequestsPathnameEdge!]! + byPlatform: [WorkerDeploymentRequestsPlatformEdge!]! + byResponseType: [WorkerDeploymentRequestsResponseTypeEdge!]! + byStatusType: [WorkerDeploymentRequestsStatusTypeEdge!]! + node: WorkerDeploymentRequestsAggregationNode + timestamp: DateTime! +} + +type WorkerDeploymentsConnection { + edges: [WorkerDeploymentEdge!]! + pageInfo: PageInfo! +} + +enum WorkerLoggerLevel { + DEBUG + ERROR + FATAL + INFO + TRACE + WARN +} + +type Workflow { + app: App! + createdAt: DateTime! + fileName: String! + id: ID! + insights(filters: WorkflowsInsightsFiltersInput, timespan: WorkflowsInsightsTimespanInput!): WorkflowsInsightsWorkflowMetrics! + name: String + revisionsPaginated(after: String, before: String, first: Int, last: Int): WorkflowRevisionsConnection! + runsPaginated(after: String, before: String, filter: WorkflowRunFilterInput, first: Int, last: Int): WorkflowRunsConnection! + updatedAt: DateTime! +} + +type WorkflowArtifact { + contentType: String + createdAt: DateTime! + downloadUrl: String + fileSizeBytes: Int + filename: String! + id: ID! + jobRun: JobRun! + + """ + Opaque JSON attached by the worker at upload time (e.g. the Maestro test screenshot + flow/attempt mapping). Interpretation is owned by the client. + """ + metadata: JSONObject + name: String! + storageType: WorkflowArtifactStorageType! + updatedAt: DateTime! +} + +enum WorkflowArtifactStorageType { + GCS + R2 +} + +type WorkflowCache { + createdAt: DateTime! + + """ + Actor who created this cache. Null if the creating user no longer exists. + """ + creatingActor: Actor + + """ + Git branch name the cache was created from. Null for user-scoped caches. + """ + gitBranchName: String + + """ + Build that created this cache. Null if the cache was created by a job run + or the build no longer exists. + """ + hydratingBuild: Build + + """ + Job run that created this cache. Null if the cache was created by a build + or the job run no longer exists. + """ + hydratingJobRun: JobRun + id: ID! + + """Cache key used to identify the cache entry.""" + key: String! + + """ + Time the cache was last used to restore, or the creation time if it has + never been restored. + """ + lastAccessedAt: DateTime! + + """ + Size of the cache archive in bytes. Null if the cache has not been hydrated yet. + """ + sizeBytes: Float +} + +type WorkflowCacheMutation { + deleteWorkflowCache(cacheId: ID!): WorkflowCache! +} + +type WorkflowCachingConfig { + """ + Whether the ccache compiler cache is enabled. Null if not explicitly + configured, in which case the account-level default applies. + """ + ccacheEnabled: Boolean + + """ + Whether the Gradle build cache is enabled. Null if not explicitly + configured, in which case it is disabled. + """ + gradleCacheEnabled: Boolean +} + +input WorkflowCachingConfigInput { + """ + Pass true or false to explicitly enable or disable, null to reset to the account-level + default, or omit the field to leave it unchanged. + """ + ccacheEnabled: Boolean + + """ + Pass true or false to explicitly enable or disable, null to reset to the default + (disabled), or omit the field to leave it unchanged. + """ + gradleCacheEnabled: Boolean +} + +""" +Grouping key from the [shard N] prefix-stripped error_message. `count` is +computed with uniqExact(test_case_result_id) so RMT pre-merge duplicates do +not inflate it. Scope: scans ALL status='failed' rows (NOT just +is_final_attempt=1) so failures from runs that ultimately passed on retry +still surface. +""" +type WorkflowDeviceTestCaseErrorPattern { + count: Int! + lastSeenAt: DateTime! + patternKey: String! + sampleMessage: String! +} + +type WorkflowDeviceTestCaseHistory { + errorPatterns(first: Int!): [WorkflowDeviceTestCaseErrorPattern!]! + recentRuns(after: String, first: Int!): WorkflowDeviceTestCaseRecentRunConnection! + timeSeries(granularity: WorkflowDeviceTestCaseInsightsTimeSeriesGranularity!): [WorkflowDeviceTestCaseInsightsBucket!]! + totals: WorkflowDeviceTestCaseInsightsTotals! +} + +input WorkflowDeviceTestCaseHistoryFiltersInput { + gitRefs: [String!] + + """Runs whose platform is unknown match no platforms filter.""" + platforms: [AppPlatform!] + workflowIds: [ID!] +} + +type WorkflowDeviceTestCaseInsights { + facets: WorkflowDeviceTestCaseInsightsFacets! + tests(after: String, first: Int!, search: String, sortDirection: WorkflowDeviceTestCaseSortDirection = DESC, sortField: WorkflowDeviceTestCaseStatSortField = FAILS): WorkflowDeviceTestCaseStatConnection! + timeSeries(granularity: WorkflowDeviceTestCaseInsightsTimeSeriesGranularity!): [WorkflowDeviceTestCaseInsightsBucket!]! + totals: WorkflowDeviceTestCaseInsightsTotals! +} + +""" +Mutually exclusive bucket counts (passedClean + flaky + failed = totalRuns for the bucket). +Buckets start at the requested timespan start and advance by the granularity interval, so +their boundaries follow the requested range. The last bucket is shorter when the timespan is +not a whole number of intervals. +""" +type WorkflowDeviceTestCaseInsightsBucket { + bucketStartAt: DateTime! + failed: Int! + flaky: Int! + passedClean: Int! +} + +type WorkflowDeviceTestCaseInsightsFacets { + gitRefs: [String!]! + tags: [String!]! + workflows: [WorkflowDeviceTestCaseWorkflowFacet!]! +} + +input WorkflowDeviceTestCaseInsightsFiltersInput { + gitRefs: [String!] + + """Runs whose platform is unknown match no platforms filter.""" + platforms: [AppPlatform!] + statuses: [WorkflowDeviceTestCaseStatusFilter!] + tags: [String!] + workflowIds: [ID!] +} + +""" +A count metric returned for the current window AND the equivalent prior window +(start − duration → start). The frontend uses (current, previous) to compute +trend deltas. Trend ratios are NOT pre-computed server-side — see +WorkflowsInsightsMetric for the equivalent convention. + +Float (not Int) to match WorkflowsInsightsMetric and avoid the GraphQL Int32 +ceiling: year-long uniqExact counts on a high-volume project can plausibly +exceed 2.1B. Values are integer-valued; the frontend reads them as JS numbers. +""" +type WorkflowDeviceTestCaseInsightsMetric { + currentValue: Float! + previousValue: Float! +} + +""" +Same shape as WorkflowDeviceTestCaseInsightsMetric but nullable on both sides — +used for metrics like avgDurationMs / p90DurationMs where "no data in the +window" is meaningful and must not collapse to 0. +""" +type WorkflowDeviceTestCaseInsightsNullableMetric { + currentValue: Float + previousValue: Float +} + +enum WorkflowDeviceTestCaseInsightsTimeSeriesGranularity { + DAY + HOUR + MINUTE +} + +input WorkflowDeviceTestCaseInsightsTimespanInput { + end: DateTime! + start: DateTime! +} + +""" +Raw counts only (paired with previous-window values). Pass rate, flake rate, +and trends are computed in the frontend from the (currentValue, previousValue) +pair on each metric. + +totalRuns = passedCleanCount + flakyCount + failedCount (over is_final_attempt=1 rows). +platforms splits totalRuns by platform. +distinctFlakyTestCount cannot be derived in the frontend from per-test rows +because pagination means the frontend doesn't see all test paths. +""" +type WorkflowDeviceTestCaseInsightsTotals { + avgDurationMs: WorkflowDeviceTestCaseInsightsNullableMetric! + distinctFlakyTestCount: WorkflowDeviceTestCaseInsightsMetric! + failedCount: WorkflowDeviceTestCaseInsightsMetric! + flakyCount: WorkflowDeviceTestCaseInsightsMetric! + p90DurationMs: WorkflowDeviceTestCaseInsightsNullableMetric! + passedCleanCount: WorkflowDeviceTestCaseInsightsMetric! + + """ + One entry per platform with runs in the current or previous window, in the order iOS, + Android, unknown. + """ + platforms: [WorkflowDeviceTestCasePlatformTotals!]! + totalRuns: WorkflowDeviceTestCaseInsightsMetric! +} + +type WorkflowDeviceTestCasePlatformTotals { + distinctTestCount: WorkflowDeviceTestCaseInsightsMetric! + + """ + Null for runs whose platform could not be derived: Maestro runs in custom jobs, and + runs ingested before the platform was recorded. + """ + platform: AppPlatform + totalRuns: WorkflowDeviceTestCaseInsightsMetric! +} + +""" +One row per execution (is_final_attempt=1). The is_flaky boolean drives the +FLAKY pill — no per-attempt timeline is exposed in v1. +""" +type WorkflowDeviceTestCaseRecentRun { + commitSha: String + createdAt: DateTime! + durationMs: Int + gitRef: String + id: ID! + isFlaky: Boolean! + status: WorkflowDeviceTestCaseStatus! + workflowRunId: ID! + workflowRunName: String! +} + +type WorkflowDeviceTestCaseRecentRunConnection { + edges: [WorkflowDeviceTestCaseRecentRunEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type WorkflowDeviceTestCaseRecentRunEdge { + cursor: String! + node: WorkflowDeviceTestCaseRecentRun! +} + +"""A device test case result from a Maestro test execution.""" +type WorkflowDeviceTestCaseResult { + createdAt: DateTime! + duration: Int + errorMessage: String + id: ID! + name: String! + path: String! + properties: JSONObject + + """0-indexed attempt number. 0 = first attempt, 1 = first retry, etc.""" + retryCount: Int + status: WorkflowDeviceTestCaseStatus! + tags: [String!] + updatedAt: DateTime! +} + +input WorkflowDeviceTestCaseResultInput { + """Execution time in milliseconds. Must be non-negative.""" + duration: Int + + """Error message if failed. Truncated to 4096 characters.""" + errorMessage: String + + """Test case name (e.g., "login", "checkout"). Max 255 characters.""" + name: String! + + """ + Path to the test file (e.g., "flows/login.yaml"). Max 1024 characters. + Used as natural key for deduplication. + """ + path: String! + + """Custom properties from test configuration. Stored as key-value pairs.""" + properties: JSONObject + + """ + 0-indexed attempt number for this test case. 0 = first attempt, 1 = first retry, etc. + """ + retryCount: Int + + """Execution result.""" + status: WorkflowDeviceTestCaseStatus! + + """Tags defined in the test config. Each tag max 255 characters.""" + tags: [String!] +} + +type WorkflowDeviceTestCaseResultMutation { + """ + Create device test case results for a workflow job. + Results are immutable - once created, subsequent calls are no-ops. + """ + createWorkflowDeviceTestCaseResults(input: CreateWorkflowDeviceTestCaseResultsInput!): [WorkflowDeviceTestCaseResult!]! +} + +enum WorkflowDeviceTestCaseSortDirection { + ASC + DESC +} + +type WorkflowDeviceTestCaseStat { + avgDurationMs: Int + failedCount: Int! + flakyCount: Int! + lastRunAt: DateTime! + lastRunIsFlaky: Boolean! + lastRunStatus: WorkflowDeviceTestCaseStatus! + name: String! + p90DurationMs: Int + passedCleanCount: Int! + path: String! + totalRuns: Int! +} + +type WorkflowDeviceTestCaseStatConnection { + edges: [WorkflowDeviceTestCaseStatEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type WorkflowDeviceTestCaseStatEdge { + cursor: String! + node: WorkflowDeviceTestCaseStat! +} + +enum WorkflowDeviceTestCaseStatSortField { + FAILS + FLAKES + FLAKE_RATE + LAST_RUN + P90_DURATION + PASS_RATE + RUNS +} + +"""Status of a device test case execution.""" +enum WorkflowDeviceTestCaseStatus { + FAILED + PASSED +} + +""" +Mutually exclusive — matches the chart bucket semantics (passedClean / flaky / failed). +PASSED_CLEAN = is_final_attempt=1 AND status=passed AND is_flaky=0 +FLAKY = is_final_attempt=1 AND is_flaky=1 (still a pass, just with retries) +FAILED = is_final_attempt=1 AND status=failed +""" +enum WorkflowDeviceTestCaseStatusFilter { + FAILED + FLAKY + PASSED_CLEAN +} + +type WorkflowDeviceTestCaseWorkflowFacet { + id: ID! + name: String! +} + +type WorkflowDispatchInput { + default: JSON + description: String + name: String! + options: [String!] + required: Boolean! + type: WorkflowDispatchInputType! +} + +enum WorkflowDispatchInputType { + BOOLEAN + CHOICE + ENVIRONMENT + NUMBER + STRING +} + +type WorkflowJob { + allDeviceTestCaseResults: [WorkflowDeviceTestCaseResult!]! + approvals: [WorkflowJobApproval!]! + createdAt: DateTime! + credentialsAppleDeviceRegistrationRequest: AppleDeviceRegistrationRequest + + """ + All test case attempt rows produced by this job's own execution (turtle job + run), ordered by path then retryCount. Unlike allDeviceTestCaseResults, + this never includes rows from other jobs in the workflow retry chain. + """ + deviceTestCaseResultAttempts: [WorkflowDeviceTestCaseResult!]! + environment: String + errors: [WorkflowJobError!]! + id: ID! + key: String! + name: String! + outputs: JSONObject! + requiredJobKeys: [String!]! + + """ + Updates in the group being rolled out by an UPDATE_ROLLOUT job, resolved from the job's + update_group_id output. Empty for other job types or when the group has no updates. + """ + rolloutUpdateGroup: [Update!]! + + """ + Why the server skipped this job without running it. Null for jobs that + were not skipped and for jobs created before the reason was recorded. + """ + skipReason: WorkflowJobSkipReason + status: WorkflowJobStatus! + turtleBuild: Build + turtleJobRun: JobRun + turtleSubmission: Submission + type: WorkflowJobType! + updatedAt: DateTime! + workflowRun: WorkflowRun! +} + +type WorkflowJobAppleDeviceRegistrationRequestMutation { + """ + Mark the workflow apple-device-registration-request job successful after reviewing the registered device. + """ + approveAppleDeviceRegistration(appleDeviceId: ID!, workflowJobId: ID!): WorkflowJob! + + """ + Fail the workflow apple-device-registration-request job after reviewing the registered device. + """ + rejectAppleDeviceRegistration(appleDeviceId: ID!, workflowJobId: ID!): WorkflowJob! +} + +type WorkflowJobApproval { + createdAt: DateTime! + decision: WorkflowJobReviewDecision! + id: ID! + reviewingActor: Actor + updatedAt: DateTime! + workflowJob: WorkflowJob! +} + +type WorkflowJobApprovalMutation { + setWorkflowJobApprovalDecision(decision: WorkflowJobReviewDecision!, workflowJobId: ID!): WorkflowJobApproval! +} + +type WorkflowJobError { + message: String! + title: String! +} + +type WorkflowJobQuery { + byId(workflowJobId: ID!): WorkflowJob! +} + +enum WorkflowJobReviewDecision { + APPROVED + REJECTED +} + +union WorkflowJobSkipReason = WorkflowJobSkipReasonIfConditionNotMet | WorkflowJobSkipReasonUnsuccessfulDependencies + +type WorkflowJobSkipReasonDependency { + key: String! + name: String! + status: WorkflowJobStatus! +} + +type WorkflowJobSkipReasonIfConditionNotMet { + """The job's if expression as written in the workflow YAML.""" + condition: String! +} + +type WorkflowJobSkipReasonUnsuccessfulDependencies { + dependencies: [WorkflowJobSkipReasonDependency!]! +} + +enum WorkflowJobStatus { + ACTION_REQUIRED + CANCELED + FAILURE + IN_PROGRESS + NEW + PENDING_CANCEL + SKIPPED + SUCCESS +} + +enum WorkflowJobType { + AGENT + APPLE_DEVICE_REGISTRATION_REQUEST + BRANCH_DELETE + BUILD + CUSTOM + DEPLOY + DOC + FINGERPRINT + GET_BUILD + GITHUB_COMMENT + MAESTRO_CLOUD + MAESTRO_TEST + REPACK + REQUIRE_APPROVAL + SLACK + SUBMISSION + TESTFLIGHT + UPDATE + UPDATE_ROLLOUT + WORKFLOW_CALL +} + +input WorkflowProjectSourceInput { + easJsonBucketKey: String + packageJsonBucketKey: String + projectArchiveBucketKey: String! + projectRootDirectory: String + type: WorkflowProjectSourceType! +} + +enum WorkflowProjectSourceType { + GCS +} + +type WorkflowQuery { + """Look up Workflow by app ID and file name""" + byAppIdAndFileName(appId: ID!, fileName: String!): Workflow! + + """Look up Workflow by ID""" + byId(workflowId: ID!): Workflow! +} + +type WorkflowRevision { + blobSha: String! + commitSha: String + createdAt: DateTime! + id: ID! + inputs: [WorkflowDispatchInput!]! + workflow: Workflow! + yamlConfig: String! +} + +type WorkflowRevisionEdge { + cursor: String! + node: WorkflowRevision! +} + +input WorkflowRevisionInput { + fileName: String! + yamlConfig: String! +} + +type WorkflowRevisionMutation { + getOrCreateWorkflowRevisionFromGitRef(appId: ID!, fileName: String!, gitRef: String!): WorkflowRevision! + getWorkflowRevisionsFromGitRef(appId: ID!, gitRef: String!): [WorkflowRevision!]! + validateWorkflowYamlConfig(appId: ID!, yamlConfig: String!): Boolean! +} + +type WorkflowRevisionQuery { + byId(workflowRevisionId: ID!): WorkflowRevision! +} + +type WorkflowRevisionsConnection { + edges: [WorkflowRevisionEdge!]! + pageInfo: PageInfo! +} + +type WorkflowRun implements ActivityTimelineProjectActivity { + activityTimestamp: DateTime! + actor: Actor + app: App! + + """ + Why the server canceled this run. Null for manually canceled runs and for + runs canceled before the reason was recorded. + """ + cancelReason: WorkflowRunCancelReason + createdAt: DateTime! + + """Rendered run_name template. Null falls back to name.""" + displayTitle: String + durationSeconds: Int + errors: [WorkflowRunError!]! + finalizedAt: DateTime + gitCommitHash: String + gitCommitMessage: String + githubRepository: GitHubRepository + id: ID! + inputs: JSONObject + jobs: [WorkflowJob!]! + + """Snapshot of the workflow's name at run creation.""" + name: String! + pullRequestNumber: Int + requestedGitRef: String + retriedWorkflowRun: WorkflowRun + retries: [WorkflowRun!]! + sourceExpiresAt: DateTime + sshSettings: WorkflowRunSshSettings + status: WorkflowRunStatus! + triggerEventType: WorkflowRunTriggerEventType! + triggeringLabelName: String + triggeringSchedule: String + updatedAt: DateTime! + workflow: Workflow! + workflowRevision: WorkflowRevision +} + +union WorkflowRunCancelReason = WorkflowRunCancelReasonConcurrencyGroup | WorkflowRunCancelReasonWorkflowCall + +type WorkflowRunCancelReasonConcurrencyGroup { + """ + The newer run in the same concurrency group whose creation canceled this run. + Null if that run no longer exists. + """ + cancelingWorkflowRun: WorkflowRun +} + +type WorkflowRunCancelReasonWorkflowCall { + """ + The run that called this run through workflow_call and whose cancellation + canceled this run. Null if that run no longer exists. + """ + parentWorkflowRun: WorkflowRun +} + +type WorkflowRunEdge { + cursor: String! + node: WorkflowRun! +} + +type WorkflowRunError { + message: String! + title: String +} + +input WorkflowRunFilterInput { + requestedGitRef: String + status: WorkflowRunStatus + timeRange: WorkflowRunTimeRangeInput +} + +input WorkflowRunGitBranchFilterInput { + searchTerm: String +} + +input WorkflowRunInput { + inputs: JSONObject + projectSource: WorkflowProjectSourceInput! + ssh: WorkflowRunSshInput +} + +type WorkflowRunMutation { + cancelWorkflowRun(workflowRunId: ID!): WorkflowRun! + createExpoGoRepackWorkflowRun(appId: ID!, projectSource: WorkflowProjectSourceInput!, sdkVersion: String): WorkflowRun! + createWorkflowRun(appId: ID!, workflowRevisionInput: WorkflowRevisionInput!, workflowRunInput: WorkflowRunInput!): WorkflowRun! + createWorkflowRunFromGitRef(gitRef: String!, inputs: JSONObject, ssh: WorkflowRunSshInput, workflowRevisionId: ID!): WorkflowRun! + retryWorkflowRun(fromFailedJobs: Boolean, ssh: WorkflowRunSshInput, workflowRunId: ID!): WorkflowRun! +} + +type WorkflowRunQuery { + byId(workflowRunId: ID!): WorkflowRun! +} + +""" +Enables ssh on the run's VM jobs. Presence turns ssh on; idleTimeoutSeconds is optional, +defaults server-side, and is validated against a supported range. +""" +input WorkflowRunSshInput { + idleTimeoutSeconds: Int +} + +type WorkflowRunSshSettings { + idleTimeoutSeconds: Int! +} + +enum WorkflowRunStatus { + ACTION_REQUIRED + CANCELED + FAILURE + IN_PROGRESS + NEW + SUCCESS + WAITING +} + +input WorkflowRunTimeRangeInput { + earliest: DateTime + latest: DateTime +} + +enum WorkflowRunTriggerEventType { + APP_STORE_CONNECT_APP_VERSION_STATE_CHANGED + APP_STORE_CONNECT_BETA_FEEDBACK_SUBMITTED + APP_STORE_CONNECT_BUILD_UPLOAD_STATE_CHANGED + APP_STORE_CONNECT_EXTERNAL_BETA_STATE_CHANGED + EAS_SUBMIT + EXPO_LAUNCH + GITHUB_PULL_REQUEST_BASE_REF_CHANGED + GITHUB_PULL_REQUEST_COMMENT_CREATED + GITHUB_PULL_REQUEST_COMMENT_DELETED + GITHUB_PULL_REQUEST_COMMENT_EDITED + GITHUB_PULL_REQUEST_EDITED + GITHUB_PULL_REQUEST_LABELED + GITHUB_PULL_REQUEST_OPENED + GITHUB_PULL_REQUEST_READY_FOR_REVIEW + GITHUB_PULL_REQUEST_REOPENED + GITHUB_PULL_REQUEST_SYNCHRONIZE + GITHUB_PUSH + GITHUB_REF_DELETE + MANUAL + REPACK_EXPO_GO + SCHEDULE + SENTRY_ISSUE_ARCHIVED + SENTRY_ISSUE_ASSIGNED + SENTRY_ISSUE_CREATED + SENTRY_ISSUE_RESOLVED + SENTRY_ISSUE_UNRESOLVED +} + +type WorkflowRunsConnection { + edges: [WorkflowRunEdge!]! + pageInfo: PageInfo! +} + +enum WorkflowsInsightsExportFormat { + CSV + NDJSON +} + +input WorkflowsInsightsExportInput { + appId: ID! + filters: WorkflowsInsightsFiltersInput + format: WorkflowsInsightsExportFormat! + timespan: WorkflowsInsightsTimespanInput! +} + +input WorkflowsInsightsFiltersInput { + gitRefRequested: [String!] + statuses: [WorkflowRunStatus!] + triggerEventTypes: [WorkflowRunTriggerEventType!] + workflowIds: [ID!] +} + +type WorkflowsInsightsMetric { + currentValue: Float! + previousValue: Float! +} + +type WorkflowsInsightsMutation { + exportWorkflowRuns(input: WorkflowsInsightsExportInput!): BackgroundJobReceipt! +} + +""" +Same shape as WorkflowsInsightsMetric but nullable on both sides. Null means the window had +no qualifying run, which is different from 0. +""" +type WorkflowsInsightsNullableMetric { + currentValue: Float + previousValue: Float +} + +type WorkflowsInsightsOverviewMetrics { + activeWorkflows: WorkflowsInsightsMetric! + + """ + Mean milliseconds from run creation to finalization, the same duration the run page shows, + so queue and approval wait count. Canceled runs are excluded. + """ + avgDurationMs: WorkflowsInsightsNullableMetric! + canceledRuns: WorkflowsInsightsMetric! + failedRuns: WorkflowsInsightsMetric! + + """ + Approximate 75th percentile of the same duration, excluding canceled runs. + """ + p75DurationMs: WorkflowsInsightsNullableMetric! + successfulRuns: WorkflowsInsightsMetric! + totalRuns: WorkflowsInsightsMetric! +} + +type WorkflowsInsightsRunsOverTimeData { + lineChart: LineChartData! +} + +enum WorkflowsInsightsRunsOverTimeGranularity { + DAY + HOUR + MINUTE +} + +input WorkflowsInsightsTimespanInput { + end: DateTime! + start: DateTime! +} + +type WorkflowsInsightsWorkflowConnection { + edges: [WorkflowsInsightsWorkflowEdge!]! + pageInfo: PageInfo! +} + +type WorkflowsInsightsWorkflowEdge { + cursor: String! + node: WorkflowsInsightsWorkflowNode! +} + +type WorkflowsInsightsWorkflowMetrics { + canceledRuns: WorkflowsInsightsMetric! + failedRuns: WorkflowsInsightsMetric! + lastRunAt: DateTime + successfulRuns: WorkflowsInsightsMetric! + totalRuns: WorkflowsInsightsMetric! +} + +type WorkflowsInsightsWorkflowNode { + avgDurationMs: Float + canceledRuns: Int! + failedRuns: Int! + lastRunAt: DateTime! + name: String! + p75DurationMs: Float + successfulRuns: Int! + totalRuns: Int! + workflowId: ID! +} + +type deleteAndroidAppBuildCredentialsResult { + id: ID! +} + +type deleteAndroidFcmResult { + id: ID! +} + +type deleteAppStoreConnectApiKeyResult { + id: ID! +} + +type deleteApplePushKeyResult { + id: ID! +} diff --git a/apps/server/schema/expo.graphql.ts b/apps/server/schema/expo.graphql.ts new file mode 100644 index 000000000000..4391c22403ec --- /dev/null +++ b/apps/server/schema/expo.graphql.ts @@ -0,0 +1,233 @@ +// Generated by @effect/graphql-generator. Do not edit. +import * as Schema from "effect/Schema" + +export const Int = Schema.Number.check(Schema.isInt32()) + +export const ID = Schema.String + +/** The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). */ +export const JSONObject = Schema.Json + +/** + * - `ANDROID` + * - `IOS` + */ +export const AppPlatform = Schema.Literals(["ANDROID", "IOS"]) +export type AppPlatform = typeof AppPlatform.Type + +/** + * Where the virtual device's proxied network traffic exits to the internet. When + * unset, all traffic exits from EAS infrastructure. + * + * - `LOCAL`: HTTP(S) requests that honor the device's system proxy exit through a reverse tunnel to the machine running the EAS CLI egress client, so third parties see that machine's public IP. Those requests fail while the client is disconnected. Requests from libraries that bypass the system proxy are not covered and exit from EAS infrastructure. The client must stay connected for the life of the session. + */ +export const DeviceRunSessionEgress = Schema.Literals(["LOCAL"]) +export type DeviceRunSessionEgress = typeof DeviceRunSessionEgress.Type + +/** + * The client that sent a session creation request, as reported by that client. Recorded + * for analytics only and never used for authorization. + * + * - `EAS_CLI` + * - `MCP` + * - `WEBSITE` + */ +export const DeviceRunSessionRequestOrigin = Schema.Literals(["EAS_CLI", "MCP", "WEBSITE"]) +export type DeviceRunSessionRequestOrigin = typeof DeviceRunSessionRequestOrigin.Type + +/** + * - `LARGE` + * - `MEDIUM` + */ +export const DeviceRunSessionResourceClass = Schema.Literals(["LARGE", "MEDIUM"]) +export type DeviceRunSessionResourceClass = typeof DeviceRunSessionResourceClass.Type + +/** + * - `ERRORED` + * - `IN_PROGRESS` + * - `NEW` + * - `QUEUED`: The session's job run waits in the queue for a worker. + * - `STARTING`: A worker runs the session's job and prepares the virtual device. + * - `STOPPED` + */ +export const DeviceRunSessionStatus = Schema.Literals(["ERRORED", "IN_PROGRESS", "NEW", "QUEUED", "STARTING", "STOPPED"]) +export type DeviceRunSessionStatus = typeof DeviceRunSessionStatus.Type + +/** + * - `AGENT_DEVICE` + * - `APPIUM` + * - `ARGENT` + * - `SERVE_SIM` Deprecated: Use WEB_PREVIEW_ONLY instead. + * - `WEB_PREVIEW_ONLY`: A session accessed only through its web preview. + */ +export const DeviceRunSessionType = Schema.Literals(["AGENT_DEVICE", "APPIUM", "ARGENT", "SERVE_SIM", "WEB_PREVIEW_ONLY"]) +export type DeviceRunSessionType = typeof DeviceRunSessionType.Type + +export const CreateAndroidDeviceRunSessionInput = Schema.Struct({ + /** AVD hardware profile id to use for the emulator (e.g. "pixel_9"). */ + deviceIdentifier: Schema.optional(Schema.NullOr(Schema.String)), + /** + * Android SDK system image package to use for the emulator (e.g. + * "system-images;android-35;google_apis;x86_64"). + */ + systemImagePackage: Schema.optional(Schema.NullOr(Schema.String)) +}) +export interface CreateAndroidDeviceRunSessionInput extends Schema.Struct.Type {} + +export const CreateIosDeviceRunSessionInput = Schema.Struct({ + /** Simulator device name or UDID to use for the session (e.g. "iPhone 16 Pro"). */ + deviceIdentifier: Schema.optional(Schema.NullOr(Schema.String)) +}) +export interface CreateIosDeviceRunSessionInput extends Schema.Struct.Type {} + +export const CreateDeviceRunSessionInput = Schema.Struct({ + /** + * The coding agent sending this request, recorded for analytics only and never used for + * authorization. Surrounding whitespace is trimmed, and a blank value or one over 200 characters + * is treated as omitted. + */ + agentId: Schema.optional(Schema.NullOr(Schema.String)), + /** + * The coding agent's own session identifier, recorded for analytics only and never used for + * authorization. Surrounding whitespace is trimmed, and a blank value or one over 200 characters + * is treated as omitted. + */ + agentSessionId: Schema.optional(Schema.NullOr(Schema.String)), + /** Android emulator options. Only supported when platform is ANDROID. */ + android: Schema.optional(Schema.NullOr(CreateAndroidDeviceRunSessionInput)), + appId: ID, + /** + * Application archive URL to download, install, and launch before the simulator session + * becomes available. Mutually exclusive with buildId, buildFingerprint, and expoGo. + */ + applicationArchiveUrl: Schema.optional(Schema.NullOr(Schema.String)), + /** + * Fingerprint hash of an EAS Build to install and launch before the simulator session becomes + * available; the server resolves it to the most recent installable build with this fingerprint. + * Mutually exclusive with buildId, applicationArchiveUrl, and expoGo. + */ + buildFingerprint: Schema.optional(Schema.NullOr(Schema.String)), + /** + * EAS Build to install and launch before the simulator session becomes available. + * Mutually exclusive with buildFingerprint, applicationArchiveUrl, and expoGo. + */ + buildId: Schema.optional(Schema.NullOr(ID)), + /** + * Origins of pages that may call the session's preview host API cross-origin, for example to + * render the device with @expo/hub-client. Framing the preview does not need this. Same value + * rules as frameAncestors. At most 10. The page still needs the preview token from remoteConfig. + * If omitted, only expo.dev may call the preview API cross-origin. + */ + corsOrigins: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))), + /** + * Where the virtual device's proxied network traffic exits to the internet. If + * omitted, all traffic exits from EAS infrastructure. + */ + egress: Schema.optional(Schema.NullOr(DeviceRunSessionEgress)), + /** + * Install and launch Expo Go before the simulator session becomes available. The server resolves + * the platform-specific application archive. Mutually exclusive with buildId, buildFingerprint, + * and applicationArchiveUrl. + */ + expoGo: Schema.optional(Schema.NullOr(Schema.Boolean)), + /** + * Origins of pages that may embed the session's preview host in a frame, next to expo.dev. + * Each value is an https origin such as https://app.example.com or a subdomain wildcard such + * as https://*.example.com; plain http is accepted for localhost only. At most 10. The + * embedding page still needs the preview token from remoteConfig, and whoever embeds the + * preview can also control the device. If omitted, only expo.dev may embed the preview. + */ + frameAncestors: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))), + /** iOS Simulator options. Only supported when platform is IOS. */ + ios: Schema.optional(Schema.NullOr(CreateIosDeviceRunSessionInput)), + /** + * Arguments passed to the installed application when it is launched. Requires buildId, + * applicationArchiveUrl, or expoGo. + */ + launchArgs: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))), + /** + * Stop the session automatically after this many minutes without observed + * session activity. Must be positive and smaller than the session's maximum + * duration (maxRunTimeMinutes when set). Only supported for + * agent-device, argent, and Appium sessions. If omitted, the session has no idle + * timeout. + */ + maxIdleTimeMinutes: Schema.optional(Schema.NullOr(Int)), + /** + * Maximum usable session duration, in minutes, starting when the remote session + * is ready. Must be non-negative and no greater than 40 for normal-priority + * accounts or 115 for high-priority accounts. The backing job receives an + * additional five minutes for cleanup. + */ + maxRunTimeMinutes: Schema.optional(Schema.NullOr(Int)), + /** + * Human-readable label for the session, at most 255 characters after leading and + * trailing whitespace is trimmed. If omitted, the session is unnamed and clients + * fall back to identifying it by id. + */ + name: Schema.optional(Schema.NullOr(Schema.String)), + /** + * Record HTTP(S) traffic from apps on the device for the whole session. iOS only. + * HTTPS is decrypted, so recordings contain credentials and cookies in cleartext. + * If omitted, no traffic is recorded. + */ + networkCapture: Schema.optional(Schema.NullOr(Schema.Boolean)), + /** + * What network capture may keep beyond method, URL, status, timing and size: header, query, + * request-body, response-body. Each can carry credentials, so nothing here is recorded unless + * it is asked for. Requires networkCapture. + */ + networkCaptureFields: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))), + /** + * Expo or development-client URL to open after launching the installed application. Requires + * buildId, applicationArchiveUrl, or expoGo. + */ + openUrl: Schema.optional(Schema.NullOr(Schema.String)), + /** + * The version of the package backing the device run session (e.g. "0.1.3-alpha.3"). + * If omitted, consumers treat the session as pinned to "latest". + */ + packageVersion: Schema.optional(Schema.NullOr(Schema.String)), + platform: AppPlatform, + /** + * Prefer public preview links and show the preview host's Share button. Defaults to false. + * This is a presentation preference and does not change access to bearer links. + */ + publicPreview: Schema.optional(Schema.NullOr(Schema.Boolean)), + /** + * The client sending this request, recorded for analytics only. Omit it when the + * client is not one of the listed values; the session is then reported as unknown. + */ + requestOrigin: Schema.optional(Schema.NullOr(DeviceRunSessionRequestOrigin)), + resourceClass: Schema.optional(Schema.NullOr(DeviceRunSessionResourceClass)), + /** + * Expo SDK version used to select an Expo Go application archive. Only supported when expoGo is + * true. If omitted, the current Expo Go archive for the platform is used. + */ + sdkVersion: Schema.optional(Schema.NullOr(Schema.String)), + /** Free-form labels for grouping sessions, for example one per app variant. */ + tags: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))), + type: DeviceRunSessionType +}) +export interface CreateDeviceRunSessionInput extends Schema.Struct.Type {} + +export const DeviceRunSessionFilterInput = Schema.Struct({ + /** Case-insensitive match anywhere in the session name. */ + name: Schema.optional(Schema.NullOr(Schema.String)), + platforms: Schema.optional(Schema.NullOr(Schema.Array(AppPlatform))), + statuses: Schema.optional(Schema.NullOr(Schema.Array(DeviceRunSessionStatus))), + /** Matches sessions carrying every listed tag. Case-insensitive. */ + tags: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))), + types: Schema.optional(Schema.NullOr(Schema.Array(DeviceRunSessionType))) +}) +export interface DeviceRunSessionFilterInput extends Schema.Struct.Type {} + +/** The possible `__typename` values of each interface and union the operations select from. */ +export declare namespace Typename { + /** Every possible `__typename` of interface `Actor`. */ + export type Actor = "PartnerActor" | "Robot" | "SSOUser" | "User" + /** Every possible `__typename` of union `DeviceRunSessionRemoteConfig`. */ + export type DeviceRunSessionRemoteConfig = + | "AgentDeviceRunSessionRemoteConfig" | "AppiumRunSessionRemoteConfig" | "ArgentRunSessionRemoteConfig" + | "ServeSimRunSessionRemoteConfig" | "WebPreviewOnlyRunSessionRemoteConfig" +} diff --git a/apps/server/scripts/fetch-expo-schema.ts b/apps/server/scripts/fetch-expo-schema.ts new file mode 100644 index 000000000000..9cf7c3920379 --- /dev/null +++ b/apps/server/scripts/fetch-expo-schema.ts @@ -0,0 +1,51 @@ +#!/usr/bin/env node +/** + * Refreshes `schema/expo.graphql` from Expo's API, which answers introspection + * without a credential. eas-cli generates its own client from the same + * endpoint. Run `vp run generate` afterwards to regenerate the operations. + */ +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; +import { + buildClientSchema, + getIntrospectionQuery, + lexicographicSortSchema, + printSchema, + type IntrospectionQuery, +} from "graphql"; + +const EXPO_GRAPHQL_URL = "https://api.expo.dev/graphql"; + +const IntrospectionResponse = Schema.Struct({ + data: Schema.Struct({ __schema: Schema.Unknown }), +}); + +const program = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const client = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); + const response = yield* HttpClientRequest.post(EXPO_GRAPHQL_URL).pipe( + HttpClientRequest.bodyJson({ query: getIntrospectionQuery() }), + Effect.flatMap(client.execute), + Effect.flatMap(HttpClientResponse.schemaBodyJson(IntrospectionResponse)), + ); + // Sorted so a refresh diffs by what Expo changed, not by its declaration order. + const sdl = printSchema( + lexicographicSortSchema(buildClientSchema(response.data as unknown as IntrospectionQuery)), + ); + const target = path.join(import.meta.dirname, "..", "schema", "expo.graphql"); + yield* fs.makeDirectory(path.dirname(target), { recursive: true }); + yield* fs.writeFileString(target, `${sdl}\n`); + yield* Effect.log(`Wrote ${target}`); +}); + +program.pipe( + Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)), + NodeRuntime.runMain, +); diff --git a/apps/server/src/device/ExpoApi.graphql b/apps/server/src/device/ExpoApi.graphql new file mode 100644 index 000000000000..af9299bfa76f --- /dev/null +++ b/apps/server/src/device/ExpoApi.graphql @@ -0,0 +1,83 @@ +query T3ExpoViewer { + meActor { + __typename + ... on UserActor { + username + } + ... on Robot { + firstName + } + accounts { + name + appsPaginated(first: 100) { + edges { + node { + id + fullName + } + } + } + } + } +} + +query T3EasSimulatorAvailability($appId: String!, $filter: [String!]) { + app { + byId(appId: $appId) { + ownerAccount { + name + accountFeatureGates(filter: $filter) + } + } + } +} + +mutation T3CreateEasSimulatorSession($input: CreateDeviceRunSessionInput!) { + deviceRunSession { + createDeviceRunSession(deviceRunSessionInput: $input) { + id + } + } +} + +query T3EasSimulatorSession($id: ID!) { + deviceRunSessions { + byId(deviceRunSessionId: $id) { + id + status + remoteConfig { + ... on AgentDeviceRunSessionRemoteConfig { + agentDeviceRemoteSessionUrl + agentDeviceRemoteSessionToken + webPreviewToken + previewApiUrl + } + } + } + } +} + +query T3ActiveEasSimulatorSessions($appId: String!, $filter: DeviceRunSessionFilterInput) { + app { + byId(appId: $appId) { + deviceRunSessionsPaginated(first: 50, filter: $filter) { + edges { + node { + id + status + name + } + } + } + } + } +} + +mutation T3StopEasSimulatorSession($id: ID!) { + deviceRunSession { + ensureDeviceRunSessionStopped(deviceRunSessionId: $id) { + id + status + } + } +} diff --git a/apps/server/src/device/ExpoApi.graphql.ts b/apps/server/src/device/ExpoApi.graphql.ts new file mode 100644 index 000000000000..6c71e029285b --- /dev/null +++ b/apps/server/src/device/ExpoApi.graphql.ts @@ -0,0 +1,330 @@ +// Generated by @effect/graphql-generator from ExpoApi.graphql. Do not edit. +import { GraphQL, GraphQLGroup } from "effect/graphql" +import * as Schema from "effect/Schema" +import * as Shared from "../../schema/expo.graphql.ts" + +/** Represents an Exponent App (or Experience in legacy terms) */ +export const NodeApp = Schema.Struct({ + id: Shared.ID, + fullName: Schema.String +}) +export interface NodeApp extends Schema.Struct.Type {} + +export const AccountAppsEdge = Schema.Struct({ + node: NodeApp +}) +export interface AccountAppsEdge extends Schema.Struct.Type {} + +export const AccountAppsConnection = Schema.Struct({ + edges: Schema.Array(AccountAppsEdge) +}) +export interface AccountAppsConnection extends Schema.Struct.Type {} + +/** + * An account is a container owning projects, credentials, billing and other organization + * data and settings. Actors may own and be members of accounts. + */ +export const AccountsAccount = Schema.Struct({ + name: Schema.String, + /** Paginated list of apps associated with this account. By default sorted by name. Use filter to adjust the sorting order. */ + appsPaginated: AccountAppsConnection +}) +export interface AccountsAccount extends Schema.Struct.Type {} + +/** Represents a human SSO (not robot) actor. */ +export const SSOUser = Schema.Struct({ + __typename: Schema.Literal("SSOUser"), + username: Schema.String, + accounts: Schema.Array(AccountsAccount) +}) +export interface SSOUser extends Schema.Struct.Type {} + +/** Represents a human (not robot) actor. */ +export const User = Schema.Struct({ + __typename: Schema.Literal("User"), + username: Schema.String, + accounts: Schema.Array(AccountsAccount) +}) +export interface User extends Schema.Struct.Type {} + +/** Represents a robot (not human) actor. */ +export const Robot = Schema.Struct({ + __typename: Schema.Literal("Robot"), + firstName: Schema.NullOr(Schema.String), + /** Associated accounts */ + accounts: Schema.Array(AccountsAccount) +}) +export interface Robot extends Schema.Struct.Type {} + +/** A regular user, SSO user, or robot that can authenticate with Expo services and be a member of accounts. */ +export const ActorOther = Schema.Struct({ + __typename: GraphQL.otherTypename()(["SSOUser", "User", "Robot"]), + /** Associated accounts */ + accounts: Schema.Array(AccountsAccount) +}) +export interface ActorOther extends Schema.Struct.Type {} + +/** `query T3ExpoViewer` */ +export const T3ExpoViewer = GraphQL.query("T3ExpoViewer", { + document: { + variables: [], + selections: [ + { name: "meActor", body: ["{__typename...on UserActor{username}...on Robot{firstName}accounts{name appsPaginated(first:100){edges{node{id fullName}}}}}"] } + ], + fragments: [] + }, + result: Schema.Struct({ + /** + * If authenticated as any type of Actor, this is the appropriate top-level + * query object + */ + meActor: Schema.NullOr(Schema.Union([ + SSOUser, + User, + Robot, + ActorOther + ])) + }) +}) +export declare namespace T3ExpoViewer { + export type Variables = typeof T3ExpoViewer.variables.Type + export type Result = typeof T3ExpoViewer.result.Type +} + +/** + * An account is a container owning projects, credentials, billing and other organization + * data and settings. Actors may own and be members of accounts. + */ +export const OwnerAccountAccount = Schema.Struct({ + name: Schema.String, + /** Server account feature gate values for this account, optionally filtering by desired gates. */ + accountFeatureGates: Shared.JSONObject +}) +export interface OwnerAccountAccount extends Schema.Struct.Type {} + +/** Represents an Exponent App (or Experience in legacy terms) */ +export const T3EasSimulatorAvailabilityAppByIdApp = Schema.Struct({ + ownerAccount: OwnerAccountAccount +}) +export interface T3EasSimulatorAvailabilityAppByIdApp extends Schema.Struct.Type {} + +export const T3EasSimulatorAvailabilityAppAppQuery = Schema.Struct({ + /** Look up app by app id */ + byId: T3EasSimulatorAvailabilityAppByIdApp +}) +export interface T3EasSimulatorAvailabilityAppAppQuery extends Schema.Struct.Type {} + +/** `query T3EasSimulatorAvailability` */ +export const T3EasSimulatorAvailability = GraphQL.query("T3EasSimulatorAvailability", { + document: { + variables: [ + { name: "appId", body: "String!" }, + { name: "filter", body: "[String!]" } + ], + selections: [ + { name: "app", body: ["{byId(appId:$", "appId", "){ownerAccount{name accountFeatureGates(filter:$", "filter", ")}}}"] } + ], + fragments: [] + }, + variables: { + appId: Schema.String, + filter: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))) + }, + result: Schema.Struct({ + app: T3EasSimulatorAvailabilityAppAppQuery + }) +}) +export declare namespace T3EasSimulatorAvailability { + export type Variables = typeof T3EasSimulatorAvailability.variables.Type + export type Result = typeof T3EasSimulatorAvailability.result.Type +} + +export const CreateDeviceRunSessionDeviceRunSession = Schema.Struct({ + id: Shared.ID +}) +export interface CreateDeviceRunSessionDeviceRunSession extends Schema.Struct.Type {} + +export const T3CreateEasSimulatorSessionDeviceRunSessionDeviceRunSessionMutation = Schema.Struct({ + /** Create a device run session */ + createDeviceRunSession: CreateDeviceRunSessionDeviceRunSession +}) +export interface T3CreateEasSimulatorSessionDeviceRunSessionDeviceRunSessionMutation extends Schema.Struct.Type {} + +/** `mutation T3CreateEasSimulatorSession` */ +export const T3CreateEasSimulatorSession = GraphQL.mutation("T3CreateEasSimulatorSession", { + document: { + variables: [ + { name: "input", body: "CreateDeviceRunSessionInput!" } + ], + selections: [ + { name: "deviceRunSession", body: ["{createDeviceRunSession(deviceRunSessionInput:$", "input", "){id}}"] } + ], + fragments: [] + }, + variables: { + input: Shared.CreateDeviceRunSessionInput + }, + result: Schema.Struct({ + /** Mutations that create, stop, update, and delete device run sessions */ + deviceRunSession: T3CreateEasSimulatorSessionDeviceRunSessionDeviceRunSessionMutation + }) +}) +export declare namespace T3CreateEasSimulatorSession { + export type Variables = typeof T3CreateEasSimulatorSession.variables.Type + export type Result = typeof T3CreateEasSimulatorSession.result.Type +} + +export const AgentDeviceRunSessionRemoteConfig = Schema.Struct({ + __typename: Schema.Literal("AgentDeviceRunSessionRemoteConfig"), + agentDeviceRemoteSessionUrl: Schema.String, + agentDeviceRemoteSessionToken: Schema.String, + /** Session token gating the web preview. Null when the preview runs ungated. */ + webPreviewToken: Schema.NullOr(Schema.String), + /** URL of the preview server, for reaching its API rather than its page. */ + previewApiUrl: Schema.NullOr(Schema.String) +}) +export interface AgentDeviceRunSessionRemoteConfig extends Schema.Struct.Type {} + +export const DeviceRunSessionRemoteConfigOther = Schema.Struct({ + __typename: GraphQL.otherTypename()(["AgentDeviceRunSessionRemoteConfig"]) +}) +export interface DeviceRunSessionRemoteConfigOther extends Schema.Struct.Type {} + +export const ByIdDeviceRunSession = Schema.Struct({ + id: Shared.ID, + status: GraphQL.enumLiterals(Shared.DeviceRunSessionStatus.literals), + remoteConfig: Schema.NullOr(Schema.Union([ + AgentDeviceRunSessionRemoteConfig, + DeviceRunSessionRemoteConfigOther + ])) +}) +export interface ByIdDeviceRunSession extends Schema.Struct.Type {} + +export const DeviceRunSessionQuery = Schema.Struct({ + byId: ByIdDeviceRunSession +}) +export interface DeviceRunSessionQuery extends Schema.Struct.Type {} + +/** `query T3EasSimulatorSession` */ +export const T3EasSimulatorSession = GraphQL.query("T3EasSimulatorSession", { + document: { + variables: [ + { name: "id", body: "ID!" } + ], + selections: [ + { name: "deviceRunSessions", body: ["{byId(deviceRunSessionId:$", "id", "){id status remoteConfig{__typename...on AgentDeviceRunSessionRemoteConfig{agentDeviceRemoteSessionUrl agentDeviceRemoteSessionToken webPreviewToken previewApiUrl}}}}"] } + ], + fragments: [] + }, + variables: { + id: Shared.ID + }, + result: Schema.Struct({ + deviceRunSessions: DeviceRunSessionQuery + }) +}) +export declare namespace T3EasSimulatorSession { + export type Variables = typeof T3EasSimulatorSession.variables.Type + export type Result = typeof T3EasSimulatorSession.result.Type +} + +export const NodeDeviceRunSession = Schema.Struct({ + id: Shared.ID, + status: GraphQL.enumLiterals(Shared.DeviceRunSessionStatus.literals), + /** + * Human-readable label set when the session is created or changed later with + * updateDeviceRunSession. Null when the session has no name. + */ + name: Schema.NullOr(Schema.String) +}) +export interface NodeDeviceRunSession extends Schema.Struct.Type {} + +export const AppDeviceRunSessionEdge = Schema.Struct({ + node: NodeDeviceRunSession +}) +export interface AppDeviceRunSessionEdge extends Schema.Struct.Type {} + +export const AppDeviceRunSessionsConnection = Schema.Struct({ + edges: Schema.Array(AppDeviceRunSessionEdge) +}) +export interface AppDeviceRunSessionsConnection extends Schema.Struct.Type {} + +/** Represents an Exponent App (or Experience in legacy terms) */ +export const T3ActiveEasSimulatorSessionsAppByIdApp = Schema.Struct({ + deviceRunSessionsPaginated: AppDeviceRunSessionsConnection +}) +export interface T3ActiveEasSimulatorSessionsAppByIdApp extends Schema.Struct.Type {} + +export const T3ActiveEasSimulatorSessionsAppAppQuery = Schema.Struct({ + /** Look up app by app id */ + byId: T3ActiveEasSimulatorSessionsAppByIdApp +}) +export interface T3ActiveEasSimulatorSessionsAppAppQuery extends Schema.Struct.Type {} + +/** `query T3ActiveEasSimulatorSessions` */ +export const T3ActiveEasSimulatorSessions = GraphQL.query("T3ActiveEasSimulatorSessions", { + document: { + variables: [ + { name: "appId", body: "String!" }, + { name: "filter", body: "DeviceRunSessionFilterInput" } + ], + selections: [ + { name: "app", body: ["{byId(appId:$", "appId", "){deviceRunSessionsPaginated(first:50 filter:$", "filter", "){edges{node{id status name}}}}}"] } + ], + fragments: [] + }, + variables: { + appId: Schema.String, + filter: Schema.optional(Schema.NullOr(Shared.DeviceRunSessionFilterInput)) + }, + result: Schema.Struct({ + app: T3ActiveEasSimulatorSessionsAppAppQuery + }) +}) +export declare namespace T3ActiveEasSimulatorSessions { + export type Variables = typeof T3ActiveEasSimulatorSessions.variables.Type + export type Result = typeof T3ActiveEasSimulatorSessions.result.Type +} + +export const EnsureDeviceRunSessionStoppedDeviceRunSession = Schema.Struct({ + id: Shared.ID, + status: GraphQL.enumLiterals(Shared.DeviceRunSessionStatus.literals) +}) +export interface EnsureDeviceRunSessionStoppedDeviceRunSession extends Schema.Struct.Type {} + +export const T3StopEasSimulatorSessionDeviceRunSessionDeviceRunSessionMutation = Schema.Struct({ + /** + * Ensure a device run session is stopped. Idempotent: if the session has already + * finished, the existing session is returned unchanged (an ERRORED session stays + * ERRORED). + */ + ensureDeviceRunSessionStopped: EnsureDeviceRunSessionStoppedDeviceRunSession +}) +export interface T3StopEasSimulatorSessionDeviceRunSessionDeviceRunSessionMutation extends Schema.Struct.Type {} + +/** `mutation T3StopEasSimulatorSession` */ +export const T3StopEasSimulatorSession = GraphQL.mutation("T3StopEasSimulatorSession", { + document: { + variables: [ + { name: "id", body: "ID!" } + ], + selections: [ + { name: "deviceRunSession", body: ["{ensureDeviceRunSessionStopped(deviceRunSessionId:$", "id", "){id status}}"] } + ], + fragments: [] + }, + variables: { + id: Shared.ID + }, + result: Schema.Struct({ + /** Mutations that create, stop, update, and delete device run sessions */ + deviceRunSession: T3StopEasSimulatorSessionDeviceRunSessionDeviceRunSessionMutation + }) +}) +export declare namespace T3StopEasSimulatorSession { + export type Variables = typeof T3StopEasSimulatorSession.variables.Type + export type Result = typeof T3StopEasSimulatorSession.result.Type +} + +/** Every operation in ExpoApi.graphql. */ +export const ExpoApiGraphQLGroup = GraphQLGroup.make(T3ExpoViewer, T3EasSimulatorAvailability, T3CreateEasSimulatorSession, T3EasSimulatorSession, T3ActiveEasSimulatorSessions, T3StopEasSimulatorSession) diff --git a/apps/server/src/device/ExpoApi.test.ts b/apps/server/src/device/ExpoApi.test.ts new file mode 100644 index 000000000000..730d34d38d9f --- /dev/null +++ b/apps/server/src/device/ExpoApi.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Redacted from "effect/Redacted"; +import { HttpClient, HttpClientError, HttpClientResponse } from "effect/http"; +import * as HostProcess from "@t3tools/shared/HostProcess"; + +import { makeExpoApi, resolveExpoCredential, type ExpoCredential } from "./ExpoApi.ts"; + +const header = (credential: ExpoCredential) => ({ + source: credential.source, + name: credential.header.name, + value: Redacted.value(credential.header.value), +}); + +describe("Expo credentials", () => { + it.effect("prefer the saved token, then EXPO_TOKEN, then the eas login session", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-expo-home-" }); + yield* fs.makeDirectory(path.join(home, ".expo")); + yield* fs.writeFileString( + path.join(home, ".expo", "state.json"), + JSON.stringify({ auth: { sessionSecret: "session-secret", username: "julius" } }), + ); + const resolve = (saved: string, env: NodeJS.ProcessEnv) => + resolveExpoCredential(saved).pipe( + Effect.provideService(HostProcess.HomeDirectory, home), + Effect.provideService(HostProcess.Environment, env), + Effect.map(header), + ); + expect(yield* resolve(" saved ", { EXPO_TOKEN: "env-token" })).toEqual({ + source: "settings", + name: "authorization", + value: "Bearer saved", + }); + expect(yield* resolve("", { EXPO_TOKEN: "env-token" })).toEqual({ + source: "env", + name: "authorization", + value: "Bearer env-token", + }); + expect(yield* resolve("", {})).toEqual({ + source: "eas-login", + name: "expo-session", + value: "session-secret", + }); + yield* fs.writeFileString( + path.join(home, ".expo", "state.json"), + JSON.stringify({ auth: null }), + ); + const missing = yield* resolve("", {}).pipe(Effect.exit); + expect(Exit.isFailure(missing) && missing.cause.toString()).toContain("eas login"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect.each([ + { case: "the redacted placeholder", saved: "\u2022\u2022\u2022\u2022\u2022\u2022", env: {} }, + { case: "a pasted newline", saved: "abc\ndef", env: {} }, + { case: "a malformed EXPO_TOKEN", saved: "", env: { EXPO_TOKEN: "abc def" } }, + ])("refuses $case without echoing it", ({ saved, env }) => + Effect.gen(function* () { + const error = yield* resolveExpoCredential(saved).pipe( + Effect.provideService(HostProcess.Environment, env), + Effect.flip, + ); + expect(error._tag).toBe("ExpoInvalidCredentialError"); + expect(error.message).not.toContain(saved || "abc def"); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); + +describe("Expo API", () => { + const credential: ExpoCredential = { + source: "eas-login", + header: { name: "expo-session", value: Redacted.make("session-secret") }, + }; + + it.effect("sends the credential and reads session endpoints once the simulator is ready", () => + Effect.gen(function* () { + const requests: Array<{ headers: Record; body: unknown }> = []; + const http = HttpClient.make((request) => + Effect.sync(() => { + const body = + request.body._tag === "Uint8Array" + ? (JSON.parse(new TextDecoder().decode(request.body.body)) as { variables: unknown }) + : null; + requests.push({ headers: request.headers, body }); + return HttpClientResponse.fromWeb( + request, + Response.json({ + data: { + deviceRunSessions: { + byId: { + id: "session-1", + status: "IN_PROGRESS", + remoteConfig: { + __typename: "AgentDeviceRunSessionRemoteConfig", + agentDeviceRemoteSessionUrl: "https://agent.test", + agentDeviceRemoteSessionToken: "agent-token", + webPreviewToken: "preview-token", + previewApiUrl: "https://preview.test?token=preview-token", + }, + }, + }, + }, + }), + ); + }), + ); + const session = yield* makeExpoApi(http, credential).session("session-1"); + expect(requests[0]?.headers["expo-session"]).toBe("session-secret"); + expect(requests[0]?.body).toMatchObject({ variables: { id: "session-1" } }); + expect(session.status).toBe("IN_PROGRESS"); + expect(session.endpoints?.agentDevice.url).toBe("https://agent.test"); + expect(Redacted.value(session.endpoints!.agentDevice.token)).toBe("agent-token"); + expect(session.endpoints?.preview?.url).toBe("https://preview.test?token=preview-token"); + expect(Redacted.value(session.endpoints!.preview!.token)).toBe("preview-token"); + }), + ); + + it.effect("keeps the credential out of errors when Expo is unreachable", () => + Effect.gen(function* () { + const http = HttpClient.make((request) => + Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + description: "connection reset", + }), + }), + ), + ); + const error = yield* makeExpoApi(http, credential).viewer.pipe(Effect.flip); + expect(error.message).toContain("Expo's API is unreachable"); + expect(JSON.stringify(error)).not.toContain("session-secret"); + expect(error.cause).toBeUndefined(); + // No answer: a mutation may have run. + expect(error.rejected).toBe(false); + }), + ); + + it.effect("reports GraphQL errors and rejected credentials as Expo failures", () => + Effect.gen(function* () { + const respond = (response: Response) => + HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, response))); + const graphQl = yield* makeExpoApi( + respond(Response.json({ errors: [{ message: "Feature not enabled" }] })), + credential, + ) + .availability("app-1") + .pipe(Effect.flip); + expect(graphQl.message).toBe( + "Expo could not check EAS Simulator access: Feature not enabled", + ); + const rejected = yield* makeExpoApi(respond(new Response("no", { status: 401 })), credential) + .stop("session-1") + .pipe(Effect.flip); + expect(rejected.message).toBe( + "Expo could not stop a simulator session: the eas login session was rejected (HTTP 401)", + ); + const serverError = yield* makeExpoApi( + respond(Response.json({ errors: [{ message: "Internal server error" }] }, { status: 502 })), + credential, + ) + .stop("session-1") + .pipe(Effect.flip); + expect(serverError.message).toBe( + "Expo could not stop a simulator session: Internal server error", + ); + // What Expo answers a refused request with: Apollo's default code, the + // user's fault, and null data. + const expoRefusal = yield* makeExpoApi( + respond( + Response.json({ + errors: [ + { + message: "Experience with id 0000 does not exist.", + extensions: { + code: "INTERNAL_SERVER_ERROR", + errorCode: "EXPERIENCE_NOT_FOUND", + errorType: "USER", + isTransient: false, + }, + }, + ], + data: null, + }), + ), + credential, + ) + .stop("session-1") + .pipe(Effect.flip); + const internalError = yield* makeExpoApi( + respond( + Response.json({ + errors: [ + { + message: "Something went wrong", + extensions: { errorCode: "INTERNAL_SERVER_ERROR" }, + }, + ], + }), + ), + credential, + ) + .stop("session-1") + .pipe(Effect.flip); + const transient = yield* makeExpoApi( + respond( + Response.json({ + errors: [ + { + message: "Something went wrong", + extensions: { errorCode: "INTERNAL_SERVER_ERROR", isTransient: true }, + }, + ], + }), + ), + credential, + ) + .stop("session-1") + .pipe(Effect.flip); + const ran = yield* makeExpoApi( + respond( + Response.json({ + data: { deviceRunSession: { stopDeviceRunSession: null } }, + errors: [ + { message: "Session is already stopping", extensions: { errorCode: "CONFLICT" } }, + ], + }), + ), + credential, + ) + .stop("session-1") + .pipe(Effect.flip); + const fault = yield* makeExpoApi( + respond( + Response.json({ + errors: [{ message: "Something went wrong", extensions: { errorType: "INTERNAL" } }], + }), + ), + credential, + ) + .stop("session-1") + .pipe(Effect.flip); + const echoed = yield* makeExpoApi( + respond( + Response.json({ + errors: [{ message: `Session session-secret is not yours (${"x".repeat(400)})` }], + }), + ), + credential, + ) + .stop("session-1") + .pipe(Effect.flip); + // A message that echoes the credential loses it, and is bounded. + expect(echoed.message).toContain("Session [redacted] is not yours"); + expect(echoed.message).not.toContain("session-secret"); + expect(echoed.message.length).toBeLessThan(400); + const userError = yield* makeExpoApi( + respond( + Response.json({ + errors: [ + { message: "Not allowed", extensions: { errorType: "USER", errorCode: "FORBIDDEN" } }, + ], + }), + ), + credential, + ) + .stop("session-1") + .pipe(Effect.flip); + // Expo answered and refused the first three: nothing was done. GraphQL + // errors at a server error, a transient, internal or other non-user + // error, or errors beside data, may come from a request that ran. + expect([ + graphQl.rejected, + rejected.rejected, + userError.rejected, + expoRefusal.rejected, + serverError.rejected, + internalError.rejected, + transient.rejected, + fault.rejected, + ran.rejected, + ]).toEqual([true, true, true, true, false, false, false, false, false]); + }), + ); +}); diff --git a/apps/server/src/device/ExpoApi.ts b/apps/server/src/device/ExpoApi.ts new file mode 100644 index 000000000000..d4bff13c6a76 --- /dev/null +++ b/apps/server/src/device/ExpoApi.ts @@ -0,0 +1,366 @@ +/** + * Expo's GraphQL API for EAS Simulator sessions. The operations in + * `ExpoApi.graphql` are the ones eas-cli's `simulator:*` commands send; their + * client is generated from `schema/expo.graphql` (`vp run generate`). + */ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import { + GraphQLClient, + GraphQLClientError, + GraphQLMiddleware, + GraphQLProtocol, +} from "effect/graphql"; +import { HttpClient } from "effect/http"; +import * as Headers from "effect/http/Headers"; +import * as HostProcess from "@t3tools/shared/HostProcess"; + +import { ExpoApiGraphQLGroup, type T3EasSimulatorSession } from "./ExpoApi.graphql.ts"; +import type { DeviceRunSessionStatus } from "../../schema/expo.graphql.ts"; + +const EXPO_GRAPHQL_URL = "https://api.expo.dev/graphql"; +const REQUEST_TIMEOUT = "30 seconds"; + +export type ExpoCredentialSource = "settings" | "env" | "eas-login"; + +export interface ExpoCredential { + readonly source: ExpoCredentialSource; + readonly header: { + readonly name: "authorization" | "expo-session"; + readonly value: Redacted.Redacted; + }; +} + +export class ExpoNotSignedInError extends Schema.TaggedError()( + "ExpoNotSignedInError", + {}, +) { + override get message(): string { + return "No Expo credential on the server: save an access token for this host, set EXPO_TOKEN, or run `eas login`."; + } +} + +const CREDENTIAL_SOURCE_NAMES: Record = { + settings: "The access token saved for this host", + env: "EXPO_TOKEN", + "eas-login": "The eas login session", +}; + +export class ExpoInvalidCredentialError extends Schema.TaggedError()( + "ExpoInvalidCredentialError", + { source: Schema.Literals(["settings", "env", "eas-login"]) }, +) { + override get message(): string { + return `${CREDENTIAL_SOURCE_NAMES[this.source]} is not a usable Expo credential. Replace it.`; + } +} + +/** + * Expo credentials are printable ASCII. Anything else (the redacted + * placeholder, a pasted newline) would fail inside fetch with the header + * value in its message, so it is refused here. + */ +const credential = ( + source: ExpoCredentialSource, + header: ExpoCredential["header"]["name"], + value: string, +) => + /^[\x21-\x7e]+$/.test(value) + ? Effect.succeed({ + source, + header: { + name: header, + value: Redacted.make(header === "authorization" ? `Bearer ${value}` : value), + }, + } satisfies ExpoCredential) + : Effect.fail(new ExpoInvalidCredentialError({ source })); + +export class ExpoApiError extends Schema.TaggedError()("ExpoApiError", { + operation: Schema.String, + detail: Schema.optional(Schema.String), + /** + * Expo answered and refused, so a mutation did nothing. Otherwise (no + * answer, a server error, an answer that did not decode) it may have run. + */ + rejected: Schema.optional(Schema.Boolean), + /** The HTTP status Expo answered with, when it answered. */ + status: Schema.optional(Schema.Int), +}) { + override get message(): string { + return `Expo could not ${this.operation}${this.detail ? `: ${this.detail}` : "."}`; + } +} + +/** `eas login` and `expo login` keep the session secret here. */ +const ExpoState = Schema.fromJsonString( + Schema.Struct({ + auth: Schema.optional( + Schema.NullOr( + Schema.Struct({ sessionSecret: Schema.optional(Schema.NullOr(Schema.String)) }), + ), + ), + }), +); +const decodeExpoState = Schema.decodeUnknownOption(ExpoState); + +/** A token saved for the host, then EXPO_TOKEN, then the `eas login` session. */ +export const resolveExpoCredential = Effect.fn("ExpoApi.resolveExpoCredential")(function* ( + savedToken: string, +) { + const saved = savedToken.trim(); + if (saved !== "") return yield* credential("settings", "authorization", saved); + const fromEnv = (yield* HostProcess.Environment).EXPO_TOKEN?.trim(); + if (fromEnv) return yield* credential("env", "authorization", fromEnv); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const statePath = path.join(yield* HostProcess.HomeDirectory, ".expo", "state.json"); + const state = yield* fs.readFileString(statePath).pipe(Effect.option); + const secret = Option.flatMap(state, decodeExpoState).pipe( + Option.flatMap((value) => Option.fromNullishOr(value.auth?.sessionSecret?.trim() || null)), + ); + if (Option.isNone(secret)) return yield* new ExpoNotSignedInError(); + return yield* credential("eas-login", "expo-session", secret.value); +}); + +export type EasSessionStatus = DeviceRunSessionStatus; + +export interface EasSession { + readonly id: string; + readonly status: EasSessionStatus; + /** Present once the simulator is ready; tokens grant full device control. */ + readonly endpoints: { + readonly agentDevice: { readonly url: string; readonly token: Redacted.Redacted }; + readonly preview: { readonly url: string; readonly token: Redacted.Redacted } | null; + } | null; +} + +export interface EasApp { + readonly id: string; + readonly fullName: string; +} + +export interface CreateEasSessionInput { + readonly appId: string; + readonly platform: "ios" | "android"; + readonly name: string; + readonly maxRunTimeMinutes: number; + readonly maxIdleTimeMinutes: number; +} + +const DEVICE_RUN_SESSIONS_GATE = "device-run-sessions"; +const isFeatureGates = Schema.is(Schema.Record(Schema.String, Schema.Unknown)); + +/** Sends every operation with the resolved credential. */ +class ExpoAuth extends GraphQLMiddleware.Service()("t3/server/device/ExpoApi/ExpoAuth") {} + +const ExpoGroup = ExpoApiGraphQLGroup.middleware(ExpoAuth); + +/** + * Expo's answer as an `ExpoApiError`, which has no cause: a transport error's + * holds the request with its credential header, and a decode error's can + * quote session tokens. These errors also travel to clients as RPC defects. + */ +const toExpoApiError = ( + operation: string, + credential: ExpoCredential, + error: GraphQLClientError.GraphQLClientError, +): ExpoApiError => { + const reason = error.reason; + const status = + reason._tag === "TransportError" || reason._tag === "ResponseError" ? reason.status : undefined; + // Expo refused: a request it never got, a 4xx, or GraphQL errors at a 2xx + // or 4xx that are the user's fault. Expo marks those `errorType: "USER"` + // and not `isTransient` (eas-cli's rule; Apollo's `code` says nothing, as + // Expo puts INTERNAL_SERVER_ERROR on user errors too) and answers them with + // `data: null`, since its mutations are non-null. GraphQL errors at a 5xx, + // beside data (the request ran), or that are Expo's own fault came from a + // request that may have run. + const errors = reason._tag === "ResponseError" ? reason.errors : []; + const fault = errors.some( + ({ extensions }) => + extensions?.isTransient === true || + (extensions?.errorType !== undefined && extensions.errorType !== "USER") || + extensions?.errorCode === "INTERNAL_SERVER_ERROR", + ); + const usersFault = + errors.length > 0 && errors.every(({ extensions }) => extensions?.errorType === "USER"); + const uncertain = + reason._tag === "ResponseError" && + (fault || (reason.data !== undefined && !(reason.data === null && usersFault))); + const rejected = + reason._tag === "EncodeError" || + (status !== undefined && + status < 500 && + !uncertain && + (status >= 400 || reason._tag === "ResponseError")); + // Expo's own messages are the reason the user acts on, but nothing promises + // they never echo a request: the credential is cut out, and they are bounded. + const secret = Redacted.value(credential.header.value); + const token = secret.replace(/^Bearer /, ""); + const scrub = (text: string) => + text.replaceAll(secret, "[redacted]").replaceAll(token, "[redacted]").slice(0, 300); + const failure = (detail: string) => + new ExpoApiError({ + operation, + detail: scrub(detail), + rejected, + ...(status !== undefined ? { status } : {}), + }); + if (status === 401 || status === 403) { + const what = credential.source === "eas-login" ? "eas login session" : "access token"; + return failure(`the ${what} was rejected (HTTP ${status})`); + } + switch (reason._tag) { + case "ResponseError": + return failure(reason.errors.map((graphQlError) => graphQlError.message).join("; ")); + case "TransportError": + return failure( + status === undefined ? "Expo's API is unreachable" : `unexpected response (HTTP ${status})`, + ); + case "EncodeError": + return failure("the request could not be encoded"); + default: + return failure("unexpected response"); + } +}; + +const toSession = ( + session: T3EasSimulatorSession.Result["deviceRunSessions"]["byId"], +): EasSession => { + const remote = session.remoteConfig; + return { + id: session.id, + status: session.status, + endpoints: + remote?.__typename === "AgentDeviceRunSessionRemoteConfig" + ? { + agentDevice: { + url: remote.agentDeviceRemoteSessionUrl, + token: Redacted.make(remote.agentDeviceRemoteSessionToken), + }, + preview: + remote.previewApiUrl && remote.webPreviewToken + ? { url: remote.previewApiUrl, token: Redacted.make(remote.webPreviewToken) } + : null, + } + : null, + }; +}; + +/** Typed calls against Expo's API with one resolved credential. */ +export const makeExpoApi = (http: HttpClient.HttpClient, credential: ExpoCredential) => { + const client = GraphQLClient.make(ExpoGroup).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed( + ExpoAuth, + GraphQLMiddleware.mapRequest((request) => + Effect.succeed({ + ...request, + headers: Headers.set( + request.headers, + credential.header.name, + Redacted.value(credential.header.value), + ), + }), + ), + ), + GraphQLProtocol.layerHttp({ url: EXPO_GRAPHQL_URL }).pipe( + Layer.provide(Layer.succeed(HttpClient.HttpClient, http)), + ), + ), + ), + ); + const call = ( + operation: string, + run: ( + client: GraphQLClient.FromGroup, + ) => Effect.Effect, + ) => + Effect.flatMap(client, run).pipe( + Effect.mapError((error) => toExpoApiError(operation, credential, error)), + Effect.timeoutOrElse({ + duration: REQUEST_TIMEOUT, + orElse: () => + Effect.fail(new ExpoApiError({ operation, detail: "Expo's API did not answer in time" })), + }), + Effect.withSpan(`ExpoApi.${operation}`), + ); + + return { + credential, + viewer: call("read the signed-in account", (expo) => expo.T3ExpoViewer()).pipe( + Effect.flatMap(({ meActor }) => + meActor === null + ? Effect.fail( + new ExpoApiError({ + operation: "read the signed-in account", + detail: "no account is signed in", + }), + ) + : Effect.succeed({ + name: + "username" in meActor + ? meActor.username + : "firstName" in meActor + ? (meActor.firstName ?? meActor.__typename) + : meActor.__typename, + apps: meActor.accounts.flatMap((account) => + account.appsPaginated.edges.map(({ node }): EasApp => node), + ), + }), + ), + ), + availability: (appId: string) => + call("check EAS Simulator access", (expo) => + expo.T3EasSimulatorAvailability({ appId, filter: [DEVICE_RUN_SESSIONS_GATE] }), + ).pipe( + Effect.map(({ app }) => { + const gates = app.byId.ownerAccount.accountFeatureGates; + return { + account: app.byId.ownerAccount.name, + available: isFeatureGates(gates) && gates[DEVICE_RUN_SESSIONS_GATE] === true, + }; + }), + ), + create: (input: CreateEasSessionInput) => + call("start a simulator session", (expo) => + expo.T3CreateEasSimulatorSession({ + input: { + appId: input.appId, + platform: input.platform === "ios" ? "IOS" : "ANDROID", + type: "AGENT_DEVICE", + name: input.name, + maxRunTimeMinutes: input.maxRunTimeMinutes, + maxIdleTimeMinutes: input.maxIdleTimeMinutes, + // Expo only issues the preview host's API URL when preview access is configured. + corsOrigins: ["http://localhost"], + }, + }), + ).pipe(Effect.map(({ deviceRunSession }) => deviceRunSession.createDeviceRunSession.id)), + session: (id: string) => + call("read a simulator session", (expo) => expo.T3EasSimulatorSession({ id })).pipe( + Effect.map(({ deviceRunSessions }) => toSession(deviceRunSessions.byId)), + ), + activeSessions: (appId: string) => + call("list simulator sessions", (expo) => + expo.T3ActiveEasSimulatorSessions({ + appId, + filter: { statuses: ["NEW", "QUEUED", "STARTING", "IN_PROGRESS"] }, + }), + ).pipe( + Effect.map(({ app }) => app.byId.deviceRunSessionsPaginated.edges.map(({ node }) => node)), + ), + stop: (id: string) => + call("stop a simulator session", (expo) => expo.T3StopEasSimulatorSession({ id })).pipe( + Effect.map(({ deviceRunSession }) => deviceRunSession.ensureDeviceRunSessionStopped.status), + ), + }; +}; + +export type ExpoApi = ReturnType; diff --git a/apps/server/src/device/easProjectId.test.ts b/apps/server/src/device/easProjectId.test.ts new file mode 100644 index 000000000000..787fd198bf36 --- /dev/null +++ b/apps/server/src/device/easProjectId.test.ts @@ -0,0 +1,706 @@ +import { expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import { findEasProjectId } from "./easProjectId.ts"; + +const ID_A = "d763fcb8-d37c-41ea-a773-b54a0ab4a454"; +const ID_B = "54d70eaf-b0a3-477a-a9cd-2cf81a5c84c9"; + +const workspace = (files: Record) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-eas-project-" }); + for (const [file, content] of Object.entries(files)) { + yield* fs.makeDirectory(path.dirname(path.join(root, file)), { recursive: true }); + yield* fs.writeFileString(path.join(root, file), content); + } + return root; + }); + +const layer = NodeServices.layer; + +it.effect("reads a literal project id from an app config without running it", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* workspace({}); + const marker = path.join(root, "config-ran"); + yield* fs.makeDirectory(path.join(root, "apps/mobile"), { recursive: true }); + yield* fs.writeFileString( + path.join(root, "apps/mobile/app.config.ts"), + `import { writeFileSync } from "node:fs"; + writeFileSync(${JSON.stringify(marker)}, ""); + export default { extra: { eas: { projectId: "${ID_A}" } } };`, + ); + expect(yield* findEasProjectId(root)).toEqual(Option.some(ID_A)); + expect(yield* fs.exists(marker)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.effect("falls back to app.json, and does not compute a project id a config builds", () => + Effect.gen(function* () { + const staticOnly = yield* workspace({ + "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }), + }); + expect(yield* findEasProjectId(staticOnly)).toEqual(Option.some(ID_B)); + const computed = yield* workspace({ + "app.config.ts": `const parts = ${JSON.stringify(ID_A.split("-"))}; + export default { extra: { eas: { projectId: parts.join("-") } } };`, + }); + expect(yield* findEasProjectId(computed)).toEqual(Option.none()); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.effect.each([ + { + case: "reads the one project id a config names, whatever else it holds", + files: { + "app.config.js": `export default { + assetBundlePatterns: ["**/*"], + extra: { eas: { projectId: "${ID_A}" }, firebase: { projectId: "my-app-12345" } }, + }; + /** docs */`, + "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), + }, + expected: Option.some(ID_A), + }, + { + case: "gives no answer when a config names a project only some builds use", + files: { + "app.config.ts": `export default ({ config }) => + process.env.APP_VARIANT === "preview" + ? { ...config, extra: { ...config.extra, eas: { projectId: "${ID_B}" } } } + : config;`, + "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), + }, + expected: Option.none(), + }, + { + case: "gives no answer when a comment keeps an old project id", + files: { + "app.config.js": `// projectId: "${ID_B}" (the old production project) + export default { extra: { eas: { projectId: "${ID_A}" } } };`, + }, + expected: Option.none(), + }, + { + case: "gives no answer for a config naming two projects, even with app.json", + files: { + "app.config.js": `export default () => ({ + extra: { eas: { projectId: process.env.PROD ? "${ID_A}" : "${ID_B}" } }, + });`, + "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), + }, + expected: Option.none(), + }, + { + case: "gives no answer when one app sets its project from a variable", + files: { + "apps/mobile/app.config.ts": `export default { + extra: { eas: { projectId: process.env.EAS_PROJECT_ID } }, + };`, + "apps/kiosk/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }), + }, + expected: Option.none(), + }, + { + case: "gives no answer when a comment's id sits beside a computed one", + files: { + "app.config.ts": `// projectId: "${ID_A}" + export default { extra: { eas: { projectId: process.env.EAS_PROJECT_ID } } };`, + }, + expected: Option.none(), + }, + { + case: "gives no answer when a config changes the project id after naming it", + files: { + "app.config.js": `export default ({ config }) => { + config.extra = { eas: { projectId: "${ID_A}" } }; + if (process.env.APP_ENV === "development") { + config.extra.eas.projectId = process.env.DEV_PROJECT_ID; + } + return config; + };`, + }, + expected: Option.none(), + }, + { + case: "uses app.json when the config only mentions a longer identifier", + files: { + "app.config.js": `const easProjectIds = {}; + export default ({ config }) => ({ ...config, name: "Mobile", easProjectIds });`, + "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }), + }, + expected: Option.some(ID_B), + }, + { + case: "gives no answer when a longer quoted key sits beside a computed project id", + files: { + "app.config.js": `const defaults = { easProjectId: "${ID_B}" }; + export default { extra: { eas: { projectId: process.env.EAS_PROJECT_ID ?? defaults.easProjectId } } };`, + }, + expected: Option.none(), + }, + { + case: "gives no answer when a config joins the project id from pieces", + files: { + "apps/mobile/app.config.ts": `export default { + extra: { eas: { projectId: "${ID_A.slice(0, 9)}" + "${ID_A.slice(9)}" } }, + };`, + "apps/kiosk/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }), + }, + expected: Option.none(), + }, + { + case: "gives no answer when a config joins the project id around a comment", + files: { + "apps/mobile/app.config.ts": `export default { + extra: { eas: { projectId: "${ID_A.slice(0, 9)}" /* prefix */ + "${ID_A.slice(9)}" } }, + };`, + "apps/kiosk/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }), + }, + expected: Option.none(), + }, + { + case: "reads a project id a comment follows", + files: { + "app.config.ts": `export default { + extra: { eas: { projectId: "${ID_A}" /* production */ // the only one + } }, + };`, + }, + expected: Option.some(ID_A), + }, + { + case: "gives no answer when a separator inside a comment precedes the rest of the id", + files: { + "apps/mobile/app.config.ts": `export default { + extra: { eas: { projectId: "${ID_A.slice(0, 9)}" // prefix, continued (below) + + "${ID_A.slice(9)}" } }, + };`, + "apps/kiosk/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }), + }, + expected: Option.none(), + }, + { + case: "gives no answer when a block comment with a separator precedes the rest of the id", + files: { + "apps/mobile/app.config.ts": `export default { + extra: { eas: { projectId: "${ID_A.slice(0, 9)}" /* prefix, (see) */ + "${ID_A.slice(9)}" } }, + };`, + "apps/kiosk/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }), + }, + expected: Option.none(), + }, + { + case: "gives no answer when a spread after the project id may replace it", + files: { + "apps/mobile/app.config.js": `import { eas } from "./app.shared.js"; + export default { extra: { eas: { projectId: "${ID_A}", ...eas } } };`, + }, + expected: Option.none(), + }, + { + case: "gives no answer when a spread after the project id hides behind a comment", + files: { + "apps/mobile/app.config.js": `import { easOverrides } from "./app.shared.js"; + export default { extra: { eas: { projectId: "${ID_A}" /* } */, ...easOverrides } } };`, + }, + expected: Option.none(), + }, + { + case: "gives no answer when a spread at the top of the config may replace the project id", + files: { + "apps/mobile/app.config.js": `import { brand } from "./app.shared.js"; + export default { extra: { eas: { projectId: "${ID_A}" } }, ...brand };`, + }, + expected: Option.none(), + }, + { + case: "gives no answer when a spread in an enclosing object may replace the project id", + files: { + "apps/mobile/app.config.js": `import { extraOverrides } from "./app.shared.js"; + export default { extra: { eas: { projectId: "${ID_A}" }, ...extraOverrides } };`, + }, + expected: Option.none(), + }, + { + case: "gives no answer when the config is assigned over after naming the project id", + files: { + "app.config.js": `export default ({ config }) => { + config.extra = { eas: { projectId: "${ID_A}" } }; + Object.assign(config.extra.eas, JSON.parse(process.env.EAS_OVERRIDES ?? "{}")); + return config; + };`, + }, + expected: Option.none(), + }, + { + case: "gives no answer when the config is assigned over across lines after naming the project id", + files: { + "app.config.js": `import { assign } from "lodash"; + export default ({ config }) => { + config.extra = { eas: { projectId: "${ID_A}" } }; + Object + .assign(config.extra.eas, JSON.parse(process.env.EAS_OVERRIDES ?? "{}")); + return config; + };`, + }, + expected: Option.none(), + }, + { + case: "gives no answer when a helper assigns over the config after naming the project id", + files: { + "app.config.js": `import { assign } from "lodash"; + export default ({ config }) => { + config.extra = { eas: { projectId: "${ID_A}" } }; + assign (config.extra.eas, JSON.parse(process.env.EAS_OVERRIDES ?? "{}")); + return config; + };`, + }, + expected: Option.none(), + }, + { + case: "gives no answer when the config is assigned over from defaults that name the project id", + files: { + "app.config.js": `module.exports = Object.assign( + { name: "Mobile", extra: { eas: { projectId: "${ID_A}" } } }, + require("./app.config.local.js"), + );`, + }, + expected: Option.none(), + }, + { + case: "gives no answer when a config spreads another file over app.json's project id", + files: { + "app.config.js": `module.exports = ({ config }) => ({ ...config, ...require("./app.local.js") });`, + "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), + }, + expected: Option.none(), + }, + { + case: "gives no answer when a comment or parentheses sit between a spread and another file", + files: { + "app.config.js": `module.exports = ({ config }) => ({ + ...config, + .../* environment overrides */ require("./app.local.js"), + });`, + "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), + }, + expected: Option.none(), + }, + { + case: "gives no answer when nested parentheses sit between a spread and another file", + files: { + "app.config.js": `module.exports = ({ config }) => ({ ...config, ...((require("./app.local.js"))) });`, + "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), + }, + expected: Option.none(), + }, + { + case: "gives no answer when another service's project id sits beside a spread over app.json's", + files: { + "app.config.js": `module.exports = ({ config }) => ({ + ...config, + ...require("./app.local.js"), + web: { config: { firebase: { projectId: "my-firebase-app" } } }, + });`, + "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), + }, + expected: Option.none(), + }, + { + case: "gives no answer when a dynamic config spreads nothing, which may or may not use app.json", + files: { + "app.config.js": `export default { name: "Mobile", slug: "mobile" };`, + "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), + }, + expected: Option.none(), + }, + { + case: "does not let a sibling win over an app whose config returns app.json's config changed", + files: { + "apps/mobile/app.config.js": `module.exports = ({ config }) => { + config.name = "Mobile"; + return config; + };`, + "apps/mobile/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), + "apps/kiosk/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }), + }, + expected: Option.none(), + }, + { + case: "gives no answer when a spread of an identifier starting with config may replace app.json's id", + files: { + "app.config.js": `const config$ = require("./app.local.js"); + module.exports = ({ config }) => ({ ...config, ...config$ });`, + "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), + }, + expected: Option.none(), + }, + { + case: "gives no answer when that identifier is spelled with a Unicode escape", + files: { + "app.config.js": `const config$ = require("./app.local.js"); + module.exports = ({ config }) => ({ ...config, ...config\\u0024 });`, + "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), + }, + expected: Option.none(), + }, + { + case: "gives no answer when a config spreads the config but replaces extra", + files: { + "app.config.js": `module.exports = ({ config }) => ({ ...config, extra: {} });`, + "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), + }, + expected: Option.none(), + }, + { + case: "gives no answer when a config spreads the config but replaces a quoted extra", + files: { + "app.config.js": `module.exports = ({ config }) => ({ ...config, "extra": {} });`, + "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), + }, + expected: Option.none(), + }, + { + case: "gives no answer when a config spreads the config but replaces a computed extra", + files: { + "app.config.js": `module.exports = ({ config }) => ({ ...config, ["extra"]: {} });`, + "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), + }, + expected: Option.none(), + }, + { + case: "gives no answer when a config spreads the config but replaces extra by shorthand", + files: { + "app.config.js": `const extra = {};\nmodule.exports = ({ config }) => ({ ...config, extra });`, + "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), + }, + expected: Option.none(), + }, + { + case: "gives no answer when a config has more extra entries than are read", + files: { + "app.config.js": `module.exports = ({ config }) => ({ ...config, extra: { ...config.extra, ${"extra: { ...config.extra, ".repeat(40)}${"}".repeat(40)} } });`, + "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), + }, + expected: Option.none(), + }, + { + case: "gives no answer when a config assigns extra in brackets after a literal id", + files: { + "app.config.js": `module.exports = ({ config }) => { + const c = { ...config, extra: { eas: { projectId: "${ID_A}" } } }; + c["extra"] = other(); + return c; + };`, + }, + expected: Option.none(), + }, + { + case: "gives no answer when a config deletes from the config it spreads", + files: { + "app.config.js": `module.exports = ({ config }) => { + delete config.extra; + return { ...config }; + };`, + "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), + }, + expected: Option.none(), + }, + { + case: "gives no answer when a config spreads part of the config over it", + files: { + "app.config.js": `module.exports = ({ config }) => ({ ...config, ...config.extra.preview });`, + "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), + }, + expected: Option.none(), + }, + { + case: "gives no answer when a config spreads a property of the config instead of it", + files: { + "app.config.js": `module.exports = ({ config }) => ({ ...config.preview });`, + "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), + }, + expected: Option.none(), + }, + { + case: "gives no answer when an extra entry spreads the whole config, not its extra", + files: { + "app.config.js": `module.exports = ({ config }) => ({ ...config, extra: { ...config } });`, + "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), + }, + expected: Option.none(), + }, + { + case: "gives no answer when an eas entry spreads the config's extra, not its eas", + files: { + "app.config.js": `module.exports = ({ config }) => ({ ...config, extra: { ...config.extra, eas: { ...config.extra } } });`, + "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), + }, + expected: Option.none(), + }, + { + case: "reads app.json's id through extra and eas entries that spread their own", + files: { + "app.config.js": `module.exports = ({ config }) => ({ + ...config, + extra: { ...config?.["extra"], eas: { ...config.extra?.eas, build: { experimental: true } }, other: 1 }, + });`, + "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), + }, + expected: Option.some(ID_A), + }, + { + case: "gives no answer when a config spreads the config but replaces eas", + files: { + "app.config.js": `module.exports = ({ config }) => ({ + ...config, + extra: { ...config.extra, eas: { build: { experimental: true } } }, + });`, + "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), + }, + expected: Option.none(), + }, + { + case: "reads app.json's project id through a config that only spreads the config itself", + files: { + "app.config.js": `module.exports = ({ config }) => ({ + ...config, + extra: { ...config.extra, eas: { ...(config.extra?.eas ?? {}) }, apiUrl: process.env.API_URL }, + });`, + "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), + }, + expected: Option.some(ID_A), + }, + { + case: "gives no answer when a config may return another file's config on some builds", + files: { + "app.config.js": `module.exports = process.env.PROD + ? { extra: { eas: { projectId: "${ID_A}" } } } + : require("./app.config.staging.js");`, + }, + expected: Option.none(), + }, + { + case: "gives no answer when a config assigns over the object holding the project id", + files: { + "app.config.js": `export default ({ config }) => { + const c = { extra: { eas: { projectId: "${ID_A}" } } }; + c.extra.eas = require("./eas.local.js"); + return c; + };`, + }, + expected: Option.none(), + }, + { + case: "reads a project id that follows spreads", + files: { + "app.config.js": `export default ({ config }) => ({ + ...config, + extra: { ...config.extra, eas: { ...(config.extra?.eas ?? {}), projectId: "${ID_A}" } }, + });`, + }, + expected: Option.some(ID_A), + }, + { + case: "gives no answer when a config joins the project id across lines", + files: { + "apps/mobile/app.config.ts": `export default { + extra: { eas: { projectId: "${ID_A.slice(0, 9)}" + + "${ID_A.slice(9)}" } }, + };`, + "apps/kiosk/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }), + }, + expected: Option.none(), + }, + { + case: "gives no answer when a config builds the project id in a template", + files: { + "app.config.js": + "export default { extra: { eas: { projectId: `${process.env.EAS_PROJECT_ID}` } } };", + "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), + }, + expected: Option.none(), + }, + { + case: "gives no answer when an app.json Expo reads as JSON5 cannot be decoded here", + files: { + "apps/mobile/app.json": `{ + // the mobile app + "expo": { "extra": { "eas": { "projectId": "${ID_B}" } }, }, + }`, + "apps/admin/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), + }, + expected: Option.none(), + }, + { + case: "reads only the first static config, like Expo", + files: { + "app.config.json": JSON.stringify({ expo: { name: "Mobile" } }), + "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), + }, + expected: Option.none(), + }, + { + case: "reads a static config that is not wrapped in `expo`", + files: { + "apps/mobile/app.config.json": JSON.stringify({ extra: { eas: { projectId: ID_A } } }), + "apps/README.md": "one app", + }, + expected: Option.some(ID_A), + }, + { + case: "does not guess between an unwrapped static config and a wrapped one", + files: { + "apps/mobile/app.config.json": JSON.stringify({ extra: { eas: { projectId: ID_A } } }), + "apps/kiosk/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }), + }, + expected: Option.none(), + }, + { + case: "uses app.json when the config never mentions a project id", + files: { + "app.config.js": `export default ({ config }) => ({ ...config, name: "Mobile" });`, + "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }), + }, + expected: Option.some(ID_B), + }, +])("$case", ({ files, expected }) => + Effect.gen(function* () { + expect(yield* findEasProjectId(yield* workspace(files))).toEqual(expected); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.effect("reads app.config.json like app.json", () => + Effect.gen(function* () { + const root = yield* workspace({ + "app.config.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }), + }); + expect(yield* findEasProjectId(root)).toEqual(Option.some(ID_B)); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.effect.each(["\r", "\u2028", "\u2029"])( + "gives no answer when a comment ending in %o precedes the rest of a joined id", + (terminator) => + Effect.gen(function* () { + // A line comment ends at any ECMAScript line terminator, not only LF. + const root = yield* workspace({ + "apps/mobile/app.config.ts": + `export default { extra: { eas: { projectId: "${ID_A.slice(0, 9)}" // prefix${terminator}` + + `+ "${ID_A.slice(9)}" } } };`, + "apps/kiosk/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }), + }); + expect(yield* findEasProjectId(root)).toEqual(Option.none()); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.effect.each([ + { + case: "a long run of spaces after a spread", + source: `({ config }) => ({ ...${" ".repeat(255 * 1024)}config })`, + }, + { + case: "many spreads inside one comment", + source: `({ config }) => ({ .../*${".../*".repeat(30_000)}*/config })`, + }, + { + case: "many spreads inside line comments", + source: `({ config }) => ({ ...//${" ...//".repeat(30_000)}\nconfig })`, + }, +])("reads $case at once", ({ source }) => + Effect.gen(function* () { + const root = yield* workspace({ + "app.config.js": `module.exports = ${source};`, + "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), + }); + const startedAt = performance.now(); + expect(yield* findEasProjectId(root)).toEqual(Option.some(ID_A)); + expect(performance.now() - startedAt).toBeLessThan(10_000); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.effect("gives no answer at once for a config with more entries than one can have", () => + Effect.gen(function* () { + // Just under the size bound, every entry followed by an unterminated + // comment would still take seconds; the entry count stops it first. + const entry = `projectId: "${ID_A.slice(0, 9)}" /* `; + const root = yield* workspace({ + "apps/mobile/app.config.ts": entry.repeat(Math.floor((255 * 1024) / entry.length)), + "apps/kiosk/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }), + }); + const startedAt = performance.now(); + expect(yield* findEasProjectId(root)).toEqual(Option.none()); + expect(performance.now() - startedAt).toBeLessThan(10_000); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.effect("gives no answer at once for a config too large to be one", () => + Effect.gen(function* () { + // Each entry followed by an unterminated comment scans to the end of the + // file: quadratic on a file made for it. A config this large is unknown. + const entry = `projectId: "${ID_A.slice(0, 9)}" /* `; + const root = yield* workspace({ + "apps/mobile/app.config.ts": entry.repeat(Math.ceil((1024 * 1024) / entry.length)), + "apps/kiosk/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }), + }); + // The bound is loose for a loaded test machine; the slow cases take minutes. + const startedAt = performance.now(); + expect(yield* findEasProjectId(root)).toEqual(Option.none()); + expect(performance.now() - startedAt).toBeLessThan(10_000); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.effect("answers at once for a long comment before the rest of a joined id", () => + Effect.gen(function* () { + // A comment with many slashes before a continuation made an earlier + // matcher try every way to split it into comments: minutes, not ms. + const urls = Array.from({ length: 40 }, () => "https://expo.dev").join(" "); + const root = yield* workspace({ + "apps/mobile/app.config.ts": `export default { + extra: { eas: { projectId: "${ID_A.slice(0, 9)}" // ${urls} + + "${ID_A.slice(9)}" } }, + };`, + "apps/kiosk/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }), + }); + // The bound is loose for a loaded test machine; the slow cases take minutes. + const startedAt = performance.now(); + expect(yield* findEasProjectId(root)).toEqual(Option.none()); + expect(performance.now() - startedAt).toBeLessThan(10_000); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.effect("gives no answer when an app's config is there but cannot be read", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + for (const file of ["app.json", "app.config.json"]) { + const root = yield* workspace({ + "apps/two/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }), + }); + // A directory where the file should be: it exists but cannot be read. + yield* fs.makeDirectory(path.join(root, "apps/one", file), { recursive: true }); + expect(yield* findEasProjectId(root)).toEqual(Option.none()); + } + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.effect("does not guess between apps that name different EAS projects", () => + Effect.gen(function* () { + const root = yield* workspace({ + "apps/one/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), + "apps/two/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }), + }); + expect(yield* findEasProjectId(root)).toEqual(Option.none()); + expect(yield* findEasProjectId(yield* workspace({ "README.md": "no app" }))).toEqual( + Option.none(), + ); + }).pipe(Effect.scoped, Effect.provide(layer)), +); diff --git a/apps/server/src/device/easProjectId.ts b/apps/server/src/device/easProjectId.ts new file mode 100644 index 000000000000..5b640967ee78 --- /dev/null +++ b/apps/server/src/device/easProjectId.ts @@ -0,0 +1,257 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +const CONFIG_FILES = [ + "app.config.ts", + "app.config.mts", + "app.config.js", + "app.config.mjs", + "app.config.cjs", +]; +const JSON_CONFIG_FILES = ["app.config.json", "app.json"]; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +// A `projectId` whose whole value is one quoted string: the entry ends at the +// quote, then after any space and comments a separator or the end of the +// file; an operator may follow a line break or a comment. A comment is +// matched whole (a line comment through its line break), so a separator +// inside one cannot end the entry. An EAS project id is a UUID; another +// service's (Firebase's, say) is not, and names no EAS project. +const QUOTED_PROJECT_ID = + /\bprojectId["']?\s*:\s*["'`]([^"'`\n]*)["'`](?=(?:\s|\/\/[^\n\r\u2028\u2029]*(?:[\n\r\u2028\u2029]|$)|\/\*(?:[^*]|\*(?!\/))*\*\/)*(?:[,;})\]]|$))/gi; +const PROJECT_ID_MENTION = /\bprojectId\b/gi; +// Reading a config is linear in its size per entry; one this large is not an +// app config but a file made to keep the server busy. +const MAX_CONFIG_LENGTH = 256 * 1024; +// Each entry is read against the rest of the file, so a file with many is +// quadratic; an app config mentions its project a few times at most. +const MAX_MENTIONS = 32; + +// A spread after an entry may replace it from elsewhere: anywhere after it, +// at any depth, since a spread in an enclosing object replaces the entry +// too, and a comment or string could hide a brace from a scan of the +// entry's own object. An assignment over the config may replace it from +// anywhere: `Object.assign(defaults, overrides)` names the call before +// both arguments. +const SPREAD_LATER = /\.\.\./; +// Space, a comment, or an opening parenthesis: what may sit between `...` +// and the thing spread. Sticky, so the scan moves through the file once. +const SPREAD_FILLER = /(?:\s+|\/\/[^\n\r\u2028\u2029]*|\/\*(?:[^*]|\*(?!\/))*\*\/|\()/y; +// `config`'s `extra` and `extra.eas` entries, the holders of the EAS id +// (dotted, optional-chained or in brackets). +const EXTRA_OF_CONFIG = String.raw`config(?:\s*\??\.\s*extra|\s*(?:\?\.)?\s*\[\s*["'\x60]extra["'\x60]\s*\])`; +const EAS_OF_CONFIG = + EXTRA_OF_CONFIG + String.raw`(?:\s*\??\.\s*eas|\s*(?:\?\.)?\s*\[\s*["'\x60]eas["'\x60]\s*\])`; +// The holder, and nothing more of it: no further property, call or operator +// (`?? {}` keeps it, so it may follow). +const HOLDER_END = String.raw`(?![\p{ID_Continue}$\u200c\u200d\\]|\s*(?:[.\[(]|\?(?!\?)))`; +const HOLDER_AT = { + extra: new RegExp(EXTRA_OF_CONFIG + HOLDER_END, "uy"), + eas: new RegExp(EAS_OF_CONFIG + HOLDER_END, "uy"), +}; +// `config` and no more of it: not an identifier continuing (`$`, Unicode +// letters and a `\u` escape do, where `\b` would not), nor a property. +const CONFIG_AT = new RegExp("config" + HOLDER_END, "uy"); + +/** + * How a dynamic config with no EAS id of its own relates to app.json's: + * `spreads` when it spreads `config`, the one Expo hands it (app.json's + * entry carries through), and nothing else but `config`'s own `extra` or + * `extra.eas`, which keep the entry wherever they land; `replaces` when it + * spreads anything else (`config.extra.preview` too), which may replace + * that entry; `unclear` when it spreads nothing, + * since it may return `config` changed, or a config of its own that Expo + * uses alone. A `...` inside a comment after a spread is skipped with the + * comment, so the scan is linear. + */ +const staticConfigUse = (source: string): "spreads" | "replaces" | "unclear" => { + let spreads = 0; + let at = 0; + for (;;) { + const next = source.indexOf("...", at); + if (next === -1) return spreads > 0 ? "spreads" : "unclear"; + at = next + 3; + for (;;) { + SPREAD_FILLER.lastIndex = at; + const filler = SPREAD_FILLER.exec(source); + if (!filler) break; + at += filler[0].length; + } + CONFIG_AT.lastIndex = at; + if (CONFIG_AT.test(source)) { + spreads++; + continue; + } + HOLDER_AT.extra.lastIndex = at; + HOLDER_AT.eas.lastIndex = at; + if (!HOLDER_AT.extra.test(source) && !HOLDER_AT.eas.test(source)) return "replaces"; + } +}; +const ASSIGNS_OVER = /\bObject\s*\.\s*assign\b|\bassign\s*\(/; +// A config may replace the object holding the entry, or the whole config, +// from somewhere this scan cannot read: an assignment to `.extra` or `.eas`, +// (as a property or in brackets), a `delete` of anything, or another file it +// requires or imports. +const REPLACES_HOLDER = + /(?:\.\s*(?:extra|eas)|\[\s*["'`](?:extra|eas)["'`]\s*\])\s*=(?!=)|\bdelete\b|\brequire\s*\(|\bimport\s*\(/; +// An `extra:` or `eas:` entry in a config that takes its id from app.json: +// unless its value is an object that starts by spreading that same entry +// of `config` (`config.extra`, `config.extra.eas`; spreading the whole +// config there puts the id elsewhere), it replaces app.json's (`extra: {}` +// drops the id). The key may be quoted +// or computed; a shorthand `extra` or `eas` entry replaces it too. More +// entries than mentions are allowed are not read. +const EXTRA_OR_EAS = /(?:\b(extra|eas)|["'`](extra|eas)["'`]\s*\]?)\s*:\s*/g; +const SHORTHAND_EXTRA_OR_EAS = /(? { + if (SHORTHAND_EXTRA_OR_EAS.test(source)) return false; + let entries = 0; + for (const entry of source.matchAll(EXTRA_OR_EAS)) { + if (++entries > MAX_MENTIONS) return false; + const holder = HOLDER_AT[(entry[1] ?? entry[2]) as "extra" | "eas"]; + let at = entry.index + entry[0].length; + if (source[at] !== "{") return false; + at++; + for (;;) { + SPREAD_FILLER.lastIndex = at; + const filler = SPREAD_FILLER.exec(source); + if (!filler) break; + at += filler[0].length; + } + if (!source.startsWith("...", at)) return false; + at += 3; + for (;;) { + SPREAD_FILLER.lastIndex = at; + const filler = SPREAD_FILLER.exec(source); + if (!filler) break; + at += filler[0].length; + } + holder.lastIndex = at; + if (!holder.test(source)) return false; + } + return true; +}; + +const Extra = Schema.optional( + Schema.Struct({ + eas: Schema.optional(Schema.Struct({ projectId: Schema.optional(Schema.String) })), + }), +); +// Expo reads a static config wrapped in `expo` or not, and as JSON5; one +// this cannot decode (comments, say) still names a project, unknown here. +const decodeAppJson = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ expo: Schema.optional(Schema.Struct({ extra: Extra })), extra: Extra }), + ), +); + +/** + * The project ids a directory's app config names. Read, never run: a dynamic + * config is the project's own code, which an agent may have written. Its text + * is taken as it is, comments included, so an old id left in a comment makes + * the answer ambiguous rather than wrong. app.config.json or app.json count + * too: a dynamic config may only replace theirs on some builds. Null when + * the project is unknown: the config sets or changes it some other way than + * a literal, or a config cannot be read. + */ +const projectIdsOf = Effect.fn("easProjectId.projectIdsOf")(function* (dir: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const ids: Array = []; + const configFile = yield* Effect.findFirst(CONFIG_FILES, (file) => + fs.exists(path.join(dir, file)).pipe(Effect.orElseSucceed(() => false)), + ); + if (Option.isSome(configFile)) { + const source = yield* fs + .readFileString(path.join(dir, configFile.value)) + .pipe(Effect.orElseSucceed(() => "projectId")); + // Every mention of `projectId` must be a quoted entry: one set from a + // variable, or assigned later, could be any project, and makes the + // answer unknown. + if (source.length > MAX_CONFIG_LENGTH) return null; + const mentions = source.match(PROJECT_ID_MENTION)?.length ?? 0; + if (mentions > MAX_MENTIONS) return null; + const entries = [...source.matchAll(QUOTED_PROJECT_ID)]; + const quoted = entries.map((match) => match[1]!); + // A template that interpolates could be any project too, and so could + // one a spread after the entry, or an assignment anywhere, replaces; + if ( + quoted.length !== mentions || + quoted.some((id) => id.includes("${")) || + ASSIGNS_OVER.test(source) || + REPLACES_HOLDER.test(source) || + entries.some((match) => SPREAD_LATER.test(source.slice(match.index + match[0].length))) + ) { + return null; + } + // Only a config shown to spread `config` and nothing else carries app.json's + // entry through unchanged; any other shape could use or replace it. + if ( + !quoted.some((id) => UUID.test(id)) && + (staticConfigUse(source) !== "spreads" || !keepsStaticExtra(source)) + ) { + return null; + } + ids.push(...quoted.filter((id) => UUID.test(id)).map((id) => id.toLowerCase())); + } + for (const file of JSON_CONFIG_FILES) { + const json = yield* fs.readFileString(path.join(dir, file)).pipe( + Effect.map(Option.some), + Effect.catch((error) => + Effect.succeed(error.reason._tag === "NotFound" ? Option.none() : null), + ), + ); + // A config that is there but cannot be read could name any project. + if (json === null) return null; + if (Option.isNone(json)) continue; + const decoded = decodeAppJson(json.value); + if (Option.isNone(decoded)) return null; + const id = (decoded.value.expo ?? decoded.value).extra?.eas?.projectId; + if (id !== undefined && UUID.test(id)) ids.push(id.toLowerCase()); + // Expo reads only the first static config it finds. + break; + } + return ids; +}); + +/** + * The EAS project of a workspace's Expo app: its root, or one app below + * `apps/` or `packages/`. None when there is none, a config is ambiguous, or + * the apps disagree, so the caller asks for an explicit choice instead of + * billing the wrong project. + */ +export const findEasProjectId = Effect.fn("easProjectId.findEasProjectId")(function* ( + workspaceRoot: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const isDirectory = (child: string) => + fs.stat(child).pipe( + Effect.map((info) => info.type === "Directory"), + Effect.orElseSucceed(() => false), + ); + // Only a directory can be an app; a README or .DS_Store beside them is not one. + const children = (parent: string) => + fs.readDirectory(path.join(workspaceRoot, parent)).pipe( + Effect.map((names) => names.map((name) => path.join(workspaceRoot, parent, name))), + Effect.flatMap((paths) => + Effect.forEach(paths, (child) => + Effect.map(isDirectory(child), (directory) => (directory ? [child] : [])), + ), + ), + Effect.map((kept) => kept.flat()), + Effect.orElseSucceed(() => []), + ); + const directories = [ + workspaceRoot, + ...(yield* children("apps")), + ...(yield* children("packages")), + ]; + const ids = yield* Effect.forEach(directories, projectIdsOf, { concurrency: 4 }); + // An app whose project is unknown could be the one the thread works on. + if (ids.some((found) => found === null)) return Option.none(); + const distinct = new Set(ids.flatMap((found) => found ?? [])); + return distinct.size === 1 ? Option.some([...distinct][0]!) : Option.none(); +}); diff --git a/apps/server/src/device/localSshDeviceHost.test.ts b/apps/server/src/device/localSshDeviceHost.test.ts index c93c41c9bab4..2832fb5756bd 100644 --- a/apps/server/src/device/localSshDeviceHost.test.ts +++ b/apps/server/src/device/localSshDeviceHost.test.ts @@ -83,6 +83,7 @@ it.effect("removes only self targets from a fanned-out host list", () => kind: "eas" as const, id: "eas", label: "EAS Simulator", + accessToken: "", maxSessionMinutes: 30, idleMinutes: 10, maxConcurrentSessions: 2, diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index fe8d5ea3d4f4..d8e3ec5ad243 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1590,6 +1590,17 @@ it.layer(NodeServices.layer)("server settings", (it) => { enabled: true, }, }, + easDeviceHosts: [ + { + kind: "eas", + id: "eas", + label: "EAS Simulator", + accessToken: "expo-secret", + maxSessionMinutes: 30, + idleMinutes: 10, + maxConcurrentSessions: 2, + }, + ], }); const values = [ "config-secret", @@ -1598,6 +1609,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { "bitbucket-secret", "gitcafe-secret", "quota-secret", + "expo-secret", ]; const client = ServerSettingsModule.redactServerSettingsForClient(saved); const disk = yield* fs.readFileString(config.settingsPath); @@ -1612,6 +1624,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { providerInstances: client.providerInstances, sourceControlHosts: client.sourceControlHosts, usageLimitSources: client.usageLimitSources, + easDeviceHosts: client.easDeviceHosts, }); for (const value of values) assert.include(JSON.stringify(echoed), value); const renamed = yield* service.updateProviderInstance({ diff --git a/apps/server/src/settingsSecrets.ts b/apps/server/src/settingsSecrets.ts index 82ef745ff00b..b80ccabc0360 100644 --- a/apps/server/src/settingsSecrets.ts +++ b/apps/server/src/settingsSecrets.ts @@ -2,6 +2,7 @@ import { AntigravitySettings, ClaudeSettings, CodexSettings, + EasDeviceHostConfig, type ServerSettings, UsageLimitSourceConfig, secretSettingsFields, @@ -31,6 +32,7 @@ const providerSecretFields = new Map([ ), ]); const usageLimitSecretFields = secretSettingsFields(UsageLimitSourceConfig); +const easDeviceHostSecretFields = secretSettingsFields(EasDeviceHostConfig); const encode = (value: string) => Buffer.from(value, "utf8").toString("base64url"); export interface SettingsSecret { @@ -142,5 +144,16 @@ export function mapSettingsSecrets( ]), ) as ServerSettings["usageLimitSources"]; - return { ...settings, providerInstances, sourceControlHosts, usageLimitSources }; + const easDeviceHosts = settings.easDeviceHosts.map( + (host) => + mapFields( + host, + easDeviceHostSecretFields, + (field, serverHost) => + `device-host-${encode(host.id)}-${encode(field)}` + + (serverHost === null ? "" : `-${encode(serverHost)}`), + ) as EasDeviceHostConfig, + ); + + return { ...settings, providerInstances, sourceControlHosts, usageLimitSources, easDeviceHosts }; } diff --git a/knip.jsonc b/knip.jsonc index e0886545f2b4..aaf5f6e7ffea 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -30,12 +30,17 @@ "src/claude-history-worker.ts!", "scripts/cli.ts", "scripts/evaluate-thread-titles.ts", + "scripts/fetch-expo-schema.ts", "scripts/measure-pr-preview.ts", "scripts/probe-claude-fork-local-rollback-replay.ts", "scripts/update-test-shard-weights.ts", "scripts/verify-background-live.ts", "src/provider/testFixtures/*.mjs", + // graphqlgen reads its config. + "graphql.config.ts", ], + // The generated operations export every selection's class. + "ignoreIssues": { "**/*.graphql.ts": ["exports", "types", "namespaceMembers"] }, // Keep the transitive Effect runtime pinned for standalone npm installs. // The Vite+ web build prerequisite. // Native and disk-loaded provider packages stay external to the bundle; the diff --git a/packages/contracts/src/device.test.ts b/packages/contracts/src/device.test.ts index f9b76384028d..d49d576b719d 100644 --- a/packages/contracts/src/device.test.ts +++ b/packages/contracts/src/device.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it } from "@effect/vitest"; import { Schema } from "effect"; import { DeviceServiceState, + EasDeviceHostConfig, EasDeviceHostConfigs, SshDeviceHostConfigs, deviceToolInstallMessage, } from "./device.ts"; +import { secretSettingsFields } from "./settings.ts"; describe("device tool install progress", () => { it("distinguishes a new install from an upgrade and chooses versions numerically", () => { @@ -49,6 +51,7 @@ describe("configured device hosts", () => { kind: "eas", id: "eas", label: "EAS Simulator", + accessToken: "", maxSessionMinutes: 30, idleMinutes: 10, maxConcurrentSessions: 2, @@ -62,6 +65,10 @@ describe("configured device hosts", () => { decodeEas([{ kind: "eas", id: "eas", label: "EAS", maxSessionMinutes: 10 }]), ).toThrow(); }); + + it("marks the EAS access token as a secret setting", () => { + expect(secretSettingsFields(EasDeviceHostConfig)).toEqual(["accessToken"]); + }); }); describe("device state for clients from before EAS hosts", () => { diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index 3529b2ca85db..f55c1aafde4e 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -2,7 +2,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; -import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ThreadId, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; export const DevicePlatform = Schema.Literals(["ios", "android"]); export type DevicePlatform = typeof DevicePlatform.Type; @@ -38,6 +38,15 @@ export const EasDeviceHostConfig = Schema.Struct({ label: TrimmedNonEmptyString, /** EAS project that sessions run under, when the project's app config does not name one. */ appId: Schema.optional(TrimmedNonEmptyString), + /** + * Expo access token, kept in the secret store. When empty the server uses + * EXPO_TOKEN, then the `eas login` session. This is `secretSetting` from + * settings.ts, which imports this module. + */ + accessToken: TrimmedString.pipe( + Schema.withDecodingDefaultKey(Effect.succeed("")), + Schema.annotateKey({ settingsSecret: true }), + ), /** EAS allows at most 40 minutes on free accounts and 115 on paid ones. */ maxSessionMinutes: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 115 })).pipe( Schema.withDecodingDefaultKey(Effect.succeed(30)), diff --git a/packages/shared/src/httpObservability.ts b/packages/shared/src/httpObservability.ts index ab37153512ec..0fb56cf50878 100644 --- a/packages/shared/src/httpObservability.ts +++ b/packages/shared/src/httpObservability.ts @@ -4,5 +4,6 @@ import * as Headers from "effect/http/Headers"; export const layer = Layer.effect( Headers.CurrentRedactedNames, - Effect.map(Headers.CurrentRedactedNames, (names) => [...names, "dpop"]), + // `expo-session` carries the `eas login` session secret on Expo API requests. + Effect.map(Headers.CurrentRedactedNames, (names) => [...names, "dpop", "expo-session"]), ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3558921ab6a..65291459dd17 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -760,6 +760,9 @@ importers: specifier: ^3.4.0 version: 3.4.0 devDependencies: + '@effect/graphql-generator': + specifier: 'catalog:' + version: 4.0.5(@effect/platform-node@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6))(effect@4.0.5) '@effect/vitest': specifier: 4.0.5 version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.1)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -802,6 +805,9 @@ importers: effect-codex-app-server: specifier: workspace:* version: link:../../packages/effect-codex-app-server + graphql: + specifier: 16.14.1 + version: 16.14.1 vite-plus: specifier: 1.0.0 version: 1.0.0(@types/node@24.12.4)(@vitest/browser-playwright@5.0.1)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -20315,8 +20321,7 @@ snapshots: graphql@16.11.0: {} - graphql@16.14.1: - optional: true + graphql@16.14.1: {} h3@1.15.11: dependencies: diff --git a/vite.config.ts b/vite.config.ts index 8866c952a5ea..6be69245ef66 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -90,6 +90,7 @@ export default defineConfig({ "**/routeTree.gen.ts", "**/*.graphql.ts", "packages/source-control-github/schema/**", + "apps/server/schema/**", "apps/mobile/android/**", "apps/mobile/ios/**", "apps/mobile/uniwind-types.d.ts", @@ -117,6 +118,7 @@ export default defineConfig({ "**/routeTree.gen.ts", "**/*.graphql.ts", "packages/source-control-github/schema/**", + "apps/server/schema/**", "apps/mobile/android/**", "apps/mobile/ios/**", "apps/mobile/uniwind-types.d.ts",