diff --git a/src/oauth/account-import/google-antigravity-adapter.ts b/src/oauth/account-import/google-antigravity-adapter.ts index 4c62e4c022e..b40d2a49c7e 100644 --- a/src/oauth/account-import/google-antigravity-adapter.ts +++ b/src/oauth/account-import/google-antigravity-adapter.ts @@ -52,7 +52,11 @@ export function createAntigravityAccountImportAdapter( const providerEmail = safeProviderEmail(credential.email); if (!providerEmail) return { status: "failed", code: "credential_rejected" }; if (providerEmail !== record.email) return { status: "failed", code: "identity_mismatch" }; - if (typeof credential.projectId !== "string" || credential.projectId.length === 0) { + // Automatic discovery takes precedence; record.projectId serves as operator-supplied fallback + const resolvedProjectId = (typeof credential.projectId === "string" && credential.projectId.length > 0) + ? credential.projectId + : record.projectId; + if (typeof resolvedProjectId !== "string" || resolvedProjectId.length === 0) { return { status: "failed", code: "missing_project" }; } @@ -61,6 +65,7 @@ export function createAntigravityAccountImportAdapter( const disposition = await deps.upsert({ ...credential, email: providerEmail, + projectId: resolvedProjectId, source: "credential-file", }); return disposition === "updated" diff --git a/src/oauth/account-import/parser.ts b/src/oauth/account-import/parser.ts index 7023ab79f4d..f69c21330b6 100644 --- a/src/oauth/account-import/parser.ts +++ b/src/oauth/account-import/parser.ts @@ -1,13 +1,14 @@ import { ACCOUNT_IMPORT_MAX_BYTES, ACCOUNT_IMPORT_MAX_EMAIL_LENGTH, + ACCOUNT_IMPORT_MAX_PROJECT_ID_LENGTH, ACCOUNT_IMPORT_MAX_RECORDS, ACCOUNT_IMPORT_MAX_REFRESH_TOKEN_BYTES, type ParsedCockpitRecord, } from "./types"; const encoder = new TextEncoder(); -const ALLOWED_RECORD_KEYS = new Set(["email", "refresh_token", "tags", "notes"]); +const ALLOWED_RECORD_KEYS = new Set(["email", "refresh_token", "project_id", "tags", "notes"]); export type CockpitDocumentParseResult = | { ok: true; records: ParsedCockpitRecord[] } @@ -50,17 +51,27 @@ function validMetadata(record: Record): boolean { return record.notes === undefined || typeof record.notes === "string"; } +function validProjectId(value: unknown): value is string | undefined { + if (value === undefined) return true; + if (typeof value !== "string") return false; + const trimmed = value.trim(); + if (trimmed.length === 0 || trimmed.length > ACCOUNT_IMPORT_MAX_PROJECT_ID_LENGTH) return false; + return /^[a-z0-9][a-z0-9_.-]*[a-z0-9]$/i.test(trimmed); +} + function parseRecord(value: unknown, index: number): ParsedCockpitRecord { if (!isPlainRecord(value)) return { index, code: "invalid_record" }; if (Object.keys(value).some(key => !ALLOWED_RECORD_KEYS.has(key))) return { index, code: "invalid_record" }; - if (!validEmail(value.email) || !validRefreshToken(value.refresh_token) || !validMetadata(value)) { + if (!validEmail(value.email) || !validRefreshToken(value.refresh_token) || !validProjectId(value.project_id) || !validMetadata(value)) { return { index, code: "invalid_record" }; } + const projectId = typeof value.project_id === "string" ? value.project_id.trim() : undefined; return { index, record: { email: value.email.toLowerCase(), refreshToken: value.refresh_token, + ...(projectId ? { projectId } : {}), }, }; } diff --git a/src/oauth/account-import/types.ts b/src/oauth/account-import/types.ts index 5330bd5bb62..ce88bcab160 100644 --- a/src/oauth/account-import/types.ts +++ b/src/oauth/account-import/types.ts @@ -11,6 +11,7 @@ export const ACCOUNT_IMPORT_MAX_REQUEST_BYTES = ACCOUNT_IMPORT_MAX_BYTES + 1024; export const ACCOUNT_IMPORT_DEADLINE_MS = 600_000; export const ACCOUNT_IMPORT_MAX_RECORDS = 25; export const ACCOUNT_IMPORT_MAX_EMAIL_LENGTH = 254; +export const ACCOUNT_IMPORT_MAX_PROJECT_ID_LENGTH = 64; export const ACCOUNT_IMPORT_MAX_REFRESH_TOKEN_BYTES = 16 * 1024; export type AccountImportStatus = "imported" | "updated" | "failed" | "unsupported"; @@ -65,6 +66,7 @@ export function throwIfAccountImportAborted(signal?: AbortSignal): void { export interface CockpitAccountRecord { email: string; refreshToken: string; + projectId?: string; } export type ParsedCockpitRecord = diff --git a/tests/server/account-import.test.ts b/tests/server/account-import.test.ts index 51732607396..10797d3f326 100644 --- a/tests/server/account-import.test.ts +++ b/tests/server/account-import.test.ts @@ -55,6 +55,11 @@ describe("Cockpit account-import parser", () => { record({ email: `${"a".repeat(250)}@x.io` }), record({ refresh_token: "token\ncontrol" }), record({ refresh_token: "x".repeat(16 * 1024 + 1) }), + record({ project_id: "" }), + record({ project_id: 123 }), + record({ project_id: "invalid project with spaces" }), + record({ project_id: "-leading-hyphen" }), + record({ project_id: "x".repeat(65) }), "not-an-object", ]); expect(parsed.ok).toBe(true); @@ -87,6 +92,39 @@ describe("Cockpit account-import service and adapter", () => { .toEqual({ ok: false, status: 400, code: "unsupported_format" }); }); + test("propagates optional project_id from document through importAccounts to persistence when discovery yields none", async () => { + let capturedCredential: any = null; + const adapter = createAntigravityAccountImportAdapter({ + validate: async () => ({ + access: "access-valid", + refresh: "refresh-valid", + expires: Date.now() + 60_000, + email: "fallback@example.com", + projectId: undefined, + }), + upsert: async (cred) => { + capturedCredential = cred; + return "inserted"; + }, + }); + + const result = await importAccounts({ + provider: ACCOUNT_IMPORT_PROVIDER, + format: ACCOUNT_IMPORT_FORMAT, + document: [ + record({ email: "fallback@example.com", refresh_token: "refresh-valid", project_id: "aicode-consumers" }), + ], + }, { resolveAdapter: () => ({ ok: true, adapter }) }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.result.importedCount).toBe(1); + expect(result.result.failedCount).toBe(0); + } + expect(capturedCredential).not.toBeNull(); + expect(capturedCredential?.projectId).toBe("aicode-consumers"); + }); + test("processes valid records sequentially and exposes only fixed result fields", async () => { let active = 0; let highWater = 0;