From e83a953b961478ebfc17f616b0d7ef186d2fe026 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Wed, 5 Aug 2026 22:41:40 +0530 Subject: [PATCH 1/7] =?UTF-8?q?feat(data-explorer):=20firestore=20REST=20c?= =?UTF-8?q?ore=20=E2=80=94=20codec,=20query=20builder,=20error=20mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../__tests__/firestore-api.test.ts | 452 ++++++++++++++++++ .../src/lib/data-explorer/firestore-api.ts | 446 +++++++++++++++++ 2 files changed, 898 insertions(+) create mode 100644 apps/desktop-ui/src/lib/data-explorer/__tests__/firestore-api.test.ts create mode 100644 apps/desktop-ui/src/lib/data-explorer/firestore-api.ts diff --git a/apps/desktop-ui/src/lib/data-explorer/__tests__/firestore-api.test.ts b/apps/desktop-ui/src/lib/data-explorer/__tests__/firestore-api.test.ts new file mode 100644 index 00000000..49fe98e2 --- /dev/null +++ b/apps/desktop-ui/src/lib/data-explorer/__tests__/firestore-api.test.ts @@ -0,0 +1,452 @@ +import { + parseServiceAccount, + decodeFields, + decodeValue, + encodeFields, + encodeValue, + escapeFieldPath, + buildUpdateMask, + buildStructuredQuery, + mapFirestoreError, + sanitizeFirestoreError, + FirestoreApiError, + type FirestoreValue, +} from "../firestore-api"; + +const VALID_SA = { + type: "service_account", + project_id: "my-proj", + client_email: "svc@my-proj.iam.gserviceaccount.com", + private_key: + "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBg\n-----END PRIVATE KEY-----\n", +}; + +describe("parseServiceAccount", () => { + test("parses a valid service account", () => { + const res = parseServiceAccount(JSON.stringify(VALID_SA)); + expect(res).toEqual({ + projectId: "my-proj", + clientEmail: "svc@my-proj.iam.gserviceaccount.com", + privateKey: VALID_SA.private_key, + }); + }); + + test("rejects unparseable JSON", () => { + expect(parseServiceAccount("{nope")).toEqual({ + errorKey: "validation.serviceAccountInvalidJson", + }); + }); + + test("rejects non-service-account type", () => { + const res = parseServiceAccount( + JSON.stringify({ ...VALID_SA, type: "authorized_user" }), + ); + expect(res).toEqual({ + errorKey: "validation.serviceAccountNotServiceAccount", + }); + }); + + test("rejects missing project_id or client_email", () => { + expect( + parseServiceAccount(JSON.stringify({ ...VALID_SA, project_id: "" })), + ).toEqual({ errorKey: "validation.serviceAccountNotServiceAccount" }); + const { client_email: _drop, ...rest } = VALID_SA; + expect(parseServiceAccount(JSON.stringify(rest))).toEqual({ + errorKey: "validation.serviceAccountNotServiceAccount", + }); + }); + + test("normalizes a double-escaped private key", () => { + const doubleEscaped = { + ...VALID_SA, + private_key: + "-----BEGIN PRIVATE KEY-----\\nMIIEvAIBADANBg\\n-----END PRIVATE KEY-----\\n", + }; + const res = parseServiceAccount(JSON.stringify(doubleEscaped)); + expect("privateKey" in res && res.privateKey).toBe(VALID_SA.private_key); + }); + + test("rejects a non-PKCS#8 key", () => { + const res = parseServiceAccount( + JSON.stringify({ + ...VALID_SA, + private_key: "-----BEGIN RSA PRIVATE KEY-----\nabc\n-----END RSA PRIVATE KEY-----\n", + }), + ); + expect(res).toEqual({ errorKey: "validation.serviceAccountBadKey" }); + }); +}); + +// A document containing every Firestore value type. +const ALL_TYPES_FIELDS: Record = { + s: { stringValue: "hello" }, + n: { nullValue: null }, + b: { booleanValue: true }, + i: { integerValue: "42" }, + bigI: { integerValue: "9007199254740993" }, + d: { doubleValue: 1.5 }, + nan: { doubleValue: "NaN" }, + inf: { doubleValue: "Infinity" }, + ts: { timestampValue: "2026-01-02T03:04:05.678Z" }, + ref: { referenceValue: "projects/p/databases/(default)/documents/users/u1" }, + bytes: { bytesValue: "aGVsbG8=" }, + geo: { geoPointValue: { latitude: 1.5, longitude: -2.5 } }, + arr: { + arrayValue: { + values: [{ integerValue: "1" }, { stringValue: "two" }], + }, + }, + map: { + mapValue: { + fields: { inner: { doubleValue: 2 } }, + }, + }, +}; + +describe("codec: decode", () => { + test("decodes every value type to plain JSON", () => { + expect(decodeFields(ALL_TYPES_FIELDS)).toEqual({ + s: "hello", + n: null, + b: true, + i: 42, + bigI: "9007199254740993", + d: 1.5, + nan: "NaN", + inf: "Infinity", + ts: "2026-01-02T03:04:05.678Z", + ref: "projects/p/databases/(default)/documents/users/u1", + bytes: "aGVsbG8=", + geo: { latitude: 1.5, longitude: -2.5 }, + arr: [1, "two"], + map: { inner: 2 }, + }); + }); + + test("decodes an empty arrayValue and mapValue", () => { + expect(decodeValue({ arrayValue: {} })).toEqual([]); + expect(decodeValue({ mapValue: {} })).toEqual({}); + }); +}); + +describe("codec: encode round-trip", () => { + test("unchanged fields re-encode to the original values verbatim", () => { + const plain = decodeFields(ALL_TYPES_FIELDS); + expect(encodeFields(plain, ALL_TYPES_FIELDS)).toEqual(ALL_TYPES_FIELDS); + }); + + test("sticky type: edited timestamp string stays a timestampValue", () => { + expect( + encodeValue("2027-05-05T00:00:00Z", { timestampValue: "2026-01-01T00:00:00Z" }), + ).toEqual({ timestampValue: "2027-05-05T00:00:00Z" }); + }); + + test("sticky type: edited reference and bytes stay typed", () => { + expect( + encodeValue("projects/p/databases/(default)/documents/users/u2", { + referenceValue: "projects/p/databases/(default)/documents/users/u1", + }), + ).toEqual({ + referenceValue: "projects/p/databases/(default)/documents/users/u2", + }); + expect(encodeValue("d29ybGQ=", { bytesValue: "aGVsbG8=" })).toEqual({ + bytesValue: "d29ybGQ=", + }); + }); + + test("sticky type: double edited to an integer number stays a double", () => { + expect(encodeValue(3, { doubleValue: 2 })).toEqual({ doubleValue: 3 }); + }); + + test("sticky type: integer edited to another integer stays an integer", () => { + expect(encodeValue(7, { integerValue: "42" })).toEqual({ integerValue: "7" }); + }); + + test("sticky type: geopoint edited via {latitude, longitude} stays a geopoint", () => { + expect( + encodeValue({ latitude: 9, longitude: 8 }, { geoPointValue: { latitude: 1, longitude: 2 } }), + ).toEqual({ geoPointValue: { latitude: 9, longitude: 8 } }); + }); + + test("inference for new fields", () => { + expect(encodeValue(null)).toEqual({ nullValue: null }); + expect(encodeValue(false)).toEqual({ booleanValue: false }); + expect(encodeValue(5)).toEqual({ integerValue: "5" }); + expect(encodeValue(5.5)).toEqual({ doubleValue: 5.5 }); + expect(encodeValue("hi")).toEqual({ stringValue: "hi" }); + expect(encodeValue([1, "a"])).toEqual({ + arrayValue: { values: [{ integerValue: "1" }, { stringValue: "a" }] }, + }); + expect(encodeValue({ k: true })).toEqual({ + mapValue: { fields: { k: { booleanValue: true } } }, + }); + }); + + test("array elements keep sticky types positionally", () => { + const original: FirestoreValue = { + arrayValue: { values: [{ doubleValue: 1 }, { timestampValue: "2026-01-01T00:00:00Z" }] }, + }; + expect(encodeValue([2, "2027-01-01T00:00:00Z"], original)).toEqual({ + arrayValue: { values: [{ doubleValue: 2 }, { timestampValue: "2027-01-01T00:00:00Z" }] }, + }); + }); + + test("map entries keep sticky types by key", () => { + const original: FirestoreValue = { + mapValue: { fields: { d: { doubleValue: 1 } } }, + }; + expect(encodeValue({ d: 4, extra: "x" }, original)).toEqual({ + mapValue: { + fields: { d: { doubleValue: 4 }, extra: { stringValue: "x" } }, + }, + }); + }); +}); + +describe("escapeFieldPath", () => { + test("passes plain identifiers through", () => { + expect(escapeFieldPath("userName_2")).toBe("userName_2"); + expect(escapeFieldPath("_private")).toBe("_private"); + }); + + test("backtick-wraps non-identifier segments", () => { + expect(escapeFieldPath("weird.key")).toBe("`weird.key`"); + expect(escapeFieldPath("key with space")).toBe("`key with space`"); + expect(escapeFieldPath("2starts-with-digit")).toBe("`2starts-with-digit`"); + }); + + test("escapes backticks and backslashes inside wrapped segments", () => { + expect(escapeFieldPath("back`tick")).toBe("`back\\`tick`"); + expect(escapeFieldPath("back\\slash")).toBe("`back\\\\slash`"); + }); +}); + +describe("buildUpdateMask", () => { + test("unions new and original top-level keys so deleted fields are removed", () => { + const mask = buildUpdateMask( + { kept: 1, added: 2 }, + { kept: { integerValue: "1" }, removed: { stringValue: "x" } }, + ); + expect(mask.sort()).toEqual(["added", "kept", "removed"]); + }); + + test("escapes non-identifier keys", () => { + const mask = buildUpdateMask({ "weird.key": 1 }, {}); + expect(mask).toEqual(["`weird.key`"]); + }); +}); + +describe("buildStructuredQuery", () => { + test("builds filter + orderBy + limit", () => { + expect( + buildStructuredQuery({ + collectionId: "users", + filterField: "age", + filterOp: ">=", + filterValue: "21", + orderByField: "age", + orderByDir: "desc", + limit: 50, + }), + ).toEqual({ + from: [{ collectionId: "users" }], + where: { + fieldFilter: { + field: { fieldPath: "age" }, + op: "GREATER_THAN_OR_EQUAL", + value: { integerValue: "21" }, + }, + }, + orderBy: [{ field: { fieldPath: "age" }, direction: "DESCENDING" }], + limit: 50, + }); + }); + + test("null value becomes a unary IS_NULL filter", () => { + expect( + buildStructuredQuery({ + collectionId: "users", + filterField: "deletedAt", + filterOp: "==", + filterValue: "null", + orderByField: "", + orderByDir: "asc", + limit: 25, + }), + ).toEqual({ + from: [{ collectionId: "users" }], + where: { + unaryFilter: { field: { fieldPath: "deletedAt" }, op: "IS_NULL" }, + }, + limit: 25, + }); + }); + + test("infers value literals: bool, double, quoted string", () => { + const q = (v: string) => + buildStructuredQuery({ + collectionId: "c", + filterField: "f", + filterOp: "==", + filterValue: v, + orderByField: "", + orderByDir: "asc", + limit: 10, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any; + expect(q("true").where.fieldFilter.value).toEqual({ booleanValue: true }); + expect(q("1.5").where.fieldFilter.value).toEqual({ doubleValue: 1.5 }); + expect(q('"42"').where.fieldFilter.value).toEqual({ stringValue: "42" }); + expect(q("plain").where.fieldFilter.value).toEqual({ stringValue: "plain" }); + }); + + test("'in' op splits comma-separated values into an arrayValue", () => { + const q = buildStructuredQuery({ + collectionId: "c", + filterField: "status", + filterOp: "in", + filterValue: "a, b, 3", + orderByField: "", + orderByDir: "asc", + limit: 10, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any; + expect(q.where.fieldFilter).toEqual({ + field: { fieldPath: "status" }, + op: "IN", + value: { + arrayValue: { + values: [{ stringValue: "a" }, { stringValue: "b" }, { integerValue: "3" }], + }, + }, + }); + }); + + test("no filter and no orderBy yields only from + limit", () => { + expect( + buildStructuredQuery({ + collectionId: "c", + filterField: "", + filterOp: "==", + filterValue: "", + orderByField: "", + orderByDir: "asc", + limit: 25, + }), + ).toEqual({ from: [{ collectionId: "c" }], limit: 25 }); + }); + + test("dotted field paths escape each segment", () => { + const q = buildStructuredQuery({ + collectionId: "c", + filterField: "address.zip code", + filterOp: "==", + filterValue: "x", + orderByField: "", + orderByDir: "asc", + limit: 10, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any; + expect(q.where.fieldFilter.field.fieldPath).toBe("address.`zip code`"); + }); +}); + +describe("mapFirestoreError", () => { + const body = (status: string, message: string, details?: unknown[]) => ({ + error: { code: 0, status, message, details }, + }); + + test("SERVICE_DISABLED yields serviceDisabled with activationUrl", () => { + const err = mapFirestoreError( + 403, + body("PERMISSION_DENIED", "Firestore API has not been used", [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + reason: "SERVICE_DISABLED", + metadata: { + activationUrl: + "https://console.developers.google.com/apis/api/firestore.googleapis.com/overview?project=my-proj", + }, + }, + ]), + ); + expect(err.kind).toBe("serviceDisabled"); + expect(err.activationUrl).toContain("console.developers.google.com"); + }); + + test("plain PERMISSION_DENIED yields permissionDenied", () => { + expect(mapFirestoreError(403, body("PERMISSION_DENIED", "denied")).kind).toBe( + "permissionDenied", + ); + }); + + test("index-required FAILED_PRECONDITION extracts the console URL", () => { + const err = mapFirestoreError( + 400, + body( + "FAILED_PRECONDITION", + "The query requires an index. You can create it here: https://console.firebase.google.com/v1/r/project/my-proj/firestore/indexes?create_composite=abc", + ), + ); + expect(err.kind).toBe("indexRequired"); + expect(err.indexUrl).toBe( + "https://console.firebase.google.com/v1/r/project/my-proj/firestore/indexes?create_composite=abc", + ); + }); + + test("Datastore Mode FAILED_PRECONDITION yields datastoreMode", () => { + expect( + mapFirestoreError( + 400, + body( + "FAILED_PRECONDITION", + "This project contains a Cloud Datastore or Cloud Firestore in Datastore Mode database", + ), + ).kind, + ).toBe("datastoreMode"); + }); + + test("NOT_FOUND, RESOURCE_EXHAUSTED, UNAUTHENTICATED map to their kinds", () => { + expect(mapFirestoreError(404, body("NOT_FOUND", "no db")).kind).toBe("notFound"); + expect(mapFirestoreError(429, body("RESOURCE_EXHAUSTED", "quota")).kind).toBe("quota"); + expect(mapFirestoreError(401, body("UNAUTHENTICATED", "bad token")).kind).toBe("auth"); + }); + + test("unknown errors keep a sanitized message", () => { + const err = mapFirestoreError( + 500, + body("INTERNAL", "boom from svc@my-proj.iam.gserviceaccount.com"), + ); + expect(err.kind).toBe("unknown"); + expect(err).toBeInstanceOf(FirestoreApiError); + expect(err.message).not.toContain("svc@my-proj"); + }); + + test("unparseable body still yields an unknown-kind error", () => { + expect(mapFirestoreError(502, undefined).kind).toBe("unknown"); + }); +}); + +describe("sanitizeFirestoreError", () => { + test("strips PEM blocks", () => { + const out = sanitizeFirestoreError( + "failed: -----BEGIN PRIVATE KEY-----\nSECRETSECRET\n-----END PRIVATE KEY----- rest", + ); + expect(out).not.toContain("SECRETSECRET"); + expect(out).toContain("rest"); + }); + + test("strips JWTs and access tokens", () => { + const jwt = + "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ4In0.c2lnbmF0dXJl"; + const out = sanitizeFirestoreError(`bad token ${jwt} and ya29.a0Af-secret123`); + expect(out).not.toContain("eyJhbGciOiJSUzI1NiIs"); + expect(out).not.toContain("ya29.a0Af-secret123"); + }); + + test("scrubs emails via the shared sanitizer", () => { + expect(sanitizeFirestoreError("who: svc@proj.iam.gserviceaccount.com")).not.toContain( + "svc@proj", + ); + }); +}); diff --git a/apps/desktop-ui/src/lib/data-explorer/firestore-api.ts b/apps/desktop-ui/src/lib/data-explorer/firestore-api.ts new file mode 100644 index 00000000..d1d5eea8 --- /dev/null +++ b/apps/desktop-ui/src/lib/data-explorer/firestore-api.ts @@ -0,0 +1,446 @@ +/** + * Cloud Firestore REST API client for the data explorer. + * + * Talks to firestore.googleapis.com/v1 directly from the webview with a + * user-supplied service account (the Firestore Web SDK cannot list root + * collections, which an explorer needs). Auth is a self-signed RS256 JWT + * exchanged for an OAuth2 access token via the jwt-bearer grant, reusing + * the existing WebCrypto signer in lib/auth/jwt-bearer. + * + * Every message that can leave this module goes through + * `sanitizeFirestoreError` — private keys, JWTs and access tokens must + * never reach a toast or log. + */ +import { signJwt } from "@/lib/auth/jwt-bearer"; +import { sanitizeError } from "@/lib/nosql-error-sanitizer"; + +// --------------------------------------------------------------------------- +// Config / service account +// --------------------------------------------------------------------------- + +export interface FirestoreConfig { + /** The full service-account JSON as pasted by the user. */ + serviceAccountJson: string; + /** Firestore database id; "(default)" unless the project uses named DBs. */ + databaseId: string; +} + +export interface ServiceAccount { + projectId: string; + clientEmail: string; + privateKey: string; +} + +/** + * Parse and validate a pasted service-account JSON. Returns the parsed + * account, or an i18n key path (relative to the `DataExplorer` namespace) + * naming the first problem found. + */ +export function parseServiceAccount( + json: string, +): ServiceAccount | { errorKey: string } { + let raw: Record; + try { + raw = JSON.parse(json); + } catch { + return { errorKey: "validation.serviceAccountInvalidJson" }; + } + if ( + !raw || + typeof raw !== "object" || + raw.type !== "service_account" || + typeof raw.project_id !== "string" || + !raw.project_id || + typeof raw.client_email !== "string" || + !raw.client_email + ) { + return { errorKey: "validation.serviceAccountNotServiceAccount" }; + } + let privateKey = typeof raw.private_key === "string" ? raw.private_key : ""; + // Common paste bug: the key arrives double-escaped ("\\n" literals, no + // real newlines). Normalize before checking the PEM marker. + if (!privateKey.includes("\n") && privateKey.includes("\\n")) { + privateKey = privateKey.replace(/\\n/g, "\n"); + } + if (!privateKey.includes("-----BEGIN PRIVATE KEY-----")) { + return { errorKey: "validation.serviceAccountBadKey" }; + } + return { + projectId: raw.project_id, + clientEmail: raw.client_email, + privateKey, + }; +} + +// --------------------------------------------------------------------------- +// Typed-value codec +// --------------------------------------------------------------------------- + +/** One Firestore REST `Value` — exactly one of these keys is set. */ +export interface FirestoreValue { + nullValue?: null; + booleanValue?: boolean; + integerValue?: string; + doubleValue?: number | string; // "NaN" / "Infinity" / "-Infinity" arrive as strings + stringValue?: string; + timestampValue?: string; + referenceValue?: string; + bytesValue?: string; + geoPointValue?: { latitude: number; longitude: number }; + arrayValue?: { values?: FirestoreValue[] }; + mapValue?: { fields?: Record }; +} + +export interface FirestoreDocument { + name: string; + fields?: Record; + createTime?: string; + updateTime?: string; +} + +export function decodeValue(value: FirestoreValue): unknown { + if ("nullValue" in value) return null; + if (value.booleanValue !== undefined) return value.booleanValue; + if (value.integerValue !== undefined) { + const n = Number(value.integerValue); + return Number.isSafeInteger(n) ? n : value.integerValue; + } + if (value.doubleValue !== undefined) return value.doubleValue; + if (value.stringValue !== undefined) return value.stringValue; + if (value.timestampValue !== undefined) return value.timestampValue; + if (value.referenceValue !== undefined) return value.referenceValue; + if (value.bytesValue !== undefined) return value.bytesValue; + if (value.geoPointValue !== undefined) + return { + latitude: value.geoPointValue.latitude, + longitude: value.geoPointValue.longitude, + }; + if (value.arrayValue !== undefined) + return (value.arrayValue.values ?? []).map(decodeValue); + if (value.mapValue !== undefined) return decodeFields(value.mapValue.fields ?? {}); + return null; +} + +export function decodeFields( + fields: Record, +): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(fields)) out[k] = decodeValue(v); + return out; +} + +function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (typeof a !== typeof b || a === null || b === null) return false; + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; + return a.every((x, i) => deepEqual(x, b[i])); + } + if (typeof a === "object") { + const ka = Object.keys(a as object); + const kb = Object.keys(b as object); + if (ka.length !== kb.length) return false; + return ka.every((k) => + deepEqual((a as Record)[k], (b as Record)[k]), + ); + } + return false; +} + +function isGeoPointShape(v: unknown): v is { latitude: number; longitude: number } { + return ( + typeof v === "object" && + v !== null && + !Array.isArray(v) && + Object.keys(v).length === 2 && + typeof (v as Record).latitude === "number" && + typeof (v as Record).longitude === "number" + ); +} + +/** + * Encode a plain JSON value back to a Firestore `Value`. + * + * Diff-preserving with sticky types: an unchanged value returns the original + * wire value verbatim (perfect round-trip for int-vs-double, timestamps, + * bytes, NaN…); a changed value keeps the original's type where the new + * value is compatible; a brand-new value falls back to inference. New + * timestamp/reference/bytes/geopoint fields cannot be created this way. + * ponytail: sticky-type-only; add $type annotations if users ask. + */ +export function encodeValue(plain: unknown, original?: FirestoreValue): FirestoreValue { + if (original !== undefined) { + if (deepEqual(decodeValue(original), plain)) return original; + if (typeof plain === "string") { + if (original.timestampValue !== undefined) return { timestampValue: plain }; + if (original.referenceValue !== undefined) return { referenceValue: plain }; + if (original.bytesValue !== undefined) return { bytesValue: plain }; + } + if (typeof plain === "number" && Number.isInteger(plain)) { + if (original.integerValue !== undefined) return { integerValue: String(plain) }; + if (original.doubleValue !== undefined) return { doubleValue: plain }; + } + if (original.geoPointValue !== undefined && isGeoPointShape(plain)) + return { geoPointValue: plain }; + if (original.arrayValue !== undefined && Array.isArray(plain)) { + const origValues = original.arrayValue.values ?? []; + return { + arrayValue: { + values: plain.map((el, i) => encodeValue(el, origValues[i])), + }, + }; + } + if ( + original.mapValue !== undefined && + typeof plain === "object" && + plain !== null && + !Array.isArray(plain) + ) { + return { + mapValue: { + fields: encodeFields( + plain as Record, + original.mapValue.fields ?? {}, + ), + }, + }; + } + } + // Inference for new values. + if (plain === null || plain === undefined) return { nullValue: null }; + if (typeof plain === "boolean") return { booleanValue: plain }; + if (typeof plain === "number") + return Number.isInteger(plain) + ? { integerValue: String(plain) } + : { doubleValue: plain }; + if (typeof plain === "string") return { stringValue: plain }; + if (Array.isArray(plain)) + return { arrayValue: { values: plain.map((el) => encodeValue(el)) } }; + return { + mapValue: { fields: encodeFields(plain as Record) }, + }; +} + +export function encodeFields( + plain: Record, + originalFields?: Record, +): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(plain)) out[k] = encodeValue(v, originalFields?.[k]); + return out; +} + +// --------------------------------------------------------------------------- +// Field paths, update masks, queries +// --------------------------------------------------------------------------- + +/** + * Escape ONE field-path segment for updateMask / query field references. + * Plain identifiers pass through; anything else is backtick-quoted with + * backslash and backtick escaped. + */ +export function escapeFieldPath(segment: string): string { + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(segment)) return segment; + return "`" + segment.replace(/\\/g, "\\\\").replace(/`/g, "\\`") + "`"; +} + +/** + * Update mask for a whole-document edit: the union of new and original + * top-level keys, so fields the user deleted from the JSON get removed + * server-side (a masked field with no value is a delete). + */ +export function buildUpdateMask( + newPlain: Record, + originalFields: Record, +): string[] { + const keys = new Set([...Object.keys(newPlain), ...Object.keys(originalFields)]); + return [...keys].map(escapeFieldPath); +} + +const QUERY_OPS: Record = { + "==": "EQUAL", + "!=": "NOT_EQUAL", + "<": "LESS_THAN", + "<=": "LESS_THAN_OR_EQUAL", + ">": "GREATER_THAN", + ">=": "GREATER_THAN_OR_EQUAL", + "array-contains": "ARRAY_CONTAINS", + in: "IN", +}; + +export const FILTER_OPS = Object.keys(QUERY_OPS); + +/** Infer a typed query literal from user text: bool, number, "quoted" string, string. */ +function inferQueryValue(text: string): FirestoreValue { + const t = text.trim(); + if (t === "true") return { booleanValue: true }; + if (t === "false") return { booleanValue: false }; + if (t !== "" && !Number.isNaN(Number(t))) { + const n = Number(t); + return Number.isInteger(n) && !t.includes(".") + ? { integerValue: String(n) } + : { doubleValue: n }; + } + if (t.length >= 2 && t.startsWith('"') && t.endsWith('"')) + return { stringValue: t.slice(1, -1) }; + return { stringValue: t }; +} + +function escapeDottedFieldPath(path: string): string { + return path.split(".").map(escapeFieldPath).join("."); +} + +export interface QueryInput { + collectionId: string; + filterField: string; + filterOp: string; + filterValue: string; + orderByField: string; + orderByDir: "asc" | "desc"; + limit: number; +} + +/** Build a REST StructuredQuery from the pane's simple filter controls. */ +export function buildStructuredQuery(input: QueryInput): Record { + const query: Record = { + from: [{ collectionId: input.collectionId }], + }; + if (input.filterField.trim()) { + const field = { fieldPath: escapeDottedFieldPath(input.filterField.trim()) }; + if (input.filterValue.trim() === "null") { + query.where = { + unaryFilter: { + field, + op: input.filterOp === "!=" ? "IS_NOT_NULL" : "IS_NULL", + }, + }; + } else { + const value = + input.filterOp === "in" + ? { + arrayValue: { + values: input.filterValue + .split(",") + .map((part) => inferQueryValue(part)), + }, + } + : inferQueryValue(input.filterValue); + query.where = { + fieldFilter: { field, op: QUERY_OPS[input.filterOp] ?? "EQUAL", value }, + }; + } + } + if (input.orderByField.trim()) { + query.orderBy = [ + { + field: { fieldPath: escapeDottedFieldPath(input.orderByField.trim()) }, + direction: input.orderByDir === "desc" ? "DESCENDING" : "ASCENDING", + }, + ]; + } + query.limit = input.limit; + return query; +} + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +export type FirestoreErrorKind = + | "serviceDisabled" + | "permissionDenied" + | "indexRequired" + | "datastoreMode" + | "notFound" + | "quota" + | "auth" + | "clockSkew" + | "serviceAccountRejected" + | "network" + | "unknown"; + +export class FirestoreApiError extends Error { + kind: FirestoreErrorKind; + httpStatus: number; + grpcStatus?: string; + /** "Enable the API" console link, present when kind === "serviceDisabled". */ + activationUrl?: string; + /** "Create index" console link, present when kind === "indexRequired". */ + indexUrl?: string; + + constructor( + kind: FirestoreErrorKind, + message: string, + httpStatus = 0, + extras?: { grpcStatus?: string; activationUrl?: string; indexUrl?: string }, + ) { + super(sanitizeFirestoreError(message)); + this.name = "FirestoreApiError"; + this.kind = kind; + this.httpStatus = httpStatus; + this.grpcStatus = extras?.grpcStatus; + this.activationUrl = extras?.activationUrl; + this.indexUrl = extras?.indexUrl; + } +} + +interface GoogleErrorBody { + error?: { + code?: number; + status?: string; + message?: string; + details?: Array<{ + "@type"?: string; + reason?: string; + metadata?: Record; + }>; + }; +} + +/** Map a non-OK Firestore REST response to a typed error. */ +export function mapFirestoreError( + httpStatus: number, + body: GoogleErrorBody | undefined, +): FirestoreApiError { + const err = body?.error; + const status = err?.status ?? ""; + const message = err?.message ?? `HTTP ${httpStatus}`; + const make = ( + kind: FirestoreErrorKind, + extras?: { activationUrl?: string; indexUrl?: string }, + ) => new FirestoreApiError(kind, message, httpStatus, { grpcStatus: status, ...extras }); + + if (status === "PERMISSION_DENIED") { + const disabled = err?.details?.find((d) => d.reason === "SERVICE_DISABLED"); + if (disabled) + return make("serviceDisabled", { + activationUrl: disabled.metadata?.activationUrl, + }); + return make("permissionDenied"); + } + if (status === "FAILED_PRECONDITION") { + if (/requires an index/i.test(message)) { + const indexUrl = message.match( + /https:\/\/console\.firebase\.google\.com\S+/, + )?.[0]; + return make("indexRequired", { indexUrl }); + } + if (/datastore mode/i.test(message)) return make("datastoreMode"); + } + if (status === "NOT_FOUND") return make("notFound"); + if (status === "RESOURCE_EXHAUSTED") return make("quota"); + if (status === "UNAUTHENTICATED") return make("auth"); + return make("unknown"); +} + +/** + * Fail-closed scrub applied to every outbound message: PEM blocks, JWTs, + * Google access tokens, then the shared credential/email sanitizer. + */ +export function sanitizeFirestoreError(message: string): string { + let out = message; + out = out.replace(/-----BEGIN[\s\S]*?-----END [A-Z ]*KEY-----/g, "***KEY***"); + out = out.replace(/eyJ[\w-]{10,}\.[\w-]+\.[\w-]+/g, "***JWT***"); + out = out.replace(/ya29\.[\w.-]+/g, "***TOKEN***"); + return sanitizeError(out); +} From bdc29db001b6aa83eb8ea1663ffa3e385a210993 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Wed, 5 Aug 2026 22:44:52 +0530 Subject: [PATCH 2/7] =?UTF-8?q?feat(data-explorer):=20firestore=20auth=20+?= =?UTF-8?q?=20REST=20methods=20=E2=80=94=20token=20cache,=20401=20retry,?= =?UTF-8?q?=20paging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../__tests__/firestore-api-net.test.ts | 224 ++++++++++++++++ .../src/lib/data-explorer/firestore-api.ts | 247 ++++++++++++++++++ 2 files changed, 471 insertions(+) create mode 100644 apps/desktop-ui/src/lib/data-explorer/__tests__/firestore-api-net.test.ts diff --git a/apps/desktop-ui/src/lib/data-explorer/__tests__/firestore-api-net.test.ts b/apps/desktop-ui/src/lib/data-explorer/__tests__/firestore-api-net.test.ts new file mode 100644 index 00000000..76213104 --- /dev/null +++ b/apps/desktop-ui/src/lib/data-explorer/__tests__/firestore-api-net.test.ts @@ -0,0 +1,224 @@ +import { + getAccessToken, + evictToken, + firestoreFetch, + listCollectionIds, + listDocuments, + patchDocument, + FirestoreApiError, + type ServiceAccount, + type FirestoreConfig, +} from "../firestore-api"; +import { signJwt } from "@/lib/auth/jwt-bearer"; + +jest.mock("@/lib/auth/jwt-bearer", () => ({ + signJwt: jest.fn(async () => "signed-assertion"), +})); + +const sa = (email: string): ServiceAccount => ({ + projectId: "my-proj", + clientEmail: email, + privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n", +}); + +const CONFIG: FirestoreConfig = { + serviceAccountJson: JSON.stringify({ + type: "service_account", + project_id: "my-proj", + client_email: "cfg@my-proj.iam.gserviceaccount.com", + private_key: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n", + }), + databaseId: "(default)", +}; + +const jsonResponse = (status: number, body: unknown) => ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, +}); + +const tokenResponse = (token = "tok-1") => + jsonResponse(200, { access_token: token, expires_in: 3600 }); + +let fetchMock: jest.Mock; +beforeEach(() => { + fetchMock = jest.fn(); + global.fetch = fetchMock as unknown as typeof fetch; + jest.clearAllMocks(); + // The module-level token cache is keyed by clientEmail|projectId and + // survives between tests — evict CONFIG's entry so every test starts + // with a token exchange. + evictToken({ + projectId: "my-proj", + clientEmail: "cfg@my-proj.iam.gserviceaccount.com", + privateKey: "", + }); +}); + +describe("getAccessToken", () => { + test("exchanges a signed JWT at the token endpoint", async () => { + fetchMock.mockResolvedValueOnce(tokenResponse("tok-A")); + const token = await getAccessToken(sa("a@x.iam"), 1_000_000); + expect(token).toBe("tok-A"); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("https://oauth2.googleapis.com/token"); + expect(String(init.body)).toContain("grant_type=urn"); + expect(String(init.body)).toContain("assertion=signed-assertion"); + }); + + test("backdates iat by 60 seconds", async () => { + fetchMock.mockResolvedValueOnce(tokenResponse()); + await getAccessToken(sa("b@x.iam"), 2_000_000); + const opts = (signJwt as jest.Mock).mock.calls[0][0]; + expect(opts.claims.iat).toBe(Math.floor(2_000_000 / 1000) - 60); + expect(opts.claims.extra.scope).toBe("https://www.googleapis.com/auth/datastore"); + }); + + test("caches the token until the 5-minute margin, then refreshes", async () => { + fetchMock.mockResolvedValue(tokenResponse("tok-C")); + const acct = sa("c@x.iam"); + await getAccessToken(acct, 0); + await getAccessToken(acct, 3_000_000); // 50 min in, > 5 min left + expect(fetchMock).toHaveBeenCalledTimes(1); + await getAccessToken(acct, 3_400_000); // < 5 min left + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test("evictToken forces a refresh", async () => { + fetchMock.mockResolvedValue(tokenResponse()); + const acct = sa("d@x.iam"); + await getAccessToken(acct, 0); + evictToken(acct); + await getAccessToken(acct, 1); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test("maps invalid_grant iat wording to clockSkew and never caches failures", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse(400, { + error: "invalid_grant", + error_description: "Invalid JWT: iat must be in the past", + }), + ); + const acct = sa("e@x.iam"); + await expect(getAccessToken(acct, 0)).rejects.toMatchObject({ kind: "clockSkew" }); + fetchMock.mockResolvedValueOnce(tokenResponse()); + await expect(getAccessToken(acct, 1)).resolves.toBe("tok-1"); + }); + + test("maps disabled/deleted account wording to serviceAccountRejected", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse(400, { + error: "invalid_grant", + error_description: "Client is disabled", + }), + ); + await expect(getAccessToken(sa("f@x.iam"), 0)).rejects.toMatchObject({ + kind: "serviceAccountRejected", + }); + }); + + test("maps other failures to auth and network errors to network", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse(401, { error: "invalid_client", error_description: "nope" }), + ); + await expect(getAccessToken(sa("g@x.iam"), 0)).rejects.toMatchObject({ kind: "auth" }); + fetchMock.mockRejectedValueOnce(new TypeError("Failed to fetch")); + await expect(getAccessToken(sa("h@x.iam"), 0)).rejects.toMatchObject({ + kind: "network", + }); + }); +}); + +describe("firestoreFetch", () => { + test("sends a bearer request and returns the JSON body", async () => { + fetchMock + .mockResolvedValueOnce(tokenResponse("tok-F")) + .mockResolvedValueOnce(jsonResponse(200, { hello: 1 })); + const out = await firestoreFetch(CONFIG, "GET", "projects/my-proj/databases/(default)"); + expect(out).toEqual({ hello: 1 }); + const [url, init] = fetchMock.mock.calls[1]; + expect(url).toBe( + "https://firestore.googleapis.com/v1/projects/my-proj/databases/(default)", + ); + expect(init.headers.Authorization).toBe("Bearer tok-F"); + }); + + test("on 401 evicts the token and retries exactly once", async () => { + fetchMock + .mockResolvedValueOnce(tokenResponse("stale")) + .mockResolvedValueOnce(jsonResponse(401, { error: { status: "UNAUTHENTICATED", message: "expired" } })) + .mockResolvedValueOnce(tokenResponse("fresh")) + .mockResolvedValueOnce(jsonResponse(200, { ok: 1 })); + const out = await firestoreFetch(CONFIG, "GET", "x"); + expect(out).toEqual({ ok: 1 }); + expect(fetchMock).toHaveBeenCalledTimes(4); + expect(fetchMock.mock.calls[3][1].headers.Authorization).toBe("Bearer fresh"); + }); + + test("throws a mapped FirestoreApiError on non-OK responses", async () => { + fetchMock + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce( + jsonResponse(403, { error: { status: "PERMISSION_DENIED", message: "denied" } }), + ); + await expect(firestoreFetch(CONFIG, "GET", "x")).rejects.toMatchObject({ + kind: "permissionDenied", + }); + }); + + test("rejects an invalid service account without any network call", async () => { + await expect( + firestoreFetch({ serviceAccountJson: "{bad", databaseId: "(default)" }, "GET", "x"), + ).rejects.toBeInstanceOf(FirestoreApiError); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("API methods", () => { + test("listCollectionIds loops page tokens and supports a parent doc path", async () => { + fetchMock + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce( + jsonResponse(200, { collectionIds: ["a", "b"], nextPageToken: "p2" }), + ) + .mockResolvedValueOnce(jsonResponse(200, { collectionIds: ["c"] })); + const ids = await listCollectionIds(CONFIG, "users/u1"); + expect(ids).toEqual(["a", "b", "c"]); + const firstUrl = fetchMock.mock.calls[1][0] as string; + expect(firstUrl).toContain( + "projects/my-proj/databases/(default)/documents/users/u1:listCollectionIds", + ); + const secondBody = JSON.parse(fetchMock.mock.calls[2][1].body); + expect(secondBody.pageToken).toBe("p2"); + }); + + test("listDocuments passes pageSize/pageToken and returns nextPageToken", async () => { + fetchMock + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce( + jsonResponse(200, { + documents: [{ name: "projects/p/databases/(default)/documents/users/u1" }], + nextPageToken: "next", + }), + ); + const out = await listDocuments(CONFIG, "users", 25, "tok"); + expect(out.documents).toHaveLength(1); + expect(out.nextPageToken).toBe("next"); + const url = fetchMock.mock.calls[1][0] as string; + expect(url).toContain("/documents/users?"); + expect(url).toContain("pageSize=25"); + expect(url).toContain("pageToken=tok"); + }); + + test("patchDocument repeats updateMask.fieldPaths query params", async () => { + fetchMock + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce(jsonResponse(200, { name: "n" })); + await patchDocument(CONFIG, "users/u1", { a: { integerValue: "1" } }, ["a", "`b c`"]); + const [url, init] = fetchMock.mock.calls[1]; + expect(init.method).toBe("PATCH"); + const params = new URL(url as string).searchParams.getAll("updateMask.fieldPaths"); + expect(params).toEqual(["a", "`b c`"]); + }); +}); diff --git a/apps/desktop-ui/src/lib/data-explorer/firestore-api.ts b/apps/desktop-ui/src/lib/data-explorer/firestore-api.ts index d1d5eea8..eabcd02a 100644 --- a/apps/desktop-ui/src/lib/data-explorer/firestore-api.ts +++ b/apps/desktop-ui/src/lib/data-explorer/firestore-api.ts @@ -444,3 +444,250 @@ export function sanitizeFirestoreError(message: string): string { out = out.replace(/ya29\.[\w.-]+/g, "***TOKEN***"); return sanitizeError(out); } + +// --------------------------------------------------------------------------- +// Auth: service account → OAuth2 access token (jwt-bearer grant) +// --------------------------------------------------------------------------- + +const TOKEN_URL = "https://oauth2.googleapis.com/token"; +const FIRESTORE_BASE = "https://firestore.googleapis.com/v1/"; + +interface TokenEntry { + token: string; + expiresAt: number; +} + +const tokenCache = new Map(); + +function tokenCacheKey(sa: ServiceAccount): string { + return sa.clientEmail + "|" + sa.projectId; +} + +export function evictToken(sa: ServiceAccount): void { + tokenCache.delete(tokenCacheKey(sa)); +} + +export async function getAccessToken( + sa: ServiceAccount, + now: number = Date.now(), +): Promise { + const cached = tokenCache.get(tokenCacheKey(sa)); + // 5-minute margin so a token never expires mid-request. + if (cached && now < cached.expiresAt - 300_000) return cached.token; + + // iat backdated 60 s to absorb typical client clock skew. + const iat = Math.floor(now / 1000) - 60; + const assertion = await signJwt({ + algorithm: "RS256", + privateKeyPem: sa.privateKey, + claims: { + iss: sa.clientEmail, + aud: TOKEN_URL, + iat, + extra: { scope: "https://www.googleapis.com/auth/datastore" }, + }, + ttlSeconds: 3600, + now, + }); + + let res: { ok: boolean; status: number; json: () => Promise }; + try { + res = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion, + }), + }); + } catch { + throw new FirestoreApiError("network", "network error reaching Google auth"); + } + const body = (await res.json().catch(() => undefined)) as + | { access_token?: string; expires_in?: number; error?: string; error_description?: string } + | undefined; + if (!res.ok || !body?.access_token) { + const desc = body?.error_description ?? body?.error ?? `HTTP ${res.status}`; + if (body?.error === "invalid_grant" && /iat|exp|expired|too early|clock|short-lived/i.test(desc)) + throw new FirestoreApiError("clockSkew", desc, res.status); + if (/disabled|deleted|not found/i.test(desc)) + throw new FirestoreApiError("serviceAccountRejected", desc, res.status); + throw new FirestoreApiError("auth", desc, res.status); + } + tokenCache.set(tokenCacheKey(sa), { + token: body.access_token, + expiresAt: now + (body.expires_in ?? 3600) * 1000, + }); + return body.access_token; +} + +// --------------------------------------------------------------------------- +// Request wrapper + API methods +// --------------------------------------------------------------------------- + +function requireServiceAccount(config: FirestoreConfig): ServiceAccount { + const sa = parseServiceAccount(config.serviceAccountJson); + if ("errorKey" in sa) + throw new FirestoreApiError("auth", "invalid service account configuration"); + return sa; +} + +/** `projects/{p}/databases/{db}` for the connection. */ +export function databasePath(config: FirestoreConfig): string { + const sa = requireServiceAccount(config); + return `projects/${sa.projectId}/databases/${config.databaseId || "(default)"}`; +} + +function encodePathSegments(path: string): string { + return path.split("/").map(encodeURIComponent).join("/"); +} + +/** + * Authenticated call against firestore.googleapis.com/v1. On a 401 the + * cached token is evicted and the request retried exactly once. + */ +export async function firestoreFetch( + config: FirestoreConfig, + method: string, + path: string, + body?: unknown, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): Promise { + const sa = requireServiceAccount(config); + const doFetch = async (token: string) => { + try { + return await fetch(FIRESTORE_BASE + path, { + method, + headers: { + Authorization: `Bearer ${token}`, + ...(body !== undefined ? { "Content-Type": "application/json" } : {}), + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + } catch { + throw new FirestoreApiError("network", "network error reaching Firestore"); + } + }; + let res = await doFetch(await getAccessToken(sa)); + if (res.status === 401) { + evictToken(sa); + res = await doFetch(await getAccessToken(sa)); + } + const json = await res.json().catch(() => undefined); + if (!res.ok) throw mapFirestoreError(res.status, json); + return json; +} + +/** + * All collection ids under the database root, or under `parentDocPath` + * (a document path like "users/u1") for subcollections. Follows page + * tokens internally so callers always see the full list. + */ +export async function listCollectionIds( + config: FirestoreConfig, + parentDocPath?: string, +): Promise { + const parent = + databasePath(config) + + "/documents" + + (parentDocPath ? "/" + encodePathSegments(parentDocPath) : ""); + const ids: string[] = []; + let pageToken: string | undefined; + do { + const out = await firestoreFetch(config, "POST", `${parent}:listCollectionIds`, { + pageSize: 300, + ...(pageToken ? { pageToken } : {}), + }); + ids.push(...(out.collectionIds ?? [])); + pageToken = out.nextPageToken; + } while (pageToken); + return ids; +} + +export async function listDocuments( + config: FirestoreConfig, + collectionPath: string, + pageSize: number, + pageToken?: string, +): Promise<{ documents: FirestoreDocument[]; nextPageToken?: string }> { + const params = new URLSearchParams({ pageSize: String(pageSize) }); + if (pageToken) params.set("pageToken", pageToken); + const out = await firestoreFetch( + config, + "GET", + `${databasePath(config)}/documents/${encodePathSegments(collectionPath)}?${params}`, + ); + return { documents: out.documents ?? [], nextPageToken: out.nextPageToken }; +} + +/** Run a StructuredQuery under the root or a parent document. */ +export async function runQuery( + config: FirestoreConfig, + parentDocPath: string | undefined, + structuredQuery: Record, +): Promise { + const parent = + databasePath(config) + + "/documents" + + (parentDocPath ? "/" + encodePathSegments(parentDocPath) : ""); + const rows: Array<{ document?: FirestoreDocument }> = await firestoreFetch( + config, + "POST", + `${parent}:runQuery`, + { structuredQuery }, + ); + return (rows ?? []).flatMap((row) => (row.document ? [row.document] : [])); +} + +export async function getDocument( + config: FirestoreConfig, + docPath: string, +): Promise { + return firestoreFetch( + config, + "GET", + `${databasePath(config)}/documents/${encodePathSegments(docPath)}`, + ); +} + +export async function createDocument( + config: FirestoreConfig, + collectionPath: string, + docId: string | undefined, + fields: Record, +): Promise { + const params = docId ? `?${new URLSearchParams({ documentId: docId })}` : ""; + return firestoreFetch( + config, + "POST", + `${databasePath(config)}/documents/${encodePathSegments(collectionPath)}${params}`, + { fields }, + ); +} + +export async function patchDocument( + config: FirestoreConfig, + docPath: string, + fields: Record, + updateMask: string[], +): Promise { + const params = new URLSearchParams(); + for (const fieldPath of updateMask) params.append("updateMask.fieldPaths", fieldPath); + return firestoreFetch( + config, + "PATCH", + `${databasePath(config)}/documents/${encodePathSegments(docPath)}?${params}`, + { fields }, + ); +} + +export async function deleteDocument( + config: FirestoreConfig, + docPath: string, +): Promise { + await firestoreFetch( + config, + "DELETE", + `${databasePath(config)}/documents/${encodePathSegments(docPath)}`, + ); +} From efe02830df5bd225ed482e310744c0a208289521 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Wed, 5 Aug 2026 22:49:20 +0530 Subject: [PATCH 3/7] =?UTF-8?q?feat(data-explorer):=20firestore=20adapter?= =?UTF-8?q?=20=E2=80=94=20connection=20form,=20registry,=20sidebar=20tree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- apps/desktop-ui/messages/en.json | 63 ++- .../__tests__/firestore-adapter.test.ts | 88 ++++ .../data-explorer/adapters/firestore.tsx | 398 ++++++++++++++++++ .../src/components/data-explorer/sources.ts | 4 +- .../__tests__/firestore-api.test.ts | 7 +- 5 files changed, 555 insertions(+), 5 deletions(-) create mode 100644 apps/desktop-ui/src/components/data-explorer/__tests__/firestore-adapter.test.ts create mode 100644 apps/desktop-ui/src/components/data-explorer/adapters/firestore.tsx diff --git a/apps/desktop-ui/messages/en.json b/apps/desktop-ui/messages/en.json index a7a156fb..2bcc7cc9 100644 --- a/apps/desktop-ui/messages/en.json +++ b/apps/desktop-ui/messages/en.json @@ -1941,7 +1941,68 @@ "connectionStringRequired": "Enter a connection string.", "connectionStringScheme": "Connection string must start with mongodb:// or mongodb+srv://.", "redisUrlRequired": "Enter a Redis URL.", - "redisUrlScheme": "Redis URL must start with redis:// or rediss://." + "redisUrlScheme": "Redis URL must start with redis:// or rediss://.", + "serviceAccountRequired": "Paste a service account JSON key.", + "serviceAccountInvalidJson": "This is not valid JSON.", + "serviceAccountNotServiceAccount": "This JSON is not a service account key (expected type \"service_account\" with project_id and client_email).", + "serviceAccountBadKey": "The private_key field is missing or not a PKCS#8 PEM key.", + "databaseIdInvalid": "Database ID must be 4–63 lowercase letters, digits or hyphens, or (default)." + }, + "firestore": { + "serviceAccount": "Service account key (JSON)", + "serviceAccountPlaceholder": "Paste the JSON key file of a service account with Firestore access…", + "serviceAccountHint": "Create one in Google Cloud console → IAM → Service Accounts → Keys. It needs the roles/datastore.user role (or roles/datastore.viewer for read-only).", + "loadFromFile": "Load from file…", + "parsedProject": "Project:", + "databaseId": "Database ID", + "databaseIdPlaceholder": "(default)", + "databaseIdHint": "Leave empty unless the project uses named Firestore databases.", + "noCollections": "No collections", + "noMatches": "No matches", + "docCount": "{count, plural, =0 {No documents} one {# document} other {# documents}}", + "newDocument": "New document", + "docIdLabel": "Document ID", + "docIdAutoHint": "Leave empty for an auto-generated ID.", + "newFieldTypesHint": "New fields can be strings, numbers, booleans, arrays or maps. Editing an existing timestamp, reference, bytes or geopoint field keeps its type.", + "create": "Create", + "edit": "Edit", + "save": "Save", + "cancel": "Cancel", + "deleteDocTitle": "Delete document?", + "deleteDocBody": "This deletes {id}. Its subcollections are NOT deleted and will be orphaned (still reachable by path, invisible in listings).", + "subcollections": "Subcollections", + "noSubcollections": "No subcollections", + "filterField": "Field", + "filterValue": "Value", + "orderBy": "Order by", + "orderByHint": "Documents missing the ordered field are excluded by Firestore.", + "apply": "Apply", + "clear": "Clear", + "nextPage": "Next", + "prevPage": "Previous", + "emptyCollection": "No documents in this collection.", + "queryLimitNote": "Showing the first {count} results — raise the page size for more.", + "selectDoc": "Select a document to view it.", + "errPermissionDenied": "Permission denied. The service account needs the roles/datastore.user role (or roles/datastore.viewer to read).", + "errServiceDisabled": "The Firestore API is not enabled for this project.", + "errOpenConsole": "Enable API in console", + "errDatastoreMode": "This database is in Datastore mode, which Firestore tools cannot browse.", + "errIndexRequired": "This query needs a composite index.", + "errCreateIndex": "Create index in console", + "errQuota": "Firestore quota exceeded — try again later.", + "errClockSkew": "Google rejected the signed token. Check that this computer's clock is set correctly.", + "errServiceAccountRejected": "The service account was disabled or deleted. Issue a new key in the Google Cloud console.", + "errNetwork": "Network error — check your connection.", + "errNotFound": "Database not found. Check the database ID.", + "errDocTooLarge": "Document exceeds Firestore's 1 MB limit.", + "toast": { + "docCreated": "Document created", + "docUpdated": "Document saved", + "docDeleted": "Document deleted", + "createFailed": "Create failed: {message}", + "saveFailed": "Save failed: {message}", + "deleteFailed": "Delete failed: {message}" + } }, "redis": { "urlMode": "URL", diff --git a/apps/desktop-ui/src/components/data-explorer/__tests__/firestore-adapter.test.ts b/apps/desktop-ui/src/components/data-explorer/__tests__/firestore-adapter.test.ts new file mode 100644 index 00000000..5e927bbe --- /dev/null +++ b/apps/desktop-ui/src/components/data-explorer/__tests__/firestore-adapter.test.ts @@ -0,0 +1,88 @@ +// firestore.tsx imports next-intl's useTranslations and (via its pane) +// @/components/ui/resizable — same mocks as the other adapter tests. +jest.mock("next-intl", () => ({ useTranslations: () => (k: string) => k })) +jest.mock("react-resizable-panels", () => ({ + Panel: () => null, + PanelGroup: () => null, + PanelResizeHandle: () => null, +})) + +import { firestoreAdapter } from "../adapters/firestore" + +const VALID_SA_JSON = JSON.stringify({ + type: "service_account", + project_id: "my-proj", + client_email: "svc@my-proj.iam.gserviceaccount.com", + private_key: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n", +}) + +describe("firestore adapter", () => { + it("starts blank with the default database", () => { + expect(firestoreAdapter.blankConfig()).toEqual({ + serviceAccountJson: "", + databaseId: "(default)", + }) + }) + + it("rejects bad configs with the right i18n keys", () => { + expect( + firestoreAdapter.validate({ serviceAccountJson: "", databaseId: "(default)" }) + ).toBe("validation.serviceAccountRequired") + expect( + firestoreAdapter.validate({ serviceAccountJson: "{bad", databaseId: "(default)" }) + ).toBe("validation.serviceAccountInvalidJson") + expect( + firestoreAdapter.validate({ + serviceAccountJson: JSON.stringify({ type: "authorized_user" }), + databaseId: "(default)", + }) + ).toBe("validation.serviceAccountNotServiceAccount") + expect( + firestoreAdapter.validate({ + serviceAccountJson: VALID_SA_JSON, + databaseId: "Bad_DB!", + }) + ).toBe("validation.databaseIdInvalid") + }) + + it("accepts a valid config, with default or named database", () => { + expect( + firestoreAdapter.validate({ serviceAccountJson: VALID_SA_JSON, databaseId: "(default)" }) + ).toBeNull() + expect( + firestoreAdapter.validate({ serviceAccountJson: VALID_SA_JSON, databaseId: "" }) + ).toBeNull() + expect( + firestoreAdapter.validate({ serviceAccountJson: VALID_SA_JSON, databaseId: "my-named-db" }) + ).toBeNull() + }) + + // A misspelled key renders as a broken path to the user — assert every + // key the adapter can return resolves in en.json. + it("returns only keys that resolve in messages/en.json", () => { + const messages = require("../../../../messages/en.json") + for (const key of [ + "validation.serviceAccountRequired", + "validation.serviceAccountInvalidJson", + "validation.serviceAccountNotServiceAccount", + "validation.serviceAccountBadKey", + "validation.databaseIdInvalid", + ]) { + const resolved = key + .split(".") + .reduce((node, part) => (node as Record)?.[part], + messages.DataExplorer) + expect(typeof resolved).toBe("string") + expect(resolved).not.toBe("") + } + }) + + it("identifies itself consistently and satisfies the contract", () => { + expect(firestoreAdapter.id).toBe("firestore") + expect(firestoreAdapter.label).toBe("Firestore") + expect(typeof firestoreAdapter.testConnection).toBe("function") + expect(firestoreAdapter.ConnectionForm).toBeTruthy() + expect(firestoreAdapter.SidebarTree).toBeTruthy() + expect(firestoreAdapter.Pane).toBeTruthy() + }) +}) diff --git a/apps/desktop-ui/src/components/data-explorer/adapters/firestore.tsx b/apps/desktop-ui/src/components/data-explorer/adapters/firestore.tsx new file mode 100644 index 00000000..2495fed6 --- /dev/null +++ b/apps/desktop-ui/src/components/data-explorer/adapters/firestore.tsx @@ -0,0 +1,398 @@ +"use client"; + +import React, { useCallback, useEffect, useRef, useState, type FormEvent } from "react"; +import { useTranslations } from "next-intl"; +import { IconFlame, IconFolder, IconX } from "@tabler/icons-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; +import { CONNECTION_COLORS } from "@/components/nosql-explorer/connection-form"; +import { + databasePath, + firestoreFetch, + listCollectionIds, + parseServiceAccount, + sanitizeFirestoreError, + type FirestoreConfig, +} from "@/lib/data-explorer/firestore-api"; +import type { ConnectionFormProps, PaneProps, SidebarTreeProps, SourceAdapter } from "../types"; + +export type { FirestoreConfig }; + +export interface FirestoreTabState { + /** Full collection path from the documents root, e.g. "users/u1/orders". */ + collectionPath: string; + filterField: string; + filterOp: string; + filterValue: string; + orderByField: string; + orderByDir: "asc" | "desc"; + pageSize: number; + /** Bumped to force a refetch. */ + refreshTick: number; +} + +export function blankTabState(collectionPath: string): FirestoreTabState { + return { + collectionPath, + filterField: "", + filterOp: "==", + filterValue: "", + orderByField: "", + orderByDir: "asc", + pageSize: 25, + refreshTick: 0, + }; +} + +function blankConfig(): FirestoreConfig { + return { serviceAccountJson: "", databaseId: "(default)" }; +} + +const DATABASE_ID_RE = /^[a-z0-9][a-z0-9-]{2,61}[a-z0-9]$/; + +function validate(config: FirestoreConfig): string | null { + if (!config.serviceAccountJson.trim()) return "validation.serviceAccountRequired"; + const parsed = parseServiceAccount(config.serviceAccountJson); + if ("errorKey" in parsed) return parsed.errorKey; + const db = config.databaseId.trim(); + if (db && db !== "(default)" && !DATABASE_ID_RE.test(db)) + return "validation.databaseIdInvalid"; + return null; +} + +/** + * Cheap authed metadata call: surfaces bad keys, SERVICE_DISABLED, + * PERMISSION_DENIED and a wrong database id before anything is saved. + * Also rejects Datastore-mode databases — the Firestore document API + * cannot browse those. + */ +async function testConnection(config: FirestoreConfig): Promise { + try { + const db = await firestoreFetch(config, "GET", databasePath(config)); + if (db?.type === "DATASTORE_MODE") { + // Reuse the runtime error copy; the dialog renders thrown + // messages as-is. Untranslatable server strings elsewhere follow + // the same rule. + throw new Error("This database is in Datastore mode, which Firestore tools cannot browse."); + } + } catch (err) { + throw new Error(sanitizeFirestoreError(err instanceof Error ? err.message : String(err))); + } +} + +/* ------------------------------------------------------------------ form */ + +function FirestoreConnectionForm({ + initial, + saving, + error, + onTest, + testState, + onSubmit, + onCancel, +}: ConnectionFormProps) { + const t = useTranslations("DataExplorer.connectionDialog"); + const tf = useTranslations("DataExplorer.firestore"); + const [name, setName] = useState(initial.name); + const [folder, setFolder] = useState(initial.folder ?? ""); + const [color, setColor] = useState(initial.color ?? null); + const [readOnly, setReadOnly] = useState(initial.readOnly ?? false); + const [serviceAccountJson, setServiceAccountJson] = useState(initial.config.serviceAccountJson); + const [databaseId, setDatabaseId] = useState( + initial.config.databaseId === "(default)" ? "" : initial.config.databaseId, + ); + const fileInputRef = useRef(null); + + function currentConfig(): FirestoreConfig { + return { + serviceAccountJson, + databaseId: databaseId.trim() || "(default)", + }; + } + + // Non-sensitive echo so the user can confirm which project they pasted + // before saving. The private key is never rendered outside the textarea. + const parsed = parseServiceAccount(serviceAccountJson); + const parsedOk = !("errorKey" in parsed) ? parsed : null; + + function handleFile(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => setServiceAccountJson(String(reader.result ?? "")); + reader.readAsText(file); + e.target.value = ""; + } + + function handleSubmit(e: FormEvent) { + e.preventDefault(); + onSubmit({ name, folder, color, readOnly, config: currentConfig() }); + } + + return ( +
+
+ + setName(e.target.value)} + placeholder={t("namePlaceholder")} + disabled={saving} + /> +
+ +
+ + setFolder(e.target.value)} + placeholder={t("folderPlaceholder")} + disabled={saving} + /> +
+ +
+
+ + + +
+