From 4fd2a950371b8c7ef48cf0f8cf833c37bc348991 Mon Sep 17 00:00:00 2001 From: Tomasz Stanisz Date: Tue, 13 Oct 2026 07:32:14 +0200 Subject: [PATCH 1/3] Publish bilingual API key boundary article --- .../en.mdx | 294 ++++++++++++++++++ .../pl.mdx | 294 ++++++++++++++++++ src/content/blog/catalog.ts | 22 ++ 3 files changed, 610 insertions(+) create mode 100644 src/content/blog/articles/your-api-key-is-private-until-you-ship-it/en.mdx create mode 100644 src/content/blog/articles/your-api-key-is-private-until-you-ship-it/pl.mdx diff --git a/src/content/blog/articles/your-api-key-is-private-until-you-ship-it/en.mdx b/src/content/blog/articles/your-api-key-is-private-until-you-ship-it/en.mdx new file mode 100644 index 0000000..b57e7c2 --- /dev/null +++ b/src/content/blog/articles/your-api-key-is-private-until-you-ship-it/en.mdx @@ -0,0 +1,294 @@ +**The VIN lookup worked. That was the easy part.** + +In Car Brain, entering a VIN can fill in vehicle details instead of making the user type them. The first implementation called auto.dev directly from the mobile app. An agent wrote that integration; I was responsible for reviewing it. + +The problem was where it read the provider credential: `EXPO_PUBLIC_AUTO_DEV_API_KEY`. + +In Expo, referenced `EXPO_PUBLIC_` variables are embedded in the client’s JavaScript bundle. Keeping the value out of Git does not keep it out of the application. A successful request proves the integration works. It says nothing about whether the credential belongs there. + +`EXPO_PUBLIC_` is remarkably honest branding. + +## A configuration choice became an architecture decision + +Environment variables answer “where does this value come from?” They do not automatically answer “who can read it?” + +A public API base URL belongs in client configuration. A provider secret that authorizes requests on my account does not. Both can look like ordinary strings during code review; their consequences are very different. + +[Expo’s documentation](https://docs.expo.dev/guides/environment-variables/) explicitly warns against putting sensitive secrets in public-prefixed variables. Renaming a variable is not enough either: if the app still needs the secret to call the provider, the architectural problem remains. + +I moved the VIN call into an Appwrite Function the same day. The client sends the VIN; the function reads the provider key from its server-side environment and calls auto.dev. + +
+ + VIN lookup: credential boundary + + Before: mobile calls auto.dev with a provider key. After: mobile sends VIN + to Appwrite; the function checks the caller and input, and calls auto.dev + with its server-side key. + + + BEFORE · DIRECT CALL + + + + Mobile client + + + Provider key in client code + + + + VIN + provider credential + + + + auto.dev + + + + AFTER · FUNCTION BOUNDARY + + + + Mobile client + + + VIN + signed-in execution + + + + + Appwrite Function + + + Caller check · validation + + + Provider key stays server-side + + + + Provider call + credential + + + + auto.dev + + +
Conceptual request paths; not a live test.
+
+ +The important change is the boundary around the credential. Appwrite was already part of Car Brain, so a function gave this operation a server-side home without introducing a separate API service. + +## A function needs a job beyond hiding a key + +The mobile service now calls Appwrite’s function-execution API and interprets the result. The function handles provider access. + +In the source reviewed for this article, the decode path checks for Appwrite’s caller identity before proceeding. It also validates VIN format, uses a timeout for the provider request and distinguishes errors such as invalid input, missing vehicle data and provider failure. + +Those responsibilities belong together: who may request the operation, what input it accepts, and how the external call fails. The client still owns loading states and useful feedback. Moving execution does not move the entire user experience. + +There is a second distinction worth keeping explicit: **hiding a credential and controlling its use are separate concerns.** A server-side key can still pay for unwanted requests if its endpoint is too permissive. Authentication is one layer; quotas, rate limits and spending controls need their own decisions. I am not claiming that moving the call proves all of those controls. + +The function is a boundary, not a tiny security department. + +## What the extra hop buys—and costs + +| Decision | Gain | Cost | +| ------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------- | +| Provider call inside a function | The mobile VIN path no longer needs the provider credential | Another execution hop and a function to deploy | +| Validate and handle provider failures server-side | One place to govern this integration | Error responses become a contract the client must interpret | +| Use the existing Appwrite platform | No separate API service for this operation | Dependence on its execution model and operational limits | + +Cold starts and provider delays are possible costs, not measured regressions here. I have no benchmark showing this is faster or cheaper than another backend. + +I would revisit the arrangement if integrations grew enough shared policy or orchestration to justify a dedicated service. For one bounded lookup, introducing that service would add another operational responsibility. + +Gemini-powered vehicle insights use a separate Appwrite Function too. That is another application of the boundary, not a reason to turn this article into an AI architecture tour. Caching and inference costs deserve their own story. + +## Remove the shortcut, keep the lesson + +The original client-side decoder survived as unused code after the active call moved. It was removed on 12 October. The current mobile VIN service uses the function path. + +That distinction matters. “Not called today” is weaker than removing an obsolete route that a future edit might reuse. An architecture decision record can preserve the reasoning without preserving an executable shortcut. + +My review questions for the next integration are: + +- Does a credential authorize public identification, or privileged use of a provider account? +- Which artifact receives it: the client bundle, the server environment, or both? +- Who is allowed to invoke the server-side operation? +- What happens when the provider times out, rejects a request or exhausts a quota? + +These are review questions, not a claim that every answer is enforced by an automated check. + +The lesson is broader than generated code. I still own the boundary, whether the first implementation came from an agent, a tutorial or my own late-night optimism. + +**Development snapshot — 12 October 2026:** this article reflects repository code and decision records, not a fresh live Appwrite test. It makes no claim of public exploitation, financial loss or a compromised store release. + +For the wider product context, see [why I built Car Brain](https://devthomas.pl/blog/en/why-i-built-car-brain/). diff --git a/src/content/blog/articles/your-api-key-is-private-until-you-ship-it/pl.mdx b/src/content/blog/articles/your-api-key-is-private-until-you-ship-it/pl.mdx new file mode 100644 index 0000000..28d5494 --- /dev/null +++ b/src/content/blog/articles/your-api-key-is-private-until-you-ship-it/pl.mdx @@ -0,0 +1,294 @@ +**Odczyt VIN działał. To była łatwiejsza część.** + +W Car Brain numer VIN pozwala uzupełnić dane pojazdu bez ręcznego wpisywania każdego pola. Pierwsza implementacja odpytywała auto.dev bezpośrednio z aplikacji mobilnej. Integrację napisał agent; za jej przegląd odpowiadałem ja. + +Problem krył się w miejscu, z którego pobierała klucz dostawcy: `EXPO_PUBLIC_AUTO_DEV_API_KEY`. + +W Expo używane w kodzie zmienne `EXPO_PUBLIC_` trafiają do paczki JavaScript aplikacji. Usunięcie wartości z Gita nie usuwa jej z aplikacji. Poprawna odpowiedź API potwierdza, że integracja działa. Nie potwierdza, że klucz znajduje się po właściwej stronie. + +Trzeba przyznać: nazwa `EXPO_PUBLIC_` niczego nie ukrywa. + +## Z konfiguracji zrobiła się decyzja architektoniczna + +Zmienne środowiskowe odpowiadają na pytanie „skąd pochodzi ta wartość?”. Nie rozstrzygają automatycznie, kto będzie mógł ją odczytać. + +Publiczny adres API może być częścią konfiguracji klienta. Sekretny klucz, który pozwala wykonywać żądania na moim koncie u dostawcy, już nie. Podczas przeglądu oba wyglądają jak zwykłe ciągi znaków. Konsekwencje są zupełnie inne. + +[Dokumentacja Expo](https://docs.expo.dev/guides/environment-variables/) wprost ostrzega przed umieszczaniem sekretów w zmiennych z publicznym prefiksem. Sama zmiana nazwy też nie wystarczy: jeśli aplikacja nadal potrzebuje klucza do wywołania dostawcy, problem architektoniczny zostaje. + +Tego samego dnia przeniosłem wywołanie do funkcji Appwrite. Klient wysyła VIN; funkcja pobiera klucz ze swojego środowiska po stronie serwera i odpytuje auto.dev. + +
+ + VIN: granica dostępu do klucza + + Wcześniej aplikacja wywołuje auto.dev z kluczem. Teraz wysyła VIN do + Appwrite; funkcja sprawdza tożsamość i dane, a potem wywołuje auto.dev z + kluczem serwera. + + + WCZEŚNIEJ · BEZPOŚREDNIO + + + + Aplikacja mobilna + + + Klucz dostawcy w kodzie klienta + + + + VIN + klucz dostawcy + + + + auto.dev + + + + TERAZ · GRANICA FUNKCJI + + + + Aplikacja mobilna + + + VIN + sesja użytkownika + + + + + Funkcja Appwrite + + + Tożsamość · walidacja + + + Klucz zostaje na serwerze + + + + Wywołanie z kluczem + + + + auto.dev + + +
Schemat wywołań, nie wynik testu usługi.
+
+ +Najważniejsza zmiana dotyczy granicy dostępu do klucza. Car Brain już korzystał z Appwrite, więc funkcja pozwoliła przenieść tę operację na serwer bez dokładania osobnego serwisu API. + +## Funkcja ma więcej do zrobienia niż schowanie klucza + +Serwis mobilny wywołuje teraz API wykonywania funkcji Appwrite i interpretuje wynik. Dostępem do zewnętrznego dostawcy zajmuje się funkcja. + +W kodzie sprawdzonym do tego artykułu ścieżka dekodowania najpierw sprawdza obecność tożsamości wywołującego przekazanej przez Appwrite. Waliduje też format VIN, ogranicza czas żądania do dostawcy i rozróżnia błędy, między innymi niepoprawne dane, brak danych pojazdu oraz błąd dostawcy. + +Te obowiązki są ze sobą związane: kto może zlecić operację, jakie dane przyjmujemy i co robimy, kiedy zewnętrzne API zawiedzie. Klient nadal odpowiada za stan ładowania i czytelny komunikat. Przeniesienie wykonania nie przenosi całego doświadczenia użytkownika. + +Ważne jest jeszcze jedno rozróżnienie: **ukrycie klucza i kontrolowanie jego użycia to dwa osobne problemy.** Klucz na serwerze nadal może finansować niechciane żądania, jeśli dostęp do operacji jest zbyt szeroki. Uwierzytelnianie to jedna warstwa; limity użycia, częstotliwości i wydatków wymagają osobnych decyzji. Samo przeniesienie wywołania nie dowodzi, że wszystkie te mechanizmy działają. + +Funkcja wyznacza granicę. Nie jest miniaturowym działem bezpieczeństwa. + +## Co daje dodatkowy krok i ile kosztuje + +| Decyzja | Zysk | Koszt | +| -------------------------------------- | -------------------------------------------------- | ---------------------------------------------------- | +| Wywołanie dostawcy w funkcji | Mobilna ścieżka VIN nie potrzebuje klucza dostawcy | Dodatkowy krok wykonania i wdrożenie funkcji | +| Walidacja i obsługa błędów na serwerze | Jedno miejsce kontroli tej integracji | Odpowiedzi błędów stają się kontraktem dla klienta | +| Wykorzystanie istniejącego Appwrite | Bez osobnego serwisu API dla tej operacji | Zależność od modelu wykonania i ograniczeń platformy | + +Zimny start funkcji i opóźnienia dostawcy to możliwe koszty, a nie zmierzone tutaj pogorszenie wydajności. Nie mam benchmarku, który wykazywałby przewagę szybkości lub ceny nad innym backendem. + +Wróciłbym do tej decyzji, gdyby integracje wymagały na tyle dużo wspólnej logiki lub koordynacji, że uzasadniałoby to osobny serwis. Dla pojedynczego, ograniczonego zadania oznaczałby on dziś dodatkowy obowiązek operacyjny. + +Analiza danych pojazdu z Gemini również korzysta z osobnej funkcji Appwrite. To kolejne zastosowanie tej granicy, a nie powód, żeby zmienić tekst w przegląd całej architektury AI. Cache i koszty generowania odpowiedzi zasługują na własny artykuł. + +## Usunąć skrót, zachować lekcję + +Pierwotny dekoder pozostał w kliencie jako nieużywany kod, mimo że aktywne wywołanie przeniesiono wcześniej. Został usunięty 12 października. Obecny serwis VIN w aplikacji mobilnej korzysta ze ścieżki przez funkcję. + +To istotna różnica. „Dziś nikt tego nie wywołuje” daje mniej pewności niż usunięcie starej drogi, którą kolejna zmiana mogłaby przywrócić. Uzasadnienie decyzji można zachować w ADR bez zachowywania wykonywalnego skrótu. + +Przy kolejnej integracji chcę sprawdzić: + +- Czy klucz służy publicznej identyfikacji, czy daje uprzywilejowany dostęp do konta u dostawcy? +- Dokąd trafia: do paczki klienta, środowiska serwera czy obu? +- Kto może uruchomić operację po stronie serwera? +- Co się dzieje, gdy dostawca nie odpowie, odrzuci żądanie lub wyczerpie limit? + +To pytania do przeglądu, nie deklaracja, że każdą odpowiedź wymusza automatyczny test. + +Wniosek wykracza poza kod generowany przez AI. Za tę granicę nadal odpowiadam ja — niezależnie od tego, czy pierwszą wersję napisał agent, znalazłem ją w tutorialu, czy podpowiedział mi ją mój własny optymizm po północy. + +**Stan na 12 października 2026:** artykuł opisuje kod i zapisy decyzji w repozytorium, nie nowy test działającego Appwrite. Nie stwierdza publicznego wykorzystania klucza, strat finansowych ani wydania aplikacji z przejętymi zabezpieczeniami. + +Kontekst produktu znajdziesz w tekście [dlaczego zbudowałem Car Brain](https://devthomas.pl/blog/pl/why-i-built-car-brain/). diff --git a/src/content/blog/catalog.ts b/src/content/blog/catalog.ts index ebeb07b..4cfad2a 100644 --- a/src/content/blog/catalog.ts +++ b/src/content/blog/catalog.ts @@ -2,6 +2,28 @@ import type { BlogPost } from "./types"; // Approved bilingual editions. Dates describe the intended release; update if deployment moves. export const blogPosts: BlogPost[] = [ + { + slug: "your-api-key-is-private-until-you-ship-it", + published: true, + editions: { + en: { + title: "Your API key is private. Until you ship it.", + description: + "A working VIN lookup put a provider credential on the wrong side of the client/server boundary. The Appwrite Function fix, its tradeoffs, and the shortcut I removed.", + topics: ["Car Brain", "Expo", "Appwrite", "Architecture"], + body: "your-api-key-is-private-until-you-ship-it/en.mdx", + }, + pl: { + title: + "Twój klucz API jest prywatny. Dopóki nie wyślesz go z aplikacją.", + description: + "Działający odczyt VIN umieścił klucz dostawcy po niewłaściwej stronie granicy klient–serwer. Funkcja Appwrite, jej koszty i usunięcie starej ścieżki.", + topics: ["Car Brain", "Expo", "Appwrite", "Architektura"], + body: "your-api-key-is-private-until-you-ship-it/pl.mdx", + }, + }, + publishedAt: "2026-10-13", + }, { slug: "why-i-built-car-brain", published: true, From 7d5cb39c26cba3f007c0da00e166962de061e50a Mon Sep 17 00:00:00 2001 From: Tomasz Stanisz Date: Tue, 13 Oct 2026 07:37:34 +0200 Subject: [PATCH 2/3] Keep SVG labels as JSX text in MDX --- .../en.mdx | 34 +++++++++---------- .../pl.mdx | 34 +++++++++---------- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/src/content/blog/articles/your-api-key-is-private-until-you-ship-it/en.mdx b/src/content/blog/articles/your-api-key-is-private-until-you-ship-it/en.mdx index b57e7c2..f526b37 100644 --- a/src/content/blog/articles/your-api-key-is-private-until-you-ship-it/en.mdx +++ b/src/content/blog/articles/your-api-key-is-private-until-you-ship-it/en.mdx @@ -34,11 +34,11 @@ I moved the VIN call into an Appwrite Function the same day. The client sends th role="img" aria-labelledby="boundary-en-title boundary-en-desc" > - VIN lookup: credential boundary + {"VIN lookup: credential boundary"} - Before: mobile calls auto.dev with a provider key. After: mobile sends VIN - to Appwrite; the function checks the caller and input, and calls auto.dev - with its server-side key. + { + "Before: mobile calls auto.dev with a provider key. After: mobile sends VIN to Appwrite; the function checks the caller and input, and calls auto.dev with its server-side key." + } - BEFORE · DIRECT CALL + {"BEFORE · DIRECT CALL"} - Mobile client + {"Mobile client"} - Provider key in client code + {"Provider key in client code"} - VIN + provider credential + {"VIN + provider credential"} - auto.dev + {"auto.dev"} - AFTER · FUNCTION BOUNDARY + {"AFTER · FUNCTION BOUNDARY"} - Mobile client + {"Mobile client"} - VIN + signed-in execution + {"VIN + signed-in execution"} - Appwrite Function + {"Appwrite Function"} - Caller check · validation + {"Caller check · validation"} - Provider key stays server-side + {"Provider key stays server-side"} - Provider call + credential + {"Provider call + credential"} - auto.dev + {"auto.dev"}
Conceptual request paths; not a live test.
diff --git a/src/content/blog/articles/your-api-key-is-private-until-you-ship-it/pl.mdx b/src/content/blog/articles/your-api-key-is-private-until-you-ship-it/pl.mdx index 28d5494..6cfbc92 100644 --- a/src/content/blog/articles/your-api-key-is-private-until-you-ship-it/pl.mdx +++ b/src/content/blog/articles/your-api-key-is-private-until-you-ship-it/pl.mdx @@ -34,11 +34,11 @@ Tego samego dnia przeniosłem wywołanie do funkcji Appwrite. Klient wysyła VIN role="img" aria-labelledby="boundary-pl-title boundary-pl-desc" > - VIN: granica dostępu do klucza + {"VIN: granica dostępu do klucza"} - Wcześniej aplikacja wywołuje auto.dev z kluczem. Teraz wysyła VIN do - Appwrite; funkcja sprawdza tożsamość i dane, a potem wywołuje auto.dev z - kluczem serwera. + { + "Wcześniej aplikacja wywołuje auto.dev z kluczem. Teraz wysyła VIN do Appwrite; funkcja sprawdza tożsamość i dane, a potem wywołuje auto.dev z kluczem serwera." + } - WCZEŚNIEJ · BEZPOŚREDNIO + {"WCZEŚNIEJ · BEZPOŚREDNIO"} - Aplikacja mobilna + {"Aplikacja mobilna"} - Klucz dostawcy w kodzie klienta + {"Klucz dostawcy w kodzie klienta"} - VIN + klucz dostawcy + {"VIN + klucz dostawcy"} - auto.dev + {"auto.dev"} - TERAZ · GRANICA FUNKCJI + {"TERAZ · GRANICA FUNKCJI"} - Aplikacja mobilna + {"Aplikacja mobilna"} - VIN + sesja użytkownika + {"VIN + sesja użytkownika"} - Funkcja Appwrite + {"Funkcja Appwrite"} - Tożsamość · walidacja + {"Tożsamość · walidacja"} - Klucz zostaje na serwerze + {"Klucz zostaje na serwerze"} - Wywołanie z kluczem + {"Wywołanie z kluczem"} - auto.dev + {"auto.dev"}
Schemat wywołań, nie wynik testu usługi.
From 4e869363085da87b3154d597110c8b3e655d63cb Mon Sep 17 00:00:00 2001 From: Tomasz Stanisz Date: Tue, 13 Oct 2026 07:40:10 +0200 Subject: [PATCH 3/3] Map translated section links for the API key article --- src/i18n/routes.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/i18n/routes.ts b/src/i18n/routes.ts index bfa6778..af7eeec 100644 --- a/src/i18n/routes.ts +++ b/src/i18n/routes.ts @@ -49,6 +49,15 @@ const common = { "What I'd do differently": "Co zrobiłbym inaczej", }; export const headingPairs: Record> = { + "article/your-api-key-is-private-until-you-ship-it": { + "A configuration choice became an architecture decision": + "Z konfiguracji zrobiła się decyzja architektoniczna", + "A function needs a job beyond hiding a key": + "Funkcja ma więcej do zrobienia niż schowanie klucza", + "What the extra hop buys—and costs": + "Co daje dodatkowy krok i ile kosztuje", + "Remove the shortcut, keep the lesson": "Usunąć skrót, zachować lekcję", + }, "work/orange-cms": { ...common, "How it works": "Jak to działa" }, "work/orange-e2e-testing": { ...common,