Skip to content

Stabilize the stubbed workspace APIs#5

Merged
jeffmaki merged 17 commits into
mainfrom
stabilize-workspace-apis
May 20, 2026
Merged

Stabilize the stubbed workspace APIs#5
jeffmaki merged 17 commits into
mainfrom
stabilize-workspace-apis

Conversation

@cyrossignol
Copy link
Copy Markdown
Collaborator

@cyrossignol cyrossignol commented May 19, 2026

This is a bag of changes that fix various omissions, security issues, and behavior differences in the stubbed workspaces APIs that were modeled from the old embedded backend. The changes align behavior as expected by several parts of the frontend and AVIV ScoutRoute.

Please see the individual commits for details.

Configuration and Infrastructure

The configuration module now exposes schema-related URLs for workspace validation: LONGFORM_SCHEMA_URL (renamed from WS_LONGFORM_SCHEMA_URL) and a new IMAGERY_SCHEMA_URL setting for imagery list schemas.

A new json_schema.py module provides shared JSON schema fetching and validation utilities. It manages a global async HTTP client with timeouts, implements lazy-loading of remote JSON schemas using double-checked locking to prevent redundant fetches, and provides two validation functions (validate_quest_definition_schema and validate_imagery_definition_schema) that translate validation and fetch failures into appropriate FastAPI HTTP exceptions (504 for timeouts, 502 for fetch errors, 400 for validation errors).

The main FastAPI app lifespan now initializes and tears down the JSON schema client alongside existing OSM and TDEI clients. The authentication bypass whitelist was updated to allow workspace creation/deletion paths (/api/0.6/workspaces) and provisioning paths (/api/0.6/user/), replacing the previous specific bbox retrieval endpoint.

Workspace Repository Updates

The WorkspaceRepository now enforces stricter access control and type safety:

  • create() now accepts typed WorkspaceCreate input and validates project-group membership using tdeiProjectGroupId, raising ForbiddenException on access violations
  • update() now accepts WorkspacePatch and applies only explicitly set fields
  • Separate create/update methods for longform quests and imagery definitions were replaced with unified upsert methods (save_longform_quest and save_imagery_def) that perform update-then-insert logic
  • Added resolve_quest_def() helper to handle quest definition resolution from either JSON or remote URLs with a 10-second timeout

The OSMRepository.getWorkspaceBBox() method signature was simplified by removing the unused current_user parameter.

API Routes and Response Contracts

Workspace endpoints now align with API expectations:

  • Workspace retrieval resolves the long-form quest definition and passes both quest and imagery definitions into the response
  • Workspace creation returns a minimal payload with only workspaceId (status 201) instead of the full workspace model
  • Workspace updates return 204 (no content) with WorkspacePatch validation, rejecting requests with no fields set

New quest-focused endpoints:

  • New GET /{workspace_id}/quests/long endpoint returns the raw JSON definition (or 204 if not set)
  • Long quest settings retrieval now returns QuestSettingsResponse with a NONE type mapping for unset quests
  • Long quest settings updates accept QuestSettingsPatch with schema validation when type is JSON

Imagery endpoints similarly updated:

  • Imagery settings retrieval returns workspace.imageryListDef directly when present
  • Imagery settings updates accept ImagerySettingsPatch with schema validation when definition is provided

Schema Models

New Pydantic models provide type-safe request/response contracts: WorkspaceCreate, WorkspacePatch, QuestSettingsPatch (with conditional validation based on quest type), QuestSettingsResponse, and ImagerySettingsPatch. A new QuestDefinitionTypeName enum standardizes quest type naming.

The WorkspaceResponse model was extended with optional longFormQuestDef and imageryListDef fields for mobile consumption, and its from_workspace constructor now accepts these as keyword-only parameters.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 19, 2026

📝 Walkthrough

Walkthrough

This PR refactors workspace quest and imagery definition management by introducing JSON schema validation infrastructure, typed API request/response schemas, and refactoring repository CRUD methods into type-safe upsert operations. It adds configuration for remote schema URLs, implements lazy-cached schema fetching with double-checked locking, updates all affected endpoints to use the new schemas, and adds schema validation during definition updates.

Changes

Workspace Quest and Imagery Definition Management

Layer / File(s) Summary
JSON Schema Infrastructure and Configuration
api/core/config.py, api/core/json_schema.py, api/main.py
Adds LONGFORM_SCHEMA_URL and IMAGERY_SCHEMA_URL config, implements shared async HTTP client with init/close lifecycle, provides lazy-cached remote schema fetching with per-schema async locks, exports validate_quest_definition_schema and validate_imagery_definition_schema that map parse/validation/timeout errors to HTTP 400/504/502, and integrates client lifecycle into FastAPI app startup/shutdown. Auth whitelist updated to permit workspace creation/deletion and user provisioning routes.
Workspace and Quest API Schema Types
api/src/workspaces/schemas.py
Introduces QuestDefinitionTypeName enum (NONE/JSON/URL) and new request/patch/response models: WorkspaceCreate, WorkspacePatch, QuestSettingsPatch (with type-dependent validation), QuestSettingsResponse, ImagerySettingsPatch. Extends WorkspaceResponse with longFormQuestDef and imageryListDef fields and updates from_workspace constructor to accept these as keyword-only parameters.
Repository Workspace CRUD with Type Safety
api/src/workspaces/repository.py
Updates create to accept WorkspaceCreate, set createdBy/createdByName from UserInfo, enforce project-group membership permission check with ForbiddenException. Updates update to accept WorkspacePatch and apply only explicitly set fields via model_dump(exclude_unset=True).
Repository Quest and Imagery Definition Upsert
api/src/workspaces/repository.py
Replaces separate create/update CRUD methods with unified save_longform_quest and save_imagery_def upsert operations (update-then-insert via rowcount). Adds resolve_quest_def static async helper to return stored JSON directly or fetch from URL (10s timeout), returning None on error/missing. Sets modifiedBy/modifiedByName from UserInfo on save.
Routes: Workspace Retrieval and Response
api/src/workspaces/routes.py
Updates get_workspace to resolve longFormQuestDef via resolve_quest_def, conditionally parse from JSON, and pass imagery_list_def and long_form_quest_def into WorkspaceResponse.from_workspace. Refactors get_workspace_bbox to call repository_ws.getById() first for access check before returning bbox.
Routes: Workspace Creation and Update
api/src/workspaces/routes.py
Changes create_workspace to accept WorkspaceCreate and return minimal {"workspaceId": ...} (201) instead of full model. Changes update_workspace to accept WorkspacePatch, return 204 with no body, reject empty patches, maintain lead-only authorization.
Routes: Quest Definition Endpoints
api/src/workspaces/routes.py
Adds new /{workspace_id}/quests/long endpoint returning raw quest definition JSON (204 when unset). Refactors get_long_quest_settings to return QuestSettingsResponse with QuestDefinitionTypeName and NONE fallback. Updates update_long_quest_settings to accept QuestSettingsPatch, validate definition schema for JSON type, persist via save_longform_quest.
Routes: Imagery Definition Endpoints
api/src/workspaces/routes.py
Updates get_imagery_settings to return workspace.imageryListDef directly. Updates update_imagery_settings to accept ImagerySettingsPatch, validate definition schema when provided, persist via save_imagery_def.

Sequence Diagram

sequenceDiagram
  participant Client
  participant Route
  participant json_schema
  participant RemoteSchema
  participant Repository
  Client->>Route: PATCH quest with definition
  Route->>json_schema: validate_quest_definition_schema(definition)
  json_schema->>json_schema: get_longform_schema (lazy cached)
  json_schema->>RemoteSchema: fetch schema.json (first time)
  RemoteSchema-->>json_schema: schema cached
  json_schema->>json_schema: jsonschema.validate(definition)
  alt schema valid
    json_schema-->>Route: validation passed
    Route->>Repository: save_longform_quest()
    Repository-->>Route: saved
    Route-->>Client: 204
  else schema invalid
    json_schema-->>Route: HTTPException 400
    Route-->>Client: 400 error
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • jeffmaki

Poem

🐰 A quest definition, neat and typed,
Now validates before it's piped,
With schemas cached and upserts clean,
The fairest API ever seen!
Imagery lists join the show,
Type-safe endpoints steal the glow. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main objective of the pull request: stabilizing workspace APIs that were previously stubbed, which aligns with the PR's stated goal of fixing omissions, security issues, and behavioral differences.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (2)
api/src/workspaces/repository.py (1)

144-150: ⚡ Quick win

Narrow the exception catch to specific HTTP errors.

The bare except Exception can inadvertently swallow programming errors (e.g., AttributeError, TypeError). Consider catching specific httpx exceptions to preserve the "return None on fetch failure" behavior while letting unexpected errors propagate.

♻️ Suggested narrower exception handling
+from httpx import HTTPError, TimeoutException
+
 # ... in resolve_quest_def:
         if quest.url:
             try:
                 response = await get_http_client().get(quest.url, timeout=10)
                 return response.text
-            except Exception:
+            except (HTTPError, TimeoutException):
                 return None
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/src/workspaces/repository.py` around lines 144 - 150, The current bare
`except Exception` around the HTTP fetch for `quest.url` (using
`get_http_client()` and returning `response.text`) swallows all errors; change
it to only catch HTTP/client errors (e.g., httpx.RequestError,
httpx.HTTPStatusError, httpx.TimeoutException) and return None for those cases,
while letting other unexpected exceptions propagate; import the appropriate
httpx exception classes and replace the generic except with a specific except
clause (optionally binding the exception to a variable like `e` for logging) so
programming errors (AttributeError/TypeError) are not suppressed.
api/src/workspaces/routes.py (1)

339-343: 💤 Low value

Truthy check skips validation for empty imagery lists.

The condition if imagery_data.definition: evaluates to False for empty lists [], meaning validate_imagery_definition_schema won't be called when an empty list is explicitly provided. If empty lists should also be schema-validated (or at minimum pass through the validator), consider an explicit None check instead.

If empty lists are trivially valid and this is intentional, no change needed.

Suggested fix if validation is desired for empty lists
-    if imagery_data.definition:
+    if imagery_data.definition is not None:
         await validate_imagery_definition_schema(imagery_data.definition)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/src/workspaces/routes.py` around lines 339 - 343, The current truthy
check skips validation for empty lists because `if imagery_data.definition:`
treats [] as False; change the condition to an explicit None check so empty
lists are passed into the validator. Modify the block around
`imagery_data.definition`, calling
`validate_imagery_definition_schema(imagery_data.definition)` when
`imagery_data.definition is not None`, ensuring
`validate_imagery_definition_schema` is invoked for [] as well before calling
`repository_ws.save_imagery_def(current_user, workspace_id, imagery_data)`.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@api/core/config.py`:
- Around line 29-34: The schema URLs LONGFORM_SCHEMA_URL and IMAGERY_SCHEMA_URL
currently point to refs/heads/main and must be pinned to immutable references;
update these constants in api/core/config.py to use either the exact commit SHA
or a release tag (or switch to vendored local schema files and point to local
file paths), e.g., replace the GitHub raw URLs containing refs/heads/main with
raw URLs containing the commit hash or change to "file://" or package-relative
paths to bundled schemas; ensure any code that loads these constants (config
consumers) still reads the new values and update tests or deployment docs if
needed.

In `@api/core/json_schema.py`:
- Around line 95-96: Replace the broad except Exception handlers around the
HTTP/schema fetch logic with specific exception catches: catch httpx.HTTPError
(to cover network, status and timeout errors from
_http_client.get()/response.raise_for_status()) and json.JSONDecodeError (for
response.json() failures), and pass those exceptions into _raise_for_fetch_error
with the appropriate schema identifier ("quest" and "imagery"); update both
catch sites that currently call _raise_for_fetch_error(e, "quest") and
_raise_for_fetch_error(e, "imagery") so only these two exception types are
converted to 502s while letting other exceptions propagate.

In `@api/main.py`:
- Around line 147-150: The whitelist regex r"^/api/0\.6/workspaces.*$" is too
permissive and bypasses X-Workspace checks for all /workspaces paths; narrow the
pattern(s) in api/main.py (and the similar entries around lines 228-232) to
match only the specific endpoints that should bypass workspace checks (e.g., the
exact create/delete/JOSM rewrite endpoints) by using precise path tokens and
anchors (avoid trailing ".*"), and if the bypass is conditional on HTTP method,
ensure the logic checks the request method in the same handler (refer to the
whitelist entries containing r"^/api/0\.6/workspaces" and the corresponding
JOSM/provisioning whitelist entries to implement the stricter regexes or
method-aware checks).

In `@api/src/workspaces/schemas.py`:
- Around line 153-170: The code currently uses truthiness checks on
self.definition and self.url which lets empty strings bypass forbidden/required
rules; update the conditional checks in the validation block to use explicit
None checks: use "is None" when checking required fields (e.g., in the
QuestDefinitionTypeName.JSON branch check "if self.definition is None: raise
...") and use "is not None" when forbidding fields (e.g., in the
QuestDefinitionTypeName.NONE and URL branches check "if self.definition is not
None: raise ..." and similarly for self.url). Keep the existing .strip() JSON
object check but only after confirming the value is not None. Ensure you update
all branches that reference self.definition/self.url (the validation code
handling QuestDefinitionTypeName.NONE, .JSON, and .URL).

---

Nitpick comments:
In `@api/src/workspaces/repository.py`:
- Around line 144-150: The current bare `except Exception` around the HTTP fetch
for `quest.url` (using `get_http_client()` and returning `response.text`)
swallows all errors; change it to only catch HTTP/client errors (e.g.,
httpx.RequestError, httpx.HTTPStatusError, httpx.TimeoutException) and return
None for those cases, while letting other unexpected exceptions propagate;
import the appropriate httpx exception classes and replace the generic except
with a specific except clause (optionally binding the exception to a variable
like `e` for logging) so programming errors (AttributeError/TypeError) are not
suppressed.

In `@api/src/workspaces/routes.py`:
- Around line 339-343: The current truthy check skips validation for empty lists
because `if imagery_data.definition:` treats [] as False; change the condition
to an explicit None check so empty lists are passed into the validator. Modify
the block around `imagery_data.definition`, calling
`validate_imagery_definition_schema(imagery_data.definition)` when
`imagery_data.definition is not None`, ensuring
`validate_imagery_definition_schema` is invoked for [] as well before calling
`repository_ws.save_imagery_def(current_user, workspace_id, imagery_data)`.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ee657b0c-ba88-49a7-8e52-b6dd16f44410

📥 Commits

Reviewing files that changed from the base of the PR and between ad29762 and 443b697.

📒 Files selected for processing (6)
  • api/core/config.py
  • api/core/json_schema.py
  • api/main.py
  • api/src/workspaces/repository.py
  • api/src/workspaces/routes.py
  • api/src/workspaces/schemas.py

Comment thread api/core/config.py
Comment thread api/core/json_schema.py
Comment on lines +95 to +96
except Exception as e:
_raise_for_fetch_error(e, "quest")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify current broad exception handling and where it appears.
rg -n -C2 'except Exception as e:' api/core/json_schema.py

# Confirm narrowed fetch-only exception handling after applying the fix.
rg -n -C2 'except \(httpx\.TimeoutException, httpx\.HTTPError\) as e:' api/core/json_schema.py

Repository: TaskarCenterAtUW/workspaces-backend

Length of output: 341


🏁 Script executed:

# Read the file to see the full context around the problematic lines
head -120 api/core/json_schema.py | tail -30

Repository: TaskarCenterAtUW/workspaces-backend

Length of output: 956


🏁 Script executed:

# Check the imports at the top of the file to see what's imported
head -20 api/core/json_schema.py

Repository: TaskarCenterAtUW/workspaces-backend

Length of output: 497


🏁 Script executed:

# Look for _raise_for_fetch_error function definition
rg -n -A 5 'def _raise_for_fetch_error' api/core/json_schema.py

Repository: TaskarCenterAtUW/workspaces-backend

Length of output: 358


🏁 Script executed:

# Search for httpx imports to verify the exception types
rg -n 'import.*httpx|from.*httpx' api/core/json_schema.py

Repository: TaskarCenterAtUW/workspaces-backend

Length of output: 94


🏁 Script executed:

# Get the complete _raise_for_fetch_error function
sed -n '61,80p' api/core/json_schema.py

Repository: TaskarCenterAtUW/workspaces-backend

Length of output: 757


🏁 Script executed:

# Look for the _fetch_longform_schema and _fetch_imagery_schema functions to see what they do
rg -n -A 10 'async def _fetch_longform_schema|async def _fetch_imagery_schema' api/core/json_schema.py

Repository: TaskarCenterAtUW/workspaces-backend

Length of output: 923


🏁 Script executed:

# Check what httpx exception types are available and used
rg -n 'httpx\.' api/core/json_schema.py

Repository: TaskarCenterAtUW/workspaces-backend

Length of output: 346


🌐 Web query:

httpx exception hierarchy TimeoutException HTTPError HTTPStatusError

💡 Result:

In the httpx library, all exceptions inherit from a common base class, HTTPError [1]. The exception hierarchy is structured to allow for granular error handling, distinguishing between general request issues, transport-level problems, and HTTP status errors [1][2]. The relationship between the requested exceptions is as follows: 1. HTTPError: This is the base class for all exceptions raised by the httpx library [1][2]. 2. TimeoutException: This class inherits from TransportError, which in turn inherits from RequestError, and finally from HTTPError [1]. It serves as the base class for all timeout-related errors, including ConnectTimeout, ReadTimeout, WriteTimeout, and PoolTimeout [1][2]. 3. HTTPStatusError: This class inherits directly from HTTPError [1]. It is raised when a response has an error status code (e.g., 4xx or 5xx) and raise_for_status is called [2]. The full hierarchy is organized as follows [1]: - HTTPError - RequestError - TransportError - TimeoutException (base for ConnectTimeout, ReadTimeout, WriteTimeout, PoolTimeout) - NetworkError (base for ConnectError, ReadError, WriteError, CloseError) - ProtocolError (base for LocalProtocolError, RemoteProtocolError) - ProxyError - UnsupportedProtocol - DecodingError - TooManyRedirects - HTTPStatusError Other notable exceptions, such as InvalidURL, CookieConflict, and various StreamError subclasses, also exist outside of the RequestError branch but remain under the HTTPError umbrella [1][2].

Citations:


🏁 Script executed:

# Check if there are other exception handlers in the codebase for httpx
rg -n 'except.*httpx\.' api/core/json_schema.py

Repository: TaskarCenterAtUW/workspaces-backend

Length of output: 61


🏁 Script executed:

# Check if json.JSONDecodeError is handled elsewhere or if there are similar patterns in the codebase
rg -n 'json\.JSONDecodeError' api/core/json_schema.py

Repository: TaskarCenterAtUW/workspaces-backend

Length of output: 120


🏁 Script executed:

# Look at the usage context - what calls validate_quest_definition_schema
rg -rn 'validate_quest_definition_schema|validate_imagery_definition_schema' --type py

Repository: TaskarCenterAtUW/workspaces-backend

Length of output: 483


Catch specific exceptions instead of all exceptions around schema fetch.

Lines 95-96 and 115-116 currently catch all exceptions and convert them to 502 errors, which can hide real app bugs like malformed JSON responses. The fetch operations call _http_client.get(), response.raise_for_status(), and response.json(), which can raise httpx.HTTPError (covers all httpx exceptions including timeouts) and json.JSONDecodeError. Catch these specific exceptions:

-    except Exception as e:
+    except (httpx.HTTPError, json.JSONDecodeError) as e:
         _raise_for_fetch_error(e, "quest")

Apply the same fix at line 115-116 for the imagery schema fetch.

Also applies to: 115-116

🧰 Tools
🪛 Ruff (0.15.13)

[warning] 95-95: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/core/json_schema.py` around lines 95 - 96, Replace the broad except
Exception handlers around the HTTP/schema fetch logic with specific exception
catches: catch httpx.HTTPError (to cover network, status and timeout errors from
_http_client.get()/response.raise_for_status()) and json.JSONDecodeError (for
response.json() failures), and pass those exceptions into _raise_for_fetch_error
with the appropriate schema identifier ("quest" and "imagery"); update both
catch sites that currently call _raise_for_fetch_error(e, "quest") and
_raise_for_fetch_error(e, "imagery") so only these two exception types are
converted to 502s while letting other exceptions propagate.

Comment thread api/main.py
Comment on lines +147 to +150
# Creating/deleting workspaces and JOSM path rewriting:
r"^/api/0\.6/workspaces.*$",
# Provisioning users during authentication:
r"^/api/0\.6/user/.*$",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Whitelist regex is overly broad and weakens access control.

Line 148 (^/api/0\.6/workspaces.*$) matches every /workspaces proxy path, so requests can skip X-Workspace checks far beyond create/delete operations.

Suggested change
-AUTH_WHITELIST_PATTERNS = [
-    re.compile(p)
-    for p in [
-        r"^/api/0\.6/workspaces.*$",
-        r"^/api/0\.6/user/.*$",
-    ]
-]
+AUTH_WHITELIST = [
+    (re.compile(r"^/api/0\.6/workspaces/create\.json$"), {"POST"}),
+    (re.compile(r"^/api/0\.6/workspaces/[0-9]+/delete\.json$"), {"POST", "DELETE"}),
+    (re.compile(r"^/api/0\.6/user/.*$"), {"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"}),
+]
...
-        if not any(p.fullmatch(request.url.path) for p in AUTH_WHITELIST_PATTERNS):
+        if not any(
+            p.fullmatch(request.url.path) and request.method in methods
+            for p, methods in AUTH_WHITELIST
+        ):
             raise HTTPException(
                 status_code=status.HTTP_400_BAD_REQUEST,
                 detail="No X-Workspace header supplied",
             )

Also applies to: 228-232

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/main.py` around lines 147 - 150, The whitelist regex
r"^/api/0\.6/workspaces.*$" is too permissive and bypasses X-Workspace checks
for all /workspaces paths; narrow the pattern(s) in api/main.py (and the similar
entries around lines 228-232) to match only the specific endpoints that should
bypass workspace checks (e.g., the exact create/delete/JOSM rewrite endpoints)
by using precise path tokens and anchors (avoid trailing ".*"), and if the
bypass is conditional on HTTP method, ensure the logic checks the request method
in the same handler (refer to the whitelist entries containing
r"^/api/0\.6/workspaces" and the corresponding JOSM/provisioning whitelist
entries to implement the stricter regexes or method-aware checks).

Comment on lines +153 to +170
if self.definition:
raise ValueError("'definition' field not allowed when type is NONE.")
if self.url:
raise ValueError("'url' field not allowed when type is NONE.")
elif self.type == QuestDefinitionTypeName.JSON:
if not self.definition:
raise ValueError("'definition' is required when type is JSON.")
if self.url:
raise ValueError("'url' field not allowed when type is JSON.")
# Inexpensive early check. Full JSON parse and schema validation
# must call validate_quest_definition_schema():
if not self.definition.strip().startswith("{"):
raise ValueError("'definition' must be a JSON object.")
elif self.type == QuestDefinitionTypeName.URL:
if not self.url:
raise ValueError("'url' is required when type is URL.")
if self.definition:
raise ValueError("'definition' field not allowed when type is URL.")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use explicit None checks for forbidden quest fields.

Lines 153-170 rely on truthiness, so empty strings can slip through forbidden-field rules (e.g., definition="" when type is NONE).

Suggested change
         if self.type == QuestDefinitionTypeName.NONE:
-            if self.definition:
+            if self.definition is not None:
                 raise ValueError("'definition' field not allowed when type is NONE.")
-            if self.url:
+            if self.url is not None:
                 raise ValueError("'url' field not allowed when type is NONE.")
         elif self.type == QuestDefinitionTypeName.JSON:
-            if not self.definition:
+            if self.definition is None or not self.definition.strip():
                 raise ValueError("'definition' is required when type is JSON.")
-            if self.url:
+            if self.url is not None:
                 raise ValueError("'url' field not allowed when type is JSON.")
             # Inexpensive early check. Full JSON parse and schema validation
             # must call validate_quest_definition_schema():
             if not self.definition.strip().startswith("{"):
                 raise ValueError("'definition' must be a JSON object.")
         elif self.type == QuestDefinitionTypeName.URL:
-            if not self.url:
+            if self.url is None or not self.url.strip():
                 raise ValueError("'url' is required when type is URL.")
-            if self.definition:
+            if self.definition is not None:
                 raise ValueError("'definition' field not allowed when type is URL.")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if self.definition:
raise ValueError("'definition' field not allowed when type is NONE.")
if self.url:
raise ValueError("'url' field not allowed when type is NONE.")
elif self.type == QuestDefinitionTypeName.JSON:
if not self.definition:
raise ValueError("'definition' is required when type is JSON.")
if self.url:
raise ValueError("'url' field not allowed when type is JSON.")
# Inexpensive early check. Full JSON parse and schema validation
# must call validate_quest_definition_schema():
if not self.definition.strip().startswith("{"):
raise ValueError("'definition' must be a JSON object.")
elif self.type == QuestDefinitionTypeName.URL:
if not self.url:
raise ValueError("'url' is required when type is URL.")
if self.definition:
raise ValueError("'definition' field not allowed when type is URL.")
if self.type == QuestDefinitionTypeName.NONE:
if self.definition is not None:
raise ValueError("'definition' field not allowed when type is NONE.")
if self.url is not None:
raise ValueError("'url' field not allowed when type is NONE.")
elif self.type == QuestDefinitionTypeName.JSON:
if self.definition is None or not self.definition.strip():
raise ValueError("'definition' is required when type is JSON.")
if self.url is not None:
raise ValueError("'url' field not allowed when type is JSON.")
# Inexpensive early check. Full JSON parse and schema validation
# must call validate_quest_definition_schema():
if not self.definition.strip().startswith("{"):
raise ValueError("'definition' must be a JSON object.")
elif self.type == QuestDefinitionTypeName.URL:
if self.url is None or not self.url.strip():
raise ValueError("'url' is required when type is URL.")
if self.definition is not None:
raise ValueError("'definition' field not allowed when type is URL.")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/src/workspaces/schemas.py` around lines 153 - 170, The code currently
uses truthiness checks on self.definition and self.url which lets empty strings
bypass forbidden/required rules; update the conditional checks in the validation
block to use explicit None checks: use "is None" when checking required fields
(e.g., in the QuestDefinitionTypeName.JSON branch check "if self.definition is
None: raise ...") and use "is not None" when forbidding fields (e.g., in the
QuestDefinitionTypeName.NONE and URL branches check "if self.definition is not
None: raise ..." and similarly for self.url). Keep the existing .strip() JSON
object check but only after confirming the value is not None. Ensure you update
all branches that reference self.definition/self.url (the validation code
handling QuestDefinitionTypeName.NONE, .JSON, and .URL).

@cyrossignol cyrossignol requested a review from jeffmaki May 19, 2026 20:43
Comment thread api/core/config.py

# used for validation
WS_LONGFORM_SCHEMA_URL: str = (
LONGFORM_SCHEMA_URL: str = (
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You saw the things here can be overridden by env vars? Just checking the var is named this and not what was there before? :-)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When you see this and respond, you can either merge or fix + merge. ty!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, this one didn't match the existing variable name, so I dropped the WS_ prefix for consistency with the rest of these vars. It gets mapped here:

https://github.com/TaskarCenterAtUW/workspaces-stack/blob/0f66fc91815b4965f817cc4c72b1122ed228081f/docker-compose.deploy.yml#L94

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perfect, copy that. Merging then.

@jeffmaki jeffmaki merged commit b0ff03a into main May 20, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants