İçeriğe atla

Tek Açık Yüzey: Server Components, Passkey ve WebView Kabuğu

Lambda Web Functions üzerindeki RSC tek açık yüzey: veri tek bir IAM rolünün arkasında, passkey töreni sunucuda, React Native WebView kabuğu aynı sayfaları açıyor.

Ayhan Sipahi Ayhan Sipahi

AWS üzerinde bir web uygulaması ile bir mobil uygulama çoğu zaman iki açık yüzeyle gelir. Tarayıcı bir BFF’yi session cookie’siyle, telefon ise bir API’yi bearer token ile çağırır. Her yetkilendirme kuralı, her rate limit ve her şema değişikliği böylece iki kez yazılır; en az gözden geçirilen yüzey de ikincisi olur. İkinci yüzeyi kaldırmak ancak mobil uygulamanın oturum açma dahil her şey için birinci yüzeyi kullanabilmesiyle mümkündür. Bir WebView ise iki platformda da WebAuthn’ı varsayılan olarak almaz. Bu denemede Lambda Web Function üzerindeki React Server Components sayfa sunucusu herkese açık adresi olan tek bileşendir. Arkasındaki veri yalnızca tek bir execution role’ün sorgulayabildiği bir tabloda durur. Cognito passkey akışı sunucu tarafında yürür ve bir React Native WebView kabuğu bunların tamamını iOS ve Android’de yeniden kullanır. Aşağıdaki bölümler bu sınırı veri erişimi, oturum açma, session ve kabuk üzerinden izliyor. Ardından sınırın kaydığı yerleri sıralıyor: platform doğrulaması, TLS’i sonlandıran bir proxy arkasındaki CSRF origin kontrolü ve istemciye göre değişen arayüz çerçevesi.

Yığın şu: CloudFront arkasında Lambda Web Functions üzerinde RSC Framework Mode ile React Router 8.5.1 ve passkey’i birinci faktör olarak kabul eden bir Cognito user pool’u. RSC desteği deneysel, bu yüzden sürümleri tam olarak sabitleyin. Kabuk ise react-native-webview 14.0.1 kullanan bir Expo SDK 58 / React Native 0.88 uygulaması; WebView paketi aşağıda patch-package ile yamalandığı için o da tam sürüme sabitlenmeli. Barındırma tarafı (giriş shim’i, NODE_ENV, streaming, CloudFront hattı ve gecikme sayıları) önceki yazıda anlatılıyor; Web Functions kaynak modeli ise Hono lambdalith yazısında. Kod, deploy script’leri ve WebView patch’i ayhansipahi/rsc-mfe-demo (yeni sekmede açılır) reposunda; aşağıdaki her parça oradan alıntı.

Sektörün telefon için üç yerleşik cevabı var. SDK ve bearer token ile çağrılan bir API (REST ya da GraphQL, cihazda Amplify) çevrimdışı yazmada, ekranların çoğunda native arayüzde ve API’yi zaten tüketen iş ortaklarında kazanır. Hotwire Native ve Capacitor tarzı kabuklar buradaki native çerçeve ve web içerik şeklini paylaşır; ancak genellikle herkese açık bir API’si de olan bir BFF’nin ya da monolitin önünde durur. İstemci tarafı micro-frontend’ler (module federation, single-spa) tarayıcıda birleşir; dolayısıyla her parça bir API’ye ihtiyaç duyar ve sınır sorusu çoğalır.

Tek Diyagramda Sınır#

Özel bölge: ürün uç noktası yok

Açık bölge: tek adres

session cookie ile istekler

session cookie ile istekler

/, /login, /auth/*

/orders*

dynamodb:Query, sub ile

AdminInitiateAuth ve AdminRespondToAuthChallenge

navigator.credentials.get()

navigator.credentials.get()

Tarayıcı

RN kabuğu, sekme başına bir WebView

CloudFront

Account web function: ana sayfa, giriş, passkey

Orders web function: /orders

Orders tablosu (DynamoDB)

Cognito user pool

Platform authenticator

Tarayıcı ve kabuk her isteği session cookie’siyle CloudFront’a gönderir; CloudFront yola göre yönlendirir: /orders* orders fonksiyonuna, geri kalanı account fonksiyonuna. Orders fonksiyonu orders tablosunu dynamodb:Query ile, zaten doğruladığı kimliği anahtar yaparak sorgular. Oturum açmada account fonksiyonu Cognito’yu, IAM kimlik bilgilerini gerektiren AdminInitiateAuth ve AdminRespondToAuthChallenge ile çağırır. Sayfa ise platform authenticator’a navigator.credentials.get() ile ulaşır ve kendi origin’inden hiç çıkmaz. Özel bölgedeki servislerin kendi AWS uç noktaları var. Bölgeyi özel yapan, ürünün bunların hiçbirini dışarı açmaması ve her birine tek bir execution role’ün ulaşabilmesi. Diyagramın asıl değeri olmayan oklarda: istemciden Cognito’ya giden bir ok yok, istemciden veriye giden bir ok yok, bir fonksiyondan diğerine giden bir ok yok.

Dikey Kendi Verisinin Sahibi#

Orders dikeyi kendi verisinin sahibi; çağrılacak bir orders servisi yok. infra/orders-table.sh, partition key’i Cognito sub değeri ve sort key’i orderId olan tek bir DynamoDB tablosu oluşturuyor. Demo kullanıcısı için de üç satır ekliyor. Web fonksiyonu tabloyu doğrudan okuyor; sayfa ile satırlar arasındaki tek şey fonksiyonun execution role’ü.

// web/app/orders.server.ts
export async function listOrders(user: { sub: string }): Promise<Order[]> {
  doc ??= DynamoDBDocumentClient.from(new DynamoDBClient({}));
  const out = await doc.send(
    new QueryCommand({
      TableName: process.env.ORDERS_TABLE!,
      KeyConditionExpression: "#owner = :sub",
      ExpressionAttributeNames: { "#owner": "owner" },
      ExpressionAttributeValues: { ":sub": user.sub },
    }),
  );
  return (out.Items ?? []) as Order[];
}

Rol tek bir inline policy tutuyor: o tek tablo ARN’i üzerinde dynamodb:Query. infra/web-role.sh bu policy’yi ORDERS_TABLE=1 olduğunda ekliyor, aksi halde kaldırıyor.

# infra/web-role.sh
if [ "${ORDERS_TABLE:-0}" = "1" ]; then
  aws iam put-role-policy --role-name "$NAME-role" --policy-name query-orders --policy-document "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":\"dynamodb:Query\",\"Resource\":\"arn:aws:dynamodb:$REGION:$ACCOUNT:table/rsc-mfe-orders\"}]}"
else
  aws iam delete-role-policy --role-name "$NAME-role" --policy-name query-orders 2>/dev/null || true
fi

Kazanç: sayfa sunucusunun arkasındaki hiçbir şeye internetten ulaşılamıyor; dolayısıyla ikinci bir istemci için taranacak, rate limit uygulanacak ya da sürümlenecek bir endpoint yok. Bedel: web fonksiyonu artık yetkilendirme noktası. Burada sub üzerindeki key condition nesne düzeyi kontrolün kendisi, çünkü sorgu yalnızca çağıranın kendi partition key’i altındaki satırları döndürüyor. Daha ince bir kural (paylaşılan bir sipariş, bir destek rolü) aynı loader’ın içine yazılırdı. Nesne düzeyi yetkilendirme yazısı bu kontrollerin neye benzediğini anlatıyor.

Bu demonun ilk sürümü private bir servis için en bilindik yolu tuttu: URL’si olmayan bir Lambda, web fonksiyonundan lambda:InvokeFunction ile çağrılıyordu. Lambda Developer Guide, “Anti-patterns in Lambda-based event-driven applications” başlığı altında şunu söylüyor: “Lambda functions directly calling other Lambda functions is generally an anti-pattern due to cost and complexity concerns” (bir Lambda fonksiyonunun başka bir Lambda fonksiyonunu doğrudan çağırması, maliyet ve karmaşıklık nedeniyle genellikle bir anti-pattern). Sayfa durable function’ları bunun dışında tutuyor. Sayfanın sıraladığı gerekçeler ilk sürümle birebir örtüşüyor: çağıran beklerken ödüyor, hata yönetimi iç içe geçiyor, en yavaş fonksiyon erişilebilirliği sınırlıyor ve ikisinin de eşit concurrency’ye ihtiyacı oluyor. Yerine geçen kural şu: bir dikey kendi verisini IAM üzerinden okuyup yazar ve dikeyler arası iş event’lerle (EventBridge, SQS) akar. Kaçınılmaz bir senkron servisin kapısına ise IAM’li private bir arayüz konur; örneğin private bir API Gateway ya da bir VPC Lattice servisi. AWS Prescriptive Guidance senkron çağrıların “in many cases, are the widely accepted standard for communication” (çoğu durumda iletişim için yaygın kabul gören standart) olduğunu söylüyor. Öngörülebilir akış kontrolü ile güçlü tutarlılığı da faydaları arasında sayıyor; dolayısıyla bu yol açıkça tanımlanmış bir istisna.

Tarayıcıyı Cognito’dan Uzak Tutan Passkey Akışı#

Oturum açma bir WebAuthn akışı ve bu akışın Cognito’ya dokunan iki yarısı da web fonksiyonunun içinde çalışıyor. Sayfa yalnızca navigator.credentials ile ve kendi origin’indeki /auth/passkey ile konuşuyor; Cognito SDK’sını import etmiyor, client secret’ı tutmuyor ve access token’ı görmüyor.

Platform authenticatorCognitoWeb fonksiyonuSayfaPlatform authenticatorCognitoWeb fonksiyonuSayfaPOST /auth/passkey, intent signin-startAdminInitiateAuth, USER_AUTH, PREFERRED_CHALLENGE WEB_AUTHNChallengeName WEB_AUTHN, Session, CREDENTIAL_REQUEST_OPTIONSoptions ve Session, Cache-Control private no-storenavigator.credentials.get()assertionPOST /auth/passkey, intent signin-finish, Session, credentialAdminRespondToAuthChallenge, WEB_AUTHN, CREDENTIALAccessToken204, Set-Cookie session

Cognito’nun USER_AUTH akışı seçime dayalı: çağıran tercih ettiği challenge’ı söylüyor, Cognito ya onunla ya da sunabildiği alternatiflerle cevap veriyor. Cognito SDK’sını import eden tek modül cognito.server.ts. Oturum açmada AdminInitiateAuth çağrısını tercih edilen challenge WEB_AUTHN ile yapıyor ve challenge’ı daha sonra AdminRespondToAuthChallenge ile, sayfanın ürettiği assertion’ı ekleyerek tamamlıyor. Her çağrı bir SECRET_HASH taşıyor: kullanıcı adı artı client ID’nin client secret ile alınmış HMAC-SHA256 özeti.

// web/app/cognito.server.ts
const UserPoolId = () => process.env.COGNITO_USER_POOL_ID!;
const ClientId = () => process.env.COGNITO_CLIENT_ID!;
// Cognito'ya çağıranın client secret'ı elinde tuttuğunun kanıtı: HMAC-SHA256(username + clientId, secret).
const secretHash = (username: string) =>
  createHmac("sha256", process.env.COGNITO_CLIENT_SECRET!).update(username + ClientId()).digest("base64");

export async function passkeySignInStart(username: string) {
  const r = await client().send(
    new AdminInitiateAuthCommand({
      UserPoolId: UserPoolId(),
      ClientId: ClientId(),
      AuthFlow: "USER_AUTH",
      AuthParameters: { USERNAME: username, PREFERRED_CHALLENGE: "WEB_AUTHN", SECRET_HASH: secretHash(username) },
    }),
  );
  if (r.ChallengeName !== "WEB_AUTHN" || !r.Session) throw new Error(`unexpected challenge ${r.ChallengeName}`);
  return { session: r.Session, options: JSON.parse(r.ChallengeParameters!.CREDENTIAL_REQUEST_OPTIONS!) };
}

export async function passkeySignInFinish(username: string, session: string, credential: unknown) {
  const r = await client().send(
    new AdminRespondToAuthChallengeCommand({
      UserPoolId: UserPoolId(),
      ClientId: ClientId(),
      ChallengeName: "WEB_AUTHN",
      Session: session,
      ChallengeResponses: { USERNAME: username, CREDENTIAL: JSON.stringify(credential), SECRET_HASH: secretHash(username) },
    }),
  );
  return tokenOf(r);
}

Public çift olan InitiateAuth ve RespondToAuthChallenge kimlik doğrulaması gerektirmeyen işlemler: client ID’yi öğrenen herkes internetten bir oturum açma başlatabilir ve client ID bir sır değil. Admin çifti ise IAM kimlik bilgisi istiyor. Dolayısıyla oturum açmanın giriş noktası account fonksiyonunun rolü, yani orders tablosuyla aynı şekil. infra/web-role.sh, COGNITO_ADMIN=1 olduğunda pool ARN’i üzerinde cognito-idp:AdminInitiateAuth ve cognito-idp:AdminRespondToAuthChallenge izinlerini ekliyor. İlk sürüm public çifti public bir client ile kullanıyor ve client ID’yi sunucuda tutuyordu. Bu, giriş noktasını gizlemekten fazlasını yapmıyordu; pool’un oturum açma API’si o ID’ye sahip herkese açık kalıyordu. Değişiklikten sonra CloudFront üzerinden doğrulananlar: parola ile giriş, passkey kaydı, çıkış ve passkey ile giriş.

Kayıt için oturum açmış bir kullanıcı gerekiyor; bu yüzden kayıt cookie’deki access token üzerinden yürüyor. StartWebAuthnRegistration bir CredentialCreationOptions döndürüyor, sayfa @simplewebauthn/browser paketindeki startRegistration({ optionsJSON }) fonksiyonunu çalıştırıyor ve CompleteWebAuthnRegistration sonucu kaydediyor.

// web/app/cognito.server.ts
export async function passkeyRegisterStart(accessToken: string) {
  const r = await client().send(new StartWebAuthnRegistrationCommand({ AccessToken: accessToken }));
  return r.CredentialCreationOptions;
}

export async function passkeyRegisterFinish(accessToken: string, credential: unknown) {
  await client().send(new CompleteWebAuthnRegistrationCommand({ AccessToken: accessToken, Credential: credential as never }));
}

Dört intent’i tek bir resource route taşıyor. Her çağrıya Cache-Control: private, no-store ile cevap veriyor ve signin-finish adımında access token’ı session cookie’sine çeviriyor.

// web/app/routes/auth.passkey.ts
export async function action({ request }: { request: Request }) {
  const body = await request.json().catch(() => ({}));
  try {
    switch (body.intent) {
      case "signin-start":
        return Response.json(await passkeySignInStart(String(body.email)), { headers: noStore });
      case "signin-finish": {
        const token = await passkeySignInFinish(String(body.email), String(body.session), body.credential);
        return new Response(null, { status: 204, headers: { ...noStore, "Set-Cookie": sessionCookie(token) } });
      }
      case "register-start": {
        const user = await getUser(request);
        if (!user) return new Response("unauthorized", { status: 401 });
        return Response.json({ options: await passkeyRegisterStart(user.token) }, { headers: noStore });
      }
      case "register-finish": {
        const user = await getUser(request);
        if (!user) return new Response("unauthorized", { status: 401 });
        await passkeyRegisterFinish(user.token, body.credential);
        return new Response(null, { status: 204, headers: noStore });
      }
      default:
        return new Response("unknown intent", { status: 400 });
    }
  } catch (e) {
    // Cognito'nun mesajı demo için yeterli; bir ürün bunları kendi mesajlarına eşlerdi.
    return new Response(e instanceof Error ? e.message : "auth failed", { status: 400, headers: noStore });
  }
}

Pool tarafı infra/cognito.sh içindeki üç çağrıdan ibaret; script ayrıca bir kullanıcı ekliyor. Çağrılar pool’u PASSWORD ve WEB_AUTHN birinci faktör olarak izinli ESSENTIALS katmanında oluşturuyor, çünkü passkey ve USER_AUTH için Essentials ya da Plus planı gerekiyor. Relying party ID’yi açık domain’e ayarlıyor. ALLOW_USER_AUTH izinli, --generate-secret ile gizli (confidential) bir client oluşturuyor; script de client ID’yi ve secret’ı web fonksiyonunun ortamı için yazdırıyor. Parola ile giriş de aynı USER_AUTH akışını PREFERRED_CHALLENGE: PASSWORD ile kullanıyor; bu yüzden giriş sayfası iki düğmeyi tek formda sunuyor.

# infra/cognito.sh
POOL=$(aws cognito-idp create-user-pool --region "$REGION" --pool-name rsc-mfe-demo --user-pool-tier ESSENTIALS \
  --policies '{"PasswordPolicy":{"MinimumLength":8},"SignInPolicy":{"AllowedFirstAuthFactors":["PASSWORD","WEB_AUTHN"]}}' \
  --admin-create-user-config '{"AllowAdminCreateUserOnly":true}' \
  --query UserPool.Id --output text)
aws cognito-idp set-user-pool-mfa-config --region "$REGION" --user-pool-id "$POOL" --mfa-configuration OFF \
  --web-authn-configuration "{\"RelyingPartyId\":\"$RP\",\"UserVerification\":\"required\"}" >/dev/null
# Gizli (confidential) bir client: secret web fonksiyonunun ortamında kalır ve oturum açma IAM altındaki
# Admin* işlemlerinden geçer; dolayısıyla hiçbir tarayıcı bu client için kendi başına oturum açma başlatamaz.
read -r CLIENT SECRET < <(aws cognito-idp create-user-pool-client --region "$REGION" --user-pool-id "$POOL" --client-name web-confidential \
  --explicit-auth-flows ALLOW_USER_AUTH ALLOW_REFRESH_TOKEN_AUTH --generate-secret \
  --query 'UserPoolClient.[ClientId,ClientSecret]' --output text)

Sınır, sunucuda kalanlara dayanıyor: client secret, Admin çağrılarının gerektirdiği IAM kimlik bilgileri ve access token sayfaya hiç ulaşmıyor. Gidip gelen tek değer Cognito’nun challenge Session dizesi; signin-start onu döndürüyor, signin-finish de credential ile birlikte değiştirmeden geri gönderiyor. Bu dize opak, tek kullanımlık ve yalnızca bu bekleyen challenge’a bağlı; yeniden oynatmak, istemcinin hiç sahip olmadığı SECRET_HASH ile IAM kimlik bilgilerini gerektirirdi. İstemcinin elinde sonunda dışa aktaramadığı bir credential ile okuyamadığı bir cookie kalıyor.

Session Cookie’si ve Tavanı#

Session, HttpOnly; Secure; SameSite=Lax bir cookie’deki Cognito access token’ı; her istekte pool’un JWKS’ine karşı aws-jwt-verify ile doğrulanıyor. Session store yok, dolayısıyla web fonksiyonu stateless kalıyor; sayfa JavaScript’i de HttpOnly sayesinde token’ı okuyamıyor.

// web/app/session.server.ts
const getVerifier = () =>
  (verifier ??= CognitoJwtVerifier.create({
    userPoolId: process.env.COGNITO_USER_POOL_ID!,
    clientId: process.env.COGNITO_CLIENT_ID!,
    tokenUse: "access",
  }));

export async function getUser(request: Request): Promise<SessionUser | null> {
  const token = readCookie(request);
  if (!token) return null;
  try {
    const payload = await getVerifier().verify(token);
    return { sub: payload.sub, username: String(payload.username), token };
  } catch {
    return null;
  }
}

export const sessionCookie = (token: string) =>
  `${COOKIE}=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600`;
export const clearedCookie = `${COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`;

Demo, access token’ı session’ın tamamı olarak tutuyor. Max-Age=3600 access token’ın bir saatlik varsayılan ömrüyle örtüşüyor, Cognito’nun döndürdüğü refresh token demoda atılıyor, iptal listesi yok, çıkış da yalnızca cookie’yi siliyor. Bir production build’i iki şeyden birini eklerdi. İlki, ikinci bir HttpOnly cookie’de o refresh token ve saat dolmadan onu değiştiren bir /session/refresh action’ı. İkincisi, DynamoDB’ye dayanan opak bir session ID; bu seçenekte iptal bir silme işlemine dönüşüyor ve token’lar ağ üzerinde dolaşmıyor. İkisi de sınırı çizildiği yerde bırakıyor, çünkü ikisi de web fonksiyonunun içinde çalışıyor.

Mobil Kabuk#

Kabuk, ürünle ilgili tek bir kod dosyası olan bir Expo uygulaması. Çerçeve native katmanın: Pressable satırlarından oluşan bir sekme çubuğu, react-native-safe-area-context ile gelen güvenli alan boşlukları ve tek origin’e sabitlenmiş, sekme başına bir WebView. API istemcisi yok, token yok.

// mobile/App.tsx
// Ürün arayüzü web fonksiyonunun sayfaları. Kabuk çerçeveyi üstleniyor: native bir sekme çubuğu,
// origin'e sabitlenmiş sekme başına bir WebView, native depoyla paylaşılan cookie'ler. API istemcisi
// yok, token yok: session cookie'si WebView içinde doğuyor, native katman yalnızca URL seçiyor.
import { useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
import { WebView } from "react-native-webview";

export const ORIGIN = "https://rscmfe.sph.sh";
// User-Agent'a eklenir. Web uygulaması çerçeve kararını buna göre verir ve sayfaları web nav'ı olmadan render eder.
const SHELL_UA = "RSCShell/1";

const tabs = [
  { key: "home", label: "Home", path: "/" },
  { key: "orders", label: "Orders", path: "/orders" },
] as const;
type TabKey = (typeof tabs)[number]["key"];

export default function App() {
  const [active, setActive] = useState<TabKey>("home");
  return (
    <SafeAreaProvider>
      <SafeAreaView style={styles.root} edges={["top", "bottom"]}>
        {/* Sekme başına bir WebView: her biri kendi geçmişini ve kaydırmasını korur, native stack gibi.
          Pasif sekmeler tam boyutta yerleşik kalır (opacity, display:none değil): sıfır yükseklikte
          yerleştirilmiş bir WKWebView gösterildiğinde içeriğini en alta kaydırmış olur. */}
        <View style={styles.pages}>
          {tabs.map((tab) => (
            <View
              key={tab.key}
              style={[
                StyleSheet.absoluteFill,
                active !== tab.key && styles.hidden,
              ]}
              pointerEvents={active === tab.key ? "auto" : "none"}
            >
              <WebView
                style={styles.page}
                source={{ uri: `${ORIGIN}${tab.path}` }}
                applicationNameForUserAgent={SHELL_UA}
                sharedCookiesEnabled
                allowsBackForwardNavigationGestures
                // Origin dışı her şey üründen çıkmak demek; kabuğu tek domain'inde tut.
                // Önek değil origin karşılaştır: startsWith benzer görünümlü host'larla da eşleşir.
                onShouldStartLoadWithRequest={(req) => new URL(req.url).origin === ORIGIN}
              />
            </View>
          ))}
        </View>
        <View style={styles.bar}>
          {tabs.map((tab) => (
            <Pressable
              key={tab.key}
              onPress={() => setActive(tab.key)}
              style={styles.tab}
            >
              <Text
                style={[styles.label, active === tab.key && styles.labelActive]}
              >
                {tab.label}
              </Text>
            </Pressable>
          ))}
        </View>
      </SafeAreaView>
    </SafeAreaProvider>
  );
}

const styles = StyleSheet.create({
  root: { flex: 1, backgroundColor: "#fff" },
  pages: { flex: 1 },
  page: { flex: 1 },
  hidden: { opacity: 0 },
  bar: {
    flexDirection: "row",
    borderTopWidth: StyleSheet.hairlineWidth,
    borderTopColor: "#d4d4d4",
  },
  tab: { flex: 1, alignItems: "center", paddingVertical: 12 },
  label: { fontSize: 13, color: "#737373" },
  labelActive: { color: "#171717", fontWeight: "600" },
});

Üç karar tasarımı taşıyor. Birincisi, sekme başına bir WebView; böylece her sekme kendi geçmişini ve kaydırma konumunu koruyor. allowsBackForwardNavigationGestures her sekmeye iOS’un geri kaydırma hareketini veriyor; prop yalnızca iOS ve macOS için, dolayısıyla Android’de goBack() çağıran bir BackHandler gerekirdi. Cookie’ler iOS’ta varsayılan WKWebsiteDataStore, Android’de süreç genelindeki CookieManager üzerinden paylaşılıyor; dolayısıyla session tek bir session olarak kalıyor. İkincisi, pasif sekmelerin tam boyutta yerleşik kalması; gizleme display: "none" ile değil opacity ile yapılıyor. iOS 27 simülatöründe sıfır yükseklikte yerleştirilmiş bir WKWebView ortaya çıktığında içeriğini en alta kaydırılmış gösterdi; yerleşimi korumak bunu önledi. Üçüncüsü, native katmanın hiç token tutmaması. Session cookie’si WebView’ın içinde sayfa sunucusunun Set-Cookie başlığıyla oluşuyor ve denemede iki platformda da uygulama kapatılıp yeniden açıldığında korundu.

applicationNameForUserAgent User-Agent’ın sonuna RSCShell/1 ekliyor; sunucunun bununla ne yaptığını bir sonraki bölüm gösteriyor.

Hotwire Native, web içeriğini saran native çerçevenin sektördeki adı. Buradaki fark, sayfa sunucusunun BFF olması, arkasındaki hiçbir şeyin adresi olmaması ve oturum açmanın köprü gerektirmemesi.

Sunucudan İstemciye Göre Arayüz Çerçevesi#

Web uygulaması tarayıcılar için üst navigasyon render ediyor, kendi sekme çubuğunu çizen kabuk içinse hiçbir şey render etmiyor. Karar sunucuda, User-Agent’taki token’a bakılarak veriliyor. Root loader bunu okuyor; root bileşeni token varsa <Nav> bileşenini atlıyor.

// web/app/root.tsx
// Çerçeve istemciye göre: mobil kabuk kendini User-Agent'ta duyurur ve kendi sekme çubuğunu çizer,
// dolayısıyla sayfaları web nav'ı olmadan render edilir. Bu dağıtımın cache policy'sinde varsayılan
// TTL 0, dolayısıyla iki varyant asla aynı cache girdisini paylaşmaz; cache'leyen bir policy token'ı anahtarına katmalı.
export function loader({ request }: Route.LoaderArgs) {
  return { shell: (request.headers.get("user-agent") ?? "").includes("RSCShell/") };
}

// User-Agent navigasyonlar arasında değişmez ve NavLink aktif durumu istemcide hesaplar; bu yüzden
// bir link tıklaması root loader'a yeniden ihtiyaç duymaz. Form gönderimleri yine revalidate eder.
export function shouldRevalidate({ formMethod, defaultShouldRevalidate }: Route.ShouldRevalidateFunctionArgs) {
  return formMethod ? defaultShouldRevalidate : false;
}

export default function App({ loaderData }: Route.ComponentProps) {
  return (
    <>
      {loaderData.shell ? null : <Nav />}
      <div className="mx-auto max-w-3xl space-y-4 p-6">
        <Outlet />
      </div>
    </>
  );
}

shouldRevalidate link navigasyonlarında false döndürüyor; dolayısıyla root loader belge başına bir kez çalışıyor, form gönderimleri ise yine revalidate ediyor.

Ana sayfa artık prerender edilemiyor: statik bir index.html kabuğa web nav’ıyla birlikte ulaşırdı, bu yüzden demodaki react-router.config.ts boş. Denemede bu dağıtımın cache policy’sinde varsayılan TTL’in 0 olması iki varyantı ayrı tutuyor. Ana sayfayı cache’leyen bir dağıtımın User-Agent token’ını cache anahtarına katması gerekir.

iOS’ta Associated Domains#

Passkey akışının iOS’ta WebView içinde yürüyebilmesi için (Apple’ın Supporting passkeys sayfasına göre iOS 16 ve üzeri) uygulama ile domain’in birbirine kefil olması gerekiyor. Uygulama tarafı app.json içindeki tek bir giriş.

"ios": {
  "bundleIdentifier": "sh.sph.rscmfe",
  "appleTeamId": "3EWL9B9GPV",
  "supportsTablet": false,
  "associatedDomains": ["webcredentials:rscmfe.sph.sh"]
}

Denemedeki development build’leri domain’e ?mode=developer ekledi. Apple’ın entitlement sayfasına göre bu ek yalnızca geliştirici modundaki cihazlarda, development profiliyle imzalanmış uygulamalarda ve kullanıcı onay verdikten sonra geçerli. TestFlight build’i, yukarıdaki gibi, eki kullanmıyor. Domain tarafı ise apple-app-site-association dosyası. Web fonksiyonu bunu bir ortam değişkeninden servis ediyor; böylece relying party ID, WebView origin’i ve associated domain tek bir hostname oluyor.

// web/index.js
// Mobil kabuktaki passkey'ler için uygulama-domain ilişkisi. Buradan servis ediliyor ki RP ID,
// WebView origin'i ve associated domain tek bir hostname olsun.
const appleAppIds = (process.env.APPLE_APP_IDS ?? "").split(",").filter(Boolean);
app.get("/.well-known/apple-app-site-association", (_req, res) => {
  res.type("application/json").send({ webcredentials: { apps: appleAppIds } });
});

Değer, Team ID ile bundle identifier’ın nokta ile birleştirilmiş hali. Başka hiçbir şey gerekmedi: react-native-webview’ın varsayılanları dışında WKWebView yapılandırması yok, native modül yok.

Kabukta, orders sayfasındaki kayıt düğmesine dokunmak, uygulamanın adını ve Passwords’teki yerini gösteren “Save a passkey?” sistem panelini açtı. Ardından Face ID, ardından sayfadaki “Passkey saved. Sign out and sign in with it.” durum mesajı geldi. Çıkış yaptıktan sonra giriş sayfasındaki passkey düğmesi “Sign in to “RSC Shell” with your passkey for “demo@example.com”?” panelini açtı. Ardından Face ID, ardından orders sayfası geldi. Sistem paneli uygulamanın adını gösterirken credential domain’e bağlı; dolayısıyla kullanıcı Safari’de de uygulamada da tek bir kimlik görüyor.

Patch Arkasındaki Android WebView Ayarı#

Android WebView, WebAuthn kapalı geliyor. Uygulama bunu androidx.webkit’teki WebSettingsCompat.setWebAuthenticationSupport(settings, WEB_AUTHENTICATION_SUPPORT_FOR_APP) çağrısıyla, bir WebViewFeature.isFeatureSupported(WEB_AUTHENTICATION) kontrolünün arkasında açıyor. react-native-webview 14.0.1’de bunun için bir prop yok; bu yüzden deneme, korumalı çağrıyı patch-package ile RNCWebViewManagerImpl.kt dosyasına ekledi. Diff düzeltmenin tamamı.

--- a/node_modules/react-native-webview/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManagerImpl.kt
+++ b/node_modules/react-native-webview/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManagerImpl.kt
@@ -82,6 +82,11 @@ class RNCWebViewManagerImpl(private val newArch: Boolean = false) {
         settings.allowFileAccessFromFileURLs = false
         settings.allowUniversalAccessFromFileURLs = false
         settings.mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
+        // WebView içinde passkey: Credential Manager, bu uygulamanın assetlinks.json ile bağlı olduğu
+        // origin'ler için navigator.credentials'a cevap verir (androidx.webkit 1.12+, çalışma zamanında isFeatureSupported ile korunur).
+        if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_AUTHENTICATION)) {
+            WebSettingsCompat.setWebAuthenticationSupport(settings, WebSettingsCompat.WEB_AUTHENTICATION_SUPPORT_FOR_APP)
+        }
 
         // Fixes broken full-screen modals/galleries due to body height being 0.
         webView.layoutParams = ViewGroup.LayoutParams(

Domain tarafı, shim’in Apple dosyasının yanında servis ettiği assetlinks.json. Dosya, paket adını ve imza sertifikasının SHA-256 değerini get_login_creds ve handle_all_urls ilişkileri altında listeliyor. Android’in WebView passkey rehberi uygulamanın siteyle Digital Asset Links üzerinden ilişkilendirilmesini şart koşuyor. Deneme yalnızca olumlu yolu, dosya yerindeyken çalıştırdı.

// web/index.js
const androidPackage = process.env.ANDROID_PACKAGE;
const androidCertShas = (process.env.ANDROID_CERT_SHA256 ?? "").split(",").filter(Boolean);
app.get("/.well-known/assetlinks.json", (_req, res) => {
  res.type("application/json").send(
    androidPackage
      ? [{ relation: ["delegate_permission/common.get_login_creds", "delegate_permission/common.handle_all_urls"], target: { namespace: "android_app", package_name: androidPackage, sha256_cert_fingerprints: androidCertShas } }]
      : [],
  );
});

İkisi yerindeyken deneme logcat’te CredentialManager: starting executeCreateCredential with callingPackage: sh.sph.rscmfe satırını, ardından demo@example.com adresini listeleyen “Create passkey on another device?” sistem panelini gözlemledi. Google hesabı olmayan emülatörde tek sağlayıcı hibrit QR akışıydı; bu yüzden yerel akış panelde durdu. Google Password Manager ile oturum açılmış bir cihaz onu yerel sağlayıcı olarak sunardı. İptal etmek sayfaya NotAllowedError döndürüyor ve yanlış bir ayar da aynı hatayı üretiyor. Dolayısıyla panel görünmediğinde bakılacak yer logcat satırı.

Bir yerleşim ayrıntısı da buraya ait. React Native 0.88 ekranı kenardan kenara (edge-to-edge) kullanıyor ve çekirdekteki SafeAreaView yalnızca iOS için, üstelik deprecated. Bu yüzden react-native-safe-area-context olmadan bir WebView durum çubuğunun altında kalıyor ve sayfanın üst satırına yapılan dokunuşlar oraya gidiyor. App.tsx içindeki SafeAreaProvider ile SafeAreaView edges={["top", "bottom"]} çifti sayfayı onun altında tutuyor.

Dikey Bölünmenin Çalışan Hali#

Bu temel üzerinde rota tabanlı micro-frontend’ler, sunucu tarafı kompozisyonun tek uygulamalı akrabası olarak, bir CloudFront yönlendirme kararı ile üç build zamanı gerçeğinden ibaret. Demo tek domain’in arkasında iki dikeye bölünmüş durumda. Account fonksiyonu (rsc-mfe-web) /, /login, /logout, /auth/passkey rotalarını ve iki well-known dosyasını servis ediyor; orders fonksiyonu (rsc-mfe-orders-web) ise /orders rotasını ve kendi asset’lerini. İkisi de tek kaynak ağacından, rota kümesini VERTICAL ile seçerek build ediliyor; bu da ortak bir çerçeve paketini paylaşan iki reponun yerine geçiyor.

// web/app/routes.ts
import { type RouteConfig, index, route } from "@react-router/dev/routes";

// Tek kaynak ağacı, dikey başına bir build. VERTICAL rota kümesini build zamanında seçer:
// "account" ana sayfayı ve auth ile ilgili her şeyi, "orders" /orders rotasını üstlenir. CloudFront
// /orders* isteklerini orders fonksiyonuna, kalanı account fonksiyonuna gönderir; boşsa hepsi build edilir.
const account = [
  index("routes/home.tsx"),
  route("login", "routes/login.tsx"),
  route("logout", "routes/logout.ts"),
  // Resource route (bileşen yok): passkey akışının sunucu yarısı.
  route("auth/passkey", "routes/auth.passkey.ts"),
];
const orders = [route("orders", "routes/orders.tsx")];
const vertical = process.env.VERTICAL;

export default (vertical === "orders" ? orders : vertical === "account" ? account : [...account, ...orders]) satisfies RouteConfig;

Orders fonksiyonu / isteğine, account fonksiyonu da /orders isteğine 404 veriyor; ikisini tek bir uygulama gibi gösteren şey CloudFront. İkinci build zamanı gerçeği asset’ler. İki build tek domain arkasında tek bir hash’li asset ad alanını paylaşamaz, çünkü /assets/* zaten account fonksiyonuna ait. Bu yüzden orders build’i Vite base değerini /orders-static/ yapıyor ve shim statik dosyalarını aynı önekin altına bağlıyor.

// web/vite.config.ts
// Her dikey kendi istemci paketini gönderir. Asset'ler onları build eden fonksiyona ulaşmalı; bu yüzden
// orders build'i /orders-static/ altına bağlanır, ki bu da /orders* CloudFront davranışının kapsamında.
const base = process.env.VERTICAL === "orders" ? "/orders-static/" : "/";
// web/index.js
// Statik dosyalar istemci paketinin build edildiği base'in altına bağlanır (bkz. vite.config.ts).
const base = vertical === "orders" ? "/orders-static" : "";
app.use(`${base}/assets`, express.static("build/client/assets", { immutable: true, maxAge: "1y" }));
app.use(
  base || "/",
  express.static("build/client", {
    // Prerender edilmiş sayfalar: CDN 5 dk tutar, tarayıcı her seferinde yeniden doğrular.
    setHeaders: (res, path) => {
      if (/\.(html|rsc)$/.test(path)) res.setHeader("Cache-Control", "public, max-age=0, s-maxage=300");
      // mime-db'de .rsc kaydı yok; send application/octet-stream'e düşüyor ve CloudFront
      // bunu sıkıştırmıyor. text/x-component sıkıştırılabilir tipler listesinde.
      if (path.endsWith(".rsc")) res.setHeader("Content-Type", "text/x-component");
    },
  }),
);

CloudFront tarafı, ikinci bir origin ve mevcutların önüne eklenen iki davranış. infra/cloudfront-vertical.sh account origin’ini klonluyor, sonra /orders-static/* ile /orders* desenlerini /assets/* ve varsayılanın önüne yerleştiriyor. Sıra belirleyici: CloudFront eşleşen ilk deseni alıyor ve /orders* deseni /orders-static/ ile de eşleşirdi.

// infra/cloudfront-vertical.sh, node -e gövdesi
const c = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).DistributionConfig;
const clone = (o) => JSON.parse(JSON.stringify(o));
const webfn = c.Origins.Items.find((o) => o.Id === "webfn");
const ordersfn = { ...clone(webfn), Id: "ordersfn", DomainName: process.argv[2] };
c.Origins.Items = [...c.Origins.Items.filter((o) => o.Id !== "ordersfn"), ordersfn];
c.Origins.Quantity = c.Origins.Items.length;
const assets = c.CacheBehaviors.Items.find((b) => b.PathPattern === "/assets/*");
const staticB = { ...clone(assets), PathPattern: "/orders-static/*", TargetOriginId: "ordersfn" };
const pageB = { ...clone(c.DefaultCacheBehavior), PathPattern: "/orders*", TargetOriginId: "ordersfn" };
const keep = c.CacheBehaviors.Items.filter((b) => !["/orders-static/*", "/orders*"].includes(b.PathPattern));
c.CacheBehaviors.Items = [staticB, pageB, ...keep];
c.CacheBehaviors.Quantity = c.CacheBehaviors.Items.length;
process.stdout.write(JSON.stringify(c));

Üçüncü gerçek, linklerin bir dikeyden belge yüklemesi olarak çıkması. Bir RSC ağacı fonksiyon sınırını geçmez: orders build’inden / adresine istemci tarafı bir navigasyon, account fonksiyonundan orders istemcisinin render edemeyeceği bir payload isterdi. nav.tsx her linke reloadDocument koyuyor; orders sayfasındaki çıkış formu da aynı nedenle düz bir <form method="post" action="/logout">.

// web/app/nav.tsx
{pages.map(([to, label]) => (
  <NavLink
    key={to}
    to={to}
    end={to === "/"}
    // Dikeyler tek domain arkasında ayrı build'ler; istemci tarafı bir navigasyon bu build'in
    // sunucusundan başka bir build'in ağacını isterdi. Dikeyler arası linkler belge yüklemesidir.
    reloadDocument
    className={({ isActive, isPending }) =>
      `rounded-md px-3 py-1.5 text-sm ${isActive ? "bg-neutral-900 text-white dark:bg-white dark:text-neutral-900" : "text-neutral-600 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:bg-neutral-800"} ${isPending ? "opacity-60" : ""}`
    }
  >
    {label}
  </NavLink>
))}

İzinler bölünmeyi izliyor. rsc-mfe-orders-web-role, loglar dışında yalnızca orders tablosu üzerinde dynamodb:Query tutuyor. rsc-mfe-web-role ise loglar dışında yalnızca pool üzerindeki iki Cognito Admin eylemini tutuyor. infra/web-role.sh her policy’yi yalnızca kendi bayrağı altında ekliyor: ORDERS_TABLE=1 ya da COGNITO_ADMIN=1. Account fonksiyonu, kodu denese bile orders verisini okuyamıyor; orders fonksiyonu da oturum açma başlatamıyor.

Kabuk değişmedi. Dikey başına bir sekme zaten onun şekliydi. Bölünmeden sonra iOS’taki Orders sekmesi /orders sayfasını /orders-static/ asset’leriyle birlikte orders fonksiyonundan, native tarafta hiçbir değişiklik olmadan yükledi. Android kabuğu tek fonksiyonlu build’de doğrulandı ve bölünmeden sonra yeniden çalıştırılmadı.

Shim her cevaba bir x-vertical başlığı ekliyor. CloudFront üzerinden gözlemlenen zincir: GET / account’tan; cookie’siz GET /orders orders’tan, /login adresine 302; POST /login account’tan, 302; GET /orders orders’tan, üç satırla 200; POST /auth/passkey sayfa orders’tan gelmişken account’tan; POST /logout account’tan; passkey ile giriş; GET /orders orders’tan, 200.

Dikeyler arası bir yan ürün ortaya çıktı. React Router’ın rota keşif isteği GET /.manifest?paths=...&version=<build> yol öneki taşımıyor. Bu yüzden orders istemcisinin manifest çağrısı account fonksiyonuna düşüyor ve bilmediği bir build sürümü için 204 alıyor. Belge yüklemeli linklerle istemcinin buna ihtiyacı olmadı. Dikeyler arasında istemci tarafı navigasyonu koruyan bir bölünme için belgelenmiş ayar react-router.config.ts içindeki routeDiscovery: dikey başına bir manifestPath ya da keşfi kapatan mode: "initial".

İki dikeyin paylaştığı şey tam olarak sınırın kendisi: domain, Cognito pool’u ile aws-jwt-verify doğrulayıcısı, çerçeve için User-Agent kuralı ve well-known dosyaları. session cookie’si iki fonksiyona da ulaşıyor, ikisi de aynı token’a güveniyor ve well-known dosyalarını varsayılan davranışa sahip fonksiyon hangisiyse o servis ediyor.

Bölünmenin bedeliBu build’de anlamı
Dikeyler arası navigasyonDikey sınırını geçen her linkte tam belge yüklemesi
Ortak çerçeveBuild zamanında ortak bir paket; her dikey kendi örneğini render ediyor
Dağıtımİki fonksiyon, iki rol, bir dağıtım değişikliği

Sınırın Kaydığı Yerler#

Aşağıdaki dokuz madde bir belirtiyi nedeni ve düzeltmesiyle eşliyor; bunlar desenin özellikleri, aynı desenin başka build’lerinde de tekrar ederler.

  1. Hydrate edilmiş form POST’ları 400, ham POST’lar 302 döndürüyor. React Router 8.5, UI rotalarının action’larından önce bir CSRF kontrolü çalıştırıyor (8.5.1 build çıktısındaki throwIfPotentialCSRFAttack) ve Origin başlığını request.url ile karşılaştırıyor. Resource route’lar bunun dışında; dolayısıyla /auth/passkey, SameSite=Lax ile JSON gövdesine dayanıyor. Lambda Web Functions arkasında Node süreci http:// ve endpoint hostname’ini görüyor; dolayısıyla gerçek bir tarayıcının isteği reddediliyor, curl ise geçiyor. Düzeltme @remix-run/node-fetch-server paketindeki createRequestListener(build.fetch, { host: PUBLIC_HOST, protocol: "https:" }); belgelenmiş alternatif, host’u değiştirmeden kabul edilen origin’leri listeleyen react-router.config.ts içindeki allowedActionOrigins. Bu düzeltmeyle doğrudan endpoint hostname’ine gönderilen tarayıcı POST’ları kontrolden geçemiyor. Denemedeki iki web fonksiyonunun endpoint’i de ApplicationManaged, dolayısıyla kendi domain’leri erişilebilir kalıyor. CloudFront’u imzasız tek giriş yapan şey, önceki yazının anlattığı gibi CloudFront origin access control ile IamAuth. Tek açık yüzey iddiasının framework’e dokunduğu tek yer burası: sunucunun kendi açık adını bilmesi gerekiyor.
  2. iOS’ta passkey paneli hiç görünmüyor. Promise NotAllowedError ile reddediliyor. Associated domain eksik, AASA dosyası servis edilmiyor ya da Team ID öneki olmadan servis ediliyor. Önce dosyayı açık domain’den çekin.
  3. Android’de aynısı, logcat’te CredentialManager satırı olmadan. WebView ayarı kapalı; varsayılan da bu. Ayar açık ama asset links yanlışsa satır görünüyor ve bu kez panel hata veriyor.
  4. İkinci sekme en alta kaydırılmış açılıyor. Pasif WebView’da display: "none". Yerleşik tutun ve opacity ile gizleyin.
  5. Ana sayfa kabuğun içinde web nav’ını gösteriyor. Prerender edilmiş bir index.html, loader çalışmadan önce statik middleware tarafından servis edildi. Çerçevesi istemciye bağlı rotalarda prerender’ı kaldırın.
  6. Session bir saat sonra bitiyor. Demoda tasarım gereği: access token’ın ömrü, refresh yok. Yükseltme yolu session bölümünde.
  7. Passkey kaydı cihazda sağlayıcı olmadığını bildiriyor (Android). Kurulu ya da oturum açmış bir credential sağlayıcısı yok. Google Password Manager bir Google hesabı istiyor; neden cihazın durumu.
  8. Sayfa sunucusunun arkasında senkron çağrılan private bir Lambda. Servisi internetten uzak tutuyor ama Lambda rehberindeki anti-pattern maddesiyle yine örtüşüyor. Bir fonksiyon diğerini beklerken bedel iki kez ödeniyor ve hata modları zincirleniyor. Veriyi dikeyin içinde sahiplenin, gerçek bir servisin önüne private bir arayüz koyun ya da asenkrona geçin.
  9. Sır gibi muamele gören public bir Cognito client ID’si. InitiateAuth kimlik doğrulaması gerektirmiyor; client ID’yi sunucuda tutmak giriş noktasını gizler, fazlasını yapmaz. Sınır, gizli (confidential) bir client artı IAM altındaki Admin oturum açma işlemleri.

Native API İstemcisinin Kazandığı Durumlar#

Girişteki üç alternatifin her birinin kazandığı bir durum var; aşağıdaki sorular onu bulmanın yolu.

SoruBu desene işaret ederBaşka yöne işaret eder
Backend’i başka kim çağırıyor?Yalnızca ürünün kendi istemcileriİş ortakları ya da herkese açık bir API; API’yi koruyun, web uygulaması yine RSC olabilir
Mobil uygulama çevrimdışı yazma gerektiriyor mu?HayırEvet; senkronizasyon katmanı olan native bir istemci
Mobil arayüzün ne kadarı native olmalı?Çerçeve ve birkaç ekran; kabuk, WebView’ların yanında native ekranlar barındırabilirEkranların çoğu; API’li native bir uygulama
Kimlik nerede tutuluyor?WebAuthn destekli tek bir IdP (Cognito, Auth0, Entra); akış sunucu tarafında yürürSosyal giriş ya da SMS OTP akışları aynı mekanizmayı kullanır; bu soru nadiren başka yöne işaret eder
Mağaza incelemesi açısından durum ne?Native navigasyon, push ve en az bir native yetenekSalt WebView’dan ibaret bir uygulama Apple’ın asgari işlevsellikle ilgili 4.2 kuralının kapsamına girebilir; denemenin bir inceleme sonucu yok

Kazanılan: tek auth implementasyonu ve tek yetkilendirme noktası. Her dikeyin verisine tek bir IAM izni var ve hiçbir fonksiyon diğerini çağırmıyor. Cihazda token durmuyor, passkey iki istemci için bir kez çalışıyor ve tek deploy web ile mobil içeriği birlikte güncelliyor. Ödenen: web fonksiyonu bir darboğaz, çünkü her mobil ekran ağ üzerinden bir sunucu render’ı; istemciye göre çerçeve de prerender’a mal oluyor. Kabuk hâlâ platform doğrulama dosyalarına ve Android’de yamalı bir WebView’a ihtiyaç duyuyor; session ömrü de cookie ne diyorsa o. Denemedeki kabukta push kaydı, sekmelere deep link, çevrimdışı okuma ve WebView sekmelerinin yanında native ekran yok. Bir ürün build’i her biri için native kod ekler; ilk üçü sınırı oynatmaz, çünkü hiçbiri web fonksiyonunun zaten önünde durmadığı bir API gerektirmez.

Varsayılanın Geçerli Olduğu Yer#

Varsayılan, backend’in ürünün kendi istemcileri dışında tüketicisi olmadığında, mobil arayüz sunucuda render edilen ekranların etrafında bir çerçeveden ibaret olduğunda ve kimlik WebAuthn destekli tek bir sağlayıcıda tutulduğunda geçerli. İş ortakları backend’i tükettiğinde, uygulama çevrimdışı yazma gerektirdiğinde ya da ekranların çoğu native olmak zorunda olduğunda yerini native bir API istemcisine bırakıyor. Varsayılanın içinde bile sınır, denemenin karşılaştığı yerlerde kayıyor. Bunlar iki taraftaki platform doğrulaması, TLS’i sonlandıran bir proxy arkasındaki CSRF origin kontrolü ve istemciye göre çerçeve uğruna kaybedilen prerender. Yeni bir build’de önce açık domain’deki iki well-known dosyasına bakın; diğer her adım onlara bağlı.

Kaynaklar#

İlgili yazılar