diff --git a/cli/package.json b/cli/package.json index 7706da9693..845c6a1589 100644 --- a/cli/package.json +++ b/cli/package.json @@ -71,6 +71,7 @@ "test:upload": "bun test/test-upload-validation.mjs", "test:credentials": "bun test/test-credentials.mjs", "test:credentials-validation": "bun test/test-credentials-validation.mjs", + "test:android-service-account-validation": "bun test/test-android-service-account-validation.mjs", "test:build-zip-filter": "bun test/test-build-zip-filter.mjs", "test:checksum": "bun test/test-checksum-algorithm.mjs", "test:build-needed": "bun test/test-build-needed.mjs", @@ -93,7 +94,7 @@ "test:macos-signing": "bun test/test-macos-signing.mjs", "test:apple-api-import-helpers": "bun test/test-apple-api-import-helpers.mjs", "test:manifest-path-encoding": "bun test/test-manifest-path-encoding.mjs", - "test": "bun run build && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:credentials && bun run test:credentials-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:posthog-exception && bun run test:build-platform-selection && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-app-conflict && bun run test:init-guardrails && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:apple-api-import-helpers && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:ai-render-markdown", + "test": "bun run build && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:credentials && bun run test:credentials-validation && bun run test:android-service-account-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:posthog-exception && bun run test:build-platform-selection && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-app-conflict && bun run test:init-guardrails && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:apple-api-import-helpers && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:ai-render-markdown", "test:build-platform-selection": "bun test/test-build-platform-selection.mjs", "test:ai-log-capture": "bun test/test-ai-log-capture.mjs", "test:ai-analyze-flow": "bun test/test-ai-analyze-flow.mjs", diff --git a/cli/src/build/onboarding/android/progress.ts b/cli/src/build/onboarding/android/progress.ts index 41babe40ca..287f73138e 100644 --- a/cli/src/build/onboarding/android/progress.ts +++ b/cli/src/build/onboarding/android/progress.ts @@ -141,7 +141,37 @@ export function getAndroidResumeStep(progress: AndroidOnboardingProgress | null) if (!keystoreFullyValid(progress)) return keystoreResumeStep(progress) - // Phase 2 — Google sign-in: marker + refresh token. We need the refresh + // Phase 2 — Service-account fork. Routes onto the import path or the OAuth + // path. Legacy progress files don't have `serviceAccountMethod` — treat + // those as OAuth (existing behavior) so in-flight onboardings continue + // along the path they started on. + if (progress.serviceAccountMethod === 'existing') { + // Phase 2a — Import existing SA JSON. + // + // `_serviceAccountKeyBase64` is set once we accept the JSON (either + // validation passed or the user picked "save anyway"). After that point + // routing is identical to the OAuth path's tail: `saving-credentials`. + if (progress._serviceAccountKeyBase64) + return 'saving-credentials' + + // Package name confirmation is the first step inside the import path. + if (!completedSteps.androidPackageChosen) + return 'android-package-select' + + // We have a package but no accepted SA yet. If the user already picked a + // file, jump back to validation; otherwise back to file selection. + if (progress.serviceAccountJsonPath) + return 'sa-json-validating' + return 'sa-json-existing-path' + } + + // Backward compatibility: legacy progress files (created before the + // service-account fork existed) never set `serviceAccountMethod`. Per the + // design contract those resume into the OAuth path they were already on — + // we must NOT route them to the new fork screen and force them to re-pick + // mid-flow. Only routes through the fork explicitly set the method. + + // Phase 2b — Google sign-in: marker + refresh token. We need the refresh // token to mint access tokens for the rest of the flow on subsequent // resumes; if it's missing we must re-auth. if (!completedSteps.googleSignInComplete || !progress._oauthRefreshToken) diff --git a/cli/src/build/onboarding/android/service-account-validation.ts b/cli/src/build/onboarding/android/service-account-validation.ts new file mode 100644 index 0000000000..5f3325e006 --- /dev/null +++ b/cli/src/build/onboarding/android/service-account-validation.ts @@ -0,0 +1,469 @@ +// src/build/onboarding/android/service-account-validation.ts +// +// Validates a user-supplied Google Play service account JSON before we save it +// as PLAY_CONFIG_JSON. Three layers: +// +// 1. Shape check — JSON.parse and confirm the file looks like a service +// account key (type, private_key, client_email, project_id, token_uri). +// 2. Token exchange — sign a JWT with the SA private key and POST it to the +// SA's token endpoint. Proves the key is cryptographically valid and the +// account isn't revoked. +// 3. App-access check — open and immediately close a draft edit on the user's +// Play Console app (`applications/{packageName}/edits`). This is exactly +// what fastlane's `supply` will do at build time — if this passes the +// build will pass. +// +// All network calls forward an AbortSignal so the React UI can cancel mid-flight. + +import type { Buffer } from 'node:buffer' +import jwt from 'jsonwebtoken' + +const ANDROIDPUBLISHER_SCOPE = 'https://www.googleapis.com/auth/androidpublisher' +const ANDROIDPUBLISHER_BASE = 'https://androidpublisher.googleapis.com/androidpublisher/v3' +const JWT_LIFETIME_SECONDS = 3600 +const DEFAULT_FETCH_TIMEOUT_MS = 30_000 +const ALLOWED_GOOGLE_TOKEN_URIS = new Set([ + 'https://oauth2.googleapis.com/token', + 'https://accounts.google.com/o/oauth2/token', + 'https://www.googleapis.com/oauth2/v4/token', +]) + +export interface ServiceAccountKey { + type: 'service_account' + client_email: string + private_key: string + project_id: string + token_uri: string + private_key_id?: string + client_id?: string +} + +export type ValidationResult + = | { ok: true, serviceAccountEmail: string, projectId: string } + | { ok: false, kind: 'shape-error', message: string } + | { ok: false, kind: 'token-error', message: string } + | { ok: false, kind: 'no-app-access', message: string, serviceAccountEmail: string } + | { ok: false, kind: 'network-error', message: string } + +export interface ValidateOptions { + jsonBytes: Buffer + packageName: string + signal?: AbortSignal + /** Override per-request timeout. Defaults to 30s. */ + timeoutMs?: number + /** Test-only injection point. Defaults to globalThis.fetch. */ + fetchImpl?: typeof fetch +} + +/** + * Parse + minimally validate the service account JSON structure. + * + * Google's SA JSON for `service_account` type has more optional fields, but + * these five are the ones we actually need to authenticate. Missing any of + * them means we can't proceed — surface a precise error so the user knows + * what's wrong rather than discovering it at token-exchange time with an + * opaque crypto error. + */ +export function parseServiceAccountKey(jsonBytes: Buffer): ServiceAccountKey { + let parsed: unknown + try { + parsed = JSON.parse(jsonBytes.toString('utf-8')) + } + catch (err) { + throw new Error(`Service account file is not valid JSON: ${err instanceof Error ? err.message : String(err)}`) + } + if (!parsed || typeof parsed !== 'object') + throw new Error('Service account file is not a JSON object.') + + const obj = parsed as Record + if (obj.type !== 'service_account') + throw new Error(`Expected "type": "service_account" — found ${JSON.stringify(obj.type)}. This file is not a service account key.`) + + const required = ['client_email', 'private_key', 'project_id', 'token_uri'] as const + for (const field of required) { + const value = obj[field] + if (typeof value !== 'string' || value.length === 0) + throw new Error(`Service account JSON is missing required field "${field}".`) + } + if (!ALLOWED_GOOGLE_TOKEN_URIS.has(obj.token_uri as string)) + throw new Error('Service account JSON has an unsupported token_uri. Expected a Google OAuth token endpoint.') + + return { + type: 'service_account', + client_email: obj.client_email as string, + private_key: obj.private_key as string, + project_id: obj.project_id as string, + token_uri: obj.token_uri as string, + private_key_id: typeof obj.private_key_id === 'string' ? obj.private_key_id : undefined, + client_id: typeof obj.client_id === 'string' ? obj.client_id : undefined, + } +} + +/** + * Sign a JWT bearer assertion suitable for Google's OAuth2 token endpoint. + * + * Google requires: + * - alg: RS256 + * - iss: service account email + * - scope: space-separated OAuth scopes + * - aud: the token endpoint URL + * - exp: now + 3600 (max accepted by Google) + * - iat: now + * + * Ref: https://developers.google.com/identity/protocols/oauth2/service-account#authorizingrequests + */ +function signSaAssertion(key: ServiceAccountKey): string { + const now = Math.floor(Date.now() / 1000) + return jwt.sign( + { + iss: key.client_email, + scope: ANDROIDPUBLISHER_SCOPE, + aud: key.token_uri, + exp: now + JWT_LIFETIME_SECONDS, + iat: now, + }, + key.private_key, + { + algorithm: 'RS256', + header: { alg: 'RS256', typ: 'JWT', kid: key.private_key_id ?? '' }, + }, + ) +} + +interface GoogleTokenResponse { + access_token: string + expires_in: number + token_type: string +} + +interface GoogleTokenErrorResponse { + error?: string + error_description?: string +} + +/** + * Marker thrown by `exchangeJwtForAccessToken` for transient/transport-class + * failures (5xx from Google, non-JSON, etc.) so the outer `validate*` catch + * can route them to `network-error` instead of `token-error`. 4xx responses + * still throw a plain Error and map to `token-error` (credentials genuinely + * rejected). + */ +class TokenExchangeTransientError extends Error { + constructor(message: string) { + super(message) + this.name = 'TokenExchangeTransientError' + } +} + +/** + * Exchange a signed JWT bearer assertion for an OAuth access token at Google's + * token endpoint. The token is short-lived (1h) and is used only for the + * downstream `edits.insert` / `edits.delete` round trip. + */ +async function exchangeJwtForAccessToken(args: { + key: ServiceAccountKey + assertion: string + signal?: AbortSignal + timeoutMs: number + fetchImpl: typeof fetch +}): Promise { + const body = new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: args.assertion, + }) + + const res = await fetchWithTimeout({ + url: args.key.token_uri, + init: { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + }, + body: body.toString(), + }, + signal: args.signal, + timeoutMs: args.timeoutMs, + fetchImpl: args.fetchImpl, + }) + + const text = await res.text() + if (!res.ok) { + let detail = `${res.status} ${res.statusText}` + try { + const errBody = JSON.parse(text) as GoogleTokenErrorResponse + if (errBody.error) { + detail = errBody.error_description + ? `${errBody.error}: ${errBody.error_description}` + : errBody.error + } + } + catch {} + // 5xx (and unexpected 1xx/3xx) are server-side or transport problems, not + // credential rejections — flag them as transient so the outer validator + // surfaces a network-error and the UI offers retry rather than telling + // the user their key is bad. + if (res.status >= 500 || res.status < 400) + throw new TokenExchangeTransientError(`Google's token endpoint returned ${detail}. Try again in a moment.`) + throw new Error(`Google rejected the service account credentials (${detail}). The private key may be revoked or invalid.`) + } + + let parsed: GoogleTokenResponse + try { + parsed = JSON.parse(text) as GoogleTokenResponse + } + catch { + throw new TokenExchangeTransientError(`Google's token endpoint returned a non-JSON response (${res.status}).`) + } + if (typeof parsed.access_token !== 'string' || parsed.access_token.length === 0) + throw new TokenExchangeTransientError('Google\'s token response was missing an access_token field.') + return parsed.access_token +} + +interface EditResponse { + id: string + expiryTimeSeconds?: string +} + +async function fetchWithTimeout(args: { + url: string + init: RequestInit + signal?: AbortSignal + timeoutMs: number + fetchImpl: typeof fetch +}): Promise { + // Compose the caller's AbortSignal with a per-request timeout signal so + // either source can cancel the request. + const timeoutController = new AbortController() + const timer = setTimeout(() => timeoutController.abort(), args.timeoutMs) + + let combinedSignal: AbortSignal + // Captured so the `finally` block can detach them on success. Without this, + // a long-lived caller `signal` would accumulate one listener per request + // (each `{ once: true }` listener auto-detaches on fire, but never on + // successful completion). + let abortComposite: (() => void) | null = null + if (args.signal) { + const composite = new AbortController() + abortComposite = () => composite.abort() + args.signal.addEventListener('abort', abortComposite, { once: true }) + timeoutController.signal.addEventListener('abort', abortComposite, { once: true }) + if (args.signal.aborted || timeoutController.signal.aborted) + composite.abort() + combinedSignal = composite.signal + } + else { + combinedSignal = timeoutController.signal + } + + try { + return await args.fetchImpl(args.url, { ...args.init, signal: combinedSignal }) + } + finally { + clearTimeout(timer) + if (abortComposite) { + args.signal?.removeEventListener('abort', abortComposite) + timeoutController.signal.removeEventListener('abort', abortComposite) + } + } +} + +/** + * Open a draft edit on the Play Console app, then immediately delete it. + * + * Mirrors fastlane's auth code path — if this round trip succeeds, every + * subsequent supply call will succeed too. The draft itself is invisible in + * Play Console for most views and auto-expires after 7 days even if our + * cleanup DELETE fails. + */ +async function probeAppAccess(args: { + accessToken: string + packageName: string + signal?: AbortSignal + timeoutMs: number + fetchImpl: typeof fetch +}): Promise<{ kind: 'ok' } | { kind: 'no-access', detail: string } | { kind: 'network', detail: string }> { + const insertUrl = `${ANDROIDPUBLISHER_BASE}/applications/${encodeURIComponent(args.packageName)}/edits` + + let insertRes: Response + try { + insertRes = await fetchWithTimeout({ + url: insertUrl, + init: { + method: 'POST', + headers: { + 'Authorization': `Bearer ${args.accessToken}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: '{}', + }, + signal: args.signal, + timeoutMs: args.timeoutMs, + fetchImpl: args.fetchImpl, + }) + } + catch (err) { + return { + kind: 'network', + detail: err instanceof Error ? err.message : String(err), + } + } + + const insertText = await insertRes.text() + + // 401 / 403 / 404 = SA exists and auth worked at the token-exchange level, + // but this SA can't see this package. Anything in the 5xx range or other + // unexpected codes is a network/server failure, not an access failure — + // surface that distinction so the user gets the right recovery options. + if (insertRes.status === 401 || insertRes.status === 403 || insertRes.status === 404) { + return { + kind: 'no-access', + detail: parseGoogleErrorMessage(insertText) ?? `${insertRes.status} ${insertRes.statusText}`, + } + } + if (!insertRes.ok) { + return { + kind: 'network', + detail: `${insertRes.status} ${insertRes.statusText}: ${insertText.slice(0, 200)}`, + } + } + + let edit: EditResponse + try { + edit = JSON.parse(insertText) as EditResponse + } + catch { + return { kind: 'network', detail: 'Play API returned non-JSON on edits.insert' } + } + if (typeof edit.id !== 'string' || edit.id.length === 0) + return { kind: 'network', detail: 'Play API returned no edit id' } + + // Best-effort cleanup — the draft auto-expires regardless. Don't surface + // failures here, just log internally via the caller. + const deleteUrl = `${ANDROIDPUBLISHER_BASE}/applications/${encodeURIComponent(args.packageName)}/edits/${encodeURIComponent(edit.id)}` + try { + await fetchWithTimeout({ + url: deleteUrl, + init: { + method: 'DELETE', + headers: { Authorization: `Bearer ${args.accessToken}` }, + }, + signal: args.signal, + timeoutMs: args.timeoutMs, + fetchImpl: args.fetchImpl, + }) + } + catch { + // swallowed by contract — auto-expiry covers us + } + + return { kind: 'ok' } +} + +function parseGoogleErrorMessage(body: string): string | null { + try { + const parsed = JSON.parse(body) as { error?: { message?: string, status?: string } } + if (parsed.error?.message) { + return parsed.error.status + ? `${parsed.error.status}: ${parsed.error.message}` + : parsed.error.message + } + } + catch {} + return null +} + +/** + * Run the full validation chain. The function never throws — all failure + * shapes are returned as `{ ok: false, kind: … }` so the UI can react to each + * case independently (e.g. "no-app-access" routes to a recovery screen with + * actionable Play Console invite instructions). + */ +export async function validateServiceAccountJson(opts: ValidateOptions): Promise { + const timeoutMs = opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS + const fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis) + + // 1. Shape + let key: ServiceAccountKey + try { + key = parseServiceAccountKey(opts.jsonBytes) + } + catch (err) { + return { + ok: false, + kind: 'shape-error', + message: err instanceof Error ? err.message : String(err), + } + } + + if (opts.signal?.aborted) + return { ok: false, kind: 'network-error', message: 'Validation cancelled.' } + + // 2. Token exchange. Distinguishes between "Google rejected the key" (real + // token-error) and transport/transient failures (network-error). Aborts and + // fetch-level rejections always go to network-error so the recovery UI can + // offer retry rather than a misleading "your credentials are bad" message. + let accessToken: string + try { + const assertion = signSaAssertion(key) + accessToken = await exchangeJwtForAccessToken({ + key, + assertion, + signal: opts.signal, + timeoutMs, + fetchImpl, + }) + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + const isAbort = (err as { name?: string } | null)?.name === 'AbortError' + const isTransient = err instanceof TokenExchangeTransientError + const looksNetworky = /timeout|timed out|network|fetch failed|ENOTFOUND|EAI_AGAIN|ECONNRESET|ECONNREFUSED/i.test(message) + if (isAbort || isTransient || looksNetworky) { + return { + ok: false, + kind: 'network-error', + message, + } + } + return { + ok: false, + kind: 'token-error', + message, + } + } + + if (opts.signal?.aborted) + return { ok: false, kind: 'network-error', message: 'Validation cancelled.' } + + // 3. App-access check + const probe = await probeAppAccess({ + accessToken, + packageName: opts.packageName, + signal: opts.signal, + timeoutMs, + fetchImpl, + }) + + if (probe.kind === 'ok') { + return { + ok: true, + serviceAccountEmail: key.client_email, + projectId: key.project_id, + } + } + if (probe.kind === 'no-access') { + return { + ok: false, + kind: 'no-app-access', + serviceAccountEmail: key.client_email, + message: `Service account "${key.client_email}" cannot access package "${opts.packageName}" on Google Play (${probe.detail}). Open Play Console → Users and permissions, invite the service account email, and grant access to this app.`, + } + } + return { + ok: false, + kind: 'network-error', + message: probe.detail, + } +} diff --git a/cli/src/build/onboarding/android/types.ts b/cli/src/build/onboarding/android/types.ts index c5f4aae1ef..d4cd58a973 100644 --- a/cli/src/build/onboarding/android/types.ts +++ b/cli/src/build/onboarding/android/types.ts @@ -21,7 +21,14 @@ export type AndroidOnboardingStep | 'keystore-new-key-password' | 'keystore-new-cn' | 'keystore-generating' - // Phase 2 — Google sign-in (OAuth) + // Phase 2 — Service account method fork: existing JSON vs. OAuth provisioning + | 'service-account-method-select' + // Phase 2a — Import existing service account JSON + | 'sa-json-existing-path' + | 'sa-json-existing-picker' + | 'sa-json-validating' + | 'sa-json-validation-failed' + // Phase 2b — Google sign-in (OAuth) | 'google-sign-in' | 'google-sign-in-running' // Phase 3 — Play developer account ID (pasted by the user — Play Developer API @@ -55,9 +62,19 @@ export type AndroidOnboardingErrorCategory = | 'keystore_invalid' | 'google_oauth_failed' | 'play_account_id_invalid' + // Imported service-account JSON validation failures. Each value mirrors + // the corresponding `ValidationResult.kind` from + // `service-account-validation.ts` so PostHog funnel analysis can + // distinguish "wrong file" from "SA not invited to app" from "transient + // network/server issue" — each implies a different recovery for the user. + | 'sa_json_shape_invalid' + | 'sa_json_token_rejected' + | 'sa_json_no_app_access' + | 'sa_json_network_error' | 'unknown' export type KeystoreMethod = 'existing' | 'generate' +export type ServiceAccountMethod = 'existing' | 'generate' export interface KeystoreReady { keystorePath: string @@ -115,6 +132,19 @@ export interface AndroidOnboardingProgress { keystoreKeyPassword?: string keystoreCommonName?: string + // Service account fork — set at `service-account-method-select`. Absent on + // legacy progress files (pre-2026-05) → resume defaults to `generate` so + // existing in-flight onboardings continue on the OAuth path they started on. + serviceAccountMethod?: ServiceAccountMethod + // Import path — path the user picked at `sa-json-existing-path` / + // `sa-json-existing-picker`. The file is read fresh at validation time so + // we never persist its contents to disk before the user explicitly accepts. + serviceAccountJsonPath?: string + // Set when the user picks "Save anyway" at `sa-json-validation-failed`. + // Read at `saving-credentials` to surface a yellow banner — does not affect + // routing. + serviceAccountValidationSkipped?: boolean + // Chosen project name for a fresh create — remembered while the async op runs pendingNewProjectId?: string pendingNewProjectDisplayName?: string @@ -159,6 +189,15 @@ export const ANDROID_STEP_PROGRESS: Record = { 'keystore-new-cn': 16, 'keystore-generating': 20, + 'service-account-method-select': 22, + + // Import path keeps the bar moving without leaping past the OAuth path's + // matching milestones (Google sign-in lands at 35, GCP setup at 70). + 'sa-json-existing-path': 28, + 'sa-json-existing-picker': 28, + 'sa-json-validating': 70, + 'sa-json-validation-failed': 70, + 'google-sign-in': 25, 'google-sign-in-running': 35, @@ -210,6 +249,13 @@ export function getAndroidPhaseLabel(step: AndroidOnboardingStep): string { case 'keystore-new-cn': case 'keystore-generating': return 'Step 1 of 4 · Keystore' + case 'service-account-method-select': + return 'Step 2 of 4 · Service account' + case 'sa-json-existing-path': + case 'sa-json-existing-picker': + case 'sa-json-validating': + case 'sa-json-validation-failed': + return 'Step 3 of 4 · Service account' case 'google-sign-in': case 'google-sign-in-running': return 'Step 2 of 4 · Sign in with Google' diff --git a/cli/src/build/onboarding/android/ui/app.tsx b/cli/src/build/onboarding/android/ui/app.tsx index 2db1fe0b2f..eafb3129c0 100644 --- a/cli/src/build/onboarding/android/ui/app.tsx +++ b/cli/src/build/onboarding/android/ui/app.tsx @@ -28,11 +28,12 @@ import { loadSavedCredentials, updateSavedCredentials } from '../../../credentia import { requestBuildInternal } from '../../../request.js' import type { CiSecretEntry, CiSecretSetupAdvice, CiSecretTarget } from '../../ci-secrets.js' import { createCiSecretEntries, detectCiSecretTargets, getCiSecretTargetLabel, listExistingCiSecretKeys, uploadCiSecrets } from '../../ci-secrets.js' -import { mapAndroidOnboardingError } from '../../error-categories.js' -import { canUseFilePicker, openKeystorePicker } from '../../file-picker.js' +import { mapAndroidOnboardingError, mapSaValidationKindToCategory } from '../../error-categories.js' +import { canUseFilePicker, openKeystorePicker, openServiceAccountJsonPicker } from '../../file-picker.js' import { trackBuilderOnboardingStep } from '../../telemetry.js' import { Divider, ErrorLine, FilteredTextInput, Header, SpinnerLine, SuccessLine } from '../../ui/components.js' import { findAndroidApplicationIds } from '../gradle-parser.js' +import { validateServiceAccountJson } from '../service-account-validation.js' import { ANDROIDPUBLISHER_API, createServiceAccountKey, @@ -219,10 +220,19 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir ? undefined : now - previous.startedAt + // Steps whose telemetry event carries an `errorCategory` dimension. The + // generic `'error'` step always has one (set by `handleError`); the + // SA-import `'sa-json-validation-failed'` step also carries one because + // the validation effect populates `errorCategoryRef.current` with the + // mapped `ValidationResult.kind` before transitioning. Funnel analysis + // in PostHog can split sa-json-validation-failed events by category to + // see whether failures are "wrong file" vs "SA not invited to app" vs + // transient network issues. + const carriesErrorCategory = step === 'error' || step === 'sa-json-validation-failed' const eventPayload = { step, durationMs, - errorCategory: step === 'error' ? errorCategoryRef.current : undefined, + errorCategory: carriesErrorCategory ? errorCategoryRef.current : undefined, } stepTimingRef.current = { step, startedAt: now } @@ -248,7 +258,36 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const pickerOpenedRef = useRef(false) const oauthStartedRef = useRef(false) const setupStartedRef = useRef(false) + const saPickerOpenedRef = useRef(false) + const validationStartedRef = useRef(false) + // Cleanup hook for the in-flight SA validation. Invoked by the main + // useEffect cleanup so a step change / unmount / Ctrl+C aborts the + // outbound JWT exchange + Play API round trip rather than letting it run + // detached. + const validationCleanupRef = useRef<(() => void) | null>(null) + /** + * Per-step submission guard for `` synchronously and the effect doesn't get a chance + * to re-fire. + */ + const selectFiredRef = useRef(false) const [keystorePathMode, setKeystorePathMode] = useState<'choose' | 'manual'>('choose') + const [saJsonPathMode, setSaJsonPathMode] = useState<'choose' | 'manual'>('choose') // Phase 1 — keystore const [, setKeystoreMethod] = useState<'existing' | 'generate' | null>( @@ -272,7 +311,21 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const [keyPasswordProbe, setKeyPasswordProbe] = useState(null) const keyPasswordProbeRef = useRef(false) - // Phase 2 — Google sign-in + // Phase 2 — Service account method fork + const [serviceAccountMethod, setServiceAccountMethod] = useState<'existing' | 'generate' | null>( + initialProgress?.serviceAccountMethod || null, + ) + const [serviceAccountJsonPath, setServiceAccountJsonPath] = useState( + initialProgress?.serviceAccountJsonPath || '', + ) + // Result of the last validation attempt — drives the sa-json-validation-failed UI. + // Loose typing here to avoid pulling the entire ValidationResult union into the + // component file; the module owns the discriminated shape. + const [saValidationResult, setSaValidationResult] = useState< + null | { ok: true } | { ok: false, kind: 'shape-error' | 'token-error' | 'no-app-access' | 'network-error', message: string } + >(null) + + // Phase 2b — Google sign-in const [, setGoogleSignIn] = useState( initialProgress?.completedSteps.googleSignInComplete || null, ) @@ -611,6 +664,13 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir oauthStartedRef.current = false if (step !== 'gcp-setup-running') setupStartedRef.current = false + if (step !== 'sa-json-existing-picker') + saPickerOpenedRef.current = false + if (step !== 'sa-json-validating') + validationStartedRef.current = false + // Reset the @inkjs/ui Select re-fire guard on every step transition so each + // new step gets a clean slate. See the JSDoc on `selectFiredRef`. + selectFiredRef.current = false if (step === 'keystore-existing-picker' && !pickerOpenedRef.current) { pickerOpenedRef.current = true @@ -635,6 +695,95 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir })() } + if (step === 'sa-json-existing-picker' && !saPickerOpenedRef.current) { + saPickerOpenedRef.current = true + ;(async () => { + try { + const selected = await openServiceAccountJsonPicker() + if (cancelled) + return + if (!selected) { + // Cancelled — fall back to manual input. Reset the chooser screen + // so we don't loop back into picker mode immediately. + setSaJsonPathMode('manual') + setStep('sa-json-existing-path') + return + } + setServiceAccountJsonPath(selected) + await persist((p) => ({ ...p, serviceAccountJsonPath: selected })) + addLog(`✔ Service account JSON · ${selected}`) + setStep('sa-json-validating') + } + catch (err) { + if (!cancelled) + handleError(err, 'sa-json-existing-path') + } + })() + } + + if (step === 'sa-json-validating' && !validationStartedRef.current) { + validationStartedRef.current = true + // Bound the network round trips to the lifetime of this step. If the + // user Ctrl+C's, picks a different file, or unmounts the component + // mid-flight, the cleanup at the bottom of this effect aborts the + // controller and the in-flight fetch/JWT exchange unwinds promptly. + const validationAbort = new AbortController() + validationCleanupRef.current = () => validationAbort.abort() + ;(async () => { + try { + if (!serviceAccountJsonPath) + throw new Error('No service account JSON path on record — pick the file again.') + if (!androidPackageChoice) + throw new Error('No Android package on record — pick the package again.') + + const jsonBytes = await readFile(serviceAccountJsonPath) + if (cancelled) + return + + const result = await validateServiceAccountJson({ + jsonBytes, + packageName: androidPackageChoice.packageName, + signal: validationAbort.signal, + }) + if (cancelled) + return + + if (result.ok) { + const base64 = jsonBytes.toString('base64') + setServiceAccountKeyBase64(base64) + setSaValidationResult({ ok: true }) + await persist((p) => ({ + ...p, + _serviceAccountKeyBase64: base64, + // Clear any stale "skipped" flag from a previous attempt. + serviceAccountValidationSkipped: false, + })) + addLog(`✔ Service account verified — ${result.serviceAccountEmail}`) + setStep('saving-credentials') + return + } + + setSaValidationResult(result) + // Stash the validation failure kind so the PostHog + // `sa-json-validation-failed` step event carries the dimension. + // Read by the telemetry useEffect on the upcoming step transition. + errorCategoryRef.current = mapSaValidationKindToCategory(result.kind) + // shape-error indicates the file itself is wrong — surface as a + // banner log and route to the same recovery screen so the user + // can pick a different file or fall back to OAuth. Other kinds + // (token, no-app-access, network) already get full text on the + // recovery screen. + if (result.kind === 'shape-error') + addLog(`✖ ${result.message}`, 'red') + setStep('sa-json-validation-failed') + } + catch (err) { + if (!cancelled) + handleError(err, 'sa-json-existing-path') + } + })() + } + if (step === 'keystore-existing-detecting-alias') { ;(async () => { try { @@ -749,11 +898,26 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir completedSteps: { ...p.completedSteps, keystoreReady: ready }, })) addLog(`✔ Keystore loaded — ${keystoreExistingPath}`) - // Smart-route: skip phases already complete (e.g. on resume). + // Smart-route: skip phases already complete (e.g. on resume into + // this step after a legacy progress file already had OAuth steps + // done). If progress shows nothing past keystoreReady, land on the + // new fork; otherwise pick up where we left off (resume contract: + // legacy progress without `serviceAccountMethod` defaults to OAuth + // via `getAndroidResumeStep`). const fresh = await loadAndroidProgress(appId) if (cancelled) return - setStep(fresh ? getAndroidResumeStep(fresh) : 'google-sign-in') + const hasAnyOAuthProgress = !!( + fresh?.completedSteps.googleSignInComplete + || fresh?.completedSteps.playAccountChosen + || fresh?.completedSteps.gcpProjectChosen + || fresh?.completedSteps.androidPackageChosen + || fresh?._oauthRefreshToken + ) + if (hasAnyOAuthProgress || fresh?.serviceAccountMethod !== undefined) + setStep(fresh ? getAndroidResumeStep(fresh) : 'service-account-method-select') + else + setStep('service-account-method-select') } catch (err) { if (!cancelled) @@ -799,7 +963,15 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir // here — at this point the password lives only in the in-memory // state and the progress file, not in `credentials.json`. setRetryCount(0) - setStep('google-sign-in') + // After keystore is freshly generated in THIS run, always land on + // the new method-select fork — we know there's no prior SA choice + // because we just finished the keystore phase. Resume mid-flow on + // a subsequent run goes through `getAndroidResumeStep`, which + // routes legacy progress (absent `serviceAccountMethod`) to the + // OAuth path for backward compatibility. + if (cancelled) + return + setStep('service-account-method-select') } catch (err) { if (!cancelled) @@ -1265,10 +1437,22 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir if (!cancelled) exit() }, 100) - return () => { cancelled = true; clearTimeout(timer) } + return () => { + cancelled = true + clearTimeout(timer) + validationCleanupRef.current?.() + validationCleanupRef.current = null + } } - return () => { cancelled = true } + return () => { + cancelled = true + // Abort any in-flight SA validation. Safe to call when there isn't one + // — the ref is reset to null on every step transition that doesn't + // start a new validation. + validationCleanupRef.current?.() + validationCleanupRef.current = null + } }, [step]) const progressPct = ANDROID_STEP_PROGRESS[step] ?? 0 @@ -1545,12 +1729,22 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir completedSteps: { ...p.completedSteps, keystoreReady: ready }, })) addLog(`✔ Keystore loaded — ${keystoreExistingPath}`) - // Smart-route: skip phases already complete (same pattern as - // the auto-probe branch in the useEffect above) so a resume - // that re-enters key-password doesn't drag the user back to - // google-sign-in if they've already completed it. + // Smart-route: same pattern as the auto-probe branch above. + // If the user has any OAuth-side progress (legacy resume or + // mid-flow), pick up where they left off; otherwise drop + // them on the new fork. const fresh = await loadAndroidProgress(appId) - setStep(fresh ? getAndroidResumeStep(fresh) : 'google-sign-in') + const hasAnyOAuthProgress = !!( + fresh?.completedSteps.googleSignInComplete + || fresh?.completedSteps.playAccountChosen + || fresh?.completedSteps.gcpProjectChosen + || fresh?.completedSteps.androidPackageChosen + || fresh?._oauthRefreshToken + ) + if (hasAnyOAuthProgress || fresh?.serviceAccountMethod !== undefined) + setStep(fresh ? getAndroidResumeStep(fresh) : 'service-account-method-select') + else + setStep('service-account-method-select') } catch (err) { handleError(err, 'keystore-existing-path') @@ -1667,7 +1861,203 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir )} - {/* ── Phase 2 — Google sign-in ── */} + {/* ── Phase 2 — Service account method fork ── */} + + {step === 'service-account-method-select' && ( + + + Capgo needs a Google Play service account JSON to upload AABs on your behalf. You can bring your own or let Capgo set one up via Google sign-in. + + + Do you already have a service account JSON? + + { + // 'manual' just flips the sub-mode (Select unmounts) and + // is safe from the re-fire bug. 'picker' triggers a step + // transition that takes time — guard against re-fires + // before commit. + if (value === 'picker') { + if (selectFiredRef.current) + return + selectFiredRef.current = true + setStep('sa-json-existing-picker') + } + else { + setSaJsonPathMode('manual') + } + }} + /> + + ) + : ( + <> + Tip: drag a file into this window to paste its path. + + { + const cleaned = cleanPath(val) + if (!cleaned) + return + const abs = resolvePath(cleaned) + if (!existsSync(abs)) { + setError(`File not found: ${abs}`) + setRetryStep('sa-json-existing-path') + setStep('error') + return + } + setServiceAccountJsonPath(abs) + addLog(`✔ Service account JSON · ${abs}`) + persistAndStep( + (p) => ({ ...p, serviceAccountJsonPath: abs }), + 'sa-json-validating', + ) + }} + /> + + )} + + )} + + {step === 'sa-json-existing-picker' && ( + + )} + + {step === 'sa-json-validating' && ( + + + + )} + + {step === 'sa-json-validation-failed' && saValidationResult && !saValidationResult.ok && ( + + + Service account validation failed. + + + + {saValidationResult.message} + + + What would you like to do? + + synchronously, + // so the @inkjs/ui re-fire bug can't replay it. The + // package-pick path goes through async persistAndStep, + // which keeps the