diff --git a/docs/content/Deploying/Air-Gapped.mdx b/docs/content/Deploying/Air-Gapped.mdx
new file mode 100644
index 00000000..a564109d
--- /dev/null
+++ b/docs/content/Deploying/Air-Gapped.mdx
@@ -0,0 +1,197 @@
+---
+title: Air-Gapped Deployment
+description: Run DocsGPT on a network with no internet access.
+---
+
+import { Callout } from 'nextra/components'
+
+# Air-Gapped Deployment
+
+DocsGPT runs without internet access once three things are inside your network: the software, the embedding models, and an LLM you host yourself. This guide covers bringing them in, the settings that switch off features that need the internet, blocking outbound traffic, and checking that nothing gets out.
+
+## What needs the internet
+
+With a self-hosted LLM, chat, document ingestion and retrieval work offline. These parts of DocsGPT contact external services by default and need a setting:
+
+| Feature | What it contacts | Air-gapped setting |
+| --- | --- | --- |
+| Default LLM (`LLM_PROVIDER=docsgpt`) | DocsGPT's hosted model API | Point DocsGPT at your own model server |
+| Version check | `gptcloud.arc53.com`, when the worker starts and periodically after | `VERSION_CHECK=0` |
+| Text-to-speech (Speak button) | Google (`google_tts`) or ElevenLabs | `TTS_PROVIDER=none` |
+| Speech-to-text (microphone, audio files) | OpenAI (`openai`) | `STT_PROVIDER=none` |
+| `read_webpage` chat tool | Any URL the model chooses | Leave it out of `DEFAULT_CHAT_TOOLS` |
+| Embedding models | Hugging Face, the first time a model is used | Bundled in the Docker image; `docsgpt prefetch-models` for pip |
+
+Features whose purpose is to fetch outside content cannot work offline: URL, crawler, sitemap, GitHub and Reddit sources, the Google Drive, SharePoint and Confluence connectors, web search and other internet-facing tools, and hosted LLM or embedding providers.
+
+
+ Keep the default document parser. `DOC_PARSER_ENGINE=anydoc` needs no models, and OCR is off unless you turn it on. Use the default backend image rather than the `-docling` variant, and don't install the `docling` extra.
+
+
+## 1. Bring the software in
+
+### Docker
+
+The backend image already contains everything the default configuration loads at run time: both default embedding models (granite for new installs, mpnet for upgrades), their tokenizers and the token-counting encoding. A container downloads nothing on first use.
+
+On a machine with internet access, pull the images and save them to one file:
+
+```bash
+TAG=latest # or a release, e.g. 0.19.0
+docker pull arc53/docsgpt:$TAG
+docker pull arc53/docsgpt-fe:$TAG
+docker pull redis:6-alpine
+docker pull postgres:16-alpine
+docker save -o docsgpt-images.tar \
+ arc53/docsgpt:$TAG arc53/docsgpt-fe:$TAG redis:6-alpine postgres:16-alpine
+```
+
+Copy `docsgpt-images.tar` and the [standalone Compose file](/Deploying/Docker-Deploying#quickest-setup-pre-built-images-no-checkout) into the air-gapped network, then load the images (or push them to your internal registry):
+
+```bash
+docker load -i docsgpt-images.tar
+```
+
+Set `DOCSGPT_IMAGE_TAG` to the same tag when you start the stack. Bring your model server's image and weights in the same way, following its own documentation.
+
+To use an embedding model other than the two defaults, build your own backend image with it baked in: `docker build -f docsgpt/Dockerfile --build-arg EMBEDDINGS_PREFETCH= .`
+
+### pip
+
+On a connected machine with the same operating system, CPU architecture and Python version as the target, download the packages and the models:
+
+```bash
+python -m venv .venv && source .venv/bin/activate
+pip download docsgpt -d wheelhouse
+pip install --no-index --find-links wheelhouse docsgpt
+DOCSGPT_HOME=./docsgpt-home docsgpt prefetch-models
+```
+
+`prefetch-models` stores both default embedding models and their tokenizers under `models/` in the [data home](/Deploying/Pip-Install). Pass model names to fetch a different set.
+
+Copy `wheelhouse/` and `docsgpt-home/models/` to the target, install from the wheelhouse, and put `models/` in the target's data home (or point `EMBEDDINGS_CACHE_DIR` at it):
+
+```bash
+pip install --no-index --find-links wheelhouse docsgpt
+```
+
+## 2. Point DocsGPT at your own LLM
+
+Run an OpenAI-compatible model server inside the network, such as vLLM, Ollama, a llama.cpp server or Text Generation Inference, and set:
+
+```env
+LLM_PROVIDER=openai
+OPENAI_BASE_URL=http://llm.internal:8000/v1
+LLM_NAME=your-model-name
+API_KEY=None
+```
+
+Setting `OPENAI_BASE_URL` also removes DocsGPT's hosted model from the model list. See [Local inference engines](/Models/local-inference) for the base URL of each server.
+
+## 3. Switch off the features that call out
+
+Add these to `.env`:
+
+```env
+VERSION_CHECK=0
+TTS_PROVIDER=none
+STT_PROVIDER=none
+DEFAULT_CHAT_TOOLS=["memory","scheduler"]
+HF_HUB_OFFLINE=1
+```
+
+- `VERSION_CHECK=0` stops the worker's anonymous version check.
+- `TTS_PROVIDER=none` hides the Speak button on answers.
+- `STT_PROVIDER=none` hides the microphone button, and audio files fail to ingest with a message naming the setting. If your model server offers an OpenAI-compatible `/v1/audio/transcriptions` endpoint, keep `STT_PROVIDER=openai` instead: speech-to-text uses `OPENAI_BASE_URL` too.
+- `DEFAULT_CHAT_TOOLS` defaults to `memory`, `read_webpage` and `scheduler`; this list drops `read_webpage`.
+- `HF_HUB_OFFLINE=1` makes the Hugging Face libraries read models from the local cache only. A model that was never fetched fails at once with an error, instead of waiting on a connection that never opens. The libraries read it from the process environment: Docker Compose's `env_file` provides that, and for pip installs export it in the environment that starts `docsgpt api` and `docsgpt worker`.
+
+Leave the document parsing and OCR settings at their defaults.
+
+## 4. Block outbound traffic in the network
+
+DocsGPT doesn't block outbound connections itself. Enforce that in the network, where the same rule also covers Postgres, Redis, your model server and anything you add later.
+
+**Hosts with no route out.** The simplest setup: servers whose firewall allows only internal address ranges and your internal DNS.
+
+**Docker.** Attach the services to a network created with `internal: true`, which has no route out of the Docker host. A container on an internal network can't publish ports, so give only a reverse proxy (nginx, Caddy, Traefik) a second, regular network and serve the UI and API through it:
+
+```yaml
+networks:
+ airgap:
+ internal: true
+ edge: {}
+
+services:
+ backend:
+ networks: [airgap]
+ worker:
+ networks: [airgap]
+ frontend:
+ networks: [airgap]
+ postgres:
+ networks: [airgap]
+ redis:
+ networks: [airgap]
+ proxy:
+ image: nginx:stable
+ networks: [airgap, edge]
+ ports: ["443:443"]
+```
+
+Docker's published ports bypass host firewalls such as `ufw`. If you rely on host rules instead of internal networks, filter container traffic in the `DOCKER-USER` iptables chain.
+
+**Kubernetes.** With a network plugin that enforces NetworkPolicy, deny egress for the namespace except to its own pods and cluster DNS:
+
+```yaml
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+ name: deny-external-egress
+ namespace: docsgpt
+spec:
+ podSelector: {}
+ policyTypes: [Egress]
+ egress:
+ - to:
+ - podSelector: {}
+ - to:
+ - namespaceSelector: {}
+ podSelector:
+ matchLabels:
+ k8s-app: kube-dns
+ ports:
+ - protocol: UDP
+ port: 53
+ - protocol: TCP
+ port: 53
+```
+
+Add a rule for your model server if it runs in another namespace.
+
+**Egress proxy.** When some traffic must leave the network, for example to a hosted LLM, send it through a forward proxy that allows only those hosts. The Python HTTP libraries DocsGPT uses honour the standard variables; exclude internal services in `NO_PROXY`:
+
+```env
+HTTPS_PROXY=http://proxy.internal:3128
+HTTP_PROXY=http://proxy.internal:3128
+NO_PROXY=localhost,127.0.0.1,backend,llm.internal
+```
+
+- Features that fetch user-supplied URLs connect to the resolved IP address, so a hostname allowlist can't match them. Keep those features off.
+- If the proxy inspects TLS, add its CA certificate with `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE`. For model downloads through such a proxy, also set `HF_HUB_DISABLE_XET=1`: Hugging Face's transfer client ignores `SSL_CERT_FILE`.
+
+## 5. Verify
+
+Check that the models load with networking disabled:
+
+```bash
+docker run --rm --network none arc53/docsgpt:$TAG python -m docsgpt.scripts.verify_offline
+```
+
+On a pip install, run `docsgpt verify-offline` on the air-gapped host. Both check token counting, each default embedding model's tokenizer, and an embedding; pass a model name to check a different model.
+
+Then use the app: upload a document and ask a question about it. Your firewall or proxy logs should show no connection attempts from DocsGPT to outside hosts. The web UI loads its fonts and scripts from the DocsGPT server only.
+
+
+ The [chat widget](/Extensions/chat-widget) loads its font from Google Fonts and, unless you pass `buttonIcon`, its launcher icon from a CDN. Set `apiHost` to your own DocsGPT API; the default is DocsGPT's cloud.
+
diff --git a/docs/content/Deploying/Development-Environment.mdx b/docs/content/Deploying/Development-Environment.mdx
index e7e877c4..6c01879f 100644
--- a/docs/content/Deploying/Development-Environment.mdx
+++ b/docs/content/Deploying/Development-Environment.mdx
@@ -78,7 +78,7 @@ To run the DocsGPT backend locally, you'll need to set up a Python environment a
3. **Embedding Model (no action needed):**
- The embedding model is downloaded automatically the first time you ingest a document, and cached for subsequent runs. Set `EMBEDDINGS_CACHE_DIR` to control where.
+ The embedding model is downloaded automatically the first time you ingest a document, and cached for subsequent runs under `models/` in the data home: the repository root, unless `DOCSGPT_HOME` points elsewhere. Set `EMBEDDINGS_CACHE_DIR` to use another directory.
For an offline or air-gapped machine, fetch it ahead of time instead:
diff --git a/docs/content/Deploying/DocsGPT-Settings.mdx b/docs/content/Deploying/DocsGPT-Settings.mdx
index 61d30483..e21e3be3 100644
--- a/docs/content/Deploying/DocsGPT-Settings.mdx
+++ b/docs/content/Deploying/DocsGPT-Settings.mdx
@@ -67,7 +67,9 @@ Here are some of the most fundamental settings you'll likely want to configure:
- **`OPENAI_BASE_URL`**: Specifically used when `LLM_PROVIDER` is set to `openai` but you are connecting to a local inference engine (like Ollama, Llama.cpp, etc.) that exposes an OpenAI-compatible API. This setting tells DocsGPT where to find your local LLM server.
-- **`STT_PROVIDER`**: Selects the speech-to-text provider used for microphone transcription in chat and for audio file ingestion through the parser pipeline.
+- **`STT_PROVIDER`**: Selects the speech-to-text provider used for microphone transcription in chat and for audio file ingestion through the parser pipeline. Set it to `none` to switch speech-to-text off: the microphone button is hidden, and audio files fail to ingest with a message naming the setting.
+
+- **`TTS_PROVIDER`**: Selects the text-to-speech provider behind the Speak button on answers: `google_tts` (default) or `elevenlabs`. Both are hosted services. Set it to `none` to switch text-to-speech off and hide the button.
- **`SERVE_UI`**: Whether the API process serves the web UI shipped in the Python package (`pip install docsgpt`). Defaults to `true`; set `false` when a separate frontend, such as the Docker frontend image, serves the UI. Has no effect in the backend Docker image, which carries no UI build.
@@ -238,7 +240,7 @@ The settings below control speech-to-text behaviour for both voice input and aud
| Setting | Purpose | Typical values |
| --- | --- | --- |
-| `STT_PROVIDER` | Speech-to-text backend provider. | `openai`, `faster_whisper` |
+| `STT_PROVIDER` | Speech-to-text backend provider, or `none` to switch speech-to-text off. | `openai`, `faster_whisper`, `none` |
| `OPENAI_STT_MODEL` | OpenAI transcription model used when `STT_PROVIDER=openai`. | `gpt-4o-mini-transcribe` |
| `STT_LANGUAGE` | Optional language hint passed to the provider. Leave unset for auto-detection when supported. | `en`, `es`, unset |
| `STT_MAX_FILE_SIZE_MB` | Maximum file size accepted by the synchronous `/api/stt` endpoint. | `50` |
diff --git a/docs/content/Deploying/Pip-Install.mdx b/docs/content/Deploying/Pip-Install.mdx
index dbfa604b..ffe92c1d 100644
--- a/docs/content/Deploying/Pip-Install.mdx
+++ b/docs/content/Deploying/Pip-Install.mdx
@@ -49,7 +49,7 @@ pipx runpip docsgpt install --force-reinstall --no-deps --index-url https://down
## Configure
-DocsGPT keeps its runtime files in a **data home**: the `.env` file it reads settings from, uploaded files under `inputs/` and vector indexes under `indexes/`. The data home is the directory you run the commands from, or the directory `DOCSGPT_HOME` points to. `DOCSGPT_ENV_FILE` points at a `.env` kept somewhere else. Both variables must be set in the process environment, not in `.env`: they decide where `.env` is read from.
+DocsGPT keeps its runtime files in a **data home**: the `.env` file it reads settings from, uploaded files under `inputs/`, vector indexes under `indexes/` and downloaded embedding models under `models/`. The data home is the directory you run the commands from, or the directory `DOCSGPT_HOME` points to. `DOCSGPT_ENV_FILE` points at a `.env` kept somewhere else. Both variables must be set in the process environment, not in `.env`: they decide where `.env` is read from.
Create a `.env` in the data home. The minimum for a hosted LLM:
@@ -87,7 +87,7 @@ The worker is not optional: query embedding runs on it, so search fails without
Other commands:
- `docsgpt api --reload`: a development server with auto-reload.
-- `docsgpt prefetch-models`: download the embedding, tokenizer and parser models ahead of time, for machines that go offline.
+- `docsgpt prefetch-models`: download the embedding models and their tokenizers ahead of time, for machines that go offline (see [Air-Gapped Deployment](/Deploying/Air-Gapped)).
- `docsgpt verify-offline`: check that a prepared install starts with networking off.
- `docsgpt reembed`: re-embed every index after changing `EMBEDDINGS_NAME` (see [Upgrading](/upgrading)).
diff --git a/docs/content/Deploying/_meta.js b/docs/content/Deploying/_meta.js
index c14234bd..4865a40e 100644
--- a/docs/content/Deploying/_meta.js
+++ b/docs/content/Deploying/_meta.js
@@ -27,6 +27,10 @@ export default {
"title": "βΈοΈ Deploying on Kubernetes",
"href": "/Deploying/Kubernetes-Deploying"
},
+ "Air-Gapped": {
+ "title": "π Air-Gapped Deployment",
+ "href": "/Deploying/Air-Gapped"
+ },
"Hosting-the-app": {
"title": "βοΈ Hosting DocsGPT",
"href": "/Deploying/Hosting-the-app"
diff --git a/docs/content/Guides/Integrations/confluence-connector.mdx b/docs/content/Guides/Integrations/confluence-connector.mdx
index e69458ae..d9702fd2 100644
--- a/docs/content/Guides/Integrations/confluence-connector.mdx
+++ b/docs/content/Guides/Integrations/confluence-connector.mdx
@@ -52,6 +52,7 @@ VITE_CONFLUENCE_CLIENT_ID=your-atlassian-client-id
| `CONFLUENCE_CLIENT_ID` | Client ID from your Atlassian OAuth app | Yes |
| `CONFLUENCE_CLIENT_SECRET` | Client secret from your Atlassian OAuth app | Yes |
| `VITE_CONFLUENCE_CLIENT_ID` | Same Client ID, used by the frontend to show the Confluence option | Yes |
+| `CONNECTOR_ALLOWED_ORIGINS` | Comma-separated frontend origins allowed to receive the sign-in result, e.g. `https://docsgpt.example.com`. Not needed when the frontend shares the API origin, or in local dev when the callback is on `localhost`/`127.0.0.1` and the frontend runs on port 5173 | When the frontend is on its own origin |
### Step 5: Restart and Use
@@ -62,6 +63,7 @@ Restart your application, then go to the upload section in DocsGPT and select **
## Troubleshooting
- **Option not appearing** β Verify `VITE_CONFLUENCE_CLIENT_ID` is set in the frontend `.env`, then restart.
+- **Sign-in popup closes but the account never connects** β The frontend origin is not allowed to receive the result. Add it to `CONNECTOR_ALLOWED_ORIGINS` in the backend `.env`.
- **Authentication failed** β Check that the callback URL matches exactly, including `?provider=confluence`.
- **No accessible sites** β Ensure the authenticating user has access to at least one Confluence Cloud site.
- **Permission denied** β Verify that the Confluence API scopes are enabled in your Atlassian app settings.
diff --git a/docs/content/Guides/Integrations/google-drive-connector.mdx b/docs/content/Guides/Integrations/google-drive-connector.mdx
index 278dfb83..3014c784 100644
--- a/docs/content/Guides/Integrations/google-drive-connector.mdx
+++ b/docs/content/Guides/Integrations/google-drive-connector.mdx
@@ -50,6 +50,7 @@ VITE_GOOGLE_CLIENT_ID=your-google-client-id
| `GOOGLE_CLIENT_ID` | OAuth Client ID from GCP Credentials | Yes |
| `GOOGLE_CLIENT_SECRET` | OAuth Client Secret from GCP Credentials | Yes |
| `VITE_GOOGLE_CLIENT_ID` | Same Client ID, used by the frontend to show the Google Drive option | Yes |
+| `CONNECTOR_ALLOWED_ORIGINS` | Comma-separated frontend origins allowed to receive the sign-in result, e.g. `https://docsgpt.example.com`. Not needed when the frontend shares the API origin, or in local dev when the callback is on `localhost`/`127.0.0.1` and the frontend runs on port 5173 | When the frontend is on its own origin |
Make sure to use the same Google Client ID in both backend and frontend configurations.
@@ -65,6 +66,7 @@ Restart your application, then go to the upload section in DocsGPT and select **
- **Option not appearing** β Verify `VITE_GOOGLE_CLIENT_ID` is set in the frontend `.env`, then restart.
- **Authentication failed** β Check that the redirect URI matches exactly, including `?provider=google_drive`. Ensure the Google Drive API is enabled.
+- **Sign-in popup closes but the account never connects** β The frontend origin is not allowed to receive the result. Add it to `CONNECTOR_ALLOWED_ORIGINS` in the backend `.env`.
- **Permission denied** β Verify the OAuth consent screen is configured and the user has access to the target files.
- **Files not processing** β Check backend logs and verify that backend environment variables are correctly set.
diff --git a/docs/content/Guides/Integrations/sharepoint-connector.mdx b/docs/content/Guides/Integrations/sharepoint-connector.mdx
index cf8b27bf..58829f38 100644
--- a/docs/content/Guides/Integrations/sharepoint-connector.mdx
+++ b/docs/content/Guides/Integrations/sharepoint-connector.mdx
@@ -45,6 +45,7 @@ MICROSOFT_TENANT_ID=your-azure-ad-tenant-id
| `MICROSOFT_CLIENT_SECRET` | Client secret value | Yes | β |
| `MICROSOFT_TENANT_ID` | Directory (tenant) ID | No | `common` |
| `MICROSOFT_AUTHORITY` | Login endpoint override | No | Auto-constructed |
+| `CONNECTOR_ALLOWED_ORIGINS` | Comma-separated frontend origins allowed to receive the sign-in result, e.g. `https://docsgpt.example.com` | When the frontend is on its own origin | API origin; also `localhost:5173` when the callback is on `localhost`/`127.0.0.1` |
`MICROSOFT_TENANT_ID=common` (the default) allows any Microsoft account to authenticate. Set this to your specific tenant ID in production.
@@ -60,4 +61,5 @@ Restart your application, then go to the upload section in DocsGPT and select **
- **Option not appearing** β Verify `MICROSOFT_CLIENT_ID` and `MICROSOFT_CLIENT_SECRET` are set, then restart.
- **Authentication failed** β Check that the redirect URI matches exactly, including `?provider=share_point`.
+- **Sign-in popup closes but the account never connects** β The frontend origin is not allowed to receive the result. Add it to `CONNECTOR_ALLOWED_ORIGINS` in the backend `.env`.
- **Permission denied** β Ensure admin consent is granted and the user has access to the target files.
diff --git a/docs/content/Models/embeddings.md b/docs/content/Models/embeddings.md
index ae11e447..972fec90 100644
--- a/docs/content/Models/embeddings.md
+++ b/docs/content/Models/embeddings.md
@@ -37,7 +37,7 @@ Set `EMBEDDINGS_NAME` in your `.env` to a registry name or a Hugging Face reposi
EMBEDDINGS_NAME=ibm-granite/granite-embedding-311m-multilingual-r2
```
-The model is downloaded on first use and cached; set `EMBEDDINGS_CACHE_DIR` to control where. There is no `model/` folder to populate by hand, and a filesystem path is not accepted as a model name.
+The model is downloaded on first use and cached, together with its tokenizer, under `models/` in the data home; set `EMBEDDINGS_CACHE_DIR` to use another directory. There is no folder to populate by hand, and a filesystem path is not accepted as a model name.
DocsGPT knows the pooling, vector width and context window of the models in its registry (`all-mpnet-base-v2`, `granite-embedding-311m-multilingual-r2`, `granite-embedding-97m-multilingual-r2`). For any other repository it reads those from the repository's own `1_Pooling/config.json` and `modules.json`. If a repository declares neither, mean pooling with L2 normalization is assumed and a warning is logged β pin the real values with `EMBEDDINGS_POOLING` (`cls` or `mean`) and `EMBEDDINGS_NORMALIZE`.
diff --git a/docsgpt/Dockerfile b/docsgpt/Dockerfile
index 5c59d815..13fcac37 100644
--- a/docsgpt/Dockerfile
+++ b/docsgpt/Dockerfile
@@ -108,11 +108,10 @@ COPY --from=builder /venv /venv
# EMBEDDINGS_CACHE_DIR / HF_HUB_CACHE FastEmbed models and their tokenizers
# (chunking reads tokenizer.json from
# the same hub-layout snapshot)
-# TIKTOKEN_CACHE_DIR cl100k_base for token accounting
# DOCLING_ARTIFACTS_PATH docling's models (docling extra only)
+# tiktoken's cl100k_base encoding ships inside the package.
ENV EMBEDDINGS_CACHE_DIR=/app/models \
HF_HUB_CACHE=/app/models \
- TIKTOKEN_CACHE_DIR=/app/models/tiktoken \
DOCLING_ARTIFACTS_PATH=/app/models/docling \
HF_HUB_DISABLE_TELEMETRY=1 \
PATH="/venv/bin:$PATH"
diff --git a/docsgpt/api/connector/routes.py b/docsgpt/api/connector/routes.py
index 7669c567..5240ee58 100644
--- a/docsgpt/api/connector/routes.py
+++ b/docsgpt/api/connector/routes.py
@@ -2,7 +2,8 @@ import base64
import html
import json
import uuid
-from urllib.parse import urlencode
+from typing import Optional
+from urllib.parse import urlencode, urlsplit
from flask import (
@@ -19,9 +20,11 @@ from docsgpt.api import api
from docsgpt.api.user.tasks import (
ingest_connector_task,
)
+from docsgpt.core.settings import settings
from docsgpt.parser.connectors.connector_creator import ConnectorCreator
from docsgpt.storage.db.repositories.connector_sessions import (
ConnectorSessionsRepository,
+ owns_connector_session,
)
from docsgpt.storage.db.repositories.sources import SourcesRepository
from docsgpt.storage.db.session import db_readonly, db_session
@@ -44,6 +47,128 @@ def build_callback_redirect(params: dict) -> str:
return f"{CALLBACK_STATUS_PATH}?{urlencode(params)}"
+_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
+_DEV_FRONTEND_PORT = 5173
+
+
+def _origin_of(url: Optional[str]) -> Optional[str]:
+ """Normalized ``scheme://host[:port]`` origin of an http(s) URL, or None."""
+ if not url:
+ return None
+ try:
+ parts = urlsplit(url.strip())
+ port = parts.port
+ except ValueError:
+ return None
+ host = parts.hostname
+ if parts.scheme not in ("http", "https") or not host:
+ return None
+ if ":" in host:
+ host = f"[{host}]"
+ if port is None or port == {"http": 80, "https": 443}[parts.scheme]:
+ return f"{parts.scheme}://{host}"
+ return f"{parts.scheme}://{host}:{port}"
+
+
+def connector_allowed_origins(request_host_url: str) -> list[str]:
+ """Frontend origins the OAuth popup may hand a connector session token to."""
+ candidates = [
+ request_host_url,
+ settings.CONNECTOR_REDIRECT_BASE_URI,
+ settings.OIDC_FRONTEND_URL,
+ *(settings.CONNECTOR_ALLOWED_ORIGINS or "").split(","),
+ ]
+ callback_origin = _origin_of(settings.CONNECTOR_REDIRECT_BASE_URI)
+ if callback_origin:
+ callback = urlsplit(callback_origin)
+ if callback.hostname in _LOOPBACK_HOSTS:
+ callback_port = f":{callback.port}" if callback.port else ""
+ for host in ("localhost", "127.0.0.1"):
+ candidates += [f"http://{host}:{_DEV_FRONTEND_PORT}", f"{callback.scheme}://{host}{callback_port}"]
+ origins: list[str] = []
+ for candidate in candidates:
+ origin = _origin_of(candidate)
+ if origin and origin not in origins:
+ origins.append(origin)
+ return origins
+
+
+def _js_literal(value) -> str:
+ """Encode a value as a JavaScript literal that is safe inside an inline script."""
+ return json.dumps(value).replace("", "<\\/").replace("