From 2a1edfbf59273241d6f84205d83dd1f7d6947b5f Mon Sep 17 00:00:00 2001 From: Dikran Samarjian Date: Fri, 2 Oct 2026 13:11:38 -0700 Subject: [PATCH 1/2] update fetch checker --- .../{http-fetch.md => http-fetch.mdx} | 14 +- docs/guides/faq.mdx | 6 +- src/components/FetchDomainChecker/index.tsx | 742 ++++++++++++++++++ .../FetchDomainChecker/styles.module.css | 464 +++++++++++ 4 files changed, 1218 insertions(+), 8 deletions(-) rename docs/capabilities/{http-fetch.md => http-fetch.mdx} (94%) create mode 100644 src/components/FetchDomainChecker/index.tsx create mode 100644 src/components/FetchDomainChecker/styles.module.css diff --git a/docs/capabilities/http-fetch.md b/docs/capabilities/http-fetch.mdx similarity index 94% rename from docs/capabilities/http-fetch.md rename to docs/capabilities/http-fetch.mdx index 8d5cd30f..806ecafc 100644 --- a/docs/capabilities/http-fetch.md +++ b/docs/capabilities/http-fetch.mdx @@ -1,8 +1,10 @@ +import FetchDomainChecker from "@site/src/components/FetchDomainChecker"; + # HTTP Fetch Make requests to allow-listed external domains. -Your Devvit app can make network requests to access allow-listed external domains using HTTP Fetch. This enables your app to leverage webhooks, personal servers, and other third-party integrations asynchronously across the network. +Your Devvit app can make network requests to access allow-listed external domains using HTTP Fetch. This enables your app to use approved APIs, webhooks, and other third-party integrations asynchronously across the network. ## Enabling HTTP fetch calls @@ -28,6 +30,8 @@ Apps may request a domain to be added to the allow-list by specifying domains in Requested domains will be submitted for review when you playtest or upload your app. Most domain requests are reviewed within **1–2 business days**, though requests with policy ambiguity may take longer. Admins may approve or deny domain requests. + + Domain entries must be exact hostnames only, such as nytimes.com or wikipedia.org. These fetch requests are not allowed: - Be specific. No using \*.example.com when you need api.example.com @@ -54,15 +58,15 @@ Devvit Web applications have two different contexts for using fetch: Server-side fetch allows your app to make HTTP requests to allowlisted external domains from your server-side code (e.g., API routes, server actions): ```ts title="server/index.ts" -const response = await fetch('https://example.com/api/data', { - method: 'GET', +const response = await fetch("https://example.com/api/data", { + method: "GET", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, }); const data = await response.json(); -console.log('External API response:', data); +console.log("External API response:", data); ``` ### Client-side fetch diff --git a/docs/guides/faq.mdx b/docs/guides/faq.mdx index 6ffec5f4..c0f6c655 100644 --- a/docs/guides/faq.mdx +++ b/docs/guides/faq.mdx @@ -175,7 +175,7 @@ Most app versions are reviewed within **1–2 business days**. New apps or versi - **Payments**: apps using the payments capability go through additional policy review. - **`runAs: 'USER'`**: user action permissions require explicit approval as part of the review. -- **External fetch domains**: new domain requests are reviewed separately under the same **1–2 business day** target, though requests with policy ambiguity may take longer (see [HTTP Fetch](../capabilities/http-fetch.md)). +- **External fetch domains**: new domain requests are reviewed separately under the same **1–2 business day** target, though requests with policy ambiguity may take longer (see [HTTP Fetch](../capabilities/http-fetch.mdx)). To keep review moving: @@ -454,10 +454,10 @@ Domain requests are reviewed separately from app publishing. Most domain request To make approval go smoothly: - Use exact hostnames only — no wildcards (`*.example.com`), no protocols (`https://`), and no paths (`api.example.com/webhooks`). -- Add a "Fetch Domains" section to your app [`README.md`](../devvit_rules.md#app-readme-requirements) listing each domain and explaining why you need it. The expected format is documented in [HTTP Fetch](../capabilities/http-fetch.md). +- Add a "Fetch Domains" section to your app [`README.md`](../devvit_rules.md#app-readme-requirements) listing each domain and explaining why you need it. The expected format is documented in [HTTP Fetch](../capabilities/http-fetch.mdx). - Include links to your Terms and Conditions and Privacy Policy in your app details form. -Before submitting, check the [global fetch allowlist](../capabilities/http-fetch.md#global-fetch-allowlist) — if your domain is already listed there, no separate request is needed. Personal domains (e.g., `personaldomain.com`) aren't approved. +Before submitting, check the [global fetch allowlist](../capabilities/http-fetch.mdx#global-fetch-allowlist) — if your domain is already listed there, no separate request is needed. Personal domains (e.g., `personaldomain.com`) aren't approved. diff --git a/src/components/FetchDomainChecker/index.tsx b/src/components/FetchDomainChecker/index.tsx new file mode 100644 index 00000000..1ce6121a --- /dev/null +++ b/src/components/FetchDomainChecker/index.tsx @@ -0,0 +1,742 @@ +import React, { useEffect, useMemo, useState } from "react"; +import Heading from "@theme/Heading"; + +import styles from "./styles.module.css"; + +type CheckStatus = "empty" | "question" | "yes" | "no" | "exception"; +type DomainKind = "public-api" | "ai-provider" | "personal"; +type RejectionReason = "invalid" | "ai-provider" | "personal"; +type HelperMode = "check" | "find"; +type AlternativeUseCase = + | "ai" + | "weather" + | "sports" + | "finance" + | "news" + | "messaging" + | "reference" + | "language" + | "media"; + +type CheckResult = { + status: CheckStatus; + title: string; + description: string; + normalizedDomain?: string; + rejectionReason?: RejectionReason; +}; + +type AlternativeRecommendation = { + label: string; + description: string; + domains: string[]; +}; + +const GLOBAL_ALLOWLIST = new Set([ + "api.openai.com", + "generativelanguage.googleapis.com", + "example.com", + "site.api.espn.com", + "cdn.espn.com", + "discord.com", + "api.polygon.io", + "api.massive.com", + "polygon.io", + "slack.com", + "lichess.org", + "api.telegram.org", + "commentanalyzer.googleapis.com", + "language.googleapis.com", + "statsapi.mlb.com", + "api.scryfall.com", + "api.nasa.gov", + "api.sportradar.us", + "api.sportradar.com", + "random.org", + "youtube.googleapis.com", + "api.weather.gov", + "wikipedia.org", + "finance.yahoo.com", + "api.twitter.com", + "api.petfinder.com", + "fonts.googleapis.com", + "nytimes.com", + "npr.org", + "propublica.org", + "pbs.org", + "i.giphy.com", + "chessboardjs.com", +]); + +const LIMITED_SCOPE_CLOUD_PROVIDERS = [ + "supabase.com", + "firebase.com", + "spacetimedb.com", + "s3.amazonaws.com", + "storage.googleapis.com", +]; + +const NON_APPROVED_AI_PROVIDER_DOMAINS = [ + "anthropic.com", + "mistral.ai", + "cohere.com", + "x.ai", + "groq.com", +]; + +const DOMAIN_KINDS: Array<{ + value: DomainKind; + label: string; + detail: string; +}> = [ + { + value: "public-api", + label: "Public API", + detail: "Publicly documented and publicly accessible", + }, + { + value: "ai-provider", + label: "AI provider", + detail: "Models, inference, or other AI services", + }, + { + value: "personal", + label: "Personal or private", + detail: "Your own server or a non-public API", + }, +]; + +const ALTERNATIVE_USE_CASES: Record< + AlternativeUseCase, + AlternativeRecommendation +> = { + ai: { + label: "Generative AI", + description: "OpenAI and Google Gemini are the approved AI providers.", + domains: ["api.openai.com", "generativelanguage.googleapis.com"], + }, + weather: { + label: "Weather", + description: + "The US National Weather Service provides a public weather API.", + domains: ["api.weather.gov"], + }, + sports: { + label: "Sports", + description: + "These allowed providers cover general and league-specific sports data.", + domains: [ + "site.api.espn.com", + "statsapi.mlb.com", + "api.sportradar.us", + "api.sportradar.com", + ], + }, + finance: { + label: "Finance and markets", + description: "These allowed providers offer financial and market data.", + domains: [ + "api.polygon.io", + "api.massive.com", + "polygon.io", + "finance.yahoo.com", + ], + }, + news: { + label: "News and public-interest data", + description: + "These allowed publishers provide news or public-interest reporting.", + domains: ["nytimes.com", "npr.org", "propublica.org", "pbs.org"], + }, + messaging: { + label: "Messaging and notifications", + description: + "These allowed services can support messaging and notification workflows.", + domains: ["discord.com", "slack.com", "api.telegram.org"], + }, + reference: { + label: "Reference and public data", + description: + "These allowed APIs provide reference, science, and other public data.", + domains: [ + "wikipedia.org", + "api.nasa.gov", + "api.scryfall.com", + "api.petfinder.com", + "random.org", + ], + }, + language: { + label: "Language and content analysis", + description: + "These allowed Google APIs support language and content analysis.", + domains: ["commentanalyzer.googleapis.com", "language.googleapis.com"], + }, + media: { + label: "Media and assets", + description: + "These allowed services provide media, video, fonts, or UI assets.", + domains: [ + "youtube.googleapis.com", + "i.giphy.com", + "fonts.googleapis.com", + "chessboardjs.com", + ], + }, +}; + +const ALTERNATIVE_USE_CASE_OPTIONS = Object.entries( + ALTERNATIVE_USE_CASES, +) as Array< + [AlternativeUseCase, (typeof ALTERNATIVE_USE_CASES)[AlternativeUseCase]] +>; + +function isSameDomainOrSubdomain(domain: string, baseDomain: string): boolean { + return domain === baseDomain || domain.endsWith(`.${baseDomain}`); +} + +function normalizeDomain(input: string): { + domain: string; + formatError?: string; +} { + const value = input.trim().toLowerCase(); + + if (!value) { + return { domain: "" }; + } + + if (value.includes("*")) { + return { + domain: value, + formatError: "Wildcards are not allowed. Enter the exact hostname.", + }; + } + + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) { + return { + domain: value, + formatError: "Remove the protocol and enter only the hostname.", + }; + } + + try { + const url = new URL(`https://${value}`); + const domain = url.hostname.replace(/\.$/, ""); + + if (url.username || url.password || url.port) { + return { + domain, + formatError: + "Credentials and ports are not allowed. Enter only the hostname.", + }; + } + + if (url.pathname !== "/" || url.search || url.hash || value.includes("/")) { + return { + domain, + formatError: "Paths, query strings, and fragments are not allowed.", + }; + } + + return { domain }; + } catch { + return { + domain: value, + formatError: "Enter a valid hostname, such as api.example.com.", + }; + } +} + +function isValidHostname(domain: string): boolean { + if (!domain || domain.length > 253 || !domain.includes(".")) { + return false; + } + + return domain.split(".").every((label) => { + return ( + label.length > 0 && + label.length <= 63 && + /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label) + ); + }); +} + +function checkDomain( + input: string, + domainKind: DomainKind | null, +): CheckResult { + const { domain, formatError } = normalizeDomain(input); + + if (!domain) { + return { + status: "empty", + title: "Enter a domain", + description: "Use the exact hostname you plan to add to devvit.json.", + }; + } + + if (formatError || !isValidHostname(domain)) { + return { + status: "no", + title: "No", + description: + formatError ?? + "Domain entries must be exact hostnames, such as api.example.com.", + normalizedDomain: domain, + rejectionReason: "invalid", + }; + } + + if (GLOBAL_ALLOWLIST.has(domain)) { + return { + status: "yes", + title: "Yes", + description: + "This hostname is on the global fetch allowlist. Add the exact hostname to your app's HTTP permissions.", + normalizedDomain: domain, + }; + } + + if ( + NON_APPROVED_AI_PROVIDER_DOMAINS.some((baseDomain) => + isSameDomainOrSubdomain(domain, baseDomain), + ) + ) { + return { + status: "no", + title: "No", + description: + "OpenAI and Google Gemini are currently the only allowed AI providers.", + normalizedDomain: domain, + rejectionReason: "ai-provider", + }; + } + + if ( + LIMITED_SCOPE_CLOUD_PROVIDERS.some((baseDomain) => + isSameDomainOrSubdomain(domain, baseDomain), + ) + ) { + return { + status: "exception", + title: "Request exception", + description: + "This limited-scope cloud provider may be approved with justification. Request the most granular hostname possible and explain which Devvit server capability does not meet your needs.", + normalizedDomain: domain, + }; + } + + if (domainKind === "public-api") { + return { + status: "yes", + title: "Yes", + description: + "Publicly documented and publicly accessible APIs are eligible for approval. Include the API documentation and your use case with the request.", + normalizedDomain: domain, + }; + } + + if (domainKind === "ai-provider") { + return { + status: "no", + title: "No", + description: + "OpenAI and Google Gemini are currently the only allowed AI providers.", + normalizedDomain: domain, + rejectionReason: "ai-provider", + }; + } + + if (domainKind === "personal") { + return { + status: "no", + title: "No", + description: + "Personal domains and non-public APIs are not approved by default. A detailed exception request may be considered when Devvit server capabilities cannot support the use case.", + normalizedDomain: domain, + rejectionReason: "personal", + }; + } + + return { + status: "question", + title: "One more detail", + description: + "Select the option that describes how this domain will be used.", + normalizedDomain: domain, + }; +} + +function SuggestedDomainResults({ + recommendation, + copiedDomain, + onCopyDomain, +}: { + recommendation: AlternativeRecommendation; + copiedDomain?: string | null; + onCopyDomain?: (domain: string) => void; +}): React.ReactElement { + return ( +
+ {recommendation.label} +

{recommendation.description}

+
    + {recommendation.domains.map((suggestedDomain) => ( +
  • + {suggestedDomain} + {onCopyDomain ? ( + + ) : null} +
  • + ))} +
+
+ ); +} + +export default function FetchDomainChecker(): React.ReactElement { + const [mode, setMode] = useState("check"); + const [domain, setDomain] = useState(""); + const [domainKind, setDomainKind] = useState(null); + const [alternativeUseCase, setAlternativeUseCase] = + useState(null); + const [copiedDomain, setCopiedDomain] = useState(null); + const result = useMemo( + () => checkDomain(domain, domainKind), + [domain, domainKind], + ); + const shouldAskDomainKind = + result.status === "question" || domainKind !== null; + const suggestedUseCase = + result.rejectionReason === "ai-provider" ? "ai" : alternativeUseCase; + const suggestedAlternatives = suggestedUseCase + ? ALTERNATIVE_USE_CASES[suggestedUseCase] + : null; + const finderRecommendation = alternativeUseCase + ? ALTERNATIVE_USE_CASES[alternativeUseCase] + : null; + const shouldShowAlternatives = + result.status === "no" && result.rejectionReason !== "invalid"; + + useEffect(() => { + const getModeFromHash = (): HelperMode | null => { + return window.location.hash === "#find-an-api" + ? "find" + : window.location.hash === "#fetch-domain-checker" + ? "check" + : null; + }; + + const syncModeToHash = () => { + const nextMode = getModeFromHash(); + + if (nextMode) { + setMode(nextMode); + } + }; + + const initialMode = getModeFromHash(); + if (initialMode) { + setMode(initialMode); + window.requestAnimationFrame(() => { + document + .getElementById( + initialMode === "find" ? "find-an-api" : "fetch-domain-checker", + ) + ?.scrollIntoView({ block: "start" }); + }); + } + + window.addEventListener("hashchange", syncModeToHash); + window.addEventListener("popstate", syncModeToHash); + return () => { + window.removeEventListener("hashchange", syncModeToHash); + window.removeEventListener("popstate", syncModeToHash); + }; + }, []); + + const selectMode = ( + event: React.MouseEvent, + nextMode: HelperMode, + ) => { + event.preventDefault(); + setMode(nextMode); + window.history.pushState( + null, + "", + nextMode === "find" ? "#find-an-api" : "#fetch-domain-checker", + ); + }; + + const updateAlternativeUseCase = (value: AlternativeUseCase | null) => { + setAlternativeUseCase(value); + setCopiedDomain(null); + }; + + const updateDomain = (value: string) => { + setDomain(value); + setDomainKind(null); + setAlternativeUseCase(null); + setCopiedDomain(null); + }; + + const updateDomainKind = (value: DomainKind) => { + setDomainKind(value); + setAlternativeUseCase(null); + setCopiedDomain(null); + }; + + const copyDomain = async (suggestedDomain: string) => { + try { + await window.navigator.clipboard.writeText(suggestedDomain); + setCopiedDomain(suggestedDomain); + } catch { + setCopiedDomain(null); + } + }; + + return ( +
+
+ + +
+ + + +
+ + + + +

+ This helper provides policy guidance, not approval. Domain requests + are reviewed when you playtest or upload your app. +

+
+
+ ); +} + +function AlternativeDisclaimer(): React.ReactElement { + return ( +

+ These are potential alternatives, not guaranteed replacements. Confirm + that the API supports your requirements, authentication method, and + permitted usage.{" "} + View the full allowlist. +

+ ); +} diff --git a/src/components/FetchDomainChecker/styles.module.css b/src/components/FetchDomainChecker/styles.module.css new file mode 100644 index 00000000..882bf1bb --- /dev/null +++ b/src/components/FetchDomainChecker/styles.module.css @@ -0,0 +1,464 @@ +.checker { + --checker-border: var(--ifm-toc-border-color); + --checker-muted: var(--ifm-color-emphasis-700); + --checker-success: #168a5b; + --checker-warning: #b05a00; + --checker-danger: #c13b19; + + margin: 1.5rem 0 2rem; + overflow: hidden; + border: 1px solid var(--checker-border); + border-radius: 8px; + background: transparent; +} + +:global([data-theme="dark"]) .checker { + --checker-success: #55c493; + --checker-warning: #f2a44f; + --checker-danger: #ff815f; +} + +.header { + padding: 1rem; + border-bottom: 1px solid var(--checker-border); + background: var(--ifm-color-emphasis-100); +} + +.title { + margin: 0; + font-size: 1.1rem; + line-height: 1.35; +} + +.description, +.resultDescription, +.disclaimer, +.alternativeResults p, +.alternativePrompt, +.alternativeNote, +.finderPrompt { + margin: 0; +} + +.description { + margin-top: 0.25rem; + color: var(--checker-muted); + font-size: 0.9rem; +} + +.modeTabs { + display: flex; + gap: 0.25rem; + padding: 0.65rem 1rem; + border-bottom: 1px solid var(--checker-border); + background: var(--ifm-background-color); +} + +.modeTab, +.activeModeTab { + min-width: 0; + padding: 0.4rem 0.75rem; + border: 1px solid transparent; + border-radius: 6px; + color: var(--checker-muted); + font-size: 0.875rem; + font-weight: 600; + text-align: center; + text-decoration: none; +} + +.modeTab:hover, +.activeModeTab:hover { + color: var(--ifm-color-primary); + text-decoration: none; +} + +.modeTab:hover { + background: var(--ifm-color-emphasis-100); +} + +.activeModeTab, +.activeModeTab:hover { + border-color: var(--ifm-color-primary); + background: color-mix(in srgb, var(--ifm-color-primary) 8%, transparent); + color: var(--ifm-color-primary-dark); +} + +:global([data-theme="dark"]) .activeModeTab, +:global([data-theme="dark"]) .activeModeTab:hover { + color: var(--ifm-color-primary-light); +} + +.modeTab:focus-visible, +.activeModeTab:focus-visible { + outline: 2px solid var(--ifm-color-primary); + outline-offset: 2px; +} + +.body { + display: grid; + gap: 1rem; + padding: 1rem; +} + +.modePanel { + display: grid; + gap: 1rem; +} + +.modePanel[hidden] { + display: none; +} + +.formRow { + display: grid; + gap: 0.4rem; +} + +.label { + font-size: 0.875rem; + font-weight: 600; +} + +.input { + width: 100%; + min-height: 42px; + padding: 0.55rem 0.7rem; + border: 1px solid var(--ifm-color-emphasis-400); + border-radius: 6px; + background: var(--ifm-background-color); + color: var(--ifm-font-color-base); + font: inherit; +} + +.input:hover { + border-color: var(--ifm-color-emphasis-600); +} + +.input:focus-visible { + border-color: var(--ifm-color-primary); + outline: 2px solid + color-mix(in srgb, var(--ifm-color-primary) 35%, transparent); + outline-offset: 1px; +} + +.hint, +.disclaimer { + color: var(--checker-muted); + font-size: 0.8rem; +} + +.kindFieldset { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +.kindFieldset .label { + margin-bottom: 0.5rem; + padding: 0; +} + +.kindOptions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.5rem; +} + +.kindOption { + position: relative; + display: flex; + min-width: 0; + min-height: 80px; + margin: 0; + cursor: pointer; +} + +.kindOption input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; +} + +.kindOption > span { + display: grid; + align-content: start; + gap: 0.25rem; + width: 100%; + padding: 0.7rem; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 6px; + background: transparent; + color: var(--ifm-font-color-base); +} + +.kindOption small { + color: var(--checker-muted); + font-size: 0.75rem; + line-height: 1.35; +} + +.kindOption:hover > span { + border-color: var(--ifm-color-primary); +} + +.kindOption input:checked + span { + border-color: var(--ifm-color-primary); + box-shadow: inset 0 0 0 1px var(--ifm-color-primary); + color: var(--ifm-color-primary-dark); +} + +:global([data-theme="dark"]) .kindOption input:checked + span { + color: var(--ifm-color-primary-light); +} + +.kindOption input:focus-visible + span { + outline: 2px solid var(--ifm-color-primary); + outline-offset: 2px; +} + +.result { + display: grid; + gap: 0.4rem; + padding: 0.8rem 0.9rem; + border-left: 3px solid var(--ifm-color-emphasis-500); + background: var(--ifm-color-emphasis-100); +} + +.result[data-status="yes"] { + border-left-color: var(--checker-success); +} + +.result[data-status="no"] { + border-left-color: var(--checker-danger); +} + +.result[data-status="exception"] { + border-left-color: var(--checker-warning); +} + +.resultHeading { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.statusMark { + width: 0.55rem; + height: 0.55rem; + flex: 0 0 auto; + border-radius: 50%; + background: var(--ifm-color-emphasis-500); +} + +.result[data-status="yes"] .statusMark { + background: var(--checker-success); +} + +.result[data-status="no"] .statusMark { + background: var(--checker-danger); +} + +.result[data-status="exception"] .statusMark { + background: var(--checker-warning); +} + +.resultTitle { + font-size: 0.95rem; +} + +.domain { + width: fit-content; + max-width: 100%; + overflow-wrap: anywhere; + font-size: var(--ifm-code-font-size); +} + +.resultDescription { + font-size: 0.9rem; +} + +.alternatives { + display: grid; + gap: 0.75rem; + padding-top: 1rem; + border-top: 1px solid var(--checker-border); +} + +.alternativesTitle { + margin: 0; + font-size: 1rem; + line-height: 1.35; +} + +.alternativePicker { + display: grid; + gap: 0.4rem; + margin: 0; +} + +.select { + appearance: none; + -webkit-appearance: none; + width: 100%; + min-height: 42px; + padding: 0.55rem 2.25rem 0.55rem 0.7rem; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: var(--ifm-global-radius); + background-color: var(--ifm-dropdown-background-color); + background-image: linear-gradient( + 45deg, + transparent 50%, + var(--ifm-dropdown-link-color) 50% + ), + linear-gradient(135deg, var(--ifm-dropdown-link-color) 50%, transparent 50%); + background-position: + calc(100% - 13px) calc(50% + 1px), + calc(100% - 9px) calc(50% + 1px); + background-repeat: no-repeat; + background-size: 4px 4px; + box-shadow: var(--ifm-global-shadow-lw); + color: var(--ifm-dropdown-link-color); + cursor: pointer; + font-family: var(--ifm-font-family-base); + font-size: 0.875rem; + font-weight: var(--ifm-dropdown-font-weight); + line-height: 1.5; + transition-duration: var(--ifm-transition-fast); + transition-property: background-color, border-color, box-shadow; + transition-timing-function: var(--ifm-transition-timing-default); +} + +.select:hover { + border-color: var(--ifm-color-emphasis-400); + background-color: var(--ifm-dropdown-hover-background-color); +} + +.select:focus-visible { + border-color: var(--ifm-color-primary); + box-shadow: 0 0 0 2px + color-mix(in srgb, var(--ifm-color-primary) 30%, transparent); + outline: none; +} + +.select option { + background-color: var(--ifm-dropdown-background-color); + color: var(--ifm-dropdown-link-color); + font-weight: var(--ifm-font-weight-base); +} + +.alternativeResults { + display: grid; + gap: 0.4rem; +} + +.alternativeResults p, +.alternativePrompt { + color: var(--checker-muted); + font-size: 0.875rem; +} + +.suggestedDomains { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin: 0; + padding: 0; + list-style: none; +} + +.suggestedDomains li { + margin: 0; +} + +.suggestedDomains code { + overflow-wrap: anywhere; +} + +.finderDomainList { + display: grid; + gap: 0; + margin: 0; + padding: 0; + border-top: 1px solid var(--checker-border); + list-style: none; +} + +.finderDomainList li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + min-width: 0; + margin: 0; + padding: 0.6rem 0; + border-bottom: 1px solid var(--checker-border); +} + +.finderDomainList code { + min-width: 0; + overflow-wrap: anywhere; +} + +.copyDomainButton { + flex: 0 0 auto; + min-width: 4.5rem; + padding: 0.35rem 0.6rem; + border: 1px solid var(--ifm-color-primary); + border-radius: 6px; + background: transparent; + color: var(--ifm-color-primary-dark); + cursor: pointer; + font: inherit; + font-size: 0.8rem; + font-weight: 600; +} + +.copyDomainButton:hover { + background: color-mix(in srgb, var(--ifm-color-primary) 8%, transparent); +} + +:global([data-theme="dark"]) .copyDomainButton { + color: var(--ifm-color-primary-light); +} + +.copyDomainButton:focus-visible { + outline: 2px solid var(--ifm-color-primary); + outline-offset: 2px; +} + +.finderPrompt { + color: var(--checker-muted); + font-size: 0.875rem; +} + +.alternativeNote { + color: var(--checker-muted); + font-size: 0.8rem; +} + +@media (max-width: 680px) { + .kindOptions { + grid-template-columns: 1fr; + } + + .kindOption { + min-height: 0; + } + + .modeTab, + .activeModeTab { + flex: 1; + } + + .finderDomainList li { + align-items: stretch; + flex-direction: column; + } + + .copyDomainButton { + width: 100%; + } +} From d4ebc2594883fdf59e33096a61d3351f925d77a4 Mon Sep 17 00:00:00 2001 From: Dikran Samarjian Date: Mon, 5 Oct 2026 12:05:11 -0700 Subject: [PATCH 2/2] update copies and add to v14 --- docs/capabilities/http-fetch.mdx | 2 +- docs/capabilities/server/http-fetch-policy.md | 2 +- src/components/FetchDomainChecker/index.tsx | 472 +++++++++++------- .../FetchDomainChecker/styles.module.css | 138 ++--- .../{http-fetch.md => http-fetch.mdx} | 14 +- .../capabilities/server/http-fetch-policy.md | 2 +- 6 files changed, 388 insertions(+), 242 deletions(-) rename versioned_docs/version-0.14/capabilities/{http-fetch.md => http-fetch.mdx} (92%) diff --git a/docs/capabilities/http-fetch.mdx b/docs/capabilities/http-fetch.mdx index 806ecafc..1f61b8c6 100644 --- a/docs/capabilities/http-fetch.mdx +++ b/docs/capabilities/http-fetch.mdx @@ -154,7 +154,7 @@ These domains are globally allowed and can be fetched by any app. Allow-listed domains fall into three categories: -1. **APIs that provide data or specific services** (e.g., api.openai.com, api.wikipedia.org) \- These will be approved if they have a **publicly documented and publicly accessible API** for valid use cases, and if they adhere to the Devvit rules. Please reference our AI providers and account linking policies for common invalid use cases. +1. **APIs that provide data or specific services** (e.g., api.openai.com, api.wikipedia.org) \- These are eligible for approval if they have a **publicly documented and publicly accessible API** for a valid use case and adhere to the Devvit rules. Approval is not guaranteed and is determined during review. Please reference our AI providers and account linking policies for common invalid use cases. 2. **Limited scope cloud providers** (e.g., username.supabase.com, my-app.firebase.com) \- May be granted with exceptions. You must: - Follow user privacy guidelines and data governance requirements - Use an approved provider from the list below (please include your subdomain, and request for the most granular domain possible, e.g. my-app.s3.amazonaws.com) diff --git a/docs/capabilities/server/http-fetch-policy.md b/docs/capabilities/server/http-fetch-policy.md index 0f6557b4..6c7f4d5f 100644 --- a/docs/capabilities/server/http-fetch-policy.md +++ b/docs/capabilities/server/http-fetch-policy.md @@ -2,7 +2,7 @@ When requesting domains to be allow-listed, they fall into three categories: -1. **APIs that provide data or specific services** (e.g., `api.openai.com`, `api.wikipedia.org`) \- These will be approved if they have a **publicly documented and publicly accessible API** for valid use cases, and if they adhere to the Devvit rules. Please reference our AI providers and account linking policies for common invalid use cases. +1. **APIs that provide data or specific services** (e.g., `api.openai.com`, `api.wikipedia.org`) \- These are eligible for approval if they have a **publicly documented and publicly accessible API** for a valid use case and adhere to the Devvit rules. Approval is not guaranteed and is determined during review. Please reference our AI providers and account linking policies for common invalid use cases. 2. **Limited scope cloud providers** (e.g., `username.supabase.com`, `my-app.firebase.com`) \- May be granted with exceptions. You must: diff --git a/src/components/FetchDomainChecker/index.tsx b/src/components/FetchDomainChecker/index.tsx index 1ce6121a..0e4253f3 100644 --- a/src/components/FetchDomainChecker/index.tsx +++ b/src/components/FetchDomainChecker/index.tsx @@ -1,11 +1,24 @@ import React, { useEffect, useMemo, useState } from "react"; +import Admonition from "@theme/Admonition"; import Heading from "@theme/Heading"; import styles from "./styles.module.css"; type CheckStatus = "empty" | "question" | "yes" | "no" | "exception"; -type DomainKind = "public-api" | "ai-provider" | "personal"; -type RejectionReason = "invalid" | "ai-provider" | "personal"; +type PolicyAnswer = "yes" | "no" | null; +type PolicyAnswers = { + aiProvider: PolicyAnswer; + personalOrPrivate: PolicyAnswer; + publiclyDocumented: PolicyAnswer; + publiclyAccessible: PolicyAnswer; + rulesCompliant: PolicyAnswer; +}; +type RejectionReason = + | "invalid" + | "ai-provider" + | "personal" + | "not-public" + | "policy-conflict"; type HelperMode = "check" | "find"; type AlternativeUseCase = | "ai" @@ -24,6 +37,8 @@ type CheckResult = { description: string; normalizedDomain?: string; rejectionReason?: RejectionReason; + showPolicyQuestions?: boolean; + requirements?: string[]; }; type AlternativeRecommendation = { @@ -77,6 +92,7 @@ const LIMITED_SCOPE_CLOUD_PROVIDERS = [ ]; const NON_APPROVED_AI_PROVIDER_DOMAINS = [ + "openrouter.ai", "anthropic.com", "mistral.ai", "cohere.com", @@ -84,27 +100,13 @@ const NON_APPROVED_AI_PROVIDER_DOMAINS = [ "groq.com", ]; -const DOMAIN_KINDS: Array<{ - value: DomainKind; - label: string; - detail: string; -}> = [ - { - value: "public-api", - label: "Public API", - detail: "Publicly documented and publicly accessible", - }, - { - value: "ai-provider", - label: "AI provider", - detail: "Models, inference, or other AI services", - }, - { - value: "personal", - label: "Personal or private", - detail: "Your own server or a non-public API", - }, -]; +const EMPTY_POLICY_ANSWERS: PolicyAnswers = { + aiProvider: null, + personalOrPrivate: null, + publiclyDocumented: null, + publiclyAccessible: null, + rulesCompliant: null, +}; const ALTERNATIVE_USE_CASES: Record< AlternativeUseCase, @@ -261,10 +263,7 @@ function isValidHostname(domain: string): boolean { }); } -function checkDomain( - input: string, - domainKind: DomainKind | null, -): CheckResult { +function checkDomain(input: string, policyAnswers: PolicyAnswers): CheckResult { const { domain, formatError } = normalizeDomain(input); if (!domain) { @@ -290,7 +289,7 @@ function checkDomain( if (GLOBAL_ALLOWLIST.has(domain)) { return { status: "yes", - title: "Yes", + title: "Globally allowed", description: "This hostname is on the global fetch allowlist. Add the exact hostname to your app's HTTP permissions.", normalizedDomain: domain, @@ -304,7 +303,7 @@ function checkDomain( ) { return { status: "no", - title: "No", + title: "Not allowed", description: "OpenAI and Google Gemini are currently the only allowed AI providers.", normalizedDomain: domain, @@ -319,89 +318,170 @@ function checkDomain( ) { return { status: "exception", - title: "Request exception", + title: "Exception review required", description: - "This limited-scope cloud provider may be approved with justification. Request the most granular hostname possible and explain which Devvit server capability does not meet your needs.", + "Limited-scope cloud providers are not approved through the standard public API path. Approval is possible only when the exception requirements are met.", normalizedDomain: domain, + requirements: [ + "Request the most granular hostname possible.", + "Follow user privacy and data governance requirements.", + "Demonstrate a capability that @devvit/server does not support.", + "Use it only for a valid exception use case, such as a relational database.", + "Provide a detailed justification for the exception.", + ], }; } - if (domainKind === "public-api") { + if (policyAnswers.aiProvider === "yes") { return { - status: "yes", - title: "Yes", + status: "no", + title: "Not allowed", description: - "Publicly documented and publicly accessible APIs are eligible for approval. Include the API documentation and your use case with the request.", + "OpenAI and Google Gemini are currently the only allowed AI providers. This applies to AI gateways and public AI APIs too.", normalizedDomain: domain, + rejectionReason: "ai-provider", + showPolicyQuestions: true, }; } - if (domainKind === "ai-provider") { + if (policyAnswers.personalOrPrivate === "yes") { return { status: "no", - title: "No", + title: "Normally not eligible", description: - "OpenAI and Google Gemini are currently the only allowed AI providers.", + "Personal domains and private services are not approved by default, even if their API is documented. A detailed exception request may be considered when Devvit server capabilities cannot support the use case.", normalizedDomain: domain, - rejectionReason: "ai-provider", + rejectionReason: "personal", + showPolicyQuestions: true, }; } - if (domainKind === "personal") { + if ( + policyAnswers.publiclyDocumented === "no" || + policyAnswers.publiclyAccessible === "no" + ) { return { status: "no", - title: "No", + title: "Does not meet standard requirements", description: - "Personal domains and non-public APIs are not approved by default. A detailed exception request may be considered when Devvit server capabilities cannot support the use case.", + "The standard approval path requires both public documentation and public accessibility. Without both, the request would need an exceptional justification.", normalizedDomain: domain, - rejectionReason: "personal", + rejectionReason: "not-public", + showPolicyQuestions: true, + }; + } + + if (policyAnswers.rulesCompliant === "no") { + return { + status: "no", + title: "Not eligible", + description: + "Domain requests must support a valid use case and follow the Devvit rules, including applicable AI-provider and account-linking policies.", + normalizedDomain: domain, + rejectionReason: "policy-conflict", + showPolicyQuestions: true, + }; + } + + if (Object.values(policyAnswers).some((answer) => answer === null)) { + return { + status: "question", + title: "Check the service details", + description: + "Answer each question independently. Restricted service types take precedence over public API eligibility.", + normalizedDomain: domain, + showPolicyQuestions: true, }; } return { - status: "question", - title: "One more detail", + status: "yes", + title: "Eligible for review", description: - "Select the option that describes how this domain will be used.", + "This domain appears to meet the baseline requirements, however, approval is not guaranteed and will be determined during app review.", normalizedDomain: domain, + showPolicyQuestions: true, + requirements: [ + "Link to the public API documentation.", + "Explain the valid use case and why the domain is needed.", + "Follow the Devvit rules and applicable account-linking policies.", + "Document the fetch domain and its purpose in your app README.", + ], }; } -function SuggestedDomainResults({ +function PolicyQuestion({ + id, + question, + detail, + value, + onChange, +}: { + id: string; + question: string; + detail: string; + value: PolicyAnswer; + onChange: (value: Exclude) => void; +}): React.ReactElement { + const labelId = `${id}-label`; + + return ( +
+
+ {question} + {detail} +
+
+ {(["yes", "no"] as const).map((answer) => ( + + ))} +
+
+ ); +} + +function DomainRecommendationResults({ recommendation, copiedDomain, onCopyDomain, }: { recommendation: AlternativeRecommendation; - copiedDomain?: string | null; - onCopyDomain?: (domain: string) => void; + copiedDomain: string | null; + onCopyDomain: (domain: string) => void; }): React.ReactElement { return (
{recommendation.label}

{recommendation.description}

-
    +
      {recommendation.domains.map((suggestedDomain) => (
    • {suggestedDomain} - {onCopyDomain ? ( - - ) : null} +
    • ))}
    @@ -412,30 +492,28 @@ function SuggestedDomainResults({ export default function FetchDomainChecker(): React.ReactElement { const [mode, setMode] = useState("check"); const [domain, setDomain] = useState(""); - const [domainKind, setDomainKind] = useState(null); + const [policyAnswers, setPolicyAnswers] = useState({ + ...EMPTY_POLICY_ANSWERS, + }); const [alternativeUseCase, setAlternativeUseCase] = useState(null); const [copiedDomain, setCopiedDomain] = useState(null); const result = useMemo( - () => checkDomain(domain, domainKind), - [domain, domainKind], + () => checkDomain(domain, policyAnswers), + [domain, policyAnswers], ); - const shouldAskDomainKind = - result.status === "question" || domainKind !== null; - const suggestedUseCase = - result.rejectionReason === "ai-provider" ? "ai" : alternativeUseCase; - const suggestedAlternatives = suggestedUseCase - ? ALTERNATIVE_USE_CASES[suggestedUseCase] - : null; const finderRecommendation = alternativeUseCase ? ALTERNATIVE_USE_CASES[alternativeUseCase] : null; - const shouldShowAlternatives = - result.status === "no" && result.rejectionReason !== "invalid"; + const shouldShowFinderLink = + result.status === "no" && + ["ai-provider", "personal", "not-public"].includes( + result.rejectionReason ?? "", + ); useEffect(() => { const getModeFromHash = (): HelperMode | null => { - return window.location.hash === "#find-an-api" + return ["#find-a-domain", "#find-an-api"].includes(window.location.hash) ? "find" : window.location.hash === "#fetch-domain-checker" ? "check" @@ -456,7 +534,7 @@ export default function FetchDomainChecker(): React.ReactElement { window.requestAnimationFrame(() => { document .getElementById( - initialMode === "find" ? "find-an-api" : "fetch-domain-checker", + initialMode === "find" ? "find-a-domain" : "fetch-domain-checker", ) ?.scrollIntoView({ block: "start" }); }); @@ -473,13 +551,17 @@ export default function FetchDomainChecker(): React.ReactElement { const selectMode = ( event: React.MouseEvent, nextMode: HelperMode, + useCase?: AlternativeUseCase | null, ) => { event.preventDefault(); setMode(nextMode); + if (useCase !== undefined) { + updateAlternativeUseCase(useCase); + } window.history.pushState( null, "", - nextMode === "find" ? "#find-an-api" : "#fetch-domain-checker", + nextMode === "find" ? "#find-a-domain" : "#fetch-domain-checker", ); }; @@ -490,13 +572,36 @@ export default function FetchDomainChecker(): React.ReactElement { const updateDomain = (value: string) => { setDomain(value); - setDomainKind(null); + setPolicyAnswers({ ...EMPTY_POLICY_ANSWERS }); setAlternativeUseCase(null); setCopiedDomain(null); }; - const updateDomainKind = (value: DomainKind) => { - setDomainKind(value); + const updatePolicyAnswer = ( + key: keyof PolicyAnswers, + value: Exclude, + ) => { + setPolicyAnswers((currentAnswers) => { + const nextAnswers = { ...currentAnswers, [key]: value }; + + if (key === "aiProvider") { + nextAnswers.personalOrPrivate = null; + nextAnswers.publiclyDocumented = null; + nextAnswers.publiclyAccessible = null; + nextAnswers.rulesCompliant = null; + } else if (key === "personalOrPrivate") { + nextAnswers.publiclyDocumented = null; + nextAnswers.publiclyAccessible = null; + nextAnswers.rulesCompliant = null; + } else if (key === "publiclyDocumented") { + nextAnswers.publiclyAccessible = null; + nextAnswers.rulesCompliant = null; + } else if (key === "publiclyAccessible") { + nextAnswers.rulesCompliant = null; + } + + return nextAnswers; + }); setAlternativeUseCase(null); setCopiedDomain(null); }; @@ -514,21 +619,21 @@ export default function FetchDomainChecker(): React.ReactElement {
    - {shouldAskDomainKind && - result.status !== "no" && - result.status !== "exception" ? ( -
    - - What kind of domain is it? - -
    - {DOMAIN_KINDS.map((option) => ( - - ))} + {result.showPolicyQuestions ? ( +
    +
    + + Tell us about the service + +

    + Answer in order. Restricted service types take precedence. +

    -
    + updatePolicyAnswer("aiProvider", value)} + /> + {policyAnswers.aiProvider === "no" ? ( + + updatePolicyAnswer("personalOrPrivate", value) + } + /> + ) : null} + {policyAnswers.aiProvider === "no" && + policyAnswers.personalOrPrivate === "no" ? ( + + updatePolicyAnswer("publiclyDocumented", value) + } + /> + ) : null} + {policyAnswers.publiclyDocumented === "yes" ? ( + + updatePolicyAnswer("publiclyAccessible", value) + } + /> + ) : null} + {policyAnswers.publiclyAccessible === "yes" ? ( + + updatePolicyAnswer("rulesCompliant", value) + } + /> + ) : null} +
    ) : null}
    {result.normalizedDomain} ) : null}

    {result.description}

    + {result.requirements ? ( +
    + Requirements +
      + {result.requirements.map((requirement) => ( +
    • {requirement}
    • + ))} +
    +
    + ) : null}
    - {shouldShowAlternatives ? ( - + {result.rejectionReason === "ai-provider" + ? "View allowed AI domains" + : "Find a globally allowed domain"} + + . +

    ) : null}