celik-muhammed commited on
Commit
355fe92
Β·
verified Β·
1 Parent(s): e7cf331

Upload 34 files

Browse files
Files changed (3) hide show
  1. README.md +100 -7
  2. _utils/_shared_logic.py +6 -0
  3. app.py +47 -14
README.md CHANGED
@@ -246,10 +246,95 @@ pages/WAL remnants, backups, CDN/provider logs, and infrastructure snapshots are
246
 
247
  ## Configuration
248
 
249
- Configure values in **Space β†’ Settings** using the correct surface:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
 
251
- - **Secrets** β€” credentials such as HF/GitHub/GitLab/Bitbucket tokens.
252
- - **Variables** β€” non-sensitive configuration such as repository IDs, token-type labels, booleans, and `RECORD_STORAGE_TARGETS`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
 
254
  See [DATASET_COLLECTION_GUIDANCE.md](./DATASET_COLLECTION_GUIDANCE.md) for the beginner-to-operator storage guide, including exact HF-only, GitHub-only, Primary+Mirror, migration, testing, and deduplication recipes.
255
 
@@ -317,7 +402,7 @@ production.
317
  | `BACKEND_URL` | No | `""` | **Path 1** β€” Explicit upstream URL. All requests forwarded here when set. Use for Docker Model Runner, Ollama, or any custom backend. |
318
  | `HF_SPACES_MODEL_URL` | No | β€” | **Path 2** β€” Custom ZeroGPU Space URL (e.g. `https://scikit-plots-ai-model.hf.space/v1/chat/completions`). Receives requests whose `model` matches `HF_SPACES_MODEL_NAMESPACES`. |
319
  | `HF_SPACES_MODEL_NAMESPACES` | No | `scikit-plots` | Comma-separated model owner prefixes routed to Path 2 (e.g. `scikit-plots,my-org`). |
320
- | `ALLOWED_MODELS` | No | bundled public model set | Exact Path-3 provider model IDs accepted by the strict chat contract. The default matches the public models shown by `_example_conf.py`: `Qwen/Qwen2.5-Coder-7B-Instruct`, `Qwen/Qwen2.5-Coder-32B-Instruct`, and `openai/gpt-oss-20b`. |
321
  | `HF_BASE` | No | `https://router.huggingface.co` | **Path 3** β€” HF Serverless API base URL. Only used when Path 1 and Path 2 do not match. |
322
  | `DEFAULT_MODEL` | No | `scikit-plots/Qwen2.5-Coder-7B-Instruct` | Fallback model when the request body omits `"model"`. |
323
 
@@ -386,7 +471,8 @@ All values are in seconds. Non-integer values silently fall back to the default
386
 
387
  | Variable | Default | Description |
388
  |---|---|---|
389
- | `ALLOWED_ORIGINS` | empty | Comma-separated **additional** exact browser origins. The official `https://scikit-plots.github.io` origin is always retained so an environment override cannot accidentally break the shipped documentation. Missing `Origin` remains valid for server-to-server clients. `*` is an explicit insecure compatibility escape hatch and is not authentication. |
 
390
  | `DEPLOYMENT_PROFILE` | `compat` | `compat` preserves explicit legacy deployment choices. The hardened Dockerfile sets `strict`, which fails startup as root, rejects wildcard origins and opaque-origin **writes**, and requires verified TLS for all configured Redis authorities. Read-only opaque Share compatibility remains a separate explicit opt-in because local `file://` viewers may need it. |
391
  | `REDIS_REQUIRE_TLS` | `false` (`true` in strict) | Requires `rediss://`; URL query parameters are rejected so callers cannot disable certificate verification through redis-py URL options. |
392
  | `REQUIRE_NON_ROOT` | `false` (`true` in strict) | Fails application startup when the process is UID 0 on POSIX. |
@@ -694,8 +780,15 @@ HF_DATASET_TOKEN = hf_<fine-grained-dataset-token>
694
  # For Primary + Mirrors, use RECORD_STORAGE_TARGETS instead; see DATASET_COLLECTION_GUIDANCE.md.
695
 
696
  # Security
697
- # Optional extra origins; official https://scikit-plots.github.io is built in.
 
 
 
698
  ALLOWED_ORIGINS =
 
 
 
 
699
  # Optional read-only local-file (Origin:null) Share compatibility.
700
  # Keep false unless you explicitly need Global Share viewing from file:// pages.
701
  SHARE_ALLOW_OPAQUE_ORIGIN = false
@@ -712,7 +805,7 @@ BASE=https://scikit-plots-ai.hf.space
712
 
713
  # 1. Liveness probe
714
  curl $BASE/health
715
- # {"status":"ok","version":"6.8.0"}
716
 
717
  # Optional deterministic stub rig status
718
  curl -s $BASE/health | python3 -m json.tool
 
246
 
247
  ## Configuration
248
 
249
+ Configure the Space from **Settings β†’ Variables and secrets**. Hugging Face exposes
250
+ Variables as non-sensitive deployment configuration and keeps Secrets private. See
251
+ [Managing Spaces variables and secrets](https://huggingface.co/docs/hub/spaces-overview#managing-secrets).
252
+
253
+ ### Variables and secrets
254
+
255
+ Use one simple rule: **configuration goes in Variables; credentials and capabilities go in Secrets**.
256
+ Both surfaces become environment variables inside the container, so putting a non-secret value
257
+ in Secrets still works technically, but it makes maintenance harder and hides which settings are
258
+ actually sensitive.
259
+
260
+ #### Variables β€” public / non-sensitive
261
+
262
+ | Name | Required? | Typical value | Notes |
263
+ |---|---:|---|---|
264
+ | `RECORD_STORAGE_TARGETS` | Multi-store deployments | JSON array | Provider-neutral Primary + Mirrors topology. May contain repo IDs and `token_env` **names**, never token values. If repository topology itself is confidential, it is acceptable to store this JSON as a Secret instead. |
265
+ | `TRAINING_DATASET_REPO` | Legacy HF-only mode | `scikit-plots/ai-assistant-contributions` | Repository identifier, not a credential. If this is currently stored as a Secret, it can be moved to Variables unless you intentionally treat the repo identity as confidential. Ignored as the active topology when `RECORD_STORAGE_TARGETS` is set. |
266
+ | `ALLOWED_MODELS` | No | comma-separated model IDs | Exact Path-3 model allow-list. Do not use `*`; an explicit model list prevents the proxy from becoming a general-purpose inference relay. |
267
+ | `HF_SPACES_MODEL_NAMESPACES` | No | `scikit-plots` | Model owner prefixes routed through the configured Path-2 model Space. |
268
+ | `ALLOWED_ORIGINS` | Custom sites only | comma-separated origins | Exact browser origins such as `https://docs.example.org`. Origins contain only scheme + host (+ optional port): no path, query, fragment, or trailing page URL. |
269
+ | `ALLOWED_ORIGINS_MODE` | No | `additive` | `additive` keeps the bundled Scikit-plots origins and adds `ALLOWED_ORIGINS`; `replace` trusts only `ALLOWED_ORIGINS` and is the recommended mode for forks/downstream sites that want their own CORS boundary. |
270
+ | `CONTRIBUTION_REVIEW_MODE` | No | `provider-pr` or `ledger` | Native provider PR/MR review or historical ledger review. |
271
+ | `HF_TOKEN_TYPE` | Recommended | `fine-grained` / `read` / `write` | Non-secret classification label for `HF_TOKEN`; avoids `unknown` startup diagnostics. |
272
+ | `HF_DATASET_TOKEN_TYPE` | Legacy HF persistence | `fine-grained` | Non-secret classification label for the dataset-persistence token. |
273
+
274
+ The proxy has two built-in browser origins because this extension is currently deployed on both
275
+ Scikit-plots documentation sites:
276
 
277
+ ```text
278
+ https://scikit-plots.github.io
279
+ https://scikit-plots-learn.readthedocs.io
280
+ ```
281
+
282
+ With the default `ALLOWED_ORIGINS_MODE=additive`, both remain trusted and any
283
+ `ALLOWED_ORIGINS` entries are appended. A downstream/open-source deployment can take complete
284
+ control without editing `app.py`:
285
+
286
+ ```text
287
+ # Custom/fork deployment: trust only these sites
288
+ ALLOWED_ORIGINS=https://docs.example.org,https://learn.example.org
289
+ ALLOWED_ORIGINS_MODE=replace
290
+ ```
291
+
292
+ To add a site while retaining the two Scikit-plots defaults:
293
+
294
+ ```text
295
+ ALLOWED_ORIGINS=https://preview.example.org
296
+ ALLOWED_ORIGINS_MODE=additive
297
+ ```
298
+
299
+ Never use `ALLOWED_ORIGINS=*` in production. CORS is a browser abuse boundary, not authentication;
300
+ server-to-server clients without an `Origin` header still rely on their own token/capability controls.
301
+
302
+ #### Secrets β€” private / server-only
303
+
304
+ | Name | Required? | Purpose |
305
+ |---|---:|---|
306
+ | `HF_TOKEN` | Path 3 inference | Hugging Face inference credential. Prefer least privilege; it should not also be your broad repository-write token. |
307
+ | `AI_RECORD_STORAGE_TOKEN_HF_PRIMARY` | When referenced by `RECORD_STORAGE_TARGETS` | Example HF Primary write credential. The exact name is configurable through each target's `token_env`. |
308
+ | `AI_RECORD_STORAGE_TOKEN_GITHUB_MIRROR` | When referenced by `RECORD_STORAGE_TARGETS` | Example GitHub Mirror write credential. Use an independent least-privilege token. |
309
+ | `AI_RECORD_STORAGE_TOKEN_*` | Per configured target | Provider-specific HF/GitHub/GitLab/Bitbucket write credential. Only names with this prefix are accepted by storage target configuration. |
310
+ | `HF_DATASET_TOKEN` | Legacy HF-only persistence | Preferred legacy dataset token; fine-grained to the target dataset repository. |
311
+ | `CONTRIBUTION_REVIEW_TOKEN` | Optional | API-driven review/promotion capability. Not required when maintainers review entirely through provider-native PR/MR UI. |
312
+ | `RATE_LIMIT_IDENTITY_SECRET` | Redis rate limiting | HMAC key used to pseudonymize shared rate-limit identities. |
313
+ | `CONTRIBUTION_LEDGER_KEY_SECRET` | Redis contribution ledger | HMAC key used to pseudonymize receipt identifiers. |
314
+ | Redis URLs containing credentials | When Redis is used | Keep `RATE_LIMIT_REDIS_URL`, `SHARE_STORE_REDIS_URL`, and `CONTRIBUTION_LEDGER_REDIS_URL` private when they contain usernames/passwords/tokens. |
315
+
316
+ A practical Scikit-plots Space layout is therefore:
317
+
318
+ ```text
319
+ # Variables
320
+ RECORD_STORAGE_TARGETS=<provider-neutral JSON topology>
321
+ ALLOWED_MODELS=openai/gpt-oss-20b,Qwen/Qwen2.5-Coder-7B-Instruct,Qwen/Qwen2.5-Coder-32B-Instruct,scikit-plots/gpt-oss-20b,scikit-plots/Qwen2.5-Coder-7B-Instruct,scikit-plots/Qwen2.5-Coder-32B-Instruct
322
+ HF_SPACES_MODEL_NAMESPACES=scikit-plots
323
+ ALLOWED_ORIGINS_MODE=additive
324
+ # ALLOWED_ORIGINS may remain empty because both current Scikit-plots sites are built in.
325
+
326
+ # Legacy-only variable (not needed as the active topology when RECORD_STORAGE_TARGETS is used)
327
+ TRAINING_DATASET_REPO=scikit-plots/ai-assistant-contributions
328
+
329
+ # Secrets
330
+ HF_TOKEN=<inference-token>
331
+ AI_RECORD_STORAGE_TOKEN_HF_PRIMARY=<repo-scoped-write-token>
332
+ AI_RECORD_STORAGE_TOKEN_GITHUB_MIRROR=<repo-scoped-write-token>
333
+ ```
334
+
335
+ The proxy never reads provider token values from `RECORD_STORAGE_TARGETS`; it reads only the
336
+ configured `token_env` name and then resolves that environment variable server-side. Keep token
337
+ values out of `conf.py`, generated Sphinx HTML, JavaScript, logs, repository URLs, and commit metadata.
338
 
339
  See [DATASET_COLLECTION_GUIDANCE.md](./DATASET_COLLECTION_GUIDANCE.md) for the beginner-to-operator storage guide, including exact HF-only, GitHub-only, Primary+Mirror, migration, testing, and deduplication recipes.
340
 
 
402
  | `BACKEND_URL` | No | `""` | **Path 1** β€” Explicit upstream URL. All requests forwarded here when set. Use for Docker Model Runner, Ollama, or any custom backend. |
403
  | `HF_SPACES_MODEL_URL` | No | β€” | **Path 2** β€” Custom ZeroGPU Space URL (e.g. `https://scikit-plots-ai-model.hf.space/v1/chat/completions`). Receives requests whose `model` matches `HF_SPACES_MODEL_NAMESPACES`. |
404
  | `HF_SPACES_MODEL_NAMESPACES` | No | `scikit-plots` | Comma-separated model owner prefixes routed to Path 2 (e.g. `scikit-plots,my-org`). |
405
+ | `ALLOWED_MODELS` | No | `DEFAULT_MODEL` + bundled provider models | Exact model IDs accepted by the strict chat contract before routing. The bundled defaults include the configured `DEFAULT_MODEL` plus `Qwen/Qwen2.5-Coder-7B-Instruct`, `Qwen/Qwen2.5-Coder-32B-Instruct`, and `openai/gpt-oss-20b`; Path-2 namespaces remain an additional explicit admission rule. |
406
  | `HF_BASE` | No | `https://router.huggingface.co` | **Path 3** β€” HF Serverless API base URL. Only used when Path 1 and Path 2 do not match. |
407
  | `DEFAULT_MODEL` | No | `scikit-plots/Qwen2.5-Coder-7B-Instruct` | Fallback model when the request body omits `"model"`. |
408
 
 
471
 
472
  | Variable | Default | Description |
473
  |---|---|---|
474
+ | `ALLOWED_ORIGINS` | empty | Comma-separated exact browser origins. In `additive` mode they are appended to the two bundled defaults (`https://scikit-plots.github.io`, `https://scikit-plots-learn.readthedocs.io`). In `replace` mode they are the complete browser allow-list. Missing `Origin` remains valid for server-to-server clients. `*` is an explicit insecure compatibility escape hatch and is not authentication. |
475
+ | `ALLOWED_ORIGINS_MODE` | `additive` | `additive` retains the bundled project origins; `replace` starts from an empty allow-list and trusts only valid entries from `ALLOWED_ORIGINS`. Invalid values fail safely back to `additive`. |
476
  | `DEPLOYMENT_PROFILE` | `compat` | `compat` preserves explicit legacy deployment choices. The hardened Dockerfile sets `strict`, which fails startup as root, rejects wildcard origins and opaque-origin **writes**, and requires verified TLS for all configured Redis authorities. Read-only opaque Share compatibility remains a separate explicit opt-in because local `file://` viewers may need it. |
477
  | `REDIS_REQUIRE_TLS` | `false` (`true` in strict) | Requires `rediss://`; URL query parameters are rejected so callers cannot disable certificate verification through redis-py URL options. |
478
  | `REQUIRE_NON_ROOT` | `false` (`true` in strict) | Fails application startup when the process is UID 0 on POSIX. |
 
780
  # For Primary + Mirrors, use RECORD_STORAGE_TARGETS instead; see DATASET_COLLECTION_GUIDANCE.md.
781
 
782
  # Security
783
+ # Both current Scikit-plots origins are built in:
784
+ # https://scikit-plots.github.io
785
+ # https://scikit-plots-learn.readthedocs.io
786
+ # Leave empty to use only those defaults, or add exact custom origins.
787
  ALLOWED_ORIGINS =
788
+ ALLOWED_ORIGINS_MODE = additive
789
+ # Fork/downstream alternative:
790
+ # ALLOWED_ORIGINS=https://docs.example.org,https://learn.example.org
791
+ # ALLOWED_ORIGINS_MODE=replace
792
  # Optional read-only local-file (Origin:null) Share compatibility.
793
  # Keep false unless you explicitly need Global Share viewing from file:// pages.
794
  SHARE_ALLOW_OPAQUE_ORIGIN = false
 
805
 
806
  # 1. Liveness probe
807
  curl $BASE/health
808
+ # {"status":"ok","version":"7.3.0"}
809
 
810
  # Optional deterministic stub rig status
811
  curl -s $BASE/health | python3 -m json.tool
_utils/_shared_logic.py CHANGED
@@ -1413,6 +1413,8 @@ def load_proxy_env() -> dict[str, Any]:
1413
  Path 3 read timeout (env ``PATH3_TIMEOUT``).
1414
  ``max_body_bytes`` : int
1415
  ``allowed_origins`` : str
 
 
1416
  ``hf_token_type`` : str
1417
  Classified token type for *hf_token* (env ``HF_TOKEN_TYPE``).
1418
  One of ``"fine-grained"``, ``"read"``, ``"write"``, ``"unknown"``.
@@ -1504,4 +1506,8 @@ def load_proxy_env() -> dict[str, Any]:
1504
  DEFAULT_MAX_BODY_BYTES,
1505
  ),
1506
  "allowed_origins": os.environ.get("ALLOWED_ORIGINS", "").strip(),
 
 
 
 
1507
  }
 
1413
  Path 3 read timeout (env ``PATH3_TIMEOUT``).
1414
  ``max_body_bytes`` : int
1415
  ``allowed_origins`` : str
1416
+ ``allowed_origins_mode`` : str
1417
+ Raw deployment composition mode (``additive`` or ``replace``).
1418
  ``hf_token_type`` : str
1419
  Classified token type for *hf_token* (env ``HF_TOKEN_TYPE``).
1420
  One of ``"fine-grained"``, ``"read"``, ``"write"``, ``"unknown"``.
 
1506
  DEFAULT_MAX_BODY_BYTES,
1507
  ),
1508
  "allowed_origins": os.environ.get("ALLOWED_ORIGINS", "").strip(),
1509
+ "allowed_origins_mode": (
1510
+ os.environ.get("ALLOWED_ORIGINS_MODE", "additive").strip().lower()
1511
+ or "additive"
1512
+ ),
1513
  }
app.py CHANGED
@@ -87,7 +87,8 @@
87
  # persistence; fine-grained repo-write is preferred.
88
  # HF_WRITE_TOKEN_TYPE Legacy type declaration for HF_WRITE_TOKEN.
89
  # Accepted values: fine-grained | read | write.
90
- # ALLOWED_ORIGINS Comma-separated additional exact CORS origins. The official docs origin is always included; set * only as an explicit insecure compatibility escape hatch.
 
91
  # RECORD_STORAGE_TARGETS Optional JSON array defining one primary record
92
  # store plus mirrors (huggingface/github/gitlab/bitbucket).
93
  # Credentials are referenced only through env names with
@@ -993,8 +994,11 @@ _protocol_retries: int = max(
993
  # CORS
994
  # ─────────────────────────────────────────────────────────────────────────────
995
 
996
- _DEFAULT_ALLOWED_ORIGINS = "https://scikit-plots.github.io"
997
- _OFFICIAL_ALLOWED_ORIGINS: tuple[str, ...] = (_DEFAULT_ALLOWED_ORIGINS,)
 
 
 
998
 
999
 
1000
  def _normalise_browser_origin(value: str) -> str:
@@ -1017,18 +1021,32 @@ def _normalise_browser_origin(value: str) -> str:
1017
  return f"{parsed.scheme.lower()}://{parsed.netloc.lower()}"
1018
 
1019
 
1020
- def _build_allowed_origins(raw: str) -> list[str]:
 
 
 
 
 
 
 
 
 
 
 
1021
  """Build the exact browser-origin allowlist.
1022
 
1023
- The official Scikit-Plots documentation origin is a package-owned trust
1024
- anchor and cannot be accidentally removed by an ``ALLOWED_ORIGINS``
1025
- deployment override. The environment variable adds exact origins. Only
1026
- an explicit ``*`` selects wildcard compatibility mode.
1027
  """
1028
  text = str(raw or "").strip()
1029
  if text == "*":
1030
  return ["*"]
1031
- merged: list[str] = list(_OFFICIAL_ALLOWED_ORIGINS)
 
 
 
1032
  for item in text.split(","):
1033
  item = item.strip() # ruff: ignore[redefined-loop-name]
1034
  if not item:
@@ -1045,7 +1063,12 @@ def _build_allowed_origins(raw: str) -> list[str]:
1045
 
1046
 
1047
  _raw_origins: str = os.environ.get("ALLOWED_ORIGINS", "").strip()
1048
- _allowed_origins: list[str] = _build_allowed_origins(_raw_origins)
 
 
 
 
 
1049
 
1050
  #: Optional Share-only compatibility for browser opaque origins such as
1051
  #: ``file://`` documents, which browsers serialize as ``Origin: null``. This is
@@ -1076,9 +1099,13 @@ if _allowed_origins == ["*"]:
1076
  "AI proxy [CORS_WILDCARD]: ALLOWED_ORIGINS=* explicitly enables every browser origin; "
1077
  "use an exact comma-separated origin list in production."
1078
  )
 
 
 
 
1079
  elif _raw_origins:
1080
  logger.info(
1081
- "AI proxy CORS: official docs origin retained; ALLOWED_ORIGINS contributes additional exact origins."
1082
  )
1083
 
1084
  #: Trust ``X-Forwarded-For`` only when a known ingress proxy strips/overwrites
@@ -2706,14 +2733,20 @@ def _canonical_payload_digest(value: Any) -> str:
2706
  def _cors_public_status() -> dict[str, Any]:
2707
  """Return public CORS diagnostics without exposing custom/private origins."""
2708
  wildcard = _allowed_origins == ["*"]
 
2709
  return {
2710
- "official_docs_origin": _DEFAULT_ALLOWED_ORIGINS,
 
2711
  "official_docs_origin_allowed": (
2712
- wildcard or _DEFAULT_ALLOWED_ORIGINS in _allowed_origins
 
 
 
 
2713
  ),
2714
  "wildcard": wildcard,
2715
  "allowed_origin_count": None if wildcard else len(_allowed_origins),
2716
- "env_semantics": "additive",
2717
  "share_opaque_origin_allowed": SHARE_ALLOW_OPAQUE_ORIGIN,
2718
  "share_opaque_origin_write_allowed": bool(
2719
  SHARE_ALLOW_OPAQUE_ORIGIN and SHARE_ALLOW_OPAQUE_ORIGIN_WRITE
 
87
  # persistence; fine-grained repo-write is preferred.
88
  # HF_WRITE_TOKEN_TYPE Legacy type declaration for HF_WRITE_TOKEN.
89
  # Accepted values: fine-grained | read | write.
90
+ # ALLOWED_ORIGINS Comma-separated exact CORS origins. Additive by default; use ALLOWED_ORIGINS_MODE=replace for downstream sites.
91
+ # ALLOWED_ORIGINS_MODE additive (default) keeps built-in Scikit-plots origins; replace trusts only ALLOWED_ORIGINS.
92
  # RECORD_STORAGE_TARGETS Optional JSON array defining one primary record
93
  # store plus mirrors (huggingface/github/gitlab/bitbucket).
94
  # Credentials are referenced only through env names with
 
994
  # CORS
995
  # ─────────────────────────────────────────────────────────────────────────────
996
 
997
+ _DEFAULT_ALLOWED_ORIGINS: tuple[str, ...] = (
998
+ "https://scikit-plots.github.io",
999
+ "https://scikit-plots-learn.readthedocs.io",
1000
+ )
1001
+ _ALLOWED_ORIGIN_MODES: frozenset[str] = frozenset({"additive", "replace"})
1002
 
1003
 
1004
  def _normalise_browser_origin(value: str) -> str:
 
1021
  return f"{parsed.scheme.lower()}://{parsed.netloc.lower()}"
1022
 
1023
 
1024
+ def _normalise_allowed_origins_mode(value: str) -> str:
1025
+ """Return the supported CORS composition mode without echoing bad input."""
1026
+ mode = str(value or "additive").strip().lower() or "additive"
1027
+ if mode not in _ALLOWED_ORIGIN_MODES:
1028
+ logger.warning(
1029
+ "AI proxy [CORS_MODE_INVALID]: invalid ALLOWED_ORIGINS_MODE; using additive defaults."
1030
+ )
1031
+ return "additive"
1032
+ return mode
1033
+
1034
+
1035
+ def _build_allowed_origins(raw: str, *, mode: str = "additive") -> list[str]:
1036
  """Build the exact browser-origin allowlist.
1037
 
1038
+ ``additive`` retains the package defaults and appends deployment-specific
1039
+ exact origins. ``replace`` starts empty so downstream/open-source deployments
1040
+ can own their complete browser-origin trust boundary without editing source.
1041
+ Only an explicit ``*`` selects wildcard compatibility mode.
1042
  """
1043
  text = str(raw or "").strip()
1044
  if text == "*":
1045
  return ["*"]
1046
+ selected_mode = _normalise_allowed_origins_mode(mode)
1047
+ merged: list[str] = (
1048
+ list(_DEFAULT_ALLOWED_ORIGINS) if selected_mode == "additive" else []
1049
+ )
1050
  for item in text.split(","):
1051
  item = item.strip() # ruff: ignore[redefined-loop-name]
1052
  if not item:
 
1063
 
1064
 
1065
  _raw_origins: str = os.environ.get("ALLOWED_ORIGINS", "").strip()
1066
+ ALLOWED_ORIGINS_MODE: str = _normalise_allowed_origins_mode(
1067
+ os.environ.get("ALLOWED_ORIGINS_MODE", "additive")
1068
+ )
1069
+ _allowed_origins: list[str] = _build_allowed_origins(
1070
+ _raw_origins, mode=ALLOWED_ORIGINS_MODE
1071
+ )
1072
 
1073
  #: Optional Share-only compatibility for browser opaque origins such as
1074
  #: ``file://`` documents, which browsers serialize as ``Origin: null``. This is
 
1099
  "AI proxy [CORS_WILDCARD]: ALLOWED_ORIGINS=* explicitly enables every browser origin; "
1100
  "use an exact comma-separated origin list in production."
1101
  )
1102
+ elif ALLOWED_ORIGINS_MODE == "replace":
1103
+ logger.info(
1104
+ "AI proxy CORS: replacement mode active; only exact ALLOWED_ORIGINS entries are trusted."
1105
+ )
1106
  elif _raw_origins:
1107
  logger.info(
1108
+ "AI proxy CORS: built-in documentation origins retained; ALLOWED_ORIGINS contributes additional exact origins."
1109
  )
1110
 
1111
  #: Trust ``X-Forwarded-For`` only when a known ingress proxy strips/overwrites
 
2733
  def _cors_public_status() -> dict[str, Any]:
2734
  """Return public CORS diagnostics without exposing custom/private origins."""
2735
  wildcard = _allowed_origins == ["*"]
2736
+ primary_default = _DEFAULT_ALLOWED_ORIGINS[0]
2737
  return {
2738
+ # Backward-compatible singular fields refer to the primary project origin.
2739
+ "official_docs_origin": primary_default,
2740
  "official_docs_origin_allowed": (
2741
+ wildcard or primary_default in _allowed_origins
2742
+ ),
2743
+ "default_allowed_origin_count": len(_DEFAULT_ALLOWED_ORIGINS),
2744
+ "default_allowed_origins_allowed": (
2745
+ wildcard or all(origin in _allowed_origins for origin in _DEFAULT_ALLOWED_ORIGINS)
2746
  ),
2747
  "wildcard": wildcard,
2748
  "allowed_origin_count": None if wildcard else len(_allowed_origins),
2749
+ "env_semantics": ALLOWED_ORIGINS_MODE,
2750
  "share_opaque_origin_allowed": SHARE_ALLOW_OPAQUE_ORIGIN,
2751
  "share_opaque_origin_write_allowed": bool(
2752
  SHARE_ALLOW_OPAQUE_ORIGIN and SHARE_ALLOW_OPAQUE_ORIGIN_WRITE