Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/oauth/account-import/google-antigravity-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
}

Expand All @@ -61,6 +65,7 @@ export function createAntigravityAccountImportAdapter(
const disposition = await deps.upsert({
...credential,
email: providerEmail,
projectId: resolvedProjectId,
source: "credential-file",
});
return disposition === "updated"
Expand Down
15 changes: 13 additions & 2 deletions src/oauth/account-import/parser.ts
Original file line number Diff line number Diff line change
@@ -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[] }
Expand Down Expand Up @@ -50,17 +51,27 @@ function validMetadata(record: Record<string, unknown>): 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 } : {}),
},
};
}
Expand Down
2 changes: 2 additions & 0 deletions src/oauth/account-import/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -65,6 +66,7 @@ export function throwIfAccountImportAborted(signal?: AbortSignal): void {
export interface CockpitAccountRecord {
email: string;
refreshToken: string;
projectId?: string;
}

export type ParsedCockpitRecord =
Expand Down
38 changes: 38 additions & 0 deletions tests/server/account-import.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
Loading