diff --git a/versioned_docs/version-0.14/capabilities/http-fetch.md b/docs/capabilities/http-fetch.mdx similarity index 90% rename from versioned_docs/version-0.14/capabilities/http-fetch.md rename to docs/capabilities/http-fetch.mdx index 8d5cd30f..1f61b8c6 100644 --- a/versioned_docs/version-0.14/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 @@ -150,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/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..0e4253f3 --- /dev/null +++ b/src/components/FetchDomainChecker/index.tsx @@ -0,0 +1,872 @@ +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 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" + | "weather" + | "sports" + | "finance" + | "news" + | "messaging" + | "reference" + | "language" + | "media"; + +type CheckResult = { + status: CheckStatus; + title: string; + description: string; + normalizedDomain?: string; + rejectionReason?: RejectionReason; + showPolicyQuestions?: boolean; + requirements?: string[]; +}; + +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 = [ + "openrouter.ai", + "anthropic.com", + "mistral.ai", + "cohere.com", + "x.ai", + "groq.com", +]; + +const EMPTY_POLICY_ANSWERS: PolicyAnswers = { + aiProvider: null, + personalOrPrivate: null, + publiclyDocumented: null, + publiclyAccessible: null, + rulesCompliant: null, +}; + +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, policyAnswers: PolicyAnswers): 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: "Globally allowed", + 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: "Not allowed", + 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: "Exception review required", + description: + "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 (policyAnswers.aiProvider === "yes") { + return { + status: "no", + title: "Not allowed", + description: + "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 (policyAnswers.personalOrPrivate === "yes") { + return { + status: "no", + title: "Normally not eligible", + description: + "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: "personal", + showPolicyQuestions: true, + }; + } + + if ( + policyAnswers.publiclyDocumented === "no" || + policyAnswers.publiclyAccessible === "no" + ) { + return { + status: "no", + title: "Does not meet standard requirements", + description: + "The standard approval path requires both public documentation and public accessibility. Without both, the request would need an exceptional justification.", + normalizedDomain: domain, + 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: "yes", + title: "Eligible for review", + description: + "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 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; +}): React.ReactElement { + return ( +
+ {recommendation.label} +

{recommendation.description}

+
    + {recommendation.domains.map((suggestedDomain) => ( +
  • + {suggestedDomain} + +
  • + ))} +
+
+ ); +} + +export default function FetchDomainChecker(): React.ReactElement { + const [mode, setMode] = useState("check"); + const [domain, setDomain] = useState(""); + const [policyAnswers, setPolicyAnswers] = useState({ + ...EMPTY_POLICY_ANSWERS, + }); + const [alternativeUseCase, setAlternativeUseCase] = + useState(null); + const [copiedDomain, setCopiedDomain] = useState(null); + const result = useMemo( + () => checkDomain(domain, policyAnswers), + [domain, policyAnswers], + ); + const finderRecommendation = alternativeUseCase + ? ALTERNATIVE_USE_CASES[alternativeUseCase] + : null; + const shouldShowFinderLink = + result.status === "no" && + ["ai-provider", "personal", "not-public"].includes( + result.rejectionReason ?? "", + ); + + useEffect(() => { + const getModeFromHash = (): HelperMode | null => { + return ["#find-a-domain", "#find-an-api"].includes(window.location.hash) + ? "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-a-domain" : "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, + useCase?: AlternativeUseCase | null, + ) => { + event.preventDefault(); + setMode(nextMode); + if (useCase !== undefined) { + updateAlternativeUseCase(useCase); + } + window.history.pushState( + null, + "", + nextMode === "find" ? "#find-a-domain" : "#fetch-domain-checker", + ); + }; + + const updateAlternativeUseCase = (value: AlternativeUseCase | null) => { + setAlternativeUseCase(value); + setCopiedDomain(null); + }; + + const updateDomain = (value: string) => { + setDomain(value); + setPolicyAnswers({ ...EMPTY_POLICY_ANSWERS }); + setAlternativeUseCase(null); + setCopiedDomain(null); + }; + + 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); + }; + + 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 service 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..f7c87412 --- /dev/null +++ b/src/components/FetchDomainChecker/styles.module.css @@ -0,0 +1,476 @@ +.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, +.alternativeResults p, +.alternativeNote, +.finderLinkPrompt, +.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 { + color: var(--checker-muted); + font-size: 0.8rem; +} + +.policyQuestions { + display: grid; + gap: 0.75rem; +} + +.policyHint { + margin: 0.2rem 0 0; + color: var(--checker-muted); + font-size: 0.8rem; +} + +.policyQuestion { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 1rem; + min-width: 0; + padding: 0.65rem 0; + border-top: 1px solid var(--checker-border); +} + +.policyQuestionText { + display: grid; + gap: 0.2rem; + min-width: 0; +} + +.policyQuestionText strong { + font-size: 0.875rem; +} + +.policyQuestionText small { + color: var(--checker-muted); + font-size: 0.75rem; + line-height: 1.35; +} + +.answerOptions { + display: grid; + grid-template-columns: repeat(2, 3.75rem); +} + +.answerOption { + position: relative; + margin: 0; + cursor: pointer; +} + +.answerOption input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; +} + +.answerOption span { + display: block; + min-height: 36px; + padding: 0.45rem 0.6rem; + border: 1px solid var(--ifm-color-emphasis-300); + background: transparent; + color: var(--ifm-font-color-base); + font-size: 0.8rem; + font-weight: 600; + text-align: center; +} + +.answerOption:first-child span { + border-radius: 6px 0 0 6px; +} + +.answerOption:last-child span { + margin-left: -1px; + border-radius: 0 6px 6px 0; +} + +.answerOption:hover span { + border-color: var(--ifm-color-primary); +} + +.answerOption input:checked + span { + position: relative; + border-color: var(--ifm-color-primary); + background: color-mix(in srgb, var(--ifm-color-primary) 8%, transparent); + box-shadow: inset 0 0 0 1px var(--ifm-color-primary); + color: var(--ifm-color-primary-dark); +} + +:global([data-theme="dark"]) .answerOption input:checked + span { + color: var(--ifm-color-primary-light); +} + +.answerOption input:focus-visible + span { + position: relative; + 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; +} + +.resultRequirements { + display: grid; + gap: 0.25rem; + margin-top: 0.25rem; + font-size: 0.85rem; +} + +.resultRequirements ul { + margin: 0; + padding-left: 1.25rem; +} + +.resultRequirements li { + margin: 0.2rem 0; +} + +.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 { + color: var(--checker-muted); + font-size: 0.875rem; +} + +.finderLinkPrompt { + color: var(--checker-muted); + font-size: 0.875rem; +} + +.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) { + .policyQuestion { + grid-template-columns: 1fr; + gap: 0.5rem; + } + + .modeTab, + .activeModeTab { + flex: 1; + } + + .finderDomainList li { + align-items: stretch; + flex-direction: column; + } + + .copyDomainButton { + width: 100%; + } +} diff --git a/docs/capabilities/http-fetch.md b/versioned_docs/version-0.14/capabilities/http-fetch.mdx similarity index 92% rename from docs/capabilities/http-fetch.md rename to versioned_docs/version-0.14/capabilities/http-fetch.mdx index 8d5cd30f..6d0ff538 100644 --- a/docs/capabilities/http-fetch.md +++ b/versioned_docs/version-0.14/capabilities/http-fetch.mdx @@ -1,3 +1,5 @@ +import FetchDomainChecker from "@site/src/components/FetchDomainChecker"; + # HTTP Fetch Make requests to allow-listed external domains. @@ -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 @@ -150,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/versioned_docs/version-0.14/capabilities/server/http-fetch-policy.md b/versioned_docs/version-0.14/capabilities/server/http-fetch-policy.md index 0f6557b4..6c7f4d5f 100644 --- a/versioned_docs/version-0.14/capabilities/server/http-fetch-policy.md +++ b/versioned_docs/version-0.14/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: